@vectojs/markdown 0.12.0 → 0.14.0

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/index.js CHANGED
@@ -30,14 +30,24 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ BlockAffordanceButton: () => BlockAffordanceButton,
34
+ BlockWithAffordances: () => BlockWithAffordances,
33
35
  CodeBlock: () => CodeBlock,
34
36
  Markdown: () => Markdown,
37
+ MathBlock: () => MathBlock,
35
38
  codeAtlas: () => codeAtlas,
36
39
  codeAtlasStats: () => codeAtlasStats,
40
+ escapeCsvField: () => escapeCsvField,
41
+ escapeMarkdownTableCell: () => escapeMarkdownTableCell,
42
+ extensionForLanguage: () => extensionForLanguage,
37
43
  isMathJaxReady: () => isMathJaxReady,
44
+ mimeForLanguage: () => mimeForLanguage,
38
45
  parseFrontMatterFields: () => parseFrontMatterFields,
39
46
  preloadMathJax: () => preloadMathJax,
40
- scanFrontMatter: () => scanFrontMatter
47
+ scanFrontMatter: () => scanFrontMatter,
48
+ tableContentOf: () => tableContentOf,
49
+ tableToCsv: () => tableToCsv,
50
+ tableToMarkdown: () => tableToMarkdown
41
51
  });
42
52
  module.exports = __toCommonJS(index_exports);
43
53
 
@@ -455,7 +465,251 @@ function createStreamController(host, options = {}) {
455
465
  }
456
466
 
457
467
  // src/Markdown.ts
468
+ var import_ui2 = require("@vectojs/ui");
469
+
470
+ // src/blockAffordances.ts
458
471
  var import_ui = require("@vectojs/ui");
