From a0b2ac6ae3f481dc05028877eeeebaaf7ab05fde Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:30:30 +0100 Subject: [PATCH 1/4] feat: footnotes (#2384) * feat: footnotes * feat: proper DOCX support --- .../public/locales/en-US/translation.json | 2 + .../groups/more-inserts-group.tsx | 7 + .../components/slash-menu/menu-items.ts | 11 + .../features/editor/extensions/extensions.ts | 14 +- .../src/features/editor/styles/footnotes.css | 26 +++ .../src/features/editor/styles/index.css | 1 + .../src/collaboration/collaboration.util.ts | 13 +- apps/server/src/ee | 2 +- packages/editor-ext/src/index.ts | 1 + .../editor-ext/src/lib/footnotes/footnote.ts | 189 +++++++++++++++ .../editor-ext/src/lib/footnotes/footnotes.ts | 46 ++++ .../editor-ext/src/lib/footnotes/index.ts | 4 + .../editor-ext/src/lib/footnotes/reference.ts | 221 ++++++++++++++++++ .../editor-ext/src/lib/footnotes/rules.ts | 90 +++++++ .../editor-ext/src/lib/footnotes/utils.ts | 123 ++++++++++ .../lib/markdown/utils/footnotes.marked.ts | 110 +++++++++ .../src/lib/markdown/utils/marked.utils.ts | 18 +- .../src/lib/markdown/utils/turndown.utils.ts | 52 +++++ .../src/lib/prosemirror-docx/schema.ts | 23 +- .../src/lib/prosemirror-docx/serializer.ts | 23 ++ packages/editor-ext/src/lib/trailing-node.ts | 20 +- 21 files changed, 987 insertions(+), 9 deletions(-) create mode 100644 apps/client/src/features/editor/styles/footnotes.css create mode 100644 packages/editor-ext/src/lib/footnotes/footnote.ts create mode 100644 packages/editor-ext/src/lib/footnotes/footnotes.ts create mode 100644 packages/editor-ext/src/lib/footnotes/index.ts create mode 100644 packages/editor-ext/src/lib/footnotes/reference.ts create mode 100644 packages/editor-ext/src/lib/footnotes/rules.ts create mode 100644 packages/editor-ext/src/lib/footnotes/utils.ts create mode 100644 packages/editor-ext/src/lib/markdown/utils/footnotes.marked.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 18c981434..aaa340181 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -387,6 +387,8 @@ "Insert horizontal rule divider": "Insert horizontal rule divider", "Page break": "Page break", "Insert a page break for printing.": "Insert a page break for printing.", + "Footnote": "Footnote", + "Insert a footnote reference.": "Insert a footnote reference.", "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/more-inserts-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx index b12f609cf..f2ae8b912 100644 --- a/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx @@ -12,6 +12,7 @@ import { IconMathFunction, IconRotate2, IconSitemap, + IconSuperscript, IconTable, IconTag, } from "@tabler/icons-react"; @@ -270,6 +271,12 @@ export const MoreInsertsGroup: FC = ({ editor, templateMode }) => { > {t("Math block")} + } + onClick={() => editor.chain().focus().addFootnote().run()} + > + {t("Footnote")} + ); 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 0327acdde..d8598c5ea 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 @@ -30,6 +30,7 @@ import { IconTag, IconMoodSmile, IconRotate2, + IconSuperscript, } from "@tabler/icons-react"; import { CommandProps, @@ -177,6 +178,16 @@ const CommandGroups: SlashMenuGroupedItemsType = { command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).setPageBreak().run(), }, + { + title: "Footnote", + description: "Insert a footnote reference.", + searchTerms: ["footnote", "reference", "citation", "note"], + icon: IconSuperscript, + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).run(); + editor.commands.addFootnote(); + }, + }, { 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 bdbd78ad8..c72456e6e 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -1,5 +1,6 @@ import { markInputRule } from "@tiptap/core"; import { StarterKit } from "@tiptap/starter-kit"; +import { Document } from "@tiptap/extension-document"; import { Code } from "@tiptap/extension-code"; import { TextAlign } from "@tiptap/extension-text-align"; import { TaskList, TaskItem } from "@tiptap/extension-list"; @@ -63,6 +64,9 @@ import { TransclusionReference, TableView, BaseEmbed as BaseEmbedNode, + Footnotes, + Footnote, + FootnoteReference, } from "@docmost/editor-ext"; import { randomElement, @@ -132,6 +136,7 @@ lowlight.register("scala", scala); // @ts-ignore export const mainExtensions = [ StarterKit.configure({ + document: false, heading: false, undoRedo: false, link: false, @@ -143,6 +148,9 @@ export const mainExtensions = [ codeBlock: false, code: false, }), + Document.extend({ + content: "block+ footnotes?", + }), // 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 @@ -203,7 +211,8 @@ export const mainExtensions = [ parentName === "tableCell" || parentName === "tableHeader" || parentName === "callout" || - parentName === "blockquote" + parentName === "blockquote" || + parentName === "footnote" ) { return i18n.t("Write..."); } @@ -417,6 +426,9 @@ export const mainExtensions = [ }).configure(), Columns, Column, + Footnotes, + Footnote, + FootnoteReference, AutoJoiner.configure({ elementsToJoin: [], }), diff --git a/apps/client/src/features/editor/styles/footnotes.css b/apps/client/src/features/editor/styles/footnotes.css new file mode 100644 index 000000000..26edf51cf --- /dev/null +++ b/apps/client/src/features/editor/styles/footnotes.css @@ -0,0 +1,26 @@ +.ProseMirror sup a.footnote-ref { + color: var(--mantine-primary-color-filled); + text-decoration: none; + cursor: pointer; + font-weight: 600; +} + +.ProseMirror sup:has(a.footnote-ref) { + padding: 0 1px; +} + +.ProseMirror ol.footnotes { + margin-top: 2rem; + padding-top: 0.75rem; + font-size: 0.875rem; + color: var(--mantine-color-dimmed); + list-style-type: decimal; +} + +.ProseMirror ol.footnotes:has(li) { + border-top: 1px solid var(--mantine-color-default-border); +} + +.ProseMirror ol.footnotes li p { + margin: 0.15rem 0; +} diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css index 7b1ce93e8..cb49785ab 100644 --- a/apps/client/src/features/editor/styles/index.css +++ b/apps/client/src/features/editor/styles/index.css @@ -18,3 +18,4 @@ @import "./columns.css"; @import "./status.css"; @import "./base-embed.css"; +@import "./footnotes.css"; diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index e8e8d4273..7ee5ba4aa 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -1,4 +1,5 @@ import { StarterKit } from '@tiptap/starter-kit'; +import { Document } from '@tiptap/extension-document'; import { TextAlign } from '@tiptap/extension-text-align'; import { Superscript } from '@tiptap/extension-superscript'; import SubScript from '@tiptap/extension-subscript'; @@ -45,6 +46,9 @@ import { TransclusionSource, TransclusionReference, BaseEmbed, + Footnotes, + Footnote, + FootnoteReference, } from '@docmost/editor-ext'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; @@ -58,11 +62,15 @@ import { Logger } from '@nestjs/common'; export const tiptapExtensions = [ StarterKit.configure({ + document: false, codeBlock: false, link: false, trailingNode: false, heading: false, }), + Document.extend({ + content: 'block+ footnotes?', + }), Heading, UniqueID.configure({ types: ['heading', 'paragraph', 'transclusionSource'], @@ -110,7 +118,10 @@ export const tiptapExtensions = [ Status, TransclusionSource, TransclusionReference, - BaseEmbed + BaseEmbed, + Footnotes, + Footnote, + FootnoteReference, ] as any; export function jsonToHtml(tiptapJson: any) { diff --git a/apps/server/src/ee b/apps/server/src/ee index 05529bcf9..c7b77ffb9 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 05529bcf97919d84f17442a9faf1a93904a5a85a +Subproject commit c7b77ffb9ed6a7bf462a4683de4f84355122e00b diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index d8ed68f2f..80a67f449 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -32,6 +32,7 @@ export * from "./lib/columns"; export * from "./lib/status"; export * from "./lib/pdf"; export * from "./lib/page-break"; +export * from "./lib/footnotes"; export * from "./lib/resizable-nodeview"; export { pageNodeToDocxBuffer, diff --git a/packages/editor-ext/src/lib/footnotes/footnote.ts b/packages/editor-ext/src/lib/footnotes/footnote.ts new file mode 100644 index 000000000..f83ab757a --- /dev/null +++ b/packages/editor-ext/src/lib/footnotes/footnote.ts @@ -0,0 +1,189 @@ +//Source MIT - https://github.com/buttondown/tiptap-footnotes +import { mergeAttributes } from "@tiptap/core"; +import ListItem, { ListItemOptions } from "@tiptap/extension-list-item"; + +declare module "@tiptap/core" { + interface Commands { + footnote: { + /** + * scrolls to & sets the text selection at the end of the footnote with the given id + * @param id the id of the footote (i.e. the `data-id` attribute value of the footnote) + * @example editor.commands.focusFootnote("a43956c1-1ab8-462f-96e4-be3a4b27fd50") + */ + focusFootnote: (id: string) => ReturnType; + }; + } +} + +export interface FootnoteOptions extends ListItemOptions { + /** + * Content expression for this node + * @default "paragraph+" + */ + content: string; +} + +const Footnote = ListItem.extend({ + name: "footnote", + content() { + return this.options.content; + }, + isolating: true, + defining: true, + draggable: false, + + addOptions() { + return { + HTMLAttributes: {}, + bulletListTypeName: 'bulletList', + orderedListTypeName: 'orderedList', + ...this.parent?.(), + content: "paragraph+", + }; + }, + + addAttributes() { + return { + id: { + isRequired: true, + }, + // the data-id field should match the data-id field of a footnote reference. + // it's used to link footnotes and references together. + "data-id": { + isRequired: true, + }, + }; + }, + parseHTML() { + return [ + { + tag: "li", + getAttrs(node) { + const id = node.getAttribute("data-id"); + if (id) { + return { + "data-id": node.getAttribute("data-id"), + }; + } + return false; + }, + priority: 1000, + }, + ]; + }, + renderHTML({ HTMLAttributes }) { + return [ + "li", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + 0, + ]; + }, + + addCommands() { + return { + focusFootnote: + (id: string) => + ({ editor, chain }) => { + const matchedFootnote = editor.$node("footnote", { + "data-id": id, + }); + if (matchedFootnote) { + // sets the text selection to the end of the footnote definition and scroll to it. + chain() + .focus() + .setTextSelection( + matchedFootnote.from + matchedFootnote.content.size + ) + .run(); + + matchedFootnote.element.scrollIntoView(); + return true; + } + return false; + }, + }; + }, + addKeyboardShortcuts() { + return { + // when inside a footnote, Mod-a should select only the footnote content + "Mod-a": ({ editor }) => { + try { + const { selection } = editor.state; + const { $from } = selection; + + for (let depth = $from.depth; depth >= 0; depth--) { + const node = $from.node(depth); + if (node.type.name === "footnote") { + const start = $from.start(depth); + const end = $from.end(depth); + + editor.commands.setTextSelection({ + from: start + 1, + to: end - 1, + }); + return true; + } + } + + return false; + } catch (e) { + return false; + } + }, + // when the user presses tab, adjust the text selection to be at the end of the next footnote + Tab: ({ editor }) => { + try { + const { selection } = editor.state; + const pos = editor.$pos(selection.anchor); + if (!pos.after) return false; + // if the next node is "footnotes", place the text selection at the end of the first footnote + if (pos.after.node.type.name == "footnotes") { + const firstChild = pos.after.node.child(0); + editor + .chain() + .setTextSelection(pos.after.from + firstChild.content.size) + .scrollIntoView() + .run(); + return true; + } else { + const startPos = selection.$from.start(2); + if (Number.isNaN(startPos)) return false; + const parent = editor.$pos(startPos); + if (parent.node.type.name != "footnote" || !parent.after) { + return false; + } + // if the next node is a footnote, place the text selection at the end of it + editor + .chain() + .setTextSelection(parent.after.to - 1) + .scrollIntoView() + .run(); + return true; + } + } catch { + return false; + } + }, + // inverse of the tab command - place the text selection at the end of the previous footnote + "Shift-Tab": ({ editor }) => { + const { selection } = editor.state; + const startPos = selection.$from.start(2); + if (Number.isNaN(startPos)) return false; + const parent = editor.$pos(startPos); + if (parent.node.type.name != "footnote" || !parent.before) { + return false; + } + + editor + .chain() + .setTextSelection(parent.before.to - 1) + .scrollIntoView() + .run(); + return true; + }, + }; + }, + +}); + +export default Footnote; diff --git a/packages/editor-ext/src/lib/footnotes/footnotes.ts b/packages/editor-ext/src/lib/footnotes/footnotes.ts new file mode 100644 index 000000000..c07528b16 --- /dev/null +++ b/packages/editor-ext/src/lib/footnotes/footnotes.ts @@ -0,0 +1,46 @@ +//Source MIT - https://github.com/buttondown/tiptap-footnotes +import OrderedList from "@tiptap/extension-ordered-list"; +import FootnoteRules from "./rules"; + +const Footnotes = OrderedList.extend({ + name: "footnotes", + group: "", // removed the default group of the ordered list extension + isolating: true, + defining: true, + draggable: false, + + content() { + return "footnote*"; + }, + addAttributes() { + return { + class: { + default: "footnotes", + }, + }; + }, + parseHTML() { + return [ + { + tag: "ol.footnotes", + priority: 1000, + }, + ]; + }, + + addKeyboardShortcuts() { + return {}; + }, + addCommands() { + return {}; + }, + addInputRules() { + return []; + }, + + addExtensions() { + return [FootnoteRules]; + }, +}); + +export default Footnotes; diff --git a/packages/editor-ext/src/lib/footnotes/index.ts b/packages/editor-ext/src/lib/footnotes/index.ts new file mode 100644 index 000000000..b22501b3b --- /dev/null +++ b/packages/editor-ext/src/lib/footnotes/index.ts @@ -0,0 +1,4 @@ +export { default as Footnotes } from "./footnotes"; +export { default as Footnote } from "./footnote"; +export type { FootnoteOptions } from "./footnote"; +export { default as FootnoteReference } from "./reference"; diff --git a/packages/editor-ext/src/lib/footnotes/reference.ts b/packages/editor-ext/src/lib/footnotes/reference.ts new file mode 100644 index 000000000..6bada1281 --- /dev/null +++ b/packages/editor-ext/src/lib/footnotes/reference.ts @@ -0,0 +1,221 @@ +//Source MIT - https://github.com/buttondown/tiptap-footnotes +import { mergeAttributes, Node } from "@tiptap/core"; +import { + Fragment as PMFragment, + Node as PMNode, + Slice, +} from "@tiptap/pm/model"; +import { NodeSelection, Plugin, PluginKey } from "@tiptap/pm/state"; +import { generateNodeId } from "../utils"; + + +const REFNUM_ATTR = "data-reference-number"; +const REF_CLASS = "footnote-ref"; + +declare module "@tiptap/core" { + interface Commands { + footnoteReference: { + /** + * add a new footnote reference + * @example editor.commands.addFootnote() + */ + addFootnote: () => ReturnType; + }; + } +} + +const FootnoteReference = Node.create({ + name: "footnoteReference", + inline: true, + content: "text*", + group: "inline", + atom: true, + draggable: true, + + parseHTML() { + return [ + { + tag: `sup`, + priority: 1000, + getAttrs(node) { + const anchor = node.querySelector( + `a.${REF_CLASS}:first-child` + ); + + if (!anchor) { + return false; + } + + const id = anchor.getAttribute("data-id"); + const ref = anchor.getAttribute(REFNUM_ATTR); + + return { + "data-id": id ?? generateNodeId(), + referenceNumber: ref ?? anchor.innerText, + }; + }, + contentElement(node) { + return node.firstChild as HTMLElement; + }, + }, + ]; + }, + + addAttributes() { + return { + class: { + default: REF_CLASS, + }, + "data-id": { + renderHTML(attributes) { + return { + "data-id": attributes["data-id"] || generateNodeId(), + }; + }, + }, + referenceNumber: {}, + + href: { + renderHTML(attributes) { + return { + href: `#fn:${attributes["referenceNumber"]}`, + }; + }, + }, + }; + }, + + renderHTML({ HTMLAttributes }) { + const { referenceNumber, ...attributes } = HTMLAttributes; + const attrs = mergeAttributes(this.options.HTMLAttributes, attributes); + attrs[REFNUM_ATTR] = referenceNumber; + + return [ + "sup", + { id: `fnref:${referenceNumber}` }, + ["a", attrs, HTMLAttributes.referenceNumber], + ]; + }, + + addProseMirrorPlugins() { + const { editor } = this; + + // Ensures pasted footnote references get unique IDs. + const mapNode = (node: PMNode): PMNode => { + if (node.type.name === this.name) { + const newAttrs = { ...node.attrs, "data-id": generateNodeId() }; + return node.type.create(newAttrs, node.content, node.marks); + } + + if (node.content && node.content.size > 0) { + const newChildren: PMNode[] = []; + let changed = false; + + node.content.forEach((child) => { + const mapped = mapNode(child); + if (mapped !== child) { + changed = true; + } + + newChildren.push(mapped); + }); + + if (changed) { + return node.copy(PMFragment.from(newChildren)); + } + } + + return node; + }; + + return [ + new Plugin({ + key: new PluginKey("footnotePasteHandler"), + props: { + transformPasted(slice) { + const mappedNodes: PMNode[] = []; + let changed = false; + + slice.content.forEach((node) => { + const mapped = mapNode(node); + if (mapped !== node) { + changed = true; + } + mappedNodes.push(mapped); + }); + + if (!changed) { + return slice; + } + + return new Slice( + PMFragment.from(mappedNodes), + slice.openStart, + slice.openEnd + ); + }, + }, + }), + new Plugin({ + key: new PluginKey("footnoteRefClick"), + + props: { + // on double-click, focus on the footnote + handleDoubleClickOn(view, pos, node, nodePos, event) { + if (node.type.name != "footnoteReference") return false; + event.preventDefault(); + const id = node.attrs["data-id"]; + return editor.commands.focusFootnote(id); + }, + // click the footnote reference once to get focus, click twice to scroll to the footnote + handleClickOn(view, pos, node, nodePos, event) { + if (node.type.name != "footnoteReference") return false; + event.preventDefault(); + const { selection } = editor.state.tr; + if (selection instanceof NodeSelection && selection.node.eq(node)) { + const id = node.attrs["data-id"]; + return editor.commands.focusFootnote(id); + } else { + editor.chain().setNodeSelection(nodePos).run(); + return true; + } + }, + }, + }), + ]; + }, + + addCommands() { + return { + addFootnote: + () => + ({ state, tr }) => { + const node = this.type.create({ + "data-id": generateNodeId(), + }); + tr.insert(state.selection.anchor, node); + return true; + }, + }; + }, + + addInputRules() { + // when a user types [^text], add a new footnote + return [ + { + find: /\[\^(.*?)\]/, + type: this.type, + undoable: true, + handler({ range, match, chain }) { + const start = range.from; + let end = range.to; + if (match[1]) { + chain().deleteRange({ from: start, to: end }).addFootnote().run(); + } + }, + }, + ]; + }, +}); + +export default FootnoteReference; diff --git a/packages/editor-ext/src/lib/footnotes/rules.ts b/packages/editor-ext/src/lib/footnotes/rules.ts new file mode 100644 index 000000000..7064916c0 --- /dev/null +++ b/packages/editor-ext/src/lib/footnotes/rules.ts @@ -0,0 +1,90 @@ +//Source MIT - https://github.com/buttondown/tiptap-footnotes +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { ReplaceStep } from "@tiptap/pm/transform"; +import { Extension } from "@tiptap/core"; +import { updateFootnotesList } from "./utils"; + +const FootnoteRules = Extension.create({ + name: "footnoteRules", + priority: 1000, + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("footnoteRules"), + filterTransaction(tr) { + const { from, to } = tr.selection; + + // Allow full document selections (Mod-a/Ctrl-a) + if (from === 0 && to === tr.doc.content.size) return true; + + let selectedFootnotes = false; + let selectedContent = false; + let footnoteCount = 0; + tr.doc.nodesBetween(from, to, (node, _, parent) => { + if (parent?.type.name == "doc" && node.type.name != "footnotes") { + selectedContent = true; + } else if (node.type.name == "footnote") { + footnoteCount += 1; + } else if (node.type.name == "footnotes") { + selectedFootnotes = true; + } + }); + const overSelected = selectedContent && selectedFootnotes; + /* + * Here, we don't allow any transaction that spans between the "content" nodes and the "footnotes" node. This also rejects any transaction that spans between more than 1 footnote. + */ + return !overSelected && footnoteCount <= 1; + }, + + // if there are some to the footnote references (added/deleted/dragged), append a transaction that updates the footnotes list accordingly + appendTransaction(transactions, oldState, newState) { + let newTr = newState.tr; + let refsChanged = false; // true if the footnote references have been changed, false otherwise + for (let tr of transactions) { + if (!tr.docChanged) continue; + if (refsChanged) break; + + for (let step of tr.steps) { + if (!(step instanceof ReplaceStep)) continue; + if (refsChanged) break; + + const isDelete = step.from != step.to; // the user deleted items from the document (from != to & the step is a replace step) + const isInsert = step.slice.size > 0; + + // check if any footnote references have been inserted + if (isInsert) { + step.slice.content.descendants((node) => { + if (node?.type.name == "footnoteReference") { + refsChanged = true; + return false; + } + }); + } + if (isDelete && !refsChanged) { + // check if any footnote references have been deleted + tr.before.nodesBetween( + step.from, + Math.min(tr.before.content.size, step.to), // make sure to not go over the old document's limit + (node) => { + if (node.type.name == "footnoteReference") { + refsChanged = true; + return false; + } + }, + ); + } + } + } + + if (refsChanged) { + updateFootnotesList(newTr, newState); + return newTr; + } + + return null; + }, + }), + ]; + }, +}); +export default FootnoteRules; diff --git a/packages/editor-ext/src/lib/footnotes/utils.ts b/packages/editor-ext/src/lib/footnotes/utils.ts new file mode 100644 index 000000000..cb9190178 --- /dev/null +++ b/packages/editor-ext/src/lib/footnotes/utils.ts @@ -0,0 +1,123 @@ +//Source MIT - https://github.com/buttondown/tiptap-footnotes +import { EditorState, Transaction } from "@tiptap/pm/state"; +import { Fragment, Node } from "@tiptap/pm/model"; + +// update the reference number of all the footnote references in the document +export function updateFootnoteReferences(tr: Transaction) { + let count = 1; + + const nodes: any[] = []; + + tr.doc.descendants((node, pos) => { + if (node.type.name == "footnoteReference") { + tr.setNodeAttribute(pos, "referenceNumber", `${count}`); + + nodes.push(node); + count += 1; + } + }); + // return the updated footnote references (in the order that they appear in the document) + return nodes; +} + +function getFootnotes(tr: Transaction) { + let footnotesRange: { from: number; to: number } | undefined; + const footnotes: Node[] = []; + tr.doc.descendants((node, pos) => { + if (node.type.name == "footnote") { + footnotes.push(node); + } else if (node.type.name == "footnotes") { + footnotesRange = { from: pos, to: pos + node.nodeSize }; + } else { + return false; + } + }); + return { footnotesRange, footnotes }; +} + +// update the "footnotes" ordered list based on the footnote references in the document +export function updateFootnotesList(tr: Transaction, state: EditorState) { + const footnoteReferences = updateFootnoteReferences(tr); + + const footnoteType = state.schema.nodes.footnote; + const footnotesType = state.schema.nodes.footnotes; + + const emptyParagraph = state.schema.nodeFromJSON({ + type: "paragraph", + content: [], + }); + + const { footnotesRange, footnotes } = getFootnotes(tr); + + // a mapping of footnote id -> footnote node + const footnoteIds: { [key: string]: Node } = footnotes.reduce( + (obj, footnote) => { + obj[footnote.attrs["data-id"]] = footnote; + return obj; + }, + {} as any, + ); + + const newFootnotes: Node[] = []; + + let footnoteRefIds = new Set( + footnoteReferences.map((ref) => ref.attrs["data-id"]), + ); + const deleteFootnoteIds: Set = new Set(); + for (let footnote of footnotes) { + const id = footnote.attrs["data-id"]; + if (!footnoteRefIds.has(id) || deleteFootnoteIds.has(id)) { + deleteFootnoteIds.add(id); + // we traverse through this footnote's content because it may contain footnote references. + // we want to delete the footnotes associated with these references, so we add them to the delete set. + footnote.content.descendants((node) => { + if (node.type.name == "footnoteReference") + deleteFootnoteIds.add(node.attrs["data-id"]); + }); + } + } + + for (let i = 0; i < footnoteReferences.length; i++) { + let refId = footnoteReferences[i].attrs["data-id"]; + + if (deleteFootnoteIds.has(refId)) continue; + // if there is a footnote w/ the same id as this `ref`, we preserve its content and update its id attribute + if (refId in footnoteIds) { + let footnote = footnoteIds[refId]; + newFootnotes.push( + footnoteType.create( + { ...footnote.attrs, id: `fn:${i + 1}` }, + footnote.content, + ), + ); + } else { + let newNode = footnoteType.create( + { + "data-id": refId, + id: `fn:${i + 1}`, + }, + [emptyParagraph], + ); + newFootnotes.push(newNode); + } + } + + if (newFootnotes.length == 0) { + // no footnotes in the doc, delete the "footnotes" node + if (footnotesRange) { + tr.delete(footnotesRange.from, footnotesRange.to); + } + } else if (!footnotesRange) { + // there is no footnotes node present in the doc, add it + tr.insert( + tr.doc.content.size, + footnotesType.create(undefined, Fragment.from(newFootnotes)), + ); + } else { + tr.replaceWith( + footnotesRange!.from + 1, // add 1 to point at the position after the opening ol tag + footnotesRange!.to - 1, // substract 1 to point to the position before the closing ol tag + Fragment.from(newFootnotes), + ); + } +} diff --git a/packages/editor-ext/src/lib/markdown/utils/footnotes.marked.ts b/packages/editor-ext/src/lib/markdown/utils/footnotes.marked.ts new file mode 100644 index 000000000..11c732d57 --- /dev/null +++ b/packages/editor-ext/src/lib/markdown/utils/footnotes.marked.ts @@ -0,0 +1,110 @@ +import { Token, marked } from 'marked'; +import { generateNodeId } from '../../utils'; + +interface FootnoteRefToken { + type: 'footnoteRef'; + label: string; + raw: string; +} + +interface FootnoteDefToken { + type: 'footnoteDef'; + label: string; + text: string; + raw: string; +} + +// Parse-scoped state: markdownToHtml resets before the top-level parse and +// appends the collected list after it. Nested marked.parse calls (callout, +// footnote definitions) share this state, so hooks cannot be used here. +let footnoteRefs: { label: string; id: string; number: number }[] = []; +let footnoteDefs = new Map(); + +export function resetFootnotes() { + footnoteRefs = []; + footnoteDefs = new Map(); +} + +export function renderFootnotesList(): string { + if (!footnoteRefs.length) return ''; + const items = footnoteRefs.map(({ label, id, number }) => { + const body = footnoteDefs.get(label) || '

'; + return `
  • ${body}
  • `; + }); + return `
      \n${items.join('\n')}\n
    \n`; +} + +export const footnoteRefExtension = { + name: 'footnoteRef', + level: 'inline', + start(src: string) { + return src.indexOf('[^'); + }, + tokenizer(src: string): FootnoteRefToken | undefined { + const match = /^\[\^([^\]\s]+)\]/.exec(src); + if (match) { + return { + type: 'footnoteRef', + raw: match[0], + label: match[1].toLowerCase(), + }; + } + }, + renderer(token: Token) { + const refToken = token as FootnoteRefToken; + const number = footnoteRefs.length + 1; + const id = generateNodeId(); + footnoteRefs.push({ label: refToken.label, id, number }); + return `${number}`; + }, +}; + +export const footnoteDefExtension = { + name: 'footnoteDef', + level: 'block', + start(src: string) { + return src.match(/^\[\^[^\]\s]+\]:/m)?.index ?? -1; + }, + tokenizer(src: string): FootnoteDefToken | undefined { + const firstLine = /^\[\^([^\]\s]+)\]:[ \t]*/.exec(src); + if (!firstLine) return undefined; + + const lines = src.split('\n'); + const contentLines = [lines[0].slice(firstLine[0].length)]; + let consumed = 1; + while (consumed < lines.length) { + const line = lines[consumed]; + if (/^[ \t]{2,}\S/.test(line)) { + contentLines.push(line.replace(/^[ \t]{1,4}/, '')); + consumed += 1; + } else if ( + /^[ \t]*$/.test(line) && + consumed + 1 < lines.length && + /^[ \t]{2,}\S/.test(lines[consumed + 1]) + ) { + contentLines.push(''); + consumed += 1; + } else { + break; + } + } + + const raw = + lines.slice(0, consumed).join('\n') + + (consumed < lines.length ? '\n' : ''); + return { + type: 'footnoteDef', + raw, + label: firstLine[1].toLowerCase(), + text: contentLines.join('\n').trim(), + }; + }, + renderer(token: Token) { + const defToken = token as FootnoteDefToken; + const body = defToken.text + ? marked.parse(defToken.text).toString() + : '

    '; + footnoteDefs.set(defToken.label, body); + return ''; + }, +}; diff --git a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts index 7556aa4f0..0377ab7f7 100644 --- a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts @@ -2,6 +2,12 @@ import { marked } from "marked"; import { calloutExtension } from "./callout.marked"; import { mathBlockExtension } from "./math-block.marked"; import { mathInlineExtension } from "./math-inline.marked"; +import { + footnoteDefExtension, + footnoteRefExtension, + renderFootnotesList, + resetFootnotes, +} from "./footnotes.marked"; marked.use({ renderer: { @@ -34,7 +40,13 @@ marked.use({ }); marked.use({ - extensions: [calloutExtension, mathBlockExtension, mathInlineExtension], + extensions: [ + calloutExtension, + mathBlockExtension, + mathInlineExtension, + footnoteDefExtension, + footnoteRefExtension, + ], }); marked.setOptions({ breaks: true }); @@ -48,5 +60,7 @@ export function markdownToHtml( .replace(YAML_FONT_MATTER_REGEX, "") .trimStart(); - return marked.parse(markdown).toString(); + resetFootnotes(); + const html = marked.parse(markdown).toString(); + return html + renderFootnotesList(); } diff --git a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts index ebfc3423e..55f4afd37 100644 --- a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts @@ -34,6 +34,8 @@ export function htmlToMarkdown(html: string): string { iframeEmbed, image, video, + footnoteRef, + footnotesList, ]); return turndownService.turndown(html).replaceAll('
    ', ' '); } @@ -203,6 +205,56 @@ function image(turndownService: _TurndownService) { }); } +function getFootnoteAnchor(node: HTMLElement): HTMLElement | null { + const child = node.firstElementChild as HTMLElement | null; + return child?.nodeName === 'A' && child.classList.contains('footnote-ref') + ? child + : null; +} + +function footnoteRef(turndownService: _TurndownService) { + turndownService.addRule('footnoteRef', { + filter: function (node: HTMLInputElement) { + return node.nodeName === 'SUP' && !!getFootnoteAnchor(node); + }, + replacement: function (_content: string, node: HTMLInputElement) { + const anchor = getFootnoteAnchor(node); + const number = + anchor.getAttribute('data-reference-number') || anchor.textContent; + return `[^${number}]`; + }, + }); +} + +function footnotesList(turndownService: _TurndownService) { + turndownService.addRule('footnotesList', { + filter: function (node: HTMLInputElement) { + return node.nodeName === 'OL' && node.classList.contains('footnotes'); + }, + replacement: function (_content: string, node: HTMLInputElement) { + const items = Array.from(node.children).filter( + (child) => child.nodeName === 'LI', + ); + const definitions = items.map((li, index) => { + const number = + (li.getAttribute('id') || '').replace('fn:', '') || + String(index + 1); + const markdown = turndownService + .turndown((li as HTMLElement).innerHTML) + .trim(); + // continuation lines need a 4-space indent to stay in the footnote + const [first, ...rest] = markdown.split('\n'); + const body = [ + first, + ...rest.map((line: string) => (line.trim() ? ` ${line}` : line)), + ].join('\n'); + return `[^${number}]: ${body}`; + }); + return `\n\n${definitions.join('\n')}\n\n`; + }, + }); +} + function video(turndownService: _TurndownService) { turndownService.addRule('video', { filter: function (node: HTMLInputElement) { diff --git a/packages/editor-ext/src/lib/prosemirror-docx/schema.ts b/packages/editor-ext/src/lib/prosemirror-docx/schema.ts index 1a2797213..df89cf1d7 100644 --- a/packages/editor-ext/src/lib/prosemirror-docx/schema.ts +++ b/packages/editor-ext/src/lib/prosemirror-docx/schema.ts @@ -1,4 +1,4 @@ -import { HeadingLevel, ShadingType } from 'docx'; +import { FootnoteReferenceRun, HeadingLevel, Paragraph, ShadingType } from 'docx'; import { Node } from 'prosemirror-model'; import { DocxSerializerAsync, @@ -168,6 +168,27 @@ export const defaultAsyncNodes: NodeSerializerAsync = { pageBreak(state, node) { state.closeBlock(node, { pageBreakBefore: true }); }, + footnoteReference(state, node) { + const number = + Number(node.attrs?.referenceNumber) || state.$footnoteCounter + 1; + state.$footnoteCounter = Math.max(state.$footnoteCounter, number); + // seed an empty body so the reference stays valid even if the trailing + // footnotes list is missing; the footnotes node overwrites it with content + if (!state.footnotes[number]) { + state.footnotes[number] = { children: [new Paragraph('')] }; + } + state.current.push(new FootnoteReferenceRun(number)); + }, + async footnotes(state, node) { + for (let i = 0; i < node.childCount; i += 1) { + const item = node.child(i); + const number = + Number(String(item.attrs?.id ?? '').replace('fn:', '')) || i + 1; + await state.footnoteDefinition(item, number); + } + }, + // items are consumed by the footnotes handler above + footnote() {}, // No usable static export representation: skip without failing. subpages() {}, transclusionReference() {}, diff --git a/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts b/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts index fa62a8cf6..b349ece3e 100644 --- a/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts +++ b/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts @@ -824,6 +824,29 @@ export class DocxSerializerStateAsync { this.current.push(new FootnoteReferenceRun(this.$footnoteCounter)); } + // Fills the footnote body for an already-referenced footnote number from a + // node holding block content (Docmost keeps footnote text in a trailing + // list, separate from the inline reference). + async footnoteDefinition(node: Node, number: number) { + const { current, children, nextRunOpts, nextParentParagraphOpts } = this; + this.current = []; + this.children = []; + delete this.nextRunOpts; + delete this.nextParentParagraphOpts; + + await this.renderContent(node); + this.footnotes[number] = { + children: this.children.filter( + (child): child is Paragraph => child instanceof Paragraph, + ), + }; + + this.current = current; + this.children = children; + this.nextRunOpts = nextRunOpts; + this.nextParentParagraphOpts = nextParentParagraphOpts; + } + closeBlock(node: Node, props?: IParagraphOptions) { const paragraph = new Paragraph({ children: this.current, diff --git a/packages/editor-ext/src/lib/trailing-node.ts b/packages/editor-ext/src/lib/trailing-node.ts index a4d77b3df..6b3d0f584 100644 --- a/packages/editor-ext/src/lib/trailing-node.ts +++ b/packages/editor-ext/src/lib/trailing-node.ts @@ -7,9 +7,19 @@ export interface TrailingNodeExtensionOptions { } function nodeEqualsType({ types, node }: { types: any, node: any }) { + if (!node) return false return (Array.isArray(types) && types.includes(node.type)) || node.type === types } +// footnotes must stay the last doc child, so the trailing node goes before it +function lastNodeBeforeFootnotes(doc: any) { + const lastChild = doc.lastChild + if (lastChild?.type.name === 'footnotes') { + return doc.childCount > 1 ? doc.child(doc.childCount - 2) : null + } + return lastChild +} + // @ts-ignore /** * Extension based on: @@ -40,19 +50,23 @@ export const TrailingNode = Extension.create({ appendTransaction: (_, __, state) => { const { doc, tr, schema } = state; const shouldInsertNodeAtEnd = plugin.getState(state); - const endPosition = doc.content.size; const type = schema.nodes[this.options.node] if (!shouldInsertNodeAtEnd) { return; } + const lastChild = doc.lastChild + const endPosition = lastChild?.type.name === 'footnotes' + ? doc.content.size - lastChild.nodeSize + : doc.content.size + return tr.insert(endPosition, type.create()); }, state: { init: (_, state) => { try { - const lastNode = state.tr.doc.lastChild + const lastNode = lastNodeBeforeFootnotes(state.tr.doc) return !nodeEqualsType({ node: lastNode, types: disabledNodes }) } catch (err){ console.log(err) @@ -70,7 +84,7 @@ export const TrailingNode = Extension.create({ return value } - const lastNode = tr.doc.lastChild + const lastNode = lastNodeBeforeFootnotes(tr.doc) return !nodeEqualsType({ node: lastNode, types: disabledNodes }) }, }, From 737cd67965d50012fef8d1c275521af1ced91b23 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:31:02 +0100 Subject: [PATCH 2/4] feat: compare two page versions (#2385) --- .../public/locales/en-US/translation.json | 8 +- .../page-history/atoms/history-atoms.ts | 11 +- .../components/css/history.module.css | 31 ++- .../components/history-editor.tsx | 1 - .../page-history/components/history-item.tsx | 188 +++++++++++++----- .../page-history/components/history-list.tsx | 97 ++++++++- .../components/history-modal-body.tsx | 58 +++++- .../components/history-modal-mobile.tsx | 2 +- .../page-history/components/history-view.tsx | 18 +- .../page-history/hooks/use-history-reset.ts | 29 ++- .../hooks/use-history-restore.tsx | 100 ++++++---- .../queries/page-history-query.ts | 8 + .../utils/resolve-compare-pair.test.ts | 32 +++ .../utils/resolve-compare-pair.ts | 18 ++ 14 files changed, 486 insertions(+), 115 deletions(-) create mode 100644 apps/client/src/features/page-history/utils/resolve-compare-pair.test.ts create mode 100644 apps/client/src/features/page-history/utils/resolve-compare-pair.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index aaa340181..e99f4b761 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1291,5 +1291,11 @@ "{{count}} rows deleted_one": "1 row deleted", "{{count}} rows deleted_other": "{{count}} rows deleted", "{{count}} selected_one": "1 selected", - "{{count}} selected_other": "{{count}} selected" + "{{count}} selected_other": "{{count}} selected", + "Compare": "Compare", + "Compare versions": "Compare versions", + "Select version from {{date}}": "Select version from {{date}}", + "Version actions for {{date}}": "Version actions for {{date}}", + "Comparing {{newer}} and {{older}}": "Comparing {{newer}} and {{older}}", + "Exit compare": "Exit compare" } diff --git a/apps/client/src/features/page-history/atoms/history-atoms.ts b/apps/client/src/features/page-history/atoms/history-atoms.ts index 2acf163d5..76e0d54cc 100644 --- a/apps/client/src/features/page-history/atoms/history-atoms.ts +++ b/apps/client/src/features/page-history/atoms/history-atoms.ts @@ -6,4 +6,13 @@ export const activeHistoryPrevIdAtom = atom(""); export const highlightChangesAtom = atom(true); export type DiffCounts = { added: number; deleted: number; total: number }; -export const diffCountsAtom = atom(null); +export const diffCountsAtom = atom( + null as DiffCounts | null, +); + +export type ComparePair = { newerId: string; olderId: string }; +export const compareModeAtom = atom(false); +export const compareSelectionAtom = atom([]); +export const comparePairAtom = atom( + null as ComparePair | null, +); diff --git a/apps/client/src/features/page-history/components/css/history.module.css b/apps/client/src/features/page-history/components/css/history.module.css index a4be38194..cd3186323 100644 --- a/apps/client/src/features/page-history/components/css/history.module.css +++ b/apps/client/src/features/page-history/components/css/history.module.css @@ -1,7 +1,7 @@ .history { - display: block; + display: flex; + align-items: center; width: 100%; - padding: var(--mantine-spacing-md); color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0)); @mixin hover { @@ -12,6 +12,28 @@ } } +.historyButton { + flex: 1; + min-width: 0; + color: inherit; +} + +.compareCheckbox { + padding-left: var(--mantine-spacing-xs); +} + +.itemMenu { + opacity: 0; + margin-right: var(--mantine-spacing-xs); +} + +.history:hover .itemMenu, +.history:focus-within .itemMenu, +.history.active .itemMenu, +.itemMenu[aria-expanded="true"] { + opacity: 1; +} + .historyEditor { :global(.ProseMirror) { padding: 0 !important; @@ -77,3 +99,8 @@ flex: 1; padding: rem(16px) rem(40px); } + +.compareBanner { + border-bottom: rem(1px) solid + light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); +} diff --git a/apps/client/src/features/page-history/components/history-editor.tsx b/apps/client/src/features/page-history/components/history-editor.tsx index c7fa07036..3773f6bcf 100644 --- a/apps/client/src/features/page-history/components/history-editor.tsx +++ b/apps/client/src/features/page-history/components/history-editor.tsx @@ -170,7 +170,6 @@ export function HistoryEditor({ } const total = addedCount + deletedCount; - // @ts-ignore setDiffCounts({ added: addedCount, deleted: deletedCount, total }); editor.setOptions({ diff --git a/apps/client/src/features/page-history/components/history-item.tsx b/apps/client/src/features/page-history/components/history-item.tsx index cc56b1911..2143fc305 100644 --- a/apps/client/src/features/page-history/components/history-item.tsx +++ b/apps/client/src/features/page-history/components/history-item.tsx @@ -1,10 +1,21 @@ -import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core"; +import { + Text, + Group, + UnstyledButton, + Avatar, + Tooltip, + ActionIcon, + Checkbox, + Menu, +} from "@mantine/core"; +import { IconDots } from "@tabler/icons-react"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { formattedDate } from "@/lib/time"; import classes from "./css/history.module.css"; import clsx from "clsx"; import { IPageHistory } from "@/features/page-history/types/page.types"; import { memo, useCallback } from "react"; +import { useTranslation } from "react-i18next"; const MAX_VISIBLE_AVATARS = 5; @@ -15,6 +26,13 @@ interface HistoryItemProps { onHover?: (id: string, index: number) => void; onHoverEnd?: () => void; isActive: boolean; + compareMode: boolean; + isChecked: boolean; + isCheckboxDisabled: boolean; + canCompare: boolean; + onToggleCompare: (id: string) => void; + onStartCompare: (id: string) => void; + onRestore?: (id: string, index: number) => void; } const HistoryItem = memo(function HistoryItem({ @@ -24,10 +42,24 @@ const HistoryItem = memo(function HistoryItem({ onHover, onHoverEnd, isActive, + compareMode, + isChecked, + isCheckboxDisabled, + canCompare, + onToggleCompare, + onStartCompare, + onRestore, }: HistoryItemProps) { + const { t } = useTranslation(); + const date = formattedDate(new Date(historyItem.createdAt)); + const handleClick = useCallback(() => { - onSelect(historyItem.id, index); - }, [onSelect, historyItem.id, index]); + if (compareMode) { + onToggleCompare(historyItem.id); + } else { + onSelect(historyItem.id, index); + } + }, [compareMode, onToggleCompare, onSelect, historyItem.id, index]); const handleMouseEnter = useCallback(() => { onHover?.(historyItem.id, index); @@ -37,63 +69,115 @@ const HistoryItem = memo(function HistoryItem({ const hasContributors = contributors && contributors.length > 0; return ( - - {formattedDate(new Date(historyItem.createdAt))} + {compareMode && ( + onToggleCompare(historyItem.id)} + aria-label={t("Select version from {{date}}", { date })} + /> + )} - - {hasContributors ? ( - <> - - - {contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => ( - - - - ))} - {contributors.length > MAX_VISIBLE_AVATARS && ( - ( -
    {c.name}
    + + {date} + + + {hasContributors ? ( + <> + + + {contributors + .slice(0, MAX_VISIBLE_AVATARS) + .map((contributor) => ( + + + ))} - > - - +{contributors.length - MAX_VISIBLE_AVATARS} - -
    - )} -
    -
    - {contributors.length === 1 && ( + {contributors.length > MAX_VISIBLE_AVATARS && ( + ( +
    {c.name}
    + ))} + > + + +{contributors.length - MAX_VISIBLE_AVATARS} + +
    + )} + + + {contributors.length === 1 && ( + + {contributors[0].name} + + )} + + ) : ( + <> + - {contributors[0].name} + {historyItem.lastUpdatedBy?.name} + + )} +
    +
    + + {!compareMode && ( + + + e.stopPropagation()} + > + + + + + onStartCompare(historyItem.id)} + > + {t("Compare")} + + {onRestore && ( + onRestore(historyItem.id, index)}> + {t("Restore")} + )} - - ) : ( - <> - - - {historyItem.lastUpdatedBy?.name} - - - )} - - + + + )} + ); }); diff --git a/apps/client/src/features/page-history/components/history-list.tsx b/apps/client/src/features/page-history/components/history-list.tsx index 4024901b3..ac761ba82 100644 --- a/apps/client/src/features/page-history/components/history-list.tsx +++ b/apps/client/src/features/page-history/components/history-list.tsx @@ -6,8 +6,12 @@ import HistoryItem from "@/features/page-history/components/history-item"; import { activeHistoryIdAtom, activeHistoryPrevIdAtom, + compareModeAtom, + comparePairAtom, + compareSelectionAtom, historyAtoms, } from "@/features/page-history/atoms/history-atoms"; +import { resolveComparePair } from "@/features/page-history/utils/resolve-compare-pair"; import { useAtom, useSetAtom } from "jotai"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { @@ -32,6 +36,9 @@ function HistoryList({ pageId }: Props) { const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom); const setHistoryModalOpen = useSetAtom(historyAtoms); + const [compareMode, setCompareMode] = useAtom(compareModeAtom); + const [compareSelection, setCompareSelection] = useAtom(compareSelectionAtom); + const setComparePair = useSetAtom(comparePairAtom); const { data: pageHistoryData, @@ -79,10 +86,58 @@ function HistoryList({ pageId }: Props) { const handleSelect = useCallback( (id: string, index: number) => { + setComparePair(null); setActiveHistoryId(id); setActiveHistoryPrevId(historyItems[index + 1]?.id ?? ""); }, - [historyItems, setActiveHistoryId, setActiveHistoryPrevId], + [historyItems, setActiveHistoryId, setActiveHistoryPrevId, setComparePair], + ); + + const handleToggleCompare = useCallback( + (id: string) => { + setCompareSelection((prev) => { + if (prev.includes(id)) return prev.filter((item) => item !== id); + if (prev.length >= 2) return prev; + return [...prev, id]; + }); + }, + [setCompareSelection], + ); + + const handleStartCompare = useCallback( + (id: string) => { + setComparePair(null); + setCompareMode(true); + setCompareSelection([id]); + }, + [setComparePair, setCompareMode, setCompareSelection], + ); + + const handleCancelCompare = useCallback(() => { + setCompareMode(false); + setCompareSelection([]); + }, [setCompareMode, setCompareSelection]); + + const handleConfirmCompare = useCallback(() => { + const pair = resolveComparePair(historyItems, compareSelection); + if (!pair) return; + setComparePair(pair); + setCompareMode(false); + setCompareSelection([]); + }, [ + historyItems, + compareSelection, + setComparePair, + setCompareMode, + setCompareSelection, + ]); + + const handleRestoreItem = useCallback( + (id: string, index: number) => { + handleSelect(id, index); + confirmRestore(id); + }, + [handleSelect, confirmRestore], ); useEffect(() => { @@ -138,6 +193,16 @@ function HistoryList({ pageId }: Props) { onHover={handleHover} onHoverEnd={clearPrefetchTimeout} isActive={historyItem.id === activeHistoryId} + compareMode={compareMode} + isChecked={compareSelection.includes(historyItem.id)} + isCheckboxDisabled={ + !compareSelection.includes(historyItem.id) && + compareSelection.length >= 2 + } + canCompare={historyItems.length >= 2} + onToggleCompare={handleToggleCompare} + onStartCompare={handleStartCompare} + onRestore={canRestore ? handleRestoreItem : undefined} /> ))} {hasNextPage &&
    } @@ -148,22 +213,44 @@ function HistoryList({ pageId }: Props) { )} - {canRestore && ( + {compareMode ? ( <> - + ) : ( + canRestore && ( + <> + + + + + + + ) )}
    ); diff --git a/apps/client/src/features/page-history/components/history-modal-body.tsx b/apps/client/src/features/page-history/components/history-modal-body.tsx index 5673c82a1..ef2820e89 100644 --- a/apps/client/src/features/page-history/components/history-modal-body.tsx +++ b/apps/client/src/features/page-history/components/history-modal-body.tsx @@ -1,5 +1,6 @@ import { ActionIcon, + CloseButton, Group, Paper, ScrollArea, @@ -12,17 +13,20 @@ import { useAtom, useAtomValue } from "jotai"; import { activeHistoryIdAtom, activeHistoryPrevIdAtom, + comparePairAtom, diffCountsAtom, highlightChangesAtom, } from "@/features/page-history/atoms/history-atoms"; import HistoryView from "@/features/page-history/components/history-view"; -import { useRef } from "react"; +import { useMemo, useRef } from "react"; import { IconChevronUp, IconChevronDown } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useDiffNavigation, useHistoryReset, } from "@/features/page-history/hooks"; +import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query"; +import { formattedDate } from "@/lib/time"; interface Props { pageId: string; @@ -36,6 +40,28 @@ export default function HistoryModalBody({ pageId }: Props) { const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom); const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom); const diffCounts = useAtomValue(diffCountsAtom); + const [comparePair, setComparePair] = useAtom(comparePairAtom); + + const { data: pageHistoryData } = usePageHistoryListQuery(pageId); + const historyItems = useMemo( + () => pageHistoryData?.pages.flatMap((page) => page.items) ?? [], + [pageHistoryData], + ); + + const compareLabel = useMemo(() => { + if (!comparePair) return null; + const newerItem = historyItems.find( + (item) => item.id === comparePair.newerId, + ); + const olderItem = historyItems.find( + (item) => item.id === comparePair.olderId, + ); + if (!newerItem || !olderItem) return null; + return t("Comparing {{newer}} and {{older}}", { + newer: formattedDate(new Date(newerItem.createdAt)), + older: formattedDate(new Date(olderItem.createdAt)), + }); + }, [comparePair, historyItems, t]); useHistoryReset(pageId); const { currentChangeIndex, handlePrevChange, handleNextChange } = @@ -50,6 +76,25 @@ export default function HistoryModalBody({ pageId }: Props) {
    + {comparePair && ( + + + {compareLabel ?? t("Compare versions")} + + setComparePair(null)} + /> + + )} +
    - {activeHistoryId && } + {comparePair ? ( + + ) : ( + activeHistoryId && + )}
    - {activeHistoryId && activeHistoryPrevId && ( + {(comparePair || (activeHistoryId && activeHistoryPrevId)) && ( setHistoryModalOpen(false)}> {t("Cancel")} - + )} diff --git a/apps/client/src/features/page-history/components/history-view.tsx b/apps/client/src/features/page-history/components/history-view.tsx index ed8a41f9a..028fa4ac1 100644 --- a/apps/client/src/features/page-history/components/history-view.tsx +++ b/apps/client/src/features/page-history/components/history-view.tsx @@ -7,21 +7,29 @@ import { activeHistoryPrevIdAtom, } from "@/features/page-history/atoms/history-atoms"; -function HistoryView() { +interface Props { + historyId?: string; + prevHistoryId?: string; +} + +function HistoryView({ historyId, prevHistoryId }: Props) { const { t } = useTranslation(); - const historyId = useAtomValue(activeHistoryIdAtom); - const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom); + const activeId = useAtomValue(activeHistoryIdAtom); + const activePrevId = useAtomValue(activeHistoryPrevIdAtom); + + const resolvedId = historyId ?? activeId; + const resolvedPrevId = prevHistoryId ?? activePrevId; const { data, isLoading: isLoadingCurrent, isError: isErrorCurrent, - } = usePageHistoryQuery(historyId); + } = usePageHistoryQuery(resolvedId); const { data: prevData, isLoading: isLoadingPrev, isError: isErrorPrev, - } = usePageHistoryQuery(prevHistoryId); + } = usePageHistoryQuery(resolvedPrevId); if (isLoadingCurrent || isLoadingPrev) { return <>; diff --git a/apps/client/src/features/page-history/hooks/use-history-reset.ts b/apps/client/src/features/page-history/hooks/use-history-reset.ts index 15ae05874..cda0cbaa1 100644 --- a/apps/client/src/features/page-history/hooks/use-history-reset.ts +++ b/apps/client/src/features/page-history/hooks/use-history-reset.ts @@ -3,22 +3,45 @@ import { useEffect } from "react"; import { activeHistoryIdAtom, activeHistoryPrevIdAtom, + compareModeAtom, + comparePairAtom, + compareSelectionAtom, diffCountsAtom, } from "@/features/page-history/atoms/history-atoms"; /** * Resets history state when pageId changes. - * Clears active selection and diff counts. + * Clears active selection, diff counts, and compare state. + * Compare state also resets on unmount so reopening the modal starts clean. */ export function useHistoryReset(pageId: string) { const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom); const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom); const [, setDiffCounts] = useAtom(diffCountsAtom); + const [, setCompareMode] = useAtom(compareModeAtom); + const [, setCompareSelection] = useAtom(compareSelectionAtom); + const [, setComparePair] = useAtom(comparePairAtom); useEffect(() => { + const resetCompare = () => { + setCompareMode(false); + setCompareSelection([]); + setComparePair(null); + }; + setActiveHistoryId(""); setActiveHistoryPrevId(""); - // @ts-ignore setDiffCounts(null); - }, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]); + resetCompare(); + + return resetCompare; + }, [ + pageId, + setActiveHistoryId, + setActiveHistoryPrevId, + setDiffCounts, + setCompareMode, + setCompareSelection, + setComparePair, + ]); } diff --git a/apps/client/src/features/page-history/hooks/use-history-restore.tsx b/apps/client/src/features/page-history/hooks/use-history-restore.tsx index f457c696a..17c4eb983 100644 --- a/apps/client/src/features/page-history/hooks/use-history-restore.tsx +++ b/apps/client/src/features/page-history/hooks/use-history-restore.tsx @@ -1,4 +1,4 @@ -import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Text } from "@mantine/core"; @@ -9,7 +9,8 @@ import { activeHistoryIdAtom, historyAtoms, } from "@/features/page-history/atoms/history-atoms"; -import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query"; +import { fetchPageHistory } from "@/features/page-history/queries/page-history-query"; +import { IPageHistory } from "@/features/page-history/types/page.types"; import { pageEditorAtom, titleEditorAtom, @@ -25,8 +26,6 @@ export function useHistoryRestore() { const { t } = useTranslation(); const activeHistoryId = useAtomValue(activeHistoryIdAtom); - const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId); - const mainEditor = useAtomValue(pageEditorAtom); const mainEditorTitle = useAtomValue(titleEditorAtom); const setHistoryModalOpen = useSetAtom(historyAtoms); @@ -40,47 +39,66 @@ export function useHistoryRestore() { SpaceCaslSubject.Page, ); - const handleRestore = useCallback(() => { - if (!activeHistoryData) return; - if ( - !mainEditor || - mainEditor.isDestroyed || - !mainEditorTitle || - mainEditorTitle.isDestroyed - ) { - return; - } + const handleRestore = useCallback( + async (historyId: string) => { + let historyData: IPageHistory; + try { + historyData = await fetchPageHistory(historyId); + } catch { + notifications.show({ + message: t("Error fetching page data."), + color: "red", + }); + return; + } - mainEditorTitle - .chain() - .clearContent() - .setContent(activeHistoryData.title, { emitUpdate: true }) - .run(); + if ( + !mainEditor || + mainEditor.isDestroyed || + !mainEditorTitle || + mainEditorTitle.isDestroyed + ) { + return; + } - mainEditor - .chain() - .clearContent() - .setContent(activeHistoryData.content) - .run(); + mainEditorTitle + .chain() + .clearContent() + .setContent(historyData.title, { emitUpdate: true }) + .run(); - setHistoryModalOpen(false); - notifications.show({ message: t("Successfully restored") }); - }, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]); + mainEditor + .chain() + .clearContent() + .setContent(historyData.content) + .run(); - const confirmRestore = useCallback(() => { - modals.openConfirmModal({ - title: t("Please confirm your action"), - children: ( - - {t( - "Are you sure you want to restore this version? Any changes not versioned will be lost.", - )} - - ), - labels: { confirm: t("Confirm"), cancel: t("Cancel") }, - onConfirm: handleRestore, - }); - }, [t, handleRestore]); + setHistoryModalOpen(false); + notifications.show({ message: t("Successfully restored") }); + }, + [mainEditor, mainEditorTitle, setHistoryModalOpen, t], + ); + + const confirmRestore = useCallback( + (historyId?: string) => { + const targetId = historyId ?? activeHistoryId; + if (!targetId) return; + + modals.openConfirmModal({ + title: t("Please confirm your action"), + children: ( + + {t( + "Are you sure you want to restore this version? Any changes not versioned will be lost.", + )} + + ), + labels: { confirm: t("Confirm"), cancel: t("Cancel") }, + onConfirm: () => handleRestore(targetId), + }); + }, + [t, handleRestore, activeHistoryId], + ); return { canRestore, confirmRestore }; } diff --git a/apps/client/src/features/page-history/queries/page-history-query.ts b/apps/client/src/features/page-history/queries/page-history-query.ts index 0fbfc6c9e..312d8aff7 100644 --- a/apps/client/src/features/page-history/queries/page-history-query.ts +++ b/apps/client/src/features/page-history/queries/page-history-query.ts @@ -23,6 +23,14 @@ export function prefetchPageHistory(historyId: string) { }); } +export function fetchPageHistory(historyId: string): Promise { + return queryClient.fetchQuery({ + queryKey: ["page-history", historyId], + queryFn: () => getPageHistoryById(historyId), + staleTime: HISTORY_STALE_TIME, + }); +} + export function usePageHistoryListQuery( pageId: string, ): UseInfiniteQueryResult, unknown>> { diff --git a/apps/client/src/features/page-history/utils/resolve-compare-pair.test.ts b/apps/client/src/features/page-history/utils/resolve-compare-pair.test.ts new file mode 100644 index 000000000..449188f02 --- /dev/null +++ b/apps/client/src/features/page-history/utils/resolve-compare-pair.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { resolveComparePair } from "./resolve-compare-pair"; + +// list is newest-first, matching usePageHistoryListQuery order +const items = [{ id: "v3" }, { id: "v2" }, { id: "v1" }]; + +describe("resolveComparePair", () => { + it("orders newer before older regardless of selection order", () => { + expect(resolveComparePair(items, ["v1", "v3"])).toEqual({ + newerId: "v3", + olderId: "v1", + }); + expect(resolveComparePair(items, ["v3", "v1"])).toEqual({ + newerId: "v3", + olderId: "v1", + }); + }); + + it("returns null unless exactly two versions are selected", () => { + expect(resolveComparePair(items, [])).toBeNull(); + expect(resolveComparePair(items, ["v1"])).toBeNull(); + expect(resolveComparePair(items, ["v1", "v2", "v3"])).toBeNull(); + }); + + it("returns null when a selected id is not in the list", () => { + expect(resolveComparePair(items, ["v1", "missing"])).toBeNull(); + }); + + it("returns null when the same id is selected twice", () => { + expect(resolveComparePair(items, ["v2", "v2"])).toBeNull(); + }); +}); diff --git a/apps/client/src/features/page-history/utils/resolve-compare-pair.ts b/apps/client/src/features/page-history/utils/resolve-compare-pair.ts new file mode 100644 index 000000000..d1d914346 --- /dev/null +++ b/apps/client/src/features/page-history/utils/resolve-compare-pair.ts @@ -0,0 +1,18 @@ +import { ComparePair } from "@/features/page-history/atoms/history-atoms"; + +/** + * Resolves which of the two selected versions is newer using their position + * in the history list (list is newest-first: lower index = newer). + */ +export function resolveComparePair( + historyItems: { id: string }[], + selection: string[], +): ComparePair | null { + if (selection.length !== 2) return null; + const indexA = historyItems.findIndex((item) => item.id === selection[0]); + const indexB = historyItems.findIndex((item) => item.id === selection[1]); + if (indexA === -1 || indexB === -1 || indexA === indexB) return null; + return indexA < indexB + ? { newerId: selection[0], olderId: selection[1] } + : { newerId: selection[1], olderId: selection[0] }; +} From 089286f6cf408586795d0f3ff37f19b2a7dd9461 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:33:34 +0100 Subject: [PATCH 3/4] fix: delete pnpm cache --- Dockerfile | 2 +- apps/server/src/ee | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 242532634..c8f6a6f46 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,7 +42,7 @@ RUN chown -R node:node /app USER node -RUN pnpm install --frozen-lockfile --prod +RUN pnpm install --frozen-lockfile --prod && rm -rf /home/node/.cache/pnpm RUN mkdir -p /app/data/storage diff --git a/apps/server/src/ee b/apps/server/src/ee index c7b77ffb9..41acc8af7 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit c7b77ffb9ed6a7bf462a4683de4f84355122e00b +Subproject commit 41acc8af7da27b6b6b3491357b22dabee9ce3695 From 9414a38215285c48b24a8accb3c277e645d0f2b9 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:04:33 +0100 Subject: [PATCH 4/4] feat: page attachments endpoint and modal (#2386) * feat: page attachments endpoint * feat: attachments modal --- .../public/locales/en-US/translation.json | 7 +- .../components/attachment-file-icon.tsx | 59 ++++++ .../components/page-attachments-modal.tsx | 191 ++++++++++++++++++ .../attachments/queries/attachment-query.ts | 25 +++ .../services/attachment-service.ts | 10 + .../features/attachments/services/index.ts | 1 + .../attachments/types/attachment.types.ts | 9 + .../components/header/page-header-menu.tsx | 21 ++ .../core/attachment/attachment.controller.ts | 50 ++++- .../src/core/attachment/dto/attachment.dto.ts | 15 +- .../repos/attachment/attachment.repo.ts | 40 ++++ apps/server/src/ee | 2 +- 12 files changed, 424 insertions(+), 6 deletions(-) create mode 100644 apps/client/src/features/attachments/components/attachment-file-icon.tsx create mode 100644 apps/client/src/features/attachments/components/page-attachments-modal.tsx create mode 100644 apps/client/src/features/attachments/queries/attachment-query.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index e99f4b761..03e10c53e 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1297,5 +1297,10 @@ "Select version from {{date}}": "Select version from {{date}}", "Version actions for {{date}}": "Version actions for {{date}}", "Comparing {{newer}} and {{older}}": "Comparing {{newer}} and {{older}}", - "Exit compare": "Exit compare" + "Exit compare": "Exit compare", + "Search attachments...": "Search attachments...", + "Error loading attachments.": "Error loading attachments.", + "No attachments on this page yet.": "No attachments on this page yet.", + "Uploaded by {{name}}": "Uploaded by {{name}}", + "Download {{name}}": "Download {{name}}" } diff --git a/apps/client/src/features/attachments/components/attachment-file-icon.tsx b/apps/client/src/features/attachments/components/attachment-file-icon.tsx new file mode 100644 index 000000000..b28e8de2c --- /dev/null +++ b/apps/client/src/features/attachments/components/attachment-file-icon.tsx @@ -0,0 +1,59 @@ +import { ThemeIcon } from "@mantine/core"; +import { + IconFile, + IconFileTypeCsv, + IconFileTypeDocx, + IconFileTypePdf, + IconFileTypePpt, + IconFileTypeXls, + IconFileZip, + IconMovie, + IconMusic, + IconPhoto, + type Icon, +} from "@tabler/icons-react"; + +const EXT_ICONS: Record = { + ".pdf": { icon: IconFileTypePdf, color: "red" }, + ".doc": { icon: IconFileTypeDocx, color: "blue" }, + ".docx": { icon: IconFileTypeDocx, color: "blue" }, + ".xls": { icon: IconFileTypeXls, color: "teal" }, + ".xlsx": { icon: IconFileTypeXls, color: "teal" }, + ".csv": { icon: IconFileTypeCsv, color: "teal" }, + ".ppt": { icon: IconFileTypePpt, color: "orange" }, + ".pptx": { icon: IconFileTypePpt, color: "orange" }, + ".zip": { icon: IconFileZip, color: "gray" }, + ".rar": { icon: IconFileZip, color: "gray" }, + ".7z": { icon: IconFileZip, color: "gray" }, + ".tar": { icon: IconFileZip, color: "gray" }, + ".gz": { icon: IconFileZip, color: "gray" }, +}; + +const MIME_ICONS: Array<{ prefix: string; icon: Icon; color: string }> = [ + { prefix: "image/", icon: IconPhoto, color: "grape" }, + { prefix: "video/", icon: IconMovie, color: "violet" }, + { prefix: "audio/", icon: IconMusic, color: "pink" }, +]; + +interface AttachmentFileIconProps { + fileExt?: string; + mimeType?: string; +} + +export function AttachmentFileIcon({ + fileExt, + mimeType, +}: AttachmentFileIconProps) { + const byExt = fileExt ? EXT_ICONS[fileExt.toLowerCase()] : undefined; + const byMime = mimeType + ? MIME_ICONS.find((entry) => mimeType.startsWith(entry.prefix)) + : undefined; + const { icon: FileIcon, color } = byExt ?? + byMime ?? { icon: IconFile, color: "gray" }; + + return ( + + + + ); +} diff --git a/apps/client/src/features/attachments/components/page-attachments-modal.tsx b/apps/client/src/features/attachments/components/page-attachments-modal.tsx new file mode 100644 index 000000000..53189b749 --- /dev/null +++ b/apps/client/src/features/attachments/components/page-attachments-modal.tsx @@ -0,0 +1,191 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ActionIcon, + Anchor, + Center, + Group, + Loader, + Modal, + ScrollArea, + Text, + Tooltip, +} from "@mantine/core"; +import { IconDownload } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { SearchInput } from "@/components/common/search-input.tsx"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { usePageAttachmentsQuery } from "@/features/attachments/queries/attachment-query.ts"; +import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts"; +import { AttachmentFileIcon } from "@/features/attachments/components/attachment-file-icon.tsx"; +import { formatBytes } from "@/lib"; +import { getFileUrl } from "@/lib/config.ts"; +import { formattedDate } from "@/lib/time.ts"; + +interface PageAttachmentsModalProps { + pageId: string; + open: boolean; + onClose: () => void; +} + +export default function PageAttachmentsModal({ + pageId, + open, + onClose, +}: PageAttachmentsModalProps) { + const { t } = useTranslation(); + + return ( + + + + ); +} + +function PageAttachmentsList({ pageId }: { pageId: string }) { + const { t } = useTranslation(); + const [search, setSearch] = useState(""); + const { + data, + isLoading, + isError, + isFetching, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = usePageAttachmentsQuery(pageId, search); + + const attachments = useMemo( + () => data?.pages.flatMap((page) => page.items) ?? [], + [data], + ); + + const loadMoreRef = useRef(null); + + useEffect(() => { + const sentinel = loadMoreRef.current; + if (!sentinel || !hasNextPage) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isFetching) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetching]); + + const handleSearch = useCallback((value: string) => setSearch(value), []); + + return ( + <> + + + {isLoading ? ( +
    + +
    + ) : isError ? ( +
    + + {t("Error loading attachments.")} + +
    + ) : attachments.length === 0 ? ( +
    + + {search + ? t("No results found") + : t("No attachments on this page yet.")} + +
    + ) : ( + + {attachments.map((attachment) => ( + + ))} + {hasNextPage &&
    } + {isFetchingNextPage && ( +
    + +
    + )} + + )} + + ); +} + +function AttachmentRow({ attachment }: { attachment: IPageAttachment }) { + const { t } = useTranslation(); + const fileUrl = getFileUrl(attachment.url); + + return ( + + + +
    + + {attachment.fileName} + + + {formatBytes(Number(attachment.fileSize))} + {" ยท "} + {formattedDate(new Date(attachment.createdAt))} + +
    + + {attachment.creator && ( + + + + )} + + + + + + +
    + ); +} diff --git a/apps/client/src/features/attachments/queries/attachment-query.ts b/apps/client/src/features/attachments/queries/attachment-query.ts new file mode 100644 index 000000000..e9630bdd6 --- /dev/null +++ b/apps/client/src/features/attachments/queries/attachment-query.ts @@ -0,0 +1,25 @@ +import { + InfiniteData, + keepPreviousData, + useInfiniteQuery, + UseInfiniteQueryResult, +} from "@tanstack/react-query"; +import { getPageAttachments } from "@/features/attachments/services/attachment-service.ts"; +import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts"; +import { IPagination } from "@/lib/types.ts"; + +export function usePageAttachmentsQuery( + pageId: string, + search?: string, +): UseInfiniteQueryResult, unknown>> { + return useInfiniteQuery({ + queryKey: ["page-attachments", pageId, search], + queryFn: ({ pageParam }) => + getPageAttachments(pageId, { cursor: pageParam, query: search }), + enabled: !!pageId, + gcTime: 0, + placeholderData: keepPreviousData, + initialPageParam: undefined, + getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined, + }); +} diff --git a/apps/client/src/features/attachments/services/attachment-service.ts b/apps/client/src/features/attachments/services/attachment-service.ts index fa43da3ca..e129c248e 100644 --- a/apps/client/src/features/attachments/services/attachment-service.ts +++ b/apps/client/src/features/attachments/services/attachment-service.ts @@ -3,7 +3,17 @@ import loadImage from "blueimp-load-image"; import { AvatarIconType, IAttachment, + IPageAttachment, } from "@/features/attachments/types/attachment.types.ts"; +import { IPagination, QueryParams } from "@/lib/types.ts"; + +export async function getPageAttachments( + pageId: string, + params?: QueryParams, +): Promise> { + const req = await api.post("/pages/attachments", { pageId, ...params }); + return req.data; +} async function compressAndResizeIcon( file: File, diff --git a/apps/client/src/features/attachments/services/index.ts b/apps/client/src/features/attachments/services/index.ts index 1732ba9fb..07e96c6ba 100644 --- a/apps/client/src/features/attachments/services/index.ts +++ b/apps/client/src/features/attachments/services/index.ts @@ -1,4 +1,5 @@ export { + getPageAttachments, uploadIcon, uploadUserAvatar, uploadSpaceIcon, diff --git a/apps/client/src/features/attachments/types/attachment.types.ts b/apps/client/src/features/attachments/types/attachment.types.ts index 018d8c7c1..ca4517517 100644 --- a/apps/client/src/features/attachments/types/attachment.types.ts +++ b/apps/client/src/features/attachments/types/attachment.types.ts @@ -15,6 +15,15 @@ export interface IAttachment { deletedAt: string | null; } +export interface IPageAttachment extends IAttachment { + url: string; + creator: { + id: string; + name: string; + avatarUrl: string | null; + } | null; +} + export enum AvatarIconType { AVATAR = "avatar", SPACE_ICON = "space-icon", diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index e011e9ec4..9b02a4596 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -11,6 +11,7 @@ import { IconList, IconMarkdown, IconMessage, + IconPaperclip, IconPrinter, IconStar, IconStarFilled, @@ -42,6 +43,7 @@ import { import { formattedDate } from "@/lib/time.ts"; import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx"; +import PageAttachmentsModal from "@/features/attachments/components/page-attachments-modal.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; import { PageShareModal } from "@/ee/page-permission"; import { @@ -157,6 +159,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { verificationOpened, { open: openVerificationModal, close: closeVerificationModal }, ] = useDisclosure(false); + const [ + attachmentsOpened, + { open: openAttachmentsModal, close: closeAttachmentsModal }, + ] = useDisclosure(false); const [pageEditor] = useAtom(pageEditorAtom); const pageUpdatedAt = useTimeAgo(page?.updatedAt); const favoriteIds = useFavoriteIds("page", page?.spaceId); @@ -293,6 +299,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { )} + {!page?.isBase && ( + } + onClick={openAttachmentsModal} + > + {t("Attachments")} + + )} + {!readOnly && !page?.isBase && ( + + ); } diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index 736058191..57d4124c3 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -53,8 +53,14 @@ import { EnvironmentService } from '../../integrations/environment/environment.s import { TokenService } from '../auth/services/token.service'; import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload'; import * as path from 'path'; -import { AttachmentInfoDto, RemoveIconDto } from './dto/attachment.dto'; +import { + AttachmentInfoDto, + PageIdDto, + RemoveIconDto, +} from './dto/attachment.dto'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PageAccessService } from '../page/page-access/page-access.service'; +import { DomainService } from '../../integrations/environment/domain.service'; import { AuditEvent, AuditResource } from '../../common/events/audit-events'; import { AUDIT_SERVICE, @@ -75,6 +81,7 @@ export class AttachmentController { private readonly environmentService: EnvironmentService, private readonly tokenService: TokenService, private readonly pageAccessService: PageAccessService, + private readonly domainService: DomainService, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} @@ -151,7 +158,10 @@ export class AttachmentController { }, }); - return res.send(fileResponse); + return res.send({ + ...fileResponse, + url: this.buildFileUrl(workspace, fileResponse), + }); } catch (err: any) { if (err?.statusCode === 413) { const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`; @@ -411,7 +421,37 @@ export class AttachmentController { await this.pageAccessService.validateCanView(page, user); - return attachment; + return { ...attachment, url: this.buildFileUrl(workspace, attachment) }; + } + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('pages/attachments') + async getPageAttachments( + @Body() dto: PageIdDto, + @Body() pagination: PaginationOptions, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const page = await this.pageRepo.findById(dto.pageId); + if (!page || page.workspaceId !== workspace.id) { + throw new NotFoundException('Page not found'); + } + + await this.pageAccessService.validateCanView(page, user); + + const result = await this.attachmentRepo.findPageAttachments( + page.id, + pagination, + ); + + return { + ...result, + items: result.items.map((attachment) => ({ + ...attachment, + url: this.buildFileUrl(workspace, attachment), + })), + }; } @UseGuards(JwtAuthGuard) @@ -465,6 +505,10 @@ export class AttachmentController { } } + private buildFileUrl(workspace: Workspace, attachment: Attachment): string { + return `${this.domainService.getUrl(workspace.hostname)}/api/files/${attachment.id}/${encodeURIComponent(attachment.fileName)}`; + } + private async sendFileResponse( req: FastifyRequest, res: FastifyReply, diff --git a/apps/server/src/core/attachment/dto/attachment.dto.ts b/apps/server/src/core/attachment/dto/attachment.dto.ts index 850de6f9e..80d510944 100644 --- a/apps/server/src/core/attachment/dto/attachment.dto.ts +++ b/apps/server/src/core/attachment/dto/attachment.dto.ts @@ -1,4 +1,11 @@ -import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; +import { + IsEnum, + IsIn, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; import { AttachmentType } from '../attachment.constants'; export class AttachmentInfoDto { @@ -7,6 +14,12 @@ export class AttachmentInfoDto { attachmentId: string; } +export class PageIdDto { + @IsString() + @IsNotEmpty() + pageId: string; +} + export class RemoveIconDto { @IsEnum(AttachmentType) @IsIn([ diff --git a/apps/server/src/database/repos/attachment/attachment.repo.ts b/apps/server/src/database/repos/attachment/attachment.repo.ts index f7d717ea0..abdc36e00 100644 --- a/apps/server/src/database/repos/attachment/attachment.repo.ts +++ b/apps/server/src/database/repos/attachment/attachment.repo.ts @@ -1,5 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectKysely } from 'nestjs-kysely'; +import { ExpressionBuilder, sql } from 'kysely'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; +import { DB } from '@docmost/db/types/db'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { dbOrTx } from '@docmost/db/utils'; import { @@ -8,6 +11,8 @@ import { UpdatableAttachment, } from '@docmost/db/types/entity.types'; import { AttachmentType } from '../../../core/attachment/attachment.constants'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; +import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; @Injectable() export class AttachmentRepo { @@ -89,6 +94,41 @@ export class AttachmentRepo { .execute(); } + async findPageAttachments(pageId: string, pagination: PaginationOptions) { + let query = this.db + .selectFrom('attachments') + .select(this.baseFields) + .select((eb) => this.withCreator(eb)) + .where('pageId', '=', pageId) + .where('type', '=', AttachmentType.File) + .where('deletedAt', 'is', null); + + if (pagination.query) { + query = query.where( + sql`f_unaccent(file_name)`, + 'ilike', + sql`f_unaccent(${'%' + pagination.query + '%'})`, + ); + } + + return executeWithCursorPagination(query, { + perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [{ expression: 'id', direction: 'desc' }], + parseCursor: (cursor) => ({ id: cursor.id }), + }); + } + + withCreator(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('users') + .select(['users.id', 'users.name', 'users.avatarUrl']) + .whereRef('users.id', '=', 'attachments.creatorId'), + ).as('creator'); + } + async findByIds( ids: string[], opts?: { diff --git a/apps/server/src/ee b/apps/server/src/ee index 41acc8af7..c0f9440de 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 41acc8af7da27b6b6b3491357b22dabee9ce3695 +Subproject commit c0f9440de97b91475d924a677073b97ec1b58527