@vectojs/markdown 0.11.0 → 0.13.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.
@@ -32,6 +32,42 @@ export interface MarkdownTheme {
32
32
  /** Base font size in px. */
33
33
  fontSize?: number;
34
34
  }
35
+ /** A simple concrete container entity for nested layouts. */
36
+ declare class MarkdownContainer extends Entity {
37
+ isPointInside(_globalX: number, _globalY: number): boolean;
38
+ render(_r: any): void;
39
+ }
40
+ /**
41
+ * One display formula: a `$$..$$` block or a closed ```` ```math ```` fence.
42
+ *
43
+ * A named class rather than a bare {@link MarkdownContainer} because the formula
44
+ * needs a stable handle, and after the switch to an inline object it has none:
45
+ * the typeset raster lives in a `paint` closure captured by the span, so removing
46
+ * the `Image` entity left nothing exposing either the source or the SVG bytes.
47
+ * Devtools, tests, and anything auditing what a formula actually rendered all
48
+ * want that. `markstream-vue` reaches the same conclusion from the DOM side and
49
+ * publishes `data-markstream-mode` on its math node for the same reason.
50
+ *
51
+ * Deliberately carries no typeset-vs-source flag. A formula MathJax has not
52
+ * converted yet renders as a bare {@link CodeBlock} of its TeX, which this class
53
+ * does not wrap — wrapping it would put a container between `content` and a
54
+ * `CodeBlock` that the streamed `setCode` path locates by type. So a flag would
55
+ * have exactly one reachable value, which is the dead-API trap that cost CTX-0208
56
+ * a debugging pass. Add it together with wrapping the fallback, or not at all.
57
+ */
58
+ export declare class MathBlock extends MarkdownContainer {
59
+ /**
60
+ * The TeX source, exactly as written between the delimiters.
61
+ *
62
+ * Also the projected text and the accessible name, so this is the one string a
63
+ * reader can find, select, and copy.
64
+ */
65
+ readonly formula: string;
66
+ /** The `data:image/svg+xml` URI of the typeset glyphs. */
67
+ readonly svgUri: string;
68
+ constructor(formula: string, svgUri: string);
69
+ getDevtoolsDescriptor(): DevtoolsDescriptor;
70
+ }
35
71
  /**
36
72
  * A single self-rendering entity for fenced code blocks.
37
73
  *
@@ -545,6 +581,71 @@ export declare class Markdown extends UIComponent {
545
581
  private paragraphImage;
546
582
  /** One table cell entity, shared by the render arm and the streamed-table path. */
547
583
  private tableCellRichText;
