From 03a70d768a3838196e2328327d336d2ab3cfaf7e Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Wed, 18 Feb 2026 14:48:15 +0100 Subject: [PATCH 01/58] fix: allow deleting last character in headings (#1954) The copy-link decoration widget (contentEditable="false") injected inside headings prevented browsers from deleting the last remaining character via Backspace or Delete keys. Only show the widget when the heading has more than one character of content. --- packages/editor-ext/src/lib/heading/heading.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ext/src/lib/heading/heading.ts b/packages/editor-ext/src/lib/heading/heading.ts index 52c463e9..26f6f0d4 100644 --- a/packages/editor-ext/src/lib/heading/heading.ts +++ b/packages/editor-ext/src/lib/heading/heading.ts @@ -20,7 +20,7 @@ export const Heading = TiptapHeading.extend({ const { doc } = state; doc.descendants((node, pos) => { - if (node.type.name === "heading" && node.content.size > 0) { + if (node.type.name === "heading" && node.content.size > 1) { const deco = Decoration.widget( pos + node.nodeSize - 1, () => { From 873c9630439c04bbdca4fb07dbf6a8b0781bee4a Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:34:07 +0000 Subject: [PATCH 02/58] fix db types duplication --- .../server/src/database/types/db.interface.ts | 49 +------------------ 1 file changed, 2 insertions(+), 47 deletions(-) diff --git a/apps/server/src/database/types/db.interface.ts b/apps/server/src/database/types/db.interface.ts index 58146b9e..be66fd8c 100644 --- a/apps/server/src/database/types/db.interface.ts +++ b/apps/server/src/database/types/db.interface.ts @@ -1,51 +1,6 @@ -import { - ApiKeys, - Attachments, - AuthAccounts, - AuthProviders, - Backlinks, - Billing, - Comments, - FileTasks, - Groups, - GroupUsers, - Notifications, - PageHistory, - Pages, - Shares, - SpaceMembers, - Spaces, - UserMfa, - Users, - UserTokens, - Watchers, - WorkspaceInvitations, - Workspaces, -} from '@docmost/db/types/db'; +import { DB } from '@docmost/db/types/db'; import { PageEmbeddings } from '@docmost/db/types/embeddings.types'; -export interface DbInterface { - attachments: Attachments; - authAccounts: AuthAccounts; - authProviders: AuthProviders; - backlinks: Backlinks; - billing: Billing; - comments: Comments; - fileTasks: FileTasks; - groups: Groups; - groupUsers: GroupUsers; - notifications: Notifications; +export interface DbInterface extends DB { pageEmbeddings: PageEmbeddings; - pageHistory: PageHistory; - pages: Pages; - shares: Shares; - spaceMembers: SpaceMembers; - spaces: Spaces; - userMfa: UserMfa; - users: Users; - userTokens: UserTokens; - watchers: Watchers; - workspaceInvitations: WorkspaceInvitations; - workspaces: Workspaces; - apiKeys: ApiKeys; } From d6472f08765918b62fe2b373e7998101f5244678 Mon Sep 17 00:00:00 2001 From: b4sh2 <58754382+b4sh2@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:59:44 +0100 Subject: [PATCH 03/58] Merge commit from fork Co-authored-by: b4sh2 --- apps/server/src/core/attachment/attachment.utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/core/attachment/attachment.utils.ts b/apps/server/src/core/attachment/attachment.utils.ts index 8f24e765..0bddee1a 100644 --- a/apps/server/src/core/attachment/attachment.utils.ts +++ b/apps/server/src/core/attachment/attachment.utils.ts @@ -2,6 +2,7 @@ import { MultipartFile } from '@fastify/multipart'; import * as path from 'path'; import { AttachmentType } from './attachment.constants'; import { sanitizeFileName } from '../../common/helpers'; +import { getMimeType } from '../../common/helpers/file.helper'; export interface PreparedFile { buffer?: Buffer; @@ -40,7 +41,7 @@ export async function prepareFile( fileName, fileSize, fileExtension, - mimeType: file.mimetype, + mimeType: getMimeType(file.originalname), multiPartFile: file, }; } catch (error) { From 53132acb0a9109fd34147be4c1947c9d7cb7580d Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 21 Feb 2026 00:02:23 +0000 Subject: [PATCH 04/58] fix: redirect to original page after re-authentication (#1959) * fix: redirect to original page after re-authentication When a session expires, the current URL is now preserved as a query parameter on the login page. After successful login (including MFA flows), the user is redirected back to their original page instead of always landing on /home. * secure --------- Co-authored-by: Julien Fontanet --- .../src/ee/components/ldap-login-modal.tsx | 8 ++++---- .../src/ee/mfa/components/mfa-challenge.tsx | 4 ++-- .../src/ee/mfa/components/mfa-setup-required.tsx | 4 ++-- .../src/ee/mfa/hooks/use-mfa-page-protection.ts | 12 +++++++----- apps/client/src/features/auth/hooks/use-auth.ts | 8 ++++---- .../auth/hooks/use-redirect-if-authenticated.ts | 4 ++-- apps/client/src/lib/api-client.ts | 6 +++++- apps/client/src/lib/app-route.ts | 16 ++++++++++++++++ 8 files changed, 42 insertions(+), 20 deletions(-) diff --git a/apps/client/src/ee/components/ldap-login-modal.tsx b/apps/client/src/ee/components/ldap-login-modal.tsx index 9360651d..0a456946 100644 --- a/apps/client/src/ee/components/ldap-login-modal.tsx +++ b/apps/client/src/ee/components/ldap-login-modal.tsx @@ -7,7 +7,7 @@ import { notifications } from "@mantine/notifications"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { IAuthProvider } from "@/ee/security/types/security.types"; -import APP_ROUTE from "@/lib/app-route"; +import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route"; import { ldapLogin } from "@/ee/security/services/ldap-auth-service"; const formSchema = z.object({ @@ -59,13 +59,13 @@ export function LdapLoginModal({ // Handle MFA like the regular login if (response?.userHasMfa) { onClose(); - navigate(APP_ROUTE.AUTH.MFA_CHALLENGE); + navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search); } else if (response?.requiresMfaSetup) { onClose(); - navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED); + navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search); } else { onClose(); - navigate(APP_ROUTE.HOME); + navigate(getPostLoginRedirect()); } } catch (err: any) { setIsLoading(false); diff --git a/apps/client/src/ee/mfa/components/mfa-challenge.tsx b/apps/client/src/ee/mfa/components/mfa-challenge.tsx index 8a9bef53..413494ef 100644 --- a/apps/client/src/ee/mfa/components/mfa-challenge.tsx +++ b/apps/client/src/ee/mfa/components/mfa-challenge.tsx @@ -18,7 +18,7 @@ import { useNavigate } from "react-router-dom"; import { notifications } from "@mantine/notifications"; import classes from "./mfa-challenge.module.css"; import { verifyMfa } from "@/ee/mfa"; -import APP_ROUTE from "@/lib/app-route"; +import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route"; import { useTranslation } from "react-i18next"; import * as z from "zod"; import { MfaBackupCodeInput } from "./mfa-backup-code-input"; @@ -53,7 +53,7 @@ export function MfaChallenge() { setIsLoading(true); try { await verifyMfa(values.code); - navigate(APP_ROUTE.HOME); + navigate(getPostLoginRedirect()); } catch (error: any) { setIsLoading(false); notifications.show({ diff --git a/apps/client/src/ee/mfa/components/mfa-setup-required.tsx b/apps/client/src/ee/mfa/components/mfa-setup-required.tsx index c657abe9..ab327c4d 100644 --- a/apps/client/src/ee/mfa/components/mfa-setup-required.tsx +++ b/apps/client/src/ee/mfa/components/mfa-setup-required.tsx @@ -3,7 +3,7 @@ import { Container, Paper, Title, Text, Alert, Stack } from "@mantine/core"; import { IconAlertCircle } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { MfaSetupModal } from "@/ee/mfa"; -import APP_ROUTE from "@/lib/app-route.ts"; +import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts"; import { useNavigate } from "react-router-dom"; export default function MfaSetupRequired() { @@ -11,7 +11,7 @@ export default function MfaSetupRequired() { const navigate = useNavigate(); const handleSetupComplete = () => { - navigate(APP_ROUTE.HOME); + navigate(getPostLoginRedirect()); }; return ( diff --git a/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts b/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts index 9200cac7..30b27427 100644 --- a/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts +++ b/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; -import APP_ROUTE from "@/lib/app-route"; +import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route"; import { validateMfaAccess } from "@/ee/mfa"; export function useMfaPageProtection() { @@ -13,8 +13,10 @@ export function useMfaPageProtection() { const checkAccess = async () => { const result = await validateMfaAccess(); + const search = location.search; + if (!result.valid) { - navigate(APP_ROUTE.AUTH.LOGIN); + navigate(APP_ROUTE.AUTH.LOGIN + search); return; } @@ -26,17 +28,17 @@ export function useMfaPageProtection() { if (result.requiresMfaSetup && !isOnSetupPage) { // User needs to set up MFA but is on challenge page - navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED); + navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + search); } else if ( !result.requiresMfaSetup && result.userHasMfa && !isOnChallengePage ) { // User has MFA and should be on challenge page - navigate(APP_ROUTE.AUTH.MFA_CHALLENGE); + navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + search); } else if (!result.isTransferToken) { // User has a regular auth token, shouldn't be on MFA pages - navigate(APP_ROUTE.HOME); + navigate(getPostLoginRedirect()); } else { setIsValid(true); } diff --git a/apps/client/src/features/auth/hooks/use-auth.ts b/apps/client/src/features/auth/hooks/use-auth.ts index decb393f..6e1b4e34 100644 --- a/apps/client/src/features/auth/hooks/use-auth.ts +++ b/apps/client/src/features/auth/hooks/use-auth.ts @@ -23,7 +23,7 @@ import { acceptInvitation, createWorkspace, } from "@/features/workspace/services/workspace-service.ts"; -import APP_ROUTE from "@/lib/app-route.ts"; +import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts"; import { RESET } from "jotai/utils"; import { useTranslation } from "react-i18next"; import { isCloud } from "@/lib/config.ts"; @@ -44,11 +44,11 @@ export default function useAuth() { // Check if MFA is required if (response?.userHasMfa) { - navigate(APP_ROUTE.AUTH.MFA_CHALLENGE); + navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search); } else if (response?.requiresMfaSetup) { - navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED); + navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search); } else { - navigate(APP_ROUTE.HOME); + navigate(getPostLoginRedirect()); } } catch (err) { setIsLoading(false); diff --git a/apps/client/src/features/auth/hooks/use-redirect-if-authenticated.ts b/apps/client/src/features/auth/hooks/use-redirect-if-authenticated.ts index 8961ea93..10c76bd3 100644 --- a/apps/client/src/features/auth/hooks/use-redirect-if-authenticated.ts +++ b/apps/client/src/features/auth/hooks/use-redirect-if-authenticated.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; import useCurrentUser from "@/features/user/hooks/use-current-user.ts"; -import APP_ROUTE from "@/lib/app-route.ts"; +import { getPostLoginRedirect } from "@/lib/app-route.ts"; import { useNavigate } from "react-router-dom"; export function useRedirectIfAuthenticated() { @@ -9,7 +9,7 @@ export function useRedirectIfAuthenticated() { useEffect(() => { if (data && data?.user) { - navigate(APP_ROUTE.HOME); + navigate(getPostLoginRedirect()); } }, [isLoading, data]); } diff --git a/apps/client/src/lib/api-client.ts b/apps/client/src/lib/api-client.ts index d4f61b05..632811db 100644 --- a/apps/client/src/lib/api-client.ts +++ b/apps/client/src/lib/api-client.ts @@ -68,10 +68,14 @@ function redirectToLogin() { APP_ROUTE.AUTH.SIGNUP, APP_ROUTE.AUTH.FORGOT_PASSWORD, APP_ROUTE.AUTH.PASSWORD_RESET, + APP_ROUTE.AUTH.MFA_CHALLENGE, + APP_ROUTE.AUTH.MFA_SETUP_REQUIRED, "/invites", ]; if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) { - window.location.href = APP_ROUTE.AUTH.LOGIN; + const redirectTo = window.location.pathname; + const params = new URLSearchParams({ redirect: redirectTo }); + window.location.href = `${APP_ROUTE.AUTH.LOGIN}?${params.toString()}`; } } diff --git a/apps/client/src/lib/app-route.ts b/apps/client/src/lib/app-route.ts index 0151c856..c4a13093 100644 --- a/apps/client/src/lib/app-route.ts +++ b/apps/client/src/lib/app-route.ts @@ -29,4 +29,20 @@ const APP_ROUTE = { }, }; +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; +} + export default APP_ROUTE; From c172d3bd5e8cbed77d50f8c9b6c3354aa0851ea1 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 21 Feb 2026 00:43:49 +0000 Subject: [PATCH 05/58] fix --- apps/server/src/core/attachment/attachment.utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/core/attachment/attachment.utils.ts b/apps/server/src/core/attachment/attachment.utils.ts index 0bddee1a..88edb2af 100644 --- a/apps/server/src/core/attachment/attachment.utils.ts +++ b/apps/server/src/core/attachment/attachment.utils.ts @@ -2,7 +2,7 @@ import { MultipartFile } from '@fastify/multipart'; import * as path from 'path'; import { AttachmentType } from './attachment.constants'; import { sanitizeFileName } from '../../common/helpers'; -import { getMimeType } from '../../common/helpers/file.helper'; +import { getMimeType } from '../../common/helpers'; export interface PreparedFile { buffer?: Buffer; @@ -41,7 +41,7 @@ export async function prepareFile( fileName, fileSize, fileExtension, - mimeType: getMimeType(file.originalname), + mimeType: getMimeType(file.filename), multiPartFile: file, }; } catch (error) { From ef87210b3d6c6989cabec98216c266dcb631ea85 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:22:37 +0000 Subject: [PATCH 06/58] feat: editor UI refresh and enhancements (#1968) * feat: new image menu * switch to resizable side handles * use pixels * refactor excalidraw and drawio menu * support image resize undo * video resize * callout menu refresh * refresh table menus * fix color scheme * fix: patch @tiptap/core ResizableNodeView to prevent resize sticking after mouseup * feat: columns * notes callout * focus on first column * capture tab key in column * fix print * hide columns menu when some nodes are focused * fix print * fix columns * selective placeholder * fix blockquote * quote * fix callout in columns --- .../public/locales/en-US/translation.json | 10 + .../src/components/icons/icon-columns-4.tsx | 27 ++ .../src/components/icons/icon-columns-5.tsx | 28 ++ .../components/callout/callout-menu.tsx | 55 ++- .../components/callout/callout-view.tsx | 8 +- .../components/callout/callout.module.css | 20 +- .../components/columns/columns-menu.tsx | 267 ++++++++++++++ .../components/common/node-resize-handles.ts | 35 ++ .../components/common/node-resize.module.css | 65 ++++ .../components/common/toolbar-menu.module.css | 29 ++ .../editor/components/drawio/drawio-menu.tsx | 290 +++++++++++++-- .../editor/components/drawio/drawio-view.tsx | 106 ++---- .../components/excalidraw/excalidraw-menu.tsx | 343 +++++++++++++++--- .../components/excalidraw/excalidraw-view.tsx | 106 ++---- .../editor/components/image/image-menu.tsx | 108 +++++- .../components/image/image-resize-handles.ts | 7 + .../components/image/image-resize.module.css | 64 ++++ .../components/slash-menu/menu-items.ts | 56 +++ .../table/table-background-color.tsx | 2 +- .../components/table/table-cell-menu.tsx | 21 +- .../editor/components/table/table-menu.tsx | 33 +- .../components/table/table-text-alignment.tsx | 2 +- .../editor/components/video/video-menu.tsx | 83 +++-- .../features/editor/extensions/extensions.ts | 66 +++- .../src/features/editor/page-editor.tsx | 2 + .../src/features/editor/styles/columns.css | 116 ++++++ .../src/features/editor/styles/core.css | 25 +- .../src/features/editor/styles/index.css | 1 + apps/client/src/main.tsx | 6 +- .../src/collaboration/collaboration.util.ts | 4 + package.json | 3 +- packages/editor-ext/src/index.ts | 1 + packages/editor-ext/src/lib/callout/utils.ts | 19 +- packages/editor-ext/src/lib/columns/column.ts | 127 +++++++ .../editor-ext/src/lib/columns/columns.ts | 196 ++++++++++ packages/editor-ext/src/lib/columns/index.ts | 4 + packages/editor-ext/src/lib/drawio.ts | 223 +++++++++++- packages/editor-ext/src/lib/excalidraw.ts | 224 +++++++++++- packages/editor-ext/src/lib/image/image.ts | 241 +++++++++++- packages/editor-ext/src/lib/video/video.ts | 208 ++++++++++- patches/@tiptap__core.patch | 105 ++++++ pnpm-lock.yaml | 279 +++++++------- 42 files changed, 3082 insertions(+), 533 deletions(-) create mode 100644 apps/client/src/components/icons/icon-columns-4.tsx create mode 100644 apps/client/src/components/icons/icon-columns-5.tsx create mode 100644 apps/client/src/features/editor/components/columns/columns-menu.tsx create mode 100644 apps/client/src/features/editor/components/common/node-resize-handles.ts create mode 100644 apps/client/src/features/editor/components/common/node-resize.module.css create mode 100644 apps/client/src/features/editor/components/common/toolbar-menu.module.css create mode 100644 apps/client/src/features/editor/components/image/image-resize-handles.ts create mode 100644 apps/client/src/features/editor/components/image/image-resize.module.css create mode 100644 apps/client/src/features/editor/styles/columns.css create mode 100644 packages/editor-ext/src/lib/columns/column.ts create mode 100644 packages/editor-ext/src/lib/columns/columns.ts create mode 100644 packages/editor-ext/src/lib/columns/index.ts create mode 100644 patches/@tiptap__core.patch diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index e46dd2c8..25848e5d 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -274,6 +274,7 @@ "Add row below": "Add row below", "Delete table": "Delete table", "Info": "Info", + "Note": "Note", "Success": "Success", "Warning": "Warning", "Danger": "Danger", @@ -363,6 +364,15 @@ "Heading {{level}}": "Heading {{level}}", "Toggle title": "Toggle title", "Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands", + "Write...": "Write...", + "Column count": "Column count", + "{{count}} Columns": "{{count}} Columns", + "Equal columns": "Equal columns", + "Left sidebar": "Left sidebar", + "Right sidebar": "Right sidebar", + "Wide center": "Wide center", + "Left wide": "Left wide", + "Right wide": "Right wide", "Names do not match": "Names do not match", "Today, {{time}}": "Today, {{time}}", "Yesterday, {{time}}": "Yesterday, {{time}}", diff --git a/apps/client/src/components/icons/icon-columns-4.tsx b/apps/client/src/components/icons/icon-columns-4.tsx new file mode 100644 index 00000000..d2b4541b --- /dev/null +++ b/apps/client/src/components/icons/icon-columns-4.tsx @@ -0,0 +1,27 @@ +import { rem } from "@mantine/core"; + +type Props = { + size?: number | string; + stroke?: number; +}; + +export function IconColumns4({ size = 24, stroke = 2 }: Props) { + return ( + + + + + + + ); +} diff --git a/apps/client/src/components/icons/icon-columns-5.tsx b/apps/client/src/components/icons/icon-columns-5.tsx new file mode 100644 index 00000000..afa4773c --- /dev/null +++ b/apps/client/src/components/icons/icon-columns-5.tsx @@ -0,0 +1,28 @@ +import { rem } from "@mantine/core"; + +type Props = { + size?: number | string; + stroke?: number; +}; + +export function IconColumns5({ size = 24, stroke = 2 }: Props) { + return ( + + + + + + + + ); +} diff --git a/apps/client/src/features/editor/components/callout/callout-menu.tsx b/apps/client/src/features/editor/components/callout/callout-menu.tsx index e7ee2138..bdc71993 100644 --- a/apps/client/src/features/editor/components/callout/callout-menu.tsx +++ b/apps/client/src/features/editor/components/callout/callout-menu.tsx @@ -7,16 +7,19 @@ import { ShouldShowProps, } from "@/features/editor/components/table/types/types.ts"; import { ActionIcon, Tooltip } from "@mantine/core"; +import clsx from "clsx"; import { IconAlertTriangleFilled, IconCircleCheckFilled, IconCircleXFilled, IconInfoCircleFilled, IconMoodSmile, + IconNotes, } from "@tabler/icons-react"; -import { CalloutType } from "@docmost/editor-ext"; +import { CalloutType, isTextSelected } from "@docmost/editor-ext"; import { useTranslation } from "react-i18next"; import EmojiPicker from "@/components/ui/emoji-picker.tsx"; +import classes from "../common/toolbar-menu.module.css"; export function CalloutMenu({ editor }: EditorMenuProps) { const { t } = useTranslation(); @@ -26,6 +29,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) { if (!state) { return false; } + if (isTextSelected(editor)) return false; return editor.isActive("callout"); }, @@ -42,6 +46,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) { return { isCallout: ctx.editor.isActive("callout"), isInfo: ctx.editor.isActive("callout", { type: "info" }), + isNote: ctx.editor.isActive("callout", { type: "note" }), isSuccess: ctx.editor.isActive("callout", { type: "success" }), isWarning: ctx.editor.isActive("callout", { type: "warning" }), isDanger: ctx.editor.isActive("callout", { type: "danger" }), @@ -126,15 +131,31 @@ export function CalloutMenu({ editor }: EditorMenuProps) { }} shouldShow={shouldShow} > - +
setCalloutType("info")} size="lg" aria-label={t("Info")} - variant={editorState?.isInfo ? "light" : "default"} + variant="subtle" + className={clsx({ [classes.active]: editorState?.isInfo })} > - + + + + + + setCalloutType("note")} + size="lg" + aria-label={t("Note")} + variant="subtle" + className={clsx({ [classes.active]: editorState?.isNote })} + > + @@ -143,9 +164,13 @@ export function CalloutMenu({ editor }: EditorMenuProps) { onClick={() => setCalloutType("success")} size="lg" aria-label={t("Success")} - variant={editorState?.isSuccess ? "light" : "default"} + variant="subtle" + className={clsx({ [classes.active]: editorState?.isSuccess })} > - + @@ -154,9 +179,13 @@ export function CalloutMenu({ editor }: EditorMenuProps) { onClick={() => setCalloutType("warning")} size="lg" aria-label={t("Warning")} - variant={editorState?.isWarning ? "light" : "default"} + variant="subtle" + className={clsx({ [classes.active]: editorState?.isWarning })} > - + @@ -165,9 +194,10 @@ export function CalloutMenu({ editor }: EditorMenuProps) { onClick={() => setCalloutType("danger")} size="lg" aria-label={t("Danger")} - variant={editorState?.isDanger ? "light" : "default"} + variant="subtle" + className={clsx({ [classes.active]: editorState?.isDanger })} > - + @@ -178,11 +208,10 @@ export function CalloutMenu({ editor }: EditorMenuProps) { icon={currentIcon || } actionIconProps={{ size: "lg", - variant: "default", - c: undefined, + variant: "subtle", }} /> - +
); } diff --git a/apps/client/src/features/editor/components/callout/callout-view.tsx b/apps/client/src/features/editor/components/callout/callout-view.tsx index 5583bd87..3cf5bb57 100644 --- a/apps/client/src/features/editor/components/callout/callout-view.tsx +++ b/apps/client/src/features/editor/components/callout/callout-view.tsx @@ -4,6 +4,7 @@ import { IconCircleCheckFilled, IconCircleXFilled, IconInfoCircleFilled, + IconNotes, } from "@tabler/icons-react"; import { Alert } from "@mantine/core"; import classes from "./callout.module.css"; @@ -22,6 +23,7 @@ export default function CalloutView(props: NodeViewProps) { icon={getCalloutIcon(type, icon)} p="xs" classNames={{ + root: classes.root, message: classes.message, icon: classes.icon, }} @@ -34,12 +36,14 @@ export default function CalloutView(props: NodeViewProps) { function getCalloutIcon(type: CalloutType, customIcon?: string) { if (customIcon && customIcon.trim() !== "") { - return {customIcon}; + return {customIcon}; } switch (type) { case "info": return ; + case "note": + return ; case "success": return ; case "warning": @@ -55,6 +59,8 @@ function getCalloutColor(type: CalloutType) { switch (type) { case "info": return "blue"; + case "note": + return "grape"; case "success": return "green"; case "warning": diff --git a/apps/client/src/features/editor/components/callout/callout.module.css b/apps/client/src/features/editor/components/callout/callout.module.css index 2839b426..8289f1a7 100644 --- a/apps/client/src/features/editor/components/callout/callout.module.css +++ b/apps/client/src/features/editor/components/callout/callout.module.css @@ -1,9 +1,13 @@ +.root { + overflow: visible; +} + .icon { font-size: 24px; line-height: 1; width: 20px; height: 20px; - margin-inline-end: var(--mantine-spacing-md); + margin-inline-end: var(--mantine-spacing-xs); margin-top: 4px; cursor: pointer; } @@ -11,18 +15,8 @@ .message { font-size: var(--mantine-font-size-md); color: var(--mantine-color-default-color); - - white-space: nowrap; + overflow: visible; + text-overflow: unset; word-break: break-word; overflow-wrap: break-word; } - -/* - @mixin where-light { - color: var(--mantine-color-default-color); - } - - @mixin where-dark { - color: var(--mantine-color-default-color); - } -*/ diff --git a/apps/client/src/features/editor/components/columns/columns-menu.tsx b/apps/client/src/features/editor/components/columns/columns-menu.tsx new file mode 100644 index 00000000..5c9e7607 --- /dev/null +++ b/apps/client/src/features/editor/components/columns/columns-menu.tsx @@ -0,0 +1,267 @@ +import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; +import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; +import React, { useCallback, useState } from "react"; +import { Node as PMNode } from "prosemirror-model"; +import { + EditorMenuProps, + ShouldShowProps, +} from "@/features/editor/components/table/types/types.ts"; +import { ActionIcon, Tooltip, Popover, Button } from "@mantine/core"; +import clsx from "clsx"; +import { + IconChevronDown, + IconCheck, + IconColumns2, + IconColumns3, + IconLayoutSidebar, + IconLayoutSidebarRight, + IconLayoutAlignCenter, +} from "@tabler/icons-react"; +import { isTextSelected } from "@docmost/editor-ext"; +import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext"; +import { useTranslation } from "react-i18next"; +import classes from "../common/toolbar-menu.module.css"; + +type LayoutPreset = { + layout: ColumnsLayout; + label: string; + icon: React.ElementType; +}; + +const twoColumnPresets: LayoutPreset[] = [ + { layout: "two_equal", label: "Equal columns", icon: IconColumns2 }, + { + layout: "two_left_sidebar", + label: "Left sidebar", + icon: IconLayoutSidebar, + }, + { + layout: "two_right_sidebar", + label: "Right sidebar", + icon: IconLayoutSidebarRight, + }, +]; + +const threeColumnPresets: LayoutPreset[] = [ + { layout: "three_equal", label: "Equal columns", icon: IconColumns3 }, + { + layout: "three_with_sidebars", + label: "Wide center", + icon: IconLayoutAlignCenter, + }, + { + layout: "three_left_wide", + label: "Left wide", + icon: IconLayoutSidebarRight, + }, + { layout: "three_right_wide", label: "Right wide", icon: IconLayoutSidebar + }, +]; + +function getPresetsForCount(count: number): LayoutPreset[] { + if (count === 2) return twoColumnPresets; + if (count === 3) return threeColumnPresets; + return []; +} + +export function ColumnsMenu({ editor }: EditorMenuProps) { + const { t } = useTranslation(); + const [isCountOpen, setIsCountOpen] = useState(false); + + const nodesWithMenus = [ + "callout", + "image", + "video", + "drawio", + "excalidraw", + "table", + ]; + + const shouldShow = useCallback( + ({ state }: ShouldShowProps) => { + if (!state) return false; + if (!editor.isActive("columns")) return false; + if (isTextSelected(editor)) return false; + if (nodesWithMenus.some((name) => editor.isActive(name))) return false; + + const parent = findParentNode( + (node: PMNode) => node.type.name === "columns", + )(state.selection); + if (!parent) return false; + + const dom = editor.view.nodeDOM(parent.pos) as HTMLElement; + if (!dom) return false; + + const rect = dom.getBoundingClientRect(); + return rect.bottom > 0 && rect.top < window.innerHeight; + }, + [editor], + ); + + const editorState = useEditorState({ + editor, + selector: (ctx) => { + if (!ctx.editor) return null; + + const { selection } = ctx.editor.state; + const parent = findParentNode( + (node: PMNode) => node.type.name === "columns", + )(selection); + + return { + columnCount: parent?.node.childCount || 2, + layout: (parent?.node.attrs.layout as ColumnsLayout) || "two_equal", + isNormal: ctx.editor.isActive("columns", { widthMode: "normal" }), + isWide: ctx.editor.isActive("columns", { widthMode: "wide" }), + }; + }, + }); + + const getReferencedVirtualElement = useCallback(() => { + if (!editor) return; + const { selection } = editor.state; + const predicate = (node: PMNode) => node.type.name === "columns"; + const parent = findParentNode(predicate)(selection); + + if (parent) { + const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; + const domRect = dom.getBoundingClientRect(); + + // Columns entirely out of viewport — return real rect so menu goes off-screen + if (domRect.bottom <= 0 || domRect.top >= window.innerHeight) { + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + } + + // Clamp bottom so menu stays within viewport when columns extend below it + // 55px = 15px offset + ~40px menu height + const maxBottom = window.innerHeight - 55; + if (domRect.bottom > maxBottom) { + const clamped = new DOMRect( + domRect.x, + domRect.y, + domRect.width, + maxBottom - domRect.y, + ); + return { + getBoundingClientRect: () => clamped, + getClientRects: () => [clamped], + }; + } + + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + } + + const domRect = posToDOMRect(editor.view, selection.from, selection.to); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + }, [editor]); + + const setColumnCount = useCallback( + (count: number) => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setColumnCount(count) + .run(); + setIsCountOpen(false); + }, + [editor], + ); + + const setLayout = useCallback( + (layout: ColumnsLayout) => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setColumnsLayout(layout) + .run(); + }, + [editor], + ); + + const columnCount = editorState?.columnCount || 2; + const currentLayout = editorState?.layout || "two_equal"; + const presets = getPresetsForCount(columnCount); + + return ( + +
+ + + + + + + {[2, 3, 4, 5].map((n) => ( + + ))} + + + + + {presets.length > 0 &&
} + + {presets.map((preset) => ( + + setLayout(preset.layout)} + size="lg" + aria-label={t(preset.label)} + variant="subtle" + className={clsx({ + [classes.active]: currentLayout === preset.layout, + })} + > + + + + ))} +
+ + ); +} + +export default ColumnsMenu; diff --git a/apps/client/src/features/editor/components/common/node-resize-handles.ts b/apps/client/src/features/editor/components/common/node-resize-handles.ts new file mode 100644 index 00000000..0785845d --- /dev/null +++ b/apps/client/src/features/editor/components/common/node-resize-handles.ts @@ -0,0 +1,35 @@ +import type { ResizableNodeViewDirection } from "@tiptap/core"; +import classes from "./node-resize.module.css"; + +export function createResizeHandle( + direction: ResizableNodeViewDirection, +): HTMLElement { + const handle = document.createElement("div"); + handle.dataset.resizeHandle = direction; + handle.style.position = "absolute"; + handle.className = classes.handle; + + if (direction === "left") { + handle.style.left = "-8px"; + handle.style.top = "0"; + handle.style.bottom = "0"; + } else if (direction === "right") { + handle.style.right = "-8px"; + handle.style.top = "0"; + handle.style.bottom = "0"; + } + + const bar = document.createElement("div"); + bar.className = classes.handleBar; + handle.appendChild(bar); + + return handle; +} + +export function buildResizeClasses(nodeClass: string) { + return { + container: `${classes.container} ${nodeClass}`, + wrapper: classes.wrapper, + resizing: classes.resizing, + }; +} diff --git a/apps/client/src/features/editor/components/common/node-resize.module.css b/apps/client/src/features/editor/components/common/node-resize.module.css new file mode 100644 index 00000000..7010e324 --- /dev/null +++ b/apps/client/src/features/editor/components/common/node-resize.module.css @@ -0,0 +1,65 @@ +.container { + display: flex; +} + +.wrapper { + position: relative; + border-radius: 8px; + overflow: visible; + max-width: 100%; +} + +.wrapper img, +.wrapper video { + height: auto !important; +} + +.resizing { + user-select: none; +} + +.handle { + position: absolute; + top: 0; + bottom: 0; + width: 16px; + display: flex; + align-items: center; + justify-content: center; + cursor: ew-resize; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 2; +} + +.handle[data-resize-handle="left"] { + left: -8px; +} + +.handle[data-resize-handle="right"] { + right: -8px; +} + +.wrapper:hover .handle { + opacity: 1; +} + +.resizing .handle { + opacity: 1; +} + +.handleBar { + width: 4px; + height: 48px; + border-radius: 4px; + transition: background-color 0.15s ease; + background-color: light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-5)); +} + +.handle:hover .handleBar { + background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4)); +} + +.resizing .handleBar { + background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4)); +} diff --git a/apps/client/src/features/editor/components/common/toolbar-menu.module.css b/apps/client/src/features/editor/components/common/toolbar-menu.module.css new file mode 100644 index 00000000..7fd91f56 --- /dev/null +++ b/apps/client/src/features/editor/components/common/toolbar-menu.module.css @@ -0,0 +1,29 @@ +.toolbar { + display: flex; + align-items: center; + gap: 2px; + padding: 3px; + border-radius: 8px; + border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); + background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6)); + box-shadow: 0 2px 12px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.35)); +} + +.toolbar :global(.mantine-ActionIcon-root) { + --ai-color: light-dark(var(--mantine-color-dark-7), var(--mantine-color-gray-4)) !important; + --ai-hover: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5)) !important; +} + +.toolbar .active { + --ai-color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3)) !important; + --ai-hover: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5)) !important; + background-color: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5)); +} + +.divider { + width: 1px; + height: 16px; + align-self: center; + margin: 0 2px; + background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-3)); +} diff --git a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx index 937b8e7d..547ea375 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx @@ -1,24 +1,41 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; -import { useCallback } from "react"; +import { useCallback, useRef, useState } from "react"; import { Node as PMNode } from "prosemirror-model"; import { EditorMenuProps, ShouldShowProps, } from "@/features/editor/components/table/types/types.ts"; -import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx"; +import { ActionIcon, Modal, Tooltip, useComputedColorScheme } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import clsx from "clsx"; +import { + IconLayoutAlignCenter, + IconLayoutAlignLeft, + IconLayoutAlignRight, + IconDownload, + IconEdit, + IconTrash, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { getDrawioUrl, getFileUrl } from "@/lib/config.ts"; +import { uploadFile } from "@/features/page/services/page-service.ts"; +import { + DrawIoEmbed, + DrawIoEmbedRef, + EventExit, + EventSave, +} from "react-drawio"; +import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils"; +import { IAttachment } from "@/features/attachments/types/attachment.types"; +import classes from "../common/toolbar-menu.module.css"; export function DrawioMenu({ editor }: EditorMenuProps) { - const shouldShow = useCallback( - ({ state }: ShouldShowProps) => { - if (!state) { - return false; - } - - return editor.isActive("drawio") && editor.getAttributes("drawio")?.src; - }, - [editor], - ); + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const [initialXML, setInitialXML] = useState(""); + const drawioRef = useRef(null); + const computedColorScheme = useComputedColorScheme(); const editorState = useEditorState({ editor, @@ -30,11 +47,26 @@ export function DrawioMenu({ editor }: EditorMenuProps) { const drawioAttr = ctx.editor.getAttributes("drawio"); return { isDrawio: ctx.editor.isActive("drawio"), - width: drawioAttr?.width ? parseInt(drawioAttr.width) : null, + isAlignLeft: ctx.editor.isActive("drawio", { align: "left" }), + isAlignCenter: ctx.editor.isActive("drawio", { align: "center" }), + isAlignRight: ctx.editor.isActive("drawio", { align: "right" }), + src: drawioAttr?.src || null, + attachmentId: drawioAttr?.attachmentId || null, }; }, }); + const shouldShow = useCallback( + ({ state }: ShouldShowProps) => { + if (!state) { + return false; + } + + return editor.isActive("drawio") && editor.getAttributes("drawio")?.src; + }, + [editor], + ); + const getReferencedVirtualElement = useCallback(() => { if (!editor) return; const { selection } = editor.state; @@ -57,38 +89,218 @@ export function DrawioMenu({ editor }: EditorMenuProps) { }; }, [editor]); - const onWidthChange = useCallback( - (value: number) => { - editor.commands.updateAttributes("drawio", { width: `${value}%` }); + const alignLeft = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setDrawioAlign("left") + .run(); + }, [editor]); + + const alignCenter = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setDrawioAlign("center") + .run(); + }, [editor]); + + const alignRight = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setDrawioAlign("right") + .run(); + }, [editor]); + + const handleDownload = useCallback(() => { + if (!editorState?.src) return; + const url = getFileUrl(editorState.src); + const a = document.createElement("a"); + a.href = url; + a.download = ""; + a.click(); + }, [editorState?.src]); + + const handleDelete = useCallback(() => { + editor.commands.deleteSelection(); + }, [editor]); + + const handleOpen = useCallback(async () => { + if (!editorState?.src) return; + + try { + const url = getFileUrl(editorState.src); + const request = await fetch(url, { + credentials: "include", + cache: "no-store", + }); + const blob = await request.blob(); + + const reader = new FileReader(); + reader.readAsDataURL(blob); + reader.onloadend = () => { + const base64data = (reader.result || "") as string; + setInitialXML(base64data); + }; + } catch (err) { + console.error(err); + } finally { + open(); + } + }, [editorState?.src, open]); + + const handleSave = useCallback( + async (data: EventSave) => { + const svgString = decodeBase64ToSvgString(data.xml); + const fileName = "diagram.drawio.svg"; + const drawioSVGFile = await svgStringToFile(svgString, fileName); + + // @ts-ignore + const pageId = editor.storage?.pageId; + const attachmentId = editorState?.attachmentId; + + let attachment: IAttachment = null; + if (attachmentId) { + attachment = await uploadFile(drawioSVGFile, pageId, attachmentId); + } else { + attachment = await uploadFile(drawioSVGFile, pageId); + } + + editor.commands.updateAttributes("drawio", { + src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, + title: attachment.fileName, + size: attachment.fileSize, + attachmentId: attachment.id, + }); + + close(); }, - [editor], + [editor, editorState?.attachmentId, close], ); return ( - -
+ - {editorState?.width && ( - - )} -
-
+
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ { + if (data.parentEvent !== "save") { + return; + } + handleSave(data); + }} + onClose={(data: EventExit) => { + if (data.parentEvent) { + return; + } + close(); + }} + /> +
+
+
+
+ ); } diff --git a/apps/client/src/features/editor/components/drawio/drawio-view.tsx b/apps/client/src/features/editor/components/drawio/drawio-view.tsx index b51e8936..0b1580ec 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-view.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-view.tsx @@ -2,7 +2,6 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { ActionIcon, Card, - Image, Modal, Text, useComputedColorScheme, @@ -10,7 +9,7 @@ import { import { useRef, useState } from "react"; import { uploadFile } from "@/features/page/services/page-service.ts"; import { useDisclosure } from "@mantine/hooks"; -import { getDrawioUrl, getFileUrl } from "@/lib/config.ts"; +import { getDrawioUrl } from "@/lib/config.ts"; import { DrawIoEmbed, DrawIoEmbedRef, @@ -26,7 +25,7 @@ import { useTranslation } from "react-i18next"; export default function DrawioView(props: NodeViewProps) { const { t } = useTranslation(); const { node, updateAttributes, editor, selected } = props; - const { src, title, width, attachmentId } = node.attrs; + const { attachmentId } = node.attrs; const drawioRef = useRef(null); const [initialXML, setInitialXML] = useState(""); const [opened, { open, close }] = useDisclosure(false); @@ -36,33 +35,11 @@ export default function DrawioView(props: NodeViewProps) { if (!editor.isEditable) { return; } - - try { - if (src) { - const url = getFileUrl(src); - const request = await fetch(url, { - credentials: "include", - cache: "no-store", - }); - const blob = await request.blob(); - - const reader = new FileReader(); - reader.readAsDataURL(blob); - reader.onloadend = () => { - const base64data = (reader.result || "") as string; - setInitialXML(base64data); - }; - } - } catch (err) { - console.error(err); - } finally { - open(); - } + open(); }; const handleSave = async (data: EventSave) => { const svgString = decodeBase64ToSvgString(data.xml); - const fileName = "diagram.drawio.svg"; const drawioSVGFile = await svgStringToFile(svgString, fileName); @@ -70,7 +47,6 @@ export default function DrawioView(props: NodeViewProps) { const pageId = editor.storage?.pageId; let attachment: IAttachment = null; - if (attachmentId) { attachment = await uploadFile(drawioSVGFile, pageId, attachmentId); } else { @@ -106,14 +82,12 @@ export default function DrawioView(props: NodeViewProps) { noSaveBtn: true, }} onSave={(data: EventSave) => { - // If the save is triggered by another event, then do nothing if (data.parentEvent !== "save") { return; } handleSave(data); }} onClose={(data: EventExit) => { - // If the exit is triggered by another event, then do nothing if (data.parentEvent) { return; } @@ -125,62 +99,28 @@ export default function DrawioView(props: NodeViewProps) { - {src ? ( -
- e.detail === 2 && handleOpen()} - radius="md" - fit="contain" - w={width} - src={getFileUrl(src)} - alt={title} - className={clsx( - selected ? "ProseMirror-selectednode" : "", - "alignCenter", - )} - /> + e.detail === 2 && handleOpen()} + p="xs" + style={{ + display: "flex", + justifyContent: "center", + alignItems: "center", + }} + withBorder + className={clsx(selected ? "ProseMirror-selectednode" : "")} + > +
+ + + - {selected && editor.isEditable && ( - - - - )} + + {t("Double-click to edit Draw.io diagram")} +
- ) : ( - e.detail === 2 && handleOpen()} - p="xs" - style={{ - display: "flex", - justifyContent: "center", - alignItems: "center", - }} - withBorder - className={clsx(selected ? "ProseMirror-selectednode" : "")} - > -
- - - - - - {t("Double-click to edit Draw.io diagram")} - -
-
- )} +
); } diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx index 06e79515..766a357c 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx @@ -1,26 +1,57 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; -import { useCallback } from "react"; +import { lazy, Suspense, useCallback, useState } from "react"; import { Node as PMNode } from "prosemirror-model"; import { EditorMenuProps, ShouldShowProps, } from "@/features/editor/components/table/types/types.ts"; -import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx"; +import { + ActionIcon, + Button, + Group, + Tooltip, + useComputedColorScheme, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import clsx from "clsx"; +import { + IconLayoutAlignCenter, + IconLayoutAlignLeft, + IconLayoutAlignRight, + IconDownload, + IconEdit, + IconTrash, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { getFileUrl } from "@/lib/config.ts"; +import { uploadFile } from "@/features/page/services/page-service.ts"; +import { svgStringToFile } from "@/lib"; +import "@excalidraw/excalidraw/index.css"; +import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; +import { IAttachment } from "@/features/attachments/types/attachment.types"; +import ReactClearModal from "react-clear-modal"; +import { useHandleLibrary } from "@excalidraw/excalidraw"; +import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts"; +import classes from "../common/toolbar-menu.module.css"; + +const ExcalidrawComponent = lazy(() => + import("@excalidraw/excalidraw").then((module) => ({ + default: module.Excalidraw, + })), +); export function ExcalidrawMenu({ editor }: EditorMenuProps) { - const shouldShow = useCallback( - ({ state }: ShouldShowProps) => { - if (!state) { - return false; - } - - return ( - editor.isActive("excalidraw") && editor.getAttributes("excalidraw")?.src - ); - }, - [editor], - ); + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const [excalidrawAPI, setExcalidrawAPI] = + useState(null); + useHandleLibrary({ + excalidrawAPI, + adapter: localStorageLibraryAdapter, + }); + const [excalidrawData, setExcalidrawData] = useState(null); + const computedColorScheme = useComputedColorScheme(); const editorState = useEditorState({ editor, @@ -32,11 +63,29 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) { const excalidrawAttr = ctx.editor.getAttributes("excalidraw"); return { isExcalidraw: ctx.editor.isActive("excalidraw"), - width: excalidrawAttr?.width ? parseInt(excalidrawAttr.width) : null, + isAlignLeft: ctx.editor.isActive("excalidraw", { align: "left" }), + isAlignCenter: ctx.editor.isActive("excalidraw", { align: "center" }), + isAlignRight: ctx.editor.isActive("excalidraw", { align: "right" }), + src: excalidrawAttr?.src || null, + attachmentId: excalidrawAttr?.attachmentId || null, }; }, }); + const shouldShow = useCallback( + ({ state }: ShouldShowProps) => { + if (!state) { + return false; + } + + return ( + editor.isActive("excalidraw") && + editor.getAttributes("excalidraw")?.src + ); + }, + [editor], + ); + const getReferencedVirtualElement = useCallback(() => { if (!editor) return; const { selection } = editor.state; @@ -59,38 +108,248 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) { }; }, [editor]); - const onWidthChange = useCallback( - (value: number) => { - editor.commands.updateAttributes("excalidraw", { width: `${value}%` }); - }, - [editor], - ); + const alignLeft = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setExcalidrawAlign("left") + .run(); + }, [editor]); + + const alignCenter = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setExcalidrawAlign("center") + .run(); + }, [editor]); + + const alignRight = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setExcalidrawAlign("right") + .run(); + }, [editor]); + + const handleDownload = useCallback(() => { + if (!editorState?.src) return; + const url = getFileUrl(editorState.src); + const a = document.createElement("a"); + a.href = url; + a.download = ""; + a.click(); + }, [editorState?.src]); + + const handleDelete = useCallback(() => { + editor.commands.deleteSelection(); + }, [editor]); + + const handleOpen = useCallback(async () => { + if (!editorState?.src) return; + + try { + const url = getFileUrl(editorState.src); + const request = await fetch(url, { + credentials: "include", + cache: "no-store", + }); + + const { loadFromBlob } = await import("@excalidraw/excalidraw"); + const data = await loadFromBlob(await request.blob(), null, null); + setExcalidrawData(data); + } catch (err) { + console.error(err); + } finally { + open(); + } + }, [editorState?.src, open]); + + const handleSave = useCallback(async () => { + if (!excalidrawAPI) { + return; + } + + const { exportToSvg } = await import("@excalidraw/excalidraw"); + + const svg = await exportToSvg({ + elements: excalidrawAPI?.getSceneElements(), + appState: { + exportEmbedScene: true, + exportWithDarkMode: false, + }, + files: excalidrawAPI?.getFiles(), + }); + + const serializer = new XMLSerializer(); + let svgString = serializer.serializeToString(svg); + + svgString = svgString.replace( + /https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g, + "https://unpkg.com/@excalidraw/excalidraw@latest", + ); + + const fileName = "diagram.excalidraw.svg"; + const excalidrawSvgFile = await svgStringToFile(svgString, fileName); + + // @ts-ignore + const pageId = editor.storage?.pageId; + const attachmentId = editorState?.attachmentId; + + let attachment: IAttachment = null; + if (attachmentId) { + attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId); + } else { + attachment = await uploadFile(excalidrawSvgFile, pageId); + } + + editor.commands.updateAttributes("excalidraw", { + src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, + title: attachment.fileName, + size: attachment.fileSize, + attachmentId: attachment.id, + }); + + close(); + }, [editor, excalidrawAPI, editorState?.attachmentId, close]); return ( - -
+ +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+ + + - {editorState?.width && ( - - )} -
-
+ + + + +
+ + setExcalidrawAPI(api)} + initialData={{ + ...excalidrawData, + scrollToContent: true, + }} + theme={computedColorScheme} + /> + +
+ + ); } diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx index 86c9665e..51ff5b06 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx @@ -4,28 +4,24 @@ import { Button, Card, Group, - Image, Text, useComputedColorScheme, } from "@mantine/core"; -import { useState } from "react"; +import { lazy, Suspense, useState } from "react"; import { uploadFile } from "@/features/page/services/page-service.ts"; import { svgStringToFile } from "@/lib"; import { useDisclosure } from "@mantine/hooks"; -import { getFileUrl } from "@/lib/config.ts"; import "@excalidraw/excalidraw/index.css"; import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; import { IAttachment } from "@/features/attachments/types/attachment.types"; import ReactClearModal from "react-clear-modal"; import clsx from "clsx"; import { IconEdit } from "@tabler/icons-react"; -import { lazy } from "react"; -import { Suspense } from "react"; import { useTranslation } from "react-i18next"; import { useHandleLibrary } from "@excalidraw/excalidraw"; import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts"; -const Excalidraw = lazy(() => +const ExcalidrawComponent = lazy(() => import("@excalidraw/excalidraw").then((module) => ({ default: module.Excalidraw, })), @@ -34,7 +30,7 @@ const Excalidraw = lazy(() => export default function ExcalidrawView(props: NodeViewProps) { const { t } = useTranslation(); const { node, updateAttributes, editor, selected } = props; - const { src, title, width, attachmentId } = node.attrs; + const { attachmentId } = node.attrs; const [excalidrawAPI, setExcalidrawAPI] = useState(null); @@ -50,25 +46,7 @@ export default function ExcalidrawView(props: NodeViewProps) { if (!editor.isEditable) { return; } - - try { - if (src) { - const url = getFileUrl(src); - const request = await fetch(url, { - credentials: "include", - cache: "no-store", - }); - - const { loadFromBlob } = await import("@excalidraw/excalidraw"); - - const data = await loadFromBlob(await request.blob(), null, null); - setExcalidrawData(data); - } - } catch (err) { - console.error(err); - } finally { - open(); - } + open(); }; const handleSave = async () => { @@ -151,7 +129,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
- setExcalidrawAPI(api)} initialData={{ ...excalidrawData, @@ -163,62 +141,28 @@ export default function ExcalidrawView(props: NodeViewProps) {
- {src ? ( -
- e.detail === 2 && handleOpen()} - radius="md" - fit="contain" - w={width} - src={getFileUrl(src)} - alt={title} - className={clsx( - selected ? "ProseMirror-selectednode" : "", - "alignCenter", - )} - /> + e.detail === 2 && handleOpen()} + p="xs" + style={{ + display: "flex", + justifyContent: "center", + alignItems: "center", + }} + withBorder + className={clsx(selected ? "ProseMirror-selectednode" : "")} + > +
+ + + - {selected && editor.isEditable && ( - - - - )} + + {t("Double-click to edit Excalidraw diagram")} +
- ) : ( - e.detail === 2 && handleOpen()} - p="xs" - style={{ - display: "flex", - justifyContent: "center", - alignItems: "center", - }} - withBorder - className={clsx(selected ? "ProseMirror-selectednode" : "")} - > -
- - - - - - {t("Double-click to edit Excalidraw diagram")} - -
-
- )} +
); } diff --git a/apps/client/src/features/editor/components/image/image-menu.tsx b/apps/client/src/features/editor/components/image/image-menu.tsx index a1699f93..abef7603 100644 --- a/apps/client/src/features/editor/components/image/image-menu.tsx +++ b/apps/client/src/features/editor/components/image/image-menu.tsx @@ -1,22 +1,29 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; -import React, { useCallback } from "react"; +import React, { useCallback, useRef } from "react"; import { Node as PMNode } from "prosemirror-model"; import { EditorMenuProps, ShouldShowProps, } from "@/features/editor/components/table/types/types.ts"; import { ActionIcon, Tooltip } from "@mantine/core"; +import clsx from "clsx"; import { IconLayoutAlignCenter, IconLayoutAlignLeft, IconLayoutAlignRight, + IconDownload, + IconRefresh, + IconTrash, } from "@tabler/icons-react"; -import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx"; import { useTranslation } from "react-i18next"; +import { getFileUrl } from "@/lib/config.ts"; +import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; +import classes from "../common/toolbar-menu.module.css"; export function ImageMenu({ editor }: EditorMenuProps) { const { t } = useTranslation(); + const fileInputRef = useRef(null); const editorState = useEditorState({ editor, @@ -32,7 +39,7 @@ export function ImageMenu({ editor }: EditorMenuProps) { isAlignLeft: ctx.editor.isActive("image", { align: "left" }), isAlignCenter: ctx.editor.isActive("image", { align: "center" }), isAlignRight: ctx.editor.isActive("image", { align: "right" }), - width: imageAttrs?.width ? parseInt(imageAttrs.width) : null, + src: imageAttrs?.src || null, }; }, }); @@ -94,17 +101,40 @@ export function ImageMenu({ editor }: EditorMenuProps) { .run(); }, [editor]); - const onWidthChange = useCallback( - (value: number) => { - editor - .chain() - .focus(undefined, { scrollIntoView: false }) - .setImageWidth(value) - .run(); + const handleDownload = useCallback(() => { + if (!editorState?.src) return; + const url = getFileUrl(editorState.src); + const a = document.createElement("a"); + a.href = url; + a.download = ""; + a.click(); + }, [editorState?.src]); + + const handleReplace = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // @ts-ignore + const pageId = editor.storage?.pageId; + if (pageId) { + const pos = editor.state.selection.from; + uploadImageAction(file, editor, pos, pageId); + } + // Reset so the same file can be selected again + e.target.value = ""; }, [editor], ); + const handleDelete = useCallback(() => { + editor.commands.deleteSelection(); + }, [editor]); + return ( - +
@@ -135,7 +166,8 @@ export function ImageMenu({ editor }: EditorMenuProps) { onClick={alignImageCenter} size="lg" aria-label={t("Align center")} - variant={editorState?.isAlignCenter ? "light" : "default"} + variant="subtle" + className={clsx({ [classes.active]: editorState?.isAlignCenter })} > @@ -146,16 +178,56 @@ export function ImageMenu({ editor }: EditorMenuProps) { onClick={alignImageRight} size="lg" aria-label={t("Align right")} - variant={editorState?.isAlignRight ? "light" : "default"} + variant="subtle" + className={clsx({ [classes.active]: editorState?.isAlignRight })} > - - {editorState?.width && ( - - )} +
+ + + + + + + + + + + + + + + + + + +
+ + ); } diff --git a/apps/client/src/features/editor/components/image/image-resize-handles.ts b/apps/client/src/features/editor/components/image/image-resize-handles.ts new file mode 100644 index 00000000..ec941497 --- /dev/null +++ b/apps/client/src/features/editor/components/image/image-resize-handles.ts @@ -0,0 +1,7 @@ +import { + createResizeHandle, + buildResizeClasses, +} from "../common/node-resize-handles"; + +export const createImageHandle = createResizeHandle; +export const imageResizeClasses = buildResizeClasses("node-image"); diff --git a/apps/client/src/features/editor/components/image/image-resize.module.css b/apps/client/src/features/editor/components/image/image-resize.module.css new file mode 100644 index 00000000..24414171 --- /dev/null +++ b/apps/client/src/features/editor/components/image/image-resize.module.css @@ -0,0 +1,64 @@ +.container { + display: flex; +} + +.wrapper { + position: relative; + border-radius: 8px; + overflow: visible; + max-width: 100%; +} + +.wrapper img { + height: auto !important; +} + +.resizing { + user-select: none; +} + +.handle { + position: absolute; + top: 0; + bottom: 0; + width: 16px; + display: flex; + align-items: center; + justify-content: center; + cursor: ew-resize; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 2; +} + +.handle[data-resize-handle="left"] { + left: -8px; +} + +.handle[data-resize-handle="right"] { + right: -8px; +} + +.wrapper:hover .handle { + opacity: 1; +} + +.resizing .handle { + opacity: 1; +} + +.handleBar { + width: 4px; + height: 48px; + border-radius: 4px; + transition: background-color 0.15s ease; + background-color: light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-5)); +} + +.handle:hover .handleBar { + background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4)); +} + +.resizing .handleBar { + background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4)); +} diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 27793f62..03ba7b80 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -20,6 +20,8 @@ import { IconCalendar, IconAppWindow, IconSitemap, + IconColumns3, + IconColumns2, } from "@tabler/icons-react"; import { CommandProps, @@ -31,6 +33,8 @@ import { uploadAttachmentAction } from "@/features/editor/components/attachment/ import IconExcalidraw from "@/components/icons/icon-excalidraw"; import IconMermaid from "@/components/icons/icon-mermaid"; import IconDrawio from "@/components/icons/icon-drawio"; +import { IconColumns4 } from "@/components/icons/icon-columns-4"; +import { IconColumns5 } from "@/components/icons/icon-columns-5"; import { AirtableIcon, FigmaIcon, @@ -390,6 +394,58 @@ const CommandGroups: SlashMenuGroupedItemsType = { editor.chain().focus().deleteRange(range).insertSubpages().run(); }, }, + { + title: "2 Columns", + description: "Split content into two columns.", + searchTerms: ["columns", "layout", "split", "side"], + icon: IconColumns2, + command: ({ editor, range }: CommandProps) => + editor + .chain() + .focus() + .deleteRange(range) + .insertColumns({ layout: "two_equal" }) + .run(), + }, + { + title: "3 Columns", + description: "Split content into three columns.", + searchTerms: ["columns", "layout", "split", "triple"], + icon: IconColumns3, + command: ({ editor, range }: CommandProps) => + editor + .chain() + .focus() + .deleteRange(range) + .insertColumns({ layout: "three_equal" }) + .run(), + }, + { + title: "4 Columns", + description: "Split content into four columns.", + searchTerms: ["columns", "layout", "split"], + icon: IconColumns4, + command: ({ editor, range }: CommandProps) => + editor + .chain() + .focus() + .deleteRange(range) + .insertColumns({ layout: "four_equal" }) + .run(), + }, + { + title: "5 Columns", + description: "Split content into five columns.", + searchTerms: ["columns", "layout", "split"], + icon: IconColumns5, + command: ({ editor, range }: CommandProps) => + editor + .chain() + .focus() + .deleteRange(range) + .insertColumns({ layout: "five_equal" }) + .run(), + }, { title: "Iframe embed", description: "Embed any Iframe", diff --git a/apps/client/src/features/editor/components/table/table-background-color.tsx b/apps/client/src/features/editor/components/table/table-background-color.tsx index 7508d4fe..3e4ce616 100644 --- a/apps/client/src/features/editor/components/table/table-background-color.tsx +++ b/apps/client/src/features/editor/components/table/table-background-color.tsx @@ -95,7 +95,7 @@ export const TableBackgroundColor: FC = ({ setOpened(!opened)} diff --git a/apps/client/src/features/editor/components/table/table-cell-menu.tsx b/apps/client/src/features/editor/components/table/table-cell-menu.tsx index 8af896b3..34ac6156 100644 --- a/apps/client/src/features/editor/components/table/table-cell-menu.tsx +++ b/apps/client/src/features/editor/components/table/table-cell-menu.tsx @@ -16,6 +16,7 @@ import { useTranslation } from "react-i18next"; import { TableBackgroundColor } from "./table-background-color"; import { TableTextAlignment } from "./table-text-alignment"; import { BubbleMenu } from "@tiptap/react/menus"; +import classes from "../common/toolbar-menu.module.css"; export const TableCellMenu = React.memo( ({ editor, appendTo }: EditorMenuProps): JSX.Element => { @@ -69,14 +70,16 @@ export const TableCellMenu = React.memo( }} shouldShow={shouldShow} > - +
+
+ @@ -87,7 +90,7 @@ export const TableCellMenu = React.memo( @@ -95,10 +98,12 @@ export const TableCellMenu = React.memo( +
+ @@ -109,7 +114,7 @@ export const TableCellMenu = React.memo( @@ -117,17 +122,19 @@ export const TableCellMenu = React.memo( +
+ - +
); } diff --git a/apps/client/src/features/editor/components/table/table-menu.tsx b/apps/client/src/features/editor/components/table/table-menu.tsx index e54a06af..66fe4d7b 100644 --- a/apps/client/src/features/editor/components/table/table-menu.tsx +++ b/apps/client/src/features/editor/components/table/table-menu.tsx @@ -18,8 +18,9 @@ import { IconTrashX, } from "@tabler/icons-react"; import { BubbleMenu } from "@tiptap/react/menus"; -import { isCellSelection } from "@docmost/editor-ext"; +import { isCellSelection, isTextSelected } from "@docmost/editor-ext"; import { useTranslation } from "react-i18next"; +import classes from "../common/toolbar-menu.module.css"; export const TableMenu = React.memo( ({ editor }: EditorMenuProps): JSX.Element => { @@ -30,6 +31,7 @@ export const TableMenu = React.memo( return false; } + if (isTextSelected(editor)) return false; return editor.isActive("table") && !isCellSelection(state.selection); }, [editor] @@ -118,11 +120,11 @@ export const TableMenu = React.memo( }} shouldShow={shouldShow} > - +
@@ -133,7 +135,7 @@ export const TableMenu = React.memo( @@ -144,7 +146,7 @@ export const TableMenu = React.memo( @@ -152,10 +154,12 @@ export const TableMenu = React.memo( +
+ @@ -166,7 +170,7 @@ export const TableMenu = React.memo( @@ -177,7 +181,7 @@ export const TableMenu = React.memo( @@ -185,10 +189,12 @@ export const TableMenu = React.memo( +
+ @@ -199,7 +205,7 @@ export const TableMenu = React.memo( @@ -207,18 +213,19 @@ export const TableMenu = React.memo( +
+ - +
); } diff --git a/apps/client/src/features/editor/components/table/table-text-alignment.tsx b/apps/client/src/features/editor/components/table/table-text-alignment.tsx index 8b966290..4d4646cf 100644 --- a/apps/client/src/features/editor/components/table/table-text-alignment.tsx +++ b/apps/client/src/features/editor/components/table/table-text-alignment.tsx @@ -88,7 +88,7 @@ export const TableTextAlignment: FC = ({ editor }) => { setOpened(!opened)} diff --git a/apps/client/src/features/editor/components/video/video-menu.tsx b/apps/client/src/features/editor/components/video/video-menu.tsx index dfece398..398f758f 100644 --- a/apps/client/src/features/editor/components/video/video-menu.tsx +++ b/apps/client/src/features/editor/components/video/video-menu.tsx @@ -1,19 +1,23 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; -import React, { useCallback } from "react"; +import { useCallback } from "react"; import { Node as PMNode } from "prosemirror-model"; import { EditorMenuProps, ShouldShowProps, } from "@/features/editor/components/table/types/types.ts"; import { ActionIcon, Tooltip } from "@mantine/core"; +import clsx from "clsx"; import { IconLayoutAlignCenter, IconLayoutAlignLeft, IconLayoutAlignRight, + IconDownload, + IconTrash, } from "@tabler/icons-react"; -import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx"; import { useTranslation } from "react-i18next"; +import { getFileUrl } from "@/lib/config.ts"; +import classes from "../common/toolbar-menu.module.css"; export function VideoMenu({ editor }: EditorMenuProps) { const { t } = useTranslation(); @@ -32,7 +36,7 @@ export function VideoMenu({ editor }: EditorMenuProps) { isAlignLeft: ctx.editor.isActive("video", { align: "left" }), isAlignCenter: ctx.editor.isActive("video", { align: "center" }), isAlignRight: ctx.editor.isActive("video", { align: "right" }), - width: videoAttrs?.width ? parseInt(videoAttrs.width) : null, + src: videoAttrs?.src || null, }; }, }); @@ -70,7 +74,7 @@ export function VideoMenu({ editor }: EditorMenuProps) { }; }, [editor]); - const alignVideoLeft = useCallback(() => { + const alignLeft = useCallback(() => { editor .chain() .focus(undefined, { scrollIntoView: false }) @@ -78,7 +82,7 @@ export function VideoMenu({ editor }: EditorMenuProps) { .run(); }, [editor]); - const alignVideoCenter = useCallback(() => { + const alignCenter = useCallback(() => { editor .chain() .focus(undefined, { scrollIntoView: false }) @@ -86,7 +90,7 @@ export function VideoMenu({ editor }: EditorMenuProps) { .run(); }, [editor]); - const alignVideoRight = useCallback(() => { + const alignRight = useCallback(() => { editor .chain() .focus(undefined, { scrollIntoView: false }) @@ -94,16 +98,18 @@ export function VideoMenu({ editor }: EditorMenuProps) { .run(); }, [editor]); - const onWidthChange = useCallback( - (value: number) => { - editor - .chain() - .focus(undefined, { scrollIntoView: false }) - .setVideoWidth(value) - .run(); - }, - [editor], - ); + const handleDownload = useCallback(() => { + if (!editorState?.src) return; + const url = getFileUrl(editorState.src); + const a = document.createElement("a"); + a.href = url; + a.download = ""; + a.click(); + }, [editorState?.src]); + + const handleDelete = useCallback(() => { + editor.commands.deleteSelection(); + }, [editor]); return ( - +
@@ -132,10 +139,11 @@ export function VideoMenu({ editor }: EditorMenuProps) { @@ -143,19 +151,40 @@ export function VideoMenu({ editor }: EditorMenuProps) { - - {editorState?.width && ( - - )} +
+ + + + + + + + + + + + +
); } diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index ef03108b..687e76f9 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -43,6 +43,8 @@ import { Highlight, UniqueID, SharedStorage, + Columns, + Column, } from "@docmost/editor-ext"; import { randomElement, @@ -52,6 +54,14 @@ import { IUser } from "@/features/user/types/user.types.ts"; import MathInlineView from "@/features/editor/components/math/math-inline.tsx"; import MathBlockView from "@/features/editor/components/math/math-block.tsx"; import ImageView from "@/features/editor/components/image/image-view.tsx"; +import { + createImageHandle, + imageResizeClasses, +} from "@/features/editor/components/image/image-resize-handles.ts"; +import { + createResizeHandle, + buildResizeClasses, +} from "@/features/editor/components/common/node-resize-handles.ts"; import CalloutView from "@/features/editor/components/callout/callout-view.tsx"; import VideoView from "@/features/editor/components/video/video-view.tsx"; import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx"; @@ -91,6 +101,7 @@ lowlight.register("fortran", fortran); lowlight.register("haskell", haskell); lowlight.register("scala", scala); +// @ts-ignore export const mainExtensions = [ StarterKit.configure({ heading: false, @@ -115,7 +126,7 @@ export const mainExtensions = [ filterTransaction: (transaction) => !isChangeOrigin(transaction), }), Placeholder.configure({ - placeholder: ({ node }) => { + placeholder: ({ editor, node, pos }) => { if (node.type.name === "heading") { return i18n.t("Heading {{level}}", { level: node.attrs.level }); } @@ -123,6 +134,17 @@ export const mainExtensions = [ return i18n.t("Toggle title"); } if (node.type.name === "paragraph") { + const $pos = editor.state.doc.resolve(pos); + const parentName = $pos.parent.type.name; + if ( + parentName === "column" || + parentName === "tableCell" || + parentName === "tableHeader" || + parentName === "callout" || + parentName === "blockquote" + ) { + return i18n.t("Write..."); + } return i18n.t('Write anything. Enter "/" for commands'); } }, @@ -200,9 +222,29 @@ export const mainExtensions = [ TiptapImage.configure({ view: ImageView, allowBase64: false, + resize: { + enabled: true, + directions: ["left", "right"], + minWidth: 80, + minHeight: 40, + alwaysPreserveAspectRatio: true, + //@ts-ignore + createCustomHandle: createImageHandle, + className: imageResizeClasses, + }, }), TiptapVideo.configure({ view: VideoView, + resize: { + enabled: true, + directions: ["left", "right"], + minWidth: 80, + minHeight: 40, + alwaysPreserveAspectRatio: true, + //@ts-ignore + createCustomHandle: createResizeHandle, + className: buildResizeClasses("node-video"), + }, }), Callout.configure({ view: CalloutView, @@ -221,9 +263,29 @@ export const mainExtensions = [ }), Drawio.configure({ view: DrawioView, + resize: { + enabled: true, + directions: ["left", "right"], + minWidth: 80, + minHeight: 40, + alwaysPreserveAspectRatio: true, + //@ts-ignore + createCustomHandle: createResizeHandle, + className: buildResizeClasses("node-drawio"), + }, }), Excalidraw.configure({ view: ExcalidrawView, + resize: { + enabled: true, + directions: ["left", "right"], + minWidth: 80, + minHeight: 40, + alwaysPreserveAspectRatio: true, + //@ts-ignore + createCustomHandle: createResizeHandle, + className: buildResizeClasses("node-excalidraw"), + }, }), Embed.configure({ view: EmbedView, @@ -253,6 +315,8 @@ export const mainExtensions = [ }; }, }).configure(), + Columns, + Column, ] as any; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index ed7ccecd..d0d1de03 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -67,6 +67,7 @@ import { jwtDecode } from "jwt-decode"; import { searchSpotlight } from "@/features/search/constants.ts"; import { useEditorScroll } from "./hooks/use-editor-scroll"; import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu"; +import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx"; interface PageEditorProps { pageId: string; @@ -416,6 +417,7 @@ export default function PageEditor({ +
)} diff --git a/apps/client/src/features/editor/styles/columns.css b/apps/client/src/features/editor/styles/columns.css new file mode 100644 index 00000000..fac034f6 --- /dev/null +++ b/apps/client/src/features/editor/styles/columns.css @@ -0,0 +1,116 @@ +div[data-type="columns"] { + display: flex; + margin: 0.75rem 0; + padding: 0.5em; +} + +div[data-type="columns"] > div[data-type="column"] { + flex: 1; + min-width: 0; +} + +div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] { + border-left: 1px solid transparent; + padding-left: 1rem; + transition: border 0.3s; +} + +div[data-type="columns"]:hover + > div[data-type="column"] + + div[data-type="column"] { + border-left: 1px solid + light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-7)); +} + +/* Confluence layout types */ +div[data-type="columns"][data-layout="two_left_sidebar"] + > div[data-type="column"]:first-child { + flex: 1; +} + +div[data-type="columns"][data-layout="two_left_sidebar"] + > div[data-type="column"]:last-child { + flex: 2; +} + +div[data-type="columns"][data-layout="two_right_sidebar"] + > div[data-type="column"]:first-child { + flex: 2; +} + +div[data-type="columns"][data-layout="two_right_sidebar"] + > div[data-type="column"]:last-child { + flex: 1; +} + +div[data-type="columns"][data-layout="three_left_wide"] + > div[data-type="column"]:first-child { + flex: 2; +} + +div[data-type="columns"][data-layout="three_right_wide"] + > div[data-type="column"]:last-child { + flex: 2; +} + +div[data-type="columns"][data-layout="three_with_sidebars"] + > div[data-type="column"]:first-child, +div[data-type="columns"][data-layout="three_with_sidebars"] + > div[data-type="column"]:last-child { + flex: 1; +} + +div[data-type="columns"][data-layout="three_with_sidebars"] + > div[data-type="column"]:nth-child(2) { + flex: 2; +} + +/* Stack columns vertically on small viewports */ +@media (max-width: 820px) { + div[data-type="columns"] { + flex-direction: column; + } + + div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] { + border-left: none; + padding-left: 0; + } + + div[data-type="columns"]:hover + > div[data-type="column"] + + div[data-type="column"] { + border-left: none; + } +} + +/* Wide width mode — extends columns to full container width */ +div[data-type="columns"][data-width-mode="wide"] { + margin-left: -3rem; + margin-right: -3rem; + width: calc(100% + 6rem); +} + +@media (max-width: $mantine-breakpoint-sm) { + div[data-type="columns"][data-width-mode="wide"] { + margin-left: -1rem; + margin-right: -1rem; + width: calc(100% + 2rem); + } +} + +@media print { + div[data-type="columns"] { + flex-direction: row !important; + } + + div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] { + border-left: none; + padding-left: 1rem; + } + + div[data-type="columns"][data-width-mode="wide"] { + margin-left: 0; + margin-right: 0; + width: 100%; + } +} diff --git a/apps/client/src/features/editor/styles/core.css b/apps/client/src/features/editor/styles/core.css index 0aed878e..c1f67554 100644 --- a/apps/client/src/features/editor/styles/core.css +++ b/apps/client/src/features/editor/styles/core.css @@ -82,13 +82,9 @@ } blockquote { - padding-left: 25px; - padding-right: 25px; - border-left: 2px solid var(--mantine-color-gray-6); - background-color: light-dark( - var(--mantine-color-gray-0), - var(--mantine-color-dark-8) - ); + padding-left: 1rem; + border-left: 3px solid + light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4)); margin: 0; } @@ -126,13 +122,14 @@ margin-bottom: 0; } - &.node-callout { - div[style*="white-space: inherit;"] { - > :first-child { - margin: 0; - } - } - } + } + + .react-renderer.node-callout div[style*="white-space: inherit;"] > :first-child { + margin-top: 0; + } + + .react-renderer.node-callout + .react-renderer.node-callout { + margin-top: 0.75em; } .selection { diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css index e32a606f..120c2a10 100644 --- a/apps/client/src/features/editor/styles/index.css +++ b/apps/client/src/features/editor/styles/index.css @@ -13,3 +13,4 @@ @import "./mention.css"; @import "./ordered-list.css"; @import "./highlight.css"; +@import "./columns.css"; diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx index 63a775de..0e4c3314 100644 --- a/apps/client/src/main.tsx +++ b/apps/client/src/main.tsx @@ -42,9 +42,9 @@ if (isCloud() && isPostHogEnabled) { }); } -const root = ReactDOM.createRoot( - document.getElementById("root") as HTMLElement, -); + +const container = document.getElementById("root") as HTMLElement; +const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container); root.render( diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index a29bb22a..9f173d44 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -35,6 +35,8 @@ import { UniqueID, addUniqueIdsToDoc, htmlToMarkdown, + Columns, + Column, } from '@docmost/editor-ext'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; @@ -91,6 +93,8 @@ export const tiptapExtensions = [ Embed, Mention, Subpages, + Columns, + Column, ] as any; export function jsonToHtml(tiptapJson: any) { diff --git a/package.json b/package.json index 7106d562..b9bf1043 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,8 @@ "packageManager": "pnpm@10.4.0", "pnpm": { "patchedDependencies": { - "react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch" + "react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch", + "@tiptap/core": "patches/@tiptap__core.patch" }, "overrides": { "jsdom": "25.0.1", diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 102cc4b1..feb7e488 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -25,3 +25,4 @@ export * from "./lib/heading/heading"; export * from "./lib/unique-id"; export * from "./lib/shared-storage"; export * from "./lib/recreate-transform"; +export * from "./lib/columns"; diff --git a/packages/editor-ext/src/lib/callout/utils.ts b/packages/editor-ext/src/lib/callout/utils.ts index 6484aa3b..26ff3856 100644 --- a/packages/editor-ext/src/lib/callout/utils.ts +++ b/packages/editor-ext/src/lib/callout/utils.ts @@ -1,8 +1,21 @@ -export type CalloutType = "default" | "info" | "success" | "warning" | "danger"; -const validCalloutTypes = ["default", "info", "success", "warning", "danger"]; +export type CalloutType = + | 'default' + | 'info' + | 'note' + | 'success' + | 'warning' + | 'danger'; +const validCalloutTypes = [ + 'default', + 'info', + 'note', + 'success', + 'warning', + 'danger', +]; export function getValidCalloutType(value: string): string { if (value) { - return validCalloutTypes.includes(value) ? value : "info"; + return validCalloutTypes.includes(value) ? value : 'info'; } } diff --git a/packages/editor-ext/src/lib/columns/column.ts b/packages/editor-ext/src/lib/columns/column.ts new file mode 100644 index 00000000..ea41acfc --- /dev/null +++ b/packages/editor-ext/src/lib/columns/column.ts @@ -0,0 +1,127 @@ +import { Node, mergeAttributes, findParentNode } from "@tiptap/core"; +import { TextSelection } from "prosemirror-state"; + +export interface ColumnOptions { + HTMLAttributes: Record; +} + +export interface ColumnAttributes { + width?: number | null; +} + +declare module "@tiptap/core" { + interface Commands { + column: { + setColumnWidth: (width: number | null) => ReturnType; + }; + } +} + +export const Column = Node.create({ + name: "column", + group: "block", + content: "block+", + defining: true, + isolating: true, + selectable: false, + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + addAttributes() { + return { + width: { + default: null, + parseHTML: (element) => { + const value = element.getAttribute("data-width"); + return value ? parseFloat(value) : null; + }, + renderHTML: (attributes: ColumnAttributes) => { + if (!attributes.width) return {}; + return { + "data-width": attributes.width, + style: `flex: ${attributes.width}`, + }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: `div[data-type="${this.name}"]`, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes( + { "data-type": this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + 0, + ]; + }, + + addKeyboardShortcuts() { + const jumpToColumn = (direction: 1 | -1) => () => { + const { state, dispatch } = this.editor.view; + + const columns = findParentNode( + (node) => node.type.name === "columns", + )(state.selection); + if (!columns) return false; + + const column = findParentNode( + (node) => node.type.name === "column", + )(state.selection); + if (!column) return false; + + let currentIndex = -1; + columns.node.forEach((_child, offset, index) => { + if (columns.pos + 1 + offset === column.pos) { + currentIndex = index; + } + }); + + const targetIndex = currentIndex + direction; + if (targetIndex < 0 || targetIndex >= columns.node.childCount) { + return true; + } + + let offset = 0; + for (let j = 0; j < targetIndex; j++) { + offset += columns.node.child(j).nodeSize; + } + + const targetPos = columns.pos + 1 + offset + 1 + 1; + if (dispatch) { + dispatch( + state.tr.setSelection(TextSelection.create(state.doc, targetPos)), + ); + } + return true; + }; + + return { + Tab: jumpToColumn(1), + "Shift-Tab": jumpToColumn(-1), + }; + }, + + addCommands() { + return { + setColumnWidth: + (width) => + ({ commands }) => + commands.updateAttributes("column", { width }), + }; + }, +}); diff --git a/packages/editor-ext/src/lib/columns/columns.ts b/packages/editor-ext/src/lib/columns/columns.ts new file mode 100644 index 00000000..f2682a73 --- /dev/null +++ b/packages/editor-ext/src/lib/columns/columns.ts @@ -0,0 +1,196 @@ +import { Node, mergeAttributes, findParentNode } from "@tiptap/core"; +import { Fragment, Node as PMNode } from "prosemirror-model"; +import { TextSelection } from "prosemirror-state"; + +export type ColumnsLayout = + | "two_equal" + | "two_left_sidebar" + | "two_right_sidebar" + | "three_equal" + | "three_left_wide" + | "three_right_wide" + | "three_with_sidebars" + | "four_equal" + | "five_equal"; + +export interface ColumnsOptions { + HTMLAttributes: Record; +} + +export type WidthMode = "normal" | "wide"; + +export interface ColumnsAttributes { + layout?: ColumnsLayout; + widthMode?: WidthMode; +} + +declare module "@tiptap/core" { + interface Commands { + columns: { + insertColumns: (attributes?: ColumnsAttributes) => ReturnType; + setColumnsWidthMode: (widthMode: WidthMode) => ReturnType; + setColumnCount: (count: number) => ReturnType; + setColumnsLayout: (layout: ColumnsLayout) => ReturnType; + }; + } +} + +function columnCountFromLayout(layout: string): number { + if (layout.startsWith("five")) return 5; + if (layout.startsWith("four")) return 4; + if (layout.startsWith("three")) return 3; + return 2; +} + +function defaultLayoutForCount(count: number): ColumnsLayout { + if (count === 3) return "three_equal"; + if (count === 4) return "four_equal"; + if (count === 5) return "five_equal"; + return "two_equal"; +} + +export const Columns = Node.create({ + name: "columns", + group: "block", + content: "column+", + defining: true, + isolating: true, + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + addAttributes() { + return { + layout: { + default: "two_equal", + parseHTML: (element) => element.getAttribute("data-layout"), + renderHTML: (attributes: ColumnsAttributes) => ({ + "data-layout": attributes.layout, + }), + }, + widthMode: { + default: "normal", + parseHTML: (element) => + element.getAttribute("data-width-mode") || "normal", + renderHTML: (attributes: ColumnsAttributes) => { + if (!attributes.widthMode || attributes.widthMode === "normal") + return {}; + return { "data-width-mode": attributes.widthMode }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: `div[data-type="${this.name}"]`, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes( + { "data-type": this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + 0, + ]; + }, + + addCommands() { + return { + insertColumns: + (attributes) => + ({ tr, state, dispatch }) => { + const layout = attributes?.layout || "two_equal"; + const count = columnCountFromLayout(layout); + + const columnType = state.schema.nodes.column; + const paraType = state.schema.nodes.paragraph; + const children = Array.from({ length: count }, () => + columnType.create(null, paraType.create()), + ); + const columnsNode = this.type.create( + attributes, + Fragment.from(children), + ); + + const stepsBefore = tr.steps.length; + tr.replaceSelectionWith(columnsNode); + + if (tr.steps.length > stepsBefore) { + const stepMap = tr.steps[tr.steps.length - 1].getMap(); + let insertStart = 0; + stepMap.forEach((_from, _to, newFrom) => { + insertStart = newFrom; + }); + tr.setSelection( + TextSelection.near(tr.doc.resolve(insertStart + 1), 1), + ); + } + + if (dispatch) dispatch(tr); + return true; + }, + + setColumnsWidthMode: + (widthMode) => + ({ commands }) => + commands.updateAttributes("columns", { widthMode }), + + setColumnCount: + (count: number) => + ({ tr, state }) => { + const predicate = (node: PMNode) => node.type.name === "columns"; + const parent = findParentNode(predicate)(state.selection); + if (!parent) return false; + + const { node: columnsNode, pos: parentPos } = parent; + const currentCount = columnsNode.childCount; + if (count === currentCount || count < 2 || count > 5) return false; + + const columnType = state.schema.nodes.column; + const paraType = state.schema.nodes.paragraph; + const newChildren: PMNode[] = []; + + if (count > currentCount) { + for (let i = 0; i < currentCount; i++) { + newChildren.push(columnsNode.child(i)); + } + for (let i = currentCount; i < count; i++) { + newChildren.push(columnType.create(null, paraType.create())); + } + } else { + for (let i = 0; i < count - 1; i++) { + newChildren.push(columnsNode.child(i)); + } + let mergedContent = columnsNode.child(count - 1).content; + for (let j = count; j < currentCount; j++) { + mergedContent = mergedContent.append(columnsNode.child(j).content); + } + newChildren.push(columnType.create(null, mergedContent)); + } + + const newLayout = defaultLayoutForCount(count); + const newNode = columnsNode.type.create( + { ...columnsNode.attrs, layout: newLayout }, + Fragment.from(newChildren), + ); + tr.replaceWith(parentPos, parentPos + columnsNode.nodeSize, newNode); + return true; + }, + + setColumnsLayout: + (layout) => + ({ commands }) => + commands.updateAttributes("columns", { layout }), + }; + }, +}); diff --git a/packages/editor-ext/src/lib/columns/index.ts b/packages/editor-ext/src/lib/columns/index.ts new file mode 100644 index 00000000..e7af35b6 --- /dev/null +++ b/packages/editor-ext/src/lib/columns/index.ts @@ -0,0 +1,4 @@ +export { Columns } from "./columns"; +export type { ColumnsOptions, ColumnsAttributes, ColumnsLayout, WidthMode } from "./columns"; +export { Column } from "./column"; +export type { ColumnOptions, ColumnAttributes } from "./column"; diff --git a/packages/editor-ext/src/lib/drawio.ts b/packages/editor-ext/src/lib/drawio.ts index 3cc041a2..bd42b0e4 100644 --- a/packages/editor-ext/src/lib/drawio.ts +++ b/packages/editor-ext/src/lib/drawio.ts @@ -1,15 +1,35 @@ -import { Node, mergeAttributes } from "@tiptap/core"; +import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core"; +import type { ResizableNodeViewDirection } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; +export type DrawioResizeOptions = { + enabled: boolean; + directions?: ResizableNodeViewDirection[]; + minWidth?: number; + minHeight?: number; + alwaysPreserveAspectRatio?: boolean; + createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement; + className?: { + container?: string; + wrapper?: string; + handle?: string; + resizing?: string; + }; +}; + export interface DrawioOptions { HTMLAttributes: Record; view: any; + resize: DrawioResizeOptions | false; } + export interface DrawioAttributes { src?: string; title?: string; size?: number; - width?: string; + width?: number | string; + height?: number; + aspectRatio?: number; align?: string; attachmentId?: string; } @@ -18,6 +38,8 @@ declare module "@tiptap/core" { interface Commands { drawio: { setDrawio: (attributes?: DrawioAttributes) => ReturnType; + setDrawioAlign: (align: "left" | "center" | "right") => ReturnType; + setDrawioSize: (width: number, height: number) => ReturnType; }; } } @@ -35,6 +57,7 @@ export const Drawio = Node.create({ return { HTMLAttributes: {}, view: null, + resize: false, }; }, @@ -55,12 +78,30 @@ export const Drawio = Node.create({ }), }, width: { - default: "100%", - parseHTML: (element) => element.getAttribute("data-width"), + default: null, + parseHTML: (element) => { + const raw = element.getAttribute("data-width"); + if (!raw) return null; + if (raw.endsWith("%")) return raw; + const num = parseFloat(raw); + return isNaN(num) ? null : num; + }, renderHTML: (attributes: DrawioAttributes) => ({ "data-width": attributes.width, }), }, + height: { + default: null, + parseHTML: (element) => { + const raw = element.getAttribute("data-height"); + if (!raw) return null; + const num = parseFloat(raw); + return isNaN(num) ? null : num; + }, + renderHTML: (attributes: DrawioAttributes) => ({ + "data-height": attributes.height, + }), + }, size: { default: null, parseHTML: (element) => element.getAttribute("data-size"), @@ -68,6 +109,13 @@ export const Drawio = Node.create({ "data-size": attributes.size, }), }, + aspectRatio: { + default: null, + parseHTML: (element) => element.getAttribute("data-aspect-ratio"), + renderHTML: (attributes: DrawioAttributes) => ({ + "data-aspect-ratio": attributes.aspectRatio, + }), + }, align: { default: "center", parseHTML: (element) => element.getAttribute("data-align"), @@ -99,7 +147,7 @@ export const Drawio = Node.create({ mergeAttributes( { "data-type": this.name }, this.options.HTMLAttributes, - HTMLAttributes + HTMLAttributes, ), [ "img", @@ -122,13 +170,172 @@ export const Drawio = Node.create({ attrs: attrs, }); }, + + setDrawioAlign: + (align) => + ({ commands }) => + commands.updateAttributes("drawio", { align }), + + setDrawioSize: + (width, height) => + ({ commands }) => + commands.updateAttributes("drawio", { width, height }), }; }, addNodeView() { - // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) - this.editor.isInitialized = true; + const resize = this.options.resize; - return ReactNodeViewRenderer(this.options.view); + if (!resize || !resize.enabled) { + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); + } + + const { + directions, + minWidth, + minHeight, + alwaysPreserveAspectRatio, + createCustomHandle, + className, + } = resize; + + return (props) => { + const { node, getPos, HTMLAttributes, editor } = props; + + if (!node.attrs.src) { + editor.isInitialized = true; + const reactView = ReactNodeViewRenderer(this.options.view); + const view = reactView(props); + + const originalUpdate = view.update?.bind(view); + view.update = (updatedNode, decorations, innerDecorations) => { + if (updatedNode.attrs.src && !node.attrs.src) { + return false; + } + if (originalUpdate) { + return originalUpdate(updatedNode, decorations, innerDecorations); + } + return true; + }; + + return view; + } + + const el = document.createElement("img"); + el.src = node.attrs.src; + el.alt = node.attrs.title || ""; + el.style.display = "block"; + el.style.maxWidth = "100%"; + el.style.borderRadius = "8px"; + + let currentNode = node; + + const nodeView = new ResizableNodeView({ + element: el, + editor, + node, + getPos, + onResize: (w, h) => { + el.style.width = `${w}px`; + el.style.height = `${h}px`; + }, + onCommit: () => { + const pos = getPos(); + if (pos === undefined) return; + + this.editor + .chain() + .setNodeSelection(pos) + .updateAttributes(this.name, { + width: Math.round(el.offsetWidth), + height: Math.round(el.offsetHeight), + }) + .run(); + }, + onUpdate: (updatedNode, _decorations, _innerDecorations) => { + if (updatedNode.type !== currentNode.type) { + return false; + } + + if (updatedNode.attrs.src !== currentNode.attrs.src) { + el.src = updatedNode.attrs.src || ""; + } + + const w = updatedNode.attrs.width; + const h = updatedNode.attrs.height; + if (w != null) { + el.style.width = `${w}px`; + } + if (h != null) { + el.style.height = `${h}px`; + } + + const align = updatedNode.attrs.align || "center"; + const container = nodeView.dom as HTMLElement; + applyAlignment(container, align); + + currentNode = updatedNode; + return true; + }, + options: { + directions, + min: { + width: minWidth, + height: minHeight, + }, + preserveAspectRatio: alwaysPreserveAspectRatio === true, + createCustomHandle, + className, + }, + }); + + const dom = nodeView.dom as HTMLElement; + + applyAlignment(dom, node.attrs.align || "center"); + + // Handle percentage width backward compat + const widthAttr = node.attrs.width; + if (typeof widthAttr === "string" && widthAttr.endsWith("%")) { + requestAnimationFrame(() => { + const parentEl = dom.parentElement; + if (parentEl) { + const containerWidth = parentEl.clientWidth; + const pctValue = parseInt(widthAttr, 10); + if (!isNaN(pctValue) && containerWidth > 0) { + const pxWidth = Math.round( + containerWidth * (pctValue / 100), + ); + el.style.width = `${pxWidth}px`; + if (node.attrs.aspectRatio) { + el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`; + } + } + } + dom.style.visibility = ""; + dom.style.pointerEvents = ""; + }); + } + + // Hide until image loads + dom.style.visibility = "hidden"; + dom.style.pointerEvents = "none"; + el.onload = () => { + dom.style.visibility = ""; + dom.style.pointerEvents = ""; + }; + + return nodeView; + }; }, }); + +function applyAlignment(container: HTMLElement, align: string) { + if (align === "left") { + container.style.justifyContent = "flex-start"; + } else if (align === "right") { + container.style.justifyContent = "flex-end"; + } else { + container.style.justifyContent = "center"; + } +} diff --git a/packages/editor-ext/src/lib/excalidraw.ts b/packages/editor-ext/src/lib/excalidraw.ts index 28b064e4..f132d37a 100644 --- a/packages/editor-ext/src/lib/excalidraw.ts +++ b/packages/editor-ext/src/lib/excalidraw.ts @@ -1,15 +1,35 @@ -import { Node, mergeAttributes } from "@tiptap/core"; +import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core"; +import type { ResizableNodeViewDirection } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; +export type ExcalidrawResizeOptions = { + enabled: boolean; + directions?: ResizableNodeViewDirection[]; + minWidth?: number; + minHeight?: number; + alwaysPreserveAspectRatio?: boolean; + createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement; + className?: { + container?: string; + wrapper?: string; + handle?: string; + resizing?: string; + }; +}; + export interface ExcalidrawOptions { HTMLAttributes: Record; view: any; + resize: ExcalidrawResizeOptions | false; } + export interface ExcalidrawAttributes { src?: string; title?: string; size?: number; - width?: string; + width?: number | string; + height?: number; + aspectRatio?: number; align?: string; attachmentId?: string; } @@ -18,6 +38,8 @@ declare module "@tiptap/core" { interface Commands { excalidraw: { setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType; + setExcalidrawAlign: (align: "left" | "center" | "right") => ReturnType; + setExcalidrawSize: (width: number, height: number) => ReturnType; }; } } @@ -35,8 +57,10 @@ export const Excalidraw = Node.create({ return { HTMLAttributes: {}, view: null, + resize: false, }; }, + addAttributes() { return { src: { @@ -54,12 +78,30 @@ export const Excalidraw = Node.create({ }), }, width: { - default: "100%", - parseHTML: (element) => element.getAttribute("data-width"), + default: null, + parseHTML: (element) => { + const raw = element.getAttribute("data-width"); + if (!raw) return null; + if (raw.endsWith("%")) return raw; + const num = parseFloat(raw); + return isNaN(num) ? null : num; + }, renderHTML: (attributes: ExcalidrawAttributes) => ({ "data-width": attributes.width, }), }, + height: { + default: null, + parseHTML: (element) => { + const raw = element.getAttribute("data-height"); + if (!raw) return null; + const num = parseFloat(raw); + return isNaN(num) ? null : num; + }, + renderHTML: (attributes: ExcalidrawAttributes) => ({ + "data-height": attributes.height, + }), + }, size: { default: null, parseHTML: (element) => element.getAttribute("data-size"), @@ -67,6 +109,13 @@ export const Excalidraw = Node.create({ "data-size": attributes.size, }), }, + aspectRatio: { + default: null, + parseHTML: (element) => element.getAttribute("data-aspect-ratio"), + renderHTML: (attributes: ExcalidrawAttributes) => ({ + "data-aspect-ratio": attributes.aspectRatio, + }), + }, align: { default: "center", parseHTML: (element) => element.getAttribute("data-align"), @@ -98,7 +147,7 @@ export const Excalidraw = Node.create({ mergeAttributes( { "data-type": this.name }, this.options.HTMLAttributes, - HTMLAttributes + HTMLAttributes, ), [ "img", @@ -121,13 +170,172 @@ export const Excalidraw = Node.create({ attrs: attrs, }); }, + + setExcalidrawAlign: + (align) => + ({ commands }) => + commands.updateAttributes("excalidraw", { align }), + + setExcalidrawSize: + (width, height) => + ({ commands }) => + commands.updateAttributes("excalidraw", { width, height }), }; }, addNodeView() { - // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) - this.editor.isInitialized = true; + const resize = this.options.resize; - return ReactNodeViewRenderer(this.options.view); + if (!resize || !resize.enabled) { + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); + } + + const { + directions, + minWidth, + minHeight, + alwaysPreserveAspectRatio, + createCustomHandle, + className, + } = resize; + + return (props) => { + const { node, getPos, HTMLAttributes, editor } = props; + + if (!node.attrs.src) { + editor.isInitialized = true; + const reactView = ReactNodeViewRenderer(this.options.view); + const view = reactView(props); + + const originalUpdate = view.update?.bind(view); + view.update = (updatedNode, decorations, innerDecorations) => { + if (updatedNode.attrs.src && !node.attrs.src) { + return false; + } + if (originalUpdate) { + return originalUpdate(updatedNode, decorations, innerDecorations); + } + return true; + }; + + return view; + } + + const el = document.createElement("img"); + el.src = node.attrs.src; + el.alt = node.attrs.title || ""; + el.style.display = "block"; + el.style.maxWidth = "100%"; + el.style.borderRadius = "8px"; + + let currentNode = node; + + const nodeView = new ResizableNodeView({ + element: el, + editor, + node, + getPos, + onResize: (w, h) => { + el.style.width = `${w}px`; + el.style.height = `${h}px`; + }, + onCommit: () => { + const pos = getPos(); + if (pos === undefined) return; + + this.editor + .chain() + .setNodeSelection(pos) + .updateAttributes(this.name, { + width: Math.round(el.offsetWidth), + height: Math.round(el.offsetHeight), + }) + .run(); + }, + onUpdate: (updatedNode, _decorations, _innerDecorations) => { + if (updatedNode.type !== currentNode.type) { + return false; + } + + if (updatedNode.attrs.src !== currentNode.attrs.src) { + el.src = updatedNode.attrs.src || ""; + } + + const w = updatedNode.attrs.width; + const h = updatedNode.attrs.height; + if (w != null) { + el.style.width = `${w}px`; + } + if (h != null) { + el.style.height = `${h}px`; + } + + const align = updatedNode.attrs.align || "center"; + const container = nodeView.dom as HTMLElement; + applyAlignment(container, align); + + currentNode = updatedNode; + return true; + }, + options: { + directions, + min: { + width: minWidth, + height: minHeight, + }, + preserveAspectRatio: alwaysPreserveAspectRatio === true, + createCustomHandle, + className, + }, + }); + + const dom = nodeView.dom as HTMLElement; + + applyAlignment(dom, node.attrs.align || "center"); + + // Handle percentage width backward compat + const widthAttr = node.attrs.width; + if (typeof widthAttr === "string" && widthAttr.endsWith("%")) { + requestAnimationFrame(() => { + const parentEl = dom.parentElement; + if (parentEl) { + const containerWidth = parentEl.clientWidth; + const pctValue = parseInt(widthAttr, 10); + if (!isNaN(pctValue) && containerWidth > 0) { + const pxWidth = Math.round( + containerWidth * (pctValue / 100), + ); + el.style.width = `${pxWidth}px`; + if (node.attrs.aspectRatio) { + el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`; + } + } + } + dom.style.visibility = ""; + dom.style.pointerEvents = ""; + }); + } + + // Hide until image loads + dom.style.visibility = "hidden"; + dom.style.pointerEvents = "none"; + el.onload = () => { + dom.style.visibility = ""; + dom.style.pointerEvents = ""; + }; + + return nodeView; + }; }, }); + +function applyAlignment(container: HTMLElement, align: string) { + if (align === "left") { + container.style.justifyContent = "flex-start"; + } else if (align === "right") { + container.style.justifyContent = "flex-end"; + } else { + container.style.justifyContent = "center"; + } +} diff --git a/packages/editor-ext/src/lib/image/image.ts b/packages/editor-ext/src/lib/image/image.ts index e0f5053d..8798a9bb 100644 --- a/packages/editor-ext/src/lib/image/image.ts +++ b/packages/editor-ext/src/lib/image/image.ts @@ -1,18 +1,41 @@ import Image from "@tiptap/extension-image"; import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; -import { mergeAttributes, Range } from "@tiptap/core"; +import { + mergeAttributes, + Range, + ResizableNodeView, +} from "@tiptap/core"; +import type { ResizableNodeViewDirection } from "@tiptap/core"; + +export type ImageResizeOptions = { + enabled: boolean; + directions?: ResizableNodeViewDirection[]; + minWidth?: number; + minHeight?: number; + alwaysPreserveAspectRatio?: boolean; + createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement; + className?: { + container?: string; + wrapper?: string; + handle?: string; + resizing?: string; + }; +}; export interface ImageOptions extends DefaultImageOptions { view: any; + resize: ImageResizeOptions | false; } + export interface ImageAttributes { src?: string; alt?: string; align?: string; attachmentId?: string; size?: number; - width?: number; + width?: number | string; + height?: number; aspectRatio?: number; placeholder?: { id: string; @@ -25,10 +48,11 @@ declare module "@tiptap/core" { imageBlock: { setImage: (attributes: ImageAttributes) => ReturnType; setImageAt: ( - attributes: ImageAttributes & { pos: number | Range } + attributes: ImageAttributes & { pos: number | Range }, ) => ReturnType; setImageAlign: (align: "left" | "center" | "right") => ReturnType; setImageWidth: (width: number) => ReturnType; + setImageSize: (width: number, height: number) => ReturnType; }; } } @@ -46,6 +70,7 @@ export const TiptapImage = Image.extend({ return { ...this.parent?.(), view: null, + resize: false, }; }, @@ -59,12 +84,30 @@ export const TiptapImage = Image.extend({ }), }, width: { - default: "100%", - parseHTML: (element) => element.getAttribute("width"), + default: null, + parseHTML: (element) => { + const raw = element.getAttribute("width"); + if (!raw) return null; + if (raw.endsWith("%")) return raw; + const num = parseFloat(raw); + return isNaN(num) ? null : num; + }, renderHTML: (attributes: ImageAttributes) => ({ width: attributes.width, }), }, + height: { + default: null, + parseHTML: (element) => { + const raw = element.getAttribute("height"); + if (!raw) return null; + const num = parseFloat(raw); + return isNaN(num) ? null : num; + }, + renderHTML: (attributes: ImageAttributes) => ({ + height: attributes.height, + }), + }, align: { default: "center", parseHTML: (element) => element.getAttribute("data-align"), @@ -142,16 +185,192 @@ export const TiptapImage = Image.extend({ setImageWidth: (width) => ({ commands }) => - commands.updateAttributes("image", { - width: `${Math.max(0, Math.min(100, width))}%`, - }), + commands.updateAttributes("image", { width }), + + setImageSize: + (width, height) => + ({ commands }) => + commands.updateAttributes("image", { width, height }), }; }, addNodeView() { - // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) - this.editor.isInitialized = true; + const resize = this.options.resize; - return ReactNodeViewRenderer(this.options.view); + if (!resize || !resize.enabled) { + // Fallback to React node view (existing behavior) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); + } + + const { + directions, + minWidth, + minHeight, + alwaysPreserveAspectRatio, + createCustomHandle, + className, + } = resize; + + return (props) => { + const { node, getPos, HTMLAttributes, editor } = props; + + // If no src yet (placeholder/uploading), use React view for loading UI + if (!HTMLAttributes.src) { + editor.isInitialized = true; + const reactView = ReactNodeViewRenderer(this.options.view); + const view = reactView(props); + + // When the node gets a src, return false from update to force rebuild + const originalUpdate = view.update?.bind(view); + view.update = (updatedNode, decorations, innerDecorations) => { + if (updatedNode.attrs.src && !node.attrs.src) { + return false; + } + if (originalUpdate) { + return originalUpdate(updatedNode, decorations, innerDecorations); + } + return true; + }; + + return view; + } + + // Has src — use ResizableNodeView + const el = document.createElement("img"); + + Object.entries(HTMLAttributes).forEach(([key, value]) => { + if (value != null) { + switch (key) { + case "width": + case "height": + break; + default: + el.setAttribute(key, String(value)); + break; + } + } + }); + + el.src = HTMLAttributes.src; + el.style.display = "block"; + el.style.maxWidth = "100%"; + el.style.borderRadius = "8px"; + + let currentNode = node; + + const nodeView = new ResizableNodeView({ + element: el, + editor, + node, + getPos, + onResize: (w, h) => { + el.style.width = `${w}px`; + el.style.height = `${h}px`; + }, + onCommit: () => { + const pos = getPos(); + if (pos === undefined) return; + + this.editor + .chain() + .setNodeSelection(pos) + .updateAttributes(this.name, { + width: Math.round(el.offsetWidth), + height: Math.round(el.offsetHeight), + }) + .run(); + }, + onUpdate: (updatedNode, _decorations, _innerDecorations) => { + if (updatedNode.type !== currentNode.type) { + return false; + } + + if (updatedNode.attrs.src !== currentNode.attrs.src) { + el.src = updatedNode.attrs.src || ""; + } + + if (updatedNode.attrs.alt !== currentNode.attrs.alt) { + el.alt = updatedNode.attrs.alt || ""; + } + + const w = updatedNode.attrs.width; + const h = updatedNode.attrs.height; + if (w != null) { + el.style.width = `${w}px`; + } + if (h != null) { + el.style.height = `${h}px`; + } + + // Update alignment on container + const align = updatedNode.attrs.align || "center"; + const container = nodeView.dom as HTMLElement; + applyAlignment(container, align); + + currentNode = updatedNode; + return true; + }, + options: { + directions, + min: { + width: minWidth, + height: minHeight, + }, + preserveAspectRatio: alwaysPreserveAspectRatio === true, + createCustomHandle, + className, + }, + }); + + const dom = nodeView.dom as HTMLElement; + + // Apply initial alignment + applyAlignment(dom, node.attrs.align || "center"); + + // Handle percentage width backward compat + const widthAttr = node.attrs.width; + if (typeof widthAttr === "string" && widthAttr.endsWith("%")) { + // Defer conversion until we can measure the container + requestAnimationFrame(() => { + const parentEl = dom.parentElement; + if (parentEl) { + const containerWidth = parentEl.clientWidth; + const pctValue = parseInt(widthAttr, 10); + if (!isNaN(pctValue) && containerWidth > 0) { + const pxWidth = Math.round( + containerWidth * (pctValue / 100), + ); + el.style.width = `${pxWidth}px`; + if (node.attrs.aspectRatio) { + el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`; + } + } + } + dom.style.visibility = ""; + dom.style.pointerEvents = ""; + }); + } + + // Hide until image loads (official TipTap pattern) + dom.style.visibility = "hidden"; + dom.style.pointerEvents = "none"; + el.onload = () => { + dom.style.visibility = ""; + dom.style.pointerEvents = ""; + }; + + return nodeView; + }; }, }); + +function applyAlignment(container: HTMLElement, align: string) { + if (align === "left") { + container.style.justifyContent = "flex-start"; + } else if (align === "right") { + container.style.justifyContent = "flex-end"; + } else { + container.style.justifyContent = "center"; + } +} diff --git a/packages/editor-ext/src/lib/video/video.ts b/packages/editor-ext/src/lib/video/video.ts index c3c6ab3e..a296d13e 100644 --- a/packages/editor-ext/src/lib/video/video.ts +++ b/packages/editor-ext/src/lib/video/video.ts @@ -1,16 +1,35 @@ import { ReactNodeViewRenderer } from "@tiptap/react"; -import { Range, Node } from "@tiptap/core"; +import { Range, Node, mergeAttributes, ResizableNodeView } from "@tiptap/core"; +import type { ResizableNodeViewDirection } from "@tiptap/core"; + +export type VideoResizeOptions = { + enabled: boolean; + directions?: ResizableNodeViewDirection[]; + minWidth?: number; + minHeight?: number; + alwaysPreserveAspectRatio?: boolean; + createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement; + className?: { + container?: string; + wrapper?: string; + handle?: string; + resizing?: string; + }; +}; export interface VideoOptions { view: any; HTMLAttributes: Record; + resize: VideoResizeOptions | false; } + export interface VideoAttributes { src?: string; align?: string; attachmentId?: string; size?: number; - width?: number; + width?: number | string; + height?: number; aspectRatio?: number; placeholder?: { id: string; @@ -27,6 +46,7 @@ declare module "@tiptap/core" { ) => ReturnType; setVideoAlign: (align: "left" | "center" | "right") => ReturnType; setVideoWidth: (width: number) => ReturnType; + setVideoSize: (width: number, height: number) => ReturnType; }; } } @@ -44,6 +64,7 @@ export const TiptapVideo = Node.create({ return { view: null, HTMLAttributes: {}, + resize: false, }; }, @@ -64,12 +85,30 @@ export const TiptapVideo = Node.create({ }), }, width: { - default: "100%", - parseHTML: (element) => element.getAttribute("width"), + default: null, + parseHTML: (element) => { + const raw = element.getAttribute("width"); + if (!raw) return null; + if (raw.endsWith("%")) return raw; + const num = parseFloat(raw); + return isNaN(num) ? null : num; + }, renderHTML: (attributes: VideoAttributes) => ({ width: attributes.width, }), }, + height: { + default: null, + parseHTML: (element) => { + const raw = element.getAttribute("height"); + if (!raw) return null; + const num = parseFloat(raw); + return isNaN(num) ? null : num; + }, + renderHTML: (attributes: VideoAttributes) => ({ + height: attributes.height, + }), + }, size: { default: null, parseHTML: (element) => element.getAttribute("data-size"), @@ -136,13 +175,168 @@ export const TiptapVideo = Node.create({ commands.updateAttributes("video", { width: `${Math.max(0, Math.min(100, width))}%`, }), + + setVideoSize: + (width, height) => + ({ commands }) => + commands.updateAttributes("video", { width, height }), }; }, addNodeView() { - // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) - this.editor.isInitialized = true; + const resize = this.options.resize; - return ReactNodeViewRenderer(this.options.view); + if (!resize || !resize.enabled) { + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); + } + + const { + directions, + minWidth, + minHeight, + alwaysPreserveAspectRatio, + createCustomHandle, + className, + } = resize; + + return (props) => { + const { node, getPos, HTMLAttributes, editor } = props; + + if (!node.attrs.src) { + editor.isInitialized = true; + const reactView = ReactNodeViewRenderer(this.options.view); + const view = reactView(props); + + const originalUpdate = view.update?.bind(view); + view.update = (updatedNode, decorations, innerDecorations) => { + if (updatedNode.attrs.src && !node.attrs.src) { + return false; + } + if (originalUpdate) { + return originalUpdate(updatedNode, decorations, innerDecorations); + } + return true; + }; + + return view; + } + + const el = document.createElement("video"); + el.src = node.attrs.src; + el.controls = true; + el.preload = "metadata"; + el.style.display = "block"; + el.style.maxWidth = "100%"; + el.style.borderRadius = "8px"; + + let currentNode = node; + + const nodeView = new ResizableNodeView({ + element: el, + editor, + node, + getPos, + onResize: (w, h) => { + el.style.width = `${w}px`; + el.style.height = `${h}px`; + }, + onCommit: () => { + const pos = getPos(); + if (pos === undefined) return; + + this.editor + .chain() + .setNodeSelection(pos) + .updateAttributes(this.name, { + width: Math.round(el.offsetWidth), + height: Math.round(el.offsetHeight), + }) + .run(); + }, + onUpdate: (updatedNode, _decorations, _innerDecorations) => { + if (updatedNode.type !== currentNode.type) { + return false; + } + + if (updatedNode.attrs.src !== currentNode.attrs.src) { + el.src = updatedNode.attrs.src || ""; + } + + const w = updatedNode.attrs.width; + const h = updatedNode.attrs.height; + if (w != null) { + el.style.width = `${w}px`; + } + if (h != null) { + el.style.height = `${h}px`; + } + + const align = updatedNode.attrs.align || "center"; + const container = nodeView.dom as HTMLElement; + applyAlignment(container, align); + + currentNode = updatedNode; + return true; + }, + options: { + directions, + min: { + width: minWidth, + height: minHeight, + }, + preserveAspectRatio: alwaysPreserveAspectRatio === true, + createCustomHandle, + className, + }, + }); + + const dom = nodeView.dom as HTMLElement; + + applyAlignment(dom, node.attrs.align || "center"); + + // Handle percentage width backward compat + const widthAttr = node.attrs.width; + if (typeof widthAttr === "string" && widthAttr.endsWith("%")) { + requestAnimationFrame(() => { + const parentEl = dom.parentElement; + if (parentEl) { + const containerWidth = parentEl.clientWidth; + const pctValue = parseInt(widthAttr, 10); + if (!isNaN(pctValue) && containerWidth > 0) { + const pxWidth = Math.round( + containerWidth * (pctValue / 100), + ); + el.style.width = `${pxWidth}px`; + if (node.attrs.aspectRatio) { + el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`; + } + } + } + dom.style.visibility = ""; + dom.style.pointerEvents = ""; + }); + } + + // Hide until video metadata loads + dom.style.visibility = "hidden"; + dom.style.pointerEvents = "none"; + el.onloadedmetadata = () => { + dom.style.visibility = ""; + dom.style.pointerEvents = ""; + }; + + return nodeView; + }; }, }); + +function applyAlignment(container: HTMLElement, align: string) { + if (align === "left") { + container.style.justifyContent = "flex-start"; + } else if (align === "right") { + container.style.justifyContent = "flex-end"; + } else { + container.style.justifyContent = "center"; + } +} diff --git a/patches/@tiptap__core.patch b/patches/@tiptap__core.patch new file mode 100644 index 00000000..58f580c8 --- /dev/null +++ b/patches/@tiptap__core.patch @@ -0,0 +1,105 @@ +diff --git a/dist/index.cjs b/dist/index.cjs +index 01d6999642c5ae990083798a1bf0ef87068e4192..891b13c6901f28a6ab413c6dbae0ea726a76a196 100644 +--- a/dist/index.cjs ++++ b/dist/index.cjs +@@ -5463,7 +5463,10 @@ var ResizableNodeView = class { + this.container.classList.remove(this.classNames.resizing); + } + document.removeEventListener("mousemove", this.handleMouseMove); ++ document.removeEventListener("touchmove", this.handleTouchMove); + document.removeEventListener("mouseup", this.handleMouseUp); ++ document.removeEventListener("touchend", this.handleMouseUp); ++ window.removeEventListener("blur", this.handleMouseUp); + document.removeEventListener("keydown", this.handleKeyDown); + document.removeEventListener("keyup", this.handleKeyUp); + }; +@@ -5593,7 +5596,10 @@ var ResizableNodeView = class { + this.container.classList.remove(this.classNames.resizing); + } + document.removeEventListener("mousemove", this.handleMouseMove); ++ document.removeEventListener("touchmove", this.handleTouchMove); + document.removeEventListener("mouseup", this.handleMouseUp); ++ document.removeEventListener("touchend", this.handleMouseUp); ++ window.removeEventListener("blur", this.handleMouseUp); + document.removeEventListener("keydown", this.handleKeyDown); + document.removeEventListener("keyup", this.handleKeyUp); + this.isResizing = false; +@@ -5796,6 +5802,8 @@ var ResizableNodeView = class { + document.addEventListener("mousemove", this.handleMouseMove); + document.addEventListener("touchmove", this.handleTouchMove); + document.addEventListener("mouseup", this.handleMouseUp); ++ document.addEventListener("touchend", this.handleMouseUp); ++ window.addEventListener("blur", this.handleMouseUp); + document.addEventListener("keydown", this.handleKeyDown); + document.addEventListener("keyup", this.handleKeyUp); + } +diff --git a/dist/index.js b/dist/index.js +index 6f357a03b038abeb5ed86967b7fc7c3e5eb1d2d6..2d2742532860821984e1ba82625821504538ebbe 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -5330,7 +5330,10 @@ var ResizableNodeView = class { + this.container.classList.remove(this.classNames.resizing); + } + document.removeEventListener("mousemove", this.handleMouseMove); ++ document.removeEventListener("touchmove", this.handleTouchMove); + document.removeEventListener("mouseup", this.handleMouseUp); ++ document.removeEventListener("touchend", this.handleMouseUp); ++ window.removeEventListener("blur", this.handleMouseUp); + document.removeEventListener("keydown", this.handleKeyDown); + document.removeEventListener("keyup", this.handleKeyUp); + }; +@@ -5460,7 +5463,10 @@ var ResizableNodeView = class { + this.container.classList.remove(this.classNames.resizing); + } + document.removeEventListener("mousemove", this.handleMouseMove); ++ document.removeEventListener("touchmove", this.handleTouchMove); + document.removeEventListener("mouseup", this.handleMouseUp); ++ document.removeEventListener("touchend", this.handleMouseUp); ++ window.removeEventListener("blur", this.handleMouseUp); + document.removeEventListener("keydown", this.handleKeyDown); + document.removeEventListener("keyup", this.handleKeyUp); + this.isResizing = false; +@@ -5663,6 +5669,8 @@ var ResizableNodeView = class { + document.addEventListener("mousemove", this.handleMouseMove); + document.addEventListener("touchmove", this.handleTouchMove); + document.addEventListener("mouseup", this.handleMouseUp); ++ document.addEventListener("touchend", this.handleMouseUp); ++ window.addEventListener("blur", this.handleMouseUp); + document.addEventListener("keydown", this.handleKeyDown); + document.addEventListener("keyup", this.handleKeyUp); + } +diff --git a/src/lib/ResizableNodeView.ts b/src/lib/ResizableNodeView.ts +index f13e210b0aa46aefe7c31105deee3d2aa8a26cd5..9bac138dbf17c6ae6c3c129cbedb3a81bd39b60c 100644 +--- a/src/lib/ResizableNodeView.ts ++++ b/src/lib/ResizableNodeView.ts +@@ -523,7 +523,10 @@ export class ResizableNodeView { + } + + document.removeEventListener('mousemove', this.handleMouseMove) ++ document.removeEventListener('touchmove', this.handleTouchMove) + document.removeEventListener('mouseup', this.handleMouseUp) ++ document.removeEventListener('touchend', this.handleMouseUp) ++ window.removeEventListener('blur', this.handleMouseUp) + document.removeEventListener('keydown', this.handleKeyDown) + document.removeEventListener('keyup', this.handleKeyUp) + this.isResizing = false +@@ -774,6 +777,8 @@ export class ResizableNodeView { + document.addEventListener('mousemove', this.handleMouseMove) + document.addEventListener('touchmove', this.handleTouchMove) + document.addEventListener('mouseup', this.handleMouseUp) ++ document.addEventListener('touchend', this.handleMouseUp) ++ window.addEventListener('blur', this.handleMouseUp) + document.addEventListener('keydown', this.handleKeyDown) + document.addEventListener('keyup', this.handleKeyUp) + } +@@ -859,7 +864,10 @@ export class ResizableNodeView { + + // Clean up document-level listeners + document.removeEventListener('mousemove', this.handleMouseMove) ++ document.removeEventListener('touchmove', this.handleTouchMove) + document.removeEventListener('mouseup', this.handleMouseUp) ++ document.removeEventListener('touchend', this.handleMouseUp) ++ window.removeEventListener('blur', this.handleMouseUp) + document.removeEventListener('keydown', this.handleKeyDown) + document.removeEventListener('keyup', this.handleKeyUp) + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 161aa6f1..73141a72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ overrides: '@tiptap/extension-code': 3.17.1 patchedDependencies: + '@tiptap/core': + hash: efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00 + path: patches/@tiptap__core.patch react-arborist@3.4.0: hash: 419b3b02e24afe928cc006a006f6e906666aff19aa6fd7daaa788ccc2202678a path: patches/react-arborist@3.4.0.patch @@ -57,7 +60,7 @@ importers: version: 3.4.4(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) '@hocuspocus/transformer': specifier: 3.4.4 - version: 3.4.4(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29) + version: 3.4.4(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29) '@joplin/turndown': specifier: ^4.0.74 version: 4.0.74 @@ -69,85 +72,85 @@ importers: version: 1.1.0 '@tiptap/core': specifier: 3.17.1 - version: 3.17.1(@tiptap/pm@3.17.1) + version: 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) '@tiptap/extension-code-block': specifier: 3.17.1 - version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/extension-collaboration': specifier: 3.17.1 - version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29) + version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29) '@tiptap/extension-collaboration-caret': specifier: 3.17.1 - version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)) + version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)) '@tiptap/extension-color': specifier: 3.17.1 - version: 3.17.1(@tiptap/extension-text-style@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))) + version: 3.17.1(@tiptap/extension-text-style@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))) '@tiptap/extension-document': specifier: 3.17.1 - version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) '@tiptap/extension-heading': specifier: 3.17.1 - version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) '@tiptap/extension-highlight': specifier: 3.17.1 - version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) '@tiptap/extension-history': specifier: 3.17.1 - version: 3.17.1(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + version: 3.17.1(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) '@tiptap/extension-image': specifier: 3.17.1 - version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) '@tiptap/extension-link': specifier: 3.17.1 - version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/extension-list': specifier: 3.17.1 - version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/extension-placeholder': specifier: 3.17.1 - version: 3.17.1(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + version: 3.17.1(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) '@tiptap/extension-subscript': specifier: 3.17.1 - version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/extension-superscript': specifier: 3.17.1 - version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/extension-table': specifier: 3.17.1 - version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/extension-text': specifier: 3.17.1 - version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) '@tiptap/extension-text-align': specifier: 3.17.1 - version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) '@tiptap/extension-text-style': specifier: 3.17.1 - version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) '@tiptap/extension-typography': specifier: 3.17.1 - version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) '@tiptap/extension-unique-id': specifier: ^3.17.1 - version: 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + version: 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/extension-youtube': specifier: 3.17.1 - version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) '@tiptap/html': specifier: 3.17.1 - version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(happy-dom@20.1.0) + version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(happy-dom@20.1.0) '@tiptap/pm': specifier: 3.17.1 version: 3.17.1 '@tiptap/react': specifier: 3.17.1 - version: 3.17.1(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.17.1(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tiptap/starter-kit': specifier: 3.17.1 version: 3.17.1 '@tiptap/suggestion': specifier: 3.17.1 - version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + version: 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/y-tiptap': specifier: ^3.0.2 version: 3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) @@ -12332,9 +12335,9 @@ snapshots: - bufferutil - utf-8-validate - '@hocuspocus/transformer@3.4.4(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)': + '@hocuspocus/transformer@3.4.4(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) '@tiptap/pm': 3.17.1 '@tiptap/starter-kit': 3.17.1 y-prosemirror: 1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) @@ -14895,191 +14898,191 @@ snapshots: '@tanstack/query-core': 5.90.17 react: 18.3.1 - '@tiptap/core@3.17.1(@tiptap/pm@3.17.1)': + '@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)': dependencies: '@tiptap/pm': 3.17.1 - '@tiptap/extension-blockquote@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': + '@tiptap/extension-blockquote@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) - '@tiptap/extension-bold@3.17.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': + '@tiptap/extension-bold@3.17.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) - '@tiptap/extension-bubble-menu@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': + '@tiptap/extension-bubble-menu@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: '@floating-ui/dom': 1.7.4 - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) '@tiptap/pm': 3.17.1 optional: true - '@tiptap/extension-bullet-list@3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': + '@tiptap/extension-bullet-list@3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-code-block@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': + '@tiptap/extension-code-block@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) '@tiptap/pm': 3.17.1 - '@tiptap/extension-code@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': + '@tiptap/extension-code@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) - '@tiptap/extension-collaboration-caret@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))': + '@tiptap/extension-collaboration-caret@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) '@tiptap/pm': 3.17.1 '@tiptap/y-tiptap': 3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) - '@tiptap/extension-collaboration@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)': + '@tiptap/extension-collaboration@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) '@tiptap/pm': 3.17.1 '@tiptap/y-tiptap': 3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) yjs: 13.6.29 - '@tiptap/extension-color@3.17.1(@tiptap/extension-text-style@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)))': + '@tiptap/extension-color@3.17.1(@tiptap/extension-text-style@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)))': dependencies: - '@tiptap/extension-text-style': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-text-style': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) - '@tiptap/extension-document@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': + '@tiptap/extension-document@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) - '@tiptap/extension-dropcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': + '@tiptap/extension-dropcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': + '@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: '@floating-ui/dom': 1.7.3 - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) '@tiptap/pm': 3.17.1 optional: true - '@tiptap/extension-gapcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': + '@tiptap/extension-gapcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-hard-break@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': + '@tiptap/extension-hard-break@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) - '@tiptap/extension-heading@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': + '@tiptap/extension-heading@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) - '@tiptap/extension-highlight@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': + '@tiptap/extension-highlight@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) - '@tiptap/extension-history@3.17.1(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': + '@tiptap/extension-history@3.17.1(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-horizontal-rule@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': + '@tiptap/extension-horizontal-rule@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) '@tiptap/pm': 3.17.1 - '@tiptap/extension-image@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': + '@tiptap/extension-image@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) - '@tiptap/extension-italic@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': + '@tiptap/extension-italic@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) - '@tiptap/extension-link@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': + '@tiptap/extension-link@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) '@tiptap/pm': 3.17.1 linkifyjs: 4.3.2 - '@tiptap/extension-list-item@3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': + '@tiptap/extension-list-item@3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-list-keymap@3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': + '@tiptap/extension-list-keymap@3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': + '@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) '@tiptap/pm': 3.17.1 - '@tiptap/extension-ordered-list@3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': + '@tiptap/extension-ordered-list@3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-paragraph@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': + '@tiptap/extension-paragraph@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) - '@tiptap/extension-placeholder@3.17.1(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': + '@tiptap/extension-placeholder@3.17.1(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-strike@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': + '@tiptap/extension-strike@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) - '@tiptap/extension-subscript@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': + '@tiptap/extension-subscript@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) '@tiptap/pm': 3.17.1 - '@tiptap/extension-superscript@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': + '@tiptap/extension-superscript@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) '@tiptap/pm': 3.17.1 - '@tiptap/extension-table@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': + '@tiptap/extension-table@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) '@tiptap/pm': 3.17.1 - '@tiptap/extension-text-align@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': + '@tiptap/extension-text-align@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) - '@tiptap/extension-text-style@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': + '@tiptap/extension-text-style@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) - '@tiptap/extension-text@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': + '@tiptap/extension-text@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) - '@tiptap/extension-typography@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': + '@tiptap/extension-typography@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) - '@tiptap/extension-underline@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': + '@tiptap/extension-underline@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) - '@tiptap/extension-unique-id@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': + '@tiptap/extension-unique-id@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) '@tiptap/pm': 3.17.1 uuid: 10.0.0 - '@tiptap/extension-youtube@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': + '@tiptap/extension-youtube@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) - '@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': + '@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) '@tiptap/pm': 3.17.1 - '@tiptap/html@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(happy-dom@20.1.0)': + '@tiptap/html@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(happy-dom@20.1.0)': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) '@tiptap/pm': 3.17.1 happy-dom: 20.1.0 @@ -15104,9 +15107,9 @@ snapshots: prosemirror-transform: 1.10.4 prosemirror-view: 1.40.0 - '@tiptap/react@3.17.1(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tiptap/react@3.17.1(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) '@tiptap/pm': 3.17.1 '@types/react': 18.3.12 '@types/react-dom': 18.3.1 @@ -15116,41 +15119,41 @@ snapshots: react-dom: 18.3.1(react@18.3.1) use-sync-external-store: 1.6.0(react@18.3.1) optionalDependencies: - '@tiptap/extension-bubble-menu': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-floating-menu': 3.19.0(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-bubble-menu': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-floating-menu': 3.19.0(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) transitivePeerDependencies: - '@floating-ui/dom' '@tiptap/starter-kit@3.17.1': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-blockquote': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) - '@tiptap/extension-bold': 3.17.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) - '@tiptap/extension-bullet-list': 3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) - '@tiptap/extension-code': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) - '@tiptap/extension-code-block': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-document': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) - '@tiptap/extension-dropcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) - '@tiptap/extension-gapcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) - '@tiptap/extension-hard-break': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) - '@tiptap/extension-heading': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) - '@tiptap/extension-horizontal-rule': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-italic': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) - '@tiptap/extension-link': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-list-item': 3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) - '@tiptap/extension-list-keymap': 3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) - '@tiptap/extension-ordered-list': 3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) - '@tiptap/extension-paragraph': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) - '@tiptap/extension-strike': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) - '@tiptap/extension-text': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) - '@tiptap/extension-underline': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) - '@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) + '@tiptap/extension-blockquote': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) + '@tiptap/extension-bold': 3.17.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) + '@tiptap/extension-bullet-list': 3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + '@tiptap/extension-code': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) + '@tiptap/extension-code-block': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-document': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) + '@tiptap/extension-dropcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + '@tiptap/extension-gapcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + '@tiptap/extension-hard-break': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) + '@tiptap/extension-heading': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) + '@tiptap/extension-horizontal-rule': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-italic': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) + '@tiptap/extension-link': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-list-item': 3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + '@tiptap/extension-list-keymap': 3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + '@tiptap/extension-ordered-list': 3.19.0(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + '@tiptap/extension-paragraph': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) + '@tiptap/extension-strike': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) + '@tiptap/extension-text': 3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) + '@tiptap/extension-underline': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1)) + '@tiptap/extensions': 3.19.0(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/pm': 3.17.1 - '@tiptap/suggestion@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': + '@tiptap/suggestion@3.17.1(@tiptap/core@3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/core': 3.17.1(patch_hash=efe36d923d71b90e115d2d468ea1ddaf04a78c2e43c811a1a4b667989c39ea00)(@tiptap/pm@3.17.1) '@tiptap/pm': 3.17.1 '@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)': From 5de1c8e3eda9c9fd31b76ad17f1c6851fe1702fd Mon Sep 17 00:00:00 2001 From: Olivier Lambert Date: Tue, 24 Feb 2026 16:51:24 +0100 Subject: [PATCH 07/58] fix: inline code input rule deletes character before opening backtick (#1923) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upstream TipTap Code extension input rule regex /(^|[^`])`([^`]+)`(?!`)$/ uses a capture group (^|[^`]) that includes the character preceding the opening backtick in the full match. When markInputRule processes this, it deletes everything from the match start to the code content, which removes that preceding character along with the backtick delimiters. For example, typing foo(`bar` would result in foo`bar` (formatted) instead of the expected foo(`bar` (formatted) — the ( is lost. Fix: disable the built-in Code extension from StarterKit and register it separately with a corrected regex that uses a lookbehind assertion (?:^|(?<=[^`])) instead of a capture group. The lookbehind asserts the preceding character without including it in the match, so markInputRule only deletes the backtick delimiters. Functionally tested on Firefox and Chrome. Co-authored-by: Claude Opus 4.6 --- .../features/editor/extensions/extensions.ts | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 687e76f9..9610e791 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -1,4 +1,6 @@ +import { markInputRule } from "@tiptap/core"; import { StarterKit } from "@tiptap/starter-kit"; +import { Code } from "@tiptap/extension-code"; import { TextAlign } from "@tiptap/extension-text-align"; import { TaskList, TaskItem } from "@tiptap/extension-list"; import { Placeholder, CharacterCount } from "@tiptap/extensions"; @@ -113,10 +115,24 @@ export const mainExtensions = [ color: "#70CFF8", }, codeBlock: false, - code: { - HTMLAttributes: { - spellcheck: false, - }, + code: false, + }), + // Override TipTap's Code extension to fix the inline code input rule. + // The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character + // before the opening backtick as part of the match, causing markInputRule + // to delete it. Using a lookbehind avoids including it in the match. + Code.configure({ + HTMLAttributes: { + spellcheck: false, + }, + }).extend({ + addInputRules() { + return [ + markInputRule({ + find: /(?:^|(?<=[^`]))`([^`]+)`(?!`)$/, + type: this.type, + }), + ]; }, }), SharedStorage, From b5803f42dac1f7c69673a55c108ae2155b7bcc44 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:53:38 +0000 Subject: [PATCH 08/58] xwiki html import cleanup (#1969) --- .../src/integrations/import/utils/import-formatter.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/server/src/integrations/import/utils/import-formatter.ts b/apps/server/src/integrations/import/utils/import-formatter.ts index 59f5eeec..360e22de 100644 --- a/apps/server/src/integrations/import/utils/import-formatter.ts +++ b/apps/server/src/integrations/import/utils/import-formatter.ts @@ -50,6 +50,7 @@ export async function formatImportHtml(opts: { } notionFormatter($, $root); + xwikiFormatter($, $root); defaultHtmlFormatter($, $root); const backlinks = await rewriteInternalLinksToMentionHtml( @@ -69,6 +70,14 @@ export async function formatImportHtml(opts: { }; } +export function xwikiFormatter($: CheerioAPI, $root: Cheerio) { + const $content = $root.find('#xwikicontent'); + if ($content.length) { + $root.children().remove(); + $root.append($content.contents()); + } +} + export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio) { $root.find('a[href]').each((_, el) => { const $el = $(el); From e0a85215662ba86b605bf5ec0e592fdf96fd1ecc Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:31:01 +0000 Subject: [PATCH 09/58] enhance columns --- .../components/columns/columns-menu.tsx | 98 ++++++++++++++++++- .../src/features/editor/styles/columns.css | 12 ++- .../editor-ext/src/lib/columns/columns.ts | 45 ++++++++- 3 files changed, 149 insertions(+), 6 deletions(-) diff --git a/apps/client/src/features/editor/components/columns/columns-menu.tsx b/apps/client/src/features/editor/components/columns/columns-menu.tsx index 5c9e7607..b0c94c90 100644 --- a/apps/client/src/features/editor/components/columns/columns-menu.tsx +++ b/apps/client/src/features/editor/components/columns/columns-menu.tsx @@ -1,7 +1,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; -import React, { useCallback, useState } from "react"; -import { Node as PMNode } from "prosemirror-model"; +import React, { useCallback, useRef, useState } from "react"; +import { DOMSerializer, Node as PMNode } from "prosemirror-model"; import { EditorMenuProps, ShouldShowProps, @@ -16,6 +16,8 @@ import { IconLayoutSidebar, IconLayoutSidebarRight, IconLayoutAlignCenter, + IconCopy, + IconTrash, } from "@tabler/icons-react"; import { isTextSelected } from "@docmost/editor-ext"; import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext"; @@ -67,6 +69,8 @@ function getPresetsForCount(count: number): LayoutPreset[] { export function ColumnsMenu({ editor }: EditorMenuProps) { const { t } = useTranslation(); const [isCountOpen, setIsCountOpen] = useState(false); + const [copied, setCopied] = useState(false); + const copyTimerRef = useRef>(); const nodesWithMenus = [ "callout", @@ -187,6 +191,68 @@ export function ColumnsMenu({ editor }: EditorMenuProps) { [editor], ); + const handleCopy = useCallback(() => { + const { state } = editor; + const parent = findParentNode( + (node: PMNode) => node.type.name === "columns", + )(state.selection); + if (!parent) return; + + const serializer = DOMSerializer.fromSchema(state.schema); + const dom = serializer.serializeNode(parent.node); + const wrapper = document.createElement("div"); + wrapper.appendChild(dom); + + const onSuccess = () => { + clearTimeout(copyTimerRef.current); + setCopied(true); + copyTimerRef.current = setTimeout(() => setCopied(false), 1500); + }; + + if (navigator.clipboard?.write) { + navigator.clipboard + .write([ + new ClipboardItem({ + "text/html": new Blob([wrapper.innerHTML], { type: "text/html" }), + "text/plain": new Blob([parent.node.textContent], { + type: "text/plain", + }), + }), + ]) + .then(onSuccess) + .catch(execCommandFallback); + } else { + execCommandFallback(); + } + + function execCommandFallback() { + wrapper.style.position = "fixed"; + wrapper.style.left = "-9999px"; + document.body.appendChild(wrapper); + const range = document.createRange(); + range.selectNodeContents(wrapper); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + document.execCommand("copy"); + sel?.removeAllRanges(); + document.body.removeChild(wrapper); + editor.view.focus(); + onSuccess(); + } + }, [editor]); + + const handleDelete = useCallback(() => { + const { state } = editor; + const parent = findParentNode( + (node: PMNode) => node.type.name === "columns", + )(state.selection); + if (!parent) return; + const { tr } = state; + tr.delete(parent.pos, parent.pos + parent.node.nodeSize); + editor.view.dispatch(tr); + }, [editor]); + const columnCount = editorState?.columnCount || 2; const currentLayout = editorState?.layout || "two_equal"; const presets = getPresetsForCount(columnCount); @@ -259,6 +325,34 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
))} + +
+ + + + {copied ? ( + + ) : ( + + )} + + + + + + + +
); diff --git a/apps/client/src/features/editor/styles/columns.css b/apps/client/src/features/editor/styles/columns.css index fac034f6..ff836067 100644 --- a/apps/client/src/features/editor/styles/columns.css +++ b/apps/client/src/features/editor/styles/columns.css @@ -1,12 +1,17 @@ div[data-type="columns"] { display: flex; margin: 0.75rem 0; - padding: 0.5em; + padding: 0.5em 0; } div[data-type="columns"] > div[data-type="column"] { flex: 1; min-width: 0; + padding-right: 1rem; +} + +div[data-type="columns"] > div[data-type="column"]:last-child { + padding-right: 0; } div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] { @@ -16,6 +21,9 @@ div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] { } div[data-type="columns"]:hover + > div[data-type="column"] + + div[data-type="column"], +div[data-type="columns"].has-focus > div[data-type="column"] + div[data-type="column"] { border-left: 1px solid @@ -66,7 +74,7 @@ div[data-type="columns"][data-layout="three_with_sidebars"] } /* Stack columns vertically on small viewports */ -@media (max-width: 820px) { +@media (max-width: 680px) { div[data-type="columns"] { flex-direction: column; } diff --git a/packages/editor-ext/src/lib/columns/columns.ts b/packages/editor-ext/src/lib/columns/columns.ts index f2682a73..9b5c93d1 100644 --- a/packages/editor-ext/src/lib/columns/columns.ts +++ b/packages/editor-ext/src/lib/columns/columns.ts @@ -1,6 +1,7 @@ import { Node, mergeAttributes, findParentNode } from "@tiptap/core"; import { Fragment, Node as PMNode } from "prosemirror-model"; -import { TextSelection } from "prosemirror-state"; +import { Plugin, PluginKey, TextSelection } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; export type ColumnsLayout = | "two_equal" @@ -173,7 +174,21 @@ export const Columns = Node.create({ } let mergedContent = columnsNode.child(count - 1).content; for (let j = count; j < currentCount; j++) { - mergedContent = mergedContent.append(columnsNode.child(j).content); + const col = columnsNode.child(j); + const nonEmpty: PMNode[] = []; + col.content.forEach((child) => { + if ( + child.type.name !== "paragraph" || + child.content.size > 0 + ) { + nonEmpty.push(child); + } + }); + if (nonEmpty.length > 0) { + mergedContent = mergedContent.append( + Fragment.from(nonEmpty), + ); + } } newChildren.push(columnType.create(null, mergedContent)); } @@ -184,6 +199,9 @@ export const Columns = Node.create({ Fragment.from(newChildren), ); tr.replaceWith(parentPos, parentPos + columnsNode.nodeSize, newNode); + tr.setSelection( + TextSelection.near(tr.doc.resolve(parentPos + 1), 1), + ); return true; }, @@ -193,4 +211,27 @@ export const Columns = Node.create({ commands.updateAttributes("columns", { layout }), }; }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("columnsFocus"), + props: { + decorations: (state) => { + const parent = findParentNode( + (node) => node.type.name === "columns", + )(state.selection); + if (!parent) return DecorationSet.empty; + return DecorationSet.create(state.doc, [ + Decoration.node( + parent.pos, + parent.pos + parent.node.nodeSize, + { class: "has-focus" }, + ), + ]); + }, + }, + }), + ]; + }, }); From 22f33bab7c2fead1d2d90dc0d1af946984ec38ea Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:41:54 +0000 Subject: [PATCH 10/58] cleanups --- .../editor/components/callout/callout-menu.tsx | 2 +- .../editor/components/columns/columns-menu.tsx | 18 +++++++++--------- .../editor/components/drawio/drawio-menu.tsx | 2 +- .../components/excalidraw/excalidraw-menu.tsx | 2 +- .../editor/components/image/image-menu.tsx | 2 +- .../editor/components/video/video-menu.tsx | 2 +- packages/editor-ext/src/lib/columns/column.ts | 2 +- packages/editor-ext/src/lib/columns/columns.ts | 6 +++--- packages/editor-ext/src/lib/heading/heading.ts | 4 ++-- 9 files changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/client/src/features/editor/components/callout/callout-menu.tsx b/apps/client/src/features/editor/components/callout/callout-menu.tsx index bdc71993..cdfc3216 100644 --- a/apps/client/src/features/editor/components/callout/callout-menu.tsx +++ b/apps/client/src/features/editor/components/callout/callout-menu.tsx @@ -1,7 +1,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import React, { useCallback } from "react"; -import { Node as PMNode } from "prosemirror-model"; +import { Node as PMNode } from "@tiptap/pm/model"; import { EditorMenuProps, ShouldShowProps, diff --git a/apps/client/src/features/editor/components/columns/columns-menu.tsx b/apps/client/src/features/editor/components/columns/columns-menu.tsx index b0c94c90..0ee99508 100644 --- a/apps/client/src/features/editor/components/columns/columns-menu.tsx +++ b/apps/client/src/features/editor/components/columns/columns-menu.tsx @@ -1,7 +1,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import React, { useCallback, useRef, useState } from "react"; -import { DOMSerializer, Node as PMNode } from "prosemirror-model"; +import { DOMSerializer, Node as PMNode } from "@tiptap/pm/model"; import { EditorMenuProps, ShouldShowProps, @@ -56,8 +56,7 @@ const threeColumnPresets: LayoutPreset[] = [ label: "Left wide", icon: IconLayoutSidebarRight, }, - { layout: "three_right_wide", label: "Right wide", icon: IconLayoutSidebar - }, + { layout: "three_right_wide", label: "Right wide", icon: IconLayoutSidebar }, ]; function getPresetsForCount(count: number): LayoutPreset[] { @@ -243,14 +242,11 @@ export function ColumnsMenu({ editor }: EditorMenuProps) { }, [editor]); const handleDelete = useCallback(() => { - const { state } = editor; const parent = findParentNode( (node: PMNode) => node.type.name === "columns", - )(state.selection); + )(editor.state.selection); if (!parent) return; - const { tr } = state; - tr.delete(parent.pos, parent.pos + parent.node.nodeSize); - editor.view.dispatch(tr); + editor.chain().focus().setNodeSelection(parent.pos).deleteSelection().run(); }, [editor]); const columnCount = editorState?.columnCount || 2; @@ -328,7 +324,11 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
- + ; diff --git a/packages/editor-ext/src/lib/columns/columns.ts b/packages/editor-ext/src/lib/columns/columns.ts index 9b5c93d1..9e37d5a6 100644 --- a/packages/editor-ext/src/lib/columns/columns.ts +++ b/packages/editor-ext/src/lib/columns/columns.ts @@ -1,7 +1,7 @@ import { Node, mergeAttributes, findParentNode } from "@tiptap/core"; -import { Fragment, Node as PMNode } from "prosemirror-model"; -import { Plugin, PluginKey, TextSelection } from "prosemirror-state"; -import { Decoration, DecorationSet } from "prosemirror-view"; +import { Fragment, Node as PMNode } from "@tiptap/pm/model"; +import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; export type ColumnsLayout = | "two_equal" diff --git a/packages/editor-ext/src/lib/heading/heading.ts b/packages/editor-ext/src/lib/heading/heading.ts index 26f6f0d4..c9e3703f 100644 --- a/packages/editor-ext/src/lib/heading/heading.ts +++ b/packages/editor-ext/src/lib/heading/heading.ts @@ -2,8 +2,8 @@ import TiptapHeading, { HeadingOptions as TiptapHeadingOptions, } from "@tiptap/extension-heading"; import { mergeAttributes } from "@tiptap/react"; -import { Decoration, DecorationSet } from "prosemirror-view"; -import { Plugin } from "prosemirror-state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { Plugin } from "@tiptap/pm/state"; import { copyToClipboard } from "../utils"; const copyIcon = ``; From 59e945562d1696f0a9b0be490b041bd99dac783e Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Thu, 26 Feb 2026 19:49:10 +0000 Subject: [PATCH 11/58] feat(ee): page-level access/permissions (#1971) * Add page_hierarchy table * feat(ee): page-level permissions * pagination * rename migration fixes * fix * tabs * fix theme * cleanup * sync * page permissions notification * other fixes * sharing disbled * fix column nodes * toggle error handling --- .../public/locales/en-US/translation.json | 29 +- .../src/ee/licence/components/oss-details.tsx | 2 +- .../components/general-access-select.tsx | 112 ++ .../components/page-permission-item.tsx | 107 ++ .../components/page-permission-list.tsx | 164 +++ .../components/page-permission-tab.tsx | 189 +++ .../components/page-permission.module.css | 128 ++ .../components/page-share-modal.tsx | 132 ++ .../components/publish-tab.tsx | 254 ++++ .../hooks/use-page-permission.ts | 26 + apps/client/src/ee/page-permission/index.ts | 11 + .../queries/page-permission-query.ts | 175 +++ .../services/page-permission-service.ts | 55 + .../types/page-permission-role-data.ts | 20 + .../types/page-permission.types.ts | 61 + .../components/comment-list-with-tabs.tsx | 37 +- .../src/features/editor/title-editor.tsx | 13 +- .../components/notification-item.tsx | 4 + .../notification/types/notification.types.ts | 3 +- .../components/header/page-header-menu.tsx | 4 +- .../page/tree/components/space-tree.tsx | 122 +- apps/client/src/features/page/tree/types.ts | 1 + .../src/features/page/tree/utils/utils.ts | 1 + .../src/features/page/types/page.types.ts | 5 + .../features/share/components/share-modal.tsx | 47 +- .../src/features/share/queries/share-query.ts | 5 +- .../space/components/multi-member-select.tsx | 4 +- .../space/components/settings-modal.tsx | 5 +- .../user/components/page-state-pref.tsx | 15 +- .../user/components/page-width-pref.tsx | 8 +- apps/client/src/main.tsx | 1 - apps/client/src/pages/page/page.tsx | 21 +- apps/client/src/theme.ts | 12 + apps/server/package.json | 9 +- apps/server/src/app.module.ts | 15 + .../src/collaboration/collaboration.util.ts | 4 +- .../extensions/authentication.extension.ts | 33 +- .../src/common/helpers/types/permission.ts | 9 + .../core/attachment/attachment.controller.ts | 22 +- .../src/core/comment/comment.controller.ts | 76 +- apps/server/src/core/core.module.ts | 2 + .../notification/notification.constants.ts | 1 + .../core/notification/notification.module.ts | 3 +- .../notification/notification.processor.ts | 12 +- .../services/comment.notification.ts | 18 +- .../services/page.notification.ts | 63 +- .../page/page-access/page-access.module.ts | 9 + .../page/page-access/page-access.service.ts | 102 ++ apps/server/src/core/page/page.controller.ts | 138 +- .../src/core/page/services/page.service.ts | 294 ++++- apps/server/src/core/search/search.service.ts | 32 +- .../server/src/core/share/share.controller.ts | 47 +- apps/server/src/core/share/share.service.ts | 27 +- apps/server/src/database/database.module.ts | 3 + .../20260224T233803-page-permissions.ts | 90 ++ .../database/pagination/cursor-pagination.ts | 15 + .../database/repos/group/group-user.repo.ts | 10 + .../repos/page/page-permission.repo.ts | 1109 +++++++++++++++++ .../src/database/repos/page/page.repo.ts | 76 +- .../repos/page/types/page-permission.types.ts | 23 + apps/server/src/database/types/db.d.ts | 25 + .../server/src/database/types/entity.types.ts | 12 + apps/server/src/ee | 2 +- .../integrations/export/export.controller.ts | 11 +- .../src/integrations/export/export.service.ts | 152 ++- .../queue/constants/queue.constants.ts | 1 + .../queue/constants/queue.interface.ts | 12 +- .../emails/permission-granted-email.tsx | 45 + apps/server/src/ws/ws-tree.service.ts | 47 + apps/server/src/ws/ws.gateway.ts | 37 +- apps/server/src/ws/ws.module.ts | 9 +- apps/server/src/ws/ws.service.ts | 157 +++ apps/server/src/ws/ws.utils.ts | 17 + apps/server/test/jest-e2e.json | 5 + pnpm-lock.yaml | 51 + 75 files changed, 4235 insertions(+), 363 deletions(-) create mode 100644 apps/client/src/ee/page-permission/components/general-access-select.tsx create mode 100644 apps/client/src/ee/page-permission/components/page-permission-item.tsx create mode 100644 apps/client/src/ee/page-permission/components/page-permission-list.tsx create mode 100644 apps/client/src/ee/page-permission/components/page-permission-tab.tsx create mode 100644 apps/client/src/ee/page-permission/components/page-permission.module.css create mode 100644 apps/client/src/ee/page-permission/components/page-share-modal.tsx create mode 100644 apps/client/src/ee/page-permission/components/publish-tab.tsx create mode 100644 apps/client/src/ee/page-permission/hooks/use-page-permission.ts create mode 100644 apps/client/src/ee/page-permission/index.ts create mode 100644 apps/client/src/ee/page-permission/queries/page-permission-query.ts create mode 100644 apps/client/src/ee/page-permission/services/page-permission-service.ts create mode 100644 apps/client/src/ee/page-permission/types/page-permission-role-data.ts create mode 100644 apps/client/src/ee/page-permission/types/page-permission.types.ts create mode 100644 apps/server/src/core/page/page-access/page-access.module.ts create mode 100644 apps/server/src/core/page/page-access/page-access.service.ts create mode 100644 apps/server/src/database/migrations/20260224T233803-page-permissions.ts create mode 100644 apps/server/src/database/repos/page/page-permission.repo.ts create mode 100644 apps/server/src/database/repos/page/types/page-permission.types.ts create mode 100644 apps/server/src/integrations/transactional/emails/permission-granted-email.tsx create mode 100644 apps/server/src/ws/ws-tree.service.ts create mode 100644 apps/server/src/ws/ws.service.ts create mode 100644 apps/server/src/ws/ws.utils.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 25848e5d..c8f979b7 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -429,6 +429,8 @@ "Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level", "Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.", "Requires an enterprise license": "Requires an enterprise license", + "Page permissions": "Page permissions", + "Control who can view and edit individual pages. Available with an enterprise license.": "Control who can view and edit individual pages. Available with an enterprise license.", "Enable public sharing": "Enable public sharing", "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Are you sure you want to enable public sharing? Members will be able to share pages publicly.", "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.", @@ -622,8 +624,33 @@ "commented on a page": "commented on a page", "resolved a comment": "resolved a comment", "mentioned you on a page": "mentioned you on a page", + "gave you edit access to a page": "gave you edit access to a page", + "gave you view access to a page": "gave you view access to a page", "Today": "Today", "Yesterday": "Yesterday", "This week": "This week", - "Older": "Older" + "Older": "Older", + "Restricted page": "Restricted page", + "Restricted pages cannot be shared publicly.": "Restricted pages cannot be shared publicly.", + "Restricted by parent": "Restricted by parent", + "Restricted": "Restricted", + "Open": "Open", + "Inherits restrictions from ancestor page": "Inherits restrictions from ancestor page", + "Only people listed below can access this page": "Only people listed below can access this page", + "Everyone in this space can access": "Everyone in this space can access", + "No additional restrictions on this page": "No additional restrictions on this page", + "Only specific people can access": "Only specific people can access", + "Use only inherited restrictions": "Use only inherited restrictions", + "Add restrictions on top of inherited": "Add restrictions on top of inherited", + "Inherited restriction": "Inherited restriction", + "Access limited by": "Access limited by", + "Restrict access to control who can view and edit this page": "Restrict access to control who can view and edit this page", + "Add additional restrictions specific to this page": "Add additional restrictions specific to this page", + "Access": "Access", + "People with access": "People with access", + "Remove all": "Remove all", + "Remove access": "Remove access", + "Remove all access": "Remove all access", + "Are you sure you want to remove this member's access to the page?": "Are you sure you want to remove this member's access to the page?", + "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "Are you sure you want to remove all specific access? This will make the page open to everyone in the space." } diff --git a/apps/client/src/ee/licence/components/oss-details.tsx b/apps/client/src/ee/licence/components/oss-details.tsx index 5a3fd6c4..3f31e1a4 100644 --- a/apps/client/src/ee/licence/components/oss-details.tsx +++ b/apps/client/src/ee/licence/components/oss-details.tsx @@ -11,7 +11,7 @@ export default function OssDetails() { withTableBorder > - To unlock enterprise features like AI, SSO, MFA, Resolve comments, contact sales@docmost.com. + To unlock enterprise features like SSO, AI, Page-level permissions, SSO, MFA, Resolve comments, contact sales@docmost.com. diff --git a/apps/client/src/ee/page-permission/components/general-access-select.tsx b/apps/client/src/ee/page-permission/components/general-access-select.tsx new file mode 100644 index 00000000..8bee6e4b --- /dev/null +++ b/apps/client/src/ee/page-permission/components/general-access-select.tsx @@ -0,0 +1,112 @@ +import { Group, Menu, Text, UnstyledButton } from "@mantine/core"; +import { + IconChevronDown, + IconLock, + IconShieldLock, + IconCheck, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import classes from "./page-permission.module.css"; + +type AccessLevel = "open" | "restricted"; + +type GeneralAccessSelectProps = { + value: AccessLevel; + onChange: (value: AccessLevel) => void; + disabled?: boolean; + hasInheritedRestriction?: boolean; +}; + +export function GeneralAccessSelect({ + value, + onChange, + disabled, + hasInheritedRestriction, +}: GeneralAccessSelectProps) { + const { t } = useTranslation(); + + const isDirectlyRestricted = value === "restricted"; + const showInheritedState = hasInheritedRestriction && !isDirectlyRestricted; + + const currentLabel = showInheritedState + ? t("Restricted by parent") + : isDirectlyRestricted + ? t("Restricted") + : t("Open"); + + const currentDescription = showInheritedState + ? t("Inherits restrictions from ancestor page") + : isDirectlyRestricted + ? t("Only people listed below can access this page") + : t("Everyone in this space can access"); + + const CurrentIcon = showInheritedState + ? IconShieldLock + : isDirectlyRestricted + ? IconLock + : IconShieldLock; + + const accessOptions = [ + { + value: "open" as const, + label: hasInheritedRestriction ? t("Restricted by parent") : t("Open"), + description: hasInheritedRestriction + ? t("Use only inherited restrictions") + : t("No additional restrictions on this page"), + icon: IconShieldLock, + }, + { + value: "restricted" as const, + label: t("Restricted"), + description: hasInheritedRestriction + ? t("Add restrictions on top of inherited") + : t("Only specific people can access"), + icon: IconLock, + }, + ]; + + return ( + + + +
+ +
+
+ + + {currentLabel} + + {!disabled && } + + + {currentDescription} + +
+
+
+ + + {accessOptions.map((option) => ( + onChange(option.value)} + leftSection={} + rightSection={ + option.value === value ? : null + } + > +
+ {option.label} + + {option.description} + +
+
+ ))} +
+
+ ); +} diff --git a/apps/client/src/ee/page-permission/components/page-permission-item.tsx b/apps/client/src/ee/page-permission/components/page-permission-item.tsx new file mode 100644 index 00000000..b0a5c5f4 --- /dev/null +++ b/apps/client/src/ee/page-permission/components/page-permission-item.tsx @@ -0,0 +1,107 @@ +import { Menu, Text, UnstyledButton, Group } from "@mantine/core"; +import { IconChevronDown, IconCheck } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useAtomValue } from "jotai"; +import { CustomAvatar } from "@/components/ui/custom-avatar"; +import { AutoTooltipText } from "@/components/ui/auto-tooltip-text"; +import { IconGroupCircle } from "@/components/icons/icon-people-circle"; +import { userAtom } from "@/features/user/atoms/current-user-atom"; +import { formatMemberCount } from "@/lib"; +import { + IPagePermissionMember, + PagePermissionRole, +} from "@/ee/page-permission/types/page-permission.types"; +import { + pagePermissionRoleData, + getPagePermissionRoleLabel, +} from "@/ee/page-permission/types/page-permission-role-data"; +import classes from "./page-permission.module.css"; + +type PagePermissionItemProps = { + member: IPagePermissionMember; + onRoleChange: (memberId: string, type: "user" | "group", role: string) => void; + onRemove: (memberId: string, type: "user" | "group") => void; + disabled?: boolean; +}; + +export function PagePermissionItem({ + member, + onRoleChange, + onRemove, + disabled, +}: PagePermissionItemProps) { + const { t } = useTranslation(); + const currentUser = useAtomValue(userAtom); + const isCurrentUser = member.type === "user" && member.id === currentUser?.id; + const roleLabel = getPagePermissionRoleLabel(member.role); + + return ( +
+
+ {member.type === "user" && ( + + )} + {member.type === "group" && } + +
+ + {member.name} + {isCurrentUser && ({t("You")})} + + + {member.type === "user" ? member.email : formatMemberCount(member.memberCount, t)} + +
+
+ +
+ {isCurrentUser || disabled ? ( + + {t(roleLabel)} + + ) : ( + + + + + {t(roleLabel)} + + + + + + + {pagePermissionRoleData.map((role) => ( + onRoleChange(member.id, member.type, role.value)} + rightSection={ + role.value === member.role ? : null + } + > +
+ {t(role.label)} + + {t(role.description)} + +
+
+ ))} + + onRemove(member.id, member.type)} + > + {t("Remove access")} + +
+
+ )} +
+
+ ); +} diff --git a/apps/client/src/ee/page-permission/components/page-permission-list.tsx b/apps/client/src/ee/page-permission/components/page-permission-list.tsx new file mode 100644 index 00000000..e586b968 --- /dev/null +++ b/apps/client/src/ee/page-permission/components/page-permission-list.tsx @@ -0,0 +1,164 @@ +import { Center, Group, Loader, ScrollArea, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { useAtomValue } from "jotai"; +import { useEffect, useRef } from "react"; +import { modals } from "@mantine/modals"; +import { userAtom } from "@/features/user/atoms/current-user-atom"; +import { PagePermissionRole } from "@/ee/page-permission/types/page-permission.types"; +import { + usePagePermissionsQuery, + useRemovePagePermissionMutation, + useUpdatePagePermissionRoleMutation, +} from "@/ee/page-permission/queries/page-permission-query"; +import { PagePermissionItem } from "@/ee/page-permission"; +import classes from "./page-permission.module.css"; + +type PagePermissionListProps = { + pageId: string; + canManage: boolean; + onRemoveAll?: () => void; +}; + +export function PagePermissionList({ + pageId, + canManage, + onRemoveAll, +}: PagePermissionListProps) { + const { t } = useTranslation(); + const currentUser = useAtomValue(userAtom); + const updateRoleMutation = useUpdatePagePermissionRoleMutation(); + const removeMutation = useRemovePagePermissionMutation(); + + const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = + usePagePermissionsQuery(pageId); + + const sentinelRef = useRef(null); + const viewportRef = useRef(null); + + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { root: viewportRef.current, threshold: 0.1 }, + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const handleRoleChange = async ( + memberId: string, + type: "user" | "group", + newRole: string, + ) => { + await updateRoleMutation.mutateAsync({ + pageId, + role: newRole as PagePermissionRole, + ...(type === "user" ? { userId: memberId } : { groupId: memberId }), + }); + }; + + const handleRemove = (memberId: string, type: "user" | "group") => { + modals.openConfirmModal({ + title: t("Remove access"), + children: ( + + {t( + "Are you sure you want to remove this member's access to the page?", + )} + + ), + centered: true, + labels: { confirm: t("Remove"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: async () => { + await removeMutation.mutateAsync({ + pageId, + ...(type === "user" + ? { userIds: [memberId] } + : { groupIds: [memberId] }), + }); + }, + }); + }; + + const handleRemoveAll = () => { + modals.openConfirmModal({ + title: t("Remove all access"), + children: ( + + {t( + "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.", + )} + + ), + centered: true, + labels: { confirm: t("Remove all"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: () => onRemoveAll?.(), + }); + }; + + const members = data?.pages.flatMap((page) => page.items) ?? []; + + const sortedMembers = [...members].sort((a, b) => { + if (a.type === "user" && a.id === currentUser?.id) return -1; + if (b.type === "user" && b.id === currentUser?.id) return 1; + if (a.type === "group" && b.type === "user") return -1; + if (a.type === "user" && b.type === "group") return 1; + return 0; + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (members.length === 0) { + return null; + } + + return ( + <> + + + {t("People with access")} + + {canManage && members.length > 0 && ( + + {t("Remove all")} + + )} + + + + {sortedMembers.map((member) => ( + + ))} + +
+ + {isFetchingNextPage && ( +
+ +
+ )} + + + ); +} diff --git a/apps/client/src/ee/page-permission/components/page-permission-tab.tsx b/apps/client/src/ee/page-permission/components/page-permission-tab.tsx new file mode 100644 index 00000000..93f9277c --- /dev/null +++ b/apps/client/src/ee/page-permission/components/page-permission-tab.tsx @@ -0,0 +1,189 @@ +import { useState } from "react"; +import { + Box, + Button, + Divider, + Group, + Paper, + Select, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Link, useParams } from "react-router-dom"; +import { IconArrowRight, IconLock, IconShieldLock } from "@tabler/icons-react"; +import { MultiMemberSelect } from "@/features/space/components/multi-member-select"; +import { + IPageRestrictionInfo, + PagePermissionRole, +} from "@/ee/page-permission/types/page-permission.types"; +import { + useAddPagePermissionMutation, + useRestrictPageMutation, + useUnrestrictPageMutation, +} from "@/ee/page-permission/queries/page-permission-query"; +import { pagePermissionRoleData } from "@/ee/page-permission/types/page-permission-role-data"; +import { GeneralAccessSelect } from "@/ee/page-permission"; +import { PagePermissionList } from "@/ee/page-permission"; +import classes from "./page-permission.module.css"; +import { buildPageUrl } from "@/features/page/page.utils"; + +type PagePermissionTabProps = { + pageId: string; + restrictionInfo: IPageRestrictionInfo; +}; + +export function PagePermissionTab({ + pageId, + restrictionInfo, +}: PagePermissionTabProps) { + const { t } = useTranslation(); + const { spaceSlug } = useParams(); + const [memberIds, setMemberIds] = useState([]); + const [role, setRole] = useState(PagePermissionRole.WRITER); + + const restrictMutation = useRestrictPageMutation(); + const unrestrictMutation = useUnrestrictPageMutation(); + const addPermissionMutation = useAddPagePermissionMutation(); + + const hasInheritedRestriction = restrictionInfo.hasInheritedRestriction; + const hasDirectRestriction = restrictionInfo.hasDirectRestriction; + const canManage = restrictionInfo.userAccess.canManage; + + const handleDirectAccessChange = async (value: "open" | "restricted") => { + if (value === "restricted" && !hasDirectRestriction) { + await restrictMutation.mutateAsync(pageId); + } else if (value === "open" && hasDirectRestriction) { + await unrestrictMutation.mutateAsync(pageId); + } + }; + + const handleAddMembers = async () => { + if (memberIds.length === 0) return; + + const userIds = memberIds + .filter((id) => id.startsWith("user-")) + .map((id) => id.replace("user-", "")); + + const groupIds = memberIds + .filter((id) => id.startsWith("group-")) + .map((id) => id.replace("group-", "")); + + await addPermissionMutation.mutateAsync({ + pageId, + role: role as PagePermissionRole, + ...(userIds.length > 0 && { userIds }), + ...(groupIds.length > 0 && { groupIds }), + }); + + setMemberIds([]); + }; + + const handleRemoveAll = async () => { + await unrestrictMutation.mutateAsync(pageId); + }; + + return ( + + {hasInheritedRestriction && ( + + + + + + + + {t("Inherited restriction")} + + + + {t("Access limited by")} + + {restrictionInfo.inheritedFrom && ( + + + + {restrictionInfo.inheritedFrom.title || t("Untitled")} + + + + + )} + + + + + )} + + + + {!hasDirectRestriction && !hasInheritedRestriction && ( + + {t("Restrict access to control who can view and edit this page")} + + )} + {!hasDirectRestriction && hasInheritedRestriction && ( + + {t("Add additional restrictions specific to this page")} + + )} + + + {hasDirectRestriction && ( + <> + + + {canManage && ( + + + + + ({ + group: t(group.group), + items: group.items.map((item) => ({ + value: item.value, + label: t(item.label), + })), + }))} + value={eventFilter} + onChange={handleEventChange} + clearable + searchable + w={220} + size="sm" + /> + + { + if (!opened) resetRetentionForm(); + setSettingsOpen(opened); + }} + > + + + setSettingsOpen((o) => !o)}> + + + + + + + {t("Retention")} + + + {t("Logs older than this period are automatically deleted.")} + + + setRetentionAmount(val)} + min={1} + hideControls + size="sm" + w={60} + /> + { + if (value === "days" || value === "months" || value === "years") { + setRetentionUnit(value); + } + }} + size="sm" + style={{ flex: 1 }} + disabled={!hasAccess} + /> + + + +
+ ); +} diff --git a/apps/client/src/ee/security/pages/security.tsx b/apps/client/src/ee/security/pages/security.tsx index a32c5867..a9530fad 100644 --- a/apps/client/src/ee/security/pages/security.tsx +++ b/apps/client/src/ee/security/pages/security.tsx @@ -11,6 +11,7 @@ import AllowedDomains from "@/ee/security/components/allowed-domains.tsx"; import { useTranslation } from "react-i18next"; import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx"; import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx"; +import TrashRetention from "@/ee/security/components/trash-retention.tsx"; import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx"; import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; @@ -42,6 +43,13 @@ export default function Security() { )} + {!isCloud() && ( + <> + + + + )} + Single sign-on (SSO) diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index a8348052..cf074ee3 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -155,7 +155,9 @@ export function useDeletePageMutation() { }); }, onError: (error) => { - notifications.show({ message: t("Failed to delete page"), color: "red" }); + const message = + error["response"]?.data?.message || t("Failed to delete page"); + notifications.show({ message, color: "red" }); }, }); } diff --git a/apps/client/src/features/page/trash/components/trash.tsx b/apps/client/src/features/page/trash/components/trash.tsx index c5335cd2..ad2ba500 100644 --- a/apps/client/src/features/page/trash/components/trash.tsx +++ b/apps/client/src/features/page/trash/components/trash.tsx @@ -31,9 +31,12 @@ import TrashPageContentModal from "@/features/page/trash/components/trash-page-c import { UserInfo } from "@/components/common/user-info.tsx"; import Paginate from "@/components/common/paginate.tsx"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; export default function Trash() { const { t } = useTranslation(); + const [workspace] = useAtom(workspaceAtom); const { spaceSlug } = useParams(); const { cursor, goNext, goPrev } = useCursorPaginate(); const { data: space } = useGetSpaceBySlugQuery(spaceSlug); @@ -108,7 +111,7 @@ export default function Trash() { } variant="light" color="red"> - {t("Pages in trash will be permanently deleted after 30 days.")} + {t("Pages in trash will be permanently deleted after {{count}} days.", { count: workspace?.trashRetentionDays ?? 30 })} diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts index 18b8bdf9..82c06336 100644 --- a/apps/client/src/features/workspace/types/workspace.types.ts +++ b/apps/client/src/features/workspace/types/workspace.types.ts @@ -25,6 +25,7 @@ export interface IWorkspace { aiSearch?: boolean; generativeAi?: boolean; disablePublicSharing?: boolean; + trashRetentionDays?: number; } export interface IWorkspaceSettings { diff --git a/apps/server/package.json b/apps/server/package.json index 0e421a1d..31ba43c4 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -36,6 +36,7 @@ "@aws-sdk/client-s3": "3.982.0", "@aws-sdk/lib-storage": "3.982.0", "@aws-sdk/s3-request-presigner": "3.982.0", + "@clickhouse/client": "^1.17.0", "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^9.4.0", "@fastify/static": "^9.0.0", @@ -83,6 +84,7 @@ "mime-types": "^2.1.35", "msgpackr": "^1.11.8", "nanoid": "3.3.11", + "nestjs-cls": "^6.2.0", "nestjs-kysely": "^1.2.0", "nestjs-pino": "^4.5.0", "nodemailer": "^7.0.12", diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 13e28e18..fc1d2c8b 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { EnvironmentService } from './integrations/environment/environment.service'; +import { AuditActorInterceptor } from './common/interceptors/audit-actor.interceptor'; import { CoreModule } from './core/core.module'; import { EnvironmentModule } from './integrations/environment/environment.module'; import { CollaborationModule } from './collaboration/collaboration.module'; @@ -22,6 +24,7 @@ import { RedisConfigService } from './integrations/redis/redis-config.service'; import { CacheModule } from '@nestjs/cache-manager'; import KeyvRedis from '@keyv/redis'; import { LoggerModule } from './common/logger/logger.module'; +import { ClsModule } from 'nestjs-cls'; const enterpriseModules = []; try { @@ -39,6 +42,10 @@ try { @Module({ imports: [ + ClsModule.forRoot({ + global: true, + middleware: { mount: true }, + }), LoggerModule, CoreModule, DatabaseModule, @@ -77,6 +84,12 @@ try { ...enterpriseModules, ], controllers: [AppController], - providers: [AppService], + providers: [ + AppService, + { + provide: APP_INTERCEPTOR, + useClass: AuditActorInterceptor, + }, + ], }) export class AppModule {} diff --git a/apps/server/src/common/events/audit-events.ts b/apps/server/src/common/events/audit-events.ts new file mode 100644 index 00000000..6792590e --- /dev/null +++ b/apps/server/src/common/events/audit-events.ts @@ -0,0 +1,136 @@ +export const AuditEvent = { + // Workspace + WORKSPACE_CREATED: 'workspace.created', + WORKSPACE_UPDATED: 'workspace.updated', + WORKSPACE_INVITE_CREATED: 'workspace.invite_created', + WORKSPACE_INVITE_RESENT: 'workspace.invite_resent', + WORKSPACE_INVITE_REVOKED: 'workspace.invite_revoked', + + // User + USER_CREATED: 'user.created', + USER_DELETED: 'user.deleted', + USER_LOGIN: 'user.login', + USER_LOGOUT: 'user.logout', + USER_ROLE_CHANGED: 'user.role_changed', + USER_PASSWORD_CHANGED: 'user.password_changed', + USER_PASSWORD_RESET: 'user.password_reset', + USER_UPDATED: 'user.updated', + + // API Keys + API_KEY_CREATED: 'api_key.created', + API_KEY_UPDATED: 'api_key.updated', + API_KEY_DELETED: 'api_key.deleted', + + // Space + SPACE_CREATED: 'space.created', + SPACE_UPDATED: 'space.updated', + SPACE_DELETED: 'space.deleted', + SPACE_MEMBER_ADDED: 'space.member_added', + SPACE_MEMBER_REMOVED: 'space.member_removed', + SPACE_MEMBER_ROLE_CHANGED: 'space.member_role_changed', + + // Group + GROUP_CREATED: 'group.created', + GROUP_UPDATED: 'group.updated', + GROUP_DELETED: 'group.deleted', + GROUP_MEMBER_ADDED: 'group.member_added', + GROUP_MEMBER_REMOVED: 'group.member_removed', + + // Comment + COMMENT_CREATED: 'comment.created', + COMMENT_DELETED: 'comment.deleted', + + // Page + PAGE_CREATED: 'page.created', + PAGE_TRASHED: 'page.trashed', + PAGE_DELETED: 'page.deleted', + PAGE_RESTORED: 'page.restored', + PAGE_MOVED_TO_SPACE: 'page.moved_to_space', + PAGE_DUPLICATED: 'page.duplicated', + + // Share + SHARE_CREATED: 'share.created', + SHARE_DELETED: 'share.deleted', + + // Import / Export + PAGE_IMPORTED: 'page.imported', + PAGE_EXPORTED: 'page.exported', + SPACE_EXPORTED: 'space.exported', + + // SSO provider management + SSO_PROVIDER_CREATED: 'sso.provider_created', + SSO_PROVIDER_UPDATED: 'sso.provider_updated', + SSO_PROVIDER_DELETED: 'sso.provider_deleted', + + // MFA + USER_MFA_ENABLED: 'user.mfa_enabled', + USER_MFA_DISABLED: 'user.mfa_disabled', + USER_MFA_BACKUP_CODE_GENERATED: 'user.mfa_backup_code_generated', + + // License + LICENSE_ACTIVATED: 'license.activated', + LICENSE_REMOVED: 'license.removed', + + // Page permission + PAGE_RESTRICTED: 'page.restricted', + PAGE_RESTRICTION_REMOVED: 'page.restriction_removed', + PAGE_PERMISSION_ADDED: 'page.permission_added', + PAGE_PERMISSION_REMOVED: 'page.permission_removed', + + // Comment updates / resolve + COMMENT_UPDATED: 'comment.updated', + COMMENT_RESOLVED: 'comment.resolved', + COMMENT_REOPENED: 'comment.reopened', + + // Attachment + ATTACHMENT_UPLOADED: 'attachment.uploaded', + // ATTACHMENT_DELETED: 'attachment.deleted', +} as const; + +export type AuditEventType = (typeof AuditEvent)[keyof typeof AuditEvent]; + +export const EXCLUDED_AUDIT_EVENTS: Set = new Set([ + // AuditEvent.PAGE_MOVED_TO_SPACE, + //AuditEvent.PAGE_DUPLICATED, +]); + +export const AuditResource = { + WORKSPACE: 'workspace', + USER: 'user', + PAGE: 'page', + SPACE: 'space', + SPACE_MEMBER: 'space_member', + GROUP: 'group', + COMMENT: 'comment', + SHARE: 'share', + API_KEY: 'api_key', + SSO_PROVIDER: 'sso_provider', + WORKSPACE_INVITATION: 'workspace_invitation', + ATTACHMENT: 'attachment', + LICENSE: 'license', +} as const; + +export type AuditResourceType = + (typeof AuditResource)[keyof typeof AuditResource]; + +export type ActorType = 'user' | 'system' | 'api_key'; + +export interface AuditLogPayload { + event: AuditEventType; + resourceType: AuditResourceType; + resourceId?: string; + spaceId?: string; + changes?: { + before?: Record; + after?: Record; + }; + metadata?: Record; +} + +export interface AuditLogData extends AuditLogPayload { + workspaceId: string; + actorId?: string; + actorType: ActorType; + ipAddress?: string; + userAgent?: string; +} diff --git a/apps/server/src/common/helpers/cache-keys.ts b/apps/server/src/common/helpers/cache-keys.ts new file mode 100644 index 00000000..570c96d8 --- /dev/null +++ b/apps/server/src/common/helpers/cache-keys.ts @@ -0,0 +1,3 @@ +export const CacheKey = { + LICENSE_VALID: (workspaceId: string) => `license:valid:${workspaceId}`, +}; diff --git a/apps/server/src/common/helpers/utils.ts b/apps/server/src/common/helpers/utils.ts index 7c94bb48..1970ecf9 100644 --- a/apps/server/src/common/helpers/utils.ts +++ b/apps/server/src/common/helpers/utils.ts @@ -120,6 +120,30 @@ export function normalizePostgresUrl(url: string): string { return parsed.toString(); } +export function diffAuditTrackedFields( + fields: readonly string[], + dto: Record, + before: Record | undefined | null, + after: Record | undefined | null, +): { before: Record; after: Record } | null { + const beforeDiff: Record = {}; + const afterDiff: Record = {}; + let hasChanges = false; + + for (const field of fields) { + if (typeof dto[field] === 'undefined') continue; + const oldVal = JSON.stringify(before?.[field] ?? null); + const newVal = JSON.stringify(after?.[field] ?? null); + if (oldVal !== newVal) { + beforeDiff[field] = before?.[field]; + afterDiff[field] = after?.[field]; + hasChanges = true; + } + } + + return hasChanges ? { before: beforeDiff, after: afterDiff } : null; +} + export function createByteCountingStream(source: Readable) { let bytesRead = 0; const stream = new Transform({ diff --git a/apps/server/src/common/interceptors/audit-actor.interceptor.ts b/apps/server/src/common/interceptors/audit-actor.interceptor.ts new file mode 100644 index 00000000..c98ceb7f --- /dev/null +++ b/apps/server/src/common/interceptors/audit-actor.interceptor.ts @@ -0,0 +1,29 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { ClsService } from 'nestjs-cls'; +import { AuditContext, AUDIT_CONTEXT_KEY } from '../middlewares/audit-context.middleware'; + +@Injectable() +export class AuditActorInterceptor implements NestInterceptor { + constructor(private readonly cls: ClsService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const user = request.user?.user; + + if (user?.id) { + const auditContext = this.cls.get(AUDIT_CONTEXT_KEY); + if (auditContext) { + auditContext.actorId = user.id; + this.cls.set(AUDIT_CONTEXT_KEY, auditContext); + } + } + + return next.handle(); + } +} diff --git a/apps/server/src/common/middlewares/audit-context.middleware.ts b/apps/server/src/common/middlewares/audit-context.middleware.ts new file mode 100644 index 00000000..d58c4353 --- /dev/null +++ b/apps/server/src/common/middlewares/audit-context.middleware.ts @@ -0,0 +1,50 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { FastifyRequest, FastifyReply } from 'fastify'; +import { ClsService } from 'nestjs-cls'; + +export interface AuditContext { + workspaceId: string | null; + actorId: string | null; + actorType: 'user' | 'system' | 'api_key'; + ipAddress: string | null; +} + +export const AUDIT_CONTEXT_KEY = 'auditContext'; + +@Injectable() +export class AuditContextMiddleware implements NestMiddleware { + constructor(private readonly cls: ClsService) {} + + use(req: FastifyRequest['raw'], res: FastifyReply['raw'], next: () => void) { + const workspaceId = (req as any).workspaceId ?? null; + const ipAddress = this.extractIpAddress(req); + + const auditContext: AuditContext = { + workspaceId, + actorId: null, + actorType: 'user', + ipAddress, + }; + + this.cls.set(AUDIT_CONTEXT_KEY, auditContext); + + next(); + } + + private extractIpAddress(req: FastifyRequest['raw']): string | null { + const xForwardedFor = req.headers['x-forwarded-for']; + if (xForwardedFor) { + const ips = Array.isArray(xForwardedFor) + ? xForwardedFor[0] + : xForwardedFor.split(',')[0]; + return ips?.trim() ?? null; + } + + const xRealIp = req.headers['x-real-ip']; + if (xRealIp) { + return Array.isArray(xRealIp) ? xRealIp[0] : xRealIp; + } + + return (req as any).socket?.remoteAddress ?? null; + } +} diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index 4694e0f0..d70f0034 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -6,6 +6,7 @@ import { Get, HttpCode, HttpStatus, + Inject, Logger, NotFoundException, Param, @@ -54,6 +55,11 @@ import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload'; import * as path from 'path'; import { AttachmentInfoDto, RemoveIconDto } from './dto/attachment.dto'; import { PageAccessService } from '../page/page-access/page-access.service'; +import { AuditEvent, AuditResource } from '../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../integrations/audit/audit.service'; @Controller() export class AttachmentController { @@ -69,6 +75,7 @@ export class AttachmentController { private readonly environmentService: EnvironmentService, private readonly tokenService: TokenService, private readonly pageAccessService: PageAccessService, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} @UseGuards(JwtAuthGuard) @@ -132,6 +139,18 @@ export class AttachmentController { attachmentId: attachmentId, }); + this.auditService.log({ + event: AuditEvent.ATTACHMENT_UPLOADED, + resourceType: AuditResource.ATTACHMENT, + resourceId: fileResponse?.id ?? attachmentId, + spaceId, + metadata: { + fileName: fileResponse?.fileName, + pageId, + spaceId, + }, + }); + return res.send(fileResponse); } catch (err: any) { if (err?.statusCode === 413) { diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts index a11e0360..f83fc1cf 100644 --- a/apps/server/src/core/auth/auth.controller.ts +++ b/apps/server/src/core/auth/auth.controller.ts @@ -3,6 +3,7 @@ import { Controller, HttpCode, HttpStatus, + Inject, Post, Res, UseGuards, @@ -24,6 +25,11 @@ import { VerifyUserTokenDto } from './dto/verify-user-token.dto'; import { FastifyReply } from 'fastify'; import { validateSsoEnforcement } from './auth.util'; import { ModuleRef } from '@nestjs/core'; +import { AuditEvent, AuditResource } from '../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../integrations/audit/audit.service'; @Controller('auth') export class AuthController { @@ -33,6 +39,7 @@ export class AuthController { private authService: AuthService, private environmentService: EnvironmentService, private moduleRef: ModuleRef, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} @HttpCode(HttpStatus.OK) @@ -169,8 +176,17 @@ export class AuthController { @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @Post('logout') - async logout(@Res({ passthrough: true }) res: FastifyReply) { + async logout( + @AuthUser() user: User, + @Res({ passthrough: true }) res: FastifyReply, + ) { res.clearCookie('authToken'); + + this.auditService.log({ + event: AuditEvent.USER_LOGOUT, + resourceType: AuditResource.USER, + resourceId: user.id, + }); } setAuthCookie(res: FastifyReply, token: string) { diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts index d2dac116..da5be855 100644 --- a/apps/server/src/core/auth/services/auth.service.ts +++ b/apps/server/src/core/auth/services/auth.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + Inject, Injectable, NotFoundException, UnauthorizedException, @@ -29,6 +30,11 @@ import { InjectKysely } from 'nestjs-kysely'; import { executeTx } from '@docmost/db/utils'; import { VerifyUserTokenDto } from '../dto/verify-user-token.dto'; import { DomainService } from '../../../integrations/environment/domain.service'; +import { AuditEvent, AuditResource } from '../../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../../integrations/audit/audit.service'; @Injectable() export class AuthService { @@ -40,6 +46,7 @@ export class AuthService { private mailService: MailService, private domainService: DomainService, @InjectKysely() private readonly db: KyselyDB, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} async login(loginDto: LoginDto, workspaceId: string) { @@ -64,6 +71,13 @@ export class AuthService { user.lastLoginAt = new Date(); await this.userRepo.updateLastLogin(user.id, workspaceId); + this.auditService.log({ + event: AuditEvent.USER_LOGIN, + resourceType: AuditResource.USER, + resourceId: user.id, + metadata: { source: 'password' }, + }); + return this.tokenService.generateAccessToken(user); } @@ -112,6 +126,12 @@ export class AuthService { workspaceId, ); + this.auditService.log({ + event: AuditEvent.USER_PASSWORD_CHANGED, + resourceType: AuditResource.USER, + resourceId: userId, + }); + const emailTemplate = ChangePasswordEmail({ username: user.name }); await this.mailService.sendToQueue({ to: user.email, @@ -135,16 +155,27 @@ export class AuthService { const token = nanoIdGen(16); - const resetLink = `${this.domainService.getUrl(workspace.hostname)}/password-reset?token=${token}`; + await executeTx(this.db, async (trx) => { + await trx + .deleteFrom('userTokens') + .where('userId', '=', user.id) + .where('type', '=', UserTokenType.FORGOT_PASSWORD) + .execute(); - await this.userTokenRepo.insertUserToken({ - token: token, - userId: user.id, - workspaceId: user.workspaceId, - expiresAt: new Date(new Date().getTime() + 60 * 60 * 1000), // 1 hour - type: UserTokenType.FORGOT_PASSWORD, + await this.userTokenRepo.insertUserToken( + { + token, + userId: user.id, + workspaceId: user.workspaceId, + expiresAt: new Date(Date.now() + 30 * 60 * 1000), // 30 minutes + type: UserTokenType.FORGOT_PASSWORD, + }, + { trx }, + ); }); + const resetLink = `${this.domainService.getUrl(workspace.hostname)}/password-reset?token=${token}`; + const emailTemplate = ForgotPasswordEmail({ username: user.name, resetLink: resetLink, @@ -201,6 +232,13 @@ export class AuthService { .execute(); }); + this.auditService.setActorId(user.id); + this.auditService.log({ + event: AuditEvent.USER_PASSWORD_RESET, + resourceType: AuditResource.USER, + resourceId: user.id, + }); + const emailTemplate = ChangePasswordEmail({ username: user.name }); await this.mailService.sendToQueue({ to: user.email, diff --git a/apps/server/src/core/auth/services/signup.service.ts b/apps/server/src/core/auth/services/signup.service.ts index bf089478..ab683c97 100644 --- a/apps/server/src/core/auth/services/signup.service.ts +++ b/apps/server/src/core/auth/services/signup.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { CreateUserDto } from '../dto/create-user.dto'; import { WorkspaceService } from '../../workspace/services/workspace.service'; import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto'; @@ -10,6 +10,11 @@ import { InjectKysely } from 'nestjs-kysely'; import { User, Workspace } from '@docmost/db/types/entity.types'; import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo'; import { UserRole } from '../../../common/helpers/types/permission'; +import { AuditEvent, AuditResource } from '../../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../../integrations/audit/audit.service'; @Injectable() export class SignupService { @@ -18,6 +23,7 @@ export class SignupService { private workspaceService: WorkspaceService, private groupUserRepo: GroupUserRepo, @InjectKysely() private readonly db: KyselyDB, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} async signup( @@ -36,7 +42,7 @@ export class SignupService { ); } - return await executeTx( + const user = await executeTx( this.db, async (trx) => { // create user @@ -66,6 +72,24 @@ export class SignupService { }, trx, ); + + this.auditService.log({ + event: AuditEvent.USER_CREATED, + resourceType: AuditResource.USER, + resourceId: user.id, + changes: { + after: { + name: user.name, + email: user.email, + role: user.role, + }, + }, + metadata: { + source: 'signup', + }, + }); + + return user; } async initialSetup( diff --git a/apps/server/src/core/comment/comment.controller.ts b/apps/server/src/core/comment/comment.controller.ts index 1872d56e..6bb23381 100644 --- a/apps/server/src/core/comment/comment.controller.ts +++ b/apps/server/src/core/comment/comment.controller.ts @@ -5,6 +5,7 @@ import { HttpCode, HttpStatus, UseGuards, + Inject, NotFoundException, ForbiddenException, } from '@nestjs/common'; @@ -25,6 +26,11 @@ import { } from '../casl/interfaces/space-ability.type'; import { CommentRepo } from '@docmost/db/repos/comment/comment.repo'; import { PageAccessService } from '../page/page-access/page-access.service'; +import { AuditEvent, AuditResource } from '../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../integrations/audit/audit.service'; @UseGuards(JwtAuthGuard) @Controller('comments') @@ -35,6 +41,7 @@ export class CommentController { private readonly pageRepo: PageRepo, private readonly spaceAbility: SpaceAbilityFactory, private readonly pageAccessService: PageAccessService, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} @HttpCode(HttpStatus.OK) @@ -51,7 +58,7 @@ export class CommentController { await this.pageAccessService.validateCanEdit(page, user); - return this.commentService.create( + const comment = await this.commentService.create( { userId: user.id, page, @@ -59,6 +66,18 @@ export class CommentController { }, createCommentDto, ); + + this.auditService.log({ + event: AuditEvent.COMMENT_CREATED, + resourceType: AuditResource.COMMENT, + resourceId: comment.id, + spaceId: page.spaceId, + metadata: { + pageId: page.id, + }, + }); + + return comment; } @HttpCode(HttpStatus.OK) @@ -136,20 +155,32 @@ export class CommentController { if (isOwner) { await this.commentRepo.deleteComment(comment.id); - return; - } - - const ability = await this.spaceAbility.createForUser( - user, - comment.spaceId, - ); - - // Space admin can delete any comment - if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) { - throw new ForbiddenException( - 'You can only delete your own comments or must be a space admin', + } else { + const ability = await this.spaceAbility.createForUser( + user, + comment.spaceId, ); + + // Space admin can delete any comment + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) { + throw new ForbiddenException( + 'You can only delete your own comments or must be a space admin', + ); + } + await this.commentRepo.deleteComment(comment.id); } - await this.commentRepo.deleteComment(comment.id); + + this.auditService.log({ + event: AuditEvent.COMMENT_DELETED, + resourceType: AuditResource.COMMENT, + resourceId: comment.id, + spaceId: comment.spaceId, + changes: { + before: { + pageId: comment.pageId, + creatorId: comment.creatorId, + }, + }, + }); } } diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index df95bff2..f336cf8c 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -16,9 +16,15 @@ import { GroupModule } from './group/group.module'; import { CaslModule } from './casl/casl.module'; import { PageAccessModule } from './page/page-access/page-access.module'; import { DomainMiddleware } from '../common/middlewares/domain.middleware'; +import { AuditContextMiddleware } from '../common/middlewares/audit-context.middleware'; import { ShareModule } from './share/share.module'; import { NotificationModule } from './notification/notification.module'; import { WatcherModule } from './watcher/watcher.module'; +import { + AUDIT_SERVICE, + NoopAuditService, +} from '../integrations/audit/audit.service'; +import { ClsMiddleware } from 'nestjs-cls'; @Module({ imports: [ @@ -37,17 +43,31 @@ import { WatcherModule } from './watcher/watcher.module'; NotificationModule, WatcherModule, ], + providers: [ + { + provide: AUDIT_SERVICE, + useClass: NoopAuditService, + }, + ], + exports: [AUDIT_SERVICE], }) export class CoreModule implements NestModule { configure(consumer: MiddlewareConsumer) { + const excludedRoutes = [ + { path: 'auth/setup', method: RequestMethod.POST }, + { path: 'health', method: RequestMethod.GET }, + { path: 'health/live', method: RequestMethod.GET }, + { path: 'billing/stripe/webhook', method: RequestMethod.POST }, + ]; + consumer .apply(DomainMiddleware) - .exclude( - { path: 'auth/setup', method: RequestMethod.POST }, - { path: 'health', method: RequestMethod.GET }, - { path: 'health/live', method: RequestMethod.GET }, - { path: 'billing/stripe/webhook', method: RequestMethod.POST }, - ) + .exclude(...excludedRoutes) + .forRoutes('*'); + + consumer + .apply(AuditContextMiddleware) + .exclude(...excludedRoutes) .forRoutes('*'); } } diff --git a/apps/server/src/core/group/services/group-user.service.ts b/apps/server/src/core/group/services/group-user.service.ts index 78ca0cf2..e0bdc23a 100644 --- a/apps/server/src/core/group/services/group-user.service.ts +++ b/apps/server/src/core/group/services/group-user.service.ts @@ -14,6 +14,11 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { executeTx } from '@docmost/db/utils'; import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo'; +import { AuditEvent, AuditResource } from '../../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../../integrations/audit/audit.service'; @Injectable() export class GroupUserService { @@ -25,6 +30,7 @@ export class GroupUserService { private groupService: GroupService, private readonly watcherRepo: WatcherRepo, @InjectKysely() private readonly db: KyselyDB, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} async getGroupUsers( @@ -72,6 +78,20 @@ export class GroupUserService { .values(groupUsersToInsert) .onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing()) .execute(); + + for (const user of validUsers) { + this.auditService.log({ + event: AuditEvent.GROUP_MEMBER_ADDED, + resourceType: AuditResource.GROUP, + resourceId: groupId, + changes: { + after: { + userId: user.id, + userName: user.name, + }, + }, + }); + } } async removeUserFromGroup( @@ -115,8 +135,24 @@ export class GroupUserService { await this.watcherRepo.deleteByUsersWithoutSpaceAccess( [userId], spaceId, + { trx }, ); } }); + + this.auditService.log({ + event: AuditEvent.GROUP_MEMBER_REMOVED, + resourceType: AuditResource.GROUP, + resourceId: groupId, + changes: { + before: { + userId: user.id, + userName: user.name, + }, + }, + metadata: { + groupName: group.name, + }, + }); } } diff --git a/apps/server/src/core/group/services/group.service.ts b/apps/server/src/core/group/services/group.service.ts index 8e8a02ef..7d6005f0 100644 --- a/apps/server/src/core/group/services/group.service.ts +++ b/apps/server/src/core/group/services/group.service.ts @@ -18,6 +18,12 @@ import { GroupUserService } from './group-user.service'; import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo'; import { executeTx } from '@docmost/db/utils'; import { InjectKysely } from 'nestjs-kysely'; +import { AuditEvent, AuditResource } from '../../../common/events/audit-events'; +import { diffAuditTrackedFields } from '../../../common/helpers'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../../integrations/audit/audit.service'; @Injectable() export class GroupService { @@ -29,6 +35,7 @@ export class GroupService { private groupUserService: GroupUserService, private readonly watcherRepo: WatcherRepo, @InjectKysely() private readonly db: KyselyDB, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} async getGroupInfo(groupId: string, workspaceId: string): Promise { @@ -74,6 +81,18 @@ export class GroupService { ); } + this.auditService.log({ + event: AuditEvent.GROUP_CREATED, + resourceType: AuditResource.GROUP, + resourceId: createdGroup.id, + changes: { + after: { + name: createdGroup.name, + description: createdGroup.description, + }, + }, + }); + return createdGroup; } @@ -95,6 +114,8 @@ export class GroupService { throw new BadRequestException('You cannot update a default group'); } + const groupBefore = { name: group.name, description: group.description }; + if (updateGroupDto.name) { const existingGroup = await this.groupRepo.findByName( updateGroupDto.name, @@ -121,6 +142,22 @@ export class GroupService { workspaceId, ); + const changes = diffAuditTrackedFields( + ['name', 'description'], + updateGroupDto, + groupBefore, + group, + ); + + if (changes) { + this.auditService.log({ + event: AuditEvent.GROUP_UPDATED, + resourceType: AuditResource.GROUP, + resourceId: group.id, + changes, + }); + } + return group; } @@ -154,6 +191,18 @@ export class GroupService { ); } }); + + this.auditService.log({ + event: AuditEvent.GROUP_DELETED, + resourceType: AuditResource.GROUP, + resourceId: groupId, + changes: { + before: { + name: group.name, + description: group.description, + }, + }, + }); } async findAndValidateGroup( diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 28962293..eff24936 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -5,6 +5,7 @@ import { ForbiddenException, HttpCode, HttpStatus, + Inject, NotFoundException, Post, UseGuards, @@ -25,7 +26,7 @@ import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; -import { User, Workspace } from '@docmost/db/types/entity.types'; +import { Page, User, Workspace } from '@docmost/db/types/entity.types'; import { SidebarPageDto } from './dto/sidebar-page.dto'; import { SpaceCaslAction, @@ -40,6 +41,12 @@ import { jsonToHtml, jsonToMarkdown, } from '../../collaboration/collaboration.util'; +import { AuditEvent, AuditResource } from '../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../integrations/audit/audit.service'; +import { getPageTitle } from '../../common/helpers'; @UseGuards(JwtAuthGuard) @Controller('pages') @@ -50,6 +57,7 @@ export class PageController { private readonly pageHistoryService: PageHistoryService, private readonly spaceAbility: SpaceAbilityFactory, private readonly pageAccessService: PageAccessService, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} @HttpCode(HttpStatus.OK) @@ -129,6 +137,19 @@ export class PageController { const permissions = { canEdit, hasRestriction }; + this.auditService.log({ + event: AuditEvent.PAGE_CREATED, + resourceType: AuditResource.PAGE, + resourceId: page.id, + spaceId: page.spaceId, + changes: { + after: { + title: getPageTitle(page.title), + spaceId: page.spaceId, + }, + }, + }); + if ( createPageDto.format && createPageDto.format !== 'json' && @@ -153,8 +174,10 @@ export class PageController { throw new NotFoundException('Page not found'); } - const { hasRestriction } = - await this.pageAccessService.validateCanEdit(page, user); + const { hasRestriction } = await this.pageAccessService.validateCanEdit( + page, + user, + ); const updatedPage = await this.pageService.update( page, @@ -202,6 +225,21 @@ export class PageController { ); } await this.pageService.forceDelete(deletePageDto.pageId, workspace.id); + + this.auditService.log({ + event: AuditEvent.PAGE_DELETED, + resourceType: AuditResource.PAGE, + resourceId: page.id, + spaceId: page.spaceId, + changes: { + before: { + pageId: page.id, + slugId: page.slugId, + title: getPageTitle(page.title), + spaceId: page.spaceId, + }, + }, + }); } else { // User with edit permission can delete await this.pageAccessService.validateCanEdit(page, user); @@ -211,6 +249,21 @@ export class PageController { user.id, workspace.id, ); + + this.auditService.log({ + event: AuditEvent.PAGE_TRASHED, + resourceType: AuditResource.PAGE, + resourceId: page.id, + spaceId: page.spaceId, + changes: { + before: { + pageId: page.id, + slugId: page.slugId, + title: getPageTitle(page.title), + spaceId: page.spaceId, + }, + }, + }); } } @@ -227,20 +280,30 @@ export class PageController { throw new NotFoundException('Page not found'); } - //Todo: currently, this means if they are not admins, they need to add a space admin to the page, which is not possible as it was soft-deleted - // so page is virtually lost. Fix. + // only users with "can edit" space level permission can restore pages const ability = await this.spaceAbility.createForUser(user, page.spaceId); - if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { + if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } - //TODO: can users with page level edit, but no space level edit restore pages they can edit? - - // Check page-level edit permission (if restoring to a restricted ancestor) + // make sure they have page level access to the page await this.pageAccessService.validateCanEdit(page, user); await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id); + this.auditService.log({ + event: AuditEvent.PAGE_RESTORED, + resourceType: AuditResource.PAGE, + resourceId: page.id, + spaceId: page.spaceId, + changes: { + after: { + title: getPageTitle(page.title), + spaceId: page.spaceId, + }, + }, + }); + return this.pageRepo.findById(pageIdDto.pageId, { includeHasChildren: true, }); @@ -286,7 +349,7 @@ export class PageController { deletedPageDto.spaceId, ); - if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { + if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } @@ -410,7 +473,26 @@ export class PageController { await this.pageAccessService.validateCanEdit(movedPage, user); // Moves only accessible pages; inaccessible child pages become root pages in original space - return this.pageService.movePageToSpace(movedPage, dto.spaceId, user.id); + const { childPageIds } = await this.pageService.movePageToSpace( + movedPage, + dto.spaceId, + user.id, + ); + + this.auditService.log({ + event: AuditEvent.PAGE_MOVED_TO_SPACE, + resourceType: AuditResource.PAGE, + resourceId: movedPage.id, + spaceId: movedPage.spaceId, + changes: { + before: { spaceId: movedPage.spaceId }, + after: { spaceId: dto.spaceId }, + }, + metadata: { + title: getPageTitle(movedPage.title), + ...(childPageIds.length > 0 && { childPageIds }), + }, + }); } @HttpCode(HttpStatus.OK) @@ -425,6 +507,8 @@ export class PageController { // Inaccessible child branches are automatically skipped during duplication await this.pageAccessService.validateCanView(copiedPage, user); + let result; + // If spaceId is provided, it's a copy to different space if (dto.spaceId) { const abilities = await Promise.all([ @@ -440,7 +524,27 @@ export class PageController { throw new ForbiddenException(); } - return this.pageService.duplicatePage(copiedPage, dto.spaceId, user); + result = await this.pageService.duplicatePage( + copiedPage, + dto.spaceId, + user, + ); + + this.auditService.log({ + event: AuditEvent.PAGE_DUPLICATED, + resourceType: AuditResource.PAGE, + resourceId: result.id, + spaceId: dto.spaceId, + metadata: { + sourcePageId: copiedPage.id, + title: getPageTitle(copiedPage.title), + sourceSpaceId: copiedPage.spaceId, + targetSpaceId: dto.spaceId, + ...(result.childPageIds.length > 0 && { + childPageIds: result.childPageIds, + }), + }, + }); } else { // If no spaceId, it's a duplicate in same space const ability = await this.spaceAbility.createForUser( @@ -451,8 +555,28 @@ export class PageController { throw new ForbiddenException(); } - return this.pageService.duplicatePage(copiedPage, undefined, user); + result = await this.pageService.duplicatePage( + copiedPage, + undefined, + user, + ); + + this.auditService.log({ + event: AuditEvent.PAGE_DUPLICATED, + resourceType: AuditResource.PAGE, + resourceId: result.id, + spaceId: copiedPage.spaceId, + metadata: { + sourcePageId: copiedPage.id, + title: getPageTitle(copiedPage.title), + ...(result.childPageIds.length > 0 && { + childPageIds: result.childPageIds, + }), + }, + }); } + + return result; } @HttpCode(HttpStatus.OK) diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 2c68d3ef..8df9e4bd 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -368,6 +368,8 @@ export class PageService { } async movePageToSpace(rootPage: Page, spaceId: string, userId: string) { + let childPageIds: string[] = []; + const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, { includeContent: false, }); @@ -413,11 +415,13 @@ export class PageService { const pageIdsToMove = accessiblePages.map((p) => p.id); + childPageIds = pageIdsToMove.filter((id) => id !== rootPage.id); + if (pageIdsToMove.length > 1) { // Update sub pages (all accessible pages except root) await this.pageRepo.updatePages( { spaceId }, - pageIdsToMove.filter((id) => id !== rootPage.id), + childPageIds, trx, ); } @@ -462,6 +466,8 @@ export class PageService { }); } }); + + return { childPageIds }; } async duplicatePage( @@ -680,10 +686,12 @@ export class PageService { }); const hasChildren = pages.length > 1; + const childPageIds = insertedPageIds.filter((id) => id !== newPageId); return { ...duplicatedPage, hasChildren, + childPageIds, }; } diff --git a/apps/server/src/core/page/services/trash-cleanup.service.ts b/apps/server/src/core/page/services/trash-cleanup.service.ts index f0646367..42a4a11a 100644 --- a/apps/server/src/core/page/services/trash-cleanup.service.ts +++ b/apps/server/src/core/page/services/trash-cleanup.service.ts @@ -6,10 +6,11 @@ import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; import { QueueJob, QueueName } from '../../../integrations/queue/constants'; +const DEFAULT_RETENTION_DAYS = 30; + @Injectable() export class TrashCleanupService { private readonly logger = new Logger(TrashCleanupService.name); - private readonly RETENTION_DAYS = 30; constructor( @InjectKysely() private readonly db: KyselyDB, @@ -21,36 +22,46 @@ export class TrashCleanupService { try { this.logger.debug('Starting trash cleanup job'); - const retentionDate = new Date(); - retentionDate.setDate(retentionDate.getDate() - this.RETENTION_DAYS); - - // Get all pages that were deleted more than 30 days ago - const oldDeletedPages = await this.db - .selectFrom('pages') - .select(['id', 'spaceId', 'workspaceId']) - .where('deletedAt', '<', retentionDate) + const workspaces = await this.db + .selectFrom('workspaces') + .select(['id', 'trashRetentionDays']) + .where('deletedAt', 'is', null) .execute(); - if (oldDeletedPages.length === 0) { - this.logger.debug('No old trash items to clean up'); - return; - } + let totalCleaned = 0; - this.logger.debug(`Found ${oldDeletedPages.length} pages to clean up`); + for (const workspace of workspaces) { + const retentionDays = + workspace.trashRetentionDays ?? DEFAULT_RETENTION_DAYS; - // Process each page - for (const page of oldDeletedPages) { - try { - await this.cleanupPage(page.id); - } catch (error) { - this.logger.error( - `Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, - error instanceof Error ? error.stack : undefined, - ); + const retentionDate = new Date(); + retentionDate.setDate(retentionDate.getDate() - retentionDays); + + const oldDeletedPages = await this.db + .selectFrom('pages') + .select(['id']) + .where('workspaceId', '=', workspace.id) + .where('deletedAt', '<', retentionDate) + .execute(); + + for (const page of oldDeletedPages) { + try { + await this.cleanupPage(page.id); + totalCleaned++; + } catch (error) { + this.logger.error( + `Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, + error instanceof Error ? error.stack : undefined, + ); + } } } - this.logger.debug('Trash cleanup job completed'); + this.logger.debug( + totalCleaned > 0 + ? `Trash cleanup completed: ${totalCleaned} pages cleaned` + : 'No old trash items to clean up', + ); } catch (error) { this.logger.error( 'Trash cleanup job failed', diff --git a/apps/server/src/core/share/share.controller.ts b/apps/server/src/core/share/share.controller.ts index 6097f197..0598dbb0 100644 --- a/apps/server/src/core/share/share.controller.ts +++ b/apps/server/src/core/share/share.controller.ts @@ -5,6 +5,7 @@ import { ForbiddenException, HttpCode, HttpStatus, + Inject, NotFoundException, Post, UseGuards, @@ -29,6 +30,11 @@ import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { EnvironmentService } from '../../integrations/environment/environment.service'; import { hasLicenseOrEE } from '../../common/helpers'; +import { AuditEvent, AuditResource } from '../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../integrations/audit/audit.service'; @UseGuards(JwtAuthGuard) @Controller('shares') @@ -40,6 +46,7 @@ export class ShareController { private readonly pagePermissionRepo: PagePermissionRepo, private readonly pageAccessService: PageAccessService, private readonly environmentService: EnvironmentService, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} @HttpCode(HttpStatus.OK) @@ -156,12 +163,25 @@ export class ShareController { throw new ForbiddenException('Public sharing is disabled'); } - return this.shareService.createShare({ + const share = await this.shareService.createShare({ page, authUserId: user.id, workspaceId: workspace.id, createShareDto, }); + + this.auditService.log({ + event: AuditEvent.SHARE_CREATED, + resourceType: AuditResource.SHARE, + resourceId: share.id, + spaceId: page.spaceId, + metadata: { + pageId: page.id, + spaceId: page.spaceId, + }, + }); + + return share; } @HttpCode(HttpStatus.OK) @@ -202,6 +222,19 @@ export class ShareController { await this.pageAccessService.validateCanEdit(page, user); await this.shareRepo.deleteShare(share.id); + + this.auditService.log({ + event: AuditEvent.SHARE_DELETED, + resourceType: AuditResource.SHARE, + resourceId: share.id, + spaceId: share.spaceId, + changes: { + before: { + pageId: share.pageId, + spaceId: share.spaceId, + }, + }, + }); } @Public() diff --git a/apps/server/src/core/space/services/space-member.service.ts b/apps/server/src/core/space/services/space-member.service.ts index f4f4aa17..0fbab02e 100644 --- a/apps/server/src/core/space/services/space-member.service.ts +++ b/apps/server/src/core/space/services/space-member.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + Inject, Injectable, NotFoundException, } from '@nestjs/common'; @@ -17,6 +18,11 @@ import { SpaceRole } from '../../../common/helpers/types/permission'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo'; import { executeTx } from '@docmost/db/utils'; +import { AuditEvent, AuditResource } from '../../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../../integrations/audit/audit.service'; @Injectable() export class SpaceMemberService { @@ -26,6 +32,7 @@ export class SpaceMemberService { private spaceRepo: SpaceRepo, private watcherRepo: WatcherRepo, @InjectKysely() private readonly db: KyselyDB, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} async addUserToSpace( @@ -90,7 +97,6 @@ export class SpaceMemberService { authUser: User, workspaceId: string, ): Promise { - // await this.spaceService.findAndValidateSpace(spaceId, workspaceId); const space = await this.spaceRepo.findById(dto.spaceId, workspaceId); if (!space) { @@ -164,8 +170,45 @@ export class SpaceMemberService { if (membersToAdd.length > 0) { await this.spaceMemberRepo.insertSpaceMember(membersToAdd); - } else { - // either they are already members or do not exist on the workspace + + // Audit log for each member added + for (const user of validUsers) { + this.auditService.log({ + event: AuditEvent.SPACE_MEMBER_ADDED, + resourceType: AuditResource.SPACE_MEMBER, + resourceId: dto.spaceId, + spaceId: dto.spaceId, + changes: { + after: { role: dto.role }, + }, + metadata: { + spaceId: dto.spaceId, + spaceName: space.name, + userId: user.id, + userName: user.name, + memberType: 'user', + }, + }); + } + + for (const group of validGroups) { + this.auditService.log({ + event: AuditEvent.SPACE_MEMBER_ADDED, + resourceType: AuditResource.SPACE_MEMBER, + resourceId: dto.spaceId, + spaceId: dto.spaceId, + changes: { + after: { role: dto.role }, + }, + metadata: { + spaceId: dto.spaceId, + spaceName: space.name, + groupId: group.id, + groupName: group.name, + memberType: 'group', + }, + }); + } } } @@ -230,6 +273,23 @@ export class SpaceMemberService { { trx }, ); }); + + this.auditService.log({ + event: AuditEvent.SPACE_MEMBER_REMOVED, + resourceType: AuditResource.SPACE_MEMBER, + resourceId: dto.spaceId, + spaceId: dto.spaceId, + changes: { + before: { role: spaceMember.role }, + }, + metadata: { + spaceId: dto.spaceId, + spaceName: space.name, + userId: spaceMember.userId, + groupId: spaceMember.groupId, + memberType: spaceMember.userId ? 'user' : 'group', + }, + }); } async updateSpaceMemberRole( @@ -280,6 +340,24 @@ export class SpaceMemberService { spaceMember.id, dto.spaceId, ); + + this.auditService.log({ + event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED, + resourceType: AuditResource.SPACE_MEMBER, + resourceId: dto.spaceId, + spaceId: dto.spaceId, + changes: { + before: { role: spaceMember.role }, + after: { role: dto.role }, + }, + metadata: { + spaceId: dto.spaceId, + spaceName: space.name, + userId: spaceMember.userId, + groupId: spaceMember.groupId, + memberType: spaceMember.userId ? 'user' : 'group', + }, + }); } async validateLastAdmin(spaceId: string): Promise { diff --git a/apps/server/src/core/space/services/space.service.ts b/apps/server/src/core/space/services/space.service.ts index 7e8e99d9..12f61299 100644 --- a/apps/server/src/core/space/services/space.service.ts +++ b/apps/server/src/core/space/services/space.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ForbiddenException, + Inject, Injectable, NotFoundException, } from '@nestjs/common'; @@ -21,6 +22,12 @@ import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { LicenseCheckService } from '../../../integrations/environment/license-check.service'; +import { AuditEvent, AuditResource } from '../../../common/events/audit-events'; +import { diffAuditTrackedFields } from '../../../common/helpers'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../../integrations/audit/audit.service'; @Injectable() export class SpaceService { @@ -32,6 +39,7 @@ export class SpaceService { private licenseCheckService: LicenseCheckService, @InjectKysely() private readonly db: KyselyDB, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} async createSpace( @@ -63,6 +71,19 @@ export class SpaceService { trx, ); + this.auditService.log({ + event: AuditEvent.SPACE_CREATED, + resourceType: AuditResource.SPACE, + resourceId: space.id, + spaceId: space.id, + changes: { + after: { + name: space.name, + slug: space.slug, + }, + }, + }); + return { ...space, memberCount: 1 }; } @@ -124,28 +145,74 @@ export class SpaceService { 'This feature requires a valid enterprise license', ); } - - await this.spaceRepo.updateSharingSettings( - updateSpaceDto.spaceId, - workspaceId, - 'disabled', - updateSpaceDto.disablePublicSharing, - ); - - if (updateSpaceDto.disablePublicSharing) { - await this.shareRepo.deleteBySpaceId(updateSpaceDto.spaceId); - } } - return await this.spaceRepo.updateSpace( - { - name: updateSpaceDto.name, - description: updateSpaceDto.description, - slug: updateSpaceDto.slug, - }, + const spaceBefore = await this.spaceRepo.findById( updateSpaceDto.spaceId, workspaceId, ); + const settingsBefore = (spaceBefore?.settings ?? {}) as Record; + + const before: Record = {}; + const after: Record = {}; + + let updatedSpace: Space; + + await executeTx(this.db, async (trx) => { + if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') { + const prev = settingsBefore?.sharing?.disabled ?? false; + if (prev !== updateSpaceDto.disablePublicSharing) { + before.disablePublicSharing = prev; + after.disablePublicSharing = updateSpaceDto.disablePublicSharing; + } + + await this.spaceRepo.updateSharingSettings( + updateSpaceDto.spaceId, + workspaceId, + 'disabled', + updateSpaceDto.disablePublicSharing, + trx, + ); + + if (updateSpaceDto.disablePublicSharing) { + await this.shareRepo.deleteBySpaceId(updateSpaceDto.spaceId, trx); + } + } + + updatedSpace = await this.spaceRepo.updateSpace( + { + name: updateSpaceDto.name, + description: updateSpaceDto.description, + slug: updateSpaceDto.slug, + }, + updateSpaceDto.spaceId, + workspaceId, + trx, + ); + }); + + const columnChanges = diffAuditTrackedFields( + ['name', 'slug', 'description'], + updateSpaceDto, + spaceBefore, + updatedSpace, + ); + if (columnChanges) { + Object.assign(before, columnChanges.before); + Object.assign(after, columnChanges.after); + } + + if (Object.keys(after).length > 0) { + this.auditService.log({ + event: AuditEvent.SPACE_UPDATED, + resourceType: AuditResource.SPACE, + resourceId: updateSpaceDto.spaceId, + spaceId: updateSpaceDto.spaceId, + changes: { before, after }, + }); + } + + return updatedSpace; } async getSpaceInfo(spaceId: string, workspaceId: string): Promise { @@ -174,5 +241,19 @@ export class SpaceService { await this.spaceRepo.deleteSpace(spaceId, workspaceId); await this.attachmentQueue.add(QueueJob.DELETE_SPACE_ATTACHMENTS, space); + + this.auditService.log({ + event: AuditEvent.SPACE_DELETED, + resourceType: AuditResource.SPACE, + resourceId: spaceId, + spaceId: spaceId, + changes: { + before: { + name: space.name, + slug: space.slug, + description: space.description, + }, + }, + }); } } diff --git a/apps/server/src/core/user/user.service.ts b/apps/server/src/core/user/user.service.ts index f71c85f1..59bc08ec 100644 --- a/apps/server/src/core/user/user.service.ts +++ b/apps/server/src/core/user/user.service.ts @@ -1,18 +1,27 @@ import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { BadRequestException, + Inject, Injectable, NotFoundException, UnauthorizedException, } from '@nestjs/common'; import { UpdateUserDto } from './dto/update-user.dto'; -import { comparePasswordHash } from 'src/common/helpers/utils'; +import { comparePasswordHash, diffAuditTrackedFields } from 'src/common/helpers/utils'; import { Workspace } from '@docmost/db/types/entity.types'; import { validateSsoEnforcement } from '../auth/auth.util'; +import { AuditEvent, AuditResource } from '../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../integrations/audit/audit.service'; @Injectable() export class UserService { - constructor(private userRepo: UserRepo) {} + constructor( + private userRepo: UserRepo, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, + ) {} async findById(userId: string, workspaceId: string) { return this.userRepo.findById(userId, workspaceId); @@ -51,6 +60,8 @@ export class UserService { ); } + const userBefore = { name: user.name, email: user.email, locale: user.locale }; + if (updateUserDto.name) { user.name = updateUserDto.name; } @@ -91,6 +102,23 @@ export class UserService { delete updateUserDto.confirmPassword; await this.userRepo.updateUser(updateUserDto, userId, workspace.id); + + const changes = diffAuditTrackedFields( + ['name', 'email'], + updateUserDto, + userBefore, + user, + ); + + if (changes) { + this.auditService.log({ + event: AuditEvent.USER_UPDATED, + resourceType: AuditResource.USER, + resourceId: userId, + changes, + }); + } + return user; } } diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts index 7b4f31eb..0e805db7 100644 --- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts +++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts @@ -1,6 +1,13 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateWorkspaceDto } from './create-workspace.dto'; -import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator'; +import { + IsArray, + IsBoolean, + IsInt, + IsOptional, + IsString, + Min, +} from 'class-validator'; export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) { @IsOptional() @@ -34,4 +41,9 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) { @IsOptional() @IsBoolean() disablePublicSharing: boolean; + + @IsOptional() + @IsInt() + @Min(1) + trashRetentionDays: number; } diff --git a/apps/server/src/core/workspace/services/workspace-invitation.service.ts b/apps/server/src/core/workspace/services/workspace-invitation.service.ts index 90d5f7b4..e6ebe7ff 100644 --- a/apps/server/src/core/workspace/services/workspace-invitation.service.ts +++ b/apps/server/src/core/workspace/services/workspace-invitation.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + Inject, Injectable, Logger, NotFoundException, @@ -33,6 +34,11 @@ import { validateAllowedEmail, validateSsoEnforcement, } from '../../auth/auth.util'; +import { AuditEvent, AuditResource } from '../../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../../integrations/audit/audit.service'; @Injectable() export class WorkspaceInvitationService { @@ -46,6 +52,7 @@ export class WorkspaceInvitationService { @InjectKysely() private readonly db: KyselyDB, @InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue, private readonly environmentService: EnvironmentService, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} async getInvitations(workspaceId: string, pagination: PaginationOptions) { @@ -180,6 +187,24 @@ export class WorkspaceInvitationService { workspace.hostname, ); }); + + // Audit log for each invitation created + for (const invitation of invites) { + this.auditService.log({ + event: AuditEvent.WORKSPACE_INVITE_CREATED, + resourceType: AuditResource.WORKSPACE_INVITATION, + resourceId: invitation.id, + changes: { + after: { + email: invitation.email, + role: invitation.role, + }, + }, + metadata: { + groupIds: invitation.groupIds, + }, + }); + } } } @@ -296,6 +321,23 @@ export class WorkspaceInvitationService { }); } + this.auditService.log({ + event: AuditEvent.USER_CREATED, + resourceType: AuditResource.USER, + resourceId: newUser.id, + changes: { + after: { + name: newUser.name, + email: newUser.email, + role: invitation.role, + }, + }, + metadata: { + source: 'invitation', + invitationId: invitation.id, + }, + }); + if (this.environmentService.isCloud()) { await this.billingQueue.add(QueueJob.STRIPE_SEATS_SYNC, { workspaceId: workspace.id, @@ -339,17 +381,48 @@ export class WorkspaceInvitationService { invitedByUser.name, workspace.hostname, ); + + this.auditService.log({ + event: AuditEvent.WORKSPACE_INVITE_RESENT, + resourceType: AuditResource.WORKSPACE_INVITATION, + resourceId: invitation.id, + metadata: { + email: invitation.email, + role: invitation.role, + }, + }); } async revokeInvitation( invitationId: string, workspaceId: string, ): Promise { + const invitation = await this.db + .selectFrom('workspaceInvitations') + .select(['id', 'email', 'role']) + .where('id', '=', invitationId) + .where('workspaceId', '=', workspaceId) + .executeTakeFirst(); + await this.db .deleteFrom('workspaceInvitations') .where('id', '=', invitationId) .where('workspaceId', '=', workspaceId) .execute(); + + if (invitation) { + this.auditService.log({ + event: AuditEvent.WORKSPACE_INVITE_REVOKED, + resourceType: AuditResource.WORKSPACE_INVITATION, + resourceId: invitation.id, + changes: { + before: { + email: invitation.email, + role: invitation.role, + }, + }, + }); + } } async getInvitationLinkById( diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index ed1a3424..d7f61b49 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ForbiddenException, + Inject, Injectable, Logger, NotFoundException, @@ -31,11 +32,19 @@ import { v4 } from 'uuid'; import { InjectQueue } from '@nestjs/bullmq'; import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { Queue } from 'bullmq'; -import { generateRandomSuffixNumbers } from '../../../common/helpers'; +import { + generateRandomSuffixNumbers, + diffAuditTrackedFields, +} from '../../../common/helpers'; import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo'; +import { AuditEvent, AuditResource } from '../../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../../integrations/audit/audit.service'; @Injectable() export class WorkspaceService { @@ -57,6 +66,7 @@ export class WorkspaceService { @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, @InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue, @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} async findById(workspaceId: string) { @@ -280,7 +290,7 @@ export class WorkspaceService { if (updateWorkspaceDto.enforceSso) { const sso = await this.db .selectFrom('authProviders') - .selectAll() + .select(['id']) .where('isEnabled', '=', true) .where('workspaceId', '=', workspaceId) .execute(); @@ -295,9 +305,7 @@ export class WorkspaceService { if (updateWorkspaceDto.emailDomains) { const regex = /(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/; - const emailDomains = updateWorkspaceDto.emailDomains || []; - updateWorkspaceDto.emailDomains = emailDomains .map((domain) => regex.exec(domain)?.[0]) .filter(Boolean); @@ -313,93 +321,170 @@ export class WorkspaceService { } } - if (typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined') { - await this.workspaceRepo.updateApiSettings( - workspaceId, - 'restrictToAdmins', - updateWorkspaceDto.restrictApiToAdmins, - ); - delete updateWorkspaceDto.restrictApiToAdmins; - } + const before: Record = {}; + const after: Record = {}; - if (typeof updateWorkspaceDto.aiSearch !== 'undefined') { - await this.workspaceRepo.updateAiSettings( - workspaceId, - 'search', - updateWorkspaceDto.aiSearch, - ); + if ( + typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' || + typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' + ) { + const ws = await this.db + .selectFrom('workspaces') + .select(['id', 'licenseKey', 'trashRetentionDays']) + .where('id', '=', workspaceId) + .executeTakeFirst(); - if (updateWorkspaceDto.aiSearch) { - const tableExists = await isPageEmbeddingsTableExists(this.db); - if (!tableExists) { - throw new BadRequestException( - 'Failed to activate. Make sure pgvector postgres extension is installed.', - ); - } - - await this.aiQueue.add(QueueJob.WORKSPACE_CREATE_EMBEDDINGS, { - workspaceId, - }); - } else { - // Schedule deletion after 24 hours - const deleteJobId = `ai-search-disabled-${workspaceId}`; - await this.aiQueue.add( - QueueJob.WORKSPACE_DELETE_EMBEDDINGS, - { workspaceId }, - { - jobId: deleteJobId, - delay: 24 * 60 * 60 * 1000, - removeOnComplete: true, - removeOnFail: true, - }, - ); - } - - delete updateWorkspaceDto.aiSearch; - } - - if (typeof updateWorkspaceDto.generativeAi !== 'undefined') { - await this.workspaceRepo.updateAiSettings( - workspaceId, - 'generative', - updateWorkspaceDto.generativeAi, - ); - delete updateWorkspaceDto.generativeAi; - } - - if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') { - const currentWorkspace = await this.workspaceRepo.findById(workspaceId, { - withLicenseKey: true, - }); - - if ( - !this.licenseCheckService.isValidEELicense(currentWorkspace.licenseKey) - ) { + if (!this.licenseCheckService.isValidEELicense(ws.licenseKey)) { throw new ForbiddenException( 'This feature requires a valid enterprise license', ); } - await this.workspaceRepo.updateSharingSettings( - workspaceId, - 'disabled', - updateWorkspaceDto.disablePublicSharing, - ); - - if (updateWorkspaceDto.disablePublicSharing) { - await this.shareRepo.deleteByWorkspaceId(workspaceId); + if ( + typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' && + updateWorkspaceDto.trashRetentionDays !== ws.trashRetentionDays + ) { + before.trashRetentionDays = ws.trashRetentionDays; + after.trashRetentionDays = updateWorkspaceDto.trashRetentionDays; } - - delete updateWorkspaceDto.disablePublicSharing; } - await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId); + if (updateWorkspaceDto.aiSearch) { + const tableExists = await isPageEmbeddingsTableExists(this.db); + if (!tableExists) { + throw new BadRequestException( + 'Failed to activate. Make sure pgvector postgres extension is installed.', + ); + } + } + + const workspaceBefore = await this.workspaceRepo.findById(workspaceId); + const settingsBefore = (workspaceBefore?.settings ?? {}) as Record< + string, + any + >; + + await executeTx(this.db, async (trx) => { + if (typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined') { + const prev = settingsBefore?.api?.restrictToAdmins ?? false; + if (prev !== updateWorkspaceDto.restrictApiToAdmins) { + before.restrictApiToAdmins = prev; + after.restrictApiToAdmins = updateWorkspaceDto.restrictApiToAdmins; + } + await this.workspaceRepo.updateApiSettings( + workspaceId, + 'restrictToAdmins', + updateWorkspaceDto.restrictApiToAdmins, + trx, + ); + } + + if (typeof updateWorkspaceDto.aiSearch !== 'undefined') { + const prev = settingsBefore?.ai?.search ?? false; + if (prev !== updateWorkspaceDto.aiSearch) { + before.aiSearch = prev; + after.aiSearch = updateWorkspaceDto.aiSearch; + } + await this.workspaceRepo.updateAiSettings( + workspaceId, + 'search', + updateWorkspaceDto.aiSearch, + trx, + ); + } + + if (typeof updateWorkspaceDto.generativeAi !== 'undefined') { + const prev = settingsBefore?.ai?.generative ?? false; + if (prev !== updateWorkspaceDto.generativeAi) { + before.generativeAi = prev; + after.generativeAi = updateWorkspaceDto.generativeAi; + } + await this.workspaceRepo.updateAiSettings( + workspaceId, + 'generative', + updateWorkspaceDto.generativeAi, + trx, + ); + } + + if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') { + const prev = settingsBefore?.sharing?.disabled ?? false; + if (prev !== updateWorkspaceDto.disablePublicSharing) { + before.disablePublicSharing = prev; + after.disablePublicSharing = updateWorkspaceDto.disablePublicSharing; + } + await this.workspaceRepo.updateSharingSettings( + workspaceId, + 'disabled', + updateWorkspaceDto.disablePublicSharing, + trx, + ); + if (updateWorkspaceDto.disablePublicSharing) { + await this.shareRepo.deleteByWorkspaceId(workspaceId, trx); + } + } + + delete updateWorkspaceDto.restrictApiToAdmins; + delete updateWorkspaceDto.aiSearch; + delete updateWorkspaceDto.generativeAi; + delete updateWorkspaceDto.disablePublicSharing; + + await this.workspaceRepo.updateWorkspace( + updateWorkspaceDto, + workspaceId, + trx, + ); + }); + + if (after.aiSearch === true) { + await this.aiQueue.add(QueueJob.WORKSPACE_CREATE_EMBEDDINGS, { + workspaceId, + }); + } else if (after.aiSearch === false) { + const deleteJobId = `ai-search-disabled-${workspaceId}`; + await this.aiQueue.add( + QueueJob.WORKSPACE_DELETE_EMBEDDINGS, + { workspaceId }, + { + jobId: deleteJobId, + delay: 24 * 60 * 60 * 1000, + removeOnComplete: true, + removeOnFail: true, + }, + ); + } const workspace = await this.workspaceRepo.findById(workspaceId, { withMemberCount: true, withLicenseKey: true, }); + const columnChanges = diffAuditTrackedFields( + [ + 'name', + 'logo', + 'enforceSso', + 'enforceMfa', + 'emailDomains', + ], + updateWorkspaceDto, + workspaceBefore, + workspace, + ); + if (columnChanges) { + Object.assign(before, columnChanges.before); + Object.assign(after, columnChanges.after); + } + + if (Object.keys(after).length > 0) { + this.auditService.log({ + event: AuditEvent.WORKSPACE_UPDATED, + resourceType: AuditResource.WORKSPACE, + resourceId: workspaceId, + changes: { before, after }, + }); + } + const { licenseKey, ...rest } = workspace; return { ...rest, @@ -457,6 +542,16 @@ export class WorkspaceService { user.id, workspaceId, ); + + this.auditService.log({ + event: AuditEvent.USER_ROLE_CHANGED, + resourceType: AuditResource.USER, + resourceId: user.id, + changes: { + before: { role: user.role }, + after: { role: newRole }, + }, + }); } async generateHostname( @@ -564,6 +659,19 @@ export class WorkspaceService { }); }); + this.auditService.log({ + event: AuditEvent.USER_DELETED, + resourceType: AuditResource.USER, + resourceId: user.id, + changes: { + before: { + name: user.name, + email: user.email, + role: user.role, + }, + }, + }); + try { await this.attachmentQueue.add(QueueJob.DELETE_USER_AVATARS, user); } catch (err) { diff --git a/apps/server/src/database/migrations/20260228T223532-audit.ts b/apps/server/src/database/migrations/20260228T223532-audit.ts new file mode 100644 index 00000000..e5a4d7d6 --- /dev/null +++ b/apps/server/src/database/migrations/20260228T223532-audit.ts @@ -0,0 +1,60 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('audit') + .ifNotExists() + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.notNull().references('workspaces.id').onDelete('cascade'), + ) + .addColumn('actor_id', 'uuid') + .addColumn('actor_type', 'varchar', (col) => + col.notNull().defaultTo('user'), + ) + .addColumn('event', 'varchar', (col) => col.notNull()) + .addColumn('resource_type', 'varchar', (col) => col.notNull()) + .addColumn('resource_id', 'uuid') + .addColumn('space_id', 'uuid') + .addColumn('changes', 'jsonb') + .addColumn('metadata', 'jsonb') + .addColumn('ip_address', sql`inet`) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); + + await db.schema + .createIndex('idx_audit_workspace_id') + .ifNotExists() + .on('audit') + .columns(['workspace_id', 'id desc']) + .execute(); + + // add new workspace columns + await db.schema + .alterTable('workspaces') + .addColumn('audit_retention_days', 'int8', (col) => col) + .execute(); + + await db.schema + .alterTable('workspaces') + .addColumn('trash_retention_days', 'int8', (col) => col) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable('workspaces') + .dropColumn('audit_retention_days') + .execute(); + + await db.schema + .alterTable('workspaces') + .dropColumn('trash_retention_days') + .execute(); + + await db.schema.dropTable('audit').execute(); +} diff --git a/apps/server/src/database/repos/share/share.repo.ts b/apps/server/src/database/repos/share/share.repo.ts index 631e0697..22ea4d8c 100644 --- a/apps/server/src/database/repos/share/share.repo.ts +++ b/apps/server/src/database/repos/share/share.repo.ts @@ -136,15 +136,23 @@ export class ShareRepo { await query.execute(); } - async deleteBySpaceId(spaceId: string): Promise { - await this.db + async deleteBySpaceId( + spaceId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db .deleteFrom('shares') .where('spaceId', '=', spaceId) .execute(); } - async deleteByWorkspaceId(workspaceId: string): Promise { - await this.db + async deleteByWorkspaceId( + workspaceId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db .deleteFrom('shares') .where('workspaceId', '=', workspaceId) .execute(); diff --git a/apps/server/src/database/repos/space/space.repo.ts b/apps/server/src/database/repos/space/space.repo.ts index 0e2bd2b7..8344a557 100644 --- a/apps/server/src/database/repos/space/space.repo.ts +++ b/apps/server/src/database/repos/space/space.repo.ts @@ -94,8 +94,10 @@ export class SpaceRepo { workspaceId: string, prefKey: string, prefValue: string | boolean, + trx?: KyselyTransaction, ) { - return this.db + const db = dbOrTx(this.db, trx); + return db .updateTable('spaces') .set({ settings: sql`COALESCE(settings, '{}'::jsonb) diff --git a/apps/server/src/database/repos/user-token/user-token.repo.ts b/apps/server/src/database/repos/user-token/user-token.repo.ts index 0137cb0a..edb5414d 100644 --- a/apps/server/src/database/repos/user-token/user-token.repo.ts +++ b/apps/server/src/database/repos/user-token/user-token.repo.ts @@ -38,9 +38,9 @@ export class UserTokenRepo { async insertUserToken( insertableUserToken: InsertableUserToken, - trx?: KyselyTransaction, + opts?: { trx?: KyselyTransaction }, ) { - const db = dbOrTx(this.db, trx); + const db = dbOrTx(this.db, opts?.trx); return db .insertInto('userTokens') .values(insertableUserToken) diff --git a/apps/server/src/database/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts index 5e054650..e4e6d342 100644 --- a/apps/server/src/database/repos/workspace/workspace.repo.ts +++ b/apps/server/src/database/repos/workspace/workspace.repo.ts @@ -33,6 +33,7 @@ export class WorkspaceRepo { 'enforceSso', 'plan', 'enforceMfa', + 'trashRetentionDays', ]; constructor(@InjectKysely() private readonly db: KyselyDB) {} @@ -162,8 +163,10 @@ export class WorkspaceRepo { workspaceId: string, prefKey: string, prefValue: string | boolean, + trx?: KyselyTransaction, ) { - return this.db + const db = dbOrTx(this.db, trx); + return db .updateTable('workspaces') .set({ settings: sql`COALESCE(settings, '{}'::jsonb) @@ -180,8 +183,10 @@ export class WorkspaceRepo { workspaceId: string, prefKey: string, prefValue: string | boolean, + trx?: KyselyTransaction, ) { - return this.db + const db = dbOrTx(this.db, trx); + return db .updateTable('workspaces') .set({ settings: sql`COALESCE(settings, '{}'::jsonb) @@ -198,8 +203,10 @@ export class WorkspaceRepo { workspaceId: string, prefKey: string, prefValue: string | boolean, + trx?: KyselyTransaction, ) { - return this.db + const db = dbOrTx(this.db, trx); + return db .updateTable('workspaces') .set({ settings: sql`COALESCE(settings, '{}'::jsonb) @@ -211,4 +218,5 @@ export class WorkspaceRepo { .returning(this.baseFields) .executeTakeFirst(); } + } diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 01c290a3..ed166b75 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -61,6 +61,21 @@ export interface Attachments { workspaceId: string; } +export interface Audit { + actorId: string | null; + actorType: Generated; + changes: Json | null; + createdAt: Generated; + event: string; + id: Generated; + ipAddress: string | null; + metadata: Json | null; + resourceId: string | null; + resourceType: string; + spaceId: string | null; + workspaceId: string; +} + export interface AuthAccounts { authProviderId: string | null; createdAt: Generated; @@ -339,6 +354,8 @@ export interface WorkspaceInvitations { } export interface Workspaces { + auditRetentionDays: Generated; + trashRetentionDays: Generated; billingEmail: string | null; createdAt: Generated; customDomain: string | null; @@ -415,6 +432,7 @@ export interface PagePermissions { export interface DB { apiKeys: ApiKeys; attachments: Attachments; + audit: Audit; authAccounts: AuthAccounts; authProviders: AuthProviders; backlinks: Backlinks; @@ -425,9 +443,8 @@ export interface DB { groupUsers: GroupUsers; notifications: Notifications; pageAccess: PageAccess; - pageHierarchy: PageHierarchy; - pageHistory: PageHistory; pagePermissions: PagePermissions; + pageHistory: PageHistory; pages: Pages; shares: Shares; spaceMembers: SpaceMembers; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index 43e9a241..f8bf9ff7 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -24,6 +24,7 @@ import { UserMfa as _UserMFA, ApiKeys, Watchers, + Audit as _Audit, } from './db'; import { PageEmbeddings } from '@docmost/db/types/embeddings.types'; @@ -155,3 +156,8 @@ export type UpdatablePageAccess = Updateable>; export type PagePermission = Selectable<_PagePermissions>; export type InsertablePagePermission = Insertable<_PagePermissions>; export type UpdatablePagePermission = Updateable>; + +// Audit +export type Audit = Selectable<_Audit>; +export type InsertableAudit = Insertable<_Audit>; +export type UpdatableAudit = Updateable>; diff --git a/apps/server/src/ee b/apps/server/src/ee index 9e493d75..9157ff1e 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 9e493d75f5435415a1ded7b4d9faef58da06b043 +Subproject commit 9157ff1e6d6ab41fdabba332a66ae9638bf833a0 diff --git a/apps/server/src/integrations/audit/audit.service.ts b/apps/server/src/integrations/audit/audit.service.ts new file mode 100644 index 00000000..58e9ef9b --- /dev/null +++ b/apps/server/src/integrations/audit/audit.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { AuditLogPayload, ActorType } from '../../common/events/audit-events'; + +export type AuditLogContext = { + workspaceId: string; + actorId?: string; + actorType?: ActorType; + ipAddress?: string; + userAgent?: string; +}; + +export type IAuditService = { + log(payload: AuditLogPayload): void | Promise; + logWithContext( + payload: AuditLogPayload, + context: AuditLogContext, + ): void | Promise; + logBatchWithContext( + payloads: AuditLogPayload[], + context: AuditLogContext, + ): void | Promise; + setActorId(actorId: string): void; + setActorType(actorType: ActorType): void; + updateRetention( + workspaceId: string, + retentionDays: number, + ): void | Promise; +}; + +export const AUDIT_SERVICE = Symbol('AUDIT_SERVICE'); + +@Injectable() +export class NoopAuditService implements IAuditService { + log(_payload: AuditLogPayload): void { + // No-op: swallow the log when EE module is not available + } + + logWithContext(_payload: AuditLogPayload, _context: AuditLogContext): void { + // No-op: swallow the log when EE module is not available + } + + logBatchWithContext( + _payloads: AuditLogPayload[], + _context: AuditLogContext, + ): void { + // No-op: swallow the log when EE module is not available + } + + setActorId(_actorId: string): void { + // No-op + } + + setActorType(_actorType: ActorType): void { + // No-op + } + + updateRetention( + _workspaceId: string, + _retentionDays: number, + ): void { + // No-op + } +} diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index 30624f58..89e4bb81 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -277,4 +277,14 @@ export class EnvironmentService { 'http://localhost:11434', ); } + + getEventStoreDriver(): string { + return this.configService + .get('EVENT_STORE_DRIVER', 'postgres') + .toLowerCase(); + } + + getClickHouseUrl(): string { + return this.configService.get('CLICKHOUSE_URL'); + } } diff --git a/apps/server/src/integrations/environment/environment.validation.ts b/apps/server/src/integrations/environment/environment.validation.ts index 5f65d018..041d0f4c 100644 --- a/apps/server/src/integrations/environment/environment.validation.ts +++ b/apps/server/src/integrations/environment/environment.validation.ts @@ -148,6 +148,22 @@ export class EnvironmentVariables { @ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'ollama') @IsUrl({ protocols: ['http', 'https'], require_tld: false }) OLLAMA_API_URL: string; + + @IsOptional() + @IsIn(['postgres', 'clickhouse']) + @IsString() + EVENT_STORE_DRIVER: string; + + @ValidateIf((obj) => obj.EVENT_STORE_DRIVER === 'clickhouse') + @IsNotEmpty() + @IsUrl( + { protocols: ['http', 'https'], require_tld: false }, + { + message: + 'CLICKHOUSE_URL must be a valid URL e.g http://user:password@localhost:8123/docmost', + }, + ) + CLICKHOUSE_URL: string; } export function validate(config: Record) { diff --git a/apps/server/src/integrations/export/export.controller.ts b/apps/server/src/integrations/export/export.controller.ts index 77e51b29..0fc5fb96 100644 --- a/apps/server/src/integrations/export/export.controller.ts +++ b/apps/server/src/integrations/export/export.controller.ts @@ -4,6 +4,7 @@ import { ForbiddenException, HttpCode, HttpStatus, + Inject, NotFoundException, Post, Res, @@ -24,8 +25,13 @@ import { import { FastifyReply } from 'fastify'; import { sanitize } from 'sanitize-filename-ts'; import { getExportExtension } from './utils'; -import { getMimeType } from '../../common/helpers'; +import { getMimeType, getPageTitle } from '../../common/helpers'; import * as path from 'path'; +import { AuditEvent, AuditResource } from '../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../integrations/audit/audit.service'; @Controller() export class ExportController { @@ -34,6 +40,7 @@ export class ExportController { private readonly pageRepo: PageRepo, private readonly spaceAbility: SpaceAbilityFactory, private readonly pageAccessService: PageAccessService, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} @UseGuards(JwtAuthGuard) @@ -62,6 +69,20 @@ export class ExportController { user.id, ); + this.auditService.log({ + event: AuditEvent.PAGE_EXPORTED, + resourceType: AuditResource.PAGE, + resourceId: page.id, + spaceId: page.spaceId, + metadata: { + title: getPageTitle(page.title), + format: dto.format, + includeChildren: dto.includeChildren, + includeAttachments: dto.includeAttachments, + spaceId: page.spaceId, + }, + }); + const fileName = sanitize(page.title || 'untitled') + '.zip'; res.headers({ @@ -93,6 +114,18 @@ export class ExportController { user.id, ); + this.auditService.log({ + event: AuditEvent.SPACE_EXPORTED, + resourceType: AuditResource.SPACE, + resourceId: dto.spaceId, + spaceId: dto.spaceId, + metadata: { + format: dto.format, + includeAttachments: dto.includeAttachments ?? false, + spaceName: exportFile.spaceName, + }, + }); + res.headers({ 'Content-Type': 'application/zip', 'Content-Disposition': diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts index 57fcb681..48be81ba 100644 --- a/apps/server/src/integrations/export/export.service.ts +++ b/apps/server/src/integrations/export/export.service.ts @@ -239,6 +239,7 @@ export class ExportService { return { fileStream: zipFile, fileName, + spaceName: space.name, }; } diff --git a/apps/server/src/integrations/import/import.controller.ts b/apps/server/src/integrations/import/import.controller.ts index 11842a51..e04e9301 100644 --- a/apps/server/src/integrations/import/import.controller.ts +++ b/apps/server/src/integrations/import/import.controller.ts @@ -4,6 +4,7 @@ import { ForbiddenException, HttpCode, HttpStatus, + Inject, Logger, Post, Req, @@ -24,6 +25,11 @@ import * as path from 'path'; import { ImportService } from './services/import.service'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { EnvironmentService } from '../environment/environment.service'; +import { AuditEvent, AuditResource } from '../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../integrations/audit/audit.service'; @Controller() export class ImportController { @@ -33,6 +39,7 @@ export class ImportController { private readonly importService: ImportService, private readonly spaceAbility: SpaceAbilityFactory, private readonly environmentService: EnvironmentService, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} @UseInterceptors(FileInterceptor) @@ -83,7 +90,34 @@ export class ImportController { throw new ForbiddenException(); } - return this.importService.importPage(file, user.id, spaceId, workspace.id); + const createdPage = await this.importService.importPage( + file, + user.id, + spaceId, + workspace.id, + ); + + const ext = path.extname(file.filename).toLowerCase(); + const sourceMap: Record = { + '.md': 'markdown', + '.html': 'html', + '.docx': 'docx', + }; + + if (createdPage) { + this.auditService.log({ + event: AuditEvent.PAGE_CREATED, + resourceType: AuditResource.PAGE, + resourceId: createdPage.id, + spaceId, + metadata: { + source: sourceMap[ext], + fileName: file.filename, + }, + }); + } + + return createdPage; } @UseInterceptors(FileInterceptor) @@ -142,6 +176,18 @@ export class ImportController { throw new ForbiddenException(); } + this.auditService.log({ + event: AuditEvent.PAGE_IMPORTED, + resourceType: AuditResource.PAGE, + resourceId: spaceId, + spaceId, + metadata: { + fileName: file.filename, + source, + spaceId, + }, + }); + return this.importService.importZip( file, source, diff --git a/apps/server/src/integrations/import/services/file-import-task.service.ts b/apps/server/src/integrations/import/services/file-import-task.service.ts index 8ae79598..6fc223a8 100644 --- a/apps/server/src/integrations/import/services/file-import-task.service.ts +++ b/apps/server/src/integrations/import/services/file-import-task.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import * as path from 'path'; import { jsonToText } from '../../../collaboration/collaboration.util'; import { InjectKysely } from 'nestjs-kysely'; @@ -36,6 +36,11 @@ import { PageService } from '../../../core/page/services/page.service'; import { ImportPageNode } from '../dto/file-task-dto'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventName } from '../../../common/events/event.contants'; +import { AuditEvent, AuditResource } from '../../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../../integrations/audit/audit.service'; @Injectable() export class FileImportTaskService { @@ -50,6 +55,7 @@ export class FileImportTaskService { private readonly importAttachmentService: ImportAttachmentService, private moduleRef: ModuleRef, private eventEmitter: EventEmitter2, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} async processZIpImport(fileTaskId: string): Promise { @@ -402,6 +408,7 @@ export class FileImportTaskService { // Process pages level by level sequentially to respect foreign key constraints const allBacklinks: any[] = []; const validPageIds = new Set(); + const pageTitles = new Map(); let totalPagesProcessed = 0; // Sort levels to process in order @@ -478,8 +485,9 @@ export class FileImportTaskService { await trx.insertInto('pages').values(insertablePage).execute(); - // Track valid page IDs and collect backlinks + // Track valid page IDs, titles, and collect backlinks validPageIds.add(insertablePage.id); + pageTitles.set(insertablePage.id, insertablePage.title); allBacklinks.push(...backlinks); totalPagesProcessed++; @@ -522,6 +530,26 @@ export class FileImportTaskService { `Successfully imported ${totalPagesProcessed} pages with ${filteredBacklinks.length} backlinks`, ); }); + + if (validPageIds.size > 0) { + const auditPayloads = Array.from(validPageIds).map((pageId) => ({ + event: AuditEvent.PAGE_CREATED, + resourceType: AuditResource.PAGE, + resourceId: pageId, + spaceId: fileTask.spaceId, + metadata: { + source: fileTask.source, + fileTaskId: fileTask.id, + title: pageTitles.get(pageId), + }, + })); + + this.auditService.logBatchWithContext(auditPayloads, { + workspaceId: fileTask.workspaceId, + actorId: fileTask.creatorId, + actorType: 'user', + }); + } } catch (error) { this.logger.error('Failed to import files:', error); throw new Error(`File import failed: ${error?.['message']}`); diff --git a/apps/server/src/integrations/import/services/import.service.ts b/apps/server/src/integrations/import/services/import.service.ts index a6aec5c5..231a6c89 100644 --- a/apps/server/src/integrations/import/services/import.service.ts +++ b/apps/server/src/integrations/import/services/import.service.ts @@ -49,7 +49,7 @@ export class ImportService { userId: string, spaceId: string, workspaceId: string, - ): Promise { + ) { const file = await filePromise; const fileBuffer = await file.toBuffer(); const fileExtension = path.extname(file.filename).toLowerCase(); diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts index c4d47947..1c66a5f3 100644 --- a/apps/server/src/integrations/queue/constants/queue.constants.ts +++ b/apps/server/src/integrations/queue/constants/queue.constants.ts @@ -8,6 +8,7 @@ export enum QueueName { AI_QUEUE = '{ai-queue}', HISTORY_QUEUE = '{history-queue}', NOTIFICATION_QUEUE = '{notification-queue}', + AUDIT_QUEUE = '{audit-queue}', } export enum QueueJob { @@ -68,4 +69,7 @@ export enum QueueJob { COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification', PAGE_MENTION_NOTIFICATION = 'page-mention-notification', PAGE_PERMISSION_GRANTED = 'page-permission-granted', + + AUDIT_LOG = 'audit-log', + AUDIT_CLEANUP = 'audit-cleanup', } diff --git a/apps/server/src/integrations/queue/queue.module.ts b/apps/server/src/integrations/queue/queue.module.ts index 6268977f..a7b83c3f 100644 --- a/apps/server/src/integrations/queue/queue.module.ts +++ b/apps/server/src/integrations/queue/queue.module.ts @@ -84,6 +84,14 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor'; BullModule.registerQueue({ name: QueueName.NOTIFICATION_QUEUE, }), + BullModule.registerQueue({ + name: QueueName.AUDIT_QUEUE, + defaultJobOptions: { + removeOnComplete: true, + removeOnFail: true, + attempts: 3, + }, + }), ], exports: [BullModule], providers: [GeneralQueueProcessor], diff --git a/apps/server/src/integrations/transactional/emails/forgot-password-email.tsx b/apps/server/src/integrations/transactional/emails/forgot-password-email.tsx index 7fb6cb89..59270e5e 100644 --- a/apps/server/src/integrations/transactional/emails/forgot-password-email.tsx +++ b/apps/server/src/integrations/transactional/emails/forgot-password-email.tsx @@ -17,6 +17,9 @@ export const ForgotPasswordEmail = ({ username, resetLink }: Props) => { We received a request from you to reset your password. Click here to set a new password + + This link is valid for 30 minutes. + If you did not request a password reset, please ignore this email. diff --git a/apps/server/src/ws/ws.service.ts b/apps/server/src/ws/ws.service.ts index e2bf4807..5476664a 100644 --- a/apps/server/src/ws/ws.service.ts +++ b/apps/server/src/ws/ws.service.ts @@ -35,7 +35,6 @@ export class WsService { const pageId = this.extractPageId(data); if (!pageId) { - client.broadcast.to(room).emit('message', data); return; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1ef0abf..bfd70602 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -468,6 +468,9 @@ importers: '@aws-sdk/s3-request-presigner': specifier: 3.982.0 version: 3.982.0 + '@clickhouse/client': + specifier: ^1.17.0 + version: 1.17.0 '@fastify/cookie': specifier: ^11.0.2 version: 11.0.2 @@ -609,6 +612,9 @@ importers: nanoid: specifier: 3.3.11 version: 3.3.11 + nestjs-cls: + specifier: ^6.2.0 + version: 6.2.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) nestjs-kysely: specifier: ^1.2.0 version: 1.2.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(kysely@0.28.2)(reflect-metadata@0.2.2) @@ -1761,6 +1767,13 @@ packages: '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@clickhouse/client-common@1.17.0': + resolution: {integrity: sha512-MiwwgXViFAQA2YZkN4ymF1ynzG0K49KeSX9/iOcmJetWkxqSekDdpyp1GjwATWa9R215uQ+hGzJtJujeQVZZIw==} + + '@clickhouse/client@1.17.0': + resolution: {integrity: sha512-Y3DQoamKZ/Iyosoq7Lj7lqpDkQDK4R/5mI52yJs4ZLPIO+d6/CYDqTbFBIb4No3C/AlXUYE4TKhj/kXDpe6rOA==} + engines: {node: '>=16'} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -8281,6 +8294,15 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + nestjs-cls@6.2.0: + resolution: {integrity: sha512-b2Remha7gV5gId3ezjr2tupjqqgYK7/JqjqX6oZ0ZIDFATUggKH1/32+ul2lOe7FepnHasDONDoePuWEE64cug==} + engines: {node: '>=18'} + peerDependencies: + '@nestjs/common': '>= 10 < 12' + '@nestjs/core': '>= 10 < 12' + reflect-metadata: '*' + rxjs: '>= 7' + nestjs-kysely@1.2.0: resolution: {integrity: sha512-KseCGb0SXCzIYC+Hx3Z3d+kPAfSZCSK6j9UoqUV/gcBCPad9utC7itmoUw0/w5sV+Jf9pc1DKpgClP1IkflA4w==} peerDependencies: @@ -11974,6 +11996,12 @@ snapshots: '@chevrotain/utils@11.0.3': {} + '@clickhouse/client-common@1.17.0': {} + + '@clickhouse/client@1.17.0': + dependencies: + '@clickhouse/client-common': 1.17.0 + '@colors/colors@1.5.0': optional: true @@ -19236,6 +19264,13 @@ snapshots: neo-async@2.6.2: {} + nestjs-cls@6.2.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2): + dependencies: + '@nestjs/common': 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.13(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + nestjs-kysely@1.2.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(kysely@0.28.2)(reflect-metadata@0.2.2): dependencies: '@nestjs/common': 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) From dcc2bacb2283e49bcd2978c821d1e622b0210f54 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 1 Mar 2026 01:31:10 +0000 Subject: [PATCH 17/58] sync --- apps/server/src/ee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/ee b/apps/server/src/ee index 9157ff1e..27c69314 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 9157ff1e6d6ab41fdabba332a66ae9638bf833a0 +Subproject commit 27c69314881979f9b308609854bdfb2b56e45182 From 2309d1434b875c8e9a54da6410c6731ac652baf7 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 1 Mar 2026 17:14:10 +0000 Subject: [PATCH 18/58] feat: support cross-space page mentions (#1979) --- .../public/locales/en-US/translation.json | 1 + .../common/editor-paste-handler.tsx | 7 -- .../components/mention/mention-list.tsx | 88 ++++++++++++------- .../components/mention/mention-suggestion.ts | 2 +- .../components/mention/mention-view.tsx | 12 ++- .../editor/components/mention/mention.type.ts | 2 + apps/server/src/core/search/search.service.ts | 20 +++-- apps/server/src/database/database.module.ts | 18 +--- .../src/integrations/export/export.service.ts | 24 ++++- 9 files changed, 103 insertions(+), 71 deletions(-) diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 3f366e4d..7b87e3eb 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -130,6 +130,7 @@ "pages": "pages", "Password": "Password", "Password changed successfully": "Password changed successfully", + "People": "People", "Pending": "Pending", "Please confirm your action": "Please confirm your action", "Preferences": "Preferences", diff --git a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx index a7a91749..dea1f73c 100644 --- a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx +++ b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx @@ -33,7 +33,6 @@ export const handlePaste = ( const url = clipboardData.trim(); const { from: pos, empty } = editor.state.selection; const match = INTERNAL_LINK_REGEX.exec(url); - const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href); // pasted link must be from the same workspace/domain and must not be on a selection if (!empty || match[2] !== window.location.host) { @@ -41,12 +40,6 @@ export const handlePaste = ( return false; } - // for now, we only support internal links from the same space - // compare space name - if (currentPageMatch[4].toLowerCase() !== match[4].toLowerCase()) { - return false; - } - const anchorId = match[6] ? match[6].split("#")[0] : undefined; const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) diff --git a/apps/client/src/features/editor/components/mention/mention-list.tsx b/apps/client/src/features/editor/components/mention/mention-list.tsx index f71e39eb..f086df49 100644 --- a/apps/client/src/features/editor/components/mention/mention-list.tsx +++ b/apps/client/src/features/editor/components/mention/mention-list.tsx @@ -31,13 +31,17 @@ import { MentionSuggestionItem, } from "@/features/editor/components/mention/mention.type.ts"; import { IPage } from "@/features/page/types/page.types"; -import { useCreatePageMutation, usePageQuery } from "@/features/page/queries/page-query"; +import { + useCreatePageMutation, + usePageQuery, +} from "@/features/page/queries/page-query"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom"; import { SimpleTree } from "react-arborist"; import { SpaceTreeNode } from "@/features/page/tree/types"; import { useTranslation } from "react-i18next"; import { useQueryEmit } from "@/features/websocket/use-query-emit"; import { extractPageSlugId } from "@/lib"; +import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx"; const MentionList = forwardRef((props, ref) => { const [selectedIndex, setSelectedIndex] = useState(1); @@ -59,11 +63,11 @@ const MentionList = forwardRef((props, ref) => { includeUsers: true, includePages: true, spaceId: space.id, - limit: 10, + limit: props.query ? 10 : 5, preload: true, }); - const createPageItem = (label: string) : MentionSuggestionItem => { + const createPageItem = (label: string): MentionSuggestionItem => { return { id: null, label: label, @@ -71,15 +75,15 @@ const MentionList = forwardRef((props, ref) => { entityId: null, slugId: null, icon: null, - } - } + }; + }; useEffect(() => { if (suggestion && !isLoading) { let items: MentionSuggestionItem[] = []; if (suggestion?.users?.length > 0) { - items.push({ entityType: "header", label: t("Users") }); + items.push({ entityType: "header", label: t("People") }); items = items.concat( suggestion.users.map((user) => ({ @@ -97,11 +101,13 @@ const MentionList = forwardRef((props, ref) => { items = items.concat( suggestion.pages.map((page) => ({ id: uuid7(), - label: page.title || "Untitled", + label: page.title || t("Untitled"), entityType: "page", entityId: page.id, slugId: page.slugId, icon: page.icon, + spaceName: page.space?.name, + spaceSlug: page.space?.slug, })), ); } @@ -129,17 +135,17 @@ const MentionList = forwardRef((props, ref) => { creatorId: currentUser?.user.id, }); } - if (item.entityType === "page" && item.id!==null) { + if (item.entityType === "page" && item.id !== null) { props.command({ id: item.id, - label: item.label || "Untitled", + label: item.label || t("Untitled"), entityType: "page", entityId: item.entityId, slugId: item.slugId, creatorId: currentUser?.user.id, }); } - if (item.entityType === "page" && item.id===null) { + if (item.entityType === "page" && item.id === null) { createPage(item.label); } } @@ -207,7 +213,7 @@ const MentionList = forwardRef((props, ref) => { const payload: { spaceId: string; parentPageId?: string; title: string } = { spaceId: space.id, parentPageId: page.id || null, - title: title + title: title, }; let createdPage: IPage; @@ -231,7 +237,7 @@ const MentionList = forwardRef((props, ref) => { props.command({ id: uuid7(), - label: createdPage.title || "Untitled", + label: createdPage.title || "Untitled", entityType: "page", entityId: createdPage.id, slugId: createdPage.slugId, @@ -239,21 +245,20 @@ const MentionList = forwardRef((props, ref) => { }); setTimeout(() => { - emit({ - operation: "addTreeNode", - spaceId: space.id, - payload: { - parentId, - index: lastIndex, - data, - }, - }); - }, 50); - + emit({ + operation: "addTreeNode", + spaceId: space.id, + payload: { + parentId, + index: lastIndex, + data, + }, + }); + }, 50); } catch (err) { throw new Error("Failed to create page"); } - } + }; useEffect(() => { viewportRef.current @@ -267,15 +272,19 @@ const MentionList = forwardRef((props, ref) => { return ( - { t("No results") } + {t("No results")} ); } const hasUsers = renderItems.some((item) => item.entityType === "user"); - const hasPages = renderItems.some((item) => item.entityType === "page" && item.id !== null); - const createPageItemData = renderItems.find((item) => item.entityType === "page" && item.id === null); + const hasPages = renderItems.some( + (item) => item.entityType === "page" && item.id !== null, + ); + const createPageItemData = renderItems.find( + (item) => item.entityType === "page" && item.id === null, + ); return ( @@ -283,7 +292,9 @@ const MentionList = forwardRef((props, ref) => { viewportRef={viewportRef} mah={350} w={popupWidth} + scrollbars={"y"} scrollbarSize={6} + styles={{ content: { minWidth: 0 } }} > {renderItems?.map((item, index) => { if (item.entityType === "header") { @@ -299,6 +310,7 @@ const MentionList = forwardRef((props, ref) => { pt={isFirst ? 2 : 4} pb={4} tt="uppercase" + style={{ userSelect: "none" }} > {item.label} @@ -323,9 +335,9 @@ const MentionList = forwardRef((props, ref) => { />
- + {item.label} - +
@@ -355,9 +367,14 @@ const MentionList = forwardRef((props, ref) => {
- + {item.label} - + + {item.spaceName && ( + + {item.spaceName} + + )}
@@ -372,9 +389,12 @@ const MentionList = forwardRef((props, ref) => { {(hasUsers || hasPages) && } selectItem(renderItems.indexOf(createPageItemData))} + onClick={() => + selectItem(renderItems.indexOf(createPageItemData)) + } className={clsx(classes.menuBtn, { - [classes.selectedItem]: renderItems.indexOf(createPageItemData) === selectedIndex, + [classes.selectedItem]: + renderItems.indexOf(createPageItemData) === selectedIndex, })} px="sm" > @@ -388,7 +408,7 @@ const MentionList = forwardRef((props, ref) => { -
+
{t("Create page")}: {createPageItemData.label} diff --git a/apps/client/src/features/editor/components/mention/mention-suggestion.ts b/apps/client/src/features/editor/components/mention/mention-suggestion.ts index 2a4fec1f..01a4ffad 100644 --- a/apps/client/src/features/editor/components/mention/mention-suggestion.ts +++ b/apps/client/src/features/editor/components/mention/mention-suggestion.ts @@ -106,7 +106,7 @@ const mentionRenderItems = () => { left: `${x}px`, top: `${y}px`, position: "absolute", - zIndex: "9999", + zIndex: "100", }); }); }, diff --git a/apps/client/src/features/editor/components/mention/mention-view.tsx b/apps/client/src/features/editor/components/mention/mention-view.tsx index 22cb8be6..a874cdf4 100644 --- a/apps/client/src/features/editor/components/mention/mention-view.tsx +++ b/apps/client/src/features/editor/components/mention/mention-view.tsx @@ -54,12 +54,20 @@ export default function MentionView(props: NodeViewProps) { )} - {entityType === "page" && ( + {entityType === "page" && isError && ( + + {label} + + )} + + {entityType === "page" && !isError && ( this.pageRepo.withSpace(eb)) .where((eb) => eb( sql`LOWER(f_unaccent(pages.title))`, @@ -209,17 +210,19 @@ export class SearchService { .where('workspaceId', '=', workspaceId) .limit(limit); - // only search spaces the user has access to + // search all spaces the user has access to, prioritizing the current space const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId); - if (suggestion?.spaceId) { - if (userSpaceIds.includes(suggestion.spaceId)) { - pageSearch = pageSearch.where('spaceId', '=', suggestion.spaceId); - pages = await pageSearch.execute(); - } - } else if (userSpaceIds?.length > 0) { - // we need this check or the query will throw an error if the userSpaceIds array is empty + if (userSpaceIds?.length > 0) { pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds); + + if (suggestion?.spaceId) { + pageSearch = pageSearch.orderBy( + sql`CASE WHEN pages."space_id" = ${suggestion.spaceId} THEN 0 ELSE 1 END`, + 'asc', + ); + } + pages = await pageSearch.execute(); } @@ -230,7 +233,6 @@ export class SearchService { await this.pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId, - spaceId: suggestion?.spaceId, }); const accessibleSet = new Set(accessibleIds); pages = pages.filter((p) => accessibleSet.has(p.id)); diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index 765fee4f..3503e4ea 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -1,10 +1,4 @@ -import { - Global, - Logger, - Module, - OnApplicationBootstrap, - BeforeApplicationShutdown, -} from '@nestjs/common'; +import { Global, Logger, Module, OnApplicationBootstrap } from '@nestjs/common'; import { InjectKysely, KyselyModule } from 'nestjs-kysely'; import { EnvironmentService } from '../integrations/environment/environment.service'; import { CamelCasePlugin, LogEvent, sql } from 'kysely'; @@ -107,9 +101,7 @@ import { normalizePostgresUrl } from '../common/helpers'; WatcherRepo, ], }) -export class DatabaseModule - implements OnApplicationBootstrap, BeforeApplicationShutdown -{ +export class DatabaseModule implements OnApplicationBootstrap { private readonly logger = new Logger(DatabaseModule.name); constructor( @@ -126,12 +118,6 @@ export class DatabaseModule } } - async beforeApplicationShutdown(): Promise { - if (this.db) { - await this.db.destroy(); - } - } - async establishConnection() { const retryAttempts = 15; const retryDelay = 3000; diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts index 48be81ba..c8cea483 100644 --- a/apps/server/src/integrations/export/export.service.ts +++ b/apps/server/src/integrations/export/export.service.ts @@ -33,6 +33,7 @@ import slugify = require('@sindresorhus/slugify'); // eslint-disable-next-line @typescript-eslint/no-require-imports const packageJson = require('../../../package.json'); import { EnvironmentService } from '../environment/environment.service'; +import { DomainService } from '../environment/domain.service'; import { getAttachmentIds, getProsemirrorContent, @@ -49,6 +50,7 @@ export class ExportService { @InjectKysely() private readonly db: KyselyDB, private readonly storageService: StorageService, private readonly environmentService: EnvironmentService, + private readonly domainService: DomainService, ) {} async exportPage(format: string, page: Page, singlePage?: boolean) { @@ -61,9 +63,11 @@ export class ExportService { let prosemirrorJson: any; if (singlePage) { + const baseUrl = await this.getWorkspaceBaseUrl(page.workspaceId); prosemirrorJson = await this.turnPageMentionsToLinks( getProsemirrorContent(page.content), page.workspaceId, + baseUrl, ); } else { // mentions is already turned to links during the zip process @@ -149,12 +153,14 @@ export class ExportService { const tree = buildTree(pages as Page[]); + const baseUrl = await this.getWorkspaceBaseUrl(pages[0].workspaceId); const zip = new JSZip(); await this.zipPages( tree, format, zip, includeAttachments, + baseUrl, userId, ignorePermissions, ); @@ -218,6 +224,7 @@ export class ExportService { const tree = buildTree(pages as Page[]); + const baseUrl = await this.getWorkspaceBaseUrl(pages[0].workspaceId); const zip = new JSZip(); await this.zipPages( @@ -225,6 +232,7 @@ export class ExportService { format, zip, includeAttachments, + baseUrl, userId, ignorePermissions, ); @@ -248,6 +256,7 @@ export class ExportService { format: string, zip: JSZip, includeAttachments: boolean, + baseUrl: string, userId?: string, ignorePermissions = false, ): Promise { @@ -271,6 +280,7 @@ export class ExportService { const prosemirrorJson = await this.turnPageMentionsToLinks( getProsemirrorContent(page.content), page.workspaceId, + baseUrl, userId, ignorePermissions, ); @@ -360,6 +370,7 @@ export class ExportService { async turnPageMentionsToLinks( prosemirrorJson: any, workspaceId: string, + baseUrl: string, userId?: string, ignorePermissions = false, ) { @@ -429,8 +440,7 @@ export class ExportService { const truncatedTitle = linkTitle?.substring(0, 70); const pageSlug = `${slugify(truncatedTitle)}-${slugId}`; - // Create the link URL - const link = `${this.environmentService.getAppUrl()}/s/${spaceSlug}/p/${pageSlug}`; + const link = `${baseUrl}/s/${spaceSlug}/p/${pageSlug}`; // Create a link mark and a text node with that mark const linkMark = editorState.schema.marks.link.create({ href: link }); @@ -476,6 +486,16 @@ export class ExportService { return updatedDoc.toJSON(); } + private async getWorkspaceBaseUrl(workspaceId: string): Promise { + const workspace = await this.db + .selectFrom('workspaces') + .select('hostname') + .where('id', '=', workspaceId) + .executeTakeFirst(); + + return this.domainService.getUrl(workspace?.hostname); + } + private async filterPagesForExport( pages: Page[], rootPageId: string | null, From 60848ea9037c2edd8ab90bce11c215a5d6cc9a50 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 1 Mar 2026 18:37:39 +0000 Subject: [PATCH 19/58] feat(ee): mcp (#1976) * feat: MCP * sync * sync --- apps/client/package.json | 2 +- .../public/locales/en-US/translation.json | 19 + apps/client/src/App.tsx | 1 + .../components/settings/settings-sidebar.tsx | 2 +- .../src/components/ui/custom-avatar.tsx | 2 +- .../src/ee/ai/components/mcp-settings.tsx | 138 +++++++ apps/client/src/ee/ai/pages/ai-settings.tsx | 65 ++- .../components/create-api-key-modal.tsx | 6 +- .../components/update-api-key-modal.tsx | 6 +- .../src/ee/api-key/pages/user-api-keys.tsx | 39 +- .../ee/api-key/pages/workspace-api-keys.tsx | 11 +- .../ee/audit/components/audit-logs-table.tsx | 1 + apps/client/src/ee/audit/types/audit.types.ts | 1 + .../src/ee/components/cloud-login-form.tsx | 7 +- .../src/ee/components/ldap-login-modal.tsx | 6 +- .../src/ee/components/manage-hostname.tsx | 9 +- .../components/activate-license-modal.tsx | 7 +- .../mfa/components/mfa-backup-codes-modal.tsx | 6 +- .../src/ee/mfa/components/mfa-challenge.tsx | 6 +- .../ee/mfa/components/mfa-disable-modal.tsx | 8 +- .../src/ee/mfa/components/mfa-setup-modal.tsx | 6 +- .../security/components/allowed-domains.tsx | 6 +- .../security/components/sso-google-form.tsx | 6 +- .../ee/security/components/sso-ldap-form.tsx | 6 +- .../ee/security/components/sso-oidc-form.tsx | 7 +- .../ee/security/components/sso-saml-form.tsx | 6 +- .../auth/components/forgot-password-form.tsx | 18 +- .../auth/components/invite-sign-up-form.tsx | 9 +- .../features/auth/components/login-form.tsx | 18 +- .../auth/components/password-reset-form.tsx | 13 +- .../auth/components/setup-workspace-form.tsx | 18 +- .../editor/components/embed/embed-view.tsx | 6 +- .../group/components/create-group-form.tsx | 6 +- .../group/components/edit-group-form.tsx | 6 +- .../space/components/create-space-form.tsx | 6 +- .../space/components/edit-space-form.tsx | 7 +- .../user/components/account-name-form.tsx | 7 +- .../features/user/components/change-email.tsx | 11 +- .../user/components/change-password.tsx | 11 +- .../members/components/invite-action-menu.tsx | 4 +- .../components/workspace-name-form.tsx | 6 +- .../workspace/types/workspace.types.ts | 2 + apps/client/src/theme.ts | 1 + apps/server/package.json | 4 +- apps/server/src/core/search/search.module.ts | 1 + .../workspace/dto/update-workspace.dto.ts | 4 + .../workspace/services/workspace.service.ts | 18 +- apps/server/src/ee | 2 +- pnpm-lock.yaml | 378 +++++++++++++++++- 49 files changed, 781 insertions(+), 154 deletions(-) create mode 100644 apps/client/src/ee/ai/components/mcp-settings.tsx diff --git a/apps/client/package.json b/apps/client/package.json index 617bf447..60ceae35 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -55,7 +55,7 @@ "semver": "^7.7.3", "socket.io-client": "^4.8.3", "tiptap-extension-global-drag-handle": "^0.1.18", - "zod": "^3.25.76" + "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.16.0", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 7b87e3eb..5c64c3e5 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -607,6 +607,25 @@ "Generative AI (Ask AI)": "Generative AI (Ask AI)", "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.", "Toggle generative AI": "Toggle generative AI", + "Enterprise feature": "Enterprise feature", + "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.", + "AI & MCP": "AI & MCP", + "AI": "AI", + "MCP": "MCP", + "Model Context Protocol (MCP)": "Model Context Protocol (MCP)", + "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.", + "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.", + "MCP documentation": "MCP documentation", + "MCP Server URL": "MCP Server URL", + "Use your API key for authentication. You can manage API keys in your account settings.": "Use your API key for authentication. You can manage API keys in your account settings.", + "Supported tools": "Supported tools", + "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Your workspace has MCP enabled. Use your API key to connect AI assistants.", + "MCP server URL:": "MCP server URL:", + "Learn more": "Learn more", + "View the": "View the", + "for usage details.": "for usage details.", + "for setup instructions.": "for setup instructions.", + "API documentation": "API documentation", "Sources": "Sources", "AI Answers not available for attachments": "AI Answers not available for attachments", "No answer available": "No answer available", diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 3a1eb621..c290157c 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -103,6 +103,7 @@ export default function App() { } /> } /> } /> + } /> } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 1c49f918..70f20782 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -113,7 +113,7 @@ const groupedData: DataGroup[] = [ showDisabledInNonEE: true, }, { - label: "AI settings", + label: "AI", icon: IconSparkles, path: "/settings/ai", isAdmin: true, diff --git a/apps/client/src/components/ui/custom-avatar.tsx b/apps/client/src/components/ui/custom-avatar.tsx index 54730127..1342cdfb 100644 --- a/apps/client/src/components/ui/custom-avatar.tsx +++ b/apps/client/src/components/ui/custom-avatar.tsx @@ -4,7 +4,7 @@ import { getAvatarUrl } from "@/lib/config.ts"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; interface CustomAvatarProps { - avatarUrl: string; + avatarUrl?: string; name: string; color?: string; size?: string | number; diff --git a/apps/client/src/ee/ai/components/mcp-settings.tsx b/apps/client/src/ee/ai/components/mcp-settings.tsx new file mode 100644 index 00000000..396e74ff --- /dev/null +++ b/apps/client/src/ee/ai/components/mcp-settings.tsx @@ -0,0 +1,138 @@ +import { + Anchor, + Group, + List, + Text, + Switch, + TextInput, + ActionIcon, + Tooltip, + Stack, + Alert, +} from "@mantine/core"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; +import { notifications } from "@mantine/notifications"; +import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; +import { getAppUrl } from "@/lib/config.ts"; +import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react"; +import { CopyButton } from "@/components/common/copy-button.tsx"; + +export default function McpSettings() { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const [checked, setChecked] = useState(workspace?.settings?.ai?.mcp); + const hasAccess = useIsCloudEE(); + + const mcpUrl = `${getAppUrl()}/api/mcp`; + + const handleChange = async (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + try { + const updatedWorkspace = await updateWorkspace({ mcpEnabled: value }); + setChecked(value); + setWorkspace(updatedWorkspace); + } catch (err) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + } + }; + + return ( + + {!hasAccess && ( + } + title={t("Enterprise feature")} + color="blue" + > + {t( + "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.", + )} + + )} + + +
+ {t("Model Context Protocol (MCP)")} + + {t( + "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.", + )}{" "} + {t("View the")}{" "} + + {t("MCP documentation")} + + . + +
+ + +
+ + {checked && ( +
+ + {t("MCP Server URL")} + + + + + {({ copied, copy }) => ( + + + {copied ? : } + + + )} + + + + {t( + "Use your API key for authentication. You can manage API keys in your account settings.", + )} + + +
+ + {t("Supported tools")} + + + search_pages, get_page, create_page, update_page + list_pages, list_child_pages, duplicate_page + copy_page_to_space, move_page, move_page_to_space + get_space, list_spaces, create_space, update_space + get_comments, create_comment, update_comment + search_attachments, list_workspace_members, get_current_user + +
+
+ )} +
+ ); +} diff --git a/apps/client/src/ee/ai/pages/ai-settings.tsx b/apps/client/src/ee/ai/pages/ai-settings.tsx index 441f91b9..d0aaeafe 100644 --- a/apps/client/src/ee/ai/pages/ai-settings.tsx +++ b/apps/client/src/ee/ai/pages/ai-settings.tsx @@ -6,44 +6,75 @@ import useUserRole from "@/hooks/use-user-role.tsx"; import { useTranslation } from "react-i18next"; import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx"; import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx"; -import { Alert, Stack } from "@mantine/core"; +import McpSettings from "@/ee/ai/components/mcp-settings.tsx"; +import { Alert, Stack, Tabs } from "@mantine/core"; import { IconInfoCircle } from "@tabler/icons-react"; import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; import { isCloud } from "@/lib/config.ts"; +import { useLocation, useNavigate } from "react-router-dom"; export default function AiSettings() { const { t } = useTranslation(); const { isAdmin } = useUserRole(); const hasAccess = useIsCloudEE(); + const location = useLocation(); + const navigate = useNavigate(); + + const activeTab = location.pathname.endsWith("/mcp") ? "mcp" : "ai"; if (!isAdmin) { return null; } + const handleTabChange = (value: string | null) => { + if (value === "mcp") { + navigate("/settings/ai/mcp"); + } else { + navigate("/settings/ai"); + } + }; + return ( <> AI - {getAppName()} - + - {!hasAccess && ( - } - title={t("Enterprise feature")} - color="blue" - mb="lg" - > - {t( - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.", + + + + {t("AI")} + + + {t("MCP")} + + + + + {!hasAccess && ( + } + title={t("Enterprise feature")} + color="blue" + mb="lg" + > + {t( + "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.", + )} + )} - - )} - - {!isCloud() && } - - + + {!isCloud() && } + + + + + + + + ); } diff --git a/apps/client/src/ee/api-key/components/create-api-key-modal.tsx b/apps/client/src/ee/api-key/components/create-api-key-modal.tsx index cade36e8..ab19552c 100644 --- a/apps/client/src/ee/api-key/components/create-api-key-modal.tsx +++ b/apps/client/src/ee/api-key/components/create-api-key-modal.tsx @@ -1,8 +1,8 @@ import { lazy, Suspense, useState } from "react"; import { Modal, TextInput, Button, Group, Stack, Select } from "@mantine/core"; import { useForm } from "@mantine/form"; -import { zodResolver } from "mantine-form-zod-resolver"; -import { z } from "zod"; +import { zod4Resolver } from "mantine-form-zod-resolver"; +import { z } from "zod/v4"; import { useTranslation } from "react-i18next"; import { useCreateApiKeyMutation } from "@/ee/api-key/queries/api-key-query"; import { IconCalendar } from "@tabler/icons-react"; @@ -36,7 +36,7 @@ export function CreateApiKeyModal({ const createApiKeyMutation = useCreateApiKeyMutation(); const form = useForm({ - validate: zodResolver(formSchema), + validate: zod4Resolver(formSchema), initialValues: { name: "", expiresAt: "", diff --git a/apps/client/src/ee/api-key/components/update-api-key-modal.tsx b/apps/client/src/ee/api-key/components/update-api-key-modal.tsx index 6edeb1c3..e4370eac 100644 --- a/apps/client/src/ee/api-key/components/update-api-key-modal.tsx +++ b/apps/client/src/ee/api-key/components/update-api-key-modal.tsx @@ -1,7 +1,7 @@ import { Modal, TextInput, Button, Group, Stack } from "@mantine/core"; import { useForm } from "@mantine/form"; -import { zodResolver } from "mantine-form-zod-resolver"; -import { z } from "zod"; +import { zod4Resolver } from "mantine-form-zod-resolver"; +import { z } from "zod/v4"; import { useTranslation } from "react-i18next"; import { useUpdateApiKeyMutation } from "@/ee/api-key/queries/api-key-query"; import { IApiKey } from "@/ee/api-key"; @@ -27,7 +27,7 @@ export function UpdateApiKeyModal({ const updateApiKeyMutation = useUpdateApiKeyMutation(); const form = useForm({ - validate: zodResolver(formSchema), + validate: zod4Resolver(formSchema), initialValues: { name: "", }, diff --git a/apps/client/src/ee/api-key/pages/user-api-keys.tsx b/apps/client/src/ee/api-key/pages/user-api-keys.tsx index 49fd9cab..9e7c3446 100644 --- a/apps/client/src/ee/api-key/pages/user-api-keys.tsx +++ b/apps/client/src/ee/api-key/pages/user-api-keys.tsx @@ -1,9 +1,9 @@ import React, { useState } from "react"; -import { Button, Group, Space } from "@mantine/core"; +import { Anchor, Alert, Button, Group, Space, Text } 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 { getAppName, getAppUrl } from "@/lib/config"; import { ApiKeyTable } from "@/ee/api-key/components/api-key-table"; import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal"; import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal"; @@ -13,6 +13,8 @@ import Paginate from "@/components/common/paginate"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts"; import { IApiKey } from "@/ee/api-key"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; export default function UserApiKeys() { const { t } = useTranslation(); @@ -23,6 +25,8 @@ export default function UserApiKeys() { const [revokeModalOpened, setRevokeModalOpened] = useState(false); const [selectedApiKey, setSelectedApiKey] = useState(null); const { data, isLoading } = useGetApiKeysQuery({ cursor }); + const [workspace] = useAtom(workspaceAtom); + const mcpEnabled = workspace?.settings?.ai?.mcp === true; const handleCreateSuccess = (response: IApiKey) => { setCreatedApiKey(response); @@ -48,6 +52,37 @@ export default function UserApiKeys() { + + {t("View the")}{" "} + + {t("API documentation")} + {" "} + {t("for usage details.")} + + + {mcpEnabled && ( + + + {t( + "Your workspace has MCP enabled. Use your API key to connect AI assistants.", + )}{" "} + + {t("Learn more")} + + + + {t("MCP server URL:")}{" "} + + {`${getAppUrl()}/api/mcp`} + + + + )} +