@vectojs/markdown 0.20.2 → 0.21.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.
@@ -83,6 +83,23 @@ export interface MarkdownOptions {
83
83
  * download behaviour to observe.
84
84
  */
85
85
  saveFile?: (filename: string, content: string, mimeType: string) => void;
86
+ /**
87
+ * Materialize only the top-level blocks near the viewport of a very large
88
+ * document, representing off-screen height as numeric offsets rather than
89
+ * entities. Off by default; pass `true` or `{ overscan }` to opt in.
90
+ *
91
+ * The host must drive it with {@link Markdown.setVisibleRange} each scroll
92
+ * frame (a `ScrollView` whose content is this `Markdown` does so
93
+ * automatically). Off-screen height is estimated from the token source and
94
+ * refined to the exact measured height when a block first mounts, so the
95
+ * scrollbar stays correct while only a window of blocks exists as entities.
96
+ *
97
+ * Not supported together with streaming (`createStream` / `appendMarkdown`):
98
+ * a document that virtualizes must be rendered whole.
99
+ */
100
+ virtualize?: boolean | {
101
+ overscan?: number;
102
+ };
86
103
  }
87
104
  /**
88
105
  * Renders Markdown content into a VectoJS entity tree using {@link marked}.
@@ -138,6 +155,19 @@ export declare class Markdown extends UIComponent {
138
155
  /** File saver used by the download controls. */
139
156
  saveFile: (filename: string, content: string, mimeType: string) => void;
140
157
  private activeBlockMetrics;
158
+ /** Whether `opts.virtualize` enabled viewport-culled block materialization. */
159
+ private readonly virtualizeBlocks;
160
+ /** Overscan margin (px) added above/below the visible window before mounting. */
161
+ private readonly virtualOverscan;
162
+ /** Top-level tokens that produce a child entity, in document order. */
163
+ private virtualTokens;
164
+ /** Fenwick tree over per-block strides (height + blockGap) when virtualizing. */
165
+ private virtualHeights;
166
+ /** token index → mounted entity, for blocks currently inside `content`. */
167
+ private readonly virtualMounted;
168
+ /** Visible window top (scroll offset) and height, last set via setVisibleRange. */
169
+ private virtualScrollY;
170
+ private virtualViewportH;
141
171
  /**
142
172
  * Called after this entity's own `width`/`height` changed because the document
143
173
  * was re-laid-out. **This is the hook to wire up when a host has to move or
@@ -388,6 +418,32 @@ export declare class Markdown extends UIComponent {
388
418
  */
389
419
  get frontMatterFields(): Readonly<Record<string, string>>;
390
420
  private renderMarkdown;
421
+ /**
422
+ * Drive the virtualized window from the host's scroll state.
423
+ *
424
+ * Only the blocks intersecting `[scrollY − overscan, scrollY + viewportHeight +
425
+ * overscan]` stay materialized; every other block is a numeric offset in the
426
+ * height tree, so the scrollbar still reports the full document height while
427
+ * only a window of blocks exists as entities. A no-op when `virtualize` was
428
+ * not enabled.
429
+ */
430
+ setVisibleRange(scrollY: number, viewportHeight: number): void;
431
+ /**
432
+ * Cheap per-block height estimate from the token source, refined to the exact
433
+ * measured height when the block first mounts. Coarse by design: the estimate
434
+ * only has to keep the scrollbar plausible before a block is first seen; the
435
+ * Fenwick tree corrects the rest.
436
+ */
437
+ private estimateBlockHeight;
438
+ /** Estimate a wrapped line count from raw character length against the max width. */
439
+ private estimateTextHeight;
440
+ /**
441
+ * Mount the blocks in the current window and unmount the rest. The `content`
442
+ * Stack lays the mounted window contiguously from y = 0; `content.y` is then
443
+ * set to the skipped prefix sum so the first mounted block lands at its true
444
+ * y — a numeric spacer, not a wrapper entity.
445
+ */
446
+ private reconcileVirtual;
391
447
  /** Create a frame-coalesced stream bound to this Markdown instance. */
392
448
  createStream(options?: StreamControllerOptions): StreamController;
