@h-ai/ui 0.1.0-alpha.10 → 0.1.0-alpha.12
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/dist/components/compounds/Form.svelte +2 -0
- package/dist/components/compounds/Modal.svelte +251 -59
- package/dist/components/primitives/Button.svelte +2 -0
- package/dist/components/scenes/ai/AiDocumentEditor.svelte +1775 -331
- package/dist/components/scenes/ai/MarkdownRenderer.svelte +2 -2
- package/dist/components/scenes/ai/document-download.js +249 -75
- package/dist/components/scenes/ai/document-parse.d.ts +8 -0
- package/dist/components/scenes/ai/document-parse.js +25 -8
- package/dist/components/scenes/ai/document-types.d.ts +42 -4
- package/dist/components/scenes/ai/editor-markdown-extensions.d.ts +11 -0
- package/dist/components/scenes/ai/editor-markdown-extensions.js +184 -0
- package/dist/components/scenes/ai/index.d.ts +3 -1
- package/dist/components/scenes/ai/index.js +1 -0
- package/dist/components/scenes/ai/markdown-parse.js +2 -0
- package/dist/components/scenes/ai/table/AiTableEditor.svelte +1640 -0
- package/dist/components/scenes/ai/table/AiTableEditor.svelte.d.ts +6 -0
- package/dist/components/scenes/ai/table/table-types.d.ts +101 -0
- package/dist/components/scenes/ai/table/table-types.js +1 -0
- package/dist/messages/en-US.json +27 -0
- package/dist/messages/zh-CN.json +27 -0
- package/dist/types.d.ts +10 -0
- package/package.json +4 -2
|
@@ -395,8 +395,8 @@
|
|
|
395
395
|
|
|
396
396
|
.hai-markdown :global(.hai-md-code-block code) {
|
|
397
397
|
/* ─── 基础色 ─── */
|
|
398
|
-
--hai-hl-
|
|
399
|
-
--hai-hl-
|
|
398
|
+
--hai-hl-foreground: oklch(var(--nc));
|
|
399
|
+
--hai-hl-background: transparent;
|
|
400
400
|
|
|
401
401
|
/* ─── Token 颜色(DaisyUI oklch 自适应) ─── */
|
|
402
402
|
--hai-hl-token-keyword: oklch(0.7 0.15 280);
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import { uiM } from '../../../messages.js';
|
|
2
2
|
import { renderMarkdownDocument } from './document-parse.js';
|
|
3
|
+
const DOCUMENT_EXPORT_ROOT_CLASS = 'hai-ai-document-export';
|
|
4
|
+
const BROWSER_PDF_PAGE_HEIGHT_MM = 297;
|
|
5
|
+
const BROWSER_PDF_VIEWPORT_WIDTH_PX = 794;
|
|
6
|
+
const BROWSER_PDF_MARGIN_MM = [14, 12, 14, 12];
|
|
7
|
+
const BROWSER_PDF_CONTENT_WIDTH_MM = 210 - BROWSER_PDF_MARGIN_MM[1] - BROWSER_PDF_MARGIN_MM[3];
|
|
8
|
+
const BROWSER_PDF_CONTENT_HEIGHT_MM = BROWSER_PDF_PAGE_HEIGHT_MM - BROWSER_PDF_MARGIN_MM[0] - BROWSER_PDF_MARGIN_MM[2];
|
|
9
|
+
const BROWSER_PDF_PAGE_BREAK_SEARCH_RATIO = 0.12;
|
|
10
|
+
const BROWSER_PDF_PAGE_BREAK_SEARCH_MAX_PX = 160;
|
|
11
|
+
const BROWSER_PDF_PAGE_BREAK_SEARCH_MIN_PX = 24;
|
|
12
|
+
const BROWSER_PDF_PAGE_BREAK_SAMPLE_STEP_PX = 4;
|
|
13
|
+
const BROWSER_PDF_PAGE_BREAK_WHITE_THRESHOLD = 246;
|
|
14
|
+
const BROWSER_PDF_PAGE_BREAK_ALPHA_THRESHOLD = 12;
|
|
15
|
+
const BROWSER_PDF_PAGE_BREAK_EMPTY_ROW_MAX_INK = 1;
|
|
3
16
|
/**
|
|
4
17
|
* code 类型产物通常只有裸代码正文。
|
|
5
18
|
* 下载和预览都通过同一份规范化逻辑包装成 fenced markdown,避免两边导出的结构不一致。
|
|
@@ -69,7 +82,7 @@ export async function downloadAiDocument(request) {
|
|
|
69
82
|
}), `${baseName}.doc`);
|
|
70
83
|
return;
|
|
71
84
|
}
|
|
72
|
-
await
|
|
85
|
+
await downloadDocumentAsPdf(htmlToPdfFilename(exportTitle), rendered.html);
|
|
73
86
|
}
|
|
74
87
|
function createBuiltInDownloadAction(format) {
|
|
75
88
|
switch (format) {
|
|
@@ -130,19 +143,10 @@ function escapeHtml(value) {
|
|
|
130
143
|
.replace(/"/g, '"')
|
|
131
144
|
.replace(/'/g, ''');
|
|
132
145
|
}
|
|
133
|
-
function
|
|
134
|
-
return
|
|
135
|
-
|
|
136
|
-
<head>
|
|
137
|
-
<meta charset="utf-8" />
|
|
138
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
139
|
-
<title>${escapeHtml(title)}</title>
|
|
140
|
-
<style>
|
|
141
|
-
:root {
|
|
146
|
+
function buildDocumentExportStyles() {
|
|
147
|
+
return `
|
|
148
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} {
|
|
142
149
|
color-scheme: light;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
body {
|
|
146
150
|
margin: 0;
|
|
147
151
|
padding: 0;
|
|
148
152
|
font-family:
|
|
@@ -152,60 +156,60 @@ function buildDocumentExportHtml(title, bodyHtml) {
|
|
|
152
156
|
background: #ffffff;
|
|
153
157
|
}
|
|
154
158
|
|
|
155
|
-
main {
|
|
159
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} main {
|
|
156
160
|
max-width: 860px;
|
|
157
161
|
margin: 0 auto;
|
|
158
162
|
padding: 40px 32px 72px;
|
|
159
163
|
}
|
|
160
164
|
|
|
161
|
-
h1,
|
|
162
|
-
h2,
|
|
163
|
-
h3,
|
|
164
|
-
h4,
|
|
165
|
-
h5,
|
|
166
|
-
h6 {
|
|
165
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} h1,
|
|
166
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} h2,
|
|
167
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} h3,
|
|
168
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} h4,
|
|
169
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} h5,
|
|
170
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} h6 {
|
|
167
171
|
margin: 1.5em 0 0.7em;
|
|
168
172
|
line-height: 1.28;
|
|
169
173
|
color: #101827;
|
|
170
174
|
}
|
|
171
175
|
|
|
172
|
-
h1 {
|
|
176
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} h1 {
|
|
173
177
|
font-size: 30px;
|
|
174
178
|
padding-bottom: 0.42em;
|
|
175
179
|
border-bottom: 1px solid #dbe4f0;
|
|
176
180
|
}
|
|
177
181
|
|
|
178
|
-
h2 {
|
|
182
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} h2 {
|
|
179
183
|
font-size: 24px;
|
|
180
184
|
}
|
|
181
185
|
|
|
182
|
-
h3 {
|
|
186
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} h3 {
|
|
183
187
|
font-size: 20px;
|
|
184
188
|
}
|
|
185
189
|
|
|
186
|
-
p,
|
|
187
|
-
li,
|
|
188
|
-
blockquote,
|
|
189
|
-
td,
|
|
190
|
-
th {
|
|
190
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} p,
|
|
191
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} li,
|
|
192
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} blockquote,
|
|
193
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} td,
|
|
194
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} th {
|
|
191
195
|
font-size: 15px;
|
|
192
196
|
line-height: 1.82;
|
|
193
197
|
}
|
|
194
198
|
|
|
195
|
-
a {
|
|
199
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} a {
|
|
196
200
|
color: #2259d1;
|
|
197
201
|
text-decoration: none;
|
|
198
202
|
}
|
|
199
203
|
|
|
200
|
-
code,
|
|
201
|
-
pre,
|
|
202
|
-
kbd {
|
|
204
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} code,
|
|
205
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} pre,
|
|
206
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} kbd {
|
|
203
207
|
font-family:
|
|
204
208
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
|
205
209
|
"Liberation Mono", monospace;
|
|
206
210
|
}
|
|
207
211
|
|
|
208
|
-
pre {
|
|
212
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} pre {
|
|
209
213
|
padding: 16px;
|
|
210
214
|
border-radius: 18px;
|
|
211
215
|
overflow: auto;
|
|
@@ -213,40 +217,40 @@ function buildDocumentExportHtml(title, bodyHtml) {
|
|
|
213
217
|
color: #f8fafc;
|
|
214
218
|
}
|
|
215
219
|
|
|
216
|
-
code {
|
|
220
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} code {
|
|
217
221
|
white-space: pre-wrap;
|
|
218
222
|
}
|
|
219
223
|
|
|
220
|
-
:not(pre) > code {
|
|
224
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} :not(pre) > code {
|
|
221
225
|
padding: 0.18em 0.42em;
|
|
222
226
|
border-radius: 7px;
|
|
223
227
|
background: #eef2f8;
|
|
224
228
|
color: #132238;
|
|
225
229
|
}
|
|
226
230
|
|
|
227
|
-
blockquote {
|
|
231
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} blockquote {
|
|
228
232
|
margin: 1.1em 0;
|
|
229
233
|
padding: 0.9em 1.1em;
|
|
230
234
|
border-left: 4px solid #7fa4ff;
|
|
231
235
|
background: #f6f8fd;
|
|
232
236
|
}
|
|
233
237
|
|
|
234
|
-
table {
|
|
238
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} table {
|
|
235
239
|
width: 100%;
|
|
236
240
|
border-collapse: collapse;
|
|
237
241
|
}
|
|
238
242
|
|
|
239
|
-
th,
|
|
240
|
-
td {
|
|
243
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} th,
|
|
244
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} td {
|
|
241
245
|
padding: 12px 14px;
|
|
242
246
|
border: 1px solid #dbe4f0;
|
|
243
247
|
}
|
|
244
248
|
|
|
245
|
-
thead {
|
|
249
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} thead {
|
|
246
250
|
background: #f5f7fb;
|
|
247
251
|
}
|
|
248
252
|
|
|
249
|
-
img {
|
|
253
|
+
.${DOCUMENT_EXPORT_ROOT_CLASS} img {
|
|
250
254
|
max-width: 100%;
|
|
251
255
|
height: auto;
|
|
252
256
|
}
|
|
@@ -255,46 +259,216 @@ function buildDocumentExportHtml(title, bodyHtml) {
|
|
|
255
259
|
size: auto;
|
|
256
260
|
margin: 14mm 12mm;
|
|
257
261
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
+
`;
|
|
263
|
+
}
|
|
264
|
+
function buildDocumentExportBody(bodyHtml) {
|
|
265
|
+
return `<main>
|
|
262
266
|
${bodyHtml}
|
|
263
|
-
</main
|
|
267
|
+
</main>`;
|
|
268
|
+
}
|
|
269
|
+
function buildDocumentExportHtml(title, bodyHtml) {
|
|
270
|
+
return `<!doctype html>
|
|
271
|
+
<html lang="zh-CN">
|
|
272
|
+
<head>
|
|
273
|
+
<meta charset="utf-8" />
|
|
274
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
275
|
+
<title>${escapeHtml(title)}</title>
|
|
276
|
+
<style>${buildDocumentExportStyles()}</style>
|
|
277
|
+
</head>
|
|
278
|
+
<body class="${DOCUMENT_EXPORT_ROOT_CLASS}">
|
|
279
|
+
${buildDocumentExportBody(bodyHtml)}
|
|
264
280
|
</body>
|
|
265
281
|
</html>`;
|
|
266
282
|
}
|
|
267
|
-
function
|
|
283
|
+
function htmlToPdfFilename(title) {
|
|
268
284
|
return title.endsWith('.pdf') ? title : `${title}.pdf`;
|
|
269
285
|
}
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
286
|
+
function createBrowserPdfRenderHost(bodyHtml) {
|
|
287
|
+
const frame = document.createElement('iframe');
|
|
288
|
+
frame.setAttribute('aria-hidden', 'true');
|
|
289
|
+
frame.tabIndex = -1;
|
|
290
|
+
frame.style.position = 'fixed';
|
|
291
|
+
frame.style.left = '-10000px';
|
|
292
|
+
frame.style.top = '0';
|
|
293
|
+
frame.style.width = `${BROWSER_PDF_VIEWPORT_WIDTH_PX}px`;
|
|
294
|
+
frame.style.height = '1px';
|
|
295
|
+
frame.style.border = '0';
|
|
296
|
+
frame.style.opacity = '0';
|
|
297
|
+
frame.style.pointerEvents = 'none';
|
|
298
|
+
frame.style.background = '#ffffff';
|
|
299
|
+
document.body.append(frame);
|
|
300
|
+
const frameDocument = frame.contentDocument;
|
|
301
|
+
const frameWindow = frame.contentWindow;
|
|
302
|
+
if (!frameDocument || !frameWindow) {
|
|
303
|
+
frame.remove();
|
|
304
|
+
throw new Error('Failed to create PDF export iframe');
|
|
305
|
+
}
|
|
306
|
+
frameDocument.open();
|
|
307
|
+
frameDocument.write(buildDocumentExportHtml('PDF Export', bodyHtml));
|
|
308
|
+
frameDocument.close();
|
|
309
|
+
const renderRoot = frameDocument.querySelector(`.${DOCUMENT_EXPORT_ROOT_CLASS}`);
|
|
310
|
+
if (!renderRoot) {
|
|
311
|
+
frame.remove();
|
|
312
|
+
throw new Error('Failed to prepare PDF export document');
|
|
288
313
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
314
|
+
for (const image of Array.from(frameDocument.images)) {
|
|
315
|
+
image.loading = 'eager';
|
|
316
|
+
image.decoding = 'sync';
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
cleanup: () => frame.remove(),
|
|
320
|
+
frameElement: frame,
|
|
321
|
+
frameDocument,
|
|
322
|
+
frameWindow,
|
|
323
|
+
renderRoot,
|
|
296
324
|
};
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
325
|
+
}
|
|
326
|
+
function syncBrowserPdfRenderHostSize(frameElement, renderRoot) {
|
|
327
|
+
const renderHeight = Math.max(renderRoot.scrollHeight, renderRoot.offsetHeight, renderRoot.clientHeight, 1);
|
|
328
|
+
frameElement.style.height = `${Math.ceil(renderHeight)}px`;
|
|
329
|
+
}
|
|
330
|
+
function waitForNextPaint(view) {
|
|
331
|
+
return new Promise((resolve) => {
|
|
332
|
+
const requestAnimationFrame = view?.requestAnimationFrame?.bind(view) ?? window.requestAnimationFrame;
|
|
333
|
+
if (typeof requestAnimationFrame !== 'function') {
|
|
334
|
+
resolve();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
requestAnimationFrame(() => resolve());
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
async function waitForBrowserPdfRenderReady(frameElement, frameDocument, frameWindow, renderRoot) {
|
|
341
|
+
await waitForNextPaint(frameWindow);
|
|
342
|
+
await waitForNextPaint(frameWindow);
|
|
343
|
+
const imageElements = Array.from(frameDocument.querySelectorAll('img'));
|
|
344
|
+
await Promise.all(imageElements.map((image) => {
|
|
345
|
+
if (image.complete) {
|
|
346
|
+
return Promise.resolve();
|
|
347
|
+
}
|
|
348
|
+
return new Promise((resolve) => {
|
|
349
|
+
image.addEventListener('load', () => resolve(), { once: true });
|
|
350
|
+
image.addEventListener('error', () => resolve(), { once: true });
|
|
351
|
+
});
|
|
352
|
+
}));
|
|
353
|
+
if (typeof frameDocument.fonts?.ready?.then === 'function') {
|
|
354
|
+
await frameDocument.fonts.ready;
|
|
355
|
+
}
|
|
356
|
+
syncBrowserPdfRenderHostSize(frameElement, renderRoot);
|
|
357
|
+
await waitForNextPaint(frameWindow);
|
|
358
|
+
}
|
|
359
|
+
function createPagedPdfFromCanvas(JsPdfConstructor, canvas) {
|
|
360
|
+
const pdfDocument = new JsPdfConstructor({
|
|
361
|
+
unit: 'mm',
|
|
362
|
+
format: 'a4',
|
|
363
|
+
orientation: 'portrait',
|
|
364
|
+
compress: true,
|
|
365
|
+
});
|
|
366
|
+
const pageCanvas = document.createElement('canvas');
|
|
367
|
+
const pageContext = pageCanvas.getContext('2d');
|
|
368
|
+
if (!pageContext) {
|
|
369
|
+
throw new Error('Failed to prepare PDF page canvas');
|
|
370
|
+
}
|
|
371
|
+
const pixelsPerMillimeter = canvas.width / BROWSER_PDF_CONTENT_WIDTH_MM;
|
|
372
|
+
const pageHeightPx = Math.max(1, Math.floor(BROWSER_PDF_CONTENT_HEIGHT_MM * pixelsPerMillimeter));
|
|
373
|
+
const sourceContext = canvas.getContext('2d', { willReadFrequently: true });
|
|
374
|
+
for (let pageIndex = 0, sourceY = 0; sourceY < canvas.height; pageIndex += 1) {
|
|
375
|
+
const remainingHeightPx = canvas.height - sourceY;
|
|
376
|
+
const sliceHeightPx = remainingHeightPx > pageHeightPx && sourceContext
|
|
377
|
+
? resolveCanvasPageSliceHeight(sourceContext, canvas, sourceY, pageHeightPx)
|
|
378
|
+
: Math.min(pageHeightPx, remainingHeightPx);
|
|
379
|
+
pageCanvas.width = canvas.width;
|
|
380
|
+
pageCanvas.height = sliceHeightPx;
|
|
381
|
+
pageContext.fillStyle = '#ffffff';
|
|
382
|
+
pageContext.fillRect(0, 0, pageCanvas.width, pageCanvas.height);
|
|
383
|
+
pageContext.drawImage(canvas, 0, sourceY, canvas.width, sliceHeightPx, 0, 0, canvas.width, sliceHeightPx);
|
|
384
|
+
if (pageIndex > 0) {
|
|
385
|
+
pdfDocument.addPage();
|
|
386
|
+
}
|
|
387
|
+
pdfDocument.addImage(pageCanvas.toDataURL('image/png'), 'PNG', BROWSER_PDF_MARGIN_MM[3], BROWSER_PDF_MARGIN_MM[0], BROWSER_PDF_CONTENT_WIDTH_MM, sliceHeightPx / pixelsPerMillimeter, undefined, 'FAST');
|
|
388
|
+
sourceY += sliceHeightPx;
|
|
389
|
+
}
|
|
390
|
+
return pdfDocument.output('blob');
|
|
391
|
+
}
|
|
392
|
+
function resolveCanvasPageSliceHeight(sourceContext, canvas, sourceY, pageHeightPx) {
|
|
393
|
+
const searchRadiusPx = Math.max(BROWSER_PDF_PAGE_BREAK_SEARCH_MIN_PX, Math.min(BROWSER_PDF_PAGE_BREAK_SEARCH_MAX_PX, Math.floor(pageHeightPx * BROWSER_PDF_PAGE_BREAK_SEARCH_RATIO)));
|
|
394
|
+
const scanStartY = Math.max(sourceY + 1, sourceY + pageHeightPx - searchRadiusPx);
|
|
395
|
+
const scanEndY = Math.min(sourceY + pageHeightPx, canvas.height);
|
|
396
|
+
const scanHeight = scanEndY - scanStartY;
|
|
397
|
+
if (scanHeight <= 0) {
|
|
398
|
+
return Math.min(pageHeightPx, canvas.height - sourceY);
|
|
399
|
+
}
|
|
400
|
+
const imageData = sourceContext.getImageData(0, scanStartY, canvas.width, scanHeight);
|
|
401
|
+
const bestBreakY = findCanvasPageBreakY(imageData, scanStartY);
|
|
402
|
+
return Math.max(1, Math.min(bestBreakY - sourceY, canvas.height - sourceY));
|
|
403
|
+
}
|
|
404
|
+
function findCanvasPageBreakY(imageData, scanStartY) {
|
|
405
|
+
const rowWidth = imageData.width;
|
|
406
|
+
const rowHeight = imageData.height;
|
|
407
|
+
const rowStride = rowWidth * 4;
|
|
408
|
+
let bestEmptyRowY = 0;
|
|
409
|
+
let bestInkRowY = 0;
|
|
410
|
+
let lowestInkScore = Number.POSITIVE_INFINITY;
|
|
411
|
+
for (let rowIndex = 0; rowIndex < rowHeight; rowIndex += 1) {
|
|
412
|
+
const rowInkScore = measureCanvasRowInk(imageData.data, rowIndex * rowStride, rowWidth);
|
|
413
|
+
if (rowInkScore <= BROWSER_PDF_PAGE_BREAK_EMPTY_ROW_MAX_INK) {
|
|
414
|
+
bestEmptyRowY = scanStartY + rowIndex + 1;
|
|
415
|
+
}
|
|
416
|
+
if (rowInkScore <= lowestInkScore) {
|
|
417
|
+
lowestInkScore = rowInkScore;
|
|
418
|
+
bestInkRowY = scanStartY + rowIndex + 1;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return bestEmptyRowY || bestInkRowY || (scanStartY + rowHeight);
|
|
422
|
+
}
|
|
423
|
+
function measureCanvasRowInk(pixels, rowOffset, rowWidth) {
|
|
424
|
+
let inkScore = 0;
|
|
425
|
+
for (let column = 0; column < rowWidth; column += BROWSER_PDF_PAGE_BREAK_SAMPLE_STEP_PX) {
|
|
426
|
+
const pixelOffset = rowOffset + (column * 4);
|
|
427
|
+
const alpha = pixels[pixelOffset + 3];
|
|
428
|
+
if (alpha < BROWSER_PDF_PAGE_BREAK_ALPHA_THRESHOLD) {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const red = pixels[pixelOffset];
|
|
432
|
+
const green = pixels[pixelOffset + 1];
|
|
433
|
+
const blue = pixels[pixelOffset + 2];
|
|
434
|
+
if (red < BROWSER_PDF_PAGE_BREAK_WHITE_THRESHOLD
|
|
435
|
+
|| green < BROWSER_PDF_PAGE_BREAK_WHITE_THRESHOLD
|
|
436
|
+
|| blue < BROWSER_PDF_PAGE_BREAK_WHITE_THRESHOLD) {
|
|
437
|
+
inkScore += 1;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return inkScore;
|
|
441
|
+
}
|
|
442
|
+
async function renderBrowserPdfCanvas(renderHost) {
|
|
443
|
+
syncBrowserPdfRenderHostSize(renderHost.frameElement, renderHost.renderRoot);
|
|
444
|
+
const { default: html2canvas } = await import('html2canvas');
|
|
445
|
+
return html2canvas(renderHost.renderRoot, {
|
|
446
|
+
backgroundColor: '#ffffff',
|
|
447
|
+
useCORS: true,
|
|
448
|
+
scale: 2,
|
|
449
|
+
logging: false,
|
|
450
|
+
width: Math.max(renderHost.renderRoot.scrollWidth, renderHost.renderRoot.clientWidth, BROWSER_PDF_VIEWPORT_WIDTH_PX),
|
|
451
|
+
height: Math.max(renderHost.renderRoot.scrollHeight, renderHost.renderRoot.clientHeight, 1),
|
|
452
|
+
windowWidth: Math.max(renderHost.renderRoot.scrollWidth, renderHost.renderRoot.clientWidth, BROWSER_PDF_VIEWPORT_WIDTH_PX),
|
|
453
|
+
windowHeight: Math.max(renderHost.renderRoot.scrollHeight, renderHost.renderRoot.clientHeight, 1),
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
let jsPdfModulePromise;
|
|
457
|
+
async function loadJsPdf() {
|
|
458
|
+
jsPdfModulePromise ??= import('jspdf');
|
|
459
|
+
return jsPdfModulePromise;
|
|
460
|
+
}
|
|
461
|
+
async function downloadDocumentAsPdf(filename, bodyHtml) {
|
|
462
|
+
const renderHost = createBrowserPdfRenderHost(bodyHtml);
|
|
463
|
+
try {
|
|
464
|
+
await waitForBrowserPdfRenderReady(renderHost.frameElement, renderHost.frameDocument, renderHost.frameWindow, renderHost.renderRoot);
|
|
465
|
+
const [{ jsPDF }, canvas] = await Promise.all([
|
|
466
|
+
loadJsPdf(),
|
|
467
|
+
renderBrowserPdfCanvas(renderHost),
|
|
468
|
+
]);
|
|
469
|
+
downloadBlob(createPagedPdfFromCanvas(jsPDF, canvas), filename);
|
|
470
|
+
}
|
|
471
|
+
finally {
|
|
472
|
+
renderHost.cleanup();
|
|
473
|
+
}
|
|
300
474
|
}
|
|
@@ -11,6 +11,14 @@ export interface MarkdownDocumentParseOptions {
|
|
|
11
11
|
showCopyButton?: boolean;
|
|
12
12
|
/** Whether to render the run button and preview slot. */
|
|
13
13
|
showRunButton?: boolean;
|
|
14
|
+
/** Whether to render code/preview tabs in code-block headers. */
|
|
15
|
+
showCodePreviewToggle?: boolean;
|
|
16
|
+
/** Label used for the "code" tab when preview toggle is enabled. */
|
|
17
|
+
codeViewCodeLabel?: string;
|
|
18
|
+
/** Label used for the "preview" tab when preview toggle is enabled. */
|
|
19
|
+
codeViewPreviewLabel?: string;
|
|
20
|
+
/** Optional helper text displayed beside language in code headers. */
|
|
21
|
+
codePreviewHint?: string;
|
|
14
22
|
/** Whether soft line breaks are converted to <br>. */
|
|
15
23
|
breaks?: boolean;
|
|
16
24
|
}
|
|
@@ -4,12 +4,17 @@
|
|
|
4
4
|
* Extracts HTML, outline, and code block metadata for document-mode rendering.
|
|
5
5
|
*/
|
|
6
6
|
import { Marked } from 'marked';
|
|
7
|
+
import { createEditorMarkdownExtensions, extractPlainTextFromTokens, } from './editor-markdown-extensions.js';
|
|
7
8
|
import { highlightCode, isLanguageSupported } from './highlight.js';
|
|
8
9
|
/** Default parsing options for document mode. */
|
|
9
10
|
const DEFAULT_OPTIONS = {
|
|
10
11
|
enableHighlight: true,
|
|
11
12
|
showCopyButton: true,
|
|
12
13
|
showRunButton: false,
|
|
14
|
+
showCodePreviewToggle: false,
|
|
15
|
+
codeViewCodeLabel: 'Code',
|
|
16
|
+
codeViewPreviewLabel: 'Preview',
|
|
17
|
+
codePreviewHint: '',
|
|
13
18
|
breaks: true,
|
|
14
19
|
};
|
|
15
20
|
const SAFE_LINK_HREF_REGEX = /^(?:https?:\/\/|\/|#|mailto:)/i;
|
|
@@ -82,8 +87,8 @@ function createRendererObject(options, state) {
|
|
|
82
87
|
heading(token) {
|
|
83
88
|
// depth is clamped to the valid heading range.
|
|
84
89
|
const depth = Math.max(1, Math.min(6, token.depth));
|
|
85
|
-
// text
|
|
86
|
-
const text = token.
|
|
90
|
+
// text 需要剥离自定义 span / 强调等 inline token,保证目录显示的是用户可见标题。
|
|
91
|
+
const text = extractPlainTextFromTokens(token.tokens).trim();
|
|
87
92
|
// id is the stable anchor for outline navigation.
|
|
88
93
|
const id = createHeadingId(state, text);
|
|
89
94
|
state.outline.push({
|
|
@@ -121,17 +126,28 @@ function createRendererObject(options, state) {
|
|
|
121
126
|
const copyBtn = options.showCopyButton
|
|
122
127
|
? `<button type="button" class="hai-md-code-action hai-md-copy-btn" data-copy-code aria-label="Copy code"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>`
|
|
123
128
|
: '';
|
|
124
|
-
//
|
|
125
|
-
const
|
|
129
|
+
// codePreviewToggle replaces run when enabled to provide a cleaner code/preview switch.
|
|
130
|
+
const codePreviewToggle = options.showCodePreviewToggle
|
|
131
|
+
? `<div class="hai-md-code-view-switch" role="group" aria-label="${escapeHtml(options.codeViewPreviewLabel)}">`
|
|
132
|
+
+ `<button type="button" class="hai-md-code-view-btn" data-code-view-toggle data-code-view="code" data-code-block-id="${escapeHtml(codeBlockId)}" aria-pressed="true">${escapeHtml(options.codeViewCodeLabel)}</button>`
|
|
133
|
+
+ `<button type="button" class="hai-md-code-view-btn" data-code-view-toggle data-code-view="preview" data-code-block-id="${escapeHtml(codeBlockId)}" aria-pressed="false">${escapeHtml(options.codeViewPreviewLabel)}</button>`
|
|
134
|
+
+ `</div>`
|
|
135
|
+
: '';
|
|
136
|
+
// hint text is optional and only rendered when the toggle is present.
|
|
137
|
+
const previewHint = options.showCodePreviewToggle && options.codePreviewHint
|
|
138
|
+
? `<span class="hai-md-code-preview-hint">${escapeHtml(options.codePreviewHint)}</span>`
|
|
139
|
+
: '';
|
|
140
|
+
// runBtn exposes the run action when enabled and no toggle is active.
|
|
141
|
+
const runBtn = options.showRunButton && !options.showCodePreviewToggle
|
|
126
142
|
? `<button type="button" class="hai-md-code-action hai-md-run-btn" data-run-code data-code-block-id="${escapeHtml(codeBlockId)}" aria-label="Run code"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5.14v14l11-7z"/></svg></button>`
|
|
127
143
|
: '';
|
|
128
|
-
// previewHost reserves the DOM slot for run previews.
|
|
129
|
-
const previewHost = options.showRunButton
|
|
144
|
+
// previewHost reserves the DOM slot for run previews or code/preview toggle mode.
|
|
145
|
+
const previewHost = options.showRunButton || options.showCodePreviewToggle
|
|
130
146
|
? `<div class="hai-md-code-preview-slot" data-code-preview-host="${escapeHtml(codeBlockId)}"></div>`
|
|
131
147
|
: '';
|
|
132
|
-
return `<div class="hai-md-code-block" data-code-block-id="${escapeHtml(codeBlockId)}">`
|
|
148
|
+
return `<div class="hai-md-code-block" data-code-block-id="${escapeHtml(codeBlockId)}" data-code-view="code">`
|
|
133
149
|
+ `<div class="hai-md-code-header">`
|
|
134
|
-
+ `<div class="hai-md-code-header-main">${langLabel}</div>`
|
|
150
|
+
+ `<div class="hai-md-code-header-main">${codePreviewToggle}${langLabel}${previewHint}</div>`
|
|
135
151
|
+ `<div class="hai-md-code-actions">${runBtn}${copyBtn}</div>`
|
|
136
152
|
+ `</div>`
|
|
137
153
|
+ `<pre><code class="hai-hl${highlightLanguage ? ` language-${escapeHtml(highlightLanguage)}` : ''}">${highlighted}</code></pre>`
|
|
@@ -205,6 +221,7 @@ function createRendererObject(options, state) {
|
|
|
205
221
|
function createMarkedInstance(options, state) {
|
|
206
222
|
return new Marked({
|
|
207
223
|
renderer: createRendererObject(options, state),
|
|
224
|
+
extensions: createEditorMarkdownExtensions(),
|
|
208
225
|
gfm: true,
|
|
209
226
|
breaks: options.breaks,
|
|
210
227
|
});
|
|
@@ -59,13 +59,31 @@ export interface MarkdownRewriteRequest {
|
|
|
59
59
|
title?: string;
|
|
60
60
|
}
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
62
|
+
* Legacy block-level format actions preserved for backward compatibility.
|
|
63
63
|
*/
|
|
64
64
|
export type MarkdownBlockFormatKind = 'heading' | 'bullet';
|
|
65
|
+
/**
|
|
66
|
+
* Rich block-style actions used by the current editor toolbar.
|
|
67
|
+
*/
|
|
68
|
+
export type MarkdownBlockStyleKind = 'paragraph' | 'heading1' | 'heading2' | 'heading3' | 'heading4';
|
|
65
69
|
/**
|
|
66
70
|
* Inline format actions for the editor toolbar.
|
|
71
|
+
* `highlight` and `link` stay in the public union so older integrations keep compiling.
|
|
67
72
|
*/
|
|
68
73
|
export type MarkdownInlineFormatKind = 'bold' | 'italic' | 'strike' | 'underline' | 'code' | 'highlight' | 'link';
|
|
74
|
+
/**
|
|
75
|
+
* Paragraph alignment actions for the editor toolbar.
|
|
76
|
+
*/
|
|
77
|
+
export type MarkdownTextAlignKind = 'left' | 'center' | 'right' | 'justify';
|
|
78
|
+
/**
|
|
79
|
+
* 颜色面板一次只会修改前景色或背景色中的一个维度。
|
|
80
|
+
*/
|
|
81
|
+
export interface MarkdownColorFormatRequest {
|
|
82
|
+
/** `text` 表示文字颜色,`background` 表示文本底色。 */
|
|
83
|
+
target: 'text' | 'background';
|
|
84
|
+
/** 颜色值为空时表示恢复默认。 */
|
|
85
|
+
value: string | null;
|
|
86
|
+
}
|
|
69
87
|
export interface MarkdownToolbarDownloadAction {
|
|
70
88
|
/** Action id used to identify the download target. */
|
|
71
89
|
id: string;
|
|
@@ -74,6 +92,13 @@ export interface MarkdownToolbarDownloadAction {
|
|
|
74
92
|
/** Optional short badge displayed beside the menu label. */
|
|
75
93
|
badgeLabel?: string;
|
|
76
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Callback props are exposed to Svelte consumers, so we keep parameter checks bivariant.
|
|
97
|
+
* This lets older handlers with narrower unions remain assignable while the component gains new APIs.
|
|
98
|
+
*/
|
|
99
|
+
type BivariantCallback<Args extends unknown[], Return = void> = {
|
|
100
|
+
bivarianceHack: (...args: Args) => Return;
|
|
101
|
+
}['bivarianceHack'];
|
|
77
102
|
export interface AiDocumentEditorProps {
|
|
78
103
|
/** Markdown source content. */
|
|
79
104
|
content?: string;
|
|
@@ -89,6 +114,10 @@ export interface AiDocumentEditorProps {
|
|
|
89
114
|
showCopyButton?: boolean;
|
|
90
115
|
/** Whether to show the run button on code blocks. */
|
|
91
116
|
showRunButton?: boolean;
|
|
117
|
+
/** Whether to show the code/preview toggle on code blocks. */
|
|
118
|
+
showCodePreviewToggle?: boolean;
|
|
119
|
+
/** Optional helper text displayed beside language when preview toggle is enabled. */
|
|
120
|
+
codePreviewHint?: string;
|
|
92
121
|
/** Whether syntax highlighting is enabled. */
|
|
93
122
|
enableHighlight?: boolean;
|
|
94
123
|
/** Whether soft line breaks are rendered as <br>. */
|
|
@@ -153,12 +182,21 @@ export interface AiDocumentEditorProps {
|
|
|
153
182
|
onhistory?: () => void;
|
|
154
183
|
/** Custom full-document copy handler. */
|
|
155
184
|
oncopydocument?: () => void | Promise<void>;
|
|
156
|
-
/**
|
|
157
|
-
onapplyblockformat?:
|
|
185
|
+
/** Legacy block-format action handler for older heading / bullet integrations. */
|
|
186
|
+
onapplyblockformat?: BivariantCallback<[kind: MarkdownBlockFormatKind], void>;
|
|
187
|
+
/** Rich block-style handler for paragraph and concrete heading-level changes. */
|
|
188
|
+
onapplyblockstyle?: BivariantCallback<[kind: MarkdownBlockStyleKind], void>;
|
|
158
189
|
/** Inline-format action handler from the selection toolbar. */
|
|
159
|
-
onapplyinlineformat?:
|
|
190
|
+
onapplyinlineformat?: BivariantCallback<[kind: MarkdownInlineFormatKind], void>;
|
|
191
|
+
/** Paragraph alignment handler from the selection toolbar. */
|
|
192
|
+
onapplyalignment?: (kind: MarkdownTextAlignKind) => void;
|
|
193
|
+
/** Link add / edit / remove handler from the selection toolbar. */
|
|
194
|
+
onapplylink?: (href: string | null) => void;
|
|
195
|
+
/** Text or background color handler from the selection toolbar. */
|
|
196
|
+
onapplycolor?: (request: MarkdownColorFormatRequest) => void;
|
|
160
197
|
/** Selection copy handler for custom behavior. */
|
|
161
198
|
oncopyselection?: () => void | Promise<void>;
|
|
162
199
|
/** Selection annotation handler. */
|
|
163
200
|
onannotation?: () => void | Promise<void>;
|
|
164
201
|
}
|
|
202
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Token, TokenizerAndRendererExtension } from 'marked';
|
|
2
|
+
/**
|
|
3
|
+
* Markdown heading 提取目录时需要一个“纯文本标题”。
|
|
4
|
+
* 这里显式递归常见 inline token,保证 `<hai-span>`、链接、强调等都能回落成纯文本。
|
|
5
|
+
*/
|
|
6
|
+
export declare function extractPlainTextFromTokens(tokens: Token[] | undefined): string;
|
|
7
|
+
/**
|
|
8
|
+
* ChatWorkspace 编辑器会把“非标准 Markdown”能力写成自定义标签,
|
|
9
|
+
* 这里把它们注册为 marked 扩展,保证普通 Markdown 和编辑器增强能力共享同一条渲染链路。
|
|
10
|
+
*/
|
|
11
|
+
export declare function createEditorMarkdownExtensions(): TokenizerAndRendererExtension[];
|