584
+ /**
585
+ * Token types a list item's fast path can render as one `RichText`.
586
+ *
587
+ * An ALLOWLIST, deliberately, following `markstream-vue`'s
588
+ * `SIMPLE_INLINE_TYPES` (`SimpleInlineRenderer/simpleInline.ts:15-31`): a block
589
+ * type is excluded by OMISSION, so a token this renderer has never heard of
590
+ * falls out of the fast path automatically instead of being silently flattened
591
+ * to its raw text. A denylist fails the other way, and the failure is quiet —
592
+ * a formula painted as literal TeX rather than an error — which is why this
593
+ * defect survived so long.
594
+ *
595
+ * Deliberately small, because a list item's DIRECT children are far less varied
596
+ * than they look. Probed against marked 18.0.7: every inline construct
597
+ * (`strong`, `em`, `del`, `codespan`, `link`, `image`, `br`, `escape`, `html`,
598
+ * `inlineMath`) arrives nested one level DEEPER, inside a container whose own
599
+ * type is `text` — so a tight item's direct child list is `text` and nothing
600
+ * else. Listing those inline types here would be dead code.
601
+ *
602
+ * `space` and `checkbox` are included because both are inert here. A blank line
603
+ * between an item's paragraph and its block sibling produces a `space`, and
604
+ * marked unshifts a `checkbox` into every TIGHT GFM task item — the box itself
605
+ * is drawn from `item.task`/`item.checked` by `listItemSpans`, so the token
606
+ * renders nothing on its own. Omitting `checkbox` sent every task item down the
607
+ * block path and moved its marker into a nested entity, which broke four
608
+ * task-list assertions in `Markdown.test.ts`.
609
+ */
610
+ private static readonly INLINE_ITEM_TOKENS;
611
+ /**
612
+ * Does this item consist purely of inline content?
613
+ *
614
+ * True keeps the single-`RichText` fast path, which is not merely an
615
+ * optimization: `updateStreamedList` reuses `stack.children[i]` by calling
616
+ * `setSpans` on it, so an item that becomes a `Stack` forfeits streamed reuse
617
+ * for its entire list. Only pay for a block container when an item holds a
618
+ * block.
619
+ *
620
+ * A lone `paragraph` counts as inline. A LOOSE list re-lexes every item's
621
+ * inline content from `text` to `paragraph` — adding one blank line anywhere
622
+ * flips `token.loose` for the whole list — so treating a single paragraph as a
623
+ * block would drop the fast path for every item of every loose list, the common
624
+ * shape in real prose, for no rendering benefit.
625
+ */
626
+ private itemIsInlineOnly;
627
+ /**
628
+ * Build a list item that holds block-level children.
629
+ *
630
+ * The item becomes a vertical `Stack`: its leading inline run (carrying the
631
+ * marker) first, then every remaining child rendered through the same
632
+ * `renderToken` the document level uses, indented to clear the marker.
633
+ *
634
+ * Recursing rather than special-casing the types we know about is the point — a
635
+ * display formula, a fence, a table, a blockquote, a nested list, an `hr` and a
636
+ * second paragraph all render exactly as they would at indent 0, and a block
637
+ * type added later works here for free.
638
+ *
639
+ * Only the FIRST child can be the lead. Everything after it becomes a block,
640
+ * including a second `paragraph`: an item's two paragraphs are two blocks, and
641
+ * folding them into the lead run would concatenate them into one line with no
642
+ * separation.
643
+ *
644
+ * The lead `RichText` is emitted even when the item has no inline text, because
645
+ * it carries the marker — an item that is nothing but a formula still shows its
646
+ * bullet or ordinal.
647
+ */
648
+ private listItemBlockStack;
548
649
  private listItemSpans;
549
650
  /** Construct the `RichText` for one list item. */
550
651
  private listItemRichText;
@@ -786,3 +887,4 @@ export declare class Markdown extends UIComponent {
786
887
  /** Structural — children draw themselves. */
787
888
  render(_r: IRenderer): void;
788
889
  }
