From 412962204c84e0899474a52a0a06a5e1d85d59d1 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 29 Mar 2026 02:19:09 +0100 Subject: [PATCH] fix: editor fixes (#2067) * autojoiner * fix marked * return clipboardTextSerializer as markdown * fix clipboardTextSerializer for single lines * cleanup two preceeding spaces in ordered lists item * fix extra paragraph in task list * don't zip sinple page exports --- .../features/editor/extensions/autojoiner.ts | 105 ++++++++++++++++++ .../features/editor/extensions/extensions.ts | 6 +- .../editor/extensions/markdown-clipboard.ts | 25 ++++- .../integrations/export/export.controller.ts | 30 +++-- .../src/integrations/export/export.service.ts | 9 +- .../src/lib/markdown/utils/marked.utils.ts | 21 ++-- .../src/lib/markdown/utils/turndown.utils.ts | 55 ++++++--- 7 files changed, 217 insertions(+), 34 deletions(-) create mode 100644 apps/client/src/features/editor/extensions/autojoiner.ts diff --git a/apps/client/src/features/editor/extensions/autojoiner.ts b/apps/client/src/features/editor/extensions/autojoiner.ts new file mode 100644 index 00000000..b69ed042 --- /dev/null +++ b/apps/client/src/features/editor/extensions/autojoiner.ts @@ -0,0 +1,105 @@ +// https://github.com/NiclasDev63/tiptap-extension-auto-joiner - MIT +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { canJoin } from "@tiptap/pm/transform"; +import { getNodeType } from "@tiptap/react"; +import { NodeType } from "@tiptap/pm/model"; +import { Transaction } from "@tiptap/pm/state"; + +// https://discuss.prosemirror.net/t/how-to-autojoin-all-the-time/2957/4 +// Adapted from prosemirror-commands wrapDispatchForJoin +function autoJoin( + transactions: readonly Transaction[], + newTr: Transaction, + nodeTypes: NodeType[] +) { + // Collect changed ranges across all transactions, mapping earlier ranges + // forward through later mappings so every position lands in newTr.doc space. + let ranges: number[] = []; + for (const tr of transactions) { + for (let i = 0; i < tr.mapping.maps.length; i++) { + let map = tr.mapping.maps[i]; + if (!map) continue; + for (let j = 0; j < ranges.length; j++) ranges[j] = map.map(ranges[j]!); + map.forEach((_s, _e, from, to) => ranges.push(from, to)); + } + } + + // Figure out which joinable points exist inside those ranges, + // by checking all node boundaries in their parent nodes. + // Resolve against newTr.doc — the same document we will join on. + let joinable: number[] = []; + for (let i = 0; i < ranges.length; i += 2) { + let from = ranges[i]!, + to = ranges[i + 1]!; + let $from = newTr.doc.resolve(from), + depth = $from.sharedDepth(to), + parent = $from.node(depth); + for ( + let index = $from.indexAfter(depth), pos = $from.after(depth + 1); + pos <= to; + ++index + ) { + let after = parent.maybeChild(index); + if (!after) break; + if (index && joinable.indexOf(pos) == -1) { + let before = parent.child(index - 1); + if (before.type == after.type && nodeTypes.includes(before.type)) + joinable.push(pos); + } + pos += after.nodeSize; + } + } + + // Join the joinable points (reverse order to preserve earlier positions) + let joined = false; + joinable.sort((a, b) => a - b); + for (let i = joinable.length - 1; i >= 0; i--) { + if (canJoin(newTr.doc, joinable[i]!)) { + newTr.join(joinable[i]!); + joined = true; + } + } + + return joined; +} + +export interface AutoJoinerOptions { + elementsToJoin: string[]; +} + +const AutoJoiner = Extension.create({ + name: "autoJoiner", + + addOptions() { + return { + elementsToJoin: [], + }; + }, + + addProseMirrorPlugins() { + const plugin = new PluginKey(this.name); + const joinableNodes = [ + this.editor.schema.nodes.bulletList, + this.editor.schema.nodes.orderedList, + ]; + this.options.elementsToJoin.forEach((element) => { + const nodeTyp = getNodeType(element, this.editor.schema); + joinableNodes.push(nodeTyp); + }); + + return [ + new Plugin({ + key: plugin, + appendTransaction(transactions, _, newState) { + let newTr = newState.tr; + if (autoJoin(transactions, newTr, joinableNodes as NodeType[])) { + return newTr; + } + }, + }), + ]; + }, +}); + +export default AutoJoiner; diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 37f173dc..c5ca4cd1 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -49,7 +49,7 @@ import { SharedStorage, Columns, Column, - Status + Status, } from "@docmost/editor-ext"; import { randomElement, @@ -97,6 +97,7 @@ import i18n from "@/i18n.ts"; import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts"; import EmojiCommand from "./emoji-command"; import { countWords } from "alfaaz"; +import AutoJoiner from "@/features/editor/extensions/autojoiner.ts"; const lowlight = createLowlight(common); lowlight.register("mermaid", plaintext); @@ -353,6 +354,9 @@ export const mainExtensions = [ }).configure(), Columns, Column, + AutoJoiner.configure({ + elementsToJoin: [], + }), ] as any; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; diff --git a/apps/client/src/features/editor/extensions/markdown-clipboard.ts b/apps/client/src/features/editor/extensions/markdown-clipboard.ts index 0d6ab263..de0ca144 100644 --- a/apps/client/src/features/editor/extensions/markdown-clipboard.ts +++ b/apps/client/src/features/editor/extensions/markdown-clipboard.ts @@ -1,9 +1,9 @@ // adapted from: https://github.com/aguingand/tiptap-markdown/blob/main/src/extensions/tiptap/clipboard.js - MIT import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; -import { DOMParser, Fragment, Slice } from "@tiptap/pm/model"; +import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model"; import { find } from "linkifyjs"; -import { markdownToHtml } from "@docmost/editor-ext"; +import { markdownToHtml, htmlToMarkdown } from "@docmost/editor-ext"; export const MarkdownClipboard = Extension.create({ name: "markdownClipboard", @@ -19,6 +19,27 @@ export const MarkdownClipboard = Extension.create({ new Plugin({ key: new PluginKey("markdownClipboard"), props: { + clipboardTextSerializer: (slice) => { + const listTypes = ["bulletList", "orderedList", "taskList"]; + let topLevelCount = 0; + let hasList = false; + slice.content.forEach((node) => { + if (listTypes.includes(node.type.name)) { + hasList = true; + topLevelCount += node.childCount; + } else { + topLevelCount++; + } + }); + + if (!hasList || topLevelCount < 2) return null; + + const div = document.createElement("div"); + const serializer = DOMSerializer.fromSchema(this.editor.schema); + const fragment = serializer.serializeFragment(slice.content); + div.appendChild(fragment); + return htmlToMarkdown(div.innerHTML); + }, handlePaste: (view, event, slice) => { if (!event.clipboardData) { return false; diff --git a/apps/server/src/integrations/export/export.controller.ts b/apps/server/src/integrations/export/export.controller.ts index 0fc5fb96..1ce3f8c8 100644 --- a/apps/server/src/integrations/export/export.controller.ts +++ b/apps/server/src/integrations/export/export.controller.ts @@ -61,7 +61,7 @@ export class ExportController { await this.pageAccessService.validateCanView(page, user); - const zipFileStream = await this.exportService.exportPages( + const result = await this.exportService.exportPages( dto.pageId, dto.format, dto.includeAttachments, @@ -83,15 +83,29 @@ export class ExportController { }, }); - const fileName = sanitize(page.title || 'untitled') + '.zip'; + if (result.type === 'file') { + const ext = getExportExtension(dto.format); + const fileName = sanitize(page.title || 'untitled') + ext; + const contentType = getMimeType(path.extname(fileName)); - res.headers({ - 'Content-Type': 'application/zip', - 'Content-Disposition': - 'attachment; filename="' + encodeURIComponent(fileName) + '"', - }); + res.headers({ + 'Content-Type': contentType, + 'Content-Disposition': + 'attachment; filename="' + encodeURIComponent(fileName) + '"', + }); - res.send(zipFileStream); + res.send(result.content); + } else { + const fileName = sanitize(page.title || 'untitled') + '.zip'; + + res.headers({ + 'Content-Type': 'application/zip', + 'Content-Disposition': + 'attachment; filename="' + encodeURIComponent(fileName) + '"', + }); + + res.send(result.stream); + } } @UseGuards(JwtAuthGuard) diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts index 4e1350f3..d93f9ba0 100644 --- a/apps/server/src/integrations/export/export.service.ts +++ b/apps/server/src/integrations/export/export.service.ts @@ -150,6 +150,13 @@ export class ExportService { // set to null to make export of pages with parentId work pages[parentPageIndex].parentPageId = null; + const isSinglePage = pages.length === 1 && !includeAttachments; + + if (isSinglePage) { + const pageContent = await this.exportPage(format, pages[0], true); + return { type: 'file' as const, content: pageContent, page: pages[0] }; + } + const tree = buildTree(pages as Page[]); const baseUrl = await this.getWorkspaceBaseUrl(pages[0].workspaceId); @@ -170,7 +177,7 @@ export class ExportService { compression: 'DEFLATE', }); - return zipFile; + return { type: 'zip' as const, stream: zipFile, page: pages[0] }; } async exportSpace( 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 15797711..04dc1978 100644 --- a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts @@ -5,18 +5,23 @@ import { mathInlineExtension } from "./math-inline.marked"; marked.use({ renderer: { - // @ts-ignore - list(body: string, isOrdered: boolean, start: number) { - if (isOrdered) { - const startAttr = start !== 1 ? ` start="${start}"` : ""; - return `
    \n${body}
\n`; + list({ ordered, start, items }) { + let body = ""; + for (const item of items) { + body += this.listitem(item); } - const dataType = body.includes(`\n${body}\n`; + } + + const isTaskList = items.some((item) => item.task); + const dataType = isTaskList ? ' data-type="taskList"' : ""; return `\n${body}\n`; }, - // @ts-ignore - listitem({ text, raw, task: isTask, checked: isChecked }): string { + listitem({ tokens, task: isTask, checked: isChecked }) { + const text = this.parser.parse(tokens); if (!isTask) { return `
  • ${text}
  • \n`; } 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 71a2b512..635983df 100644 --- a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts @@ -21,6 +21,7 @@ export function htmlToMarkdown(html: string): string { callout, preserveDetail, listParagraph, + orderedListItem, mathInline, mathBlock, iframeEmbed, @@ -41,6 +42,40 @@ function listParagraph(turndownService: _TurndownService) { }); } +function orderedListItem(turndownService: _TurndownService) { + turndownService.addRule('orderedListItem', { + filter: function (node: HTMLInputElement) { + return node.nodeName === 'LI' && node.getAttribute('data-type') !== 'taskItem'; + }, + replacement: (content: string, node: HTMLInputElement, options: any) => { + const parent = node.parentNode as HTMLElement; + if (parent.nodeName !== 'OL' && parent.nodeName !== 'UL') { + return content; + } + + content = content + .replace(/^\n+/, '') + .replace(/\n+$/, '\n') + .replace(/\n/gm, '\n '); + + let prefix: string; + if (parent.nodeName === 'OL') { + const start = parseInt(parent.getAttribute('start') || '1', 10); + const index = Array.prototype.indexOf.call(parent.children, node); + prefix = `${start + index}. `; + } else { + prefix = `${options.bulletListMarker} `; + } + + return ( + prefix + + content + + (node.nextSibling && !/\n$/.test(content) ? '\n' : '') + ); + }, + }); +} + function callout(turndownService: _TurndownService) { turndownService.addRule('callout', { filter: function (node: HTMLInputElement) { @@ -63,25 +98,17 @@ function taskList(turndownService: _TurndownService) { node.parentNode.nodeName === 'UL' ); }, - replacement: function (content: string, node: HTMLInputElement) { - const checkbox = node.querySelector( - 'input[type="checkbox"]', - ) as HTMLInputElement; - const isChecked = checkbox.checked; + replacement: function (_content: string, node: HTMLInputElement) { + const isChecked = node.getAttribute('data-checked') === 'true'; + const div = node.querySelector('div'); + const text = div ? div.textContent.trim() : node.textContent.trim(); - // Process content like regular list items - content = content - .replace(/^\n+/, '') // remove leading newlines - .replace(/\n+$/, '\n') // replace trailing newlines with just a single one - .replace(/\n/gm, '\n '); // indent nested content with 2 spaces - - // Create the checkbox prefix const prefix = `- ${isChecked ? '[x]' : '[ ]'} `; return ( prefix + - content + - (node.nextSibling && !/\n$/.test(content) ? '\n' : '') + text + + (node.nextSibling && !/\n$/.test(text) ? '\n' : '') ); }, });