Compare commits

..

19 Commits

Author SHA1 Message Date
Philipinho e02f0acc65 fix: add i18next_json type to crowdin 2026-05-20 17:34:34 +01:00
Philipinho adb1f27767 v0.90.0 2026-05-20 16:55:23 +01:00
Philip Okugbe 92c0e36e46 fix(a11y): WCAG 2.1 AA fixes (#2219) 2026-05-20 16:47:25 +01:00
Olivier Lambert 1c166c4736 feat(editor): add alt text support for images (#2097)
* feat(editor): add alt text support for images
* feat:  extend alt text support to videos and diagrams

---------
Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-05-20 16:45:59 +01:00
Philip Okugbe 66a754c9eb Revert "fix: prevent browser tab fallback in editor (#2123)" (#2216)
This reverts commit 1d2486455f.
2026-05-19 14:07:07 +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
269 changed files with 10872 additions and 3814 deletions
+7
View File
@@ -48,6 +48,13 @@ GOTENBERG_URL=
DISABLE_TELEMETRY=false 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) # Enable debug logging in production (default: false)
DEBUG_MODE=false DEBUG_MODE=false
+69 -58
View File
@@ -1,84 +1,95 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.80.1", "version": "0.90.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"" "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "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:*", "@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-3a5ef40", "@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@mantine/core": "^8.3.18", "@mantine/core": "8.3.18",
"@mantine/dates": "^8.3.18", "@mantine/dates": "8.3.18",
"@mantine/form": "^8.3.18", "@mantine/form": "8.3.18",
"@mantine/hooks": "^8.3.18", "@mantine/hooks": "8.3.18",
"@mantine/modals": "^8.3.18", "@mantine/modals": "8.3.18",
"@mantine/notifications": "^8.3.18", "@mantine/notifications": "8.3.18",
"@mantine/spotlight": "^8.3.18", "@mantine/spotlight": "8.3.18",
"@tabler/icons-react": "^3.40.0", "@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", "@tanstack/react-query": "5.90.17",
"alfaaz": "^1.1.0", "@tanstack/react-virtual": "3.13.24",
"alfaaz": "1.1.0",
"axios": "1.16.0", "axios": "1.16.0",
"blueimp-load-image": "^5.16.0", "blueimp-load-image": "5.16.0",
"clsx": "^2.1.1", "clsx": "2.1.1",
"emoji-mart": "^5.6.0", "file-saver": "2.0.5",
"file-saver": "^2.0.5", "highlightjs-sap-abap": "0.3.0",
"highlightjs-sap-abap": "^0.3.0",
"i18next": "25.10.1", "i18next": "25.10.1",
"i18next-http-backend": "3.0.6", "i18next-http-backend": "3.0.6",
"jotai": "^2.18.1", "jotai": "2.18.1",
"jotai-optics": "^0.4.0", "jotai-optics": "0.4.0",
"js-cookie": "^3.0.5", "js-cookie": "3.0.5",
"jwt-decode": "^4.0.0", "jwt-decode": "4.0.0",
"katex": "0.16.40", "katex": "0.16.40",
"lowlight": "^3.3.0", "lowlight": "3.3.0",
"mantine-form-zod-resolver": "^1.3.0", "mantine-form-zod-resolver": "1.3.0",
"mermaid": "^11.13.0", "mermaid": "11.15.0",
"mitt": "^3.0.1", "mitt": "3.0.1",
"posthog-js": "1.372.2", "posthog-js": "1.372.2",
"react": "^18.3.1", "react": "18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.18", "react-clear-modal": "^2.0.18",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-drawio": "^1.0.7", "react-drawio": "1.0.7",
"react-error-boundary": "^6.1.1", "react-error-boundary": "6.1.1",
"react-helmet-async": "^3.0.0", "react-helmet-async": "3.0.0",
"react-i18next": "16.5.8", "react-i18next": "16.5.8",
"react-router-dom": "^7.13.1", "react-router-dom": "7.13.1",
"semver": "^7.7.4", "semver": "7.7.4",
"socket.io-client": "^4.8.3", "socket.io-client": "4.8.3",
"zod": "^4.3.6" "zod": "4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.28.0", "@eslint/js": "9.28.0",
"@tanstack/eslint-plugin-query": "^5.94.4", "@tanstack/eslint-plugin-query": "5.94.4",
"@types/blueimp-load-image": "^5.16.6", "@testing-library/jest-dom": "6.6.0",
"@types/file-saver": "^2.0.7", "@testing-library/react": "16.1.0",
"@types/js-cookie": "^3.0.6", "@types/blueimp-load-image": "5.16.6",
"@types/katex": "^0.16.8", "@types/file-saver": "2.0.7",
"@types/js-cookie": "3.0.6",
"@types/katex": "0.16.8",
"@types/node": "22.19.1", "@types/node": "22.19.1",
"@types/react": "^18.3.12", "@types/react": "18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "6.0.1",
"eslint": "^9.28.0", "eslint": "9.28.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "0.5.2",
"globals": "^15.13.0", "globals": "15.13.0",
"optics-ts": "^2.4.1", "jsdom": "25.0.0",
"postcss": "^8.5.12", "optics-ts": "2.4.1",
"postcss-preset-mantine": "^1.18.0", "postcss": "8.5.14",
"postcss-simple-vars": "^7.0.1", "postcss-preset-mantine": "1.18.0",
"prettier": "^3.8.1", "postcss-simple-vars": "7.0.1",
"typescript": "^5.9.3", "prettier": "3.8.1",
"typescript-eslint": "^8.57.1", "typescript": "5.9.3",
"vite": "8.0.5" "typescript-eslint": "8.57.1",
"vite": "8.0.5",
"vitest": "4.1.6"
} }
} }
@@ -71,6 +71,7 @@
"Export": "Export", "Export": "Export",
"Failed to create page": "Failed to create page", "Failed to create page": "Failed to create page",
"Failed to delete page": "Failed to delete 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 fetch recent pages": "Failed to fetch recent pages",
"Failed to import pages": "Failed to import pages", "Failed to import pages": "Failed to import pages",
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.", "Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
@@ -276,6 +277,9 @@
"Align left": "Align left", "Align left": "Align left",
"Align right": "Align right", "Align right": "Align right",
"Align center": "Align center", "Align center": "Align center",
"Alt text": "Alt text",
"Describe this for accessibility.": "Describe this for accessibility.",
"Add a description": "Add a description",
"Justify": "Justify", "Justify": "Justify",
"Merge cells": "Merge cells", "Merge cells": "Merge cells",
"Split cell": "Split cell", "Split cell": "Split cell",
@@ -286,6 +290,19 @@
"Add row above": "Add row above", "Add row above": "Add row above",
"Add row below": "Add row below", "Add row below": "Add row below",
"Delete table": "Delete table", "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", "Info": "Info",
"Note": "Note", "Note": "Note",
"Success": "Success", "Success": "Success",
@@ -348,6 +365,8 @@
"Create block quote.": "Create block quote.", "Create block quote.": "Create block quote.",
"Insert code snippet.": "Insert code snippet.", "Insert code snippet.": "Insert code snippet.",
"Insert horizontal rule divider": "Insert horizontal rule divider", "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 image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video 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.", "Upload any audio from your device.": "Upload any audio from your device.",
@@ -392,6 +411,10 @@
"Write...": "Write...", "Write...": "Write...",
"Column count": "Column count", "Column count": "Column count",
"{{count}} Columns": "{{count}} Columns", "{{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", "Equal columns": "Equal columns",
"Left sidebar": "Left sidebar", "Left sidebar": "Left sidebar",
"Right sidebar": "Right sidebar", "Right sidebar": "Right sidebar",
@@ -566,6 +589,8 @@
"Move to trash": "Move to trash", "Move to trash": "Move to trash",
"Move this page to trash?": "Move this page to trash?", "Move this page to trash?": "Move this page to trash?",
"Restore page": "Restore page", "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 moved to trash": "Page moved to trash",
"Page restored successfully": "Page restored successfully", "Page restored successfully": "Page restored successfully",
"Deleted by": "Deleted by", "Deleted by": "Deleted by",
@@ -855,9 +880,12 @@
"AI Chat": "AI Chat", "AI Chat": "AI Chat",
"Analyze for insights": "Analyze for insights", "Analyze for insights": "Analyze for insights",
"Ask anything...": "Ask anything...", "Ask anything...": "Ask anything...",
"Assistant said:": "Assistant said:",
"Chat history": "Chat history", "Chat history": "Chat history",
"Chat name": "Chat name", "Chat name": "Chat name",
"Chat transcript": "Chat transcript",
"Close": "Close", "Close": "Close",
"Copy assistant response": "Copy assistant response",
"Docmost AI": "Docmost AI", "Docmost AI": "Docmost AI",
"Failed to load chat. An error occurred.": "Failed to load chat. An error occurred.", "Failed to load chat. An error occurred.": "Failed to load chat. An error occurred.",
"Failed to render this message.": "Failed to render this message.", "Failed to render this message.": "Failed to render this message.",
@@ -867,6 +895,8 @@
"No chats found": "No chats found", "No chats found": "No chats found",
"No conversations yet": "No conversations yet", "No conversations yet": "No conversations yet",
"Open full page": "Open full page", "Open full page": "Open full page",
"Scroll to bottom": "Scroll to bottom",
"You said:": "You said:",
"Previous 7 days": "Previous 7 days", "Previous 7 days": "Previous 7 days",
"Previous 30 days": "Previous 30 days", "Previous 30 days": "Previous 30 days",
"Search chats...": "Search chats...", "Search chats...": "Search chats...",
@@ -918,6 +948,35 @@
"Page actions": "Page actions", "Page actions": "Page actions",
"Pick emoji": "Pick emoji", "Pick emoji": "Pick emoji",
"Template menu": "Template menu", "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", "Chat menu": "Chat menu",
"API key menu": "API key menu", "API key menu": "API key menu",
"Jump to comment selection": "Jump to comment selection", "Jump to comment selection": "Jump to comment selection",
@@ -997,5 +1056,33 @@
"No pages with this label": "No pages with this 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.", "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.", "No pages match your search.": "No pages match your search.",
"Updated {{date}}": "Updated {{date}}" "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}}"
} }
@@ -80,12 +80,20 @@ export default function AvatarUploader({
} }
}; };
const ariaLabel = { const actionLabel = {
[AvatarIconType.AVATAR]: t("Change avatar"), [AvatarIconType.AVATAR]: t("Change avatar"),
[AvatarIconType.SPACE_ICON]: t("Change space icon"), [AvatarIconType.SPACE_ICON]: t("Change space icon"),
[AvatarIconType.WORKSPACE_ICON]: t("Change workspace icon"), [AvatarIconType.WORKSPACE_ICON]: t("Change workspace icon"),
}[type]; }[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 () => { const handleRemove = async () => {
if (disabled) return; if (disabled) return;
+7 -3
View File
@@ -8,15 +8,19 @@ interface CopyProps {
text: string; text: string;
size?: MantineSize; size?: MantineSize;
color?: MantineColor; 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 { t } = useTranslation();
const copyLabel = label ?? t("Copy");
return ( return (
<CopyButton value={text} timeout={2000}> <CopyButton value={text} timeout={2000}>
{({ copied, copy }) => ( {({ copied, copy }) => (
<Tooltip <Tooltip
label={copied ? t("Copied") : t("Copy")} label={copied ? t("Copied") : copyLabel}
withArrow withArrow
position="right" position="right"
> >
@@ -25,7 +29,7 @@ export default function CopyTextButton({ text, size }: CopyProps) {
variant="subtle" variant="subtle"
onClick={copy} onClick={copy}
size={size} size={size}
aria-label={copied ? t("Copied") : t("Copy")} aria-label={copied ? t("Copied") : copyLabel}
> >
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />} {copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon> </ActionIcon>
@@ -81,7 +81,7 @@ export default function ExportModal({
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}> <Modal.Header py={0}>
<Modal.Title fw={500}>{t(`Export ${type}`)}</Modal.Title> <Modal.Title fw={500}>{t(`Export ${type}`)}</Modal.Title>
<Modal.CloseButton /> <Modal.CloseButton aria-label={t("Close")} />
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<Group justify="space-between" wrap="nowrap"> <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 { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getInitialsColor } from "@/lib/get-initials-color.ts"; import { getInitialsColor } from "@/lib/get-initials-color.ts";
import rowClasses from "@/components/ui/clickable-table-row.module.css";
interface Props { interface Props {
spaceId?: string; spaceId?: string;
@@ -41,9 +42,10 @@ export default function RecentChanges({ spaceId }: Props) {
<Table highlightOnHover verticalSpacing="sm"> <Table highlightOnHover verticalSpacing="sm">
<Table.Tbody> <Table.Tbody>
{pages.map((page) => ( {pages.map((page) => (
<Table.Tr key={page.id}> <Table.Tr key={page.id} className={rowClasses.row}>
<Table.Td> <Table.Td>
<UnstyledButton <UnstyledButton
className={rowClasses.link}
component={Link} component={Link}
to={buildPageUrl(page?.space.slug, page.slugId, page.title)} 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 CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; 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 { useTranslation } from "react-i18next";
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx"; import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel"; import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx"; 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() { export default function Aside() {
const [{ tab }] = useAtom(asideStateAtom); const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom); 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 title: string;
let component: ReactNode; let component: ReactNode;
@@ -45,9 +53,19 @@ export default function Aside() {
{component && ( {component && (
<> <>
{tab !== "chat" && ( {tab !== "chat" && (
<Text mb="md" fw={500}> <Group justify="space-between" wrap="nowrap" mb="md">
{t(title)} <Title order={2} size="h6" fw={500}>{t(title)}</Title>
</Text> <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" ? ( {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 { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx"; 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({ export default function GlobalAppShell({
children, children,
@@ -81,6 +83,8 @@ export default function GlobalAppShell({
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute; const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
return ( return (
<>
<SkipToMain />
<AppShell <AppShell
header={{ height: 45 }} header={{ height: 45 }}
navbar={{ navbar={{
@@ -125,7 +129,7 @@ export default function GlobalAppShell({
{isAiRoute && <AiChatSidebar />} {isAiRoute && <AiChatSidebar />}
{showGlobalSidebar && <GlobalSidebar />} {showGlobalSidebar && <GlobalSidebar />}
</AppShell.Navbar> </AppShell.Navbar>
<AppShell.Main id="main-content"> <AppShell.Main id={MAIN_CONTENT_ID} tabIndex={-1}>
{isSettingsRoute ? ( {isSettingsRoute ? (
<Container size={900} pb={80}> <Container size={900} pb={80}>
{children} {children}
@@ -137,6 +141,8 @@ export default function GlobalAppShell({
{isPageRoute && ( {isPageRoute && (
<AppShell.Aside <AppShell.Aside
id={ASIDE_PANEL_ID}
tabIndex={-1}
className={classes.aside} className={classes.aside}
p="md" p="md"
withBorder={false} withBorder={false}
@@ -156,5 +162,6 @@ export default function GlobalAppShell({
</AppShell.Aside> </AppShell.Aside>
)} )}
</AppShell> </AppShell>
</>
); );
} }
@@ -31,6 +31,11 @@
color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); 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] { &[data-active] {
&, &,
& :hover { & :hover {
@@ -38,6 +43,16 @@
color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); 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 { .linkIcon {
@@ -86,4 +101,9 @@
); );
color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); 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 { useEffect, useState } from "react";
import { ScrollArea, Text, Divider, Modal, UnstyledButton } from "@mantine/core"; import { ScrollArea, Text, Divider, Modal, UnstyledButton, Tooltip } from "@mantine/core";
import { import {
IconHome, IconHome,
IconClock, IconClock,
@@ -7,6 +7,7 @@ import {
IconLayoutGrid, IconLayoutGrid,
IconSettings, IconSettings,
IconUserPlus, IconUserPlus,
IconTemplate,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import classes from "./global-sidebar.module.css"; 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 { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form";
import { CustomAvatar } from "@/components/ui/custom-avatar"; import { CustomAvatar } from "@/components/ui/custom-avatar";
import { AvatarIconType } from "@/features/attachments/types/attachment.types"; import { AvatarIconType } from "@/features/attachments/types/attachment.types";
import { useHasFeature } from "@/ee/hooks/use-feature";
const mainNavItems = [ import { Feature } from "@/ee/features";
{ label: "Home", icon: IconHome, path: "/home" }, import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
{ label: "Favorites", icon: IconStar, path: "/favorites" },
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
];
export default function GlobalSidebar() { export default function GlobalSidebar() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -33,6 +31,19 @@ export default function GlobalSidebar() {
const [active, setActive] = useState(location.pathname); const [active, setActive] = useState(location.pathname);
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(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 { data: favoriteSpacesData, isPending: isFavoritesPending } = useFavoritesQuery("space");
const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? []; const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? [];
const sortedFavoriteSpaces = [...favoriteSpaces] const sortedFavoriteSpaces = [...favoriteSpaces]
@@ -58,18 +69,38 @@ export default function GlobalSidebar() {
<div className={classes.navbar}> <div className={classes.navbar}>
<ScrollArea w="100%" style={{ flex: 1 }}> <ScrollArea w="100%" style={{ flex: 1 }}>
<div className={classes.section}> <div className={classes.section}>
{mainNavItems.map((item) => ( {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 <Link
key={item.label} key={item.label}
className={classes.link} className={classes.link}
data-active={active === item.path || undefined} data-active={active === item.path || undefined}
aria-current={active === item.path ? "page" : undefined}
to={item.path} to={item.path}
onClick={handleNavClick} onClick={handleNavClick}
> >
<item.icon className={classes.linkIcon} stroke={2} /> <item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span> <span>{t(item.label)}</span>
</Link> </Link>
))} ),
)}
</div> </div>
<Divider my="xs" /> <Divider my="xs" />
@@ -129,6 +160,7 @@ export default function GlobalSidebar() {
<Link <Link
className={classes.link} className={classes.link}
data-active={active.startsWith("/settings") || undefined} data-active={active.startsWith("/settings") || undefined}
aria-current={active.startsWith("/settings") ? "page" : undefined}
to="/settings/account/profile" to="/settings/account/profile"
onClick={handleNavClick} onClick={handleNavClick}
> >
@@ -4,7 +4,7 @@ import { Divider, Title } from '@mantine/core';
export default function SettingsTitle({ title }: { title: string }) { export default function SettingsTitle({ title }: { title: string }) {
return ( return (
<> <>
<Title order={3}> <Title order={1} size="h3">
{title} {title}
</Title> </Title>
<Divider my="md" /> <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; mt?: string | number;
} }
// `color.shade` pairs whose filled background meets WCAG AA (4.5:1) against // `color.shade` pairs whose contrast meets WCAG AA (4.5:1) in BOTH variants:
// white text. Avoids lime/yellow/green/orange — even their dark shades have // - filled: white text on the shade as bg
// weak white-text contrast. // - 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[] = [ const SAFE_INITIALS_COLORS: MantineColor[] = [
"blue.8", "blue.8",
"cyan.9", "cyan.9",
"grape.7", "grape.9",
"indigo.7", "indigo.8",
"pink.8", "pink.8",
"red.8", "red.8",
"violet.7", "violet.7",
@@ -16,6 +16,8 @@ export function DestinationPickerModal({
loading, loading,
excludePageId, excludePageId,
pageLimit, pageLimit,
initialSpaceId,
searchSpacesOnly,
}: DestinationPickerModalProps) { }: DestinationPickerModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [selection, setSelection] = useState<DestinationSelection | null>(null); const [selection, setSelection] = useState<DestinationSelection | null>(null);
@@ -39,13 +41,15 @@ export function DestinationPickerModal({
<Modal.Content> <Modal.Content>
<Modal.Header py={0}> <Modal.Header py={0}>
<Modal.Title fw={500}>{title}</Modal.Title> <Modal.Title fw={500}>{title}</Modal.Title>
<Modal.CloseButton /> <Modal.CloseButton aria-label={t("Close")} />
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<DestinationPicker <DestinationPicker
onSelectionChange={setSelection} onSelectionChange={setSelection}
excludePageId={excludePageId} excludePageId={excludePageId}
pageLimit={pageLimit} pageLimit={pageLimit}
initialSpaceId={initialSpaceId}
searchSpacesOnly={searchSpacesOnly}
/> />
<Group justify="flex-end" mt="md"> <Group justify="flex-end" mt="md">
@@ -13,6 +13,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
transition: background-color 150ms ease; transition: background-color 150ms ease;
user-select: none; user-select: none;
@@ -22,6 +23,11 @@
var(--mantine-color-dark-6) var(--mantine-color-dark-6)
); );
} }
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
} }
.selected { .selected {
@@ -57,7 +63,7 @@
border-radius: var(--mantine-radius-sm); border-radius: var(--mantine-radius-sm);
flex-shrink: 0; flex-shrink: 0;
transition: transform 150ms ease; 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 { @mixin hover {
background-color: light-dark( background-color: light-dark(
@@ -111,7 +117,7 @@
} }
.spaceName { .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); font-size: var(--mantine-font-size-xs);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -1,7 +1,7 @@
import { useState, useCallback } from "react"; import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { TextInput, ScrollArea, Loader } from "@mantine/core"; import { ActionIcon, TextInput, ScrollArea, Loader } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks"; 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 { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query"; import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
@@ -15,23 +15,29 @@ type DestinationPickerProps = {
onSelectionChange: (selection: DestinationSelection | null) => void; onSelectionChange: (selection: DestinationSelection | null) => void;
excludePageId?: string; excludePageId?: string;
pageLimit?: number; pageLimit?: number;
initialSpaceId?: string;
searchSpacesOnly?: boolean;
}; };
export function DestinationPicker({ export function DestinationPicker({
onSelectionChange, onSelectionChange,
excludePageId, excludePageId,
pageLimit = 15, pageLimit = 15,
initialSpaceId,
searchSpacesOnly,
}: DestinationPickerProps) { }: DestinationPickerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [selection, setSelection] = useState<DestinationSelection | null>(null); const [selection, setSelection] = useState<DestinationSelection | null>(null);
const [debouncedQuery] = useDebouncedValue(searchQuery, 300); const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const viewportRef = useRef<HTMLDivElement>(null);
const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({ const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({
limit: 100, limit: 100,
}); });
const searchEnabled = debouncedQuery && debouncedQuery.length >= 2; const searchEnabled =
!searchSpacesOnly && debouncedQuery && debouncedQuery.length >= 2;
const { data: searchData, isLoading: searchLoading } = const { data: searchData, isLoading: searchLoading } =
useSearchSuggestionsQuery({ useSearchSuggestionsQuery({
@@ -42,6 +48,18 @@ export function DestinationPicker({
const isSearching = !!searchEnabled; 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 = const selectedId =
selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null; selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null;
@@ -87,18 +105,48 @@ export function DestinationPicker({
[updateSelection], [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 ( return (
<> <>
<TextInput <TextInput
leftSection={<IconSearch size={16} />} 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" variant="filled"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)} onChange={(e) => setSearchQuery(e.currentTarget.value)}
className={classes.searchInput} className={classes.searchInput}
/> />
<ScrollArea h="50vh" offsetScrollbars className={classes.scrollArea}> <ScrollArea
h="50vh"
offsetScrollbars
className={classes.scrollArea}
viewportRef={viewportRef}
>
{isSearching ? ( {isSearching ? (
searchLoading ? ( searchLoading ? (
<div className={classes.emptyState}> <div className={classes.emptyState}>
@@ -111,16 +159,28 @@ export function DestinationPicker({
<div <div
key={page.id} key={page.id}
className={classes.searchResult} className={classes.searchResult}
role="button"
tabIndex={0}
onClick={() => handleSearchResultClick(page)} onClick={() => handleSearchResultClick(page)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSearchResultClick(page);
}
}}
> >
<div className={classes.iconWrapper}> <div className={classes.iconWrapper}>
{page.icon ? ( {page.icon ? (
page.icon page.icon
) : ( ) : (
<IconFile <ActionIcon
size={16} component="div"
color="var(--mantine-color-gray-5)" variant="transparent"
/> c="gray"
size={22}
>
<IconFileDescription size={18} />
</ActionIcon>
)} )}
</div> </div>
<div className={classes.pageTitle}> <div className={classes.pageTitle}>
@@ -141,8 +201,14 @@ export function DestinationPicker({
<div className={classes.emptyState}> <div className={classes.emptyState}>
<Loader size="xs" /> <Loader size="xs" />
</div> </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 <SpaceRow
key={space.id} key={space.id}
space={space} space={space}
@@ -20,4 +20,6 @@ export type DestinationPickerModalProps = {
loading?: boolean; loading?: boolean;
excludePageId?: string; excludePageId?: string;
pageLimit?: number; pageLimit?: number;
initialSpaceId?: string;
searchSpacesOnly?: boolean;
}; };
@@ -1,5 +1,6 @@
import { useState } from "react"; import { KeyboardEvent, useState } from "react";
import { IconChevronRight, IconFile } from "@tabler/icons-react"; import { ActionIcon } from "@mantine/core";
import { IconChevronRight, IconFileDescription } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IPage } from "@/features/page/types/page.types"; import { IPage } from "@/features/page/types/page.types";
import { PageChildren } from "./page-children"; import { PageChildren } from "./page-children";
@@ -36,23 +37,44 @@ export function PageRow({
.filter(Boolean) .filter(Boolean)
.join(" "); .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 ( return (
<> <>
<div <div
className={rowClasses} className={rowClasses}
style={{ paddingLeft: depth * 20 + 12 }} 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 ? ( {page.hasChildren ? (
<div <ActionIcon
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`} className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
variant="subtle"
color="gray"
size="sm"
aria-label={expanded ? t("Collapse") : t("Expand")}
aria-expanded={expanded}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setExpanded(!expanded); setExpanded(!expanded);
}} }}
> >
<IconChevronRight size={14} /> <IconChevronRight size={14} />
</div> </ActionIcon>
) : ( ) : (
<div style={{ width: 20, flexShrink: 0 }} /> <div style={{ width: 20, flexShrink: 0 }} />
)} )}
@@ -61,10 +83,14 @@ export function PageRow({
{page.icon ? ( {page.icon ? (
page.icon page.icon
) : ( ) : (
<IconFile <ActionIcon
size={16} component="div"
color="var(--mantine-color-gray-5)" variant="transparent"
/> c="gray"
size={22}
>
<IconFileDescription size={18} />
</ActionIcon>
)} )}
</div> </div>
@@ -1,5 +1,5 @@
import { useState } from "react"; import { KeyboardEvent, useState } from "react";
import { Tooltip } from "@mantine/core"; import { ActionIcon, Tooltip } from "@mantine/core";
import { IconChevronRight, IconLock } from "@tabler/icons-react"; import { IconChevronRight, IconLock } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types"; import { ISpace } from "@/features/space/types/space.types";
@@ -42,21 +42,43 @@ export function SpaceRow({
.filter(Boolean) .filter(Boolean)
.join(" "); .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 = ( const rowContent = (
<div <div
className={rowClasses} 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 ? ( {writable ? (
<div <ActionIcon
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`} className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
variant="subtle"
color="gray"
size="sm"
aria-label={expanded ? t("Collapse") : t("Expand")}
aria-expanded={expanded}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setExpanded(!expanded); setExpanded(!expanded);
}} }}
> >
<IconChevronRight size={14} /> <IconChevronRight size={14} />
</div> </ActionIcon>
) : ( ) : (
<div style={{ width: 20, flexShrink: 0 }} /> <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 { import {
ActionIcon, ActionIcon,
Popover, Popover,
@@ -7,9 +7,24 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks"; import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks";
import { Suspense } from "react"; import { Suspense } from "react";
const Picker = React.lazy(() => import("@emoji-mart/react"));
import { useTranslation } from "react-i18next"; 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 { export interface EmojiPickerInterface {
onEmojiSelect: (emoji: any) => void; onEmojiSelect: (emoji: any) => void;
icon: ReactNode; icon: ReactNode;
@@ -19,6 +34,7 @@ export interface EmojiPickerInterface {
size?: string; size?: string;
variant?: string; variant?: string;
c?: 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) => { const handleEmojiSelect = (emoji) => {
onEmojiSelect(emoji); onEmojiSelect(emoji);
handlers.close(); handlers.close();
@@ -74,6 +122,7 @@ function EmojiPicker({
c={actionIconProps?.c || "gray"} c={actionIconProps?.c || "gray"}
variant={actionIconProps?.variant || "transparent"} variant={actionIconProps?.variant || "transparent"}
size={actionIconProps?.size} size={actionIconProps?.size}
tabIndex={actionIconProps?.tabIndex}
onClick={handlers.toggle} onClick={handlers.toggle}
aria-label={t("Pick emoji")} aria-label={t("Pick emoji")}
aria-haspopup="dialog" aria-haspopup="dialog"
@@ -85,7 +134,6 @@ function EmojiPicker({
<Suspense fallback={null}> <Suspense fallback={null}>
<Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}> <Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}>
<Picker <Picker
data={async () => (await import("@emoji-mart/data")).default}
onEmojiSelect={handleEmojiSelect} onEmojiSelect={handleEmojiSelect}
perLine={8} perLine={8}
skinTonePosition="search" skinTonePosition="search"
@@ -14,7 +14,14 @@ export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> {
const SidebarToggle = React.forwardRef<HTMLButtonElement, SidebarToggleProps>( const SidebarToggle = React.forwardRef<HTMLButtonElement, SidebarToggleProps>(
({ opened, size = "sm", ...others }, ref) => { ({ opened, size = "sm", ...others }, ref) => {
return ( 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 ? ( {opened ? (
<IconLayoutSidebarRightExpand /> <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 ( return (
<div className={classes.sidebar}> <div className={classes.sidebar}>
<div className={classes.header}> <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> <Tooltip label={t("New chat")} openDelay={250} withArrow>
<ActionIcon <ActionIcon
component={Link} component={Link}
@@ -176,7 +176,7 @@ export default function AiChatSidebar() {
)) ))
: groupedChats.map((group) => ( : groupedChats.map((group) => (
<div key={group.key} className={classes.chatGroup}> <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) => ( {group.chats.map((chat) => (
<AiChatSidebarItem <AiChatSidebarItem
key={chat.id} key={chat.id}
@@ -56,9 +56,9 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
<div className={classes.emptyState}> <div className={classes.emptyState}>
<IconSparkles size={48} stroke={1.5} className={classes.emptyStateIcon} /> <IconSparkles size={48} stroke={1.5} className={classes.emptyStateIcon} />
<div className={classes.emptyStateBrand}>{t("Docmost AI")}</div> <div className={classes.emptyStateBrand}>{t("Docmost AI")}</div>
<div className={classes.emptyStateTitle}> <h1 className={classes.emptyStateTitle}>
{t("What can I help you with?")} {t("What can I help you with?")}
</div> </h1>
<div className={classes.emptyStateInput}> <div className={classes.emptyStateInput}>
<ChatInput <ChatInput
@@ -71,7 +71,7 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
</div> </div>
<div className={classes.suggestionsSection}> <div className={classes.suggestionsSection}>
<div className={classes.suggestionsLabel}>Get started</div> <h2 className={classes.suggestionsLabel}>{t("Get started")}</h2>
<div className={classes.suggestionsGrid}> <div className={classes.suggestionsGrid}>
{SUGGESTIONS.map((s) => ( {SUGGESTIONS.map((s) => (
<button <button
@@ -226,6 +226,7 @@ export default function ChatInput({
], ],
editorProps: { editorProps: {
attributes: { attributes: {
role: "textbox",
"aria-label": placeholder || t("Ask anything... Use @ to mention pages"), "aria-label": placeholder || t("Ask anything... Use @ to mention pages"),
"aria-multiline": "true", "aria-multiline": "true",
}, },
@@ -335,7 +336,15 @@ export default function ChatInput({
<EditorContent editor={editor} className={classes.editorContent} /> <EditorContent editor={editor} className={classes.editorContent} />
<div className={classes.actions}> <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> <Popover.Target>
<button <button
type="button" type="button"
@@ -2,6 +2,7 @@ import { useEffect, useRef, useCallback, useState } from "react";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { IconArrowDown, IconAlertTriangle } from "@tabler/icons-react"; import { IconArrowDown, IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { VisuallyHidden } from "@mantine/core";
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types"; import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
import ChatMessage from "./chat-message"; import ChatMessage from "./chat-message";
import classes from "../styles/ai-chat.module.css"; import classes from "../styles/ai-chat.module.css";
@@ -33,6 +34,7 @@ export default function ChatMessageList({
streamingContent, streamingContent,
streamingToolCalls, streamingToolCalls,
}: Props) { }: Props) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true); const isAtBottomRef = useRef(true);
@@ -40,6 +42,38 @@ export default function ChatMessageList({
const prevScrollTopRef = useRef(0); const prevScrollTopRef = useRef(0);
const [showScrollButton, setShowScrollButton] = useState(false); 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 scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
@@ -127,7 +161,18 @@ export default function ChatMessageList({
return ( return (
<div className={classes.messageListWrapper}> <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) => ( {messages.map((msg) => (
<ErrorBoundary <ErrorBoundary
key={msg.id} key={msg.id}
@@ -162,7 +207,7 @@ export default function ChatMessageList({
{showScrollButton && ( {showScrollButton && (
<button <button
type="button" type="button"
aria-label="Scroll to bottom" aria-label={t("Scroll to bottom")}
className={classes.scrollToBottomButton} className={classes.scrollToBottomButton}
onClick={() => scrollToBottom("smooth")} onClick={() => scrollToBottom("smooth")}
> >
@@ -1,5 +1,6 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { ActionIcon, Tooltip } from "@mantine/core"; import { ActionIcon, Tooltip } from "@mantine/core";
import { import {
@@ -43,6 +44,7 @@ export default function ChatMessage({
streamingToolCalls, streamingToolCalls,
}: Props) { }: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
const handleContentClick = useCallback( const handleContentClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => { (e: React.MouseEvent<HTMLDivElement>) => {
@@ -78,7 +80,11 @@ export default function ChatMessage({
}[]) || []; }[]) || [];
return ( return (
<div className={classes.userMessage}> <div
className={classes.userMessage}
role="article"
aria-label={t("You said:")}
>
<div className={classes.userBubble}> <div className={classes.userBubble}>
{attachments.length > 0 && ( {attachments.length > 0 && (
<div className={classes.messageAttachments}> <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 ( return (
<div className={classes.assistantMessage}> <div
className={classes.assistantMessage}
role="article"
aria-label={hasAnnouncableContent ? t("Assistant said:") : undefined}
>
<div className={classes.messageContent}> <div className={classes.messageContent}>
{toolCalls && toolCalls.length > 0 && ( {toolCalls && toolCalls.length > 0 && (
<ChatToolGroup toolCalls={toolCalls} isStreaming={isStreaming} /> <ChatToolGroup toolCalls={toolCalls} isStreaming={isStreaming} />
@@ -131,7 +145,10 @@ export default function ChatMessage({
</div> </div>
{!isStreaming && message.content && ( {!isStreaming && message.content && (
<div className={classes.messageActions}> <div className={classes.messageActions}>
<CopyTextButton text={message?.content} /> <CopyTextButton
text={message?.content}
label={t("Copy assistant response")}
/>
</div> </div>
)} )}
</div> </div>
@@ -106,6 +106,7 @@
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0)); color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
margin-top: 0;
margin-bottom: var(--mantine-spacing-xl); margin-bottom: var(--mantine-spacing-xl);
text-align: center; text-align: center;
} }
@@ -128,6 +129,7 @@
color: var(--mantine-color-dimmed); color: var(--mantine-color-dimmed);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin-top: 0;
margin-bottom: var(--mantine-spacing-sm); margin-bottom: var(--mantine-spacing-sm);
} }
@@ -114,7 +114,7 @@
} }
:global(.ProseMirror p.is-editor-empty:first-child::before) { :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); content: attr(data-placeholder);
float: left; float: left;
height: 0; height: 0;
@@ -183,7 +183,7 @@
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background: none; background: none;
cursor: pointer; 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; transition: color 150ms, background-color 150ms;
@mixin hover { @mixin hover {
@@ -15,6 +15,7 @@
} }
.title { .title {
margin: 0;
font-weight: 600; font-weight: 600;
font-size: var(--mantine-font-size-sm); font-size: var(--mantine-font-size-sm);
} }
@@ -33,6 +34,7 @@
} }
.chatGroupLabel { .chatGroupLabel {
margin: 0;
padding: 4px var(--mantine-spacing-xs); padding: 4px var(--mantine-spacing-xs);
font-size: var(--mantine-font-size-xs); font-size: var(--mantine-font-size-xs);
font-weight: 600; font-weight: 600;
@@ -118,7 +120,8 @@
color: inherit; color: inherit;
} }
.chatItem:hover .chatItemDate { .chatItem:hover .chatItemDate,
.chatItem:focus-within .chatItemDate {
opacity: 0; opacity: 0;
} }
@@ -133,6 +136,12 @@
position: relative; position: relative;
} }
.chatItem:hover .chatItemActions { .chatItem:hover .chatItemActions,
.chatItem:focus-within .chatItemActions {
opacity: 1; 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} onClose={onClose}
title={t("{{credential}} created", { credential: t("API key") })} title={t("{{credential}} created", { credential: t("API key") })}
size="lg" size="lg"
closeButtonProps={{ "aria-label": t("Close") }}
> >
<Stack gap="md"> <Stack gap="md">
<Alert <Alert
@@ -107,6 +107,7 @@ export function CreateApiKeyModal({
onClose={handleClose} onClose={handleClose}
title={t("Create {{credential}}", { credential: t("API key") })} title={t("Create {{credential}}", { credential: t("API key") })}
size="md" size="md"
closeButtonProps={{ "aria-label": t("Close") }}
> >
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}> <form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md"> <Stack gap="md">
@@ -32,6 +32,7 @@ export function RevokeApiKeyModal({
onClose={onClose} onClose={onClose}
title={t("Revoke {{credential}}", { credential: t("API key") })} title={t("Revoke {{credential}}", { credential: t("API key") })}
size="md" size="md"
closeButtonProps={{ "aria-label": t("Close") }}
> >
<Stack gap="md"> <Stack gap="md">
<Text> <Text>
@@ -55,6 +55,7 @@ export function UpdateApiKeyModal({
onClose={onClose} onClose={onClose}
title={t("Update {{credential}}", { credential: t("API key") })} title={t("Update {{credential}}", { credential: t("API key") })}
size="md" size="md"
closeButtonProps={{ "aria-label": t("Close") }}
> >
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}> <form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md"> <Stack gap="md">
@@ -111,6 +111,11 @@ export function LdapLoginModal({
placeholder={t("Enter your LDAP password")} placeholder={t("Enter your LDAP password")}
variant="filled" variant="filled"
disabled={isLoading} disabled={isLoading}
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("password")} {...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 { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { Button, Divider, Stack } from "@mantine/core"; import { Button, Divider, Stack } from "@mantine/core";
import { IconLock, IconServer } from "@tabler/icons-react"; 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 { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx"; import { GoogleIcon } from "@/components/icons/google-icon.tsx";
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.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() { export default function SsoLogin() {
const { data, isLoading } = useWorkspacePublicDataQuery(); const { data, isLoading } = useWorkspacePublicDataQuery();
const { data: currentUser } = useCurrentUser();
const [ldapModalOpened, setLdapModalOpened] = useState(false); const [ldapModalOpened, setLdapModalOpened] = useState(false);
const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null); const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null);
const autoRedirectedRef = useRef(false);
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const handleSsoLogin = (provider: IAuthProvider) => { const handleSsoLogin = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.LDAP) { if (provider.type === SSO_PROVIDER.LDAP) {
@@ -28,10 +50,47 @@ export default function SsoLogin() {
providerId: provider.id, providerId: provider.id,
type: provider.type, type: provider.type,
workspaceId: data.id, 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) => { const getProviderIcon = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.GOOGLE) { if (provider.type === SSO_PROVIDER.GOOGLE) {
return <GoogleIcon size={16} />; return <GoogleIcon size={16} />;
@@ -130,6 +130,11 @@ export function MfaBackupCodesModal({
label={t("Confirm password")} label={t("Confirm password")}
placeholder={t("Enter your password")} placeholder={t("Enter your password")}
variant="filled" variant="filled"
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("confirmPassword")} {...form.getInputProps("confirmPassword")}
autoFocus autoFocus
data-autofocus data-autofocus
@@ -107,6 +107,11 @@ export function MfaDisableModal({
<PasswordInput <PasswordInput
label={t("Password")} label={t("Password")}
placeholder={t("Enter your password")} placeholder={t("Enter your password")}
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("confirmPassword")} {...form.getInputProps("confirmPassword")}
autoFocus autoFocus
data-autofocus data-autofocus
@@ -79,7 +79,13 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
{t("Share")} {t("Share")}
</Button> </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 value={activeTab} color="dark" onChange={setActiveTab}>
<Tabs.List mb="md"> <Tabs.List mb="md">
<Tabs.Tab value="access">{t("Access")}</Tabs.Tab> <Tabs.Tab value="access">{t("Access")}</Tabs.Tab>
@@ -4,8 +4,8 @@ import {
Menu, Menu,
Modal, Modal,
Text, Text,
ThemeIcon,
Tooltip, Tooltip,
UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { import {
@@ -100,15 +100,20 @@ export function PageVerificationBadge({
if (!pageId) return null; if (!pageId) return null;
if (!hasVerificationFeature) { if (!hasVerificationFeature) {
if (readOnly) return null; 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 ( return (
<Tooltip <Tooltip label={lockedLabel} withArrow openDelay={250}>
label={`${t("Add verification")}${upgradeLabel}`} <ActionIcon
withArrow variant="subtle"
openDelay={250} color="gray"
aria-label={lockedLabel}
> >
<ThemeIcon variant="subtle" color="gray">
<IconShieldCheck size={20} stroke={1.5} /> <IconShieldCheck size={20} stroke={1.5} />
</ThemeIcon> </ActionIcon>
</Tooltip> </Tooltip>
); );
} }
@@ -132,20 +137,25 @@ export function PageVerificationBadge({
<> <>
{status !== "none" ? ( {status !== "none" ? (
<Tooltip label={tooltipLabel} withArrow openDelay={250}> <Tooltip label={tooltipLabel} withArrow openDelay={250}>
<Group <UnstyledButton
gap={4}
onClick={open} onClick={open}
style={{ cursor: "pointer" }} aria-label={tooltipLabel}
wrap="nowrap" style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
cursor: "pointer",
}}
> >
<IconRosetteDiscountCheckFilled <IconRosetteDiscountCheckFilled
size={18} size={18}
color={`var(--mantine-color-${getStatusColor(status).replace(".", "-")})`} color={`var(--mantine-color-${getStatusColor(status).replace(".", "-")})`}
aria-hidden="true"
/> />
<Text size="sm" c={getStatusColor(status)}> <Text size="sm" c={getStatusColor(status)}>
{getStatusLabel(status, t)} {getStatusLabel(status, t)}
</Text> </Text>
</Group> </UnstyledButton>
</Tooltip> </Tooltip>
) : !readOnly ? ( ) : !readOnly ? (
<Tooltip label={t("Set up verification")} withArrow openDelay={250}> <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 { buildPageUrl } from "@/features/page/page.utils";
import { format } from "date-fns"; import { format } from "date-fns";
import NoTableResults from "@/components/common/no-table-results"; import NoTableResults from "@/components/common/no-table-results";
import rowClasses from "@/components/ui/clickable-table-row.module.css";
const MAX_VISIBLE_VERIFIERS = 5; const MAX_VISIBLE_VERIFIERS = 5;
@@ -124,12 +125,13 @@ export default function VerificationListTable({
); );
return ( return (
<Table.Tr key={item.id}> <Table.Tr key={item.id} className={rowClasses.row}>
<Table.Td> <Table.Td>
<Anchor <Anchor
size="sm" size="sm"
underline="never" underline="never"
style={{ color: "var(--mantine-color-text)" }} style={{ color: "var(--mantine-color-text)" }}
className={rowClasses.link}
component={Link} component={Link}
to={pageUrl} to={pageUrl}
> >
@@ -52,6 +52,7 @@ export function CreateScimTokenModal({
onClose={handleClose} onClose={handleClose}
title={t("Create {{credential}}", { credential: t("SCIM token") })} title={t("Create {{credential}}", { credential: t("SCIM token") })}
size="md" size="md"
closeButtonProps={{ "aria-label": t("Close") }}
> >
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}> <form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md"> <Stack gap="md">
@@ -29,6 +29,7 @@ export function RevokeScimTokenModal({
onClose={onClose} onClose={onClose}
title={t("Revoke {{credential}}", { credential: t("SCIM token") })} title={t("Revoke {{credential}}", { credential: t("SCIM token") })}
size="md" size="md"
closeButtonProps={{ "aria-label": t("Close") }}
> >
<Stack gap="md"> <Stack gap="md">
<Text> <Text>
@@ -32,6 +32,7 @@ export function ScimTokenCreatedModal({
onClose={onClose} onClose={onClose}
title={t("{{credential}} created", { credential: t("SCIM token") })} title={t("{{credential}} created", { credential: t("SCIM token") })}
size="lg" size="lg"
closeButtonProps={{ "aria-label": t("Close") }}
> >
<Stack gap="md"> <Stack gap="md">
<Alert <Alert
@@ -93,7 +93,11 @@ export function ScimTokenTable({
<Table.Td> <Table.Td>
<Menu position="bottom-end" withinPortal> <Menu position="bottom-end" withinPortal>
<Menu.Target> <Menu.Target>
<ActionIcon variant="subtle" color="gray"> <ActionIcon
variant="subtle"
color="gray"
aria-label={t("Token actions")}
>
<IconDots size={16} /> <IconDots size={16} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
@@ -52,6 +52,7 @@ export function UpdateScimTokenModal({
onClose={onClose} onClose={onClose}
title={t("Update {{credential}}", { credential: t("SCIM token") })} title={t("Update {{credential}}", { credential: t("SCIM token") })}
size="md" size="md"
closeButtonProps={{ "aria-label": t("Close") }}
> >
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}> <form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md"> <Stack gap="md">
@@ -34,7 +34,7 @@ function AllowMemberTemplatesToggle() {
const [checked, setChecked] = useState( const [checked, setChecked] = useState(
workspace?.settings?.templates?.allowMemberTemplates === true, workspace?.settings?.templates?.allowMemberTemplates === true,
); );
const hasSecuritySettings = useHasFeature(Feature.SECURITY_SETTINGS); const hasTemplates = useHasFeature(Feature.TEMPLATES);
const upgradeLabel = useUpgradeLabel(); const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -54,15 +54,11 @@ function AllowMemberTemplatesToggle() {
}; };
return ( return (
<Tooltip <Tooltip label={upgradeLabel} disabled={hasTemplates} refProp="rootRef">
label={upgradeLabel}
disabled={hasSecuritySettings}
refProp="rootRef"
>
<Switch <Switch
checked={checked} checked={checked}
onChange={handleChange} onChange={handleChange}
disabled={!hasSecuritySettings} disabled={!hasTemplates}
aria-label={t("Toggle allow members to create templates")} aria-label={t("Toggle allow members to create templates")}
/> />
</Tooltip> </Tooltip>
@@ -32,6 +32,7 @@ export default function SsoProviderModal({
ssoProviderType: provider.type.toUpperCase(), ssoProviderType: provider.type.toUpperCase(),
})} })}
onClose={onClose} onClose={onClose}
closeButtonProps={{ "aria-label": t("Close") }}
> >
{provider.type === SSO_PROVIDER.SAML && ( {provider.type === SSO_PROVIDER.SAML && (
<SsoSamlForm provider={provider} onClose={onClose} /> <SsoSamlForm provider={provider} onClose={onClose} />
@@ -137,7 +137,6 @@ export default function Security() {
{ max: SCIM_TOKEN_LIMIT }, { max: SCIM_TOKEN_LIMIT },
)} )}
disabled={(scimData?.items.length ?? 0) < SCIM_TOKEN_LIMIT} disabled={(scimData?.items.length ?? 0) < SCIM_TOKEN_LIMIT}
refProp="rootRef"
> >
<Button <Button
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
+10 -3
View File
@@ -18,14 +18,21 @@ export function buildSsoLoginUrl(opts: {
providerId: string; providerId: string;
type: SSO_PROVIDER; type: SSO_PROVIDER;
workspaceId?: string; workspaceId?: string;
redirect?: string;
}): string { }): string {
const { providerId, type, workspaceId } = opts; const { providerId, type, workspaceId, redirect } = opts;
const domain = getAppUrl(); const domain = getAppUrl();
const params = new URLSearchParams();
if (redirect) params.set("redirect", redirect);
if (type === SSO_PROVIDER.GOOGLE) { 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 { export function getGoogleSignupUrl(): string {
@@ -8,6 +8,11 @@
@mixin hover { @mixin hover {
transform: scale(1.02); transform: scale(1.02);
} }
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
} }
.cardBody { .cardBody {
@@ -50,18 +55,27 @@
.footer { .footer {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 6px;
gap: var(--mantine-spacing-xs);
padding-top: var(--mantine-spacing-sm); padding-top: var(--mantine-spacing-sm);
margin-top: var(--mantine-spacing-lg); margin-top: var(--mantine-spacing-lg);
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); 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 { .menuTarget {
opacity: 0; opacity: 0;
transition: opacity 100ms ease; transition: opacity 100ms ease;
.card:hover & { .card:hover &,
.card:focus-within & {
opacity: 1; 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 { import {
IconDots, IconDots,
IconEdit, IconEdit,
@@ -12,6 +12,7 @@ import classes from "./template-card.module.css";
type TemplateCardProps = { type TemplateCardProps = {
template: ITemplate; template: ITemplate;
spaceName?: string; spaceName?: string;
onPreview: (template: ITemplate) => void;
onUse: (template: ITemplate) => void; onUse: (template: ITemplate) => void;
onEdit?: (template: ITemplate) => void; onEdit?: (template: ITemplate) => void;
onDelete?: (template: ITemplate) => void; onDelete?: (template: ITemplate) => void;
@@ -21,6 +22,7 @@ type TemplateCardProps = {
export default function TemplateCard({ export default function TemplateCard({
template, template,
spaceName, spaceName,
onPreview,
onUse, onUse,
onEdit, onEdit,
onDelete, onDelete,
@@ -34,7 +36,17 @@ export default function TemplateCard({
padding="lg" padding="lg"
className={classes.card} className={classes.card}
style={{ cursor: "pointer" }} 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}> <div className={classes.cardBody}>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md"> <Group justify="space-between" align="flex-start" wrap="nowrap" mb="md">
@@ -47,6 +59,17 @@ export default function TemplateCard({
)} )}
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<Button
size="compact-xs"
variant="filled"
className={classes.menuTarget}
onClick={(e) => {
e.stopPropagation();
onUse(template);
}}
>
{t("Use")}
</Button>
{canManage && ( {canManage && (
<Menu width={150} shadow="md" withArrow> <Menu width={150} shadow="md" withArrow>
<Menu.Target> <Menu.Target>
@@ -91,6 +114,7 @@ export default function TemplateCard({
<div className={classes.title}>{template.title}</div> <div className={classes.title}>{template.title}</div>
<div className={classes.footer}> <div className={classes.footer}>
<span className={classes.scopeDot} aria-hidden="true" />
<Text size="sm" fw={500} c="dimmed"> <Text size="sm" fw={500} c="dimmed">
{template.spaceId ? (spaceName || t("Space")) : t("Global")} {template.spaceId ? (spaceName || t("Space")) : t("Global")}
</Text> </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; onClose: () => void;
onUse: () => void; onUse: () => void;
onEdit?: () => void; onEdit?: () => void;
useLoading?: boolean;
}; };
export default function TemplatePreviewModal({ export default function TemplatePreviewModal({
@@ -17,6 +18,7 @@ export default function TemplatePreviewModal({
onClose, onClose,
onUse, onUse,
onEdit, onEdit,
useLoading,
}: TemplatePreviewModalProps) { }: TemplatePreviewModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: template, isLoading } = useGetTemplateByIdQuery(templateId); const { data: template, isLoading } = useGetTemplateByIdQuery(templateId);
@@ -37,15 +39,20 @@ export default function TemplatePreviewModal({
</Group> </Group>
</Modal.Title> </Modal.Title>
<Group gap="sm"> <Group gap="sm">
<Button
size="xs"
onClick={onUse}
loading={useLoading}
disabled={useLoading}
>
{t("Use template")}
</Button>
{onEdit && ( {onEdit && (
<Button size="xs" variant="default" onClick={onEdit}> <Button size="xs" variant="default" onClick={onEdit}>
{t("Edit")} {t("Edit")}
</Button> </Button>
)} )}
<Button size="xs" onClick={onUse}> <Modal.CloseButton aria-label={t("Close")} />
{t("Use template")}
</Button>
<Modal.CloseButton />
</Group> </Group>
</Modal.Header> </Modal.Header>
<Modal.Body p={0}> <Modal.Body p={0}>
@@ -10,12 +10,14 @@ type UseTemplateModalProps = {
template: ITemplate; template: ITemplate;
opened: boolean; opened: boolean;
onClose: () => void; onClose: () => void;
initialSpaceId?: string;
}; };
export default function UseTemplateModal({ export default function UseTemplateModal({
template, template,
opened, opened,
onClose, onClose,
initialSpaceId,
}: UseTemplateModalProps) { }: UseTemplateModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -54,6 +56,8 @@ export default function UseTemplateModal({
actionLabel={t("Create page")} actionLabel={t("Create page")}
onSelect={handleSelect} onSelect={handleSelect}
loading={useTemplateMutation.isPending} loading={useTemplateMutation.isPending}
initialSpaceId={initialSpaceId ?? template.spaceId}
searchSpacesOnly
/> />
); );
} }
@@ -75,6 +75,18 @@ export default function TemplateEditor() {
const editor = useEditor({ const editor = useEditor({
extensions: templateExtensions, extensions: templateExtensions,
content: "", content: "",
editorProps: {
handleDOMEvents: {
keydown: (_view, event) => {
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command");
if (slashCommand) {
return true;
}
}
},
},
},
onUpdate() { onUpdate() {
if (loadedRef.current) { if (loadedRef.current) {
markDirty(); markDirty();
@@ -271,6 +283,7 @@ export default function TemplateEditor() {
variant="subtle" variant="subtle"
color="gray" color="gray"
size="md" size="md"
aria-label={t("Template settings")}
onClick={() => { onClick={() => {
setDraftSpaceId(spaceId); setDraftSpaceId(spaceId);
openSettings(); openSettings();
@@ -160,7 +160,8 @@ export default function TemplateList() {
? spaceNameMap.get(template.spaceId) ? spaceNameMap.get(template.spaceId)
: undefined : undefined
} }
onUse={handlePreview} onPreview={handlePreview}
onUse={handleUse}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
canManage={isWorkspaceAdmin} canManage={isWorkspaceAdmin}
@@ -6,6 +6,7 @@ import {
UseQueryResult, UseQueryResult,
InfiniteData, InfiniteData,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useAtom, useStore } from "jotai";
import { import {
getTemplates, getTemplates,
getTemplateById, getTemplateById,
@@ -18,6 +19,12 @@ import { ITemplate } from "@/ee/template/types/template.types";
import { IPagination } from "@/lib/types.ts"; import { IPagination } from "@/lib/types.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next"; 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 }) { export function useGetTemplatesQuery(params?: { spaceId?: string }) {
const { spaceId } = params ?? {}; const { spaceId } = params ?? {};
@@ -149,13 +156,64 @@ export function useDeleteTemplateMutation() {
export function useUseTemplateMutation() { export function useUseTemplateMutation() {
const { t } = useTranslation(); const { t } = useTranslation();
const [, setTreeData] = useAtom(treeDataAtom);
const store = useStore();
const emit = useQueryEmit();
return useMutation({ return useMutation<
mutationFn: (data: { IPage,
templateId: string; Error,
spaceId: string; { templateId: string; spaceId: string; parentPageId?: string }
parentPageId?: string; >({
}) => useTemplate(data), 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) => { onError: (error) => {
const errorMessage = error["response"]?.data?.message; const errorMessage = error["response"]?.data?.message;
notifications.show({ notifications.show({
@@ -1,5 +1,6 @@
import api from "@/lib/api-client"; import api from "@/lib/api-client";
import { ITemplate } from "@/ee/template/types/template.types"; import { ITemplate } from "@/ee/template/types/template.types";
import { IPage } from "@/features/page/types/page.types";
import { IPagination } from "@/lib/types.ts"; import { IPagination } from "@/lib/types.ts";
export async function getTemplates(params?: { export async function getTemplates(params?: {
@@ -40,7 +41,7 @@ export async function useTemplate(data: {
templateId: string; templateId: string;
spaceId: string; spaceId: string;
parentPageId?: string; parentPageId?: string;
}): Promise<any> { }): Promise<IPage> {
const req = await api.post("/templates/use", data); const req = await api.post<IPage>("/templates/use", data);
return req.data; return req.data;
} }
@@ -20,7 +20,7 @@ export function AuthLayout({ children }: AuthLayoutProps) {
Docmost Docmost
</Text> </Text>
</Group> </Group>
{children} <main>{children}</main>
</> </>
); );
} }
@@ -103,6 +103,11 @@ export function InviteSignUpForm() {
placeholder={t("Your password")} placeholder={t("Your password")}
variant="filled" variant="filled"
mt="md" mt="md"
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("password")} {...form.getInputProps("password")}
/> />
<Button type="submit" fullWidth mt="xl" loading={isLoading}> <Button type="submit" fullWidth mt="xl" loading={isLoading}>
@@ -54,6 +54,13 @@ export function LoginForm() {
await signIn(data); await signIn(data);
} }
function handleValidationFailure(errors: Record<string, unknown>) {
const firstInvalidId = Object.keys(errors)[0];
if (firstInvalidId) {
document.getElementById(firstInvalidId)?.focus();
}
}
if (isDataLoading) { if (isDataLoading) {
return null; return null;
} }
@@ -66,7 +73,7 @@ export function LoginForm() {
<AuthLayout> <AuthLayout>
<Container size={420} className={classes.container}> <Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}> <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")} {t("Login")}
</Title> </Title>
@@ -74,21 +81,31 @@ export function LoginForm() {
{!data?.enforceSso && ( {!data?.enforceSso && (
<> <>
<form onSubmit={form.onSubmit(onSubmit)}> <form onSubmit={form.onSubmit(onSubmit, handleValidationFailure)}>
<TextInput <TextInput
id="email" id="email"
type="email" type="email"
label={t("Email")} label={t("Email")}
placeholder="email@example.com" placeholder="email@example.com"
variant="filled" variant="filled"
autoComplete="email"
errorProps={{ role: "alert" }}
{...form.getInputProps("email")} {...form.getInputProps("email")}
/> />
<PasswordInput <PasswordInput
id="password"
label={t("Password")} label={t("Password")}
placeholder={t("Your password")} placeholder={t("Your password")}
variant="filled" variant="filled"
mt="md" mt="md"
autoComplete="current-password"
errorProps={{ role: "alert" }}
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("password")} {...form.getInputProps("password")}
/> />
@@ -52,6 +52,11 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
placeholder={t("Your new password")} placeholder={t("Your new password")}
variant="filled" variant="filled"
mt="md" mt="md"
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("newPassword")} {...form.getInputProps("newPassword")}
/> />
@@ -98,6 +98,11 @@ export function SetupWorkspaceForm() {
placeholder={t("Enter a strong password")} placeholder={t("Enter a strong password")}
variant="filled" variant="filled"
mt="md" mt="md"
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("password")} {...form.getInputProps("password")}
/> />
<Button type="submit" fullWidth mt="xl" loading={isLoading}> <Button type="submit" fullWidth mt="xl" loading={isLoading}>
@@ -166,7 +166,7 @@ export default function useAuth() {
const handleLogout = async () => { const handleLogout = async () => {
setCurrentUser(RESET); setCurrentUser(RESET);
await logout(); await logout();
window.location.replace(APP_ROUTE.AUTH.LOGIN); window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
}; };
const handleForgotPassword = async (data: IForgotPassword) => { const handleForgotPassword = async (data: IForgotPassword) => {
@@ -19,6 +19,7 @@ interface CommentEditorProps {
editable: boolean; editable: boolean;
placeholder?: string; placeholder?: string;
autofocus?: boolean; autofocus?: boolean;
surface?: "default" | "muted";
} }
const CommentEditor = forwardRef( const CommentEditor = forwardRef(
@@ -30,6 +31,7 @@ const CommentEditor = forwardRef(
editable, editable,
placeholder, placeholder,
autofocus, autofocus,
surface,
}: CommentEditorProps, }: CommentEditorProps,
ref, ref,
) => { ) => {
@@ -66,6 +68,9 @@ const CommentEditor = forwardRef(
}), }),
], ],
editorProps: { editorProps: {
attributes: {
"aria-label": placeholder || t("Comment"),
},
handleDOMEvents: { handleDOMEvents: {
keydown: (_view, event) => { keydown: (_view, event) => {
if ( if (
@@ -131,6 +136,7 @@ const CommentEditor = forwardRef(
ref={focusRef} ref={focusRef}
className={classes.commentEditor} className={classes.commentEditor}
data-editable={editable || undefined} data-editable={editable || undefined}
data-surface={surface}
> >
<EditorContent <EditorContent
editor={commentEditor} editor={commentEditor}
@@ -383,6 +383,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
onSave={handleSave} onSave={handleSave}
editable={true} editable={true}
placeholder={t("Add a comment...")} placeholder={t("Add a comment...")}
surface="muted"
/> />
</div> </div>
</Group> </Group>
@@ -391,6 +392,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
variant="filled" variant="filled"
radius="xl" radius="xl"
size="sm" size="sm"
aria-label={t("Send comment")}
onClick={handleSave} onClick={handleSave}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
loading={isLoading} loading={isLoading}
@@ -22,6 +22,11 @@
.commentEditor { .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 { .focused {
border-radius: var(--mantine-radius-sm); border-radius: var(--mantine-radius-sm);
box-shadow: 0 0 0 2px var(--mantine-color-blue-3); box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
@@ -1,5 +1,6 @@
import { atom } from "jotai"; import { atom } from "jotai";
import { Editor } from "@tiptap/core"; import { Editor } from "@tiptap/core";
import { PageEditMode } from "@/features/user/types/user.types.ts";
export const pageEditorAtom = atom<Editor | null>(null); export const pageEditorAtom = atom<Editor | null>(null);
@@ -12,3 +13,7 @@ export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false); export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = 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 { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react"; import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -46,7 +47,7 @@ export function AudioMenu({ editor }: EditorMenuProps) {
); );
const getReferencedVirtualElement = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return; if (!isEditorReady(editor)) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "audio"; const predicate = (node: PMNode) => node.type.name === "audio";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
@@ -16,7 +16,7 @@ import {
IconMoodSmile, IconMoodSmile,
IconNotes, IconNotes,
} from "@tabler/icons-react"; } 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 { useTranslation } from "react-i18next";
import EmojiPicker from "@/components/ui/emoji-picker.tsx"; import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
@@ -55,7 +55,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
}); });
const getReferencedVirtualElement = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return; if (!isEditorReady(editor)) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout"; const predicate = (node: PMNode) => node.type.name === "callout";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
@@ -19,7 +19,7 @@ import {
IconCopy, IconCopy,
IconTrash, IconTrash,
} from "@tabler/icons-react"; } 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 type { WidthMode, ColumnsLayout } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
@@ -82,7 +82,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback( const shouldShow = useCallback(
({ state }: ShouldShowProps) => { ({ state }: ShouldShowProps) => {
if (!state) return false; if (!state || !isEditorReady(editor)) return false;
if (!editor.isActive("columns")) return false; if (!editor.isActive("columns")) return false;
if (isTextSelected(editor)) return false; if (isTextSelected(editor)) return false;
if (nodesWithMenus.some((name) => editor.isActive(name))) return false; if (nodesWithMenus.some((name) => editor.isActive(name))) return false;
@@ -121,7 +121,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
}); });
const getReferencedVirtualElement = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return; if (!isEditorReady(editor)) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "columns"; const predicate = (node: PMNode) => node.type.name === "columns";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
@@ -0,0 +1,139 @@
import React, { useCallback, useEffect, useState } from "react";
import { Editor } from "@tiptap/react";
import {
ActionIcon,
Button,
Group,
Paper,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import { IconAlt } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
const ALT_MAX_LENGTH = 300;
function sanitizeAlt(value: string): string {
return value
.replace(/[\\\[\]!]/g, "")
.replace(/\s+/g, " ")
.trim();
}
type UseAltTextControlArgs = {
editor: Editor;
nodeName: string;
currentAlt: string;
};
export function useAltTextControl({
editor,
nodeName,
currentAlt,
}: UseAltTextControlArgs) {
const { t } = useTranslation();
const [showInput, setShowInput] = useState(false);
const [draft, setDraft] = useState("");
const open = useCallback(() => {
setDraft(currentAlt || "");
setShowInput(true);
}, [currentAlt]);
useEffect(() => {
const handler = () => {
if (!editor.isActive(nodeName)) {
setShowInput(false);
}
};
editor.on("selectionUpdate", handler);
return () => {
editor.off("selectionUpdate", handler);
};
}, [editor, nodeName]);
const cancel = useCallback(() => {
setShowInput(false);
}, []);
const save = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.updateAttributes(nodeName, { alt: sanitizeAlt(draft) || undefined })
.run();
setShowInput(false);
}, [editor, nodeName, draft]);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
save();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
},
[save, cancel],
);
const button = (
<Tooltip position="top" label={t("Alt text")} withinPortal={false}>
<ActionIcon
onClick={open}
size="lg"
aria-label={t("Alt text")}
variant="subtle"
>
<IconAlt size={18} />
</ActionIcon>
</Tooltip>
);
const panel = showInput ? (
<Paper
withBorder
shadow="md"
radius={6}
p="sm"
w={320}
style={{ position: "relative", zIndex: 100 }}
>
<Text size="sm" fw={600} mb={2}>
{t("Alt text")}
</Text>
<Text size="xs" c="dimmed" mb="xs">
{t("Describe this for accessibility.")}
</Text>
<Textarea
size="xs"
placeholder={t("Add a description")}
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
onKeyDown={onKeyDown}
autoFocus
autosize
minRows={2}
maxRows={5}
maxLength={ALT_MAX_LENGTH}
/>
<Group justify="space-between" align="center" mt="xs" wrap="nowrap">
<Text size="xs" c="dimmed">
{draft.length}/{ALT_MAX_LENGTH}
</Text>
<Group gap="xs">
<Button size="compact-xs" variant="default" onClick={cancel}>
{t("Cancel")}
</Button>
<Button size="compact-xs" onClick={save}>
{t("Save")}
</Button>
</Group>
</Group>
</Paper>
) : null;
return { button, panel, isEditing: showInput };
}
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -37,6 +38,7 @@ import {
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils"; import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import { IAttachment } from "@/features/attachments/types/attachment.types"; import { IAttachment } from "@/features/attachments/types/attachment.types";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
export function DrawioMenu({ editor }: EditorMenuProps) { export function DrawioMenu({ editor }: EditorMenuProps) {
@@ -65,6 +67,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
isAlignRight: ctx.editor.isActive("drawio", { align: "right" }), isAlignRight: ctx.editor.isActive("drawio", { align: "right" }),
src: drawioAttr?.src || null, src: drawioAttr?.src || null,
attachmentId: drawioAttr?.attachmentId || null, attachmentId: drawioAttr?.attachmentId || null,
alt: drawioAttr?.alt || "",
}; };
}, },
}); });
@@ -81,7 +84,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
); );
const getReferencedVirtualElement = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return; if (!isEditorReady(editor)) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "drawio"; const predicate = (node: PMNode) => node.type.name === "drawio";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
@@ -139,6 +142,16 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
editor.commands.deleteSelection(); editor.commands.deleteSelection();
}, [editor]); }, [editor]);
const {
button: altTextButton,
panel: altTextPanel,
isEditing: isEditingAlt,
} = useAltTextControl({
editor,
nodeName: "drawio",
currentAlt: editorState?.alt || "",
});
const saveData = useCallback(async (svgXml: string) => { const saveData = useCallback(async (svgXml: string) => {
if (isSavingRef.current) return; if (isSavingRef.current) return;
@@ -265,6 +278,9 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
{isEditingAlt ? (
altTextPanel
) : (
<div className={classes.toolbar}> <div className={classes.toolbar}>
<Tooltip position="top" label={t("Align left")} withinPortal={false}> <Tooltip position="top" label={t("Align left")} withinPortal={false}>
<ActionIcon <ActionIcon
@@ -308,6 +324,10 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} /> <div className={classes.divider} />
{altTextButton}
<div className={classes.divider} />
<Tooltip position="top" label={t("Edit")} withinPortal={false}> <Tooltip position="top" label={t("Edit")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={handleOpen} onClick={handleOpen}
@@ -342,6 +362,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</div> </div>
)}
</BaseBubbleMenu> </BaseBubbleMenu>
<Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}> <Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
@@ -198,7 +198,11 @@ export default function DrawioView(props: NodeViewProps) {
className={clsx(selected ? "ProseMirror-selectednode" : "")} className={clsx(selected ? "ProseMirror-selectednode" : "")}
> >
<div style={{ display: "flex", alignItems: "center" }}> <div style={{ display: "flex", alignItems: "center" }}>
<ActionIcon variant="transparent" color="gray"> <ActionIcon
variant="transparent"
color="gray"
aria-label={t("Edit diagram")}
>
<IconEdit size={18} /> <IconEdit size={18} />
</ActionIcon> </ActionIcon>
@@ -131,7 +131,11 @@ export default function EmbedView(props: NodeViewProps) {
className={clsx(selected ? "ProseMirror-selectednode" : "")} className={clsx(selected ? "ProseMirror-selectednode" : "")}
> >
<div style={{ display: "flex", alignItems: "center" }}> <div style={{ display: "flex", alignItems: "center" }}>
<ActionIcon variant="transparent" color="gray"> <ActionIcon
variant="transparent"
color="gray"
aria-label={t("Edit embed")}
>
<IconEdit size={18} /> <IconEdit size={18} />
</ActionIcon> </ActionIcon>
@@ -44,9 +44,11 @@ function EmojiList({
const [cats, setCats] = useState<EmojiCategory[]>([]); const [cats, setCats] = useState<EmojiCategory[]>([]);
const [activeCat, setActiveCat] = useState(""); const [activeCat, setActiveCat] = useState("");
const [focusZone, setFocusZone] = useState<"grid" | "tabs">("grid"); const [focusZone, setFocusZone] = useState<"grid" | "tabs">("grid");
const [announce, setAnnounce] = useState("");
const listViewport = useRef<HTMLDivElement>(null); const listViewport = useRef<HTMLDivElement>(null);
const gridViewport = useRef<HTMLDivElement>(null); const gridViewport = useRef<HTMLDivElement>(null);
const catBar = useRef<HTMLDivElement>(null); const catBar = useRef<HTMLDivElement>(null);
const userInteractedRef = useRef(false);
const searching = query.length > 0; const searching = query.length > 0;
const browseLoading = !searching && cats.length === 0; const browseLoading = !searching && cats.length === 0;
@@ -74,6 +76,53 @@ function EmojiList({
vp?.querySelector<HTMLElement>(`[data-i="${idx}"]`)?.scrollIntoView({ block: "nearest" }); vp?.querySelector<HTMLElement>(`[data-i="${idx}"]`)?.scrollIntoView({ block: "nearest" });
}, [idx, searching, focusZone]); }, [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( const pickSearchItem = useCallback(
(i: number) => { (i: number) => {
const item = items[i]; const item = items[i];
@@ -94,6 +143,13 @@ function EmojiList({
useEffect(() => { useEffect(() => {
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if (
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"].includes(
e.key,
)
) {
userInteractedRef.current = true;
}
if (searching) { if (searching) {
if (e.key === "ArrowDown") { e.preventDefault(); setIdx((i) => Math.min(i + 1, items.length - 1)); } 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)); } else if (e.key === "ArrowUp") { e.preventDefault(); setIdx((i) => Math.max(i - 1, 0)); }
@@ -131,6 +187,24 @@ function EmojiList({
role="listbox" role="listbox"
aria-label={t("Emoji picker")} 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 ? ( {searching ? (
<> <>
{isLoading && <Loader m="xs" size="xs" color="blue" type="dots" />} {isLoading && <Loader m="xs" size="xs" color="blue" type="dots" />}
@@ -171,6 +245,7 @@ function EmojiList({
title={c.id} title={c.id}
role="tab" role="tab"
aria-selected={isActive} aria-selected={isActive}
aria-label={t("{{name}} category", { name: c.id })}
className={clsx(classes.catTab, { className={clsx(classes.catTab, {
[classes.catTabActive]: isActive, [classes.catTabActive]: isActive,
[classes.catTabFocused]: isFocused, [classes.catTabFocused]: isFocused,
@@ -190,6 +265,9 @@ function EmojiList({
key={entry.id} key={entry.id}
data-i={i} data-i={i}
title={`:${entry.id}:`} title={`:${entry.id}:`}
role="option"
aria-selected={i === idx}
aria-label={entry.id}
className={clsx(classes.emojiBtn, { [classes.active]: i === idx })} className={clsx(classes.emojiBtn, { [classes.active]: i === idx })}
onClick={() => pickGridItem(entry)} onClick={() => pickGridItem(entry)}
onMouseEnter={() => setIdx(i)} onMouseEnter={() => setIdx(i)}
@@ -21,7 +21,7 @@ let _emojiIndex: EmojiIndexEntry[] | null = null;
export const buildEmojiIndex = async (): Promise<EmojiIndexEntry[]> => { export const buildEmojiIndex = async (): Promise<EmojiIndexEntry[]> => {
if (_emojiIndex) return _emojiIndex; 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[]) _emojiIndex = (Object.values((data as any).emojis) as any[])
.filter((e) => e.id && e.name && e.skins?.[0]?.native) .filter((e) => e.id && e.name && e.skins?.[0]?.native)
.map((e) => ({ .map((e) => ({
@@ -74,7 +74,7 @@ let _cats: EmojiCategory[] | null = null;
export const getEmojiCategories = async (): Promise<EmojiCategory[]> => { export const getEmojiCategories = async (): Promise<EmojiCategory[]> => {
if (_cats) return _cats; if (_cats) return _cats;
const [{ default: data }, index] = await Promise.all([ const [{ default: data }, index] = await Promise.all([
import("@emoji-mart/data"), import("@slidoapp/emoji-mart-data"),
buildEmojiIndex(), buildEmojiIndex(),
]); ]);
const byId = new Map(index.map((e) => [e.id, e])); 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 { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -35,6 +36,7 @@ import { IAttachment } from "@/features/attachments/types/attachment.types";
import ReactClearModal from "react-clear-modal"; import ReactClearModal from "react-clear-modal";
import { useHandleLibrary } from "@excalidraw/excalidraw"; import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts"; import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
const ExcalidrawComponent = lazy(() => const ExcalidrawComponent = lazy(() =>
@@ -76,6 +78,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
isAlignRight: ctx.editor.isActive("excalidraw", { align: "right" }), isAlignRight: ctx.editor.isActive("excalidraw", { align: "right" }),
src: excalidrawAttr?.src || null, src: excalidrawAttr?.src || null,
attachmentId: excalidrawAttr?.attachmentId || null, attachmentId: excalidrawAttr?.attachmentId || null,
alt: excalidrawAttr?.alt || "",
}; };
}, },
}); });
@@ -94,7 +97,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
); );
const getReferencedVirtualElement = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return; if (!isEditorReady(editor)) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "excalidraw"; const predicate = (node: PMNode) => node.type.name === "excalidraw";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
@@ -152,6 +155,16 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
editor.commands.deleteSelection(); editor.commands.deleteSelection();
}, [editor]); }, [editor]);
const {
button: altTextButton,
panel: altTextPanel,
isEditing: isEditingAlt,
} = useAltTextControl({
editor,
nodeName: "excalidraw",
currentAlt: editorState?.alt || "",
});
const handleOpen = useCallback(async () => { const handleOpen = useCallback(async () => {
if (!editorState?.src) return; if (!editorState?.src) return;
@@ -290,6 +303,9 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
{isEditingAlt ? (
altTextPanel
) : (
<div className={classes.toolbar}> <div className={classes.toolbar}>
<Tooltip position="top" label={t("Align left")} withinPortal={false}> <Tooltip position="top" label={t("Align left")} withinPortal={false}>
<ActionIcon <ActionIcon
@@ -339,6 +355,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} /> <div className={classes.divider} />
{altTextButton}
<div className={classes.divider} />
<Tooltip position="top" label={t("Edit")} withinPortal={false}> <Tooltip position="top" label={t("Edit")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={handleOpen} onClick={handleOpen}
@@ -373,6 +393,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</div> </div>
)}
</BaseBubbleMenu> </BaseBubbleMenu>
<ReactClearModal <ReactClearModal
@@ -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" : "")} className={clsx(selected ? "ProseMirror-selectednode" : "")}
> >
<div style={{ display: "flex", alignItems: "center" }}> <div style={{ display: "flex", alignItems: "center" }}>
<ActionIcon variant="transparent" color="gray"> <ActionIcon
variant="transparent"
color="gray"
aria-label={t("Edit drawing")}
>
<IconEdit size={18} /> <IconEdit size={18} />
</ActionIcon> </ActionIcon>
@@ -3,7 +3,7 @@
top: calc(var(--app-shell-header-offset, 0rem) + 45px); top: calc(var(--app-shell-header-offset, 0rem) + 45px);
inset-inline-start: var(--app-shell-navbar-offset, 0rem); inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem); inset-inline-end: var(--app-shell-aside-offset, 0rem);
z-index: 50; z-index: 99;
display: flex; display: flex;
align-items: center; align-items: center;
background: var(--mantine-color-body); background: var(--mantine-color-body);
@@ -28,6 +28,7 @@ export const FixedToolbar: FC = () => {
<> <>
<div <div
className={classes.fixedToolbar} className={classes.fixedToolbar}
data-fixed-toolbar="true"
role="toolbar" role="toolbar"
aria-label="Editor toolbar" aria-label="Editor toolbar"
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
@@ -10,6 +10,7 @@ import {
IconH2, IconH2,
IconH3, IconH3,
IconMenu4, IconMenu4,
IconPageBreak,
IconTypography, IconTypography,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -102,6 +103,12 @@ export const BlockTypeGroup: FC<Props> = ({ editor }) => {
> >
{t("Divider")} {t("Divider")}
</Menu.Item> </Menu.Item>
<Menu.Item
leftSection={<IconPageBreak size={16} />}
onClick={() => editor.chain().focus().setPageBreak().run()}
>
{t("Page break")}
</Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
); );
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback, useRef } from "react"; import React, { useCallback, useRef } from "react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -19,6 +20,7 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
export function ImageMenu({ editor }: EditorMenuProps) { export function ImageMenu({ editor }: EditorMenuProps) {
@@ -40,6 +42,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
isAlignCenter: ctx.editor.isActive("image", { align: "center" }), isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
isAlignRight: ctx.editor.isActive("image", { align: "right" }), isAlignRight: ctx.editor.isActive("image", { align: "right" }),
src: imageAttrs?.src || null, src: imageAttrs?.src || null,
alt: imageAttrs?.alt || "",
}; };
}, },
}); });
@@ -56,7 +59,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
); );
const getReferencedVirtualElement = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return; if (!isEditorReady(editor)) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image"; const predicate = (node: PMNode) => node.type.name === "image";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
@@ -135,6 +138,16 @@ export function ImageMenu({ editor }: EditorMenuProps) {
editor.commands.deleteSelection(); editor.commands.deleteSelection();
}, [editor]); }, [editor]);
const {
button: altTextButton,
panel: altTextPanel,
isEditing: isEditingAlt,
} = useAltTextControl({
editor,
nodeName: "image",
currentAlt: editorState?.alt || "",
});
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
@@ -148,6 +161,9 @@ export function ImageMenu({ editor }: EditorMenuProps) {
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
{isEditingAlt ? (
altTextPanel
) : (
<div className={classes.toolbar}> <div className={classes.toolbar}>
<Tooltip position="top" label={t("Align left")} withinPortal={false}> <Tooltip position="top" label={t("Align left")} withinPortal={false}>
<ActionIcon <ActionIcon
@@ -187,6 +203,10 @@ export function ImageMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} /> <div className={classes.divider} />
{altTextButton}
<div className={classes.divider} />
<Tooltip position="top" label={t("Download")} withinPortal={false}> <Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={handleDownload} onClick={handleDownload}
@@ -220,6 +240,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</div> </div>
)}
<input <input
ref={fileInputRef} ref={fileInputRef}
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
export default function ImageView(props: NodeViewProps) { export default function ImageView(props: NodeViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { editor, node, selected } = props; const { editor, node, selected } = props;
const { src, width, align, title, aspectRatio, placeholder } = node.attrs; const { src, width, align, alt, aspectRatio, placeholder } = node.attrs;
const alignClass = useMemo(() => { const alignClass = useMemo(() => {
if (align === "left") return "alignLeft"; if (align === "left") return "alignLeft";
if (align === "right") return "alignRight"; if (align === "right") return "alignRight";
@@ -42,7 +42,7 @@ export default function ImageView(props: NodeViewProps) {
}} }}
> >
{src && ( {src && (
<Image radius="md" fit="contain" src={getFileUrl(src)} alt={title} /> <Image radius="md" fit="contain" src={getFileUrl(src)} alt={alt} />
)} )}
{!src && previewSrc && ( {!src && previewSrc && (
<Group pos="relative" h="100%" w="100%"> <Group pos="relative" h="100%" w="100%">
@@ -149,8 +149,13 @@ export default function MathBlockView(props: NodeViewProps) {
></Textarea> ></Textarea>
<Flex justify="flex-end" align="flex-end"> <Flex justify="flex-end" align="flex-end">
<ActionIcon variant="light" color="red"> <ActionIcon
<IconTrashX size={18} onClick={() => props.deleteNode()} /> variant="light"
color="red"
aria-label={t("Delete equation")}
onClick={() => props.deleteNode()}
>
<IconTrashX size={18} />
</ActionIcon> </ActionIcon>
</Flex> </Flex>
</Stack> </Stack>
@@ -16,6 +16,7 @@ import {
ScrollArea, ScrollArea,
Text, Text,
UnstyledButton, UnstyledButton,
VisuallyHidden,
} from "@mantine/core"; } from "@mantine/core";
import clsx from "clsx"; import clsx from "clsx";
import classes from "./mention.module.css"; import classes from "./mention.module.css";
@@ -36,7 +37,7 @@ import {
usePageQuery, usePageQuery,
} from "@/features/page/queries/page-query"; } from "@/features/page/queries/page-query";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom"; 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 { SpaceTreeNode } from "@/features/page/tree/types";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit"; 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 MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(1); const [selectedIndex, setSelectedIndex] = useState(1);
const viewportRef = useRef<HTMLDivElement>(null); const viewportRef = useRef<HTMLDivElement>(null);
const [countAnnouncement, setCountAnnouncement] = useState("");
const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
const { pageSlug, spaceSlug } = useParams(); const { pageSlug, spaceSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) }); const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: space } = useSpaceQuery(spaceSlug); const { data: space } = useSpaceQuery(spaceSlug);
@@ -53,7 +56,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]); const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
const { t } = useTranslation(); const { t } = useTranslation();
const [data, setData] = useAtom(treeDataAtom); const [data, setData] = useAtom(treeDataAtom);
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
const createPageMutation = useCreatePageMutation(); const createPageMutation = useCreatePageMutation();
const emit = useQueryEmit(); const emit = useQueryEmit();
const isInCommentContext = props.isInCommentContext ?? false; const isInCommentContext = props.isInCommentContext ?? false;
@@ -184,6 +186,45 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
setSelectedIndex(1); setSelectedIndex(1);
}, [suggestion]); }, [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, () => ({ useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => { onKeyDown: ({ event }) => {
if (event.key === "ArrowUp") { if (event.key === "ArrowUp") {
@@ -220,20 +261,20 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
try { try {
createdPage = await createPageMutation.mutateAsync(payload); createdPage = await createPageMutation.mutateAsync(payload);
const parentId = page.id || null; const parentId = page.id || null;
const data = { const newNode: SpaceTreeNode = {
id: createdPage.id, id: createdPage.id,
slugId: createdPage.slugId, slugId: createdPage.slugId,
name: createdPage.title, name: createdPage.title,
position: createdPage.position, position: createdPage.position,
spaceId: createdPage.spaceId, spaceId: createdPage.spaceId,
parentPageId: createdPage.parentPageId, parentPageId: createdPage.parentPageId,
hasChildren: false,
children: [], children: [],
} as any; };
const lastIndex = tree.data.length; const lastIndex = data.length;
tree.create({ parentId, index: lastIndex, data }); setData(treeModel.insert(data, parentId, newNode, lastIndex));
setData(tree.data);
props.command({ props.command({
id: uuid7(), id: uuid7(),
@@ -251,7 +292,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
payload: { payload: {
parentId, parentId,
index: lastIndex, index: lastIndex,
data, data: newNode,
}, },
}); });
}, 50); }, 50);
@@ -271,6 +312,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
if (renderItems.length === 0) { if (renderItems.length === 0) {
return ( return (
<Paper id="mention" shadow="md" py="xs" withBorder radius="md"> <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"> <Text c="dimmed" size="sm" px="sm">
{t("No results")} {t("No results")}
</Text> </Text>
@@ -297,6 +341,12 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
aria-label={t("Mention suggestions")} aria-label={t("Mention suggestions")}
aria-activedescendant={`mention-option-${selectedIndex}`} 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 <ScrollArea.Autosize
viewportRef={viewportRef} viewportRef={viewportRef}
mah={350} mah={350}
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react"; import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -37,9 +38,8 @@ export function PdfMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback( const shouldShow = useCallback(
({ state }: ShouldShowProps) => { ({ state }: ShouldShowProps) => {
if (!state || !editor.isActive("pdf")) { if (!state || !isEditorReady(editor)) return false;
return false; if (!editor.isActive("pdf")) return false;
}
const { selection } = state; const { selection } = state;
const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null; const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null;
@@ -51,7 +51,7 @@ export function PdfMenu({ editor }: EditorMenuProps) {
); );
const getReferencedVirtualElement = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return; if (!isEditorReady(editor)) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "pdf"; const predicate = (node: PMNode) => node.type.name === "pdf";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
@@ -10,6 +10,7 @@ import {
ScrollArea, ScrollArea,
Text, Text,
UnstyledButton, UnstyledButton,
VisuallyHidden,
} from "@mantine/core"; } from "@mantine/core";
import classes from "./slash-menu.module.css"; import classes from "./slash-menu.module.css";
import clsx from "clsx"; import clsx from "clsx";
@@ -29,6 +30,8 @@ const CommandList = ({
const { t } = useTranslation(); const { t } = useTranslation();
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const viewportRef = useRef<HTMLDivElement>(null); const viewportRef = useRef<HTMLDivElement>(null);
const [countAnnouncement, setCountAnnouncement] = useState("");
const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
const flatItems = useMemo(() => { const flatItems = useMemo(() => {
return Object.values(items).flat(); return Object.values(items).flat();
@@ -79,6 +82,25 @@ const CommandList = ({
setSelectedIndex(0); setSelectedIndex(0);
}, [flatItems]); }, [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(() => { useEffect(() => {
viewportRef.current viewportRef.current
?.querySelector(`[data-item-index="${selectedIndex}"]`) ?.querySelector(`[data-item-index="${selectedIndex}"]`)
@@ -95,6 +117,12 @@ const CommandList = ({
aria-label={t("Slash commands")} aria-label={t("Slash commands")}
aria-activedescendant={`slash-command-option-${selectedIndex}`} 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 <ScrollArea
viewportRef={viewportRef} viewportRef={viewportRef}
h={350} h={350}
@@ -19,6 +19,7 @@ import {
IconTable, IconTable,
IconTypography, IconTypography,
IconMenu4, IconMenu4,
IconPageBreak,
IconCalendar, IconCalendar,
IconAppWindow, IconAppWindow,
IconSitemap, IconSitemap,
@@ -164,6 +165,14 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setHorizontalRule().run(), 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", title: "Image",
description: "Upload any image from your device.", description: "Upload any image from your device.",
@@ -6,6 +6,7 @@ import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react"; import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core"; import { Editor } from "@tiptap/core";
import { isEditorReady } from "@docmost/editor-ext";
interface SubpagesMenuProps { interface SubpagesMenuProps {
editor: Editor; editor: Editor;
@@ -33,6 +34,7 @@ export const SubpagesMenu = React.memo(
); );
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
if (!isEditorReady(editor)) return new DOMRect();
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "subpages"; const predicate = (node: PMNode) => node.type.name === "subpages";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
@@ -3,7 +3,7 @@ import { TextSelection } from "@tiptap/pm/state";
import React, { FC, useEffect, useRef, useState } from "react"; import React, { FC, useEffect, useRef, useState } from "react";
import classes from "./table-of-contents.module.css"; import classes from "./table-of-contents.module.css";
import clsx from "clsx"; import clsx from "clsx";
import { Box, Text } from "@mantine/core"; import { Box, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type TableOfContentsProps = { type TableOfContentsProps = {
@@ -156,9 +156,9 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
return ( return (
<> <>
{props.isShare && ( {props.isShare && (
<Text mb="md" fw={500}> <Title order={2} size="h6" mb="md" fw={500}>
{t("Table of contents")} {t("Table of contents")}
</Text> </Title>
)} )}
<div className={props.isShare ? classes.leftBorder : ""}> <div className={props.isShare ? classes.leftBorder : ""}>
{links.map((item, idx) => ( {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;
}
}

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