mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat(export): add export loading state and copy as markdown (#1867)
* feat: add loading state to export * feat: copy as markdown * preserve taskList comment
This commit is contained in:
@@ -29,6 +29,7 @@
|
|||||||
"Choose your preferred interface language.": "Choose your preferred interface language.",
|
"Choose your preferred interface language.": "Choose your preferred interface language.",
|
||||||
"Choose your preferred page width.": "Choose your preferred page width.",
|
"Choose your preferred page width.": "Choose your preferred page width.",
|
||||||
"Confirm": "Confirm",
|
"Confirm": "Confirm",
|
||||||
|
"Copy as Markdown": "Copy as Markdown",
|
||||||
"Copy link": "Copy link",
|
"Copy link": "Copy link",
|
||||||
"Create": "Create",
|
"Create": "Create",
|
||||||
"Create group": "Create group",
|
"Create group": "Create group",
|
||||||
@@ -253,6 +254,7 @@
|
|||||||
"Export failed:": "Export failed:",
|
"Export failed:": "Export failed:",
|
||||||
"export error": "export error",
|
"export error": "export error",
|
||||||
"Export page": "Export page",
|
"Export page": "Export page",
|
||||||
|
"Export successful": "Export successful",
|
||||||
"Export space": "Export space",
|
"Export space": "Export space",
|
||||||
"Export {{type}}": "Export {{type}}",
|
"Export {{type}}": "Export {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
|
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ export default function ExportModal({
|
|||||||
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||||
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
||||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
||||||
|
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
|
setIsExporting(true);
|
||||||
try {
|
try {
|
||||||
if (type === "page") {
|
if (type === "page") {
|
||||||
await exportPage({
|
await exportPage({
|
||||||
@@ -45,6 +47,9 @@ export default function ExportModal({
|
|||||||
if (type === "space") {
|
if (type === "space") {
|
||||||
await exportSpace({ spaceId: id, format, includeAttachments });
|
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||||
}
|
}
|
||||||
|
notifications.show({
|
||||||
|
message: t("Export successful"),
|
||||||
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -52,6 +57,8 @@ export default function ExportModal({
|
|||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
console.error("export error", err);
|
console.error("export error", err);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,7 +143,7 @@ export default function ExportModal({
|
|||||||
<Button onClick={onClose} variant="default">
|
<Button onClick={onClose} variant="default">
|
||||||
{t("Cancel")}
|
{t("Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleExport}>{t("Export")}</Button>
|
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
IconHistory,
|
IconHistory,
|
||||||
IconLink,
|
IconLink,
|
||||||
IconList,
|
IconList,
|
||||||
|
IconMarkdown,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
IconTrash,
|
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 { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import ExportModal from "@/components/common/export-modal";
|
import ExportModal from "@/components/common/export-modal";
|
||||||
|
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
@@ -129,6 +131,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
notifications.show({ message: t("Link copied") });
|
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 = () => {
|
const handlePrint = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.print();
|
window.print();
|
||||||
@@ -166,6 +177,13 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
>
|
>
|
||||||
{t("Copy link")}
|
{t("Copy link")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconMarkdown size={16} />}
|
||||||
|
onClick={handleCopyAsMarkdown}
|
||||||
|
>
|
||||||
|
{t("Copy as Markdown")}
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { jsonToHtml, jsonToNode } from '../../collaboration/collaboration.util';
|
import { jsonToHtml, jsonToNode } from '../../collaboration/collaboration.util';
|
||||||
import { turndown } from './turndown-utils';
|
|
||||||
import { ExportFormat } from './dto/export-dto';
|
import { ExportFormat } from './dto/export-dto';
|
||||||
import { Page } from '@docmost/db/types/entity.types';
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
@@ -31,6 +30,7 @@ import {
|
|||||||
getAttachmentIds,
|
getAttachmentIds,
|
||||||
getProsemirrorContent,
|
getProsemirrorContent,
|
||||||
} from '../../common/helpers/prosemirror/utils';
|
} from '../../common/helpers/prosemirror/utils';
|
||||||
|
import { htmlToMarkdown } from '@docmost/editor-ext';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExportService {
|
export class ExportService {
|
||||||
@@ -83,7 +83,7 @@ export class ExportService {
|
|||||||
/<colgroup[^>]*>[\s\S]*?<\/colgroup>/gim,
|
/<colgroup[^>]*>[\s\S]*?<\/colgroup>/gim,
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
return turndown(newPageHtml);
|
return htmlToMarkdown(newPageHtml);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nx/js": "20.4.5",
|
"@nx/js": "20.4.5",
|
||||||
"@types/bytes": "^3.1.5",
|
"@types/bytes": "^3.1.5",
|
||||||
|
"@types/turndown": "^5.0.6",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"nx": "20.4.5",
|
"nx": "20.4.5",
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./utils/marked.utils";
|
export * from "./utils/marked.utils";
|
||||||
|
export * from "./utils/turndown.utils";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+30
-28
@@ -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 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({
|
const turndownService = new TurndownService({
|
||||||
headingStyle: 'atx',
|
headingStyle: 'atx',
|
||||||
codeBlockStyle: 'fenced',
|
codeBlockStyle: 'fenced',
|
||||||
hr: '---',
|
hr: '---',
|
||||||
bulletListMarker: '-',
|
bulletListMarker: '-',
|
||||||
});
|
});
|
||||||
const tables = TurndownPluginGfm.tables;
|
|
||||||
const strikethrough = TurndownPluginGfm.strikethrough;
|
|
||||||
const highlightedCodeBlock = TurndownPluginGfm.highlightedCodeBlock;
|
|
||||||
|
|
||||||
turndownService.use([
|
turndownService.use([
|
||||||
tables,
|
TurndownPluginGfm.tables,
|
||||||
strikethrough,
|
TurndownPluginGfm.strikethrough,
|
||||||
highlightedCodeBlock,
|
TurndownPluginGfm.highlightedCodeBlock,
|
||||||
taskList,
|
taskList,
|
||||||
callout,
|
callout,
|
||||||
preserveDetail,
|
preserveDetail,
|
||||||
@@ -29,34 +28,33 @@ export function turndown(html: string): string {
|
|||||||
return turndownService.turndown(html).replaceAll('<br>', ' ');
|
return turndownService.turndown(html).replaceAll('<br>', ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function listParagraph(turndownService: TurndownService) {
|
function listParagraph(turndownService: _TurndownService) {
|
||||||
turndownService.addRule('paragraph', {
|
turndownService.addRule('paragraph', {
|
||||||
filter: ['p'],
|
filter: ['p'],
|
||||||
replacement: (content: any, node: HTMLInputElement) => {
|
replacement: (content: string, node: HTMLInputElement) => {
|
||||||
if (node.parentElement?.nodeName === 'LI') {
|
if (node.parentElement?.nodeName === 'LI') {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `\n\n${content}\n\n`;
|
return `\n\n${content}\n\n`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function callout(turndownService: TurndownService) {
|
function callout(turndownService: _TurndownService) {
|
||||||
turndownService.addRule('callout', {
|
turndownService.addRule('callout', {
|
||||||
filter: function (node: HTMLInputElement) {
|
filter: function (node: HTMLInputElement) {
|
||||||
return (
|
return (
|
||||||
node.nodeName === 'DIV' && node.getAttribute('data-type') === 'callout'
|
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');
|
const calloutType = node.getAttribute('data-callout-type');
|
||||||
return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`;
|
return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskList(turndownService: TurndownService) {
|
function taskList(turndownService: _TurndownService) {
|
||||||
turndownService.addRule('taskListItem', {
|
turndownService.addRule('taskListItem', {
|
||||||
filter: function (node: HTMLInputElement) {
|
filter: function (node: HTMLInputElement) {
|
||||||
return (
|
return (
|
||||||
@@ -64,7 +62,7 @@ function taskList(turndownService: TurndownService) {
|
|||||||
node.parentNode.nodeName === 'UL'
|
node.parentNode.nodeName === 'UL'
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
replacement: function (content: any, node: HTMLInputElement) {
|
replacement: function (content: string, node: HTMLInputElement) {
|
||||||
const checkbox = node.querySelector(
|
const checkbox = node.querySelector(
|
||||||
'input[type="checkbox"]',
|
'input[type="checkbox"]',
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
@@ -79,17 +77,21 @@ function taskList(turndownService: TurndownService) {
|
|||||||
// Create the checkbox prefix
|
// Create the checkbox prefix
|
||||||
const prefix = `- ${isChecked ? '[x]' : '[ ]'} `;
|
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', {
|
turndownService.addRule('preserveDetail', {
|
||||||
filter: function (node: HTMLInputElement) {
|
filter: function (node: HTMLInputElement) {
|
||||||
return node.nodeName === 'DETAILS';
|
return node.nodeName === 'DETAILS';
|
||||||
},
|
},
|
||||||
replacement: function (content: any, node: HTMLInputElement) {
|
replacement: function (_content: string, node: HTMLInputElement) {
|
||||||
const summary = node.querySelector(':scope > summary');
|
const summary = node.querySelector(':scope > summary');
|
||||||
let detailSummary = '';
|
let detailSummary = '';
|
||||||
|
|
||||||
@@ -111,7 +113,7 @@ function preserveDetail(turndownService: TurndownService) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mathInline(turndownService: TurndownService) {
|
function mathInline(turndownService: _TurndownService) {
|
||||||
turndownService.addRule('mathInline', {
|
turndownService.addRule('mathInline', {
|
||||||
filter: function (node: HTMLInputElement) {
|
filter: function (node: HTMLInputElement) {
|
||||||
return (
|
return (
|
||||||
@@ -119,13 +121,13 @@ function mathInline(turndownService: TurndownService) {
|
|||||||
node.getAttribute('data-type') === 'mathInline'
|
node.getAttribute('data-type') === 'mathInline'
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
replacement: function (content: any, node: HTMLInputElement) {
|
replacement: function (content: string) {
|
||||||
return `$${content}$`;
|
return `$${content}$`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mathBlock(turndownService: TurndownService) {
|
function mathBlock(turndownService: _TurndownService) {
|
||||||
turndownService.addRule('mathBlock', {
|
turndownService.addRule('mathBlock', {
|
||||||
filter: function (node: HTMLInputElement) {
|
filter: function (node: HTMLInputElement) {
|
||||||
return (
|
return (
|
||||||
@@ -133,32 +135,32 @@ function mathBlock(turndownService: TurndownService) {
|
|||||||
node.getAttribute('data-type') === 'mathBlock'
|
node.getAttribute('data-type') === 'mathBlock'
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
replacement: function (content: any, node: HTMLInputElement) {
|
replacement: function (content: string) {
|
||||||
return `\n$$\n${content}\n$$\n`;
|
return `\n$$\n${content}\n$$\n`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function iframeEmbed(turndownService: TurndownService) {
|
function iframeEmbed(turndownService: _TurndownService) {
|
||||||
turndownService.addRule('iframeEmbed', {
|
turndownService.addRule('iframeEmbed', {
|
||||||
filter: function (node: HTMLInputElement) {
|
filter: function (node: HTMLInputElement) {
|
||||||
return node.nodeName === 'IFRAME';
|
return node.nodeName === 'IFRAME';
|
||||||
},
|
},
|
||||||
replacement: function (content: any, node: HTMLInputElement) {
|
replacement: function (_content: string, node: HTMLInputElement) {
|
||||||
const src = node.getAttribute('src');
|
const src = node.getAttribute('src');
|
||||||
return '[' + src + '](' + src + ')';
|
return '[' + src + '](' + src + ')';
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function video(turndownService: TurndownService) {
|
function video(turndownService: _TurndownService) {
|
||||||
turndownService.addRule('video', {
|
turndownService.addRule('video', {
|
||||||
filter: function (node: HTMLInputElement) {
|
filter: function (node: HTMLInputElement) {
|
||||||
return node.tagName === 'VIDEO';
|
return node.tagName === 'VIDEO';
|
||||||
},
|
},
|
||||||
replacement: function (content: any, node: HTMLInputElement) {
|
replacement: function (_content: string, node: HTMLInputElement) {
|
||||||
const src = node.getAttribute('src') || '';
|
const src = node.getAttribute('src') || '';
|
||||||
const name = path.basename(src);
|
const name = src.split('/').pop() || src;
|
||||||
return '[' + name + '](' + src + ')';
|
return '[' + name + '](' + src + ')';
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Generated
+8
@@ -193,6 +193,9 @@ importers:
|
|||||||
'@types/bytes':
|
'@types/bytes':
|
||||||
specifier: ^3.1.5
|
specifier: ^3.1.5
|
||||||
version: 3.1.5
|
version: 3.1.5
|
||||||
|
'@types/turndown':
|
||||||
|
specifier: ^5.0.6
|
||||||
|
version: 5.0.6
|
||||||
'@types/uuid':
|
'@types/uuid':
|
||||||
specifier: ^10.0.0
|
specifier: ^10.0.0
|
||||||
version: 10.0.0
|
version: 10.0.0
|
||||||
@@ -4812,6 +4815,9 @@ packages:
|
|||||||
'@types/trusted-types@2.0.7':
|
'@types/trusted-types@2.0.7':
|
||||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
|
'@types/turndown@5.0.6':
|
||||||
|
resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==}
|
||||||
|
|
||||||
'@types/unist@2.0.11':
|
'@types/unist@2.0.11':
|
||||||
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
||||||
|
|
||||||
@@ -15274,6 +15280,8 @@ snapshots:
|
|||||||
'@types/trusted-types@2.0.7':
|
'@types/trusted-types@2.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@types/turndown@5.0.6': {}
|
||||||
|
|
||||||
'@types/unist@2.0.11': {}
|
'@types/unist@2.0.11': {}
|
||||||
|
|
||||||
'@types/unist@3.0.2': {}
|
'@types/unist@3.0.2': {}
|
||||||
|
|||||||
Reference in New Issue
Block a user