393
449
  /**
package/dist/index.js CHANGED
@@ -1327,6 +1327,70 @@ function highlightLine(line, lang, theme, carry = null) {
1327
1327
  flush(theme.codeColor);
1328
1328
  return { segments, carry: null };
1329
1329
  }
1330
+ var HSCROLL_HIT_H = 12;
1331
+ var HSCROLL_THUMB_H = 4;
1332
+ var HSCROLL_THUMB_INSET = 4;
1333
+ var HSCROLL_THUMB_MIN = 24;
1334
+ var HSCROLL_TRACK_COLOR = "rgba(128, 128, 128, 0.14)";
1335
+ var HSCROLL_THUMB_COLOR = "rgba(128, 128, 128, 0.45)";
1336
+ var HSCROLL_THUMB_ACTIVE_COLOR = "rgba(128, 128, 128, 0.75)";
1337
+ function hScrollMetrics(trackW, maxScrollX) {
1338
+ const content = trackW + maxScrollX;
1339
+ const proportional = content > 0 ? trackW / content * trackW : trackW;
1340
+ const thumbW = Math.min(trackW, Math.max(HSCROLL_THUMB_MIN, proportional));
1341
+ return { thumbW, range: Math.max(1, trackW - thumbW) };
1342
+ }
1343
+ var HScrollTrack = class extends import_ui.UIComponent {
1344
+ constructor(block) {
1345
+ super();
1346
+ this.block = block;
1347
+ this.on("pointerdown", (e) => {
1348
+ if (e.localX === void 0) return;
1349
+ const maxSX = this.block.maxScrollX;
1350
+ if (maxSX <= 0) return;
1351
+ const { thumbW, range } = hScrollMetrics(this.width, maxSX);
1352
+ const thumbX = this.block.scrollX / maxSX * range;
1353
+ if (e.localX < thumbX || e.localX > thumbX + thumbW) {
1354
+ this.block.setScrollX((e.localX - thumbW / 2) / range * maxSX);
1355
+ }
1356
+ this.dragging = true;
1357
+ this.dragAnchorX = e.localX;
1358
+ this.scrollAtDragStart = this.block.scrollX;
1359
+ this.scene?.markDirty();
1360
+ });
1361
+ this.on("pointermove", (e) => {
1362
+ if (!this.dragging || e.localX === void 0) return;
1363
+ const maxSX = this.block.maxScrollX;
1364
+ const { range } = hScrollMetrics(this.width, maxSX);
1365
+ const dx = e.localX - this.dragAnchorX;
1366
+ this.block.setScrollX(this.scrollAtDragStart + dx / range * maxSX);
1367
+ });
1368
+ const endDrag = () => {
1369
+ if (!this.dragging) return;
1370
+ this.dragging = false;
1371
+ this.scene?.markDirty();
1372
+ };
1373
+ this.on("pointerup", endDrag);
1374
+ this.on("pointerleave", endDrag);
1375
+ }
1376
+ block;
1377
+ /** True while a drag owns the thumb; read by the block's painter for the active tint. */
1378
+ dragging = false;
1379
+ dragAnchorX = 0;
1380
+ scrollAtDragStart = 0;
1381
+ getA11yAttributes() {
1382
+ return {
1383
+ role: "scrollbar",
1384
+ label: "Scroll code horizontally",
1385
+ value: String(Math.round(this.block.scrollX)),
1386
+ valuemin: "0",
1387
+ valuemax: String(Math.round(this.block.maxScrollX))
1388
+ };
1389
+ }
1390
+ /** Nothing to draw: the thumb is painted by the owning block, so both share one pass. */
1391
+ render() {
1392
+ }
1393
+ };
1330
1394
  var CodeBlock = class extends import_ui.UIComponent {
1331
1395
  lines;
1332
1396
  /**
@@ -1364,6 +1428,12 @@ var CodeBlock = class extends import_ui.UIComponent {
1364
1428
  /** Memoized widest prepared line, keyed by the grid identity it came from. */
1365
1429
  contentWidthGrid = null;
1366
1430
  contentWidthValue = 0;
1431
+ /**
1432
+ * The interactive horizontal scrollbar strip (#527), created lazily by
1433
+ * {@link syncScrollTrack} the first time the block overflows. Kept, and merely
1434
+ * disabled, when the overflow later disappears — see there for why.
1435
+ */
1436
+ hTrack = null;
1367
1437
  lang;
1368
1438
  theme;
1369
1439
  /**
@@ -1668,6 +1738,31 @@ var CodeBlock = class extends import_ui.UIComponent {
1668
1738
  }
1669
1739
  return this.grid;
1670
1740
  }
1741
+ /**
1742
+ * Fit the interactive scrollbar strip (#527) to the current overflow.
1743
+ *
1744
+ * Called from {@link render} rather than from `setWidth` or `buildLines`:
1745
+ * `setWidth` is documented as costing nothing and `maxScrollX` costs a grid
1746
+ * build, while `render` is about to pay for that grid anyway. Once created,
1747
+ * the child is KEPT and merely de-`interactive`d when the overflow disappears
1748
+ * — this runs inside the scene's tree walk, and removing a child mid-walk is
1749
+ * how siblings get skipped.
1750
+ */
1751
+ syncScrollTrack() {
1752
+ if (this.maxScrollX <= 0) {
1753
+ if (this.hTrack) this.hTrack.interactive = false;
1754
+ return;
1755
+ }
1756
+ if (!this.hTrack) {
1757
+ this.hTrack = new HScrollTrack(this);
1758
+ this.add(this.hTrack);
1759
+ }
1760
+ this.hTrack.interactive = true;
1761
+ this.hTrack.x = this.pad;
1762
+ this.hTrack.y = this.height - HSCROLL_HIT_H;
1763
+ this.hTrack.width = Math.max(0, this.width - this.pad * 2);
1764
+ this.hTrack.height = HSCROLL_HIT_H;
1765
+ }
1671
1766
  /**
1672
1767
  * Not hit-testable, and deliberately still not `interactive`, even though the
1673
1768
  * block now consumes wheel events to scroll.
@@ -1676,11 +1771,16 @@ var CodeBlock = class extends import_ui.UIComponent {
1676
1771
  * hit-testing, so no a11y shadow node is needed. Creating one would place a
1677
1772
  * `pointer-events: auto` element above the transparent text mirror and swallow
1678
1773
  * the mousedown that starts a native drag-selection.
1774
+ *
1775
+ * Pointer-driven scrolling therefore lives on the {@link HScrollTrack} CHILD,
1776
+ * whose shadow node covers only the bottom-padding strip below the last
1777
+ * selectable carrier — never the text itself.
1679
1778
  */
1680
1779
  isPointInside() {
1681
1780
  return false;
1682
1781
  }
1683
1782
  render(r) {
1783
+ this.syncScrollTrack();
1684
1784
  r.beginPath();
1685
1785
  r.roundRect(0, 0, this.width, this.height, this.theme.codeRadius);
1686
1786
  r.fill(this.theme.codeBgColor);
@@ -1750,6 +1850,24 @@ var CodeBlock = class extends import_ui.UIComponent {
1750
1850
  }
1751
1851
  }
1752
1852
  r.restore();
1853
+ const maxSX = this.maxScrollX;
1854
+ if (maxSX > 0) {
1855
+ const trackW = Math.max(0, this.width - this.pad * 2);
1856
+ const { thumbW, range } = hScrollMetrics(trackW, maxSX);
1857
+ const trackY = this.height - HSCROLL_THUMB_H - HSCROLL_THUMB_INSET;
1858
+ r.beginPath();
1859
+ r.roundRect(this.pad, trackY, trackW, HSCROLL_THUMB_H, HSCROLL_THUMB_H / 2);
1860
+ r.fill(HSCROLL_TRACK_COLOR);
1861
+ r.beginPath();
1862
+ r.roundRect(
1863
+ this.pad + this.scrollX / maxSX * range,
1864
+ trackY,
1865
+ thumbW,
1866
+ HSCROLL_THUMB_H,
1867
+ HSCROLL_THUMB_H / 2
1868
+ );
1869
+ r.fill(this.hTrack?.dragging ? HSCROLL_THUMB_ACTIVE_COLOR : HSCROLL_THUMB_COLOR);
1870
+ }
1753
1871
  }
