Compare commits

...

5 Commits

Author SHA1 Message Date
Julien Fontanet 82d065669d fix: page mode toggle no longer overwrites default preference (#1996)
The header edit/read toggle now controls only the current session's mode
without saving it as the user's preference. The saved preference (set in
profile settings) is applied once on initial load and sticks across page
navigations within the session, so navigating to a new page no longer
resets the mode mid-session.

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

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

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

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

* sync

* package overrides
2026-05-14 02:54:00 +01:00
35 changed files with 1012 additions and 945 deletions
@@ -361,6 +361,8 @@
"Create block quote.": "Create block quote.", "Create block quote.": "Create block quote.",
"Insert code snippet.": "Insert code snippet.", "Insert code snippet.": "Insert code snippet.",
"Insert horizontal rule divider": "Insert horizontal rule divider", "Insert horizontal rule divider": "Insert horizontal rule divider",
"Page break": "Page break",
"Insert a page break for printing.": "Insert a page break for printing.",
"Upload any image from your device.": "Upload any image from your device.", "Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.", "Upload any video from your device.": "Upload any video from your device.",
"Upload any audio from your device.": "Upload any audio from your device.", "Upload any audio from your device.": "Upload any audio from your device.",
+64 -5
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts"; import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { Button, Divider, Stack } from "@mantine/core"; import { Button, Divider, Stack } from "@mantine/core";
import { IconLock, IconServer } from "@tabler/icons-react"; import { IconLock, IconServer } from "@tabler/icons-react";
@@ -7,15 +7,37 @@ import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts"; import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx"; import { GoogleIcon } from "@/components/icons/google-icon.tsx";
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx"; import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
import { getRedirectParam } from "@/lib/app-route.ts";
import useCurrentUser from "@/features/user/hooks/use-current-user.ts";
const SSO_AUTO_ATTEMPT_KEY = "docmost:ssoAutoAttempt";
const SSO_AUTO_ATTEMPT_TTL_MS = 5 * 60_000;
function recentAutoAttempt(): boolean {
try {
const raw = window.sessionStorage.getItem(SSO_AUTO_ATTEMPT_KEY);
if (!raw) return false;
const ts = Number(raw);
return Number.isFinite(ts) && Date.now() - ts < SSO_AUTO_ATTEMPT_TTL_MS;
} catch {
return false;
}
}
function markAutoAttempt(): void {
try {
window.sessionStorage.setItem(SSO_AUTO_ATTEMPT_KEY, String(Date.now()));
} catch {
/* sessionStorage unavailable (private mode, etc.) — best effort */
}
}
export default function SsoLogin() { export default function SsoLogin() {
const { data, isLoading } = useWorkspacePublicDataQuery(); const { data, isLoading } = useWorkspacePublicDataQuery();
const { data: currentUser } = useCurrentUser();
const [ldapModalOpened, setLdapModalOpened] = useState(false); const [ldapModalOpened, setLdapModalOpened] = useState(false);
const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null); const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null);
const autoRedirectedRef = useRef(false);
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const handleSsoLogin = (provider: IAuthProvider) => { const handleSsoLogin = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.LDAP) { if (provider.type === SSO_PROVIDER.LDAP) {
@@ -28,10 +50,47 @@ export default function SsoLogin() {
providerId: provider.id, providerId: provider.id,
type: provider.type, type: provider.type,
workspaceId: data.id, workspaceId: data.id,
redirect: getRedirectParam() ?? undefined,
}); });
} }
}; };
// Auto-redirect when SSO is enforced and there is exactly one non-LDAP
// provider. The user has no other option, so skip the extra click.
useEffect(() => {
if (autoRedirectedRef.current) return;
if (!data?.enforceSso) return;
if (!data.authProviders || data.authProviders.length !== 1) return;
const onlyProvider = data.authProviders[0];
if (onlyProvider.type === SSO_PROVIDER.LDAP) return;
// Already signed in: let useRedirectIfAuthenticated handle navigation
// instead of racing it through the IdP.
if (currentUser?.user) return;
// Explicit logout: don't immediately bounce them back to the IdP.
const params = new URLSearchParams(window.location.search);
if (params.has("logout")) return;
// Circuit-breaker: if we already auto-redirected within the TTL, the
// user came back (likely from an IdP failure). Show the page so they
// can read errors or pick a different account.
if (recentAutoAttempt()) return;
autoRedirectedRef.current = true;
markAutoAttempt();
window.location.href = buildSsoLoginUrl({
providerId: onlyProvider.id,
type: onlyProvider.type,
workspaceId: data.id,
redirect: getRedirectParam() ?? undefined,
});
}, [data, currentUser]);
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const getProviderIcon = (provider: IAuthProvider) => { const getProviderIcon = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.GOOGLE) { if (provider.type === SSO_PROVIDER.GOOGLE) {
return <GoogleIcon size={16} />; return <GoogleIcon size={16} />;
+10 -3
View File
@@ -18,14 +18,21 @@ export function buildSsoLoginUrl(opts: {
providerId: string; providerId: string;
type: SSO_PROVIDER; type: SSO_PROVIDER;
workspaceId?: string; workspaceId?: string;
redirect?: string;
}): string { }): string {
const { providerId, type, workspaceId } = opts; const { providerId, type, workspaceId, redirect } = opts;
const domain = getAppUrl(); const domain = getAppUrl();
const params = new URLSearchParams();
if (redirect) params.set("redirect", redirect);
if (type === SSO_PROVIDER.GOOGLE) { if (type === SSO_PROVIDER.GOOGLE) {
return `${getServerAppUrl()}/api/sso/${type}/login?workspaceId=${workspaceId}`; if (workspaceId) params.set("workspaceId", workspaceId);
return `${getServerAppUrl()}/api/sso/${type}/login?${params.toString()}`;
} }
return `${domain}/api/sso/${type}/${providerId}/login`; const query = params.toString();
const base = `${domain}/api/sso/${type}/${providerId}/login`;
return query ? `${base}?${query}` : base;
} }
export function getGoogleSignupUrl(): string { export function getGoogleSignupUrl(): string {
@@ -166,7 +166,7 @@ export default function useAuth() {
const handleLogout = async () => { const handleLogout = async () => {
setCurrentUser(RESET); setCurrentUser(RESET);
await logout(); await logout();
window.location.replace(APP_ROUTE.AUTH.LOGIN); window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
}; };
const handleForgotPassword = async (data: IForgotPassword) => { const handleForgotPassword = async (data: IForgotPassword) => {
@@ -1,5 +1,6 @@
import { atom } from "jotai"; import { atom } from "jotai";
import { Editor } from "@tiptap/core"; import { Editor } from "@tiptap/core";
import { PageEditMode } from "@/features/user/types/user.types.ts";
export const pageEditorAtom = atom<Editor | null>(null); export const pageEditorAtom = atom<Editor | null>(null);
@@ -12,3 +13,7 @@ export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false); export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false); export const showLinkMenuAtom = atom(false);
// Current page's edit mode — initialized from the user's saved preference on
// first load, can be toggled locally without persisting to the server.
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
@@ -10,6 +10,7 @@ import {
IconH2, IconH2,
IconH3, IconH3,
IconMenu4, IconMenu4,
IconPageBreak,
IconTypography, IconTypography,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -102,6 +103,12 @@ export const BlockTypeGroup: FC<Props> = ({ editor }) => {
> >
{t("Divider")} {t("Divider")}
</Menu.Item> </Menu.Item>
<Menu.Item
leftSection={<IconPageBreak size={16} />}
onClick={() => editor.chain().focus().setPageBreak().run()}
>
{t("Page break")}
</Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
); );
@@ -19,6 +19,7 @@ import {
IconTable, IconTable,
IconTypography, IconTypography,
IconMenu4, IconMenu4,
IconPageBreak,
IconCalendar, IconCalendar,
IconAppWindow, IconAppWindow,
IconSitemap, IconSitemap,
@@ -164,6 +165,14 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setHorizontalRule().run(), editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
}, },
{
title: "Page break",
description: "Insert a page break for printing.",
searchTerms: ["page", "break", "pagebreak", "print"],
icon: IconPageBreak,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setPageBreak().run(),
},
{ {
title: "Image", title: "Image",
description: "Upload any image from your device.", description: "Upload any image from your device.",
@@ -35,6 +35,7 @@ export default function TransclusionReferenceView(props: NodeViewProps) {
return ( return (
<NodeViewWrapper <NodeViewWrapper
className={classes.includeWrap} className={classes.includeWrap}
data-editable={isEditable ? "true" : "false"}
data-focused={isEditable && props.selected ? "true" : "false"} data-focused={isEditable && props.selected ? "true" : "false"}
data-menu-open={openMenus > 0 ? "true" : "false"} data-menu-open={openMenus > 0 ? "true" : "false"}
contentEditable={false} contentEditable={false}
@@ -62,6 +62,7 @@ export default function TransclusionView(props: NodeViewProps) {
return ( return (
<NodeViewWrapper <NodeViewWrapper
className={classes.transclusionWrap} className={classes.transclusionWrap}
data-editable={isEditable ? "true" : "false"}
data-menu-open={openMenus > 0 ? "true" : "false"} data-menu-open={openMenus > 0 ? "true" : "false"}
data-id={transclusionId ?? undefined} data-id={transclusionId ?? undefined}
> >
@@ -44,8 +44,29 @@
transition: border 0.3s; transition: border 0.3s;
} }
.transclusionWrap:hover, .transclusionWrap[data-editable="false"],
.transclusionWrap:focus-within { .includeWrap[data-editable="false"] {
margin-left: 0;
margin-right: 0;
width: 100%;
padding: 0;
}
/* Cancel the wrapping .react-renderer's vertical spacing in read-only mode
so the synced block sits flush with surrounding paragraphs (whose own
margins already provide the right rhythm). */
:global(.react-renderer.node-transclusionSource):has(
.transclusionWrap[data-editable="false"]
),
:global(.react-renderer.node-transclusionReference):has(
.includeWrap[data-editable="false"]
) {
margin-top: 0;
margin-bottom: 0;
}
.transclusionWrap[data-editable="true"]:hover,
.transclusionWrap[data-editable="true"]:focus-within {
border: 2px solid border: 2px solid
light-dark( light-dark(
var(--mantine-color-orange-2), var(--mantine-color-orange-2),
@@ -114,9 +135,9 @@
transition: border 0.3s; transition: border 0.3s;
} }
.includeWrap:hover, .includeWrap[data-editable="true"]:hover,
.includeWrap[data-focused="true"], .includeWrap[data-editable="true"][data-focused="true"],
.includeWrap[data-menu-open="true"] { .includeWrap[data-editable="true"][data-menu-open="true"] {
border: 2px solid border: 2px solid
light-dark( light-dark(
var(--mantine-color-orange-2), var(--mantine-color-orange-2),
@@ -42,6 +42,7 @@ import {
Excalidraw, Excalidraw,
Embed, Embed,
TiptapPdf, TiptapPdf,
PageBreak,
SearchAndReplace, SearchAndReplace,
Mention, Mention,
TableDndExtension, TableDndExtension,
@@ -366,6 +367,7 @@ export const mainExtensions = [
TiptapPdf.configure({ TiptapPdf.configure({
view: PdfView, view: PdfView,
}), }),
PageBreak,
Subpages.configure({ Subpages.configure({
view: SubpagesView, view: SubpagesView,
}), }),
@@ -1,5 +1,5 @@
import classes from "@/features/editor/styles/editor.module.css"; import classes from "@/features/editor/styles/editor.module.css";
import React from "react"; import React, { useEffect } from "react";
import { TitleEditor } from "@/features/editor/title-editor"; import { TitleEditor } from "@/features/editor/title-editor";
import PageEditor from "@/features/editor/page-editor"; import PageEditor from "@/features/editor/page-editor";
import { import {
@@ -24,6 +24,7 @@ import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-t
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import clsx from "clsx"; import clsx from "clsx";
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts";
const MemoizedTitleEditor = React.memo(TitleEditor); const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor); const MemoizedPageEditor = React.memo(PageEditor);
@@ -34,6 +35,10 @@ type PageCreator = {
avatarUrl: string; avatarUrl: string;
}; };
// Module-level flag: survives component unmount/remount on page navigation,
// reset only on full page reload (i.e. a new app session).
let defaultEditModeApplied = false;
export interface FullEditorProps { export interface FullEditorProps {
pageId: string; pageId: string;
slugId: string; slugId: string;
@@ -61,9 +66,19 @@ export function FullEditor({
const fullPageWidth = user.settings?.preferences?.fullPageWidth; const fullPageWidth = user.settings?.preferences?.fullPageWidth;
const editorToolbarEnabled = const editorToolbarEnabled =
user.settings?.preferences?.editorToolbar ?? false; user.settings?.preferences?.editorToolbar ?? false;
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(currentPageEditModeAtom);
const userPageEditMode = const userPageEditMode =
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const isEditMode = userPageEditMode === PageEditMode.Edit; const isEditMode = currentPageEditMode === PageEditMode.Edit;
// Apply the user's saved preference only once on initial load, not on every
// page navigation — so the mode sticks across navigations within a session.
useEffect(() => {
if (!defaultEditModeApplied) {
setCurrentPageEditMode(userPageEditMode);
defaultEditModeApplied = true;
}
}, [userPageEditMode, setCurrentPageEditMode]);
return ( return (
<Container <Container
@@ -26,10 +26,11 @@ import {
collabExtensions, collabExtensions,
mainExtensions, mainExtensions,
} from "@/features/editor/extensions/extensions"; } from "@/features/editor/extensions/extensions";
import { useAtom } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url"; import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { import {
currentPageEditModeAtom,
pageEditorAtom, pageEditorAtom,
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms"; } from "@/features/editor/atoms/editor-atoms";
@@ -112,8 +113,7 @@ export default function PageEditor({
const documentState = useDocumentVisibility(); const documentState = useDocumentVisibility();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug); const slugId = extractPageSlugId(pageSlug);
const userPageEditMode = const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const canScroll = useCallback( const canScroll = useCallback(
() => Boolean(isComponentMounted.current && editorRef.current), () => Boolean(isComponentMounted.current && editorRef.current),
[isComponentMounted], [isComponentMounted],
@@ -373,19 +373,9 @@ export default function PageEditor({
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}, [yjsConnectionStatus, isSynced]); }, [yjsConnectionStatus, isSynced]);
useEffect(() => { useEffect(() => {
// Only honor user default page edit mode preference and permissions if (!editor) return;
if (editor) { editor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
if (userPageEditMode && editable) { }, [currentPageEditMode, editor, editable]);
if (userPageEditMode === PageEditMode.Edit) {
editor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
editor.setEditable(false);
}
} else {
editor.setEditable(false);
}
}
}, [userPageEditMode, editor, editable]);
const hasConnectedOnceRef = useRef(false); const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true); const [showStatic, setShowStatic] = useState(true);
@@ -9,6 +9,7 @@
@import "./media.css"; @import "./media.css";
@import "./code.css"; @import "./code.css";
@import "./print.css"; @import "./print.css";
@import "./page-break.css";
@import "./find.css"; @import "./find.css";
@import "./mention.css"; @import "./mention.css";
@import "./ordered-list.css"; @import "./ordered-list.css";
@@ -0,0 +1,50 @@
.ProseMirror .page-break {
position: relative;
margin: 1.5rem 0;
border-top: 1px dashed var(--mantine-color-default-border);
height: 0;
user-select: none;
}
.ProseMirror[contenteditable="false"] .page-break {
margin: 0;
border: none;
height: 0;
}
.ProseMirror[contenteditable="false"] .page-break::after {
content: none;
}
.ProseMirror .page-break::after {
content: "Page break";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 0 0.5rem;
background: var(--mantine-color-body);
color: var(--mantine-color-dimmed);
font-size: 0.75rem;
line-height: 1;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.ProseMirror .page-break.ProseMirror-selectednode {
border-top-color: var(--mantine-primary-color-filled);
}
@media print {
.ProseMirror .page-break {
break-before: always;
page-break-before: always;
visibility: hidden;
border: none;
margin: 0;
}
.ProseMirror .page-break::after {
content: none;
}
}
@@ -7,6 +7,7 @@ import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder"; import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { import {
currentPageEditModeAtom,
pageEditorAtom, pageEditorAtom,
titleEditorAtom, titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms"; } from "@/features/editor/atoms/editor-atoms";
@@ -24,7 +25,6 @@ import { useTranslation } from "react-i18next";
import EmojiCommand from "@/features/editor/extensions/emoji-command.ts"; import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
import { UpdateEvent } from "@/features/websocket/types"; import { UpdateEvent } from "@/features/websocket/types";
import localEmitter from "@/lib/local-emitter.ts"; import localEmitter from "@/lib/local-emitter.ts";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import { searchSpotlight } from "@/features/search/constants.ts"; import { searchSpotlight } from "@/features/search/constants.ts";
import { platformModifierKey } from "@/lib"; import { platformModifierKey } from "@/lib";
@@ -52,9 +52,7 @@ export function TitleEditor({
const emit = useQueryEmit(); const emit = useQueryEmit();
const navigate = useNavigate(); const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId); const [activePageId, setActivePageId] = useState(pageId);
const [currentUser] = useAtom(currentUserAtom); const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const titleEditor = useEditor({ const titleEditor = useEditor({
extensions: [ extensions: [
@@ -172,18 +170,9 @@ export function TitleEditor({
}, [pageId]); }, [pageId]);
useEffect(() => { useEffect(() => {
if (titleEditor) { if (!titleEditor) return;
if (userPageEditMode && editable) { titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
if (userPageEditMode === PageEditMode.Edit) { }, [currentPageEditMode, titleEditor, editable]);
titleEditor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
titleEditor.setEditable(false);
}
} else {
titleEditor.setEditable(false);
}
}
}, [userPageEditMode, titleEditor, editable]);
const openSearchDialog = () => { const openSearchDialog = () => {
const event = new CustomEvent("openFindDialogFromEditor", {}); const event = new CustomEvent("openFindDialogFromEditor", {});
@@ -40,7 +40,7 @@ import {
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts"; } from "@/features/editor/atoms/editor-atoms.ts";
import { formattedDate } from "@/lib/time.ts"; import { formattedDate } from "@/lib/time.ts";
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx"; import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission"; import { PageShareModal } from "@/ee/page-permission";
@@ -91,7 +91,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
<> <>
<ConnectionWarning /> <ConnectionWarning />
{!readOnly && <PageStateSegmentedControl size="xs" />} {!readOnly && <PageEditModeToggle size="xs" />}
<PageShareModal readOnly={readOnly} /> <PageShareModal readOnly={readOnly} />
@@ -1,6 +1,11 @@
import { atom } from "jotai"; import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { ISharedPageTree } from "@/features/share/types/share.types"; import { ISharedPageTree } from "@/features/share/types/share.types";
import { SharedPageTreeNode } from "@/features/share/utils"; import { SharedPageTreeNode } from "@/features/share/utils";
export const sharedPageTreeAtom = atom<ISharedPageTree | null>(null); export const sharedPageTreeAtom = atom<ISharedPageTree | null>(null);
export const sharedTreeDataAtom = atom<SharedPageTreeNode[] | null>(null); export const sharedTreeDataAtom = atom<SharedPageTreeNode[] | null>(null);
export const sharedPageFullWidthAtom = atomWithStorage<boolean>(
"sharedPageFullWidth",
false,
);
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
ActionIcon, ActionIcon,
AppShell, AppShell,
@@ -14,11 +14,16 @@ import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { ThemeToggle } from "@/components/theme-toggle.tsx"; import { ThemeToggle } from "@/components/theme-toggle.tsx";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { sharedPageTreeAtom, sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom"; import {
sharedPageFullWidthAtom,
sharedPageTreeAtom,
sharedTreeDataAtom,
} from "@/features/share/atoms/shared-page-atom";
import { buildSharedPageTree } from "@/features/share/utils"; import { buildSharedPageTree } from "@/features/share/utils";
import { import {
desktopSidebarAtom, desktopSidebarAtom,
mobileSidebarAtom, mobileSidebarAtom,
sidebarWidthAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx"; import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -27,7 +32,7 @@ import {
mobileTableOfContentAsideAtom, mobileTableOfContentAsideAtom,
tableOfContentAsideAtom, tableOfContentAsideAtom,
} from "@/features/share/atoms/sidebar-atom.ts"; } from "@/features/share/atoms/sidebar-atom.ts";
import { IconList } from "@tabler/icons-react"; import { IconArrowsHorizontal, IconList } from "@tabler/icons-react";
import { useToggleToc } from "@/features/share/hooks/use-toggle-toc.ts"; import { useToggleToc } from "@/features/share/hooks/use-toggle-toc.ts";
import classes from "./share.module.css"; import classes from "./share.module.css";
import { import {
@@ -55,6 +60,46 @@ export default function ShareShell({
const [mobileTocOpened] = useAtom(mobileTableOfContentAsideAtom); const [mobileTocOpened] = useAtom(mobileTableOfContentAsideAtom);
const toggleTocMobile = useToggleToc(mobileTableOfContentAsideAtom); const toggleTocMobile = useToggleToc(mobileTableOfContentAsideAtom);
const toggleToc = useToggleToc(tableOfContentAsideAtom); const toggleToc = useToggleToc(tableOfContentAsideAtom);
const [fullWidth, setFullWidth] = useAtom(sharedPageFullWidthAtom);
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef<HTMLElement | null>(null);
const startResizing = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
}, []);
const stopResizing = useCallback(() => {
setIsResizing(false);
}, []);
const resize = useCallback(
(e: MouseEvent) => {
if (!isResizing || !sidebarRef.current) return;
const newWidth =
e.clientX - sidebarRef.current.getBoundingClientRect().left;
if (newWidth < 220) {
setSidebarWidth(220);
return;
}
if (newWidth > 600) {
setSidebarWidth(600);
return;
}
setSidebarWidth(newWidth);
},
[isResizing, setSidebarWidth],
);
useEffect(() => {
window.addEventListener("mousemove", resize);
window.addEventListener("mouseup", stopResizing);
return () => {
window.removeEventListener("mousemove", resize);
window.removeEventListener("mouseup", stopResizing);
};
}, [resize, stopResizing]);
const { shareId } = useParams(); const { shareId } = useParams();
const { data } = useGetSharedPageTreeQuery(shareId); const { data } = useGetSharedPageTreeQuery(shareId);
@@ -81,7 +126,7 @@ export default function ShareShell({
header={{ height: 50 }} header={{ height: 50 }}
{...(data?.pageTree?.length > 1 && { {...(data?.pageTree?.length > 1 && {
navbar: { navbar: {
width: 300, width: sidebarWidth,
breakpoint: "sm", breakpoint: "sm",
collapsed: { collapsed: {
mobile: !mobileOpened, mobile: !mobileOpened,
@@ -166,6 +211,20 @@ export default function ShareShell({
<IconList size={20} stroke={2} /> <IconList size={20} stroke={2} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label={t("Full width")} withArrow>
<ActionIcon
variant={fullWidth ? "light" : "default"}
style={fullWidth ? undefined : { border: "none" }}
aria-label={t("Full width")}
aria-pressed={fullWidth}
onClick={() => setFullWidth((v) => !v)}
visibleFrom="sm"
size="sm"
>
<IconArrowsHorizontal size={20} stroke={2} />
</ActionIcon>
</Tooltip>
</> </>
<ThemeToggle /> <ThemeToggle />
@@ -174,7 +233,11 @@ export default function ShareShell({
</AppShell.Header> </AppShell.Header>
{data?.pageTree?.length > 1 && ( {data?.pageTree?.length > 1 && (
<AppShell.Navbar p="md" className={classes.navbar}> <AppShell.Navbar p="md" className={classes.navbar} ref={sidebarRef}>
<div
className={classes.resizeHandle}
onMouseDown={startResizing}
/>
<MemoizedSharedTree sharedPageTree={data} /> <MemoizedSharedTree sharedPageTree={data} />
</AppShell.Navbar> </AppShell.Navbar>
)} )}
@@ -10,6 +10,7 @@
.treeNode { .treeNode {
text-decoration: none; text-decoration: none;
user-select: none; user-select: none;
padding-bottom: 0;
} }
.navbar, .navbar,
@@ -18,3 +19,26 @@
width: 350px; width: 350px;
} }
} }
.resizeHandle {
width: 3px;
cursor: col-resize;
position: absolute;
right: 0;
top: 0;
bottom: 0;
z-index: 1;
&:hover,
&:active {
width: 5px;
background: light-dark(
var(--mantine-color-gray-4),
var(--mantine-color-dark-5)
);
}
@media (max-width: $mantine-breakpoint-sm) {
display: none;
}
}
@@ -6,6 +6,7 @@ import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row"; import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row";
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts";
export default function PageStatePref() { export default function PageStatePref() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -71,3 +72,24 @@ export function PageStateSegmentedControl({
/> />
); );
} }
// Header variant: updates the current page's mode locally without persisting
// the preference to the server.
export function PageEditModeToggle({ size }: { size?: MantineSize }) {
const { t } = useTranslation();
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
currentPageEditModeAtom,
);
return (
<SegmentedControl
size={size}
value={currentPageEditMode}
onChange={(v) => setCurrentPageEditMode(v as PageEditMode)}
data={[
{ label: t("Edit"), value: PageEditMode.Edit },
{ label: t("Read"), value: PageEditMode.Read },
]}
/>
);
}
+30 -12
View File
@@ -31,20 +31,38 @@ const APP_ROUTE = {
}, },
}; };
export function safeRedirectPath(input: unknown): string | null {
if (typeof input !== "string") return null;
if (input.length === 0 || input.length > 2048) return null;
// Reject whitespace, backslash, and any Unicode "Other" category char
// (ASCII controls, zero-width space, BOM, bidi marks, etc).
if (/[\s\\]|\p{C}/u.test(input)) return null;
if (!input.startsWith("/") || input.startsWith("//")) return null;
if (input.toLowerCase().includes("://")) return null;
if (/^\/[a-z][a-z0-9+\-.]*:/i.test(input)) return null;
try {
const resolved = new URL(input, window.location.origin);
if (resolved.origin !== window.location.origin) return null;
return resolved.pathname + resolved.search + resolved.hash;
} catch {
return null;
}
}
export function getPostLoginRedirect(): string { export function getPostLoginRedirect(): string {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const redirect = params.get("redirect"); return safeRedirectPath(params.get("redirect")) ?? APP_ROUTE.HOME;
if (redirect) { }
try {
const resolved = new URL(redirect, window.location.origin); /**
if (resolved.origin === window.location.origin) { * Returns the `?redirect=` value from the current URL only when it is a safe
return resolved.pathname + resolved.search + resolved.hash; * same-origin path. Unlike {@link getPostLoginRedirect} this returns `null`
} * (not `/home`) when no redirect is present, so callers can distinguish
} catch { * "user came here directly" from "user was bounced from a deep link".
// malformed URL, fall through to default */
} export function getRedirectParam(): string | null {
} const params = new URLSearchParams(window.location.search);
return APP_ROUTE.HOME; return safeRedirectPath(params.get("redirect"));
} }
export default APP_ROUTE; export default APP_ROUTE;
+6 -2
View File
@@ -9,7 +9,10 @@ import { extractPageSlugId } from "@/lib";
import { Error404 } from "@/components/ui/error-404.tsx"; import { Error404 } from "@/components/ui/error-404.tsx";
import ShareBranding from "@/features/share/components/share-branding.tsx"; import ShareBranding from "@/features/share/components/share-branding.tsx";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom.ts"; import {
sharedPageFullWidthAtom,
sharedTreeDataAtom,
} from "@/features/share/atoms/shared-page-atom.ts";
import { isPageInTree } from "@/features/share/utils.ts"; import { isPageInTree } from "@/features/share/utils.ts";
export default function SharedPage() { export default function SharedPage() {
@@ -23,6 +26,7 @@ export default function SharedPage() {
}); });
const sharedTreeData = useAtomValue(sharedTreeDataAtom); const sharedTreeData = useAtomValue(sharedTreeDataAtom);
const fullWidth = useAtomValue(sharedPageFullWidthAtom);
useEffect(() => { useEffect(() => {
if (shareId && data) { if (shareId && data) {
@@ -59,7 +63,7 @@ export default function SharedPage() {
)} )}
</Helmet> </Helmet>
<Container size={900} p={0}> <Container fluid={fullWidth} size={fullWidth ? undefined : 900} p={0}>
<ReadonlyPageEditor <ReadonlyPageEditor
key={data.page.id} key={data.page.id}
title={data.page.title} title={data.page.title}
+2 -2
View File
@@ -42,7 +42,7 @@
"@fastify/multipart": "^10.0.0", "@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3", "@fastify/static": "^9.1.3",
"@keyv/redis": "^5.1.6", "@keyv/redis": "^5.1.6",
"@langchain/core": "1.1.39", "@langchain/core": "1.1.46",
"@langchain/textsplitters": "1.0.1", "@langchain/textsplitters": "1.0.1",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@nest-lab/throttler-storage-redis": "^1.2.0", "@nest-lab/throttler-storage-redis": "^1.2.0",
@@ -81,7 +81,7 @@
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"js-tiktoken": "^1.0.21", "js-tiktoken": "^1.0.21",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"kysely": "^0.28.14", "kysely": "^0.28.17",
"kysely-migration-cli": "^0.4.2", "kysely-migration-cli": "^0.4.2",
"kysely-postgres-js": "^3.0.0", "kysely-postgres-js": "^3.0.0",
"ldapts": "^8.1.7", "ldapts": "^8.1.7",
@@ -26,6 +26,7 @@ import {
TiptapVideo, TiptapVideo,
TiptapAudio, TiptapAudio,
TiptapPdf, TiptapPdf,
PageBreak,
TrailingNode, TrailingNode,
Attachment, Attachment,
Drawio, Drawio,
@@ -94,6 +95,7 @@ export const tiptapExtensions = [
TiptapVideo, TiptapVideo,
TiptapAudio, TiptapAudio,
TiptapPdf, TiptapPdf,
PageBreak,
Callout, Callout,
Attachment, Attachment,
CustomCodeBlock, CustomCodeBlock,
@@ -1,3 +1,11 @@
export const CacheKey = { export const CacheKey = {
LICENSE_VALID: (workspaceId: string) => `license:valid:${workspaceId}`, LICENSE_VALID: (workspaceId: string) => `license:valid:${workspaceId}`,
SPACE_ROLES: (userId: string, spaceId: string) =>
`perm:space-roles:${userId}:${spaceId}`,
PAGE_CAN_EDIT: (userId: string, pageId: string) =>
`perm:can-edit:${userId}:${pageId}`,
}; };
// Permission caches dedupe repeated checks within and across short request bursts.
// 5s keeps staleness on revocations bounded.
export const PERMISSION_CACHE_TTL_MS = 5_000;
@@ -0,0 +1,27 @@
import { Cache } from 'cache-manager';
export async function withCache<T>(
cacheManager: Cache,
key: string,
ttlMs: number,
fn: () => Promise<T>,
): Promise<T> {
try {
const cached = await cacheManager.get<{ v: T }>(key);
if (cached !== undefined && cached !== null) {
return cached.v;
}
} catch (err) {
console.warn(`[withCache] get failed for "${key}", falling back to source`, err);
}
const value = await fn();
try {
await cacheManager.set(key, { v: value }, ttlMs);
} catch (err) {
console.warn(`[withCache] set failed for "${key}"`, err);
}
return value;
}
@@ -1,4 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils'; import { dbOrTx } from '@docmost/db/utils';
@@ -17,6 +19,11 @@ import {
executeWithCursorPagination, executeWithCursorPagination,
} from '@docmost/db/pagination/cursor-pagination'; } from '@docmost/db/pagination/cursor-pagination';
import { PagePermissionMember } from './types/page-permission.types'; import { PagePermissionMember } from './types/page-permission.types';
import { withCache } from '../../../common/helpers/with-cache';
import {
CacheKey,
PERMISSION_CACHE_TTL_MS,
} from '../../../common/helpers/cache-keys';
export { PagePermissionMember } from './types/page-permission.types'; export { PagePermissionMember } from './types/page-permission.types';
@@ -25,6 +32,7 @@ export class PagePermissionRepo {
constructor( constructor(
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private readonly groupRepo: GroupRepo, private readonly groupRepo: GroupRepo,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {} ) {}
async findPageAccessByPageId( async findPageAccessByPageId(
@@ -361,40 +369,8 @@ export class PagePermissionRepo {
* Check if user can access a page by verifying they have permission on ALL restricted ancestors. * Check if user can access a page by verifying they have permission on ALL restricted ancestors.
*/ */
async canUserAccessPage(userId: string, pageId: string): Promise<boolean> { async canUserAccessPage(userId: string, pageId: string): Promise<boolean> {
const deniedAncestor = await this.db const { canAccess } = await this.canUserEditPage(userId, pageId);
.withRecursive('ancestors', (qb) => return canAccess;
qb
.selectFrom('pages')
.select(['pages.id as ancestorId', 'pages.parentPageId'])
.where('pages.id', '=', pageId)
.unionAll((eb) =>
eb
.selectFrom('pages')
.innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
.select(['pages.id as ancestorId', 'pages.parentPageId']),
),
)
.selectFrom('ancestors')
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
this.userGroupIdsSubquery(eb, userId),
),
]),
),
)
.select('pageAccess.pageId')
.where('pagePermissions.id', 'is', null)
.executeTakeFirst();
return !deniedAncestor;
} }
/** /**
@@ -412,6 +388,11 @@ export class PagePermissionRepo {
canAccess: boolean; canAccess: boolean;
canEdit: boolean; canEdit: boolean;
}> { }> {
return withCache(
this.cacheManager,
CacheKey.PAGE_CAN_EDIT(userId, pageId),
PERMISSION_CACHE_TTL_MS,
async () => {
const result = await sql<{ const result = await sql<{
canAccess: boolean | null; canAccess: boolean | null;
canEdit: boolean | null; canEdit: boolean | null;
@@ -449,6 +430,8 @@ export class PagePermissionRepo {
canAccess: row.canAccess, canAccess: row.canAccess,
canEdit: row.canAccess && (row.canEdit ?? false), canEdit: row.canAccess && (row.canEdit ?? false),
}; };
},
);
} }
/** /**
@@ -1,4 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils'; import { dbOrTx } from '@docmost/db/utils';
@@ -13,6 +15,11 @@ import { MemberInfo, UserSpaceRole } from './types';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { withCache } from '../../../common/helpers/with-cache';
import {
CacheKey,
PERMISSION_CACHE_TTL_MS,
} from '../../../common/helpers/cache-keys';
@Injectable() @Injectable()
export class SpaceMemberRepo { export class SpaceMemberRepo {
@@ -20,6 +27,7 @@ export class SpaceMemberRepo {
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private readonly groupRepo: GroupRepo, private readonly groupRepo: GroupRepo,
private readonly spaceRepo: SpaceRepo, private readonly spaceRepo: SpaceRepo,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {} ) {}
async insertSpaceMember( async insertSpaceMember(
@@ -214,6 +222,11 @@ export class SpaceMemberRepo {
userId: string, userId: string,
spaceId: string, spaceId: string,
): Promise<UserSpaceRole[]> { ): Promise<UserSpaceRole[]> {
return withCache(
this.cacheManager,
CacheKey.SPACE_ROLES(userId, spaceId),
PERMISSION_CACHE_TTL_MS,
async () => {
const roles = await this.db const roles = await this.db
.selectFrom('spaceMembers') .selectFrom('spaceMembers')
.select(['userId', 'role']) .select(['userId', 'role'])
@@ -222,7 +235,11 @@ export class SpaceMemberRepo {
.unionAll( .unionAll(
this.db this.db
.selectFrom('spaceMembers') .selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId') .innerJoin(
'groupUsers',
'groupUsers.groupId',
'spaceMembers.groupId',
)
.select(['groupUsers.userId', 'spaceMembers.role']) .select(['groupUsers.userId', 'spaceMembers.role'])
.where('groupUsers.userId', '=', userId) .where('groupUsers.userId', '=', userId)
.where('spaceMembers.spaceId', '=', spaceId), .where('spaceMembers.spaceId', '=', spaceId),
@@ -233,6 +250,8 @@ export class SpaceMemberRepo {
return undefined; return undefined;
} }
return roles; return roles;
},
);
} }
async getUserIdsWithSpaceAccess( async getUserIdsWithSpaceAccess(
+5 -4
View File
@@ -104,8 +104,8 @@
"ws": "8.20.0", "ws": "8.20.0",
"dompurify": "3.4.1", "dompurify": "3.4.1",
"tmp": "0.2.5", "tmp": "0.2.5",
"hono": "4.12.14", "hono": "4.12.18",
"mermaid": "11.13.0", "mermaid": "11.15.0",
"nanoid@^3": "3.3.8", "nanoid@^3": "3.3.8",
"socket.io-parser": "4.2.6", "socket.io-parser": "4.2.6",
"serialize-javascript": "7.0.3", "serialize-javascript": "7.0.3",
@@ -131,9 +131,10 @@
"@xmldom/xmldom": "0.8.13", "@xmldom/xmldom": "0.8.13",
"handlebars": "4.7.9", "handlebars": "4.7.9",
"axios": "1.16.0", "axios": "1.16.0",
"langsmith": "0.5.19", "langsmith": "0.7.0",
"follow-redirects": "1.16.0", "follow-redirects": "1.16.0",
"protobufjs": "7.5.5" "protobufjs": "7.5.6",
"ip-address": "10.1.1"
}, },
"neverBuiltDependencies": [] "neverBuiltDependencies": []
} }
+1
View File
@@ -31,5 +31,6 @@ export * from "./lib/recreate-transform";
export * from "./lib/columns"; export * from "./lib/columns";
export * from "./lib/status"; export * from "./lib/status";
export * from "./lib/pdf"; export * from "./lib/pdf";
export * from "./lib/page-break";
export * from "./lib/resizable-nodeview"; export * from "./lib/resizable-nodeview";
@@ -0,0 +1 @@
export * from "./page-break";
@@ -0,0 +1,60 @@
import { mergeAttributes, Node } from "@tiptap/core";
export interface PageBreakOptions {
HTMLAttributes: Record<string, any>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
pageBreak: {
setPageBreak: () => ReturnType;
};
}
}
export const PageBreak = Node.create<PageBreakOptions>({
name: "pageBreak",
group: "block",
atom: true,
selectable: true,
addOptions() {
return {
HTMLAttributes: {},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-type": this.name, class: "page-break" },
this.options.HTMLAttributes,
HTMLAttributes,
),
];
},
addCommands() {
return {
setPageBreak:
() =>
({ chain }) =>
chain()
.insertContent({ type: this.name })
.focus()
.run(),
};
},
});
+450 -780
View File
File diff suppressed because it is too large Load Diff