890
+ export {};
package/dist/index.js CHANGED
@@ -32,6 +32,7 @@ var index_exports = {};
32
32
  __export(index_exports, {
33
33
  CodeBlock: () => CodeBlock,
34
34
  Markdown: () => Markdown,
35
+ MathBlock: () => MathBlock,
35
36
  codeAtlas: () => codeAtlas,
36
37
  codeAtlasStats: () => codeAtlasStats,
37
38
  isMathJaxReady: () => isMathJaxReady,
@@ -871,6 +872,33 @@ var MarkdownContainer = class extends import_core.Entity {
871
872
  render(_r) {
872
873
  }
873
874
  };
875
+ var MathBlock = class extends MarkdownContainer {
876
+ /**
877
+ * The TeX source, exactly as written between the delimiters.
878
+ *
879
+ * Also the projected text and the accessible name, so this is the one string a
880
+ * reader can find, select, and copy.
881
+ */
882
+ formula;
883
+ /** The `data:image/svg+xml` URI of the typeset glyphs. */
884
+ svgUri;
885
+ constructor(formula, svgUri) {
886
+ super();
887
+ this.formula = formula;
888
+ this.svgUri = svgUri;
889
+ }
890
+ getDevtoolsDescriptor() {
891
+ return {
892
+ kind: "MathBlock",
893
+ groups: [
894
+ {
895
+ label: "Math",
896
+ fields: [{ label: "formula", value: this.formula, readOnly: true }]
897
+ }
898
+ ]
899
+ };
900
+ }
901
+ };
874
902
  var KEYWORD_SETS = {
875
903
  js: /* @__PURE__ */ new Set([
876
904
  "const",
@@ -1531,7 +1559,7 @@ function renderInlineToRichText(tokens, fallbackText, font, color, maxWidth, the
1531
1559
  onLinkClick
1532
1560
  });
1533
1561
  }
1534
- var Markdown = class extends import_ui.UIComponent {
1562
+ var Markdown = class _Markdown extends import_ui.UIComponent {
1535
1563
  content;
1536
1564
  maxWidth;
1537
1565
  theme;
@@ -2580,6 +2608,109 @@ var Markdown = class extends import_ui.UIComponent {
2580
2608
  onLinkClick: this.onLinkClick
2581
2609
  });
2582
2610
  }
2611
+ /**
2612
+ * Token types a list item's fast path can render as one `RichText`.
2613
+ *
2614
+ * An ALLOWLIST, deliberately, following `markstream-vue`'s
2615
+ * `SIMPLE_INLINE_TYPES` (`SimpleInlineRenderer/simpleInline.ts:15-31`): a block
2616
+ * type is excluded by OMISSION, so a token this renderer has never heard of
2617
+ * falls out of the fast path automatically instead of being silently flattened
2618
+ * to its raw text. A denylist fails the other way, and the failure is quiet —
2619
+ * a formula painted as literal TeX rather than an error — which is why this
2620
+ * defect survived so long.
2621
+ *
2622
+ * Deliberately small, because a list item's DIRECT children are far less varied
2623
+ * than they look. Probed against marked 18.0.7: every inline construct
2624
+ * (`strong`, `em`, `del`, `codespan`, `link`, `image`, `br`, `escape`, `html`,
2625
+ * `inlineMath`) arrives nested one level DEEPER, inside a container whose own
2626
+ * type is `text` — so a tight item's direct child list is `text` and nothing
2627
+ * else. Listing those inline types here would be dead code.
2628
+ *
2629
+ * `space` and `checkbox` are included because both are inert here. A blank line
2630
+ * between an item's paragraph and its block sibling produces a `space`, and
2631
+ * marked unshifts a `checkbox` into every TIGHT GFM task item — the box itself
2632
+ * is drawn from `item.task`/`item.checked` by `listItemSpans`, so the token
2633
+ * renders nothing on its own. Omitting `checkbox` sent every task item down the
2634
+ * block path and moved its marker into a nested entity, which broke four
2635
+ * task-list assertions in `Markdown.test.ts`.
2636
+ */
2637
+ static INLINE_ITEM_TOKENS = /* @__PURE__ */ new Set([
2638
+ "text",
2639
+ "space",
2640
+ "checkbox"
2641
+ ]);
2642
+ /**
2643
+ * Does this item consist purely of inline content?
2644
+ *
2645
+ * True keeps the single-`RichText` fast path, which is not merely an
2646
+ * optimization: `updateStreamedList` reuses `stack.children[i]` by calling
2647
+ * `setSpans` on it, so an item that becomes a `Stack` forfeits streamed reuse
2648
+ * for its entire list. Only pay for a block container when an item holds a
2649
+ * block.
2650
+ *
2651
+ * A lone `paragraph` counts as inline. A LOOSE list re-lexes every item's
2652
+ * inline content from `text` to `paragraph` — adding one blank line anywhere
2653
+ * flips `token.loose` for the whole list — so treating a single paragraph as a
2654
+ * block would drop the fast path for every item of every loose list, the common
2655
+ * shape in real prose, for no rendering benefit.
2656
+ */
2657
+ itemIsInlineOnly(item) {
2658
+ const children = item.tokens;
2659
+ if (!children || children.length === 0) return true;
2660
+ if (children.length === 1 && children[0].type === "paragraph") return true;
2661
+ return children.every((child) => _Markdown.INLINE_ITEM_TOKENS.has(child.type));
2662
+ }
2663
+ /**
2664
+ * Build a list item that holds block-level children.
2665
+ *
2666
+ * The item becomes a vertical `Stack`: its leading inline run (carrying the
2667
+ * marker) first, then every remaining child rendered through the same
2668
+ * `renderToken` the document level uses, indented to clear the marker.
2669
+ *
2670
+ * Recursing rather than special-casing the types we know about is the point — a
2671
+ * display formula, a fence, a table, a blockquote, a nested list, an `hr` and a
2672
+ * second paragraph all render exactly as they would at indent 0, and a block
2673
+ * type added later works here for free.
2674
+ *
2675
+ * Only the FIRST child can be the lead. Everything after it becomes a block,
2676
+ * including a second `paragraph`: an item's two paragraphs are two blocks, and
2677
+ * folding them into the lead run would concatenate them into one line with no
2678
+ * separation.
2679
+ *
2680
+ * The lead `RichText` is emitted even when the item has no inline text, because
2681
+ * it carries the marker — an item that is nothing but a formula still shows its
2682
+ * bullet or ordinal.
2683
+ */
2684
+ listItemBlockStack(token, index, availableWidth, t) {
2685
+ const item = token.items[index];
2686
+ const children = item.tokens ?? [];
2687
+ const stack = new import_ui.Stack({ direction: "vertical", gap: 4 });
2688
+ const first = children[0];
2689
+ const leadChildren = first && (first.type === "text" || first.type === "paragraph") ? [first] : [];
2690
+ const leadToken = {
2691
+ ...token,
2692
+ items: token.items.map((it, i) => i === index ? { ...it, tokens: leadChildren } : it)
2693
+ };
2694
+ stack.add(this.listItemRichText(leadToken, index, availableWidth, t));
2695
+ const indent = Math.round(t.fontSize);
2696
+ const childMetrics = {
2697
+ marginBefore: 0,
2698
+ marginAfter: 0,
2699
+ indentStart: indent,
2700
+ availableWidth: Math.max(1, availableWidth - indent)
2701
+ };
2702
+ for (let i = leadChildren.length; i < children.length; i++) {
2703
+ const el = this.renderTokenWithMetrics(children[i], childMetrics);
2704
+ if (!el) continue;
2705
+ const wrapper = new MarkdownContainer();
2706
+ el.x = indent;
2707
+ wrapper.add(el);
2708
+ wrapper.width = el.width + indent;
2709
+ wrapper.height = el.height;
2710
+ stack.add(wrapper);
2711
+ }
2712
+ return stack;
2713
+ }
2583
2714
  listItemSpans(token, index) {
2584
2715
  const item = token.items[index];
2585
2716
  const num = Number(token.start ?? 1) + index;
@@ -2659,18 +2790,23 @@ var Markdown = class extends import_ui.UIComponent {
2659
2790
  const lastRetained = oldToken.items.length - 1;
2660
2791
  for (let i = 0; i < lastRetained; i++) {
2661
2792
  if (oldToken.items[i].text !== newToken.items[i].text) return false;
2793
+ const isStack = stack.children[i] instanceof import_ui.Stack;
2794
+ if (isStack !== !this.itemIsInlineOnly(newToken.items[i])) return false;
2662
2795
  }
2663
2796
  const availableWidth = this.activeBlockMetrics?.availableWidth ?? this.maxWidth;
2664
2797
  const t = this.theme;
2665
2798
  const tailEntity = stack.children[lastRetained];
2666
2799
  if (oldToken.items[lastRetained].text !== newToken.items[lastRetained].text) {
2800
+ if (!this.itemIsInlineOnly(newToken.items[lastRetained])) return false;
2667
2801
  if (!("setSpans" in tailEntity)) return false;
2668
2802
  tailEntity.setSpans(
2669
2803
  this.listItemSpans(newToken, lastRetained)
2670
2804
  );
2671
2805
  }
2672
2806
  for (let i = oldToken.items.length; i < newToken.items.length; i++) {
2673
- stack.add(this.listItemRichText(newToken, i, availableWidth, t));
2807
+ stack.add(
2808
+ this.itemIsInlineOnly(newToken.items[i]) ? this.listItemRichText(newToken, i, availableWidth, t) : this.listItemBlockStack(newToken, i, availableWidth, t)
2809
+ );
2674
2810
  }
2675
2811
  const last = stack.children.at(-1);
2676
2812
  if (last) stack.resizeLastChild(last);
@@ -3285,23 +3421,40 @@ var Markdown = class extends import_ui.UIComponent {
3285
3421
  if (!mathData) return null;
3286
3422
  const intrinsicW = exToPx(mathData.widthEx, t.fontSize);
3287
3423
  const intrinsicH = exToPx(mathData.heightEx, t.fontSize);
3288
- const mathImg = new import_ui.Image(mathData.uri, {
3289
- width: Math.min(availableWidth, intrinsicW),
3290
- height: intrinsicH * Math.min(1, availableWidth / intrinsicW),
3291
- alt: formula,
3292
- // The SVG decodes asynchronously and Image paints a placeholder until it
3293
- // lands. Without this an `onDemand` scene, which repaints only when marked
3294
- // dirty, leaves the formula a blank slab forever.
3295
- onLoad: () => {
3296
- this.scene?.markDirty();
3424
+ const scale = Math.min(1, availableWidth / intrinsicW);
3425
+ const width = intrinsicW * scale;
3426
+ const height = intrinsicH * scale;
3427
+ const uri = mathData.uri;
3428
+ const math = new import_ui.RichText(
3429
+ [
3430
+ {
3431
+ text: import_core.OBJECT_REPLACEMENT,
3432
+ object: {
3433
+ width,
3434
+ height,
3435
+ // The TeX source is what a reader copies and what a screen reader
3436
+ // announces. KaTeX's dual-layer contract carries the same string in an
3437
+ // `<annotation encoding="application/x-tex">`; here the projection is
3438
+ // the semantic layer, so one copy of the source serves both.
3439
+ alt: formula,
3440
+ paint: (surface, box) => paintInlineMath(uri, surface, box)
3441
+ }
3442
+ }
3443
+ ],
3444
+ {
3445
+ font: `${t.fontSize}px ${t.bodyFont}`,
3446
+ color: t.textColor,
3447
+ maxWidth: availableWidth,
3448
+ selectable: this.selectable
3297
3449
  }
3298
- });
3299
- const wrapper = new MarkdownContainer();
3300
- mathImg.x = 16;
3301
- mathImg.y = 8;
3302
- wrapper.add(mathImg);
3303
- wrapper.width = mathImg.width + 16;
3304
- wrapper.height = mathImg.height + 16;
3450
+ );
3451
+ this.subscribeInlineMathRepaint();
3452
+ const wrapper = new MathBlock(formula, uri);
3453
+ math.x = 16;
3454
+ math.y = 8;
3455
+ wrapper.add(math);
3456
+ wrapper.width = width + 16;
3457
+ wrapper.height = height + 16;
3305
3458
  return wrapper;
3306
3459
  }
3307
3460
  renderToken(token) {
@@ -3434,7 +3587,9 @@ var Markdown = class extends import_ui.UIComponent {
3434
3587
  const listToken = token;
3435
3588
  const listStack = new import_ui.Stack({ direction: "vertical", gap: 6 });
3436
3589
  for (let i = 0; i < listToken.items.length; i++) {
3437
- listStack.add(this.listItemRichText(listToken, i, availableWidth, t));
3590
+ listStack.add(
3591
+ this.itemIsInlineOnly(listToken.items[i]) ? this.listItemRichText(listToken, i, availableWidth, t) : this.listItemBlockStack(listToken, i, availableWidth, t)
3592
+ );
3438
3593
  }
3439
3594
  return listStack;
3440
3595
  }
@@ -3497,6 +3652,7 @@ var Markdown = class extends import_ui.UIComponent {
3497
3652
  0 && (module.exports = {
3498
3653
  CodeBlock,
3499
3654
  Markdown,
3655
+ MathBlock,
3500
3656
  codeAtlas,
3501
3657
  codeAtlasStats,
3502
3658
  isMathJaxReady,
package/dist/index.mjs CHANGED
@@ -839,6 +839,33 @@ var MarkdownContainer = class extends Entity {
839
839
  render(_r) {
840
840
  }
841
841
  };
842
+ var MathBlock = class extends MarkdownContainer {
843
+ /**
844
+ * The TeX source, exactly as written between the delimiters.
845
+ *
846
+ * Also the projected text and the accessible name, so this is the one string a
847
+ * reader can find, select, and copy.
848
+ */
849
+ formula;
850
+ /** The `data:image/svg+xml` URI of the typeset glyphs. */
851
+ svgUri;
852
+ constructor(formula, svgUri) {
853
+ super();
854
+ this.formula = formula;
855
+ this.svgUri = svgUri;
856
+ }
857
+ getDevtoolsDescriptor() {
858
+ return {
859
+ kind: "MathBlock",
860
+ groups: [
861
+ {
862
+ label: "Math",
863
+ fields: [{ label: "formula", value: this.formula, readOnly: true }]
864
+ }
865
+ ]
866
+ };
867
+ }
868
+ };
842
869
  var KEYWORD_SETS = {
843
870
  js: /* @__PURE__ */ new Set([
844
871
  "const",
@@ -1499,7 +1526,7 @@ function renderInlineToRichText(tokens, fallbackText, font, color, maxWidth, the
1499
1526
  onLinkClick
1500
1527
  });
1501
1528
  }
1502
- var Markdown = class extends UIComponent {
1529
+ var Markdown = class _Markdown extends UIComponent {
1503
1530
  content;
1504
1531
  maxWidth;
1505
1532
  theme;
@@ -2548,6 +2575,109 @@ var Markdown = class extends UIComponent {
2548
2575
  onLinkClick: this.onLinkClick
2549
2576
  });
2550
2577
  }
2578
+ /**
2579
+ * Token types a list item's fast path can render as one `RichText`.
2580
+ *
2581
+ * An ALLOWLIST, deliberately, following `markstream-vue`'s
2582
+ * `SIMPLE_INLINE_TYPES` (`SimpleInlineRenderer/simpleInline.ts:15-31`): a block
2583
+ * type is excluded by OMISSION, so a token this renderer has never heard of
2584
+ * falls out of the fast path automatically instead of being silently flattened
2585
+ * to its raw text. A denylist fails the other way, and the failure is quiet —
2586
+ * a formula painted as literal TeX rather than an error — which is why this
2587
+ * defect survived so long.
2588
+ *
2589
+ * Deliberately small, because a list item's DIRECT children are far less varied
2590
+ * than they look. Probed against marked 18.0.7: every inline construct
2591
+ * (`strong`, `em`, `del`, `codespan`, `link`, `image`, `br`, `escape`, `html`,
2592
+ * `inlineMath`) arrives nested one level DEEPER, inside a container whose own
2593
+ * type is `text` — so a tight item's direct child list is `text` and nothing
2594
+ * else. Listing those inline types here would be dead code.
2595
+ *
2596
+ * `space` and `checkbox` are included because both are inert here. A blank line
2597
+ * between an item's paragraph and its block sibling produces a `space`, and
2598
+ * marked unshifts a `checkbox` into every TIGHT GFM task item — the box itself
2599
+ * is drawn from `item.task`/`item.checked` by `listItemSpans`, so the token
2600
+ * renders nothing on its own. Omitting `checkbox` sent every task item down the
2601
+ * block path and moved its marker into a nested entity, which broke four
2602
+ * task-list assertions in `Markdown.test.ts`.
2603
+ */
2604
+ static INLINE_ITEM_TOKENS = /* @__PURE__ */ new Set([
2605
+ "text",
2606
+ "space",
2607
+ "checkbox"
2608
+ ]);
2609
+ /**
2610
+ * Does this item consist purely of inline content?
2611
+ *
2612
+ * True keeps the single-`RichText` fast path, which is not merely an
2613
+ * optimization: `updateStreamedList` reuses `stack.children[i]` by calling
2614
+ * `setSpans` on it, so an item that becomes a `Stack` forfeits streamed reuse
2615
+ * for its entire list. Only pay for a block container when an item holds a
2616
+ * block.
2617
+ *
2618
+ * A lone `paragraph` counts as inline. A LOOSE list re-lexes every item's
2619
+ * inline content from `text` to `paragraph` — adding one blank line anywhere
2620
+ * flips `token.loose` for the whole list — so treating a single paragraph as a
2621
+ * block would drop the fast path for every item of every loose list, the common
2622
+ * shape in real prose, for no rendering benefit.
2623
+ */
2624
+ itemIsInlineOnly(item) {
2625
+ const children = item.tokens;
2626
+ if (!children || children.length === 0) return true;
2627
+ if (children.length === 1 && children[0].type === "paragraph") return true;
2628
+ return children.every((child) => _Markdown.INLINE_ITEM_TOKENS.has(child.type));
2629
+ }
2630
+ /**
2631
+ * Build a list item that holds block-level children.
2632
+ *
2633
+ * The item becomes a vertical `Stack`: its leading inline run (carrying the
2634
+ * marker) first, then every remaining child rendered through the same
2635
+ * `renderToken` the document level uses, indented to clear the marker.
2636
+ *
2637
+ * Recursing rather than special-casing the types we know about is the point — a
2638
+ * display formula, a fence, a table, a blockquote, a nested list, an `hr` and a
2639
+ * second paragraph all render exactly as they would at indent 0, and a block
2640
+ * type added later works here for free.
2641
+ *
2642
+ * Only the FIRST child can be the lead. Everything after it becomes a block,
2643
+ * including a second `paragraph`: an item's two paragraphs are two blocks, and
2644
+ * folding them into the lead run would concatenate them into one line with no
2645
+ * separation.
2646
+ *
2647
+ * The lead `RichText` is emitted even when the item has no inline text, because
2648
+ * it carries the marker — an item that is nothing but a formula still shows its
2649
+ * bullet or ordinal.
2650
+ */
2651
+ listItemBlockStack(token, index, availableWidth, t) {
2652
+ const item = token.items[index];
2653
+ const children = item.tokens ?? [];
2654
+ const stack = new Stack({ direction: "vertical", gap: 4 });
2655
+ const first = children[0];
2656
+ const leadChildren = first && (first.type === "text" || first.type === "paragraph") ? [first] : [];
2657
+ const leadToken = {
2658
+ ...token,
2659
+ items: token.items.map((it, i) => i === index ? { ...it, tokens: leadChildren } : it)
2660
+ };
2661
+ stack.add(this.listItemRichText(leadToken, index, availableWidth, t));
2662
+ const indent = Math.round(t.fontSize);
2663
+ const childMetrics = {
2664
+ marginBefore: 0,
2665
+ marginAfter: 0,
2666
+ indentStart: indent,
2667
+ availableWidth: Math.max(1, availableWidth - indent)
2668
+ };
2669
+ for (let i = leadChildren.length; i < children.length; i++) {
2670
+ const el = this.renderTokenWithMetrics(children[i], childMetrics);
2671
+ if (!el) continue;
2672
+ const wrapper = new MarkdownContainer();
2673
+ el.x = indent;
2674
+ wrapper.add(el);
2675
+ wrapper.width = el.width + indent;
2676
+ wrapper.height = el.height;
2677
+ stack.add(wrapper);
2678
+ }
2679
+ return stack;
2680
+ }
2551
2681
  listItemSpans(token, index) {
2552
2682
  const item = token.items[index];
2553
2683
  const num = Number(token.start ?? 1) + index;
@@ -2627,18 +2757,23 @@ var Markdown = class extends UIComponent {
2627
2757
  const lastRetained = oldToken.items.length - 1;
2628
2758
  for (let i = 0; i < lastRetained; i++) {
2629
2759
  if (oldToken.items[i].text !== newToken.items[i].text) return false;
2760
+ const isStack = stack.children[i] instanceof Stack;
2761
+ if (isStack !== !this.itemIsInlineOnly(newToken.items[i])) return false;
2630
2762
  }
2631
2763
  const availableWidth = this.activeBlockMetrics?.availableWidth ?? this.maxWidth;
2632
2764
  const t = this.theme;
2633
2765
  const tailEntity = stack.children[lastRetained];
2634
2766
  if (oldToken.items[lastRetained].text !== newToken.items[lastRetained].text) {
2767
+ if (!this.itemIsInlineOnly(newToken.items[lastRetained])) return false;
2635
2768
  if (!("setSpans" in tailEntity)) return false;
2636
2769
  tailEntity.setSpans(
2637
2770
  this.listItemSpans(newToken, lastRetained)
2638
2771
  );
2639
2772
  }
2640
2773
  for (let i = oldToken.items.length; i < newToken.items.length; i++) {
2641
- stack.add(this.listItemRichText(newToken, i, availableWidth, t));
2774
+ stack.add(
2775
+ this.itemIsInlineOnly(newToken.items[i]) ? this.listItemRichText(newToken, i, availableWidth, t) : this.listItemBlockStack(newToken, i, availableWidth, t)
2776
+ );
2642
2777
  }
2643
2778
  const last = stack.children.at(-1);
2644
2779
  if (last) stack.resizeLastChild(last);
@@ -3253,23 +3388,40 @@ var Markdown = class extends UIComponent {
3253
3388
  if (!mathData) return null;
3254
3389
  const intrinsicW = exToPx(mathData.widthEx, t.fontSize);
3255
3390
  const intrinsicH = exToPx(mathData.heightEx, t.fontSize);
3256
- const mathImg = new Image(mathData.uri, {
3257
- width: Math.min(availableWidth, intrinsicW),
3258
- height: intrinsicH * Math.min(1, availableWidth / intrinsicW),
3259
- alt: formula,
3260
- // The SVG decodes asynchronously and Image paints a placeholder until it
3261
- // lands. Without this an `onDemand` scene, which repaints only when marked
3262
- // dirty, leaves the formula a blank slab forever.
3263
- onLoad: () => {
3264
- this.scene?.markDirty();
3391
+ const scale = Math.min(1, availableWidth / intrinsicW);
3392
+ const width = intrinsicW * scale;
3393
+ const height = intrinsicH * scale;
3394
+ const uri = mathData.uri;
3395
+ const math = new RichText(
3396
+ [
3397
+ {
3398
+ text: OBJECT_REPLACEMENT,
3399
+ object: {
3400
+ width,
3401
+ height,
3402
+ // The TeX source is what a reader copies and what a screen reader
3403
+ // announces. KaTeX's dual-layer contract carries the same string in an
3404
+ // `<annotation encoding="application/x-tex">`; here the projection is
3405
+ // the semantic layer, so one copy of the source serves both.
3406
+ alt: formula,
3407
+ paint: (surface, box) => paintInlineMath(uri, surface, box)
3408
+ }
3409
+ }
3410
+ ],
3411
+ {
3412
+ font: `${t.fontSize}px ${t.bodyFont}`,
3413
+ color: t.textColor,
3414
+ maxWidth: availableWidth,
3415
+ selectable: this.selectable
3265
3416
  }
3266
- });
3267
- const wrapper = new MarkdownContainer();
3268
- mathImg.x = 16;
3269
- mathImg.y = 8;
3270
- wrapper.add(mathImg);
3271
- wrapper.width = mathImg.width + 16;
3272
- wrapper.height = mathImg.height + 16;
3417
+ );
3418
+ this.subscribeInlineMathRepaint();
3419
+ const wrapper = new MathBlock(formula, uri);
3420
+ math.x = 16;
3421
+ math.y = 8;
3422
+ wrapper.add(math);
3423
+ wrapper.width = width + 16;
3424
+ wrapper.height = height + 16;
3273
3425
  return wrapper;
3274
3426
  }
3275
3427
  renderToken(token) {
@@ -3402,7 +3554,9 @@ var Markdown = class extends UIComponent {
3402
3554
  const listToken = token;
3403
3555
  const listStack = new Stack({ direction: "vertical", gap: 6 });
3404
3556
  for (let i = 0; i < listToken.items.length; i++) {
3405
- listStack.add(this.listItemRichText(listToken, i, availableWidth, t));
3557
+ listStack.add(
3558
+ this.itemIsInlineOnly(listToken.items[i]) ? this.listItemRichText(listToken, i, availableWidth, t) : this.listItemBlockStack(listToken, i, availableWidth, t)
3559
+ );
3406
3560
  }
3407
3561
  return listStack;
3408
3562
  }
@@ -3464,6 +3618,7 @@ var Markdown = class extends UIComponent {
3464
3618
  export {
3465
3619
  CodeBlock,
3466
3620
  Markdown,
3621
+ MathBlock,
3467
3622
  codeAtlas,
3468
3623
  codeAtlasStats,
3469
3624
  isMathJaxReady,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectojs/markdown",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },