@vectojs/markdown 0.1.1 → 0.2.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 +54 -1
- package/dist/index.js +199 -19
- package/dist/index.mjs +197 -18
- package/package.json +1 -1
package/dist/Markdown.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Entity, IRenderer, type ContentProjection } from '@vectojs/core';
|
|
1
|
+
import { Entity, type DevtoolsDescriptor, GlyphRasterAtlas, type GlyphRasterAtlasStats, IRenderer, type ContentProjection } from '@vectojs/core';
|
|
2
2
|
import { type Token } from 'marked';
|
|
3
3
|
import { Stack, UIComponent } from '@vectojs/ui';
|
|
4
4
|
/** Color and typography theme for Markdown rendering. */
|
|
@@ -37,6 +37,8 @@ export interface MarkdownTheme {
|
|
|
37
37
|
export declare class CodeBlock extends UIComponent {
|
|
38
38
|
private lines;
|
|
39
39
|
private grid;
|
|
40
|
+
/** Raw (unhighlighted) lines of the last build, for prefix reuse in buildLines. */
|
|
41
|
+
private rawLines;
|
|
40
42
|
private cellWidth;
|
|
41
43
|
private source;
|
|
42
44
|
private lang;
|
|
@@ -51,12 +53,43 @@ export declare class CodeBlock extends UIComponent {
|
|
|
51
53
|
/** Enable or disable browser-native selection for this code block. */
|
|
52
54
|
setSelectable(selectable: boolean): this;
|
|
53
55
|
getContentProjection(): ContentProjection | null;
|
|
56
|
+
/**
|
|
57
|
+
* Re-highlight the code, reusing the highlight of any unchanged line prefix.
|
|
58
|
+
*
|
|
59
|
+
* Streaming appends to the END of a block, so all but the last line or two are
|
|
60
|
+
* byte-identical to the previous call — yet this used to re-highlight every
|
|
61
|
+
* line on every chunk, making a streamed block O(N) per append and O(N^2)
|
|
62
|
+
* overall. Reusing the stable prefix makes an append proportional to what
|
|
63
|
+
* actually changed.
|
|
64
|
+
*
|
|
65
|
+
* The last previously-seen line is deliberately NOT reused: a chunk usually
|
|
66
|
+
* lands mid-line, so that line's text (and therefore its tokenization) changes.
|
|
67
|
+
*/
|
|
54
68
|
private buildLines;
|
|
55
69
|
private ensureGrid;
|
|
56
70
|
/** Code blocks are decorative — not interactive. */
|
|
57
71
|
isPointInside(): boolean;
|
|
58
72
|
render(r: IRenderer): void;
|
|
59
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Instrumentation for the shared code-block glyph atlas, or `null` before first
|
|
76
|
+
* use.
|
|
77
|
+
*
|
|
78
|
+
* Exposed so an app or benchmark can confirm the atlas is actually active and
|
|
79
|
+
* reusing slots. Watch `resets`: a steadily climbing count means the glyph set is
|
|
80
|
+
* unbounded for the atlas size, so every reset re-rasterizes everything and the
|
|
81
|
+
* atlas is doing net harm rather than saving work.
|
|
82
|
+
*/
|
|
83
|
+
export declare function codeAtlasStats(): GlyphRasterAtlasStats | null;
|
|
84
|
+
/**
|
|
85
|
+
* The shared code-block atlas itself, or `null` before first use.
|
|
86
|
+
*
|
|
87
|
+
* For instrumentation that must map a traced `drawImage` back to the glyph it
|
|
88
|
+
* painted — a blit carries only a source rect, so `slotAt()` is the only way to
|
|
89
|
+
* recover the cluster and its metrics. Used by `e2e/text-projection.e2e.ts` to
|
|
90
|
+
* keep the code-grid positioning assertions working on the blit path.
|
|
91
|
+
*/
|
|
92
|
+
export declare function codeAtlas(): GlyphRasterAtlas | null;
|
|
60
93
|
export interface MarkdownOptions {
|
|
61
94
|
maxWidth?: number;
|
|
62
95
|
theme?: MarkdownTheme;
|
|
@@ -91,6 +124,16 @@ export declare class Markdown extends UIComponent {
|
|
|
91
124
|
private tokens;
|
|
92
125
|
private appendInFlight;
|
|
93
126
|
private appendPending;
|
|
127
|
+
/**
|
|
128
|
+
* Streaming counters for the DevTools inspector.
|
|
129
|
+
*
|
|
130
|
+
* Cheap enough to keep always-on (four integer increments per append) and the
|
|
131
|
+
* only way to see, from outside, whether incremental reuse is actually working:
|
|
132
|
+
* a stable-prefix ratio near 1 means the worker is matching almost everything
|
|
133
|
+
* and only the tail is re-lexed, while a ratio near 0 means every chunk is
|
|
134
|
+
* re-parsing the whole document.
|
|
135
|
+
*/
|
|
136
|
+
private streamStats;
|
|
94
137
|
private pendingWorkerIds;
|
|
95
138
|
private readonly workerInstanceId;
|
|
96
139
|
private tokenVersion;
|
|
@@ -116,6 +159,16 @@ export declare class Markdown extends UIComponent {
|
|
|
116
159
|
* content subtree via `super.destroy()` so every block's resources are freed.
|
|
117
160
|
*/
|
|
118
161
|
destroy(): void;
|
|
162
|
+
/**
|
|
163
|
+
* Streaming and parse state — the markdown streaming inspector.
|
|
164
|
+
*
|
|
165
|
+
* Source length, chunk count, worker in-flight state, and the stable-prefix
|
|
166
|
+
* versus re-lexed-tail split. That last ratio is the one worth watching: it is
|
|
167
|
+
* how you tell incremental reuse is working from outside, and nothing else
|
|
168
|
+
* surfaces it. A ratio near 1 means the worker matched almost the whole prefix
|
|
169
|
+
* and only re-lexed the tail; near 0 means every chunk re-parses the document.
|
|
170
|
+
*/
|
|
171
|
+
getDevtoolsDescriptor(): DevtoolsDescriptor;
|
|
119
172
|
/** Enable or disable native selection for existing and future Markdown text. */
|
|
120
173
|
setSelectable(selectable: boolean): this;
|
|
121
174
|
/** Append a markdown chunk incrementally. Reuses unchanged prefix entities. */
|
package/dist/index.js
CHANGED
|
@@ -21,7 +21,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
CodeBlock: () => CodeBlock,
|
|
24
|
-
Markdown: () => Markdown
|
|
24
|
+
Markdown: () => Markdown,
|
|
25
|
+
codeAtlas: () => codeAtlas,
|
|
26
|
+
codeAtlasStats: () => codeAtlasStats
|
|
25
27
|
});
|
|
26
28
|
module.exports = __toCommonJS(index_exports);
|
|
27
29
|
|
|
@@ -438,6 +440,8 @@ function highlightLine(line, lang, theme) {
|
|
|
438
440
|
var CodeBlock = class extends import_ui.UIComponent {
|
|
439
441
|
lines;
|
|
440
442
|
grid = null;
|
|
443
|
+
/** Raw (unhighlighted) lines of the last build, for prefix reuse in buildLines. */
|
|
444
|
+
rawLines = null;
|
|
441
445
|
cellWidth = 0;
|
|
442
446
|
source;
|
|
443
447
|
lang;
|
|
@@ -497,9 +501,36 @@ var CodeBlock = class extends import_ui.UIComponent {
|
|
|
497
501
|
grid
|
|
498
502
|
};
|
|
499
503
|
}
|
|
504
|
+
/**
|
|
505
|
+
* Re-highlight the code, reusing the highlight of any unchanged line prefix.
|
|
506
|
+
*
|
|
507
|
+
* Streaming appends to the END of a block, so all but the last line or two are
|
|
508
|
+
* byte-identical to the previous call — yet this used to re-highlight every
|
|
509
|
+
* line on every chunk, making a streamed block O(N) per append and O(N^2)
|
|
510
|
+
* overall. Reusing the stable prefix makes an append proportional to what
|
|
511
|
+
* actually changed.
|
|
512
|
+
*
|
|
513
|
+
* The last previously-seen line is deliberately NOT reused: a chunk usually
|
|
514
|
+
* lands mid-line, so that line's text (and therefore its tokenization) changes.
|
|
515
|
+
*/
|
|
500
516
|
buildLines(code) {
|
|
501
517
|
const rawLines = code.split(/\r\n|\r|\n/);
|
|
502
|
-
|
|
518
|
+
const previous = this.rawLines;
|
|
519
|
+
let reusable = 0;
|
|
520
|
+
if (previous && this.lines.length === previous.length) {
|
|
521
|
+
const limit = Math.min(previous.length - 1, rawLines.length);
|
|
522
|
+
while (reusable < limit && previous[reusable] === rawLines[reusable]) reusable++;
|
|
523
|
+
}
|
|
524
|
+
if (reusable > 0) {
|
|
525
|
+
const next = this.lines.slice(0, reusable);
|
|
526
|
+
for (let i = reusable; i < rawLines.length; i++) {
|
|
527
|
+
next.push(highlightLine(rawLines[i], this.lang, this.theme));
|
|
528
|
+
}
|
|
529
|
+
this.lines = next;
|
|
530
|
+
} else {
|
|
531
|
+
this.lines = rawLines.map((l) => highlightLine(l, this.lang, this.theme));
|
|
532
|
+
}
|
|
533
|
+
this.rawLines = rawLines;
|
|
503
534
|
this.grid = null;
|
|
504
535
|
this.height = this.pad * 2 + rawLines.length * this.lineH;
|
|
505
536
|
}
|
|
@@ -524,6 +555,9 @@ var CodeBlock = class extends import_ui.UIComponent {
|
|
|
524
555
|
r.roundRect(0, 0, this.width, this.height, 8);
|
|
525
556
|
r.fill(this.theme.codeBgColor);
|
|
526
557
|
const grid = this.ensureGrid();
|
|
558
|
+
const atlas = codeGlyphAtlas(r);
|
|
559
|
+
const atlasSource = atlas?.source ?? null;
|
|
560
|
+
const blit = atlas ? r.drawImageRect : void 0;
|
|
527
561
|
for (let row = 0; row < grid.lines.length; row++) {
|
|
528
562
|
const yBaseline = this.pad + row * this.lineH + this.lineH * 0.75;
|
|
529
563
|
const segments = this.lines[row];
|
|
@@ -538,17 +572,51 @@ var CodeBlock = class extends import_ui.UIComponent {
|
|
|
538
572
|
}
|
|
539
573
|
const sourceText = this.source.slice(cell.sourceStart, cell.sourceEnd);
|
|
540
574
|
if (cell.advance <= 0 || sourceText === " " || sourceText === " ") continue;
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
575
|
+
const color = segments[segmentIndex]?.color ?? this.theme.codeColor;
|
|
576
|
+
const x = this.pad + cell.x;
|
|
577
|
+
if (blit && atlas) {
|
|
578
|
+
const slot = atlas.get(this.codeFont, color, cell.glyph);
|
|
579
|
+
const src = atlasSource ?? atlas.source;
|
|
580
|
+
if (slot && src) {
|
|
581
|
+
blit.call(
|
|
582
|
+
r,
|
|
583
|
+
src,
|
|
584
|
+
slot.sx,
|
|
585
|
+
slot.sy,
|
|
586
|
+
slot.sw,
|
|
587
|
+
slot.sh,
|
|
588
|
+
x - slot.offsetX,
|
|
589
|
+
yBaseline - slot.offsetY,
|
|
590
|
+
slot.w,
|
|
591
|
+
slot.h
|
|
592
|
+
);
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
r.fillText(cell.glyph, x, yBaseline, this.codeFont, color);
|
|
548
597
|
}
|
|
549
598
|
}
|
|
550
599
|
}
|
|
551
600
|
};
|
|
601
|
+
var sharedCodeAtlas = null;
|
|
602
|
+
function codeGlyphAtlas(r) {
|
|
603
|
+
if (typeof r.drawImageRect !== "function") return void 0;
|
|
604
|
+
if (typeof document === "undefined") return void 0;
|
|
605
|
+
sharedCodeAtlas ??= new import_core.GlyphRasterAtlas({
|
|
606
|
+
// Match the display so a HiDPI blit stays crisp. Capped at 3 because atlas
|
|
607
|
+
// area grows with dpr² and a 4x display would otherwise blow the size cap
|
|
608
|
+
// with a few hundred glyphs.
|
|
609
|
+
dpr: typeof window !== "undefined" ? Math.min(window.devicePixelRatio || 1, 3) : 1,
|
|
610
|
+
maxSize: 2048
|
|
611
|
+
});
|
|
612
|
+
return sharedCodeAtlas;
|
|
613
|
+
}
|
|
614
|
+
function codeAtlasStats() {
|
|
615
|
+
return sharedCodeAtlas ? sharedCodeAtlas.stats : null;
|
|
616
|
+
}
|
|
617
|
+
function codeAtlas() {
|
|
618
|
+
return sharedCodeAtlas;
|
|
619
|
+
}
|
|
552
620
|
function decodeEntities(text) {
|
|
553
621
|
return text.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
554
622
|
}
|
|
@@ -688,6 +756,23 @@ var Markdown = class extends import_ui.UIComponent {
|
|
|
688
756
|
// with the latest accumulated text once it resolves.
|
|
689
757
|
appendInFlight = false;
|
|
690
758
|
appendPending = false;
|
|
759
|
+
/**
|
|
760
|
+
* Streaming counters for the DevTools inspector.
|
|
761
|
+
*
|
|
762
|
+
* Cheap enough to keep always-on (four integer increments per append) and the
|
|
763
|
+
* only way to see, from outside, whether incremental reuse is actually working:
|
|
764
|
+
* a stable-prefix ratio near 1 means the worker is matching almost everything
|
|
765
|
+
* and only the tail is re-lexed, while a ratio near 0 means every chunk is
|
|
766
|
+
* re-parsing the whole document.
|
|
767
|
+
*/
|
|
768
|
+
streamStats = {
|
|
769
|
+
appends: 0,
|
|
770
|
+
workerResponses: 0,
|
|
771
|
+
/** Sum of `matchLen` across responses, i.e. tokens reused rather than re-lexed. */
|
|
772
|
+
tokensReused: 0,
|
|
773
|
+
/** Sum of returned tail lengths, i.e. tokens the worker had to re-lex. */
|
|
774
|
+
tokensRelexed: 0
|
|
775
|
+
};
|
|
691
776
|
// Worker request ids dispatched by *this* instance that haven't resolved yet.
|
|
692
777
|
// The module-level `workerCallbacks` map holds a closure capturing `this`, so
|
|
693
778
|
// destroying a Markdown mid-stream would pin the whole entity (and its subtree)
|
|
@@ -780,6 +865,81 @@ var Markdown = class extends import_ui.UIComponent {
|
|
|
780
865
|
});
|
|
781
866
|
super.destroy();
|
|
782
867
|
}
|
|
868
|
+
/**
|
|
869
|
+
* Streaming and parse state — the markdown streaming inspector.
|
|
870
|
+
*
|
|
871
|
+
* Source length, chunk count, worker in-flight state, and the stable-prefix
|
|
872
|
+
* versus re-lexed-tail split. That last ratio is the one worth watching: it is
|
|
873
|
+
* how you tell incremental reuse is working from outside, and nothing else
|
|
874
|
+
* surfaces it. A ratio near 1 means the worker matched almost the whole prefix
|
|
875
|
+
* and only re-lexed the tail; near 0 means every chunk re-parses the document.
|
|
876
|
+
*/
|
|
877
|
+
getDevtoolsDescriptor() {
|
|
878
|
+
const s = this.streamStats;
|
|
879
|
+
const lexed = s.tokensReused + s.tokensRelexed;
|
|
880
|
+
const reuseRatio = lexed > 0 ? s.tokensReused / lexed : 0;
|
|
881
|
+
return {
|
|
882
|
+
kind: "Markdown",
|
|
883
|
+
groups: [
|
|
884
|
+
{
|
|
885
|
+
label: "Source",
|
|
886
|
+
fields: [
|
|
887
|
+
{ label: "sourceLength", value: this.rawMarkdown.length, readOnly: true },
|
|
888
|
+
{ label: "topLevelTokens", value: this.tokens.length, readOnly: true },
|
|
889
|
+
{ label: "childEntities", value: this.content.children.length, readOnly: true },
|
|
890
|
+
{ label: "selectable", value: this.selectable }
|
|
891
|
+
]
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
label: "Streaming",
|
|
895
|
+
fields: [
|
|
896
|
+
{ label: "appends", value: s.appends, readOnly: true },
|
|
897
|
+
{
|
|
898
|
+
label: "workerResponses",
|
|
899
|
+
value: s.workerResponses,
|
|
900
|
+
hint: "Fewer than appends means chunks were coalesced while a request was in flight",
|
|
901
|
+
readOnly: true
|
|
902
|
+
},
|
|
903
|
+
{
|
|
904
|
+
label: "appendInFlight",
|
|
905
|
+
value: this.appendInFlight,
|
|
906
|
+
hint: "One lex request at a time; the delta protocol requires it",
|
|
907
|
+
readOnly: true
|
|
908
|
+
},
|
|
909
|
+
{ label: "appendPending", value: this.appendPending, readOnly: true }
|
|
910
|
+
]
|
|
911
|
+
},
|
|
912
|
+
{
|
|
913
|
+
label: "Incremental reuse",
|
|
914
|
+
fields: [
|
|
915
|
+
{
|
|
916
|
+
label: "tokensReused",
|
|
917
|
+
value: s.tokensReused,
|
|
918
|
+
hint: "Sum of matchLen: prefix tokens the worker matched and did not re-lex",
|
|
919
|
+
readOnly: true
|
|
920
|
+
},
|
|
921
|
+
{
|
|
922
|
+
label: "tokensRelexed",
|
|
923
|
+
value: s.tokensRelexed,
|
|
924
|
+
hint: "Sum of returned tail lengths: tokens the worker had to re-lex",
|
|
925
|
+
readOnly: true
|
|
926
|
+
},
|
|
927
|
+
{
|
|
928
|
+
label: "reuseRatio",
|
|
929
|
+
value: Math.round(reuseRatio * 1e3) / 1e3,
|
|
930
|
+
hint: "reused / (reused + relexed). Near 1 is healthy; near 0 means no reuse",
|
|
931
|
+
readOnly: true
|
|
932
|
+
}
|
|
933
|
+
]
|
|
934
|
+
}
|
|
935
|
+
],
|
|
936
|
+
notes: s.workerResponses === 0 && s.appends > 0 ? [
|
|
937
|
+
"No worker responses yet: either the worker is unavailable and parsing ran synchronously on the main thread, or the first request is still in flight."
|
|
938
|
+
] : reuseRatio > 0 && reuseRatio < 0.5 ? [
|
|
939
|
+
`Only ${Math.round(reuseRatio * 100)}% of lexed tokens were reused, so most of the document is being re-lexed per chunk. Expect O(document) work per append.`
|
|
940
|
+
] : void 0
|
|
941
|
+
};
|
|
942
|
+
}
|
|
783
943
|
/** Enable or disable native selection for existing and future Markdown text. */
|
|
784
944
|
setSelectable(selectable) {
|
|
785
945
|
this.selectable = selectable;
|
|
@@ -795,6 +955,7 @@ var Markdown = class extends import_ui.UIComponent {
|
|
|
795
955
|
/** Append a markdown chunk incrementally. Reuses unchanged prefix entities. */
|
|
796
956
|
appendMarkdown(chunk) {
|
|
797
957
|
this.rawMarkdown += chunk;
|
|
958
|
+
this.streamStats.appends++;
|
|
798
959
|
if (!markdownWorker) {
|
|
799
960
|
const newTokens = import_marked.marked.lexer(this.rawMarkdown);
|
|
800
961
|
this.updateTokens(newTokens);
|
|
@@ -829,9 +990,12 @@ var Markdown = class extends import_ui.UIComponent {
|
|
|
829
990
|
workerCallbacks.set(id, {
|
|
830
991
|
cb: (matchLen, tail) => {
|
|
831
992
|
this.pendingWorkerIds.delete(id);
|
|
993
|
+
this.streamStats.workerResponses++;
|
|
994
|
+
this.streamStats.tokensReused += matchLen;
|
|
995
|
+
this.streamStats.tokensRelexed += tail.length;
|
|
832
996
|
this.appendInFlight = false;
|
|
833
997
|
const newTokens = [...oldTokensSnapshot.slice(0, matchLen), ...tail];
|
|
834
|
-
this.updateTokens(newTokens);
|
|
998
|
+
this.updateTokens(newTokens, matchLen);
|
|
835
999
|
if (this.appendPending) {
|
|
836
1000
|
this.appendPending = false;
|
|
837
1001
|
this.dispatchAppend();
|
|
@@ -855,21 +1019,35 @@ var Markdown = class extends import_ui.UIComponent {
|
|
|
855
1019
|
...sendRaws ? { oldRaws: oldTokensSnapshot.map((t) => t.raw) } : {}
|
|
856
1020
|
});
|
|
857
1021
|
}
|
|
858
|
-
updateTokens(newTokens) {
|
|
1022
|
+
updateTokens(newTokens, knownMatchLen) {
|
|
859
1023
|
const oldTokens = this.tokens;
|
|
860
1024
|
const oldChildren = [...this.content.children];
|
|
861
|
-
let matchLen
|
|
1025
|
+
let matchLen;
|
|
862
1026
|
const minLen = Math.min(oldTokens.length, newTokens.length);
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1027
|
+
if (knownMatchLen !== void 0 && knownMatchLen >= 0 && knownMatchLen <= minLen) {
|
|
1028
|
+
matchLen = knownMatchLen;
|
|
1029
|
+
} else {
|
|
1030
|
+
matchLen = 0;
|
|
1031
|
+
for (let i = 0; i < minLen; i++) {
|
|
1032
|
+
if (oldTokens[i].raw === newTokens[i].raw) {
|
|
1033
|
+
matchLen++;
|
|
1034
|
+
} else {
|
|
1035
|
+
break;
|
|
1036
|
+
}
|
|
868
1037
|
}
|
|
869
1038
|
}
|
|
870
1039
|
const oldTokenToChild = this.tokenChildPrefix;
|
|
871
1040
|
const rawMatchLen = matchLen;
|
|
872
|
-
|
|
1041
|
+
const lastTokenSameType = matchLen === oldTokens.length - 1 && matchLen < newTokens.length && oldTokens[matchLen]?.type === newTokens[matchLen]?.type;
|
|
1042
|
+
if (lastTokenSameType && newTokens[matchLen]?.type === "code") {
|
|
1043
|
+
const existingEntity = oldChildren[oldTokenToChild[matchLen]];
|
|
1044
|
+
const codeToken = newTokens[matchLen];
|
|
1045
|
+
if (existingEntity instanceof CodeBlock) {
|
|
1046
|
+
existingEntity.setCode(codeToken.text, codeToken.lang ?? void 0);
|
|
1047
|
+
matchLen++;
|
|
1048
|
+
this.content.resizeLastChild(existingEntity);
|
|
1049
|
+
}
|
|
1050
|
+
} else if (lastTokenSameType && newTokens[matchLen]?.type === "paragraph") {
|
|
873
1051
|
const entityIdx = oldTokenToChild[matchLen];
|
|
874
1052
|
const existingEntity = oldChildren[entityIdx];
|
|
875
1053
|
if (existingEntity && "setSpans" in existingEntity) {
|
|
@@ -1182,5 +1360,7 @@ var Markdown = class extends import_ui.UIComponent {
|
|
|
1182
1360
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1183
1361
|
0 && (module.exports = {
|
|
1184
1362
|
CodeBlock,
|
|
1185
|
-
Markdown
|
|
1363
|
+
Markdown,
|
|
1364
|
+
codeAtlas,
|
|
1365
|
+
codeAtlasStats
|
|
1186
1366
|
});
|
package/dist/index.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import {
|
|
3
3
|
BidiResolver,
|
|
4
4
|
Entity,
|
|
5
|
+
GlyphRasterAtlas,
|
|
5
6
|
prepareContentGrid,
|
|
6
7
|
SVGEntity
|
|
7
8
|
} from "@vectojs/core";
|
|
@@ -416,6 +417,8 @@ function highlightLine(line, lang, theme) {
|
|
|
416
417
|
var CodeBlock = class extends UIComponent {
|
|
417
418
|
lines;
|
|
418
419
|
grid = null;
|
|
420
|
+
/** Raw (unhighlighted) lines of the last build, for prefix reuse in buildLines. */
|
|
421
|
+
rawLines = null;
|
|
419
422
|
cellWidth = 0;
|
|
420
423
|
source;
|
|
421
424
|
lang;
|
|
@@ -475,9 +478,36 @@ var CodeBlock = class extends UIComponent {
|
|
|
475
478
|
grid
|
|
476
479
|
};
|
|
477
480
|
}
|
|
481
|
+
/**
|
|
482
|
+
* Re-highlight the code, reusing the highlight of any unchanged line prefix.
|
|
483
|
+
*
|
|
484
|
+
* Streaming appends to the END of a block, so all but the last line or two are
|
|
485
|
+
* byte-identical to the previous call — yet this used to re-highlight every
|
|
486
|
+
* line on every chunk, making a streamed block O(N) per append and O(N^2)
|
|
487
|
+
* overall. Reusing the stable prefix makes an append proportional to what
|
|
488
|
+
* actually changed.
|
|
489
|
+
*
|
|
490
|
+
* The last previously-seen line is deliberately NOT reused: a chunk usually
|
|
491
|
+
* lands mid-line, so that line's text (and therefore its tokenization) changes.
|
|
492
|
+
*/
|
|
478
493
|
buildLines(code) {
|
|
479
494
|
const rawLines = code.split(/\r\n|\r|\n/);
|
|
480
|
-
|
|
495
|
+
const previous = this.rawLines;
|
|
496
|
+
let reusable = 0;
|
|
497
|
+
if (previous && this.lines.length === previous.length) {
|
|
498
|
+
const limit = Math.min(previous.length - 1, rawLines.length);
|
|
499
|
+
while (reusable < limit && previous[reusable] === rawLines[reusable]) reusable++;
|
|
500
|
+
}
|
|
501
|
+
if (reusable > 0) {
|
|
502
|
+
const next = this.lines.slice(0, reusable);
|
|
503
|
+
for (let i = reusable; i < rawLines.length; i++) {
|
|
504
|
+
next.push(highlightLine(rawLines[i], this.lang, this.theme));
|
|
505
|
+
}
|
|
506
|
+
this.lines = next;
|
|
507
|
+
} else {
|
|
508
|
+
this.lines = rawLines.map((l) => highlightLine(l, this.lang, this.theme));
|
|
509
|
+
}
|
|
510
|
+
this.rawLines = rawLines;
|
|
481
511
|
this.grid = null;
|
|
482
512
|
this.height = this.pad * 2 + rawLines.length * this.lineH;
|
|
483
513
|
}
|
|
@@ -502,6 +532,9 @@ var CodeBlock = class extends UIComponent {
|
|
|
502
532
|
r.roundRect(0, 0, this.width, this.height, 8);
|
|
503
533
|
r.fill(this.theme.codeBgColor);
|
|
504
534
|
const grid = this.ensureGrid();
|
|
535
|
+
const atlas = codeGlyphAtlas(r);
|
|
536
|
+
const atlasSource = atlas?.source ?? null;
|
|
537
|
+
const blit = atlas ? r.drawImageRect : void 0;
|
|
505
538
|
for (let row = 0; row < grid.lines.length; row++) {
|
|
506
539
|
const yBaseline = this.pad + row * this.lineH + this.lineH * 0.75;
|
|
507
540
|
const segments = this.lines[row];
|
|
@@ -516,17 +549,51 @@ var CodeBlock = class extends UIComponent {
|
|
|
516
549
|
}
|
|
517
550
|
const sourceText = this.source.slice(cell.sourceStart, cell.sourceEnd);
|
|
518
551
|
if (cell.advance <= 0 || sourceText === " " || sourceText === " ") continue;
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
552
|
+
const color = segments[segmentIndex]?.color ?? this.theme.codeColor;
|
|
553
|
+
const x = this.pad + cell.x;
|
|
554
|
+
if (blit && atlas) {
|
|
555
|
+
const slot = atlas.get(this.codeFont, color, cell.glyph);
|
|
556
|
+
const src = atlasSource ?? atlas.source;
|
|
557
|
+
if (slot && src) {
|
|
558
|
+
blit.call(
|
|
559
|
+
r,
|
|
560
|
+
src,
|
|
561
|
+
slot.sx,
|
|
562
|
+
slot.sy,
|
|
563
|
+
slot.sw,
|
|
564
|
+
slot.sh,
|
|
565
|
+
x - slot.offsetX,
|
|
566
|
+
yBaseline - slot.offsetY,
|
|
567
|
+
slot.w,
|
|
568
|
+
slot.h
|
|
569
|
+
);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
r.fillText(cell.glyph, x, yBaseline, this.codeFont, color);
|
|
526
574
|
}
|
|
527
575
|
}
|
|
528
576
|
}
|
|
529
577
|
};
|
|
578
|
+
var sharedCodeAtlas = null;
|
|
579
|
+
function codeGlyphAtlas(r) {
|
|
580
|
+
if (typeof r.drawImageRect !== "function") return void 0;
|
|
581
|
+
if (typeof document === "undefined") return void 0;
|
|
582
|
+
sharedCodeAtlas ??= new GlyphRasterAtlas({
|
|
583
|
+
// Match the display so a HiDPI blit stays crisp. Capped at 3 because atlas
|
|
584
|
+
// area grows with dpr² and a 4x display would otherwise blow the size cap
|
|
585
|
+
// with a few hundred glyphs.
|
|
586
|
+
dpr: typeof window !== "undefined" ? Math.min(window.devicePixelRatio || 1, 3) : 1,
|
|
587
|
+
maxSize: 2048
|
|
588
|
+
});
|
|
589
|
+
return sharedCodeAtlas;
|
|
590
|
+
}
|
|
591
|
+
function codeAtlasStats() {
|
|
592
|
+
return sharedCodeAtlas ? sharedCodeAtlas.stats : null;
|
|
593
|
+
}
|
|
594
|
+
function codeAtlas() {
|
|
595
|
+
return sharedCodeAtlas;
|
|
596
|
+
}
|
|
530
597
|
function decodeEntities(text) {
|
|
531
598
|
return text.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
532
599
|
}
|
|
@@ -666,6 +733,23 @@ var Markdown = class extends UIComponent {
|
|
|
666
733
|
// with the latest accumulated text once it resolves.
|
|
667
734
|
appendInFlight = false;
|
|
668
735
|
appendPending = false;
|
|
736
|
+
/**
|
|
737
|
+
* Streaming counters for the DevTools inspector.
|
|
738
|
+
*
|
|
739
|
+
* Cheap enough to keep always-on (four integer increments per append) and the
|
|
740
|
+
* only way to see, from outside, whether incremental reuse is actually working:
|
|
741
|
+
* a stable-prefix ratio near 1 means the worker is matching almost everything
|
|
742
|
+
* and only the tail is re-lexed, while a ratio near 0 means every chunk is
|
|
743
|
+
* re-parsing the whole document.
|
|
744
|
+
*/
|
|
745
|
+
streamStats = {
|
|
746
|
+
appends: 0,
|
|
747
|
+
workerResponses: 0,
|
|
748
|
+
/** Sum of `matchLen` across responses, i.e. tokens reused rather than re-lexed. */
|
|
749
|
+
tokensReused: 0,
|
|
750
|
+
/** Sum of returned tail lengths, i.e. tokens the worker had to re-lex. */
|
|
751
|
+
tokensRelexed: 0
|
|
752
|
+
};
|
|
669
753
|
// Worker request ids dispatched by *this* instance that haven't resolved yet.
|
|
670
754
|
// The module-level `workerCallbacks` map holds a closure capturing `this`, so
|
|
671
755
|
// destroying a Markdown mid-stream would pin the whole entity (and its subtree)
|
|
@@ -758,6 +842,81 @@ var Markdown = class extends UIComponent {
|
|
|
758
842
|
});
|
|
759
843
|
super.destroy();
|
|
760
844
|
}
|
|
845
|
+
/**
|
|
846
|
+
* Streaming and parse state — the markdown streaming inspector.
|
|
847
|
+
*
|
|
848
|
+
* Source length, chunk count, worker in-flight state, and the stable-prefix
|
|
849
|
+
* versus re-lexed-tail split. That last ratio is the one worth watching: it is
|
|
850
|
+
* how you tell incremental reuse is working from outside, and nothing else
|
|
851
|
+
* surfaces it. A ratio near 1 means the worker matched almost the whole prefix
|
|
852
|
+
* and only re-lexed the tail; near 0 means every chunk re-parses the document.
|
|
853
|
+
*/
|
|
854
|
+
getDevtoolsDescriptor() {
|
|
855
|
+
const s = this.streamStats;
|
|
856
|
+
const lexed = s.tokensReused + s.tokensRelexed;
|
|
857
|
+
const reuseRatio = lexed > 0 ? s.tokensReused / lexed : 0;
|
|
858
|
+
return {
|
|
859
|
+
kind: "Markdown",
|
|
860
|
+
groups: [
|
|
861
|
+
{
|
|
862
|
+
label: "Source",
|
|
863
|
+
fields: [
|
|
864
|
+
{ label: "sourceLength", value: this.rawMarkdown.length, readOnly: true },
|
|
865
|
+
{ label: "topLevelTokens", value: this.tokens.length, readOnly: true },
|
|
866
|
+
{ label: "childEntities", value: this.content.children.length, readOnly: true },
|
|
867
|
+
{ label: "selectable", value: this.selectable }
|
|
868
|
+
]
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
label: "Streaming",
|
|
872
|
+
fields: [
|
|
873
|
+
{ label: "appends", value: s.appends, readOnly: true },
|
|
874
|
+
{
|
|
875
|
+
label: "workerResponses",
|
|
876
|
+
value: s.workerResponses,
|
|
877
|
+
hint: "Fewer than appends means chunks were coalesced while a request was in flight",
|
|
878
|
+
readOnly: true
|
|
879
|
+
},
|
|
880
|
+
{
|
|
881
|
+
label: "appendInFlight",
|
|
882
|
+
value: this.appendInFlight,
|
|
883
|
+
hint: "One lex request at a time; the delta protocol requires it",
|
|
884
|
+
readOnly: true
|
|
885
|
+
},
|
|
886
|
+
{ label: "appendPending", value: this.appendPending, readOnly: true }
|
|
887
|
+
]
|
|
888
|
+
},
|
|
889
|
+
{
|
|
890
|
+
label: "Incremental reuse",
|
|
891
|
+
fields: [
|
|
892
|
+
{
|
|
893
|
+
label: "tokensReused",
|
|
894
|
+
value: s.tokensReused,
|
|
895
|
+
hint: "Sum of matchLen: prefix tokens the worker matched and did not re-lex",
|
|
896
|
+
readOnly: true
|
|
897
|
+
},
|
|
898
|
+
{
|
|
899
|
+
label: "tokensRelexed",
|
|
900
|
+
value: s.tokensRelexed,
|
|
901
|
+
hint: "Sum of returned tail lengths: tokens the worker had to re-lex",
|
|
902
|
+
readOnly: true
|
|
903
|
+
},
|
|
904
|
+
{
|
|
905
|
+
label: "reuseRatio",
|
|
906
|
+
value: Math.round(reuseRatio * 1e3) / 1e3,
|
|
907
|
+
hint: "reused / (reused + relexed). Near 1 is healthy; near 0 means no reuse",
|
|
908
|
+
readOnly: true
|
|
909
|
+
}
|
|
910
|
+
]
|
|
911
|
+
}
|
|
912
|
+
],
|
|
913
|
+
notes: s.workerResponses === 0 && s.appends > 0 ? [
|
|
914
|
+
"No worker responses yet: either the worker is unavailable and parsing ran synchronously on the main thread, or the first request is still in flight."
|
|
915
|
+
] : reuseRatio > 0 && reuseRatio < 0.5 ? [
|
|
916
|
+
`Only ${Math.round(reuseRatio * 100)}% of lexed tokens were reused, so most of the document is being re-lexed per chunk. Expect O(document) work per append.`
|
|
917
|
+
] : void 0
|
|
918
|
+
};
|
|
919
|
+
}
|
|
761
920
|
/** Enable or disable native selection for existing and future Markdown text. */
|
|
762
921
|
setSelectable(selectable) {
|
|
763
922
|
this.selectable = selectable;
|
|
@@ -773,6 +932,7 @@ var Markdown = class extends UIComponent {
|
|
|
773
932
|
/** Append a markdown chunk incrementally. Reuses unchanged prefix entities. */
|
|
774
933
|
appendMarkdown(chunk) {
|
|
775
934
|
this.rawMarkdown += chunk;
|
|
935
|
+
this.streamStats.appends++;
|
|
776
936
|
if (!markdownWorker) {
|
|
777
937
|
const newTokens = marked.lexer(this.rawMarkdown);
|
|
778
938
|
this.updateTokens(newTokens);
|
|
@@ -807,9 +967,12 @@ var Markdown = class extends UIComponent {
|
|
|
807
967
|
workerCallbacks.set(id, {
|
|
808
968
|
cb: (matchLen, tail) => {
|
|
809
969
|
this.pendingWorkerIds.delete(id);
|
|
970
|
+
this.streamStats.workerResponses++;
|
|
971
|
+
this.streamStats.tokensReused += matchLen;
|
|
972
|
+
this.streamStats.tokensRelexed += tail.length;
|
|
810
973
|
this.appendInFlight = false;
|
|
811
974
|
const newTokens = [...oldTokensSnapshot.slice(0, matchLen), ...tail];
|
|
812
|
-
this.updateTokens(newTokens);
|
|
975
|
+
this.updateTokens(newTokens, matchLen);
|
|
813
976
|
if (this.appendPending) {
|
|
814
977
|
this.appendPending = false;
|
|
815
978
|
this.dispatchAppend();
|
|
@@ -833,21 +996,35 @@ var Markdown = class extends UIComponent {
|
|
|
833
996
|
...sendRaws ? { oldRaws: oldTokensSnapshot.map((t) => t.raw) } : {}
|
|
834
997
|
});
|
|
835
998
|
}
|
|
836
|
-
updateTokens(newTokens) {
|
|
999
|
+
updateTokens(newTokens, knownMatchLen) {
|
|
837
1000
|
const oldTokens = this.tokens;
|
|
838
1001
|
const oldChildren = [...this.content.children];
|
|
839
|
-
let matchLen
|
|
1002
|
+
let matchLen;
|
|
840
1003
|
const minLen = Math.min(oldTokens.length, newTokens.length);
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1004
|
+
if (knownMatchLen !== void 0 && knownMatchLen >= 0 && knownMatchLen <= minLen) {
|
|
1005
|
+
matchLen = knownMatchLen;
|
|
1006
|
+
} else {
|
|
1007
|
+
matchLen = 0;
|
|
1008
|
+
for (let i = 0; i < minLen; i++) {
|
|
1009
|
+
if (oldTokens[i].raw === newTokens[i].raw) {
|
|
1010
|
+
matchLen++;
|
|
1011
|
+
} else {
|
|
1012
|
+
break;
|
|
1013
|
+
}
|
|
846
1014
|
}
|
|
847
1015
|
}
|
|
848
1016
|
const oldTokenToChild = this.tokenChildPrefix;
|
|
849
1017
|
const rawMatchLen = matchLen;
|
|
850
|
-
|
|
1018
|
+
const lastTokenSameType = matchLen === oldTokens.length - 1 && matchLen < newTokens.length && oldTokens[matchLen]?.type === newTokens[matchLen]?.type;
|
|
1019
|
+
if (lastTokenSameType && newTokens[matchLen]?.type === "code") {
|
|
1020
|
+
const existingEntity = oldChildren[oldTokenToChild[matchLen]];
|
|
1021
|
+
const codeToken = newTokens[matchLen];
|
|
1022
|
+
if (existingEntity instanceof CodeBlock) {
|
|
1023
|
+
existingEntity.setCode(codeToken.text, codeToken.lang ?? void 0);
|
|
1024
|
+
matchLen++;
|
|
1025
|
+
this.content.resizeLastChild(existingEntity);
|
|
1026
|
+
}
|
|
1027
|
+
} else if (lastTokenSameType && newTokens[matchLen]?.type === "paragraph") {
|
|
851
1028
|
const entityIdx = oldTokenToChild[matchLen];
|
|
852
1029
|
const existingEntity = oldChildren[entityIdx];
|
|
853
1030
|
if (existingEntity && "setSpans" in existingEntity) {
|
|
@@ -1159,5 +1336,7 @@ var Markdown = class extends UIComponent {
|
|
|
1159
1336
|
};
|
|
1160
1337
|
export {
|
|
1161
1338
|
CodeBlock,
|
|
1162
|
-
Markdown
|
|
1339
|
+
Markdown,
|
|
1340
|
+
codeAtlas,
|
|
1341
|
+
codeAtlasStats
|
|
1163
1342
|
};
|