@h-ai/ui 0.1.0-alpha.11 → 0.1.0-alpha.13

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.
@@ -395,8 +395,8 @@
395
395
 
396
396
  .hai-markdown :global(.hai-md-code-block code) {
397
397
  /* ─── 基础色 ─── */
398
- --hai-hl-fg: oklch(var(--nc));
399
- --hai-hl-bg: transparent;
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 printDocumentAsPdf(htmlToPdfTitle(exportTitle), exportHtml);
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 buildDocumentExportHtml(title, bodyHtml) {
134
- return `<!doctype html>
135
- <html lang="zh-CN">
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
- </style>
259
- </head>
260
- <body>
261
- <main>
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 htmlToPdfTitle(title) {
283
+ function htmlToPdfFilename(title) {
268
284
  return title.endsWith('.pdf') ? title : `${title}.pdf`;
269
285
  }
270
- async function printDocumentAsPdf(pdfTitle, html) {
271
- const iframe = document.createElement('iframe');
272
- iframe.setAttribute('aria-hidden', 'true');
273
- iframe.style.position = 'fixed';
274
- iframe.style.right = '0';
275
- iframe.style.bottom = '0';
276
- iframe.style.width = '0';
277
- iframe.style.height = '0';
278
- iframe.style.border = '0';
279
- document.body.append(iframe);
280
- await new Promise((resolve) => {
281
- iframe.onload = () => resolve();
282
- iframe.srcdoc = html;
283
- });
284
- const previewWindow = iframe.contentWindow;
285
- if (!previewWindow) {
286
- iframe.remove();
287
- return;
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
- previewWindow.document.title = pdfTitle;
290
- previewWindow.focus();
291
- // PDF 导出当前走浏览器打印能力,让用户在不额外引入 PDF 库的前提下使用系统“另存为 PDF”。
292
- const cleanup = () => {
293
- window.setTimeout(() => {
294
- iframe.remove();
295
- }, 240);
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
- previewWindow.addEventListener('afterprint', cleanup, { once: true });
298
- previewWindow.print();
299
- window.setTimeout(cleanup, 1200);
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
  }
@@ -11,6 +11,10 @@ const DEFAULT_OPTIONS = {
11
11
  enableHighlight: true,
12
12
  showCopyButton: true,
13
13
  showRunButton: false,
14
+ showCodePreviewToggle: false,
15
+ codeViewCodeLabel: 'Code',
16
+ codeViewPreviewLabel: 'Preview',
17
+ codePreviewHint: '',
14
18
  breaks: true,
15
19
  };
16
20
  const SAFE_LINK_HREF_REGEX = /^(?:https?:\/\/|\/|#|mailto:)/i;
@@ -122,17 +126,28 @@ function createRendererObject(options, state) {
122
126
  const copyBtn = options.showCopyButton
123
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>`
124
128
  : '';
125
- // runBtn exposes the run action when enabled.
126
- const runBtn = options.showRunButton
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
127
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>`
128
143
  : '';
129
- // previewHost reserves the DOM slot for run previews.
130
- 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
131
146
  ? `<div class="hai-md-code-preview-slot" data-code-preview-host="${escapeHtml(codeBlockId)}"></div>`
132
147
  : '';
133
- 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">`
134
149
  + `<div class="hai-md-code-header">`
135
- + `<div class="hai-md-code-header-main">${langLabel}</div>`
150
+ + `<div class="hai-md-code-header-main">${codePreviewToggle}${langLabel}${previewHint}</div>`
136
151
  + `<div class="hai-md-code-actions">${runBtn}${copyBtn}</div>`
137
152
  + `</div>`
138
153
  + `<pre><code class="hai-hl${highlightLanguage ? ` language-${escapeHtml(highlightLanguage)}` : ''}">${highlighted}</code></pre>`
@@ -65,7 +65,7 @@ export type MarkdownBlockFormatKind = 'heading' | 'bullet';
65
65
  /**
66
66
  * Rich block-style actions used by the current editor toolbar.
67
67
  */
68
- export type MarkdownBlockStyleKind = 'paragraph' | 'heading1' | 'heading2' | 'heading3' | 'heading4';
68
+ export type MarkdownBlockStyleKind = 'paragraph' | 'heading1' | 'heading2' | 'heading3' | 'heading4' | 'orderedList' | 'bulletList';
69
69
  /**
70
70
  * Inline format actions for the editor toolbar.
71
71
  * `highlight` and `link` stay in the public union so older integrations keep compiling.
@@ -75,6 +75,14 @@ export type MarkdownInlineFormatKind = 'bold' | 'italic' | 'strike' | 'underline
75
75
  * Paragraph alignment actions for the editor toolbar.
76
76
  */
77
77
  export type MarkdownTextAlignKind = 'left' | 'center' | 'right' | 'justify';
78
+ export interface MarkdownSelectionSnapshot {
79
+ /** Selection start offset within the plain-text document. */
80
+ start: number;
81
+ /** Selection end offset within the plain-text document. */
82
+ end: number;
83
+ /** Selected plain text, used as a fallback when structure changes. */
84
+ text: string;
85
+ }
78
86
  /**
79
87
  * 颜色面板一次只会修改前景色或背景色中的一个维度。
80
88
  */
@@ -83,6 +91,18 @@ export interface MarkdownColorFormatRequest {
83
91
  target: 'text' | 'background';
84
92
  /** 颜色值为空时表示恢复默认。 */
85
93
  value: string | null;
94
+ /** 当前工具条记住的选中文本,供外层在 DOM 重建后兜底恢复定位。 */
95
+ selectedText?: string;
96
+ /** 当前工具条记住的精确选区偏移,优先用于重复文本场景的恢复。 */
97
+ selectionSnapshot?: MarkdownSelectionSnapshot;
98
+ }
99
+ export interface MarkdownLinkFormatRequest {
100
+ /** 为空时表示移除当前链接。 */
101
+ href: string | null;
102
+ /** 当前工具条记住的精确选区偏移。 */
103
+ selectionSnapshot?: MarkdownSelectionSnapshot;
104
+ /** 当前工具条记住的选中文本,供宿主做文本兜底。 */
105
+ selectedText?: string;
86
106
  }
87
107
  export interface MarkdownToolbarDownloadAction {
88
108
  /** Action id used to identify the download target. */
@@ -114,6 +134,10 @@ export interface AiDocumentEditorProps {
114
134
  showCopyButton?: boolean;
115
135
  /** Whether to show the run button on code blocks. */
116
136
  showRunButton?: boolean;
137
+ /** Whether to show the code/preview toggle on code blocks. */
138
+ showCodePreviewToggle?: boolean;
139
+ /** Optional helper text displayed beside language when preview toggle is enabled. */
140
+ codePreviewHint?: string;
117
141
  /** Whether syntax highlighting is enabled. */
118
142
  enableHighlight?: boolean;
119
143
  /** Whether soft line breaks are rendered as <br>. */
@@ -187,7 +211,11 @@ export interface AiDocumentEditorProps {
187
211
  /** Paragraph alignment handler from the selection toolbar. */
188
212
  onapplyalignment?: (kind: MarkdownTextAlignKind) => void;
189
213
  /** Link add / edit / remove handler from the selection toolbar. */
190
- onapplylink?: (href: string | null) => void;
214
+ onapplylink?: BivariantCallback<[
215
+ href: string | null,
216
+ selectionSnapshot?: MarkdownSelectionSnapshot,
217
+ selectedText?: string
218
+ ], void>;
191
219
  /** Text or background color handler from the selection toolbar. */
192
220
  onapplycolor?: (request: MarkdownColorFormatRequest) => void;
193
221
  /** Selection copy handler for custom behavior. */
@@ -7,7 +7,8 @@ const HEX_COLOR_REGEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
7
7
  */
8
8
  const ALIGN_VALUE_REGEX = /^(?:left|center|right|justify)$/i;
9
9
  const HAI_ALIGN_BLOCK_REGEX = /^<hai-align\s+value="(left|center|right|justify)">\n?([\s\S]+?)\n?<\/hai-align>\n{0,2}/i;
10
- const HAI_SPAN_INLINE_REGEX = /^<hai-span(?:\s+color="([^"]+)")?(?:\s+bg="([^"]+)")?\s*>([\s\S]+?)<\/hai-span>/i;
10
+ const HAI_SPAN_INLINE_REGEX = /^<hai-span\b([^>]*)>([\s\S]+?)<\/hai-span>/i;
11
+ const HAI_SPAN_ATTR_REGEX = /\b(color|bg)="([^"]+)"/gi;
11
12
  const HAI_UNDERLINE_REGEX = /^\+\+((?:\\.|[\s\S])+?)\+\+/;
12
13
  const HAI_HIGHLIGHT_REGEX = /^==((?:\\.|[\s\S])+?)==/;
13
14
  /**
@@ -55,6 +56,21 @@ function sanitizeAlign(value) {
55
56
  ? normalized
56
57
  : undefined;
57
58
  }
59
+ function parseHaiSpanAttributes(rawAttributes) {
60
+ let color;
61
+ let background;
62
+ for (const match of rawAttributes.matchAll(HAI_SPAN_ATTR_REGEX)) {
63
+ const [, attrName, attrValue] = match;
64
+ if (attrName === 'color') {
65
+ color = sanitizeColor(attrValue) ?? color;
66
+ continue;
67
+ }
68
+ if (attrName === 'bg') {
69
+ background = sanitizeColor(attrValue) ?? background;
70
+ }
71
+ }
72
+ return { color, background };
73
+ }
58
74
  /**
59
75
  * ChatWorkspace 编辑器会把“非标准 Markdown”能力写成自定义标签,
60
76
  * 这里把它们注册为 marked 扩展,保证普通 Markdown 和编辑器增强能力共享同一条渲染链路。
@@ -101,14 +117,13 @@ export function createEditorMarkdownExtensions() {
101
117
  if (!match) {
102
118
  return undefined;
103
119
  }
104
- const color = sanitizeColor(match[1]);
105
- const background = sanitizeColor(match[2]);
120
+ const { color, background } = parseHaiSpanAttributes(match[1] ?? '');
106
121
  return {
107
122
  type: 'haiSpan',
108
123
  raw: match[0],
109
124
  color,
110
125
  background,
111
- tokens: this.lexer.inlineTokens(match[3]),
126
+ tokens: this.lexer.inlineTokens(match[2]),
112
127
  };
113
128
  },
114
129
  renderer(token) {
@@ -15,3 +15,5 @@ export type { AiDocumentEditorProps, MarkdownBlockFormatKind, MarkdownBlockStyle
15
15
  export { parseMarkdown } from './markdown-parse.js';
16
16
  export type { MarkdownParseOptions } from './markdown-parse.js';
17
17
  export { default as MarkdownRenderer } from './MarkdownRenderer.svelte';
18
+ export { default as AiTableEditor } from './table/AiTableEditor.svelte';
19
+ export type { AiTableColumn, AiTableColumnType, AiTableCopyPayload, AiTableData, AiTableDownloadPayload, AiTableEditorChangeAction, AiTableEditorChangePayload, AiTableEditorProps, AiTableRow, } from './table/table-types.js';
@@ -11,3 +11,4 @@ export { downloadAiDocument, resolveDocumentDownloadActions, resolveDocumentMark
11
11
  export { renderMarkdownDocument } from './document-parse.js';
12
12
  export { parseMarkdown } from './markdown-parse.js';
13
13
  export { default as MarkdownRenderer } from './MarkdownRenderer.svelte';
14
+ export { default as AiTableEditor } from './table/AiTableEditor.svelte';