1754
1872
  };
1755
1873
  var codeAtlases = /* @__PURE__ */ new Map();
@@ -3381,6 +3499,19 @@ var Markdown = class _Markdown extends import_ui4.UIComponent {
3381
3499
  /** File saver used by the download controls. */
3382
3500
  saveFile;
3383
3501
  activeBlockMetrics = null;
3502
+ /** Whether `opts.virtualize` enabled viewport-culled block materialization. */
3503
+ virtualizeBlocks;
3504
+ /** Overscan margin (px) added above/below the visible window before mounting. */
3505
+ virtualOverscan;
3506
+ /** Top-level tokens that produce a child entity, in document order. */
3507
+ virtualTokens = null;
3508
+ /** Fenwick tree over per-block strides (height + blockGap) when virtualizing. */
3509
+ virtualHeights = null;
3510
+ /** token index → mounted entity, for blocks currently inside `content`. */
3511
+ virtualMounted = /* @__PURE__ */ new Map();
3512
+ /** Visible window top (scroll offset) and height, last set via setVisibleRange. */
3513
+ virtualScrollY = 0;
3514
+ virtualViewportH = 0;
3384
3515
  /**
3385
3516
  * Called after this entity's own `width`/`height` changed because the document
3386
3517
  * was re-laid-out. **This is the hook to wire up when a host has to move or
@@ -3666,6 +3797,9 @@ var Markdown = class _Markdown extends import_ui4.UIComponent {
3666
3797
  this.showCodeLanguage = opts.showCodeLanguage ?? false;
3667
3798
  this.writeClipboard = opts.writeClipboard ?? defaultWriteClipboard;
3668
3799
  this.saveFile = opts.saveFile ?? defaultSaveFile;
3800
+ const virt = opts.virtualize;
3801
+ this.virtualizeBlocks = virt === true || typeof virt === "object" && virt !== null;
3802
+ this.virtualOverscan = typeof virt === "object" && virt !== null && typeof virt.overscan === "number" ? virt.overscan : 800;
3669
3803
  this.content = new import_ui4.Stack({
3670
3804
  direction: "vertical",
3671
3805
  gap: this.theme.blockGap
@@ -3778,6 +3912,20 @@ var Markdown = class _Markdown extends import_ui4.UIComponent {
3778
3912
  const tokens = lexMarkdown(text, this._userTiming);
3779
3913
  this.setTokens(tokens);
3780
3914
  this.abbreviations = collectAbbreviations(tokens);
3915
+ if (this.virtualizeBlocks) {
3916
+ this.virtualTokens = tokens.filter((token) => this.producesEntity(token));
3917
+ const n = this.virtualTokens.length;
3918
+ this.virtualHeights = new import_ui4.RowHeights(n, 0);
3919
+ for (let i = 0; i < n; i++) {
3920
+ this.virtualHeights.set(
3921
+ i,
3922
+ this.estimateBlockHeight(this.virtualTokens[i]) + this.theme.blockGap
3923
+ );
3924
+ }
3925
+ this.virtualMounted.clear();
3926
+ this.reconcileVirtual();
3927
+ return;
3928
+ }
3781
3929
  for (const token of tokens) {
3782
3930
  const el = this.renderToken(token);
3783
3931
  if (el) {
@@ -3787,8 +3935,123 @@ var Markdown = class _Markdown extends import_ui4.UIComponent {
3787
3935
  this.width = this.content.width;
3788
3936
  this.height = this.content.height;
3789
3937
  }
3938
+ /**
3939
+ * Drive the virtualized window from the host's scroll state.
3940
+ *
3941
+ * Only the blocks intersecting `[scrollY − overscan, scrollY + viewportHeight +
3942
+ * overscan]` stay materialized; every other block is a numeric offset in the
3943
+ * height tree, so the scrollbar still reports the full document height while
3944
+ * only a window of blocks exists as entities. A no-op when `virtualize` was
3945
+ * not enabled.
3946
+ */
3947
+ setVisibleRange(scrollY, viewportHeight) {
3948
+ if (!this.virtualizeBlocks) return;
3949
+ this.virtualScrollY = Math.max(0, scrollY);
3950
+ this.virtualViewportH = Math.max(0, viewportHeight);
3951
+ this.reconcileVirtual();
3952
+ }
3953
+ /**
3954
+ * Cheap per-block height estimate from the token source, refined to the exact
3955
+ * measured height when the block first mounts. Coarse by design: the estimate
3956
+ * only has to keep the scrollbar plausible before a block is first seen; the
3957
+ * Fenwick tree corrects the rest.
3958
+ */
3959
+ estimateBlockHeight(token) {
3960
+ const t = this.theme;
3961
+ switch (token.type) {
3962
+ case "heading": {
3963
+ const size = headingSize(t, token.depth);
3964
+ return Math.ceil(size * 1.5) + 8;
3965
+ }
3966
+ case "paragraph":
3967
+ return this.estimateTextHeight(token.text, t.bodyLineHeight);
3968
+ case "code": {
3969
+ const raw = token.text;
3970
+ const lines = Math.max(1, raw.split("\n").length - (raw.endsWith("\n") ? 1 : 0));
3971
+ let h = lines * t.codeLineHeight + t.codePadding * 2;
3972
+ if (this.showCodeLanguage) h += t.codeLangFontSize + 8;
3973
+ return h;
3974
+ }
3975
+ case "hr":
3976
+ return 24;
3977
+ case "blockquote":
3978
+ case "container": {
3979
+ const inner = token.tokens ?? [];
3980
+ let sum = 0;
3981
+ for (const tk of inner) sum += this.estimateBlockHeight(tk) + t.blockGap;
3982
+ return sum + t.quoteInnerGap * 2 + 8;
3983
+ }
3984
+ case "list": {
3985
+ const items = token.items ?? [];
3986
+ let sum = 0;
3987
+ for (const item of items) {
3988
+ for (const tk of item.tokens ?? []) sum += this.estimateBlockHeight(tk) + t.listItemGap;
3989
+ }
3990
+ return sum + t.listGap;
3991
+ }
3992
+ case "table": {
3993
+ const table = token;
3994
+ const rows = (table.rows?.length ?? 0) + (table.header?.length ?? 0);
3995
+ return rows * (t.tableFontSize + 16) + 16;
3996
+ }
3997
+ case "footnoteDef":
3998
+ return this.estimateTextHeight(token.text ?? "", t.bodyLineHeight);
3999
+ case "html":
4000
+ return 200;
4001
+ default:
4002
+ return t.bodyLineHeight;
4003
+ }
4004
+ }
4005
+ /** Estimate a wrapped line count from raw character length against the max width. */
4006
+ estimateTextHeight(text, lineHeight) {
4007
+ const charsPerLine = Math.max(20, Math.floor(this.maxWidth / (this.theme.fontSize * 0.5)));
4008
+ const lines = Math.max(1, Math.ceil(text.length / charsPerLine));
4009
+ return lines * lineHeight;
4010
+ }
4011
+ /**
4012
+ * Mount the blocks in the current window and unmount the rest. The `content`
4013
+ * Stack lays the mounted window contiguously from y = 0; `content.y` is then
4014
+ * set to the skipped prefix sum so the first mounted block lands at its true
4015
+ * y — a numeric spacer, not a wrapper entity.
4016
+ */
4017
+ reconcileVirtual() {
4018
+ const tree = this.virtualHeights;
4019
+ const tokens = this.virtualTokens;
4020
+ if (!tree || !tokens) return;
4021
+ const first = tree.indexAt(Math.max(0, this.virtualScrollY - this.virtualOverscan));
4022
+ const last = tree.indexAt(
4023
+ Math.max(0, this.virtualScrollY + this.virtualViewportH + this.virtualOverscan)
4024
+ );
4025
+ let changed = false;
4026
+ for (const [idx, el] of this.virtualMounted) {
4027
+ if (idx < first || idx > last) {
4028
+ el.destroy();
4029
+ this.virtualMounted.delete(idx);
4030
+ changed = true;
4031
+ }
4032
+ }
4033
+ for (let i = first; i <= last; i++) {
4034
+ if (this.virtualMounted.has(i)) continue;
4035
+ const el = this.renderToken(tokens[i]);
4036
+ if (!el) continue;
4037
+ this.content.add(el);
4038
+ tree.set(i, el.height + this.theme.blockGap);
4039
+ this.virtualMounted.set(i, el);
4040
+ changed = true;
4041
+ }
4042
+ if (changed) this.content.layout();
4043
+ this.content.y = tree.prefix(first);
4044
+ const nextHeight = Math.max(0, tree.total() - this.theme.blockGap);
4045
+ const heightChanged = nextHeight !== this.height;
4046
+ this.width = this.content.width;
4047
+ this.height = nextHeight;
4048
+ if (heightChanged) this.notifyLayoutUpdated();
4049
+ }
3790
4050
  /** Create a frame-coalesced stream bound to this Markdown instance. */
3791
4051
  createStream(options = {}) {
4052
+ if (this.virtualizeBlocks) {
4053
+ throw new Error("Markdown.createStream is not supported when virtualize is enabled");
4054
+ }
3792
4055
  if (this.streamController) {
3793
4056
  throw new Error("Markdown already has an active StreamController");
3794
4057
  }
@@ -3861,6 +4124,18 @@ var Markdown = class _Markdown extends import_ui4.UIComponent {
3861
4124
  const next = Math.max(0, maxWidth);
3862
4125
  if (next === this.maxWidth) return this;
3863
4126
  this.maxWidth = next;
4127
+ if (this.virtualizeBlocks && this.virtualTokens && this.virtualHeights) {
4128
+ const tokens = this.virtualTokens;
4129
+ const tree = this.virtualHeights;
4130
+ for (const el of this.virtualMounted.values()) el.destroy();
4131
+ this.virtualMounted.clear();
4132
+ for (let i = 0; i < tokens.length; i++) {
4133
+ tree.set(i, this.estimateBlockHeight(tokens[i]) + this.theme.blockGap);
4134
+ }
4135
+ this.reconcileVirtual();
4136
+ this.scene?.markDirty();
4137
+ return this;
4138
+ }
3864
4139
  let childIndex = 0;
3865
4140
  const children = this.content.children;
3866
4141
  for (const token of this.tokens) {
@@ -4395,6 +4670,9 @@ var Markdown = class _Markdown extends import_ui4.UIComponent {
4395
4670
  return this.appendMarkdownCore(chunk);
4396
4671
  }
4397
4672
  appendMarkdownCore(chunk) {
4673
+ if (this.virtualizeBlocks) {
4674
+ throw new Error("Markdown.appendMarkdown is not supported when virtualize is enabled");
4675
+ }
4398
4676
  const before = this.rawMarkdown.length;
4399
4677
  this.consumeFrontMatter(chunk);
4400
4678
  this.streamStats.appends++;
package/dist/index.mjs CHANGED
@@ -1273,6 +1273,70 @@ function highlightLine(line, lang, theme, carry = null) {
1273
1273
  flush(theme.codeColor);
1274
1274
  return { segments, carry: null };
1275
1275
  }
1276
+ var HSCROLL_HIT_H = 12;
1277
+ var HSCROLL_THUMB_H = 4;
1278
+ var HSCROLL_THUMB_INSET = 4;
1279
+ var HSCROLL_THUMB_MIN = 24;
1280
+ var HSCROLL_TRACK_COLOR = "rgba(128, 128, 128, 0.14)";
1281
+ var HSCROLL_THUMB_COLOR = "rgba(128, 128, 128, 0.45)";
1282
+ var HSCROLL_THUMB_ACTIVE_COLOR = "rgba(128, 128, 128, 0.75)";
1283
+ function hScrollMetrics(trackW, maxScrollX) {
1284
+ const content = trackW + maxScrollX;
1285
+ const proportional = content > 0 ? trackW / content * trackW : trackW;
1286
+ const thumbW = Math.min(trackW, Math.max(HSCROLL_THUMB_MIN, proportional));
1287
+ return { thumbW, range: Math.max(1, trackW - thumbW) };
1288
+ }
1289
+ var HScrollTrack = class extends UIComponent {
1290
+ constructor(block) {
1291
+ super();
1292
+ this.block = block;
1293
+ this.on("pointerdown", (e) => {
1294
+ if (e.localX === void 0) return;
1295
+ const maxSX = this.block.maxScrollX;
1296
+ if (maxSX <= 0) return;
1297
+ const { thumbW, range } = hScrollMetrics(this.width, maxSX);
1298
+ const thumbX = this.block.scrollX / maxSX * range;
1299
+ if (e.localX < thumbX || e.localX > thumbX + thumbW) {
1300
+ this.block.setScrollX((e.localX - thumbW / 2) / range * maxSX);
1301
+ }
1302
+ this.dragging = true;
1303
+ this.dragAnchorX = e.localX;
1304
+ this.scrollAtDragStart = this.block.scrollX;
1305
+ this.scene?.markDirty();
1306
+ });
1307
+ this.on("pointermove", (e) => {
1308
+ if (!this.dragging || e.localX === void 0) return;
1309
+ const maxSX = this.block.maxScrollX;
1310
+ const { range } = hScrollMetrics(this.width, maxSX);
1311
+ const dx = e.localX - this.dragAnchorX;
1312
+ this.block.setScrollX(this.scrollAtDragStart + dx / range * maxSX);
1313
+ });
1314
+ const endDrag = () => {
1315
+ if (!this.dragging) return;
1316
+ this.dragging = false;
1317
+ this.scene?.markDirty();
1318
+ };
1319
+ this.on("pointerup", endDrag);
1320
+ this.on("pointerleave", endDrag);
1321
+ }
1322
+ block;
1323
+ /** True while a drag owns the thumb; read by the block's painter for the active tint. */
1324
+ dragging = false;
1325
+ dragAnchorX = 0;
1326
+ scrollAtDragStart = 0;
1327
+ getA11yAttributes() {
1328
+ return {
1329
+ role: "scrollbar",
1330
+ label: "Scroll code horizontally",
1331
+ value: String(Math.round(this.block.scrollX)),
1332
+ valuemin: "0",
1333
+ valuemax: String(Math.round(this.block.maxScrollX))
1334
+ };
1335
+ }
1336
+ /** Nothing to draw: the thumb is painted by the owning block, so both share one pass. */
1337
+ render() {
1338
+ }
1339
+ };
1276
1340
  var CodeBlock = class extends UIComponent {
1277
1341
  lines;
1278
1342
  /**
@@ -1310,6 +1374,12 @@ var CodeBlock = class extends UIComponent {
1310
1374
  /** Memoized widest prepared line, keyed by the grid identity it came from. */
1311
1375
  contentWidthGrid = null;
1312
1376
  contentWidthValue = 0;
1377
+ /**
1378
+ * The interactive horizontal scrollbar strip (#527), created lazily by
1379
+ * {@link syncScrollTrack} the first time the block overflows. Kept, and merely
1380
+ * disabled, when the overflow later disappears — see there for why.
1381
+ */
1382
+ hTrack = null;
1313
1383
  lang;
1314
1384
  theme;
1315
1385
  /**
@@ -1614,6 +1684,31 @@ var CodeBlock = class extends UIComponent {
1614
1684
  }
1615
1685
  return this.grid;
1616
1686
  }
1687
+ /**
1688
+ * Fit the interactive scrollbar strip (#527) to the current overflow.
1689
+ *
1690
+ * Called from {@link render} rather than from `setWidth` or `buildLines`:
1691
+ * `setWidth` is documented as costing nothing and `maxScrollX` costs a grid
1692
+ * build, while `render` is about to pay for that grid anyway. Once created,
1693
+ * the child is KEPT and merely de-`interactive`d when the overflow disappears
1694
+ * — this runs inside the scene's tree walk, and removing a child mid-walk is
1695
+ * how siblings get skipped.
1696
+ */
1697
+ syncScrollTrack() {
1698
+ if (this.maxScrollX <= 0) {
1699
+ if (this.hTrack) this.hTrack.interactive = false;
1700
+ return;
1701
+ }
1702
+ if (!this.hTrack) {
1703
+ this.hTrack = new HScrollTrack(this);
1704
+ this.add(this.hTrack);
1705
+ }
1706
+ this.hTrack.interactive = true;
1707
+ this.hTrack.x = this.pad;
1708
+ this.hTrack.y = this.height - HSCROLL_HIT_H;
1709
+ this.hTrack.width = Math.max(0, this.width - this.pad * 2);
1710
+ this.hTrack.height = HSCROLL_HIT_H;
1711
+ }
1617
1712
  /**
1618
1713
  * Not hit-testable, and deliberately still not `interactive`, even though the
1619
1714
  * block now consumes wheel events to scroll.
@@ -1622,11 +1717,16 @@ var CodeBlock = class extends UIComponent {
1622
1717
  * hit-testing, so no a11y shadow node is needed. Creating one would place a
1623
1718
  * `pointer-events: auto` element above the transparent text mirror and swallow
1624
1719
  * the mousedown that starts a native drag-selection.
1720
+ *
1721
+ * Pointer-driven scrolling therefore lives on the {@link HScrollTrack} CHILD,
1722
+ * whose shadow node covers only the bottom-padding strip below the last
1723
+ * selectable carrier — never the text itself.
1625
1724
  */
1626
1725
  isPointInside() {
1627
1726
  return false;
1628
1727
  }
1629
1728
  render(r) {
1729
+ this.syncScrollTrack();
1630
1730
  r.beginPath();
1631
1731
  r.roundRect(0, 0, this.width, this.height, this.theme.codeRadius);
1632
1732
  r.fill(this.theme.codeBgColor);
@@ -1696,6 +1796,24 @@ var CodeBlock = class extends UIComponent {
1696
1796
  }
1697
1797
  }
1698
1798
  r.restore();
1799
+ const maxSX = this.maxScrollX;
1800
+ if (maxSX > 0) {
1801
+ const trackW = Math.max(0, this.width - this.pad * 2);
1802
+ const { thumbW, range } = hScrollMetrics(trackW, maxSX);
1803
+ const trackY = this.height - HSCROLL_THUMB_H - HSCROLL_THUMB_INSET;
1804
+ r.beginPath();
1805
+ r.roundRect(this.pad, trackY, trackW, HSCROLL_THUMB_H, HSCROLL_THUMB_H / 2);
1806
+ r.fill(HSCROLL_TRACK_COLOR);
1807
+ r.beginPath();
1808
+ r.roundRect(
1809
+ this.pad + this.scrollX / maxSX * range,
1810
+ trackY,
1811
+ thumbW,
1812
+ HSCROLL_THUMB_H,
1813
+ HSCROLL_THUMB_H / 2
1814
+ );
1815
+ r.fill(this.hTrack?.dragging ? HSCROLL_THUMB_ACTIVE_COLOR : HSCROLL_THUMB_COLOR);
1816
+ }
1699
1817
  }
1700
1818
  };
1701
1819
  var codeAtlases = /* @__PURE__ */ new Map();
@@ -2830,7 +2948,15 @@ function renderFencedBlock(source, lang, options) {
2830
2948
  }
2831
2949
 
2832
2950
  // src/Markdown.ts
2833
- import { RichText as RichText2, Stack, Table, Text, Image, UIComponent as UIComponent3 } from "@vectojs/ui";
2951
+ import {
2952
+ RichText as RichText2,
2953
+ RowHeights,
2954
+ Stack,
2955
+ Table,
2956
+ Text,
2957
+ Image,
2958
+ UIComponent as UIComponent3
2959
+ } from "@vectojs/ui";
2834
2960
 
2835
2961
  // src/blockAffordances.ts
2836
2962
  import { Button, measureText as measureText2, UIComponent as UIComponent2 } from "@vectojs/ui";
@@ -3327,6 +3453,19 @@ var Markdown = class _Markdown extends UIComponent3 {
3327
3453
  /** File saver used by the download controls. */
3328
3454
  saveFile;
3329
3455
  activeBlockMetrics = null;
3456
+ /** Whether `opts.virtualize` enabled viewport-culled block materialization. */
3457
+ virtualizeBlocks;
3458
+ /** Overscan margin (px) added above/below the visible window before mounting. */
3459
+ virtualOverscan;
3460
+ /** Top-level tokens that produce a child entity, in document order. */
3461
+ virtualTokens = null;
3462
+ /** Fenwick tree over per-block strides (height + blockGap) when virtualizing. */
3463
+ virtualHeights = null;
3464
+ /** token index → mounted entity, for blocks currently inside `content`. */
3465
+ virtualMounted = /* @__PURE__ */ new Map();
3466
+ /** Visible window top (scroll offset) and height, last set via setVisibleRange. */
3467
+ virtualScrollY = 0;
3468
+ virtualViewportH = 0;
3330
3469
  /**
3331
3470
  * Called after this entity's own `width`/`height` changed because the document
3332
3471
  * was re-laid-out. **This is the hook to wire up when a host has to move or
@@ -3612,6 +3751,9 @@ var Markdown = class _Markdown extends UIComponent3 {
3612
3751
  this.showCodeLanguage = opts.showCodeLanguage ?? false;
3613
3752
  this.writeClipboard = opts.writeClipboard ?? defaultWriteClipboard;
3614
3753
  this.saveFile = opts.saveFile ?? defaultSaveFile;
3754
+ const virt = opts.virtualize;
3755
+ this.virtualizeBlocks = virt === true || typeof virt === "object" && virt !== null;
3756
+ this.virtualOverscan = typeof virt === "object" && virt !== null && typeof virt.overscan === "number" ? virt.overscan : 800;
3615
3757
  this.content = new Stack({
3616
3758
  direction: "vertical",
3617
3759
  gap: this.theme.blockGap
@@ -3724,6 +3866,20 @@ var Markdown = class _Markdown extends UIComponent3 {
3724
3866
  const tokens = lexMarkdown(text, this._userTiming);
3725
3867
  this.setTokens(tokens);
3726
3868
  this.abbreviations = collectAbbreviations(tokens);
3869
+ if (this.virtualizeBlocks) {
3870
+ this.virtualTokens = tokens.filter((token) => this.producesEntity(token));
3871
+ const n = this.virtualTokens.length;
3872
+ this.virtualHeights = new RowHeights(n, 0);
3873
+ for (let i = 0; i < n; i++) {
3874
+ this.virtualHeights.set(
3875
+ i,
3876
+ this.estimateBlockHeight(this.virtualTokens[i]) + this.theme.blockGap
3877
+ );
3878
+ }
3879
+ this.virtualMounted.clear();
3880
+ this.reconcileVirtual();
3881
+ return;
3882
+ }
3727
3883
  for (const token of tokens) {
3728
3884
  const el = this.renderToken(token);
3729
3885
  if (el) {
@@ -3733,8 +3889,123 @@ var Markdown = class _Markdown extends UIComponent3 {
3733
3889
  this.width = this.content.width;
3734
3890
  this.height = this.content.height;
3735
3891
  }
3892
+ /**
3893
+ * Drive the virtualized window from the host's scroll state.
3894
+ *
3895
+ * Only the blocks intersecting `[scrollY − overscan, scrollY + viewportHeight +
3896
+ * overscan]` stay materialized; every other block is a numeric offset in the
3897
+ * height tree, so the scrollbar still reports the full document height while
3898
+ * only a window of blocks exists as entities. A no-op when `virtualize` was
3899
+ * not enabled.
3900
+ */
3901
+ setVisibleRange(scrollY, viewportHeight) {
3902
+ if (!this.virtualizeBlocks) return;
3903
+ this.virtualScrollY = Math.max(0, scrollY);
3904
+ this.virtualViewportH = Math.max(0, viewportHeight);
3905
+ this.reconcileVirtual();
3906
+ }
3907
+ /**
3908
+ * Cheap per-block height estimate from the token source, refined to the exact
3909
+ * measured height when the block first mounts. Coarse by design: the estimate
3910
+ * only has to keep the scrollbar plausible before a block is first seen; the
3911
+ * Fenwick tree corrects the rest.
3912
+ */
3913
+ estimateBlockHeight(token) {
3914
+ const t = this.theme;
3915
+ switch (token.type) {
3916
+ case "heading": {
3917
+ const size = headingSize(t, token.depth);
3918
+ return Math.ceil(size * 1.5) + 8;
3919
+ }
3920
+ case "paragraph":
3921
+ return this.estimateTextHeight(token.text, t.bodyLineHeight);
3922
+ case "code": {
3923
+ const raw = token.text;
3924
+ const lines = Math.max(1, raw.split("\n").length - (raw.endsWith("\n") ? 1 : 0));
3925
+ let h = lines * t.codeLineHeight + t.codePadding * 2;
3926
+ if (this.showCodeLanguage) h += t.codeLangFontSize + 8;
3927
+ return h;
3928
+ }
3929
+ case "hr":
3930
+ return 24;
3931
+ case "blockquote":
3932
+ case "container": {
3933
+ const inner = token.tokens ?? [];
3934
+ let sum = 0;
3935
+ for (const tk of inner) sum += this.estimateBlockHeight(tk) + t.blockGap;
3936
+ return sum + t.quoteInnerGap * 2 + 8;
3937
+ }
3938
+ case "list": {
3939
+ const items = token.items ?? [];
3940
+ let sum = 0;
3941
+ for (const item of items) {
3942
+ for (const tk of item.tokens ?? []) sum += this.estimateBlockHeight(tk) + t.listItemGap;
3943
+ }
3944
+ return sum + t.listGap;
3945
+ }
3946
+ case "table": {
3947
+ const table = token;
3948
+ const rows = (table.rows?.length ?? 0) + (table.header?.length ?? 0);
3949
+ return rows * (t.tableFontSize + 16) + 16;
3950
+ }
3951
+ case "footnoteDef":
3952
+ return this.estimateTextHeight(token.text ?? "", t.bodyLineHeight);
3953
+ case "html":
3954
+ return 200;
3955
+ default:
3956
+ return t.bodyLineHeight;
3957
+ }
3958
+ }
3959
+ /** Estimate a wrapped line count from raw character length against the max width. */
3960
+ estimateTextHeight(text, lineHeight) {
3961
+ const charsPerLine = Math.max(20, Math.floor(this.maxWidth / (this.theme.fontSize * 0.5)));
3962
+ const lines = Math.max(1, Math.ceil(text.length / charsPerLine));
3963
+ return lines * lineHeight;
3964
+ }
3965
+ /**
3966
+ * Mount the blocks in the current window and unmount the rest. The `content`
3967
+ * Stack lays the mounted window contiguously from y = 0; `content.y` is then
3968
+ * set to the skipped prefix sum so the first mounted block lands at its true
3969
+ * y — a numeric spacer, not a wrapper entity.
3970
+ */
3971
+ reconcileVirtual() {
3972
+ const tree = this.virtualHeights;
3973
+ const tokens = this.virtualTokens;
3974
+ if (!tree || !tokens) return;
3975
+ const first = tree.indexAt(Math.max(0, this.virtualScrollY - this.virtualOverscan));
3976
+ const last = tree.indexAt(
3977
+ Math.max(0, this.virtualScrollY + this.virtualViewportH + this.virtualOverscan)
3978
+ );
3979
+ let changed = false;
3980
+ for (const [idx, el] of this.virtualMounted) {
3981
+ if (idx < first || idx > last) {
3982
+ el.destroy();
3983
+ this.virtualMounted.delete(idx);
3984
+ changed = true;
3985
+ }
3986
+ }
3987
+ for (let i = first; i <= last; i++) {
3988
+ if (this.virtualMounted.has(i)) continue;
3989
+ const el = this.renderToken(tokens[i]);
3990
+ if (!el) continue;
3991
+ this.content.add(el);
3992
+ tree.set(i, el.height + this.theme.blockGap);
3993
+ this.virtualMounted.set(i, el);
3994
+ changed = true;
3995
+ }
3996
+ if (changed) this.content.layout();
3997
+ this.content.y = tree.prefix(first);
3998
+ const nextHeight = Math.max(0, tree.total() - this.theme.blockGap);
3999
+ const heightChanged = nextHeight !== this.height;
4000
+ this.width = this.content.width;
4001
+ this.height = nextHeight;
4002
+ if (heightChanged) this.notifyLayoutUpdated();
4003
+ }
3736
4004
  /** Create a frame-coalesced stream bound to this Markdown instance. */
3737
4005
  createStream(options = {}) {
4006
+ if (this.virtualizeBlocks) {
4007
+ throw new Error("Markdown.createStream is not supported when virtualize is enabled");
4008
+ }
3738
4009
  if (this.streamController) {
3739
4010
  throw new Error("Markdown already has an active StreamController");
3740
4011
  }
@@ -3807,6 +4078,18 @@ var Markdown = class _Markdown extends UIComponent3 {
3807
4078
  const next = Math.max(0, maxWidth);
3808
4079
  if (next === this.maxWidth) return this;
3809
4080
  this.maxWidth = next;
4081
+ if (this.virtualizeBlocks && this.virtualTokens && this.virtualHeights) {
4082
+ const tokens = this.virtualTokens;
4083
+ const tree = this.virtualHeights;
4084
+ for (const el of this.virtualMounted.values()) el.destroy();
4085
+ this.virtualMounted.clear();
4086
+ for (let i = 0; i < tokens.length; i++) {
4087
+ tree.set(i, this.estimateBlockHeight(tokens[i]) + this.theme.blockGap);
4088
+ }
4089
+ this.reconcileVirtual();
4090
+ this.scene?.markDirty();
4091
+ return this;
4092
+ }
3810
4093
  let childIndex = 0;
3811
4094
  const children = this.content.children;
3812
4095
  for (const token of this.tokens) {
@@ -4341,6 +4624,9 @@ var Markdown = class _Markdown extends UIComponent3 {
4341
4624
  return this.appendMarkdownCore(chunk);
4342
4625
  }
4343
4626
  appendMarkdownCore(chunk) {
4627
+ if (this.virtualizeBlocks) {
4628
+ throw new Error("Markdown.appendMarkdown is not supported when virtualize is enabled");
4629
+ }
4344
4630
  const before = this.rawMarkdown.length;
4345
4631
  this.consumeFrontMatter(chunk);
4346
4632
  this.streamStats.appends++;
@@ -71,6 +71,12 @@ export declare class CodeBlock extends UIComponent {
71
71
  /** Memoized widest prepared line, keyed by the grid identity it came from. */
72
72
  private contentWidthGrid;
73
73
  private contentWidthValue;
74
+ /**
75
+ * The interactive horizontal scrollbar strip (#527), created lazily by
76
+ * {@link syncScrollTrack} the first time the block overflows. Kept, and merely
77
+ * disabled, when the overflow later disappears — see there for why.
78
+ */
79
+ private hTrack;
74
80
  private lang;
75
81
  private theme;
76
82
  /**
@@ -203,6 +209,17 @@ export declare class CodeBlock extends UIComponent {
203
209
  */
204
210
  private buildLines;
205
211
  private ensureGrid;
212
+ /**
213
+ * Fit the interactive scrollbar strip (#527) to the current overflow.
214
+ *
215
+ * Called from {@link render} rather than from `setWidth` or `buildLines`:
216
+ * `setWidth` is documented as costing nothing and `maxScrollX` costs a grid
217
+ * build, while `render` is about to pay for that grid anyway. Once created,
218
+ * the child is KEPT and merely de-`interactive`d when the overflow disappears
219
+ * — this runs inside the scene's tree walk, and removing a child mid-walk is
220
+ * how siblings get skipped.
221
+ */
222
+ private syncScrollTrack;
206
223
  /**
207
224
  * Not hit-testable, and deliberately still not `interactive`, even though the
208
225
  * block now consumes wheel events to scroll.
@@ -211,6 +228,10 @@ export declare class CodeBlock extends UIComponent {
211
228
  * hit-testing, so no a11y shadow node is needed. Creating one would place a
212
229
  * `pointer-events: auto` element above the transparent text mirror and swallow
213
230
  * the mousedown that starts a native drag-selection.
231
+ *
232
+ * Pointer-driven scrolling therefore lives on the {@link HScrollTrack} CHILD,
233
+ * whose shadow node covers only the bottom-padding strip below the last
234
+ * selectable carrier — never the text itself.
214
235
  */
215
236
  isPointInside(): boolean;
216
237
  render(r: IRenderer): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectojs/markdown",
3
- "version": "0.20.2",
3
+ "version": "0.21.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },