Compare commits

..

16 Commits

Author SHA1 Message Date
Philipinho a8c0033ebb fix(a11y): WCAG 2.1 AA fixes 2026-05-20 14:47:04 +01:00
Philip Okugbe 6cf8101ab3 feat(ee): templates (#2215)
* feat(ee): templates
* fix tree
* fix
2026-05-19 02:41:52 +01:00
Philipinho 0d6538ab1a feat: iframe configuration 2026-05-18 22:02:31 +01:00
Philip Okugbe b7b99cb3b2 fix: code splitting and editor fixes (#2211)
* fix table

* fix code splitting

* fix: editor ready check

* fix codeblock/mermaid gap cursor

* fix callout
2026-05-15 02:46:54 +01:00
Philipinho 03c1e8c4ed fix collab module 2026-05-14 15:06:51 +01:00
Philipinho e41518a93d fix type 2026-05-14 14:49:02 +01:00
Peter Tripp 932c1ad5b7 Better trash (#2190)
* Better trash

I recently lost a bunch of time editing and searching for pages that were actually in the Trash. Docmost intentionally tries to not link to Trashed pages, but the url of that Trashed page and any inbound links still work.  This makes it clearer when a page you are interacting with is in the Trash.

- /trash
  - Refactored banner into `trash-banner.tsx`
  - Refactored "Restore" modal into `use-restore-page-modal.tsx`
- Page (when isDeleted)
  - Add: `trash-banner.tsx`
  - Add breadcrumbs: `Parent / Child / Page (Deleted)`
  - Change: Deleted Pages are read-only
  - Replace "Move to Trash" with "Restore" in page menu (invokes `use-restore-page-modal`)

I tried very hard to keep this simple and re-use existing translation strings wherever possible.

* cleanup

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-05-14 14:41:10 +01:00
Julien Fontanet 82d065669d fix: page mode toggle no longer overwrites default preference (#1996)
The header edit/read toggle now controls only the current session's mode
without saving it as the user's preference. The saved preference (set in
profile settings) is applied once on initial load and sticks across page
navigations within the session, so navigating to a new page no longer
resets the mode mid-session.

Fixes #1693
2026-05-14 13:15:03 +01:00
Philip Okugbe f758091b2a perf(permissions): cache space role and page edit lookups (#2208) 2026-05-14 13:11:28 +01:00
Philip Okugbe f4af4c3fc0 feat(editor): add page break node (#2202) 2026-05-14 03:48:13 +01:00
Philipinho 3b983a27f6 sync 2026-05-14 03:01:55 +01:00
Philip Okugbe 299a9ca3c8 fix: bug fixes (#2201)
* fix(editor): hide transclusion borders and reset spacing in read-only mode

* feat(share): add full width toggle for shared pages

* feat(share): support resizing sidebar on shared pages

* fix: auto redirect if there is only one SSO provider.
- fix tighten sso redirect
- fix share tree margin

* sync

* package overrides
2026-05-14 02:54:00 +01:00
Philip Okugbe cea9be7926 feat: table enhancement (#2191) 2026-05-14 00:37:44 +01:00
Philip Okugbe 31ed0df3f7 feat(tree): replace sidebar tree (react-aborist) with custom tree implementation (#2199)
* feat(tree): replace react-arborist with custom tree implementation

* feat(tree): keyboard arrow navigation between rows

* feat(emoji-picker): focus search input on open

* refactor(emoji): switch to @slidoapp/emoji-mart fork for accessibility

* feat(tree): Home/End and typeahead keyboard navigation

* feat(tree): roving tabindex and * to expand sibling subtrees

* feat(tree): Space activation and ARIA refinements

* fix(tree): move treeitem role to focusable row + aria-current
2026-05-13 23:01:04 +01:00
Philip Okugbe a689cca7a0 feat: page labels/tags (#2188)
* feat: labels (WIP)
* full implementation
2026-05-10 18:14:15 +01:00
Philip Okugbe 537e45bc11 feat: page details section and backlinks (#2186)
* feat: page details section and backlinks
2026-05-09 17:03:08 +01:00
289 changed files with 12877 additions and 3912 deletions
+7
View File
@@ -48,6 +48,13 @@ GOTENBERG_URL=
DISABLE_TELEMETRY=false
# Allow other sites to embed Docmost in an iframe.
IFRAME_EMBED_ALLOWED=false
# Only used when IFRAME_EMBED_ALLOWED=true. When empty, any origin is allowed.
# Example: https://intranet.example.com,https://portal.example.com
IFRAME_ALLOWED_ORIGINS=
# Enable debug logging in production (default: false)
DEBUG_MODE=false
+68 -57
View File
@@ -7,78 +7,89 @@
"build": "tsc && vite build",
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@casl/react": "^5.0.1",
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0",
"@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4",
"@casl/react": "5.0.1",
"@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@mantine/core": "^8.3.18",
"@mantine/dates": "^8.3.18",
"@mantine/form": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"@mantine/modals": "^8.3.18",
"@mantine/notifications": "^8.3.18",
"@mantine/spotlight": "^8.3.18",
"@tabler/icons-react": "^3.40.0",
"@mantine/core": "8.3.18",
"@mantine/dates": "8.3.18",
"@mantine/form": "8.3.18",
"@mantine/hooks": "8.3.18",
"@mantine/modals": "8.3.18",
"@mantine/notifications": "8.3.18",
"@mantine/spotlight": "8.3.18",
"@slidoapp/emoji-mart": "5.8.7",
"@slidoapp/emoji-mart-data": "1.2.4",
"@slidoapp/emoji-mart-react": "1.1.5",
"@tabler/icons-react": "3.40.0",
"@tanstack/react-query": "5.90.17",
"alfaaz": "^1.1.0",
"@tanstack/react-virtual": "3.13.24",
"alfaaz": "1.1.0",
"axios": "1.16.0",
"blueimp-load-image": "^5.16.0",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
"highlightjs-sap-abap": "^0.3.0",
"blueimp-load-image": "5.16.0",
"clsx": "2.1.1",
"file-saver": "2.0.5",
"highlightjs-sap-abap": "0.3.0",
"i18next": "25.10.1",
"i18next-http-backend": "3.0.6",
"jotai": "^2.18.1",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"jotai": "2.18.1",
"jotai-optics": "0.4.0",
"js-cookie": "3.0.5",
"jwt-decode": "4.0.0",
"katex": "0.16.40",
"lowlight": "^3.3.0",
"mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.13.0",
"mitt": "^3.0.1",
"lowlight": "3.3.0",
"mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.15.0",
"mitt": "3.0.1",
"posthog-js": "1.372.2",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react": "18.3.1",
"react-clear-modal": "^2.0.18",
"react-dom": "^18.3.1",
"react-drawio": "^1.0.7",
"react-error-boundary": "^6.1.1",
"react-helmet-async": "^3.0.0",
"react-drawio": "1.0.7",
"react-error-boundary": "6.1.1",
"react-helmet-async": "3.0.0",
"react-i18next": "16.5.8",
"react-router-dom": "^7.13.1",
"semver": "^7.7.4",
"socket.io-client": "^4.8.3",
"zod": "^4.3.6"
"react-router-dom": "7.13.1",
"semver": "7.7.4",
"socket.io-client": "4.8.3",
"zod": "4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.28.0",
"@tanstack/eslint-plugin-query": "^5.94.4",
"@types/blueimp-load-image": "^5.16.6",
"@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.8",
"@eslint/js": "9.28.0",
"@tanstack/eslint-plugin-query": "5.94.4",
"@testing-library/jest-dom": "6.6.0",
"@testing-library/react": "16.1.0",
"@types/blueimp-load-image": "5.16.6",
"@types/file-saver": "2.0.7",
"@types/js-cookie": "3.0.6",
"@types/katex": "0.16.8",
"@types/node": "22.19.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.28.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^15.13.0",
"optics-ts": "^2.4.1",
"postcss": "^8.5.12",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.1",
"vite": "8.0.5"
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.1",
"eslint": "9.28.0",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-react-refresh": "0.5.2",
"globals": "15.13.0",
"jsdom": "25.0.0",
"optics-ts": "2.4.1",
"postcss": "8.5.14",
"postcss-preset-mantine": "1.18.0",
"postcss-simple-vars": "7.0.1",
"prettier": "3.8.1",
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"vite": "8.0.5",
"vitest": "4.1.6"
}
}
@@ -71,6 +71,7 @@
"Export": "Export",
"Failed to create page": "Failed to create page",
"Failed to delete page": "Failed to delete page",
"Failed to restore page": "Failed to restore page",
"Failed to fetch recent pages": "Failed to fetch recent pages",
"Failed to import pages": "Failed to import pages",
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
@@ -286,6 +287,19 @@
"Add row above": "Add row above",
"Add row below": "Add row below",
"Delete table": "Delete table",
"Add column left": "Add column left",
"Add column right": "Add column right",
"Clear cell": "Clear cell",
"Clear cells": "Clear cells",
"Toggle header cell": "Toggle header cell",
"Toggle header column": "Toggle header column",
"Toggle header row": "Toggle header row",
"Move column left": "Move column left",
"Move column right": "Move column right",
"Move row down": "Move row down",
"Move row up": "Move row up",
"Sort A → Z": "Sort A → Z",
"Sort Z → A": "Sort Z → A",
"Info": "Info",
"Note": "Note",
"Success": "Success",
@@ -348,6 +362,8 @@
"Create block quote.": "Create block quote.",
"Insert code snippet.": "Insert code snippet.",
"Insert horizontal rule divider": "Insert horizontal rule divider",
"Page break": "Page break",
"Insert a page break for printing.": "Insert a page break for printing.",
"Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.",
"Upload any audio from your device.": "Upload any audio from your device.",
@@ -392,6 +408,10 @@
"Write...": "Write...",
"Column count": "Column count",
"{{count}} Columns": "{{count}} Columns",
"{{count}} command available_one": "1 command available",
"{{count}} command available_other": "{{count}} commands available",
"{{count}} result available_one": "1 result available",
"{{count}} result available_other": "{{count}} results available",
"Equal columns": "Equal columns",
"Left sidebar": "Left sidebar",
"Right sidebar": "Right sidebar",
@@ -566,6 +586,8 @@
"Move to trash": "Move to trash",
"Move this page to trash?": "Move this page to trash?",
"Restore page": "Restore page",
"Permanently delete": "Permanently delete",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> moved this page to Trash {{time}}.",
"Page moved to trash": "Page moved to trash",
"Page restored successfully": "Page restored successfully",
"Deleted by": "Deleted by",
@@ -855,9 +877,12 @@
"AI Chat": "AI Chat",
"Analyze for insights": "Analyze for insights",
"Ask anything...": "Ask anything...",
"Assistant said:": "Assistant said:",
"Chat history": "Chat history",
"Chat name": "Chat name",
"Chat transcript": "Chat transcript",
"Close": "Close",
"Copy assistant response": "Copy assistant response",
"Docmost AI": "Docmost AI",
"Failed to load chat. An error occurred.": "Failed to load chat. An error occurred.",
"Failed to render this message.": "Failed to render this message.",
@@ -867,6 +892,8 @@
"No chats found": "No chats found",
"No conversations yet": "No conversations yet",
"Open full page": "Open full page",
"Scroll to bottom": "Scroll to bottom",
"You said:": "You said:",
"Previous 7 days": "Previous 7 days",
"Previous 30 days": "Previous 30 days",
"Search chats...": "Search chats...",
@@ -918,6 +945,35 @@
"Page actions": "Page actions",
"Pick emoji": "Pick emoji",
"Template menu": "Template menu",
"Use": "Use",
"Use template": "Use template",
"Preview template: {{title}}": "Preview template: {{title}}",
"Use a template": "Use a template",
"Search templates...": "Search templates...",
"Search spaces...": "Search spaces...",
"No templates found": "No templates found",
"No spaces found": "No spaces found",
"Browse all templates": "Browse all templates",
"This space": "This space",
"All templates": "All templates",
"Global": "Global",
"New template": "New template",
"Edit template": "Edit template",
"Are you sure you want to delete this template?": "Are you sure you want to delete this template?",
"Template scope updated": "Template scope updated",
"Choose which space this template belongs to": "Choose which space this template belongs to",
"Scope": "Scope",
"Select scope": "Select scope",
"Title": "Title",
"Saving...": "Saving...",
"Saved": "Saved",
"Save failed. Retry": "Save failed. Retry",
"By {{name}}": "By {{name}}",
"Updated {{time}}": "Updated {{time}}",
"Choose destination": "Choose destination",
"Search pages and spaces...": "Search pages and spaces...",
"No results found": "No results found",
"You don't have permission to create pages here": "You don't have permission to create pages here",
"Chat menu": "Chat menu",
"API key menu": "API key menu",
"Jump to comment selection": "Jump to comment selection",
@@ -983,5 +1039,47 @@
"Outgoing links ({{count}})": "Outgoing links ({{count}})",
"No pages link here yet.": "No pages link here yet.",
"This page doesn't link to other pages yet.": "This page doesn't link to other pages yet.",
"Verified until {{date}}": "Verified until {{date}}"
"Verified until {{date}}": "Verified until {{date}}",
"Labels": "Labels",
"Add label": "Add label",
"No labels yet": "No labels yet",
"Already added": "Already added",
"Invalid label name": "Invalid label name",
"No matches": "No matches",
"Search or create…": "Search or create…",
"Remove label {{name}}": "Remove label {{name}}",
"Failed to add label": "Failed to add label",
"Failed to remove label": "Failed to remove label",
"No pages with this label": "No pages with this label",
"Pages tagged with this label will appear here.": "Pages tagged with this label will appear here.",
"No pages match your search.": "No pages match your search.",
"Updated {{date}}": "Updated {{date}}",
"Cell actions": "Cell actions",
"Column actions": "Column actions",
"Row actions": "Row actions",
"Filter": "Filter",
"Page title": "Page title",
"Page content": "Page content",
"Member actions": "Member actions",
"Toggle password visibility": "Toggle password visibility",
"Send comment": "Send comment",
"Token actions": "Token actions",
"Template settings": "Template settings",
"Edit diagram": "Edit diagram",
"Edit embed": "Edit embed",
"Edit drawing": "Edit drawing",
"Delete equation": "Delete equation",
"Invite actions": "Invite actions",
"Get started": "Get started",
"* indicates required fields": "* indicates required fields",
"List of spaces in this workspace": "List of spaces in this workspace",
"Active sessions": "Active sessions",
"Add {{name}} to favorites": "Add {{name}} to favorites",
"Remove {{name}} from favorites": "Remove {{name}} from favorites",
"Added to favorites": "Added to favorites",
"Removed from favorites": "Removed from favorites",
"Added {{name}} to favorites": "Added {{name}} to favorites",
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
"Page menu for {{name}}": "Page menu for {{name}}",
"Create subpage of {{name}}": "Create subpage of {{name}}"
}
+2
View File
@@ -45,6 +45,7 @@ import TemplateEditor from "@/ee/template/pages/template-editor";
import FavoritesPage from "@/pages/favorites/favorites-page";
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx";
import LabelPage from "@/pages/label/label-page";
export default function App() {
const { t } = useTranslation();
@@ -92,6 +93,7 @@ export default function App() {
<Route path={"/ai/chat/:chatId"} element={<AiChat />} />
<Route path={"/spaces"} element={<SpacesPage />} />
<Route path={"/favorites"} element={<FavoritesPage />} />
<Route path={"/labels/:labelName"} element={<LabelPage />} />
<Route path={"/templates"} element={<TemplateList />} />
<Route
path={"/templates/:templateId"}
@@ -80,12 +80,20 @@ export default function AvatarUploader({
}
};
const ariaLabel = {
const actionLabel = {
[AvatarIconType.AVATAR]: t("Change avatar"),
[AvatarIconType.SPACE_ICON]: t("Change space icon"),
[AvatarIconType.WORKSPACE_ICON]: t("Change workspace icon"),
}[type];
// Per WCAG 2.5.3 (Label in Name), the accessible name must include the
// visible text. When no image is set, the avatar renders the name's
// initials, so prepend the name to the action label.
const ariaLabel =
!currentImageUrl && fallbackName
? `${fallbackName} ${actionLabel}`
: actionLabel;
const handleRemove = async () => {
if (disabled) return;
+7 -3
View File
@@ -8,15 +8,19 @@ interface CopyProps {
text: string;
size?: MantineSize;
color?: MantineColor;
/** Override the accessible name (and tooltip) when not yet copied. Lets callers disambiguate adjacent copy buttons for screen readers. */
label?: string;
}
export default function CopyTextButton({ text, size }: CopyProps) {
export default function CopyTextButton({ text, size, label }: CopyProps) {
const { t } = useTranslation();
const copyLabel = label ?? t("Copy");
return (
<CopyButton value={text} timeout={2000}>
{({ copied, copy }) => (
<Tooltip
label={copied ? t("Copied") : t("Copy")}
label={copied ? t("Copied") : copyLabel}
withArrow
position="right"
>
@@ -25,7 +29,7 @@ export default function CopyTextButton({ text, size }: CopyProps) {
variant="subtle"
onClick={copy}
size={size}
aria-label={copied ? t("Copied") : t("Copy")}
aria-label={copied ? t("Copied") : copyLabel}
>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
@@ -81,7 +81,7 @@ export default function ExportModal({
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>{t(`Export ${type}`)}</Modal.Title>
<Modal.CloseButton />
<Modal.CloseButton aria-label={t("Close")} />
</Modal.Header>
<Modal.Body>
<Group justify="space-between" wrap="nowrap">
@@ -17,6 +17,7 @@ import { EmptyState } from "@/components/ui/empty-state.tsx";
import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { getInitialsColor } from "@/lib/get-initials-color.ts";
import rowClasses from "@/components/ui/clickable-table-row.module.css";
interface Props {
spaceId?: string;
@@ -41,9 +42,10 @@ export default function RecentChanges({ spaceId }: Props) {
<Table highlightOnHover verticalSpacing="sm">
<Table.Tbody>
{pages.map((page) => (
<Table.Tr key={page.id}>
<Table.Tr key={page.id} className={rowClasses.row}>
<Table.Td>
<UnstyledButton
className={rowClasses.link}
component={Link}
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
>
@@ -1,19 +1,27 @@
import { Box, ScrollArea, Text } from "@mantine/core";
import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import React, { ReactNode } from "react";
import React, { ReactNode, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
export default function Aside() {
const [{ tab }] = useAtom(asideStateAtom);
const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom);
const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom);
const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false }));
useEffect(() => {
if (!isAsideOpen) return;
document.getElementById(ASIDE_PANEL_ID)?.focus();
}, [isAsideOpen, tab]);
let title: string;
let component: ReactNode;
@@ -45,9 +53,19 @@ export default function Aside() {
{component && (
<>
{tab !== "chat" && (
<Text mb="md" fw={500}>
{t(title)}
</Text>
<Group justify="space-between" wrap="nowrap" mb="md">
<Title order={2} size="h6" fw={500}>{t(title)}</Title>
<Tooltip label={t("Close")} withArrow>
<ActionIcon
variant="subtle"
color="gray"
onClick={closeAside}
aria-label={t("Close")}
>
<IconX size={18} />
</ActionIcon>
</Tooltip>
</Group>
)}
{tab === "comments" || tab === "chat" ? (
@@ -18,6 +18,8 @@ import classes from "./app-shell.module.css";
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
export default function GlobalAppShell({
children,
@@ -81,7 +83,9 @@ export default function GlobalAppShell({
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
return (
<AppShell
<>
<SkipToMain />
<AppShell
header={{ height: 45 }}
navbar={{
width: isSpaceRoute ? sidebarWidth : 300,
@@ -125,7 +129,7 @@ export default function GlobalAppShell({
{isAiRoute && <AiChatSidebar />}
{showGlobalSidebar && <GlobalSidebar />}
</AppShell.Navbar>
<AppShell.Main id="main-content">
<AppShell.Main id={MAIN_CONTENT_ID} tabIndex={-1}>
{isSettingsRoute ? (
<Container size={900} pb={80}>
{children}
@@ -137,6 +141,8 @@ export default function GlobalAppShell({
{isPageRoute && (
<AppShell.Aside
id={ASIDE_PANEL_ID}
tabIndex={-1}
className={classes.aside}
p="md"
withBorder={false}
@@ -156,5 +162,6 @@ export default function GlobalAppShell({
</AppShell.Aside>
)}
</AppShell>
</>
);
}
@@ -31,6 +31,11 @@
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
&[data-active] {
&,
& :hover {
@@ -38,6 +43,16 @@
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
}
}
&[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
@mixin hover {
background-color: transparent;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
}
}
.linkIcon {
@@ -86,4 +101,9 @@
);
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
}
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { ScrollArea, Text, Divider, Modal, UnstyledButton } from "@mantine/core";
import { ScrollArea, Text, Divider, Modal, UnstyledButton, Tooltip } from "@mantine/core";
import {
IconHome,
IconClock,
@@ -7,6 +7,7 @@ import {
IconLayoutGrid,
IconSettings,
IconUserPlus,
IconTemplate,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import classes from "./global-sidebar.module.css";
@@ -20,12 +21,9 @@ import { useDisclosure } from "@mantine/hooks";
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
const mainNavItems = [
{ label: "Home", icon: IconHome, path: "/home" },
{ label: "Favorites", icon: IconStar, path: "/favorites" },
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
];
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function GlobalSidebar() {
const { t } = useTranslation();
@@ -33,6 +31,19 @@ export default function GlobalSidebar() {
const [active, setActive] = useState(location.pathname);
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const hasTemplates = useHasFeature(Feature.TEMPLATES);
const upgradeLabel = useUpgradeLabel();
const mainNavItems = [
{ label: "Home", icon: IconHome, path: "/home" },
{ label: "Favorites", icon: IconStar, path: "/favorites" },
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
{
label: "Templates",
icon: IconTemplate,
path: "/templates",
disabled: !hasTemplates,
},
];
const { data: favoriteSpacesData, isPending: isFavoritesPending } = useFavoritesQuery("space");
const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? [];
const sortedFavoriteSpaces = [...favoriteSpaces]
@@ -58,18 +69,38 @@ export default function GlobalSidebar() {
<div className={classes.navbar}>
<ScrollArea w="100%" style={{ flex: 1 }}>
<div className={classes.section}>
{mainNavItems.map((item) => (
<Link
key={item.label}
className={classes.link}
data-active={active === item.path || undefined}
to={item.path}
onClick={handleNavClick}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
))}
{mainNavItems.map((item) =>
item.disabled ? (
<Tooltip
key={item.label}
label={upgradeLabel}
position="right"
withArrow
>
<UnstyledButton
className={classes.link}
data-disabled
aria-disabled="true"
tabIndex={-1}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</UnstyledButton>
</Tooltip>
) : (
<Link
key={item.label}
className={classes.link}
data-active={active === item.path || undefined}
aria-current={active === item.path ? "page" : undefined}
to={item.path}
onClick={handleNavClick}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
),
)}
</div>
<Divider my="xs" />
@@ -129,6 +160,7 @@ export default function GlobalSidebar() {
<Link
className={classes.link}
data-active={active.startsWith("/settings") || undefined}
aria-current={active.startsWith("/settings") ? "page" : undefined}
to="/settings/account/profile"
onClick={handleNavClick}
>
@@ -4,7 +4,7 @@ import { Divider, Title } from '@mantine/core';
export default function SettingsTitle({ title }: { title: string }) {
return (
<>
<Title order={3}>
<Title order={1} size="h3">
{title}
</Title>
<Divider my="md" />
@@ -0,0 +1,29 @@
/*
* Focus styling for list-style tables (recent changes, favorites, all
* spaces, groups, verified pages, shares).
*
* Per WAI-ARIA Authoring Practices and Adrian Roselli's guidance on table
* accessibility (https://adrianroselli.com/2020/02/block-links-cards-clickable-regions-etc.html),
* data tables should not be made fully clickable. Only the title cell is the
* link, and that link is what receives Tab focus.
*
* - `.row` adds a subtle background tint when the row contains the focused
* element, so keyboard users can see which row they're inspecting.
* - `.link` adds a visible :focus-visible outline on the title link itself.
*
* No stretched-link pseudo here on purpose: absolutely-positioned pseudos
* inside table cells cause column reflow on focus in Chromium.
*/
.row:focus-within {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
}
.link:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
border-radius: var(--mantine-radius-sm);
}
@@ -16,14 +16,18 @@ interface CustomAvatarProps {
mt?: string | number;
}
// `color.shade` pairs whose filled background meets WCAG AA (4.5:1) against
// white text. Avoids lime/yellow/green/orange — even their dark shades have
// weak white-text contrast.
// `color.shade` pairs whose contrast meets WCAG AA (4.5:1) in BOTH variants:
// - filled: white text on the shade as bg
// - light: shade as text on the color's light-bg (10% color.6 over white)
// Avoids lime/yellow/green/orange — even their dark shades have weak
// contrast. grape and indigo were bumped from .7 to darker shades because
// the original picks failed: grape.7 was 4.02/3.61 (both fail) and
// indigo.7 was 4.98/4.39 (light fails by a hair).
const SAFE_INITIALS_COLORS: MantineColor[] = [
"blue.8",
"cyan.9",
"grape.7",
"indigo.7",
"grape.9",
"indigo.8",
"pink.8",
"red.8",
"violet.7",
@@ -16,6 +16,8 @@ export function DestinationPickerModal({
loading,
excludePageId,
pageLimit,
initialSpaceId,
searchSpacesOnly,
}: DestinationPickerModalProps) {
const { t } = useTranslation();
const [selection, setSelection] = useState<DestinationSelection | null>(null);
@@ -39,13 +41,15 @@ export function DestinationPickerModal({
<Modal.Content>
<Modal.Header py={0}>
<Modal.Title fw={500}>{title}</Modal.Title>
<Modal.CloseButton />
<Modal.CloseButton aria-label={t("Close")} />
</Modal.Header>
<Modal.Body>
<DestinationPicker
onSelectionChange={setSelection}
excludePageId={excludePageId}
pageLimit={pageLimit}
initialSpaceId={initialSpaceId}
searchSpacesOnly={searchSpacesOnly}
/>
<Group justify="flex-end" mt="md">
@@ -13,6 +13,7 @@
display: flex;
align-items: center;
gap: 8px;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
transition: background-color 150ms ease;
user-select: none;
@@ -22,6 +23,11 @@
var(--mantine-color-dark-6)
);
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
}
.selected {
@@ -57,7 +63,7 @@
border-radius: var(--mantine-radius-sm);
flex-shrink: 0;
transition: transform 150ms ease;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
@mixin hover {
background-color: light-dark(
@@ -111,7 +117,7 @@
}
.spaceName {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
font-size: var(--mantine-font-size-xs);
flex-shrink: 0;
}
@@ -1,7 +1,7 @@
import { useState, useCallback } from "react";
import { TextInput, ScrollArea, Loader } from "@mantine/core";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { ActionIcon, TextInput, ScrollArea, Loader } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconSearch, IconFile } from "@tabler/icons-react";
import { IconSearch, IconFileDescription } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
@@ -15,23 +15,29 @@ type DestinationPickerProps = {
onSelectionChange: (selection: DestinationSelection | null) => void;
excludePageId?: string;
pageLimit?: number;
initialSpaceId?: string;
searchSpacesOnly?: boolean;
};
export function DestinationPicker({
onSelectionChange,
excludePageId,
pageLimit = 15,
initialSpaceId,
searchSpacesOnly,
}: DestinationPickerProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [selection, setSelection] = useState<DestinationSelection | null>(null);
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const viewportRef = useRef<HTMLDivElement>(null);
const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({
limit: 100,
});
const searchEnabled = debouncedQuery && debouncedQuery.length >= 2;
const searchEnabled =
!searchSpacesOnly && debouncedQuery && debouncedQuery.length >= 2;
const { data: searchData, isLoading: searchLoading } =
useSearchSuggestionsQuery({
@@ -42,6 +48,18 @@ export function DestinationPicker({
const isSearching = !!searchEnabled;
const filteredSpaces = useMemo(() => {
const items = spacesData?.items ?? [];
if (!searchSpacesOnly || !debouncedQuery) return items;
const fold = (s: string) =>
s
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLocaleLowerCase();
const term = fold(debouncedQuery);
return items.filter((s) => fold(s.name).includes(term));
}, [spacesData, searchSpacesOnly, debouncedQuery]);
const selectedId =
selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null;
@@ -87,18 +105,48 @@ export function DestinationPicker({
[updateSelection],
);
// Pre-select space when initialSpaceId is set and spaces have loaded.
// Only runs once: skip if user has already made a selection.
useEffect(() => {
if (!initialSpaceId || selection) return;
const match = spacesData?.items?.find((s) => s.id === initialSpaceId);
if (match) {
updateSelection({ type: "space", spaceId: match.id, space: match });
requestAnimationFrame(() => {
const el = viewportRef.current?.querySelector<HTMLElement>(
`[data-space-id="${match.id}"]`,
);
el?.scrollIntoView({ block: "nearest" });
});
}
}, [initialSpaceId, selection, spacesData, updateSelection]);
return (
<>
<TextInput
leftSection={<IconSearch size={16} />}
placeholder={t("Search pages and spaces...")}
placeholder={
searchSpacesOnly
? t("Search spaces...")
: t("Search pages and spaces...")
}
aria-label={
searchSpacesOnly
? t("Search spaces...")
: t("Search pages and spaces...")
}
variant="filled"
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
className={classes.searchInput}
/>
<ScrollArea h="50vh" offsetScrollbars className={classes.scrollArea}>
<ScrollArea
h="50vh"
offsetScrollbars
className={classes.scrollArea}
viewportRef={viewportRef}
>
{isSearching ? (
searchLoading ? (
<div className={classes.emptyState}>
@@ -111,16 +159,28 @@ export function DestinationPicker({
<div
key={page.id}
className={classes.searchResult}
role="button"
tabIndex={0}
onClick={() => handleSearchResultClick(page)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSearchResultClick(page);
}
}}
>
<div className={classes.iconWrapper}>
{page.icon ? (
page.icon
) : (
<IconFile
size={16}
color="var(--mantine-color-gray-5)"
/>
<ActionIcon
component="div"
variant="transparent"
c="gray"
size={22}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
</div>
<div className={classes.pageTitle}>
@@ -141,8 +201,14 @@ export function DestinationPicker({
<div className={classes.emptyState}>
<Loader size="xs" />
</div>
) : filteredSpaces.length === 0 ? (
<div className={classes.emptyState}>
{searchSpacesOnly && debouncedQuery
? t("No spaces found")
: t("No results found")}
</div>
) : (
spacesData?.items?.map((space) => (
filteredSpaces.map((space) => (
<SpaceRow
key={space.id}
space={space}
@@ -20,4 +20,6 @@ export type DestinationPickerModalProps = {
loading?: boolean;
excludePageId?: string;
pageLimit?: number;
initialSpaceId?: string;
searchSpacesOnly?: boolean;
};
@@ -1,5 +1,6 @@
import { useState } from "react";
import { IconChevronRight, IconFile } from "@tabler/icons-react";
import { KeyboardEvent, useState } from "react";
import { ActionIcon } from "@mantine/core";
import { IconChevronRight, IconFileDescription } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IPage } from "@/features/page/types/page.types";
import { PageChildren } from "./page-children";
@@ -36,23 +37,44 @@ export function PageRow({
.filter(Boolean)
.join(" ");
const handleSelect = () => {
if (!isExcluded) onSelect(page);
};
const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelect();
}
};
return (
<>
<div
className={rowClasses}
style={{ paddingLeft: depth * 20 + 12 }}
onClick={() => !isExcluded && onSelect(page)}
role="button"
tabIndex={isExcluded ? -1 : 0}
aria-disabled={isExcluded || undefined}
onClick={handleSelect}
onKeyDown={handleRowKeyDown}
>
{page.hasChildren ? (
<div
<ActionIcon
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
variant="subtle"
color="gray"
size="sm"
aria-label={expanded ? t("Collapse") : t("Expand")}
aria-expanded={expanded}
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
>
<IconChevronRight size={14} />
</div>
</ActionIcon>
) : (
<div style={{ width: 20, flexShrink: 0 }} />
)}
@@ -61,10 +83,14 @@ export function PageRow({
{page.icon ? (
page.icon
) : (
<IconFile
size={16}
color="var(--mantine-color-gray-5)"
/>
<ActionIcon
component="div"
variant="transparent"
c="gray"
size={22}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
</div>
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Tooltip } from "@mantine/core";
import { KeyboardEvent, useState } from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconChevronRight, IconLock } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types";
@@ -42,21 +42,43 @@ export function SpaceRow({
.filter(Boolean)
.join(" ");
const handleSelect = () => {
if (writable) onSelectSpace(space);
};
const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelect();
}
};
const rowContent = (
<div
className={rowClasses}
onClick={() => writable && onSelectSpace(space)}
data-space-id={space.id}
role="button"
tabIndex={writable ? 0 : -1}
aria-disabled={!writable || undefined}
onClick={handleSelect}
onKeyDown={handleRowKeyDown}
>
{writable ? (
<div
<ActionIcon
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
variant="subtle"
color="gray"
size="sm"
aria-label={expanded ? t("Collapse") : t("Expand")}
aria-expanded={expanded}
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
>
<IconChevronRight size={14} />
</div>
</ActionIcon>
) : (
<div style={{ width: 20, flexShrink: 0 }} />
)}
+51 -3
View File
@@ -1,4 +1,4 @@
import React, { ReactNode, useState } from "react";
import React, { ReactNode, useEffect, useState } from "react";
import {
ActionIcon,
Popover,
@@ -7,9 +7,24 @@ import {
} from "@mantine/core";
import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks";
import { Suspense } from "react";
const Picker = React.lazy(() => import("@emoji-mart/react"));
import { useTranslation } from "react-i18next";
// Load the picker module AND the emoji data in parallel inside the lazy
// resolution, then bind the data into the component. React.lazy only finishes
// suspending once both are in memory, so the Suspense boundary hides the
// Remove button until the Picker can render with real content.
const Picker = React.lazy(async () => {
const [pickerModule, dataModule] = await Promise.all([
import("@slidoapp/emoji-mart-react"),
import("@slidoapp/emoji-mart-data"),
]);
const PickerComp = pickerModule.default;
const data = dataModule.default;
return {
default: (props: any) => <PickerComp {...props} data={data} />,
};
});
export interface EmojiPickerInterface {
onEmojiSelect: (emoji: any) => void;
icon: ReactNode;
@@ -19,6 +34,7 @@ export interface EmojiPickerInterface {
size?: string;
variant?: string;
c?: string;
tabIndex?: number;
};
}
@@ -50,6 +66,38 @@ function EmojiPicker({
}
});
// emoji-mart's built-in autoFocus calls .focus() without preventScroll, which
// makes the browser scroll every scrollable ancestor of the search input to
// bring it on screen — including the page editor's scroll container, so the
// page jumps to the top whenever the picker is opened from a scrolled-down
// position. The search input lives inside the <em-emoji-picker> custom
// element's shadow root, so we poll for it after the dropdown mounts and
// focus it ourselves with preventScroll.
useEffect(() => {
if (!opened || !dropdown) return;
let cancelled = false;
let rafId = 0;
const tryFocus = (attempts: number) => {
if (cancelled) return;
const pickerEl = dropdown.querySelector("em-emoji-picker");
const input = pickerEl?.shadowRoot?.querySelector<HTMLInputElement>(
'input[type="search"]',
);
if (input) {
input.focus({ preventScroll: true });
return;
}
if (attempts < 60) {
rafId = requestAnimationFrame(() => tryFocus(attempts + 1));
}
};
rafId = requestAnimationFrame(() => tryFocus(0));
return () => {
cancelled = true;
cancelAnimationFrame(rafId);
};
}, [opened, dropdown]);
const handleEmojiSelect = (emoji) => {
onEmojiSelect(emoji);
handlers.close();
@@ -74,6 +122,7 @@ function EmojiPicker({
c={actionIconProps?.c || "gray"}
variant={actionIconProps?.variant || "transparent"}
size={actionIconProps?.size}
tabIndex={actionIconProps?.tabIndex}
onClick={handlers.toggle}
aria-label={t("Pick emoji")}
aria-haspopup="dialog"
@@ -85,7 +134,6 @@ function EmojiPicker({
<Suspense fallback={null}>
<Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}>
<Picker
data={async () => (await import("@emoji-mart/data")).default}
onEmojiSelect={handleEmojiSelect}
perLine={8}
skinTonePosition="search"
@@ -14,7 +14,14 @@ export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> {
const SidebarToggle = React.forwardRef<HTMLButtonElement, SidebarToggleProps>(
({ opened, size = "sm", ...others }, ref) => {
return (
<ActionIcon size={size} {...others} variant="subtle" color="gray" ref={ref}>
<ActionIcon
size={size}
aria-expanded={opened}
{...others}
variant="subtle"
color="gray"
ref={ref}
>
{opened ? (
<IconLayoutSidebarRightExpand />
) : (
@@ -0,0 +1,27 @@
.skipLink {
position: absolute;
top: 8px;
left: 8px;
z-index: 9999;
padding: 8px 16px;
background: var(--mantine-color-body);
color: var(--mantine-color-text);
border: 2px solid var(--mantine-color-blue-6);
border-radius: 4px;
text-decoration: none;
font-weight: 500;
font-size: var(--mantine-font-size-sm);
transform: translateY(-200%);
transition: transform 0.15s ease-out;
}
.skipLink:focus {
transform: translateY(0);
outline: none;
}
@media print {
.skipLink {
display: none !important;
}
}
@@ -0,0 +1,13 @@
import { useTranslation } from "react-i18next";
import classes from "./skip-to-main.module.css";
export const MAIN_CONTENT_ID = "main-content";
export function SkipToMain() {
const { t } = useTranslation();
return (
<a href={`#${MAIN_CONTENT_ID}`} className={classes.skipLink}>
{t("Skip to main content")}
</a>
);
}
@@ -120,7 +120,7 @@ export default function AiChatSidebar() {
return (
<div className={classes.sidebar}>
<div className={classes.header}>
<span className={classes.title}>{t("AI Chat")}</span>
<h2 className={classes.title}>{t("AI Chat")}</h2>
<Tooltip label={t("New chat")} openDelay={250} withArrow>
<ActionIcon
component={Link}
@@ -176,7 +176,7 @@ export default function AiChatSidebar() {
))
: groupedChats.map((group) => (
<div key={group.key} className={classes.chatGroup}>
<div className={classes.chatGroupLabel}>{group.label}</div>
<h3 className={classes.chatGroupLabel}>{group.label}</h3>
{group.chats.map((chat) => (
<AiChatSidebarItem
key={chat.id}
@@ -56,9 +56,9 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
<div className={classes.emptyState}>
<IconSparkles size={48} stroke={1.5} className={classes.emptyStateIcon} />
<div className={classes.emptyStateBrand}>{t("Docmost AI")}</div>
<div className={classes.emptyStateTitle}>
<h1 className={classes.emptyStateTitle}>
{t("What can I help you with?")}
</div>
</h1>
<div className={classes.emptyStateInput}>
<ChatInput
@@ -71,7 +71,7 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
</div>
<div className={classes.suggestionsSection}>
<div className={classes.suggestionsLabel}>Get started</div>
<h2 className={classes.suggestionsLabel}>{t("Get started")}</h2>
<div className={classes.suggestionsGrid}>
{SUGGESTIONS.map((s) => (
<button
@@ -226,6 +226,7 @@ export default function ChatInput({
],
editorProps: {
attributes: {
role: "textbox",
"aria-label": placeholder || t("Ask anything... Use @ to mention pages"),
"aria-multiline": "true",
},
@@ -335,7 +336,15 @@ export default function ChatInput({
<EditorContent editor={editor} className={classes.editorContent} />
<div className={classes.actions}>
<Popover opened={plusMenuOpen} onChange={setPlusMenuOpen} position="top-start" width={220} shadow="md">
<Popover
opened={plusMenuOpen}
onChange={setPlusMenuOpen}
position="top-start"
width={220}
shadow="md"
trapFocus
returnFocus
>
<Popover.Target>
<button
type="button"
@@ -2,6 +2,7 @@ import { useEffect, useRef, useCallback, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { IconArrowDown, IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { VisuallyHidden } from "@mantine/core";
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
import ChatMessage from "./chat-message";
import classes from "../styles/ai-chat.module.css";
@@ -33,6 +34,7 @@ export default function ChatMessageList({
streamingContent,
streamingToolCalls,
}: Props) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
@@ -40,6 +42,38 @@ export default function ChatMessageList({
const prevScrollTopRef = useRef(0);
const [showScrollButton, setShowScrollButton] = useState(false);
// Dedicated status-region announcement for screen readers. Rather than
// putting aria-live on the whole transcript (which re-fires for every
// streamed token), announce "AI is thinking…" when streaming starts and
// the full assistant reply once streaming completes — a single, clean read.
const [statusAnnouncement, setStatusAnnouncement] = useState("");
const wasStreamingRef = useRef(false);
useEffect(() => {
const justStartedStreaming = isStreaming && !wasStreamingRef.current;
const justFinishedStreaming = !isStreaming && wasStreamingRef.current;
if (justStartedStreaming) {
setStatusAnnouncement(t("AI is thinking..."));
} else if (justFinishedStreaming) {
const lastMessage = messages[messages.length - 1];
if (lastMessage?.role === "assistant" && lastMessage.content) {
// Strip markdown punctuation so screen readers don't read symbols
// like # * _ ` ~ aloud. A plain-text version is fine — the styled
// version stays in the DOM for visual users.
const plainText = lastMessage.content
.replace(/[#*_`~]/g, "")
.replace(/\s+/g, " ")
.trim();
setStatusAnnouncement(plainText);
} else {
setStatusAnnouncement("");
}
}
wasStreamingRef.current = isStreaming;
}, [isStreaming, messages, t]);
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const container = containerRef.current;
if (!container) return;
@@ -127,7 +161,18 @@ export default function ChatMessageList({
return (
<div className={classes.messageListWrapper}>
<div ref={containerRef} className={classes.messageList}>
{/* Single status region for chat announcements. Kept outside the
scrolling transcript so changes here trigger one polite read per
state change instead of re-announcing every streamed token. */}
<VisuallyHidden role="status" aria-live="polite">
{statusAnnouncement}
</VisuallyHidden>
<div
ref={containerRef}
className={classes.messageList}
aria-label={t("Chat transcript")}
>
{messages.map((msg) => (
<ErrorBoundary
key={msg.id}
@@ -162,7 +207,7 @@ export default function ChatMessageList({
{showScrollButton && (
<button
type="button"
aria-label="Scroll to bottom"
aria-label={t("Scroll to bottom")}
className={classes.scrollToBottomButton}
onClick={() => scrollToBottom("smooth")}
>
@@ -1,5 +1,6 @@
import { useCallback } from "react";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import DOMPurify from "dompurify";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
@@ -43,6 +44,7 @@ export default function ChatMessage({
streamingToolCalls,
}: Props) {
const navigate = useNavigate();
const { t } = useTranslation();
const handleContentClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
@@ -78,7 +80,11 @@ export default function ChatMessage({
}[]) || [];
return (
<div className={classes.userMessage}>
<div
className={classes.userMessage}
role="article"
aria-label={t("You said:")}
>
<div className={classes.userBubble}>
{attachments.length > 0 && (
<div className={classes.messageAttachments}>
@@ -100,8 +106,16 @@ export default function ChatMessage({
);
}
// Only label the article when there's something meaningful to announce.
// Tool-only assistant turns (no text) shouldn't announce "Assistant said:" with empty content.
const hasAnnouncableContent = Boolean(content);
return (
<div className={classes.assistantMessage}>
<div
className={classes.assistantMessage}
role="article"
aria-label={hasAnnouncableContent ? t("Assistant said:") : undefined}
>
<div className={classes.messageContent}>
{toolCalls && toolCalls.length > 0 && (
<ChatToolGroup toolCalls={toolCalls} isStreaming={isStreaming} />
@@ -131,7 +145,10 @@ export default function ChatMessage({
</div>
{!isStreaming && message.content && (
<div className={classes.messageActions}>
<CopyTextButton text={message?.content} />
<CopyTextButton
text={message?.content}
label={t("Copy assistant response")}
/>
</div>
)}
</div>
@@ -106,6 +106,7 @@
font-size: 1.5rem;
font-weight: 600;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
margin-top: 0;
margin-bottom: var(--mantine-spacing-xl);
text-align: center;
}
@@ -128,6 +129,7 @@
color: var(--mantine-color-dimmed);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 0;
margin-bottom: var(--mantine-spacing-sm);
}
@@ -114,7 +114,7 @@
}
:global(.ProseMirror p.is-editor-empty:first-child::before) {
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
color: var(--mantine-color-placeholder);
content: attr(data-placeholder);
float: left;
height: 0;
@@ -183,7 +183,7 @@
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background: none;
cursor: pointer;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3));
transition: color 150ms, background-color 150ms;
@mixin hover {
@@ -15,6 +15,7 @@
}
.title {
margin: 0;
font-weight: 600;
font-size: var(--mantine-font-size-sm);
}
@@ -33,6 +34,7 @@
}
.chatGroupLabel {
margin: 0;
padding: 4px var(--mantine-spacing-xs);
font-size: var(--mantine-font-size-xs);
font-weight: 600;
@@ -118,7 +120,8 @@
color: inherit;
}
.chatItem:hover .chatItemDate {
.chatItem:hover .chatItemDate,
.chatItem:focus-within .chatItemDate {
opacity: 0;
}
@@ -133,6 +136,12 @@
position: relative;
}
.chatItem:hover .chatItemActions {
.chatItem:hover .chatItemActions,
.chatItem:focus-within .chatItemActions {
opacity: 1;
}
.chatItemActions :global(.mantine-ActionIcon-root):focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
@@ -33,6 +33,7 @@ export function ApiKeyCreatedModal({
onClose={onClose}
title={t("{{credential}} created", { credential: t("API key") })}
size="lg"
closeButtonProps={{ "aria-label": t("Close") }}
>
<Stack gap="md">
<Alert
@@ -107,6 +107,7 @@ export function CreateApiKeyModal({
onClose={handleClose}
title={t("Create {{credential}}", { credential: t("API key") })}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
@@ -32,6 +32,7 @@ export function RevokeApiKeyModal({
onClose={onClose}
title={t("Revoke {{credential}}", { credential: t("API key") })}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<Stack gap="md">
<Text>
@@ -55,6 +55,7 @@ export function UpdateApiKeyModal({
onClose={onClose}
title={t("Update {{credential}}", { credential: t("API key") })}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
@@ -111,6 +111,11 @@ export function LdapLoginModal({
placeholder={t("Enter your LDAP password")}
variant="filled"
disabled={isLoading}
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("password")}
/>
+64 -5
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { Button, Divider, Stack } from "@mantine/core";
import { IconLock, IconServer } from "@tabler/icons-react";
@@ -7,15 +7,37 @@ import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
import { getRedirectParam } from "@/lib/app-route.ts";
import useCurrentUser from "@/features/user/hooks/use-current-user.ts";
const SSO_AUTO_ATTEMPT_KEY = "docmost:ssoAutoAttempt";
const SSO_AUTO_ATTEMPT_TTL_MS = 5 * 60_000;
function recentAutoAttempt(): boolean {
try {
const raw = window.sessionStorage.getItem(SSO_AUTO_ATTEMPT_KEY);
if (!raw) return false;
const ts = Number(raw);
return Number.isFinite(ts) && Date.now() - ts < SSO_AUTO_ATTEMPT_TTL_MS;
} catch {
return false;
}
}
function markAutoAttempt(): void {
try {
window.sessionStorage.setItem(SSO_AUTO_ATTEMPT_KEY, String(Date.now()));
} catch {
/* sessionStorage unavailable (private mode, etc.) — best effort */
}
}
export default function SsoLogin() {
const { data, isLoading } = useWorkspacePublicDataQuery();
const { data: currentUser } = useCurrentUser();
const [ldapModalOpened, setLdapModalOpened] = useState(false);
const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null);
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const autoRedirectedRef = useRef(false);
const handleSsoLogin = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.LDAP) {
@@ -28,10 +50,47 @@ export default function SsoLogin() {
providerId: provider.id,
type: provider.type,
workspaceId: data.id,
redirect: getRedirectParam() ?? undefined,
});
}
};
// Auto-redirect when SSO is enforced and there is exactly one non-LDAP
// provider. The user has no other option, so skip the extra click.
useEffect(() => {
if (autoRedirectedRef.current) return;
if (!data?.enforceSso) return;
if (!data.authProviders || data.authProviders.length !== 1) return;
const onlyProvider = data.authProviders[0];
if (onlyProvider.type === SSO_PROVIDER.LDAP) return;
// Already signed in: let useRedirectIfAuthenticated handle navigation
// instead of racing it through the IdP.
if (currentUser?.user) return;
// Explicit logout: don't immediately bounce them back to the IdP.
const params = new URLSearchParams(window.location.search);
if (params.has("logout")) return;
// Circuit-breaker: if we already auto-redirected within the TTL, the
// user came back (likely from an IdP failure). Show the page so they
// can read errors or pick a different account.
if (recentAutoAttempt()) return;
autoRedirectedRef.current = true;
markAutoAttempt();
window.location.href = buildSsoLoginUrl({
providerId: onlyProvider.id,
type: onlyProvider.type,
workspaceId: data.id,
redirect: getRedirectParam() ?? undefined,
});
}, [data, currentUser]);
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const getProviderIcon = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.GOOGLE) {
return <GoogleIcon size={16} />;
@@ -130,6 +130,11 @@ export function MfaBackupCodesModal({
label={t("Confirm password")}
placeholder={t("Enter your password")}
variant="filled"
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("confirmPassword")}
autoFocus
data-autofocus
@@ -107,6 +107,11 @@ export function MfaDisableModal({
<PasswordInput
label={t("Password")}
placeholder={t("Enter your password")}
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("confirmPassword")}
autoFocus
data-autofocus
@@ -79,7 +79,13 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
{t("Share")}
</Button>
<Modal opened={opened} onClose={close} title={t("Share")} size={600}>
<Modal
opened={opened}
onClose={close}
title={t("Share")}
size={600}
closeButtonProps={{ "aria-label": t("Close") }}
>
<Tabs value={activeTab} color="dark" onChange={setActiveTab}>
<Tabs.List mb="md">
<Tabs.Tab value="access">{t("Access")}</Tabs.Tab>
@@ -4,8 +4,8 @@ import {
Menu,
Modal,
Text,
ThemeIcon,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
@@ -100,15 +100,20 @@ export function PageVerificationBadge({
if (!pageId) return null;
if (!hasVerificationFeature) {
if (readOnly) return null;
const lockedLabel = `${t("Add verification")}${upgradeLabel}`;
// Use ActionIcon (a real <button>) instead of a ThemeIcon so the tooltip
// is reachable on keyboard focus, and screen readers announce the upgrade
// hint via the accessible name. Click is a no-op since the feature is
// gated; the tooltip explains why.
return (
<Tooltip
label={`${t("Add verification")}${upgradeLabel}`}
withArrow
openDelay={250}
>
<ThemeIcon variant="subtle" color="gray">
<Tooltip label={lockedLabel} withArrow openDelay={250}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={lockedLabel}
>
<IconShieldCheck size={20} stroke={1.5} />
</ThemeIcon>
</ActionIcon>
</Tooltip>
);
}
@@ -132,20 +137,25 @@ export function PageVerificationBadge({
<>
{status !== "none" ? (
<Tooltip label={tooltipLabel} withArrow openDelay={250}>
<Group
gap={4}
<UnstyledButton
onClick={open}
style={{ cursor: "pointer" }}
wrap="nowrap"
aria-label={tooltipLabel}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
cursor: "pointer",
}}
>
<IconRosetteDiscountCheckFilled
size={18}
color={`var(--mantine-color-${getStatusColor(status).replace(".", "-")})`}
aria-hidden="true"
/>
<Text size="sm" c={getStatusColor(status)}>
{getStatusLabel(status, t)}
</Text>
</Group>
</UnstyledButton>
</Tooltip>
) : !readOnly ? (
<Tooltip label={t("Set up verification")} withArrow openDelay={250}>
@@ -18,6 +18,7 @@ import { CustomAvatar } from "@/components/ui/custom-avatar";
import { buildPageUrl } from "@/features/page/page.utils";
import { format } from "date-fns";
import NoTableResults from "@/components/common/no-table-results";
import rowClasses from "@/components/ui/clickable-table-row.module.css";
const MAX_VISIBLE_VERIFIERS = 5;
@@ -124,12 +125,13 @@ export default function VerificationListTable({
);
return (
<Table.Tr key={item.id}>
<Table.Tr key={item.id} className={rowClasses.row}>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{ color: "var(--mantine-color-text)" }}
className={rowClasses.link}
component={Link}
to={pageUrl}
>
@@ -52,6 +52,7 @@ export function CreateScimTokenModal({
onClose={handleClose}
title={t("Create {{credential}}", { credential: t("SCIM token") })}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
@@ -29,6 +29,7 @@ export function RevokeScimTokenModal({
onClose={onClose}
title={t("Revoke {{credential}}", { credential: t("SCIM token") })}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<Stack gap="md">
<Text>
@@ -32,6 +32,7 @@ export function ScimTokenCreatedModal({
onClose={onClose}
title={t("{{credential}} created", { credential: t("SCIM token") })}
size="lg"
closeButtonProps={{ "aria-label": t("Close") }}
>
<Stack gap="md">
<Alert
@@ -93,7 +93,11 @@ export function ScimTokenTable({
<Table.Td>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("Token actions")}
>
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
@@ -52,6 +52,7 @@ export function UpdateScimTokenModal({
onClose={onClose}
title={t("Update {{credential}}", { credential: t("SCIM token") })}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
@@ -34,7 +34,7 @@ function AllowMemberTemplatesToggle() {
const [checked, setChecked] = useState(
workspace?.settings?.templates?.allowMemberTemplates === true,
);
const hasSecuritySettings = useHasFeature(Feature.SECURITY_SETTINGS);
const hasTemplates = useHasFeature(Feature.TEMPLATES);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -54,15 +54,11 @@ function AllowMemberTemplatesToggle() {
};
return (
<Tooltip
label={upgradeLabel}
disabled={hasSecuritySettings}
refProp="rootRef"
>
<Tooltip label={upgradeLabel} disabled={hasTemplates} refProp="rootRef">
<Switch
checked={checked}
onChange={handleChange}
disabled={!hasSecuritySettings}
disabled={!hasTemplates}
aria-label={t("Toggle allow members to create templates")}
/>
</Tooltip>
@@ -32,6 +32,7 @@ export default function SsoProviderModal({
ssoProviderType: provider.type.toUpperCase(),
})}
onClose={onClose}
closeButtonProps={{ "aria-label": t("Close") }}
>
{provider.type === SSO_PROVIDER.SAML && (
<SsoSamlForm provider={provider} onClose={onClose} />
@@ -137,7 +137,6 @@ export default function Security() {
{ max: SCIM_TOKEN_LIMIT },
)}
disabled={(scimData?.items.length ?? 0) < SCIM_TOKEN_LIMIT}
refProp="rootRef"
>
<Button
onClick={() => setCreateOpen(true)}
+10 -3
View File
@@ -18,14 +18,21 @@ export function buildSsoLoginUrl(opts: {
providerId: string;
type: SSO_PROVIDER;
workspaceId?: string;
redirect?: string;
}): string {
const { providerId, type, workspaceId } = opts;
const { providerId, type, workspaceId, redirect } = opts;
const domain = getAppUrl();
const params = new URLSearchParams();
if (redirect) params.set("redirect", redirect);
if (type === SSO_PROVIDER.GOOGLE) {
return `${getServerAppUrl()}/api/sso/${type}/login?workspaceId=${workspaceId}`;
if (workspaceId) params.set("workspaceId", workspaceId);
return `${getServerAppUrl()}/api/sso/${type}/login?${params.toString()}`;
}
return `${domain}/api/sso/${type}/${providerId}/login`;
const query = params.toString();
const base = `${domain}/api/sso/${type}/${providerId}/login`;
return query ? `${base}?${query}` : base;
}
export function getGoogleSignupUrl(): string {
@@ -8,6 +8,11 @@
@mixin hover {
transform: scale(1.02);
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
}
.cardBody {
@@ -50,18 +55,27 @@
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-xs);
gap: 6px;
padding-top: var(--mantine-spacing-sm);
margin-top: var(--mantine-spacing-lg);
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.scopeDot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background-color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.menuTarget {
opacity: 0;
transition: opacity 100ms ease;
.card:hover & {
.card:hover &,
.card:focus-within & {
opacity: 1;
}
}
@@ -1,4 +1,4 @@
import { Card, Text, ActionIcon, Menu, Group } from "@mantine/core";
import { Button, Card, Text, ActionIcon, Menu, Group } from "@mantine/core";
import {
IconDots,
IconEdit,
@@ -12,6 +12,7 @@ import classes from "./template-card.module.css";
type TemplateCardProps = {
template: ITemplate;
spaceName?: string;
onPreview: (template: ITemplate) => void;
onUse: (template: ITemplate) => void;
onEdit?: (template: ITemplate) => void;
onDelete?: (template: ITemplate) => void;
@@ -21,6 +22,7 @@ type TemplateCardProps = {
export default function TemplateCard({
template,
spaceName,
onPreview,
onUse,
onEdit,
onDelete,
@@ -34,7 +36,17 @@ export default function TemplateCard({
padding="lg"
className={classes.card}
style={{ cursor: "pointer" }}
onClick={() => onUse(template)}
role="button"
tabIndex={0}
aria-label={t("Preview template: {{title}}", { title: template.title })}
onClick={() => onPreview(template)}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onPreview(template);
}
}}
>
<div className={classes.cardBody}>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md">
@@ -47,6 +59,17 @@ export default function TemplateCard({
)}
<Group gap={6} wrap="nowrap">
<Button
size="compact-xs"
variant="filled"
className={classes.menuTarget}
onClick={(e) => {
e.stopPropagation();
onUse(template);
}}
>
{t("Use")}
</Button>
{canManage && (
<Menu width={150} shadow="md" withArrow>
<Menu.Target>
@@ -91,6 +114,7 @@ export default function TemplateCard({
<div className={classes.title}>{template.title}</div>
<div className={classes.footer}>
<span className={classes.scopeDot} aria-hidden="true" />
<Text size="sm" fw={500} c="dimmed">
{template.spaceId ? (spaceName || t("Space")) : t("Global")}
</Text>
@@ -0,0 +1,70 @@
.row {
position: relative;
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border-radius: var(--mantine-radius-sm);
width: 100%;
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
}
}
.icon {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 16px;
line-height: 1;
}
.title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--mantine-font-size-sm);
text-align: left;
}
.scope {
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
font-size: var(--mantine-font-size-xs);
flex-shrink: 0;
transition: opacity 100ms ease;
.row:hover &,
.row:focus-within & {
opacity: 0;
}
}
.useButton {
position: absolute;
top: 50%;
right: var(--mantine-spacing-sm);
transform: translateY(-50%);
opacity: 0;
transition: opacity 100ms ease;
.row:hover &,
.row:focus-within &,
&:focus-visible {
opacity: 1;
}
}
.empty {
display: flex;
align-items: center;
justify-content: center;
padding: var(--mantine-spacing-xl);
}
@@ -0,0 +1,259 @@
import { useMemo, useState } from "react";
import {
Button,
Modal,
TextInput,
ScrollArea,
Loader,
Text,
UnstyledButton,
Group,
SegmentedControl,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import {
IconArrowRight,
IconSearch,
IconFileText,
} from "@tabler/icons-react";
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
useGetTemplatesQuery,
useUseTemplateMutation,
} from "@/ee/template/queries/template-query";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { ITemplate } from "@/ee/template/types/template.types";
import UseTemplateModal from "@/ee/template/components/use-template-modal";
import TemplatePreviewModal from "@/ee/template/components/template-preview-modal";
import { buildPageUrl } from "@/features/page/page.utils";
import classes from "./template-picker-modal.module.css";
type TemplatePickerModalProps = {
opened: boolean;
onClose: () => void;
/** Pre-select this space in the destination picker after a template is chosen. */
initialSpaceId?: string;
};
type ScopeFilter = "current" | "all";
export default function TemplatePickerModal({
opened,
onClose,
initialSpaceId,
}: TemplatePickerModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const useTemplateMutation = useUseTemplateMutation();
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 200);
const [scope, setScope] = useState<ScopeFilter>(
initialSpaceId ? "current" : "all",
);
// Two-stage selection: previewing first, then destination-picker.
// `previewTemplate` is set when the user clicks a row in the picker.
// `destinationTemplate` is set when they click "Use template" in the preview.
const [previewTemplate, setPreviewTemplate] = useState<ITemplate | null>(
null,
);
const [destinationTemplate, setDestinationTemplate] =
useState<ITemplate | null>(null);
const { data, isPending } = useGetTemplatesQuery({
spaceId: scope === "current" ? initialSpaceId : undefined,
});
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
const spaceNamesById = useMemo(() => {
const map = new Map<string, string>();
spacesData?.items?.forEach((s) => map.set(s.id, s.name));
return map;
}, [spacesData]);
const filtered = useMemo(() => {
const all = data?.pages.flatMap((p) => p.items) ?? [];
const term = debouncedQuery.trim().toLowerCase();
if (!term) return all;
return all.filter((tpl) => tpl.title.toLowerCase().includes(term));
}, [data, debouncedQuery]);
const createInInitialSpace = async (tpl: ITemplate) => {
if (!initialSpaceId) return;
try {
const page = await useTemplateMutation.mutateAsync({
templateId: tpl.id,
spaceId: initialSpaceId,
});
setPreviewTemplate(null);
onClose();
const space = spacesData?.items?.find((s) => s.id === initialSpaceId);
if (page?.slugId && space?.slug) {
navigate(buildPageUrl(space.slug, page.slugId, page.title));
}
} catch {
// error notification handled by mutation's onError
}
};
const handlePick = (tpl: ITemplate) => {
setPreviewTemplate(tpl);
};
const handleQuickUse = (tpl: ITemplate) => {
if (initialSpaceId) {
createInInitialSpace(tpl);
return;
}
setDestinationTemplate(tpl);
};
const handlePreviewClose = () => {
// Closing preview returns to the picker list (no full unmount).
setPreviewTemplate(null);
};
const handlePreviewUse = () => {
if (initialSpaceId && previewTemplate) {
createInInitialSpace(previewTemplate);
return;
}
// Move from preview into destination-picker stage.
setDestinationTemplate(previewTemplate);
setPreviewTemplate(null);
};
const handleDestinationClose = () => {
setDestinationTemplate(null);
onClose();
};
const handleClose = () => {
setQuery("");
setScope(initialSpaceId ? "current" : "all");
setPreviewTemplate(null);
setDestinationTemplate(null);
onClose();
};
return (
<>
<Modal
opened={opened && !previewTemplate && !destinationTemplate}
onClose={handleClose}
size={550}
padding="lg"
yOffset="10vh"
title={<Text fw={500}>{t("Use a template")}</Text>}
>
<TextInput
leftSection={<IconSearch size={16} />}
placeholder={t("Search templates...")}
variant="filled"
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
mb="xs"
autoFocus
/>
{initialSpaceId && (
<SegmentedControl
fullWidth
size="xs"
mb="sm"
value={scope}
onChange={(v) => setScope(v as ScopeFilter)}
data={[
{ label: t("This space"), value: "current" },
{ label: t("All templates"), value: "all" },
]}
/>
)}
<ScrollArea h="50vh" offsetScrollbars>
{isPending ? (
<div className={classes.empty}>
<Loader size="xs" />
</div>
) : filtered.length === 0 ? (
<div className={classes.empty}>
<Text size="sm" c="dimmed">
{t("No templates found")}
</Text>
</div>
) : (
filtered.map((tpl) => (
<UnstyledButton
key={tpl.id}
className={classes.row}
onClick={() => handlePick(tpl)}
>
<div className={classes.icon}>
{tpl.icon ? (
<span>{tpl.icon}</span>
) : (
<IconFileText
size={16}
color="var(--mantine-color-gray-6)"
/>
)}
</div>
<div className={classes.title}>{tpl.title}</div>
<div className={classes.scope}>
{tpl.spaceId
? spaceNamesById.get(tpl.spaceId) ?? t("Space")
: t("Global")}
</div>
<Button
size="compact-xs"
variant="filled"
className={classes.useButton}
loading={useTemplateMutation.isPending}
disabled={useTemplateMutation.isPending}
onClick={(e) => {
e.stopPropagation();
handleQuickUse(tpl);
}}
>
{t("Use")}
</Button>
</UnstyledButton>
))
)}
</ScrollArea>
<Group justify="flex-end" mt="md">
<Button
component={Link}
to="/templates"
variant="subtle"
size="sm"
rightSection={<IconArrowRight size={16} />}
onClick={handleClose}
>
{t("Browse all templates")}
</Button>
</Group>
</Modal>
{previewTemplate && (
<TemplatePreviewModal
templateId={previewTemplate.id}
opened={true}
onClose={handlePreviewClose}
onUse={handlePreviewUse}
useLoading={useTemplateMutation.isPending}
/>
)}
{destinationTemplate && (
<UseTemplateModal
template={destinationTemplate}
opened={true}
onClose={handleDestinationClose}
initialSpaceId={initialSpaceId}
/>
)}
</>
);
}
@@ -9,6 +9,7 @@ type TemplatePreviewModalProps = {
onClose: () => void;
onUse: () => void;
onEdit?: () => void;
useLoading?: boolean;
};
export default function TemplatePreviewModal({
@@ -17,6 +18,7 @@ export default function TemplatePreviewModal({
onClose,
onUse,
onEdit,
useLoading,
}: TemplatePreviewModalProps) {
const { t } = useTranslation();
const { data: template, isLoading } = useGetTemplateByIdQuery(templateId);
@@ -37,15 +39,20 @@ export default function TemplatePreviewModal({
</Group>
</Modal.Title>
<Group gap="sm">
<Button
size="xs"
onClick={onUse}
loading={useLoading}
disabled={useLoading}
>
{t("Use template")}
</Button>
{onEdit && (
<Button size="xs" variant="default" onClick={onEdit}>
{t("Edit")}
</Button>
)}
<Button size="xs" onClick={onUse}>
{t("Use template")}
</Button>
<Modal.CloseButton />
<Modal.CloseButton aria-label={t("Close")} />
</Group>
</Modal.Header>
<Modal.Body p={0}>
@@ -10,12 +10,14 @@ type UseTemplateModalProps = {
template: ITemplate;
opened: boolean;
onClose: () => void;
initialSpaceId?: string;
};
export default function UseTemplateModal({
template,
opened,
onClose,
initialSpaceId,
}: UseTemplateModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -54,6 +56,8 @@ export default function UseTemplateModal({
actionLabel={t("Create page")}
onSelect={handleSelect}
loading={useTemplateMutation.isPending}
initialSpaceId={initialSpaceId ?? template.spaceId}
searchSpacesOnly
/>
);
}
@@ -75,6 +75,18 @@ export default function TemplateEditor() {
const editor = useEditor({
extensions: templateExtensions,
content: "",
editorProps: {
handleDOMEvents: {
keydown: (_view, event) => {
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command");
if (slashCommand) {
return true;
}
}
},
},
},
onUpdate() {
if (loadedRef.current) {
markDirty();
@@ -271,6 +283,7 @@ export default function TemplateEditor() {
variant="subtle"
color="gray"
size="md"
aria-label={t("Template settings")}
onClick={() => {
setDraftSpaceId(spaceId);
openSettings();
@@ -160,7 +160,8 @@ export default function TemplateList() {
? spaceNameMap.get(template.spaceId)
: undefined
}
onUse={handlePreview}
onPreview={handlePreview}
onUse={handleUse}
onEdit={handleEdit}
onDelete={handleDelete}
canManage={isWorkspaceAdmin}
@@ -6,6 +6,7 @@ import {
UseQueryResult,
InfiniteData,
} from "@tanstack/react-query";
import { useAtom, useStore } from "jotai";
import {
getTemplates,
getTemplateById,
@@ -18,6 +19,12 @@ import { ITemplate } from "@/ee/template/types/template.types";
import { IPagination } from "@/lib/types.ts";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { invalidateOnCreatePage } from "@/features/page/queries/page-query.ts";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
export function useGetTemplatesQuery(params?: { spaceId?: string }) {
const { spaceId } = params ?? {};
@@ -149,13 +156,64 @@ export function useDeleteTemplateMutation() {
export function useUseTemplateMutation() {
const { t } = useTranslation();
const [, setTreeData] = useAtom(treeDataAtom);
const store = useStore();
const emit = useQueryEmit();
return useMutation({
mutationFn: (data: {
templateId: string;
spaceId: string;
parentPageId?: string;
}) => useTemplate(data),
return useMutation<
IPage,
Error,
{ templateId: string; spaceId: string; parentPageId?: string }
>({
mutationFn: (data) => useTemplate(data),
onSuccess: (page) => {
// React Query sidebar-pages cache update (same path useCreatePageMutation takes).
invalidateOnCreatePage(page);
const parentId = page.parentPageId ?? null;
const newNode: SpaceTreeNode = {
id: page.id,
slugId: page.slugId,
name: page.title,
icon: page.icon,
position: page.position,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
hasChildren: false,
children: [],
};
// Only mutate the tree atom and broadcast if it currently represents
// this space. Cross-space template-use (e.g., from the gallery picking
// a different space) lets the target space's clients pick up the new
// page on their next React Query refetch (focus, navigation, etc.).
// Without this guard we'd both pollute the local tree and send a wrong
// `index` to remote clients in the target space.
const current = store.get(treeDataAtom);
const treeIsForThisSpace = current[0]?.spaceId === page.spaceId;
if (!treeIsForThisSpace) return;
const lastIndex =
parentId === null
? current.length
: (treeModel.find(current, parentId)?.children?.length ?? 0);
setTreeData((prev) =>
treeModel.insert(prev, parentId, newNode, lastIndex),
);
setTimeout(() => {
emit({
operation: "addTreeNode",
spaceId: page.spaceId,
payload: {
parentId,
index: lastIndex,
data: newNode,
},
});
}, 50);
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
@@ -1,5 +1,6 @@
import api from "@/lib/api-client";
import { ITemplate } from "@/ee/template/types/template.types";
import { IPage } from "@/features/page/types/page.types";
import { IPagination } from "@/lib/types.ts";
export async function getTemplates(params?: {
@@ -40,7 +41,7 @@ export async function useTemplate(data: {
templateId: string;
spaceId: string;
parentPageId?: string;
}): Promise<any> {
const req = await api.post("/templates/use", data);
}): Promise<IPage> {
const req = await api.post<IPage>("/templates/use", data);
return req.data;
}
@@ -20,7 +20,7 @@ export function AuthLayout({ children }: AuthLayoutProps) {
Docmost
</Text>
</Group>
{children}
<main>{children}</main>
</>
);
}
@@ -103,6 +103,11 @@ export function InviteSignUpForm() {
placeholder={t("Your password")}
variant="filled"
mt="md"
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
@@ -54,6 +54,13 @@ export function LoginForm() {
await signIn(data);
}
function handleValidationFailure(errors: Record<string, unknown>) {
const firstInvalidId = Object.keys(errors)[0];
if (firstInvalidId) {
document.getElementById(firstInvalidId)?.focus();
}
}
if (isDataLoading) {
return null;
}
@@ -66,7 +73,7 @@ export function LoginForm() {
<AuthLayout>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
<Title order={1} size="h2" ta="center" fw={500} mb="md">
{t("Login")}
</Title>
@@ -74,21 +81,31 @@ export function LoginForm() {
{!data?.enforceSso && (
<>
<form onSubmit={form.onSubmit(onSubmit)}>
<form onSubmit={form.onSubmit(onSubmit, handleValidationFailure)}>
<TextInput
id="email"
type="email"
label={t("Email")}
placeholder="email@example.com"
variant="filled"
autoComplete="email"
errorProps={{ role: "alert" }}
{...form.getInputProps("email")}
/>
<PasswordInput
id="password"
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
autoComplete="current-password"
errorProps={{ role: "alert" }}
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("password")}
/>
@@ -52,6 +52,11 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
placeholder={t("Your new password")}
variant="filled"
mt="md"
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("newPassword")}
/>
@@ -98,6 +98,11 @@ export function SetupWorkspaceForm() {
placeholder={t("Enter a strong password")}
variant="filled"
mt="md"
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
@@ -166,7 +166,7 @@ export default function useAuth() {
const handleLogout = async () => {
setCurrentUser(RESET);
await logout();
window.location.replace(APP_ROUTE.AUTH.LOGIN);
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
};
const handleForgotPassword = async (data: IForgotPassword) => {
@@ -19,6 +19,7 @@ interface CommentEditorProps {
editable: boolean;
placeholder?: string;
autofocus?: boolean;
surface?: "default" | "muted";
}
const CommentEditor = forwardRef(
@@ -30,6 +31,7 @@ const CommentEditor = forwardRef(
editable,
placeholder,
autofocus,
surface,
}: CommentEditorProps,
ref,
) => {
@@ -66,6 +68,9 @@ const CommentEditor = forwardRef(
}),
],
editorProps: {
attributes: {
"aria-label": placeholder || t("Comment"),
},
handleDOMEvents: {
keydown: (_view, event) => {
if (
@@ -131,6 +136,7 @@ const CommentEditor = forwardRef(
ref={focusRef}
className={classes.commentEditor}
data-editable={editable || undefined}
data-surface={surface}
>
<EditorContent
editor={commentEditor}
@@ -383,6 +383,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
onSave={handleSave}
editable={true}
placeholder={t("Add a comment...")}
surface="muted"
/>
</div>
</Group>
@@ -391,6 +392,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
variant="filled"
radius="xl"
size="sm"
aria-label={t("Send comment")}
onClick={handleSave}
onMouseDown={(e) => e.preventDefault()}
loading={isLoading}
@@ -22,6 +22,11 @@
.commentEditor {
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
border-radius: var(--mantine-radius-sm);
box-shadow: 0 0 0 1px light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-4));
}
.focused {
border-radius: var(--mantine-radius-sm);
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
@@ -1,5 +1,6 @@
import { atom } from "jotai";
import { Editor } from "@tiptap/core";
import { PageEditMode } from "@/features/user/types/user.types.ts";
export const pageEditorAtom = atom<Editor | null>(null);
@@ -12,3 +13,7 @@ export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false);
// Current page's edit mode — initialized from the user's saved preference on
// first load, can be toggled locally without persisting to the server.
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -46,7 +47,7 @@ export function AudioMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "audio";
const parent = findParentNode(predicate)(selection);
@@ -16,7 +16,7 @@ import {
IconMoodSmile,
IconNotes,
} from "@tabler/icons-react";
import { CalloutType, isTextSelected } from "@docmost/editor-ext";
import { CalloutType, isEditorReady, isTextSelected } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import classes from "../common/toolbar-menu.module.css";
@@ -55,7 +55,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
});
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout";
const parent = findParentNode(predicate)(selection);
@@ -19,7 +19,7 @@ import {
IconCopy,
IconTrash,
} from "@tabler/icons-react";
import { isTextSelected } from "@docmost/editor-ext";
import { isEditorReady, isTextSelected } from "@docmost/editor-ext";
import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import classes from "../common/toolbar-menu.module.css";
@@ -82,7 +82,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) return false;
if (!state || !isEditorReady(editor)) return false;
if (!editor.isActive("columns")) return false;
if (isTextSelected(editor)) return false;
if (nodesWithMenus.some((name) => editor.isActive(name))) return false;
@@ -121,7 +121,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
});
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "columns";
const parent = findParentNode(predicate)(selection);
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -81,7 +82,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "drawio";
const parent = findParentNode(predicate)(selection);
@@ -198,7 +198,11 @@ export default function DrawioView(props: NodeViewProps) {
className={clsx(selected ? "ProseMirror-selectednode" : "")}
>
<div style={{ display: "flex", alignItems: "center" }}>
<ActionIcon variant="transparent" color="gray">
<ActionIcon
variant="transparent"
color="gray"
aria-label={t("Edit diagram")}
>
<IconEdit size={18} />
</ActionIcon>
@@ -131,7 +131,11 @@ export default function EmbedView(props: NodeViewProps) {
className={clsx(selected ? "ProseMirror-selectednode" : "")}
>
<div style={{ display: "flex", alignItems: "center" }}>
<ActionIcon variant="transparent" color="gray">
<ActionIcon
variant="transparent"
color="gray"
aria-label={t("Edit embed")}
>
<IconEdit size={18} />
</ActionIcon>
@@ -44,9 +44,11 @@ function EmojiList({
const [cats, setCats] = useState<EmojiCategory[]>([]);
const [activeCat, setActiveCat] = useState("");
const [focusZone, setFocusZone] = useState<"grid" | "tabs">("grid");
const [announce, setAnnounce] = useState("");
const listViewport = useRef<HTMLDivElement>(null);
const gridViewport = useRef<HTMLDivElement>(null);
const catBar = useRef<HTMLDivElement>(null);
const userInteractedRef = useRef(false);
const searching = query.length > 0;
const browseLoading = !searching && cats.length === 0;
@@ -74,6 +76,53 @@ function EmojiList({
vp?.querySelector<HTMLElement>(`[data-i="${idx}"]`)?.scrollIntoView({ block: "nearest" });
}, [idx, searching, focusZone]);
// Announce picker open and selection changes via a live region. Focus
// stays in the editor, so without this the screen reader has no way to
// know the picker exists or that arrow keys are changing the selection.
// The setTimeout defers the open message past the initial render so the
// live region is in the DOM before its content changes (screen readers
// ignore content that's present at mount time).
useEffect(() => {
const timer = setTimeout(() => {
setAnnounce(
t("Emoji picker open. Use arrow keys to navigate, Enter to select."),
);
}, 100);
return () => clearTimeout(timer);
}, [t]);
useEffect(() => {
// Skip data-driven updates (idx reset, async cat load); only announce
// selection changes that come from real user navigation.
if (!userInteractedRef.current) return;
if (focusZone === "tabs") {
if (activeCat) setAnnounce(t("{{name}} category", { name: activeCat }));
return;
}
if (searching) {
const item = items[idx];
if (item)
setAnnounce(
t("{{name}}, {{n}} of {{total}}", {
name: item.id,
n: idx + 1,
total: items.length,
}),
);
return;
}
const entry = gridItems[idx];
if (entry)
setAnnounce(
t("{{name}}, {{n}} of {{total}}", {
name: entry.id,
n: idx + 1,
total: gridItems.length,
}),
);
}, [idx, activeCat, focusZone, searching, items, gridItems, t]);
const pickSearchItem = useCallback(
(i: number) => {
const item = items[i];
@@ -94,6 +143,13 @@ function EmojiList({
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"].includes(
e.key,
)
) {
userInteractedRef.current = true;
}
if (searching) {
if (e.key === "ArrowDown") { e.preventDefault(); setIdx((i) => Math.min(i + 1, items.length - 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setIdx((i) => Math.max(i - 1, 0)); }
@@ -131,6 +187,24 @@ function EmojiList({
role="listbox"
aria-label={t("Emoji picker")}
>
<div
role="status"
aria-live="polite"
aria-atomic="true"
style={{
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0,0,0,0)",
whiteSpace: "nowrap",
border: 0,
}}
>
{announce}
</div>
{searching ? (
<>
{isLoading && <Loader m="xs" size="xs" color="blue" type="dots" />}
@@ -171,6 +245,7 @@ function EmojiList({
title={c.id}
role="tab"
aria-selected={isActive}
aria-label={t("{{name}} category", { name: c.id })}
className={clsx(classes.catTab, {
[classes.catTabActive]: isActive,
[classes.catTabFocused]: isFocused,
@@ -190,6 +265,9 @@ function EmojiList({
key={entry.id}
data-i={i}
title={`:${entry.id}:`}
role="option"
aria-selected={i === idx}
aria-label={entry.id}
className={clsx(classes.emojiBtn, { [classes.active]: i === idx })}
onClick={() => pickGridItem(entry)}
onMouseEnter={() => setIdx(i)}
@@ -21,7 +21,7 @@ let _emojiIndex: EmojiIndexEntry[] | null = null;
export const buildEmojiIndex = async (): Promise<EmojiIndexEntry[]> => {
if (_emojiIndex) return _emojiIndex;
const { default: data } = await import("@emoji-mart/data");
const { default: data } = await import('@slidoapp/emoji-mart-data');
_emojiIndex = (Object.values((data as any).emojis) as any[])
.filter((e) => e.id && e.name && e.skins?.[0]?.native)
.map((e) => ({
@@ -74,7 +74,7 @@ let _cats: EmojiCategory[] | null = null;
export const getEmojiCategories = async (): Promise<EmojiCategory[]> => {
if (_cats) return _cats;
const [{ default: data }, index] = await Promise.all([
import("@emoji-mart/data"),
import("@slidoapp/emoji-mart-data"),
buildEmojiIndex(),
]);
const byId = new Map(index.map((e) => [e.id, e]));
@@ -0,0 +1,14 @@
import { lazy, Suspense } from "react";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
const ExcalidrawMenu = lazy(
() => import("@/features/editor/components/excalidraw/excalidraw-menu.tsx"),
);
export default function ExcalidrawMenuLazy(props: EditorMenuProps) {
return (
<Suspense fallback={null}>
<ExcalidrawMenu {...props} />
</Suspense>
);
}
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -94,7 +95,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "excalidraw";
const parent = findParentNode(predicate)(selection);
@@ -0,0 +1,14 @@
import { lazy, Suspense } from "react";
import { NodeViewProps } from "@tiptap/react";
const ExcalidrawView = lazy(
() => import("@/features/editor/components/excalidraw/excalidraw-view.tsx"),
);
export default function ExcalidrawViewLazy(props: NodeViewProps) {
return (
<Suspense fallback={null}>
<ExcalidrawView {...props} />
</Suspense>
);
}
@@ -240,7 +240,11 @@ export default function ExcalidrawView(props: NodeViewProps) {
className={clsx(selected ? "ProseMirror-selectednode" : "")}
>
<div style={{ display: "flex", alignItems: "center" }}>
<ActionIcon variant="transparent" color="gray">
<ActionIcon
variant="transparent"
color="gray"
aria-label={t("Edit drawing")}
>
<IconEdit size={18} />
</ActionIcon>
@@ -3,7 +3,7 @@
top: calc(var(--app-shell-header-offset, 0rem) + 45px);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
z-index: 50;
z-index: 99;
display: flex;
align-items: center;
background: var(--mantine-color-body);
@@ -28,6 +28,7 @@ export const FixedToolbar: FC = () => {
<>
<div
className={classes.fixedToolbar}
data-fixed-toolbar="true"
role="toolbar"
aria-label="Editor toolbar"
onMouseDown={(e) => e.preventDefault()}
@@ -10,6 +10,7 @@ import {
IconH2,
IconH3,
IconMenu4,
IconPageBreak,
IconTypography,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
@@ -102,6 +103,12 @@ export const BlockTypeGroup: FC<Props> = ({ editor }) => {
>
{t("Divider")}
</Menu.Item>
<Menu.Item
leftSection={<IconPageBreak size={16} />}
onClick={() => editor.chain().focus().setPageBreak().run()}
>
{t("Page break")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback, useRef } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -56,7 +57,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image";
const parent = findParentNode(predicate)(selection);
@@ -149,8 +149,13 @@ export default function MathBlockView(props: NodeViewProps) {
></Textarea>
<Flex justify="flex-end" align="flex-end">
<ActionIcon variant="light" color="red">
<IconTrashX size={18} onClick={() => props.deleteNode()} />
<ActionIcon
variant="light"
color="red"
aria-label={t("Delete equation")}
onClick={() => props.deleteNode()}
>
<IconTrashX size={18} />
</ActionIcon>
</Flex>
</Stack>
@@ -16,6 +16,7 @@ import {
ScrollArea,
Text,
UnstyledButton,
VisuallyHidden,
} from "@mantine/core";
import clsx from "clsx";
import classes from "./mention.module.css";
@@ -36,7 +37,7 @@ import {
usePageQuery,
} from "@/features/page/queries/page-query";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
import { SimpleTree } from "react-arborist";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
@@ -46,6 +47,8 @@ import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(1);
const viewportRef = useRef<HTMLDivElement>(null);
const [countAnnouncement, setCountAnnouncement] = useState("");
const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
const { pageSlug, spaceSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: space } = useSpaceQuery(spaceSlug);
@@ -53,7 +56,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
const { t } = useTranslation();
const [data, setData] = useAtom(treeDataAtom);
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
const createPageMutation = useCreatePageMutation();
const emit = useQueryEmit();
const isInCommentContext = props.isInCommentContext ?? false;
@@ -184,6 +186,45 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
setSelectedIndex(1);
}, [suggestion]);
const selectableCount = useMemo(
() => renderItems.filter((item) => item.entityType !== "header").length,
[renderItems],
);
useEffect(() => {
if (renderItems.length === 0) {
setCountAnnouncement(t("No results"));
return;
}
setCountAnnouncement(
t("{{count}} result available", { count: selectableCount }),
);
}, [renderItems.length, selectableCount, t]);
useEffect(() => {
const item = renderItems[selectedIndex];
if (!item || item.entityType === "header") {
setSelectionAnnouncement("");
return;
}
if (item.entityType === "user") {
setSelectionAnnouncement(`${t("People")}: ${item.label}`);
return;
}
if (item.entityType === "page") {
if (item.id === null) {
setSelectionAnnouncement(`${t("Create page")}: ${item.label}`);
return;
}
const pageLabel = item.label || t("Untitled");
setSelectionAnnouncement(
item.spaceName
? `${t("Pages")}: ${pageLabel}, ${item.spaceName}`
: `${t("Pages")}: ${pageLabel}`,
);
}
}, [selectedIndex, renderItems, t]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === "ArrowUp") {
@@ -220,20 +261,20 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
try {
createdPage = await createPageMutation.mutateAsync(payload);
const parentId = page.id || null;
const data = {
const newNode: SpaceTreeNode = {
id: createdPage.id,
slugId: createdPage.slugId,
name: createdPage.title,
position: createdPage.position,
spaceId: createdPage.spaceId,
parentPageId: createdPage.parentPageId,
hasChildren: false,
children: [],
} as any;
};
const lastIndex = tree.data.length;
const lastIndex = data.length;
tree.create({ parentId, index: lastIndex, data });
setData(tree.data);
setData(treeModel.insert(data, parentId, newNode, lastIndex));
props.command({
id: uuid7(),
@@ -251,7 +292,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
payload: {
parentId,
index: lastIndex,
data,
data: newNode,
},
});
}, 50);
@@ -271,6 +312,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
if (renderItems.length === 0) {
return (
<Paper id="mention" shadow="md" py="xs" withBorder radius="md">
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
{countAnnouncement}
</VisuallyHidden>
<Text c="dimmed" size="sm" px="sm">
{t("No results")}
</Text>
@@ -297,6 +341,12 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
aria-label={t("Mention suggestions")}
aria-activedescendant={`mention-option-${selectedIndex}`}
>
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
{countAnnouncement}
</VisuallyHidden>
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
{selectionAnnouncement}
</VisuallyHidden>
<ScrollArea.Autosize
viewportRef={viewportRef}
mah={350}
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -37,9 +38,8 @@ export function PdfMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state || !editor.isActive("pdf")) {
return false;
}
if (!state || !isEditorReady(editor)) return false;
if (!editor.isActive("pdf")) return false;
const { selection } = state;
const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null;
@@ -51,7 +51,7 @@ export function PdfMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "pdf";
const parent = findParentNode(predicate)(selection);
@@ -10,6 +10,7 @@ import {
ScrollArea,
Text,
UnstyledButton,
VisuallyHidden,
} from "@mantine/core";
import classes from "./slash-menu.module.css";
import clsx from "clsx";
@@ -29,6 +30,8 @@ const CommandList = ({
const { t } = useTranslation();
const [selectedIndex, setSelectedIndex] = useState(0);
const viewportRef = useRef<HTMLDivElement>(null);
const [countAnnouncement, setCountAnnouncement] = useState("");
const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
const flatItems = useMemo(() => {
return Object.values(items).flat();
@@ -79,6 +82,25 @@ const CommandList = ({
setSelectedIndex(0);
}, [flatItems]);
useEffect(() => {
if (flatItems.length === 0) {
setCountAnnouncement("");
return;
}
setCountAnnouncement(
t("{{count}} command available", { count: flatItems.length }),
);
}, [flatItems.length, t]);
useEffect(() => {
const item = flatItems[selectedIndex];
if (!item) {
setSelectionAnnouncement("");
return;
}
setSelectionAnnouncement(`${t(item.title)}, ${t(item.description)}`);
}, [selectedIndex, flatItems, t]);
useEffect(() => {
viewportRef.current
?.querySelector(`[data-item-index="${selectedIndex}"]`)
@@ -95,6 +117,12 @@ const CommandList = ({
aria-label={t("Slash commands")}
aria-activedescendant={`slash-command-option-${selectedIndex}`}
>
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
{countAnnouncement}
</VisuallyHidden>
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
{selectionAnnouncement}
</VisuallyHidden>
<ScrollArea
viewportRef={viewportRef}
h={350}
@@ -19,6 +19,7 @@ import {
IconTable,
IconTypography,
IconMenu4,
IconPageBreak,
IconCalendar,
IconAppWindow,
IconSitemap,
@@ -164,6 +165,14 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
},
{
title: "Page break",
description: "Insert a page break for printing.",
searchTerms: ["page", "break", "pagebreak", "print"],
icon: IconPageBreak,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setPageBreak().run(),
},
{
title: "Image",
description: "Upload any image from your device.",
@@ -6,6 +6,7 @@ import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core";
import { isEditorReady } from "@docmost/editor-ext";
interface SubpagesMenuProps {
editor: Editor;
@@ -33,6 +34,7 @@ export const SubpagesMenu = React.memo(
);
const getReferenceClientRect = useCallback(() => {
if (!isEditorReady(editor)) return new DOMRect();
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "subpages";
const parent = findParentNode(predicate)(selection);
@@ -3,7 +3,7 @@ import { TextSelection } from "@tiptap/pm/state";
import React, { FC, useEffect, useRef, useState } from "react";
import classes from "./table-of-contents.module.css";
import clsx from "clsx";
import { Box, Text } from "@mantine/core";
import { Box, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
type TableOfContentsProps = {
@@ -156,9 +156,9 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
return (
<>
{props.isShare && (
<Text mb="md" fw={500}>
<Title order={2} size="h6" mb="md" fw={500}>
{t("Table of contents")}
</Text>
</Title>
)}
<div className={props.isShare ? classes.leftBorder : ""}>
{links.map((item, idx) => (
@@ -0,0 +1,126 @@
import React, { useCallback, useEffect } from "react";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { TextSelection } from "@tiptap/pm/state";
import { columnResizingPluginKey } from "@tiptap/pm/tables";
import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
import { Menu, UnstyledButton } from "@mantine/core";
import { IconChevronDown } from "@tabler/icons-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { isCellSelection } from "@docmost/editor-ext";
import { CellChevronMenu } from "./menus/cell-chevron-menu";
import classes from "./handle.module.css";
interface CellChevronProps {
editor: Editor;
cellPos: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export const CellChevron = React.memo(function CellChevron({
editor,
cellPos,
tableNode,
tablePos,
}: CellChevronProps) {
const { t } = useTranslation();
const cellDom = editor.view.nodeDOM(cellPos) as HTMLElement | null;
const { refs, floatingStyles, middlewareData } = useFloating({
placement: "top-end",
// crossAxis pulls the chevron INWARD from the cell's right edge. We need
// enough inset that we don't overlap PM-tables' column-resize hot zone
// (~5px wide around the column boundary). Without this, hovering near the
// column edge picks up the chevron's `cursor: pointer` instead of
// `col-resize`, and a drag near the edge clicks the chevron.
middleware: [offset({ mainAxis: -22, crossAxis: -10 }), hide()],
whileElementsMounted: autoUpdate,
strategy: "absolute",
});
const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
useEffect(() => {
refs.setReference(cellDom);
}, [cellDom, refs]);
// Hide the chevron while the user is resizing a column. PM-tables sets
// `activeHandle > -1` whenever the mouse is near a column boundary OR
// actively dragging it. Either way we don't want the chevron in the way.
const isResizingColumn = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) return false;
const state = columnResizingPluginKey.getState(ctx.editor.state) as
| { activeHandle: number }
| undefined;
return !!state && state.activeHandle > -1;
},
});
const onOpen = useCallback(() => {
const current = editor.state.selection;
// Preserve an existing multi-cell CellSelection that already covers
// this cell so merge etc. operate on the user's whole range.
let preserveExisting = false;
if (isCellSelection(current)) {
current.forEachCell((_node, pos) => {
if (pos === cellPos) preserveExisting = true;
});
}
if (!preserveExisting) {
// Drop a collapsed cursor inside the cell rather than a single-cell
// CellSelection — PM-tables paints the latter as a text-range
// highlight on the cell content.
try {
const $inside = editor.state.doc.resolve(cellPos + 1);
const sel = TextSelection.near($inside, 1);
editor.view.dispatch(editor.state.tr.setSelection(sel));
} catch {}
}
editor.commands.freezeHandles();
}, [editor, cellPos]);
const onClose = useCallback(() => {
editor.commands.unfreezeHandles();
}, [editor]);
if (!cellDom) return null;
if (isResizingColumn) return null;
return (
<Menu
position="bottom-end"
onOpen={onOpen}
onClose={onClose}
withinPortal
shadow="md"
>
<Menu.Target>
<UnstyledButton
ref={refs.setFloating}
style={{
...floatingStyles,
...(isReferenceHidden ? { visibility: "hidden" as const } : {}),
}}
className={clsx(classes.cellChevron)}
aria-label={t("Cell actions")}
>
<IconChevronDown size={14} />
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<CellChevronMenu
editor={editor}
cellPos={cellPos}
tableNode={tableNode}
tablePos={tablePos}
/>
</Menu.Dropdown>
</Menu>
);
});
@@ -0,0 +1,132 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
import { Menu } from "@mantine/core";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { useTableHandleDrag } from "./hooks/use-table-handle-drag";
import { useColumnRowMenuLifecycle } from "./hooks/use-column-row-menu-lifecycle";
import { ColumnHandleMenu } from "./menus/column-handle-menu";
import classes from "./handle.module.css";
interface ColumnHandleProps {
editor: Editor;
index: number;
anchorPos: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export const ColumnHandle = React.memo(function ColumnHandle({
editor,
index,
anchorPos,
tableNode,
tablePos,
}: ColumnHandleProps) {
const { t } = useTranslation();
// Hold the cell DOM in a ref-backed state so we never unmount the handle
// mid-drag. A remote edit can transiently flip `nodeDOM(anchorPos)` to null
// (the plugin re-emits `hoveringCell` with the mapped pos a tick later);
// unmounting the source element here would make pragmatic-dnd silently
// abort the active drag.
// `nodeDOM` is typed as `Node | null` — when `anchorPos` goes stale (e.g.
// an external drop reflows the doc before the plugin re-emits
// hoveringCell), it can resolve to a Text node, on which `.closest` is
// undefined. Filter to HTMLElement so downstream consumers stay safe.
const lookupDom = editor.view.nodeDOM(anchorPos);
const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : null;
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
useEffect(() => {
if (lookupCellDom && lookupCellDom !== lastCellDomRef.current) {
lastCellDomRef.current = lookupCellDom;
setCellDom(lookupCellDom);
}
}, [lookupCellDom]);
const [handleEl, setHandleEl] = useState<HTMLDivElement | null>(null);
const { refs, floatingStyles, middlewareData } = useFloating({
placement: "top",
middleware: [offset(-4), hide()],
whileElementsMounted: autoUpdate,
});
const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
useEffect(() => {
refs.setReference(cellDom);
}, [cellDom, refs]);
// `cellDom` is inside the table, so `closest('.tableWrapper')` finds the
// wrapper for this drag's auto-scroll. The handle itself lives in a
// floating layer outside the editor DOM, so we can't walk up from it.
const wrapper = cellDom?.closest<HTMLElement>(".tableWrapper") ?? null;
const [menuOpened, setMenuOpened] = useState(false);
const closeMenu = useCallback(() => setMenuOpened(false), []);
useTableHandleDrag(editor, "col", handleEl, wrapper, closeMenu);
const { onOpen, onClose } = useColumnRowMenuLifecycle({
editor,
orientation: "col",
index,
tableNode,
tablePos,
});
if (!cellDom) return null;
return (
<Menu
opened={menuOpened}
onChange={setMenuOpened}
position="bottom-start"
onOpen={onOpen}
onClose={onClose}
withinPortal
shadow="md"
>
<Menu.Target>
<div
ref={(node) => {
refs.setFloating(node);
setHandleEl(node);
}}
style={{
...floatingStyles,
...(isReferenceHidden ? { visibility: "hidden" as const } : {}),
}}
className={clsx(classes.handle, classes.columnHandle)}
role="button"
tabIndex={0}
aria-label={t("Column actions")}
>
<span style={{ pointerEvents: "none", display: "inline-flex" }}>
<GripIcon />
</span>
</div>
</Menu.Target>
<Menu.Dropdown>
<ColumnHandleMenu
editor={editor}
index={index}
tableNode={tableNode}
tablePos={tablePos}
/>
</Menu.Dropdown>
</Menu>
);
});
function GripIcon() {
return (
<svg viewBox="0 0 10 10" width="14" height="14" aria-hidden>
<path
fill="currentColor"
d="M3,2 A1,1 0 1 1 3,0 A1,1 0 0 1 3,2 Z M3,6 A1,1 0 1 1 3,4 A1,1 0 0 1 3,6 Z M3,10 A1,1 0 1 1 3,8 A1,1 0 0 1 3,10 Z M7,2 A1,1 0 1 1 7,0 A1,1 0 0 1 7,2 Z M7,6 A1,1 0 1 1 7,4 A1,1 0 0 1 7,6 Z M7,10 A1,1 0 1 1 7,8 A1,1 0 0 1 7,10 Z"
/>
</svg>
);
}
@@ -0,0 +1,108 @@
.handle {
position: absolute;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: rgba(55, 53, 47, 0.45);
background: var(--mantine-color-body);
border: 1px solid rgba(55, 53, 47, 0.12);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
cursor: grab;
padding: 0;
transition: background-color 120ms ease, color 120ms ease;
@mixin dark {
color: rgba(255, 255, 255, 0.55);
background: var(--mantine-color-dark-7);
border-color: rgba(255, 255, 255, 0.12);
}
}
.handle:hover {
background: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
color: light-dark(
var(--mantine-color-gray-7),
var(--mantine-color-dark-0)
);
}
.handle:active {
cursor: grabbing;
}
.columnHandle {
width: 28px;
height: 16px;
}
.columnHandle svg {
transform: rotate(90deg);
}
.rowHandle {
width: 16px;
height: 28px;
}
@media (max-width: 600px) {
.handle {
display: none;
}
}
.cellChevron {
position: absolute;
z-index: 50;
width: 18px;
height: 18px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: light-dark(
var(--mantine-color-gray-7),
var(--mantine-color-dark-1)
);
background: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
border: 1px solid rgba(55, 53, 47, 0.12);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
cursor: pointer;
padding: 0;
transition: background-color 120ms ease, color 120ms ease;
@mixin dark {
border-color: rgba(255, 255, 255, 0.12);
}
}
.cellChevron:hover {
background: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-4)
);
color: light-dark(
var(--mantine-color-gray-8),
var(--mantine-color-dark-0)
);
}
@media (max-width: 600px) {
.cellChevron {
display: none;
}
}
@media print {
.handle,
.cellChevron {
display: none !important;
}
}
@@ -0,0 +1,40 @@
import { useCallback } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { buildRowOrColumnSelection, Orientation } from "../lib/select-row-column";
interface Args {
editor: Editor;
orientation: Orientation;
index: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export function useColumnRowMenuLifecycle({
editor,
orientation,
index,
tableNode,
tablePos,
}: Args) {
const onOpen = useCallback(() => {
const selection = buildRowOrColumnSelection(
editor.state,
tableNode,
tablePos,
orientation,
index,
);
const tr = editor.state.tr;
if (selection) tr.setSelection(selection);
editor.view.dispatch(tr);
editor.commands.freezeHandles();
}, [editor, orientation, index, tableNode, tablePos]);
const onClose = useCallback(() => {
editor.commands.unfreezeHandles();
}, [editor]);
return { onOpen, onClose };
}

Some files were not shown because too many files have changed in this diff Show More