472
+ var LANGUAGE_EXTENSIONS = {
473
+ bash: "sh",
474
+ c: "c",
475
+ cpp: "cpp",
476
+ cs: "cs",
477
+ css: "css",
478
+ diff: "diff",
479
+ dockerfile: "dockerfile",
480
+ go: "go",
481
+ graphql: "graphql",
482
+ haskell: "hs",
483
+ html: "html",
484
+ java: "java",
485
+ javascript: "js",
486
+ js: "js",
487
+ json: "json",
488
+ jsonc: "jsonc",
489
+ jsx: "jsx",
490
+ kotlin: "kt",
491
+ latex: "tex",
492
+ lua: "lua",
493
+ make: "mk",
494
+ markdown: "md",
495
+ md: "md",
496
+ nix: "nix",
497
+ php: "php",
498
+ python: "py",
499
+ py: "py",
500
+ ruby: "rb",
501
+ rust: "rs",
502
+ rs: "rs",
503
+ scss: "scss",
504
+ sh: "sh",
505
+ shell: "sh",
506
+ sql: "sql",
507
+ svelte: "svelte",
508
+ swift: "swift",
509
+ tex: "tex",
510
+ toml: "toml",
511
+ ts: "ts",
512
+ tsx: "tsx",
513
+ typescript: "ts",
514
+ vue: "vue",
515
+ xml: "xml",
516
+ yaml: "yaml",
517
+ yml: "yaml",
518
+ zig: "zig",
519
+ zsh: "sh"
520
+ };
521
+ function extensionForLanguage(lang) {
522
+ const first = lang.trim().toLowerCase().split(/[\s:,{]/)[0] ?? "";
523
+ return LANGUAGE_EXTENSIONS[first] ?? "txt";
524
+ }
525
+ function mimeForLanguage(lang) {
526
+ const ext = extensionForLanguage(lang);
527
+ if (ext === "json" || ext === "jsonc") return "application/json";
528
+ if (ext === "html") return "text/html";
529
+ if (ext === "css") return "text/css";
530
+ if (ext === "xml" || ext === "svelte" || ext === "vue") return "text/plain";
531
+ return "text/plain";
532
+ }
533
+ function escapeCsvField(value) {
534
+ let needsQuoting = false;
535
+ let hasQuote = false;
536
+ for (const char of value) {
537
+ if (char === '"') {
538
+ hasQuote = true;
539
+ needsQuoting = true;
540
+ break;
541
+ }
542
+ if (char === "," || char === "\n" || char === "\r") needsQuoting = true;
543
+ }
544
+ if (!needsQuoting) return value;
545
+ return hasQuote ? `"${value.replace(/"/g, '""')}"` : `"${value}"`;
546
+ }
547
+ function escapeMarkdownTableCell(cell) {
548
+ let needsEscaping = false;
549
+ for (const char of cell) {
550
+ if (char === "\\" || char === "|") {
551
+ needsEscaping = true;
552
+ break;
553
+ }
554
+ }
555
+ if (!needsEscaping) return cell;
556
+ return cell.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
557
+ }
558
+ function tableToCsv(table) {
559
+ const lines = [table.headers.map(escapeCsvField).join(",")];
560
+ for (const row of table.rows) lines.push(row.map(escapeCsvField).join(","));
561
+ return `\uFEFF${lines.join("\r\n")}`;
562
+ }
563
+ function tableToMarkdown(table) {
564
+ const header = `| ${table.headers.map(escapeMarkdownTableCell).join(" | ")} |`;
565
+ const divider = `| ${table.headers.map((_cell, index) => {
566
+ switch (table.align[index]) {
567
+ case "left":
568
+ return ":---";
569
+ case "center":
570
+ return ":---:";
571
+ case "right":
572
+ return "---:";
573
+ default:
574
+ return "---";
575
+ }
576
+ }).join(" | ")} |`;
577
+ const body = table.rows.map(
578
+ (row) => `| ${table.headers.map((_cell, index) => escapeMarkdownTableCell(row[index] ?? "")).join(" | ")} |`
579
+ );
580
+ return [header, divider, ...body].join("\n");
581
+ }
582
+ function defaultWriteClipboard(text) {
583
+ const clipboard = globalThis.navigator?.clipboard;
584
+ clipboard?.writeText?.(text);
585
+ }
586
+ function defaultSaveFile(filename, content, mimeType) {
587
+ const doc = globalThis.document;
588
+ if (!doc?.body) return;
589
+ const blob = new Blob([content], { type: mimeType });
590
+ const url = URL.createObjectURL(blob);
591
+ const anchor = doc.createElement("a");
592
+ anchor.href = url;
593
+ anchor.download = filename;
594
+ doc.body.appendChild(anchor);
595
+ anchor.click();
596
+ doc.body.removeChild(anchor);
597
+ URL.revokeObjectURL(url);
598
+ }
599
+ var BlockAffordanceButton = class _BlockAffordanceButton extends import_ui.Button {
600
+ constructor(label, successLabel, act, opts = {}) {
601
+ super(label, { ...opts, onClick: () => this.run() });
602
+ this.act = act;
603
+ this.restingLabel = label;
604
+ this.successLabel = successLabel;
605
+ this.width = Math.max(this.width, (0, import_ui.measureText)(successLabel, this.font) + 24);
606
+ }
607
+ act;
608
+ /** How long the confirmation label stays up, in ms. */
609
+ static FEEDBACK_MS = 1600;
610
+ restingLabel;
611
+ successLabel;
612
+ feedbackTimer;
613
+ /**
614
+ * Runs the action, then shows the confirmation.
615
+ *
616
+ * The action runs first and a throw propagates: a clipboard write the browser
617
+ * rejected must not be reported as a success.
618
+ */
619
+ run() {
620
+ this.act();
621
+ this.setTransientLabel(this.successLabel);
622
+ if (this.feedbackTimer !== void 0) clearTimeout(this.feedbackTimer);
623
+ this.feedbackTimer = setTimeout(() => {
624
+ this.setTransientLabel(this.restingLabel);
625
+ this.feedbackTimer = void 0;
626
+ }, _BlockAffordanceButton.FEEDBACK_MS);
627
+ }
628
+ setTransientLabel(label) {
629
+ this.label = label;
630
+ this.textWidth = (0, import_ui.measureText)(label, this.font);
631
+ this.scene?.markDirty();
632
+ }
633
+ /**
634
+ * The label a reader hears is the one they see, transient confirmation
635
+ * included, so an AT user gets the same feedback a sighted user does.
636
+ */
637
+ getA11yAttributes() {
638
+ return { ...super.getA11yAttributes(), label: this.label };
639
+ }
640
+ /** Clears the pending revert so a destroyed block leaves no timer behind. */
641
+ destroy() {
642
+ if (this.feedbackTimer !== void 0) {
643
+ clearTimeout(this.feedbackTimer);
644
+ this.feedbackTimer = void 0;
645
+ }
646
+ super.destroy();
647
+ }
648
+ };
649
+ var BlockWithAffordances = class _BlockWithAffordances extends import_ui.UIComponent {
650
+ constructor(block, controls) {
651
+ super();
652
+ this.block = block;
653
+ this.controls = controls;
654
+ this.add(block);
655
+ for (const control of controls) this.add(control);
656
+ this.layoutAffordances();
657
+ }
658
+ block;
659
+ controls;
660
+ /** Gap between the block's edges and the controls, in px. */
661
+ static INSET = 8;
662
+ /** Gap between adjacent controls, in px. */
663
+ static GAP = 6;
664
+ /**
665
+ * Places the controls right-aligned along the block's top edge.
666
+ *
667
+ * Laid out right-to-left from the block's right edge so the first control in
668
+ * the list ends up leftmost, which keeps DOM order (and therefore tab order and
669
+ * the a11y reading order) matching the visual order.
670
+ */
671
+ layoutAffordances() {
672
+ this.width = this.block.width;
673
+ this.height = this.block.height;
674
+ let right = this.block.width - _BlockWithAffordances.INSET;
675
+ for (let i = this.controls.length - 1; i >= 0; i--) {
676
+ const control = this.controls[i];
677
+ control.x = right - control.width;
678
+ control.y = _BlockWithAffordances.INSET;
679
+ right = control.x - _BlockWithAffordances.GAP;
680
+ }
681
+ }
682
+ /**
683
+ * Re-places the controls after the block's own box changed.
684
+ *
685
+ * Called by the owner when a block is resized or its content grew; the controls
686
+ * are anchored to the right edge, so a width change moves them.
687
+ */
688
+ refreshAffordances() {
689
+ this.layoutAffordances();
690
+ this.scene?.markDirty();
691
+ }
692
+ /** The wrapper is a pass-through: its size is the block's size. */
693
+ getLayoutControlledProperties() {
694
+ return ["x", "y"];
695
+ }
696
+ /**
697
+ * Projected as a group so assistive technology reports one labelled region
698
+ * containing the block and its controls, rather than two unrelated siblings.
699
+ */
700
+ getA11yAttributes() {
701
+ return { role: "group", pointerEvents: "none" };
702
+ }
703
+ render() {
704
+ }
705
+ };
706
+ function tableContentOf(token) {
707
+ return {
708
+ headers: token.header.map((cell) => cell.text),
709
+ rows: token.rows.map((row) => row.map((cell) => cell.text)),
710
+ align: token.align
711
+ };
712
+ }
459
713
 
460
714
  // src/frontMatter.ts
461
715
  var OPEN_RE = /^---[ \t]*\r?\n/;
@@ -700,7 +954,59 @@ function isFenceClosed(raw) {
700
954
  return false;
701
955
  }
702
956
  function paragraphHasImage(token) {
703
- return token.tokens?.some((child) => child.type === "image") === true;
957
+ return containsImage(token.tokens);
958
+ }
959
+ function containsImage(tokens) {
960
+ if (!tokens) return false;
961
+ for (const token of tokens) {
962
+ if (token.type === "image") return true;
963
+ if (containsImage(token.tokens)) return true;
964
+ }
965
+ return false;
966
+ }
967
+ function imagesOf(tokens) {
968
+ const images = [];
969
+ for (const token of tokens ?? []) {
970
+ if (token.type === "image") {
971
+ images.push(token);
972
+ continue;
973
+ }
974
+ images.push(...imagesOf(token.tokens));
975
+ }
976
+ return images;
977
+ }
978
+ function stripImages(token) {
979
+ const children = token.tokens;
980
+ if (!children) return token;
981
+ const kept = [];
982
+ for (const child of children) {
983
+ if (child.type === "image") continue;
984
+ const grandchildren = child.tokens;
985
+ if (grandchildren && containsImage(grandchildren)) {
986
+ const stripped = stripImages(child);
987
+ const remaining = stripped.tokens;
988
+ if (remaining && remaining.length > 0) kept.push(stripped);
989
+ continue;
990
+ }
991
+ kept.push(child);
992
+ }
993
+ return { ...token, tokens: kept };
994
+ }
995
+ function liftNestedImages(tokens) {
996
+ const lifted = [];
997
+ for (const token of tokens) {
998
+ if (token.type === "image") {
999
+ lifted.push(token);
1000
+ continue;
1001
+ }
1002
+ const children = token.tokens;
1003
+ if (children && containsImage(children)) {
1004
+ lifted.push(...liftNestedImages(children));
1005
+ continue;
1006
+ }
1007
+ lifted.push(token);
1008
+ }
1009
+ return lifted;
704
1010
  }
705
1011
  function lastIndexOfImage(tokens) {
706
1012
  for (let i = tokens.length - 1; i >= 0; i--) {
@@ -711,7 +1017,7 @@ function lastIndexOfImage(tokens) {
711
1017
  function expectedImageParagraphChildren(tokens) {
712
1018
  let children = 0;
713
1019
  let inTextRun = false;
714
- for (const token of tokens) {
1020
+ for (const token of liftNestedImages(tokens)) {
715
1021
  if (token.type === "image") {
716
1022
  children++;
717
1023
  inTextRun = false;
@@ -871,6 +1177,33 @@ var MarkdownContainer = class extends import_core.Entity {
871
1177
  render(_r) {
872
1178
  }
873
1179
  };
1180
+ var MathBlock = class extends MarkdownContainer {
1181
+ /**
1182
+ * The TeX source, exactly as written between the delimiters.
1183
+ *
1184
+ * Also the projected text and the accessible name, so this is the one string a
1185
+ * reader can find, select, and copy.
1186
+ */
1187
+ formula;
1188
+ /** The `data:image/svg+xml` URI of the typeset glyphs. */
1189
+ svgUri;
1190
+ constructor(formula, svgUri) {
1191
+ super();
1192
+ this.formula = formula;
1193
+ this.svgUri = svgUri;
1194
+ }
1195
+ getDevtoolsDescriptor() {
1196
+ return {
1197
+ kind: "MathBlock",
1198
+ groups: [
1199
+ {
1200
+ label: "Math",
1201
+ fields: [{ label: "formula", value: this.formula, readOnly: true }]
1202
+ }
1203
+ ]
1204
+ };
1205
+ }
1206
+ };
874
1207
  var KEYWORD_SETS = {
875
1208
  js: /* @__PURE__ */ new Set([
876
1209
  "const",
@@ -1120,7 +1453,7 @@ function highlightLine(line, lang, theme) {
1120
1453
  flush(theme.codeColor);
1121
1454
  return segments;
1122
1455
  }
1123
- var CodeBlock = class extends import_ui.UIComponent {
1456
+ var CodeBlock = class extends import_ui2.UIComponent {
1124
1457
  lines;
1125
1458
  grid = null;
1126
1459
  /** Raw (unhighlighted) lines of the last build, for prefix reuse in buildLines. */
@@ -1253,7 +1586,7 @@ var CodeBlock = class extends import_ui.UIComponent {
1253
1586
  this.height = this.pad * 2 + rawLines.length * this.lineH;
1254
1587
  }
1255
1588
  ensureGrid() {
1256
- const cellWidth = this.cellWidth || Math.max(1, (0, import_ui.measureText)("M", this.codeFont));
1589
+ const cellWidth = this.cellWidth || Math.max(1, (0, import_ui2.measureText)("M", this.codeFont));
1257
1590
  if (!this.grid || this.grid.source !== this.source || this.grid.font !== this.codeFont || this.grid.cellWidth !== cellWidth) {
1258
1591
  this.grid = (0, import_core.prepareContentGrid)(this.source, {
1259
1592
  font: this.codeFont,
@@ -1522,7 +1855,7 @@ function renderInlineToRichText(tokens, fallbackText, font, color, maxWidth, the
1522
1855
  if (spans.length === 0) {
1523
1856
  spans.push({ text: decodeEntities(fallbackText) });
1524
1857
  }
1525
- return new import_ui.RichText(spans, {
1858
+ return new import_ui2.RichText(spans, {
1526
1859
  font,
1527
1860
  color,
1528
1861
  maxWidth,
@@ -1531,12 +1864,24 @@ function renderInlineToRichText(tokens, fallbackText, font, color, maxWidth, the
1531
1864
  onLinkClick
1532
1865
  });
1533
1866
  }
1534
- var Markdown = class _Markdown extends import_ui.UIComponent {
1867
+ var Markdown = class _Markdown extends import_ui2.UIComponent {
1535
1868
  content;
1536
1869
  maxWidth;
1537
1870
  theme;
1538
1871
  onLinkClick;
1539
1872
  selectable;
1873
+ /**
1874
+ * Whether code blocks and tables carry copy / download controls.
1875
+ *
1876
+ * Read when a block entity is built, so it affects blocks rendered from here on
1877
+ * rather than retroactively; a document does not rebuild to gain or lose an
1878
+ * affordance.
1879
+ */
1880
+ blockAffordances;
1881
+ /** Clipboard writer used by the copy controls. */
1882
+ writeClipboard;
1883
+ /** File saver used by the download controls. */
1884
+ saveFile;
1540
1885
  activeBlockMetrics = null;
1541
1886
  /**
1542
1887
  * Called after a streamed append has re-laid-out the document.
@@ -1761,7 +2106,10 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
1761
2106
  this.onLinkClick = opts.onLinkClick;
1762
2107
  this.selectable = opts.selectable ?? true;
1763
2108
  this._userTiming = opts.userTiming ?? false;
1764
- this.content = new import_ui.Stack({ direction: "vertical", gap: 16 });
2109
+ this.blockAffordances = opts.blockAffordances ?? false;
2110
+ this.writeClipboard = opts.writeClipboard ?? defaultWriteClipboard;
2111
+ this.saveFile = opts.saveFile ?? defaultSaveFile;
2112
+ this.content = new import_ui2.Stack({ direction: "vertical", gap: 16 });
1765
2113
  this.add(this.content);
1766
2114
  this.rawMarkdown = "";
1767
2115
  this.setTokens([]);
@@ -1980,15 +2328,15 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
1980
2328
  switch (token.type) {
1981
2329
  case "heading":
1982
2330
  case "paragraph": {
1983
- if (entity instanceof import_ui.RichText) {
2331
+ if (entity instanceof import_ui2.RichText) {
1984
2332
  entity.setMaxWidth(availableWidth);
1985
2333
  return;
1986
2334
  }
1987
- if (entity instanceof import_ui.Stack) {
2335
+ if (entity instanceof import_ui2.Stack) {
1988
2336
  entity.maxWidth = availableWidth;
1989
2337
  for (const run of entity.children) {
1990
- if (run instanceof import_ui.RichText) run.setMaxWidth(availableWidth);
1991
- else if (run instanceof import_ui.Image) this.refitParagraphImage(run, availableWidth);
2338
+ if (run instanceof import_ui2.RichText) run.setMaxWidth(availableWidth);
2339
+ else if (run instanceof import_ui2.Image) this.refitParagraphImage(run, availableWidth);
1992
2340
  }
1993
2341
  entity.layout();
1994
2342
  }
@@ -2001,11 +2349,11 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
2001
2349
  }
2002
2350
  case "blockquote": {
2003
2351
  const bqToken = token;
2004
- const innerStack = entity.children.find((c) => c instanceof import_ui.Stack);
2352
+ const innerStack = entity.children.find((c) => c instanceof import_ui2.Stack);
2005
2353
  const border = entity.children.find((c) => c instanceof QuoteBorder);
2006
2354
  const indentStart = Math.min(16, availableWidth);
2007
2355
  const childWidth = Math.max(0, availableWidth - indentStart);
2008
- if (innerStack instanceof import_ui.Stack && bqToken.tokens) {
2356
+ if (innerStack instanceof import_ui2.Stack && bqToken.tokens) {
2009
2357
  let index = 0;
2010
2358
  for (const inner of bqToken.tokens) {
2011
2359
  if (!this.producesEntity(inner)) continue;
@@ -2028,15 +2376,15 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
2028
2376
  return;
2029
2377
  }
2030
2378
  case "list": {
2031
- if (!(entity instanceof import_ui.Stack)) return;
2379
+ if (!(entity instanceof import_ui2.Stack)) return;
2032
2380
  for (const item of entity.children) {
2033
- if (item instanceof import_ui.RichText) item.setMaxWidth(availableWidth);
2381
+ if (item instanceof import_ui2.RichText) item.setMaxWidth(availableWidth);
2034
2382
  }
2035
2383
  entity.layout();
2036
2384
  return;
2037
2385
  }
2038
2386
  case "table": {
2039
- if (entity instanceof import_ui.Table) entity.setWidth(availableWidth);
2387
+ if (entity instanceof import_ui2.Table) entity.setWidth(availableWidth);
2040
2388
  return;
2041
2389
  }
2042
2390
  case "hr": {
@@ -2044,7 +2392,7 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
2044
2392
  return;
2045
2393
  }
2046
2394
  default: {
2047
- if (entity instanceof import_ui.Text) entity.setMaxWidth(availableWidth);
2395
+ if (entity instanceof import_ui2.Text) entity.setMaxWidth(availableWidth);
2048
2396
  return;
2049
2397
  }
2050
2398
  }
@@ -2515,7 +2863,7 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
2515
2863
  }
2516
2864
  /** One text run of an image-bearing paragraph, as both paths build it. */
2517
2865
  inlineRunRichText(tokens, availableWidth, t) {
2518
- return new import_ui.RichText(this.inlineRunSpans(tokens, t), {
2866
+ return new import_ui2.RichText(this.inlineRunSpans(tokens, t), {
2519
2867
  font: `${t.fontSize}px ${t.bodyFont}`,
2520
2868
  color: t.textColor,
2521
2869
  maxWidth: availableWidth,
@@ -2549,10 +2897,77 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
2549
2897
  * policy for a zero-dimension source is a separate decision from notifying
2550
2898
  * the scene, which is the actual defect here.
2551
2899
  */
2900
+ /**
2901
+ * Wraps a block in its copy / download controls, or returns it untouched.
2902
+ *
2903
+ * The controls are built lazily through `make` so a document with
2904
+ * `blockAffordances` off pays nothing — not the closures, not the measurement
2905
+ * `BlockAffordanceButton` does in its constructor.
2906
+ */
2907
+ withBlockAffordances(block, make) {
2908
+ if (!this.blockAffordances) return block;
2909
+ const controls = make();
2910
+ return controls.length > 0 ? new BlockWithAffordances(block, controls) : block;
2911
+ }
2912
+ /** Copy and download controls for one fenced code block. */
2913
+ codeBlockAffordances(source, lang) {
2914
+ const opts = this.affordanceButtonOptions();
2915
+ return [
2916
+ new BlockAffordanceButton("Copy code", "Copied", () => this.writeClipboard(source), opts),
2917
+ new BlockAffordanceButton(
2918
+ "Download code",
2919
+ "Saved",
2920
+ () => this.saveFile(`code.${extensionForLanguage(lang)}`, source, mimeForLanguage(lang)),
2921
+ opts
2922
+ )
2923
+ ];
2924
+ }
2925
+ /** Copy (as Markdown) and download (as CSV) controls for one table. */
2926
+ tableAffordances(tblToken) {
2927
+ const content = tableContentOf(tblToken);
2928
+ const opts = this.affordanceButtonOptions();
2929
+ return [
2930
+ // Markdown rather than CSV for the clipboard: the reader copied it out of a
2931
+ // Markdown document and the overwhelmingly likely destination is another
2932
+ // one. CSV is what the download is for, where a spreadsheet is the target.
2933
+ new BlockAffordanceButton(
2934
+ "Copy table",
2935
+ "Copied",
2936
+ () => this.writeClipboard(tableToMarkdown(content)),
2937
+ opts
2938
+ ),
2939
+ new BlockAffordanceButton(
2940
+ "Download table",
2941
+ "Saved",
2942
+ () => this.saveFile("table.csv", tableToCsv(content), "text/csv;charset=utf-8"),
2943
+ opts
2944
+ )
2945
+ ];
2946
+ }
2947
+ /**
2948
+ * Button styling for the affordances, derived from the document theme.
2949
+ *
2950
+ * Themed rather than hardcoded so a light-theme document does not get the dark
2951
+ * default palette. `focusColor` is set explicitly from the theme's accent
2952
+ * because `Button`'s default cyan is tuned for the dark palette and reads as
2953
+ * off-brand elsewhere — while a focus ring is the one affordance a keyboard
2954
+ * user cannot do without.
2955
+ */
2956
+ affordanceButtonOptions() {
2957
+ return {
2958
+ font: `600 12px ${this.theme.bodyFont}`,
2959
+ padding: 6,
2960
+ radius: 6,
2961
+ bg: this.theme.codeBgColor,
2962
+ hoverBg: this.theme.tableHeaderBgColor,
2963
+ color: this.theme.textColor,
2964
+ focusColor: this.theme.codeColor
2965
+ };
2966
+ }
2552
2967
  paragraphImage(imgToken, availableWidth) {
2553
2968
  const initialWidth = Math.min(800, availableWidth);
2554
2969
  const initialHeight = Math.round(initialWidth * 0.6);
2555
- const img = new import_ui.Image(imgToken.href, {
2970
+ const img = new import_ui2.Image(imgToken.href, {
2556
2971
  width: initialWidth,
2557
2972
  height: initialHeight,
2558
2973
  alt: imgToken.text,
@@ -2571,7 +2986,7 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
2571
2986
  }
2572
2987
  /** One table cell entity, shared by the render arm and the streamed-table path. */
2573
2988
  tableCellRichText(cell, header, t) {
2574
- return new import_ui.RichText(this.tableCellSpans(cell, t), {
2989
+ return new import_ui2.RichText(this.tableCellSpans(cell, t), {
2575
2990
  font: `${t.fontSize - 2}px ${t.bodyFont}`,
2576
2991
  color: header ? t.headingColor : t.textColor,
2577
2992
  baseStyle: header ? { bold: true } : void 0,
@@ -2629,6 +3044,7 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
2629
3044
  itemIsInlineOnly(item) {
2630
3045
  const children = item.tokens;
2631
3046
  if (!children || children.length === 0) return true;
3047
+ if (containsImage(children)) return false;
2632
3048
  if (children.length === 1 && children[0].type === "paragraph") return true;
2633
3049
  return children.every((child) => _Markdown.INLINE_ITEM_TOKENS.has(child.type));
2634
3050
  }
@@ -2656,9 +3072,12 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
2656
3072
  listItemBlockStack(token, index, availableWidth, t) {
2657
3073
  const item = token.items[index];
2658
3074
  const children = item.tokens ?? [];
2659
- const stack = new import_ui.Stack({ direction: "vertical", gap: 4 });
3075
+ const stack = new import_ui2.Stack({ direction: "vertical", gap: 4 });
2660
3076
  const first = children[0];
2661
- const leadChildren = first && (first.type === "text" || first.type === "paragraph") ? [first] : [];
3077
+ const firstIsInline = Boolean(first) && (first.type === "text" || first.type === "paragraph");
3078
+ const leadHasImage = firstIsInline && containsImage(first.tokens);
3079
+ const leadChildren = firstIsInline ? [leadHasImage ? stripImages(first) : first] : [];
3080
+ const leadImages = leadHasImage ? imagesOf(first.tokens) : [];
2662
3081
  const leadToken = {
2663
3082
  ...token,
2664
3083
  items: token.items.map((it, i) => i === index ? { ...it, tokens: leadChildren } : it)
@@ -2671,6 +3090,13 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
2671
3090
  indentStart: indent,
2672
3091
  availableWidth: Math.max(1, availableWidth - indent)
2673
3092
  };
3093
+ for (const image of leadImages) {
3094
+ const el = this.paragraphImage(image, childMetrics.availableWidth);
3095
+ const wrapper = new MarkdownContainer();
3096
+ el.x = indent;
3097
+ wrapper.add(el);
3098
+ stack.add(wrapper);
3099
+ }
2674
3100
  for (let i = leadChildren.length; i < children.length; i++) {
2675
3101
  const el = this.renderTokenWithMetrics(children[i], childMetrics);
2676
3102
  if (!el) continue;
@@ -2718,7 +3144,7 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
2718
3144
  }
2719
3145
  /** Construct the `RichText` for one list item. */
2720
3146
  listItemRichText(token, index, availableWidth, t) {
2721
- return new import_ui.RichText(this.listItemSpans(token, index), {
3147
+ return new import_ui2.RichText(this.listItemSpans(token, index), {
2722
3148
  font: `${t.fontSize}px ${t.bodyFont}`,
2723
3149
  color: t.textColor,
2724
3150
  maxWidth: availableWidth,
@@ -2753,7 +3179,7 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
2753
3179
  * and keep stale spans. Bail when `loose` flips.
2754
3180
  */
2755
3181
  updateStreamedList(stack, oldToken, newToken) {
2756
- if (!(stack instanceof import_ui.Stack)) return false;
3182
+ if (!(stack instanceof import_ui2.Stack)) return false;
2757
3183
  if (newToken.items.length < oldToken.items.length || oldToken.items.length === 0) return false;
2758
3184
  if (oldToken.ordered !== newToken.ordered) return false;
2759
3185
  if ((oldToken.start ?? 1) !== (newToken.start ?? 1)) return false;
@@ -2762,7 +3188,7 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
2762
3188
  const lastRetained = oldToken.items.length - 1;
2763
3189
  for (let i = 0; i < lastRetained; i++) {
2764
3190
  if (oldToken.items[i].text !== newToken.items[i].text) return false;
2765
- const isStack = stack.children[i] instanceof import_ui.Stack;
3191
+ const isStack = stack.children[i] instanceof import_ui2.Stack;
2766
3192
  if (isStack !== !this.itemIsInlineOnly(newToken.items[i])) return false;
2767
3193
  }
2768
3194
  const availableWidth = this.activeBlockMetrics?.availableWidth ?? this.maxWidth;
@@ -2821,7 +3247,7 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
2821
3247
  * *token runs* split at the last image, never token index against child index.
2822
3248
  */
2823
3249
  updateImageParagraph(entity, oldToken, newToken) {
2824
- if (!(entity instanceof import_ui.Stack)) return false;
3250
+ if (!(entity instanceof import_ui2.Stack)) return false;
2825
3251
  const oldTokens = oldToken.tokens;
2826
3252
  const newTokens = newToken.tokens;
2827
3253
  if (!oldTokens || !newTokens) return false;
@@ -2846,7 +3272,7 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
2846
3272
  entity.add(this.inlineRunRichText(newTail, availableWidth, t));
2847
3273
  } else {
2848
3274
  const tailEntity = entity.children[entity.children.length - 1];
2849
- if (!(tailEntity instanceof import_ui.RichText)) return false;
3275
+ if (!(tailEntity instanceof import_ui2.RichText)) return false;
2850
3276
  tailEntity.setSpans(this.inlineRunSpans(newTail, t));
2851
3277
  }
2852
3278
  const last = entity.children[entity.children.length - 1];
@@ -2878,7 +3304,7 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
2878
3304
  * (its keys are `text`/`tokens`/`header`/`align`).
2879
3305
  */
2880
3306
  updateStreamedTable(entity, oldToken, newToken) {
2881
- if (!(entity instanceof import_ui.Table)) return false;
3307
+ if (!(entity instanceof import_ui2.Table)) return false;
2882
3308
  if (oldToken.header.length !== newToken.header.length) return false;
2883
3309
  for (let c = 0; c < oldToken.header.length; c++) {
2884
3310
  if (oldToken.header[c].text !== newToken.header[c].text) return false;
@@ -2899,7 +3325,7 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
2899
3325
  if (lastRetained >= 0) {
2900
3326
  for (let c = 0; c < oldToken.header.length; c++) {
2901
3327
  const cell = entity.rows[lastRetained]?.[c];
2902
- if (!(cell instanceof import_ui.RichText)) return false;
3328
+ if (!(cell instanceof import_ui2.RichText)) return false;
2903
3329
  }
2904
3330
  }
2905
3331
  const t = this.theme;
@@ -2932,7 +3358,7 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
2932
3358
  const newTail = newInner[tail];
2933
3359
  if (oldTail.type !== newTail.type) return false;
2934
3360
  const innerStack = container.children[1];
2935
- if (!(innerStack instanceof import_ui.Stack)) return false;
3361
+ if (!(innerStack instanceof import_ui2.Stack)) return false;
2936
3362
  const wrapper = innerStack.children.at(-1);
2937
3363
  if (!wrapper || wrapper.children.length !== 1) return false;
2938
3364
  const entity = wrapper.children[0];
@@ -3393,23 +3819,40 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
3393
3819
  if (!mathData) return null;
3394
3820
  const intrinsicW = exToPx(mathData.widthEx, t.fontSize);
3395
3821
  const intrinsicH = exToPx(mathData.heightEx, t.fontSize);
3396
- const mathImg = new import_ui.Image(mathData.uri, {
3397
- width: Math.min(availableWidth, intrinsicW),
3398
- height: intrinsicH * Math.min(1, availableWidth / intrinsicW),
3399
- alt: formula,
3400
- // The SVG decodes asynchronously and Image paints a placeholder until it
3401
- // lands. Without this an `onDemand` scene, which repaints only when marked
3402
- // dirty, leaves the formula a blank slab forever.
3403
- onLoad: () => {
3404
- this.scene?.markDirty();
3822
+ const scale = Math.min(1, availableWidth / intrinsicW);
3823
+ const width = intrinsicW * scale;
3824
+ const height = intrinsicH * scale;
3825
+ const uri = mathData.uri;
3826
+ const math = new import_ui2.RichText(
3827
+ [
3828
+ {
3829
+ text: import_core.OBJECT_REPLACEMENT,
3830
+ object: {
3831
+ width,
3832
+ height,
3833
+ // The TeX source is what a reader copies and what a screen reader
3834
+ // announces. KaTeX's dual-layer contract carries the same string in an
3835
+ // `<annotation encoding="application/x-tex">`; here the projection is
3836
+ // the semantic layer, so one copy of the source serves both.
3837
+ alt: formula,
3838
+ paint: (surface, box) => paintInlineMath(uri, surface, box)
3839
+ }
3840
+ }
3841
+ ],
3842
+ {
3843
+ font: `${t.fontSize}px ${t.bodyFont}`,
3844
+ color: t.textColor,
3845
+ maxWidth: availableWidth,
3846
+ selectable: this.selectable
3405
3847
  }
3406
- });
3407
- const wrapper = new MarkdownContainer();
3408
- mathImg.x = 16;
3409
- mathImg.y = 8;
3410
- wrapper.add(mathImg);
3411
- wrapper.width = mathImg.width + 16;
3412
- wrapper.height = mathImg.height + 16;
3848
+ );
3849
+ this.subscribeInlineMathRepaint();
3850
+ const wrapper = new MathBlock(formula, uri);
3851
+ math.x = 16;
3852
+ math.y = 8;
3853
+ wrapper.add(math);
3854
+ wrapper.width = width + 16;
3855
+ wrapper.height = height + 16;
3413
3856
  return wrapper;
3414
3857
  }
3415
3858
  renderToken(token) {
@@ -3459,7 +3902,7 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
3459
3902
  this.onLinkClick
3460
3903
  );
3461
3904
  }
3462
- const stack = new import_ui.Stack({
3905
+ const stack = new import_ui2.Stack({
3463
3906
  direction: "vertical",
3464
3907
  gap: 16,
3465
3908
  maxWidth: availableWidth
@@ -3471,7 +3914,7 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
3471
3914
  currentTokens = [];
3472
3915
  }
3473
3916
  };
3474
- for (const child of pToken.tokens) {
3917
+ for (const child of liftNestedImages(pToken.tokens)) {
3475
3918
  if (child.type === "image") {
3476
3919
  flushText();
3477
3920
  stack.add(this.paragraphImage(child, availableWidth));
@@ -3499,12 +3942,15 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
3499
3942
  const mathBlock = this.renderDisplayMath(codeToken.text, availableWidth);
3500
3943
  if (mathBlock) return mathBlock;
3501
3944
  }
3502
- return new CodeBlock(codeToken.text, lang, availableWidth, t, this.selectable);
3945
+ return this.withBlockAffordances(
3946
+ new CodeBlock(codeToken.text, lang, availableWidth, t, this.selectable),
3947
+ () => this.codeBlockAffordances(codeToken.text, lang)
3948
+ );
3503
3949
  }
3504
3950
  // ── Blockquotes ──────────────────────────────────────────────────
3505
3951
  case "blockquote": {
3506
3952
  const bqToken = token;
3507
- const innerStack = new import_ui.Stack({ direction: "vertical", gap: 8 });
3953
+ const innerStack = new import_ui2.Stack({ direction: "vertical", gap: 8 });
3508
3954
  const indentStart = Math.min(16, availableWidth);
3509
3955
  const childMetrics = {
3510
3956
  marginBefore: 0,
@@ -3540,7 +3986,7 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
3540
3986
  // ── Lists ────────────────────────────────────────────────
3541
3987
  case "list": {
3542
3988
  const listToken = token;
3543
- const listStack = new import_ui.Stack({ direction: "vertical", gap: 6 });
3989
+ const listStack = new import_ui2.Stack({ direction: "vertical", gap: 6 });
3544
3990
  for (let i = 0; i < listToken.items.length; i++) {
3545
3991
  listStack.add(
3546
3992
  this.itemIsInlineOnly(listToken.items[i]) ? this.listItemRichText(listToken, i, availableWidth, t) : this.listItemBlockStack(listToken, i, availableWidth, t)
@@ -3555,21 +4001,24 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
3555
4001
  const rows = tblToken.rows.map(
3556
4002
  (row) => row.map((cell) => this.tableCellRichText(cell, false, t))
3557
4003
  );
3558
- return new import_ui.Table({
3559
- headers,
3560
- rows,
3561
- // `| :--- | :---: | ---: |` already resolves to this on the token; it
3562
- // was previously discarded, so every column rendered left-aligned.
3563
- align: tblToken.align,
3564
- width: availableWidth,
3565
- textColor: t.textColor,
3566
- headerTextColor: t.headingColor,
3567
- font: `${t.fontSize - 2}px ${t.bodyFont}`,
3568
- borderColor: t.hrColor,
3569
- bg: t.tableBgColor,
3570
- headerBg: t.tableHeaderBgColor,
3571
- selectable: this.selectable
3572
- });
4004
+ return this.withBlockAffordances(
4005
+ new import_ui2.Table({
4006
+ headers,
4007
+ rows,
4008
+ // `| :--- | :---: | ---: |` already resolves to this on the token; it
4009
+ // was previously discarded, so every column rendered left-aligned.
4010
+ align: tblToken.align,
4011
+ width: availableWidth,
4012
+ textColor: t.textColor,
4013
+ headerTextColor: t.headingColor,
4014
+ font: `${t.fontSize - 2}px ${t.bodyFont}`,
4015
+ borderColor: t.hrColor,
4016
+ bg: t.tableBgColor,
4017
+ headerBg: t.tableHeaderBgColor,
4018
+ selectable: this.selectable
4019
+ }),
4020
+ () => this.tableAffordances(tblToken)
4021
+ );
3573
4022
  }
3574
4023
  // ── Horizontal rule ──────────────────────────────────────────────
3575
4024
  case "hr":
@@ -3588,7 +4037,7 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
3588
4037
  // ── Fallback ─────────────────────────────────────────────────────
3589
4038
  default:
3590
4039
  if ("text" in token) {
3591
- return new import_ui.Text(token.text, {
4040
+ return new import_ui2.Text(token.text, {
3592
4041
  font: bodyFont,
3593
4042
  color: t.textColor,
3594
4043
  maxWidth: availableWidth,
@@ -3605,12 +4054,22 @@ var Markdown = class _Markdown extends import_ui.UIComponent {
3605
4054
  };
3606
4055
  // Annotate the CommonJS export names for ESM import in node:
3607
4056
  0 && (module.exports = {
4057
+ BlockAffordanceButton,
4058
+ BlockWithAffordances,
3608
4059
  CodeBlock,
3609
4060
  Markdown,
4061
+ MathBlock,
3610
4062
  codeAtlas,
3611
4063
  codeAtlasStats,
4064
+ escapeCsvField,
4065
+ escapeMarkdownTableCell,
4066
+ extensionForLanguage,
3612
4067
  isMathJaxReady,
4068
+ mimeForLanguage,
3613
4069
  parseFrontMatterFields,
3614
4070
  preloadMathJax,
3615
- scanFrontMatter
4071
+ scanFrontMatter,
4072
+ tableContentOf,
4073
+ tableToCsv,
4074
+ tableToMarkdown
3616
4075
  });