@spacecorexdev/core 0.0.4 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -36
- package/src/api/_base.ts +243 -0
- package/src/api/index.ts +17 -0
- package/src/api/project.ts +1345 -0
- package/src/api/user.ts +1174 -0
- package/src/contract.ts +50 -0
- package/src/hooks/index.ts +2 -0
- package/{dist/hooks/index.js → src/hooks/useLongPress.ts} +53 -49
- package/src/hooks/useOnlineStatus.ts +22 -0
- package/src/index.ts +14 -0
- package/src/types/id.ts +2 -0
- package/src/types/index.ts +5 -0
- package/src/types/project/base.ts +63 -0
- package/src/types/project/contract.ts +79 -0
- package/src/types/project/contractStatus.tsx +15 -0
- package/src/types/project/index.ts +5 -0
- package/src/types/project/member.ts +46 -0
- package/src/types/project/progress.ts +43 -0
- package/src/types/projectRole.ts +17 -0
- package/src/types/theme.ts +1 -0
- package/src/types/user/index.ts +1 -0
- package/src/types/user/role.ts +7 -0
- package/src/ui/CommentTimeline/index.tsx +330 -0
- package/src/ui/FileManager/FileContextMenu.tsx +628 -0
- package/src/ui/FileManager/FileGridView.tsx +529 -0
- package/src/ui/FileManager/FileTableView.tsx +405 -0
- package/src/ui/FileManager/FileToolbar.tsx +119 -0
- package/src/ui/FileManager/FileVersionHistoryModal.tsx +175 -0
- package/src/ui/FileManager/MoveCopyToProjectModal.tsx +119 -0
- package/src/ui/FileManager/ShareToWechatModal.tsx +419 -0
- package/src/ui/FileManager/hooks/useFileManagerActions.ts +2532 -0
- package/src/ui/FileManager/hooks/useFileManagerState.ts +419 -0
- package/src/ui/FileManager/index.tsx +580 -0
- package/src/ui/FileManager/projectFileManagerApi.ts +57 -0
- package/src/ui/FileManager/types.ts +184 -0
- package/src/ui/FileManager/utils/fileDeltaWorker.ts +193 -0
- package/src/ui/FileManager/utils/fileTree.ts +178 -0
- package/src/ui/FileManager/utils/fileUpload.ts +714 -0
- package/src/ui/FileManager/utils/fileUtils.ts +65 -0
- package/src/ui/InvoiceRecordsList/index.tsx +813 -0
- package/src/ui/MyFileSelectorModal.tsx +261 -0
- package/src/ui/ProjectSelector/index.tsx +472 -0
- package/src/ui/ProjectSelector/projectCache.ts +40 -0
- package/src/ui/RoleSelector/index.tsx +473 -0
- package/src/ui/RoleSelector/roleCache.ts +40 -0
- package/src/ui/UIAlert.tsx +399 -0
- package/src/ui/UIAlertExample.tsx +129 -0
- package/src/ui/UIConfirm.tsx +335 -0
- package/src/ui/UIConfirmExample.tsx +165 -0
- package/src/ui/UIDatePicker.tsx +146 -0
- package/src/ui/UIDateRangePicker.tsx +38 -0
- package/src/ui/UIFilePreview/ExcelEditor/index.tsx +617 -0
- package/src/ui/UIFilePreview/ExcelEditor/types.ts +24 -0
- package/src/ui/UIFilePreview/ExportDropdown.css +85 -0
- package/src/ui/UIFilePreview/ExportDropdown.tsx +109 -0
- package/src/ui/UIFilePreview/IndexedDBUtils.ts +564 -0
- package/src/ui/UIFilePreview/MarkdownEditor/exportMarkdownPdf.ts +576 -0
- package/src/ui/UIFilePreview/MarkdownEditor/index.tsx +677 -0
- package/src/ui/UIFilePreview/MarkdownEditor/markdownEditor.css +1005 -0
- package/src/ui/UIFilePreview/MarkdownEditor/types.ts +15 -0
- package/src/ui/UIFilePreview/PPTXEditor/index.tsx +602 -0
- package/src/ui/UIFilePreview/RichDocEditor/exportRDocMarkdown.ts +77 -0
- package/src/ui/UIFilePreview/RichDocEditor/exportRDocPdf.ts +374 -0
- package/src/ui/UIFilePreview/RichDocEditor/index.tsx +730 -0
- package/src/ui/UIFilePreview/RichDocEditor/richdoc-editor.css +59 -0
- package/src/ui/UIFilePreview/UIFilePreview.tsx +611 -0
- package/src/ui/UIFilePreview/WordEditor/index.tsx +3933 -0
- package/src/ui/UIFilePreview/WordEditor/style.ts +1213 -0
- package/src/ui/UIFilePreview/WordEditor/word-editor.css +999 -0
- package/src/ui/UIFilePreview/filePreview.css +193 -0
- package/src/ui/UIFilePreview/filetypes/archive.tsx +66 -0
- package/src/ui/UIFilePreview/filetypes/cad.tsx +287 -0
- package/src/ui/UIFilePreview/filetypes/excel.tsx +355 -0
- package/src/ui/UIFilePreview/filetypes/image.tsx +20 -0
- package/src/ui/UIFilePreview/filetypes/imageLoader.ts +63 -0
- package/src/ui/UIFilePreview/filetypes/pdf.tsx +156 -0
- package/src/ui/UIFilePreview/filetypes/ppt.tsx +535 -0
- package/src/ui/UIFilePreview/filetypes/pptExportPdf.ts +523 -0
- package/src/ui/UIFilePreview/filetypes/video.tsx +482 -0
- package/src/ui/UIFilePreview/filetypes/word.tsx +245 -0
- package/src/ui/UIFilePreview/filetypes/wordExportMarkdown.ts +86 -0
- package/src/ui/UIFilePreview/filetypes/wordExportPdf.tsx +427 -0
- package/src/ui/UIFilePreview/index.ts +35 -0
- package/src/ui/UIFilePreview/pdfPagination.ts +143 -0
- package/src/ui/UIFilePreview/previewUtils.tsx +531 -0
- package/src/ui/UIFileUpload.tsx +382 -0
- package/src/ui/UIImageUpload.tsx +195 -0
- package/src/ui/UILoading.tsx +255 -0
- package/src/ui/UILoadingExample.tsx +139 -0
- package/src/ui/UISelect.tsx +105 -0
- package/src/ui/UIToast.tsx +381 -0
- package/src/ui/UIToastExample.tsx +69 -0
- package/src/ui/UIToastSingle.tsx +279 -0
- package/src/ui/UIViewToggle.tsx +64 -0
- package/src/ui/UserSelector/index.tsx +583 -0
- package/src/ui/index.ts +84 -0
- package/src/ui/upload/image.ts +135 -0
- package/src/ui/upload/index.ts +73 -0
- package/src/ui/upload/submitFile.ts +106 -0
- package/src/ui/upload/submitForm.ts +124 -0
- package/src/ui/upload/upload.err.ts +15 -0
- package/src/ui/upload/upload.ts +250 -0
- package/src/utils/CacheDepartmentInfo.ts +372 -0
- package/src/utils/CacheFileContent.ts +315 -0
- package/src/utils/CachePermissionInfo.ts +235 -0
- package/src/utils/CacheRoleInfo.ts +303 -0
- package/src/utils/CacheUserInfo.ts +818 -0
- package/src/utils/CacheViewToggleInfo.ts +182 -0
- package/src/utils/MessageContentUtils.ts +268 -0
- package/src/utils/clipboard.ts +70 -0
- package/src/utils/companyEmail.ts +19 -0
- package/src/utils/forbidDebugger.ts +59 -0
- package/src/utils/formatters.ts +94 -0
- package/src/utils/htmlUtils.tsx +59 -0
- package/src/utils/index.ts +18 -0
- package/src/utils/keyboard.ts +114 -0
- package/src/utils/mammothApi.ts +16 -0
- package/src/utils/polyfills.ts +928 -0
- package/src/utils/regex.ts +14 -0
- package/src/utils/storage.ts +95 -0
- package/src/utils/workerBootstrap.ts +87 -0
- package/dist/api/index.js +0 -1553
- package/dist/contract.js +0 -5
- package/dist/index.js +0 -4492
- package/dist/types/index.js +0 -49
- package/dist/ui/index.css +0 -1876
- package/dist/ui/index.js +0 -22245
- package/dist/utils/index.js +0 -4296
- /package/{dist → src}/styles/ant-modal.css +0 -0
- /package/{dist → src}/styles/ant-spin.css +0 -0
- /package/{dist → src}/styles/ant-table.css +0 -0
- /package/{dist → src}/styles/index.css +0 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import type EasyMDE from 'easymde';
|
|
2
|
+
import { buildPageSlices, measureBlocks } from '../pdfPagination';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Markdown 导出 PDF(纯前端生成,不经过浏览器打印对话框)。
|
|
6
|
+
*
|
|
7
|
+
* 实现思路:
|
|
8
|
+
* 1. 复用 EasyMDE 的 previewRender(marked) 把 Markdown 渲染成与预览一致的 HTML;
|
|
9
|
+
* 2. 放入离屏容器(.EasyMDEContainer .editor-preview),强制使用浅色主题变量,
|
|
10
|
+
* 导出排版与编辑器预览保持 1:1;
|
|
11
|
+
* 3. 按 A4 内容区高度对容器分块截图,规避浏览器单 canvas ~16384px 的高度上限,
|
|
12
|
+
* 同时天然完成分页;
|
|
13
|
+
* 4. 逐块写入 jsPDF(A4),最后直接触发下载。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface ExportMarkdownPdfOptions {
|
|
17
|
+
/** 最新的 Markdown 原文 */
|
|
18
|
+
content: string;
|
|
19
|
+
/** 文件名(.md/.markdown 后缀会被替换为 .pdf) */
|
|
20
|
+
fileName: string;
|
|
21
|
+
/** EasyMDE 实例,用于复用其 previewRender 渲染逻辑 */
|
|
22
|
+
editor?: EasyMDE | null;
|
|
23
|
+
/** 分页进度回调(当前页 / 总页数) */
|
|
24
|
+
onProgress?: (page: number, total: number) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A4:210mm x 297mm,四周留白 10mm,内容区 190mm x 277mm
|
|
28
|
+
const PAGE_MARGIN_MM = 10;
|
|
29
|
+
const PAGE_WIDTH_MM = 210;
|
|
30
|
+
const PAGE_HEIGHT_MM = 297;
|
|
31
|
+
const CONTENT_WIDTH_MM = PAGE_WIDTH_MM - PAGE_MARGIN_MM * 2; // 190
|
|
32
|
+
const CONTENT_HEIGHT_MM = PAGE_HEIGHT_MM - PAGE_MARGIN_MM * 2; // 277
|
|
33
|
+
// 内容区 CSS 像素宽度(96dpi),离屏容器按此固定宽度排版
|
|
34
|
+
const CONTENT_WIDTH_PX = Math.round((CONTENT_WIDTH_MM / 25.4) * 96); // 718
|
|
35
|
+
const CONTENT_HEIGHT_PX = (CONTENT_WIDTH_PX * CONTENT_HEIGHT_MM) / CONTENT_WIDTH_MM;
|
|
36
|
+
// 截图倍率,保证 PDF 清晰度(3 倍使删除线等细线在 PDF 渲染器降采样后仍可见)
|
|
37
|
+
const SCALE = 3;
|
|
38
|
+
// 图片加载最长等待时间(ms),避免外链图失效时永久阻塞导出
|
|
39
|
+
const IMAGE_WAIT_TIMEOUT = 15000;
|
|
40
|
+
|
|
41
|
+
/** 浅色主题变量(取自 src/styles/index.css 的 :root 浅色值),保证暗色模式下导出仍是白底黑字 */
|
|
42
|
+
const LIGHT_THEME_VARS: Record<string, string> = {
|
|
43
|
+
'--color-surface': '255 255 255',
|
|
44
|
+
'--color-foreground': '31 41 55',
|
|
45
|
+
'--color-muted': '107 114 128',
|
|
46
|
+
'--color-border': '229 231 235',
|
|
47
|
+
'--color-markdown-comment': '133 133 133',
|
|
48
|
+
'--color-gray-50': '249 250 251',
|
|
49
|
+
'--color-gray-100': '243 244 246',
|
|
50
|
+
'--color-gray-200': '229 231 235',
|
|
51
|
+
'--color-gray-300': '209 213 219',
|
|
52
|
+
'--color-gray-400': '156 163 175',
|
|
53
|
+
'--color-green-500': '34 197 94',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const escapeHtml = (text: string): string =>
|
|
57
|
+
text
|
|
58
|
+
.replace(/&/g, '&')
|
|
59
|
+
.replace(/</g, '<')
|
|
60
|
+
.replace(/>/g, '>');
|
|
61
|
+
|
|
62
|
+
/** EasyMDE 的类型声明未公开 options/markdown,但运行时存在 */
|
|
63
|
+
interface EasyMdeLike {
|
|
64
|
+
/** 原型方法:内部按 options.renderingConfig 配置 marked 后渲染,与编辑器实时预览同源 */
|
|
65
|
+
markdown?: (text: string) => string;
|
|
66
|
+
options?: {
|
|
67
|
+
/**
|
|
68
|
+
* 默认实现为 function(text) { return this.parent.markdown(text) },
|
|
69
|
+
* 调用时 this 必须是 options(this.parent 才是编辑器实例)
|
|
70
|
+
*/
|
|
71
|
+
previewRender?: (markdown: string, previewElement?: HTMLElement) => string | void | null;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const asEasyMdeLike = (editor?: EasyMDE | null): EasyMdeLike | null =>
|
|
76
|
+
(editor as unknown as EasyMdeLike) || null;
|
|
77
|
+
|
|
78
|
+
/** EasyMDE 的 markdown() 返回值可能被 <html><head>...<body> 包裹,剥掉外壳只留正文节点 */
|
|
79
|
+
const unwrapDocumentEnvelope = (html: string): string =>
|
|
80
|
+
html
|
|
81
|
+
.replace(/^\s*<html[^>]*>/i, '')
|
|
82
|
+
.replace(/<\/html>\s*$/i, '')
|
|
83
|
+
.replace(/^\s*<head[^>]*>[\s\S]*?<\/head>/i, '')
|
|
84
|
+
.replace(/^\s*<body[^>]*>/i, '')
|
|
85
|
+
.replace(/<\/body>\s*$/i, '')
|
|
86
|
+
.trim();
|
|
87
|
+
|
|
88
|
+
/** 复用 EasyMDE 的渲染链路把 Markdown 转为 HTML;实例不可用时降级为纯文本 */
|
|
89
|
+
const renderMarkdownHtml = (content: string, editor?: EasyMDE | null): string => {
|
|
90
|
+
const mde = asEasyMdeLike(editor);
|
|
91
|
+
|
|
92
|
+
// 首选:实例原型方法 markdown(),与编辑器实时预览使用同一套 marked 配置,
|
|
93
|
+
// 且不依赖 options.parent 的 this 绑定
|
|
94
|
+
if (mde && typeof mde.markdown === 'function') {
|
|
95
|
+
try {
|
|
96
|
+
const html = unwrapDocumentEnvelope(mde.markdown(content));
|
|
97
|
+
if (html) {
|
|
98
|
+
return html;
|
|
99
|
+
}
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.warn('[exportMarkdownPdf] EasyMDE markdown() 渲染失败,尝试 previewRender:', error);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 次选:previewRender。注意默认函数内部用 this.parent.markdown(),
|
|
106
|
+
// this 必须绑定为 options 对象
|
|
107
|
+
const options = mde?.options;
|
|
108
|
+
const previewRender = options?.previewRender;
|
|
109
|
+
if (options && typeof previewRender === 'function') {
|
|
110
|
+
const probe = document.createElement('div');
|
|
111
|
+
try {
|
|
112
|
+
const result = previewRender.call(options, content, probe);
|
|
113
|
+
const html = unwrapDocumentEnvelope(
|
|
114
|
+
typeof result === 'string' && result ? result : probe.innerHTML,
|
|
115
|
+
);
|
|
116
|
+
if (html) {
|
|
117
|
+
return html;
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.warn('[exportMarkdownPdf] EasyMDE previewRender 渲染失败,降级为纯文本:', error);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return `<pre style="white-space:pre-wrap;font-size:16px;line-height:1.6;">${escapeHtml(content)}</pre>`;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** 等待容器内图片全部加载完成(设置 crossOrigin 以支持跨域图片入 canvas) */
|
|
128
|
+
const waitForImagesLoaded = (root: HTMLElement): Promise<void> => {
|
|
129
|
+
const images = Array.from(root.querySelectorAll('img'));
|
|
130
|
+
if (images.length === 0) {
|
|
131
|
+
return Promise.resolve();
|
|
132
|
+
}
|
|
133
|
+
const tasks = images.map((img) =>
|
|
134
|
+
new Promise<void>((resolve) => {
|
|
135
|
+
let settled = false;
|
|
136
|
+
const finish = () => {
|
|
137
|
+
if (settled) return;
|
|
138
|
+
settled = true;
|
|
139
|
+
window.clearTimeout(timer);
|
|
140
|
+
img.removeEventListener('load', onLoad);
|
|
141
|
+
img.removeEventListener('error', onError);
|
|
142
|
+
// 图片加载失败不阻塞导出,html2canvas 内部会再次尝试 CORS 抓取
|
|
143
|
+
resolve();
|
|
144
|
+
};
|
|
145
|
+
const timer = window.setTimeout(finish, IMAGE_WAIT_TIMEOUT);
|
|
146
|
+
const onLoad = () => finish();
|
|
147
|
+
const onError = () => finish();
|
|
148
|
+
img.addEventListener('load', onLoad);
|
|
149
|
+
img.addEventListener('error', onError);
|
|
150
|
+
// crossOrigin 必须在发起请求前设置;重置 src 触发一次带 CORS 的请求
|
|
151
|
+
const rawSrc = img.getAttribute('src');
|
|
152
|
+
img.crossOrigin = 'anonymous';
|
|
153
|
+
img.removeAttribute('src');
|
|
154
|
+
if (rawSrc) {
|
|
155
|
+
img.setAttribute('src', rawSrc);
|
|
156
|
+
}
|
|
157
|
+
// 重置后恰好已命中缓存
|
|
158
|
+
if (img.complete) {
|
|
159
|
+
finish();
|
|
160
|
+
}
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
return Promise.all(tasks).then(() => undefined);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 导出专用预览样式:全部使用具体色值,不依赖 CSS 变量。
|
|
168
|
+
*
|
|
169
|
+
* 原因:
|
|
170
|
+
* 1. markdownEditor.css 中预览样式大量使用 rgb(var(--color-xxx) / alpha),
|
|
171
|
+
* html2canvas 1.4 无法正确解析这种现代语法,会导致背景/边框丢失(如行内 code 灰底);
|
|
172
|
+
* 2. hr 默认的 inset 3D 边框在 html2canvas 中会被渲染成明显的黑线,与预览不一致。
|
|
173
|
+
*
|
|
174
|
+
* 这里用 .EasyMDEContainer .editor-preview 作用域 + !important 覆盖原有变量规则,
|
|
175
|
+
* 保证导出视觉与预览一致。
|
|
176
|
+
*/
|
|
177
|
+
const PREVIEW_EXPORT_STYLES = `
|
|
178
|
+
/* 注意:white-space: pre-wrap 只能作用于子元素(* 选择器),不能加在容器自身——
|
|
179
|
+
否则 marked 输出 HTML 中块级元素之间的换行符会被渲染成额外空白行(每处约一行高),
|
|
180
|
+
导致导出 PDF 中标题、分隔线、引用块等前后间距比真实预览大出一截。
|
|
181
|
+
真实预览容器自身为 white-space: normal,块间换行符直接折叠。 */
|
|
182
|
+
.EasyMDEContainer .editor-preview * {
|
|
183
|
+
word-wrap: break-word;
|
|
184
|
+
white-space: pre-wrap;
|
|
185
|
+
word-break: normal;
|
|
186
|
+
}
|
|
187
|
+
.EasyMDEContainer .editor-preview {
|
|
188
|
+
color: #1f2937 !important;
|
|
189
|
+
background: #ffffff !important;
|
|
190
|
+
font-size: 16px !important;
|
|
191
|
+
line-height: 1.6 !important;
|
|
192
|
+
}
|
|
193
|
+
.EasyMDEContainer .editor-preview h1,
|
|
194
|
+
.EasyMDEContainer .editor-preview h2,
|
|
195
|
+
.EasyMDEContainer .editor-preview h3,
|
|
196
|
+
.EasyMDEContainer .editor-preview h4,
|
|
197
|
+
.EasyMDEContainer .editor-preview h5,
|
|
198
|
+
.EasyMDEContainer .editor-preview h6 {
|
|
199
|
+
color: #000 !important;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.EasyMDEContainer .editor-preview h1 {
|
|
203
|
+
font-size: 200%;
|
|
204
|
+
line-height: 200%;
|
|
205
|
+
font-weight: 700;
|
|
206
|
+
margin-top: 0.5em;
|
|
207
|
+
margin-bottom: 0em;
|
|
208
|
+
}
|
|
209
|
+
.EasyMDEContainer .editor-preview h2 {
|
|
210
|
+
font-size: 160%;
|
|
211
|
+
line-height: 160%;
|
|
212
|
+
font-weight: 700;
|
|
213
|
+
margin-top: 0.5em;
|
|
214
|
+
margin-bottom: 0em;
|
|
215
|
+
}
|
|
216
|
+
.EasyMDEContainer .editor-preview h3 {
|
|
217
|
+
font-size: 125%;
|
|
218
|
+
line-height: 125%;
|
|
219
|
+
font-weight: 700;
|
|
220
|
+
margin-top: 1em;
|
|
221
|
+
margin-bottom: 1em;
|
|
222
|
+
}
|
|
223
|
+
.EasyMDEContainer .editor-preview h4,
|
|
224
|
+
.EasyMDEContainer .editor-preview h5,
|
|
225
|
+
.EasyMDEContainer .editor-preview h6 {
|
|
226
|
+
font-size: 110%;
|
|
227
|
+
line-height: 110%;
|
|
228
|
+
font-weight: 700;
|
|
229
|
+
margin-top: 1em;
|
|
230
|
+
margin-bottom: 1em;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.EasyMDEContainer .editor-preview p {
|
|
234
|
+
white-space: pre-wrap;
|
|
235
|
+
word-wrap: break-word;
|
|
236
|
+
margin: 0 0 1em;
|
|
237
|
+
line-height: 1.6;
|
|
238
|
+
}
|
|
239
|
+
/* 注意:标题(h1-h6)、段落的字号/行高/上下 margin 不在此重复声明,
|
|
240
|
+
统一由 markdownEditor.css 的同名规则生效(离屏容器同样带 .EasyMDEContainer 作用域),
|
|
241
|
+
避免两处值漂移导致导出与预览间距不一致。 */
|
|
242
|
+
/* 行内代码:用具体色值,避免 var() 解析失败
|
|
243
|
+
line-height:1 让盒高=文字高度,避免 html2canvas 把背景画偏(偏上) */
|
|
244
|
+
.EasyMDEContainer .editor-preview code {
|
|
245
|
+
background: rgba(133, 133, 133, 0.3) !important;
|
|
246
|
+
border-radius: 2px !important;
|
|
247
|
+
padding: 1px 2px !important;
|
|
248
|
+
color: #1f2937 !important;
|
|
249
|
+
line-height: 1 !important;
|
|
250
|
+
vertical-align: baseline !important;
|
|
251
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
252
|
+
}
|
|
253
|
+
/* 修复 html2canvas 1.4 的 FontMetrics 测量环境(关键:影响所有文字的纵向绘制位置)。
|
|
254
|
+
html2canvas 用 textBounds.top + FontMetrics.baseline 以 alphabetic 基线画文字,
|
|
255
|
+
而 FontMetrics.parseMetrics 在【当前页面 document.body】下创建一个隐藏 div,
|
|
256
|
+
内部用 1x1 img(vertical-align:baseline) 与文字 span 的 offsetTop 差来推算基线。
|
|
257
|
+
Tailwind preflight 的 img{display:block} 会让该测试 img 掉出文字行盒、独占一块,
|
|
258
|
+
使 baseline 从 ~16 膨胀到 ~23(middle 同样失真),导致所有文字被系统性下画 ~6.7px:
|
|
259
|
+
删除线偏上、code 灰底偏上、表格单元格文字偏下,均为同一根因。
|
|
260
|
+
该测试 img 的特征:body 直属子 div 下的 1x1 img,用此精准选择器恢复 inline,
|
|
261
|
+
不影响正文图片(正文 img 位于 .editor-preview 内部更深层级)。
|
|
262
|
+
注意不能设 vertical-align:!important 会覆盖 parseMetrics 内联的 baseline/super。 */
|
|
263
|
+
body > div > img[width="1"][height="1"] {
|
|
264
|
+
display: inline !important;
|
|
265
|
+
}
|
|
266
|
+
/* 删除线:html2canvas 1.4 对 text-decoration:line-through 的位置计算有 bug
|
|
267
|
+
(用 vertical-align:super 近似出的 middle 画线,且 middle 测量同样受上述
|
|
268
|
+
FontMetrics 环境影响失真),改用显式色值的 linear-gradient 背景画横线。注意:
|
|
269
|
+
1. 必须用具体色值,不能用 currentColor;不要覆盖 line-height;
|
|
270
|
+
2. 百分比基于修复 FontMetrics 后的文字真实位置校准:渐变参考系是 del 元素
|
|
271
|
+
盒(getBoundingClientRect,高 18px),正文文字视觉中线约在盒高 54% 处,
|
|
272
|
+
52%-57% 色带(约 1px 粗)在正文/列表/行内代码/标题中偏差均 ≤0.5px。 */
|
|
273
|
+
.EasyMDEContainer .editor-preview del,
|
|
274
|
+
.EasyMDEContainer .editor-preview s,
|
|
275
|
+
.EasyMDEContainer .editor-preview strike {
|
|
276
|
+
text-decoration: none !important;
|
|
277
|
+
background-image: linear-gradient(
|
|
278
|
+
to bottom,
|
|
279
|
+
transparent 52%,
|
|
280
|
+
#1f2937 52%,
|
|
281
|
+
#1f2937 57%,
|
|
282
|
+
transparent 57%
|
|
283
|
+
) !important;
|
|
284
|
+
background-repeat: no-repeat !important;
|
|
285
|
+
}
|
|
286
|
+
/* 代码块 */
|
|
287
|
+
/* 上 1em(浏览器默认)、下 10px,与 markdownEditor.css 的 pre 规则一致 */
|
|
288
|
+
.EasyMDEContainer .editor-preview pre {
|
|
289
|
+
background: #f3f4f6 !important;
|
|
290
|
+
padding: 10px !important;
|
|
291
|
+
margin: 1em 0 10px !important;
|
|
292
|
+
border-radius: 4px;
|
|
293
|
+
overflow-x: auto;
|
|
294
|
+
}
|
|
295
|
+
.EasyMDEContainer .editor-preview pre code {
|
|
296
|
+
background: transparent !important;
|
|
297
|
+
padding: 0 !important;
|
|
298
|
+
border-radius: 0 !important;
|
|
299
|
+
}
|
|
300
|
+
/* 引用块 */
|
|
301
|
+
.EasyMDEContainer .editor-preview blockquote {
|
|
302
|
+
border-left: 2px solid #22c55e !important;
|
|
303
|
+
color: #858585;
|
|
304
|
+
margin: 1.5em 0 !important;
|
|
305
|
+
padding: 0 1em !important;
|
|
306
|
+
display: block;
|
|
307
|
+
white-space: normal;
|
|
308
|
+
word-wrap: normal;
|
|
309
|
+
word-break: normal;
|
|
310
|
+
}
|
|
311
|
+
/* 列表:关闭浏览器/ html2canvas 原生标记。
|
|
312
|
+
html2canvas 1.4 自绘的列表标记用 lineHeight/2 的固定公式定位纵向位置,
|
|
313
|
+
与正文真实基线不一致(line-height:1.6 时有序序号整体偏上约 1/3 em)。
|
|
314
|
+
标记改由 onclone 中注入的 inline-block span 承担(见 injectListMarkers),
|
|
315
|
+
与正文处于同一行盒,基线天然对齐;2em 左内边距即标记占位槽。 */
|
|
316
|
+
.EasyMDEContainer .editor-preview ul,
|
|
317
|
+
.EasyMDEContainer .editor-preview ol,
|
|
318
|
+
.EasyMDEContainer .editor-preview li {
|
|
319
|
+
word-wrap: normal;
|
|
320
|
+
white-space: normal;
|
|
321
|
+
word-break: normal;
|
|
322
|
+
list-style: none !important;
|
|
323
|
+
margin: revert;
|
|
324
|
+
padding: revert;
|
|
325
|
+
}
|
|
326
|
+
.EasyMDEContainer .editor-preview ul,
|
|
327
|
+
.EasyMDEContainer .editor-preview ol {
|
|
328
|
+
padding-left: 2em;
|
|
329
|
+
}
|
|
330
|
+
/* 表格 */
|
|
331
|
+
.EasyMDEContainer .editor-preview table {
|
|
332
|
+
border-collapse: collapse;
|
|
333
|
+
margin: 1em 0;
|
|
334
|
+
}
|
|
335
|
+
.EasyMDEContainer .editor-preview table td,
|
|
336
|
+
.EasyMDEContainer .editor-preview table th {
|
|
337
|
+
border: 1px solid #e5e7eb !important;
|
|
338
|
+
padding: 5px !important;
|
|
339
|
+
}
|
|
340
|
+
.EasyMDEContainer .editor-preview table th {
|
|
341
|
+
background: #f9fafb;
|
|
342
|
+
font-weight: 700;
|
|
343
|
+
}
|
|
344
|
+
/* 分隔线:显式 solid 浅灰,避免 inset 被 html2canvas 渲染成黑线 */
|
|
345
|
+
.EasyMDEContainer .editor-preview hr {
|
|
346
|
+
border: none !important;
|
|
347
|
+
border-top: 1px solid #d1d5db !important;
|
|
348
|
+
height: 0 !important;
|
|
349
|
+
/* 与 markdownEditor.css 的 hr 上下 1em 一致 */
|
|
350
|
+
margin: 1em 0 !important;
|
|
351
|
+
background: transparent !important;
|
|
352
|
+
}
|
|
353
|
+
.EasyMDEContainer .editor-preview img {
|
|
354
|
+
max-width: 100%;
|
|
355
|
+
height: auto;
|
|
356
|
+
}
|
|
357
|
+
.EasyMDEContainer .editor-preview a {
|
|
358
|
+
color: #2563eb;
|
|
359
|
+
text-decoration: underline;
|
|
360
|
+
}
|
|
361
|
+
`;
|
|
362
|
+
|
|
363
|
+
const buildOffscreenContainer = (html: string): HTMLElement => {
|
|
364
|
+
// 外层带 .EasyMDEContainer,使 markdownEditor.css 中以其为作用域的预览样式生效
|
|
365
|
+
const wrapper = document.createElement('div');
|
|
366
|
+
wrapper.className = 'EasyMDEContainer';
|
|
367
|
+
// 页面上可能同时存在真实编辑器的 .EasyMDEContainer,onclone 中靠此属性定位离屏容器
|
|
368
|
+
wrapper.setAttribute('data-md-export-root', 'true');
|
|
369
|
+
Object.entries(LIGHT_THEME_VARS).forEach(([key, value]) => {
|
|
370
|
+
wrapper.style.setProperty(key, value);
|
|
371
|
+
});
|
|
372
|
+
Object.assign(wrapper.style, {
|
|
373
|
+
position: 'fixed',
|
|
374
|
+
top: '0',
|
|
375
|
+
left: '-100000px',
|
|
376
|
+
width: `${CONTENT_WIDTH_PX}px`,
|
|
377
|
+
background: '#ffffff',
|
|
378
|
+
pointerEvents: 'none',
|
|
379
|
+
} as Partial<CSSStyleDeclaration>);
|
|
380
|
+
|
|
381
|
+
// 注入自包含预览样式(具体色值,不依赖 CSS 变量),覆盖原有变量规则
|
|
382
|
+
const styleEl = document.createElement('style');
|
|
383
|
+
styleEl.setAttribute('data-md-export', 'true');
|
|
384
|
+
styleEl.textContent = PREVIEW_EXPORT_STYLES;
|
|
385
|
+
wrapper.appendChild(styleEl);
|
|
386
|
+
|
|
387
|
+
// 内层即“预览区”,复用 .editor-preview 的全部样式(标题/代码/表格/引用等)
|
|
388
|
+
const preview = document.createElement('div');
|
|
389
|
+
preview.className = 'editor-preview editor-preview-full';
|
|
390
|
+
Object.assign(preview.style, {
|
|
391
|
+
display: 'block',
|
|
392
|
+
position: 'static',
|
|
393
|
+
width: `${CONTENT_WIDTH_PX}px`,
|
|
394
|
+
height: 'auto',
|
|
395
|
+
minHeight: 'auto',
|
|
396
|
+
margin: '0',
|
|
397
|
+
padding: '0',
|
|
398
|
+
overflow: 'visible',
|
|
399
|
+
zIndex: 'auto',
|
|
400
|
+
background: '#ffffff',
|
|
401
|
+
color: '#1f2937',
|
|
402
|
+
fontSize: '16px',
|
|
403
|
+
lineHeight: '1.6',
|
|
404
|
+
fontFamily:
|
|
405
|
+
'-apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif',
|
|
406
|
+
} as Partial<CSSStyleDeclaration>);
|
|
407
|
+
preview.innerHTML = html;
|
|
408
|
+
|
|
409
|
+
wrapper.appendChild(preview);
|
|
410
|
+
document.body.appendChild(wrapper);
|
|
411
|
+
return preview;
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* 在 html2canvas 的克隆文档中把原生列表标记替换为插入 li 开头的 inline-block span。
|
|
416
|
+
*
|
|
417
|
+
* 背景:html2canvas 1.4 对 list-style:decimal/disc 的标记是自己用 canvas 文字绘制的,
|
|
418
|
+
* 纵向位置取 li.bounds.top + lineHeight/2 的固定公式(见其 renderer 中 paint.listValue 分支),
|
|
419
|
+
* 与正文按浏览器真实行盒排版的基线不是同一套计算,line-height 1.6 下有序序号会整体偏上。
|
|
420
|
+
* 改为普通行内元素后,标记与正文处于同一个行盒,基线天然一致(且由浏览器直接布局、html2canvas 只负责绘制)。
|
|
421
|
+
* 仅操作克隆文档,不影响页面上真实的编辑器预览。
|
|
422
|
+
*/
|
|
423
|
+
const injectListMarkers = (root: ParentNode, doc: Document): void => {
|
|
424
|
+
// 负左外边距把标记拉进 ul/ol 的 2em 左内边距槽位;换行的后续行仍与首行正文对齐。
|
|
425
|
+
// 不写 font/color:span 作为普通行内元素自然继承 li 的字体与颜色
|
|
426
|
+
const MARKER_STYLE =
|
|
427
|
+
'display:inline-block;width:1.6em;margin-left:-2em;padding-right:0.4em;' +
|
|
428
|
+
'text-align:right;white-space:nowrap;';
|
|
429
|
+
|
|
430
|
+
const inject = (list: Element, textFor: (index: number) => string): void => {
|
|
431
|
+
const start = Number((list as HTMLElement).getAttribute?.('start') ?? '1');
|
|
432
|
+
let index = Number.isFinite(start) && start >= 1 ? Math.floor(start) : 1;
|
|
433
|
+
// 只处理直接子 li;嵌套列表独立计数
|
|
434
|
+
list.querySelectorAll(':scope > li').forEach((li) => {
|
|
435
|
+
if (
|
|
436
|
+
li.querySelector(
|
|
437
|
+
':scope > [data-md-list-marker], :scope > p > [data-md-list-marker]',
|
|
438
|
+
)
|
|
439
|
+
) {
|
|
440
|
+
index += 1;
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const marker = doc.createElement('span');
|
|
444
|
+
marker.setAttribute('data-md-list-marker', 'true');
|
|
445
|
+
marker.style.cssText = MARKER_STYLE;
|
|
446
|
+
marker.textContent = textFor(index);
|
|
447
|
+
// 松散列表(项之间有空行)EasyMDE 会渲染成 li>p,直接插到 li 下标记会独占一行
|
|
448
|
+
const first = li.firstElementChild;
|
|
449
|
+
const target = first && first.tagName === 'P' ? first : li;
|
|
450
|
+
target.insertBefore(marker, target.firstChild);
|
|
451
|
+
index += 1;
|
|
452
|
+
});
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
root.querySelectorAll('ol').forEach((ol) => inject(ol, (i) => `${i}.`));
|
|
456
|
+
root.querySelectorAll('ul').forEach((ul) => inject(ul, () => '•'));
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const toPdfFileName = (fileName: string): string => {
|
|
460
|
+
const base = fileName.replace(/\.(md|markdown)$/i, '').trim() || 'document';
|
|
461
|
+
return `${base}.pdf`;
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* 将 Markdown 内容导出为 PDF 并触发浏览器下载。
|
|
466
|
+
* @throws 导出失败时抛出异常,由调用方提示用户
|
|
467
|
+
*/
|
|
468
|
+
export const exportMarkdownPdf = async (
|
|
469
|
+
options: ExportMarkdownPdfOptions,
|
|
470
|
+
): Promise<void> => {
|
|
471
|
+
const { content, fileName, editor, onProgress } = options;
|
|
472
|
+
if (!content || !content.trim()) {
|
|
473
|
+
throw new Error('内容为空,无法导出 PDF');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const html = renderMarkdownHtml(content, editor);
|
|
477
|
+
const preview = buildOffscreenContainer(html);
|
|
478
|
+
|
|
479
|
+
try {
|
|
480
|
+
// 等字体和图片就绪,避免截图时出现回退字体或图片高度未撑开
|
|
481
|
+
if (document.fonts?.ready) {
|
|
482
|
+
await document.fonts.ready.catch(() => undefined);
|
|
483
|
+
}
|
|
484
|
+
await waitForImagesLoaded(preview);
|
|
485
|
+
|
|
486
|
+
// 双 raf 确保最终布局(图片尺寸、换行)生效
|
|
487
|
+
await new Promise<void>((resolve) =>
|
|
488
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
// 预览区顶层内容块(标题/段落/列表/代码块等)作为分页单位
|
|
492
|
+
const blockElements = Array.from(preview.children);
|
|
493
|
+
const slices = buildPageSlices(measureBlocks(preview, blockElements), CONTENT_HEIGHT_PX);
|
|
494
|
+
if (slices.length === 0) {
|
|
495
|
+
throw new Error('内容为空,无法导出 PDF');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// 依赖体积较大,按需动态加载,不进入主包
|
|
499
|
+
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
|
|
500
|
+
import('html2canvas'),
|
|
501
|
+
import('jspdf'),
|
|
502
|
+
]);
|
|
503
|
+
|
|
504
|
+
const doc = new jsPDF({
|
|
505
|
+
orientation: 'portrait',
|
|
506
|
+
unit: 'mm',
|
|
507
|
+
format: 'a4',
|
|
508
|
+
compress: true,
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// 注:html2canvas 1.4 的 Options 类型把大量字段声明为必填,
|
|
512
|
+
// 这里不显式标注,交由 html2canvas 调用处检查(y/height 在每次调用时补齐)
|
|
513
|
+
const baseCanvasOptions = {
|
|
514
|
+
scale: SCALE,
|
|
515
|
+
x: 0,
|
|
516
|
+
width: CONTENT_WIDTH_PX,
|
|
517
|
+
backgroundColor: '#ffffff',
|
|
518
|
+
useCORS: true,
|
|
519
|
+
logging: false,
|
|
520
|
+
// html2canvas 1.4 的类型声明要求显式提供窗口参数
|
|
521
|
+
scrollX: window.scrollX,
|
|
522
|
+
scrollY: window.scrollY,
|
|
523
|
+
windowWidth: document.documentElement.clientWidth,
|
|
524
|
+
windowHeight: document.documentElement.clientHeight,
|
|
525
|
+
// 与离屏容器保持一致的浅色变量,克隆节点渲染时同样生效
|
|
526
|
+
onclone: (clonedDoc: Document) => {
|
|
527
|
+
const cloned = clonedDoc.querySelector(
|
|
528
|
+
'.EasyMDEContainer[data-md-export-root]',
|
|
529
|
+
) as HTMLElement | null;
|
|
530
|
+
if (cloned) {
|
|
531
|
+
Object.entries(LIGHT_THEME_VARS).forEach(([key, value]) => {
|
|
532
|
+
cloned.style.setProperty(key, value);
|
|
533
|
+
});
|
|
534
|
+
const clonedPreview = cloned.querySelector('.editor-preview');
|
|
535
|
+
if (clonedPreview) {
|
|
536
|
+
injectListMarkers(clonedPreview, clonedDoc);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
for (let page = 0; page < slices.length; page++) {
|
|
543
|
+
const slice = slices[page];
|
|
544
|
+
|
|
545
|
+
const canvas = await html2canvas(preview, {
|
|
546
|
+
...baseCanvasOptions,
|
|
547
|
+
y: slice.y,
|
|
548
|
+
height: slice.height,
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// canvas 像素 -> mm(经 scale 与 96dpi 换算)
|
|
552
|
+
// 使用 PNG(无损)而非 JPEG:JPEG 压缩会让删除线等 1px 细线在部分 PDF 渲染器中丢失
|
|
553
|
+
const dataUrl = canvas.toDataURL('image/png');
|
|
554
|
+
const imgWidthMm = (canvas.width / SCALE / 96) * 25.4;
|
|
555
|
+
const imgHeightMm = (canvas.height / SCALE / 96) * 25.4;
|
|
556
|
+
doc.addImage(
|
|
557
|
+
dataUrl,
|
|
558
|
+
'PNG',
|
|
559
|
+
PAGE_MARGIN_MM,
|
|
560
|
+
PAGE_MARGIN_MM,
|
|
561
|
+
imgWidthMm,
|
|
562
|
+
imgHeightMm,
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
onProgress?.(page + 1, slices.length);
|
|
566
|
+
|
|
567
|
+
if (page < slices.length - 1) {
|
|
568
|
+
doc.addPage();
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
doc.save(toPdfFileName(fileName));
|
|
573
|
+
} finally {
|
|
574
|
+
preview.parentElement?.remove();
|
|
575
|
+
}
|
|
576
|
+
};
|