mirror of https://github.com/fantasticit/think.git
feat: export document
parent
b6c652baed
commit
20659d377c
|
@ -55,11 +55,14 @@
|
|||
"@tiptap/react": "^2.0.0-beta.107",
|
||||
"@tiptap/suggestion": "^2.0.0-beta.90",
|
||||
"axios": "^0.25.0",
|
||||
"buffer-image-size": "^0.6.4",
|
||||
"classnames": "^2.3.1",
|
||||
"clone": "^2.1.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"deep-equal": "^2.0.5",
|
||||
"docx": "^7.3.0",
|
||||
"dompurify": "^2.3.5",
|
||||
"downloadjs": "^1.4.7",
|
||||
"interactjs": "^1.10.11",
|
||||
"katex": "^0.15.2",
|
||||
"kity": "^2.0.4",
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { IconArticle, IconBranch, IconHistory, IconMore, IconPlus, IconStar } from '@douyinfe/semi-icons';
|
||||
import { IconArticle, IconBranch, IconExport, IconHistory, IconMore, IconPlus, IconStar } from '@douyinfe/semi-icons';
|
||||
import { Button, Dropdown, Space, Typography } from '@douyinfe/semi-ui';
|
||||
import { ButtonProps } from '@douyinfe/semi-ui/button/Button';
|
||||
import { IDocument } from '@think/domains';
|
||||
import cls from 'classnames';
|
||||
import { DocumentCreator } from 'components/document/create';
|
||||
import { DocumentDeletor } from 'components/document/delete';
|
||||
import { DocumentExporter } from 'components/document/export';
|
||||
import { DocumentLinkCopyer } from 'components/document/link';
|
||||
import { DocumentShare } from 'components/document/share';
|
||||
import { DocumentStar } from 'components/document/star';
|
||||
|
@ -17,6 +19,7 @@ import styles from './index.module.scss';
|
|||
interface IProps {
|
||||
wikiId: string;
|
||||
documentId: string;
|
||||
document?: IDocument;
|
||||
hoverVisible?: boolean;
|
||||
onStar?: () => void;
|
||||
onCreate?: () => void;
|
||||
|
@ -34,6 +37,7 @@ export const DocumentActions: React.FC<IProps> = ({
|
|||
wikiId,
|
||||
documentId,
|
||||
hoverVisible,
|
||||
document,
|
||||
onStar,
|
||||
onCreate,
|
||||
onDelete,
|
||||
|
@ -179,6 +183,24 @@ export const DocumentActions: React.FC<IProps> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{document && (
|
||||
<DocumentExporter
|
||||
document={document}
|
||||
render={({ toggleVisible }) => {
|
||||
return (
|
||||
<Dropdown.Item onClick={() => toggleVisible(true)}>
|
||||
<Text>
|
||||
<Space>
|
||||
<IconExport />
|
||||
文档导出
|
||||
</Space>
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dropdown.Divider />
|
||||
|
||||
<DocumentDeletor
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
.templateItem {
|
||||
display: flex;
|
||||
width: 118px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--border-radius);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--semi-color-border);
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,170 @@
|
|||
import { Badge, Button, Dropdown, Modal, Space, Typography } from '@douyinfe/semi-ui';
|
||||
import { IDocument } from '@think/domains';
|
||||
import { IconJSON, IconMarkdown, IconPDF, IconWord } from 'components/icons';
|
||||
import download from 'downloadjs';
|
||||
import { safeJSONParse, safeJSONStringify } from 'helpers/json';
|
||||
import { IsOnMobile } from 'hooks/use-on-mobile';
|
||||
import { useToggle } from 'hooks/use-toggle';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { createEditor } from 'tiptap/core';
|
||||
import { AllExtensions } from 'tiptap/core/all-kit';
|
||||
import { prosemirrorToDocx } from 'tiptap/docx';
|
||||
import { prosemirrorToMarkdown } from 'tiptap/markdown/prosemirror-to-markdown';
|
||||
|
||||
import styles from './index.module.scss';
|
||||
import { printEditorContent } from './pdf';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface IProps {
|
||||
document: IDocument;
|
||||
render?: (arg: { toggleVisible: (arg: boolean) => void }) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const DocumentExporter: React.FC<IProps> = ({ document, render }) => {
|
||||
const { isMobile } = IsOnMobile.useHook();
|
||||
const [visible, toggleVisible] = useToggle(false);
|
||||
|
||||
const editor = useMemo(() => {
|
||||
return createEditor({
|
||||
editable: false,
|
||||
extensions: AllExtensions,
|
||||
content: '',
|
||||
});
|
||||
}, []);
|
||||
|
||||
const exportMarkdown = useCallback(() => {
|
||||
const md = prosemirrorToMarkdown({ content: editor.state.doc.slice(0).content });
|
||||
download(md, `${document.title}.md`, 'text/plain');
|
||||
}, [document, editor]);
|
||||
|
||||
const exportJSON = useCallback(() => {
|
||||
download(safeJSONStringify(editor.getJSON()), `${document.title}.json`, 'text/plain');
|
||||
}, [document, editor]);
|
||||
|
||||
const exportWord = useCallback(() => {
|
||||
prosemirrorToDocx(editor.view, editor.state).then((buffer) => {
|
||||
download(buffer, `${document.title}.docx`);
|
||||
});
|
||||
}, [document, editor]);
|
||||
|
||||
const exportPDF = useCallback(() => {
|
||||
printEditorContent(editor.view);
|
||||
}, [editor]);
|
||||
|
||||
const content = useMemo(
|
||||
() => (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '96vw',
|
||||
overflow: 'auto',
|
||||
padding: '16px 0',
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<div className={styles.templateItem} onClick={exportMarkdown}>
|
||||
<header>
|
||||
<IconMarkdown style={{ fontSize: 40 }} />
|
||||
</header>
|
||||
<main>
|
||||
<Text>Markdown</Text>
|
||||
</main>
|
||||
<footer>
|
||||
<Text type="tertiary">.md</Text>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div className={styles.templateItem} onClick={exportJSON}>
|
||||
<header>
|
||||
<IconJSON style={{ fontSize: 40 }} />
|
||||
</header>
|
||||
<main>
|
||||
<Text>JSON</Text>
|
||||
</main>
|
||||
<footer>
|
||||
<Text type="tertiary">.json</Text>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div className={styles.templateItem} onClick={exportWord}>
|
||||
<header>
|
||||
<Badge count="beta" type="danger">
|
||||
<IconWord style={{ fontSize: 40 }} />
|
||||
</Badge>
|
||||
</header>
|
||||
<main>
|
||||
<Text>Word</Text>
|
||||
</main>
|
||||
<footer>
|
||||
<Text type="tertiary">.docx</Text>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div className={styles.templateItem} onClick={exportPDF}>
|
||||
<header>
|
||||
<Badge count="beta" type="danger">
|
||||
<IconPDF style={{ fontSize: 40 }} />
|
||||
</Badge>
|
||||
</header>
|
||||
<main>
|
||||
<Text>PDF</Text>
|
||||
</main>
|
||||
<footer>
|
||||
<Text type="tertiary">.pdf</Text>
|
||||
</footer>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
),
|
||||
[exportMarkdown, exportJSON, exportWord, exportPDF]
|
||||
);
|
||||
|
||||
const btn = useMemo(
|
||||
() =>
|
||||
render ? (
|
||||
render({ toggleVisible })
|
||||
) : (
|
||||
<Button type="primary" theme="light" onClick={toggleVisible}>
|
||||
导出
|
||||
</Button>
|
||||
),
|
||||
[render, toggleVisible]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const c = safeJSONParse(document && document.content);
|
||||
const json = c.default || c;
|
||||
editor.commands.setContent(json);
|
||||
}, [editor, document]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<Modal
|
||||
centered
|
||||
title="文档导出"
|
||||
visible={visible}
|
||||
footer={null}
|
||||
onCancel={toggleVisible}
|
||||
style={{ maxWidth: '96vw' }}
|
||||
zIndex={1061}
|
||||
>
|
||||
{content}
|
||||
</Modal>
|
||||
{btn}
|
||||
</>
|
||||
) : (
|
||||
<Dropdown
|
||||
visible={visible}
|
||||
onVisibleChange={toggleVisible}
|
||||
trigger="click"
|
||||
position="bottomRight"
|
||||
content={<div style={{ padding: '0 16px' }}>{content}</div>}
|
||||
>
|
||||
{btn}
|
||||
</Dropdown>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,57 @@
|
|||
import { EditorView } from 'prosemirror-view';
|
||||
|
||||
function printHtml(dom: Element) {
|
||||
const style: string = Array.from(document.querySelectorAll('style, link')).reduce(
|
||||
(str, style) => str + style.outerHTML,
|
||||
''
|
||||
);
|
||||
|
||||
const content: string = style + dom.outerHTML;
|
||||
|
||||
const iframe: HTMLIFrameElement = document.createElement('iframe');
|
||||
iframe.id = 'el-tiptap-iframe';
|
||||
iframe.setAttribute('style', 'position: absolute; width: 0; height: 0; top: -10px; left: -10px;');
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
const frameWindow = iframe.contentWindow;
|
||||
const doc = iframe.contentDocument || (iframe.contentWindow && iframe.contentWindow.document);
|
||||
|
||||
if (doc) {
|
||||
doc.open();
|
||||
doc.write(content);
|
||||
doc.close();
|
||||
}
|
||||
|
||||
if (frameWindow) {
|
||||
iframe.onload = function () {
|
||||
try {
|
||||
setTimeout(() => {
|
||||
frameWindow.focus();
|
||||
try {
|
||||
if (!frameWindow.document.execCommand('print', false)) {
|
||||
frameWindow.print();
|
||||
}
|
||||
} catch (e) {
|
||||
frameWindow.print();
|
||||
}
|
||||
frameWindow.close();
|
||||
}, 10);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
setTimeout(function () {
|
||||
document.body.removeChild(iframe);
|
||||
}, 100);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function printEditorContent(view: EditorView) {
|
||||
const editorContent = view.dom.closest('.ProseMirror');
|
||||
if (editorContent) {
|
||||
printHtml(editorContent);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
|
@ -74,7 +74,7 @@ export const DocumentReader: React.FC<IProps> = ({ documentId }) => {
|
|||
<Tooltip key="edit" content="编辑" position="bottom">
|
||||
<Button disabled={!editable} icon={<IconEdit />} onMouseDown={gotoEdit} />
|
||||
</Tooltip>
|
||||
{document && <DocumentActions wikiId={document.wikiId} documentId={documentId} />}
|
||||
{document && <DocumentActions wikiId={document.wikiId} documentId={documentId} document={document} />}
|
||||
<DocumentVersion documentId={documentId} />
|
||||
</Space>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
import { Icon } from '@douyinfe/semi-ui';
|
||||
|
||||
export const IconJSON: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => {
|
||||
return (
|
||||
<Icon
|
||||
style={style}
|
||||
svg={
|
||||
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<path
|
||||
d="M902.4 926.72c0 26.88-21.76 48.64-48.64 48.64H170.24c-26.88 0-48.64-21.76-48.64-48.64V48.64c0-26.88 21.76-48.64 48.64-48.64H588.8c12.8 0 25.6 5.12 34.56 14.08l263.68 263.68c8.96 8.96 14.08 21.76 14.08 34.56 1.28 0 1.28 614.4 1.28 614.4z"
|
||||
fill="#ee9254"
|
||||
p-id="13275"
|
||||
></path>
|
||||
<path
|
||||
d="M902.4 926.72v48.64c0 26.88-21.76 48.64-48.64 48.64H170.24c-26.88 0-48.64-21.76-48.64-48.64v-48.64c0 26.88 21.76 48.64 48.64 48.64h682.24c28.16 0 49.92-21.76 49.92-48.64z"
|
||||
fill="#C1C7D0"
|
||||
p-id="13276"
|
||||
></path>
|
||||
<path
|
||||
d="M24.32 536.32h975.36v243.2c0 26.88-21.76 48.64-48.64 48.64H72.96c-26.88 0-48.64-21.76-48.64-48.64v-243.2z"
|
||||
fill="#FFAB00"
|
||||
p-id="13277"
|
||||
></path>
|
||||
<path
|
||||
d="M121.6 536.32v-97.28l-97.28 97.28h97.28z m780.8 0l1.28-97.28 97.28 97.28h-98.56z"
|
||||
fill="#FF8B00"
|
||||
p-id="13278"
|
||||
></path>
|
||||
<path
|
||||
d="M902.4 312.32v7.68H637.44c-26.88 0-48.64-21.76-48.64-48.64V0c12.8 0 25.6 5.12 34.56 14.08l263.68 263.68c10.24 8.96 15.36 21.76 15.36 34.56z"
|
||||
fill="#f8b87c"
|
||||
p-id="13279"
|
||||
></path>
|
||||
<path
|
||||
d="M186.88 784.64c-29.44 0-47.36-12.8-60.16-30.72l21.76-21.76c11.52 14.08 21.76 21.76 38.4 21.76 17.92 0 29.44-11.52 29.44-37.12v-122.88h33.28v124.16c1.28 44.8-25.6 66.56-62.72 66.56zM368.64 672c39.68 10.24 60.16 24.32 60.16 55.04 0 35.84-28.16 56.32-66.56 56.32-28.16 0-56.32-10.24-78.08-30.72l20.48-23.04c17.92 15.36 35.84 24.32 58.88 24.32 20.48 0 33.28-8.96 33.28-24.32 0-14.08-7.68-20.48-42.24-29.44-39.68-10.24-62.72-21.76-62.72-56.32 0-33.28 26.88-55.04 64-55.04 26.88 0 48.64 8.96 67.84 23.04l-17.92 25.6c-16.64-12.8-33.28-19.2-51.2-19.2-19.2 0-30.72 10.24-30.72 23.04 0 16.64 8.96 23.04 44.8 30.72zM558.08 784.64c-57.6 0-98.56-43.52-98.56-97.28s40.96-97.28 98.56-97.28 98.56 43.52 98.56 97.28-40.96 97.28-98.56 97.28z m0-163.84c-37.12 0-64 29.44-64 66.56 0 37.12 26.88 66.56 64 66.56s64-29.44 64-66.56c0-35.84-26.88-66.56-64-66.56zM828.16 593.92h32v188.16H832l-102.4-134.4v134.4h-32V593.92h30.72l101.12 130.56V593.92z"
|
||||
fill="#FFFFFF"
|
||||
p-id="13280"
|
||||
></path>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,30 @@
|
|||
import { Icon } from '@douyinfe/semi-ui';
|
||||
|
||||
export const IconMarkdown: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => {
|
||||
return (
|
||||
<Icon
|
||||
style={style}
|
||||
svg={
|
||||
<svg
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="29352"
|
||||
width="1em"
|
||||
height="1em"
|
||||
>
|
||||
<path
|
||||
d="M970.24 431.104h-53.76V215.552L700.416 0H215.552a107.52 107.52 0 0 0-107.52 108.032v323.072H53.76A53.76 53.76 0 0 0 0 484.864v323.584a53.76 53.76 0 0 0 53.76 53.76h54.272v53.76A108.032 108.032 0 0 0 215.552 1024h592.896a108.032 108.032 0 0 0 107.52-108.032v-53.76h53.76a53.76 53.76 0 0 0 54.272-53.76V484.864a53.76 53.76 0 0 0-53.76-53.76zM161.792 102.4a53.76 53.76 0 0 1 53.76-55.808h431.104V153.6a108.032 108.032 0 0 0 108.032 108.032H862.72v162.304H161.792V102.4z m700.416 806.4a53.76 53.76 0 0 1-53.76 53.76H215.552a53.76 53.76 0 0 1-53.76-53.76v-53.76h700.416v53.76z"
|
||||
fill="#1bb668"
|
||||
p-id="29353"
|
||||
></path>
|
||||
<path
|
||||
d="M157 786.615V495.333h72.82l109.231 109.231 109.231-109.23h72.82v291.281h-72.82v-188.24l-109.23 109.23-109.231-109.23v188.24H157m509.744-291.282h109.23v145.641H867L721.359 804.821 575.718 640.974h91.026v-145.64z"
|
||||
fill="#FFFFFF"
|
||||
p-id="29354"
|
||||
></path>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,37 @@
|
|||
import { Icon } from '@douyinfe/semi-ui';
|
||||
|
||||
export const IconPDF: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => {
|
||||
return (
|
||||
<Icon
|
||||
style={style}
|
||||
svg={
|
||||
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<path
|
||||
d="M901.850593 926.476283a48.761858 48.761858 0 0 1-48.761859 48.761859H170.422718a48.761858 48.761858 0 0 1-48.761858-48.761859V48.762834a48.761858 48.761858 0 0 1 48.761858-48.761859h418.864363a48.761858 48.761858 0 0 1 34.620919 14.140939l263.801654 263.801654a48.761858 48.761858 0 0 1 14.140939 34.620919V926.476283z"
|
||||
fill="#EBECF0"
|
||||
></path>
|
||||
<path
|
||||
d="M901.850593 926.476283v48.761859a48.761858 48.761858 0 0 1-48.761859 48.761858H170.422718a48.761858 48.761858 0 0 1-48.761858-48.761858v-48.761859a48.761858 48.761858 0 0 0 48.761858 48.761859h682.666016a48.761858 48.761858 0 0 0 48.761859-48.761859z"
|
||||
fill="#C1C7D0"
|
||||
></path>
|
||||
<path
|
||||
d="M24.137143 536.381417h975.237166v243.809291a48.761858 48.761858 0 0 1-48.761858 48.761859H72.899001a48.761858 48.761858 0 0 1-48.761858-48.761859v-243.809291z"
|
||||
fill="#FF5630"
|
||||
></path>
|
||||
<path
|
||||
d="M121.66086 536.381417V438.8577l-97.523717 97.523717h97.523717zM901.850593 536.381417l0.975237-97.523717 97.036098 97.523717H901.850593z"
|
||||
fill="#DE350B"
|
||||
></path>
|
||||
<path
|
||||
d="M267.946434 585.143275h84.845634a57.051374 57.051374 0 0 1 41.935198 15.603795 55.1009 55.1009 0 0 1 16.091413 40.959961 55.588518 55.588518 0 0 1-16.091413 40.959961 59.001849 59.001849 0 0 1-43.398054 16.091413h-48.761858v76.556118H267.946434z m32.670446 81.919922h43.885672a42.422817 42.422817 0 0 0 25.843785-6.339041 23.893311 23.893311 0 0 0 7.801897-19.992362q0-24.868548-32.670445-24.868548h-44.860909zM434.71199 588.068987H511.755726a73.142787 73.142787 0 0 1 58.51423 25.356166 100.937047 100.937047 0 0 1 21.942836 68.266602 110.689418 110.689418 0 0 1-20.967599 69.729457A71.679932 71.679932 0 0 1 511.755726 780.190708H434.71199z m32.670445 158.963658H511.755726a43.398054 43.398054 0 0 0 36.083775-17.066651A75.093262 75.093262 0 0 0 560.517584 682.666992a70.704695 70.704695 0 0 0-13.65332-48.761859 48.761858 48.761858 0 0 0-37.546631-16.579031h-41.935198zM755.565018 618.788957h-100.937047v45.348529H755.565018v31.207589h-100.937047v81.919922h-32.670445v-190.171248H755.565018z"
|
||||
fill="#FFFFFF"
|
||||
></path>
|
||||
<path
|
||||
d="M901.850593 312.564487v6.82666h-263.801654a48.761858 48.761858 0 0 1-48.761858-48.761858V0.000975a48.761858 48.761858 0 0 1 34.620919 14.140939l264.289272 263.801654a48.761858 48.761858 0 0 1 13.653321 34.620919z"
|
||||
fill="#C1C7D0"
|
||||
></path>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,37 @@
|
|||
import { Icon } from '@douyinfe/semi-ui';
|
||||
|
||||
export const IconWord: React.FC<{ style?: React.CSSProperties }> = ({ style = {} }) => {
|
||||
return (
|
||||
<Icon
|
||||
style={style}
|
||||
svg={
|
||||
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">
|
||||
<path
|
||||
d="M901.08928 925.3888a48.13824 48.13824 0 0 1-14.24896 34.38592 48.77312 48.77312 0 0 1-34.38592 14.24896H171.07456a48.13824 48.13824 0 0 1-34.3808-14.24896 48.74752 48.74752 0 0 1-14.24896-34.38592V49.42336a48.18944 48.18944 0 0 1 14.24896-34.40128A48.74752 48.74752 0 0 1 171.07456 0.77824h417.9968a48.5888 48.5888 0 0 1 34.59072 14.09024l263.3472 263.33184a48.68096 48.68096 0 0 1 14.08 34.59072z"
|
||||
fill="#EBECF0"
|
||||
></path>
|
||||
<path
|
||||
d="M901.08928 925.3888v48.68608a48.18944 48.18944 0 0 1-14.24896 34.39616 48.77312 48.77312 0 0 1-34.38592 14.24896H171.07456a48.70656 48.70656 0 0 1-48.68096-48.69632v-48.63488a48.13824 48.13824 0 0 0 14.24384 34.38592 48.74752 48.74752 0 0 0 34.38592 14.24896h681.32864a48.81408 48.81408 0 0 0 48.68608-48.68608z"
|
||||
fill="#C1C7D0"
|
||||
></path>
|
||||
<path
|
||||
d="M25.11872 536.09472h973.24544v243.33824a48.81408 48.81408 0 0 1-48.61952 48.68096H73.81504a48.18944 48.18944 0 0 1-34.39616-14.24384 48.74752 48.74752 0 0 1-14.24896-34.38592z"
|
||||
fill="#317BFF"
|
||||
></path>
|
||||
<path
|
||||
d="M122.496 536.08448V438.71744L25.11872 536.08448z m778.59328 0l0.93184-97.36704 96.86016 97.36704z"
|
||||
fill="#234AE8"
|
||||
></path>
|
||||
<path
|
||||
d="M901.08928 312.73984v6.84032h-263.3216a48.6912 48.6912 0 0 1-48.6912-48.68096V0.72704a48.5888 48.5888 0 0 1 34.5856 14.09024l263.76192 263.22944a49.3312 49.3312 0 0 1 13.66528 34.69312z"
|
||||
fill="#C1C7D0"
|
||||
></path>
|
||||
<path
|
||||
d="M259.67616 606.58176l19.7888 102.54848 18.8928-102.5536h52.1728l-40.47872 165.52448H256.0768l-18.8928-96.25088h-0.90112l-18.88768 96.25088H164.32128l-41.37984-165.51936h53.07392l17.99168 102.54848h0.90112l19.7888-102.5536zM447.68256 602.0864q81.8432 3.60448 87.25504 86.35392-3.6096 82.77504-87.25504 86.35904-83.6608-2.69824-86.35904-87.26016 4.49024-80.95232 86.35904-85.4528z m0 40.47872q-30.59712 0.90624-32.384 45.8752 1.792 46.78656 32.384 47.67744 32.384-0.89088 33.28-47.67744-1.81248-45.8752-33.28-45.8752zM628.49024 714.53184h-16.18944V772.096h-53.96992v-165.51936h78.2592q76.4416-1.792 72.8576 46.77632-0.896 32.38912-27.88864 40.48384 30.5664 6.2976 28.78976 44.07296v7.19872q-0.90112 24.28416 5.4016 21.59104v5.39648h-54.88128q-2.68288-5.4016-2.688-28.78464 3.584-31.47776-29.69088-28.78464z m-16.18944-69.26848v32.384h21.59104q26.9824 0.90624 26.07616-15.29344-0.896-16.18944-22.4768-17.09056zM809.29792 772.096h-71.0656v-165.51424h72.86784q85.4528 0.90624 86.35904 79.16544 0 86.35392-88.16128 86.35392z m-17.08544-126.83264v88.15616h11.68896q38.66624 0 38.67648-44.9792 2.70336-46.76096-39.5776-43.17696z"
|
||||
fill="#FFFFFF"
|
||||
></path>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -26,9 +26,11 @@ export * from './IconHeading3';
|
|||
export * from './IconHorizontalRule';
|
||||
export * from './IconImage';
|
||||
export * from './IconInfo';
|
||||
export * from './IconJSON';
|
||||
export * from './IconLeft';
|
||||
export * from './IconLink';
|
||||
export * from './IconList';
|
||||
export * from './IconMarkdown';
|
||||
export * from './IconMath';
|
||||
export * from './IconMergeCell';
|
||||
export * from './IconMessage';
|
||||
|
@ -40,6 +42,7 @@ export * from './IconMindRight';
|
|||
export * from './IconMindSide';
|
||||
export * from './IconOrderedList';
|
||||
export * from './IconOverview';
|
||||
export * from './IconPDF';
|
||||
export * from './IconQuote';
|
||||
export * from './IconRight';
|
||||
export * from './IconSearch';
|
||||
|
@ -57,5 +60,6 @@ export * from './IconTableHeaderColumn';
|
|||
export * from './IconTableHeaderRow';
|
||||
export * from './IconTableOfContents';
|
||||
export * from './IconTask';
|
||||
export * from './IconWord';
|
||||
export * from './IconZoomIn';
|
||||
export * from './IconZoomOut';
|
||||
|
|
|
@ -0,0 +1,149 @@
|
|||
import axios from 'axios';
|
||||
import { HeadingLevel } from 'docx';
|
||||
import { EditorState } from 'prosemirror-state';
|
||||
import { EditorView } from 'prosemirror-view';
|
||||
import { Attachment } from 'tiptap/core/extensions/attachment';
|
||||
import { BulletList } from 'tiptap/core/extensions/bullet-list';
|
||||
import { Callout } from 'tiptap/core/extensions/callout';
|
||||
import { CodeBlock } from 'tiptap/core/extensions/code-block';
|
||||
import { DocumentChildren } from 'tiptap/core/extensions/document-children';
|
||||
import { DocumentReference } from 'tiptap/core/extensions/document-reference';
|
||||
import { Flow } from 'tiptap/core/extensions/flow';
|
||||
import { HardBreak } from 'tiptap/core/extensions/hard-break';
|
||||
import { HorizontalRule } from 'tiptap/core/extensions/horizontal-rule';
|
||||
import { Iframe } from 'tiptap/core/extensions/iframe';
|
||||
import { Katex } from 'tiptap/core/extensions/katex';
|
||||
import { ListItem } from 'tiptap/core/extensions/listItem';
|
||||
import { Mind } from 'tiptap/core/extensions/mind';
|
||||
import { OrderedList } from 'tiptap/core/extensions/ordered-list';
|
||||
import { Status } from 'tiptap/core/extensions/status';
|
||||
import { TableOfContents } from 'tiptap/core/extensions/table-of-contents';
|
||||
import { TaskItem } from 'tiptap/core/extensions/task-item';
|
||||
import { TaskList } from 'tiptap/core/extensions/task-list';
|
||||
import { Title } from 'tiptap/core/extensions/title';
|
||||
|
||||
import { defaultMarks, defaultNodes, DocxSerializer, writeDocx } from './prosemirror-docx';
|
||||
|
||||
function getLatexFromNode(node): string {
|
||||
return node.attrs.text;
|
||||
}
|
||||
|
||||
const nodeSerializer = {
|
||||
...defaultNodes,
|
||||
[Title.name](state, node) {
|
||||
state.renderInline(node);
|
||||
state.closeBlock(node, { heading: HeadingLevel.TITLE });
|
||||
},
|
||||
[DocumentChildren.name](state, node) {
|
||||
state.renderInline(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
[DocumentReference.name](state, node) {
|
||||
state.renderInline(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
[TableOfContents.name](state, node) {
|
||||
state.renderInline(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
[BulletList.name](state, node) {
|
||||
state.renderList(node, 'bullets');
|
||||
},
|
||||
[OrderedList.name](state, node) {
|
||||
state.renderList(node, 'numbered');
|
||||
},
|
||||
[ListItem.name](state, node) {
|
||||
state.renderListItem(node);
|
||||
},
|
||||
[HorizontalRule.name](state, node) {
|
||||
state.closeBlock(node, { thematicBreak: true });
|
||||
state.closeBlock(node);
|
||||
},
|
||||
[TaskList.name](state, node) {
|
||||
state.renderInline(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
[TaskItem.name](state, node) {
|
||||
state.renderInline(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
[CodeBlock.name](state, node) {
|
||||
state.renderInline(node.content?.content ?? '');
|
||||
state.closeBlock(node);
|
||||
},
|
||||
[Status.name](state, node) {
|
||||
state.text(node.attrs.text ?? '');
|
||||
state.closeBlock(node);
|
||||
},
|
||||
[Flow.name](state, node) {
|
||||
state.renderContent(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
[Mind.name](state, node) {
|
||||
state.renderContent(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
[HardBreak.name](state, node) {
|
||||
state.addRunOptions({ break: 1 });
|
||||
},
|
||||
[Katex.name](state, node) {
|
||||
state.math(getLatexFromNode(node), { inline: false });
|
||||
state.closeBlock(node);
|
||||
},
|
||||
[Iframe.name](state, node) {
|
||||
state.renderContent(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
[Attachment.name](state, node) {
|
||||
state.renderContent(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
[Callout.name](state, node) {
|
||||
state.renderContent(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
};
|
||||
|
||||
const docxSerializer = new DocxSerializer(nodeSerializer, defaultMarks);
|
||||
|
||||
async function getImageBuffer(src: string) {
|
||||
const image = await axios
|
||||
.get(src, {
|
||||
responseType: 'arraybuffer',
|
||||
})
|
||||
.catch(() => {
|
||||
return { data: '' };
|
||||
});
|
||||
return Buffer.from(image.data);
|
||||
}
|
||||
|
||||
export const prosemirrorToDocx = async (view: EditorView, state: EditorState): Promise<Blob> => {
|
||||
const dom = view.dom.closest('.ProseMirror');
|
||||
const imageBufferCache = new Map();
|
||||
const images = Array.from(await dom.querySelectorAll('img')) as HTMLImageElement[];
|
||||
|
||||
await Promise.all(
|
||||
images.map(async (img) => {
|
||||
try {
|
||||
const buffer = await getImageBuffer(img.src);
|
||||
imageBufferCache.set(img.src, buffer);
|
||||
} catch (e) {
|
||||
imageBufferCache.set(img.src, Buffer.from('图片加载失败'));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const wordDocument = docxSerializer.serialize(state.doc, {
|
||||
getImageBuffer(src) {
|
||||
return imageBufferCache.get(src);
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise(async (resolve) => {
|
||||
await writeDocx(wordDocument, (buffer) => {
|
||||
imageBufferCache.clear();
|
||||
resolve(new Blob([buffer]));
|
||||
});
|
||||
});
|
||||
};
|
|
@ -0,0 +1,4 @@
|
|||
export { defaultDocxSerializer, defaultMarks, defaultNodes } from './schema';
|
||||
export type { MarkSerializer, NodeSerializer } from './serializer';
|
||||
export { DocxSerializer, DocxSerializerState } from './serializer';
|
||||
export { createDocFromState, writeDocx } from './utils';
|
|
@ -0,0 +1,48 @@
|
|||
import { AlignmentType, convertInchesToTwip, ILevelsOptions, LevelFormat } from 'docx';
|
||||
|
||||
import { INumbering } from './types';
|
||||
|
||||
function basicIndentStyle(indent: number): Pick<ILevelsOptions, 'style' | 'alignment'> {
|
||||
return {
|
||||
alignment: AlignmentType.START,
|
||||
style: {
|
||||
paragraph: {
|
||||
indent: { left: convertInchesToTwip(indent), hanging: convertInchesToTwip(0.18) },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const numbered = Array(3)
|
||||
.fill([LevelFormat.DECIMAL, LevelFormat.LOWER_LETTER, LevelFormat.LOWER_ROMAN])
|
||||
.flat()
|
||||
.map((format, level) => ({
|
||||
level,
|
||||
format,
|
||||
text: `%${level + 1}.`,
|
||||
...basicIndentStyle((level + 1) / 2),
|
||||
}));
|
||||
|
||||
const bullets = Array(3)
|
||||
.fill(['●', '○', '■'])
|
||||
.flat()
|
||||
.map((text, level) => ({
|
||||
level,
|
||||
format: LevelFormat.BULLET,
|
||||
text,
|
||||
...basicIndentStyle((level + 1) / 2),
|
||||
}));
|
||||
|
||||
const styles = {
|
||||
numbered,
|
||||
bullets,
|
||||
};
|
||||
|
||||
export type NumberingStyles = keyof typeof styles;
|
||||
|
||||
export function createNumbering(reference: string, style: NumberingStyles): INumbering {
|
||||
return {
|
||||
reference,
|
||||
levels: styles[style],
|
||||
};
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
import { HeadingLevel, ShadingType } from 'docx';
|
||||
|
||||
import { DocxSerializer, MarkSerializer, NodeSerializer } from './serializer';
|
||||
import { getLatexFromNode } from './utils';
|
||||
|
||||
export const defaultNodes: NodeSerializer = {
|
||||
text(state, node) {
|
||||
state.text(node.text ?? '');
|
||||
},
|
||||
paragraph(state, node) {
|
||||
state.renderInline(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
heading(state, node) {
|
||||
state.renderInline(node);
|
||||
const heading = [
|
||||
HeadingLevel.HEADING_1,
|
||||
HeadingLevel.HEADING_2,
|
||||
HeadingLevel.HEADING_3,
|
||||
HeadingLevel.HEADING_4,
|
||||
HeadingLevel.HEADING_5,
|
||||
HeadingLevel.HEADING_6,
|
||||
][node.attrs.level - 1];
|
||||
state.closeBlock(node, { heading });
|
||||
},
|
||||
blockquote(state, node) {
|
||||
state.renderContent(node, { style: 'IntenseQuote' });
|
||||
},
|
||||
code_block(state, node) {
|
||||
// TODO: something for code
|
||||
state.renderContent(node);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
horizontal_rule(state, node) {
|
||||
// Kinda hacky, but this works to insert two paragraphs, the first with a break
|
||||
state.closeBlock(node, { thematicBreak: true });
|
||||
state.closeBlock(node);
|
||||
},
|
||||
hard_break(state) {
|
||||
state.addRunOptions({ break: 1 });
|
||||
},
|
||||
ordered_list(state, node) {
|
||||
state.renderList(node, 'numbered');
|
||||
},
|
||||
bullet_list(state, node) {
|
||||
state.renderList(node, 'bullets');
|
||||
},
|
||||
list_item(state, node) {
|
||||
state.renderListItem(node);
|
||||
},
|
||||
// Presentational
|
||||
image(state, node) {
|
||||
const { src } = node.attrs;
|
||||
state.image(src);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
// Technical
|
||||
math(state, node) {
|
||||
state.math(getLatexFromNode(node), { inline: true });
|
||||
},
|
||||
equation(state, node) {
|
||||
const { id, numbered } = node.attrs;
|
||||
state.math(getLatexFromNode(node), { inline: false, numbered, id });
|
||||
state.closeBlock(node);
|
||||
},
|
||||
table(state, node) {
|
||||
state.table(node);
|
||||
},
|
||||
};
|
||||
|
||||
export const defaultMarks: MarkSerializer = {
|
||||
em() {
|
||||
return { italics: true };
|
||||
},
|
||||
strong() {
|
||||
return { bold: true };
|
||||
},
|
||||
link() {
|
||||
// Note, this is handled specifically in the serializer
|
||||
// Word treats links more like a Node rather than a mark
|
||||
return {};
|
||||
},
|
||||
code() {
|
||||
return {
|
||||
font: {
|
||||
name: 'Monospace',
|
||||
},
|
||||
color: '000000',
|
||||
shading: {
|
||||
type: ShadingType.SOLID,
|
||||
color: 'D2D3D2',
|
||||
fill: 'D2D3D2',
|
||||
},
|
||||
};
|
||||
},
|
||||
abbr() {
|
||||
// TODO: abbreviation
|
||||
return {};
|
||||
},
|
||||
subscript() {
|
||||
return { subScript: true };
|
||||
},
|
||||
superscript() {
|
||||
return { superScript: true };
|
||||
},
|
||||
strikethrough() {
|
||||
// doubleStrike!
|
||||
return { strike: true };
|
||||
},
|
||||
underline() {
|
||||
return {
|
||||
underline: {},
|
||||
};
|
||||
},
|
||||
smallcaps() {
|
||||
return { smallCaps: true };
|
||||
},
|
||||
allcaps() {
|
||||
return { allCaps: true };
|
||||
},
|
||||
};
|
||||
|
||||
export const defaultDocxSerializer = new DocxSerializer(defaultNodes, defaultMarks);
|
|
@ -0,0 +1,376 @@
|
|||
import sizeOf from 'buffer-image-size';
|
||||
import {
|
||||
AlignmentType,
|
||||
Bookmark,
|
||||
ExternalHyperlink,
|
||||
FootnoteReferenceRun,
|
||||
ImageRun,
|
||||
InternalHyperlink,
|
||||
IParagraphOptions,
|
||||
IRunOptions,
|
||||
ITableCellOptions,
|
||||
Math,
|
||||
MathRun,
|
||||
Paragraph,
|
||||
ParagraphChild,
|
||||
SequentialIdentifier,
|
||||
SimpleField,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TabStopPosition,
|
||||
TabStopType,
|
||||
TextRun,
|
||||
WidthType,
|
||||
} from 'docx';
|
||||
import { Mark, Node as ProsemirrorNode, Schema } from 'prosemirror-model';
|
||||
|
||||
import { createNumbering, NumberingStyles } from './numbering';
|
||||
import { IFootnotes, INumbering, Mutable } from './types';
|
||||
import { createDocFromState, createShortId } from './utils';
|
||||
|
||||
// This is duplicated from @curvenote/schema
|
||||
export type AlignOptions = 'left' | 'center' | 'right';
|
||||
|
||||
export type NodeSerializer<S extends Schema = any> = Record<
|
||||
string,
|
||||
(state: DocxSerializerState<S>, node: ProsemirrorNode<S>, parent: ProsemirrorNode<S>, index: number) => void
|
||||
>;
|
||||
|
||||
export type MarkSerializer<S extends Schema = any> = Record<
|
||||
string,
|
||||
(state: DocxSerializerState<S>, node: ProsemirrorNode<S>, mark: Mark<S>) => IRunOptions
|
||||
>;
|
||||
|
||||
export type Options = {
|
||||
getImageBuffer: (src: string) => Buffer;
|
||||
};
|
||||
|
||||
export type IMathOpts = {
|
||||
inline?: boolean;
|
||||
id?: string | null;
|
||||
numbered?: boolean;
|
||||
};
|
||||
|
||||
const MAX_IMAGE_WIDTH = 600;
|
||||
|
||||
function createReferenceBookmark(id: string, kind: 'Equation' | 'Figure' | 'Table', before?: string, after?: string) {
|
||||
const textBefore = before ? [new TextRun(before)] : [];
|
||||
const textAfter = after ? [new TextRun(after)] : [];
|
||||
return new Bookmark({
|
||||
id,
|
||||
children: [...textBefore, new SequentialIdentifier(kind), ...textAfter],
|
||||
});
|
||||
}
|
||||
|
||||
export class DocxSerializerState<S extends Schema = any> {
|
||||
nodes: NodeSerializer<S>;
|
||||
|
||||
options: Options;
|
||||
|
||||
marks: MarkSerializer<S>;
|
||||
|
||||
children: (Paragraph | Table)[];
|
||||
|
||||
numbering: INumbering[];
|
||||
|
||||
footnotes: IFootnotes = {};
|
||||
|
||||
nextRunOpts?: IRunOptions;
|
||||
|
||||
current: ParagraphChild[] = [];
|
||||
|
||||
currentLink?: { link: string; children: IRunOptions[] };
|
||||
|
||||
// Optionally add options
|
||||
nextParentParagraphOpts?: IParagraphOptions;
|
||||
|
||||
currentNumbering?: { reference: string; level: number };
|
||||
|
||||
constructor(nodes: NodeSerializer<S>, marks: MarkSerializer<S>, options: Options) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
// @ts-ignore
|
||||
this.options = options ?? {};
|
||||
this.children = [];
|
||||
this.numbering = [];
|
||||
}
|
||||
|
||||
renderContent(parent: ProsemirrorNode<S>, opts?: IParagraphOptions) {
|
||||
parent.forEach((node, _, i) => {
|
||||
if (opts) this.addParagraphOptions(opts);
|
||||
this.render(node, parent, i);
|
||||
});
|
||||
}
|
||||
|
||||
render(node: ProsemirrorNode<S>, parent: ProsemirrorNode<S>, index: number) {
|
||||
if (typeof parent === 'number') throw new Error('!');
|
||||
if (!this.nodes[node.type.name]) throw new Error(`Token type \`${node.type.name}\` not supported by Word renderer`);
|
||||
this.nodes[node.type.name](this, node, parent, index);
|
||||
}
|
||||
|
||||
renderMarks(node: ProsemirrorNode<S>, marks: Mark[]): IRunOptions {
|
||||
return marks
|
||||
.map((mark) => {
|
||||
return this.marks[mark.type.name]?.(this, node, mark);
|
||||
})
|
||||
.reduce((a, b) => ({ ...a, ...b }), {});
|
||||
}
|
||||
|
||||
renderInline(parent: ProsemirrorNode<S>) {
|
||||
// Pop the stack over to this object when we encounter a link, and closeLink restores it
|
||||
let currentLink: { link: string; stack: ParagraphChild[] } | undefined;
|
||||
const closeLink = () => {
|
||||
if (!currentLink) return;
|
||||
const hyperlink = new ExternalHyperlink({
|
||||
link: currentLink.link,
|
||||
// child: this.current[0],
|
||||
children: this.current,
|
||||
});
|
||||
this.current = [...currentLink.stack, hyperlink];
|
||||
currentLink = undefined;
|
||||
};
|
||||
const openLink = (href: string) => {
|
||||
const sameLink = href === currentLink?.link;
|
||||
this.addRunOptions({ style: 'Hyperlink' });
|
||||
// TODO: https://github.com/dolanmiu/docx/issues/1119
|
||||
// Remove the if statement here and oneLink!
|
||||
const oneLink = true;
|
||||
if (!oneLink) {
|
||||
closeLink();
|
||||
} else {
|
||||
if (currentLink && sameLink) return;
|
||||
if (currentLink && !sameLink) {
|
||||
// Close previous, and open a new one
|
||||
closeLink();
|
||||
}
|
||||
}
|
||||
currentLink = {
|
||||
link: href,
|
||||
stack: this.current,
|
||||
};
|
||||
this.current = [];
|
||||
};
|
||||
const progress = (node: ProsemirrorNode<S>, offset: number, index: number) => {
|
||||
const links = node.marks.filter((m) => m.type.name === 'link');
|
||||
const hasLink = links.length > 0;
|
||||
if (hasLink) {
|
||||
openLink(links[0].attrs.href);
|
||||
} else if (!hasLink && currentLink) {
|
||||
closeLink();
|
||||
}
|
||||
if (node.isText) {
|
||||
this.text(node.text, this.renderMarks(node, node.marks));
|
||||
} else {
|
||||
this.render(node, parent, index);
|
||||
}
|
||||
};
|
||||
parent.forEach(progress);
|
||||
// Must call close at the end of everything, just in case
|
||||
closeLink();
|
||||
}
|
||||
|
||||
renderList(node: ProsemirrorNode<S>, style: NumberingStyles) {
|
||||
if (!this.currentNumbering) {
|
||||
const nextId = createShortId();
|
||||
this.numbering.push(createNumbering(nextId, style));
|
||||
this.currentNumbering = { reference: nextId, level: 0 };
|
||||
} else {
|
||||
const { reference, level } = this.currentNumbering;
|
||||
this.currentNumbering = { reference, level: level + 1 };
|
||||
}
|
||||
this.renderContent(node);
|
||||
if (this.currentNumbering.level === 0) {
|
||||
delete this.currentNumbering;
|
||||
} else {
|
||||
const { reference, level } = this.currentNumbering;
|
||||
this.currentNumbering = { reference, level: level - 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// This is a pass through to the paragraphs, etc. underneath they will close the block
|
||||
renderListItem(node: ProsemirrorNode<S>) {
|
||||
if (!this.currentNumbering) throw new Error('Trying to create a list item without a list?');
|
||||
this.addParagraphOptions({ numbering: this.currentNumbering });
|
||||
this.renderContent(node);
|
||||
}
|
||||
|
||||
addParagraphOptions(opts: IParagraphOptions) {
|
||||
this.nextParentParagraphOpts = { ...this.nextParentParagraphOpts, ...opts };
|
||||
}
|
||||
|
||||
addRunOptions(opts: IRunOptions) {
|
||||
this.nextRunOpts = { ...this.nextRunOpts, ...opts };
|
||||
}
|
||||
|
||||
text(text: string | null | undefined, opts?: IRunOptions) {
|
||||
if (!text) return;
|
||||
this.current.push(new TextRun({ text, ...this.nextRunOpts, ...opts }));
|
||||
delete this.nextRunOpts;
|
||||
}
|
||||
|
||||
math(latex: string, opts: IMathOpts = { inline: true }) {
|
||||
if (opts.inline || !opts.numbered) {
|
||||
this.current.push(new Math({ children: [new MathRun(latex)] }));
|
||||
return;
|
||||
}
|
||||
const id = opts.id ?? createShortId();
|
||||
this.current = [
|
||||
new TextRun('\t'),
|
||||
new Math({
|
||||
children: [new MathRun(latex)],
|
||||
}),
|
||||
new TextRun('\t('),
|
||||
createReferenceBookmark(id, 'Equation'),
|
||||
new TextRun(')'),
|
||||
];
|
||||
this.addParagraphOptions({
|
||||
tabStops: [
|
||||
{
|
||||
type: TabStopType.CENTER,
|
||||
position: TabStopPosition.MAX / 2,
|
||||
},
|
||||
{
|
||||
type: TabStopType.RIGHT,
|
||||
position: TabStopPosition.MAX,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// not sure what this actually is, seems to be close for 8.5x11
|
||||
maxImageWidth = MAX_IMAGE_WIDTH;
|
||||
|
||||
image(src: string, widthPercent = 70, align: AlignOptions = 'center') {
|
||||
const buffer = this.options.getImageBuffer(src);
|
||||
|
||||
if (!buffer) return;
|
||||
|
||||
const dimensions = sizeOf(buffer);
|
||||
const aspect = dimensions.height / dimensions.width;
|
||||
const width = this.maxImageWidth * (widthPercent / 100);
|
||||
this.current.push(
|
||||
new ImageRun({
|
||||
data: buffer,
|
||||
transformation: {
|
||||
width,
|
||||
height: width * aspect,
|
||||
},
|
||||
})
|
||||
);
|
||||
let alignment: AlignmentType;
|
||||
switch (align) {
|
||||
case 'right':
|
||||
alignment = AlignmentType.RIGHT;
|
||||
break;
|
||||
case 'left':
|
||||
alignment = AlignmentType.LEFT;
|
||||
break;
|
||||
default:
|
||||
alignment = AlignmentType.CENTER;
|
||||
}
|
||||
this.addParagraphOptions({
|
||||
alignment,
|
||||
});
|
||||
}
|
||||
|
||||
table(node: ProsemirrorNode<S>) {
|
||||
const actualChildren = this.children;
|
||||
const rows: TableRow[] = [];
|
||||
node.content.forEach(({ content: rowContent }) => {
|
||||
const cells: TableCell[] = [];
|
||||
// Check if all cells are headers in this row
|
||||
let tableHeader = true;
|
||||
rowContent.forEach((cell) => {
|
||||
if (cell.type.name !== 'table_header') {
|
||||
tableHeader = false;
|
||||
}
|
||||
});
|
||||
// This scales images inside of tables
|
||||
this.maxImageWidth = MAX_IMAGE_WIDTH / rowContent.childCount;
|
||||
rowContent.forEach((cell) => {
|
||||
this.children = [];
|
||||
this.renderContent(cell);
|
||||
const tableCellOpts: Mutable<ITableCellOptions> = {
|
||||
children: this.children,
|
||||
};
|
||||
const colspan = cell.attrs.colspan ?? 1;
|
||||
const rowspan = cell.attrs.rowspan ?? 1;
|
||||
if (colspan > 1) tableCellOpts.columnSpan = colspan;
|
||||
if (rowspan > 1) tableCellOpts.rowSpan = rowspan;
|
||||
cells.push(new TableCell(tableCellOpts));
|
||||
});
|
||||
rows.push(new TableRow({ children: cells, tableHeader }));
|
||||
});
|
||||
this.maxImageWidth = MAX_IMAGE_WIDTH;
|
||||
|
||||
const table = new Table({
|
||||
rows,
|
||||
// columnWidths: Array.from({ length: rows[0].cells.length }, () => 3505),
|
||||
});
|
||||
actualChildren.push(table);
|
||||
// If there are multiple tables, this seperates them
|
||||
actualChildren.push(new Paragraph(''));
|
||||
this.children = actualChildren;
|
||||
}
|
||||
|
||||
captionLabel(id: string, kind: 'Figure' | 'Table', { suffix } = { suffix: ': ' }) {
|
||||
this.current.push(...[createReferenceBookmark(id, kind, `${kind} `), new TextRun(suffix)]);
|
||||
}
|
||||
|
||||
$footnoteCounter = 0;
|
||||
|
||||
footnote(node: ProsemirrorNode<S>) {
|
||||
const { current, nextRunOpts } = this;
|
||||
// Delete everything and work with the footnote inline on the current
|
||||
this.current = [];
|
||||
delete this.nextRunOpts;
|
||||
|
||||
this.$footnoteCounter += 1;
|
||||
this.renderInline(node);
|
||||
this.footnotes[this.$footnoteCounter] = {
|
||||
children: [new Paragraph({ children: this.current })],
|
||||
};
|
||||
this.current = current;
|
||||
this.nextRunOpts = nextRunOpts;
|
||||
this.current.push(new FootnoteReferenceRun(this.$footnoteCounter));
|
||||
}
|
||||
|
||||
closeBlock(node: ProsemirrorNode<S>, props?: IParagraphOptions) {
|
||||
const paragraph = new Paragraph({
|
||||
children: this.current,
|
||||
...this.nextParentParagraphOpts,
|
||||
...props,
|
||||
});
|
||||
this.current = [];
|
||||
delete this.nextParentParagraphOpts;
|
||||
this.children.push(paragraph);
|
||||
}
|
||||
|
||||
createReference(id: string, before?: string, after?: string) {
|
||||
const children: ParagraphChild[] = [];
|
||||
if (before) children.push(new TextRun(before));
|
||||
children.push(new SimpleField(`REF ${id} \\h`));
|
||||
if (after) children.push(new TextRun(after));
|
||||
const ref = new InternalHyperlink({ anchor: id, children });
|
||||
this.current.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
export class DocxSerializer<S extends Schema = any> {
|
||||
nodes: NodeSerializer<S>;
|
||||
|
||||
marks: MarkSerializer<S>;
|
||||
|
||||
constructor(nodes: NodeSerializer<S>, marks: MarkSerializer<S>) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
}
|
||||
|
||||
serialize(content: ProsemirrorNode<S>, options: Options) {
|
||||
const state = new DocxSerializerState<S>(this.nodes, this.marks, options);
|
||||
state.renderContent(content);
|
||||
const doc = createDocFromState(state);
|
||||
return doc;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { INumberingOptions } from 'docx';
|
||||
import { IPropertiesOptions } from 'docx/build/file/core-properties';
|
||||
|
||||
export type Mutable<T> = {
|
||||
-readonly [k in keyof T]: T[k];
|
||||
};
|
||||
|
||||
export type IFootnotes = Mutable<Required<IPropertiesOptions>['footnotes']>;
|
||||
export type INumbering = INumberingOptions['config'][0];
|
|
@ -0,0 +1,47 @@
|
|||
import { Document, INumberingOptions, ISectionOptions, Packer, SectionType } from 'docx';
|
||||
import { Node as ProsemirrorNode } from 'prosemirror-model';
|
||||
|
||||
import { IFootnotes } from './types';
|
||||
|
||||
export function createShortId() {
|
||||
return Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
export function createDocFromState(state: {
|
||||
numbering: INumberingOptions['config'];
|
||||
children: ISectionOptions['children'];
|
||||
footnotes?: IFootnotes;
|
||||
}) {
|
||||
const doc = new Document({
|
||||
footnotes: state.footnotes,
|
||||
numbering: {
|
||||
config: state.numbering,
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
properties: {
|
||||
type: SectionType.CONTINUOUS,
|
||||
},
|
||||
children: state.children,
|
||||
},
|
||||
],
|
||||
});
|
||||
return doc;
|
||||
}
|
||||
|
||||
export async function writeDocx(
|
||||
doc: Document,
|
||||
write: ((buffer: Buffer) => void) | ((buffer: Buffer) => Promise<void>)
|
||||
) {
|
||||
const buffer = await Packer.toBuffer(doc);
|
||||
return write(buffer);
|
||||
}
|
||||
|
||||
export function getLatexFromNode(node: ProsemirrorNode): string {
|
||||
let math = '';
|
||||
node.forEach((child) => {
|
||||
if (child.isText) math += child.text;
|
||||
// TODO: improve this as we may have other things in the future
|
||||
});
|
||||
return math;
|
||||
}
|
|
@ -454,7 +454,7 @@ export class DocumentService {
|
|||
}),
|
||||
this.viewService.getDocumentTotalViews(documentId),
|
||||
]);
|
||||
const doc = lodash.omit(instanceToPlain(document), ['state', 'content']);
|
||||
const doc = lodash.omit(instanceToPlain(document), ['state']);
|
||||
const createUser = await this.userService.findById(doc.createUserId);
|
||||
return { document: { ...doc, views, createUser }, authority };
|
||||
}
|
||||
|
|
|
@ -91,12 +91,15 @@ importers:
|
|||
'@typescript-eslint/eslint-plugin': ^5.21.0
|
||||
'@typescript-eslint/parser': ^5.21.0
|
||||
axios: ^0.25.0
|
||||
buffer-image-size: ^0.6.4
|
||||
classnames: ^2.3.1
|
||||
clone: ^2.1.2
|
||||
copy-webpack-plugin: 11.0.0
|
||||
cross-env: ^7.0.3
|
||||
deep-equal: ^2.0.5
|
||||
docx: ^7.3.0
|
||||
dompurify: ^2.3.5
|
||||
downloadjs: ^1.4.7
|
||||
eslint: ^8.14.0
|
||||
eslint-config-prettier: ^8.5.0
|
||||
eslint-plugin-import: ^2.26.0
|
||||
|
@ -194,11 +197,14 @@ importers:
|
|||
'@tiptap/react': 2.0.0-beta.107_a3fcdb91535fe17b69dfabaa94f3bb3d
|
||||
'@tiptap/suggestion': 2.0.0-beta.90_@tiptap+core@2.0.0-beta.171
|
||||
axios: 0.25.0
|
||||
buffer-image-size: 0.6.4
|
||||
classnames: 2.3.1
|
||||
clone: 2.1.2
|
||||
cross-env: 7.0.3
|
||||
deep-equal: 2.0.5
|
||||
docx: 7.3.0
|
||||
dompurify: 2.3.5
|
||||
downloadjs: 1.4.7
|
||||
interactjs: 1.10.11
|
||||
katex: 0.15.2
|
||||
kity: 2.0.4
|
||||
|
@ -4388,6 +4394,13 @@ packages:
|
|||
/buffer-from/1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
/buffer-image-size/0.6.4:
|
||||
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
dependencies:
|
||||
'@types/node': 17.0.35
|
||||
dev: false
|
||||
|
||||
/buffer/5.7.1:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
dependencies:
|
||||
|
@ -5281,6 +5294,17 @@ packages:
|
|||
esutils: 2.0.3
|
||||
dev: true
|
||||
|
||||
/docx/7.3.0:
|
||||
resolution: {integrity: sha512-OkSGlDNWMRFY07OEhUTx1ouuzYi8s1b67JDI6m5/5ek4xoshtP+/Rx8eRdY8LbhvpFkngvUantvTsxY4XW8Heg==}
|
||||
engines: {node: '>=10'}
|
||||
dependencies:
|
||||
'@types/node': 17.0.35
|
||||
jszip: 3.10.0
|
||||
nanoid: 3.3.1
|
||||
xml: 1.0.1
|
||||
xml-js: 1.6.11
|
||||
dev: false
|
||||
|
||||
/domexception/2.0.1:
|
||||
resolution: {integrity: sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==}
|
||||
engines: {node: '>=8'}
|
||||
|
@ -5313,6 +5337,10 @@ packages:
|
|||
engines: {node: '>=10'}
|
||||
dev: false
|
||||
|
||||
/downloadjs/1.4.7:
|
||||
resolution: {integrity: sha512-LN1gO7+u9xjU5oEScGFKvXhYf7Y/empUIIEAGBs1LzUq/rg5duiDrkuH5A2lQGd5jfMOb9X9usDa2oVXwJ0U/Q==}
|
||||
dev: false
|
||||
|
||||
/duplexify/4.1.2:
|
||||
resolution: {integrity: sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==}
|
||||
dependencies:
|
||||
|
@ -6613,6 +6641,10 @@ packages:
|
|||
resolution: {integrity: sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
/immediate/3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
dev: false
|
||||
|
||||
/import-fresh/3.3.0:
|
||||
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
|
||||
engines: {node: '>=6'}
|
||||
|
@ -6994,7 +7026,7 @@ packages:
|
|||
dev: false
|
||||
|
||||
/isarray/1.0.0:
|
||||
resolution: {integrity: sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=}
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
dev: false
|
||||
|
||||
/isarray/2.0.5:
|
||||
|
@ -7739,6 +7771,15 @@ packages:
|
|||
object.assign: 4.1.2
|
||||
dev: true
|
||||
|
||||
/jszip/3.10.0:
|
||||
resolution: {integrity: sha512-LDfVtOLtOxb9RXkYOwPyNBTQDL4eUbqahtoY6x07GiDJHwSYvn8sHHIw8wINImV3MqbMNve2gSuM1DDqEKk09Q==}
|
||||
dependencies:
|
||||
lie: 3.3.0
|
||||
pako: 1.0.11
|
||||
readable-stream: 2.3.7
|
||||
setimmediate: 1.0.5
|
||||
dev: false
|
||||
|
||||
/jwa/1.4.1:
|
||||
resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==}
|
||||
dependencies:
|
||||
|
@ -7837,6 +7878,12 @@ packages:
|
|||
resolution: {integrity: sha512-QqTX4UVsGy24njtCgLRspiKpxfRniRBZE/P+d0vQXuYWQ+hwDS6X0ouo0O/SRyf7bhhMCE71b6vAvLMtY5PfEw==}
|
||||
dev: false
|
||||
|
||||
/lie/3.3.0:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
dev: false
|
||||
|
||||
/lilconfig/2.0.4:
|
||||
resolution: {integrity: sha512-bfTIN7lEsiooCocSISTWXkiWJkRqtL9wYtYy+8EK3Y41qh3mpwPU0ycTOgjdY9ErwXCc8QyrQp82bdL0Xkm9yA==}
|
||||
engines: {node: '>=10'}
|
||||
|
@ -8820,6 +8867,10 @@ packages:
|
|||
netmask: 2.0.2
|
||||
dev: false
|
||||
|
||||
/pako/1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
dev: false
|
||||
|
||||
/parent-module/1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
|
@ -10141,6 +10192,10 @@ packages:
|
|||
send: 0.17.2
|
||||
dev: false
|
||||
|
||||
/setimmediate/1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
dev: false
|
||||
|
||||
/setprototypeof/1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
dev: false
|
||||
|
@ -11452,7 +11507,7 @@ packages:
|
|||
dev: false
|
||||
|
||||
/util-deprecate/1.0.2:
|
||||
resolution: {integrity: sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=}
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
/utility-types/3.10.0:
|
||||
resolution: {integrity: sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg==}
|
||||
|
@ -11977,10 +12032,21 @@ packages:
|
|||
optional: true
|
||||
dev: false
|
||||
|
||||
/xml-js/1.6.11:
|
||||
resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
sax: 1.2.4
|
||||
dev: false
|
||||
|
||||
/xml-name-validator/3.0.0:
|
||||
resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==}
|
||||
dev: true
|
||||
|
||||
/xml/1.0.1:
|
||||
resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==}
|
||||
dev: false
|
||||
|
||||
/xml2js/0.4.23:
|
||||
resolution: {integrity: sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
|
Loading…
Reference in New Issue