From 425924cb4dcb179765f759fcd94a2306bada586b Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Thu, 14 May 2026 03:47:27 +0100 Subject: [PATCH] feat(editor): add page break node --- .../public/locales/en-US/translation.json | 2 + .../fixed-toolbar/groups/block-type-group.tsx | 7 +++ .../components/slash-menu/menu-items.ts | 9 +++ .../features/editor/extensions/extensions.ts | 2 + .../src/features/editor/styles/index.css | 1 + .../src/features/editor/styles/page-break.css | 50 ++++++++++++++++ .../src/collaboration/collaboration.util.ts | 2 + packages/editor-ext/src/index.ts | 1 + .../editor-ext/src/lib/page-break/index.ts | 1 + .../src/lib/page-break/page-break.ts | 60 +++++++++++++++++++ 10 files changed, 135 insertions(+) create mode 100644 apps/client/src/features/editor/styles/page-break.css create mode 100644 packages/editor-ext/src/lib/page-break/index.ts create mode 100644 packages/editor-ext/src/lib/page-break/page-break.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 268d696c8..e85f2af23 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -361,6 +361,8 @@ "Create block quote.": "Create block quote.", "Insert code snippet.": "Insert code snippet.", "Insert horizontal rule divider": "Insert horizontal rule divider", + "Page break": "Page break", + "Insert a page break for printing.": "Insert a page break for printing.", "Upload any image from your device.": "Upload any image from your device.", "Upload any video from your device.": "Upload any video from your device.", "Upload any audio from your device.": "Upload any audio from your device.", diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/block-type-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/block-type-group.tsx index 3edb28eda..69911f7cb 100644 --- a/apps/client/src/features/editor/components/fixed-toolbar/groups/block-type-group.tsx +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/block-type-group.tsx @@ -10,6 +10,7 @@ import { IconH2, IconH3, IconMenu4, + IconPageBreak, IconTypography, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; @@ -102,6 +103,12 @@ export const BlockTypeGroup: FC = ({ editor }) => { > {t("Divider")} + } + onClick={() => editor.chain().focus().setPageBreak().run()} + > + {t("Page break")} + ); 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 4a0532fe3..cddddc35f 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 @@ -19,6 +19,7 @@ import { IconTable, IconTypography, IconMenu4, + IconPageBreak, IconCalendar, IconAppWindow, IconSitemap, @@ -164,6 +165,14 @@ const CommandGroups: SlashMenuGroupedItemsType = { command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(), }, + { + title: "Page break", + description: "Insert a page break for printing.", + searchTerms: ["page", "break", "pagebreak", "print"], + icon: IconPageBreak, + command: ({ editor, range }: CommandProps) => + editor.chain().focus().deleteRange(range).setPageBreak().run(), + }, { title: "Image", description: "Upload any image from your device.", diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 1f09bef37..91411daef 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -42,6 +42,7 @@ import { Excalidraw, Embed, TiptapPdf, + PageBreak, SearchAndReplace, Mention, TableDndExtension, @@ -366,6 +367,7 @@ export const mainExtensions = [ TiptapPdf.configure({ view: PdfView, }), + PageBreak, Subpages.configure({ view: SubpagesView, }), diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css index 7abfe1086..52d9268e1 100644 --- a/apps/client/src/features/editor/styles/index.css +++ b/apps/client/src/features/editor/styles/index.css @@ -9,6 +9,7 @@ @import "./media.css"; @import "./code.css"; @import "./print.css"; +@import "./page-break.css"; @import "./find.css"; @import "./mention.css"; @import "./ordered-list.css"; diff --git a/apps/client/src/features/editor/styles/page-break.css b/apps/client/src/features/editor/styles/page-break.css new file mode 100644 index 000000000..6dc97c738 --- /dev/null +++ b/apps/client/src/features/editor/styles/page-break.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; + } +} diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 5787e2e3a..554aa43bd 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -26,6 +26,7 @@ import { TiptapVideo, TiptapAudio, TiptapPdf, + PageBreak, TrailingNode, Attachment, Drawio, @@ -94,6 +95,7 @@ export const tiptapExtensions = [ TiptapVideo, TiptapAudio, TiptapPdf, + PageBreak, Callout, Attachment, CustomCodeBlock, diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 354b1a617..003d22886 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -31,5 +31,6 @@ export * from "./lib/recreate-transform"; export * from "./lib/columns"; export * from "./lib/status"; export * from "./lib/pdf"; +export * from "./lib/page-break"; export * from "./lib/resizable-nodeview"; diff --git a/packages/editor-ext/src/lib/page-break/index.ts b/packages/editor-ext/src/lib/page-break/index.ts new file mode 100644 index 000000000..701b20b78 --- /dev/null +++ b/packages/editor-ext/src/lib/page-break/index.ts @@ -0,0 +1 @@ +export * from "./page-break"; diff --git a/packages/editor-ext/src/lib/page-break/page-break.ts b/packages/editor-ext/src/lib/page-break/page-break.ts new file mode 100644 index 000000000..f8991b266 --- /dev/null +++ b/packages/editor-ext/src/lib/page-break/page-break.ts @@ -0,0 +1,60 @@ +import { mergeAttributes, Node } from "@tiptap/core"; + +export interface PageBreakOptions { + HTMLAttributes: Record; +} + +declare module "@tiptap/core" { + interface Commands { + pageBreak: { + setPageBreak: () => ReturnType; + }; + } +} + +export const PageBreak = Node.create({ + 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(), + }; + }, +});