@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.
- package/dist/Markdown.d.ts +56 -0
- package/dist/index.js +278 -0
- package/dist/index.mjs +287 -1
- package/dist/markdown-code.d.ts +21 -0
- package/package.json +1 -1
package/dist/Markdown.d.ts
CHANGED
|
@@ -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 {
|
|
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++;
|
package/dist/markdown-code.d.ts
CHANGED
|
@@ -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;
|