Compare commits

..

12 Commits

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

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

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

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

* cleanup

---------

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

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

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

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

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

* sync

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

* feat(tree): keyboard arrow navigation between rows

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

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

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

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

* feat(tree): Space activation and ARIA refinements

* fix(tree): move treeitem role to focusable row + aria-current
2026-05-13 23:01:04 +01:00
Philip Okugbe a689cca7a0 feat: page labels/tags (#2188)
* feat: labels (WIP)
* full implementation
2026-05-10 18:14:15 +01:00
Philip Okugbe 537e45bc11 feat: page details section and backlinks (#2186)
* feat: page details section and backlinks
2026-05-09 17:03:08 +01:00
146 changed files with 10718 additions and 3623 deletions
+68 -57
View File
@@ -7,78 +7,89 @@
"build": "tsc && vite build",
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@casl/react": "^5.0.1",
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0",
"@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4",
"@casl/react": "5.0.1",
"@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@mantine/core": "^8.3.18",
"@mantine/dates": "^8.3.18",
"@mantine/form": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"@mantine/modals": "^8.3.18",
"@mantine/notifications": "^8.3.18",
"@mantine/spotlight": "^8.3.18",
"@tabler/icons-react": "^3.40.0",
"@mantine/core": "8.3.18",
"@mantine/dates": "8.3.18",
"@mantine/form": "8.3.18",
"@mantine/hooks": "8.3.18",
"@mantine/modals": "8.3.18",
"@mantine/notifications": "8.3.18",
"@mantine/spotlight": "8.3.18",
"@slidoapp/emoji-mart": "5.8.7",
"@slidoapp/emoji-mart-data": "1.2.4",
"@slidoapp/emoji-mart-react": "1.1.5",
"@tabler/icons-react": "3.40.0",
"@tanstack/react-query": "5.90.17",
"alfaaz": "^1.1.0",
"@tanstack/react-virtual": "3.13.24",
"alfaaz": "1.1.0",
"axios": "1.16.0",
"blueimp-load-image": "^5.16.0",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
"highlightjs-sap-abap": "^0.3.0",
"blueimp-load-image": "5.16.0",
"clsx": "2.1.1",
"file-saver": "2.0.5",
"highlightjs-sap-abap": "0.3.0",
"i18next": "25.10.1",
"i18next-http-backend": "3.0.6",
"jotai": "^2.18.1",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"jotai": "2.18.1",
"jotai-optics": "0.4.0",
"js-cookie": "3.0.5",
"jwt-decode": "4.0.0",
"katex": "0.16.40",
"lowlight": "^3.3.0",
"mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.13.0",
"mitt": "^3.0.1",
"lowlight": "3.3.0",
"mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.15.0",
"mitt": "3.0.1",
"posthog-js": "1.372.2",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react": "18.3.1",
"react-clear-modal": "^2.0.18",
"react-dom": "^18.3.1",
"react-drawio": "^1.0.7",
"react-error-boundary": "^6.1.1",
"react-helmet-async": "^3.0.0",
"react-drawio": "1.0.7",
"react-error-boundary": "6.1.1",
"react-helmet-async": "3.0.0",
"react-i18next": "16.5.8",
"react-router-dom": "^7.13.1",
"semver": "^7.7.4",
"socket.io-client": "^4.8.3",
"zod": "^4.3.6"
"react-router-dom": "7.13.1",
"semver": "7.7.4",
"socket.io-client": "4.8.3",
"zod": "4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.28.0",
"@tanstack/eslint-plugin-query": "^5.94.4",
"@types/blueimp-load-image": "^5.16.6",
"@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.8",
"@eslint/js": "9.28.0",
"@tanstack/eslint-plugin-query": "5.94.4",
"@testing-library/jest-dom": "6.6.0",
"@testing-library/react": "16.1.0",
"@types/blueimp-load-image": "5.16.6",
"@types/file-saver": "2.0.7",
"@types/js-cookie": "3.0.6",
"@types/katex": "0.16.8",
"@types/node": "22.19.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.28.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^15.13.0",
"optics-ts": "^2.4.1",
"postcss": "^8.5.12",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.1",
"vite": "8.0.5"
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.1",
"eslint": "9.28.0",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-react-refresh": "0.5.2",
"globals": "15.13.0",
"jsdom": "25.0.0",
"optics-ts": "2.4.1",
"postcss": "8.5.14",
"postcss-preset-mantine": "1.18.0",
"postcss-simple-vars": "7.0.1",
"prettier": "3.8.1",
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"vite": "8.0.5",
"vitest": "4.1.6"
}
}
@@ -71,6 +71,7 @@
"Export": "Export",
"Failed to create page": "Failed to create page",
"Failed to delete page": "Failed to delete page",
"Failed to restore page": "Failed to restore page",
"Failed to fetch recent pages": "Failed to fetch recent pages",
"Failed to import pages": "Failed to import pages",
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
@@ -286,6 +287,19 @@
"Add row above": "Add row above",
"Add row below": "Add row below",
"Delete table": "Delete table",
"Add column left": "Add column left",
"Add column right": "Add column right",
"Clear cell": "Clear cell",
"Clear cells": "Clear cells",
"Toggle header cell": "Toggle header cell",
"Toggle header column": "Toggle header column",
"Toggle header row": "Toggle header row",
"Move column left": "Move column left",
"Move column right": "Move column right",
"Move row down": "Move row down",
"Move row up": "Move row up",
"Sort A → Z": "Sort A → Z",
"Sort Z → A": "Sort Z → A",
"Info": "Info",
"Note": "Note",
"Success": "Success",
@@ -348,6 +362,8 @@
"Create block quote.": "Create block quote.",
"Insert code snippet.": "Insert code snippet.",
"Insert horizontal rule divider": "Insert horizontal rule divider",
"Page break": "Page break",
"Insert a page break for printing.": "Insert a page break for printing.",
"Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.",
"Upload any audio from your device.": "Upload any audio from your device.",
@@ -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",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> moved this page to Trash {{time}}.",
"Page moved to trash": "Page moved to trash",
"Page restored successfully": "Page restored successfully",
"Deleted by": "Deleted by",
@@ -983,5 +1001,22 @@
"Outgoing links ({{count}})": "Outgoing links ({{count}})",
"No pages link here yet.": "No pages link here yet.",
"This page doesn't link to other pages yet.": "This page doesn't link to other pages yet.",
"Verified until {{date}}": "Verified until {{date}}"
"Verified until {{date}}": "Verified until {{date}}",
"Labels": "Labels",
"Add label": "Add label",
"No labels yet": "No labels yet",
"Already added": "Already added",
"Invalid label name": "Invalid label name",
"No matches": "No matches",
"Search or create…": "Search or create…",
"Remove label {{name}}": "Remove label {{name}}",
"Failed to add label": "Failed to add label",
"Failed to remove label": "Failed to remove label",
"No pages with this label": "No pages with this label",
"Pages tagged with this label will appear here.": "Pages tagged with this label will appear here.",
"No pages match your search.": "No pages match your search.",
"Updated {{date}}": "Updated {{date}}",
"Cell actions": "Cell actions",
"Column actions": "Column actions",
"Row actions": "Row actions"
}
+2
View File
@@ -45,6 +45,7 @@ import TemplateEditor from "@/ee/template/pages/template-editor";
import FavoritesPage from "@/pages/favorites/favorites-page";
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx";
import LabelPage from "@/pages/label/label-page";
export default function App() {
const { t } = useTranslation();
@@ -92,6 +93,7 @@ export default function App() {
<Route path={"/ai/chat/:chatId"} element={<AiChat />} />
<Route path={"/spaces"} element={<SpacesPage />} />
<Route path={"/favorites"} element={<FavoritesPage />} />
<Route path={"/labels/:labelName"} element={<LabelPage />} />
<Route path={"/templates"} element={<TemplateList />} />
<Route
path={"/templates/:templateId"}
@@ -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" && (
<Text mb="md" fw={500}>
{t(title)}
</Text>
<Group justify="space-between" wrap="nowrap" mb="md">
<Text fw={500}>{t(title)}</Text>
<Tooltip label={t("Close")} withArrow>
<ActionIcon
variant="subtle"
color="gray"
onClick={closeAside}
aria-label={t("Close")}
>
<IconX size={18} />
</ActionIcon>
</Tooltip>
</Group>
)}
{tab === "comments" || tab === "chat" ? (
+51 -3
View File
@@ -1,4 +1,4 @@
import React, { ReactNode, useState } from "react";
import React, { ReactNode, useEffect, useState } from "react";
import {
ActionIcon,
Popover,
@@ -7,9 +7,24 @@ import {
} from "@mantine/core";
import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks";
import { Suspense } from "react";
const Picker = React.lazy(() => import("@emoji-mart/react"));
import { useTranslation } from "react-i18next";
// Load the picker module AND the emoji data in parallel inside the lazy
// resolution, then bind the data into the component. React.lazy only finishes
// suspending once both are in memory, so the Suspense boundary hides the
// Remove button until the Picker can render with real content.
const Picker = React.lazy(async () => {
const [pickerModule, dataModule] = await Promise.all([
import("@slidoapp/emoji-mart-react"),
import("@slidoapp/emoji-mart-data"),
]);
const PickerComp = pickerModule.default;
const data = dataModule.default;
return {
default: (props: any) => <PickerComp {...props} data={data} />,
};
});
export interface EmojiPickerInterface {
onEmojiSelect: (emoji: any) => void;
icon: ReactNode;
@@ -19,6 +34,7 @@ export interface EmojiPickerInterface {
size?: string;
variant?: string;
c?: string;
tabIndex?: number;
};
}
@@ -50,6 +66,38 @@ function EmojiPicker({
}
});
// emoji-mart's built-in autoFocus calls .focus() without preventScroll, which
// makes the browser scroll every scrollable ancestor of the search input to
// bring it on screen — including the page editor's scroll container, so the
// page jumps to the top whenever the picker is opened from a scrolled-down
// position. The search input lives inside the <em-emoji-picker> custom
// element's shadow root, so we poll for it after the dropdown mounts and
// focus it ourselves with preventScroll.
useEffect(() => {
if (!opened || !dropdown) return;
let cancelled = false;
let rafId = 0;
const tryFocus = (attempts: number) => {
if (cancelled) return;
const pickerEl = dropdown.querySelector("em-emoji-picker");
const input = pickerEl?.shadowRoot?.querySelector<HTMLInputElement>(
'input[type="search"]',
);
if (input) {
input.focus({ preventScroll: true });
return;
}
if (attempts < 60) {
rafId = requestAnimationFrame(() => tryFocus(attempts + 1));
}
};
rafId = requestAnimationFrame(() => tryFocus(0));
return () => {
cancelled = true;
cancelAnimationFrame(rafId);
};
}, [opened, dropdown]);
const handleEmojiSelect = (emoji) => {
onEmojiSelect(emoji);
handlers.close();
@@ -74,6 +122,7 @@ function EmojiPicker({
c={actionIconProps?.c || "gray"}
variant={actionIconProps?.variant || "transparent"}
size={actionIconProps?.size}
tabIndex={actionIconProps?.tabIndex}
onClick={handlers.toggle}
aria-label={t("Pick emoji")}
aria-haspopup="dialog"
@@ -85,7 +134,6 @@ function EmojiPicker({
<Suspense fallback={null}>
<Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}>
<Picker
data={async () => (await import("@emoji-mart/data")).default}
onEmojiSelect={handleEmojiSelect}
perLine={8}
skinTonePosition="search"
+64 -5
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { Button, Divider, Stack } from "@mantine/core";
import { IconLock, IconServer } from "@tabler/icons-react";
@@ -7,15 +7,37 @@ import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
import { getRedirectParam } from "@/lib/app-route.ts";
import useCurrentUser from "@/features/user/hooks/use-current-user.ts";
const SSO_AUTO_ATTEMPT_KEY = "docmost:ssoAutoAttempt";
const SSO_AUTO_ATTEMPT_TTL_MS = 5 * 60_000;
function recentAutoAttempt(): boolean {
try {
const raw = window.sessionStorage.getItem(SSO_AUTO_ATTEMPT_KEY);
if (!raw) return false;
const ts = Number(raw);
return Number.isFinite(ts) && Date.now() - ts < SSO_AUTO_ATTEMPT_TTL_MS;
} catch {
return false;
}
}
function markAutoAttempt(): void {
try {
window.sessionStorage.setItem(SSO_AUTO_ATTEMPT_KEY, String(Date.now()));
} catch {
/* sessionStorage unavailable (private mode, etc.) — best effort */
}
}
export default function SsoLogin() {
const { data, isLoading } = useWorkspacePublicDataQuery();
const { data: currentUser } = useCurrentUser();
const [ldapModalOpened, setLdapModalOpened] = useState(false);
const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null);
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const autoRedirectedRef = useRef(false);
const handleSsoLogin = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.LDAP) {
@@ -28,10 +50,47 @@ export default function SsoLogin() {
providerId: provider.id,
type: provider.type,
workspaceId: data.id,
redirect: getRedirectParam() ?? undefined,
});
}
};
// Auto-redirect when SSO is enforced and there is exactly one non-LDAP
// provider. The user has no other option, so skip the extra click.
useEffect(() => {
if (autoRedirectedRef.current) return;
if (!data?.enforceSso) return;
if (!data.authProviders || data.authProviders.length !== 1) return;
const onlyProvider = data.authProviders[0];
if (onlyProvider.type === SSO_PROVIDER.LDAP) return;
// Already signed in: let useRedirectIfAuthenticated handle navigation
// instead of racing it through the IdP.
if (currentUser?.user) return;
// Explicit logout: don't immediately bounce them back to the IdP.
const params = new URLSearchParams(window.location.search);
if (params.has("logout")) return;
// Circuit-breaker: if we already auto-redirected within the TTL, the
// user came back (likely from an IdP failure). Show the page so they
// can read errors or pick a different account.
if (recentAutoAttempt()) return;
autoRedirectedRef.current = true;
markAutoAttempt();
window.location.href = buildSsoLoginUrl({
providerId: onlyProvider.id,
type: onlyProvider.type,
workspaceId: data.id,
redirect: getRedirectParam() ?? undefined,
});
}, [data, currentUser]);
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const getProviderIcon = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.GOOGLE) {
return <GoogleIcon size={16} />;
+10 -3
View File
@@ -18,14 +18,21 @@ export function buildSsoLoginUrl(opts: {
providerId: string;
type: SSO_PROVIDER;
workspaceId?: string;
redirect?: string;
}): string {
const { providerId, type, workspaceId } = opts;
const { providerId, type, workspaceId, redirect } = opts;
const domain = getAppUrl();
const params = new URLSearchParams();
if (redirect) params.set("redirect", redirect);
if (type === SSO_PROVIDER.GOOGLE) {
return `${getServerAppUrl()}/api/sso/${type}/login?workspaceId=${workspaceId}`;
if (workspaceId) params.set("workspaceId", workspaceId);
return `${getServerAppUrl()}/api/sso/${type}/login?${params.toString()}`;
}
return `${domain}/api/sso/${type}/${providerId}/login`;
const query = params.toString();
const base = `${domain}/api/sso/${type}/${providerId}/login`;
return query ? `${base}?${query}` : base;
}
export function getGoogleSignupUrl(): string {
@@ -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) => {
@@ -1,5 +1,6 @@
import { atom } from "jotai";
import { Editor } from "@tiptap/core";
import { PageEditMode } from "@/features/user/types/user.types.ts";
export const pageEditorAtom = atom<Editor | null>(null);
@@ -12,3 +13,7 @@ export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false);
// Current page's edit mode — initialized from the user's saved preference on
// first load, can be toggled locally without persisting to the server.
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
@@ -21,7 +21,7 @@ let _emojiIndex: EmojiIndexEntry[] | null = null;
export const buildEmojiIndex = async (): Promise<EmojiIndexEntry[]> => {
if (_emojiIndex) return _emojiIndex;
const { default: data } = await import("@emoji-mart/data");
const { default: data } = await import('@slidoapp/emoji-mart-data');
_emojiIndex = (Object.values((data as any).emojis) as any[])
.filter((e) => e.id && e.name && e.skins?.[0]?.native)
.map((e) => ({
@@ -74,7 +74,7 @@ let _cats: EmojiCategory[] | null = null;
export const getEmojiCategories = async (): Promise<EmojiCategory[]> => {
if (_cats) return _cats;
const [{ default: data }, index] = await Promise.all([
import("@emoji-mart/data"),
import("@slidoapp/emoji-mart-data"),
buildEmojiIndex(),
]);
const byId = new Map(index.map((e) => [e.id, e]));
@@ -3,7 +3,7 @@
top: calc(var(--app-shell-header-offset, 0rem) + 45px);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
z-index: 50;
z-index: 99;
display: flex;
align-items: center;
background: var(--mantine-color-body);
@@ -28,6 +28,7 @@ export const FixedToolbar: FC = () => {
<>
<div
className={classes.fixedToolbar}
data-fixed-toolbar="true"
role="toolbar"
aria-label="Editor toolbar"
onMouseDown={(e) => e.preventDefault()}
@@ -10,6 +10,7 @@ import {
IconH2,
IconH3,
IconMenu4,
IconPageBreak,
IconTypography,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
@@ -102,6 +103,12 @@ export const BlockTypeGroup: FC<Props> = ({ editor }) => {
>
{t("Divider")}
</Menu.Item>
<Menu.Item
leftSection={<IconPageBreak size={16} />}
onClick={() => editor.chain().focus().setPageBreak().run()}
>
{t("Page break")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
@@ -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<any, MentionListProps>((props, ref) => {
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
const { t } = useTranslation();
const [data, setData] = useAtom(treeDataAtom);
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
const createPageMutation = useCreatePageMutation();
const emit = useQueryEmit();
const isInCommentContext = props.isInCommentContext ?? false;
@@ -220,20 +218,20 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
try {
createdPage = await createPageMutation.mutateAsync(payload);
const parentId = page.id || null;
const data = {
const newNode: SpaceTreeNode = {
id: createdPage.id,
slugId: createdPage.slugId,
name: createdPage.title,
position: createdPage.position,
spaceId: createdPage.spaceId,
parentPageId: createdPage.parentPageId,
hasChildren: false,
children: [],
} as any;
};
const lastIndex = tree.data.length;
const lastIndex = data.length;
tree.create({ parentId, index: lastIndex, data });
setData(tree.data);
setData(treeModel.insert(data, parentId, newNode, lastIndex));
props.command({
id: uuid7(),
@@ -251,7 +249,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
payload: {
parentId,
index: lastIndex,
data,
data: newNode,
},
});
}, 50);
@@ -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.",
@@ -0,0 +1,126 @@
import React, { useCallback, useEffect } from "react";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { TextSelection } from "@tiptap/pm/state";
import { columnResizingPluginKey } from "@tiptap/pm/tables";
import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
import { Menu, UnstyledButton } from "@mantine/core";
import { IconChevronDown } from "@tabler/icons-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { isCellSelection } from "@docmost/editor-ext";
import { CellChevronMenu } from "./menus/cell-chevron-menu";
import classes from "./handle.module.css";
interface CellChevronProps {
editor: Editor;
cellPos: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export const CellChevron = React.memo(function CellChevron({
editor,
cellPos,
tableNode,
tablePos,
}: CellChevronProps) {
const { t } = useTranslation();
const cellDom = editor.view.nodeDOM(cellPos) as HTMLElement | null;
const { refs, floatingStyles, middlewareData } = useFloating({
placement: "top-end",
// crossAxis pulls the chevron INWARD from the cell's right edge. We need
// enough inset that we don't overlap PM-tables' column-resize hot zone
// (~5px wide around the column boundary). Without this, hovering near the
// column edge picks up the chevron's `cursor: pointer` instead of
// `col-resize`, and a drag near the edge clicks the chevron.
middleware: [offset({ mainAxis: -22, crossAxis: -10 }), hide()],
whileElementsMounted: autoUpdate,
strategy: "absolute",
});
const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
useEffect(() => {
refs.setReference(cellDom);
}, [cellDom, refs]);
// Hide the chevron while the user is resizing a column. PM-tables sets
// `activeHandle > -1` whenever the mouse is near a column boundary OR
// actively dragging it. Either way we don't want the chevron in the way.
const isResizingColumn = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) return false;
const state = columnResizingPluginKey.getState(ctx.editor.state) as
| { activeHandle: number }
| undefined;
return !!state && state.activeHandle > -1;
},
});
const onOpen = useCallback(() => {
const current = editor.state.selection;
// Preserve an existing multi-cell CellSelection that already covers
// this cell so merge etc. operate on the user's whole range.
let preserveExisting = false;
if (isCellSelection(current)) {
current.forEachCell((_node, pos) => {
if (pos === cellPos) preserveExisting = true;
});
}
if (!preserveExisting) {
// Drop a collapsed cursor inside the cell rather than a single-cell
// CellSelection — PM-tables paints the latter as a text-range
// highlight on the cell content.
try {
const $inside = editor.state.doc.resolve(cellPos + 1);
const sel = TextSelection.near($inside, 1);
editor.view.dispatch(editor.state.tr.setSelection(sel));
} catch {}
}
editor.commands.freezeHandles();
}, [editor, cellPos]);
const onClose = useCallback(() => {
editor.commands.unfreezeHandles();
}, [editor]);
if (!cellDom) return null;
if (isResizingColumn) return null;
return (
<Menu
position="bottom-end"
onOpen={onOpen}
onClose={onClose}
withinPortal
shadow="md"
>
<Menu.Target>
<UnstyledButton
ref={refs.setFloating}
style={{
...floatingStyles,
...(isReferenceHidden ? { visibility: "hidden" as const } : {}),
}}
className={clsx(classes.cellChevron)}
aria-label={t("Cell actions")}
>
<IconChevronDown size={14} />
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<CellChevronMenu
editor={editor}
cellPos={cellPos}
tableNode={tableNode}
tablePos={tablePos}
/>
</Menu.Dropdown>
</Menu>
);
});
@@ -0,0 +1,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 { 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.
const lookupCellDom = editor.view.nodeDOM(anchorPos) as HTMLElement | null;
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
useEffect(() => {
if (lookupCellDom && lookupCellDom !== lastCellDomRef.current) {
lastCellDomRef.current = lookupCellDom;
setCellDom(lookupCellDom);
}
}, [lookupCellDom]);
const [handleEl, setHandleEl] = useState<HTMLDivElement | null>(null);
const { refs, floatingStyles, middlewareData } = useFloating({
placement: "top",
middleware: [offset(-4), hide()],
whileElementsMounted: autoUpdate,
});
const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
useEffect(() => {
refs.setReference(cellDom);
}, [cellDom, refs]);
// `cellDom` is inside the table, so `closest('.tableWrapper')` finds the
// wrapper for this drag's auto-scroll. The handle itself lives in a
// floating layer outside the editor DOM, so we can't walk up from it.
const wrapper = cellDom?.closest<HTMLElement>(".tableWrapper") ?? null;
const [menuOpened, setMenuOpened] = useState(false);
const closeMenu = useCallback(() => setMenuOpened(false), []);
useTableHandleDrag(editor, "col", handleEl, wrapper, closeMenu);
const { onOpen, onClose } = useColumnRowMenuLifecycle({
editor,
orientation: "col",
index,
tableNode,
tablePos,
});
if (!cellDom) return null;
return (
<Menu
opened={menuOpened}
onChange={setMenuOpened}
position="bottom-start"
onOpen={onOpen}
onClose={onClose}
withinPortal
shadow="md"
>
<Menu.Target>
<div
ref={(node) => {
refs.setFloating(node);
setHandleEl(node);
}}
style={{
...floatingStyles,
...(isReferenceHidden ? { visibility: "hidden" as const } : {}),
}}
className={clsx(classes.handle, classes.columnHandle)}
role="button"
tabIndex={0}
aria-label={t("Column actions")}
>
<span style={{ pointerEvents: "none", display: "inline-flex" }}>
<GripIcon />
</span>
</div>
</Menu.Target>
<Menu.Dropdown>
<ColumnHandleMenu
editor={editor}
index={index}
tableNode={tableNode}
tablePos={tablePos}
/>
</Menu.Dropdown>
</Menu>
);
});
function GripIcon() {
return (
<svg viewBox="0 0 10 10" width="14" height="14" aria-hidden>
<path
fill="currentColor"
d="M3,2 A1,1 0 1 1 3,0 A1,1 0 0 1 3,2 Z M3,6 A1,1 0 1 1 3,4 A1,1 0 0 1 3,6 Z M3,10 A1,1 0 1 1 3,8 A1,1 0 0 1 3,10 Z M7,2 A1,1 0 1 1 7,0 A1,1 0 0 1 7,2 Z M7,6 A1,1 0 1 1 7,4 A1,1 0 0 1 7,6 Z M7,10 A1,1 0 1 1 7,8 A1,1 0 0 1 7,10 Z"
/>
</svg>
);
}
@@ -0,0 +1,108 @@
.handle {
position: absolute;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: rgba(55, 53, 47, 0.45);
background: var(--mantine-color-body);
border: 1px solid rgba(55, 53, 47, 0.12);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
cursor: grab;
padding: 0;
transition: background-color 120ms ease, color 120ms ease;
@mixin dark {
color: rgba(255, 255, 255, 0.55);
background: var(--mantine-color-dark-7);
border-color: rgba(255, 255, 255, 0.12);
}
}
.handle:hover {
background: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
color: light-dark(
var(--mantine-color-gray-7),
var(--mantine-color-dark-0)
);
}
.handle:active {
cursor: grabbing;
}
.columnHandle {
width: 28px;
height: 16px;
}
.columnHandle svg {
transform: rotate(90deg);
}
.rowHandle {
width: 16px;
height: 28px;
}
@media (max-width: 600px) {
.handle {
display: none;
}
}
.cellChevron {
position: absolute;
z-index: 50;
width: 18px;
height: 18px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: light-dark(
var(--mantine-color-gray-7),
var(--mantine-color-dark-1)
);
background: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
border: 1px solid rgba(55, 53, 47, 0.12);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
cursor: pointer;
padding: 0;
transition: background-color 120ms ease, color 120ms ease;
@mixin dark {
border-color: rgba(255, 255, 255, 0.12);
}
}
.cellChevron:hover {
background: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-4)
);
color: light-dark(
var(--mantine-color-gray-8),
var(--mantine-color-dark-0)
);
}
@media (max-width: 600px) {
.cellChevron {
display: none;
}
}
@media print {
.handle,
.cellChevron {
display: none !important;
}
}
@@ -0,0 +1,40 @@
import { useCallback } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { buildRowOrColumnSelection, Orientation } from "../lib/select-row-column";
interface Args {
editor: Editor;
orientation: Orientation;
index: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export function useColumnRowMenuLifecycle({
editor,
orientation,
index,
tableNode,
tablePos,
}: Args) {
const onOpen = useCallback(() => {
const selection = buildRowOrColumnSelection(
editor.state,
tableNode,
tablePos,
orientation,
index,
);
const tr = editor.state.tr;
if (selection) tr.setSelection(selection);
editor.view.dispatch(tr);
editor.commands.freezeHandles();
}, [editor, orientation, index, tableNode, tablePos]);
const onClose = useCallback(() => {
editor.commands.unfreezeHandles();
}, [editor]);
return { onOpen, onClose };
}
@@ -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]);
}
@@ -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]);
}
@@ -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;
}
@@ -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 };
}
@@ -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 };
}
@@ -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);
}
@@ -0,0 +1,57 @@
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
export type SortDirection = "asc" | "desc";
export interface SortableItem<T> {
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<T>(
data: SortableItem<T>[],
direction: SortDirection,
): SortableItem<T>[] {
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<T>(
all: SortableItem<T>[],
sortedData: SortableItem<T>[],
): SortableItem<T>[] {
const dataQueue = [...sortedData];
return all.map((item) => (item.isHeader ? item : dataQueue.shift()!));
}
@@ -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 (
<Menu.Sub position="right-start">
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconAlignLeft size={16} />}>
{t("Text alignment")}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<Menu.Item
leftSection={<IconAlignLeft size={16} />}
onClick={() => editor.chain().focus().setTextAlign("left").run()}
>
{t("Align left")}
</Menu.Item>
<Menu.Item
leftSection={<IconAlignCenter size={16} />}
onClick={() => editor.chain().focus().setTextAlign("center").run()}
>
{t("Align center")}
</Menu.Item>
<Menu.Item
leftSection={<IconAlignRight size={16} />}
onClick={() => editor.chain().focus().setTextAlign("right").run()}
>
{t("Align right")}
</Menu.Item>
</Menu.Sub.Dropdown>
</Menu.Sub>
);
});
@@ -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 (
<>
<Menu.Sub position="right-start">
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconPalette size={16} />}>
{t("Background color")}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: 8,
padding: 8,
}}
>
{TABLE_COLORS.map((c) => (
<button
key={c.name}
type="button"
onClick={() => setBackground(c.color, c.name)}
aria-label={t(c.name)}
style={{
border: "none",
background: "transparent",
padding: 0,
cursor: "pointer",
}}
>
<ColorSwatch
color={c.color || "#ffffff"}
size={22}
style={{
border: c.color === "" ? "1px solid #e5e7eb" : undefined,
}}
/>
</button>
))}
</div>
</Menu.Sub.Dropdown>
</Menu.Sub>
<AlignmentSubmenu editor={editor} />
<Menu.Item
leftSection={<IconBoxMargin size={16} />}
onClick={() => editor.chain().focus().mergeCells().run()}
disabled={!editor.can().mergeCells()}
>
{t("Merge cells")}
</Menu.Item>
<Menu.Item
leftSection={<IconSquareToggle size={16} />}
onClick={() => editor.chain().focus().splitCell().run()}
disabled={!editor.can().splitCell()}
>
{t("Split cell")}
</Menu.Item>
<Menu.Item
leftSection={<IconTableRow size={16} />}
onClick={() => editor.chain().focus().toggleHeaderCell().run()}
>
{t("Toggle header cell")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconColumnInsertRight size={16} />}
onClick={() => editor.chain().focus().addColumnAfter().run()}
>
{t("Add column right")}
</Menu.Item>
<Menu.Item
leftSection={<IconRowInsertBottom size={16} />}
onClick={() => editor.chain().focus().addRowAfter().run()}
>
{t("Add row below")}
</Menu.Item>
<Menu.Item leftSection={<IconEraser size={16} />} onClick={clearCell}>
{t("Clear cell")}
</Menu.Item>
<Menu.Item
leftSection={<IconColumnRemove size={16} />}
onClick={() => editor.chain().focus().deleteColumn().run()}
>
{t("Delete column")}
</Menu.Item>
<Menu.Item
leftSection={<IconRowRemove size={16} />}
onClick={() => editor.chain().focus().deleteRow().run()}
>
{t("Delete row")}
</Menu.Item>
</>
);
});
@@ -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 (
<>
<Menu.Item
leftSection={<IconSortAscendingLetters size={16} />}
onClick={sortAsc.handleSort}
disabled={!sortAsc.canSort}
>
{t("Sort A → Z")}
</Menu.Item>
<Menu.Item
leftSection={<IconSortDescendingLetters size={16} />}
onClick={sortDesc.handleSort}
disabled={!sortDesc.canSort}
>
{t("Sort Z → A")}
</Menu.Item>
<Menu.Divider />
<Menu.Sub position="right-start">
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconPalette size={16} />}>
{t("Background color")}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, padding: 8 }}>
{TABLE_COLORS.map((c) => (
<button
key={c.name}
type="button"
onClick={() => setBackground(c.color, c.name)}
aria-label={t(c.name)}
style={{
border: "none",
background: "transparent",
padding: 0,
cursor: "pointer",
}}
>
<ColorSwatch
color={c.color || "#ffffff"}
size={22}
style={{ border: c.color === "" ? "1px solid #e5e7eb" : undefined }}
/>
</button>
))}
</div>
</Menu.Sub.Dropdown>
</Menu.Sub>
<AlignmentSubmenu editor={editor} />
<Menu.Divider />
<Menu.Item
leftSection={<IconColumnInsertLeft size={16} />}
onClick={() => editor.chain().focus().addColumnBefore().run()}
>
{t("Add column left")}
</Menu.Item>
<Menu.Item
leftSection={<IconColumnInsertRight size={16} />}
onClick={() => editor.chain().focus().addColumnAfter().run()}
>
{t("Add column right")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconEraser size={16} />}
onClick={clearCol}
>
{t("Clear cells")}
</Menu.Item>
<Menu.Item
leftSection={<IconColumnRemove size={16} />}
onClick={() => editor.chain().focus().deleteColumn().run()}
>
{t("Delete column")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconArrowLeft size={16} />}
onClick={moveLeft.handleMove}
disabled={!moveLeft.canMove}
>
{t("Move column left")}
</Menu.Item>
<Menu.Item
leftSection={<IconArrowRight size={16} />}
onClick={moveRight.handleMove}
disabled={!moveRight.canMove}
>
{t("Move column right")}
</Menu.Item>
</>
);
});
@@ -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 (
<>
<Menu.Sub position="right-start">
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconPalette size={16} />}>
{t("Background color")}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, padding: 8 }}>
{TABLE_COLORS.map((c) => (
<button
key={c.name}
type="button"
onClick={() => setBackground(c.color, c.name)}
aria-label={t(c.name)}
style={{
border: "none",
background: "transparent",
padding: 0,
cursor: "pointer",
}}
>
<ColorSwatch
color={c.color || "#ffffff"}
size={22}
style={{ border: c.color === "" ? "1px solid #e5e7eb" : undefined }}
/>
</button>
))}
</div>
</Menu.Sub.Dropdown>
</Menu.Sub>
<AlignmentSubmenu editor={editor} />
<Menu.Divider />
<Menu.Item
leftSection={<IconRowInsertTop size={16} />}
onClick={() => editor.chain().focus().addRowBefore().run()}
>
{t("Add row above")}
</Menu.Item>
<Menu.Item
leftSection={<IconRowInsertBottom size={16} />}
onClick={() => editor.chain().focus().addRowAfter().run()}
>
{t("Add row below")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconEraser size={16} />} onClick={clearRow}>
{t("Clear cells")}
</Menu.Item>
<Menu.Item
leftSection={<IconRowRemove size={16} />}
onClick={() => editor.chain().focus().deleteRow().run()}
>
{t("Delete row")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconArrowUp size={16} />}
onClick={moveUp.handleMove}
disabled={!moveUp.canMove}
>
{t("Move row up")}
</Menu.Item>
<Menu.Item
leftSection={<IconArrowDown size={16} />}
onClick={moveDown.handleMove}
disabled={!moveDown.canMove}
>
{t("Move row down")}
</Menu.Item>
</>
);
});
@@ -0,0 +1,122 @@
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.
const lookupCellDom = editor.view.nodeDOM(anchorPos) as HTMLElement | null;
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
useEffect(() => {
if (lookupCellDom && lookupCellDom !== lastCellDomRef.current) {
lastCellDomRef.current = lookupCellDom;
setCellDom(lookupCellDom);
}
}, [lookupCellDom]);
const [handleEl, setHandleEl] = useState<HTMLDivElement | null>(null);
const { refs, floatingStyles, middlewareData } = useFloating({
placement: "left",
middleware: [offset(-4), hide()],
whileElementsMounted: autoUpdate,
});
const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
useEffect(() => {
refs.setReference(cellDom);
}, [cellDom, refs]);
const wrapper = cellDom?.closest<HTMLElement>(".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 (
<Menu
opened={menuOpened}
onChange={setMenuOpened}
position="right-start"
onOpen={onOpen}
onClose={onClose}
withinPortal
shadow="md"
>
<Menu.Target>
<div
ref={(node) => {
refs.setFloating(node);
setHandleEl(node);
}}
style={{
...floatingStyles,
...(isReferenceHidden ? { visibility: "hidden" as const } : {}),
}}
className={clsx(classes.handle, classes.rowHandle)}
role="button"
tabIndex={0}
aria-label={t("Row actions")}
>
<span style={{ pointerEvents: "none", display: "inline-flex" }}>
<GripIcon />
</span>
</div>
</Menu.Target>
<Menu.Dropdown>
<RowHandleMenu
editor={editor}
index={index}
tableNode={tableNode}
tablePos={tablePos}
/>
</Menu.Dropdown>
</Menu>
);
});
function GripIcon() {
return (
<svg viewBox="0 0 10 10" width="14" height="14" aria-hidden>
<path
fill="currentColor"
d="M3,2 A1,1 0 1 1 3,0 A1,1 0 0 1 3,2 Z M3,6 A1,1 0 1 1 3,4 A1,1 0 0 1 3,6 Z M3,10 A1,1 0 1 1 3,8 A1,1 0 0 1 3,10 Z M7,2 A1,1 0 1 1 7,0 A1,1 0 0 1 7,2 Z M7,6 A1,1 0 1 1 7,4 A1,1 0 0 1 7,6 Z M7,10 A1,1 0 1 1 7,8 A1,1 0 0 1 7,10 Z"
/>
</svg>
);
}
@@ -0,0 +1,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 (
<>
<ColumnHandle
editor={editor}
index={state.hoveringCell.colIndex}
anchorPos={state.hoveringCell.colFirstCellPos}
tableNode={state.tableNode!}
tablePos={state.tablePos!}
/>
<RowHandle
editor={editor}
index={state.hoveringCell.rowIndex}
anchorPos={state.hoveringCell.rowFirstCellPos}
tableNode={state.tableNode!}
tablePos={state.tablePos!}
/>
<CellChevron
editor={editor}
cellPos={state.hoveringCell.cellPos}
tableNode={state.tableNode!}
tablePos={state.tablePos!}
/>
</>
);
});
@@ -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" },
@@ -104,12 +104,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,
},
@@ -86,11 +86,11 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
transitionProps={{ transition: "pop" }}
>
<Popover.Target>
<Tooltip label={t("Text alignment")} withArrow>
<Tooltip label={t("Text align")} withArrow>
<ActionIcon
variant="subtle"
size="lg"
aria-label={t("Text alignment")}
aria-label={t("Text align")}
onClick={() => setOpened(!opened)}
>
<activeItem.icon size={18} />
@@ -35,6 +35,7 @@ export default function TransclusionReferenceView(props: NodeViewProps) {
return (
<NodeViewWrapper
className={classes.includeWrap}
data-editable={isEditable ? "true" : "false"}
data-focused={isEditable && props.selected ? "true" : "false"}
data-menu-open={openMenus > 0 ? "true" : "false"}
contentEditable={false}
@@ -62,6 +62,7 @@ export default function TransclusionView(props: NodeViewProps) {
return (
<NodeViewWrapper
className={classes.transclusionWrap}
data-editable={isEditable ? "true" : "false"}
data-menu-open={openMenus > 0 ? "true" : "false"}
data-id={transclusionId ?? undefined}
>
@@ -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),
@@ -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;
@@ -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,
@@ -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,
}),
+30 -11
View File
@@ -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 (
<Container
@@ -71,7 +91,10 @@ export function FullEditor({
size={!fullPageWidth && 900}
className={classes.editor}
>
{editorToolbarEnabled && editable && isEditMode && <FixedToolbar />}
{editorToolbarEnabled && editable && isEditMode && (
<MemoizedFixedToolbar />
)}
<MemoizedDeletedPageBanner slugId={slugId} />
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}
@@ -95,16 +118,12 @@ export function FullEditor({
}
type PageBylineProps = {
creator?: PageCreator;
creator?: PageUser;
contributors?: IContributor[];
readOnly?: boolean;
};
function PageByline({
creator,
contributors,
readOnly,
}: PageBylineProps) {
function PageByline({ creator, contributors, readOnly }: PageBylineProps) {
const { t } = useTranslation();
const toggleAside = useToggleAside();
@@ -26,10 +26,11 @@ import {
collabExtensions,
mainExtensions,
} from "@/features/editor/extensions/extensions";
import { useAtom } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
currentPageEditModeAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms";
@@ -44,6 +45,7 @@ import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubbl
import { ReadonlyBubbleMenu } from "@/features/editor/components/bubble-menu/readonly-bubble-menu";
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
import { TableHandlesLayer } from "@/features/editor/components/table/handle/table-handles-layer";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
@@ -111,8 +113,7 @@ export default function PageEditor({
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const canScroll = useCallback(
() => 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({
<EditorLinkMenu editor={editor} />
<EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} />
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
<TableHandlesLayer editor={editor} />
<ImageMenu editor={editor} />
<VideoMenu editor={editor} />
<PdfMenu editor={editor} />
@@ -203,7 +203,8 @@
}
}
.resize-cursor {
&.resize-cursor,
&.resize-cursor * {
cursor: ew-resize;
cursor: col-resize;
}
@@ -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";
@@ -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;
}
}
@@ -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 <p>) 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;
@@ -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", {});
@@ -0,0 +1,51 @@
import { Link } from "react-router-dom";
import { useComputedColorScheme } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { ILabel } from "@/features/label/types/label.types.ts";
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
import classes from "@/features/label/label.module.css";
type LabelChipProps = {
label: Pick<ILabel, "id" | "name">;
onRemove?: () => void;
asLink?: boolean;
};
export function LabelChip({ label, onRemove, asLink }: LabelChipProps) {
const { t } = useTranslation();
const scheme = useComputedColorScheme("light");
const c = getLabelColor(label.name, scheme);
const nameNode = asLink ? (
<Link
to={`/labels/${encodeURIComponent(label.name)}`}
className={classes.chipLink}
onClick={(e) => e.stopPropagation()}
>
<span className={classes.chipName}>{label.name}</span>
</Link>
) : (
<span className={classes.chipName}>{label.name}</span>
);
return (
<span className={classes.chip} style={{ background: c.bg, color: c.fg }}>
{nameNode}
{onRemove && (
<button
type="button"
className={classes.chipX}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove();
}}
aria-label={t("Remove label {{name}}", { name: label.name })}
>
<IconX size={12} stroke={2} />
</button>
)}
</span>
);
}
@@ -0,0 +1,29 @@
import { Skeleton } from "@mantine/core";
import classes from "@/features/label/label.module.css";
type LabelPageRowSkeletonProps = {
titleWidth?: number;
metaWidth?: number;
};
export function LabelPageRowSkeleton({
titleWidth = 220,
metaWidth = 180,
}: LabelPageRowSkeletonProps) {
return (
<div className={classes.row} aria-hidden="true">
<div className={classes.rowMain}>
<div className={classes.rowIcon}>
<Skeleton height={18} width={18} radius="sm" />
</div>
<div className={classes.rowBody}>
<Skeleton height={15} width={titleWidth} radius="xs" />
<div className={classes.rowMeta}>
<Skeleton height={18} width={18} radius="sm" />
<Skeleton height={12} width={metaWidth} radius="xs" />
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,91 @@
import { Link } from "react-router-dom";
import { ThemeIcon, Tooltip } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { ILabelPageItem } from "@/features/label/types/label.types.ts";
import { LabelChip } from "@/features/label/components/label-chip.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { buildPageUrl } from "@/features/page/page.utils";
import { formatLabelListDate } from "@/features/label/utils/format-label-date.ts";
import classes from "@/features/label/label.module.css";
type LabelPageRowProps = {
page: ILabelPageItem;
currentLabelName: string;
};
const MAX_VISIBLE_CHIPS = 3;
export function LabelPageRow({ page, currentLabelName }: LabelPageRowProps) {
const { t } = useTranslation();
const otherLabels = page.labels.filter((l) => l.name !== currentLabelName);
const visibleLabels = otherLabels.slice(0, MAX_VISIBLE_CHIPS);
const hiddenLabels = otherLabels.slice(MAX_VISIBLE_CHIPS);
return (
<Link
to={buildPageUrl(page.space?.slug, page.slugId, page.title ?? undefined)}
className={classes.row}
>
<div className={classes.rowMain}>
<div className={classes.rowIcon}>
{page.icon ? (
<span style={{ fontSize: 16, lineHeight: 1 }}>{page.icon}</span>
) : (
<ThemeIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18} />
</ThemeIcon>
)}
</div>
<div className={classes.rowBody}>
<div className={classes.rowTitle}>
{page.title || t("Untitled")}
</div>
<div className={classes.rowMeta}>
{page.space && (
<>
<CustomAvatar
name={page.space.name}
avatarUrl={page.space.logo ?? undefined}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size={18}
/>
<span>{page.space.name}</span>
<span className={classes.metaDot} aria-hidden="true">
</span>
</>
)}
<span className={classes.rowDate}>
{t("Updated {{date}}", {
date: formatLabelListDate(new Date(page.updatedAt)),
})}
</span>
</div>
{/* {otherLabels.length > 0 && (
<div className={classes.rowChips}>
{visibleLabels.map((label) => (
<LabelChip key={label.id} label={label} asLink />
))}
{hiddenLabels.length > 0 && (
<Tooltip
label={hiddenLabels.map((l) => l.name).join(", ")}
withArrow
openDelay={200}
>
<span className={classes.chipMore}>
+{hiddenLabels.length}
</span>
</Tooltip>
)}
</div>
)} */}
</div>
</div>
</Link>
);
}
@@ -0,0 +1,160 @@
import { useMemo, useRef, useState, KeyboardEvent } from "react";
import clsx from "clsx";
import { IconPlus } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useComputedColorScheme } from "@mantine/core";
import { ILabel } from "@/features/label/types/label.types.ts";
import { useWorkspaceLabelsQuery } from "@/features/label/queries/label-query.ts";
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
import { normalizeLabelName } from "@/features/label/utils/normalize-label.ts";
import classes from "@/features/label/label.module.css";
type LabelPickerProps = {
applied: ILabel[];
enabled: boolean;
onAdd: (name: string) => void;
onClose: () => void;
};
const NAME_PATTERN = /^[a-z0-9_-][a-z0-9_~-]*$/;
const MAX_LABEL_NAME_LENGTH = 100;
function isValidLabelName(name: string): boolean {
return (
name.length > 0 &&
name.length <= MAX_LABEL_NAME_LENGTH &&
NAME_PATTERN.test(name)
);
}
export function LabelPicker({
applied,
enabled,
onAdd,
onClose,
}: LabelPickerProps) {
const { t } = useTranslation();
const scheme = useComputedColorScheme("light");
const [query, setQuery] = useState("");
const [hover, setHover] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const normalized = normalizeLabelName(query);
const { data } = useWorkspaceLabelsQuery(normalized, enabled);
const appliedNames = useMemo(
() => new Set(applied.map((l) => l.name.toLowerCase())),
[applied],
);
const suggestions = useMemo(() => {
const items = data?.items ?? [];
return items.filter((l) => !appliedNames.has(l.name.toLowerCase()));
}, [data, appliedNames]);
const exact = suggestions.find((l) => l.name === normalized);
const canCreate =
!exact && !appliedNames.has(normalized) && isValidLabelName(normalized);
const total = suggestions.length + (canCreate ? 1 : 0);
const select = (idx: number) => {
if (idx < suggestions.length) {
onAdd(suggestions[idx].name);
} else if (canCreate) {
onAdd(normalized);
}
setQuery("");
setHover(0);
inputRef.current?.focus();
};
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setHover((h) => Math.min(Math.max(total - 1, 0), h + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHover((h) => Math.max(0, h - 1));
} else if (e.key === "Enter") {
e.preventDefault();
if (total === 0) return;
select(hover);
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
return (
<div className={classes.popover}>
<div className={classes.popoverSearch}>
<input
ref={inputRef}
type="text"
autoFocus
maxLength={MAX_LABEL_NAME_LENGTH}
placeholder={t("Search or create…")}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setHover(0);
}}
onKeyDown={onKey}
/>
</div>
<div className={classes.popoverList}>
{total === 0 && (
<div className={classes.popoverEmpty}>
{normalized.length === 0
? t("No labels yet")
: appliedNames.has(normalized)
? t("Already added")
: !isValidLabelName(normalized)
? t("Invalid label name")
: t("No matches")}
</div>
)}
{suggestions.map((s, i) => {
const c = getLabelColor(s.name, scheme);
return (
<button
key={s.id}
type="button"
className={clsx(
classes.popoverItem,
hover === i && classes.popoverItemHover,
)}
onMouseEnter={() => setHover(i)}
onClick={() => select(i)}
>
<span
className={classes.popoverItemDot}
style={{ background: c.dot }}
/>
<span className={classes.popoverItemName}>{s.name}</span>
</button>
);
})}
{canCreate && (
<button
type="button"
className={clsx(
classes.popoverItem,
hover === suggestions.length && classes.popoverItemHover,
)}
onMouseEnter={() => setHover(suggestions.length)}
onClick={() => select(suggestions.length)}
>
<span className={classes.popoverCreatePlus}>
<IconPlus size={12} stroke={2} />
</span>
<span className={classes.popoverItemName}>
{t("Create")} <b>"{normalized}"</b>
</span>
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,93 @@
import { useState } from "react";
import clsx from "clsx";
import { Divider, Popover, Stack, Text } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { LabelChip } from "@/features/label/components/label-chip.tsx";
import { LabelPicker } from "@/features/label/components/label-picker.tsx";
import {
useAddLabelsMutation,
usePageLabelsQuery,
useRemoveLabelMutation,
} from "@/features/label/queries/label-query.ts";
import classes from "@/features/label/label.module.css";
type LabelsSectionProps = {
pageId: string;
canEdit: boolean;
};
export function LabelsSection({ pageId, canEdit }: LabelsSectionProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { data } = usePageLabelsQuery(pageId);
const addMutation = useAddLabelsMutation(pageId);
const removeMutation = useRemoveLabelMutation(pageId);
const labels = data?.items ?? [];
if (!canEdit && labels.length === 0) {
return null;
}
const handleAdd = (name: string) => {
addMutation.mutate({ pageId, names: [name] });
};
const handleRemove = (labelId: string) => {
removeMutation.mutate({ pageId, labelId });
};
return (
<>
<Divider />
<Stack gap="xs">
<Text size="xs" fw={500} c="dimmed">
{t("Labels")}
</Text>
<div className={classes.labelsWrap}>
{labels.map((label) => (
<LabelChip
key={label.id}
label={label}
asLink
onRemove={canEdit ? () => handleRemove(label.id) : undefined}
/>
))}
{canEdit && (
<Popover
opened={open}
onChange={setOpen}
position="bottom-end"
shadow="lg"
withinPortal
offset={6}
>
<Popover.Target>
<button
type="button"
className={clsx(classes.addBtn, open && classes.addBtnOpen)}
onClick={() => setOpen((v) => !v)}
>
<IconPlus size={12} stroke={2} />
<span>
{labels.length === 0 ? t("Add label") : t("Add")}
</span>
</button>
</Popover.Target>
<Popover.Dropdown p={0} className={classes.popover}>
<LabelPicker
applied={labels}
enabled={open}
onAdd={(name) => handleAdd(name)}
onClose={() => setOpen(false)}
/>
</Popover.Dropdown>
</Popover>
)}
</div>
</Stack>
</>
);
}
@@ -0,0 +1,325 @@
.labelsWrap {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-top: 4px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 24px;
padding: 0 8px;
border-radius: 4px;
font-size: 12.5px;
font-weight: 500;
line-height: 1;
user-select: none;
white-space: nowrap;
}
.chipName {
letter-spacing: 0.005em;
}
.chipX {
appearance: none;
border: 0;
background: transparent;
color: currentColor;
width: 18px;
height: 18px;
border-radius: 4px;
margin-right: -4px;
margin-left: 0;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
opacity: 0.6;
}
.chipX:hover {
opacity: 1;
background: light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.12));
}
.addBtn {
appearance: none;
border: 1px dashed
light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
background: transparent;
color: var(--mantine-color-dimmed);
height: 24px;
padding: 0 8px;
border-radius: 4px;
font: inherit;
font-size: 12.5px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
transition:
background 100ms ease,
border-color 100ms ease,
color 100ms ease;
}
.addBtn:hover {
background: light-dark(rgba(0, 0, 0, 0.03), rgba(255, 255, 255, 0.04));
color: var(--mantine-color-text);
border-color: light-dark(
var(--mantine-color-gray-5),
var(--mantine-color-dark-2)
);
}
.addBtnOpen {
background: var(--mantine-color-body);
border-style: solid;
border-color: light-dark(
var(--mantine-color-gray-5),
var(--mantine-color-dark-2)
);
color: var(--mantine-color-text);
}
.popover {
width: 240px;
padding: 0;
overflow: hidden;
}
.popoverSearch {
padding: 8px 8px 4px;
border-bottom: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.popoverSearch input {
width: 100%;
border: 0;
background: transparent;
font: inherit;
font-size: 13px;
padding: 4px 4px;
color: var(--mantine-color-text);
outline: none;
}
.popoverSearch input::placeholder {
color: var(--mantine-color-placeholder);
}
.popoverList {
max-height: 240px;
overflow-y: auto;
padding: 4px;
}
.popoverEmpty {
padding: 12px 8px;
color: var(--mantine-color-dimmed);
font-size: 12.5px;
text-align: center;
}
.popoverItem {
appearance: none;
width: 100%;
border: 0;
background: transparent;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
font: inherit;
font-size: 13px;
color: var(--mantine-color-text);
cursor: pointer;
text-align: left;
}
.popoverItemHover {
background: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
}
.popoverItemDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.popoverItemName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.popoverCreatePlus {
width: 14px;
height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--mantine-color-dimmed);
}
.headerChip {
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 14px;
border-radius: 8px;
font-size: 22px;
font-weight: 600;
line-height: 1;
letter-spacing: -0.005em;
text-decoration: none;
user-select: none;
transition: filter 100ms ease;
}
.headerChip:hover {
filter: brightness(0.97);
}
.headerDot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 14px 12px;
margin: 0 -12px;
border-radius: 8px;
text-decoration: none;
color: inherit;
cursor: pointer;
transition: background-color 80ms ease;
}
.row + .row {
border-top: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.row:hover {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
}
.row:hover + .row,
.row:has(+ .row:hover) {
border-top-color: transparent;
}
.rowMain {
display: flex;
gap: 12px;
min-width: 0;
flex: 1;
}
.rowIcon {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--mantine-color-dimmed);
margin-top: 2px;
}
.rowBody {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.rowTitle {
font-size: 15px;
font-weight: 500;
color: var(--mantine-color-text);
line-height: 1.3;
word-break: break-word;
}
.rowChips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chipMore {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 8px;
border-radius: 4px;
font-size: 12.5px;
font-weight: 500;
line-height: 1;
color: var(--mantine-color-dimmed);
background: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
user-select: none;
white-space: nowrap;
}
.rowMeta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
color: var(--mantine-color-dimmed);
font-size: 13px;
}
.rowDate {
color: var(--mantine-color-dimmed);
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
}
.metaDot {
font-size: 14px;
line-height: 1;
color: var(--mantine-color-dimmed);
}
.chipLink {
text-decoration: none;
color: inherit;
display: inline-flex;
}
.chipLink:hover {
filter: brightness(0.97);
}
@@ -0,0 +1,157 @@
import {
keepPreviousData,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
addLabelsToPage,
findPagesByLabel,
getLabelInfo,
getPageLabels,
getWorkspaceLabels,
removeLabelFromPage,
} from "@/features/label/services/label-service.ts";
import {
IAddLabels,
ILabel,
IRemoveLabel,
} from "@/features/label/types/label.types.ts";
import { IPagination } from "@/lib/types.ts";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
const PAGE_LABELS_KEY = (pageId: string) => ["page-labels", pageId];
const WORKSPACE_LABELS_KEY = (query?: string) => ["workspace-labels", query ?? ""];
export function usePageLabelsQuery(pageId: string | undefined) {
return useQuery({
queryKey: PAGE_LABELS_KEY(pageId ?? ""),
queryFn: () => getPageLabels({ pageId: pageId as string, limit: 100 }),
enabled: !!pageId,
});
}
export function useWorkspaceLabelsQuery(query: string, enabled: boolean) {
return useQuery({
queryKey: WORKSPACE_LABELS_KEY(query),
queryFn: () => getWorkspaceLabels({ type: "page", query, limit: 50 }),
enabled,
staleTime: 30 * 1000,
});
}
export function useAddLabelsMutation(pageId: string | undefined) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<ILabel[], Error, IAddLabels>({
mutationFn: (data) => addLabelsToPage(data),
onSuccess: (added) => {
queryClient.setQueryData<IPagination<ILabel>>(
PAGE_LABELS_KEY(pageId ?? ""),
(cache) => {
if (!cache) return cache;
const existing = new Set(cache.items.map((l) => l.id));
const additions = added.filter((l) => !existing.has(l.id));
if (additions.length === 0) return cache;
return { ...cache, items: [...cache.items, ...additions] };
},
);
queryClient.setQueriesData<IPagination<ILabel>>(
{ queryKey: ["workspace-labels"] },
(cache) => {
if (!cache) return cache;
const existing = new Set(cache.items.map((l) => l.id));
const additions = added.filter((l) => !existing.has(l.id));
if (additions.length === 0) return cache;
return {
...cache,
items: [...cache.items, ...additions].sort((a, b) =>
a.name.localeCompare(b.name),
),
};
},
);
queryClient.invalidateQueries({ queryKey: ["label-pages"] });
queryClient.invalidateQueries({ queryKey: ["label-info"] });
},
onError: (error: any) => {
notifications.show({
message: error?.response?.data?.message ?? t("Failed to add label"),
color: "red",
});
},
});
}
export function useRemoveLabelMutation(pageId: string | undefined) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IRemoveLabel>({
mutationFn: (data) => removeLabelFromPage(data),
onSuccess: (_data, variables) => {
const cache = queryClient.getQueryData<IPagination<ILabel>>(
PAGE_LABELS_KEY(pageId ?? ""),
);
if (cache) {
queryClient.setQueryData<IPagination<ILabel>>(
PAGE_LABELS_KEY(pageId ?? ""),
{
...cache,
items: cache.items.filter((l) => l.id !== variables.labelId),
},
);
}
queryClient.invalidateQueries({ queryKey: ["workspace-labels"] });
queryClient.invalidateQueries({ queryKey: ["label-pages"] });
queryClient.invalidateQueries({ queryKey: ["label-info"] });
},
onError: () => {
notifications.show({
message: t("Failed to remove label"),
color: "red",
});
},
});
}
export function useLabelInfoQuery(name: string, spaceId?: string) {
return useQuery({
queryKey: ["label-info", name, spaceId ?? ""],
queryFn: () => getLabelInfo({ name, type: "page", spaceId }),
enabled: !!name,
placeholderData: keepPreviousData,
});
}
const LABEL_PAGES_LIMIT = 25;
export function useLabelPagesQuery(
name: string,
query: string,
spaceId?: string,
) {
return useInfiniteQuery({
queryKey: ["label-pages", name, query, spaceId ?? ""],
queryFn: ({ pageParam }) =>
findPagesByLabel({
name,
query,
spaceId,
cursor: pageParam,
limit: LABEL_PAGES_LIMIT,
}),
enabled: !!name,
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage
? (lastPage.meta.nextCursor ?? undefined)
: undefined,
placeholderData: keepPreviousData,
});
}
@@ -0,0 +1,55 @@
import api from "@/lib/api-client";
import { IPagination } from "@/lib/types.ts";
import {
IAddLabels,
IFindPagesByLabelParams,
ILabel,
ILabelInfo,
ILabelInfoParams,
ILabelPageItem,
IListLabelsParams,
IPageLabelsParams,
IRemoveLabel,
} from "@/features/label/types/label.types.ts";
export async function getPageLabels(
params: IPageLabelsParams,
): Promise<IPagination<ILabel>> {
const req = await api.post<IPagination<ILabel>>("/pages/labels", params);
return req.data;
}
export async function getWorkspaceLabels(
params: IListLabelsParams,
): Promise<IPagination<ILabel>> {
const req = await api.post<IPagination<ILabel>>("/labels", params);
return req.data;
}
export async function addLabelsToPage(
data: IAddLabels,
): Promise<ILabel[]> {
const req = await api.post<ILabel[]>("/pages/labels/add", data);
return req.data;
}
export async function removeLabelFromPage(data: IRemoveLabel): Promise<void> {
await api.post("/pages/labels/remove", data);
}
export async function getLabelInfo(
params: ILabelInfoParams,
): Promise<ILabelInfo> {
const req = await api.post<ILabelInfo>("/labels/info", params);
return req.data;
}
export async function findPagesByLabel(
params: IFindPagesByLabelParams,
): Promise<IPagination<ILabelPageItem>> {
const req = await api.post<IPagination<ILabelPageItem>>(
"/labels/pages",
params,
);
return req.data;
}
@@ -0,0 +1,70 @@
import { QueryParams } from "@/lib/types.ts";
export type LabelType = "page" | "space";
export interface ILabel {
id: string;
name: string;
type: LabelType;
workspaceId: string;
createdAt: string;
updatedAt: string;
}
export interface IAddLabels {
pageId: string;
names: string[];
}
export interface IRemoveLabel {
pageId: string;
labelId: string;
}
export interface IPageLabelsParams {
pageId: string;
cursor?: string;
limit?: number;
}
export interface IListLabelsParams {
type: LabelType;
query?: string;
cursor?: string;
limit?: number;
}
export interface ILabelInfo {
name: string;
usageCount: number;
}
export interface ILabelPageItem {
id: string;
slugId: string;
title: string | null;
icon: string | null;
spaceId: string;
createdAt: string;
updatedAt: string;
space: {
id: string;
name: string;
slug: string;
logo: string | null;
} | null;
creator: { id: string; name: string; avatarUrl: string | null } | null;
labels: { id: string; name: string }[];
}
export interface IFindPagesByLabelParams extends QueryParams {
labelId?: string;
name?: string;
spaceId?: string;
}
export interface ILabelInfoParams {
name: string;
type: LabelType;
spaceId?: string;
}
@@ -0,0 +1,15 @@
import { format, isThisYear, isToday, isYesterday } from "date-fns";
import i18n from "@/i18n.ts";
export function formatLabelListDate(date: Date): string {
if (isToday(date)) {
return i18n.t("Today, {{time}}", { time: format(date, "h:mma") });
}
if (isYesterday(date)) {
return i18n.t("Yesterday, {{time}}", { time: format(date, "h:mma") });
}
if (isThisYear(date)) {
return format(date, "MMM dd");
}
return format(date, "MMM dd, yyyy");
}
@@ -0,0 +1,55 @@
type LabelColor = {
bg: string;
fg: string;
dot: string;
};
const LABEL_PALETTE: Record<string, LabelColor> = {
slate: { bg: "#eef1f5", fg: "#3b475a", dot: "#6b7a90" },
blue: { bg: "#e6f0ff", fg: "#1e4fbf", dot: "#3b82f6" },
green: { bg: "#e3f5ea", fg: "#1f7a47", dot: "#22a05a" },
amber: { bg: "#fbf0d9", fg: "#8a5a00", dot: "#d99c1f" },
red: { bg: "#fde6e6", fg: "#a02b2b", dot: "#dc4a4a" },
purple: { bg: "#efe9fb", fg: "#5a3aa8", dot: "#8b6bd9" },
pink: { bg: "#fce6ee", fg: "#a8336d", dot: "#dc6699" },
teal: { bg: "#daf1ee", fg: "#1f6f6a", dot: "#2fa39a" },
};
const PALETTE_KEYS = Object.keys(LABEL_PALETTE);
const DARK_PALETTE: Record<string, LabelColor> = {
slate: { bg: "#2a3140", fg: "#c8d3e3", dot: "#7e8da8" },
blue: { bg: "#152a52", fg: "#a9c4ff", dot: "#5b9aff" },
green: { bg: "#143b27", fg: "#9ce3b8", dot: "#3ec97c" },
amber: { bg: "#3d2c0e", fg: "#f5cf85", dot: "#e6b34a" },
red: { bg: "#401a1a", fg: "#f1a8a8", dot: "#e26565" },
purple: { bg: "#2a1f4d", fg: "#c8b4f4", dot: "#a48ce6" },
pink: { bg: "#3c1a2a", fg: "#f3a9c9", dot: "#e07ab0" },
teal: { bg: "#103633", fg: "#92d5cf", dot: "#48b8af" },
};
function hashName(name: string): number {
// Per-char accumulation with 31. Note: 31 ≡ -1 (mod 8), so the low bits of
// this hash are highly correlated across short strings — `% 8` would cluster.
let h = 0;
for (let i = 0; i < name.length; i++) {
h = (Math.imul(h, 31) + name.charCodeAt(i)) | 0;
}
// Murmur3 fmix32 finalizer — avalanches high bits into low bits so the
// subsequent `% palette.length` (small power of two) is well-distributed.
h ^= h >>> 16;
h = Math.imul(h, 0x85ebca6b);
h ^= h >>> 13;
h = Math.imul(h, 0xc2b2ae35);
h ^= h >>> 16;
return h >>> 0;
}
export function getLabelColor(
name: string,
scheme: "light" | "dark" = "light",
): LabelColor {
const key = PALETTE_KEYS[hashName(name) % PALETTE_KEYS.length];
const palette = scheme === "dark" ? DARK_PALETTE : LABEL_PALETTE;
return palette[key];
}
@@ -0,0 +1,3 @@
export function normalizeLabelName(name: string): string {
return name.trim().replace(/\s+/g, "-").toLowerCase();
}
@@ -18,6 +18,7 @@ import { useBacklinksCountQuery } from "@/features/page-details/queries/backlink
import { BacklinksModal } from "./backlinks-modal";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { LabelsSection } from "@/features/label/components/labels-section.tsx";
export function PageDetailsAside() {
const { pageSlug } = useParams();
@@ -61,6 +62,11 @@ export function PageDetailsAside() {
isLoading={countsLoading}
onClick={openModal}
/>
<LabelsSection
pageId={page.id}
canEdit={page.permissions?.canEdit ?? false}
/>
</Stack>
<BacklinksModal
@@ -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 (
<>
<ConnectionWarning />
{!readOnly && <PageStateSegmentedControl size="xs" />}
{!readOnly && <PageEditModeToggle size="xs" />}
<PageShareModal 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 = () => {
@@ -8,7 +8,7 @@ interface Props {
}
export default function PageHeader({ readOnly }: Props) {
return (
<div className={classes.header}>
<div className={classes.header} data-page-header="true">
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
<Breadcrumb />
@@ -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: (
<Text size="sm">
{t("Restore '{{title}}' and its sub-pages?", {
title: title || t("Untitled"),
})}
</Text>
),
centered: true,
labels: { confirm: t("Restore"), cancel: t("Cancel") },
confirmProps: { color: "blue" },
onConfirm,
});
};
return { openRestoreModal } as const;
}
@@ -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<IPage>(["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<SpaceTreeNode>(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<IPage>(["pages", restoredPage.id], merge);
queryClient.setQueryData<IPage>(["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" });
},
});
}
@@ -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 (
<Paper radius="sm" mb="md" px="md" py="xs" bg="red.0">
<Group justify="space-between" wrap="wrap" gap="sm">
<Text size="sm" style={{ flex: 1, minWidth: 0 }}>
<Trans
i18nKey="<b>{{name}}</b> moved this page to Trash {{time}}."
values={{ name: actorName, time: deletedTimeAgo }}
components={{ b: <Text span fw={600} inherit /> }}
/>
</Text>
{hasAnyAction && (
<>
<Group gap="xs" wrap="nowrap" visibleFrom="sm">
{canRestore && (
<Button
size="xs"
variant="light"
color="red"
leftSection={<IconRestore size={16} />}
onClick={handleRestore}
loading={restorePageMutation.isPending}
>
{t("Restore page")}
</Button>
)}
{canPermanentlyDelete && (
<Button
size="xs"
variant="light"
color="red"
leftSection={<IconTrash size={16} />}
onClick={handlePermanentDelete}
loading={deletePageMutation.isPending}
>
{t("Permanently delete")}
</Button>
)}
</Group>
<Group gap="xs" wrap="nowrap" hiddenFrom="sm">
{canRestore && (
<Tooltip label={t("Restore page")} withArrow>
<ActionIcon
size="lg"
variant="default"
onClick={handleRestore}
loading={restorePageMutation.isPending}
aria-label={t("Restore page")}
>
<IconRestore size={18} />
</ActionIcon>
</Tooltip>
)}
{canPermanentlyDelete && (
<Tooltip label={t("Permanently delete")} withArrow>
<ActionIcon
size="lg"
variant="light"
color="red"
onClick={handlePermanentDelete}
loading={deletePageMutation.isPending}
aria-label={t("Permanently delete")}
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
)}
</Group>
</>
)}
</Group>
</Paper>
);
}
@@ -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 (
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red">
<Text size="sm" lh={1.35}>
{t("Pages in trash will be permanently deleted after {{count}} days.", {
count: retentionDays,
})}
</Text>
</Alert>
);
}
@@ -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: (
<Text size="sm">
{t("Restore '{{title}}' and its sub-pages?", {
title: pageTitle || "Untitled",
})}
</Text>
),
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() {
<Title order={2}>{t("Trash")}</Title>
</Group>
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red">
<Text size="sm">
{t("Pages in trash will be permanently deleted after {{count}} days.", { count: workspace?.trashRetentionDays ?? 30 })}
</Text>
</Alert>
<TrashBanner />
{isLoading || !deletedPages ? (
<></>
@@ -181,7 +158,10 @@ export default function Trash() {
<Menu.Item
leftSection={<IconRestore size={16} />}
onClick={() =>
openRestoreModal(page.id, page.title)
openRestoreModal({
title: page.title,
onConfirm: () => handleRestorePage(page.id),
})
}
>
{t("Restore")}
@@ -0,0 +1,5 @@
import { atom } from "jotai";
export type OpenMap = Record<string, boolean>;
export const openTreeNodesAtom = atom<OpenMap>({});
@@ -1,5 +0,0 @@
import { atom } from "jotai";
import { TreeApi } from "react-arborist";
import { SpaceTreeNode } from "../types";
export const treeApiAtom = atom<TreeApi<SpaceTreeNode> | null>(null);
@@ -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;
}
@@ -0,0 +1,9 @@
import styles from './doc-tree-drag-preview.module.css';
type Props = {
label: string;
};
export function DocTreeDragPreview({ label }: Props) {
return <div className={styles.preview}>{label || 'Untitled'}</div>;
}
@@ -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 (
<div
className={styles.dropLine}
data-edge="top"
data-blocked={blocked || undefined}
style={style}
/>
);
}
if (inst.type === 'reorder-below') {
return (
<div
className={styles.dropLine}
data-edge="bottom"
data-blocked={blocked || undefined}
style={style}
/>
);
}
// 'combine' (make-child) is rendered via [data-receiving-drop] on the row itself.
return null;
}
@@ -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<T extends object> = {
node: TreeNode<T>;
level: number;
isLastSibling: boolean;
openIds: ReadonlySet<string>;
selectedId?: string;
// Roving tabindex: the single row that currently carries tabIndex={0}.
activeId?: string;
renderRow: (props: RenderRowProps<T>) => ReactNode;
indentPerLevel: number;
onMove: (sourceId: string, op: DropOp) => void | Promise<void>;
onToggle: (id: string, isOpen: boolean) => void;
readOnly: boolean;
disableDrag?: (node: TreeNode<T>) => boolean;
disableDrop?: (node: TreeNode<T>) => boolean;
getDragLabel: (node: TreeNode<T>) => 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<T>[];
};
const DRAG_TYPE = 'doc-tree-item';
const AUTO_EXPAND_MS = 500;
function DocTreeRowInner<T extends object>(props: Props<T>) {
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<HTMLElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [instruction, setInstruction] = useState<Instruction | null>(null);
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | 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(<DocTreeDragPreview label={getDragLabel(node)} />);
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
// <a>). The outer <li> 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 (
<div
className={styles.rowWrapper}
style={{ paddingLeft: level * indentPerLevel }}
>
<div
className={styles.node}
data-dragging={isDragging || undefined}
data-selected={isSelected || undefined}
data-receiving-drop={
receivingDrop === 'make-child'
? blocked
? 'make-child-blocked'
: 'make-child'
: undefined
}
>
{renderRow({
node,
level,
isOpen,
hasChildren,
isSelected,
isDragging,
isReceivingDrop: receivingDrop,
rowRef,
tabIndex: activeId === node.id ? 0 : -1,
treeItemProps,
toggleOpen,
})}
</div>
{instruction && (
<DocTreeDropIndicator
instruction={instruction}
indentPx={level * indentPerLevel}
/>
)}
</div>
);
}
// 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<T extends object>(
prev: Props<T>,
next: Props<T>,
): 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;
@@ -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<T extends object> = {
node: TreeNode<T>;
level: number;
isOpen: boolean;
hasChildren: boolean;
isSelected: boolean;
isDragging: boolean;
isReceivingDrop: 'before' | 'after' | 'make-child' | null;
rowRef: Ref<HTMLElement>;
// 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<T extends object> = {
data: TreeNode<T>[];
openIds: ReadonlySet<string>;
selectedId?: string;
renderRow: (props: RenderRowProps<T>) => ReactNode;
indentPerLevel?: number;
rowHeight?: number;
emptyState?: ReactNode;
onMove: (sourceId: string, op: DropOp) => void | Promise<void>;
onToggle: (id: string, isOpen: boolean) => void;
onSelect?: (id: string) => void;
readOnly?: boolean;
disableDrag?: (node: TreeNode<T>) => boolean;
disableDrop?: (node: TreeNode<T>) => boolean;
getDragLabel: (node: TreeNode<T>) => string;
uniqueContextId?: symbol;
// Accessible name for the tree itself (e.g. "Pages"). Rendered as
// aria-label on the <ul role="tree"> so screen readers announce what
// collection of items the user has entered.
'aria-label'?: string;
};
export type DocTreeApi = {
select: (
id: string,
opts?: { scrollIntoView?: boolean; focus?: boolean },
) => void;
scrollTo: (id: string) => void;
focus: (id: string) => void;
};
type FlatRow<T extends object> = {
node: TreeNode<T>;
level: number;
isLastSibling: boolean;
};
// DFS-walk the tree, emitting only the visible nodes (root nodes always, plus
// the descendants of nodes whose id is in `openIds`). Each emitted row carries
// the precomputed `level` and `isLastSibling` it needs.
function flattenVisible<T extends object>(
data: TreeNode<T>[],
openIds: ReadonlySet<string>,
): FlatRow<T>[] {
const out: FlatRow<T>[] = [];
const walk = (nodes: TreeNode<T>[], level: number) => {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
out.push({ node, level, isLastSibling: i === nodes.length - 1 });
if (openIds.has(node.id) && node.children?.length) {
walk(node.children, level + 1);
}
}
};
walk(data, 0);
return out;
}
type RowElementMap = Map<string, HTMLElement>;
function DocTreeInner<T extends object>(
props: DocTreeProps<T>,
ref: Ref<DocTreeApi>,
) {
const {
data,
openIds,
selectedId,
renderRow,
indentPerLevel = 16,
rowHeight = 32,
onMove,
onToggle,
onSelect,
readOnly = false,
disableDrag,
disableDrop,
getDragLabel,
uniqueContextId,
emptyState,
'aria-label': ariaLabel,
} = props;
const scrollRef = useRef<HTMLDivElement>(null);
const rowElementsRef = useRef<RowElementMap>(new Map());
// Set by the keyboard handler when the navigation target hasn't been
// virtualized yet. Consumed by registerRowElement when the row mounts.
const pendingFocusIdRef = useRef<string | null>(null);
// Typeahead state: accumulated buffer, plus the timer that clears it after
// ~500ms of no typing. Refs only — no re-render needed per keystroke.
const typeaheadBufferRef = useRef('');
const typeaheadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Roving tabindex: the row most-recently focused by the user. Falls back
// to selectedId, then to the first visible row, when the tracked id is
// gone from the flat list (e.g. its branch was collapsed).
const [activeId, setActiveId] = useState<string | undefined>(undefined);
const contextId = useMemo(
() => uniqueContextId ?? Symbol('doc-tree'),
[uniqueContextId],
);
const registerRowElement = useCallback(
(id: string, el: HTMLElement | null) => {
if (el) {
rowElementsRef.current.set(id, el);
if (pendingFocusIdRef.current === id) {
pendingFocusIdRef.current = null;
// rAF lets the virtualizer settle layout/transform before focus,
// so the freshly-scrolled-in row is actually painted in view.
requestAnimationFrame(() => el.focus());
}
} else {
rowElementsRef.current.delete(id);
}
},
[],
);
// Stable live tree accessor — keeps the row useEffect deps stable across
// tree mutations.
const rootDataRef = useRef(data);
rootDataRef.current = data;
const getRootData = useCallback(() => rootDataRef.current, []);
// Flat visible list drives virtualization. Re-flattens on data or openIds
// change — cheap O(N) walk of the loaded tree.
const flat = useMemo(
() => flattenVisible(data, openIds),
[data, openIds],
);
// Membership lookup for the flat list. Used to validate activeId/selectedId
// before promoting them to the effective active row.
const flatIds = useMemo(() => new Set(flat.map((r) => r.node.id)), [flat]);
// Effective active row for tabindex purposes. Prefers user-focused row,
// then the currently selected page, then the first visible row. The user's
// arrow / Home / End / typeahead navigation updates activeId via the focus
// event delegated on the <ul>; explicit clicks also flow through focus.
const effectiveActiveId = useMemo(() => {
if (activeId && flatIds.has(activeId)) return activeId;
if (selectedId && flatIds.has(selectedId)) return selectedId;
return flat[0]?.node.id;
}, [activeId, selectedId, flatIds, flat]);
const virtualizer = useVirtualizer({
count: flat.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => rowHeight,
overscan: 10,
});
useImperativeHandle(
ref,
(): DocTreeApi => ({
select: (id, opts) => {
onSelect?.(id);
const idx = flat.findIndex((r) => r.node.id === id);
if (idx >= 0 && opts?.scrollIntoView) {
virtualizer.scrollToIndex(idx, { align: 'auto' });
}
if (opts?.focus) rowElementsRef.current.get(id)?.focus();
},
scrollTo: (id) => {
const idx = flat.findIndex((r) => r.node.id === id);
if (idx >= 0) virtualizer.scrollToIndex(idx, { align: 'auto' });
},
focus: (id) => {
rowElementsRef.current.get(id)?.focus();
},
}),
[onSelect, flat, virtualizer],
);
// Auto-scroll the container during drag so users can target rows currently
// scrolled off-screen. Scoped to drags originating in this DocTree instance
// via uniqueContextId.
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
return autoScrollForElements({
element: el,
canScroll: ({ source }) =>
source.data.uniqueContextId === contextId,
});
}, [contextId]);
// Scroll the selected row into view when it enters the flat list. If the
// row is already fully visible, leave the user's scroll position alone —
// only scroll when it's off-screen, and when we do, center it for context.
// Deep pages may not be in flat at the moment selectedId changes (ancestors
// still lazy-loading); the effect re-fires once flat contains the row.
// Guarded by a ref so subsequent flat changes don't fight manual scroll.
const lastScrolledIdRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (!selectedId) {
lastScrolledIdRef.current = undefined;
return;
}
if (lastScrolledIdRef.current === selectedId) return;
const idx = flat.findIndex((r) => r.node.id === selectedId);
if (idx < 0) return;
const containerHeight = scrollRef.current?.clientHeight ?? 0;
const scrollOffset = virtualizer.scrollOffset ?? 0;
const item = virtualizer
.getVirtualItems()
.find((v) => v.index === idx);
const isFullyVisible =
!!item &&
item.start >= scrollOffset &&
item.start + item.size <= scrollOffset + containerHeight;
if (!isFullyVisible) {
virtualizer.scrollToIndex(idx, { align: 'center' });
}
lastScrolledIdRef.current = selectedId;
}, [selectedId, flat, virtualizer]);
// Keyboard navigation handler — single delegated listener on the <ul role="tree">.
// The focused row is identified by walking up the DOM to the nearest element
// carrying data-row-id, so this works whether the user has focused the row
// itself or one of its inner buttons (chevron, +). No per-row re-renders;
// focus is moved via .focus() on the registered element, with a pending-id
// hand-off when the target row is currently virtualized out of view.
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLUListElement>) => {
// Ctrl/Alt/Meta are reserved for browser/OS shortcuts; bail out.
// Shift is allowed through so typeahead can match capital letters.
if (e.altKey || e.ctrlKey || e.metaKey) return;
const isNavKey =
!e.shiftKey &&
(e.key === 'ArrowDown' ||
e.key === 'ArrowUp' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.key === 'Home' ||
e.key === 'End');
// Star expands all sibling subtrees of the focused row (WAI-ARIA tree
// pattern). Allowed with Shift since on most keyboards Shift+8 is how
// "*" is produced. Handled separately from typeahead.
const isStarKey = e.key === '*';
// Space activates the focused row — same effect as clicking it. Native
// <a> doesn't get this for free (only <button> does), so we wire it up
// explicitly to satisfy the WAI-ARIA tree pattern.
const isActivateKey = e.key === ' ';
// Single printable character → typeahead. e.key.length === 1 excludes
// multi-char names like "ArrowDown", "Enter", "Tab", etc.
const isTypeahead =
e.key.length === 1 && !isNavKey && !isStarKey && !isActivateKey;
if (!isNavKey && !isTypeahead && !isStarKey && !isActivateKey) return;
const target = e.target as HTMLElement;
if (target.matches('input, textarea, [contenteditable="true"]')) return;
const rowEl = target.closest('[data-row-id]');
if (!rowEl) return;
const id = rowEl.getAttribute('data-row-id');
if (!id) return;
const idx = flat.findIndex((r) => r.node.id === id);
if (idx < 0) return;
const focusByIndex = (targetIdx: number) => {
if (targetIdx < 0 || targetIdx >= flat.length) return;
const targetId = flat[targetIdx].node.id;
const existing = rowElementsRef.current.get(targetId);
if (existing) {
existing.focus();
} else {
pendingFocusIdRef.current = targetId;
virtualizer.scrollToIndex(targetIdx, { align: 'auto' });
}
};
// Space activates the focused row by synthesizing a click on the
// registered row element (its <a> Link). Skip if focus is on an inner
// button (chevron, +, menu) — those handle Space via native button
// semantics, and intercepting here would block their default behavior.
if (isActivateKey) {
const registered = rowElementsRef.current.get(id);
if (target === registered) {
e.preventDefault();
registered.click();
}
return;
}
// Typeahead: accumulate printable chars, jump to next row whose label
// starts with the buffer. Same-letter presses cycle through matches; a
// multi-char buffer searches from the current row so the user can
// refine the prefix. Buffer resets after ~500ms of no typing.
if (isTypeahead) {
e.preventDefault();
const wasEmpty = typeaheadBufferRef.current.length === 0;
typeaheadBufferRef.current = (
typeaheadBufferRef.current + e.key
).toLowerCase();
const buffer = typeaheadBufferRef.current;
if (typeaheadTimerRef.current) {
clearTimeout(typeaheadTimerRef.current);
}
typeaheadTimerRef.current = setTimeout(() => {
typeaheadBufferRef.current = '';
typeaheadTimerRef.current = null;
}, 500);
// Single-char buffer cycles to the next match (start at idx + 1);
// multi-char buffer can keep matching the current row.
const startIdx = wasEmpty ? (idx + 1) % flat.length : idx;
for (let i = 0; i < flat.length; i++) {
const probeIdx = (startIdx + i) % flat.length;
const label = getDragLabel(flat[probeIdx].node).toLowerCase();
if (label.startsWith(buffer)) {
focusByIndex(probeIdx);
break;
}
}
return;
}
const row = flat[idx];
const hasChildren =
(row.node.children && row.node.children.length > 0) ||
(row.node as { hasChildren?: boolean }).hasChildren === true;
const isOpen = openIds.has(row.node.id);
// Asterisk: expand every sibling subtree at the focused row's level.
// Walks the authoritative tree (not flat, which only carries visible
// rows) so we also expand siblings whose own subtree is currently
// collapsed. Focus and selection stay put per the WAI-ARIA pattern.
if (isStarKey) {
e.preventDefault();
const info = treeModel.siblingsOf(rootDataRef.current, row.node.id);
if (info) {
for (const sib of info.siblings) {
const sibHasChildren =
(sib.children && sib.children.length > 0) ||
(sib as { hasChildren?: boolean }).hasChildren === true;
if (sibHasChildren && !openIds.has(sib.id)) {
onToggle(sib.id, true);
}
}
}
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
focusByIndex(idx + 1);
break;
case 'ArrowUp':
e.preventDefault();
focusByIndex(idx - 1);
break;
case 'ArrowRight':
e.preventDefault();
if (hasChildren && !isOpen) {
onToggle(row.node.id, true);
} else if (
isOpen &&
row.node.children &&
row.node.children.length > 0
) {
focusByIndex(idx + 1);
}
break;
case 'ArrowLeft': {
e.preventDefault();
if (isOpen && hasChildren) {
onToggle(row.node.id, false);
} else {
// Move to parent — first preceding row with smaller level.
// Bounded by sibling-count to parent in the flat list; tree depth
// and sibling counts are small in practice.
const currentLevel = row.level;
for (let i = idx - 1; i >= 0; i--) {
if (flat[i].level < currentLevel) {
focusByIndex(i);
break;
}
}
}
break;
}
case 'Home':
e.preventDefault();
focusByIndex(0);
break;
case 'End':
e.preventDefault();
focusByIndex(flat.length - 1);
break;
}
},
[flat, openIds, onToggle, virtualizer, getDragLabel],
);
// Clear the typeahead timer if the component unmounts mid-buffer.
useEffect(
() => () => {
if (typeaheadTimerRef.current) clearTimeout(typeaheadTimerRef.current);
},
[],
);
// Event-delegated focus tracking — when any descendant (a row's Link, or an
// inner action button) gains focus, mark the enclosing row as active. Keeps
// tabIndex aligned with the user's current position whether they got there
// by click, arrow nav, or focusByIndex's programmatic .focus() call.
const handleFocusIn = useCallback(
(e: React.FocusEvent<HTMLUListElement>) => {
const rowEl = (e.target as HTMLElement).closest('[data-row-id]');
const id = rowEl?.getAttribute('data-row-id');
if (id) setActiveId(id);
},
[],
);
if (data.length === 0 && emptyState) {
return <div className={styles.treeContainer}>{emptyState}</div>;
}
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
return (
<div ref={scrollRef} className={styles.treeContainer}>
<ul
role="tree"
aria-label={ariaLabel}
onKeyDown={handleKeyDown}
onFocus={handleFocusIn}
style={{
position: 'relative',
height: totalSize,
margin: 0,
padding: 0,
listStyle: 'none',
}}
>
{virtualItems.map((virtualItem) => {
const row = flat[virtualItem.index];
return (
<li
key={row.node.id}
// role="none" — the treeitem role lives on the focusable child
// (the row's <a>), so screen readers announce "treeitem" on
// navigation. The <li> is just layout glue.
role="none"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<DocTreeRow
node={row.node}
level={row.level}
isLastSibling={row.isLastSibling}
openIds={openIds}
selectedId={selectedId}
activeId={effectiveActiveId}
renderRow={renderRow}
indentPerLevel={indentPerLevel}
onMove={onMove}
onToggle={onToggle}
readOnly={readOnly}
disableDrag={disableDrag}
disableDrop={disableDrop}
getDragLabel={getDragLabel}
contextId={contextId}
registerRowElement={registerRowElement}
getRootData={getRootData}
/>
</li>
);
})}
</ul>
</div>
);
}
export const DocTree = forwardRef(DocTreeInner) as <T extends object>(
props: DocTreeProps<T> & { ref?: Ref<DocTreeApi> },
) => ReturnType<typeof DocTreeInner>;
@@ -0,0 +1,259 @@
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { ActionIcon, Menu, rem } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import {
IconArrowRight,
IconCopy,
IconDotsVertical,
IconFileExport,
IconLink,
IconStar,
IconStarFilled,
IconTrash,
} from "@tabler/icons-react";
import ExportModal from "@/components/common/export-modal";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import CopyPageModal from "@/features/page/components/copy-page-modal.tsx";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { duplicatePage } from "@/features/page/services/page-service.ts";
import { useClipboard } from "@/hooks/use-clipboard";
import { getAppUrl } from "@/lib/config.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import {
useFavoriteIds,
useAddFavoriteMutation,
useRemoveFavoriteMutation,
} from "@/features/favorite/queries/favorite-query";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
export interface NodeMenuProps {
node: SpaceTreeNode;
canEdit: boolean;
}
export function NodeMenu({ node, canEdit }: NodeMenuProps) {
const { t } = useTranslation();
const clipboard = useClipboard({ timeout: 500 });
const { spaceSlug } = useParams();
const { openDeleteModal } = useDeletePageModal();
const { handleDelete } = useTreeMutation(node.spaceId);
const [data, setData] = useAtom(treeDataAtom);
const emit = useQueryEmit();
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const [
movePageModalOpened,
{ open: openMovePageModal, close: closeMoveSpaceModal },
] = useDisclosure(false);
const [
copyPageModalOpened,
{ open: openCopyPageModal, close: closeCopySpaceModal },
] = useDisclosure(false);
const favoriteIds = useFavoriteIds("page", node.spaceId);
const addFavorite = useAddFavoriteMutation();
const removeFavorite = useRemoveFavoriteMutation();
const isFavorited = favoriteIds.has(node.id);
const handleCopyLink = () => {
const pageUrl =
getAppUrl() + buildPageUrl(spaceSlug, node.slugId, node.name);
clipboard.copy(pageUrl);
notifications.show({ message: t("Link copied") });
};
const handleDuplicatePage = async () => {
try {
const duplicatedPage = await duplicatePage({ pageId: node.id });
// figure out parent + insertion index
const siblings = treeModel.siblingsOf(data, node.id);
const parentId = siblings?.parentId ?? null;
const currentIndex = siblings?.index ?? 0;
const newIndex = currentIndex + 1;
const treeNodeData: SpaceTreeNode = {
id: duplicatedPage.id,
slugId: duplicatedPage.slugId,
name: duplicatedPage.title,
position: duplicatedPage.position,
spaceId: duplicatedPage.spaceId,
parentPageId: duplicatedPage.parentPageId,
icon: duplicatedPage.icon,
hasChildren: duplicatedPage.hasChildren,
canEdit: true,
children: [],
};
setData((prev) =>
treeModel.insert(prev, parentId, treeNodeData, newIndex),
);
setTimeout(() => {
emit({
operation: "addTreeNode",
spaceId: node.spaceId,
payload: {
parentId,
index: newIndex,
data: treeNodeData,
},
});
}, 50);
notifications.show({ message: t("Page duplicated successfully") });
} catch (err: any) {
notifications.show({
message: err?.response?.data?.message || "An error occurred",
color: "red",
});
}
};
return (
<>
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon
variant="transparent"
c="gray"
aria-label={t("Page menu")}
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<IconDotsVertical
style={{ width: rem(20), height: rem(20) }}
stroke={2}
/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLink size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopyLink();
}}
>
{t("Copy link")}
</Menu.Item>
<Menu.Item
leftSection={
isFavorited ? <IconStarFilled size={16} /> : <IconStar size={16} />
}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (isFavorited) {
removeFavorite.mutate({ type: "page", pageId: node.id });
} else {
addFavorite.mutate({ type: "page", pageId: node.id });
}
}}
>
{isFavorited ? t("Remove from favorites") : t("Add to favorites")}
</Menu.Item>
<Menu.Item
leftSection={<IconFileExport size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openExportModal();
}}
>
{t("Export page")}
</Menu.Item>
{canEdit && (
<>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDuplicatePage();
}}
>
{t("Duplicate")}
</Menu.Item>
<Menu.Item
leftSection={<IconArrowRight size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openMovePageModal();
}}
>
{t("Move")}
</Menu.Item>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openCopyPageModal();
}}
>
{t("Copy to space")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
c="red"
leftSection={<IconTrash size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openDeleteModal({
onConfirm: () => handleDelete(node.id),
});
}}
>
{t("Move to trash")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
<MovePageModal
pageId={node.id}
slugId={node.slugId}
currentSpaceSlug={spaceSlug}
onClose={closeMoveSpaceModal}
open={movePageModalOpened}
/>
<CopyPageModal
pageId={node.id}
currentSpaceSlug={spaceSlug}
onClose={closeCopySpaceModal}
open={copyPageModalOpened}
/>
<ExportModal
type="page"
id={node.id}
open={exportOpened}
onClose={closeExportModal}
/>
</>
);
}
@@ -0,0 +1,288 @@
import { useRef } from "react";
import { Link, useParams } from "react-router-dom";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { ActionIcon, rem } from "@mantine/core";
import {
IconChevronDown,
IconChevronRight,
IconFileDescription,
IconPlus,
IconPointFilled,
} from "@tabler/icons-react";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import { queryClient } from "@/main.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getPageById } from "@/features/page/services/page-service.ts";
import {
useUpdatePageMutation,
fetchAllAncestorChildren,
} from "@/features/page/queries/page-query.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
import type { RenderRowProps } from "./doc-tree";
import { NodeMenu } from "./space-tree-node-menu";
import classes from "@/features/page/tree/styles/tree.module.css";
import { updateTreeNodeIcon } from "@/features/page/tree/utils/utils.ts";
type SpaceTreeRowProps = RenderRowProps<SpaceTreeNode> & {
readOnly: boolean;
};
export function SpaceTreeRow({
node,
isOpen,
hasChildren,
toggleOpen,
rowRef,
tabIndex,
treeItemProps,
readOnly,
}: SpaceTreeRowProps) {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const updatePageMutation = useUpdatePageMutation();
const [, setTreeData] = useAtom(treeDataAtom);
const emit = useQueryEmit();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const canEdit = !readOnly && node.canEdit !== false;
const pageUrl = buildPageUrl(spaceSlug, node.slugId, node.name);
const prefetchPage = () => {
timerRef.current = setTimeout(async () => {
const page = await queryClient.fetchQuery({
queryKey: ["pages", node.id],
queryFn: () => getPageById({ pageId: node.id }),
staleTime: 5 * 60 * 1000,
});
if (page?.slugId) {
queryClient.setQueryData(["pages", page.slugId], page);
}
}, 150);
};
const cancelPagePrefetch = () => {
if (timerRef.current) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
};
const handleUpdateNodeIcon = (nodeId: string, newIcon: string | null) => {
setTreeData((prev) =>
updateTreeNodeIcon(prev, nodeId, newIcon),
);
};
const handleEmojiIconClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleEmojiSelect = (emoji: { native: string }) => {
handleUpdateNodeIcon(node.id, emoji.native);
updatePageMutation
.mutateAsync({ pageId: node.id, icon: emoji.native })
.then((data) => {
setTimeout(() => {
emit({
operation: "updateOne",
spaceId: node.spaceId,
entity: ["pages"],
id: node.id,
payload: { icon: emoji.native, parentPageId: data.parentPageId },
});
}, 50);
});
};
const handleRemoveEmoji = () => {
handleUpdateNodeIcon(node.id, null);
updatePageMutation.mutateAsync({ pageId: node.id, icon: null });
setTimeout(() => {
emit({
operation: "updateOne",
spaceId: node.spaceId,
entity: ["pages"],
id: node.id,
payload: { icon: null },
});
}, 50);
};
const handleLoadChildren = async () => {
if (!node.hasChildren) return;
try {
const childrenTree = await fetchAllAncestorChildren({
pageId: node.id,
spaceId: node.spaceId,
});
setTreeData((prev) =>
treeModel.appendChildren(prev, node.id, childrenTree),
);
} catch (error) {
console.error("Failed to fetch children:", error);
}
};
return (
<Link
ref={rowRef as React.Ref<HTMLAnchorElement>}
to={pageUrl}
className={classes.node}
tabIndex={tabIndex}
{...treeItemProps}
onClick={() => {
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
onMouseEnter={prefetchPage}
onMouseLeave={cancelPagePrefetch}
>
<PageArrow
isOpen={isOpen}
hasChildren={hasChildren}
onToggle={toggleOpen}
/>
<div onClick={handleEmojiIconClick} style={{ marginRight: "4px" }}>
<EmojiPicker
onEmojiSelect={handleEmojiSelect}
icon={
node.icon ? node.icon : <IconFileDescription size="18" />
}
readOnly={!canEdit}
removeEmojiAction={handleRemoveEmoji}
actionIconProps={{ tabIndex: -1 }}
/>
</div>
<span className={classes.text}>{node.name || t("untitled")}</span>
<div className={classes.actions}>
<NodeMenu node={node} canEdit={canEdit} />
{canEdit && (
<CreateNode
node={node}
isOpen={isOpen}
hasChildren={hasChildren}
onToggle={toggleOpen}
onExpandTree={handleLoadChildren}
/>
)}
</div>
</Link>
);
}
interface PageArrowProps {
isOpen: boolean;
hasChildren: boolean;
onToggle: () => void;
}
function PageArrow({ isOpen, hasChildren, onToggle }: PageArrowProps) {
const { t } = useTranslation();
if (!hasChildren) {
return (
<span
aria-hidden
style={{
width: 20,
height: 20,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
color: "var(--mantine-color-gray-6)",
flexShrink: 0,
}}
>
<IconPointFilled size={8} />
</span>
);
}
return (
<ActionIcon
size={20}
variant="subtle"
c="gray"
aria-label={isOpen ? t("Collapse") : t("Expand")}
aria-expanded={isOpen}
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggle();
}}
>
{isOpen ? (
<IconChevronDown stroke={2} size={18} />
) : (
<IconChevronRight stroke={2} size={18} />
)}
</ActionIcon>
);
}
interface CreateNodeProps {
node: SpaceTreeNode;
isOpen: boolean;
hasChildren: boolean;
onToggle: () => void;
onExpandTree: () => Promise<void> | void;
}
function CreateNode({
node,
isOpen,
hasChildren,
onToggle,
onExpandTree,
}: CreateNodeProps) {
const { t } = useTranslation();
const { handleCreate } = useTreeMutation(node.spaceId);
async function handleClickCreate() {
if (node.hasChildren && !hasChildren) {
// Expand and lazy-load before creating a child. handleCreate reads the
// latest tree imperatively (via useStore) so we no longer need a
// setTimeout to wait for React to rerun the closure with fresh data.
if (!isOpen) onToggle();
await onExpandTree();
} else if (!isOpen) {
onToggle();
}
handleCreate(node.id);
}
return (
<ActionIcon
variant="transparent"
c="gray"
aria-label={t("Create page")}
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleClickCreate();
}}
>
<IconPlus style={{ width: rem(20), height: rem(20) }} stroke={2} />
</ActionIcon>
);
}
@@ -1,110 +1,47 @@
import {
NodeApi,
NodeRendererProps,
Tree,
TreeApi,
SimpleTree,
} from "react-arborist";
import { atom, useAtom } from "jotai";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core";
import {
fetchAllAncestorChildren,
useGetRootSidebarPagesQuery,
usePageQuery,
useUpdatePageMutation,
} from "@/features/page/queries/page-query.ts";
import { useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import classes from "@/features/page/tree/styles/tree.module.css";
import { ActionIcon, Box, Menu, rem, Text } from "@mantine/core";
import {
IconArrowRight,
IconChevronDown,
IconChevronRight,
IconCopy,
IconDotsVertical,
IconFileDescription,
IconFileExport,
IconLink,
IconPlus,
IconPointFilled,
IconStar,
IconStarFilled,
IconTrash,
} from "@tabler/icons-react";
import {
appendNodeChildrenAtom,
treeDataAtom,
} from "@/features/page/tree/atoms/tree-data-atom.ts";
import clsx from "clsx";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { openTreeNodesAtom } from "@/features/page/tree/atoms/open-tree-nodes-atom.ts";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import {
appendNodeChildren,
buildTree,
buildTreeWithChildren,
mergeRootTrees,
updateTreeNodeIcon,
} from "@/features/page/tree/utils/utils.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import {
getPageBreadcrumbs,
getPageById,
getSidebarPages,
} from "@/features/page/services/page-service.ts";
import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
import { queryClient } from "@/main.tsx";
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
import { useDisclosure, useElementSize, useMergedRef } from "@mantine/hooks";
import { useClipboard } from "@/hooks/use-clipboard";
import { dfs } from "react-arborist/dist/module/utils";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { notifications } from "@mantine/notifications";
import { getAppUrl } from "@/lib/config.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { getPageBreadcrumbs } from "@/features/page/services/page-service.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { extractPageSlugId } from "@/lib";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal";
import MovePageModal from "../../components/move-page-modal.tsx";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import CopyPageModal from "../../components/copy-page-modal.tsx";
import { duplicatePage } from "../../services/page-service.ts";
import { useFavoriteIds, useAddFavoriteMutation, useRemoveFavoriteMutation } from "@/features/favorite/queries/favorite-query";
import { DocTree } from "./doc-tree";
import { SpaceTreeRow } from "./space-tree-row";
interface SpaceTreeProps {
spaceId: string;
readOnly: boolean;
}
const openTreeNodesAtom = atom<OpenMap>({});
export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
const { t } = useTranslation();
const { pageSlug } = useParams();
const { data, setData, controllers } =
useTreeMutation<TreeApi<SpaceTreeNode>>(spaceId);
const [data, setData] = useAtom(treeDataAtom);
const { handleMove } = useTreeMutation(spaceId);
const {
data: pagesData,
hasNextPage,
fetchNextPage,
isFetching,
} = useGetRootSidebarPagesQuery({
spaceId,
});
const [, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom);
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
const rootElement = useRef<HTMLDivElement>();
const [isRootReady, setIsRootReady] = useState(false);
const { ref: sizeRef, width, height } = useElementSize();
const mergedRef = useMergedRef((element) => {
rootElement.current = element;
if (element && !isRootReady) {
setIsRootReady(true);
}
}, sizeRef);
} = useGetRootSidebarPagesQuery({ spaceId });
const [openTreeNodes, setOpenTreeNodes] = useAtom(openTreeNodesAtom);
const [isDataLoaded, setIsDataLoaded] = useState(false);
const spaceIdRef = useRef(spaceId);
spaceIdRef.current = spaceId;
@@ -123,23 +60,24 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
}, [hasNextPage, fetchNextPage, isFetching, spaceId]);
useEffect(() => {
if (pagesData?.pages && !hasNextPage) {
const allItems = pagesData.pages.flatMap((page) => page.items);
const treeData = buildTree(allItems);
if (!pagesData?.pages || hasNextPage) return;
setData((prev) => {
// fresh space; full reset
if (prev.length === 0 || prev[0]?.spaceId !== spaceId) {
setIsDataLoaded(true);
setOpenTreeNodes({});
return treeData;
}
const allItems = pagesData.pages.flatMap((page) => page.items);
const treeData = buildTree(allItems);
// same space; append only missing roots
setIsDataLoaded(true);
return mergeRootTrees(prev, treeData);
});
}
setData((prev) => {
// Keep nodes belonging to other spaces — filteredData filters by spaceId
// for rendering, so accumulating is safe. Preserves lazy-loaded children
// and open-state when the user returns to a previously-visited space.
const otherSpaces = prev.filter((n) => n?.spaceId !== spaceId);
const currentSpace = prev.filter((n) => n?.spaceId === spaceId);
const refreshed =
currentSpace.length > 0
? mergeRootTrees(currentSpace, treeData)
: treeData;
return [...otherSpaces, ...refreshed];
});
setIsDataLoaded(true);
}, [pagesData, hasNextPage, spaceId]);
useEffect(() => {
@@ -148,7 +86,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
const fetchData = async () => {
if (isDataLoaded && currentPage) {
// check if pageId node is present in the tree
const node = dfs(treeApiRef.current?.root, currentPage.id);
const node = treeModel.find(data, currentPage.id);
if (node) {
// if node is found, no need to traverse its ancestors
return;
@@ -160,14 +98,12 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
if (spaceIdRef.current !== effectSpaceId) return;
if (ancestors && ancestors?.length > 1) {
if (ancestors && ancestors.length > 1) {
let flatTreeItems = [...buildTree(ancestors)];
const fetchAndUpdateChildren = async (ancestor: IPage) => {
// we don't want to fetch the children of the opened page
if (ancestor.id === currentPage.id) {
return;
}
if (ancestor.id === currentPage.id) return;
const children = await fetchAllAncestorChildren({
pageId: ancestor.id,
spaceId: ancestor.spaceId,
@@ -185,7 +121,6 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
fetchAndUpdateChildren(ancestor),
);
// Wait for all fetch operations to complete
Promise.all(fetchPromises).then(() => {
if (spaceIdRef.current !== effectSpaceId) return;
@@ -195,15 +130,24 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
const rootChild = ancestorsTree[0];
// attach built ancestors to tree using functional updater
// to avoid stale closure overwriting the current tree data
setData((currentData) =>
appendNodeChildren(currentData, rootChild.id, rootChild.children),
treeModel.appendChildren(
currentData,
rootChild.id,
rootChild.children ?? [],
),
);
setTimeout(() => {
// focus on node and open all parents
treeApiRef.current?.select(currentPage.id);
}, 100);
// open all ancestors of the current page. DocTree picks up the
// selectedId change and scrolls the row into view on its own once
// flat contains it.
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const a of ancestors) {
if (a.id !== currentPage.id) next[a.id] = true;
}
return next;
});
});
}
}
@@ -212,556 +156,76 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
fetchData();
}, [isDataLoaded, currentPage?.id]);
useEffect(() => {
if (currentPage?.id) {
setTimeout(() => {
// focus on node and open all parents
treeApiRef.current?.select(currentPage.id, { align: "auto" });
}, 200);
} else {
treeApiRef.current?.deselectAll();
}
}, [currentPage?.id]);
const openIds = useMemo(
() => new Set(Object.keys(openTreeNodes).filter((k) => openTreeNodes[k])),
[openTreeNodes],
);
// Clean up tree API on unmount
useEffect(() => {
return () => {
// @ts-ignore
setTreeApi(null);
};
}, [setTreeApi]);
const handleToggle = useCallback(
async (id: string, isOpen: boolean) => {
setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen }));
if (isOpen) {
const node = treeModel.find(data, id) as SpaceTreeNode | null;
if (
node?.hasChildren &&
(!node.children || node.children.length === 0)
) {
const fetched = await fetchAllAncestorChildren({
pageId: id,
spaceId: node.spaceId,
});
setData((prev) => treeModel.appendChildren(prev, id, fetched));
}
}
},
[data, setOpenTreeNodes, setData],
);
const filteredData = data.filter((node) => node?.spaceId === spaceId);
const filteredData = useMemo(
() => data.filter((node) => node?.spaceId === spaceId),
[data, spaceId],
);
// Stable callbacks for DocTree. Without these, every parent render recreates
// the props and tears down every row's draggable/dropTarget subscription,
// defeating memo(DocTreeRow).
const renderRow = useCallback(
(rowProps: Parameters<typeof SpaceTreeRow>[0]) => (
<SpaceTreeRow {...rowProps} readOnly={readOnly} />
),
[readOnly],
);
const disableDragDrop = useCallback(
(n: SpaceTreeNode) => n.canEdit === false,
[],
);
const getDragLabel = useCallback(
(n: SpaceTreeNode) => n.name || t("untitled"),
[t],
);
return (
<div ref={mergedRef} className={classes.treeContainer}>
<div className={classes.treeContainer}>
{isDataLoaded && filteredData.length === 0 && (
<Text size="xs" c="dimmed" py="xs" px="sm">
{t("No pages yet")}
</Text>
)}
{isRootReady && rootElement.current && (
<Tree
{isDataLoaded && filteredData.length > 0 && (
<DocTree<SpaceTreeNode>
data={filteredData}
disableDrag={
readOnly
? true
: (data) => {
return data.canEdit === false;
}
}
disableDrop={
readOnly
? true
: ({ parentNode }) => parentNode?.data?.canEdit === false
}
disableEdit={readOnly ? true : (data) => data.canEdit === false}
{...controllers}
width={width}
height={rootElement.current.clientHeight}
ref={(ref) => {
treeApiRef.current = ref;
if (ref) {
//@ts-ignore
setTreeApi(ref);
}
}}
openByDefault={false}
disableMultiSelection={true}
className={classes.tree}
rowClassName={classes.row}
rowHeight={30}
overscanCount={10}
dndRootElement={rootElement.current}
onToggle={() => {
setOpenTreeNodes(treeApiRef.current?.openState);
}}
initialOpenState={openTreeNodes}
>
{Node}
</Tree>
openIds={openIds}
selectedId={currentPage?.id}
renderRow={renderRow}
onMove={handleMove}
onToggle={handleToggle}
readOnly={readOnly}
disableDrag={disableDragDrop}
disableDrop={disableDragDrop}
getDragLabel={getDragLabel}
aria-label={t("Pages")}
/>
)}
</div>
);
}
function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const { t } = useTranslation();
const updatePageMutation = useUpdatePageMutation();
const [treeData, setTreeData] = useAtom(treeDataAtom);
const [, appendChildren] = useAtom(appendNodeChildrenAtom);
const emit = useQueryEmit();
const { spaceSlug } = useParams();
const timerRef = useRef(null);
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const prefetchPage = () => {
timerRef.current = setTimeout(async () => {
const page = await queryClient.fetchQuery({
queryKey: ["pages", node.data.id],
queryFn: () => getPageById({ pageId: node.data.id }),
staleTime: 5 * 60 * 1000,
});
if (page?.slugId) {
queryClient.setQueryData(["pages", page.slugId], page);
}
}, 150);
};
const cancelPagePrefetch = () => {
if (timerRef.current) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
};
async function handleLoadChildren(node: NodeApi<SpaceTreeNode>) {
if (!node.data.hasChildren) return;
// in conflict with use-query-subscription.ts => case "addTreeNode","moveTreeNode" etc with websocket
// if (node.data.children && node.data.children.length > 0) {
// return;
// }
try {
const params: SidebarPagesParams = {
pageId: node.data.id,
spaceId: node.data.spaceId,
};
const childrenTree = await fetchAllAncestorChildren(params);
appendChildren({
parentId: node.data.id,
children: childrenTree,
});
} catch (error) {
console.error("Failed to fetch children:", error);
}
}
const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => {
const updatedTree = updateTreeNodeIcon(treeData, nodeId, newIcon);
setTreeData(updatedTree);
};
const handleEmojiIconClick = (e: any) => {
e.preventDefault();
e.stopPropagation();
};
const handleEmojiSelect = (emoji: { native: string }) => {
handleUpdateNodeIcon(node.id, emoji.native);
updatePageMutation
.mutateAsync({ pageId: node.id, icon: emoji.native })
.then((data) => {
setTimeout(() => {
emit({
operation: "updateOne",
spaceId: node.data.spaceId,
entity: ["pages"],
id: node.id,
payload: { icon: emoji.native, parentPageId: data.parentPageId },
});
}, 50);
});
};
const handleRemoveEmoji = () => {
handleUpdateNodeIcon(node.id, null);
updatePageMutation.mutateAsync({ pageId: node.id, icon: null });
setTimeout(() => {
emit({
operation: "updateOne",
spaceId: node.data.spaceId,
entity: ["pages"],
id: node.id,
payload: { icon: null },
});
}, 50);
};
if (
node.willReceiveDrop &&
node.isClosed &&
(node.children.length > 0 || node.data.hasChildren)
) {
handleLoadChildren(node);
setTimeout(() => {
if (node.state.willReceiveDrop) {
node.open();
}
}, 650);
}
const pageUrl = buildPageUrl(spaceSlug, node.data.slugId, node.data.name);
return (
<>
<Box
style={style}
className={clsx(classes.node, node.state)}
component={Link}
to={pageUrl}
// @ts-ignore
ref={dragHandle}
onClick={() => {
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
onMouseEnter={prefetchPage}
onMouseLeave={cancelPagePrefetch}
>
<PageArrow node={node} onExpandTree={() => handleLoadChildren(node)} />
<div onClick={handleEmojiIconClick} style={{ marginRight: "4px" }}>
<EmojiPicker
onEmojiSelect={handleEmojiSelect}
icon={
node.data.icon ? (
node.data.icon
) : (
<IconFileDescription size="18" />
)
}
readOnly={
tree.props.disableEdit === true || node.data.canEdit === false
}
removeEmojiAction={handleRemoveEmoji}
/>
</div>
<span className={classes.text}>{node.data.name || t("untitled")}</span>
<div className={classes.actions}>
<NodeMenu node={node} treeApi={tree} spaceId={node.data.spaceId} />
{tree.props.disableEdit !== true && node.data.canEdit !== false && (
<CreateNode
node={node}
treeApi={tree}
onExpandTree={() => handleLoadChildren(node)}
/>
)}
</div>
</Box>
</>
);
}
interface CreateNodeProps {
node: NodeApi<SpaceTreeNode>;
treeApi: TreeApi<SpaceTreeNode>;
onExpandTree?: () => void;
}
function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
const { t } = useTranslation();
function handleCreate() {
if (node.data.hasChildren && node.children.length === 0) {
node.toggle();
onExpandTree();
setTimeout(() => {
treeApi?.create({ type: "internal", parentId: node.id, index: 0 });
}, 500);
} else {
treeApi?.create({ type: "internal", parentId: node.id });
}
}
return (
<ActionIcon
variant="transparent"
c="gray"
aria-label={t("Create page")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCreate();
}}
>
<IconPlus style={{ width: rem(20), height: rem(20) }} stroke={2} />
</ActionIcon>
);
}
interface NodeMenuProps {
node: NodeApi<SpaceTreeNode>;
treeApi: TreeApi<SpaceTreeNode>;
spaceId: string;
}
function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
const { t } = useTranslation();
const clipboard = useClipboard({ timeout: 500 });
const { spaceSlug } = useParams();
const { openDeleteModal } = useDeletePageModal();
const [data, setData] = useAtom(treeDataAtom);
const emit = useQueryEmit();
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const [
movePageModalOpened,
{ open: openMovePageModal, close: closeMoveSpaceModal },
] = useDisclosure(false);
const [
copyPageModalOpened,
{ open: openCopyPageModal, close: closeCopySpaceModal },
] = useDisclosure(false);
const favoriteIds = useFavoriteIds("page", spaceId);
const addFavorite = useAddFavoriteMutation();
const removeFavorite = useRemoveFavoriteMutation();
const isFavorited = favoriteIds.has(node.data.id);
const handleCopyLink = () => {
const pageUrl =
getAppUrl() + buildPageUrl(spaceSlug, node.data.slugId, node.data.name);
clipboard.copy(pageUrl);
notifications.show({ message: t("Link copied") });
};
const handleDuplicatePage = async () => {
try {
const duplicatedPage = await duplicatePage({
pageId: node.id,
});
// Find the index of the current node
const parentId =
node.parent?.id === "__REACT_ARBORIST_INTERNAL_ROOT__"
? null
: node.parent?.id;
const siblings = parentId ? node.parent.children : treeApi?.props.data;
const currentIndex =
siblings?.findIndex((sibling) => sibling.id === node.id) || 0;
const newIndex = currentIndex + 1;
// Add the duplicated page to the tree
const treeNodeData: SpaceTreeNode = {
id: duplicatedPage.id,
slugId: duplicatedPage.slugId,
name: duplicatedPage.title,
position: duplicatedPage.position,
spaceId: duplicatedPage.spaceId,
parentPageId: duplicatedPage.parentPageId,
icon: duplicatedPage.icon,
hasChildren: duplicatedPage.hasChildren,
canEdit: true,
children: [],
};
// Update local tree
const simpleTree = new SimpleTree(data);
simpleTree.create({
parentId,
index: newIndex,
data: treeNodeData,
});
setData(simpleTree.data);
// Emit socket event
setTimeout(() => {
emit({
operation: "addTreeNode",
spaceId: spaceId,
payload: {
parentId,
index: newIndex,
data: treeNodeData,
},
});
}, 50);
notifications.show({
message: t("Page duplicated successfully"),
});
} catch (err) {
notifications.show({
message: err.response?.data.message || "An error occurred",
color: "red",
});
}
};
return (
<>
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon
variant="transparent"
c="gray"
aria-label={t("Page menu")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<IconDotsVertical
style={{ width: rem(20), height: rem(20) }}
stroke={2}
/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLink size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopyLink();
}}
>
{t("Copy link")}
</Menu.Item>
<Menu.Item
leftSection={isFavorited ? <IconStarFilled size={16} /> : <IconStar size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (isFavorited) {
removeFavorite.mutate({ type: "page", pageId: node.data.id });
} else {
addFavorite.mutate({ type: "page", pageId: node.data.id });
}
}}
>
{isFavorited ? t("Remove from favorites") : t("Add to favorites")}
</Menu.Item>
<Menu.Item
leftSection={<IconFileExport size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openExportModal();
}}
>
{t("Export page")}
</Menu.Item>
{treeApi.props.disableEdit !== true &&
node.data.canEdit !== false && (
<>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDuplicatePage();
}}
>
{t("Duplicate")}
</Menu.Item>
<Menu.Item
leftSection={<IconArrowRight size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openMovePageModal();
}}
>
{t("Move")}
</Menu.Item>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openCopyPageModal();
}}
>
{t("Copy to space")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
c="red"
leftSection={<IconTrash size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
}}
>
{t("Move to trash")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
<MovePageModal
pageId={node.id}
slugId={node.data.slugId}
currentSpaceSlug={spaceSlug}
onClose={closeMoveSpaceModal}
open={movePageModalOpened}
/>
<CopyPageModal
pageId={node.id}
currentSpaceSlug={spaceSlug}
onClose={closeCopySpaceModal}
open={copyPageModalOpened}
/>
<ExportModal
type="page"
id={node.id}
open={exportOpened}
onClose={closeExportModal}
/>
</>
);
}
interface PageArrowProps {
node: NodeApi<SpaceTreeNode>;
onExpandTree?: () => void;
}
function PageArrow({ node, onExpandTree }: PageArrowProps) {
const { t } = useTranslation();
useEffect(() => {
if (node.isOpen) {
onExpandTree();
}
}, []);
return (
<ActionIcon
size={20}
variant="subtle"
c="gray"
aria-label={node.isOpen ? t("Collapse") : t("Expand")}
aria-expanded={node.isInternal ? node.isOpen : undefined}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
node.toggle();
onExpandTree();
}}
>
{node.isInternal ? (
node.children && (node.children.length > 0 || node.data.hasChildren) ? (
node.isOpen ? (
<IconChevronDown stroke={2} size={18} />
) : (
<IconChevronRight stroke={2} size={18} />
)
) : (
<IconPointFilled size={8} />
)
) : null}
</ActionIcon>
);
}
@@ -0,0 +1,100 @@
import { describe, it, expect, vi } from 'vitest';
import type { SpaceTreeNode } from '@/features/page/tree/types';
import { dropOpToMovePayload } from './drop-op-to-move-payload';
vi.mock('fractional-indexing-jittered', () => ({
generateJitteredKeyBetween: (a: string | null, b: string | null) =>
`${a ?? 'START'}|${b ?? 'END'}`,
}));
const n = (id: string, position: string, children?: SpaceTreeNode[]): SpaceTreeNode =>
({ id, position, children, name: id } as unknown as SpaceTreeNode);
const tree: SpaceTreeNode[] = [
n('a', 'A', [n('a1', 'AA'), n('a2', 'AB')]),
n('b', 'B'),
];
describe('dropOpToMovePayload', () => {
it('reorder-before computes parentId + position between prev and target', () => {
const p = dropOpToMovePayload(tree, 'a2', {
kind: 'reorder-before',
targetId: 'a1',
});
expect(p).toEqual({ pageId: 'a2', parentPageId: 'a', position: 'START|AA' });
});
it('reorder-after computes position between target and next', () => {
const p = dropOpToMovePayload(tree, 'a1', {
kind: 'reorder-after',
targetId: 'a2',
});
expect(p).toEqual({ pageId: 'a1', parentPageId: 'a', position: 'AB|END' });
});
it('make-child appends with position after last child', () => {
const p = dropOpToMovePayload(tree, 'b', {
kind: 'make-child',
targetId: 'a',
});
expect(p).toEqual({ pageId: 'b', parentPageId: 'a', position: 'AB|END' });
});
it('reorder-before at root: parentPageId is null', () => {
const p = dropOpToMovePayload(tree, 'b', {
kind: 'reorder-before',
targetId: 'a',
});
expect(p).toEqual({ pageId: 'b', parentPageId: null, position: 'START|A' });
});
// Regression: when source is already adjacent to target, the BEFORE-tree
// treats source itself as the target's neighbor and falls back to null,
// producing an unbounded fractional key that overshoots other siblings.
// The fix uses the AFTER-tree, where source occupies its destination slot
// surrounded by its REAL neighbors.
it('reorder-after when source is immediately after target uses post-move neighbors', () => {
const adjacent: SpaceTreeNode[] = [
n('a', 'A'),
n('b', 'AB'),
n('c', 'B'),
n('d', 'BC'),
];
const p = dropOpToMovePayload(adjacent, 'b', {
kind: 'reorder-after',
targetId: 'a',
});
// After-tree is [a, b, c, d] (no-op shape). Source 'b' at index 1.
// prev = 'A', next = 'B'. Old buggy code returned prev='A', next=null.
expect(p).toEqual({ pageId: 'b', parentPageId: null, position: 'A|B' });
});
it('reorder-before when source is immediately before target uses post-move neighbors', () => {
const adjacent: SpaceTreeNode[] = [
n('a', 'A'),
n('b', 'AB'),
n('c', 'B'),
n('d', 'BC'),
];
const p = dropOpToMovePayload(adjacent, 'b', {
kind: 'reorder-before',
targetId: 'c',
});
// After-tree is [a, b, c, d]. Source 'b' at index 1.
// prev = 'A', next = 'B'. Old buggy code returned prev=null, next='B'.
expect(p).toEqual({ pageId: 'b', parentPageId: null, position: 'A|B' });
});
it('make-child when source is already last child of target uses post-move neighbors', () => {
const t: SpaceTreeNode[] = [
n('p', 'P', [n('x', 'X'), n('y', 'Y')]),
];
const p = dropOpToMovePayload(t, 'y', {
kind: 'make-child',
targetId: 'p',
});
// After-tree: 'y' becomes last child of 'p' → [x, y]. y at index 1.
// prev = 'X', next = null. Old buggy: prev=Y (source's own position), next=null.
expect(p).toEqual({ pageId: 'y', parentPageId: 'p', position: 'X|END' });
});
});
@@ -0,0 +1,36 @@
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import type { SpaceTreeNode } from '@/features/page/tree/types';
import type { IMovePage } from '@/features/page/types/page.types';
import type { DropOp } from '@/features/page/tree/model/tree-model.types';
import { treeModel } from '@/features/page/tree/model/tree-model';
export function dropOpToMovePayload(
tree: SpaceTreeNode[],
sourceId: string,
op: DropOp,
): IMovePage {
// Compute the post-move tree so we read source's REAL neighbors at its new
// position. Reading from the before-tree would mean treating source itself
// as a neighbor of the target — wrong when source is adjacent to target.
const { tree: after } = treeModel.move(tree, sourceId, op);
const info = treeModel.siblingsOf(after, sourceId);
if (!info) {
return {
pageId: sourceId,
parentPageId: null,
position: generateJitteredKeyBetween(null, null),
};
}
const prev = info.siblings[info.index - 1] as SpaceTreeNode | undefined;
const next = info.siblings[info.index + 1] as SpaceTreeNode | undefined;
return {
pageId: sourceId,
parentPageId: info.parentId,
position: generateJitteredKeyBetween(
prev?.position ?? null,
next?.position ?? null,
),
};
}
@@ -1,16 +1,15 @@
import { useMemo } from "react";
import {
CreateHandler,
DeleteHandler,
MoveHandler,
NodeApi,
RenameHandler,
SimpleTree,
} from "react-arborist";
import { useAtom } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { IMovePage, IPage } from "@/features/page/types/page.types.ts";
import { useCallback } from "react";
import { useAtom, useStore } from "jotai";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import type { DropOp } from "@/features/page/tree/model/tree-model.types";
import { dropOpToMovePayload } from "./drop-op-to-move-payload";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import {
useCreatePageMutation,
useRemovePageMutation,
@@ -18,258 +17,250 @@ import {
useUpdatePageMutation,
updateCacheOnMovePage,
} from "@/features/page/queries/page-query.ts";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getSpaceUrl } from "@/lib/config.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
export function useTreeMutation<T>(spaceId: string) {
const [data, setData] = useAtom(treeDataAtom);
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
export type UseTreeMutation = {
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
handleCreate: (parentId: string | null) => Promise<void>;
handleRename: (id: string, name: string) => Promise<void>;
handleDelete: (id: string) => Promise<void>;
};
export function useTreeMutation(spaceId: string): UseTreeMutation {
const { t } = useTranslation();
const [, setData] = useAtom(treeDataAtom);
// `store` reads the *current* treeDataAtom imperatively in handlers — avoids
// stale-closure issues when the caller updates the tree (e.g. lazy-load
// children) and then immediately invokes a handler.
const store = useStore();
const createPageMutation = useCreatePageMutation();
const updatePageMutation = useUpdatePageMutation();
const removePageMutation = useRemovePageMutation();
const movePageMutation = useMovePageMutation();
const navigate = useNavigate();
const { spaceSlug } = useParams();
const { pageSlug } = useParams();
const { spaceSlug, pageSlug } = useParams();
const emit = useQueryEmit();
const onCreate: CreateHandler<T> = async ({ parentId, index, type }) => {
const payload: { spaceId: string; parentPageId?: string } = {
spaceId: spaceId,
};
if (parentId) {
payload.parentPageId = parentId;
}
const handleMove = useCallback(
async (sourceId: string, op: DropOp) => {
const before = store.get(treeDataAtom);
const { tree: after, result } = treeModel.move(before, sourceId, op);
if (after === before) return;
let createdPage: IPage;
try {
createdPage = await createPageMutation.mutateAsync(payload);
} catch (err) {
throw new Error("Failed to create page");
}
const payload = dropOpToMovePayload(before, sourceId, op);
const source = treeModel.find(before, sourceId) as SpaceTreeNode | null;
if (!source) return;
const oldParentId = source.parentPageId ?? null;
const data = {
id: createdPage.id,
slugId: createdPage.slugId,
name: "",
position: createdPage.position,
spaceId: createdPage.spaceId,
parentPageId: createdPage.parentPageId,
children: [],
} as any;
// optimistic apply with the new position from the payload
let optimistic = treeModel.update(after, sourceId, {
position: payload.position,
parentPageId: payload.parentPageId,
} as Partial<SpaceTreeNode>);
let lastIndex: number;
if (parentId === null) {
lastIndex = tree.data.length;
} else {
lastIndex = tree.find(parentId).children.length;
}
// to place the newly created node at the bottom
index = lastIndex;
tree.create({ parentId, index, data });
setData(tree.data);
setTimeout(() => {
emit({
operation: "addTreeNode",
spaceId: spaceId,
payload: {
parentId,
index,
data,
},
});
}, 50);
const pageUrl = buildPageUrl(
spaceSlug,
createdPage.slugId,
createdPage.title
);
navigate(pageUrl);
return data;
};
const onMove: MoveHandler<T> = async (args: {
dragIds: string[];
dragNodes: NodeApi<T>[];
parentId: string | null;
parentNode: NodeApi<T> | null;
index: number;
}) => {
const draggedNodeId = args.dragIds[0];
tree.move({
id: draggedNodeId,
parentId: args.parentId,
index: args.index,
});
const newDragIndex = tree.find(draggedNodeId)?.childIndex;
const currentTreeData = args.parentId
? tree.find(args.parentId).children
: tree.data;
// if there is a parentId, tree.find(args.parentId).children returns a SimpleNode array
// we have to access the node differently via currentTreeData[args.index]?.data?.position
// this makes it possible to correctly sort children of a parent node that is not the root
const afterPosition =
// @ts-ignore
currentTreeData[newDragIndex - 1]?.position ||
// @ts-ignore
currentTreeData[args.index - 1]?.data?.position ||
null;
const beforePosition =
// @ts-ignore
currentTreeData[newDragIndex + 1]?.position ||
// @ts-ignore
currentTreeData[args.index + 1]?.data?.position ||
null;
let newPosition: string;
if (afterPosition && beforePosition && afterPosition === beforePosition) {
// if after is equal to before, put it next to the after node
newPosition = generateJitteredKeyBetween(afterPosition, null);
} else {
// if both are null then, it is the first index
newPosition = generateJitteredKeyBetween(afterPosition, beforePosition);
}
// update the node position in tree
tree.update({
id: draggedNodeId,
changes: { position: newPosition } as any,
});
const previousParent = args.dragNodes[0].parent;
if (
previousParent.id !== args.parentId &&
previousParent.id !== "__REACT_ARBORIST_INTERNAL_ROOT__"
) {
// if the page was moved to another parent,
// check if the previous still has children
// if no children left, change 'hasChildren' to false, to make the page toggle arrows work properly
const childrenCount = previousParent.children.filter(
(child) => child.id !== draggedNodeId
).length;
if (childrenCount === 0) {
tree.update({
id: previousParent.id,
changes: { ...previousParent.data, hasChildren: false } as any,
});
// If the old parent has no children left, mark hasChildren: false so the
// chevron disappears. Without this, the empty parent keeps rendering an
// expand toggle that fetches zero rows on click.
if (oldParentId) {
const oldParent = treeModel.find(optimistic, oldParentId);
if (!oldParent?.children?.length) {
optimistic = treeModel.update(optimistic, oldParentId, {
hasChildren: false,
} as Partial<SpaceTreeNode>);
}
}
}
setData(tree.data);
// For make-child onto a previously-childless target: flip hasChildren on
// so the new parent shows its chevron.
if (op.kind === "make-child") {
optimistic = treeModel.update(optimistic, op.targetId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
const payload: IMovePage = {
pageId: draggedNodeId,
position: newPosition,
parentPageId: args.parentId,
};
setData(optimistic);
const draggedNode = args.dragNodes[0];
const nodeData = draggedNode.data as SpaceTreeNode;
const oldParentId = nodeData.parentPageId ?? null;
const pageData = {
id: nodeData.id,
slugId: nodeData.slugId,
title: nodeData.name,
icon: nodeData.icon,
position: newPosition,
spaceId: nodeData.spaceId,
parentPageId: args.parentId,
hasChildren: nodeData.hasChildren,
};
try {
await movePageMutation.mutateAsync(payload);
} catch {
setData(before);
notifications.show({
message: t("Failed to move page"),
color: "red",
});
return;
}
try {
await movePageMutation.mutateAsync(payload);
const pageData: Partial<IPage> = {
id: source.id,
slugId: source.slugId,
title: source.name,
icon: source.icon,
position: payload.position,
spaceId: source.spaceId,
parentPageId: payload.parentPageId,
hasChildren: source.hasChildren,
};
updateCacheOnMovePage(spaceId, draggedNodeId, oldParentId, args.parentId, pageData);
updateCacheOnMovePage(
spaceId,
sourceId,
oldParentId,
payload.parentPageId,
pageData,
);
setTimeout(() => {
emit({
operation: "moveTreeNode",
spaceId: spaceId,
payload: {
id: draggedNodeId,
parentId: args.parentId,
id: sourceId,
parentId: payload.parentPageId,
oldParentId,
index: args.index,
position: newPosition,
index: result.index,
position: payload.position,
pageData,
},
});
}, 50);
} catch (error) {
console.error("Error moving page:", error);
}
};
},
[setData, store, movePageMutation, spaceId, emit, t],
);
const onRename: RenameHandler<T> = ({ name, id }) => {
tree.update({ id, changes: { name } as any });
setData(tree.data);
const handleCreate = useCallback(
async (parentId: string | null) => {
const payload: { spaceId: string; parentPageId?: string } = { spaceId };
if (parentId) payload.parentPageId = parentId;
try {
updatePageMutation.mutateAsync({ pageId: id, title: name });
} catch (error) {
console.error("Error updating page title:", error);
}
};
let createdPage: IPage;
try {
createdPage = await createPageMutation.mutateAsync(payload);
} catch {
throw new Error("Failed to create page");
}
const isPageInNode = (
node: { data: SpaceTreeNode; children?: any[] },
pageSlug: string
): boolean => {
if (node.data.slugId === pageSlug) {
return true;
}
for (const item of node.children) {
if (item.data.slugId === pageSlug) {
return true;
const newNode: SpaceTreeNode = {
id: createdPage.id,
slugId: createdPage.slugId,
name: "",
position: createdPage.position,
spaceId: createdPage.spaceId,
parentPageId: createdPage.parentPageId,
hasChildren: false,
children: [],
};
// Read latest tree at call time. Without this, callers that mutate the
// tree (e.g. lazy-load children on expand) immediately before calling
// handleCreate hit a stale closure and compute lastIndex against the
// pre-load tree, requiring a setTimeout-based wait at the call site.
const current = store.get(treeDataAtom);
let lastIndex: number;
if (parentId === null) {
lastIndex = current.length;
} else {
return isPageInNode(item, pageSlug);
}
}
return false;
};
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
try {
await removePageMutation.mutateAsync(args.ids[0]);
const node = tree.find(args.ids[0]);
if (!node) {
return;
const parent = treeModel.find(current, parentId);
lastIndex = parent?.children?.length ?? 0;
}
tree.drop({ id: args.ids[0] });
setData(tree.data);
if (pageSlug && isPageInNode(node, pageSlug.split("-")[1])) {
navigate(getSpaceUrl(spaceSlug));
}
setData((prev) => treeModel.insert(prev, parentId, newNode, lastIndex));
setTimeout(() => {
emit({
operation: "deleteTreeNode",
spaceId: spaceId,
payload: { node: node.data },
operation: "addTreeNode",
spaceId,
payload: {
parentId,
index: lastIndex,
data: newNode,
},
});
}, 50);
} catch (error) {
console.error("Failed to delete page:", error);
}
};
const controllers = { onMove, onRename, onCreate, onDelete };
return { data, setData, controllers } as const;
const pageUrl = buildPageUrl(
spaceSlug,
createdPage.slugId,
createdPage.title,
);
navigate(pageUrl);
},
[spaceId, createPageMutation, setData, store, emit, navigate, spaceSlug],
);
const handleRename = useCallback(
async (id: string, name: string) => {
setData((prev) =>
treeModel.update(prev, id, { name } as Partial<SpaceTreeNode>),
);
try {
await updatePageMutation.mutateAsync({ pageId: id, title: name });
} catch (error) {
console.error("Error updating page title:", error);
}
},
[updatePageMutation, setData],
);
const handleDelete = useCallback(
async (id: string) => {
const node = treeModel.find(
store.get(treeDataAtom),
id,
) as SpaceTreeNode | null;
const parentPageId = node?.parentPageId ?? null;
try {
await removePageMutation.mutateAsync(id);
setData((prev) => {
let next = treeModel.remove(prev, id);
// If the parent has no children left, mark hasChildren: false so the
// chevron disappears. Without this, the empty parent keeps rendering an
// expand toggle that fetches zero rows on click.
if (parentPageId) {
const parent = treeModel.find(next, parentPageId);
if (!parent?.children?.length) {
next = treeModel.update(next, parentPageId, {
hasChildren: false,
} as Partial<SpaceTreeNode>);
}
}
return next;
});
if (
node &&
pageSlug &&
(node.slugId === pageSlug.split("-")[1] ||
isPageInNode(node, pageSlug.split("-")[1]))
) {
navigate(getSpaceUrl(spaceSlug));
}
setTimeout(() => {
if (!node) return;
emit({
operation: "deleteTreeNode",
spaceId,
payload: { node },
});
}, 50);
} catch (error) {
console.error("Failed to delete page:", error);
}
},
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug, emit, spaceId],
);
return { handleMove, handleCreate, handleRename, handleDelete };
}
function isPageInNode(node: SpaceTreeNode, pageSlug: string): boolean {
if (node.slugId === pageSlug) return true;
if (!node.children) return false;
for (const child of node.children) {
if (isPageInNode(child, pageSlug)) return true;
}
return false;
}
@@ -0,0 +1,329 @@
import { describe, it, expect } from 'vitest';
import { treeModel } from './tree-model';
import type { TreeNode } from './tree-model.types';
type N = TreeNode<{ name: string }>;
const fixture: N[] = [
{
id: 'a',
name: 'A',
children: [
{ id: 'a1', name: 'A1', children: [{ id: 'a1a', name: 'A1a' }] },
{ id: 'a2', name: 'A2' },
],
},
{ id: 'b', name: 'B' },
];
describe('treeModel.find', () => {
it('finds a root node', () => {
expect(treeModel.find(fixture, 'a')?.name).toBe('A');
});
it('finds a deeply nested node', () => {
expect(treeModel.find(fixture, 'a1a')?.name).toBe('A1a');
});
it('returns null for unknown id', () => {
expect(treeModel.find(fixture, 'zzz')).toBeNull();
});
});
describe('treeModel.path', () => {
it('returns root-to-leaf path for nested id', () => {
const p = treeModel.path(fixture, 'a1a');
expect(p?.map((n) => n.id)).toEqual(['a', 'a1', 'a1a']);
});
it('returns [node] for root-level id', () => {
expect(treeModel.path(fixture, 'b')?.map((n) => n.id)).toEqual(['b']);
});
it('returns null for unknown id', () => {
expect(treeModel.path(fixture, 'zzz')).toBeNull();
});
});
describe('treeModel.siblingsOf', () => {
it('returns siblings + parent + index for a child', () => {
const info = treeModel.siblingsOf(fixture, 'a2');
expect(info?.parentId).toBe('a');
expect(info?.siblings.map((n) => n.id)).toEqual(['a1', 'a2']);
expect(info?.index).toBe(1);
});
it('returns parentId null + root siblings for a root id', () => {
const info = treeModel.siblingsOf(fixture, 'b');
expect(info?.parentId).toBeNull();
expect(info?.siblings.map((n) => n.id)).toEqual(['a', 'b']);
expect(info?.index).toBe(1);
});
it('returns null for unknown id', () => {
expect(treeModel.siblingsOf(fixture, 'zzz')).toBeNull();
});
});
describe('treeModel.isDescendant', () => {
it('returns true when descendantId is nested under ancestorId', () => {
expect(treeModel.isDescendant(fixture, 'a', 'a1a')).toBe(true);
});
it('returns false when ids are siblings', () => {
expect(treeModel.isDescendant(fixture, 'a1', 'a2')).toBe(false);
});
it('returns false when ancestorId is the same as descendantId', () => {
expect(treeModel.isDescendant(fixture, 'a', 'a')).toBe(false);
});
it('returns false for unknown ids', () => {
expect(treeModel.isDescendant(fixture, 'zzz', 'a')).toBe(false);
});
});
describe('treeModel.visible', () => {
it('returns only root nodes when no openIds', () => {
const v = treeModel.visible(fixture, new Set());
expect(v.map((n) => n.id)).toEqual(['a', 'b']);
});
it('includes children of open ids in DFS order', () => {
const v = treeModel.visible(fixture, new Set(['a']));
expect(v.map((n) => n.id)).toEqual(['a', 'a1', 'a2', 'b']);
});
it('recursively descends through chains of open ids', () => {
const v = treeModel.visible(fixture, new Set(['a', 'a1']));
expect(v.map((n) => n.id)).toEqual(['a', 'a1', 'a1a', 'a2', 'b']);
});
it('ignores openIds that are not in the tree', () => {
const v = treeModel.visible(fixture, new Set(['ghost']));
expect(v.map((n) => n.id)).toEqual(['a', 'b']);
});
});
describe('treeModel.insert', () => {
const leaf = (id: string): N => ({ id, name: id.toUpperCase() });
it('inserts at end when index is undefined', () => {
const t = treeModel.insert(fixture, 'a', leaf('a3'));
expect(treeModel.siblingsOf(t, 'a3')?.siblings.map((n) => n.id)).toEqual([
'a1', 'a2', 'a3',
]);
});
it('inserts at index 0', () => {
const t = treeModel.insert(fixture, 'a', leaf('a0'), 0);
expect(treeModel.siblingsOf(t, 'a0')?.siblings.map((n) => n.id)).toEqual([
'a0', 'a1', 'a2',
]);
});
it('inserts in the middle', () => {
const t = treeModel.insert(fixture, 'a', leaf('a1half'), 1);
expect(
treeModel.siblingsOf(t, 'a1half')?.siblings.map((n) => n.id),
).toEqual(['a1', 'a1half', 'a2']);
});
it('inserts at root when parentId is null', () => {
const t = treeModel.insert(fixture, null, leaf('c'));
expect(t.map((n) => n.id)).toEqual(['a', 'b', 'c']);
});
it('returns same array reference for unknown parentId', () => {
const t = treeModel.insert(fixture, 'ghost', leaf('zz'));
expect(t).toBe(fixture);
});
it('initializes children array when parent had no children', () => {
const t = treeModel.insert(fixture, 'b', leaf('b1'));
expect(treeModel.find(t, 'b')?.children?.map((n) => n.id)).toEqual(['b1']);
});
});
describe('treeModel.remove', () => {
it('removes a leaf', () => {
const t = treeModel.remove(fixture, 'a2');
expect(treeModel.find(t, 'a2')).toBeNull();
});
it('removes a subtree', () => {
const t = treeModel.remove(fixture, 'a1');
expect(treeModel.find(t, 'a1')).toBeNull();
expect(treeModel.find(t, 'a1a')).toBeNull();
});
it('removes a root node', () => {
const t = treeModel.remove(fixture, 'b');
expect(t.map((n) => n.id)).toEqual(['a']);
});
it('returns same array reference for unknown id', () => {
expect(treeModel.remove(fixture, 'ghost')).toBe(fixture);
});
});
describe('treeModel.update', () => {
it('shallow-merges a patch on the matching node', () => {
const t = treeModel.update(fixture, 'a1', { name: 'A1-renamed' });
expect(treeModel.find(t, 'a1')?.name).toBe('A1-renamed');
});
it('returns same array reference for unknown id', () => {
expect(treeModel.update(fixture, 'ghost', { name: 'x' })).toBe(fixture);
});
it("preserves children when patching parent's own fields", () => {
const t = treeModel.update(fixture, 'a', { name: 'A-renamed' });
expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual([
'a1', 'a2',
]);
});
it('preserves reference identity of unrelated subtrees', () => {
const t = treeModel.update(fixture, 'a1', { name: 'X' });
expect(t[1]).toBe(fixture[1]);
});
});
describe('treeModel.appendChildren', () => {
const kid = (id: string): N => ({ id, name: id });
it('appends to existing children', () => {
const t = treeModel.appendChildren(fixture, 'a', [kid('a3'), kid('a4')]);
expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual([
'a1', 'a2', 'a3', 'a4',
]);
});
it('initializes children when parent had none', () => {
const t = treeModel.appendChildren(fixture, 'b', [kid('b1')]);
expect(treeModel.find(t, 'b')?.children?.map((n) => n.id)).toEqual(['b1']);
});
it('returns same array reference for unknown parentId', () => {
expect(treeModel.appendChildren(fixture, 'ghost', [kid('zz')])).toBe(
fixture,
);
});
// Regression: lazy-load + auto-expand can race and call appendChildren with
// children that overlap what's already there. React then crashes on duplicate
// keys. Defensive dedup at the model level.
it('dedups against existing children by id', () => {
const t1 = treeModel.appendChildren(fixture, 'a', [
kid('a3'),
kid('a4'),
]);
const t2 = treeModel.appendChildren(t1, 'a', [
kid('a3'),
kid('a4'),
kid('a5'),
]);
expect(treeModel.find(t2, 'a')?.children?.map((n) => n.id)).toEqual([
'a1', 'a2', 'a3', 'a4', 'a5',
]);
});
it('returns same array reference when every child is a duplicate', () => {
const t1 = treeModel.appendChildren(fixture, 'a', [kid('a3')]);
const t2 = treeModel.appendChildren(t1, 'a', [kid('a3')]);
expect(t2).toBe(t1);
});
});
describe('treeModel.place', () => {
it('moves a node to a new parent at a given index', () => {
const t = treeModel.place(fixture, 'a2', { parentId: 'b', index: 0 });
expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual(['a1']);
expect(treeModel.find(t, 'b')?.children?.map((n) => n.id)).toEqual(['a2']);
});
it('moves a node to root', () => {
const t = treeModel.place(fixture, 'a1', { parentId: null, index: 0 });
expect(t.map((n) => n.id)).toEqual(['a1', 'a', 'b']);
expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual(['a2']);
});
it('reorders within the same parent', () => {
const t = treeModel.place(fixture, 'a2', { parentId: 'a', index: 0 });
expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual([
'a2', 'a1',
]);
});
it('returns same array reference for unknown source', () => {
expect(
treeModel.place(fixture, 'ghost', { parentId: 'a', index: 0 }),
).toBe(fixture);
});
it('returns same array reference for unknown destination parent', () => {
expect(
treeModel.place(fixture, 'a1', { parentId: 'ghost', index: 0 }),
).toBe(fixture);
});
});
describe('treeModel.move', () => {
it('reorder-before within same parent: moves source to target index', () => {
const { tree: t, result } = treeModel.move(fixture, 'a2', {
kind: 'reorder-before',
targetId: 'a1',
});
expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual([
'a2', 'a1',
]);
expect(result).toEqual({ parentId: 'a', index: 0 });
});
it('reorder-after within same parent', () => {
const { tree: t, result } = treeModel.move(fixture, 'a1', {
kind: 'reorder-after',
targetId: 'a2',
});
expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual([
'a2', 'a1',
]);
expect(result).toEqual({ parentId: 'a', index: 1 });
});
it('make-child appends at end of target children', () => {
const { tree: t, result } = treeModel.move(fixture, 'b', {
kind: 'make-child',
targetId: 'a',
});
expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual([
'a1', 'a2', 'b',
]);
expect(result).toEqual({ parentId: 'a', index: 2 });
});
it('make-child initializes children when target had none', () => {
const { tree: t, result } = treeModel.move(fixture, 'a2', {
kind: 'make-child',
targetId: 'b',
});
expect(treeModel.find(t, 'b')?.children?.map((n) => n.id)).toEqual(['a2']);
expect(result).toEqual({ parentId: 'b', index: 0 });
});
it('reorder-before across parents', () => {
const { tree: t, result } = treeModel.move(fixture, 'b', {
kind: 'reorder-before',
targetId: 'a1',
});
expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual([
'b', 'a1', 'a2',
]);
expect(result).toEqual({ parentId: 'a', index: 0 });
});
it('reorder-after to root', () => {
const { tree: t, result } = treeModel.move(fixture, 'a1', {
kind: 'reorder-after',
targetId: 'a',
});
expect(t.map((n) => n.id)).toEqual(['a', 'a1', 'b']);
expect(treeModel.find(t, 'a')?.children?.map((n) => n.id)).toEqual(['a2']);
expect(result).toEqual({ parentId: null, index: 1 });
});
it('no-op when sourceId === targetId', () => {
const out = treeModel.move(fixture, 'a', {
kind: 'make-child',
targetId: 'a',
});
expect(out.tree).toBe(fixture);
});
it('no-op when target is descendant of source', () => {
const out = treeModel.move(fixture, 'a', {
kind: 'make-child',
targetId: 'a1a',
});
expect(out.tree).toBe(fixture);
});
it('no-op when source is unknown', () => {
const out = treeModel.move(fixture, 'ghost', {
kind: 'reorder-before',
targetId: 'a',
});
expect(out.tree).toBe(fixture);
});
it('no-op when target is unknown', () => {
const out = treeModel.move(fixture, 'a1', {
kind: 'reorder-before',
targetId: 'ghost',
});
expect(out.tree).toBe(fixture);
});
});
@@ -0,0 +1,222 @@
import type { TreeNode, SiblingsInfo } from './tree-model.types';
function findInternal<T extends object>(
nodes: TreeNode<T>[],
id: string,
): { parents: TreeNode<T>[]; node: TreeNode<T> } | null {
for (const node of nodes) {
if (node.id === id) return { parents: [], node };
if (node.children) {
const inner = findInternal(node.children, id);
if (inner) return { parents: [node, ...inner.parents], node: inner.node };
}
}
return null;
}
export const treeModel = {
find<T extends object>(tree: TreeNode<T>[], id: string): TreeNode<T> | null {
return findInternal(tree, id)?.node ?? null;
},
path<T extends object>(tree: TreeNode<T>[], id: string): TreeNode<T>[] | null {
const found = findInternal(tree, id);
if (!found) return null;
return [...found.parents, found.node];
},
siblingsOf<T extends object>(
tree: TreeNode<T>[],
id: string,
): SiblingsInfo<T> | null {
const found = findInternal(tree, id);
if (!found) return null;
const parent = found.parents[found.parents.length - 1];
const siblings = parent ? parent.children! : tree;
return {
parentId: parent?.id ?? null,
siblings,
index: siblings.findIndex((n) => n.id === id),
};
},
isDescendant<T extends object>(
tree: TreeNode<T>[],
ancestorId: string,
descendantId: string,
): boolean {
if (ancestorId === descendantId) return false;
const ancestor = treeModel.find(tree, ancestorId);
if (!ancestor?.children) return false;
return findInternal(ancestor.children, descendantId) !== null;
},
visible<T extends object>(
tree: TreeNode<T>[],
openIds: ReadonlySet<string>,
): TreeNode<T>[] {
const out: TreeNode<T>[] = [];
const walk = (nodes: TreeNode<T>[]) => {
for (const node of nodes) {
out.push(node);
if (openIds.has(node.id) && node.children?.length) walk(node.children);
}
};
walk(tree);
return out;
},
insert<T extends object>(
tree: TreeNode<T>[],
parentId: string | null,
node: TreeNode<T>,
index?: number,
): TreeNode<T>[] {
if (parentId === null) {
const idx = index ?? tree.length;
return [...tree.slice(0, idx), node, ...tree.slice(idx)];
}
let touched = false;
const walk = (nodes: TreeNode<T>[]): TreeNode<T>[] =>
nodes.map((n) => {
if (n.id === parentId) {
touched = true;
const kids = n.children ?? [];
const idx = index ?? kids.length;
return {
...n,
children: [...kids.slice(0, idx), node, ...kids.slice(idx)],
};
}
if (n.children) {
const next = walk(n.children);
if (next !== n.children) return { ...n, children: next };
}
return n;
});
const out = walk(tree);
return touched ? out : tree;
},
remove<T extends object>(tree: TreeNode<T>[], id: string): TreeNode<T>[] {
let touched = false;
const walk = (nodes: TreeNode<T>[]): TreeNode<T>[] => {
const filtered = nodes.filter((n) => {
if (n.id === id) {
touched = true;
return false;
}
return true;
});
return filtered.map((n) => {
if (n.children) {
const next = walk(n.children);
if (next !== n.children) return { ...n, children: next };
}
return n;
});
};
const out = walk(tree);
return touched ? out : tree;
},
// `patch` excludes `id` (immutable) and `children` (use insert / remove /
// appendChildren for structural changes — otherwise referential identity of
// unrelated subtrees gets blown away).
update<T extends object>(
tree: TreeNode<T>[],
id: string,
patch: Omit<Partial<T>, "id" | "children">,
): TreeNode<T>[] {
let touched = false;
const walk = (nodes: TreeNode<T>[]): TreeNode<T>[] =>
nodes.map((n) => {
if (n.id === id) {
touched = true;
return { ...n, ...patch };
}
if (n.children) {
const next = walk(n.children);
if (next !== n.children) return { ...n, children: next };
}
return n;
});
const out = walk(tree);
return touched ? out : tree;
},
appendChildren<T extends object>(
tree: TreeNode<T>[],
parentId: string,
children: TreeNode<T>[],
): TreeNode<T>[] {
let touched = false;
const walk = (nodes: TreeNode<T>[]): TreeNode<T>[] =>
nodes.map((n) => {
if (n.id === parentId) {
const existing = n.children ?? [];
// Dedup against existing ids — auto-expand + manual toggle can race
// and produce overlapping fetches; we don't want React to see two
// children with the same key.
const existingIds = new Set(existing.map((c) => c.id));
const fresh = children.filter((c) => !existingIds.has(c.id));
if (fresh.length === 0) return n;
touched = true;
return { ...n, children: [...existing, ...fresh] };
}
if (n.children) {
const next = walk(n.children);
if (next !== n.children) return { ...n, children: next };
}
return n;
});
const out = walk(tree);
return touched ? out : tree;
},
place<T extends object>(
tree: TreeNode<T>[],
sourceId: string,
to: { parentId: string | null; index: number },
): TreeNode<T>[] {
const source = treeModel.find(tree, sourceId);
if (!source) return tree;
if (to.parentId !== null && !treeModel.find(tree, to.parentId)) return tree;
const removed = treeModel.remove(tree, sourceId);
return treeModel.insert(removed, to.parentId, source, to.index);
},
move<T extends object>(
tree: TreeNode<T>[],
sourceId: string,
op: import('./tree-model.types').DropOp,
): { tree: TreeNode<T>[]; result: import('./tree-model.types').DropResult } {
if (sourceId === op.targetId) return { tree, result: { parentId: null, index: 0 } };
if (!treeModel.find(tree, sourceId) || !treeModel.find(tree, op.targetId)) {
return { tree, result: { parentId: null, index: 0 } };
}
if (treeModel.isDescendant(tree, sourceId, op.targetId)) {
return { tree, result: { parentId: null, index: 0 } };
}
let parentId: string | null;
let index: number;
if (op.kind === 'make-child') {
parentId = op.targetId;
const target = treeModel.find(tree, op.targetId)!;
index = target.children?.length ?? 0;
} else {
const info = treeModel.siblingsOf(tree, op.targetId)!;
parentId = info.parentId;
const sourceInfo = treeModel.siblingsOf(tree, sourceId)!;
const sameParent = sourceInfo.parentId === parentId;
const adjust =
sameParent && sourceInfo.index < info.index ? -1 : 0;
index = info.index + adjust + (op.kind === 'reorder-after' ? 1 : 0);
}
const next = treeModel.place(tree, sourceId, { parentId, index });
return { tree: next, result: { parentId, index } };
},
};
@@ -0,0 +1,20 @@
export type TreeNode<T extends object = object> = T & {
id: string;
children?: TreeNode<T>[];
};
export type DropOp =
| { kind: 'reorder-before'; targetId: string }
| { kind: 'reorder-after'; targetId: string }
| { kind: 'make-child'; targetId: string };
export type DropResult = {
parentId: string | null;
index: number;
};
export type SiblingsInfo<T extends object> = {
parentId: string | null;
siblings: TreeNode<T>[];
index: number;
};
@@ -5,10 +5,11 @@
.treeContainer {
height: 100%;
min-width: 0;
> div, > div > .tree {
height: 100% !important;
}
/* DocTree renders a vanilla <ul role="tree"> with no internal virtualizer,
so the container must own the scroll. Without this the tree grows past
its parent and the page scrolls instead. */
overflow-y: auto;
overflow-x: hidden;
}
.node {
@@ -17,76 +18,39 @@
display: flex;
align-items: center;
height: 100%;
width: 93%; /* not to overlap with scroll bar */
width: 100%;
text-decoration: none;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
&:hover {
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
/*background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));*/
}
/* Gate hover styles to mouse-capable devices. Touch browsers synthesize
:hover on the first tap (sticky hover) and only fire click on the
second tap, requiring a double-tap to navigate. */
@media (hover: hover) {
&:hover {
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
&:hover .actions {
opacity: 1;
pointer-events: auto;
}
}
.actions {
visibility: hidden;
position: absolute;
height: 100%;
top: 0;
right: 0;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6));
display: inline-flex;
flex-shrink: 0;
align-items: center;
margin-left: 4px;
opacity: 0;
pointer-events: none;
}
&:hover .actions {
visibility: visible;
&:focus-within .actions {
opacity: 1;
pointer-events: auto;
}
}
.node:global(.willReceiveDrop) {
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-gray-7));
}
.node:global(.isSelected) {
border-radius: 0;
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6));
/*
color: white;
// background-color: light-dark(
// var(--mantine-color-gray-0),
// var(--mantine-color-dark-6)
//);
//background: rgb(20, 127, 250, 0.5);*/
}
.node:global(.isSelectedStart.isSelectedEnd) {
border-radius: 4px;
}
.row:focus .node:global(.isSelected) {
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.row:focus .node:global(.isFocused) {
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.row {
white-space: nowrap;
cursor: pointer;
}
.row:focus {
outline: none;
}
.row:focus .node {
/** come back to this **/
/* background-color: light-dark(var(--mantine-color-red-2), var(--mantine-color-dark-5));*/
}
.icon {
margin: 0 rem(10px);
@@ -95,8 +59,12 @@
.text {
flex: 1;
/* min-width: 0 lets a flex child shrink below its content size — required
for text-overflow: ellipsis on flex items. */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: rem(14px);
font-weight: 500;
}
@@ -108,3 +76,113 @@
[role="treeitem"] {
padding-bottom: 2px;
}
/* Strip the browser's default <ul> bullet + indent from the DocTree
<ul role="tree"> and nested <ul role="group"> nodes. The tree's own indent
is driven by paddingLeft on .rowWrapper. */
[role="tree"],
[role="tree"] [role="group"] {
list-style: none;
margin: 0;
padding: 0;
}
/* ---- pragmatic-tree additions ---- */
.rowWrapper {
position: relative;
display: flex;
align-items: center;
border-radius: 4px;
}
.node[data-dragging="true"] {
opacity: 0.4;
}
.node:focus-visible {
outline: 2px solid light-dark(
var(--mantine-color-blue-5),
var(--mantine-color-blue-4)
);
outline-offset: -2px;
}
.node :focus-visible {
outline-offset: -2px;
}
.node[data-selected="true"] {
background-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-6)
);
}
.node[data-selected="true"] .actions {
opacity: 1;
pointer-events: auto;
}
.node[data-receiving-drop="make-child"] {
background-color: light-dark(
var(--mantine-color-blue-1),
rgba(56, 139, 253, 0.15)
);
outline: 2px solid light-dark(
var(--mantine-color-blue-5),
var(--mantine-color-blue-7)
);
outline-offset: -1px;
}
.node[data-receiving-drop="make-child-blocked"] {
outline-color: light-dark(
var(--mantine-color-red-5),
var(--mantine-color-red-7)
);
}
.dropLine {
position: absolute;
left: var(--drop-line-indent, 0);
right: 8px;
height: 2px;
background: light-dark(
var(--mantine-color-blue-5),
var(--mantine-color-blue-4)
);
pointer-events: none;
z-index: 1;
}
.dropLine::before {
content: "";
position: absolute;
left: -4px;
top: -3px;
width: 8px;
height: 8px;
border: 2px solid currentColor;
border-radius: 50%;
color: light-dark(
var(--mantine-color-blue-5),
var(--mantine-color-blue-4)
);
background: var(--mantine-color-body);
}
.dropLine[data-blocked="true"] {
background: light-dark(
var(--mantine-color-red-5),
var(--mantine-color-red-4)
);
}
.dropLine[data-edge="top"] {
top: -1px;
}
.dropLine[data-edge="bottom"] {
bottom: -1px;
}
@@ -1,13 +1,9 @@
import React, { useState, useMemo, useEffect } from "react";
import React, { useState, useEffect } from "react";
import {
Button,
Menu,
Text,
TextInput,
Divider,
Badge,
ScrollArea,
Avatar,
Group,
Switch,
getDefaultZIndex,
@@ -16,12 +12,11 @@ import {
IconChevronDown,
IconBuilding,
IconFileDescription,
IconSearch,
IconCheck,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useDebouncedValue } from "@mantine/hooks";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import classes from "./search-spotlight-filters.module.css";
@@ -46,32 +41,13 @@ export function SearchSpotlightFilters({
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
spaceId || null,
);
const [spaceSearchQuery, setSpaceSearchQuery] = useState("");
const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300);
const [contentType, setContentType] = useState<string | null>("page");
const [workspace] = useAtom(workspaceAtom);
const { data: spacesData } = useGetSpacesQuery({
limit: 100,
query: debouncedSpaceQuery,
});
const selectedSpaceData = useMemo(() => {
if (!spacesData?.items || !selectedSpaceId) return null;
return spacesData.items.find((space) => space.id === selectedSpaceId);
}, [spacesData?.items, selectedSpaceId]);
const availableSpaces = useMemo(() => {
const spaces = spacesData?.items || [];
if (!selectedSpaceId) return spaces;
// Sort to put selected space first
return [...spaces].sort((a, b) => {
if (a.id === selectedSpaceId) return -1;
if (b.id === selectedSpaceId) return 1;
return 0;
});
}, [spacesData?.items, selectedSpaceId]);
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
const selectedSpaceData = selectedSpaceId
? spacesData?.items.find((space) => space.id === selectedSpaceId)
: null;
useEffect(() => {
if (onFiltersChange) {
@@ -152,86 +128,27 @@ export function SearchSpotlightFilters({
</div>
)}
<Menu
shadow="md"
width={250}
<SpaceFilterMenu
value={selectedSpaceId}
onChange={handleSpaceSelect}
position="bottom-start"
width={250}
zIndex={getDefaultZIndex("max")}
>
<Menu.Target>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconBuilding size={16} />}
className={classes.filterButton}
fw={500}
>
{selectedSpaceId
? `${t("Space")}: ${selectedSpaceData?.name || t("Unknown")}`
: `${t("Space")}: ${t("All spaces")}`}
</Button>
</Menu.Target>
<Menu.Dropdown>
<TextInput
placeholder={t("Find a space")}
data-autofocus
autoFocus
leftSection={<IconSearch size={16} />}
value={spaceSearchQuery}
onChange={(e) => setSpaceSearchQuery(e.target.value)}
size="sm"
variant="filled"
radius="sm"
styles={{ input: { marginBottom: 8 } }}
/>
<ScrollArea.Autosize mah={280}>
<Menu.Item onClick={() => handleSpaceSelect(null)}>
<Group flex="1" gap="xs">
<Avatar
color="initials"
variant="filled"
name={t("All spaces")}
size={20}
/>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("All spaces")}
</Text>
<Text size="xs" c="dimmed">
{t("Search in all your spaces")}
</Text>
</div>
{!selectedSpaceId && <IconCheck size={20} />}
</Group>
</Menu.Item>
<Divider my="xs" />
{availableSpaces.map((space) => (
<Menu.Item
key={space.id}
onClick={() => handleSpaceSelect(space.id)}
>
<Group flex="1" gap="xs">
<Avatar
color="initials"
variant="filled"
name={space.name}
size={20}
/>
<Text size="sm" fw={500} style={{ flex: 1 }} truncate>
{space.name}
</Text>
{selectedSpaceId === space.id && <IconCheck size={20} />}
</Group>
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconBuilding size={16} />}
className={classes.filterButton}
fw={500}
>
{selectedSpaceId
? `${t("Space")}: ${selectedSpaceData?.name || t("Unknown")}`
: `${t("Space")}: ${t("All spaces")}`}
</Button>
</SpaceFilterMenu>
<Menu
shadow="md"
@@ -0,0 +1,3 @@
import { atom } from "jotai";
export const openSharedTreeNodesAtom = atom<Record<string, boolean>>({});
@@ -1,6 +1,11 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { ISharedPageTree } from "@/features/share/types/share.types";
import { SharedPageTreeNode } from "@/features/share/utils";
export const sharedPageTreeAtom = atom<ISharedPageTree | null>(null);
export const sharedTreeDataAtom = atom<SharedPageTreeNode[] | null>(null);
export const sharedTreeDataAtom = atom<SharedPageTreeNode[] | null>(null);
export const sharedPageFullWidthAtom = atomWithStorage<boolean>(
"sharedPageFullWidth",
false,
);
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActionIcon,
AppShell,
@@ -14,11 +14,16 @@ import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { ThemeToggle } from "@/components/theme-toggle.tsx";
import { useAtomValue, useSetAtom } from "jotai";
import { useAtom } from "jotai";
import { sharedPageTreeAtom, sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
import {
sharedPageFullWidthAtom,
sharedPageTreeAtom,
sharedTreeDataAtom,
} from "@/features/share/atoms/shared-page-atom";
import { buildSharedPageTree } from "@/features/share/utils";
import {
desktopSidebarAtom,
mobileSidebarAtom,
sidebarWidthAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
import { useTranslation } from "react-i18next";
@@ -27,7 +32,7 @@ import {
mobileTableOfContentAsideAtom,
tableOfContentAsideAtom,
} from "@/features/share/atoms/sidebar-atom.ts";
import { IconList } from "@tabler/icons-react";
import { IconArrowsHorizontal, IconList } from "@tabler/icons-react";
import { useToggleToc } from "@/features/share/hooks/use-toggle-toc.ts";
import classes from "./share.module.css";
import {
@@ -55,6 +60,46 @@ export default function ShareShell({
const [mobileTocOpened] = useAtom(mobileTableOfContentAsideAtom);
const toggleTocMobile = useToggleToc(mobileTableOfContentAsideAtom);
const toggleToc = useToggleToc(tableOfContentAsideAtom);
const [fullWidth, setFullWidth] = useAtom(sharedPageFullWidthAtom);
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef<HTMLElement | null>(null);
const startResizing = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
}, []);
const stopResizing = useCallback(() => {
setIsResizing(false);
}, []);
const resize = useCallback(
(e: MouseEvent) => {
if (!isResizing || !sidebarRef.current) return;
const newWidth =
e.clientX - sidebarRef.current.getBoundingClientRect().left;
if (newWidth < 220) {
setSidebarWidth(220);
return;
}
if (newWidth > 600) {
setSidebarWidth(600);
return;
}
setSidebarWidth(newWidth);
},
[isResizing, setSidebarWidth],
);
useEffect(() => {
window.addEventListener("mousemove", resize);
window.addEventListener("mouseup", stopResizing);
return () => {
window.removeEventListener("mousemove", resize);
window.removeEventListener("mouseup", stopResizing);
};
}, [resize, stopResizing]);
const { shareId } = useParams();
const { data } = useGetSharedPageTreeQuery(shareId);
@@ -81,7 +126,7 @@ export default function ShareShell({
header={{ height: 50 }}
{...(data?.pageTree?.length > 1 && {
navbar: {
width: 300,
width: sidebarWidth,
breakpoint: "sm",
collapsed: {
mobile: !mobileOpened,
@@ -166,6 +211,20 @@ export default function ShareShell({
<IconList size={20} stroke={2} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Full width")} withArrow>
<ActionIcon
variant={fullWidth ? "light" : "default"}
style={fullWidth ? undefined : { border: "none" }}
aria-label={t("Full width")}
aria-pressed={fullWidth}
onClick={() => setFullWidth((v) => !v)}
visibleFrom="sm"
size="sm"
>
<IconArrowsHorizontal size={20} stroke={2} />
</ActionIcon>
</Tooltip>
</>
<ThemeToggle />
@@ -174,7 +233,11 @@ export default function ShareShell({
</AppShell.Header>
{data?.pageTree?.length > 1 && (
<AppShell.Navbar p="md" className={classes.navbar}>
<AppShell.Navbar p="md" className={classes.navbar} ref={sidebarRef}>
<div
className={classes.resizeHandle}
onMouseDown={startResizing}
/>
<MemoizedSharedTree sharedPageTree={data} />
</AppShell.Navbar>
)}
@@ -10,6 +10,7 @@
.treeNode {
text-decoration: none;
user-select: none;
padding-bottom: 0;
}
.navbar,
@@ -18,3 +19,26 @@
width: 350px;
}
}
.resizeHandle {
width: 3px;
cursor: col-resize;
position: absolute;
right: 0;
top: 0;
bottom: 0;
z-index: 1;
&:hover,
&:active {
width: 5px;
background: light-dark(
var(--mantine-color-gray-4),
var(--mantine-color-dark-5)
);
}
@media (max-width: $mantine-breakpoint-sm) {
display: none;
}
}
@@ -1,14 +1,11 @@
import { ISharedPageTree } from "@/features/share/types/share.types.ts";
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
import {
buildSharedPageTree,
SharedPageTreeNode,
} from "@/features/share/utils.ts";
import { useEffect, useMemo, useRef, useState } from "react";
import { useElementSize, useMergedRef } from "@mantine/hooks";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Link, useParams } from "react-router-dom";
import { atom, useAtom } from "jotai/index";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import clsx from "clsx";
@@ -20,176 +17,204 @@ import {
} from "@tabler/icons-react";
import { ActionIcon, Box } from "@mantine/core";
import { extractPageSlugId } from "@/lib";
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
import classes from "@/features/page/tree/styles/tree.module.css";
import styles from "./share.module.css";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import {
DocTree,
type DocTreeApi,
type RenderRowProps,
} from "@/features/page/tree/components/doc-tree";
import { openSharedTreeNodesAtom } from "@/features/share/atoms/open-shared-tree-nodes-atom";
interface SharedTree {
interface SharedTreeProps {
sharedPageTree: ISharedPageTree;
}
const openSharedTreeNodesAtom = atom<OpenMap>({});
export default function SharedTree({ sharedPageTree }: SharedTree) {
const [tree, setTree] = useState<
TreeApi<SharedPageTreeNode> | null | undefined
>(null);
const rootElement = useRef<HTMLDivElement>();
const { ref: sizeRef, width, height } = useElementSize();
const mergedRef = useMergedRef(rootElement, sizeRef);
export default function SharedTree({ sharedPageTree }: SharedTreeProps) {
const { t } = useTranslation();
const treeRef = useRef<DocTreeApi | null>(null);
const { pageSlug } = useParams();
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(
openSharedTreeNodesAtom,
);
const [openTreeNodes, setOpenTreeNodes] = useAtom(openSharedTreeNodesAtom);
const currentNodeId = extractPageSlugId(pageSlug);
const treeData: SharedPageTreeNode[] = useMemo(() => {
if (!sharedPageTree?.pageTree) return;
if (!sharedPageTree?.pageTree) return [] as SharedPageTreeNode[];
return buildSharedPageTree(sharedPageTree.pageTree);
}, [sharedPageTree?.pageTree]);
useEffect(() => {
const parentNodeId = treeData?.[0]?.slugId;
if (parentNodeId && tree) {
const parentNode = tree.get(parentNodeId);
setTimeout(() => {
if (parentNode) {
tree.openSiblings(parentNode);
}
});
// open direct children of parent node
parentNode?.children.forEach((node) => {
tree.openSiblings(node);
});
}
}, [treeData, tree]);
const openIds = useMemo(
() =>
new Set(
Object.keys(openTreeNodes).filter((k) => openTreeNodes[k]),
),
[openTreeNodes],
);
useEffect(() => {
if (currentNodeId && tree) {
setTimeout(() => {
// focus on node and open all parents
tree?.select(currentNodeId, { align: "auto" });
}, 200);
} else {
tree?.deselectAll();
// Auto-open the first level of the shared tree on initial load.
const root = treeData?.[0];
if (!root) return;
setOpenTreeNodes((prev) => {
if (prev[root.slugId]) return prev;
const next = { ...prev, [root.slugId]: true };
for (const child of root.children ?? []) {
next[child.slugId] = true;
}
return next;
});
}, [treeData, setOpenTreeNodes]);
useEffect(() => {
if (currentNodeId) {
treeRef.current?.select(currentNodeId, { scrollIntoView: true });
}
}, [currentNodeId, tree]);
}, [currentNodeId, treeData]);
// Stable callbacks so memo(DocTreeRow) actually saves work — see I2 in the
// post-implementation code review.
const handleToggle = useCallback(
(id: string, isOpen: boolean) =>
setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen })),
[setOpenTreeNodes],
);
const getDragLabel = useCallback(
(n: SharedPageTreeNode) => n.name || "untitled",
[],
);
if (!sharedPageTree || !sharedPageTree?.pageTree) {
return null;
}
return (
<div ref={mergedRef} className={classes.treeContainer}>
{rootElement.current && (
<Tree
data={treeData}
disableDrag={true}
disableDrop={true}
disableEdit={true}
width={width}
height={rootElement.current.clientHeight}
ref={(t) => setTree(t)}
openByDefault={false}
disableMultiSelection={true}
className={classes.tree}
rowClassName={classes.row}
rowHeight={30}
overscanCount={10}
dndRootElement={rootElement.current}
onToggle={() => {
setOpenTreeNodes(tree?.openState);
}}
initialOpenState={openTreeNodes}
onClick={(e) => {
if (tree && tree.focusedNode) {
tree.select(tree.focusedNode);
}
}}
>
{Node}
</Tree>
)}
<div className={classes.treeContainer}>
<DocTree<SharedPageTreeNode>
readOnly
ref={treeRef}
data={treeData}
openIds={openIds}
selectedId={currentNodeId}
renderRow={SharedTreeRow}
onMove={noopMove}
onToggle={handleToggle}
getDragLabel={getDragLabel}
aria-label={t("Pages")}
/>
</div>
);
}
function Node({ node, style, tree }: NodeRendererProps<any>) {
// Module-scope noop so it's a stable reference across renders.
const noopMove = () => {};
function SharedTreeRow({
node,
isOpen,
hasChildren,
isSelected,
rowRef,
tabIndex,
treeItemProps,
toggleOpen,
}: RenderRowProps<SharedPageTreeNode>) {
const { shareId } = useParams();
const { t } = useTranslation();
const [, setMobileSidebarState] = useAtom(mobileSidebarAtom);
const pageUrl = buildSharedPageUrl({
shareId: shareId,
pageSlugId: node.data.slugId,
pageTitle: node.data.name,
pageSlugId: node.slugId,
pageTitle: node.name,
});
return (
<>
<Box
style={style}
className={clsx(classes.node, node.state, styles.treeNode)}
component={Link}
to={pageUrl}
onClick={() => {
setMobileSidebarState(false);
}}
>
<PageArrow node={node} />
<div style={{ marginRight: "4px" }}>
<EmojiPicker
onEmojiSelect={() => {}}
icon={
node.data.icon ? (
node.data.icon
) : (
<IconFileDescription size="18" />
)
}
readOnly={true}
removeEmojiAction={() => {}}
/>
</div>
<span className={classes.text}>{node.data.name || t("untitled")}</span>
</Box>
</>
<Box
ref={rowRef as React.Ref<HTMLAnchorElement>}
tabIndex={tabIndex}
{...treeItemProps}
data-selected={isSelected || undefined}
className={clsx(classes.node, styles.treeNode)}
component={Link}
to={pageUrl}
onClick={() => {
setMobileSidebarState(false);
}}
>
<SharedPageArrow
isOpen={isOpen}
hasChildren={hasChildren}
onToggle={toggleOpen}
/>
<div style={{ marginRight: "4px" }}>
<EmojiPicker
onEmojiSelect={() => {}}
icon={
node.icon ? (
node.icon
) : (
<IconFileDescription size="18" />
)
}
readOnly={true}
removeEmojiAction={() => {}}
actionIconProps={{ tabIndex: -1 }}
/>
</div>
<span className={classes.text}>{node.name || t("untitled")}</span>
</Box>
);
}
interface PageArrowProps {
node: NodeApi<SpaceTreeNode>;
interface SharedPageArrowProps {
isOpen: boolean;
hasChildren: boolean;
onToggle: () => void;
}
function PageArrow({ node }: PageArrowProps) {
function SharedPageArrow({
isOpen,
hasChildren,
onToggle,
}: SharedPageArrowProps) {
if (!hasChildren) {
return (
<span
aria-hidden
style={{
width: 20,
height: 20,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
color: "var(--mantine-color-gray-6)",
flexShrink: 0,
}}
>
<IconPointFilled size={4} />
</span>
);
}
return (
<ActionIcon
size={20}
variant="subtle"
c="gray"
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
node.toggle();
onToggle();
}}
>
{node.isInternal ? (
node.children && (node.children.length > 0 || node.data.hasChildren) ? (
node.isOpen ? (
<IconChevronDown stroke={2} size={16} />
) : (
<IconChevronRight stroke={2} size={16} />
)
) : (
<IconPointFilled size={4} />
)
) : null}
{isOpen ? (
<IconChevronDown stroke={2} size={16} />
) : (
<IconChevronRight stroke={2} size={16} />
)}
</ActionIcon>
);
}
@@ -28,7 +28,7 @@ import {
import classes from "./space-sidebar.module.css";
import React from "react";
import { useAtom } from "jotai";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import { Link, useLocation, useParams } from "react-router-dom";
import clsx from "clsx";
import { useDisclosure } from "@mantine/hooks";
@@ -56,7 +56,6 @@ import { searchSpotlight } from "@/features/search/constants";
export function SpaceSidebar() {
const { t } = useTranslation();
const [tree] = useAtom(treeApiAtom);
const location = useLocation();
const [opened, { open: openSettings, close: closeSettings }] =
useDisclosure(false);
@@ -68,13 +67,14 @@ export function SpaceSidebar() {
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
const { handleCreate } = useTreeMutation(space?.id ?? "");
if (!space) {
return <></>;
}
function handleCreatePage() {
tree?.create({ parentId: null, type: "internal", index: 0 });
handleCreate(null);
}
return (
@@ -0,0 +1,121 @@
import { ReactNode, useMemo, useState } from "react";
import {
Avatar,
Divider,
Group,
Menu,
ScrollArea,
Text,
TextInput,
getDefaultZIndex,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconCheck, IconSearch } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
type SpaceFilterMenuProps = {
value: string | null;
onChange: (spaceId: string | null) => void;
children: ReactNode;
width?: number;
position?:
| "bottom-start"
| "bottom-end"
| "bottom"
| "top-start"
| "top-end"
| "top";
zIndex?: number;
};
export function SpaceFilterMenu({
value,
onChange,
children,
width = 280,
position = "bottom-end",
zIndex,
}: SpaceFilterMenuProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const { data: spacesData } = useGetSpacesQuery({
limit: 100,
query: debouncedQuery,
});
const spaces = spacesData?.items ?? [];
const orderedSpaces = useMemo(() => {
if (!value) return spaces;
return [...spaces].sort((a, b) => {
if (a.id === value) return -1;
if (b.id === value) return 1;
return 0;
});
}, [spaces, value]);
return (
<Menu shadow="md" width={width} position={position} zIndex={zIndex}>
<Menu.Target>{children}</Menu.Target>
<Menu.Dropdown>
<TextInput
placeholder={t("Find a space")}
data-autofocus
autoFocus
leftSection={<IconSearch size={16} />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
size="sm"
variant="filled"
radius="sm"
styles={{ input: { marginBottom: 8 } }}
/>
<ScrollArea.Autosize mah={280}>
<Menu.Item onClick={() => onChange(null)}>
<Group flex="1" gap="xs">
<Avatar
color="initials"
variant="filled"
name={t("All spaces")}
size={20}
/>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("All spaces")}
</Text>
<Text size="xs" c="dimmed">
{t("Search in all your spaces")}
</Text>
</div>
{!value && <IconCheck size={20} />}
</Group>
</Menu.Item>
<Divider my="xs" />
{orderedSpaces.map((space) => (
<Menu.Item key={space.id} onClick={() => onChange(space.id)}>
<Group flex="1" gap="xs">
<Avatar
color="initials"
variant="filled"
name={space.name}
size={20}
/>
<Text size="sm" fw={500} style={{ flex: 1 }} truncate>
{space.name}
</Text>
{value === space.id && <IconCheck size={20} />}
</Group>
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
);
}
export const SPACE_FILTER_MENU_MAX_Z = getDefaultZIndex("max");
@@ -6,6 +6,7 @@ import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row";
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts";
export default function PageStatePref() {
const { t } = useTranslation();
@@ -71,3 +72,24 @@ export function PageStateSegmentedControl({
/>
);
}
// Header variant: updates the current page's mode locally without persisting
// the preference to the server.
export function PageEditModeToggle({ size }: { size?: MantineSize }) {
const { t } = useTranslation();
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
currentPageEditModeAtom,
);
return (
<SegmentedControl
size={size}
value={currentPageEditMode}
onChange={(v) => setCurrentPageEditMode(v as PageEditMode)}
data={[
{ label: t("Edit"), value: PageEditMode.Edit },
{ label: t("Read"), value: PageEditMode.Read },
]}
/>
);
}
@@ -1,37 +1,27 @@
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
import { useAtom } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { WebSocketEvent } from "@/features/websocket/types";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { useQueryClient } from "@tanstack/react-query";
import { SimpleTree } from "react-arborist";
import { treeModel } from "@/features/page/tree/model/tree-model";
import localEmitter from "@/lib/local-emitter.ts";
export const useTreeSocket = () => {
const [socket] = useAtom(socketAtom);
const [treeData, setTreeData] = useAtom(treeDataAtom);
const [, setTreeData] = useAtom(treeDataAtom);
const queryClient = useQueryClient();
const initialTreeData = useRef(treeData);
useEffect(() => {
initialTreeData.current = treeData;
}, [treeData]);
useEffect(() => {
const updateNodeName = (event) => {
const initialData = initialTreeData.current;
const treeApi = new SimpleTree<SpaceTreeNode>(initialData);
if (treeApi.find(event?.id)) {
if (event.payload?.title !== undefined) {
treeApi.update({
id: event.id,
changes: { name: event.payload.title },
});
setTreeData(treeApi.data);
}
}
if (event.payload?.title === undefined) return;
setTreeData((prev) => {
if (!treeModel.find(prev, event?.id)) return prev;
return treeModel.update(prev, event.id, {
name: event.payload.title,
} as Partial<SpaceTreeNode>);
});
};
localEmitter.on("message", updateNodeName);
@@ -42,70 +32,110 @@ export const useTreeSocket = () => {
useEffect(() => {
socket?.on("message", (event: WebSocketEvent) => {
const initialData = initialTreeData.current;
const treeApi = new SimpleTree<SpaceTreeNode>(initialData);
switch (event.operation) {
case "updateOne":
if (event.entity[0] === "pages") {
if (treeApi.find(event.id)) {
setTreeData((prev) => {
if (!treeModel.find(prev, event.id)) return prev;
let next = prev;
if (event.payload?.title !== undefined) {
treeApi.update({
id: event.id,
changes: { name: event.payload.title },
});
next = treeModel.update(next, event.id, {
name: event.payload.title,
} as Partial<SpaceTreeNode>);
}
if (event.payload?.icon !== undefined) {
treeApi.update({
id: event.id,
changes: { icon: event.payload.icon },
});
next = treeModel.update(next, event.id, {
icon: event.payload.icon,
} as Partial<SpaceTreeNode>);
}
setTreeData(treeApi.data);
}
return next;
});
}
break;
case "addTreeNode":
if (treeApi.find(event.payload.data.id)) return;
treeApi.create({
parentId: event.payload.parentId,
index: event.payload.index,
data: event.payload.data,
setTreeData((prev) => {
if (treeModel.find(prev, event.payload.data.id)) return prev;
const newParentId = event.payload.parentId as string | null;
let next = treeModel.insert(
prev,
newParentId,
event.payload.data,
event.payload.index,
);
// Mirror the emitter: flip new parent's hasChildren to true so
// the chevron renders on the receiver.
if (newParentId) {
next = treeModel.update(next, newParentId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
return next;
});
setTreeData(treeApi.data);
break;
case "moveTreeNode":
// move node
if (treeApi.find(event.payload.id)) {
treeApi.move({
id: event.payload.id,
parentId: event.payload.parentId,
setTreeData((prev) => {
const sourceBefore = treeModel.find(prev, event.payload.id);
if (!sourceBefore) return prev;
const oldParentId =
(sourceBefore as SpaceTreeNode).parentPageId ?? null;
const newParentId = event.payload.parentId as string | null;
const placed = treeModel.place(prev, event.payload.id, {
parentId: newParentId,
index: event.payload.index,
});
// `place` silently returns the same reference if the destination
// parent isn't loaded on this client. Falling back to removing the
// source keeps the UI consistent (the source will reappear when
// the user expands the new parent and lazy-load fetches it).
if (placed === prev) {
return treeModel.remove(prev, event.payload.id);
}
// update node position
treeApi.update({
id: event.payload.id,
changes: {
position: event.payload.position,
},
});
let next = treeModel.update(placed, event.payload.id, {
position: event.payload.position,
parentPageId: newParentId,
} as Partial<SpaceTreeNode>);
setTreeData(treeApi.data);
}
// Mirror the emitter's hasChildren bookkeeping so both clients
// converge to the same chevron state.
if (oldParentId) {
const oldParent = treeModel.find(next, oldParentId);
if (!oldParent?.children?.length) {
next = treeModel.update(next, oldParentId, {
hasChildren: false,
} as Partial<SpaceTreeNode>);
}
}
if (newParentId) {
next = treeModel.update(next, newParentId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
return next;
});
break;
case "deleteTreeNode":
if (treeApi.find(event.payload.node.id)) {
treeApi.drop({ id: event.payload.node.id });
setTreeData(treeApi.data);
setTreeData((prev) => {
if (!treeModel.find(prev, event.payload.node.id)) return prev;
queryClient.invalidateQueries({
queryKey: ["pages", event.payload.node.slugId].filter(Boolean),
});
}
let next = treeModel.remove(prev, event.payload.node.id);
// Mirror the emitter's hasChildren bookkeeping so both clients
// converge to the same chevron state when the last child is deleted.
const parentPageId = event.payload.node.parentPageId;
if (parentPageId) {
const parent = treeModel.find(next, parentPageId);
if (!parent?.children?.length) {
next = treeModel.update(next, parentPageId, {
hasChildren: false,
} as Partial<SpaceTreeNode>);
}
}
return next;
});
break;
}
});
+30 -12
View File
@@ -31,20 +31,38 @@ const APP_ROUTE = {
},
};
export function safeRedirectPath(input: unknown): string | null {
if (typeof input !== "string") return null;
if (input.length === 0 || input.length > 2048) return null;
// Reject whitespace, backslash, and any Unicode "Other" category char
// (ASCII controls, zero-width space, BOM, bidi marks, etc).
if (/[\s\\]|\p{C}/u.test(input)) return null;
if (!input.startsWith("/") || input.startsWith("//")) return null;
if (input.toLowerCase().includes("://")) return null;
if (/^\/[a-z][a-z0-9+\-.]*:/i.test(input)) return null;
try {
const resolved = new URL(input, window.location.origin);
if (resolved.origin !== window.location.origin) return null;
return resolved.pathname + resolved.search + resolved.hash;
} catch {
return null;
}
}
export function getPostLoginRedirect(): string {
const params = new URLSearchParams(window.location.search);
const redirect = params.get("redirect");
if (redirect) {
try {
const resolved = new URL(redirect, window.location.origin);
if (resolved.origin === window.location.origin) {
return resolved.pathname + resolved.search + resolved.hash;
}
} catch {
// malformed URL, fall through to default
}
}
return APP_ROUTE.HOME;
return safeRedirectPath(params.get("redirect")) ?? APP_ROUTE.HOME;
}
/**
* Returns the `?redirect=` value from the current URL only when it is a safe
* same-origin path. Unlike {@link getPostLoginRedirect} this returns `null`
* (not `/home`) when no redirect is present, so callers can distinguish
* "user came here directly" from "user was bounced from a deep link".
*/
export function getRedirectParam(): string | null {
const params = new URLSearchParams(window.location.search);
return safeRedirectPath(params.get("redirect"));
}
export default APP_ROUTE;
+180
View File
@@ -0,0 +1,180 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Button,
Center,
Container,
Group,
Loader,
Stack,
Text,
TextInput,
useComputedColorScheme,
} from "@mantine/core";
import {
IconChevronDown,
IconLabel,
IconSearch,
} from "@tabler/icons-react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Helmet } from "react-helmet-async";
import { useDebouncedValue } from "@mantine/hooks";
import { getAppName } from "@/lib/config";
import { useLabelPagesQuery } from "@/features/label/queries/label-query.ts";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
import { LabelPageRow } from "@/features/label/components/label-page-row.tsx";
import { LabelPageRowSkeleton } from "@/features/label/components/label-page-row-skeleton.tsx";
import { normalizeLabelName } from "@/features/label/utils/normalize-label.ts";
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu.tsx";
import { EmptyState } from "@/components/ui/empty-state";
import classes from "@/features/label/label.module.css";
export default function LabelPage() {
const { t } = useTranslation();
const { labelName: rawName } = useParams<{ labelName: string }>();
const labelName = normalizeLabelName(decodeURIComponent(rawName ?? ""));
const scheme = useComputedColorScheme("light");
const c = getLabelColor(labelName, scheme);
const [spaceId, setSpaceId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search.trim(), 200);
const activeSpaceId = spaceId ?? undefined;
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
const spaces = spacesData?.items ?? [];
const {
data: pagesData,
isLoading: pagesLoading,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useLabelPagesQuery(labelName, debouncedSearch, activeSpaceId);
const pages = useMemo(
() => pagesData?.pages.flatMap((p) => p.items) ?? [],
[pagesData],
);
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ rootMargin: "200px 0px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const selectedSpaceName = useMemo(() => {
if (!spaceId) return t("All spaces");
return spaces.find((s) => s.id === spaceId)?.name ?? t("All spaces");
}, [spaceId, spaces, t]);
return (
<>
<Helmet>
<title>
{labelName} - {getAppName()}
</title>
</Helmet>
<Container size={820} py="xl">
<Stack gap="lg">
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t("Labels")}
{" / "}
<Text component="span" c="bright" fw={500}>
{labelName}
</Text>
</Text>
<Group gap="md" align="center" wrap="nowrap">
<Link
to={`/labels/${encodeURIComponent(labelName)}`}
className={classes.headerChip}
style={{ background: c.bg, color: c.fg }}
>
<span
className={classes.headerDot}
style={{ background: c.dot }}
/>
<span>{labelName}</span>
</Link>
</Group>
</Stack>
<Group gap="sm" wrap="nowrap" align="center">
<TextInput
placeholder={t("Search by title")}
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.target.value)}
size="sm"
style={{ flex: 1 }}
/>
<SpaceFilterMenu value={spaceId} onChange={setSpaceId}>
<Button
variant="default"
size="sm"
rightSection={<IconChevronDown size={14} />}
>
{selectedSpaceName}
</Button>
</SpaceFilterMenu>
</Group>
{pagesLoading && pages.length === 0 ? (
<div>
<LabelPageRowSkeleton titleWidth={260} metaWidth={170} />
<LabelPageRowSkeleton titleWidth={180} metaWidth={150} />
<LabelPageRowSkeleton titleWidth={220} metaWidth={190} />
<LabelPageRowSkeleton titleWidth={140} metaWidth={140} />
<LabelPageRowSkeleton titleWidth={240} metaWidth={170} />
</div>
) : pages.length > 0 ? (
<div>
{pages.map((page) => (
<LabelPageRow
key={page.id}
page={page}
currentLabelName={labelName}
/>
))}
<div ref={sentinelRef} />
{isFetchingNextPage && (
<Center py="md">
<Loader size="sm" />
</Center>
)}
</div>
) : (
<EmptyState
icon={IconLabel}
title={
debouncedSearch
? t("No matches")
: t("No pages with this label")
}
description={
debouncedSearch
? t("No pages match your search.")
: t("Pages tagged with this label will appear here.")
}
/>
)}
</Stack>
</Container>
</>
);
}
+1 -1
View File
@@ -52,7 +52,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canEdit = page?.permissions?.canEdit ?? false;
const canEdit = !page?.deletedAt && (page?.permissions?.canEdit ?? false);
const canComment =
canEdit ||
(space?.settings?.comments?.allowViewerComments === true);
+6 -2
View File
@@ -9,7 +9,10 @@ import { extractPageSlugId } from "@/lib";
import { Error404 } from "@/components/ui/error-404.tsx";
import ShareBranding from "@/features/share/components/share-branding.tsx";
import { useAtomValue } from "jotai";
import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom.ts";
import {
sharedPageFullWidthAtom,
sharedTreeDataAtom,
} from "@/features/share/atoms/shared-page-atom.ts";
import { isPageInTree } from "@/features/share/utils.ts";
export default function SharedPage() {
@@ -23,6 +26,7 @@ export default function SharedPage() {
});
const sharedTreeData = useAtomValue(sharedTreeDataAtom);
const fullWidth = useAtomValue(sharedPageFullWidthAtom);
useEffect(() => {
if (shareId && data) {
@@ -59,7 +63,7 @@ export default function SharedPage() {
)}
</Helmet>
<Container size={900} p={0}>
<Container fluid={fullWidth} size={fullWidth ? undefined : 900} p={0}>
<ReadonlyPageEditor
key={data.page.id}
title={data.page.title}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import * as path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: [],
},
});
+2 -2
View File
@@ -42,7 +42,7 @@
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"@keyv/redis": "^5.1.6",
"@langchain/core": "1.1.39",
"@langchain/core": "1.1.46",
"@langchain/textsplitters": "1.0.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@nest-lab/throttler-storage-redis": "^1.2.0",
@@ -81,7 +81,7 @@
"ioredis": "^5.10.1",
"js-tiktoken": "^1.0.21",
"jsonwebtoken": "^9.0.3",
"kysely": "^0.28.14",
"kysely": "^0.28.17",
"kysely-migration-cli": "^0.4.2",
"kysely-postgres-js": "^3.0.0",
"ldapts": "^8.1.7",

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