Compare commits

..

19 Commits

Author SHA1 Message Date
Philipinho 834b6f68f7 feat(webhooks): bump submodule for at-rest encryption of signing secret 2026-05-15 02:36:05 +01:00
Philipinho 214daa1ec3 feat(webhooks): admin UI for managing webhooks and viewing deliveries 2026-05-15 02:07:13 +01:00
Philipinho 6af74eb3d4 feat(webhooks): dispatch domain events to webhook subscribers 2026-05-15 01:59:02 +01:00
Philipinho e7fff3c9b5 feat(webhooks): backend module, dispatcher, processor, controller 2026-05-15 01:44:58 +01:00
Philipinho 63c1241125 feat(webhooks): scaffold feature flag, CASL subject, queue, and migration 2026-05-15 01:36:05 +01:00
Philipinho 0ae407839f fix codeblock/mermaid gap cursor 2026-05-14 23:23:20 +01:00
Philipinho d524073d86 fix: editor ready check 2026-05-14 18:40:03 +01:00
Philipinho 5c5fff517c fix code splitting 2026-05-14 18:05:39 +01:00
Philipinho 3115c5e097 fix table 2026-05-14 18:04:25 +01:00
Philipinho 03c1e8c4ed fix collab module 2026-05-14 15:06:51 +01:00
Philipinho e41518a93d fix type 2026-05-14 14:49:02 +01:00
Peter Tripp 932c1ad5b7 Better trash (#2190)
* Better trash

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

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

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

* cleanup

---------

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

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

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

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

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

* sync

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

* feat(tree): keyboard arrow navigation between rows

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

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

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

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

* feat(tree): Space activation and ARIA refinements

* fix(tree): move treeitem role to focusable row + aria-current
2026-05-13 23:01:04 +01:00
134 changed files with 6787 additions and 2150 deletions
+63 -63
View File
@@ -12,84 +12,84 @@
"test:watch": "vitest"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.0",
"@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",
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0",
"@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4",
"@casl/react": "5.0.1",
"@docmost/editor-ext": "workspace:*",
"@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",
"@slidoapp/emoji-mart": "5.8.7",
"@slidoapp/emoji-mart-data": "1.2.4",
"@slidoapp/emoji-mart-react": "1.1.5",
"@tabler/icons-react": "3.40.0",
"@tanstack/react-query": "5.90.17",
"@tanstack/react-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",
"file-saver": "2.0.5",
"highlightjs-sap-abap": "0.3.0",
"i18next": "25.10.1",
"i18next-http-backend": "3.0.6",
"jotai": "^2.18.1",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"jotai": "2.18.1",
"jotai-optics": "0.4.0",
"js-cookie": "3.0.5",
"jwt-decode": "4.0.0",
"katex": "0.16.40",
"lowlight": "^3.3.0",
"mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.13.0",
"mitt": "^3.0.1",
"lowlight": "3.3.0",
"mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.15.0",
"mitt": "3.0.1",
"posthog-js": "1.372.2",
"react": "^18.3.1",
"react": "18.3.1",
"react-clear-modal": "^2.0.18",
"react-dom": "^18.3.1",
"react-drawio": "^1.0.7",
"react-error-boundary": "^6.1.1",
"react-helmet-async": "^3.0.0",
"react-drawio": "1.0.7",
"react-error-boundary": "6.1.1",
"react-helmet-async": "3.0.0",
"react-i18next": "16.5.8",
"react-router-dom": "^7.13.1",
"semver": "^7.7.4",
"socket.io-client": "^4.8.3",
"zod": "^4.3.6"
"react-router-dom": "7.13.1",
"semver": "7.7.4",
"socket.io-client": "4.8.3",
"zod": "4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.28.0",
"@tanstack/eslint-plugin-query": "^5.94.4",
"@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",
"@testing-library/jest-dom": "6.6.0",
"@testing-library/react": "16.1.0",
"@types/blueimp-load-image": "5.16.6",
"@types/file-saver": "2.0.7",
"@types/js-cookie": "3.0.6",
"@types/katex": "0.16.8",
"@types/node": "22.19.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.28.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^15.13.0",
"jsdom": "^25.0.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",
"@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"
"vitest": "4.1.6"
}
}
@@ -71,6 +71,7 @@
"Export": "Export",
"Failed to create page": "Failed to create page",
"Failed to delete page": "Failed to delete page",
"Failed to restore page": "Failed to restore page",
"Failed to fetch recent pages": "Failed to fetch recent pages",
"Failed to import pages": "Failed to import pages",
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
@@ -286,6 +287,19 @@
"Add row above": "Add row above",
"Add row below": "Add row below",
"Delete table": "Delete table",
"Add column left": "Add column left",
"Add column right": "Add column right",
"Clear cell": "Clear cell",
"Clear cells": "Clear cells",
"Toggle header cell": "Toggle header cell",
"Toggle header column": "Toggle header column",
"Toggle header row": "Toggle header row",
"Move column left": "Move column left",
"Move column right": "Move column right",
"Move row down": "Move row down",
"Move row up": "Move row up",
"Sort A → Z": "Sort A → Z",
"Sort Z → A": "Sort Z → A",
"Info": "Info",
"Note": "Note",
"Success": "Success",
@@ -348,6 +362,8 @@
"Create block quote.": "Create block quote.",
"Insert code snippet.": "Insert code snippet.",
"Insert horizontal rule divider": "Insert horizontal rule divider",
"Page break": "Page break",
"Insert a page break for printing.": "Insert a page break for printing.",
"Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.",
"Upload any audio from your device.": "Upload any audio from your device.",
@@ -566,6 +582,8 @@
"Move to trash": "Move to trash",
"Move this page to trash?": "Move this page to trash?",
"Restore page": "Restore page",
"Permanently delete": "Permanently delete",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> moved this page to Trash {{time}}.",
"Page moved to trash": "Page moved to trash",
"Page restored successfully": "Page restored successfully",
"Deleted by": "Deleted by",
@@ -997,5 +1015,8 @@
"No pages with this label": "No pages with this label",
"Pages tagged with this label will appear here.": "Pages tagged with this label will appear here.",
"No pages match your search.": "No pages match your search.",
"Updated {{date}}": "Updated {{date}}"
"Updated {{date}}": "Updated {{date}}",
"Cell actions": "Cell actions",
"Column actions": "Column actions",
"Row actions": "Row actions"
}
+2
View File
@@ -39,6 +39,7 @@ 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";
@@ -124,6 +125,7 @@ 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,4 +1,5 @@
import { Box, ScrollArea, Text } from "@mantine/core";
import { ActionIcon, Box, Group, ScrollArea, Text, Tooltip } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
@@ -11,9 +12,10 @@ import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
export default function Aside() {
const [{ tab }] = useAtom(asideStateAtom);
const [{ tab }, setAsideState] = useAtom(asideStateAtom);
const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom);
const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false }));
let title: string;
let component: ReactNode;
@@ -45,9 +47,19 @@ export default function Aside() {
{component && (
<>
{tab !== "chat" && (
<Text mb="md" fw={500}>
{t(title)}
</Text>
<Group justify="space-between" wrap="nowrap" mb="md">
<Text fw={500}>{t(title)}</Text>
<Tooltip label={t("Close")} withArrow>
<ActionIcon
variant="subtle"
color="gray"
onClick={closeAside}
aria-label={t("Close")}
>
<IconX size={18} />
</ActionIcon>
</Tooltip>
</Group>
)}
{tab === "comments" || tab === "chat" ? (
@@ -14,6 +14,7 @@ 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: "" };
@@ -106,3 +107,11 @@ export const prefetchScimTokens = () => {
queryFn: () => getScimTokens({}),
});
};
export const prefetchWebhooks = () => {
const params = { limit: 50 };
queryClient.prefetchQuery({
queryKey: ["webhook-list", params],
queryFn: () => getWebhooks(params),
});
};
@@ -15,6 +15,7 @@ import {
IconSparkles,
IconHistory,
IconShieldCheck,
IconWebhook,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css";
@@ -38,6 +39,7 @@ 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";
@@ -125,6 +127,13 @@ const groupedData: DataGroup[] = [
role: "owner",
env: "selfhosted",
},
{
label: "Webhooks",
icon: IconWebhook,
path: "/settings/webhooks",
feature: Feature.WEBHOOKS,
role: "admin",
},
],
},
{
@@ -222,6 +231,9 @@ export default function SettingsSidebar() {
case "Audit log":
prefetchHandler = prefetchAuditLogs;
break;
case "Webhooks":
prefetchHandler = prefetchWebhooks;
break;
case "Verified pages":
prefetchHandler = prefetchVerifiedPages;
break;
+64 -5
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { Button, Divider, Stack } from "@mantine/core";
import { IconLock, IconServer } from "@tabler/icons-react";
@@ -7,15 +7,37 @@ import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
import { getRedirectParam } from "@/lib/app-route.ts";
import useCurrentUser from "@/features/user/hooks/use-current-user.ts";
const SSO_AUTO_ATTEMPT_KEY = "docmost:ssoAutoAttempt";
const SSO_AUTO_ATTEMPT_TTL_MS = 5 * 60_000;
function recentAutoAttempt(): boolean {
try {
const raw = window.sessionStorage.getItem(SSO_AUTO_ATTEMPT_KEY);
if (!raw) return false;
const ts = Number(raw);
return Number.isFinite(ts) && Date.now() - ts < SSO_AUTO_ATTEMPT_TTL_MS;
} catch {
return false;
}
}
function markAutoAttempt(): void {
try {
window.sessionStorage.setItem(SSO_AUTO_ATTEMPT_KEY, String(Date.now()));
} catch {
/* sessionStorage unavailable (private mode, etc.) — best effort */
}
}
export default function SsoLogin() {
const { data, isLoading } = useWorkspacePublicDataQuery();
const { data: currentUser } = useCurrentUser();
const [ldapModalOpened, setLdapModalOpened] = useState(false);
const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null);
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const autoRedirectedRef = useRef(false);
const handleSsoLogin = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.LDAP) {
@@ -28,10 +50,47 @@ export default function SsoLogin() {
providerId: provider.id,
type: provider.type,
workspaceId: data.id,
redirect: getRedirectParam() ?? undefined,
});
}
};
// Auto-redirect when SSO is enforced and there is exactly one non-LDAP
// provider. The user has no other option, so skip the extra click.
useEffect(() => {
if (autoRedirectedRef.current) return;
if (!data?.enforceSso) return;
if (!data.authProviders || data.authProviders.length !== 1) return;
const onlyProvider = data.authProviders[0];
if (onlyProvider.type === SSO_PROVIDER.LDAP) return;
// Already signed in: let useRedirectIfAuthenticated handle navigation
// instead of racing it through the IdP.
if (currentUser?.user) return;
// Explicit logout: don't immediately bounce them back to the IdP.
const params = new URLSearchParams(window.location.search);
if (params.has("logout")) return;
// Circuit-breaker: if we already auto-redirected within the TTL, the
// user came back (likely from an IdP failure). Show the page so they
// can read errors or pick a different account.
if (recentAutoAttempt()) return;
autoRedirectedRef.current = true;
markAutoAttempt();
window.location.href = buildSsoLoginUrl({
providerId: onlyProvider.id,
type: onlyProvider.type,
workspaceId: data.id,
redirect: getRedirectParam() ?? undefined,
});
}, [data, currentUser]);
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const getProviderIcon = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.GOOGLE) {
return <GoogleIcon size={16} />;
+1
View File
@@ -19,4 +19,5 @@ export const Feature = {
SHARING_CONTROLS: 'sharing:controls',
TEMPLATES: 'templates',
VIEWER_COMMENTS: 'comment:viewer',
WEBHOOKS: 'webhooks',
} as const;
+10 -3
View File
@@ -18,14 +18,21 @@ export function buildSsoLoginUrl(opts: {
providerId: string;
type: SSO_PROVIDER;
workspaceId?: string;
redirect?: string;
}): string {
const { providerId, type, workspaceId } = opts;
const { providerId, type, workspaceId, redirect } = opts;
const domain = getAppUrl();
const params = new URLSearchParams();
if (redirect) params.set("redirect", redirect);
if (type === SSO_PROVIDER.GOOGLE) {
return `${getServerAppUrl()}/api/sso/${type}/login?workspaceId=${workspaceId}`;
if (workspaceId) params.set("workspaceId", workspaceId);
return `${getServerAppUrl()}/api/sso/${type}/login?${params.toString()}`;
}
return `${domain}/api/sso/${type}/${providerId}/login`;
const query = params.toString();
const base = `${domain}/api/sso/${type}/${providerId}/login`;
return query ? `${base}?${query}` : base;
}
export function getGoogleSignupUrl(): string {
@@ -0,0 +1,140 @@
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>
);
}
@@ -0,0 +1,310 @@
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>
);
}
@@ -0,0 +1,240 @@
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}
/>
</>
);
}
@@ -0,0 +1,78 @@
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>
);
}
@@ -0,0 +1,226 @@
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>
);
}
@@ -0,0 +1,43 @@
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 })),
}));
@@ -0,0 +1,108 @@
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}
/>
</>
);
}
@@ -0,0 +1,190 @@
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" });
},
});
}
@@ -0,0 +1,81 @@
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;
}
@@ -0,0 +1,77 @@
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);
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
};
const handleForgotPassword = async (data: IForgotPassword) => {
@@ -1,5 +1,6 @@
import { atom } from "jotai";
import { Editor } from "@tiptap/core";
import { PageEditMode } from "@/features/user/types/user.types.ts";
export const pageEditorAtom = atom<Editor | null>(null);
@@ -12,3 +13,7 @@ export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false);
// Current page's edit mode — initialized from the user's saved preference on
// first load, can be toggled locally without persisting to the server.
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -46,7 +47,7 @@ export function AudioMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "audio";
const parent = findParentNode(predicate)(selection);
@@ -16,7 +16,7 @@ import {
IconMoodSmile,
IconNotes,
} from "@tabler/icons-react";
import { CalloutType, isTextSelected } from "@docmost/editor-ext";
import { CalloutType, isEditorReady, isTextSelected } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import classes from "../common/toolbar-menu.module.css";
@@ -55,7 +55,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
});
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout";
const parent = findParentNode(predicate)(selection);
@@ -19,7 +19,7 @@ import {
IconCopy,
IconTrash,
} from "@tabler/icons-react";
import { isTextSelected } from "@docmost/editor-ext";
import { isEditorReady, isTextSelected } from "@docmost/editor-ext";
import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import classes from "../common/toolbar-menu.module.css";
@@ -82,7 +82,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) return false;
if (!state || !isEditorReady(editor)) return false;
if (!editor.isActive("columns")) return false;
if (isTextSelected(editor)) return false;
if (nodesWithMenus.some((name) => editor.isActive(name))) return false;
@@ -121,7 +121,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
});
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "columns";
const parent = findParentNode(predicate)(selection);
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -81,7 +82,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "drawio";
const parent = findParentNode(predicate)(selection);
@@ -0,0 +1,14 @@
import { lazy, Suspense } from "react";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
const ExcalidrawMenu = lazy(
() => import("@/features/editor/components/excalidraw/excalidraw-menu.tsx"),
);
export default function ExcalidrawMenuLazy(props: EditorMenuProps) {
return (
<Suspense fallback={null}>
<ExcalidrawMenu {...props} />
</Suspense>
);
}
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -94,7 +95,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "excalidraw";
const parent = findParentNode(predicate)(selection);
@@ -0,0 +1,14 @@
import { lazy, Suspense } from "react";
import { NodeViewProps } from "@tiptap/react";
const ExcalidrawView = lazy(
() => import("@/features/editor/components/excalidraw/excalidraw-view.tsx"),
);
export default function ExcalidrawViewLazy(props: NodeViewProps) {
return (
<Suspense fallback={null}>
<ExcalidrawView {...props} />
</Suspense>
);
}
@@ -3,7 +3,7 @@
top: calc(var(--app-shell-header-offset, 0rem) + 45px);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
z-index: 50;
z-index: 99;
display: flex;
align-items: center;
background: var(--mantine-color-body);
@@ -28,6 +28,7 @@ export const FixedToolbar: FC = () => {
<>
<div
className={classes.fixedToolbar}
data-fixed-toolbar="true"
role="toolbar"
aria-label="Editor toolbar"
onMouseDown={(e) => e.preventDefault()}
@@ -10,6 +10,7 @@ import {
IconH2,
IconH3,
IconMenu4,
IconPageBreak,
IconTypography,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
@@ -102,6 +103,12 @@ export const BlockTypeGroup: FC<Props> = ({ editor }) => {
>
{t("Divider")}
</Menu.Item>
<Menu.Item
leftSection={<IconPageBreak size={16} />}
onClick={() => editor.chain().focus().setPageBreak().run()}
>
{t("Page break")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback, useRef } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -56,7 +57,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image";
const parent = findParentNode(predicate)(selection);
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -37,9 +38,8 @@ export function PdfMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state || !editor.isActive("pdf")) {
return false;
}
if (!state || !isEditorReady(editor)) return false;
if (!editor.isActive("pdf")) return false;
const { selection } = state;
const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null;
@@ -51,7 +51,7 @@ export function PdfMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "pdf";
const parent = findParentNode(predicate)(selection);
@@ -19,6 +19,7 @@ import {
IconTable,
IconTypography,
IconMenu4,
IconPageBreak,
IconCalendar,
IconAppWindow,
IconSitemap,
@@ -164,6 +165,14 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
},
{
title: "Page break",
description: "Insert a page break for printing.",
searchTerms: ["page", "break", "pagebreak", "print"],
icon: IconPageBreak,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setPageBreak().run(),
},
{
title: "Image",
description: "Upload any image from your device.",
@@ -6,6 +6,7 @@ import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core";
import { isEditorReady } from "@docmost/editor-ext";
interface SubpagesMenuProps {
editor: Editor;
@@ -33,6 +34,7 @@ export const SubpagesMenu = React.memo(
);
const getReferenceClientRect = useCallback(() => {
if (!isEditorReady(editor)) return new DOMRect();
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "subpages";
const parent = findParentNode(predicate)(selection);
@@ -0,0 +1,126 @@
import React, { useCallback, useEffect } from "react";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { TextSelection } from "@tiptap/pm/state";
import { columnResizingPluginKey } from "@tiptap/pm/tables";
import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
import { Menu, UnstyledButton } from "@mantine/core";
import { IconChevronDown } from "@tabler/icons-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { isCellSelection } from "@docmost/editor-ext";
import { CellChevronMenu } from "./menus/cell-chevron-menu";
import classes from "./handle.module.css";
interface CellChevronProps {
editor: Editor;
cellPos: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export const CellChevron = React.memo(function CellChevron({
editor,
cellPos,
tableNode,
tablePos,
}: CellChevronProps) {
const { t } = useTranslation();
const cellDom = editor.view.nodeDOM(cellPos) as HTMLElement | null;
const { refs, floatingStyles, middlewareData } = useFloating({
placement: "top-end",
// crossAxis pulls the chevron INWARD from the cell's right edge. We need
// enough inset that we don't overlap PM-tables' column-resize hot zone
// (~5px wide around the column boundary). Without this, hovering near the
// column edge picks up the chevron's `cursor: pointer` instead of
// `col-resize`, and a drag near the edge clicks the chevron.
middleware: [offset({ mainAxis: -22, crossAxis: -10 }), hide()],
whileElementsMounted: autoUpdate,
strategy: "absolute",
});
const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
useEffect(() => {
refs.setReference(cellDom);
}, [cellDom, refs]);
// Hide the chevron while the user is resizing a column. PM-tables sets
// `activeHandle > -1` whenever the mouse is near a column boundary OR
// actively dragging it. Either way we don't want the chevron in the way.
const isResizingColumn = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) return false;
const state = columnResizingPluginKey.getState(ctx.editor.state) as
| { activeHandle: number }
| undefined;
return !!state && state.activeHandle > -1;
},
});
const onOpen = useCallback(() => {
const current = editor.state.selection;
// Preserve an existing multi-cell CellSelection that already covers
// this cell so merge etc. operate on the user's whole range.
let preserveExisting = false;
if (isCellSelection(current)) {
current.forEachCell((_node, pos) => {
if (pos === cellPos) preserveExisting = true;
});
}
if (!preserveExisting) {
// Drop a collapsed cursor inside the cell rather than a single-cell
// CellSelection — PM-tables paints the latter as a text-range
// highlight on the cell content.
try {
const $inside = editor.state.doc.resolve(cellPos + 1);
const sel = TextSelection.near($inside, 1);
editor.view.dispatch(editor.state.tr.setSelection(sel));
} catch {}
}
editor.commands.freezeHandles();
}, [editor, cellPos]);
const onClose = useCallback(() => {
editor.commands.unfreezeHandles();
}, [editor]);
if (!cellDom) return null;
if (isResizingColumn) return null;
return (
<Menu
position="bottom-end"
onOpen={onOpen}
onClose={onClose}
withinPortal
shadow="md"
>
<Menu.Target>
<UnstyledButton
ref={refs.setFloating}
style={{
...floatingStyles,
...(isReferenceHidden ? { visibility: "hidden" as const } : {}),
}}
className={clsx(classes.cellChevron)}
aria-label={t("Cell actions")}
>
<IconChevronDown size={14} />
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<CellChevronMenu
editor={editor}
cellPos={cellPos}
tableNode={tableNode}
tablePos={tablePos}
/>
</Menu.Dropdown>
</Menu>
);
});
@@ -0,0 +1,132 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
import { Menu } from "@mantine/core";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { useTableHandleDrag } from "./hooks/use-table-handle-drag";
import { useColumnRowMenuLifecycle } from "./hooks/use-column-row-menu-lifecycle";
import { ColumnHandleMenu } from "./menus/column-handle-menu";
import classes from "./handle.module.css";
interface ColumnHandleProps {
editor: Editor;
index: number;
anchorPos: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export const ColumnHandle = React.memo(function ColumnHandle({
editor,
index,
anchorPos,
tableNode,
tablePos,
}: ColumnHandleProps) {
const { t } = useTranslation();
// Hold the cell DOM in a ref-backed state so we never unmount the handle
// mid-drag. A remote edit can transiently flip `nodeDOM(anchorPos)` to null
// (the plugin re-emits `hoveringCell` with the mapped pos a tick later);
// unmounting the source element here would make pragmatic-dnd silently
// abort the active drag.
// `nodeDOM` is typed as `Node | null` — when `anchorPos` goes stale (e.g.
// an external drop reflows the doc before the plugin re-emits
// hoveringCell), it can resolve to a Text node, on which `.closest` is
// undefined. Filter to HTMLElement so downstream consumers stay safe.
const lookupDom = editor.view.nodeDOM(anchorPos);
const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : null;
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
useEffect(() => {
if (lookupCellDom && lookupCellDom !== lastCellDomRef.current) {
lastCellDomRef.current = lookupCellDom;
setCellDom(lookupCellDom);
}
}, [lookupCellDom]);
const [handleEl, setHandleEl] = useState<HTMLDivElement | null>(null);
const { refs, floatingStyles, middlewareData } = useFloating({
placement: "top",
middleware: [offset(-4), hide()],
whileElementsMounted: autoUpdate,
});
const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
useEffect(() => {
refs.setReference(cellDom);
}, [cellDom, refs]);
// `cellDom` is inside the table, so `closest('.tableWrapper')` finds the
// wrapper for this drag's auto-scroll. The handle itself lives in a
// floating layer outside the editor DOM, so we can't walk up from it.
const wrapper = cellDom?.closest<HTMLElement>(".tableWrapper") ?? null;
const [menuOpened, setMenuOpened] = useState(false);
const closeMenu = useCallback(() => setMenuOpened(false), []);
useTableHandleDrag(editor, "col", handleEl, wrapper, closeMenu);
const { onOpen, onClose } = useColumnRowMenuLifecycle({
editor,
orientation: "col",
index,
tableNode,
tablePos,
});
if (!cellDom) return null;
return (
<Menu
opened={menuOpened}
onChange={setMenuOpened}
position="bottom-start"
onOpen={onOpen}
onClose={onClose}
withinPortal
shadow="md"
>
<Menu.Target>
<div
ref={(node) => {
refs.setFloating(node);
setHandleEl(node);
}}
style={{
...floatingStyles,
...(isReferenceHidden ? { visibility: "hidden" as const } : {}),
}}
className={clsx(classes.handle, classes.columnHandle)}
role="button"
tabIndex={0}
aria-label={t("Column actions")}
>
<span style={{ pointerEvents: "none", display: "inline-flex" }}>
<GripIcon />
</span>
</div>
</Menu.Target>
<Menu.Dropdown>
<ColumnHandleMenu
editor={editor}
index={index}
tableNode={tableNode}
tablePos={tablePos}
/>
</Menu.Dropdown>
</Menu>
);
});
function GripIcon() {
return (
<svg viewBox="0 0 10 10" width="14" height="14" aria-hidden>
<path
fill="currentColor"
d="M3,2 A1,1 0 1 1 3,0 A1,1 0 0 1 3,2 Z M3,6 A1,1 0 1 1 3,4 A1,1 0 0 1 3,6 Z M3,10 A1,1 0 1 1 3,8 A1,1 0 0 1 3,10 Z M7,2 A1,1 0 1 1 7,0 A1,1 0 0 1 7,2 Z M7,6 A1,1 0 1 1 7,4 A1,1 0 0 1 7,6 Z M7,10 A1,1 0 1 1 7,8 A1,1 0 0 1 7,10 Z"
/>
</svg>
);
}
@@ -0,0 +1,108 @@
.handle {
position: absolute;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: rgba(55, 53, 47, 0.45);
background: var(--mantine-color-body);
border: 1px solid rgba(55, 53, 47, 0.12);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
cursor: grab;
padding: 0;
transition: background-color 120ms ease, color 120ms ease;
@mixin dark {
color: rgba(255, 255, 255, 0.55);
background: var(--mantine-color-dark-7);
border-color: rgba(255, 255, 255, 0.12);
}
}
.handle:hover {
background: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
color: light-dark(
var(--mantine-color-gray-7),
var(--mantine-color-dark-0)
);
}
.handle:active {
cursor: grabbing;
}
.columnHandle {
width: 28px;
height: 16px;
}
.columnHandle svg {
transform: rotate(90deg);
}
.rowHandle {
width: 16px;
height: 28px;
}
@media (max-width: 600px) {
.handle {
display: none;
}
}
.cellChevron {
position: absolute;
z-index: 50;
width: 18px;
height: 18px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: light-dark(
var(--mantine-color-gray-7),
var(--mantine-color-dark-1)
);
background: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
border: 1px solid rgba(55, 53, 47, 0.12);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
cursor: pointer;
padding: 0;
transition: background-color 120ms ease, color 120ms ease;
@mixin dark {
border-color: rgba(255, 255, 255, 0.12);
}
}
.cellChevron:hover {
background: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-4)
);
color: light-dark(
var(--mantine-color-gray-8),
var(--mantine-color-dark-0)
);
}
@media (max-width: 600px) {
.cellChevron {
display: none;
}
}
@media print {
.handle,
.cellChevron {
display: none !important;
}
}
@@ -0,0 +1,40 @@
import { useCallback } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { buildRowOrColumnSelection, Orientation } from "../lib/select-row-column";
interface Args {
editor: Editor;
orientation: Orientation;
index: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export function useColumnRowMenuLifecycle({
editor,
orientation,
index,
tableNode,
tablePos,
}: Args) {
const onOpen = useCallback(() => {
const selection = buildRowOrColumnSelection(
editor.state,
tableNode,
tablePos,
orientation,
index,
);
const tr = editor.state.tr;
if (selection) tr.setSelection(selection);
editor.view.dispatch(tr);
editor.commands.freezeHandles();
}, [editor, orientation, index, tableNode, tablePos]);
const onClose = useCallback(() => {
editor.commands.unfreezeHandles();
}, [editor]);
return { onOpen, onClose };
}
@@ -0,0 +1,54 @@
import { useCallback } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { TableMap } from "@tiptap/pm/tables";
type Scope =
| { kind: "col"; index: number }
| { kind: "row"; index: number }
| { kind: "cell"; cellPos: number };
export function useTableClear(
editor: Editor,
tableNode: ProseMirrorNode,
tablePos: number,
scope: Scope,
) {
return useCallback(() => {
const tr = editor.state.tr;
const tableStart = tablePos + 1;
const map = TableMap.get(tableNode);
const paragraph = editor.schema.nodes.paragraph;
if (!paragraph) return;
const cellOffsets: number[] = [];
if (scope.kind === "col") {
for (let row = 0; row < map.height; row++) {
cellOffsets.push(map.map[row * map.width + scope.index]);
}
} else if (scope.kind === "row") {
for (let col = 0; col < map.width; col++) {
cellOffsets.push(map.map[scope.index * map.width + col]);
}
}
const targets =
scope.kind === "cell"
? [scope.cellPos]
: Array.from(new Set(cellOffsets)).map((o) => tableStart + o);
// Process in reverse position order so earlier replacements don't shift later ones.
targets.sort((a, b) => b - a);
for (const cellPos of targets) {
const node = tr.doc.nodeAt(cellPos);
if (!node) continue;
const start = cellPos + 1;
const end = cellPos + node.nodeSize - 1;
tr.replaceWith(start, end, paragraph.create());
}
if (tr.docChanged) editor.view.dispatch(tr);
}, [editor, tableNode, tablePos, scope]);
}
@@ -0,0 +1,79 @@
import { useEffect } from "react";
import type { Editor } from "@tiptap/react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { disableNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview";
import {
autoScrollForElements,
autoScrollWindowForElements,
} from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { getTableHandlePluginSpec } from "@docmost/editor-ext";
// Uses pragmatic-drag-and-drop instead of native HTML5 DnD because the native
// dragstart→dragover→drop lifecycle was being silently cancelled
export function useTableHandleDrag(
editor: Editor,
orientation: "col" | "row",
element: HTMLElement | null,
wrapper: HTMLElement | null,
onDragStart?: () => void,
) {
useEffect(() => {
if (!element) return;
return combine(
draggable({
element,
getInitialData: () => ({ type: `table-${orientation}` }),
onGenerateDragPreview: ({ nativeSetDragImage }) => {
// We render our own floating preview via PreviewController, so hide
// the native drag image entirely.
disableNativeDragPreview({ nativeSetDragImage });
},
onDragStart: ({ location }) => {
// The menu (if open from a prior click on the handle) won't dismiss
// on its own — pragmatic-dnd swallows the events Mantine listens for.
onDragStart?.();
const spec = getTableHandlePluginSpec(editor);
if (!spec) return;
const { clientX, clientY } = location.initial.input;
spec.startDragFromHandle(orientation, clientX, clientY);
},
onDrag: ({ location }) => {
const spec = getTableHandlePluginSpec(editor);
if (!spec) return;
const { clientX, clientY } = location.current.input;
spec.updateDragPosition(clientX, clientY);
},
onDrop: ({ location }) => {
const spec = getTableHandlePluginSpec(editor);
if (!spec) return;
const { clientX, clientY } = location.current.input;
// Make sure the final position is recorded before committing the drop.
spec.updateDragPosition(clientX, clientY);
spec.commitDrop();
spec.endDrag();
},
}),
// Wrapper owns horizontal auto-scroll (it has `overflow-x: auto`);
// window owns vertical. Locking each axis prevents the window's
// horizontal auto-scroll from running when the cursor approaches
// the viewport edge — without the cap, the preview's `left` follows
// the cursor past the viewport, the page widens to contain it, the
// plugin scrolls the now-wider page further, and the loop never
// ends.
// Only the column handle registers wrapper auto-scroll (rows can't
// scroll horizontally) — registering twice on the same wrapper
// triggers a dev-mode warning from pragmatic-dnd-auto-scroll.
orientation === "col" &&
wrapper &&
!wrapper.classList.contains("tableWrapperNoOverflow")
? autoScrollForElements({
element: wrapper,
getAllowedAxis: () => "horizontal",
})
: () => {},
autoScrollWindowForElements({ getAllowedAxis: () => "vertical" }),
);
}, [editor, orientation, element, wrapper, onDragStart]);
}
@@ -0,0 +1,23 @@
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { TableDndKey, TableHandleState } from "@docmost/editor-ext";
const FALLBACK: TableHandleState = {
hoveringCell: null,
tableNode: null,
tablePos: null,
dragging: null,
frozen: false,
};
export function useTableHandleState(editor: Editor | null): TableHandleState {
const state = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) return null;
return TableDndKey.getState(ctx.editor.state) ?? null;
},
});
return state ?? FALLBACK;
}
@@ -0,0 +1,50 @@
import { useCallback, useMemo } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { TableMap } from "@tiptap/pm/tables";
import { moveColumn, moveRow } from "@docmost/editor-ext";
export type MoveDirection = "left" | "right" | "up" | "down";
export function useTableMoveRowColumn(
editor: Editor,
orientation: "col" | "row",
index: number,
direction: MoveDirection,
tableNode: ProseMirrorNode,
tablePos: number,
) {
const target =
direction === "left" || direction === "up" ? index - 1 : index + 1;
const maxIndex = useMemo(() => {
const map = TableMap.get(tableNode);
return orientation === "col" ? map.width - 1 : map.height - 1;
}, [tableNode, orientation]);
const canMove = target >= 0 && target <= maxIndex;
const handleMove = useCallback(() => {
if (!canMove) return;
const tr = editor.state.tr;
const moved =
orientation === "col"
? moveColumn({
tr,
originIndex: index,
targetIndex: target,
select: true,
pos: tablePos + 1,
})
: moveRow({
tr,
originIndex: index,
targetIndex: target,
select: true,
pos: tablePos + 1,
});
if (moved) editor.view.dispatch(tr);
}, [editor, orientation, index, target, tablePos, canMove]);
return { canMove, handleMove };
}
@@ -0,0 +1,100 @@
import { useCallback, useMemo } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import {
convertArrayOfRowsToTableNode,
convertTableNodeToArrayOfRows,
transpose,
} from "@docmost/editor-ext";
import {
getCellSortText,
isCellEmpty,
isHeaderCell,
type SortDirection,
type SortableItem,
sortItems,
weaveItems,
} from "../lib/sort-cells";
interface Args {
editor: Editor;
orientation: "col" | "row";
index: number;
tableNode: ProseMirrorNode;
tablePos: number;
direction: SortDirection;
}
function tableHasMergedCells(tableNode: ProseMirrorNode): boolean {
for (let r = 0; r < tableNode.childCount; r++) {
const row = tableNode.child(r);
for (let c = 0; c < row.childCount; c++) {
const { colspan = 1, rowspan = 1 } = row.child(c).attrs;
if (colspan > 1 || rowspan > 1) return true;
}
}
return false;
}
function isAllHeader(cells: (ProseMirrorNode | null)[]): boolean {
return cells.every((c) => c !== null && isHeaderCell(c));
}
export function useTableSort({
editor,
orientation,
index,
tableNode,
tablePos,
direction,
}: Args) {
const canSort = useMemo(() => {
if (tableHasMergedCells(tableNode)) return false;
const rows = convertTableNodeToArrayOfRows(tableNode);
const axes = orientation === "col" ? rows : transpose(rows);
if (axes.length < 2) return false;
return axes.some((cells) => {
if (isAllHeader(cells)) return false;
const sortCell = cells[index];
return !!sortCell && !isCellEmpty(sortCell);
});
}, [tableNode, orientation, index]);
const handleSort = useCallback(() => {
if (!canSort) return;
const rows = convertTableNodeToArrayOfRows(tableNode);
const axes = orientation === "col" ? rows : transpose(rows);
const items: SortableItem<(ProseMirrorNode | null)[]>[] = axes.map(
(cells, originalOrder) => {
const sortCell = cells[index];
return {
payload: cells,
text: sortCell ? getCellSortText(sortCell) : "",
isHeader: isAllHeader(cells),
isEmpty: !sortCell || isCellEmpty(sortCell),
originalOrder,
};
},
);
const dataItems = items.filter((it) => !it.isHeader);
const sortedData = sortItems(dataItems, direction);
const woven = weaveItems(items, sortedData);
const newAxes = woven.map((it) => it.payload);
const newRows = orientation === "col" ? newAxes : transpose(newAxes);
const newTable = convertArrayOfRowsToTableNode(tableNode, newRows);
const tr = editor.state.tr;
tr.replaceWith(tablePos, tablePos + tableNode.nodeSize, newTable);
if (tr.docChanged) editor.view.dispatch(tr);
}, [editor, tableNode, tablePos, orientation, index, direction, canSort]);
return { canSort, handleSort };
}
@@ -0,0 +1,34 @@
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import type { EditorState } from "@tiptap/pm/state";
import { CellSelection, TableMap } from "@tiptap/pm/tables";
export type Orientation = "col" | "row";
export function buildRowOrColumnSelection(
state: EditorState,
tableNode: ProseMirrorNode,
tablePos: number,
orientation: Orientation,
index: number,
): CellSelection | null {
const map = TableMap.get(tableNode);
const tableStart = tablePos + 1;
if (orientation === "col") {
if (index < 0 || index >= map.width) return null;
const firstCellPos = tableStart + map.map[index];
const lastCellPos =
tableStart + map.map[(map.height - 1) * map.width + index];
const $first = state.doc.resolve(firstCellPos);
const $last = state.doc.resolve(lastCellPos);
return CellSelection.colSelection($first, $last);
}
if (index < 0 || index >= map.height) return null;
const firstCellPos = tableStart + map.map[index * map.width];
const lastCellPos =
tableStart + map.map[index * map.width + (map.width - 1)];
const $first = state.doc.resolve(firstCellPos);
const $last = state.doc.resolve(lastCellPos);
return CellSelection.rowSelection($first, $last);
}
@@ -0,0 +1,57 @@
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
export type SortDirection = "asc" | "desc";
export interface SortableItem<T> {
payload: T;
text: string;
isHeader: boolean;
isEmpty: boolean;
originalOrder: number;
}
const HEADER_TYPE_NAMES = new Set(["tableHeader", "table_header"]);
export function isHeaderCell(node: ProseMirrorNode): boolean {
if (HEADER_TYPE_NAMES.has(node.type.name)) return true;
return node.attrs?.header === true;
}
export function getCellSortText(node: ProseMirrorNode): string {
let text = "";
node.descendants((child) => {
if (child.isText) text += child.text ?? "";
return true;
});
return text.trim().toLowerCase();
}
export function isCellEmpty(node: ProseMirrorNode): boolean {
return getCellSortText(node) === "";
}
export const collator = new Intl.Collator(undefined, {
sensitivity: "base",
numeric: true,
});
export function sortItems<T>(
data: SortableItem<T>[],
direction: SortDirection,
): SortableItem<T>[] {
return [...data].sort((a, b) => {
if (a.isEmpty && !b.isEmpty) return 1;
if (!a.isEmpty && b.isEmpty) return -1;
if (a.isEmpty && b.isEmpty) return a.originalOrder - b.originalOrder;
const cmp = collator.compare(a.text, b.text);
return direction === "asc" ? cmp : -cmp;
});
}
export function weaveItems<T>(
all: SortableItem<T>[],
sortedData: SortableItem<T>[],
): SortableItem<T>[] {
const dataQueue = [...sortedData];
return all.map((item) => (item.isHeader ? item : dataQueue.shift()!));
}
@@ -0,0 +1,49 @@
import React from "react";
import type { Editor } from "@tiptap/react";
import { Menu } from "@mantine/core";
import {
IconAlignCenter,
IconAlignLeft,
IconAlignRight,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
interface AlignmentSubmenuProps {
editor: Editor;
}
export const AlignmentSubmenu = React.memo(function AlignmentSubmenu({
editor,
}: AlignmentSubmenuProps) {
const { t } = useTranslation();
return (
<Menu.Sub position="right-start">
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconAlignLeft size={16} />}>
{t("Text alignment")}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<Menu.Item
leftSection={<IconAlignLeft size={16} />}
onClick={() => editor.chain().focus().setTextAlign("left").run()}
>
{t("Align left")}
</Menu.Item>
<Menu.Item
leftSection={<IconAlignCenter size={16} />}
onClick={() => editor.chain().focus().setTextAlign("center").run()}
>
{t("Align center")}
</Menu.Item>
<Menu.Item
leftSection={<IconAlignRight size={16} />}
onClick={() => editor.chain().focus().setTextAlign("right").run()}
>
{t("Align right")}
</Menu.Item>
</Menu.Sub.Dropdown>
</Menu.Sub>
);
});
@@ -0,0 +1,154 @@
import React from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { ColorSwatch, Menu } from "@mantine/core";
import {
IconBoxMargin,
IconColumnInsertRight,
IconColumnRemove,
IconEraser,
IconPalette,
IconRowInsertBottom,
IconRowRemove,
IconSquareToggle,
IconTableRow,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useTableClear } from "../hooks/use-table-clear";
import { TABLE_COLORS } from "../../table-background-color";
import { AlignmentSubmenu } from "./alignment-submenu";
interface CellChevronMenuProps {
editor: Editor;
cellPos: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export const CellChevronMenu = React.memo(function CellChevronMenu({
editor,
cellPos,
tableNode,
tablePos,
}: CellChevronMenuProps) {
const { t } = useTranslation();
const clearCell = useTableClear(editor, tableNode, tablePos, {
kind: "cell",
cellPos,
});
const setBackground = (color: string, name: string) => {
editor
.chain()
.focus()
.updateAttributes("tableCell", {
backgroundColor: color || null,
backgroundColorName: color ? name : null,
})
.updateAttributes("tableHeader", {
backgroundColor: color || null,
backgroundColorName: color ? name : null,
})
.run();
};
return (
<>
<Menu.Sub position="right-start">
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconPalette size={16} />}>
{t("Background color")}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: 8,
padding: 8,
}}
>
{TABLE_COLORS.map((c) => (
<button
key={c.name}
type="button"
onClick={() => setBackground(c.color, c.name)}
aria-label={t(c.name)}
style={{
border: "none",
background: "transparent",
padding: 0,
cursor: "pointer",
}}
>
<ColorSwatch
color={c.color || "#ffffff"}
size={22}
style={{
border: c.color === "" ? "1px solid #e5e7eb" : undefined,
}}
/>
</button>
))}
</div>
</Menu.Sub.Dropdown>
</Menu.Sub>
<AlignmentSubmenu editor={editor} />
<Menu.Item
leftSection={<IconBoxMargin size={16} />}
onClick={() => editor.chain().focus().mergeCells().run()}
disabled={!editor.can().mergeCells()}
>
{t("Merge cells")}
</Menu.Item>
<Menu.Item
leftSection={<IconSquareToggle size={16} />}
onClick={() => editor.chain().focus().splitCell().run()}
disabled={!editor.can().splitCell()}
>
{t("Split cell")}
</Menu.Item>
<Menu.Item
leftSection={<IconTableRow size={16} />}
onClick={() => editor.chain().focus().toggleHeaderCell().run()}
>
{t("Toggle header cell")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconColumnInsertRight size={16} />}
onClick={() => editor.chain().focus().addColumnAfter().run()}
>
{t("Add column right")}
</Menu.Item>
<Menu.Item
leftSection={<IconRowInsertBottom size={16} />}
onClick={() => editor.chain().focus().addRowAfter().run()}
>
{t("Add row below")}
</Menu.Item>
<Menu.Item leftSection={<IconEraser size={16} />} onClick={clearCell}>
{t("Clear cell")}
</Menu.Item>
<Menu.Item
leftSection={<IconColumnRemove size={16} />}
onClick={() => editor.chain().focus().deleteColumn().run()}
>
{t("Delete column")}
</Menu.Item>
<Menu.Item
leftSection={<IconRowRemove size={16} />}
onClick={() => editor.chain().focus().deleteRow().run()}
>
{t("Delete row")}
</Menu.Item>
</>
);
});
@@ -0,0 +1,177 @@
import React from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { ColorSwatch, Menu } from "@mantine/core";
import { TABLE_COLORS } from "../../table-background-color";
import {
IconArrowLeft,
IconArrowRight,
IconColumnInsertLeft,
IconColumnInsertRight,
IconColumnRemove,
IconEraser,
IconPalette,
IconSortAscendingLetters,
IconSortDescendingLetters,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useTableMoveRowColumn } from "../hooks/use-table-move-row-column";
import { useTableClear } from "../hooks/use-table-clear";
import { useTableSort } from "../hooks/use-table-sort";
import { AlignmentSubmenu } from "./alignment-submenu";
interface ColumnHandleMenuProps {
editor: Editor;
index: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export const ColumnHandleMenu = React.memo(function ColumnHandleMenu({
editor,
index,
tableNode,
tablePos,
}: ColumnHandleMenuProps) {
const { t } = useTranslation();
const moveLeft = useTableMoveRowColumn(editor, "col", index, "left", tableNode, tablePos);
const moveRight = useTableMoveRowColumn(editor, "col", index, "right", tableNode, tablePos);
const clearCol = useTableClear(editor, tableNode, tablePos, {
kind: "col",
index,
});
const setBackground = (color: string, name: string) => {
editor
.chain()
.focus()
.updateAttributes("tableCell", {
backgroundColor: color || null,
backgroundColorName: color ? name : null,
})
.updateAttributes("tableHeader", {
backgroundColor: color || null,
backgroundColorName: color ? name : null,
})
.run();
};
const sortAsc = useTableSort({
editor,
orientation: "col",
index,
tableNode,
tablePos,
direction: "asc",
});
const sortDesc = useTableSort({
editor,
orientation: "col",
index,
tableNode,
tablePos,
direction: "desc",
});
return (
<>
<Menu.Item
leftSection={<IconSortAscendingLetters size={16} />}
onClick={sortAsc.handleSort}
disabled={!sortAsc.canSort}
>
{t("Sort A → Z")}
</Menu.Item>
<Menu.Item
leftSection={<IconSortDescendingLetters size={16} />}
onClick={sortDesc.handleSort}
disabled={!sortDesc.canSort}
>
{t("Sort Z → A")}
</Menu.Item>
<Menu.Divider />
<Menu.Sub position="right-start">
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconPalette size={16} />}>
{t("Background color")}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, padding: 8 }}>
{TABLE_COLORS.map((c) => (
<button
key={c.name}
type="button"
onClick={() => setBackground(c.color, c.name)}
aria-label={t(c.name)}
style={{
border: "none",
background: "transparent",
padding: 0,
cursor: "pointer",
}}
>
<ColorSwatch
color={c.color || "#ffffff"}
size={22}
style={{ border: c.color === "" ? "1px solid #e5e7eb" : undefined }}
/>
</button>
))}
</div>
</Menu.Sub.Dropdown>
</Menu.Sub>
<AlignmentSubmenu editor={editor} />
<Menu.Divider />
<Menu.Item
leftSection={<IconColumnInsertLeft size={16} />}
onClick={() => editor.chain().focus().addColumnBefore().run()}
>
{t("Add column left")}
</Menu.Item>
<Menu.Item
leftSection={<IconColumnInsertRight size={16} />}
onClick={() => editor.chain().focus().addColumnAfter().run()}
>
{t("Add column right")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconEraser size={16} />}
onClick={clearCol}
>
{t("Clear cells")}
</Menu.Item>
<Menu.Item
leftSection={<IconColumnRemove size={16} />}
onClick={() => editor.chain().focus().deleteColumn().run()}
>
{t("Delete column")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconArrowLeft size={16} />}
onClick={moveLeft.handleMove}
disabled={!moveLeft.canMove}
>
{t("Move column left")}
</Menu.Item>
<Menu.Item
leftSection={<IconArrowRight size={16} />}
onClick={moveRight.handleMove}
disabled={!moveRight.canMove}
>
{t("Move column right")}
</Menu.Item>
</>
);
});
@@ -0,0 +1,138 @@
import React from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { ColorSwatch, Menu } from "@mantine/core";
import { TABLE_COLORS } from "../../table-background-color";
import {
IconArrowDown,
IconArrowUp,
IconEraser,
IconPalette,
IconRowInsertBottom,
IconRowInsertTop,
IconRowRemove,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useTableMoveRowColumn } from "../hooks/use-table-move-row-column";
import { useTableClear } from "../hooks/use-table-clear";
import { AlignmentSubmenu } from "./alignment-submenu";
interface RowHandleMenuProps {
editor: Editor;
index: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export const RowHandleMenu = React.memo(function RowHandleMenu({
editor,
index,
tableNode,
tablePos,
}: RowHandleMenuProps) {
const { t } = useTranslation();
const setBackground = (color: string, name: string) => {
editor
.chain()
.focus()
.updateAttributes("tableCell", {
backgroundColor: color || null,
backgroundColorName: color ? name : null,
})
.updateAttributes("tableHeader", {
backgroundColor: color || null,
backgroundColorName: color ? name : null,
})
.run();
};
const moveUp = useTableMoveRowColumn(editor, "row", index, "up", tableNode, tablePos);
const moveDown = useTableMoveRowColumn(editor, "row", index, "down", tableNode, tablePos);
const clearRow = useTableClear(editor, tableNode, tablePos, {
kind: "row",
index,
});
return (
<>
<Menu.Sub position="right-start">
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconPalette size={16} />}>
{t("Background color")}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, padding: 8 }}>
{TABLE_COLORS.map((c) => (
<button
key={c.name}
type="button"
onClick={() => setBackground(c.color, c.name)}
aria-label={t(c.name)}
style={{
border: "none",
background: "transparent",
padding: 0,
cursor: "pointer",
}}
>
<ColorSwatch
color={c.color || "#ffffff"}
size={22}
style={{ border: c.color === "" ? "1px solid #e5e7eb" : undefined }}
/>
</button>
))}
</div>
</Menu.Sub.Dropdown>
</Menu.Sub>
<AlignmentSubmenu editor={editor} />
<Menu.Divider />
<Menu.Item
leftSection={<IconRowInsertTop size={16} />}
onClick={() => editor.chain().focus().addRowBefore().run()}
>
{t("Add row above")}
</Menu.Item>
<Menu.Item
leftSection={<IconRowInsertBottom size={16} />}
onClick={() => editor.chain().focus().addRowAfter().run()}
>
{t("Add row below")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconEraser size={16} />} onClick={clearRow}>
{t("Clear cells")}
</Menu.Item>
<Menu.Item
leftSection={<IconRowRemove size={16} />}
onClick={() => editor.chain().focus().deleteRow().run()}
>
{t("Delete row")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconArrowUp size={16} />}
onClick={moveUp.handleMove}
disabled={!moveUp.canMove}
>
{t("Move row up")}
</Menu.Item>
<Menu.Item
leftSection={<IconArrowDown size={16} />}
onClick={moveDown.handleMove}
disabled={!moveDown.canMove}
>
{t("Move row down")}
</Menu.Item>
</>
);
});
@@ -0,0 +1,127 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { Editor } from "@tiptap/react";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
import { Menu } from "@mantine/core";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { useTableHandleDrag } from "./hooks/use-table-handle-drag";
import { useColumnRowMenuLifecycle } from "./hooks/use-column-row-menu-lifecycle";
import { RowHandleMenu } from "./menus/row-handle-menu";
import classes from "./handle.module.css";
interface RowHandleProps {
editor: Editor;
index: number;
anchorPos: number;
tableNode: ProseMirrorNode;
tablePos: number;
}
export const RowHandle = React.memo(function RowHandle({
editor,
index,
anchorPos,
tableNode,
tablePos,
}: RowHandleProps) {
const { t } = useTranslation();
// See ColumnHandle for the rationale: keep the last valid cell DOM cached
// so the handle div stays mounted across stale-anchor renders, otherwise
// pragmatic-dnd silently aborts an in-flight drag.
// `nodeDOM` is typed as `Node | null` — when `anchorPos` goes stale (e.g.
// an external drop reflows the doc before the plugin re-emits
// hoveringCell), it can resolve to a Text node, on which `.closest` is
// undefined. Filter to HTMLElement so downstream consumers stay safe.
const lookupDom = editor.view.nodeDOM(anchorPos);
const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : null;
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
useEffect(() => {
if (lookupCellDom && lookupCellDom !== lastCellDomRef.current) {
lastCellDomRef.current = lookupCellDom;
setCellDom(lookupCellDom);
}
}, [lookupCellDom]);
const [handleEl, setHandleEl] = useState<HTMLDivElement | null>(null);
const { refs, floatingStyles, middlewareData } = useFloating({
placement: "left",
middleware: [offset(-4), hide()],
whileElementsMounted: autoUpdate,
});
const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
useEffect(() => {
refs.setReference(cellDom);
}, [cellDom, refs]);
const wrapper = cellDom?.closest<HTMLElement>(".tableWrapper") ?? null;
const [menuOpened, setMenuOpened] = useState(false);
const closeMenu = useCallback(() => setMenuOpened(false), []);
useTableHandleDrag(editor, "row", handleEl, wrapper, closeMenu);
const { onOpen, onClose } = useColumnRowMenuLifecycle({
editor,
orientation: "row",
index,
tableNode,
tablePos,
});
if (!cellDom) return null;
return (
<Menu
opened={menuOpened}
onChange={setMenuOpened}
position="right-start"
onOpen={onOpen}
onClose={onClose}
withinPortal
shadow="md"
>
<Menu.Target>
<div
ref={(node) => {
refs.setFloating(node);
setHandleEl(node);
}}
style={{
...floatingStyles,
...(isReferenceHidden ? { visibility: "hidden" as const } : {}),
}}
className={clsx(classes.handle, classes.rowHandle)}
role="button"
tabIndex={0}
aria-label={t("Row actions")}
>
<span style={{ pointerEvents: "none", display: "inline-flex" }}>
<GripIcon />
</span>
</div>
</Menu.Target>
<Menu.Dropdown>
<RowHandleMenu
editor={editor}
index={index}
tableNode={tableNode}
tablePos={tablePos}
/>
</Menu.Dropdown>
</Menu>
);
});
function GripIcon() {
return (
<svg viewBox="0 0 10 10" width="14" height="14" aria-hidden>
<path
fill="currentColor"
d="M3,2 A1,1 0 1 1 3,0 A1,1 0 0 1 3,2 Z M3,6 A1,1 0 1 1 3,4 A1,1 0 0 1 3,6 Z M3,10 A1,1 0 1 1 3,8 A1,1 0 0 1 3,10 Z M7,2 A1,1 0 1 1 7,0 A1,1 0 0 1 7,2 Z M7,6 A1,1 0 1 1 7,4 A1,1 0 0 1 7,6 Z M7,10 A1,1 0 1 1 7,8 A1,1 0 0 1 7,10 Z"
/>
</svg>
);
}
@@ -0,0 +1,44 @@
import React from "react";
import type { Editor } from "@tiptap/react";
import { useTableHandleState } from "./hooks/use-table-handle-state";
import { ColumnHandle } from "./column-handle";
import { RowHandle } from "./row-handle";
import { CellChevron } from "./cell-chevron";
interface TableHandlesLayerProps {
editor: Editor | null;
}
export const TableHandlesLayer = React.memo(function TableHandlesLayer({
editor,
}: TableHandlesLayerProps) {
const state = useTableHandleState(editor);
if (!editor || !editor.isEditable) return null;
if (!state.hoveringCell || !state.tableNode || state.tablePos == null) return null;
return (
<>
<ColumnHandle
editor={editor}
index={state.hoveringCell.colIndex}
anchorPos={state.hoveringCell.colFirstCellPos}
tableNode={state.tableNode!}
tablePos={state.tablePos!}
/>
<RowHandle
editor={editor}
index={state.hoveringCell.rowIndex}
anchorPos={state.hoveringCell.rowFirstCellPos}
tableNode={state.tableNode!}
tablePos={state.tablePos!}
/>
<CellChevron
editor={editor}
cellPos={state.hoveringCell.cellPos}
tableNode={state.tableNode!}
tablePos={state.tablePos!}
/>
</>
);
});
@@ -22,7 +22,7 @@ interface TableBackgroundColorProps {
editor: Editor | null;
}
const TABLE_COLORS: TableColorItem[] = [
export const TABLE_COLORS: TableColorItem[] = [
{ name: "Default", color: "" },
{ name: "Blue", color: "#b4d5ff" },
{ name: "Green", color: "#acf5d2" },
@@ -18,7 +18,7 @@ import {
IconTrashX,
} from "@tabler/icons-react";
import { BubbleMenu } from "@tiptap/react/menus";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { isCellSelection, isEditorReady, isTextSelected } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import classes from "../common/toolbar-menu.module.css";
@@ -38,6 +38,7 @@ export const TableMenu = React.memo(
);
const getReferencedVirtualElement = useCallback(() => {
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "table";
const parent = findParentNode(predicate)(selection);
@@ -104,12 +105,12 @@ export const TableMenu = React.memo(
element.style.zIndex = "99";
}}
options={{
placement: "top",
placement: "bottom",
offset: {
mainAxis: 15,
},
flip: {
fallbackPlacements: ["top", "bottom"],
fallbackPlacements: ["bottom", "top"],
padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity },
boundary: editor.options.element as HTMLElement,
},
@@ -86,11 +86,11 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
transitionProps={{ transition: "pop" }}
>
<Popover.Target>
<Tooltip label={t("Text alignment")} withArrow>
<Tooltip label={t("Text align")} withArrow>
<ActionIcon
variant="subtle"
size="lg"
aria-label={t("Text alignment")}
aria-label={t("Text align")}
onClick={() => setOpened(!opened)}
>
<activeItem.icon size={18} />
@@ -35,6 +35,7 @@ export default function TransclusionReferenceView(props: NodeViewProps) {
return (
<NodeViewWrapper
className={classes.includeWrap}
data-editable={isEditable ? "true" : "false"}
data-focused={isEditable && props.selected ? "true" : "false"}
data-menu-open={openMenus > 0 ? "true" : "false"}
contentEditable={false}
@@ -62,6 +62,7 @@ export default function TransclusionView(props: NodeViewProps) {
return (
<NodeViewWrapper
className={classes.transclusionWrap}
data-editable={isEditable ? "true" : "false"}
data-menu-open={openMenus > 0 ? "true" : "false"}
data-id={transclusionId ?? undefined}
>
@@ -44,8 +44,29 @@
transition: border 0.3s;
}
.transclusionWrap:hover,
.transclusionWrap:focus-within {
.transclusionWrap[data-editable="false"],
.includeWrap[data-editable="false"] {
margin-left: 0;
margin-right: 0;
width: 100%;
padding: 0;
}
/* Cancel the wrapping .react-renderer's vertical spacing in read-only mode
so the synced block sits flush with surrounding paragraphs (whose own
margins already provide the right rhythm). */
:global(.react-renderer.node-transclusionSource):has(
.transclusionWrap[data-editable="false"]
),
:global(.react-renderer.node-transclusionReference):has(
.includeWrap[data-editable="false"]
) {
margin-top: 0;
margin-bottom: 0;
}
.transclusionWrap[data-editable="true"]:hover,
.transclusionWrap[data-editable="true"]:focus-within {
border: 2px solid
light-dark(
var(--mantine-color-orange-2),
@@ -114,9 +135,9 @@
transition: border 0.3s;
}
.includeWrap:hover,
.includeWrap[data-focused="true"],
.includeWrap[data-menu-open="true"] {
.includeWrap[data-editable="true"]:hover,
.includeWrap[data-editable="true"][data-focused="true"],
.includeWrap[data-editable="true"][data-menu-open="true"] {
border: 2px solid
light-dark(
var(--mantine-color-orange-2),
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
EditorMenuProps,
ShouldShowProps,
@@ -53,7 +54,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "video";
const parent = findParentNode(predicate)(selection);
@@ -60,6 +60,23 @@ function nodeDOMAtCoords(
options: GlobalDragHandleOptions,
view: EditorView,
) {
// Custom nodes (transclusion, …) render via tiptap's React node-view
// renderer, which emits `class="react-renderer node-${name}"` on the
// live wrapper — the `data-type` attribute is for static HTML
// serialization only. Match both so we cover live and parsed DOM.
// Inside a custom node, also match plain `p` so the first paragraph
// (which doesn't match `:not(:first-child)`) still gets its own
// handle; only hovers on the custom node's padding/border fall
// through to the wrapper.
const customSelectors = options.customNodes.flatMap((node) => [
`[data-type=${node}]`,
`.node-${node}`,
]);
const customParagraphSelectors = options.customNodes.flatMap((node) => [
`[data-type=${node}] p`,
`.node-${node} p`,
]);
const selectors = [
"li",
"p:not(:first-child)",
@@ -71,7 +88,13 @@ function nodeDOMAtCoords(
"h4",
"h5",
"h6",
...options.customNodes.map((node) => `[data-type=${node}]`),
// Tables nested in another block (toggle, transclusion, …) have a
// wrapper that isn't a direct child of .ProseMirror, so the
// parent-check below skips it. Match the wrapper explicitly so the
// handle shows up even with empty cells.
".tableWrapper",
...customParagraphSelectors,
...customSelectors,
].join(", ");
return document
.elementsFromPoint(coords.x, coords.y)
@@ -99,6 +122,22 @@ function nodePosAtDOM(
})?.inside;
}
function isCustomNodeDOM(
elem: Element | null | undefined,
options: GlobalDragHandleOptions,
): boolean {
if (!elem) return false;
for (const name of options.customNodes) {
if (
elem.getAttribute("data-type") === name ||
elem.classList.contains(`node-${name}`)
) {
return true;
}
}
return false;
}
function calcNodePos(pos: number, view: EditorView) {
const $pos = view.state.doc.resolve(pos);
if ($pos.depth > 1) return $pos.before($pos.depth);
@@ -137,7 +176,6 @@ export function DragHandlePlugin(
const nodePos = view.state.doc.resolve(fromSelectionPos);
// Check if nodePos points to the top level node
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
else {
const nodeSelection = NodeSelection.create(
@@ -166,14 +204,46 @@ export function DragHandlePlugin(
} else {
selection = NodeSelection.create(view.state.doc, draggedNodePos);
// if inline node is selected, e.g mention -> go to the parent node to select the whole node
// if table row is selected, go to the parent node to select the whole node
if (
(selection as NodeSelection).node.type.isInline ||
(selection as NodeSelection).node.type.name === "tableRow"
) {
let $pos = view.state.doc.resolve(selection.from);
selection = NodeSelection.create(view.state.doc, $pos.before());
const $sel = view.state.doc.resolve(selection.from);
if (isCustomNodeDOM(node, options)) {
// The drag landed on a custom-node container (transclusion etc.).
// Walk up to the matching node so the drag moves the whole
// container, not whatever inner element the click landed on.
const customTypes = new Set(options.customNodes);
for (let d = $sel.depth; d > 0; d--) {
if (customTypes.has($sel.node(d).type.name)) {
selection = NodeSelection.create(
view.state.doc,
$sel.before(d),
);
break;
}
}
} else {
// If the selected node lives inside a table (at any nesting
// depth), promote to the whole table — the global drag handle is
// meant to move the table as a single block, not a row/cell. The
// earlier tableRow-only check only worked when the table sat at
// the doc root; once wrapped in another node (toggle, layout,
// etc.) the selection lands on a cell/paragraph and that check
// never fired.
let tableDepth = -1;
for (let d = $sel.depth; d > 0; d--) {
if ($sel.node(d).type.name === "table") {
tableDepth = d;
break;
}
}
if (tableDepth > 0) {
selection = NodeSelection.create(
view.state.doc,
$sel.before(tableDepth),
);
} else if ((selection as NodeSelection).node.type.isInline) {
// Inline node (e.g. mention): walk up to the parent block.
selection = NodeSelection.create(view.state.doc, $sel.before());
}
}
}
view.dispatch(view.state.tr.setSelection(selection));
@@ -313,6 +383,27 @@ export function DragHandlePlugin(
return;
}
const isCustomNode = isCustomNodeDOM(node, options);
// Custom nodes pin the handle to the inner NodeViewWrapper's top-left:
// the natural anchor sits in transient/empty space outside the visible block.
if (isCustomNode) {
// tiptap React node-views emit an outer `.react-renderer` whose first
// child is the visible NodeViewWrapper; walk to that outer first since
// `node` may be either the outer or an inner element with data-type.
const rendererOuter =
(node.closest(".react-renderer") as HTMLElement | null) ?? node;
const inner =
(rendererOuter.firstElementChild as HTMLElement | null) ??
rendererOuter;
const innerRect = absoluteRect(inner);
if (!dragHandleElement) return;
dragHandleElement.style.left = `${innerRect.left + 4}px`;
dragHandleElement.style.top = `${innerRect.top + 4}px`;
showDragHandle();
return;
}
const compStyle = window.getComputedStyle(node);
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
const lineHeight = isNaN(parsedLineHeight)
@@ -328,6 +419,13 @@ export function DragHandlePlugin(
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= options.dragHandleWidth;
}
// Tables: clear the table's own row-drag handle so the two
// grips don't stack on each other. `nodeDOMAtCoords` returns
// the wrapper for top-level hovers (wrapper is direct child of
// .ProseMirror) and a descendant for deeper hovers — cover both.
if (node.closest(".tableWrapper")) {
rect.left -= options.dragHandleWidth;
}
rect.width = options.dragHandleWidth;
if (!dragHandleElement) return;
@@ -42,9 +42,13 @@ import {
Excalidraw,
Embed,
TiptapPdf,
PageBreak,
SearchAndReplace,
Mention,
TableDndExtension,
TableHandleCommandsExtension,
TableHeaderPin,
TableReadonlySort,
Subpages,
Heading,
Highlight,
@@ -56,6 +60,7 @@ import {
Status,
TransclusionSource,
TransclusionReference,
TableView,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -80,7 +85,7 @@ import AudioView from "@/features/editor/components/audio/audio-view.tsx";
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
@@ -259,11 +264,16 @@ export const mainExtensions = [
resizable: true,
lastColumnResizable: true,
allowTableNodeSelection: true,
cellMinWidth: 49,
View: TableView,
}),
TableRow,
TableCell,
TableHeader,
TableDndExtension,
TableHandleCommandsExtension,
TableHeaderPin,
TableReadonlySort,
MathInline.configure({
view: MathInlineView,
}),
@@ -357,6 +367,7 @@ export const mainExtensions = [
TiptapPdf.configure({
view: PdfView,
}),
PageBreak,
Subpages.configure({
view: SubpagesView,
}),
+30 -11
View File
@@ -1,5 +1,5 @@
import classes from "@/features/editor/styles/editor.module.css";
import React from "react";
import React, { useEffect } from "react";
import { TitleEditor } from "@/features/editor/title-editor";
import PageEditor from "@/features/editor/page-editor";
import {
@@ -23,17 +23,25 @@ import { IContributor } from "@/features/page/types/page.types.ts";
import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
import clsx from "clsx";
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts";
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor);
const MemoizedFixedToolbar = React.memo(FixedToolbar);
const MemoizedDeletedPageBanner = React.memo(DeletedPageBanner);
type PageCreator = {
type PageUser = {
id: string;
name: string;
avatarUrl: string;
};
// Module-level flag: survives component unmount/remount on page navigation,
// reset only on full page reload (i.e. a new app session).
let defaultEditModeApplied = false;
export interface FullEditorProps {
pageId: string;
slugId: string;
@@ -41,7 +49,7 @@ export interface FullEditorProps {
content: string;
spaceSlug: string;
editable: boolean;
creator?: PageCreator;
creator?: PageUser;
contributors?: IContributor[];
canComment?: boolean;
}
@@ -61,9 +69,21 @@ export function FullEditor({
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
const editorToolbarEnabled =
user.settings?.preferences?.editorToolbar ?? false;
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
currentPageEditModeAtom,
);
const userPageEditMode =
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const isEditMode = userPageEditMode === PageEditMode.Edit;
const isEditMode = currentPageEditMode === PageEditMode.Edit;
// Apply the user's saved preference only once on initial load, not on every
// page navigation — so the mode sticks across navigations within a session.
useEffect(() => {
if (!defaultEditModeApplied) {
setCurrentPageEditMode(userPageEditMode as PageEditMode);
defaultEditModeApplied = true;
}
}, [userPageEditMode, setCurrentPageEditMode]);
return (
<Container
@@ -71,7 +91,10 @@ export function FullEditor({
size={!fullPageWidth && 900}
className={classes.editor}
>
{editorToolbarEnabled && editable && isEditMode && <FixedToolbar />}
{editorToolbarEnabled && editable && isEditMode && (
<MemoizedFixedToolbar />
)}
<MemoizedDeletedPageBanner slugId={slugId} />
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}
@@ -95,16 +118,12 @@ export function FullEditor({
}
type PageBylineProps = {
creator?: PageCreator;
creator?: PageUser;
contributors?: IContributor[];
readOnly?: boolean;
};
function PageByline({
creator,
contributors,
readOnly,
}: PageBylineProps) {
function PageByline({ creator, contributors, readOnly }: PageBylineProps) {
const { t } = useTranslation();
const toggleAside = useToggleAside();
@@ -26,10 +26,11 @@ import {
collabExtensions,
mainExtensions,
} from "@/features/editor/extensions/extensions";
import { useAtom } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
currentPageEditModeAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms";
@@ -44,6 +45,7 @@ import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubbl
import { ReadonlyBubbleMenu } from "@/features/editor/components/bubble-menu/readonly-bubble-menu";
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
import { TableHandlesLayer } from "@/features/editor/components/table/handle/table-handles-layer";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
@@ -53,7 +55,7 @@ import {
handleFileDrop,
handlePaste,
} from "@/features/editor/components/common/editor-paste-handler.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
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";
@@ -111,8 +113,7 @@ export default function PageEditor({
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const canScroll = useCallback(
() => Boolean(isComponentMounted.current && editorRef.current),
[isComponentMounted],
@@ -372,19 +373,9 @@ export default function PageEditor({
return () => clearTimeout(timeout);
}, [yjsConnectionStatus, isSynced]);
useEffect(() => {
// Only honor user default page edit mode preference and permissions
if (editor) {
if (userPageEditMode && editable) {
if (userPageEditMode === PageEditMode.Edit) {
editor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
editor.setEditable(false);
}
} else {
editor.setEditable(false);
}
}
}, [userPageEditMode, editor, editable]);
if (!editor) return;
editor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
}, [currentPageEditMode, editor, editable]);
const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true);
@@ -424,7 +415,7 @@ export default function PageEditor({
<EditorLinkMenu editor={editor} />
<EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} />
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
<TableHandlesLayer editor={editor} />
<ImageMenu editor={editor} />
<VideoMenu editor={editor} />
<PdfMenu editor={editor} />
@@ -203,7 +203,8 @@
}
}
.resize-cursor {
&.resize-cursor,
&.resize-cursor * {
cursor: ew-resize;
cursor: col-resize;
}
@@ -9,6 +9,7 @@
@import "./media.css";
@import "./code.css";
@import "./print.css";
@import "./page-break.css";
@import "./find.css";
@import "./mention.css";
@import "./ordered-list.css";
@@ -0,0 +1,50 @@
.ProseMirror .page-break {
position: relative;
margin: 1.5rem 0;
border-top: 1px dashed var(--mantine-color-default-border);
height: 0;
user-select: none;
}
.ProseMirror[contenteditable="false"] .page-break {
margin: 0;
border: none;
height: 0;
}
.ProseMirror[contenteditable="false"] .page-break::after {
content: none;
}
.ProseMirror .page-break::after {
content: "Page break";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 0 0.5rem;
background: var(--mantine-color-body);
color: var(--mantine-color-dimmed);
font-size: 0.75rem;
line-height: 1;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.ProseMirror .page-break.ProseMirror-selectednode {
border-top-color: var(--mantine-primary-color-filled);
}
@media print {
.ProseMirror .page-break {
break-before: always;
page-break-before: always;
visibility: hidden;
border: none;
margin: 0;
}
.ProseMirror .page-break::after {
content: none;
}
}
@@ -15,7 +15,8 @@
}
.table-dnd-drop-indicator {
background-color: #adf;
background-color: var(--mantine-color-blue-5);
z-index: 3;
}
.ProseMirror {
@@ -57,13 +58,14 @@
}
.column-resize-handle {
background-color: #adf;
background-color: var(--mantine-color-blue-5);
bottom: -1px;
position: absolute;
right: -2px;
right: -1px;
pointer-events: none;
top: 0;
width: 4px;
width: 2px;
z-index: 3;
}
.selectedCell:after {
@@ -129,6 +131,139 @@
}
}
/* Header-row pinning. Two CSS paths, picked by `header-pin/controller.ts`:
- native sticky (preferred): wrapper drops its overflow constraint so
`position: sticky` on the row can resolve against the document scroll.
- transform fallback: wrapper keeps `overflow-x: auto` for horizontal
scrolling; the row is positioned imperatively per scroll frame.
`--editor-pin-offset` is published to :root by `pinOffsetWatcher` in
`header-pin/offset.ts`, measured against the lowest fixed surface above
the editor (app shell header, page header, fixed toolbar). */
.tableWrapper.tableWrapperNoOverflow,
.tableWrapper.tableWrapperNoOverflow table {
overflow: visible;
}
.tableWrapper.tableHeaderPinned table tr:first-child {
z-index: 2;
}
.tableWrapper.tableWrapperNoOverflow.tableHeaderPinned table tr:first-child {
position: sticky;
top: var(--editor-pin-offset, 90px);
}
.tableWrapper.tableHeaderPinned:not(.tableWrapperNoOverflow) table tr:first-child {
position: relative;
transform: translateY(var(--table-pin-offset, 0px));
}
@media print {
.tableWrapper.tableHeaderPinned table tr:first-child {
position: static;
transform: none;
}
}
.tableReadonlySortChevron {
/* Anchor to the cell's right edge, vertically centered with the cell
content. The cell content (a <p>) is block-level so an inline chevron
would wrap to a new line; absolute positioning takes it out of flow. */
position: absolute;
top: 50%;
right: 6px;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
background: light-dark(
rgba(55, 53, 47, 0.08),
rgba(255, 255, 255, 0.08)
);
color: light-dark(
rgba(55, 53, 47, 0.55),
rgba(255, 255, 255, 0.55)
);
user-select: none;
cursor: pointer;
z-index: 1;
/* Hidden by default; revealed on header-cell hover or when this column is
the active sort (see selectors below). */
opacity: 0;
transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease;
}
.ProseMirror table th:hover .tableReadonlySortChevron,
.tableReadonlySortChevron[data-sort] {
opacity: 1;
}
.ProseMirror table th:has(.tableReadonlySortChevron) {
padding-right: 30px;
}
.tableReadonlySortChevron:hover {
background: light-dark(
rgba(55, 53, 47, 0.16),
rgba(255, 255, 255, 0.16)
);
}
/* Immediate tooltip on the chevron — same style language as the rest of the
app (small, dark, rounded), unlike the native `title` tooltip which only
appears after a long delay. */
.tableReadonlySortChevron::after {
content: attr(data-tooltip);
position: absolute;
/* Below the chevron — placing it above the cell hits the table's
overflow clipping (the wrapper has `overflow-x: auto` which forces
`overflow-y: auto` per spec). */
top: calc(100% + 6px);
right: 0;
padding: 4px 8px;
border-radius: 4px;
background: var(--mantine-color-dark-7);
color: var(--mantine-color-white);
font-size: 12px;
font-weight: 400;
line-height: 1.4;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
z-index: 10;
}
.tableReadonlySortChevron:hover::after {
opacity: 1;
}
.tableReadonlySortChevron svg {
display: block;
}
.tableReadonlySortChevron[data-sort="asc"],
.tableReadonlySortChevron[data-sort="desc"] {
background: light-dark(
var(--mantine-color-blue-1),
var(--mantine-color-blue-9)
);
color: light-dark(
var(--mantine-color-blue-7),
var(--mantine-color-blue-2)
);
}
.tableReadonlySortChevron[data-sort="asc"] svg {
transform: rotate(180deg);
}
.editor-container:has(.table-dnd-drop-indicator[data-dragging="true"]) {
.prosemirror-dropcursor-block {
display: none;
@@ -7,6 +7,7 @@ import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtomValue } from "jotai";
import {
currentPageEditModeAtom,
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms";
@@ -24,7 +25,6 @@ import { useTranslation } from "react-i18next";
import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
import { UpdateEvent } from "@/features/websocket/types";
import localEmitter from "@/lib/local-emitter.ts";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { searchSpotlight } from "@/features/search/constants.ts";
import { platformModifierKey } from "@/lib";
@@ -52,9 +52,7 @@ export function TitleEditor({
const emit = useQueryEmit();
const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId);
const [currentUser] = useAtom(currentUserAtom);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const titleEditor = useEditor({
extensions: [
@@ -172,18 +170,9 @@ export function TitleEditor({
}, [pageId]);
useEffect(() => {
if (titleEditor) {
if (userPageEditMode && editable) {
if (userPageEditMode === PageEditMode.Edit) {
titleEditor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
titleEditor.setEditable(false);
}
} else {
titleEditor.setEditable(false);
}
}
}, [userPageEditMode, titleEditor, editable]);
if (!titleEditor) return;
titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
}, [currentPageEditMode, titleEditor, editable]);
const openSearchDialog = () => {
const event = new CustomEvent("openFindDialogFromEditor", {});
@@ -40,7 +40,7 @@ import {
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { formattedDate } from "@/lib/time.ts";
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission";
@@ -65,6 +65,11 @@ interface PageHeaderMenuProps {
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const { t } = useTranslation();
const toggleAside = useToggleAside();
const { pageSlug } = useParams();
const { data: page } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
const isDeleted = !!page?.deletedAt;
useHotkeys(
[
@@ -87,11 +92,15 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
[],
);
if (isDeleted) {
return null;
}
return (
<>
<ConnectionWarning />
{!readOnly && <PageStateSegmentedControl size="xs" />}
{!readOnly && <PageEditModeToggle size="xs" />}
<PageShareModal readOnly={readOnly} />
@@ -8,7 +8,7 @@ interface Props {
}
export default function PageHeader({ readOnly }: Props) {
return (
<div className={classes.header}>
<div className={classes.header} data-page-header="true">
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
<Breadcrumb />
@@ -0,0 +1,30 @@
import { modals } from "@mantine/modals";
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
type UseRestoreModalProps = {
title?: string | null;
onConfirm: () => void;
};
export function useRestorePageModal() {
const { t } = useTranslation();
const openRestoreModal = ({ title, onConfirm }: UseRestoreModalProps) => {
modals.openConfirmModal({
title: t("Restore page"),
children: (
<Text size="sm">
{t("Restore '{{title}}' and its sub-pages?", {
title: title || t("Untitled"),
})}
</Text>
),
centered: true,
labels: { confirm: t("Restore"), cancel: t("Cancel") },
confirmProps: { color: "blue" },
onConfirm,
});
};
return { openRestoreModal } as const;
}
@@ -117,10 +117,20 @@ export function useUpdatePageMutation() {
}
export function useRemovePageMutation() {
const { t } = useTranslation();
return useMutation({
mutationFn: (pageId: string) => deletePage(pageId, false),
onSuccess: (_, pageId) => {
notifications.show({ message: "Page moved to trash" });
notifications.show({ message: t("Page moved to trash") });
// Stamp deletedAt so a re-visit shows the trash banner, not stale state.
const cached = queryClient.getQueryData<IPage>(["pages", pageId]);
if (cached) {
const stamped = { ...cached, deletedAt: new Date() };
queryClient.setQueryData(["pages", cached.id], stamped);
queryClient.setQueryData(["pages", cached.slugId], stamped);
}
invalidateOnDeletePage(pageId);
queryClient.invalidateQueries({
predicate: (item) =>
@@ -128,7 +138,7 @@ export function useRemovePageMutation() {
});
},
onError: (error) => {
notifications.show({ message: "Failed to delete page", color: "red" });
notifications.show({ message: t("Failed to delete page"), color: "red" });
},
});
}
@@ -162,13 +172,14 @@ export function useMovePageMutation() {
}
export function useRestorePageMutation() {
const { t } = useTranslation();
const [treeData, setTreeData] = useAtom(treeDataAtom);
const emit = useQueryEmit();
return useMutation({
mutationFn: (pageId: string) => restorePage(pageId),
onSuccess: async (restoredPage) => {
notifications.show({ message: "Page restored successfully" });
notifications.show({ message: t("Page restored successfully") });
// Check if the page already exists in the tree (it shouldn't)
if (!treeModel.find(treeData, restoredPage.id)) {
@@ -222,9 +233,16 @@ export function useRestorePageMutation() {
await queryClient.invalidateQueries({
queryKey: ["trash-list", restoredPage.spaceId],
});
// Merge — restore endpoint returns a skinny page;
// Replace would strip space/permissions/content and break the editor.
const merge = (cached: IPage | undefined) =>
cached ? { ...cached, ...restoredPage } : cached;
queryClient.setQueryData<IPage>(["pages", restoredPage.id], merge);
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
},
onError: (error) => {
notifications.show({ message: "Failed to restore page", color: "red" });
notifications.show({ message: t("Failed to restore page"), color: "red" });
},
});
}
@@ -0,0 +1,140 @@
import { ActionIcon, Button, Group, Paper, Text, Tooltip } from "@mantine/core";
import { IconRestore, IconTrash } from "@tabler/icons-react";
import { useNavigate } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { useRestorePageModal } from "@/features/page/hooks/use-restore-page-modal.tsx";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import {
useDeletePageMutation,
usePageQuery,
useRestorePageMutation,
} from "@/features/page/queries/page-query.ts";
import { getSpaceUrl } from "@/lib/config.ts";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
type DeletedPageBannerProps = {
slugId: string;
};
export function DeletedPageBanner({ slugId }: DeletedPageBannerProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: page } = usePageQuery({ pageId: slugId });
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
const deletedTimeAgo = useTimeAgo(page?.deletedAt);
const restorePageMutation = useRestorePageMutation();
const deletePageMutation = useDeletePageMutation();
const { openRestoreModal } = useRestorePageModal();
const { openDeleteModal } = useDeletePageModal();
if (!page?.deletedAt) return null;
const canRestore = spaceAbility.can(
SpaceCaslAction.Edit,
SpaceCaslSubject.Page,
);
const canPermanentlyDelete = spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
);
const actorName = page.deletedBy?.name ?? t("Someone");
const handleRestore = () => {
openRestoreModal({
title: page.title,
onConfirm: () => restorePageMutation.mutate(page.id),
});
};
const handlePermanentDelete = () => {
openDeleteModal({
isPermanent: true,
onConfirm: async () => {
await deletePageMutation.mutateAsync(page.id);
navigate(getSpaceUrl(page.space?.slug));
},
});
};
const hasAnyAction = canRestore || canPermanentlyDelete;
return (
<Paper radius="sm" mb="md" px="md" py="xs" bg="red.0">
<Group justify="space-between" wrap="wrap" gap="sm">
<Text size="sm" style={{ flex: 1, minWidth: 0 }}>
<Trans
i18nKey="<b>{{name}}</b> moved this page to Trash {{time}}."
values={{ name: actorName, time: deletedTimeAgo }}
components={{ b: <Text span fw={600} inherit /> }}
/>
</Text>
{hasAnyAction && (
<>
<Group gap="xs" wrap="nowrap" visibleFrom="sm">
{canRestore && (
<Button
size="xs"
variant="light"
color="red"
leftSection={<IconRestore size={16} />}
onClick={handleRestore}
loading={restorePageMutation.isPending}
>
{t("Restore page")}
</Button>
)}
{canPermanentlyDelete && (
<Button
size="xs"
variant="light"
color="red"
leftSection={<IconTrash size={16} />}
onClick={handlePermanentDelete}
loading={deletePageMutation.isPending}
>
{t("Permanently delete")}
</Button>
)}
</Group>
<Group gap="xs" wrap="nowrap" hiddenFrom="sm">
{canRestore && (
<Tooltip label={t("Restore page")} withArrow>
<ActionIcon
size="lg"
variant="default"
onClick={handleRestore}
loading={restorePageMutation.isPending}
aria-label={t("Restore page")}
>
<IconRestore size={18} />
</ActionIcon>
</Tooltip>
)}
{canPermanentlyDelete && (
<Tooltip label={t("Permanently delete")} withArrow>
<ActionIcon
size="lg"
variant="light"
color="red"
onClick={handlePermanentDelete}
loading={deletePageMutation.isPending}
aria-label={t("Permanently delete")}
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
)}
</Group>
</>
)}
</Group>
</Paper>
);
}
@@ -0,0 +1,21 @@
import { Alert, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
export function TrashBanner() {
const { t } = useTranslation();
const workspace = useAtomValue(workspaceAtom);
const retentionDays = workspace?.trashRetentionDays ?? 30;
return (
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red">
<Text size="sm" lh={1.35}>
{t("Pages in trash will be permanently deleted after {{count}} days.", {
count: retentionDays,
})}
</Text>
</Alert>
);
}
@@ -7,17 +7,16 @@ import {
Group,
ActionIcon,
Text,
Alert,
Stack,
Menu,
} from "@mantine/core";
import {
IconInfoCircle,
IconDots,
IconRestore,
IconTrash,
IconFileDescription,
} from "@tabler/icons-react";
import { TrashBanner } from "@/features/page/trash/components/trash-banner.tsx";
import {
useDeletedPagesQuery,
useRestorePageMutation,
@@ -31,12 +30,10 @@ import TrashPageContentModal from "@/features/page/trash/components/trash-page-c
import { UserInfo } from "@/components/common/user-info.tsx";
import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useRestorePageModal } from "@/features/page/hooks/use-restore-page-modal.tsx";
export default function Trash() {
const { t } = useTranslation();
const [workspace] = useAtom(workspaceAtom);
const { spaceSlug } = useParams();
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
@@ -45,6 +42,7 @@ export default function Trash() {
});
const restorePageMutation = useRestorePageMutation();
const deletePageMutation = useDeletePageMutation();
const { openRestoreModal } = useRestorePageModal();
const [selectedPage, setSelectedPage] = useState<{
title: string;
@@ -78,23 +76,6 @@ export default function Trash() {
});
};
const openRestoreModal = (pageId: string, pageTitle: string) => {
modals.openConfirmModal({
title: t("Restore page"),
children: (
<Text size="sm">
{t("Restore '{{title}}' and its sub-pages?", {
title: pageTitle || "Untitled",
})}
</Text>
),
centered: true,
labels: { confirm: t("Restore"), cancel: t("Cancel") },
confirmProps: { color: "blue" },
onConfirm: () => handleRestorePage(pageId),
});
};
const hasPages = deletedPages && deletedPages.items.length > 0;
const handlePageClick = (page: any) => {
@@ -109,11 +90,7 @@ export default function Trash() {
<Title order={2}>{t("Trash")}</Title>
</Group>
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red">
<Text size="sm">
{t("Pages in trash will be permanently deleted after {{count}} days.", { count: workspace?.trashRetentionDays ?? 30 })}
</Text>
</Alert>
<TrashBanner />
{isLoading || !deletedPages ? (
<></>
@@ -181,7 +158,10 @@ export default function Trash() {
<Menu.Item
leftSection={<IconRestore size={16} />}
onClick={() =>
openRestoreModal(page.id, page.title)
openRestoreModal({
title: page.title,
onConfirm: () => handleRestorePage(page.id),
})
}
>
{t("Restore")}
@@ -1,6 +1,11 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { ISharedPageTree } from "@/features/share/types/share.types";
import { SharedPageTreeNode } from "@/features/share/utils";
export const sharedPageTreeAtom = atom<ISharedPageTree | null>(null);
export const sharedTreeDataAtom = atom<SharedPageTreeNode[] | null>(null);
export const sharedTreeDataAtom = atom<SharedPageTreeNode[] | null>(null);
export const sharedPageFullWidthAtom = atomWithStorage<boolean>(
"sharedPageFullWidth",
false,
);
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActionIcon,
AppShell,
@@ -14,11 +14,16 @@ import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { ThemeToggle } from "@/components/theme-toggle.tsx";
import { useAtomValue, useSetAtom } from "jotai";
import { useAtom } from "jotai";
import { sharedPageTreeAtom, sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
import {
sharedPageFullWidthAtom,
sharedPageTreeAtom,
sharedTreeDataAtom,
} from "@/features/share/atoms/shared-page-atom";
import { buildSharedPageTree } from "@/features/share/utils";
import {
desktopSidebarAtom,
mobileSidebarAtom,
sidebarWidthAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
import { useTranslation } from "react-i18next";
@@ -27,7 +32,7 @@ import {
mobileTableOfContentAsideAtom,
tableOfContentAsideAtom,
} from "@/features/share/atoms/sidebar-atom.ts";
import { IconList } from "@tabler/icons-react";
import { IconArrowsHorizontal, IconList } from "@tabler/icons-react";
import { useToggleToc } from "@/features/share/hooks/use-toggle-toc.ts";
import classes from "./share.module.css";
import {
@@ -55,6 +60,46 @@ export default function ShareShell({
const [mobileTocOpened] = useAtom(mobileTableOfContentAsideAtom);
const toggleTocMobile = useToggleToc(mobileTableOfContentAsideAtom);
const toggleToc = useToggleToc(tableOfContentAsideAtom);
const [fullWidth, setFullWidth] = useAtom(sharedPageFullWidthAtom);
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef<HTMLElement | null>(null);
const startResizing = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
}, []);
const stopResizing = useCallback(() => {
setIsResizing(false);
}, []);
const resize = useCallback(
(e: MouseEvent) => {
if (!isResizing || !sidebarRef.current) return;
const newWidth =
e.clientX - sidebarRef.current.getBoundingClientRect().left;
if (newWidth < 220) {
setSidebarWidth(220);
return;
}
if (newWidth > 600) {
setSidebarWidth(600);
return;
}
setSidebarWidth(newWidth);
},
[isResizing, setSidebarWidth],
);
useEffect(() => {
window.addEventListener("mousemove", resize);
window.addEventListener("mouseup", stopResizing);
return () => {
window.removeEventListener("mousemove", resize);
window.removeEventListener("mouseup", stopResizing);
};
}, [resize, stopResizing]);
const { shareId } = useParams();
const { data } = useGetSharedPageTreeQuery(shareId);
@@ -81,7 +126,7 @@ export default function ShareShell({
header={{ height: 50 }}
{...(data?.pageTree?.length > 1 && {
navbar: {
width: 300,
width: sidebarWidth,
breakpoint: "sm",
collapsed: {
mobile: !mobileOpened,
@@ -166,6 +211,20 @@ export default function ShareShell({
<IconList size={20} stroke={2} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Full width")} withArrow>
<ActionIcon
variant={fullWidth ? "light" : "default"}
style={fullWidth ? undefined : { border: "none" }}
aria-label={t("Full width")}
aria-pressed={fullWidth}
onClick={() => setFullWidth((v) => !v)}
visibleFrom="sm"
size="sm"
>
<IconArrowsHorizontal size={20} stroke={2} />
</ActionIcon>
</Tooltip>
</>
<ThemeToggle />
@@ -174,7 +233,11 @@ export default function ShareShell({
</AppShell.Header>
{data?.pageTree?.length > 1 && (
<AppShell.Navbar p="md" className={classes.navbar}>
<AppShell.Navbar p="md" className={classes.navbar} ref={sidebarRef}>
<div
className={classes.resizeHandle}
onMouseDown={startResizing}
/>
<MemoizedSharedTree sharedPageTree={data} />
</AppShell.Navbar>
)}
@@ -10,6 +10,7 @@
.treeNode {
text-decoration: none;
user-select: none;
padding-bottom: 0;
}
.navbar,
@@ -18,3 +19,26 @@
width: 350px;
}
}
.resizeHandle {
width: 3px;
cursor: col-resize;
position: absolute;
right: 0;
top: 0;
bottom: 0;
z-index: 1;
&:hover,
&:active {
width: 5px;
background: light-dark(
var(--mantine-color-gray-4),
var(--mantine-color-dark-5)
);
}
@media (max-width: $mantine-breakpoint-sm) {
display: none;
}
}
@@ -6,6 +6,7 @@ import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row";
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts";
export default function PageStatePref() {
const { t } = useTranslation();
@@ -71,3 +72,24 @@ export function PageStateSegmentedControl({
/>
);
}
// Header variant: updates the current page's mode locally without persisting
// the preference to the server.
export function PageEditModeToggle({ size }: { size?: MantineSize }) {
const { t } = useTranslation();
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
currentPageEditModeAtom,
);
return (
<SegmentedControl
size={size}
value={currentPageEditMode}
onChange={(v) => setCurrentPageEditMode(v as PageEditMode)}
data={[
{ label: t("Edit"), value: PageEditMode.Edit },
{ label: t("Read"), value: PageEditMode.Read },
]}
/>
);
}
+30 -12
View File
@@ -31,20 +31,38 @@ const APP_ROUTE = {
},
};
export function safeRedirectPath(input: unknown): string | null {
if (typeof input !== "string") return null;
if (input.length === 0 || input.length > 2048) return null;
// Reject whitespace, backslash, and any Unicode "Other" category char
// (ASCII controls, zero-width space, BOM, bidi marks, etc).
if (/[\s\\]|\p{C}/u.test(input)) return null;
if (!input.startsWith("/") || input.startsWith("//")) return null;
if (input.toLowerCase().includes("://")) return null;
if (/^\/[a-z][a-z0-9+\-.]*:/i.test(input)) return null;
try {
const resolved = new URL(input, window.location.origin);
if (resolved.origin !== window.location.origin) return null;
return resolved.pathname + resolved.search + resolved.hash;
} catch {
return null;
}
}
export function getPostLoginRedirect(): string {
const params = new URLSearchParams(window.location.search);
const redirect = params.get("redirect");
if (redirect) {
try {
const resolved = new URL(redirect, window.location.origin);
if (resolved.origin === window.location.origin) {
return resolved.pathname + resolved.search + resolved.hash;
}
} catch {
// malformed URL, fall through to default
}
}
return APP_ROUTE.HOME;
return safeRedirectPath(params.get("redirect")) ?? APP_ROUTE.HOME;
}
/**
* Returns the `?redirect=` value from the current URL only when it is a safe
* same-origin path. Unlike {@link getPostLoginRedirect} this returns `null`
* (not `/home`) when no redirect is present, so callers can distinguish
* "user came here directly" from "user was bounced from a deep link".
*/
export function getRedirectParam(): string | null {
const params = new URLSearchParams(window.location.search);
return safeRedirectPath(params.get("redirect"));
}
export default APP_ROUTE;
+1 -1
View File
@@ -52,7 +52,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canEdit = page?.permissions?.canEdit ?? false;
const canEdit = !page?.deletedAt && (page?.permissions?.canEdit ?? false);
const canComment =
canEdit ||
(space?.settings?.comments?.allowViewerComments === true);
+6 -2
View File
@@ -9,7 +9,10 @@ import { extractPageSlugId } from "@/lib";
import { Error404 } from "@/components/ui/error-404.tsx";
import ShareBranding from "@/features/share/components/share-branding.tsx";
import { useAtomValue } from "jotai";
import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom.ts";
import {
sharedPageFullWidthAtom,
sharedTreeDataAtom,
} from "@/features/share/atoms/shared-page-atom.ts";
import { isPageInTree } from "@/features/share/utils.ts";
export default function SharedPage() {
@@ -23,6 +26,7 @@ export default function SharedPage() {
});
const sharedTreeData = useAtomValue(sharedTreeDataAtom);
const fullWidth = useAtomValue(sharedPageFullWidthAtom);
useEffect(() => {
if (shareId && data) {
@@ -59,7 +63,7 @@ export default function SharedPage() {
)}
</Helmet>
<Container size={900} p={0}>
<Container fluid={fullWidth} size={fullWidth ? undefined : 900} p={0}>
<ReadonlyPageEditor
key={data.page.id}
title={data.page.title}
+5 -5
View File
@@ -38,12 +38,12 @@ export default defineConfig(({ mode }) => {
build: {
rolldownOptions: {
output: {
codeSplitting: {
advancedChunks: {
groups: [
{ name: "vendor-mantine", test: /@mantine/ },
{ name: "vendor-mermaid", test: /mermaid|cytoscape|elkjs/ },
{ name: "vendor-excalidraw", test: /excalidraw/ },
{ name: "vendor-katex", test: /katex/ },
{
name: "vendor-mantine",
test: /[\\/]node_modules[\\/]@mantine[\\/]/,
},
],
},
},
+2 -2
View File
@@ -42,7 +42,7 @@
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"@keyv/redis": "^5.1.6",
"@langchain/core": "1.1.39",
"@langchain/core": "1.1.46",
"@langchain/textsplitters": "1.0.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@nest-lab/throttler-storage-redis": "^1.2.0",
@@ -81,7 +81,7 @@
"ioredis": "^5.10.1",
"js-tiktoken": "^1.0.21",
"jsonwebtoken": "^9.0.3",
"kysely": "^0.28.14",
"kysely": "^0.28.17",
"kysely-migration-cli": "^0.4.2",
"kysely-postgres-js": "^3.0.0",
"ldapts": "^8.1.7",
@@ -26,6 +26,7 @@ import {
TiptapVideo,
TiptapAudio,
TiptapPdf,
PageBreak,
TrailingNode,
Attachment,
Drawio,
@@ -94,6 +95,7 @@ export const tiptapExtensions = [
TiptapVideo,
TiptapAudio,
TiptapPdf,
PageBreak,
Callout,
Attachment,
CustomCodeBlock,
@@ -33,6 +33,8 @@ import {
HISTORY_INTERVAL,
} from '../constants';
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
import { WebhookEvent } from '@docmost/ee/webhook/constants';
@Injectable()
export class PersistenceExtension implements Extension {
@@ -47,6 +49,7 @@ export class PersistenceExtension implements Extension {
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
private readonly collabHistory: CollabHistoryService,
private readonly transclusionService: TransclusionService,
private readonly webhookDispatcher: WebhookDispatcher,
) {}
async onLoadDocument(data: onLoadDocumentPayload) {
@@ -199,6 +202,19 @@ export class PersistenceExtension implements Extension {
});
await this.enqueuePageHistory(page);
this.webhookDispatcher.dispatch(
page.workspaceId,
WebhookEvent.PageUpdated,
{
id: page.id,
slugId: page.slugId,
title: page.title,
spaceId: page.spaceId,
workspaceId: page.workspaceId,
updatedAt: page.updatedAt,
},
);
}
}
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { AppController } from '../../app.controller';
import { AppService } from '../../app.service';
import { EnvironmentModule } from '../../integrations/environment/environment.module';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { CollaborationModule } from '../collaboration.module';
import { DatabaseModule } from '@docmost/db/database.module';
import { QueueModule } from '../../integrations/queue/queue.module';
@@ -12,6 +13,8 @@ import { LoggerModule } from '../../common/logger/logger.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from '../../integrations/redis/redis-config.service';
import { CaslModule } from '../../core/casl/casl.module';
import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';
@Module({
imports: [
@@ -26,6 +29,18 @@ import { CaslModule } from '../../core/casl/casl.module';
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
CacheModule.registerAsync({
isGlobal: true,
useFactory: async (environmentService: EnvironmentService) => {
const redisUrl = environmentService.getRedisUrl();
return {
ttl: 5 * 1000,
stores: [new KeyvRedis(redisUrl)],
};
},
inject: [EnvironmentService],
}),
],
controllers: [
AppController,
+1
View File
@@ -20,6 +20,7 @@ export const Feature = {
VIEWER_COMMENTS: 'comment:viewer',
TEMPLATES: 'templates',
PDF_EXPORT: 'export:pdf',
WEBHOOKS: 'webhooks',
} as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
@@ -1,3 +1,11 @@
export const CacheKey = {
LICENSE_VALID: (workspaceId: string) => `license:valid:${workspaceId}`,
SPACE_ROLES: (userId: string, spaceId: string) =>
`perm:space-roles:${userId}:${spaceId}`,
PAGE_CAN_EDIT: (userId: string, pageId: string) =>
`perm:can-edit:${userId}:${pageId}`,
};
// Permission caches dedupe repeated checks within and across short request bursts.
// 5s keeps staleness on revocations bounded.
export const PERMISSION_CACHE_TTL_MS = 5_000;
@@ -0,0 +1,27 @@
import { Cache } from 'cache-manager';
export async function withCache<T>(
cacheManager: Cache,
key: string,
ttlMs: number,
fn: () => Promise<T>,
): Promise<T> {
try {
const cached = await cacheManager.get<{ v: T }>(key);
if (cached !== undefined && cached !== null) {
return cached.v;
}
} catch (err) {
console.warn(`[withCache] get failed for "${key}", falling back to source`, err);
}
const value = await fn();
try {
await cacheManager.set(key, { v: value }, ttlMs);
} catch (err) {
console.warn(`[withCache] set failed for "${key}"`, err);
}
return value;
}
@@ -27,6 +27,8 @@ import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq';
import { createByteCountingStream } from '../../../common/helpers/utils';
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
import { WebhookEvent } from '@docmost/ee/webhook/constants';
@Injectable()
export class AttachmentService {
@@ -39,6 +41,7 @@ export class AttachmentService {
private readonly spaceRepo: SpaceRepo,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
private readonly webhookDispatcher: WebhookDispatcher,
) {}
async uploadFile(opts: {
@@ -271,7 +274,7 @@ export class AttachmentService {
spaceId,
trx,
} = opts;
return this.attachmentRepo.insertAttachment(
const attachment = await this.attachmentRepo.insertAttachment(
{
id: attachmentId,
type: type,
@@ -287,6 +290,23 @@ export class AttachmentService {
},
trx,
);
this.webhookDispatcher.dispatch(
workspaceId,
WebhookEvent.AttachmentUploaded,
{
id: attachment.id,
fileName: attachment.fileName,
mimeType: attachment.mimeType,
fileSize: attachment.fileSize,
pageId: attachment.pageId,
spaceId: attachment.spaceId,
workspaceId: attachment.workspaceId,
creatorId: attachment.creatorId,
},
);
return attachment;
}
async handleDeleteAiChatAttachments(aiChatId: string) {
@@ -42,6 +42,7 @@ function buildWorkspaceOwnerAbility() {
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Audit);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Webhook);
return build();
}
@@ -58,6 +59,7 @@ function buildWorkspaceAdminAbility() {
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Webhook);
return build();
}
@@ -13,6 +13,7 @@ export enum WorkspaceCaslSubject {
Attachment = 'attachment',
API = 'api_key',
Audit = 'audit',
Webhook = 'webhook',
}
export type IWorkspaceAbility =
@@ -22,4 +23,5 @@ export type IWorkspaceAbility =
| [WorkspaceCaslAction, WorkspaceCaslSubject.Group]
| [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment]
| [WorkspaceCaslAction, WorkspaceCaslSubject.API]
| [WorkspaceCaslAction, WorkspaceCaslSubject.Audit];
| [WorkspaceCaslAction, WorkspaceCaslSubject.Audit]
| [WorkspaceCaslAction, WorkspaceCaslSubject.Webhook];
@@ -32,6 +32,8 @@ import {
IAuditService,
} from '../../integrations/audit/audit.service';
import { WsService } from '../../ws/ws.service';
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
import { WebhookEvent } from '@docmost/ee/webhook/constants';
@UseGuards(JwtAuthGuard)
@Controller('comments')
@@ -44,6 +46,7 @@ export class CommentController {
private readonly pageAccessService: PageAccessService,
private readonly wsService: WsService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
private readonly webhookDispatcher: WebhookDispatcher,
) {}
@HttpCode(HttpStatus.OK)
@@ -192,5 +195,16 @@ export class CommentController {
},
},
});
this.webhookDispatcher.dispatch(
comment.workspaceId,
WebhookEvent.CommentDeleted,
{
id: comment.id,
pageId: comment.pageId,
spaceId: comment.spaceId,
workspaceId: comment.workspaceId,
},
);
}
}
@@ -19,6 +19,8 @@ import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { extractUserMentionIdsFromJson } from '../../common/helpers/prosemirror/utils';
import { ICommentNotificationJob } from '../../integrations/queue/constants/queue.interface';
import { WsService } from '../../ws/ws.service';
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
import { WebhookEvent } from '@docmost/ee/webhook/constants';
@Injectable()
export class CommentService {
@@ -33,6 +35,7 @@ export class CommentService {
private generalQueue: Queue,
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
private notificationQueue: Queue,
private readonly webhookDispatcher: WebhookDispatcher,
) {}
async findById(commentId: string) {
@@ -142,6 +145,21 @@ export class CommentService {
comment,
});
this.webhookDispatcher.dispatch(
workspaceId,
WebhookEvent.CommentCreated,
{
id: comment.id,
pageId: comment.pageId,
spaceId: comment.spaceId,
workspaceId: comment.workspaceId,
type: comment.type,
content: comment.content,
creatorId: comment.creatorId,
createdAt: comment.createdAt,
},
);
return comment;
}
@@ -203,6 +221,22 @@ export class CommentService {
comment,
});
this.webhookDispatcher.dispatch(
comment.workspaceId,
WebhookEvent.CommentUpdated,
{
id: comment.id,
pageId: comment.pageId,
spaceId: comment.spaceId,
workspaceId: comment.workspaceId,
type: comment.type,
content: comment.content,
creatorId: comment.creatorId,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
},
);
return comment;
}
@@ -52,6 +52,8 @@ import {
IAuditService,
} from '../../integrations/audit/audit.service';
import { getPageTitle } from '../../common/helpers';
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
import { WebhookEvent } from '@docmost/ee/webhook/constants';
@UseGuards(JwtAuthGuard)
@Controller('pages')
@@ -65,6 +67,7 @@ export class PageController {
private readonly backlinkService: BacklinkService,
private readonly labelService: LabelService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
private readonly webhookDispatcher: WebhookDispatcher,
) {}
@HttpCode(HttpStatus.OK)
@@ -76,6 +79,7 @@ export class PageController {
includeCreator: true,
includeLastUpdatedBy: true,
includeContributors: true,
includeDeletedBy: true,
});
if (!page) {
@@ -365,6 +369,18 @@ export class PageController {
},
},
});
this.webhookDispatcher.dispatch(
workspace.id,
WebhookEvent.PageDeleted,
{
id: page.id,
slugId: page.slugId,
title: page.title,
spaceId: page.spaceId,
workspaceId: workspace.id,
},
);
}
}
@@ -405,6 +421,18 @@ export class PageController {
},
});
this.webhookDispatcher.dispatch(
workspace.id,
WebhookEvent.PageRestored,
{
id: page.id,
slugId: page.slugId,
title: page.title,
spaceId: page.spaceId,
workspaceId: workspace.id,
},
);
return this.pageRepo.findById(pageIdDto.pageId, {
includeHasChildren: true,
});
@@ -55,6 +55,8 @@ import { markdownToHtml } from '@docmost/editor-ext';
import { WatcherService } from '../../watcher/watcher.service';
import { sql } from 'kysely';
import { TransclusionService } from '../transclusion/transclusion.service';
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
import { WebhookEvent } from '@docmost/ee/webhook/constants';
@Injectable()
export class PageService {
@@ -73,6 +75,7 @@ export class PageService {
private collaborationGateway: CollaborationGateway,
private readonly watcherService: WatcherService,
private readonly transclusionService: TransclusionService,
private readonly webhookDispatcher: WebhookDispatcher,
) {}
async findById(
@@ -156,9 +159,30 @@ export class PageService {
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
);
this.webhookDispatcher.dispatch(
page.workspaceId,
WebhookEvent.PageCreated,
this.toWebhookPagePayload(page),
);
return page;
}
private toWebhookPagePayload(page: Page) {
return {
id: page.id,
slugId: page.slugId,
title: page.title,
icon: page.icon,
parentPageId: page.parentPageId,
spaceId: page.spaceId,
workspaceId: page.workspaceId,
creatorId: page.creatorId,
createdAt: page.createdAt,
updatedAt: page.updatedAt,
};
}
async nextPagePosition(spaceId: string, parentPageId?: string) {
let pagePosition: string;
@@ -245,13 +269,21 @@ export class PageService {
);
}
return await this.pageRepo.findById(page.id, {
const updatedPage = await this.pageRepo.findById(page.id, {
includeSpace: true,
includeContent: true,
includeCreator: true,
includeLastUpdatedBy: true,
includeContributors: true,
});
this.webhookDispatcher.dispatch(
updatedPage.workspaceId,
WebhookEvent.PageUpdated,
this.toWebhookPagePayload(updatedPage),
);
return updatedPage;
}
async updatePageContent(
@@ -487,6 +519,18 @@ export class PageService {
}
});
this.webhookDispatcher.dispatch(
rootPage.workspaceId,
WebhookEvent.PageMoved,
{
id: rootPage.id,
slugId: rootPage.slugId,
fromSpaceId: rootPage.spaceId,
toSpaceId: spaceId,
workspaceId: rootPage.workspaceId,
},
);
return { childPageIds };
}
@@ -1015,6 +1059,14 @@ export class PageService {
pageIds: pageIds,
workspaceId,
});
for (const id of pageIds) {
this.webhookDispatcher.dispatch(
workspaceId,
WebhookEvent.PageDeleted,
{ id, workspaceId },
);
}
}
}
@@ -29,6 +29,8 @@ import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
import { WebhookEvent } from '@docmost/ee/webhook/constants';
@Injectable()
export class SpaceService {
@@ -41,6 +43,7 @@ export class SpaceService {
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
private readonly webhookDispatcher: WebhookDispatcher,
) {}
async createSpace(
@@ -85,6 +88,17 @@ export class SpaceService {
},
});
this.webhookDispatcher.dispatch(
workspaceId,
WebhookEvent.SpaceCreated,
{
id: space.id,
name: space.name,
slug: space.slug,
workspaceId,
},
);
return { ...space, memberCount: 1 };
}
@@ -244,6 +258,17 @@ export class SpaceService {
spaceId: updateSpaceDto.spaceId,
changes: { before, after },
});
this.webhookDispatcher.dispatch(
workspaceId,
WebhookEvent.SpaceUpdated,
{
id: updatedSpace.id,
name: updatedSpace.name,
slug: updatedSpace.slug,
workspaceId,
},
);
}
return updatedSpace;
@@ -289,5 +314,15 @@ export class SpaceService {
},
},
});
this.webhookDispatcher.dispatch(
workspaceId,
WebhookEvent.SpaceDeleted,
{
id: spaceId,
name: space.name,
workspaceId,
},
);
}
}
@@ -40,6 +40,8 @@ import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
import { WebhookEvent } from '@docmost/ee/webhook/constants';
@Injectable()
export class WorkspaceInvitationService {
@@ -55,6 +57,7 @@ export class WorkspaceInvitationService {
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
private readonly environmentService: EnvironmentService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
private readonly webhookDispatcher: WebhookDispatcher,
) {}
async getInvitations(workspaceId: string, pagination: PaginationOptions) {
@@ -304,6 +307,18 @@ export class WorkspaceInvitationService {
return;
}
this.webhookDispatcher.dispatch(
workspace.id,
WebhookEvent.UserCreated,
{
id: newUser.id,
name: newUser.name,
email: newUser.email,
role: newUser.role,
workspaceId: workspace.id,
},
);
// notify the inviter
const invitedByUser = await this.userRepo.findById(
invitation.invitedById,
@@ -48,6 +48,8 @@ import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
import { WebhookDispatcher } from '@docmost/ee/webhook/services/webhook-dispatcher.service';
import { WebhookEvent } from '@docmost/ee/webhook/constants';
@Injectable()
export class WorkspaceService {
@@ -72,6 +74,7 @@ export class WorkspaceService {
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
private userSessionRepo: UserSessionRepo,
private readonly webhookDispatcher: WebhookDispatcher,
) {}
async findById(workspaceId: string) {
@@ -736,6 +739,17 @@ export class WorkspaceService {
},
},
});
this.webhookDispatcher.dispatch(
workspaceId,
WebhookEvent.UserDeactivated,
{
id: user.id,
name: user.name,
email: user.email,
workspaceId,
},
);
}
async activateUser(

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