Compare commits

..

2 Commits

Author SHA1 Message Date
Philipinho faf4a29cb9 refactor(backlinks): nest space details its own object 2026-05-09 17:01:12 +01:00
Philipinho 3eb29e2a46 feat: page details section and backlinks 2026-05-09 16:48:13 +01:00
187 changed files with 3653 additions and 12883 deletions
+57 -68
View File
@@ -7,89 +7,78 @@
"build": "tsc && vite build",
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest"
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
},
"dependencies": {
"@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",
"@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",
"@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",
"@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",
"@tanstack/react-query": "5.90.17",
"@tanstack/react-virtual": "3.13.24",
"alfaaz": "1.1.0",
"alfaaz": "^1.1.0",
"axios": "1.16.0",
"blueimp-load-image": "5.16.0",
"clsx": "2.1.1",
"file-saver": "2.0.5",
"highlightjs-sap-abap": "0.3.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",
"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.15.0",
"mitt": "3.0.1",
"lowlight": "^3.3.0",
"mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.13.0",
"mitt": "^3.0.1",
"posthog-js": "1.372.2",
"react": "18.3.1",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.18",
"react-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",
"@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",
"@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",
"@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",
"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"
"@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"
}
}
@@ -71,7 +71,6 @@
"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.",
@@ -287,19 +286,6 @@
"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",
@@ -362,8 +348,6 @@
"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.",
@@ -582,8 +566,6 @@
"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",
@@ -1001,22 +983,5 @@
"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}}",
"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"
"Verified until {{date}}": "Verified until {{date}}"
}
-4
View File
@@ -39,14 +39,12 @@ import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
import Webhooks from "@/ee/webhook/pages/webhooks.tsx";
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
import TemplateList from "@/ee/template/pages/template-list";
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();
@@ -94,7 +92,6 @@ 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"}
@@ -125,7 +122,6 @@ export default function App() {
<Route path={"ai"} element={<AiSettings />} />
<Route path={"ai/mcp"} element={<AiSettings />} />
<Route path={"audit"} element={<AuditLogs />} />
<Route path={"webhooks"} element={<Webhooks />} />
<Route path={"verifications"} element={<VerifiedPages />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
@@ -1,5 +1,4 @@
import { ActionIcon, Box, Group, ScrollArea, Text, Tooltip } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import { Box, ScrollArea, Text } from "@mantine/core";
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";
@@ -12,10 +11,9 @@ 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 }, setAsideState] = useAtom(asideStateAtom);
const [{ tab }] = useAtom(asideStateAtom);
const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom);
const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false }));
let title: string;
let component: ReactNode;
@@ -47,19 +45,9 @@ export default function Aside() {
{component && (
<>
{tab !== "chat" && (
<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>
<Text mb="md" fw={500}>
{t(title)}
</Text>
)}
{tab === "comments" || tab === "chat" ? (
@@ -14,7 +14,6 @@ import { getApiKeys } from "@/ee/api-key";
import { getAuditLogs } from "@/ee/audit/services/audit-service";
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
import { getScimTokens } from "@/ee/scim/services/scim-token-service";
import { getWebhooks } from "@/ee/webhook/services/webhook-service";
export const prefetchWorkspaceMembers = () => {
const params: QueryParams = { limit: 100, query: "" };
@@ -107,11 +106,3 @@ export const prefetchScimTokens = () => {
queryFn: () => getScimTokens({}),
});
};
export const prefetchWebhooks = () => {
const params = { limit: 50 };
queryClient.prefetchQuery({
queryKey: ["webhook-list", params],
queryFn: () => getWebhooks(params),
});
};
@@ -15,7 +15,6 @@ import {
IconSparkles,
IconHistory,
IconShieldCheck,
IconWebhook,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css";
@@ -39,7 +38,6 @@ import {
prefetchWorkspaceMembers,
prefetchAuditLogs,
prefetchVerifiedPages,
prefetchWebhooks,
} from "@/components/settings/settings-queries.tsx";
import AppVersion from "@/components/settings/app-version.tsx";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
@@ -127,13 +125,6 @@ const groupedData: DataGroup[] = [
role: "owner",
env: "selfhosted",
},
{
label: "Webhooks",
icon: IconWebhook,
path: "/settings/webhooks",
feature: Feature.WEBHOOKS,
role: "admin",
},
],
},
{
@@ -231,9 +222,6 @@ export default function SettingsSidebar() {
case "Audit log":
prefetchHandler = prefetchAuditLogs;
break;
case "Webhooks":
prefetchHandler = prefetchWebhooks;
break;
case "Verified pages":
prefetchHandler = prefetchVerifiedPages;
break;
+3 -51
View File
@@ -1,4 +1,4 @@
import React, { ReactNode, useEffect, useState } from "react";
import React, { ReactNode, useState } from "react";
import {
ActionIcon,
Popover,
@@ -7,24 +7,9 @@ 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;
@@ -34,7 +19,6 @@ export interface EmojiPickerInterface {
size?: string;
variant?: string;
c?: string;
tabIndex?: number;
};
}
@@ -66,38 +50,6 @@ 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();
@@ -122,7 +74,6 @@ 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"
@@ -134,6 +85,7 @@ 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"
+5 -64
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { 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,37 +7,15 @@ 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);
const autoRedirectedRef = useRef(false);
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const handleSsoLogin = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.LDAP) {
@@ -50,47 +28,10 @@ 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} />;
-1
View File
@@ -19,5 +19,4 @@ export const Feature = {
SHARING_CONTROLS: 'sharing:controls',
TEMPLATES: 'templates',
VIEWER_COMMENTS: 'comment:viewer',
WEBHOOKS: 'webhooks',
} as const;
+3 -10
View File
@@ -18,21 +18,14 @@ export function buildSsoLoginUrl(opts: {
providerId: string;
type: SSO_PROVIDER;
workspaceId?: string;
redirect?: string;
}): string {
const { providerId, type, workspaceId, redirect } = opts;
const { providerId, type, workspaceId } = opts;
const domain = getAppUrl();
const params = new URLSearchParams();
if (redirect) params.set("redirect", redirect);
if (type === SSO_PROVIDER.GOOGLE) {
if (workspaceId) params.set("workspaceId", workspaceId);
return `${getServerAppUrl()}/api/sso/${type}/login?${params.toString()}`;
return `${getServerAppUrl()}/api/sso/${type}/login?workspaceId=${workspaceId}`;
}
const query = params.toString();
const base = `${domain}/api/sso/${type}/${providerId}/login`;
return query ? `${base}?${query}` : base;
return `${domain}/api/sso/${type}/${providerId}/login`;
}
export function getGoogleSignupUrl(): string {
@@ -1,140 +0,0 @@
import {
Button,
Group,
Modal,
MultiSelect,
Stack,
Switch,
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useTranslation } from "react-i18next";
import { useCreateWebhookMutation } from "@/ee/webhook/queries/webhook-query";
import {
EVENT_GROUPS,
multiSelectData,
} from "@/ee/webhook/lib/webhook-event-labels";
import type { WebhookEvent } from "@/ee/webhook/types/webhook.types";
interface CreateWebhookModalProps {
opened: boolean;
onClose: () => void;
onSuccess: (signingSecret: string) => void;
}
const allowedEvents: WebhookEvent[] = EVENT_GROUPS.flatMap((g) => g.events);
const formSchema = z.object({
name: z.string().min(1, "Name is required").max(100, "Name is too long"),
url: z
.string()
.min(1, "URL is required")
.refine(
(value) => /^https?:\/\//i.test(value),
"URL must start with http:// or https://",
),
subscribedEvents: z
.array(z.enum(allowedEvents as [WebhookEvent, ...WebhookEvent[]]))
.min(1, "Select at least one event"),
isActive: z.boolean(),
});
type FormValues = z.infer<typeof formSchema>;
export function CreateWebhookModal({
opened,
onClose,
onSuccess,
}: CreateWebhookModalProps) {
const { t } = useTranslation();
const createWebhookMutation = useCreateWebhookMutation();
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {
name: "",
url: "",
subscribedEvents: [],
isActive: true,
},
});
const handleClose = () => {
form.reset();
onClose();
};
const handleSubmit = async (values: FormValues) => {
try {
const result = await createWebhookMutation.mutateAsync({
name: values.name,
url: values.url,
subscribedEvents: values.subscribedEvents,
isActive: values.isActive,
});
form.reset();
onClose();
onSuccess(result.signingSecret);
} catch (_err) {
// notification handled inside mutation
}
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("Create webhook")}
size="lg"
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("e.g. Production alerts")}
required
data-autofocus
{...form.getInputProps("name")}
/>
<TextInput
label={t("URL")}
placeholder="https://example.com/webhook"
required
{...form.getInputProps("url")}
/>
<MultiSelect
label={t("Events")}
placeholder={t("Select events to subscribe to")}
data={multiSelectData()}
searchable
clearable
required
{...form.getInputProps("subscribedEvents")}
/>
<Switch
label={t("Active")}
description={t("Deliveries fire only when the webhook is active")}
checked={form.values.isActive}
onChange={(event) =>
form.setFieldValue("isActive", event.currentTarget.checked)
}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={handleClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={createWebhookMutation.isPending}>
{t("Create")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -1,310 +0,0 @@
import { Fragment, useState } from "react";
import {
Badge,
Box,
Button,
Collapse,
Drawer,
Group,
ScrollArea,
Skeleton,
Table,
Text,
} from "@mantine/core";
import {
IconChevronDown,
IconChevronRight,
IconRefresh,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useRedeliverMutation,
useWebhookDeliveries,
} from "@/ee/webhook/queries/webhook-query";
import { formattedDate } from "@/lib/time";
import NoTableResults from "@/components/common/no-table-results";
import type {
IWebhookDelivery,
WebhookDeliveryStatus,
} from "@/ee/webhook/types/webhook.types";
interface DeliveryDrawerProps {
opened: boolean;
onClose: () => void;
webhookId: string | null;
}
function statusColor(status: WebhookDeliveryStatus): string {
switch (status) {
case "success":
return "green";
case "failed":
return "red";
case "pending":
return "yellow";
case "skipped_cooldown":
case "skipped_inflight":
case "skipped_disabled":
default:
return "gray";
}
}
function statusLabel(status: WebhookDeliveryStatus): string {
switch (status) {
case "skipped_cooldown":
return "skipped (cooldown)";
case "skipped_inflight":
return "skipped (in-flight)";
case "skipped_disabled":
return "skipped (disabled)";
default:
return status;
}
}
function canRedeliver(status: WebhookDeliveryStatus): boolean {
return (
status === "failed" ||
status === "skipped_cooldown" ||
status === "skipped_inflight" ||
status === "skipped_disabled"
);
}
function DeliveryRow({
delivery,
expanded,
onToggle,
onRedeliver,
isRedelivering,
}: {
delivery: IWebhookDelivery;
expanded: boolean;
onToggle: () => void;
onRedeliver: () => void;
isRedelivering: boolean;
}) {
const { t } = useTranslation();
return (
<Fragment>
<Table.Tr style={{ cursor: "pointer" }} onClick={onToggle}>
<Table.Td>
<Group gap="xs" wrap="nowrap">
{expanded ? (
<IconChevronDown
size={16}
color="var(--mantine-color-dimmed)"
/>
) : (
<IconChevronRight
size={16}
color="var(--mantine-color-dimmed)"
/>
)}
<Text fz="sm" fw={500}>
{delivery.event}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Badge color={statusColor(delivery.status)} variant="light">
{statusLabel(delivery.status)}
</Badge>
</Table.Td>
<Table.Td>
<Text fz="sm">
{delivery.httpStatus ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm">
{delivery.durationMs != null ? `${delivery.durationMs} ms` : "—"}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formattedDate(new Date(delivery.createdAt))}
</Text>
</Table.Td>
<Table.Td onClick={(e) => e.stopPropagation()}>
{canRedeliver(delivery.status) ? (
<Button
size="compact-xs"
variant="default"
leftSection={<IconRefresh size={12} />}
onClick={onRedeliver}
loading={isRedelivering}
>
{t("Redeliver")}
</Button>
) : null}
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td colSpan={6} p={0} style={{ border: "none" }}>
<Collapse in={expanded}>
<Box
px="md"
py="sm"
style={{ background: "var(--mantine-color-gray-light)" }}
>
<Text fz="xs" fw={600} mb={4}>
{t("Payload")}
</Text>
<Box
component="pre"
style={{
fontSize: 11,
margin: 0,
padding: 8,
background: "var(--mantine-color-body)",
borderRadius: 4,
overflowX: "auto",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{JSON.stringify(delivery.payload, null, 2)}
</Box>
{delivery.responseBody && (
<>
<Text fz="xs" fw={600} mt="sm" mb={4}>
{t("Response body")}
</Text>
<Box
component="pre"
style={{
fontSize: 11,
margin: 0,
padding: 8,
background: "var(--mantine-color-body)",
borderRadius: 4,
overflowX: "auto",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{delivery.responseBody}
</Box>
</>
)}
{delivery.errorMessage && (
<>
<Text fz="xs" fw={600} mt="sm" mb={4} c="red">
{t("Error")}
</Text>
<Text fz="xs" c="red">
{delivery.errorMessage}
</Text>
</>
)}
</Box>
</Collapse>
</Table.Td>
</Table.Tr>
</Fragment>
);
}
export function DeliveryDrawer({
opened,
onClose,
webhookId,
}: DeliveryDrawerProps) {
const { t } = useTranslation();
const { data, isLoading } = useWebhookDeliveries(opened ? webhookId : null);
const redeliverMutation = useRedeliverMutation(webhookId ?? undefined);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [pendingId, setPendingId] = useState<string | null>(null);
const toggle = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleRedeliver = async (deliveryId: string) => {
setPendingId(deliveryId);
try {
await redeliverMutation.mutateAsync({ deliveryId });
} catch (_err) {
// notification handled inside mutation
} finally {
setPendingId(null);
}
};
return (
<Drawer
opened={opened}
onClose={onClose}
title={t("Recent deliveries")}
position="right"
size="xl"
>
<ScrollArea h="calc(100vh - 80px)">
<Table verticalSpacing="xs" striped={false}>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Event")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th>
<Table.Th>{t("HTTP")}</Table.Th>
<Table.Th>{t("Duration")}</Table.Th>
<Table.Th>{t("Timestamp")}</Table.Th>
<Table.Th aria-label={t("Action")} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
Array.from({ length: 6 }).map((_, i) => (
<Table.Tr key={i}>
<Table.Td>
<Skeleton height={14} width={120} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={70} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={40} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={60} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={140} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={70} />
</Table.Td>
</Table.Tr>
))
) : data && data.length > 0 ? (
data.map((delivery) => (
<DeliveryRow
key={delivery.id}
delivery={delivery}
expanded={expanded.has(delivery.id)}
onToggle={() => toggle(delivery.id)}
onRedeliver={() => handleRedeliver(delivery.id)}
isRedelivering={
pendingId === delivery.id && redeliverMutation.isPending
}
/>
))
) : (
<NoTableResults colSpan={6} text={t("No deliveries yet")} />
)}
</Table.Tbody>
</Table>
</ScrollArea>
</Drawer>
);
}
@@ -1,240 +0,0 @@
import { useEffect, useState } from "react";
import {
Button,
Divider,
Group,
Modal,
MultiSelect,
PasswordInput,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useTranslation } from "react-i18next";
import {
useRotateSecretMutation,
useSendTestMutation,
useUpdateWebhookMutation,
useWebhook,
} from "@/ee/webhook/queries/webhook-query";
import {
EVENT_GROUPS,
multiSelectData,
} from "@/ee/webhook/lib/webhook-event-labels";
import type { WebhookEvent } from "@/ee/webhook/types/webhook.types";
import { WebhookSecretModal } from "@/ee/webhook/components/webhook-secret-modal";
interface EditWebhookModalProps {
opened: boolean;
onClose: () => void;
webhookId: string | null;
}
const allowedEvents: WebhookEvent[] = EVENT_GROUPS.flatMap((g) => g.events);
const formSchema = z.object({
name: z.string().min(1, "Name is required").max(100, "Name is too long"),
url: z
.string()
.min(1, "URL is required")
.refine(
(value) => /^https?:\/\//i.test(value),
"URL must start with http:// or https://",
),
subscribedEvents: z
.array(z.enum(allowedEvents as [WebhookEvent, ...WebhookEvent[]]))
.min(1, "Select at least one event"),
isActive: z.boolean(),
});
type FormValues = z.infer<typeof formSchema>;
export function EditWebhookModal({
opened,
onClose,
webhookId,
}: EditWebhookModalProps) {
const { t } = useTranslation();
const { data: webhook, isLoading } = useWebhook(opened ? webhookId : null);
const updateMutation = useUpdateWebhookMutation();
const rotateMutation = useRotateSecretMutation();
const sendTestMutation = useSendTestMutation();
const [revealedSecret, setRevealedSecret] = useState<string | null>(null);
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {
name: "",
url: "",
subscribedEvents: [],
isActive: true,
},
});
useEffect(() => {
if (opened && webhook) {
form.setValues({
name: webhook.name,
url: webhook.url,
subscribedEvents: webhook.subscribedEvents,
isActive: webhook.isActive,
});
form.resetDirty();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, webhook?.id]);
const handleClose = () => {
form.reset();
onClose();
};
const handleSubmit = async (values: FormValues) => {
if (!webhookId) return;
try {
await updateMutation.mutateAsync({
webhookId,
name: values.name,
url: values.url,
subscribedEvents: values.subscribedEvents,
isActive: values.isActive,
});
onClose();
} catch (_err) {
// notification handled inside mutation
}
};
const handleRotate = async () => {
if (!webhookId) return;
try {
const result = await rotateMutation.mutateAsync({ webhookId });
setRevealedSecret(result.signingSecret);
} catch (_err) {
// notification handled inside mutation
}
};
const handleSendTest = async () => {
if (!webhookId) return;
try {
await sendTestMutation.mutateAsync({ webhookId });
} catch (_err) {
// notification handled inside mutation
}
};
return (
<>
<Modal
opened={opened}
onClose={handleClose}
title={t("Edit webhook")}
size="lg"
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("e.g. Production alerts")}
required
disabled={isLoading}
{...form.getInputProps("name")}
/>
<TextInput
label={t("URL")}
placeholder="https://example.com/webhook"
required
disabled={isLoading}
{...form.getInputProps("url")}
/>
<MultiSelect
label={t("Events")}
placeholder={t("Select events to subscribe to")}
data={multiSelectData()}
searchable
clearable
required
disabled={isLoading}
{...form.getInputProps("subscribedEvents")}
/>
<Switch
label={t("Active")}
description={t(
"Deliveries fire only when the webhook is active",
)}
checked={form.values.isActive}
disabled={isLoading}
onChange={(event) =>
form.setFieldValue("isActive", event.currentTarget.checked)
}
/>
<Divider my="xs" />
<div>
<Text size="sm" fw={500} mb="xs">
{t("Signing secret")}
</Text>
<Group gap="xs" wrap="nowrap" align="flex-start">
<PasswordInput
value="dm_wh_••••••••••••••••••••••••••••••••"
readOnly
visible={false}
style={{ flex: 1 }}
/>
<Button
variant="default"
onClick={handleRotate}
loading={rotateMutation.isPending}
>
{t("Rotate")}
</Button>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{t(
"Rotating generates a new signing secret. The previous secret stops working immediately.",
)}
</Text>
</div>
<Divider my="xs" />
<Group justify="space-between" mt="md">
<Button
variant="default"
onClick={handleSendTest}
loading={sendTestMutation.isPending}
>
{t("Send test event")}
</Button>
<Group>
<Button variant="default" onClick={handleClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={updateMutation.isPending}>
{t("Save")}
</Button>
</Group>
</Group>
</Stack>
</form>
</Modal>
<WebhookSecretModal
opened={!!revealedSecret}
onClose={() => setRevealedSecret(null)}
secret={revealedSecret}
/>
</>
);
}
@@ -1,78 +0,0 @@
import {
Alert,
Button,
Code,
Group,
Modal,
Stack,
Text,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy";
interface WebhookSecretModalProps {
opened: boolean;
onClose: () => void;
secret: string | null;
}
export function WebhookSecretModal({
opened,
onClose,
secret,
}: WebhookSecretModalProps) {
const { t } = useTranslation();
if (!secret) return null;
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Save this signing secret")}
size="lg"
>
<Stack gap="md">
<Alert
icon={<IconAlertTriangle size={16} />}
title={t("Important")}
color="red"
>
{t(
"We won't show it again. Copy it now and store it somewhere safe. You can rotate it later if needed.",
)}
</Alert>
<div>
<Text size="sm" fw={500} mb="xs">
{t("Signing secret")}
</Text>
<Group gap="xs" wrap="nowrap" align="center">
<Code
block
style={{
flex: 1,
wordBreak: "break-all",
whiteSpace: "pre-wrap",
}}
>
{secret}
</Code>
<CopyTextButton text={secret} />
</Group>
</div>
<Text size="sm" c="dimmed">
{t(
"Use this secret to verify the X-Docmost-Signature header on incoming webhook deliveries.",
)}
</Text>
<Button fullWidth onClick={onClose} mt="md">
{t("I've saved my signing secret")}
</Button>
</Stack>
</Modal>
);
}
@@ -1,226 +0,0 @@
import {
ActionIcon,
Anchor,
Badge,
Group,
Menu,
Skeleton,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { modals } from "@mantine/modals";
import {
IconDots,
IconEdit,
IconList,
IconSend,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useDeleteWebhookMutation,
useSendTestMutation,
} from "@/ee/webhook/queries/webhook-query";
import { formattedDate } from "@/lib/time";
import NoTableResults from "@/components/common/no-table-results";
import type { IWebhook } from "@/ee/webhook/types/webhook.types";
interface WebhookTableProps {
webhooks: IWebhook[] | undefined;
isLoading: boolean;
onEdit: (webhook: IWebhook) => void;
onViewDeliveries: (webhook: IWebhook) => void;
}
function truncate(value: string, max: number): string {
if (value.length <= max) return value;
return value.slice(0, max) + "…";
}
function TableSkeleton() {
return (
<>
{Array.from({ length: 5 }).map((_, i) => (
<Table.Tr key={i}>
<Table.Td>
<Skeleton height={14} width={140} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={220} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={70} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={70} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={120} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={24} />
</Table.Td>
</Table.Tr>
))}
</>
);
}
export function WebhookTable({
webhooks,
isLoading,
onEdit,
onViewDeliveries,
}: WebhookTableProps) {
const { t } = useTranslation();
const deleteMutation = useDeleteWebhookMutation();
const sendTestMutation = useSendTestMutation();
const handleDelete = (webhook: IWebhook) => {
modals.openConfirmModal({
title: t("Delete webhook"),
children: (
<Text size="sm">
{t(
"Are you sure you want to delete the webhook {{name}}? This action cannot be undone.",
{ name: webhook.name },
)}
</Text>
),
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
deleteMutation.mutate({ webhookId: webhook.id });
},
});
};
return (
<Table.ScrollContainer minWidth={760}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
<Table.Th>{t("URL")}</Table.Th>
<Table.Th>{t("Events")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th aria-label={t("Action")} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<TableSkeleton />
) : webhooks && webhooks.length > 0 ? (
webhooks.map((webhook) => (
<Table.Tr key={webhook.id}>
<Table.Td>
<Anchor
component="button"
type="button"
onClick={() => onEdit(webhook)}
underline="never"
style={{ color: "var(--mantine-color-text)" }}
>
<Text fz="sm" fw={500} lineClamp={1}>
{webhook.name}
</Text>
</Anchor>
</Table.Td>
<Table.Td>
<Tooltip label={webhook.url} withArrow position="top-start">
<Text fz="sm" c="dimmed" style={{ fontFamily: "monospace" }}>
{truncate(webhook.url, 60)}
</Text>
</Tooltip>
</Table.Td>
<Table.Td>
<Tooltip
label={webhook.subscribedEvents.join(", ")}
withArrow
multiline
w={280}
>
<Badge variant="light" color="blue">
{t("{{count}} events", {
count: webhook.subscribedEvents.length,
})}
</Badge>
</Tooltip>
</Table.Td>
<Table.Td>
<Badge
color={webhook.isActive ? "green" : "gray"}
variant="light"
>
{webhook.isActive ? t("Active") : t("Inactive")}
</Badge>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formattedDate(new Date(webhook.createdAt))}
</Text>
</Table.Td>
<Table.Td>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("Webhook menu")}
>
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconEdit size={16} />}
onClick={() => onEdit(webhook)}
>
{t("Edit")}
</Menu.Item>
<Menu.Item
leftSection={<IconSend size={16} />}
onClick={() =>
sendTestMutation.mutate({ webhookId: webhook.id })
}
>
{t("Send test event")}
</Menu.Item>
<Menu.Item
leftSection={<IconList size={16} />}
onClick={() => onViewDeliveries(webhook)}
>
{t("View deliveries")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={() => handleDelete(webhook)}
>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults
colSpan={6}
text={t("No webhooks yet. Add one to start receiving events.")}
/>
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -1,43 +0,0 @@
import type { WebhookEvent } from "@/ee/webhook/types/webhook.types";
export const EVENT_GROUPS: { group: string; events: WebhookEvent[] }[] = [
{
group: "Pages",
events: [
"page.created",
"page.updated",
"page.moved",
"page.deleted",
"page.restored",
],
},
{
group: "Comments",
events: [
"comment.created",
"comment.updated",
"comment.deleted",
"comment.resolved",
],
},
{
group: "Spaces",
events: ["space.created", "space.updated", "space.deleted"],
},
{
group: "Attachments",
events: ["attachment.uploaded"],
},
{
group: "Members",
events: ["user.created", "user.deactivated"],
},
];
export const eventLabel = (event: string): string => event;
export const multiSelectData = () =>
EVENT_GROUPS.map(({ group, events }) => ({
group,
items: events.map((e) => ({ value: e, label: e })),
}));
@@ -1,108 +0,0 @@
import { useMemo, useState } from "react";
import { Button, Group, Space } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import useUserRole from "@/hooks/use-user-role";
import { useWebhooks } from "@/ee/webhook/queries/webhook-query";
import { WebhookTable } from "@/ee/webhook/components/webhook-table";
import { CreateWebhookModal } from "@/ee/webhook/components/create-webhook-modal";
import { EditWebhookModal } from "@/ee/webhook/components/edit-webhook-modal";
import { WebhookSecretModal } from "@/ee/webhook/components/webhook-secret-modal";
import { DeliveryDrawer } from "@/ee/webhook/components/delivery-drawer";
import type { IWebhook, IListWebhooksParams } from "@/ee/webhook/types/webhook.types";
export default function Webhooks() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const { cursor, goNext, goPrev } = useCursorPaginate();
const [createOpened, setCreateOpened] = useState(false);
const [revealedSecret, setRevealedSecret] = useState<string | null>(null);
const [editingWebhookId, setEditingWebhookId] = useState<string | null>(null);
const [deliveryWebhookId, setDeliveryWebhookId] = useState<string | null>(
null,
);
const params: IListWebhooksParams = useMemo(
() => ({ cursor, limit: 50 }),
[cursor],
);
const { data, isLoading } = useWebhooks(params);
if (!isAdmin) {
return null;
}
const handleEdit = (webhook: IWebhook) => {
setEditingWebhookId(webhook.id);
};
const handleViewDeliveries = (webhook: IWebhook) => {
setDeliveryWebhookId(webhook.id);
};
return (
<>
<Helmet>
<title>
{t("Webhooks")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Webhooks")} />
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateOpened(true)}>
{t("Add webhook")}
</Button>
</Group>
<WebhookTable
webhooks={data?.items}
isLoading={isLoading}
onEdit={handleEdit}
onViewDeliveries={handleViewDeliveries}
/>
<Space h="md" />
{data?.items && data.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
<CreateWebhookModal
opened={createOpened}
onClose={() => setCreateOpened(false)}
onSuccess={(signingSecret) => setRevealedSecret(signingSecret)}
/>
<WebhookSecretModal
opened={!!revealedSecret}
onClose={() => setRevealedSecret(null)}
secret={revealedSecret}
/>
<EditWebhookModal
opened={!!editingWebhookId}
onClose={() => setEditingWebhookId(null)}
webhookId={editingWebhookId}
/>
<DeliveryDrawer
opened={!!deliveryWebhookId}
onClose={() => setDeliveryWebhookId(null)}
webhookId={deliveryWebhookId}
/>
</>
);
}
@@ -1,190 +0,0 @@
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import {
createWebhook,
deleteWebhook,
getWebhook,
getWebhookDeliveries,
getWebhooks,
redeliverWebhook,
rotateWebhookSecret,
sendWebhookTest,
updateWebhook,
} from "@/ee/webhook/services/webhook-service";
import {
ICreateWebhook,
IListWebhooksParams,
IUpdateWebhook,
IWebhook,
IWebhookCreated,
IWebhookDelivery,
} from "@/ee/webhook/types/webhook.types";
import { IPagination } from "@/lib/types";
const WEBHOOK_LIST_KEY = "webhook-list";
const WEBHOOK_INFO_KEY = "webhook-info";
const WEBHOOK_DELIVERIES_KEY = "webhook-deliveries";
export function useWebhooks(
params?: IListWebhooksParams,
): UseQueryResult<IPagination<IWebhook>, Error> {
return useQuery({
queryKey: [WEBHOOK_LIST_KEY, params],
queryFn: () => getWebhooks(params),
placeholderData: keepPreviousData,
});
}
export function useWebhook(
webhookId: string | null | undefined,
): UseQueryResult<IWebhook, Error> {
return useQuery({
queryKey: [WEBHOOK_INFO_KEY, webhookId],
queryFn: () => getWebhook(webhookId as string),
enabled: !!webhookId,
});
}
export function useWebhookDeliveries(
webhookId: string | null | undefined,
): UseQueryResult<IWebhookDelivery[], Error> {
return useQuery({
queryKey: [WEBHOOK_DELIVERIES_KEY, webhookId],
queryFn: () => getWebhookDeliveries(webhookId as string),
enabled: !!webhookId,
});
}
function invalidateLists(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({
predicate: (item) => item.queryKey[0] === WEBHOOK_LIST_KEY,
});
}
export function useCreateWebhookMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IWebhookCreated, Error, ICreateWebhook>({
mutationFn: (data) => createWebhook(data),
onSuccess: () => {
notifications.show({ message: t("Webhook created") });
invalidateLists(queryClient);
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useUpdateWebhookMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IWebhook, Error, IUpdateWebhook>({
mutationFn: (data) => updateWebhook(data),
onSuccess: (data) => {
notifications.show({ message: t("Webhook updated") });
invalidateLists(queryClient);
queryClient.invalidateQueries({
queryKey: [WEBHOOK_INFO_KEY, data.id],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useDeleteWebhookMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<{ success: boolean }, Error, { webhookId: string }>({
mutationFn: ({ webhookId }) => deleteWebhook(webhookId),
onSuccess: () => {
notifications.show({ message: t("Webhook deleted") });
invalidateLists(queryClient);
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useRotateSecretMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<
{ signingSecret: string },
Error,
{ webhookId: string }
>({
mutationFn: ({ webhookId }) => rotateWebhookSecret(webhookId),
onSuccess: (_data, variables) => {
notifications.show({ message: t("Signing secret rotated") });
queryClient.invalidateQueries({
queryKey: [WEBHOOK_INFO_KEY, variables.webhookId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useSendTestMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<{ deliveryId: string }, Error, { webhookId: string }>({
mutationFn: ({ webhookId }) => sendWebhookTest(webhookId),
onSuccess: (_data, variables) => {
notifications.show({ message: t("Test event sent") });
queryClient.invalidateQueries({
queryKey: [WEBHOOK_DELIVERIES_KEY, variables.webhookId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useRedeliverMutation(webhookId?: string) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<{ deliveryId: string }, Error, { deliveryId: string }>({
mutationFn: ({ deliveryId }) => redeliverWebhook(deliveryId),
onSuccess: () => {
notifications.show({ message: t("Redelivery queued") });
if (webhookId) {
queryClient.invalidateQueries({
queryKey: [WEBHOOK_DELIVERIES_KEY, webhookId],
});
} else {
queryClient.invalidateQueries({
predicate: (item) => item.queryKey[0] === WEBHOOK_DELIVERIES_KEY,
});
}
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
@@ -1,81 +0,0 @@
import api from "@/lib/api-client";
import { IPagination } from "@/lib/types";
import {
ICreateWebhook,
IListWebhooksParams,
IUpdateWebhook,
IWebhook,
IWebhookCreated,
IWebhookDelivery,
} from "@/ee/webhook/types/webhook.types";
export async function getWebhooks(
params?: IListWebhooksParams,
): Promise<IPagination<IWebhook>> {
const req = await api.post("/webhooks", { ...params });
return req.data;
}
export async function getWebhook(webhookId: string): Promise<IWebhook> {
const req = await api.post<IWebhook>("/webhooks/info", { webhookId });
return req.data;
}
export async function createWebhook(
data: ICreateWebhook,
): Promise<IWebhookCreated> {
const req = await api.post<IWebhookCreated>("/webhooks/create", data);
return req.data;
}
export async function updateWebhook(data: IUpdateWebhook): Promise<IWebhook> {
const req = await api.post<IWebhook>("/webhooks/update", data);
return req.data;
}
export async function deleteWebhook(
webhookId: string,
): Promise<{ success: boolean }> {
const req = await api.post("/webhooks/delete", { webhookId });
return req.data;
}
export async function rotateWebhookSecret(
webhookId: string,
): Promise<{ signingSecret: string }> {
const req = await api.post<{ signingSecret: string }>(
"/webhooks/rotate-secret",
{ webhookId },
);
return req.data;
}
export async function sendWebhookTest(
webhookId: string,
): Promise<{ deliveryId: string }> {
const req = await api.post<{ deliveryId: string }>("/webhooks/test", {
webhookId,
});
return req.data;
}
export async function getWebhookDeliveries(
webhookId: string,
limit?: number,
): Promise<IWebhookDelivery[]> {
const req = await api.post<IWebhookDelivery[]>("/webhooks/deliveries", {
webhookId,
limit,
});
return req.data;
}
export async function redeliverWebhook(
deliveryId: string,
): Promise<{ deliveryId: string }> {
const req = await api.post<{ deliveryId: string }>(
"/webhooks/deliveries/redeliver",
{ deliveryId },
);
return req.data;
}
@@ -1,77 +0,0 @@
export type WebhookEvent =
| "page.created"
| "page.updated"
| "page.moved"
| "page.deleted"
| "page.restored"
| "comment.created"
| "comment.updated"
| "comment.deleted"
| "comment.resolved"
| "space.created"
| "space.updated"
| "space.deleted"
| "attachment.uploaded"
| "user.created"
| "user.deactivated";
export type WebhookDeliveryStatus =
| "pending"
| "success"
| "failed"
| "skipped_cooldown"
| "skipped_disabled"
| "skipped_inflight";
export interface IWebhook {
id: string;
workspaceId: string;
name: string;
url: string;
subscribedEvents: WebhookEvent[];
isActive: boolean;
consecutiveFailureCount: number;
disabledAt: string | null;
creatorId: string | null;
createdAt: string;
updatedAt: string;
}
export interface IWebhookCreated extends IWebhook {
signingSecret: string;
}
export interface IWebhookDelivery {
id: string;
webhookId: string;
event: string;
payload: Record<string, unknown>;
status: WebhookDeliveryStatus;
httpStatus: number | null;
responseBody: string | null;
errorMessage: string | null;
attemptCount: number;
durationMs: number | null;
deliveredAt: string | null;
createdAt: string;
}
export interface ICreateWebhook {
name: string;
url: string;
subscribedEvents: WebhookEvent[];
isActive?: boolean;
}
export interface IUpdateWebhook {
webhookId: string;
name?: string;
url?: string;
subscribedEvents?: WebhookEvent[];
isActive?: boolean;
}
export interface IListWebhooksParams {
cursor?: string;
limit?: number;
}
@@ -166,7 +166,7 @@ export default function useAuth() {
const handleLogout = async () => {
setCurrentUser(RESET);
await logout();
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
window.location.replace(APP_ROUTE.AUTH.LOGIN);
};
const handleForgotPassword = async (data: IForgotPassword) => {
@@ -1,6 +1,5 @@
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);
@@ -13,7 +12,3 @@ export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false);
// Current page's edit mode — initialized from the user's saved preference on
// first load, can be toggled locally without persisting to the server.
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
@@ -2,7 +2,6 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -47,7 +46,7 @@ export function AudioMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!isEditorReady(editor)) return;
if (!editor) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "audio";
const parent = findParentNode(predicate)(selection);
@@ -16,7 +16,7 @@ import {
IconMoodSmile,
IconNotes,
} from "@tabler/icons-react";
import { CalloutType, isEditorReady, isTextSelected } from "@docmost/editor-ext";
import { CalloutType, isTextSelected } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import classes from "../common/toolbar-menu.module.css";
@@ -55,7 +55,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
});
const getReferencedVirtualElement = useCallback(() => {
if (!isEditorReady(editor)) return;
if (!editor) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout";
const parent = findParentNode(predicate)(selection);
@@ -19,7 +19,7 @@ import {
IconCopy,
IconTrash,
} from "@tabler/icons-react";
import { isEditorReady, isTextSelected } from "@docmost/editor-ext";
import { isTextSelected } from "@docmost/editor-ext";
import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import classes from "../common/toolbar-menu.module.css";
@@ -82,7 +82,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state || !isEditorReady(editor)) return false;
if (!state) return false;
if (!editor.isActive("columns")) return false;
if (isTextSelected(editor)) return false;
if (nodesWithMenus.some((name) => editor.isActive(name))) return false;
@@ -121,7 +121,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
});
const getReferencedVirtualElement = useCallback(() => {
if (!isEditorReady(editor)) return;
if (!editor) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "columns";
const parent = findParentNode(predicate)(selection);
@@ -2,7 +2,6 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -82,7 +81,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!isEditorReady(editor)) return;
if (!editor) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "drawio";
const parent = findParentNode(predicate)(selection);
@@ -21,7 +21,7 @@ let _emojiIndex: EmojiIndexEntry[] | null = null;
export const buildEmojiIndex = async (): Promise<EmojiIndexEntry[]> => {
if (_emojiIndex) return _emojiIndex;
const { default: data } = await import('@slidoapp/emoji-mart-data');
const { default: data } = await import("@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("@slidoapp/emoji-mart-data"),
import("@emoji-mart/data"),
buildEmojiIndex(),
]);
const byId = new Map(index.map((e) => [e.id, e]));
@@ -1,14 +0,0 @@
import { lazy, Suspense } from "react";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
const ExcalidrawMenu = lazy(
() => import("@/features/editor/components/excalidraw/excalidraw-menu.tsx"),
);
export default function ExcalidrawMenuLazy(props: EditorMenuProps) {
return (
<Suspense fallback={null}>
<ExcalidrawMenu {...props} />
</Suspense>
);
}
@@ -2,7 +2,6 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -95,7 +94,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!isEditorReady(editor)) return;
if (!editor) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "excalidraw";
const parent = findParentNode(predicate)(selection);
@@ -1,14 +0,0 @@
import { lazy, Suspense } from "react";
import { NodeViewProps } from "@tiptap/react";
const ExcalidrawView = lazy(
() => import("@/features/editor/components/excalidraw/excalidraw-view.tsx"),
);
export default function ExcalidrawViewLazy(props: NodeViewProps) {
return (
<Suspense fallback={null}>
<ExcalidrawView {...props} />
</Suspense>
);
}
@@ -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: 99;
z-index: 50;
display: flex;
align-items: center;
background: var(--mantine-color-body);
@@ -28,7 +28,6 @@ export const FixedToolbar: FC = () => {
<>
<div
className={classes.fixedToolbar}
data-fixed-toolbar="true"
role="toolbar"
aria-label="Editor toolbar"
onMouseDown={(e) => e.preventDefault()}
@@ -10,7 +10,6 @@ import {
IconH2,
IconH3,
IconMenu4,
IconPageBreak,
IconTypography,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
@@ -103,12 +102,6 @@ export const BlockTypeGroup: FC<Props> = ({ editor }) => {
>
{t("Divider")}
</Menu.Item>
<Menu.Item
leftSection={<IconPageBreak size={16} />}
onClick={() => editor.chain().focus().setPageBreak().run()}
>
{t("Page break")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
@@ -2,7 +2,6 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback, useRef } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -57,7 +56,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!isEditorReady(editor)) return;
if (!editor) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image";
const parent = findParentNode(predicate)(selection);
@@ -3,6 +3,7 @@ import React, {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
@@ -35,7 +36,7 @@ import {
usePageQuery,
} from "@/features/page/queries/page-query";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { SimpleTree } from "react-arborist";
import { SpaceTreeNode } from "@/features/page/tree/types";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
@@ -52,6 +53,7 @@ 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;
@@ -218,20 +220,20 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
try {
createdPage = await createPageMutation.mutateAsync(payload);
const parentId = page.id || null;
const newNode: SpaceTreeNode = {
const data = {
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 = data.length;
const lastIndex = tree.data.length;
setData(treeModel.insert(data, parentId, newNode, lastIndex));
tree.create({ parentId, index: lastIndex, data });
setData(tree.data);
props.command({
id: uuid7(),
@@ -249,7 +251,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
payload: {
parentId,
index: lastIndex,
data: newNode,
data,
},
});
}, 50);
@@ -2,7 +2,6 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -38,8 +37,9 @@ export function PdfMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state || !isEditorReady(editor)) return false;
if (!editor.isActive("pdf")) return false;
if (!state || !editor.isActive("pdf")) {
return false;
}
const { selection } = state;
const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null;
@@ -51,7 +51,7 @@ export function PdfMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!isEditorReady(editor)) return;
if (!editor) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "pdf";
const parent = findParentNode(predicate)(selection);
@@ -19,7 +19,6 @@ import {
IconTable,
IconTypography,
IconMenu4,
IconPageBreak,
IconCalendar,
IconAppWindow,
IconSitemap,
@@ -165,14 +164,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
},
{
title: "Page break",
description: "Insert a page break for printing.",
searchTerms: ["page", "break", "pagebreak", "print"],
icon: IconPageBreak,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setPageBreak().run(),
},
{
title: "Image",
description: "Upload any image from your device.",
@@ -6,7 +6,6 @@ import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core";
import { isEditorReady } from "@docmost/editor-ext";
interface SubpagesMenuProps {
editor: Editor;
@@ -34,7 +33,6 @@ export const SubpagesMenu = React.memo(
);
const getReferenceClientRect = useCallback(() => {
if (!isEditorReady(editor)) return new DOMRect();
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "subpages";
const parent = findParentNode(predicate)(selection);
@@ -1,126 +0,0 @@
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>
);
});
@@ -1,132 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
import { Menu } from "@mantine/core";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { useTableHandleDrag } from "./hooks/use-table-handle-drag";
import { useColumnRowMenuLifecycle } from "./hooks/use-column-row-menu-lifecycle";
import { ColumnHandleMenu } from "./menus/column-handle-menu";
import classes from "./handle.module.css";
interface ColumnHandleProps {
editor: Editor;
index: number;
anchorPos: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export const ColumnHandle = React.memo(function ColumnHandle({
editor,
index,
anchorPos,
tableNode,
tablePos,
}: ColumnHandleProps) {
const { t } = useTranslation();
// Hold the cell DOM in a ref-backed state so we never unmount the handle
// mid-drag. A remote edit can transiently flip `nodeDOM(anchorPos)` to null
// (the plugin re-emits `hoveringCell` with the mapped pos a tick later);
// unmounting the source element here would make pragmatic-dnd silently
// abort the active drag.
// `nodeDOM` is typed as `Node | null` — when `anchorPos` goes stale (e.g.
// an external drop reflows the doc before the plugin re-emits
// hoveringCell), it can resolve to a Text node, on which `.closest` is
// undefined. Filter to HTMLElement so downstream consumers stay safe.
const lookupDom = editor.view.nodeDOM(anchorPos);
const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : null;
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
useEffect(() => {
if (lookupCellDom && lookupCellDom !== lastCellDomRef.current) {
lastCellDomRef.current = lookupCellDom;
setCellDom(lookupCellDom);
}
}, [lookupCellDom]);
const [handleEl, setHandleEl] = useState<HTMLDivElement | null>(null);
const { refs, floatingStyles, middlewareData } = useFloating({
placement: "top",
middleware: [offset(-4), hide()],
whileElementsMounted: autoUpdate,
});
const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
useEffect(() => {
refs.setReference(cellDom);
}, [cellDom, refs]);
// `cellDom` is inside the table, so `closest('.tableWrapper')` finds the
// wrapper for this drag's auto-scroll. The handle itself lives in a
// floating layer outside the editor DOM, so we can't walk up from it.
const wrapper = cellDom?.closest<HTMLElement>(".tableWrapper") ?? null;
const [menuOpened, setMenuOpened] = useState(false);
const closeMenu = useCallback(() => setMenuOpened(false), []);
useTableHandleDrag(editor, "col", handleEl, wrapper, closeMenu);
const { onOpen, onClose } = useColumnRowMenuLifecycle({
editor,
orientation: "col",
index,
tableNode,
tablePos,
});
if (!cellDom) return null;
return (
<Menu
opened={menuOpened}
onChange={setMenuOpened}
position="bottom-start"
onOpen={onOpen}
onClose={onClose}
withinPortal
shadow="md"
>
<Menu.Target>
<div
ref={(node) => {
refs.setFloating(node);
setHandleEl(node);
}}
style={{
...floatingStyles,
...(isReferenceHidden ? { visibility: "hidden" as const } : {}),
}}
className={clsx(classes.handle, classes.columnHandle)}
role="button"
tabIndex={0}
aria-label={t("Column actions")}
>
<span style={{ pointerEvents: "none", display: "inline-flex" }}>
<GripIcon />
</span>
</div>
</Menu.Target>
<Menu.Dropdown>
<ColumnHandleMenu
editor={editor}
index={index}
tableNode={tableNode}
tablePos={tablePos}
/>
</Menu.Dropdown>
</Menu>
);
});
function GripIcon() {
return (
<svg viewBox="0 0 10 10" width="14" height="14" aria-hidden>
<path
fill="currentColor"
d="M3,2 A1,1 0 1 1 3,0 A1,1 0 0 1 3,2 Z M3,6 A1,1 0 1 1 3,4 A1,1 0 0 1 3,6 Z M3,10 A1,1 0 1 1 3,8 A1,1 0 0 1 3,10 Z M7,2 A1,1 0 1 1 7,0 A1,1 0 0 1 7,2 Z M7,6 A1,1 0 1 1 7,4 A1,1 0 0 1 7,6 Z M7,10 A1,1 0 1 1 7,8 A1,1 0 0 1 7,10 Z"
/>
</svg>
);
}
@@ -1,108 +0,0 @@
.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;
}
}
@@ -1,40 +0,0 @@
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 };
}
@@ -1,54 +0,0 @@
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]);
}
@@ -1,79 +0,0 @@
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]);
}
@@ -1,23 +0,0 @@
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;
}
@@ -1,50 +0,0 @@
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 };
}
@@ -1,100 +0,0 @@
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 };
}
@@ -1,34 +0,0 @@
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);
}
@@ -1,57 +0,0 @@
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()!));
}
@@ -1,49 +0,0 @@
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>
);
});
@@ -1,154 +0,0 @@
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>
</>
);
});
@@ -1,177 +0,0 @@
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>
</>
);
});
@@ -1,138 +0,0 @@
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>
</>
);
});
@@ -1,127 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
import { Menu } from "@mantine/core";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { useTableHandleDrag } from "./hooks/use-table-handle-drag";
import { useColumnRowMenuLifecycle } from "./hooks/use-column-row-menu-lifecycle";
import { RowHandleMenu } from "./menus/row-handle-menu";
import classes from "./handle.module.css";
interface RowHandleProps {
editor: Editor;
index: number;
anchorPos: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export const RowHandle = React.memo(function RowHandle({
editor,
index,
anchorPos,
tableNode,
tablePos,
}: RowHandleProps) {
const { t } = useTranslation();
// See ColumnHandle for the rationale: keep the last valid cell DOM cached
// so the handle div stays mounted across stale-anchor renders, otherwise
// pragmatic-dnd silently aborts an in-flight drag.
// `nodeDOM` is typed as `Node | null` — when `anchorPos` goes stale (e.g.
// an external drop reflows the doc before the plugin re-emits
// hoveringCell), it can resolve to a Text node, on which `.closest` is
// undefined. Filter to HTMLElement so downstream consumers stay safe.
const lookupDom = editor.view.nodeDOM(anchorPos);
const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : null;
const [cellDom, setCellDom] = useState<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>
);
}
@@ -1,44 +0,0 @@
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;
}
export const TABLE_COLORS: TableColorItem[] = [
const TABLE_COLORS: TableColorItem[] = [
{ name: "Default", color: "" },
{ name: "Blue", color: "#b4d5ff" },
{ name: "Green", color: "#acf5d2" },
@@ -18,7 +18,7 @@ import {
IconTrashX,
} from "@tabler/icons-react";
import { BubbleMenu } from "@tiptap/react/menus";
import { isCellSelection, isEditorReady, isTextSelected } from "@docmost/editor-ext";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import classes from "../common/toolbar-menu.module.css";
@@ -38,7 +38,6 @@ export const TableMenu = React.memo(
);
const getReferencedVirtualElement = useCallback(() => {
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "table";
const parent = findParentNode(predicate)(selection);
@@ -105,12 +104,12 @@ export const TableMenu = React.memo(
element.style.zIndex = "99";
}}
options={{
placement: "bottom",
placement: "top",
offset: {
mainAxis: 15,
},
flip: {
fallbackPlacements: ["bottom", "top"],
fallbackPlacements: ["top", "bottom"],
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 align")} withArrow>
<Tooltip label={t("Text alignment")} withArrow>
<ActionIcon
variant="subtle"
size="lg"
aria-label={t("Text align")}
aria-label={t("Text alignment")}
onClick={() => setOpened(!opened)}
>
<activeItem.icon size={18} />
@@ -35,7 +35,6 @@ 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,7 +62,6 @@ 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,29 +44,8 @@
transition: border 0.3s;
}
.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 {
.transclusionWrap:hover,
.transclusionWrap:focus-within {
border: 2px solid
light-dark(
var(--mantine-color-orange-2),
@@ -135,9 +114,9 @@
transition: border 0.3s;
}
.includeWrap[data-editable="true"]:hover,
.includeWrap[data-editable="true"][data-focused="true"],
.includeWrap[data-editable="true"][data-menu-open="true"] {
.includeWrap:hover,
.includeWrap[data-focused="true"],
.includeWrap[data-menu-open="true"] {
border: 2px solid
light-dark(
var(--mantine-color-orange-2),
@@ -2,7 +2,6 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -54,7 +53,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!isEditorReady(editor)) return;
if (!editor) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "video";
const parent = findParentNode(predicate)(selection);
@@ -60,23 +60,6 @@ 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)",
@@ -88,13 +71,7 @@ function nodeDOMAtCoords(
"h4",
"h5",
"h6",
// 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,
...options.customNodes.map((node) => `[data-type=${node}]`),
].join(", ");
return document
.elementsFromPoint(coords.x, coords.y)
@@ -122,22 +99,6 @@ 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);
@@ -176,6 +137,7 @@ 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(
@@ -204,46 +166,14 @@ export function DragHandlePlugin(
} else {
selection = NodeSelection.create(view.state.doc, draggedNodePos);
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());
}
// 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());
}
}
view.dispatch(view.state.tr.setSelection(selection));
@@ -383,27 +313,6 @@ 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)
@@ -419,13 +328,6 @@ 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,13 +42,9 @@ import {
Excalidraw,
Embed,
TiptapPdf,
PageBreak,
SearchAndReplace,
Mention,
TableDndExtension,
TableHandleCommandsExtension,
TableHeaderPin,
TableReadonlySort,
Subpages,
Heading,
Highlight,
@@ -60,7 +56,6 @@ import {
Status,
TransclusionSource,
TransclusionReference,
TableView,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -85,7 +80,7 @@ import AudioView from "@/features/editor/components/audio/audio-view.tsx";
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
@@ -264,16 +259,11 @@ export const mainExtensions = [
resizable: true,
lastColumnResizable: true,
allowTableNodeSelection: true,
cellMinWidth: 49,
View: TableView,
}),
TableRow,
TableCell,
TableHeader,
TableDndExtension,
TableHandleCommandsExtension,
TableHeaderPin,
TableReadonlySort,
MathInline.configure({
view: MathInlineView,
}),
@@ -367,7 +357,6 @@ export const mainExtensions = [
TiptapPdf.configure({
view: PdfView,
}),
PageBreak,
Subpages.configure({
view: SubpagesView,
}),
+11 -30
View File
@@ -1,5 +1,5 @@
import classes from "@/features/editor/styles/editor.module.css";
import React, { useEffect } from "react";
import React from "react";
import { TitleEditor } from "@/features/editor/title-editor";
import PageEditor from "@/features/editor/page-editor";
import {
@@ -23,25 +23,17 @@ 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 PageUser = {
type PageCreator = {
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;
@@ -49,7 +41,7 @@ export interface FullEditorProps {
content: string;
spaceSlug: string;
editable: boolean;
creator?: PageUser;
creator?: PageCreator;
contributors?: IContributor[];
canComment?: boolean;
}
@@ -69,21 +61,9 @@ 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 = 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]);
const isEditMode = userPageEditMode === PageEditMode.Edit;
return (
<Container
@@ -91,10 +71,7 @@ export function FullEditor({
size={!fullPageWidth && 900}
className={classes.editor}
>
{editorToolbarEnabled && editable && isEditMode && (
<MemoizedFixedToolbar />
)}
<MemoizedDeletedPageBanner slugId={slugId} />
{editorToolbarEnabled && editable && isEditMode && <FixedToolbar />}
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}
@@ -118,12 +95,16 @@ export function FullEditor({
}
type PageBylineProps = {
creator?: PageUser;
creator?: PageCreator;
contributors?: IContributor[];
readOnly?: boolean;
};
function PageByline({ creator, contributors, readOnly }: PageBylineProps) {
function PageByline({
creator,
contributors,
readOnly,
}: PageBylineProps) {
const { t } = useTranslation();
const toggleAside = useToggleAside();
@@ -26,11 +26,10 @@ import {
collabExtensions,
mainExtensions,
} from "@/features/editor/extensions/extensions";
import { useAtom, useAtomValue } from "jotai";
import { useAtom } 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";
@@ -45,7 +44,6 @@ 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";
@@ -55,7 +53,7 @@ import {
handleFileDrop,
handlePaste,
} from "@/features/editor/components/common/editor-paste-handler.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
@@ -113,7 +111,8 @@ export default function PageEditor({
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const canScroll = useCallback(
() => Boolean(isComponentMounted.current && editorRef.current),
[isComponentMounted],
@@ -373,9 +372,19 @@ export default function PageEditor({
return () => clearTimeout(timeout);
}, [yjsConnectionStatus, isSynced]);
useEffect(() => {
if (!editor) return;
editor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
}, [currentPageEditMode, editor, editable]);
// 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]);
const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true);
@@ -415,7 +424,7 @@ export default function PageEditor({
<EditorLinkMenu editor={editor} />
<EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} />
<TableHandlesLayer editor={editor} />
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
<ImageMenu editor={editor} />
<VideoMenu editor={editor} />
<PdfMenu editor={editor} />
@@ -203,8 +203,7 @@
}
}
&.resize-cursor,
&.resize-cursor * {
.resize-cursor {
cursor: ew-resize;
cursor: col-resize;
}
@@ -9,7 +9,6 @@
@import "./media.css";
@import "./code.css";
@import "./print.css";
@import "./page-break.css";
@import "./find.css";
@import "./mention.css";
@import "./ordered-list.css";
@@ -1,50 +0,0 @@
.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,8 +15,7 @@
}
.table-dnd-drop-indicator {
background-color: var(--mantine-color-blue-5);
z-index: 3;
background-color: #adf;
}
.ProseMirror {
@@ -58,14 +57,13 @@
}
.column-resize-handle {
background-color: var(--mantine-color-blue-5);
background-color: #adf;
bottom: -1px;
position: absolute;
right: -1px;
right: -2px;
pointer-events: none;
top: 0;
width: 2px;
z-index: 3;
width: 4px;
}
.selectedCell:after {
@@ -131,139 +129,6 @@
}
}
/* 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,7 +7,6 @@ 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";
@@ -25,6 +24,7 @@ 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,7 +52,9 @@ export function TitleEditor({
const emit = useQueryEmit();
const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const [currentUser] = useAtom(currentUserAtom);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const titleEditor = useEditor({
extensions: [
@@ -170,9 +172,18 @@ export function TitleEditor({
}, [pageId]);
useEffect(() => {
if (!titleEditor) return;
titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
}, [currentPageEditMode, titleEditor, editable]);
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]);
const openSearchDialog = () => {
const event = new CustomEvent("openFindDialogFromEditor", {});
@@ -1,51 +0,0 @@
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>
);
}
@@ -1,29 +0,0 @@
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>
);
}
@@ -1,91 +0,0 @@
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>
);
}
@@ -1,160 +0,0 @@
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>
);
}
@@ -1,93 +0,0 @@
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>
</>
);
}
@@ -1,325 +0,0 @@
.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);
}
@@ -1,157 +0,0 @@
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,
});
}
@@ -1,55 +0,0 @@
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;
}
@@ -1,70 +0,0 @@
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;
}
@@ -1,15 +0,0 @@
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");
}
@@ -1,55 +0,0 @@
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];
}
@@ -1,3 +0,0 @@
export function normalizeLabelName(name: string): string {
return name.trim().replace(/\s+/g, "-").toLowerCase();
}
@@ -18,7 +18,6 @@ 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();
@@ -62,11 +61,6 @@ 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 { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.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 { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
import { PageStateSegmentedControl } 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,11 +65,6 @@ 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(
[
@@ -92,15 +87,11 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
[],
);
if (isDeleted) {
return null;
}
return (
<>
<ConnectionWarning />
{!readOnly && <PageEditModeToggle size="xs" />}
{!readOnly && <PageStateSegmentedControl size="xs" />}
<PageShareModal readOnly={readOnly} />
@@ -143,7 +134,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
pageId: extractPageSlugId(pageSlug),
});
const { openDeleteModal } = useDeletePageModal();
const { handleDelete } = useTreeMutation(page?.spaceId ?? "");
const [tree] = useAtom(treeApiAtom);
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const [
@@ -192,7 +183,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
};
const handleDeletePage = () => {
openDeleteModal({ onConfirm: () => handleDelete(page.id) });
openDeleteModal({ onConfirm: () => tree?.delete(page.id) });
};
const handleToggleFavorite = () => {
@@ -8,7 +8,7 @@ interface Props {
}
export default function PageHeader({ readOnly }: Props) {
return (
<div className={classes.header} data-page-header="true">
<div className={classes.header}>
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
<Breadcrumb />
@@ -1,30 +0,0 @@
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 { treeModel } from "@/features/page/tree/model/tree-model";
import { SimpleTree } from "react-arborist";
import { SpaceTreeNode } from "@/features/page/tree/types";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
@@ -117,20 +117,10 @@ export function useUpdatePageMutation() {
}
export function useRemovePageMutation() {
const { t } = useTranslation();
return useMutation({
mutationFn: (pageId: string) => deletePage(pageId, false),
onSuccess: (_, pageId) => {
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);
}
notifications.show({ message: "Page moved to trash" });
invalidateOnDeletePage(pageId);
queryClient.invalidateQueries({
predicate: (item) =>
@@ -138,7 +128,7 @@ export function useRemovePageMutation() {
});
},
onError: (error) => {
notifications.show({ message: t("Failed to delete page"), color: "red" });
notifications.show({ message: "Failed to delete page", color: "red" });
},
});
}
@@ -172,17 +162,19 @@ 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: t("Page restored successfully") });
notifications.show({ message: "Page restored successfully" });
// Add the restored page back to the tree
const treeApi = new SimpleTree<SpaceTreeNode>(treeData);
// Check if the page already exists in the tree (it shouldn't)
if (!treeModel.find(treeData, restoredPage.id)) {
if (!treeApi.find(restoredPage.id)) {
// Create the tree node data with hasChildren from backend
const nodeData: SpaceTreeNode = {
id: restoredPage.id,
@@ -201,17 +193,24 @@ export function useRestorePageMutation() {
let index = 0;
if (parentId) {
const parentNode = treeModel.find(treeData, parentId);
const parentNode = treeApi.find(parentId);
if (parentNode) {
index = parentNode.children?.length || 0;
}
} else {
// Root level page
index = treeData.length;
index = treeApi.data.length;
}
// Add the node to the tree
setTreeData(treeModel.insert(treeData, parentId, nodeData, index));
treeApi.create({
parentId,
index,
data: nodeData,
});
// Update the tree data
setTreeData(treeApi.data);
// Emit websocket event to sync with other users
setTimeout(() => {
@@ -233,16 +232,9 @@ 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: t("Failed to restore page"), color: "red" });
notifications.show({ message: "Failed to restore page", color: "red" });
},
});
}
@@ -1,140 +0,0 @@
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>
);
}
@@ -1,21 +0,0 @@
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,16 +7,17 @@ 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,
@@ -30,10 +31,12 @@ 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 { useRestorePageModal } from "@/features/page/hooks/use-restore-page-modal.tsx";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
export default function Trash() {
const { t } = useTranslation();
const [workspace] = useAtom(workspaceAtom);
const { spaceSlug } = useParams();
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
@@ -42,7 +45,6 @@ export default function Trash() {
});
const restorePageMutation = useRestorePageMutation();
const deletePageMutation = useDeletePageMutation();
const { openRestoreModal } = useRestorePageModal();
const [selectedPage, setSelectedPage] = useState<{
title: string;
@@ -76,6 +78,23 @@ 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) => {
@@ -90,7 +109,11 @@ export default function Trash() {
<Title order={2}>{t("Trash")}</Title>
</Group>
<TrashBanner />
<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>
{isLoading || !deletedPages ? (
<></>
@@ -158,10 +181,7 @@ export default function Trash() {
<Menu.Item
leftSection={<IconRestore size={16} />}
onClick={() =>
openRestoreModal({
title: page.title,
onConfirm: () => handleRestorePage(page.id),
})
openRestoreModal(page.id, page.title)
}
>
{t("Restore")}
@@ -1,5 +0,0 @@
import { atom } from "jotai";
export type OpenMap = Record<string, boolean>;
export const openTreeNodesAtom = atom<OpenMap>({});
@@ -0,0 +1,5 @@
import { atom } from "jotai";
import { TreeApi } from "react-arborist";
import { SpaceTreeNode } from "../types";
export const treeApiAtom = atom<TreeApi<SpaceTreeNode> | null>(null);
@@ -1,26 +0,0 @@
.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;
}
@@ -1,9 +0,0 @@
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>;
}
@@ -1,39 +0,0 @@
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;
}
@@ -1,398 +0,0 @@
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;
@@ -1,541 +0,0 @@
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>;
@@ -1,259 +0,0 @@
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}
/>
</>
);
}
@@ -1,288 +0,0 @@
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>
);
}

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