diff --git a/.env.example b/.env.example index b218bdb84..cf2dafc1d 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,13 @@ GOTENBERG_URL= DISABLE_TELEMETRY=false +# Allow other sites to embed Docmost in an iframe. +IFRAME_EMBED_ALLOWED=false + +# Only used when IFRAME_EMBED_ALLOWED=true. When empty, any origin is allowed. +# Example: https://intranet.example.com,https://portal.example.com +IFRAME_ALLOWED_ORIGINS= + # Enable debug logging in production (default: false) DEBUG_MODE=false diff --git a/apps/client/package.json b/apps/client/package.json index f85c008e1..c769f6090 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -7,78 +7,89 @@ "build": "tsc && vite build", "lint": "eslint .", "preview": "vite preview", - "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"" + "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { - "@casl/react": "^5.0.1", + "@atlaskit/pragmatic-drag-and-drop": "1.8.1", + "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5", + "@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0", + "@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4", + "@casl/react": "5.0.1", "@docmost/editor-ext": "workspace:*", - "@emoji-mart/data": "^1.2.1", - "@emoji-mart/react": "^1.1.1", "@excalidraw/excalidraw": "0.18.0-3a5ef40", - "@mantine/core": "^8.3.18", - "@mantine/dates": "^8.3.18", - "@mantine/form": "^8.3.18", - "@mantine/hooks": "^8.3.18", - "@mantine/modals": "^8.3.18", - "@mantine/notifications": "^8.3.18", - "@mantine/spotlight": "^8.3.18", - "@tabler/icons-react": "^3.40.0", + "@mantine/core": "8.3.18", + "@mantine/dates": "8.3.18", + "@mantine/form": "8.3.18", + "@mantine/hooks": "8.3.18", + "@mantine/modals": "8.3.18", + "@mantine/notifications": "8.3.18", + "@mantine/spotlight": "8.3.18", + "@slidoapp/emoji-mart": "5.8.7", + "@slidoapp/emoji-mart-data": "1.2.4", + "@slidoapp/emoji-mart-react": "1.1.5", + "@tabler/icons-react": "3.40.0", "@tanstack/react-query": "5.90.17", - "alfaaz": "^1.1.0", + "@tanstack/react-virtual": "3.13.24", + "alfaaz": "1.1.0", "axios": "1.16.0", - "blueimp-load-image": "^5.16.0", - "clsx": "^2.1.1", - "emoji-mart": "^5.6.0", - "file-saver": "^2.0.5", - "highlightjs-sap-abap": "^0.3.0", + "blueimp-load-image": "5.16.0", + "clsx": "2.1.1", + "file-saver": "2.0.5", + "highlightjs-sap-abap": "0.3.0", "i18next": "25.10.1", "i18next-http-backend": "3.0.6", - "jotai": "^2.18.1", - "jotai-optics": "^0.4.0", - "js-cookie": "^3.0.5", - "jwt-decode": "^4.0.0", + "jotai": "2.18.1", + "jotai-optics": "0.4.0", + "js-cookie": "3.0.5", + "jwt-decode": "4.0.0", "katex": "0.16.40", - "lowlight": "^3.3.0", - "mantine-form-zod-resolver": "^1.3.0", - "mermaid": "^11.13.0", - "mitt": "^3.0.1", + "lowlight": "3.3.0", + "mantine-form-zod-resolver": "1.3.0", + "mermaid": "11.15.0", + "mitt": "3.0.1", "posthog-js": "1.372.2", - "react": "^18.3.1", - "react-arborist": "3.4.0", + "react": "18.3.1", "react-clear-modal": "^2.0.18", "react-dom": "^18.3.1", - "react-drawio": "^1.0.7", - "react-error-boundary": "^6.1.1", - "react-helmet-async": "^3.0.0", + "react-drawio": "1.0.7", + "react-error-boundary": "6.1.1", + "react-helmet-async": "3.0.0", "react-i18next": "16.5.8", - "react-router-dom": "^7.13.1", - "semver": "^7.7.4", - "socket.io-client": "^4.8.3", - "zod": "^4.3.6" + "react-router-dom": "7.13.1", + "semver": "7.7.4", + "socket.io-client": "4.8.3", + "zod": "4.3.6" }, "devDependencies": { - "@eslint/js": "^9.28.0", - "@tanstack/eslint-plugin-query": "^5.94.4", - "@types/blueimp-load-image": "^5.16.6", - "@types/file-saver": "^2.0.7", - "@types/js-cookie": "^3.0.6", - "@types/katex": "^0.16.8", + "@eslint/js": "9.28.0", + "@tanstack/eslint-plugin-query": "5.94.4", + "@testing-library/jest-dom": "6.6.0", + "@testing-library/react": "16.1.0", + "@types/blueimp-load-image": "5.16.6", + "@types/file-saver": "2.0.7", + "@types/js-cookie": "3.0.6", + "@types/katex": "0.16.8", "@types/node": "22.19.1", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^6.0.1", - "eslint": "^9.28.0", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^15.13.0", - "optics-ts": "^2.4.1", - "postcss": "^8.5.12", - "postcss-preset-mantine": "^1.18.0", - "postcss-simple-vars": "^7.0.1", - "prettier": "^3.8.1", - "typescript": "^5.9.3", - "typescript-eslint": "^8.57.1", - "vite": "8.0.5" + "@types/react": "18.3.12", + "@types/react-dom": "18.3.1", + "@vitejs/plugin-react": "6.0.1", + "eslint": "9.28.0", + "eslint-plugin-react": "7.37.5", + "eslint-plugin-react-hooks": "7.0.1", + "eslint-plugin-react-refresh": "0.5.2", + "globals": "15.13.0", + "jsdom": "25.0.0", + "optics-ts": "2.4.1", + "postcss": "8.5.14", + "postcss-preset-mantine": "1.18.0", + "postcss-simple-vars": "7.0.1", + "prettier": "3.8.1", + "typescript": "5.9.3", + "typescript-eslint": "8.57.1", + "vite": "8.0.5", + "vitest": "4.1.6" } } diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index de2e4505d..62927f66a 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -71,6 +71,7 @@ "Export": "Export", "Failed to create page": "Failed to create page", "Failed to delete page": "Failed to delete page", + "Failed to restore page": "Failed to restore page", "Failed to fetch recent pages": "Failed to fetch recent pages", "Failed to import pages": "Failed to import pages", "Failed to load page. An error occurred.": "Failed to load page. An error occurred.", @@ -286,6 +287,19 @@ "Add row above": "Add row above", "Add row below": "Add row below", "Delete table": "Delete table", + "Add column left": "Add column left", + "Add column right": "Add column right", + "Clear cell": "Clear cell", + "Clear cells": "Clear cells", + "Toggle header cell": "Toggle header cell", + "Toggle header column": "Toggle header column", + "Toggle header row": "Toggle header row", + "Move column left": "Move column left", + "Move column right": "Move column right", + "Move row down": "Move row down", + "Move row up": "Move row up", + "Sort A → Z": "Sort A → Z", + "Sort Z → A": "Sort Z → A", "Info": "Info", "Note": "Note", "Success": "Success", @@ -348,6 +362,8 @@ "Create block quote.": "Create block quote.", "Insert code snippet.": "Insert code snippet.", "Insert horizontal rule divider": "Insert horizontal rule divider", + "Page break": "Page break", + "Insert a page break for printing.": "Insert a page break for printing.", "Upload any image from your device.": "Upload any image from your device.", "Upload any video from your device.": "Upload any video from your device.", "Upload any audio from your device.": "Upload any audio from your device.", @@ -566,6 +582,8 @@ "Move to trash": "Move to trash", "Move this page to trash?": "Move this page to trash?", "Restore page": "Restore page", + "Permanently delete": "Permanently delete", + "{{name}} moved this page to Trash {{time}}.": "{{name}} moved this page to Trash {{time}}.", "Page moved to trash": "Page moved to trash", "Page restored successfully": "Page restored successfully", "Deleted by": "Deleted by", @@ -1026,5 +1044,8 @@ "No pages with this label": "No pages with this label", "Pages tagged with this label will appear here.": "Pages tagged with this label will appear here.", "No pages match your search.": "No pages match your search.", - "Updated {{date}}": "Updated {{date}}" + "Updated {{date}}": "Updated {{date}}", + "Cell actions": "Cell actions", + "Column actions": "Column actions", + "Row actions": "Row actions" } diff --git a/apps/client/src/components/layouts/global/aside.tsx b/apps/client/src/components/layouts/global/aside.tsx index 73e6a381d..23ebe7b7c 100644 --- a/apps/client/src/components/layouts/global/aside.tsx +++ b/apps/client/src/components/layouts/global/aside.tsx @@ -1,4 +1,5 @@ -import { Box, ScrollArea, Text } from "@mantine/core"; +import { ActionIcon, Box, Group, ScrollArea, Text, Tooltip } from "@mantine/core"; +import { IconX } from "@tabler/icons-react"; import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx"; import { useAtom } from "jotai"; import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; @@ -11,9 +12,10 @@ import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel"; import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx"; export default function Aside() { - const [{ tab }] = useAtom(asideStateAtom); + const [{ tab }, setAsideState] = useAtom(asideStateAtom); const { t } = useTranslation(); const pageEditor = useAtomValue(pageEditorAtom); + const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false })); let title: string; let component: ReactNode; @@ -45,9 +47,19 @@ export default function Aside() { {component && ( <> {tab !== "chat" && ( - - {t(title)} - + + {t(title)} + + + + + + )} {tab === "comments" || tab === "chat" ? ( diff --git a/apps/client/src/components/ui/emoji-picker.tsx b/apps/client/src/components/ui/emoji-picker.tsx index 804d1b0f4..c360998a3 100644 --- a/apps/client/src/components/ui/emoji-picker.tsx +++ b/apps/client/src/components/ui/emoji-picker.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useState } from "react"; +import React, { ReactNode, useEffect, useState } from "react"; import { ActionIcon, Popover, @@ -7,9 +7,24 @@ import { } from "@mantine/core"; import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks"; import { Suspense } from "react"; -const Picker = React.lazy(() => import("@emoji-mart/react")); import { useTranslation } from "react-i18next"; +// Load the picker module AND the emoji data in parallel inside the lazy +// resolution, then bind the data into the component. React.lazy only finishes +// suspending once both are in memory, so the Suspense boundary hides the +// Remove button until the Picker can render with real content. +const Picker = React.lazy(async () => { + const [pickerModule, dataModule] = await Promise.all([ + import("@slidoapp/emoji-mart-react"), + import("@slidoapp/emoji-mart-data"), + ]); + const PickerComp = pickerModule.default; + const data = dataModule.default; + return { + default: (props: any) => , + }; +}); + export interface EmojiPickerInterface { onEmojiSelect: (emoji: any) => void; icon: ReactNode; @@ -19,6 +34,7 @@ export interface EmojiPickerInterface { size?: string; variant?: string; c?: string; + tabIndex?: number; }; } @@ -50,6 +66,38 @@ function EmojiPicker({ } }); + // emoji-mart's built-in autoFocus calls .focus() without preventScroll, which + // makes the browser scroll every scrollable ancestor of the search input to + // bring it on screen — including the page editor's scroll container, so the + // page jumps to the top whenever the picker is opened from a scrolled-down + // position. The search input lives inside the 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( + 'input[type="search"]', + ); + if (input) { + input.focus({ preventScroll: true }); + return; + } + if (attempts < 60) { + rafId = requestAnimationFrame(() => tryFocus(attempts + 1)); + } + }; + rafId = requestAnimationFrame(() => tryFocus(0)); + return () => { + cancelled = true; + cancelAnimationFrame(rafId); + }; + }, [opened, dropdown]); + const handleEmojiSelect = (emoji) => { onEmojiSelect(emoji); handlers.close(); @@ -74,6 +122,7 @@ function EmojiPicker({ c={actionIconProps?.c || "gray"} variant={actionIconProps?.variant || "transparent"} size={actionIconProps?.size} + tabIndex={actionIconProps?.tabIndex} onClick={handlers.toggle} aria-label={t("Pick emoji")} aria-haspopup="dialog" @@ -85,7 +134,6 @@ function EmojiPicker({ (await import("@emoji-mart/data")).default} onEmojiSelect={handleEmojiSelect} perLine={8} skinTonePosition="search" diff --git a/apps/client/src/ee/components/sso-login.tsx b/apps/client/src/ee/components/sso-login.tsx index ff739dd35..3e84f8efc 100644 --- a/apps/client/src/ee/components/sso-login.tsx +++ b/apps/client/src/ee/components/sso-login.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts"; import { Button, Divider, Stack } from "@mantine/core"; import { IconLock, IconServer } from "@tabler/icons-react"; @@ -7,15 +7,37 @@ import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts"; import { SSO_PROVIDER } from "@/ee/security/contants.ts"; import { GoogleIcon } from "@/components/icons/google-icon.tsx"; import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx"; +import { getRedirectParam } from "@/lib/app-route.ts"; +import useCurrentUser from "@/features/user/hooks/use-current-user.ts"; + +const SSO_AUTO_ATTEMPT_KEY = "docmost:ssoAutoAttempt"; +const SSO_AUTO_ATTEMPT_TTL_MS = 5 * 60_000; + +function recentAutoAttempt(): boolean { + try { + const raw = window.sessionStorage.getItem(SSO_AUTO_ATTEMPT_KEY); + if (!raw) return false; + const ts = Number(raw); + return Number.isFinite(ts) && Date.now() - ts < SSO_AUTO_ATTEMPT_TTL_MS; + } catch { + return false; + } +} + +function markAutoAttempt(): void { + try { + window.sessionStorage.setItem(SSO_AUTO_ATTEMPT_KEY, String(Date.now())); + } catch { + /* sessionStorage unavailable (private mode, etc.) — best effort */ + } +} export default function SsoLogin() { const { data, isLoading } = useWorkspacePublicDataQuery(); + const { data: currentUser } = useCurrentUser(); const [ldapModalOpened, setLdapModalOpened] = useState(false); const [selectedLdapProvider, setSelectedLdapProvider] = useState(null); - - if (!data?.authProviders || data?.authProviders?.length === 0) { - return null; - } + const autoRedirectedRef = useRef(false); const handleSsoLogin = (provider: IAuthProvider) => { if (provider.type === SSO_PROVIDER.LDAP) { @@ -28,10 +50,47 @@ export default function SsoLogin() { providerId: provider.id, type: provider.type, workspaceId: data.id, + redirect: getRedirectParam() ?? undefined, }); } }; + // Auto-redirect when SSO is enforced and there is exactly one non-LDAP + // provider. The user has no other option, so skip the extra click. + useEffect(() => { + if (autoRedirectedRef.current) return; + if (!data?.enforceSso) return; + if (!data.authProviders || data.authProviders.length !== 1) return; + const onlyProvider = data.authProviders[0]; + if (onlyProvider.type === SSO_PROVIDER.LDAP) return; + + // Already signed in: let useRedirectIfAuthenticated handle navigation + // instead of racing it through the IdP. + if (currentUser?.user) return; + + // Explicit logout: don't immediately bounce them back to the IdP. + const params = new URLSearchParams(window.location.search); + if (params.has("logout")) return; + + // Circuit-breaker: if we already auto-redirected within the TTL, the + // user came back (likely from an IdP failure). Show the page so they + // can read errors or pick a different account. + if (recentAutoAttempt()) return; + + autoRedirectedRef.current = true; + markAutoAttempt(); + window.location.href = buildSsoLoginUrl({ + providerId: onlyProvider.id, + type: onlyProvider.type, + workspaceId: data.id, + redirect: getRedirectParam() ?? undefined, + }); + }, [data, currentUser]); + + if (!data?.authProviders || data?.authProviders?.length === 0) { + return null; + } + const getProviderIcon = (provider: IAuthProvider) => { if (provider.type === SSO_PROVIDER.GOOGLE) { return ; diff --git a/apps/client/src/ee/security/sso.utils.ts b/apps/client/src/ee/security/sso.utils.ts index 4a4665c16..bfeb7c062 100644 --- a/apps/client/src/ee/security/sso.utils.ts +++ b/apps/client/src/ee/security/sso.utils.ts @@ -18,14 +18,21 @@ export function buildSsoLoginUrl(opts: { providerId: string; type: SSO_PROVIDER; workspaceId?: string; + redirect?: string; }): string { - const { providerId, type, workspaceId } = opts; + const { providerId, type, workspaceId, redirect } = opts; const domain = getAppUrl(); + const params = new URLSearchParams(); + if (redirect) params.set("redirect", redirect); + if (type === SSO_PROVIDER.GOOGLE) { - return `${getServerAppUrl()}/api/sso/${type}/login?workspaceId=${workspaceId}`; + if (workspaceId) params.set("workspaceId", workspaceId); + return `${getServerAppUrl()}/api/sso/${type}/login?${params.toString()}`; } - return `${domain}/api/sso/${type}/${providerId}/login`; + const query = params.toString(); + const base = `${domain}/api/sso/${type}/${providerId}/login`; + return query ? `${base}?${query}` : base; } export function getGoogleSignupUrl(): string { diff --git a/apps/client/src/features/auth/hooks/use-auth.ts b/apps/client/src/features/auth/hooks/use-auth.ts index 411e04b41..970a85897 100644 --- a/apps/client/src/features/auth/hooks/use-auth.ts +++ b/apps/client/src/features/auth/hooks/use-auth.ts @@ -166,7 +166,7 @@ export default function useAuth() { const handleLogout = async () => { setCurrentUser(RESET); await logout(); - window.location.replace(APP_ROUTE.AUTH.LOGIN); + window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`); }; const handleForgotPassword = async (data: IForgotPassword) => { diff --git a/apps/client/src/features/editor/atoms/editor-atoms.ts b/apps/client/src/features/editor/atoms/editor-atoms.ts index 8982765e4..c0873adfa 100644 --- a/apps/client/src/features/editor/atoms/editor-atoms.ts +++ b/apps/client/src/features/editor/atoms/editor-atoms.ts @@ -1,5 +1,6 @@ import { atom } from "jotai"; import { Editor } from "@tiptap/core"; +import { PageEditMode } from "@/features/user/types/user.types.ts"; export const pageEditorAtom = atom(null); @@ -12,3 +13,7 @@ export const yjsConnectionStatusAtom = atom(""); export const showAiMenuAtom = atom(false); export const showLinkMenuAtom = atom(false); + +// Current page's edit mode — initialized from the user's saved preference on +// first load, can be toggled locally without persisting to the server. +export const currentPageEditModeAtom = atom(PageEditMode.Edit); diff --git a/apps/client/src/features/editor/components/audio/audio-menu.tsx b/apps/client/src/features/editor/components/audio/audio-menu.tsx index 3ca1950da..eadc1afe5 100644 --- a/apps/client/src/features/editor/components/audio/audio-menu.tsx +++ b/apps/client/src/features/editor/components/audio/audio-menu.tsx @@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { useCallback } from "react"; import { Node as PMNode } from "@tiptap/pm/model"; +import { isEditorReady } from "@docmost/editor-ext"; import { EditorMenuProps, ShouldShowProps, @@ -46,7 +47,7 @@ export function AudioMenu({ editor }: EditorMenuProps) { ); const getReferencedVirtualElement = useCallback(() => { - if (!editor) return; + if (!isEditorReady(editor)) return; const { selection } = editor.state; const predicate = (node: PMNode) => node.type.name === "audio"; const parent = findParentNode(predicate)(selection); diff --git a/apps/client/src/features/editor/components/callout/callout-menu.tsx b/apps/client/src/features/editor/components/callout/callout-menu.tsx index 69c836934..3ce022dae 100644 --- a/apps/client/src/features/editor/components/callout/callout-menu.tsx +++ b/apps/client/src/features/editor/components/callout/callout-menu.tsx @@ -16,7 +16,7 @@ import { IconMoodSmile, IconNotes, } from "@tabler/icons-react"; -import { CalloutType, isTextSelected } from "@docmost/editor-ext"; +import { CalloutType, isEditorReady, isTextSelected } from "@docmost/editor-ext"; import { useTranslation } from "react-i18next"; import EmojiPicker from "@/components/ui/emoji-picker.tsx"; import classes from "../common/toolbar-menu.module.css"; @@ -55,7 +55,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) { }); const getReferencedVirtualElement = useCallback(() => { - if (!editor) return; + if (!isEditorReady(editor)) return; const { selection } = editor.state; const predicate = (node: PMNode) => node.type.name === "callout"; const parent = findParentNode(predicate)(selection); diff --git a/apps/client/src/features/editor/components/columns/columns-menu.tsx b/apps/client/src/features/editor/components/columns/columns-menu.tsx index 0ee99508c..4a1f041eb 100644 --- a/apps/client/src/features/editor/components/columns/columns-menu.tsx +++ b/apps/client/src/features/editor/components/columns/columns-menu.tsx @@ -19,7 +19,7 @@ import { IconCopy, IconTrash, } from "@tabler/icons-react"; -import { isTextSelected } from "@docmost/editor-ext"; +import { isEditorReady, isTextSelected } from "@docmost/editor-ext"; import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext"; import { useTranslation } from "react-i18next"; import classes from "../common/toolbar-menu.module.css"; @@ -82,7 +82,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) { const shouldShow = useCallback( ({ state }: ShouldShowProps) => { - if (!state) return false; + if (!state || !isEditorReady(editor)) return false; if (!editor.isActive("columns")) return false; if (isTextSelected(editor)) return false; if (nodesWithMenus.some((name) => editor.isActive(name))) return false; @@ -121,7 +121,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) { }); const getReferencedVirtualElement = useCallback(() => { - if (!editor) return; + if (!isEditorReady(editor)) return; const { selection } = editor.state; const predicate = (node: PMNode) => node.type.name === "columns"; const parent = findParentNode(predicate)(selection); diff --git a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx index 869decd71..877911750 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx @@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { useCallback, useEffect, useRef, useState } from "react"; import { Node as PMNode } from "@tiptap/pm/model"; +import { isEditorReady } from "@docmost/editor-ext"; import { EditorMenuProps, ShouldShowProps, @@ -81,7 +82,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) { ); const getReferencedVirtualElement = useCallback(() => { - if (!editor) return; + if (!isEditorReady(editor)) return; const { selection } = editor.state; const predicate = (node: PMNode) => node.type.name === "drawio"; const parent = findParentNode(predicate)(selection); diff --git a/apps/client/src/features/editor/components/emoji-menu/utils.ts b/apps/client/src/features/editor/components/emoji-menu/utils.ts index 8a86ee501..bded7bcd5 100644 --- a/apps/client/src/features/editor/components/emoji-menu/utils.ts +++ b/apps/client/src/features/editor/components/emoji-menu/utils.ts @@ -21,7 +21,7 @@ let _emojiIndex: EmojiIndexEntry[] | null = null; export const buildEmojiIndex = async (): Promise => { if (_emojiIndex) return _emojiIndex; - const { default: data } = await import("@emoji-mart/data"); + const { default: data } = await import('@slidoapp/emoji-mart-data'); _emojiIndex = (Object.values((data as any).emojis) as any[]) .filter((e) => e.id && e.name && e.skins?.[0]?.native) .map((e) => ({ @@ -74,7 +74,7 @@ let _cats: EmojiCategory[] | null = null; export const getEmojiCategories = async (): Promise => { if (_cats) return _cats; const [{ default: data }, index] = await Promise.all([ - import("@emoji-mart/data"), + import("@slidoapp/emoji-mart-data"), buildEmojiIndex(), ]); const byId = new Map(index.map((e) => [e.id, e])); diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu-lazy.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu-lazy.tsx new file mode 100644 index 000000000..acdf5440d --- /dev/null +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu-lazy.tsx @@ -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 ( + + + + ); +} diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx index fd3128062..823c2c213 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx @@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { Node as PMNode } from "@tiptap/pm/model"; +import { isEditorReady } from "@docmost/editor-ext"; import { EditorMenuProps, ShouldShowProps, @@ -94,7 +95,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) { ); const getReferencedVirtualElement = useCallback(() => { - if (!editor) return; + if (!isEditorReady(editor)) return; const { selection } = editor.state; const predicate = (node: PMNode) => node.type.name === "excalidraw"; const parent = findParentNode(predicate)(selection); diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-view-lazy.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-view-lazy.tsx new file mode 100644 index 000000000..573a25dbd --- /dev/null +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-view-lazy.tsx @@ -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 ( + + + + ); +} diff --git a/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.module.css b/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.module.css index f5cf09cbb..ef5595ea1 100644 --- a/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.module.css +++ b/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.module.css @@ -3,7 +3,7 @@ top: calc(var(--app-shell-header-offset, 0rem) + 45px); inset-inline-start: var(--app-shell-navbar-offset, 0rem); inset-inline-end: var(--app-shell-aside-offset, 0rem); - z-index: 50; + z-index: 99; display: flex; align-items: center; background: var(--mantine-color-body); diff --git a/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.tsx b/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.tsx index 2a2135e8c..d72db0c7d 100644 --- a/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.tsx +++ b/apps/client/src/features/editor/components/fixed-toolbar/fixed-toolbar.tsx @@ -28,6 +28,7 @@ export const FixedToolbar: FC = () => { <>
e.preventDefault()} diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/block-type-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/block-type-group.tsx index 3edb28eda..69911f7cb 100644 --- a/apps/client/src/features/editor/components/fixed-toolbar/groups/block-type-group.tsx +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/block-type-group.tsx @@ -10,6 +10,7 @@ import { IconH2, IconH3, IconMenu4, + IconPageBreak, IconTypography, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; @@ -102,6 +103,12 @@ export const BlockTypeGroup: FC = ({ editor }) => { > {t("Divider")} + } + onClick={() => editor.chain().focus().setPageBreak().run()} + > + {t("Page break")} + ); diff --git a/apps/client/src/features/editor/components/image/image-menu.tsx b/apps/client/src/features/editor/components/image/image-menu.tsx index 666fab7dc..1b2d00e7e 100644 --- a/apps/client/src/features/editor/components/image/image-menu.tsx +++ b/apps/client/src/features/editor/components/image/image-menu.tsx @@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import React, { useCallback, useRef } from "react"; import { Node as PMNode } from "@tiptap/pm/model"; +import { isEditorReady } from "@docmost/editor-ext"; import { EditorMenuProps, ShouldShowProps, @@ -56,7 +57,7 @@ export function ImageMenu({ editor }: EditorMenuProps) { ); const getReferencedVirtualElement = useCallback(() => { - if (!editor) return; + if (!isEditorReady(editor)) return; const { selection } = editor.state; const predicate = (node: PMNode) => node.type.name === "image"; const parent = findParentNode(predicate)(selection); diff --git a/apps/client/src/features/editor/components/mention/mention-list.tsx b/apps/client/src/features/editor/components/mention/mention-list.tsx index 330bded9a..8f6269060 100644 --- a/apps/client/src/features/editor/components/mention/mention-list.tsx +++ b/apps/client/src/features/editor/components/mention/mention-list.tsx @@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useImperativeHandle, - useMemo, useRef, useState, } from "react"; @@ -36,7 +35,7 @@ import { usePageQuery, } from "@/features/page/queries/page-query"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom"; -import { SimpleTree } from "react-arborist"; +import { treeModel } from "@/features/page/tree/model/tree-model"; import { SpaceTreeNode } from "@/features/page/tree/types"; import { useTranslation } from "react-i18next"; import { useQueryEmit } from "@/features/websocket/use-query-emit"; @@ -53,7 +52,6 @@ const MentionList = forwardRef((props, ref) => { const [renderItems, setRenderItems] = useState([]); const { t } = useTranslation(); const [data, setData] = useAtom(treeDataAtom); - const tree = useMemo(() => new SimpleTree(data), [data]); const createPageMutation = useCreatePageMutation(); const emit = useQueryEmit(); const isInCommentContext = props.isInCommentContext ?? false; @@ -220,20 +218,20 @@ const MentionList = forwardRef((props, ref) => { try { createdPage = await createPageMutation.mutateAsync(payload); const parentId = page.id || null; - const data = { + const newNode: SpaceTreeNode = { id: createdPage.id, slugId: createdPage.slugId, name: createdPage.title, position: createdPage.position, spaceId: createdPage.spaceId, parentPageId: createdPage.parentPageId, + hasChildren: false, children: [], - } as any; + }; - const lastIndex = tree.data.length; + const lastIndex = data.length; - tree.create({ parentId, index: lastIndex, data }); - setData(tree.data); + setData(treeModel.insert(data, parentId, newNode, lastIndex)); props.command({ id: uuid7(), @@ -251,7 +249,7 @@ const MentionList = forwardRef((props, ref) => { payload: { parentId, index: lastIndex, - data, + data: newNode, }, }); }, 50); diff --git a/apps/client/src/features/editor/components/pdf/pdf-menu.tsx b/apps/client/src/features/editor/components/pdf/pdf-menu.tsx index 2104bfbc6..3fc8b6fd1 100644 --- a/apps/client/src/features/editor/components/pdf/pdf-menu.tsx +++ b/apps/client/src/features/editor/components/pdf/pdf-menu.tsx @@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { useCallback } from "react"; import { Node as PMNode } from "@tiptap/pm/model"; +import { isEditorReady } from "@docmost/editor-ext"; import { EditorMenuProps, ShouldShowProps, @@ -37,9 +38,8 @@ export function PdfMenu({ editor }: EditorMenuProps) { const shouldShow = useCallback( ({ state }: ShouldShowProps) => { - if (!state || !editor.isActive("pdf")) { - return false; - } + if (!state || !isEditorReady(editor)) return false; + if (!editor.isActive("pdf")) return false; const { selection } = state; const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null; @@ -51,7 +51,7 @@ export function PdfMenu({ editor }: EditorMenuProps) { ); const getReferencedVirtualElement = useCallback(() => { - if (!editor) return; + if (!isEditorReady(editor)) return; const { selection } = editor.state; const predicate = (node: PMNode) => node.type.name === "pdf"; const parent = findParentNode(predicate)(selection); diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 4a0532fe3..cddddc35f 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -19,6 +19,7 @@ import { IconTable, IconTypography, IconMenu4, + IconPageBreak, IconCalendar, IconAppWindow, IconSitemap, @@ -164,6 +165,14 @@ const CommandGroups: SlashMenuGroupedItemsType = { command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(), }, + { + title: "Page break", + description: "Insert a page break for printing.", + searchTerms: ["page", "break", "pagebreak", "print"], + icon: IconPageBreak, + command: ({ editor, range }: CommandProps) => + editor.chain().focus().deleteRange(range).setPageBreak().run(), + }, { title: "Image", description: "Upload any image from your device.", diff --git a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx index 9f0544e67..a626e1ee2 100644 --- a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx +++ b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx @@ -6,6 +6,7 @@ import { ActionIcon, Tooltip } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { Editor } from "@tiptap/core"; +import { isEditorReady } from "@docmost/editor-ext"; interface SubpagesMenuProps { editor: Editor; @@ -33,6 +34,7 @@ export const SubpagesMenu = React.memo( ); const getReferenceClientRect = useCallback(() => { + if (!isEditorReady(editor)) return new DOMRect(); const { selection } = editor.state; const predicate = (node: PMNode) => node.type.name === "subpages"; const parent = findParentNode(predicate)(selection); diff --git a/apps/client/src/features/editor/components/table/handle/cell-chevron.tsx b/apps/client/src/features/editor/components/table/handle/cell-chevron.tsx new file mode 100644 index 000000000..db79844e8 --- /dev/null +++ b/apps/client/src/features/editor/components/table/handle/cell-chevron.tsx @@ -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 ( + + + + + + + + + + + ); +}); diff --git a/apps/client/src/features/editor/components/table/handle/column-handle.tsx b/apps/client/src/features/editor/components/table/handle/column-handle.tsx new file mode 100644 index 000000000..a46ac50d5 --- /dev/null +++ b/apps/client/src/features/editor/components/table/handle/column-handle.tsx @@ -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(lookupCellDom); + const lastCellDomRef = useRef(lookupCellDom); + useEffect(() => { + if (lookupCellDom && lookupCellDom !== lastCellDomRef.current) { + lastCellDomRef.current = lookupCellDom; + setCellDom(lookupCellDom); + } + }, [lookupCellDom]); + + const [handleEl, setHandleEl] = useState(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(".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 ( + + +
{ + 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")} + > + + + +
+
+ + + +
+ ); +}); + +function GripIcon() { + return ( + + + + ); +} diff --git a/apps/client/src/features/editor/components/table/handle/handle.module.css b/apps/client/src/features/editor/components/table/handle/handle.module.css new file mode 100644 index 000000000..e7d9ac124 --- /dev/null +++ b/apps/client/src/features/editor/components/table/handle/handle.module.css @@ -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; + } +} diff --git a/apps/client/src/features/editor/components/table/handle/hooks/use-column-row-menu-lifecycle.ts b/apps/client/src/features/editor/components/table/handle/hooks/use-column-row-menu-lifecycle.ts new file mode 100644 index 000000000..a30595597 --- /dev/null +++ b/apps/client/src/features/editor/components/table/handle/hooks/use-column-row-menu-lifecycle.ts @@ -0,0 +1,40 @@ +import { useCallback } from "react"; +import type { Editor } from "@tiptap/react"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { buildRowOrColumnSelection, Orientation } from "../lib/select-row-column"; + +interface Args { + editor: Editor; + orientation: Orientation; + index: number; + tableNode: ProseMirrorNode; + tablePos: number; +} + +export function useColumnRowMenuLifecycle({ + editor, + orientation, + index, + tableNode, + tablePos, +}: Args) { + const onOpen = useCallback(() => { + const selection = buildRowOrColumnSelection( + editor.state, + tableNode, + tablePos, + orientation, + index, + ); + const tr = editor.state.tr; + if (selection) tr.setSelection(selection); + editor.view.dispatch(tr); + editor.commands.freezeHandles(); + }, [editor, orientation, index, tableNode, tablePos]); + + const onClose = useCallback(() => { + editor.commands.unfreezeHandles(); + }, [editor]); + + return { onOpen, onClose }; +} diff --git a/apps/client/src/features/editor/components/table/handle/hooks/use-table-clear.ts b/apps/client/src/features/editor/components/table/handle/hooks/use-table-clear.ts new file mode 100644 index 000000000..1bd4cb209 --- /dev/null +++ b/apps/client/src/features/editor/components/table/handle/hooks/use-table-clear.ts @@ -0,0 +1,54 @@ +import { useCallback } from "react"; +import type { Editor } from "@tiptap/react"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { TableMap } from "@tiptap/pm/tables"; + +type Scope = + | { kind: "col"; index: number } + | { kind: "row"; index: number } + | { kind: "cell"; cellPos: number }; + +export function useTableClear( + editor: Editor, + tableNode: ProseMirrorNode, + tablePos: number, + scope: Scope, +) { + return useCallback(() => { + const tr = editor.state.tr; + const tableStart = tablePos + 1; + const map = TableMap.get(tableNode); + const paragraph = editor.schema.nodes.paragraph; + if (!paragraph) return; + + const cellOffsets: number[] = []; + + if (scope.kind === "col") { + for (let row = 0; row < map.height; row++) { + cellOffsets.push(map.map[row * map.width + scope.index]); + } + } else if (scope.kind === "row") { + for (let col = 0; col < map.width; col++) { + cellOffsets.push(map.map[scope.index * map.width + col]); + } + } + + const targets = + scope.kind === "cell" + ? [scope.cellPos] + : Array.from(new Set(cellOffsets)).map((o) => tableStart + o); + + // Process in reverse position order so earlier replacements don't shift later ones. + targets.sort((a, b) => b - a); + + for (const cellPos of targets) { + const node = tr.doc.nodeAt(cellPos); + if (!node) continue; + const start = cellPos + 1; + const end = cellPos + node.nodeSize - 1; + tr.replaceWith(start, end, paragraph.create()); + } + + if (tr.docChanged) editor.view.dispatch(tr); + }, [editor, tableNode, tablePos, scope]); +} diff --git a/apps/client/src/features/editor/components/table/handle/hooks/use-table-handle-drag.ts b/apps/client/src/features/editor/components/table/handle/hooks/use-table-handle-drag.ts new file mode 100644 index 000000000..30b179689 --- /dev/null +++ b/apps/client/src/features/editor/components/table/handle/hooks/use-table-handle-drag.ts @@ -0,0 +1,79 @@ +import { useEffect } from "react"; +import type { Editor } from "@tiptap/react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { disableNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview"; +import { + autoScrollForElements, + autoScrollWindowForElements, +} from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; +import { getTableHandlePluginSpec } from "@docmost/editor-ext"; + +// Uses pragmatic-drag-and-drop instead of native HTML5 DnD because the native +// dragstart→dragover→drop lifecycle was being silently cancelled +export function useTableHandleDrag( + editor: Editor, + orientation: "col" | "row", + element: HTMLElement | null, + wrapper: HTMLElement | null, + onDragStart?: () => void, +) { + useEffect(() => { + if (!element) return; + + return combine( + draggable({ + element, + getInitialData: () => ({ type: `table-${orientation}` }), + onGenerateDragPreview: ({ nativeSetDragImage }) => { + // We render our own floating preview via PreviewController, so hide + // the native drag image entirely. + disableNativeDragPreview({ nativeSetDragImage }); + }, + onDragStart: ({ location }) => { + // The menu (if open from a prior click on the handle) won't dismiss + // on its own — pragmatic-dnd swallows the events Mantine listens for. + onDragStart?.(); + const spec = getTableHandlePluginSpec(editor); + if (!spec) return; + const { clientX, clientY } = location.initial.input; + spec.startDragFromHandle(orientation, clientX, clientY); + }, + onDrag: ({ location }) => { + const spec = getTableHandlePluginSpec(editor); + if (!spec) return; + const { clientX, clientY } = location.current.input; + spec.updateDragPosition(clientX, clientY); + }, + onDrop: ({ location }) => { + const spec = getTableHandlePluginSpec(editor); + if (!spec) return; + const { clientX, clientY } = location.current.input; + // Make sure the final position is recorded before committing the drop. + spec.updateDragPosition(clientX, clientY); + spec.commitDrop(); + spec.endDrag(); + }, + }), + // Wrapper owns horizontal auto-scroll (it has `overflow-x: auto`); + // window owns vertical. Locking each axis prevents the window's + // horizontal auto-scroll from running when the cursor approaches + // the viewport edge — without the cap, the preview's `left` follows + // the cursor past the viewport, the page widens to contain it, the + // plugin scrolls the now-wider page further, and the loop never + // ends. + // Only the column handle registers wrapper auto-scroll (rows can't + // scroll horizontally) — registering twice on the same wrapper + // triggers a dev-mode warning from pragmatic-dnd-auto-scroll. + orientation === "col" && + wrapper && + !wrapper.classList.contains("tableWrapperNoOverflow") + ? autoScrollForElements({ + element: wrapper, + getAllowedAxis: () => "horizontal", + }) + : () => {}, + autoScrollWindowForElements({ getAllowedAxis: () => "vertical" }), + ); + }, [editor, orientation, element, wrapper, onDragStart]); +} diff --git a/apps/client/src/features/editor/components/table/handle/hooks/use-table-handle-state.ts b/apps/client/src/features/editor/components/table/handle/hooks/use-table-handle-state.ts new file mode 100644 index 000000000..ab8893566 --- /dev/null +++ b/apps/client/src/features/editor/components/table/handle/hooks/use-table-handle-state.ts @@ -0,0 +1,23 @@ +import type { Editor } from "@tiptap/react"; +import { useEditorState } from "@tiptap/react"; +import { TableDndKey, TableHandleState } from "@docmost/editor-ext"; + +const FALLBACK: TableHandleState = { + hoveringCell: null, + tableNode: null, + tablePos: null, + dragging: null, + frozen: false, +}; + +export function useTableHandleState(editor: Editor | null): TableHandleState { + const state = useEditorState({ + editor, + selector: (ctx) => { + if (!ctx.editor) return null; + return TableDndKey.getState(ctx.editor.state) ?? null; + }, + }); + + return state ?? FALLBACK; +} diff --git a/apps/client/src/features/editor/components/table/handle/hooks/use-table-move-row-column.ts b/apps/client/src/features/editor/components/table/handle/hooks/use-table-move-row-column.ts new file mode 100644 index 000000000..476c68f8d --- /dev/null +++ b/apps/client/src/features/editor/components/table/handle/hooks/use-table-move-row-column.ts @@ -0,0 +1,50 @@ +import { useCallback, useMemo } from "react"; +import type { Editor } from "@tiptap/react"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { TableMap } from "@tiptap/pm/tables"; +import { moveColumn, moveRow } from "@docmost/editor-ext"; + +export type MoveDirection = "left" | "right" | "up" | "down"; + +export function useTableMoveRowColumn( + editor: Editor, + orientation: "col" | "row", + index: number, + direction: MoveDirection, + tableNode: ProseMirrorNode, + tablePos: number, +) { + const target = + direction === "left" || direction === "up" ? index - 1 : index + 1; + + const maxIndex = useMemo(() => { + const map = TableMap.get(tableNode); + return orientation === "col" ? map.width - 1 : map.height - 1; + }, [tableNode, orientation]); + + const canMove = target >= 0 && target <= maxIndex; + + const handleMove = useCallback(() => { + if (!canMove) return; + const tr = editor.state.tr; + const moved = + orientation === "col" + ? moveColumn({ + tr, + originIndex: index, + targetIndex: target, + select: true, + pos: tablePos + 1, + }) + : moveRow({ + tr, + originIndex: index, + targetIndex: target, + select: true, + pos: tablePos + 1, + }); + if (moved) editor.view.dispatch(tr); + }, [editor, orientation, index, target, tablePos, canMove]); + + return { canMove, handleMove }; +} diff --git a/apps/client/src/features/editor/components/table/handle/hooks/use-table-sort.ts b/apps/client/src/features/editor/components/table/handle/hooks/use-table-sort.ts new file mode 100644 index 000000000..afc6a2774 --- /dev/null +++ b/apps/client/src/features/editor/components/table/handle/hooks/use-table-sort.ts @@ -0,0 +1,100 @@ +import { useCallback, useMemo } from "react"; +import type { Editor } from "@tiptap/react"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { + convertArrayOfRowsToTableNode, + convertTableNodeToArrayOfRows, + transpose, +} from "@docmost/editor-ext"; +import { + getCellSortText, + isCellEmpty, + isHeaderCell, + type SortDirection, + type SortableItem, + sortItems, + weaveItems, +} from "../lib/sort-cells"; + +interface Args { + editor: Editor; + orientation: "col" | "row"; + index: number; + tableNode: ProseMirrorNode; + tablePos: number; + direction: SortDirection; +} + +function tableHasMergedCells(tableNode: ProseMirrorNode): boolean { + for (let r = 0; r < tableNode.childCount; r++) { + const row = tableNode.child(r); + for (let c = 0; c < row.childCount; c++) { + const { colspan = 1, rowspan = 1 } = row.child(c).attrs; + if (colspan > 1 || rowspan > 1) return true; + } + } + return false; +} + +function isAllHeader(cells: (ProseMirrorNode | null)[]): boolean { + return cells.every((c) => c !== null && isHeaderCell(c)); +} + +export function useTableSort({ + editor, + orientation, + index, + tableNode, + tablePos, + direction, +}: Args) { + const canSort = useMemo(() => { + if (tableHasMergedCells(tableNode)) return false; + + const rows = convertTableNodeToArrayOfRows(tableNode); + const axes = orientation === "col" ? rows : transpose(rows); + if (axes.length < 2) return false; + + return axes.some((cells) => { + if (isAllHeader(cells)) return false; + const sortCell = cells[index]; + return !!sortCell && !isCellEmpty(sortCell); + }); + }, [tableNode, orientation, index]); + + const handleSort = useCallback(() => { + if (!canSort) return; + + const rows = convertTableNodeToArrayOfRows(tableNode); + const axes = orientation === "col" ? rows : transpose(rows); + + const items: SortableItem<(ProseMirrorNode | null)[]>[] = axes.map( + (cells, originalOrder) => { + const sortCell = cells[index]; + return { + payload: cells, + text: sortCell ? getCellSortText(sortCell) : "", + isHeader: isAllHeader(cells), + isEmpty: !sortCell || isCellEmpty(sortCell), + originalOrder, + }; + }, + ); + + const dataItems = items.filter((it) => !it.isHeader); + const sortedData = sortItems(dataItems, direction); + const woven = weaveItems(items, sortedData); + + const newAxes = woven.map((it) => it.payload); + const newRows = orientation === "col" ? newAxes : transpose(newAxes); + + const newTable = convertArrayOfRowsToTableNode(tableNode, newRows); + + const tr = editor.state.tr; + tr.replaceWith(tablePos, tablePos + tableNode.nodeSize, newTable); + + if (tr.docChanged) editor.view.dispatch(tr); + }, [editor, tableNode, tablePos, orientation, index, direction, canSort]); + + return { canSort, handleSort }; +} diff --git a/apps/client/src/features/editor/components/table/handle/lib/select-row-column.ts b/apps/client/src/features/editor/components/table/handle/lib/select-row-column.ts new file mode 100644 index 000000000..5ef315cf1 --- /dev/null +++ b/apps/client/src/features/editor/components/table/handle/lib/select-row-column.ts @@ -0,0 +1,34 @@ +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import type { EditorState } from "@tiptap/pm/state"; +import { CellSelection, TableMap } from "@tiptap/pm/tables"; + +export type Orientation = "col" | "row"; + +export function buildRowOrColumnSelection( + state: EditorState, + tableNode: ProseMirrorNode, + tablePos: number, + orientation: Orientation, + index: number, +): CellSelection | null { + const map = TableMap.get(tableNode); + const tableStart = tablePos + 1; + + if (orientation === "col") { + if (index < 0 || index >= map.width) return null; + const firstCellPos = tableStart + map.map[index]; + const lastCellPos = + tableStart + map.map[(map.height - 1) * map.width + index]; + const $first = state.doc.resolve(firstCellPos); + const $last = state.doc.resolve(lastCellPos); + return CellSelection.colSelection($first, $last); + } + + if (index < 0 || index >= map.height) return null; + const firstCellPos = tableStart + map.map[index * map.width]; + const lastCellPos = + tableStart + map.map[index * map.width + (map.width - 1)]; + const $first = state.doc.resolve(firstCellPos); + const $last = state.doc.resolve(lastCellPos); + return CellSelection.rowSelection($first, $last); +} diff --git a/apps/client/src/features/editor/components/table/handle/lib/sort-cells.ts b/apps/client/src/features/editor/components/table/handle/lib/sort-cells.ts new file mode 100644 index 000000000..ffd039c2b --- /dev/null +++ b/apps/client/src/features/editor/components/table/handle/lib/sort-cells.ts @@ -0,0 +1,57 @@ +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +export type SortDirection = "asc" | "desc"; + +export interface SortableItem { + payload: T; + text: string; + isHeader: boolean; + isEmpty: boolean; + originalOrder: number; +} + +const HEADER_TYPE_NAMES = new Set(["tableHeader", "table_header"]); + +export function isHeaderCell(node: ProseMirrorNode): boolean { + if (HEADER_TYPE_NAMES.has(node.type.name)) return true; + return node.attrs?.header === true; +} + +export function getCellSortText(node: ProseMirrorNode): string { + let text = ""; + node.descendants((child) => { + if (child.isText) text += child.text ?? ""; + return true; + }); + return text.trim().toLowerCase(); +} + +export function isCellEmpty(node: ProseMirrorNode): boolean { + return getCellSortText(node) === ""; +} + +export const collator = new Intl.Collator(undefined, { + sensitivity: "base", + numeric: true, +}); + +export function sortItems( + data: SortableItem[], + direction: SortDirection, +): SortableItem[] { + return [...data].sort((a, b) => { + if (a.isEmpty && !b.isEmpty) return 1; + if (!a.isEmpty && b.isEmpty) return -1; + if (a.isEmpty && b.isEmpty) return a.originalOrder - b.originalOrder; + const cmp = collator.compare(a.text, b.text); + return direction === "asc" ? cmp : -cmp; + }); +} + +export function weaveItems( + all: SortableItem[], + sortedData: SortableItem[], +): SortableItem[] { + const dataQueue = [...sortedData]; + return all.map((item) => (item.isHeader ? item : dataQueue.shift()!)); +} diff --git a/apps/client/src/features/editor/components/table/handle/menus/alignment-submenu.tsx b/apps/client/src/features/editor/components/table/handle/menus/alignment-submenu.tsx new file mode 100644 index 000000000..c58f5a967 --- /dev/null +++ b/apps/client/src/features/editor/components/table/handle/menus/alignment-submenu.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import type { Editor } from "@tiptap/react"; +import { Menu } from "@mantine/core"; +import { + IconAlignCenter, + IconAlignLeft, + IconAlignRight, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; + +interface AlignmentSubmenuProps { + editor: Editor; +} + +export const AlignmentSubmenu = React.memo(function AlignmentSubmenu({ + editor, +}: AlignmentSubmenuProps) { + const { t } = useTranslation(); + + return ( + + + }> + {t("Text alignment")} + + + + } + onClick={() => editor.chain().focus().setTextAlign("left").run()} + > + {t("Align left")} + + } + onClick={() => editor.chain().focus().setTextAlign("center").run()} + > + {t("Align center")} + + } + onClick={() => editor.chain().focus().setTextAlign("right").run()} + > + {t("Align right")} + + + + ); +}); diff --git a/apps/client/src/features/editor/components/table/handle/menus/cell-chevron-menu.tsx b/apps/client/src/features/editor/components/table/handle/menus/cell-chevron-menu.tsx new file mode 100644 index 000000000..84f904ca7 --- /dev/null +++ b/apps/client/src/features/editor/components/table/handle/menus/cell-chevron-menu.tsx @@ -0,0 +1,154 @@ +import React from "react"; +import type { Editor } from "@tiptap/react"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { ColorSwatch, Menu } from "@mantine/core"; +import { + IconBoxMargin, + IconColumnInsertRight, + IconColumnRemove, + IconEraser, + IconPalette, + IconRowInsertBottom, + IconRowRemove, + IconSquareToggle, + IconTableRow, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useTableClear } from "../hooks/use-table-clear"; +import { TABLE_COLORS } from "../../table-background-color"; +import { AlignmentSubmenu } from "./alignment-submenu"; + +interface CellChevronMenuProps { + editor: Editor; + cellPos: number; + tableNode: ProseMirrorNode; + tablePos: number; +} + +export const CellChevronMenu = React.memo(function CellChevronMenu({ + editor, + cellPos, + tableNode, + tablePos, +}: CellChevronMenuProps) { + const { t } = useTranslation(); + + const clearCell = useTableClear(editor, tableNode, tablePos, { + kind: "cell", + cellPos, + }); + + const setBackground = (color: string, name: string) => { + editor + .chain() + .focus() + .updateAttributes("tableCell", { + backgroundColor: color || null, + backgroundColorName: color ? name : null, + }) + .updateAttributes("tableHeader", { + backgroundColor: color || null, + backgroundColorName: color ? name : null, + }) + .run(); + }; + + return ( + <> + + + }> + {t("Background color")} + + + +
+ {TABLE_COLORS.map((c) => ( + + ))} +
+
+
+ + + + } + onClick={() => editor.chain().focus().mergeCells().run()} + disabled={!editor.can().mergeCells()} + > + {t("Merge cells")} + + } + onClick={() => editor.chain().focus().splitCell().run()} + disabled={!editor.can().splitCell()} + > + {t("Split cell")} + + } + onClick={() => editor.chain().focus().toggleHeaderCell().run()} + > + {t("Toggle header cell")} + + + + + } + onClick={() => editor.chain().focus().addColumnAfter().run()} + > + {t("Add column right")} + + } + onClick={() => editor.chain().focus().addRowAfter().run()} + > + {t("Add row below")} + + + } onClick={clearCell}> + {t("Clear cell")} + + } + onClick={() => editor.chain().focus().deleteColumn().run()} + > + {t("Delete column")} + + } + onClick={() => editor.chain().focus().deleteRow().run()} + > + {t("Delete row")} + + + ); +}); diff --git a/apps/client/src/features/editor/components/table/handle/menus/column-handle-menu.tsx b/apps/client/src/features/editor/components/table/handle/menus/column-handle-menu.tsx new file mode 100644 index 000000000..8dbe9d326 --- /dev/null +++ b/apps/client/src/features/editor/components/table/handle/menus/column-handle-menu.tsx @@ -0,0 +1,177 @@ +import React from "react"; +import type { Editor } from "@tiptap/react"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { ColorSwatch, Menu } from "@mantine/core"; +import { TABLE_COLORS } from "../../table-background-color"; +import { + IconArrowLeft, + IconArrowRight, + IconColumnInsertLeft, + IconColumnInsertRight, + IconColumnRemove, + IconEraser, + IconPalette, + IconSortAscendingLetters, + IconSortDescendingLetters, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useTableMoveRowColumn } from "../hooks/use-table-move-row-column"; +import { useTableClear } from "../hooks/use-table-clear"; +import { useTableSort } from "../hooks/use-table-sort"; +import { AlignmentSubmenu } from "./alignment-submenu"; + +interface ColumnHandleMenuProps { + editor: Editor; + index: number; + tableNode: ProseMirrorNode; + tablePos: number; +} + +export const ColumnHandleMenu = React.memo(function ColumnHandleMenu({ + editor, + index, + tableNode, + tablePos, +}: ColumnHandleMenuProps) { + const { t } = useTranslation(); + + const moveLeft = useTableMoveRowColumn(editor, "col", index, "left", tableNode, tablePos); + const moveRight = useTableMoveRowColumn(editor, "col", index, "right", tableNode, tablePos); + const clearCol = useTableClear(editor, tableNode, tablePos, { + kind: "col", + index, + }); + + const setBackground = (color: string, name: string) => { + editor + .chain() + .focus() + .updateAttributes("tableCell", { + backgroundColor: color || null, + backgroundColorName: color ? name : null, + }) + .updateAttributes("tableHeader", { + backgroundColor: color || null, + backgroundColorName: color ? name : null, + }) + .run(); + }; + + const sortAsc = useTableSort({ + editor, + orientation: "col", + index, + tableNode, + tablePos, + direction: "asc", + }); + const sortDesc = useTableSort({ + editor, + orientation: "col", + index, + tableNode, + tablePos, + direction: "desc", + }); + + return ( + <> + } + onClick={sortAsc.handleSort} + disabled={!sortAsc.canSort} + > + {t("Sort A → Z")} + + } + onClick={sortDesc.handleSort} + disabled={!sortDesc.canSort} + > + {t("Sort Z → A")} + + + + + + }> + {t("Background color")} + + + +
+ {TABLE_COLORS.map((c) => ( + + ))} +
+
+
+ + + + + + } + onClick={() => editor.chain().focus().addColumnBefore().run()} + > + {t("Add column left")} + + } + onClick={() => editor.chain().focus().addColumnAfter().run()} + > + {t("Add column right")} + + + + + } + onClick={clearCol} + > + {t("Clear cells")} + + } + onClick={() => editor.chain().focus().deleteColumn().run()} + > + {t("Delete column")} + + + + + } + onClick={moveLeft.handleMove} + disabled={!moveLeft.canMove} + > + {t("Move column left")} + + } + onClick={moveRight.handleMove} + disabled={!moveRight.canMove} + > + {t("Move column right")} + + + ); +}); diff --git a/apps/client/src/features/editor/components/table/handle/menus/row-handle-menu.tsx b/apps/client/src/features/editor/components/table/handle/menus/row-handle-menu.tsx new file mode 100644 index 000000000..13b968b76 --- /dev/null +++ b/apps/client/src/features/editor/components/table/handle/menus/row-handle-menu.tsx @@ -0,0 +1,138 @@ +import React from "react"; +import type { Editor } from "@tiptap/react"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { ColorSwatch, Menu } from "@mantine/core"; +import { TABLE_COLORS } from "../../table-background-color"; +import { + IconArrowDown, + IconArrowUp, + IconEraser, + IconPalette, + IconRowInsertBottom, + IconRowInsertTop, + IconRowRemove, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useTableMoveRowColumn } from "../hooks/use-table-move-row-column"; +import { useTableClear } from "../hooks/use-table-clear"; +import { AlignmentSubmenu } from "./alignment-submenu"; + +interface RowHandleMenuProps { + editor: Editor; + index: number; + tableNode: ProseMirrorNode; + tablePos: number; +} + +export const RowHandleMenu = React.memo(function RowHandleMenu({ + editor, + index, + tableNode, + tablePos, +}: RowHandleMenuProps) { + const { t } = useTranslation(); + + const setBackground = (color: string, name: string) => { + editor + .chain() + .focus() + .updateAttributes("tableCell", { + backgroundColor: color || null, + backgroundColorName: color ? name : null, + }) + .updateAttributes("tableHeader", { + backgroundColor: color || null, + backgroundColorName: color ? name : null, + }) + .run(); + }; + + const moveUp = useTableMoveRowColumn(editor, "row", index, "up", tableNode, tablePos); + const moveDown = useTableMoveRowColumn(editor, "row", index, "down", tableNode, tablePos); + const clearRow = useTableClear(editor, tableNode, tablePos, { + kind: "row", + index, + }); + + return ( + <> + + + }> + {t("Background color")} + + + +
+ {TABLE_COLORS.map((c) => ( + + ))} +
+
+
+ + + + + + } + onClick={() => editor.chain().focus().addRowBefore().run()} + > + {t("Add row above")} + + } + onClick={() => editor.chain().focus().addRowAfter().run()} + > + {t("Add row below")} + + + + + } onClick={clearRow}> + {t("Clear cells")} + + } + onClick={() => editor.chain().focus().deleteRow().run()} + > + {t("Delete row")} + + + + + } + onClick={moveUp.handleMove} + disabled={!moveUp.canMove} + > + {t("Move row up")} + + } + onClick={moveDown.handleMove} + disabled={!moveDown.canMove} + > + {t("Move row down")} + + + ); +}); diff --git a/apps/client/src/features/editor/components/table/handle/row-handle.tsx b/apps/client/src/features/editor/components/table/handle/row-handle.tsx new file mode 100644 index 000000000..1f3e3cc51 --- /dev/null +++ b/apps/client/src/features/editor/components/table/handle/row-handle.tsx @@ -0,0 +1,127 @@ +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 { RowHandleMenu } from "./menus/row-handle-menu"; +import classes from "./handle.module.css"; + +interface RowHandleProps { + editor: Editor; + index: number; + anchorPos: number; + tableNode: ProseMirrorNode; + tablePos: number; +} + +export const RowHandle = React.memo(function RowHandle({ + editor, + index, + anchorPos, + tableNode, + tablePos, +}: RowHandleProps) { + const { t } = useTranslation(); + // See ColumnHandle for the rationale: keep the last valid cell DOM cached + // so the handle div stays mounted across stale-anchor renders, otherwise + // pragmatic-dnd silently aborts an in-flight 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(lookupCellDom); + const lastCellDomRef = useRef(lookupCellDom); + useEffect(() => { + if (lookupCellDom && lookupCellDom !== lastCellDomRef.current) { + lastCellDomRef.current = lookupCellDom; + setCellDom(lookupCellDom); + } + }, [lookupCellDom]); + + const [handleEl, setHandleEl] = useState(null); + + const { refs, floatingStyles, middlewareData } = useFloating({ + placement: "left", + middleware: [offset(-4), hide()], + whileElementsMounted: autoUpdate, + }); + const isReferenceHidden = !!middlewareData.hide?.referenceHidden; + + useEffect(() => { + refs.setReference(cellDom); + }, [cellDom, refs]); + + const wrapper = cellDom?.closest(".tableWrapper") ?? null; + + const [menuOpened, setMenuOpened] = useState(false); + const closeMenu = useCallback(() => setMenuOpened(false), []); + useTableHandleDrag(editor, "row", handleEl, wrapper, closeMenu); + + const { onOpen, onClose } = useColumnRowMenuLifecycle({ + editor, + orientation: "row", + index, + tableNode, + tablePos, + }); + + if (!cellDom) return null; + + return ( + + +
{ + refs.setFloating(node); + setHandleEl(node); + }} + style={{ + ...floatingStyles, + ...(isReferenceHidden ? { visibility: "hidden" as const } : {}), + }} + className={clsx(classes.handle, classes.rowHandle)} + role="button" + tabIndex={0} + aria-label={t("Row actions")} + > + + + +
+
+ + + +
+ ); +}); + +function GripIcon() { + return ( + + + + ); +} diff --git a/apps/client/src/features/editor/components/table/handle/table-handles-layer.tsx b/apps/client/src/features/editor/components/table/handle/table-handles-layer.tsx new file mode 100644 index 000000000..e40c7baac --- /dev/null +++ b/apps/client/src/features/editor/components/table/handle/table-handles-layer.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import type { Editor } from "@tiptap/react"; +import { useTableHandleState } from "./hooks/use-table-handle-state"; +import { ColumnHandle } from "./column-handle"; +import { RowHandle } from "./row-handle"; +import { CellChevron } from "./cell-chevron"; + +interface TableHandlesLayerProps { + editor: Editor | null; +} + +export const TableHandlesLayer = React.memo(function TableHandlesLayer({ + editor, +}: TableHandlesLayerProps) { + const state = useTableHandleState(editor); + + if (!editor || !editor.isEditable) return null; + if (!state.hoveringCell || !state.tableNode || state.tablePos == null) return null; + + return ( + <> + + + + + ); +}); diff --git a/apps/client/src/features/editor/components/table/table-background-color.tsx b/apps/client/src/features/editor/components/table/table-background-color.tsx index 3e4ce6168..c0df52d81 100644 --- a/apps/client/src/features/editor/components/table/table-background-color.tsx +++ b/apps/client/src/features/editor/components/table/table-background-color.tsx @@ -22,7 +22,7 @@ interface TableBackgroundColorProps { editor: Editor | null; } -const TABLE_COLORS: TableColorItem[] = [ +export const TABLE_COLORS: TableColorItem[] = [ { name: "Default", color: "" }, { name: "Blue", color: "#b4d5ff" }, { name: "Green", color: "#acf5d2" }, diff --git a/apps/client/src/features/editor/components/table/table-menu.tsx b/apps/client/src/features/editor/components/table/table-menu.tsx index 4adafb206..92cc318e9 100644 --- a/apps/client/src/features/editor/components/table/table-menu.tsx +++ b/apps/client/src/features/editor/components/table/table-menu.tsx @@ -18,7 +18,7 @@ import { IconTrashX, } from "@tabler/icons-react"; import { BubbleMenu } from "@tiptap/react/menus"; -import { isCellSelection, isTextSelected } from "@docmost/editor-ext"; +import { isCellSelection, isEditorReady, isTextSelected } from "@docmost/editor-ext"; import { useTranslation } from "react-i18next"; import classes from "../common/toolbar-menu.module.css"; @@ -38,6 +38,7 @@ export const TableMenu = React.memo( ); const getReferencedVirtualElement = useCallback(() => { + if (!isEditorReady(editor)) return; const { selection } = editor.state; const predicate = (node: PMNode) => node.type.name === "table"; const parent = findParentNode(predicate)(selection); @@ -104,12 +105,12 @@ export const TableMenu = React.memo( element.style.zIndex = "99"; }} options={{ - placement: "top", + placement: "bottom", offset: { mainAxis: 15, }, flip: { - fallbackPlacements: ["top", "bottom"], + fallbackPlacements: ["bottom", "top"], padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity }, boundary: editor.options.element as HTMLElement, }, diff --git a/apps/client/src/features/editor/components/table/table-text-alignment.tsx b/apps/client/src/features/editor/components/table/table-text-alignment.tsx index 4d4646cf5..17ef7c42e 100644 --- a/apps/client/src/features/editor/components/table/table-text-alignment.tsx +++ b/apps/client/src/features/editor/components/table/table-text-alignment.tsx @@ -86,11 +86,11 @@ export const TableTextAlignment: FC = ({ editor }) => { transitionProps={{ transition: "pop" }} > - + setOpened(!opened)} > diff --git a/apps/client/src/features/editor/components/transclusion/transclusion-reference-view.tsx b/apps/client/src/features/editor/components/transclusion/transclusion-reference-view.tsx index 490e179b2..e50793149 100644 --- a/apps/client/src/features/editor/components/transclusion/transclusion-reference-view.tsx +++ b/apps/client/src/features/editor/components/transclusion/transclusion-reference-view.tsx @@ -35,6 +35,7 @@ export default function TransclusionReferenceView(props: NodeViewProps) { return ( 0 ? "true" : "false"} contentEditable={false} diff --git a/apps/client/src/features/editor/components/transclusion/transclusion-view.tsx b/apps/client/src/features/editor/components/transclusion/transclusion-view.tsx index c27024472..82997f5d5 100644 --- a/apps/client/src/features/editor/components/transclusion/transclusion-view.tsx +++ b/apps/client/src/features/editor/components/transclusion/transclusion-view.tsx @@ -62,6 +62,7 @@ export default function TransclusionView(props: NodeViewProps) { return ( 0 ? "true" : "false"} data-id={transclusionId ?? undefined} > diff --git a/apps/client/src/features/editor/components/transclusion/transclusion.module.css b/apps/client/src/features/editor/components/transclusion/transclusion.module.css index 2fb5f7547..4d8d321a1 100644 --- a/apps/client/src/features/editor/components/transclusion/transclusion.module.css +++ b/apps/client/src/features/editor/components/transclusion/transclusion.module.css @@ -44,8 +44,29 @@ transition: border 0.3s; } -.transclusionWrap:hover, -.transclusionWrap:focus-within { +.transclusionWrap[data-editable="false"], +.includeWrap[data-editable="false"] { + margin-left: 0; + margin-right: 0; + width: 100%; + padding: 0; +} + +/* Cancel the wrapping .react-renderer's vertical spacing in read-only mode + so the synced block sits flush with surrounding paragraphs (whose own + margins already provide the right rhythm). */ +:global(.react-renderer.node-transclusionSource):has( + .transclusionWrap[data-editable="false"] + ), +:global(.react-renderer.node-transclusionReference):has( + .includeWrap[data-editable="false"] + ) { + margin-top: 0; + margin-bottom: 0; +} + +.transclusionWrap[data-editable="true"]:hover, +.transclusionWrap[data-editable="true"]:focus-within { border: 2px solid light-dark( var(--mantine-color-orange-2), @@ -114,9 +135,9 @@ transition: border 0.3s; } -.includeWrap:hover, -.includeWrap[data-focused="true"], -.includeWrap[data-menu-open="true"] { +.includeWrap[data-editable="true"]:hover, +.includeWrap[data-editable="true"][data-focused="true"], +.includeWrap[data-editable="true"][data-menu-open="true"] { border: 2px solid light-dark( var(--mantine-color-orange-2), diff --git a/apps/client/src/features/editor/components/video/video-menu.tsx b/apps/client/src/features/editor/components/video/video-menu.tsx index 3f232625f..429e02f87 100644 --- a/apps/client/src/features/editor/components/video/video-menu.tsx +++ b/apps/client/src/features/editor/components/video/video-menu.tsx @@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { useCallback } from "react"; import { Node as PMNode } from "@tiptap/pm/model"; +import { isEditorReady } from "@docmost/editor-ext"; import { EditorMenuProps, ShouldShowProps, @@ -53,7 +54,7 @@ export function VideoMenu({ editor }: EditorMenuProps) { ); const getReferencedVirtualElement = useCallback(() => { - if (!editor) return; + if (!isEditorReady(editor)) return; const { selection } = editor.state; const predicate = (node: PMNode) => node.type.name === "video"; const parent = findParentNode(predicate)(selection); diff --git a/apps/client/src/features/editor/extensions/drag-handle.ts b/apps/client/src/features/editor/extensions/drag-handle.ts index a4843ed67..6b10678a1 100644 --- a/apps/client/src/features/editor/extensions/drag-handle.ts +++ b/apps/client/src/features/editor/extensions/drag-handle.ts @@ -60,6 +60,23 @@ function nodeDOMAtCoords( options: GlobalDragHandleOptions, view: EditorView, ) { + // Custom nodes (transclusion, …) render via tiptap's React node-view + // renderer, which emits `class="react-renderer node-${name}"` on the + // live wrapper — the `data-type` attribute is for static HTML + // serialization only. Match both so we cover live and parsed DOM. + // Inside a custom node, also match plain `p` so the first paragraph + // (which doesn't match `:not(:first-child)`) still gets its own + // handle; only hovers on the custom node's padding/border fall + // through to the wrapper. + const customSelectors = options.customNodes.flatMap((node) => [ + `[data-type=${node}]`, + `.node-${node}`, + ]); + const customParagraphSelectors = options.customNodes.flatMap((node) => [ + `[data-type=${node}] p`, + `.node-${node} p`, + ]); + const selectors = [ "li", "p:not(:first-child)", @@ -71,7 +88,13 @@ function nodeDOMAtCoords( "h4", "h5", "h6", - ...options.customNodes.map((node) => `[data-type=${node}]`), + // Tables nested in another block (toggle, transclusion, …) have a + // wrapper that isn't a direct child of .ProseMirror, so the + // parent-check below skips it. Match the wrapper explicitly so the + // handle shows up even with empty cells. + ".tableWrapper", + ...customParagraphSelectors, + ...customSelectors, ].join(", "); return document .elementsFromPoint(coords.x, coords.y) @@ -99,6 +122,22 @@ function nodePosAtDOM( })?.inside; } +function isCustomNodeDOM( + elem: Element | null | undefined, + options: GlobalDragHandleOptions, +): boolean { + if (!elem) return false; + for (const name of options.customNodes) { + if ( + elem.getAttribute("data-type") === name || + elem.classList.contains(`node-${name}`) + ) { + return true; + } + } + return false; +} + function calcNodePos(pos: number, view: EditorView) { const $pos = view.state.doc.resolve(pos); if ($pos.depth > 1) return $pos.before($pos.depth); @@ -137,7 +176,6 @@ export function DragHandlePlugin( const nodePos = view.state.doc.resolve(fromSelectionPos); - // Check if nodePos points to the top level node if (nodePos.node().type.name === "doc") differentNodeSelected = true; else { const nodeSelection = NodeSelection.create( @@ -166,14 +204,46 @@ export function DragHandlePlugin( } else { selection = NodeSelection.create(view.state.doc, draggedNodePos); - // if inline node is selected, e.g mention -> go to the parent node to select the whole node - // if table row is selected, go to the parent node to select the whole node - if ( - (selection as NodeSelection).node.type.isInline || - (selection as NodeSelection).node.type.name === "tableRow" - ) { - let $pos = view.state.doc.resolve(selection.from); - selection = NodeSelection.create(view.state.doc, $pos.before()); + const $sel = view.state.doc.resolve(selection.from); + + if (isCustomNodeDOM(node, options)) { + // The drag landed on a custom-node container (transclusion etc.). + // Walk up to the matching node so the drag moves the whole + // container, not whatever inner element the click landed on. + const customTypes = new Set(options.customNodes); + for (let d = $sel.depth; d > 0; d--) { + if (customTypes.has($sel.node(d).type.name)) { + selection = NodeSelection.create( + view.state.doc, + $sel.before(d), + ); + break; + } + } + } else { + // If the selected node lives inside a table (at any nesting + // depth), promote to the whole table — the global drag handle is + // meant to move the table as a single block, not a row/cell. The + // earlier tableRow-only check only worked when the table sat at + // the doc root; once wrapped in another node (toggle, layout, + // etc.) the selection lands on a cell/paragraph and that check + // never fired. + let tableDepth = -1; + for (let d = $sel.depth; d > 0; d--) { + if ($sel.node(d).type.name === "table") { + tableDepth = d; + break; + } + } + if (tableDepth > 0) { + selection = NodeSelection.create( + view.state.doc, + $sel.before(tableDepth), + ); + } else if ((selection as NodeSelection).node.type.isInline) { + // Inline node (e.g. mention): walk up to the parent block. + selection = NodeSelection.create(view.state.doc, $sel.before()); + } } } view.dispatch(view.state.tr.setSelection(selection)); @@ -313,6 +383,27 @@ export function DragHandlePlugin( return; } + const isCustomNode = isCustomNodeDOM(node, options); + + // Custom nodes pin the handle to the inner NodeViewWrapper's top-left: + // the natural anchor sits in transient/empty space outside the visible block. + if (isCustomNode) { + // tiptap React node-views emit an outer `.react-renderer` whose first + // child is the visible NodeViewWrapper; walk to that outer first since + // `node` may be either the outer or an inner element with data-type. + const rendererOuter = + (node.closest(".react-renderer") as HTMLElement | null) ?? node; + const inner = + (rendererOuter.firstElementChild as HTMLElement | null) ?? + rendererOuter; + const innerRect = absoluteRect(inner); + if (!dragHandleElement) return; + dragHandleElement.style.left = `${innerRect.left + 4}px`; + dragHandleElement.style.top = `${innerRect.top + 4}px`; + showDragHandle(); + return; + } + const compStyle = window.getComputedStyle(node); const parsedLineHeight = parseInt(compStyle.lineHeight, 10); const lineHeight = isNaN(parsedLineHeight) @@ -328,6 +419,13 @@ export function DragHandlePlugin( if (node.matches("ul:not([data-type=taskList]) li, ol li")) { rect.left -= options.dragHandleWidth; } + // Tables: clear the table's own row-drag handle so the two + // grips don't stack on each other. `nodeDOMAtCoords` returns + // the wrapper for top-level hovers (wrapper is direct child of + // .ProseMirror) and a descendant for deeper hovers — cover both. + if (node.closest(".tableWrapper")) { + rect.left -= options.dragHandleWidth; + } rect.width = options.dragHandleWidth; if (!dragHandleElement) return; diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 0dd78eae6..ef3127c65 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -42,9 +42,13 @@ import { Excalidraw, Embed, TiptapPdf, + PageBreak, SearchAndReplace, Mention, TableDndExtension, + TableHandleCommandsExtension, + TableHeaderPin, + TableReadonlySort, Subpages, Heading, Highlight, @@ -56,6 +60,7 @@ import { Status, TransclusionSource, TransclusionReference, + TableView, } from "@docmost/editor-ext"; import { randomElement, @@ -80,7 +85,7 @@ import AudioView from "@/features/editor/components/audio/audio-view.tsx"; import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx"; import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx"; import DrawioView from "../components/drawio/drawio-view"; -import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx"; +import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx"; import EmbedView from "@/features/editor/components/embed/embed-view.tsx"; import PdfView from "@/features/editor/components/pdf/pdf-view.tsx"; import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx"; @@ -259,11 +264,16 @@ export const mainExtensions = [ resizable: true, lastColumnResizable: true, allowTableNodeSelection: true, + cellMinWidth: 49, + View: TableView, }), TableRow, TableCell, TableHeader, TableDndExtension, + TableHandleCommandsExtension, + TableHeaderPin, + TableReadonlySort, MathInline.configure({ view: MathInlineView, }), @@ -357,6 +367,7 @@ export const mainExtensions = [ TiptapPdf.configure({ view: PdfView, }), + PageBreak, Subpages.configure({ view: SubpagesView, }), diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx index 69bf2628f..412a3b3de 100644 --- a/apps/client/src/features/editor/full-editor.tsx +++ b/apps/client/src/features/editor/full-editor.tsx @@ -1,5 +1,5 @@ import classes from "@/features/editor/styles/editor.module.css"; -import React from "react"; +import React, { useEffect } from "react"; import { TitleEditor } from "@/features/editor/title-editor"; import PageEditor from "@/features/editor/page-editor"; import { @@ -23,17 +23,25 @@ import { IContributor } from "@/features/page/types/page.types.ts"; import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar"; import { PageEditMode } from "@/features/user/types/user.types.ts"; import useToggleAside from "@/hooks/use-toggle-aside.tsx"; +import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx"; import clsx from "clsx"; +import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts"; const MemoizedTitleEditor = React.memo(TitleEditor); const MemoizedPageEditor = React.memo(PageEditor); +const MemoizedFixedToolbar = React.memo(FixedToolbar); +const MemoizedDeletedPageBanner = React.memo(DeletedPageBanner); -type PageCreator = { +type PageUser = { id: string; name: string; avatarUrl: string; }; +// Module-level flag: survives component unmount/remount on page navigation, +// reset only on full page reload (i.e. a new app session). +let defaultEditModeApplied = false; + export interface FullEditorProps { pageId: string; slugId: string; @@ -41,7 +49,7 @@ export interface FullEditorProps { content: string; spaceSlug: string; editable: boolean; - creator?: PageCreator; + creator?: PageUser; contributors?: IContributor[]; canComment?: boolean; } @@ -61,9 +69,21 @@ export function FullEditor({ const fullPageWidth = user.settings?.preferences?.fullPageWidth; const editorToolbarEnabled = user.settings?.preferences?.editorToolbar ?? false; + const [currentPageEditMode, setCurrentPageEditMode] = useAtom( + currentPageEditModeAtom, + ); const userPageEditMode = user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; - const isEditMode = userPageEditMode === PageEditMode.Edit; + const isEditMode = currentPageEditMode === PageEditMode.Edit; + + // Apply the user's saved preference only once on initial load, not on every + // page navigation — so the mode sticks across navigations within a session. + useEffect(() => { + if (!defaultEditModeApplied) { + setCurrentPageEditMode(userPageEditMode as PageEditMode); + defaultEditModeApplied = true; + } + }, [userPageEditMode, setCurrentPageEditMode]); return ( - {editorToolbarEnabled && editable && isEditMode && } + {editorToolbarEnabled && editable && isEditMode && ( + + )} + Boolean(isComponentMounted.current && editorRef.current), [isComponentMounted], @@ -372,19 +373,9 @@ export default function PageEditor({ return () => clearTimeout(timeout); }, [yjsConnectionStatus, isSynced]); useEffect(() => { - // Only honor user default page edit mode preference and permissions - if (editor) { - if (userPageEditMode && editable) { - if (userPageEditMode === PageEditMode.Edit) { - editor.setEditable(true); - } else if (userPageEditMode === PageEditMode.Read) { - editor.setEditable(false); - } - } else { - editor.setEditable(false); - } - } - }, [userPageEditMode, editor, editable]); + if (!editor) return; + editor.setEditable(editable && currentPageEditMode === PageEditMode.Edit); + }, [currentPageEditMode, editor, editable]); const hasConnectedOnceRef = useRef(false); const [showStatic, setShowStatic] = useState(true); @@ -424,7 +415,7 @@ export default function PageEditor({ - + diff --git a/apps/client/src/features/editor/styles/core.css b/apps/client/src/features/editor/styles/core.css index 34ddaca3c..077570fb5 100644 --- a/apps/client/src/features/editor/styles/core.css +++ b/apps/client/src/features/editor/styles/core.css @@ -203,7 +203,8 @@ } } - .resize-cursor { + &.resize-cursor, + &.resize-cursor * { cursor: ew-resize; cursor: col-resize; } diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css index 7abfe1086..52d9268e1 100644 --- a/apps/client/src/features/editor/styles/index.css +++ b/apps/client/src/features/editor/styles/index.css @@ -9,6 +9,7 @@ @import "./media.css"; @import "./code.css"; @import "./print.css"; +@import "./page-break.css"; @import "./find.css"; @import "./mention.css"; @import "./ordered-list.css"; diff --git a/apps/client/src/features/editor/styles/page-break.css b/apps/client/src/features/editor/styles/page-break.css new file mode 100644 index 000000000..6dc97c738 --- /dev/null +++ b/apps/client/src/features/editor/styles/page-break.css @@ -0,0 +1,50 @@ +.ProseMirror .page-break { + position: relative; + margin: 1.5rem 0; + border-top: 1px dashed var(--mantine-color-default-border); + height: 0; + user-select: none; +} + +.ProseMirror[contenteditable="false"] .page-break { + margin: 0; + border: none; + height: 0; +} + +.ProseMirror[contenteditable="false"] .page-break::after { + content: none; +} + +.ProseMirror .page-break::after { + content: "Page break"; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 0 0.5rem; + background: var(--mantine-color-body); + color: var(--mantine-color-dimmed); + font-size: 0.75rem; + line-height: 1; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.ProseMirror .page-break.ProseMirror-selectednode { + border-top-color: var(--mantine-primary-color-filled); +} + +@media print { + .ProseMirror .page-break { + break-before: always; + page-break-before: always; + visibility: hidden; + border: none; + margin: 0; + } + + .ProseMirror .page-break::after { + content: none; + } +} diff --git a/apps/client/src/features/editor/styles/table.css b/apps/client/src/features/editor/styles/table.css index 9926d0bc0..5d802e4ab 100644 --- a/apps/client/src/features/editor/styles/table.css +++ b/apps/client/src/features/editor/styles/table.css @@ -15,7 +15,8 @@ } .table-dnd-drop-indicator { - background-color: #adf; + background-color: var(--mantine-color-blue-5); + z-index: 3; } .ProseMirror { @@ -57,13 +58,14 @@ } .column-resize-handle { - background-color: #adf; + background-color: var(--mantine-color-blue-5); bottom: -1px; position: absolute; - right: -2px; + right: -1px; pointer-events: none; top: 0; - width: 4px; + width: 2px; + z-index: 3; } .selectedCell:after { @@ -129,6 +131,139 @@ } } + +/* Header-row pinning. Two CSS paths, picked by `header-pin/controller.ts`: + - native sticky (preferred): wrapper drops its overflow constraint so + `position: sticky` on the row can resolve against the document scroll. + - transform fallback: wrapper keeps `overflow-x: auto` for horizontal + scrolling; the row is positioned imperatively per scroll frame. + + `--editor-pin-offset` is published to :root by `pinOffsetWatcher` in + `header-pin/offset.ts`, measured against the lowest fixed surface above + the editor (app shell header, page header, fixed toolbar). */ + +.tableWrapper.tableWrapperNoOverflow, +.tableWrapper.tableWrapperNoOverflow table { + overflow: visible; +} + +.tableWrapper.tableHeaderPinned table tr:first-child { + z-index: 2; +} + +.tableWrapper.tableWrapperNoOverflow.tableHeaderPinned table tr:first-child { + position: sticky; + top: var(--editor-pin-offset, 90px); +} + +.tableWrapper.tableHeaderPinned:not(.tableWrapperNoOverflow) table tr:first-child { + position: relative; + transform: translateY(var(--table-pin-offset, 0px)); +} + +@media print { + .tableWrapper.tableHeaderPinned table tr:first-child { + position: static; + transform: none; + } +} + +.tableReadonlySortChevron { + /* Anchor to the cell's right edge, vertically centered with the cell + content. The cell content (a

) is block-level so an inline chevron + would wrap to a new line; absolute positioning takes it out of flow. */ + position: absolute; + top: 50%; + right: 6px; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 4px; + background: light-dark( + rgba(55, 53, 47, 0.08), + rgba(255, 255, 255, 0.08) + ); + color: light-dark( + rgba(55, 53, 47, 0.55), + rgba(255, 255, 255, 0.55) + ); + user-select: none; + cursor: pointer; + z-index: 1; + /* Hidden by default; revealed on header-cell hover or when this column is + the active sort (see selectors below). */ + opacity: 0; + transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease; +} + +.ProseMirror table th:hover .tableReadonlySortChevron, +.tableReadonlySortChevron[data-sort] { + opacity: 1; +} + +.ProseMirror table th:has(.tableReadonlySortChevron) { + padding-right: 30px; +} + +.tableReadonlySortChevron:hover { + background: light-dark( + rgba(55, 53, 47, 0.16), + rgba(255, 255, 255, 0.16) + ); +} + +/* Immediate tooltip on the chevron — same style language as the rest of the + app (small, dark, rounded), unlike the native `title` tooltip which only + appears after a long delay. */ +.tableReadonlySortChevron::after { + content: attr(data-tooltip); + position: absolute; + /* Below the chevron — placing it above the cell hits the table's + overflow clipping (the wrapper has `overflow-x: auto` which forces + `overflow-y: auto` per spec). */ + top: calc(100% + 6px); + right: 0; + padding: 4px 8px; + border-radius: 4px; + background: var(--mantine-color-dark-7); + color: var(--mantine-color-white); + font-size: 12px; + font-weight: 400; + line-height: 1.4; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease; + z-index: 10; +} + +.tableReadonlySortChevron:hover::after { + opacity: 1; +} + +.tableReadonlySortChevron svg { + display: block; +} + +.tableReadonlySortChevron[data-sort="asc"], +.tableReadonlySortChevron[data-sort="desc"] { + background: light-dark( + var(--mantine-color-blue-1), + var(--mantine-color-blue-9) + ); + color: light-dark( + var(--mantine-color-blue-7), + var(--mantine-color-blue-2) + ); +} + +.tableReadonlySortChevron[data-sort="asc"] svg { + transform: rotate(180deg); +} + .editor-container:has(.table-dnd-drop-indicator[data-dragging="true"]) { .prosemirror-dropcursor-block { display: none; diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index e61d8c042..3ff2d7614 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -7,6 +7,7 @@ import { Text } from "@tiptap/extension-text"; import { Placeholder } from "@tiptap/extension-placeholder"; import { useAtomValue } from "jotai"; import { + currentPageEditModeAtom, pageEditorAtom, titleEditorAtom, } from "@/features/editor/atoms/editor-atoms"; @@ -24,7 +25,6 @@ import { useTranslation } from "react-i18next"; import EmojiCommand from "@/features/editor/extensions/emoji-command.ts"; import { UpdateEvent } from "@/features/websocket/types"; import localEmitter from "@/lib/local-emitter.ts"; -import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts"; import { searchSpotlight } from "@/features/search/constants.ts"; import { platformModifierKey } from "@/lib"; @@ -52,9 +52,7 @@ export function TitleEditor({ const emit = useQueryEmit(); const navigate = useNavigate(); const [activePageId, setActivePageId] = useState(pageId); - const [currentUser] = useAtom(currentUserAtom); - const userPageEditMode = - currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; + const currentPageEditMode = useAtomValue(currentPageEditModeAtom); const titleEditor = useEditor({ extensions: [ @@ -172,18 +170,9 @@ export function TitleEditor({ }, [pageId]); useEffect(() => { - if (titleEditor) { - if (userPageEditMode && editable) { - if (userPageEditMode === PageEditMode.Edit) { - titleEditor.setEditable(true); - } else if (userPageEditMode === PageEditMode.Read) { - titleEditor.setEditable(false); - } - } else { - titleEditor.setEditable(false); - } - } - }, [userPageEditMode, titleEditor, editable]); + if (!titleEditor) return; + titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit); + }, [currentPageEditMode, titleEditor, editable]); const openSearchDialog = () => { const event = new CustomEvent("openFindDialogFromEditor", {}); diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 81c25e825..75b113eaa 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -29,7 +29,7 @@ import { buildPageUrl } from "@/features/page/page.utils.ts"; import { notifications } from "@mantine/notifications"; import { getAppUrl } from "@/lib/config.ts"; import { extractPageSlugId } from "@/lib"; -import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; +import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx"; import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx"; import { Trans, useTranslation } from "react-i18next"; @@ -40,7 +40,7 @@ import { yjsConnectionStatusAtom, } from "@/features/editor/atoms/editor-atoms.ts"; import { formattedDate } from "@/lib/time.ts"; -import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx"; +import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; import { PageShareModal } from "@/ee/page-permission"; @@ -65,6 +65,11 @@ interface PageHeaderMenuProps { export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { const { t } = useTranslation(); const toggleAside = useToggleAside(); + const { pageSlug } = useParams(); + const { data: page } = usePageQuery({ + pageId: extractPageSlugId(pageSlug), + }); + const isDeleted = !!page?.deletedAt; useHotkeys( [ @@ -87,11 +92,15 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { [], ); + if (isDeleted) { + return null; + } + return ( <> - {!readOnly && } + {!readOnly && } @@ -134,7 +143,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { pageId: extractPageSlugId(pageSlug), }); const { openDeleteModal } = useDeletePageModal(); - const [tree] = useAtom(treeApiAtom); + const { handleDelete } = useTreeMutation(page?.spaceId ?? ""); const [exportOpened, { open: openExportModal, close: closeExportModal }] = useDisclosure(false); const [ @@ -183,7 +192,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { }; const handleDeletePage = () => { - openDeleteModal({ onConfirm: () => tree?.delete(page.id) }); + openDeleteModal({ onConfirm: () => handleDelete(page.id) }); }; const handleToggleFavorite = () => { diff --git a/apps/client/src/features/page/components/header/page-header.tsx b/apps/client/src/features/page/components/header/page-header.tsx index 12f131b8d..0614cf0bd 100644 --- a/apps/client/src/features/page/components/header/page-header.tsx +++ b/apps/client/src/features/page/components/header/page-header.tsx @@ -8,7 +8,7 @@ interface Props { } export default function PageHeader({ readOnly }: Props) { return ( -

+
diff --git a/apps/client/src/features/page/hooks/use-restore-page-modal.tsx b/apps/client/src/features/page/hooks/use-restore-page-modal.tsx new file mode 100644 index 000000000..f2089f37f --- /dev/null +++ b/apps/client/src/features/page/hooks/use-restore-page-modal.tsx @@ -0,0 +1,30 @@ +import { modals } from "@mantine/modals"; +import { Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +type UseRestoreModalProps = { + title?: string | null; + onConfirm: () => void; +}; + +export function useRestorePageModal() { + const { t } = useTranslation(); + const openRestoreModal = ({ title, onConfirm }: UseRestoreModalProps) => { + modals.openConfirmModal({ + title: t("Restore page"), + children: ( + + {t("Restore '{{title}}' and its sub-pages?", { + title: title || t("Untitled"), + })} + + ), + centered: true, + labels: { confirm: t("Restore"), cancel: t("Cancel") }, + confirmProps: { color: "blue" }, + onConfirm, + }); + }; + + return { openRestoreModal } as const; +} diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index 89526aa69..11ba7f32d 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -37,7 +37,7 @@ import { validate as isValidUuid } from "uuid"; import { useTranslation } from "react-i18next"; import { useAtom } from "jotai"; 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 { useQueryEmit } from "@/features/websocket/use-query-emit"; @@ -117,10 +117,20 @@ export function useUpdatePageMutation() { } export function useRemovePageMutation() { + const { t } = useTranslation(); return useMutation({ mutationFn: (pageId: string) => deletePage(pageId, false), onSuccess: (_, pageId) => { - notifications.show({ message: "Page moved to trash" }); + notifications.show({ message: t("Page moved to trash") }); + + // Stamp deletedAt so a re-visit shows the trash banner, not stale state. + const cached = queryClient.getQueryData(["pages", pageId]); + if (cached) { + const stamped = { ...cached, deletedAt: new Date() }; + queryClient.setQueryData(["pages", cached.id], stamped); + queryClient.setQueryData(["pages", cached.slugId], stamped); + } + invalidateOnDeletePage(pageId); queryClient.invalidateQueries({ predicate: (item) => @@ -128,7 +138,7 @@ export function useRemovePageMutation() { }); }, onError: (error) => { - notifications.show({ message: "Failed to delete page", color: "red" }); + notifications.show({ message: t("Failed to delete page"), color: "red" }); }, }); } @@ -162,19 +172,17 @@ export function useMovePageMutation() { } export function useRestorePageMutation() { + const { t } = useTranslation(); const [treeData, setTreeData] = useAtom(treeDataAtom); const emit = useQueryEmit(); return useMutation({ mutationFn: (pageId: string) => restorePage(pageId), onSuccess: async (restoredPage) => { - notifications.show({ message: "Page restored successfully" }); - - // Add the restored page back to the tree - const treeApi = new SimpleTree(treeData); + notifications.show({ message: t("Page restored successfully") }); // Check if the page already exists in the tree (it shouldn't) - if (!treeApi.find(restoredPage.id)) { + if (!treeModel.find(treeData, restoredPage.id)) { // Create the tree node data with hasChildren from backend const nodeData: SpaceTreeNode = { id: restoredPage.id, @@ -193,24 +201,17 @@ export function useRestorePageMutation() { let index = 0; if (parentId) { - const parentNode = treeApi.find(parentId); + const parentNode = treeModel.find(treeData, parentId); if (parentNode) { index = parentNode.children?.length || 0; } } else { // Root level page - index = treeApi.data.length; + index = treeData.length; } // Add the node to the tree - treeApi.create({ - parentId, - index, - data: nodeData, - }); - - // Update the tree data - setTreeData(treeApi.data); + setTreeData(treeModel.insert(treeData, parentId, nodeData, index)); // Emit websocket event to sync with other users setTimeout(() => { @@ -232,9 +233,16 @@ export function useRestorePageMutation() { await queryClient.invalidateQueries({ queryKey: ["trash-list", restoredPage.spaceId], }); + + // Merge — restore endpoint returns a skinny page; + // Replace would strip space/permissions/content and break the editor. + const merge = (cached: IPage | undefined) => + cached ? { ...cached, ...restoredPage } : cached; + queryClient.setQueryData(["pages", restoredPage.id], merge); + queryClient.setQueryData(["pages", restoredPage.slugId], merge); }, onError: (error) => { - notifications.show({ message: "Failed to restore page", color: "red" }); + notifications.show({ message: t("Failed to restore page"), color: "red" }); }, }); } diff --git a/apps/client/src/features/page/trash/components/deleted-page-banner.tsx b/apps/client/src/features/page/trash/components/deleted-page-banner.tsx new file mode 100644 index 000000000..f01a6ab49 --- /dev/null +++ b/apps/client/src/features/page/trash/components/deleted-page-banner.tsx @@ -0,0 +1,140 @@ +import { ActionIcon, Button, Group, Paper, Text, Tooltip } from "@mantine/core"; +import { IconRestore, IconTrash } from "@tabler/icons-react"; +import { useNavigate } from "react-router-dom"; +import { Trans, useTranslation } from "react-i18next"; +import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; +import { useRestorePageModal } from "@/features/page/hooks/use-restore-page-modal.tsx"; +import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx"; +import { + useDeletePageMutation, + usePageQuery, + useRestorePageMutation, +} from "@/features/page/queries/page-query.ts"; +import { getSpaceUrl } from "@/lib/config.ts"; +import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; +import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; +import { + SpaceCaslAction, + SpaceCaslSubject, +} from "@/features/space/permissions/permissions.type.ts"; + +type DeletedPageBannerProps = { + slugId: string; +}; + +export function DeletedPageBanner({ slugId }: DeletedPageBannerProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { data: page } = usePageQuery({ pageId: slugId }); + const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug); + const spaceAbility = useSpaceAbility(space?.membership?.permissions); + const deletedTimeAgo = useTimeAgo(page?.deletedAt); + const restorePageMutation = useRestorePageMutation(); + const deletePageMutation = useDeletePageMutation(); + const { openRestoreModal } = useRestorePageModal(); + const { openDeleteModal } = useDeletePageModal(); + + if (!page?.deletedAt) return null; + + const canRestore = spaceAbility.can( + SpaceCaslAction.Edit, + SpaceCaslSubject.Page, + ); + const canPermanentlyDelete = spaceAbility.can( + SpaceCaslAction.Manage, + SpaceCaslSubject.Settings, + ); + const actorName = page.deletedBy?.name ?? t("Someone"); + + const handleRestore = () => { + openRestoreModal({ + title: page.title, + onConfirm: () => restorePageMutation.mutate(page.id), + }); + }; + + const handlePermanentDelete = () => { + openDeleteModal({ + isPermanent: true, + onConfirm: async () => { + await deletePageMutation.mutateAsync(page.id); + navigate(getSpaceUrl(page.space?.slug)); + }, + }); + }; + + const hasAnyAction = canRestore || canPermanentlyDelete; + + return ( + + + + }} + /> + + {hasAnyAction && ( + <> + + {canRestore && ( + + )} + {canPermanentlyDelete && ( + + )} + + + {canRestore && ( + + + + + + )} + {canPermanentlyDelete && ( + + + + + + )} + + + )} + + + ); +} diff --git a/apps/client/src/features/page/trash/components/trash-banner.tsx b/apps/client/src/features/page/trash/components/trash-banner.tsx new file mode 100644 index 000000000..90ec4a066 --- /dev/null +++ b/apps/client/src/features/page/trash/components/trash-banner.tsx @@ -0,0 +1,21 @@ +import { Alert, Text } from "@mantine/core"; +import { IconInfoCircle } from "@tabler/icons-react"; +import { useAtomValue } from "jotai"; +import { useTranslation } from "react-i18next"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; + +export function TrashBanner() { + const { t } = useTranslation(); + const workspace = useAtomValue(workspaceAtom); + const retentionDays = workspace?.trashRetentionDays ?? 30; + + return ( + } variant="light" color="red"> + + {t("Pages in trash will be permanently deleted after {{count}} days.", { + count: retentionDays, + })} + + + ); +} diff --git a/apps/client/src/features/page/trash/components/trash.tsx b/apps/client/src/features/page/trash/components/trash.tsx index de7e7ca4a..da33d828f 100644 --- a/apps/client/src/features/page/trash/components/trash.tsx +++ b/apps/client/src/features/page/trash/components/trash.tsx @@ -7,17 +7,16 @@ import { Group, ActionIcon, Text, - Alert, Stack, Menu, } from "@mantine/core"; import { - IconInfoCircle, IconDots, IconRestore, IconTrash, IconFileDescription, } from "@tabler/icons-react"; +import { TrashBanner } from "@/features/page/trash/components/trash-banner.tsx"; import { useDeletedPagesQuery, useRestorePageMutation, @@ -31,12 +30,10 @@ import TrashPageContentModal from "@/features/page/trash/components/trash-page-c import { UserInfo } from "@/components/common/user-info.tsx"; import Paginate from "@/components/common/paginate.tsx"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; -import { useAtom } from "jotai"; -import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { useRestorePageModal } from "@/features/page/hooks/use-restore-page-modal.tsx"; export default function Trash() { const { t } = useTranslation(); - const [workspace] = useAtom(workspaceAtom); const { spaceSlug } = useParams(); const { cursor, goNext, goPrev } = useCursorPaginate(); const { data: space } = useGetSpaceBySlugQuery(spaceSlug); @@ -45,6 +42,7 @@ export default function Trash() { }); const restorePageMutation = useRestorePageMutation(); const deletePageMutation = useDeletePageMutation(); + const { openRestoreModal } = useRestorePageModal(); const [selectedPage, setSelectedPage] = useState<{ title: string; @@ -78,23 +76,6 @@ export default function Trash() { }); }; - const openRestoreModal = (pageId: string, pageTitle: string) => { - modals.openConfirmModal({ - title: t("Restore page"), - children: ( - - {t("Restore '{{title}}' and its sub-pages?", { - title: pageTitle || "Untitled", - })} - - ), - centered: true, - labels: { confirm: t("Restore"), cancel: t("Cancel") }, - confirmProps: { color: "blue" }, - onConfirm: () => handleRestorePage(pageId), - }); - }; - const hasPages = deletedPages && deletedPages.items.length > 0; const handlePageClick = (page: any) => { @@ -109,11 +90,7 @@ export default function Trash() { {t("Trash")} - } variant="light" color="red"> - - {t("Pages in trash will be permanently deleted after {{count}} days.", { count: workspace?.trashRetentionDays ?? 30 })} - - + {isLoading || !deletedPages ? ( <> @@ -181,7 +158,10 @@ export default function Trash() { } onClick={() => - openRestoreModal(page.id, page.title) + openRestoreModal({ + title: page.title, + onConfirm: () => handleRestorePage(page.id), + }) } > {t("Restore")} diff --git a/apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts b/apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts new file mode 100644 index 000000000..3dd2d98bc --- /dev/null +++ b/apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts @@ -0,0 +1,5 @@ +import { atom } from "jotai"; + +export type OpenMap = Record; + +export const openTreeNodesAtom = atom({}); diff --git a/apps/client/src/features/page/tree/atoms/tree-api-atom.ts b/apps/client/src/features/page/tree/atoms/tree-api-atom.ts deleted file mode 100644 index f12106f99..000000000 --- a/apps/client/src/features/page/tree/atoms/tree-api-atom.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { atom } from "jotai"; -import { TreeApi } from "react-arborist"; -import { SpaceTreeNode } from "../types"; - -export const treeApiAtom = atom | null>(null); diff --git a/apps/client/src/features/page/tree/components/doc-tree-drag-preview.module.css b/apps/client/src/features/page/tree/components/doc-tree-drag-preview.module.css new file mode 100644 index 000000000..acd0da016 --- /dev/null +++ b/apps/client/src/features/page/tree/components/doc-tree-drag-preview.module.css @@ -0,0 +1,26 @@ +.preview { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + background-color: light-dark( + var(--mantine-color-white), + var(--mantine-color-dark-6) + ); + color: light-dark( + var(--mantine-color-gray-9), + var(--mantine-color-dark-0) + ); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.18); + border: 1px solid light-dark( + var(--mantine-color-gray-3), + var(--mantine-color-dark-4) + ); + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/apps/client/src/features/page/tree/components/doc-tree-drag-preview.tsx b/apps/client/src/features/page/tree/components/doc-tree-drag-preview.tsx new file mode 100644 index 000000000..f8a8a88b1 --- /dev/null +++ b/apps/client/src/features/page/tree/components/doc-tree-drag-preview.tsx @@ -0,0 +1,9 @@ +import styles from './doc-tree-drag-preview.module.css'; + +type Props = { + label: string; +}; + +export function DocTreeDragPreview({ label }: Props) { + return
{label || 'Untitled'}
; +} diff --git a/apps/client/src/features/page/tree/components/doc-tree-drop-indicator.tsx b/apps/client/src/features/page/tree/components/doc-tree-drop-indicator.tsx new file mode 100644 index 000000000..9d6352ab5 --- /dev/null +++ b/apps/client/src/features/page/tree/components/doc-tree-drop-indicator.tsx @@ -0,0 +1,39 @@ +import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; +import styles from '../styles/tree.module.css'; + +type Props = { + instruction: Instruction; + indentPx: number; +}; + +export function DocTreeDropIndicator({ instruction, indentPx }: Props) { + const blocked = instruction.type === 'instruction-blocked'; + const inst = blocked ? instruction.desired : instruction; + + const style = { + ['--drop-line-indent' as never]: `${indentPx}px`, + } as React.CSSProperties; + + if (inst.type === 'reorder-above') { + return ( +
+ ); + } + if (inst.type === 'reorder-below') { + return ( +
+ ); + } + // 'combine' (make-child) is rendered via [data-receiving-drop] on the row itself. + return null; +} diff --git a/apps/client/src/features/page/tree/components/doc-tree-row.tsx b/apps/client/src/features/page/tree/components/doc-tree-row.tsx new file mode 100644 index 000000000..347f1f3e7 --- /dev/null +++ b/apps/client/src/features/page/tree/components/doc-tree-row.tsx @@ -0,0 +1,398 @@ +import { + memo, + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from 'react'; +import { createRoot } from 'react-dom/client'; +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { + draggable, + dropTargetForElements, +} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { pointerOutsideOfPreview } from '@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview'; +import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; +import { + attachInstruction, + extractInstruction, + type Instruction, + type ItemMode, +} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; +import { triggerPostMoveFlash } from '@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash'; +import * as liveRegion from '@atlaskit/pragmatic-drag-and-drop-live-region'; + +import type { TreeNode, DropOp } from '../model/tree-model.types'; +import { treeModel } from '../model/tree-model'; +import { DocTreeDropIndicator } from './doc-tree-drop-indicator'; +import { DocTreeDragPreview } from './doc-tree-drag-preview'; +import type { RenderRowProps } from './doc-tree'; +import styles from '../styles/tree.module.css'; + +type Props = { + node: TreeNode; + level: number; + isLastSibling: boolean; + openIds: ReadonlySet; + selectedId?: string; + // Roving tabindex: the single row that currently carries tabIndex={0}. + activeId?: string; + renderRow: (props: RenderRowProps) => ReactNode; + indentPerLevel: number; + onMove: (sourceId: string, op: DropOp) => void | Promise; + onToggle: (id: string, isOpen: boolean) => void; + readOnly: boolean; + disableDrag?: (node: TreeNode) => boolean; + disableDrop?: (node: TreeNode) => boolean; + getDragLabel: (node: TreeNode) => string; + contextId: symbol; + registerRowElement: (id: string, el: HTMLElement | null) => void; + // Stable accessor — calling it returns the latest tree. Avoids passing the + // tree itself as a prop (which would break memo and re-run every row's DnD + // useEffect on every mutation). + getRootData: () => TreeNode[]; +}; + +const DRAG_TYPE = 'doc-tree-item'; +const AUTO_EXPAND_MS = 500; + +function DocTreeRowInner(props: Props) { + const { + node, + level, + isLastSibling, + openIds, + selectedId, + activeId, + renderRow, + indentPerLevel, + onMove, + onToggle, + readOnly, + disableDrag, + disableDrop, + getDragLabel, + contextId, + registerRowElement, + getRootData, + } = props; + + const isOpen = openIds.has(node.id); + // "Has children" includes both already-loaded children AND the consumer's + // own server-side flag (`hasChildren` is a docmost convention on + // SpaceTreeNode / SharedPageTreeNode). The flag lets the chevron and the + // auto-expand timer recognize unloaded subtrees so the consumer's lazy-load + // (via onToggle) can populate them on demand. + const hasLoadedChildren = !!node.children && node.children.length > 0; + const declaredHasChildren = + (node as { hasChildren?: boolean }).hasChildren === true; + const hasChildren = hasLoadedChildren || declaredHasChildren; + const isSelected = selectedId === node.id; + + const rowRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [instruction, setInstruction] = useState(null); + const autoExpandTimerRef = useRef | null>(null); + + const cancelAutoExpand = useCallback(() => { + if (autoExpandTimerRef.current) { + clearTimeout(autoExpandTimerRef.current); + autoExpandTimerRef.current = null; + } + }, []); + + const toggleOpen = useCallback(() => { + onToggle(node.id, !isOpen); + }, [onToggle, node.id, isOpen]); + + useEffect(() => { + registerRowElement(node.id, rowRef.current); + return () => registerRowElement(node.id, null); + }, [registerRowElement, node.id]); + + // Restore lazy-loaded children when the row mounts open but its children + // aren't loaded (e.g. cross-space page move drops a node into a new tree + // that still has its id in openIds). Calling onToggle(id, true) is + // idempotent for open state and triggers the consumer's lazy-load. + useEffect(() => { + if (isOpen && declaredHasChildren && !hasLoadedChildren) { + onToggle(node.id, true); + } + }, [isOpen, declaredHasChildren, hasLoadedChildren, node.id, onToggle]); + + useEffect(() => { + const el = rowRef.current; + if (!el || readOnly) return; + const dragDisabled = disableDrag?.(node) ?? false; + const dropDisabled = disableDrop?.(node) ?? false; + + const cleanups: Array<() => void> = []; + + if (!dragDisabled) { + cleanups.push( + draggable({ + element: el, + getInitialData: () => ({ + id: node.id, + type: DRAG_TYPE, + uniqueContextId: contextId, + isOpenOnDragStart: isOpen, + }), + onGenerateDragPreview: ({ nativeSetDragImage }) => { + setCustomNativeDragPreview({ + nativeSetDragImage, + getOffset: pointerOutsideOfPreview({ x: '16px', y: '8px' }), + render: ({ container }) => { + const root = createRoot(container); + root.render(); + return () => root.unmount(); + }, + }); + }, + onDragStart: () => setIsDragging(true), + onDrop: () => setIsDragging(false), + }), + ); + } + + if (!dropDisabled) { + const mode: ItemMode = + isOpen && hasChildren + ? 'expanded' + : isLastSibling + ? 'last-in-group' + : 'standard'; + // Always block 'reparent' (out of scope per spec). + // Block 'reorder-below' when the row is open with children — ambiguous gesture, + // force users to drop into the folder via 'make-child' instead. + const block: Instruction['type'][] = ['reparent']; + if (isOpen && hasChildren) block.push('reorder-below'); + + cleanups.push( + dropTargetForElements({ + element: el, + canDrop: ({ source }) => + source.data.type === DRAG_TYPE && + source.data.uniqueContextId === contextId && + source.data.id !== node.id && + !treeModel.isDescendant( + getRootData(), + source.data.id as string, + node.id, + ), + getData: ({ input, element }) => + attachInstruction( + { id: node.id, type: DRAG_TYPE }, + { + input, + element, + currentLevel: level, + indentPerLevel, + mode, + block, + }, + ), + onDrag: ({ self }) => { + const inst = extractInstruction(self.data); + setInstruction(inst); + // Auto-expand on hover over any collapsed row that has children, + // regardless of the specific instruction type. Reorder-before and + // reorder-after also benefit: once expanded, the user can see the + // children and refine their drop target. + if ( + inst && + hasChildren && + !isOpen && + !autoExpandTimerRef.current + ) { + autoExpandTimerRef.current = setTimeout(() => { + onToggle(node.id, true); + autoExpandTimerRef.current = null; + }, AUTO_EXPAND_MS); + } + }, + onDragLeave: () => { + setInstruction(null); + cancelAutoExpand(); + }, + onDrop: ({ source, self }) => { + setInstruction(null); + cancelAutoExpand(); + const inst = extractInstruction(self.data); + if (!inst || inst.type === 'instruction-blocked') return; + const sourceId = source.data.id as string; + const op: DropOp = + inst.type === 'reorder-above' + ? { kind: 'reorder-before', targetId: node.id } + : inst.type === 'reorder-below' + ? { kind: 'reorder-after', targetId: node.id } + : inst.type === 'make-child' + ? { kind: 'make-child', targetId: node.id } + : null!; + if (!op) return; + onMove(sourceId, op); + triggerPostMoveFlash(el); + const liveTree = getRootData(); + const parentName = + op.kind === 'make-child' + ? getDragLabel(node) + : (() => { + const sib = treeModel.siblingsOf(liveTree, op.targetId); + const parent = sib?.parentId + ? treeModel.find(liveTree, sib.parentId) + : null; + return parent ? getDragLabel(parent) : 'root'; + })(); + const sourceNode = treeModel.find(liveTree, sourceId); + const sourceLabel = sourceNode + ? getDragLabel(sourceNode) + : 'item'; + liveRegion.announce(`Moved ${sourceLabel} under ${parentName}.`); + // After a make-child drop, expand this row so the user sees the + // just-dropped child — especially important when the row had no + // children before (chevron just appeared) so the drop would + // otherwise be invisible. + if (op.kind === 'make-child') onToggle(node.id, true); + if (source.data.isOpenOnDragStart) onToggle(sourceId, true); + }, + }), + ); + } + + return combine(...cleanups); + }, [ + node, + level, + isOpen, + hasChildren, + isLastSibling, + readOnly, + disableDrag, + disableDrop, + contextId, + indentPerLevel, + getDragLabel, + onMove, + onToggle, + getRootData, + cancelAutoExpand, + ]); + + useEffect(() => () => cancelAutoExpand(), [cancelAutoExpand]); + + const effectiveInst = + instruction?.type === 'instruction-blocked' + ? instruction.desired + : instruction; + const blocked = instruction?.type === 'instruction-blocked'; + const receivingDrop: 'before' | 'after' | 'make-child' | null = (() => { + if (!effectiveInst) return null; + if (effectiveInst.type === 'reorder-above') return 'before'; + if (effectiveInst.type === 'reorder-below') return 'after'; + if (effectiveInst.type === 'make-child') return 'make-child'; + return null; + })(); + + // Treeitem semantics ride on the row's focusable element (the consumer's + // ). The outer
  • is presentational layout. aria-label uses the row's + // label so the SR's accessible name is just the page title, not the + // concatenation of inner action-button aria-labels. + const treeItemProps = { + role: 'treeitem' as const, + 'aria-level': level + 1, + 'aria-expanded': hasChildren ? isOpen : undefined, + 'aria-selected': isSelected ? (true as const) : undefined, + 'aria-current': isSelected ? ('page' as const) : undefined, + 'aria-label': getDragLabel(node), + 'data-row-id': node.id, + }; + + return ( +
    +
    + {renderRow({ + node, + level, + isOpen, + hasChildren, + isSelected, + isDragging, + isReceivingDrop: receivingDrop, + rowRef, + tabIndex: activeId === node.id ? 0 : -1, + treeItemProps, + toggleOpen, + })} +
    + {instruction && ( + + )} +
    + ); +} + +// Custom memo comparator. The default shallow compare re-renders every row +// when `openIds` (a Set) or `selectedId` (a string) on the parent changes, +// because all rows receive the same reference via {...props} spread. With 1K +// rows that's a perceptible stall on every expand and every navigate. +// +// Resolve openIds / selectedId per-row: only re-render if THIS row's own +// open-state or selected-state actually flipped. Everything else uses +// reference equality (callbacks are useCallback-stable from the parent). +function arePropsEqual( + prev: Props, + next: Props, +): boolean { + if (prev.node !== next.node) return false; + if (prev.level !== next.level) return false; + if (prev.isLastSibling !== next.isLastSibling) return false; + if (prev.readOnly !== next.readOnly) return false; + if (prev.contextId !== next.contextId) return false; + if (prev.indentPerLevel !== next.indentPerLevel) return false; + if (prev.renderRow !== next.renderRow) return false; + if (prev.onMove !== next.onMove) return false; + if (prev.onToggle !== next.onToggle) return false; + if (prev.disableDrag !== next.disableDrag) return false; + if (prev.disableDrop !== next.disableDrop) return false; + if (prev.getDragLabel !== next.getDragLabel) return false; + if (prev.registerRowElement !== next.registerRowElement) return false; + if (prev.getRootData !== next.getRootData) return false; + + const id = next.node.id; + // openIds: only this row's own membership matters. + if (prev.openIds.has(id) !== next.openIds.has(id)) return false; + // selectedId: re-render only the rows whose isSelected actually flipped. + const wasSelected = prev.selectedId === id; + const isSelected = next.selectedId === id; + if (wasSelected !== isSelected) return false; + // activeId: same trick — only the outgoing and incoming active rows + // re-render when the user moves focus through the tree. + const wasActive = prev.activeId === id; + const isActive = next.activeId === id; + if (wasActive !== isActive) return false; + + return true; +} + +export const DocTreeRow = memo( + DocTreeRowInner, + arePropsEqual, +) as typeof DocTreeRowInner; diff --git a/apps/client/src/features/page/tree/components/doc-tree.tsx b/apps/client/src/features/page/tree/components/doc-tree.tsx new file mode 100644 index 000000000..3dbbfda16 --- /dev/null +++ b/apps/client/src/features/page/tree/components/doc-tree.tsx @@ -0,0 +1,541 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type ReactNode, + type Ref, +} from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; +import type { TreeNode, DropOp } from '../model/tree-model.types'; +import { treeModel } from '../model/tree-model'; +import { DocTreeRow } from './doc-tree-row'; +import styles from '../styles/tree.module.css'; + +export type RenderRowProps = { + node: TreeNode; + level: number; + isOpen: boolean; + hasChildren: boolean; + isSelected: boolean; + isDragging: boolean; + isReceivingDrop: 'before' | 'after' | 'make-child' | null; + + rowRef: Ref; + // Roving tabindex: exactly one row in the tree carries tabIndex={0} (the + // active row); every other row gets tabIndex={-1}. Consumers must spread + // this onto the same element they wire rowRef to. + tabIndex: 0 | -1; + // Treeitem semantics for the row's focusable element. Consumers MUST spread + // these onto the same element rowRef points at, so the focused element IS + // the treeitem. This makes screen readers announce "treeitem" (not "link") + // and replaces the descendant-text accname with the row's label, so action + // button labels inside the row don't get concatenated. + treeItemProps: { + role: 'treeitem'; + 'aria-level': number; + 'aria-expanded'?: boolean; + 'aria-selected'?: true; + 'aria-current'?: 'page'; + 'aria-label': string; + 'data-row-id': string; + }; + toggleOpen: () => void; +}; + +export type DocTreeProps = { + data: TreeNode[]; + openIds: ReadonlySet; + selectedId?: string; + + renderRow: (props: RenderRowProps) => ReactNode; + indentPerLevel?: number; + rowHeight?: number; + emptyState?: ReactNode; + + onMove: (sourceId: string, op: DropOp) => void | Promise; + onToggle: (id: string, isOpen: boolean) => void; + onSelect?: (id: string) => void; + + readOnly?: boolean; + disableDrag?: (node: TreeNode) => boolean; + disableDrop?: (node: TreeNode) => boolean; + + getDragLabel: (node: TreeNode) => string; + uniqueContextId?: symbol; + + // Accessible name for the tree itself (e.g. "Pages"). Rendered as + // aria-label on the