From aa6a046aa610ef8d29c7b222deb48ef87bd422a3 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 24 Jan 2026 23:30:17 +0000 Subject: [PATCH] feat(export): add export loading state and copy as markdown (#1867) * feat: add loading state to export * feat: copy as markdown * preserve taskList comment --- .../public/locales/en-US/translation.json | 2 + .../src/components/common/export-modal.tsx | 9 ++- .../components/header/page-header-menu.tsx | 18 ++++++ .../src/integrations/export/export.service.ts | 4 +- package.json | 1 + packages/editor-ext/.prettierrc | 4 ++ packages/editor-ext/src/lib/markdown/index.ts | 1 + .../src/lib/markdown/utils/turndown.d.ts | 12 ++++ .../src/lib/markdown/utils/turndown.utils.ts | 64 ++++++++++--------- pnpm-lock.yaml | 8 +++ 10 files changed, 89 insertions(+), 34 deletions(-) create mode 100644 packages/editor-ext/.prettierrc create mode 100644 packages/editor-ext/src/lib/markdown/utils/turndown.d.ts rename apps/server/src/integrations/export/turndown-utils.ts => packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts (70%) diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 0cdfbee0..c0578d2b 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "Choose your preferred interface language.", "Choose your preferred page width.": "Choose your preferred page width.", "Confirm": "Confirm", + "Copy as Markdown": "Copy as Markdown", "Copy link": "Copy link", "Create": "Create", "Create group": "Create group", @@ -253,6 +254,7 @@ "Export failed:": "Export failed:", "export error": "export error", "Export page": "Export page", + "Export successful": "Export successful", "Export space": "Export space", "Export {{type}}": "Export {{type}}", "File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit", diff --git a/apps/client/src/components/common/export-modal.tsx b/apps/client/src/components/common/export-modal.tsx index 25f4d328..53de8246 100644 --- a/apps/client/src/components/common/export-modal.tsx +++ b/apps/client/src/components/common/export-modal.tsx @@ -30,9 +30,11 @@ export default function ExportModal({ const [format, setFormat] = useState(ExportFormat.Markdown); const [includeChildren, setIncludeChildren] = useState(false); const [includeAttachments, setIncludeAttachments] = useState(false); + const [isExporting, setIsExporting] = useState(false); const { t } = useTranslation(); const handleExport = async () => { + setIsExporting(true); try { if (type === "page") { await exportPage({ @@ -45,6 +47,9 @@ export default function ExportModal({ if (type === "space") { await exportSpace({ spaceId: id, format, includeAttachments }); } + notifications.show({ + message: t("Export successful"), + }); onClose(); } catch (err) { notifications.show({ @@ -52,6 +57,8 @@ export default function ExportModal({ color: "red", }); console.error("export error", err); + } finally { + setIsExporting(false); } }; @@ -136,7 +143,7 @@ export default function ExportModal({ - + 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 6e9625b1..9cd4362d 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 @@ -7,6 +7,7 @@ import { IconHistory, IconLink, IconList, + IconMarkdown, IconMessage, IconPrinter, IconTrash, @@ -28,6 +29,7 @@ import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal. import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx"; import { Trans, useTranslation } from "react-i18next"; import ExportModal from "@/components/common/export-modal"; +import { htmlToMarkdown } from "@docmost/editor-ext"; import { pageEditorAtom, yjsConnectionStatusAtom, @@ -129,6 +131,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { notifications.show({ message: t("Link copied") }); }; + const handleCopyAsMarkdown = () => { + if (!pageEditor) return; + const html = pageEditor.getHTML(); + const markdown = htmlToMarkdown(html); + const title = page?.title ? `# ${page.title}\n\n` : ""; + clipboard.copy(`${title}${markdown}`); + notifications.show({ message: t("Copied") }); + }; + const handlePrint = () => { setTimeout(() => { window.print(); @@ -166,6 +177,13 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { > {t("Copy link")} + + } + onClick={handleCopyAsMarkdown} + > + {t("Copy as Markdown")} + }> diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts index 91b84250..e33ac11b 100644 --- a/apps/server/src/integrations/export/export.service.ts +++ b/apps/server/src/integrations/export/export.service.ts @@ -5,7 +5,6 @@ import { NotFoundException, } from '@nestjs/common'; import { jsonToHtml, jsonToNode } from '../../collaboration/collaboration.util'; -import { turndown } from './turndown-utils'; import { ExportFormat } from './dto/export-dto'; import { Page } from '@docmost/db/types/entity.types'; import { InjectKysely } from 'nestjs-kysely'; @@ -31,6 +30,7 @@ import { getAttachmentIds, getProsemirrorContent, } from '../../common/helpers/prosemirror/utils'; +import { htmlToMarkdown } from '@docmost/editor-ext'; @Injectable() export class ExportService { @@ -83,7 +83,7 @@ export class ExportService { /]*>[\s\S]*?<\/colgroup>/gim, '', ); - return turndown(newPageHtml); + return htmlToMarkdown(newPageHtml); } return; diff --git a/package.json b/package.json index 6a5103e8..2b5096ef 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "devDependencies": { "@nx/js": "20.4.5", "@types/bytes": "^3.1.5", + "@types/turndown": "^5.0.6", "@types/uuid": "^10.0.0", "concurrently": "^9.1.2", "nx": "20.4.5", diff --git a/packages/editor-ext/.prettierrc b/packages/editor-ext/.prettierrc new file mode 100644 index 00000000..dcb72794 --- /dev/null +++ b/packages/editor-ext/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/packages/editor-ext/src/lib/markdown/index.ts b/packages/editor-ext/src/lib/markdown/index.ts index 96daf9c9..26eb5d48 100644 --- a/packages/editor-ext/src/lib/markdown/index.ts +++ b/packages/editor-ext/src/lib/markdown/index.ts @@ -1 +1,2 @@ export * from "./utils/marked.utils"; +export * from "./utils/turndown.utils"; diff --git a/packages/editor-ext/src/lib/markdown/utils/turndown.d.ts b/packages/editor-ext/src/lib/markdown/utils/turndown.d.ts new file mode 100644 index 00000000..0e8a9a2d --- /dev/null +++ b/packages/editor-ext/src/lib/markdown/utils/turndown.d.ts @@ -0,0 +1,12 @@ +// Map @joplin/turndown types to @types/turndown +declare module "@joplin/turndown" { + import TurndownService from "turndown"; + export = TurndownService; +} + +declare module "@joplin/turndown-plugin-gfm" { + import TurndownService from "turndown"; + export const tables: TurndownService.Plugin; + export const strikethrough: TurndownService.Plugin; + export const highlightedCodeBlock: TurndownService.Plugin; +} diff --git a/apps/server/src/integrations/export/turndown-utils.ts b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts similarity index 70% rename from apps/server/src/integrations/export/turndown-utils.ts rename to packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts index b20e6733..0f84aa40 100644 --- a/apps/server/src/integrations/export/turndown-utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts @@ -1,22 +1,21 @@ -import * as TurndownService from '@joplin/turndown'; +import * as _TurndownService from '@joplin/turndown'; import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm'; -import * as path from 'path'; -export function turndown(html: string): string { +// CJS/ESM interop: .default exists in Vite, not in NestJS +const TurndownService = (_TurndownService as any).default || _TurndownService; + +export function htmlToMarkdown(html: string): string { const turndownService = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', hr: '---', bulletListMarker: '-', }); - const tables = TurndownPluginGfm.tables; - const strikethrough = TurndownPluginGfm.strikethrough; - const highlightedCodeBlock = TurndownPluginGfm.highlightedCodeBlock; turndownService.use([ - tables, - strikethrough, - highlightedCodeBlock, + TurndownPluginGfm.tables, + TurndownPluginGfm.strikethrough, + TurndownPluginGfm.highlightedCodeBlock, taskList, callout, preserveDetail, @@ -29,34 +28,33 @@ export function turndown(html: string): string { return turndownService.turndown(html).replaceAll('
', ' '); } -function listParagraph(turndownService: TurndownService) { +function listParagraph(turndownService: _TurndownService) { turndownService.addRule('paragraph', { filter: ['p'], - replacement: (content: any, node: HTMLInputElement) => { + replacement: (content: string, node: HTMLInputElement) => { if (node.parentElement?.nodeName === 'LI') { return content; } - return `\n\n${content}\n\n`; }, }); } -function callout(turndownService: TurndownService) { +function callout(turndownService: _TurndownService) { turndownService.addRule('callout', { filter: function (node: HTMLInputElement) { return ( node.nodeName === 'DIV' && node.getAttribute('data-type') === 'callout' ); }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (content: string, node: HTMLInputElement) { const calloutType = node.getAttribute('data-callout-type'); return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`; }, }); } -function taskList(turndownService: TurndownService) { +function taskList(turndownService: _TurndownService) { turndownService.addRule('taskListItem', { filter: function (node: HTMLInputElement) { return ( @@ -64,32 +62,36 @@ function taskList(turndownService: TurndownService) { node.parentNode.nodeName === 'UL' ); }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (content: string, node: HTMLInputElement) { const checkbox = node.querySelector( 'input[type="checkbox"]', ) as HTMLInputElement; const isChecked = checkbox.checked; - + // 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' : ''); + + return ( + prefix + + content + + (node.nextSibling && !/\n$/.test(content) ? '\n' : '') + ); }, }); } -function preserveDetail(turndownService: TurndownService) { +function preserveDetail(turndownService: _TurndownService) { turndownService.addRule('preserveDetail', { filter: function (node: HTMLInputElement) { return node.nodeName === 'DETAILS'; }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (_content: string, node: HTMLInputElement) { const summary = node.querySelector(':scope > summary'); let detailSummary = ''; @@ -111,7 +113,7 @@ function preserveDetail(turndownService: TurndownService) { }); } -function mathInline(turndownService: TurndownService) { +function mathInline(turndownService: _TurndownService) { turndownService.addRule('mathInline', { filter: function (node: HTMLInputElement) { return ( @@ -119,13 +121,13 @@ function mathInline(turndownService: TurndownService) { node.getAttribute('data-type') === 'mathInline' ); }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (content: string) { return `$${content}$`; }, }); } -function mathBlock(turndownService: TurndownService) { +function mathBlock(turndownService: _TurndownService) { turndownService.addRule('mathBlock', { filter: function (node: HTMLInputElement) { return ( @@ -133,32 +135,32 @@ function mathBlock(turndownService: TurndownService) { node.getAttribute('data-type') === 'mathBlock' ); }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (content: string) { return `\n$$\n${content}\n$$\n`; }, }); } -function iframeEmbed(turndownService: TurndownService) { +function iframeEmbed(turndownService: _TurndownService) { turndownService.addRule('iframeEmbed', { filter: function (node: HTMLInputElement) { return node.nodeName === 'IFRAME'; }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (_content: string, node: HTMLInputElement) { const src = node.getAttribute('src'); return '[' + src + '](' + src + ')'; }, }); } -function video(turndownService: TurndownService) { +function video(turndownService: _TurndownService) { turndownService.addRule('video', { filter: function (node: HTMLInputElement) { return node.tagName === 'VIDEO'; }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (_content: string, node: HTMLInputElement) { const src = node.getAttribute('src') || ''; - const name = path.basename(src); + const name = src.split('/').pop() || src; return '[' + name + '](' + src + ')'; }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b5e98be..e3904280 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: '@types/bytes': specifier: ^3.1.5 version: 3.1.5 + '@types/turndown': + specifier: ^5.0.6 + version: 5.0.6 '@types/uuid': specifier: ^10.0.0 version: 10.0.0 @@ -4812,6 +4815,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/turndown@5.0.6': + resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -15274,6 +15280,8 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@types/turndown@5.0.6': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.2': {}