@miliastry/quasar 1.0.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/LICENSE +119 -0
- package/README.md +45 -0
- package/dist/Visuals/lyne.css +1027 -0
- package/dist/Visuals/osu.css +257 -0
- package/dist/index.d.mts +4563 -0
- package/dist/index.d.ts +4563 -0
- package/dist/index.js +11291 -0
- package/dist/index.mjs +11197 -0
- package/package.json +51 -0
- package/src/Analysis/Contracts/AnalysisReport.ts +26 -0
- package/src/Analysis/Contracts/Contribution.ts +76 -0
- package/src/Analysis/Contracts/Pass.ts +76 -0
- package/src/Analysis/Contracts/PipelineContext.ts +48 -0
- package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +292 -0
- package/src/Analysis/Passes/Analysis/MergeableColorAnalyzer.ts +112 -0
- package/src/Analysis/Passes/Analysis/RainbowAnalyzer.ts +233 -0
- package/src/Analysis/Passes/Analysis/WaveAnalyzer.ts +211 -0
- package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +135 -0
- package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +84 -0
- package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +99 -0
- package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +119 -0
- package/src/Analysis/Passes/Decision/DefaultDecision.ts +139 -0
- package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +179 -0
- package/src/Analysis/Passes/Transform/CollapseGradientTransform.ts +176 -0
- package/src/Analysis/Passes/Transform/MergeColorsTransform.ts +126 -0
- package/src/Analysis/Passes/Transform/RainbowCollapseTransform.ts +83 -0
- package/src/Analysis/Passes/Transform/WaveCollapseTransform.ts +88 -0
- package/src/Analysis/Passes/Utility/CharacterCountAnalyzer.ts +45 -0
- package/src/Analysis/Pipeline/Pipeline.ts +133 -0
- package/src/Analysis/Pipeline/PipelineBuilder.ts +55 -0
- package/src/Analysis/Pipeline/PipelineStage.ts +19 -0
- package/src/Analysis/Utils/color-utils.ts +132 -0
- package/src/Analysis/__tests__/Integration.test.ts +162 -0
- package/src/Analysis/__tests__/Pipeline.test.ts +133 -0
- package/src/Analysis/index.ts +52 -0
- package/src/BBCode/BBCodeDocumentModel.ts +175 -0
- package/src/BBCode/BBCodeToGreenNode.ts +755 -0
- package/src/BBCode/Parser.ts +384 -0
- package/src/BBCode/index.ts +12 -0
- package/src/Collab/positions.ts +91 -0
- package/src/Commands/Command.ts +44 -0
- package/src/Commands/CommandRegistry.ts +78 -0
- package/src/Commands/DeleteNode.ts +20 -0
- package/src/Commands/InsertText.ts +21 -0
- package/src/Commands/SplitMerge.ts +28 -0
- package/src/Commands/WrapInTag.ts +21 -0
- package/src/Commands/index.ts +6 -0
- package/src/Diff/TreeDiffer.ts +264 -0
- package/src/Diff/__tests__/TreeDiffer.test.ts +65 -0
- package/src/Diff/index.ts +2 -0
- package/src/Events/EventBus.ts +160 -0
- package/src/Events/index.ts +2 -0
- package/src/Formatter/Formatter.ts +54 -0
- package/src/Formatter/index.ts +2 -0
- package/src/HTML/HTMLDocumentModel.ts +35 -0
- package/src/HTML/HTMLToGreenNode.ts +290 -0
- package/src/Incremental/ChangeTracker.ts +105 -0
- package/src/Incremental/IncrementalParser.ts +591 -0
- package/src/Incremental/__tests__/IncrementalParser.test.ts +164 -0
- package/src/Incremental/index.ts +4 -0
- package/src/Lexer/BBCodeLexer.ts +382 -0
- package/src/Lexer/Lexer.ts +181 -0
- package/src/Lexer/index.ts +10 -0
- package/src/Linter/Linter.ts +193 -0
- package/src/Linter/index.ts +2 -0
- package/src/Markdown/MarkdownAST.ts +112 -0
- package/src/Markdown/MarkdownDocumentModel.ts +55 -0
- package/src/Markdown/MarkdownLexer.ts +203 -0
- package/src/Markdown/MarkdownParser.ts +455 -0
- package/src/Markdown/MarkdownToGreenNode.ts +153 -0
- package/src/Model/DocumentModel.ts +694 -0
- package/src/Model/NodeFactory.ts +117 -0
- package/src/Model/TagRegistry.ts +495 -0
- package/src/Model/index.ts +5 -0
- package/src/Plugins/PluginAPI.ts +119 -0
- package/src/Plugins/PluginRegistry.ts +132 -0
- package/src/Plugins/index.ts +3 -0
- package/src/Queries/QueryEngine.ts +152 -0
- package/src/Queries/index.ts +1 -0
- package/src/RenderPipeline/RenderPipeline.ts +125 -0
- package/src/RenderPipeline/RenderTree.ts +134 -0
- package/src/RenderPipeline/index.ts +4 -0
- package/src/Semantic/SemanticAnalyzer.ts +506 -0
- package/src/Semantic/index.ts +2 -0
- package/src/Symbols/SymbolTable.ts +124 -0
- package/src/Symbols/index.ts +1 -0
- package/src/Syntax/GreenNode.ts +324 -0
- package/src/Syntax/GreenNodePool.ts +269 -0
- package/src/Syntax/NodeMatcher.ts +370 -0
- package/src/Syntax/RedNode.ts +569 -0
- package/src/Syntax/RedNodeStore.ts +184 -0
- package/src/Syntax/TreeBuilder.ts +214 -0
- package/src/Syntax/__tests__/GreenNode.test.ts +33 -0
- package/src/Syntax/__tests__/RedNode.test.ts +81 -0
- package/src/Syntax/__tests__/RedNodeStore.test.ts +104 -0
- package/src/Syntax/greenEdit.ts +110 -0
- package/src/Syntax/hash.ts +30 -0
- package/src/Syntax/index.ts +12 -0
- package/src/Syntax/partition.ts +161 -0
- package/src/Syntax/preserveNodeIds.ts +201 -0
- package/src/Tests/ASTOptimizerIdempotence.test.ts +77 -0
- package/src/Tests/BlockPatcher.test.ts +437 -0
- package/src/Tests/BlockPatcherWindowed.test.ts +364 -0
- package/src/Tests/BoxDrawer.test.ts +217 -0
- package/src/Tests/BoxRichTitle.test.ts +105 -0
- package/src/Tests/Chars500kBenchmark.test.ts +151 -0
- package/src/Tests/Chars500kEdits.test.ts +321 -0
- package/src/Tests/CollabPositions.test.ts +146 -0
- package/src/Tests/CompilerPathProfiling.test.ts +186 -0
- package/src/Tests/DOMMorpher.test.ts +142 -0
- package/src/Tests/DomPatchPerf.test.ts +60 -0
- package/src/Tests/EffectSegments.snapshot.json +616 -0
- package/src/Tests/EffectSegments.test.ts +68 -0
- package/src/Tests/FindNodeAtOffset.test.ts +65 -0
- package/src/Tests/Fuzzer.test.ts +166 -0
- package/src/Tests/GreenNodePool.test.ts +153 -0
- package/src/Tests/Lexer.test.ts +238 -0
- package/src/Tests/LyneMode.test.ts +187 -0
- package/src/Tests/ModelCoherence.test.ts +180 -0
- package/src/Tests/Partition.test.ts +238 -0
- package/src/Tests/PluginTags.test.ts +150 -0
- package/src/Tests/ProblematicSection.test.ts +46 -0
- package/src/Tests/ProblematicSectionHTML.test.ts +58 -0
- package/src/Tests/RedReuse.test.ts +134 -0
- package/src/Tests/ReproDelete20k.test.ts +62 -0
- package/src/Tests/SemanticValidators.test.ts +136 -0
- package/src/Tests/StableNodeIds.test.ts +210 -0
- package/src/Tests/StudioColorBloat.test.ts +25 -0
- package/src/Tests/StudioDebugText.test.ts +27 -0
- package/src/Tests/StudioTrailingChar.test.ts +25 -0
- package/src/Tests/StudioValidText.test.ts +25 -0
- package/src/Tests/UrlImgBug.test.ts +23 -0
- package/src/Tests/VisualBuilderFidelity.test.ts +105 -0
- package/src/Tests/referenceDocument.ts +119 -0
- package/src/Transactions/Transaction.ts +176 -0
- package/src/Transactions/UndoManager.ts +111 -0
- package/src/Transactions/index.ts +3 -0
- package/src/Transformers/ASTOptimizer.ts +315 -0
- package/src/Transformers/GradientTransformer.ts +143 -0
- package/src/Transformers/GrowTransformer.ts +115 -0
- package/src/Transformers/RainbowTransformer.ts +121 -0
- package/src/Transformers/SineWaveTransformer.ts +130 -0
- package/src/Transformers/Transformer.ts +22 -0
- package/src/Types/core.ts +270 -0
- package/src/Types/diagnostics.ts +156 -0
- package/src/Types/index.ts +23 -0
- package/src/Types/operations.ts +180 -0
- package/src/Types/queries.ts +121 -0
- package/src/Types/symbols.ts +69 -0
- package/src/Types/tokens.ts +186 -0
- package/src/Utils/BBCodeGenerator.ts +126 -0
- package/src/Utils/ColorMath.ts +276 -0
- package/src/Utils/color.ts +112 -0
- package/src/Utils/dom-to-svg.test.ts +86 -0
- package/src/Utils/dom-to-svg.ts +615 -0
- package/src/Utils/treeTransformers.ts +717 -0
- package/src/Visitors/BBBlocksExporter.ts +69 -0
- package/src/Visitors/BBCodeExporter.ts +318 -0
- package/src/Visitors/BlockPatcher.ts +963 -0
- package/src/Visitors/DOMMorpher.ts +134 -0
- package/src/Visitors/HTMLRenderer.ts +1077 -0
- package/src/Visitors/JSONExporter.ts +66 -0
- package/src/Visitors/MarkdownExporter.ts +99 -0
- package/src/Visitors/SVGRenderer.ts +35 -0
- package/src/Visitors/TiptapExporter.ts +145 -0
- package/src/Visitors/Visitor.ts +48 -0
- package/src/Visitors/index.ts +9 -0
- package/src/Visuals/BoxDrawer.ts +175 -0
- package/src/Visuals/index.ts +42 -0
- package/src/Visuals/lyne.css +1027 -0
- package/src/Visuals/osu.css +257 -0
- package/src/index.ts +197 -0
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — BlockPatcher
|
|
3
|
+
*
|
|
4
|
+
* Incremental rendering for the HTML preview.
|
|
5
|
+
*
|
|
6
|
+
* The naive path — `renderToHTML(root)` + `morphHTML(container, html)` on every
|
|
7
|
+
* keystroke — re-serializes the ENTIRE document to a string and, worse, makes
|
|
8
|
+
* the browser re-parse that whole string (`template.innerHTML`) even when only
|
|
9
|
+
* one character changed. Profiled on an 18.5 KB document: `morphHTML` ≈ 29.8 ms
|
|
10
|
+
* per keystroke, of which ~27 ms is the full-HTML parse (morph ≈ baseline
|
|
11
|
+
* `innerHTML=`). The parser itself is ~0.5 ms.
|
|
12
|
+
*
|
|
13
|
+
* This module makes the DOM update O(changed block) instead of O(document):
|
|
14
|
+
*
|
|
15
|
+
* - The document's top-level children are the independent "blocks".
|
|
16
|
+
* - With stable `data-node-id`s (see `preserveNodeIds`) a block that did not
|
|
17
|
+
* change renders to the SAME html string, or is even the SAME RedNode
|
|
18
|
+
* object (red-subtree reuse). Either way we can detect "unchanged" without
|
|
19
|
+
* touching the DOM.
|
|
20
|
+
* - Only changed blocks are re-rendered and re-morphed, in place, keyed by
|
|
21
|
+
* their element.
|
|
22
|
+
* - Structural edits — a block inserted, removed or reordered (Enter,
|
|
23
|
+
* backspace between blocks, moving a block in the visual builder) — are
|
|
24
|
+
* reconciled BY KEY too. The stable ids let us insert/remove/move ONLY the
|
|
25
|
+
* affected elements, so every untouched block keeps its DOM identity and
|
|
26
|
+
* runtime state (an open `<details>`, media playback). When the edit
|
|
27
|
+
* script adds more blocks than the previous document had (a whole new
|
|
28
|
+
* document patched into the same container, ids regenerated en masse) we
|
|
29
|
+
* fall back to a full rebuild: one `innerHTML` parse beats N per-block
|
|
30
|
+
* ones.
|
|
31
|
+
*
|
|
32
|
+
* DOM alignment — "runs": a top-level block may render to an element, to a
|
|
33
|
+
* bare text node (`text`, osu `spacing`/`empty_line` quirks) or to nothing
|
|
34
|
+
* (`''`, an empty text leaf). Two adjacent bare-text blocks are merged into a
|
|
35
|
+
* single text node by the HTML parser, so a block↔node 1:1 pairing would
|
|
36
|
+
* silently misalign after such a merge. The reconciliation therefore works on
|
|
37
|
+
* RUNS: consecutive bare-text blocks form one "text run" that maps to exactly
|
|
38
|
+
* one text node (mirroring the parser), and element blocks are their own runs.
|
|
39
|
+
* The run list is the exact 1:1 mirror of `container.childNodes`.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { RedNode } from '../Syntax/RedNode'
|
|
43
|
+
import { HTMLRenderer } from './HTMLRenderer'
|
|
44
|
+
import { morphHTML } from './DOMMorpher'
|
|
45
|
+
import type { TextChangeRange } from '../Incremental/ChangeTracker'
|
|
46
|
+
|
|
47
|
+
export interface PatchBlocksOptions {
|
|
48
|
+
/** Renderer used to serialize blocks. Defaults to a shared HTMLRenderer. */
|
|
49
|
+
renderer?: HTMLRenderer
|
|
50
|
+
/** Called when a block fails to morph; the caller can log it. */
|
|
51
|
+
onError?: (err: unknown) => void
|
|
52
|
+
/**
|
|
53
|
+
* The source range of the edit that produced `rootNode`, if known.
|
|
54
|
+
*
|
|
55
|
+
* Lets the patcher reconcile ONLY the runs overlapping the edit (O(edit))
|
|
56
|
+
* instead of walking the whole document — the difference between ~2ms and
|
|
57
|
+
* ~100ms per keystroke on a 500k-character document. Coordinates are in
|
|
58
|
+
* new-source space with the old end kept separately; see `TextChangeRange`.
|
|
59
|
+
* When omitted, the patcher falls back to the full keyed reconcile — and it
|
|
60
|
+
* also reads the range attached to the root by `DocumentModel`.
|
|
61
|
+
*/
|
|
62
|
+
change?: TextChangeRange
|
|
63
|
+
/**
|
|
64
|
+
* Minimum top-level blocks for the windowed path to run; below it the full
|
|
65
|
+
* keyed walk is cheaper than the windowed bookkeeping. Defaults to
|
|
66
|
+
* `MIN_WINDOWED_BLOCKS` (200). Tests force `0` to exercise the windowed
|
|
67
|
+
* path on small documents.
|
|
68
|
+
*/
|
|
69
|
+
minWindowedBlocks?: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface PatchBlocksStats {
|
|
73
|
+
/** Which path was taken. */
|
|
74
|
+
mode: 'full' | 'blocks'
|
|
75
|
+
/** Number of top-level blocks. */
|
|
76
|
+
total: number
|
|
77
|
+
/** Blocks whose DOM was actually updated (0 = nothing changed). */
|
|
78
|
+
patched: number
|
|
79
|
+
/** True when the windowed (O(edit)) reconcile ran, not the full keyed walk. */
|
|
80
|
+
windowed?: boolean
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** How a top-level block renders: an element, bare text, or nothing. */
|
|
84
|
+
type RunKind = 'element' | 'text' | 'none'
|
|
85
|
+
|
|
86
|
+
/** A DOM-level unit: one element block, or consecutive bare-text blocks. */
|
|
87
|
+
interface PatchRun {
|
|
88
|
+
/** Stable key — the first block's id. */
|
|
89
|
+
key: string
|
|
90
|
+
kind: 'element' | 'text'
|
|
91
|
+
/** Rendered output of the whole run (concatenated for text runs). */
|
|
92
|
+
html: string
|
|
93
|
+
/** Block keys composing the run (1 for element runs). */
|
|
94
|
+
blockKeys: string[]
|
|
95
|
+
/** The run's first block, for content morphing. */
|
|
96
|
+
node: RedNode
|
|
97
|
+
/**
|
|
98
|
+
* Source offsets spanned by the run (first block's start to last block's
|
|
99
|
+
* end), accumulated from GREEN widths, never from red `range` reads — see
|
|
100
|
+
* `buildRuns`. Used by the windowed reconcile to locate the runs
|
|
101
|
+
* overlapping an edit by source position, which stays correct when the
|
|
102
|
+
* edit inserts or deletes blocks (index-based lookup would land on the
|
|
103
|
+
* wrong runs).
|
|
104
|
+
*/
|
|
105
|
+
start: number
|
|
106
|
+
end: number
|
|
107
|
+
/**
|
|
108
|
+
* Block-list indices spanned by the run (`blockTo` exclusive), used by the
|
|
109
|
+
* windowed reconcile to locate runs by CHURN (block identity) instead of
|
|
110
|
+
* source spans — spans would force a lazy-shift materialization on every
|
|
111
|
+
* displaced block (see `RedNode.setStart`).
|
|
112
|
+
*/
|
|
113
|
+
blockFrom: number
|
|
114
|
+
blockTo: number
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface PatchCache {
|
|
118
|
+
/** Ordered block keys from the previous patch. */
|
|
119
|
+
lastKeys: string[]
|
|
120
|
+
/**
|
|
121
|
+
* Accumulated set of every block key ever patched into this container.
|
|
122
|
+
*
|
|
123
|
+
* Used by the `added` heuristic in `patchBlocksInto` WITHOUT rebuilding a
|
|
124
|
+
* Set from `lastKeys` on every keystroke. Growing it (new keys get added as
|
|
125
|
+
* they appear) keeps the guard O(keys) but with a plain `has` per block
|
|
126
|
+
* (~µs) instead of an 80k-entry Set construction (~80ms on the big doc).
|
|
127
|
+
* It is deliberately cumulative: a key that disappears from the doc stays
|
|
128
|
+
* in the set, which is exactly right — the heuristic only asks "was this
|
|
129
|
+
* key ever here?" for the previous document, and stale-but-true entries
|
|
130
|
+
* only ever make `added` smaller, never larger. It is rebuilt when it
|
|
131
|
+
* grows past 2× the live document (see the prune in `reconcileKeyed`).
|
|
132
|
+
*/
|
|
133
|
+
lastKeySet: Set<string>
|
|
134
|
+
/** Last RedNode per block key — reference equality skips unchanged blocks. */
|
|
135
|
+
lastNode: Map<string, RedNode>
|
|
136
|
+
/** Last rendered html per block key. */
|
|
137
|
+
lastHtml: Map<string, string>
|
|
138
|
+
/** Last render classification per block key. */
|
|
139
|
+
lastClass: Map<string, RunKind>
|
|
140
|
+
/** Ordered runs from the previous patch — the 1:1 mirror of childNodes. */
|
|
141
|
+
lastRuns: PatchRun[]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const caches = new WeakMap<HTMLElement, PatchCache>()
|
|
145
|
+
|
|
146
|
+
const defaultRenderer = new HTMLRenderer()
|
|
147
|
+
|
|
148
|
+
function getCache(container: HTMLElement): PatchCache {
|
|
149
|
+
let cache = caches.get(container)
|
|
150
|
+
if (!cache) {
|
|
151
|
+
cache = {
|
|
152
|
+
lastKeys: [],
|
|
153
|
+
lastKeySet: new Set(),
|
|
154
|
+
lastNode: new Map(),
|
|
155
|
+
lastHtml: new Map(),
|
|
156
|
+
lastClass: new Map(),
|
|
157
|
+
lastRuns: [],
|
|
158
|
+
}
|
|
159
|
+
caches.set(container, cache)
|
|
160
|
+
}
|
|
161
|
+
return cache
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Stable key for a top-level block: its node id, or a positional one when id-less. */
|
|
165
|
+
function blockKey(node: RedNode, index: number): string {
|
|
166
|
+
return node.id ?? `__block_${index}`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Build a DOM node from an HTML fragment (first child), decoding entities. */
|
|
170
|
+
function nodeFromHtml(html: string): Node {
|
|
171
|
+
const t = document.createElement('template')
|
|
172
|
+
t.innerHTML = html
|
|
173
|
+
return t.content.firstChild as Node
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Classify a block's rendered output. Bare text starts with a non-`<` char
|
|
178
|
+
* (escaped entities never emit `<`), elements start with `<`, and `''` emits
|
|
179
|
+
* nothing. The HTML parser merges adjacent bare-text nodes, which is exactly
|
|
180
|
+
* why they are grouped into runs.
|
|
181
|
+
*/
|
|
182
|
+
function classifyHtml(html: string): RunKind {
|
|
183
|
+
if (html === '') return 'none'
|
|
184
|
+
return html.charCodeAt(0) === 60 /* < */ ? 'element' : 'text'
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Block kinds whose rendered element wraps its children in a renderer-added
|
|
189
|
+
* structure (not present in the red tree), so morphing the element's inner
|
|
190
|
+
* content via `renderChildren` would destroy it:
|
|
191
|
+
*
|
|
192
|
+
* - `code` → `<pre><code>…</code></pre>` (the `<code>` is added by the
|
|
193
|
+
* renderer; `renderChildren` only yields the raw text)
|
|
194
|
+
* - `svg` → `<svg><foreignObject><div>…</div></foreignObject></svg>`
|
|
195
|
+
* - `imagemap` → a container with an `<img>` and clickable areas
|
|
196
|
+
*
|
|
197
|
+
* These must be replaced outright when their content changes — they carry no
|
|
198
|
+
* runtime state worth preserving, so replacement is free.
|
|
199
|
+
*/
|
|
200
|
+
/** Can the element's inner content be safely updated via `renderChildren`? */
|
|
201
|
+
function canMorphInPlace(node: RedNode): boolean {
|
|
202
|
+
return node.kind !== 'code' && node.kind !== 'svg' && node.kind !== 'imagemap'
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* First tag of a rendered block html (`<h2 …>` → `H2`), or null when the
|
|
207
|
+
* output is bare text or empty (never passed here — those go the replace
|
|
208
|
+
* path). The tag check exists because `morphHTML` only patches CONTENT: it
|
|
209
|
+
* cannot change the element's tag. When a block's kind changes in place (an
|
|
210
|
+
* `empty_line` morphing into a `heading` at the same top-level slot keeps the
|
|
211
|
+
* same node id via positional id-preservation), morphing the old `<br>` with
|
|
212
|
+
* the heading's inner text silently drops the element — the heading vanishes.
|
|
213
|
+
* Comparing tags (O(1)) sends those to the replace path instead.
|
|
214
|
+
*/
|
|
215
|
+
function renderedTag(html: string): string | null {
|
|
216
|
+
if (html.charCodeAt(0) !== 60 /* < */) return null
|
|
217
|
+
const m = /^<([a-zA-Z][\w-]*)/.exec(html)
|
|
218
|
+
return m ? m[1].toUpperCase() : null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Decide whether a run's element can be morphed in place (content-only) or
|
|
223
|
+
* must be replaced outright. Shared by the full and windowed reconciles so a
|
|
224
|
+
* tag change (kind change at a stable slot) behaves identically on both paths.
|
|
225
|
+
*/
|
|
226
|
+
function shouldMorphInPlace(
|
|
227
|
+
element: Element,
|
|
228
|
+
run: PatchRun,
|
|
229
|
+
): boolean {
|
|
230
|
+
return (
|
|
231
|
+
run.kind === 'element' &&
|
|
232
|
+
!!run.node.id &&
|
|
233
|
+
run.node.children.length > 0 &&
|
|
234
|
+
canMorphInPlace(run.node) &&
|
|
235
|
+
renderedTag(run.html) === element.tagName
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Group the block list into runs, using the per-block render info.
|
|
241
|
+
*
|
|
242
|
+
* Run spans are accumulated from GREEN widths, never from red `range` reads:
|
|
243
|
+
* a mid-document edit displaces every block after it, and each displaced-but-
|
|
244
|
+
* unchanged block carries a pending lazy shift (see `RedNode.setStart`) that
|
|
245
|
+
* a range read would materialize — walking the whole displaced tail per
|
|
246
|
+
* keystroke, the exact cost the lazy shift was introduced to remove. Green
|
|
247
|
+
* widths are position-free and always current, and the partition invariant (a
|
|
248
|
+
* block's accumulated width equals its materialized `range.start`; verified
|
|
249
|
+
* on the 500 KB fixture across every edit shape) makes the accumulation exact.
|
|
250
|
+
*/
|
|
251
|
+
function buildRuns(
|
|
252
|
+
blocks: RedNode[],
|
|
253
|
+
keys: string[],
|
|
254
|
+
getHtml: (node: RedNode, key: string) => { html: string; kind: RunKind },
|
|
255
|
+
baseStart: number,
|
|
256
|
+
): PatchRun[] {
|
|
257
|
+
const runs: PatchRun[] = []
|
|
258
|
+
let textRun: PatchRun | null = null
|
|
259
|
+
let offset = baseStart
|
|
260
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
261
|
+
const node = blocks[i]
|
|
262
|
+
const key = keys[i]
|
|
263
|
+
const start = offset
|
|
264
|
+
offset += node.green.width
|
|
265
|
+
const { html, kind } = getHtml(node, key)
|
|
266
|
+
if (kind === 'none') continue
|
|
267
|
+
if (kind === 'element') {
|
|
268
|
+
textRun = null
|
|
269
|
+
runs.push({ key, kind, html, blockKeys: [key], node, start, end: offset, blockFrom: i, blockTo: i + 1 })
|
|
270
|
+
} else if (textRun) {
|
|
271
|
+
textRun.html += html
|
|
272
|
+
textRun.blockKeys.push(key)
|
|
273
|
+
textRun.end = offset
|
|
274
|
+
textRun.blockTo = i + 1
|
|
275
|
+
} else {
|
|
276
|
+
textRun = { key, kind, html, blockKeys: [key], node, start, end: offset, blockFrom: i, blockTo: i + 1 }
|
|
277
|
+
runs.push(textRun)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return runs
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Full rebuild of the container from the whole tree. */
|
|
284
|
+
function fullRebuild(
|
|
285
|
+
container: HTMLElement,
|
|
286
|
+
rootNode: RedNode,
|
|
287
|
+
renderer: HTMLRenderer,
|
|
288
|
+
cache: PatchCache,
|
|
289
|
+
): PatchBlocksStats {
|
|
290
|
+
container.innerHTML = renderer.render(rootNode)
|
|
291
|
+
const blocks = rootNode.children
|
|
292
|
+
const keys: string[] = []
|
|
293
|
+
cache.lastHtml.clear()
|
|
294
|
+
cache.lastNode.clear()
|
|
295
|
+
cache.lastClass.clear()
|
|
296
|
+
cache.lastKeySet = new Set()
|
|
297
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
298
|
+
const node = blocks[i]
|
|
299
|
+
const key = blockKey(node, i)
|
|
300
|
+
keys.push(key)
|
|
301
|
+
cache.lastKeySet.add(key)
|
|
302
|
+
const html = renderer.render(node)
|
|
303
|
+
cache.lastHtml.set(key, html)
|
|
304
|
+
cache.lastNode.set(key, node)
|
|
305
|
+
cache.lastClass.set(key, classifyHtml(html))
|
|
306
|
+
}
|
|
307
|
+
cache.lastKeys = keys
|
|
308
|
+
cache.lastRuns = buildRuns(blocks, keys, (node, key) => ({
|
|
309
|
+
html: cache.lastHtml.get(key)!,
|
|
310
|
+
kind: cache.lastClass.get(key)!,
|
|
311
|
+
}), rootNode.range.start + rootNode.green.leadingWidth)
|
|
312
|
+
return { mode: 'full', total: blocks.length, patched: blocks.length }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Reconcile `container`'s children against `rootNode`'s top-level blocks by
|
|
317
|
+
* stable key, over the RUN lists (which mirror the DOM 1:1 — see module docs).
|
|
318
|
+
*
|
|
319
|
+
* Surviving runs keep the SAME DOM node (moved in place when their position
|
|
320
|
+
* shifted, so identity and runtime state survive); new runs are inserted;
|
|
321
|
+
* orphans are removed at the tail. Content changes morph element runs in place
|
|
322
|
+
* and swap the text node of text runs.
|
|
323
|
+
*
|
|
324
|
+
* Returns what was done so callers/tests can assert the fast path fired.
|
|
325
|
+
*/
|
|
326
|
+
function reconcileKeyed(
|
|
327
|
+
container: HTMLElement,
|
|
328
|
+
rootNode: RedNode,
|
|
329
|
+
keys: string[],
|
|
330
|
+
renderer: HTMLRenderer,
|
|
331
|
+
cache: PatchCache,
|
|
332
|
+
options: PatchBlocksOptions,
|
|
333
|
+
): PatchBlocksStats {
|
|
334
|
+
const oldRuns = cache.lastRuns
|
|
335
|
+
const oldRunByKey = new Map(oldRuns.map((r) => [r.key, r]))
|
|
336
|
+
|
|
337
|
+
// Old DOM ↔ run-key map (1:1 by construction — runs mirror childNodes).
|
|
338
|
+
const oldByKey = new Map<string, Node>()
|
|
339
|
+
for (let i = 0; i < oldRuns.length; i++) {
|
|
340
|
+
const child = container.childNodes[i]
|
|
341
|
+
if (child) oldByKey.set(oldRuns[i].key, child)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── Pass 1 (no DOM writes): compute the new runs. Reference-equal blocks
|
|
345
|
+
// reuse their cached html/classification (no render); changed blocks render.
|
|
346
|
+
// ── Pass 2: reconcile the DOM against the new run order.
|
|
347
|
+
// Ambos passes comparten el fallback: si el render de un bloque o el morph
|
|
348
|
+
// lanzan, se notifica vía onError y se reconstruye completo (el contrato
|
|
349
|
+
// histórico de patchBlocksInto es nunca lanzar hacia el llamante).
|
|
350
|
+
const blocks = rootNode.children
|
|
351
|
+
let runs: PatchRun[]
|
|
352
|
+
let patched = 0
|
|
353
|
+
try {
|
|
354
|
+
runs = buildRuns(blocks, keys, (node, key) => {
|
|
355
|
+
if (cache.lastNode.get(key) === node) {
|
|
356
|
+
return { html: cache.lastHtml.get(key) ?? '', kind: cache.lastClass.get(key) ?? 'none' }
|
|
357
|
+
}
|
|
358
|
+
const html = renderer.render(node)
|
|
359
|
+
const kind = classifyHtml(html)
|
|
360
|
+
cache.lastHtml.set(key, html)
|
|
361
|
+
cache.lastNode.set(key, node)
|
|
362
|
+
cache.lastClass.set(key, kind)
|
|
363
|
+
return { html, kind }
|
|
364
|
+
}, rootNode.range.start + rootNode.green.leadingWidth)
|
|
365
|
+
|
|
366
|
+
// ── Pre-pass: drop DOM nodes whose run no longer exists. ──────────────
|
|
367
|
+
// A run whose key vanished (its block was replaced by a new id — e.g.
|
|
368
|
+
// typing at the START of the document re-keys the first block) must leave
|
|
369
|
+
// the DOM BEFORE the reconcile loop. The old algorithm only removed it at
|
|
370
|
+
// the tail: every surviving run then saw `element !== anchor` (the corpse
|
|
371
|
+
// sat at the front) and got moved one slot — 79997 `insertBefore` calls on
|
|
372
|
+
// an 80k-child container ≈ 14s. Removing orphans first means a pure shift
|
|
373
|
+
// (insert/delete anywhere) moves ZERO nodes.
|
|
374
|
+
const newKeySet = new Set<string>()
|
|
375
|
+
for (let i = 0; i < runs.length; i++) newKeySet.add(runs[i].key)
|
|
376
|
+
// Walk the OLD runs; any whose key is gone is an orphan. `childNodes` is a
|
|
377
|
+
// live list, so collect the nodes first, then remove them (the survivors
|
|
378
|
+
// keep their identity — this is what preserves open `<details>`).
|
|
379
|
+
const orphanNodes: Node[] = []
|
|
380
|
+
for (let i = 0; i < oldRuns.length; i++) {
|
|
381
|
+
if (!newKeySet.has(oldRuns[i].key)) {
|
|
382
|
+
const node = container.childNodes[i]
|
|
383
|
+
if (node) orphanNodes.push(node)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
for (let i = 0; i < orphanNodes.length; i++) {
|
|
387
|
+
container.removeChild(orphanNodes[i])
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ── Pass 2: reconcile the DOM against the new run order. ──────────────
|
|
391
|
+
// `anchor` is the next DOM node the new order expects; walking it with
|
|
392
|
+
// `nextSibling` (not `childNodes[domIndex]`) means a node already in place
|
|
393
|
+
// is never touched, and a freshly inserted node does not shift the anchor
|
|
394
|
+
// of every run after it.
|
|
395
|
+
let anchor: Node | null = container.firstChild
|
|
396
|
+
for (let i = 0; i < runs.length; i++) {
|
|
397
|
+
const run = runs[i]
|
|
398
|
+
const prev = oldRunByKey.get(run.key)
|
|
399
|
+
const element = oldByKey.get(run.key)
|
|
400
|
+
|
|
401
|
+
if (!element) {
|
|
402
|
+
// Brand-new run (block insertion, id churn, text-run split).
|
|
403
|
+
container.insertBefore(nodeFromHtml(run.html), anchor)
|
|
404
|
+
patched++
|
|
405
|
+
continue
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (element !== anchor) {
|
|
409
|
+
// Surviving run that moved (a genuine reorder): move the SAME node —
|
|
410
|
+
// its runtime state survives. A pure shift never lands here: the
|
|
411
|
+
// orphan pre-pass already removed the node that used to sit in front.
|
|
412
|
+
container.insertBefore(element, anchor)
|
|
413
|
+
} else {
|
|
414
|
+
anchor = anchor!.nextSibling
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Unchanged content (same composition, same rendered output).
|
|
418
|
+
if (prev && prev.blockKeys.length === run.blockKeys.length && prev.html === run.html) {
|
|
419
|
+
continue
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (element.nodeType === 1 && shouldMorphInPlace(element as Element, run)) {
|
|
423
|
+
// Element with children and a matching tag: morph its inner content in
|
|
424
|
+
// place, preserving the element (and runtime state like an open
|
|
425
|
+
// `<details>`). Tag mismatch (kind change at a stable slot) or wrapper
|
|
426
|
+
// kinds (code/svg/imagemap) go the replace path below.
|
|
427
|
+
morphHTML(element as HTMLElement, renderer.renderChildren(run.node))
|
|
428
|
+
} else {
|
|
429
|
+
// Text run, an element leaf (img/video/audio/`<br>`), a kind whose tag
|
|
430
|
+
// changed, or a wrapper renderer kind: the node's own structure must
|
|
431
|
+
// change — replace outright.
|
|
432
|
+
container.replaceChild(nodeFromHtml(run.html), element)
|
|
433
|
+
}
|
|
434
|
+
patched++
|
|
435
|
+
}
|
|
436
|
+
} catch (err) {
|
|
437
|
+
options.onError?.(err)
|
|
438
|
+
return fullRebuild(container, rootNode, renderer, cache)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Safety net: with orphans removed up front and every surviving node walked
|
|
442
|
+
// exactly once by the loop, nothing should remain — but if a run rendered to
|
|
443
|
+
// a different node count than last time (parser merge edge), drop the tail.
|
|
444
|
+
while (container.childNodes.length > runs.length) {
|
|
445
|
+
container.removeChild(container.childNodes[runs.length])
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Prune the cache: removed blocks leave dead entries, and node ids are never
|
|
449
|
+
// reused, so without pruning the maps would grow unbounded between rebuilds.
|
|
450
|
+
if (cache.lastNode.size !== keys.length) {
|
|
451
|
+
const live = new Set(keys)
|
|
452
|
+
for (const k of Array.from(cache.lastNode.keys())) {
|
|
453
|
+
if (!live.has(k)) {
|
|
454
|
+
cache.lastNode.delete(k)
|
|
455
|
+
cache.lastHtml.delete(k)
|
|
456
|
+
cache.lastClass.delete(k)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// The cumulative key set stays hot while the document churns ids (typing at
|
|
461
|
+
// the start minted a fresh id per keystroke); rebuild it once it dwarfs the
|
|
462
|
+
// live document so the `added` guard stays cheap forever.
|
|
463
|
+
if (cache.lastKeySet.size > keys.length * 2) {
|
|
464
|
+
cache.lastKeySet = new Set(keys)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
cache.lastKeys = keys
|
|
468
|
+
cache.lastRuns = runs
|
|
469
|
+
return { mode: 'blocks', total: keys.length, patched }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Below this many top-level blocks, skip the windowed path entirely.
|
|
474
|
+
*
|
|
475
|
+
* The windowed bookkeeping (churn scan, key/reference unions, scoped maps,
|
|
476
|
+
* anchor walk) costs a fixed ~2-3 ms; the full keyed walk costs O(blocks).
|
|
477
|
+
* Measured crossover on this module's shape (100 to 4000 blocks, typing and
|
|
478
|
+
* mid-document edits on the real 500 KB fixture): the windowed path wins at
|
|
479
|
+
* EVERY size — 2× on the 457-block fixture and still ahead at 4000. The
|
|
480
|
+
* earlier "crossover well above 2k" claim predates the lazy-offset work,
|
|
481
|
+
* which removed the per-block range reads the windowed path used to pay
|
|
482
|
+
* (the walk now locates the window by churn and reads nothing). The
|
|
483
|
+
* threshold sits at 200 to skip the bookkeeping only for tiny documents
|
|
484
|
+
* where a single innerHTML parse beats either reconcile.
|
|
485
|
+
*/
|
|
486
|
+
const MIN_WINDOWED_BLOCKS = 200
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Windowed reconciliation — the 500k-character path.
|
|
490
|
+
*
|
|
491
|
+
* The full `reconcileKeyed` walks every run of the document, which at 100k+
|
|
492
|
+
* top-level blocks costs tens of milliseconds per keystroke even when only a
|
|
493
|
+
* handful of blocks changed. With a `change` range (the model knows exactly
|
|
494
|
+
* what it edited) plus the parser's red-subtree reuse (blocks the edit did not
|
|
495
|
+
* touch are the SAME RedNode objects), this path locates the edited region by
|
|
496
|
+
* source offset and reconciles ONLY the runs overlapping it:
|
|
497
|
+
*
|
|
498
|
+
* - the NEW runs overlapping [start, end) (new coordinates), and
|
|
499
|
+
* - the OLD runs overlapping [start, endOld) (OLD coordinates — `endOld` is
|
|
500
|
+
* what keeps this correct when the edit inserted or deleted blocks, where
|
|
501
|
+
* index-based mapping would land on the wrong old runs).
|
|
502
|
+
*
|
|
503
|
+
* Everything outside the window is guaranteed unchanged by the incremental
|
|
504
|
+
* parser contract (green-sharing → red reuse → reference identity), so its DOM
|
|
505
|
+
* nodes are left completely untouched — a pure shift now costs O(window)
|
|
506
|
+
* instead of O(document). The window is still reconciled by stable key with
|
|
507
|
+
* the same morph/insert/remove semantics as the full path, so runtime state
|
|
508
|
+
* (an open `<details>`) inside the edited region survives.
|
|
509
|
+
*
|
|
510
|
+
* Returns the stats, or `null` when the caller should fall back to the full
|
|
511
|
+
* reconcile: no reliable window (whole-document edit), the tree churned
|
|
512
|
+
* outside the window (a rebuild where the parser contract does not hold), or
|
|
513
|
+
* the document is small enough that the full walk is already cheaper than the
|
|
514
|
+
* windowed bookkeeping (see `MIN_WINDOWED_BLOCKS`).
|
|
515
|
+
*/
|
|
516
|
+
function reconcileWindowed(
|
|
517
|
+
container: HTMLElement,
|
|
518
|
+
rootNode: RedNode,
|
|
519
|
+
change: TextChangeRange,
|
|
520
|
+
renderer: HTMLRenderer,
|
|
521
|
+
cache: PatchCache,
|
|
522
|
+
options: PatchBlocksOptions,
|
|
523
|
+
): PatchBlocksStats | null {
|
|
524
|
+
const blocks = rootNode.children
|
|
525
|
+
const n = blocks.length
|
|
526
|
+
if (n === 0) return null
|
|
527
|
+
const minBlocks = options.minWindowedBlocks ?? MIN_WINDOWED_BLOCKS
|
|
528
|
+
if (n < minBlocks) return null
|
|
529
|
+
const __bail = (): null => null
|
|
530
|
+
|
|
531
|
+
// ── Block window (new coordinates), derived from the CHURN. ────────────
|
|
532
|
+
// The incremental parser re-keys every block inside its reparse window
|
|
533
|
+
// (roughly [first-1, last+2], plus a leftward walk over newline runs) and
|
|
534
|
+
// leaves everything else reference-identical. So the blocks the edit really
|
|
535
|
+
// touched are exactly the ones that are NOT reference-identical to the
|
|
536
|
+
// cache. Walk outward from the churn until the blocks are identical again:
|
|
537
|
+
// the resulting window is exact by construction — no margin guessing, and
|
|
538
|
+
// it stays small because a normal edit churns only the parser's window.
|
|
539
|
+
//
|
|
540
|
+
// The window is seeded by CHURN, not by source span: locating it through
|
|
541
|
+
// ranges reads `blocks[i].range` for every block up to the edit, and each
|
|
542
|
+
// read materializes the pending lazy shift of every displaced-but-unchanged
|
|
543
|
+
// block (see `RedNode.setStart`) — the subtree walk the lazy shift was
|
|
544
|
+
// meant to eliminate, moved from buildRed to here. By the partition
|
|
545
|
+
// invariant the edit always lands inside some churned block, so the churn
|
|
546
|
+
// scan finds the same window and reads nothing. Keyed with `blockKey` (the
|
|
547
|
+
// same criterion the cache uses) — a bare `.id` would silently treat every
|
|
548
|
+
// block as churned if an id were ever falsy, bailing to the full path on
|
|
549
|
+
// every keystroke.
|
|
550
|
+
const churned = (b: RedNode, i: number): boolean =>
|
|
551
|
+
cache.lastNode.get(blockKey(b, i)) !== b
|
|
552
|
+
let firstChanged = n
|
|
553
|
+
for (let i = 0; i < n; i++) {
|
|
554
|
+
if (churned(blocks[i], i)) {
|
|
555
|
+
firstChanged = i
|
|
556
|
+
break
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
let lastChanged = -1
|
|
560
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
561
|
+
if (churned(blocks[i], i)) {
|
|
562
|
+
lastChanged = i
|
|
563
|
+
break
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
// Defensive pin when nothing churned (an edit with no observable effect, or
|
|
567
|
+
// a first patch where the cache was still empty): `winEnd` would index
|
|
568
|
+
// `blocks[n]` (OOB crash — this code sits outside the try/catch, so it would
|
|
569
|
+
// escape to the caller). Pin the window to the tail blocks; the churn-walk
|
|
570
|
+
// below widens it over the real edited region.
|
|
571
|
+
if (firstChanged === n) {
|
|
572
|
+
firstChanged = Math.max(0, n - 1)
|
|
573
|
+
lastChanged = Math.max(lastChanged, firstChanged)
|
|
574
|
+
}
|
|
575
|
+
if (lastChanged < firstChanged) lastChanged = firstChanged
|
|
576
|
+
let winStart = firstChanged
|
|
577
|
+
let winEnd = lastChanged + 1
|
|
578
|
+
while (winStart > 0 && churned(blocks[winStart - 1], winStart - 1)) winStart--
|
|
579
|
+
while (winEnd < n && churned(blocks[winEnd], winEnd)) winEnd++
|
|
580
|
+
|
|
581
|
+
// A window covering most of the document (paste / load / whole-doc edit — a
|
|
582
|
+
// whole-tree rebuild churns everything) is better served by the single-pass
|
|
583
|
+
// full reconcile.
|
|
584
|
+
if (winEnd - winStart > n / 2) return __bail()
|
|
585
|
+
|
|
586
|
+
const keys = blocks.map((b, i) => blockKey(b, i))
|
|
587
|
+
|
|
588
|
+
// ── Build the new runs. Reference-identical blocks reuse cached html, so
|
|
589
|
+
// this is O(n) cheap map lookups; only window blocks render.
|
|
590
|
+
let runs: PatchRun[]
|
|
591
|
+
try {
|
|
592
|
+
runs = buildRuns(blocks, keys, (node, key) => {
|
|
593
|
+
if (cache.lastNode.get(key) === node) {
|
|
594
|
+
return { html: cache.lastHtml.get(key) ?? '', kind: cache.lastClass.get(key) ?? 'none' }
|
|
595
|
+
}
|
|
596
|
+
const html = renderer.render(node)
|
|
597
|
+
const kind = classifyHtml(html)
|
|
598
|
+
cache.lastHtml.set(key, html)
|
|
599
|
+
cache.lastNode.set(key, node)
|
|
600
|
+
cache.lastClass.set(key, kind)
|
|
601
|
+
return { html, kind }
|
|
602
|
+
}, rootNode.range.start + rootNode.green.leadingWidth)
|
|
603
|
+
} catch (err) {
|
|
604
|
+
options.onError?.(err)
|
|
605
|
+
return null
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const oldRuns = cache.lastRuns
|
|
609
|
+
|
|
610
|
+
const DBG = (globalThis as { __BP_DEBUG__?: boolean }).__BP_DEBUG__ === true
|
|
611
|
+
const dbg = (...a: unknown[]): void => {
|
|
612
|
+
if (DBG) console.log('[BP]', ...a)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
// ── Old window (OLD coordinates). ──────────────────────────────────────
|
|
617
|
+
// Walk the old runs (lockstep with the DOM — a 1:1 mirror) until we pass
|
|
618
|
+
// the edited region. Using `endOld` (old coordinates) keeps the boundary
|
|
619
|
+
// correct under insertions/deletions: a deleted region spans old runs the
|
|
620
|
+
// new tree no longer has, and only the old span can see them.
|
|
621
|
+
let foundStart = false
|
|
622
|
+
let oldFrom = 0
|
|
623
|
+
let oldTo = oldRuns.length
|
|
624
|
+
for (let i = 0; i < oldRuns.length; i++) {
|
|
625
|
+
const run = oldRuns[i]
|
|
626
|
+
if (!foundStart && run.end >= change.start) {
|
|
627
|
+
foundStart = true
|
|
628
|
+
oldFrom = i
|
|
629
|
+
}
|
|
630
|
+
if (run.start >= change.endOld) {
|
|
631
|
+
oldTo = i
|
|
632
|
+
break
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (!foundStart) oldFrom = 0
|
|
636
|
+
// Margins: the previous run (run-boundary merges) and the parser's widened
|
|
637
|
+
// tail (last+2 blocks — re-parsed, so their old nodes are orphans).
|
|
638
|
+
if (oldFrom > 0) oldFrom -= 1
|
|
639
|
+
oldTo = Math.min(oldRuns.length, oldTo + 2)
|
|
640
|
+
|
|
641
|
+
// ── New window in run space: the runs overlapping the churn block window. ──
|
|
642
|
+
// Runs are the DOM unit and can straddle block boundaries (text runs), so
|
|
643
|
+
// slice by BLOCK INDEX — `winStart`/`winEnd` are block positions and every
|
|
644
|
+
// run's `blockFrom`/`blockTo` are block positions too, so the overlap test
|
|
645
|
+
// needs no range reads (a span comparison would materialize the displaced
|
|
646
|
+
// blocks' lazy shifts again). One run of margin on each side covers
|
|
647
|
+
// boundary merges/splits.
|
|
648
|
+
let newFrom = runs.length
|
|
649
|
+
let newTo = runs.length
|
|
650
|
+
for (let i = 0; i < runs.length; i++) {
|
|
651
|
+
const run = runs[i]
|
|
652
|
+
if (newFrom === runs.length && run.blockTo > winStart) newFrom = i
|
|
653
|
+
if (run.blockFrom >= winEnd) {
|
|
654
|
+
newTo = i
|
|
655
|
+
break
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (newFrom === runs.length) newFrom = Math.max(0, runs.length - 1)
|
|
659
|
+
if (newFrom > 0) newFrom -= 1
|
|
660
|
+
newTo = Math.min(runs.length, newTo + 2)
|
|
661
|
+
|
|
662
|
+
// ── Align: both windows must cover the SAME run region. ────────────────
|
|
663
|
+
// Runs outside the region are identical in both trees (same keys, same
|
|
664
|
+
// order), so the two span computations must agree — the parser's reparse
|
|
665
|
+
// window can re-key runs just past the edit on either side, and the old
|
|
666
|
+
// side can span a deleted region the new tree no longer has. If one window
|
|
667
|
+
// covered a run the other did not, the run would be matched on one side and
|
|
668
|
+
// treated as brand-new on the other — duplicating its node. Union both
|
|
669
|
+
// ranges; the extra coverage is harmless (unchanged runs match by key and
|
|
670
|
+
// cost no DOM writes).
|
|
671
|
+
const from = Math.min(newFrom, oldFrom)
|
|
672
|
+
const to = Math.max(newTo, oldTo)
|
|
673
|
+
|
|
674
|
+
// ── Scoped keyed reconcile over the window ─────────────────────────────
|
|
675
|
+
const oldToClamped = Math.min(oldRuns.length, to)
|
|
676
|
+
|
|
677
|
+
// Key-set union, not just index union: a prepend/append re-keys the churned
|
|
678
|
+
// blocks (they get brand-new ids at the same window positions) while
|
|
679
|
+
// reference-identical runs just outside the reparse window (the quote below)
|
|
680
|
+
// KEEP their keys — and land at a different index in the two run lists. The
|
|
681
|
+
// index union then slices one list without the other's surviving run, and
|
|
682
|
+
// the orphan pre-pass would remove it from the DOM even though it still
|
|
683
|
+
// exists in the new tree. Expand the NEW window to cover every old-window
|
|
684
|
+
// key that survived (identical ⇒ the walk costs nothing), both ends, and
|
|
685
|
+
// anchor the DOM at the earliest start of either window.
|
|
686
|
+
const oldWinRuns = oldRuns.slice(from, oldToClamped)
|
|
687
|
+
const oldWinKeySet = new Set<string>()
|
|
688
|
+
for (let i = 0; i < oldWinRuns.length; i++) oldWinKeySet.add(oldWinRuns[i].key)
|
|
689
|
+
let toNew = Math.min(runs.length, to)
|
|
690
|
+
while (toNew < runs.length && oldWinKeySet.has(runs[toNew].key)) toNew++
|
|
691
|
+
let fromNew = from
|
|
692
|
+
while (fromNew > 0 && oldWinKeySet.has(runs[fromNew - 1].key)) fromNew--
|
|
693
|
+
const newWinRuns = runs.slice(fromNew, toNew)
|
|
694
|
+
// Old runs may begin before the index window too (backward expansion): the
|
|
695
|
+
// DOM anchor and old node list must start at the same earliest index.
|
|
696
|
+
const oldWinFrom = Math.min(from, fromNew)
|
|
697
|
+
|
|
698
|
+
// Reference-identity union: a block DELETED before a surviving run re-keys
|
|
699
|
+
// every block after it (the quote went n732 → n762 above), so the key-set
|
|
700
|
+
// union cannot see that the old n732 run and the new n762 run are the SAME
|
|
701
|
+
// RedNode object. The red-reuse contract guarantees it, so widen the OLD
|
|
702
|
+
// window to include runs whose NODE lives on in the new window — the walk
|
|
703
|
+
// then MOVES the old node in place instead of inserting a duplicate.
|
|
704
|
+
//
|
|
705
|
+
// The twin is NOT always near the window edge. The reparse window can be
|
|
706
|
+
// large (a 20 KB mid-document delete re-keys a whole run of siblings), and
|
|
707
|
+
// every surviving run after the edit is then displaced by the number of
|
|
708
|
+
// deleted runs — measured: delete-20k left 24 twins in oldRuns[251..274]
|
|
709
|
+
// while the fixed band stopped at oldToClamped + 8 = 251. A bounded band
|
|
710
|
+
// misses them, the walk inserts duplicates, and the DOM ends up with
|
|
711
|
+
// orphaned blocks (452 childNodes vs 428 on the real 500 KB fixture).
|
|
712
|
+
//
|
|
713
|
+
// Fix: extend the band over the old runs whose NODE is present in the new
|
|
714
|
+
// window — those are exactly the re-keyed survivors the walk must move, not
|
|
715
|
+
// orphan (a run whose node is NOT in the new window stops the scan: it is
|
|
716
|
+
// a genuine structural change, where removing + inserting is right). Both
|
|
717
|
+
// sides, so a large insert re-keys survivors backward too.
|
|
718
|
+
const newByNode = new Map<RedNode, PatchRun>()
|
|
719
|
+
for (let i = 0; i < newWinRuns.length; i++) {
|
|
720
|
+
if (!newByNode.has(newWinRuns[i].node)) newByNode.set(newWinRuns[i].node, newWinRuns[i])
|
|
721
|
+
}
|
|
722
|
+
let oldWinTo = oldToClamped
|
|
723
|
+
let oldWinFrom2 = oldWinFrom
|
|
724
|
+
let bandTo = Math.min(oldRuns.length, oldToClamped + 8)
|
|
725
|
+
// Extend the tail of the identity band while the old runs are twins of the
|
|
726
|
+
// new window. `oldToClamped` is where the old window ended by KEY; survivors
|
|
727
|
+
// re-keyed by a big edit live right after it, each one reference-identical
|
|
728
|
+
// to a new-window run, so scanning until the first non-twin covers exactly
|
|
729
|
+
// the displaced tail with no arbitrary margin to guess. Cost is bounded by
|
|
730
|
+
// the displaced tail itself — normal keystrokes stop after ~0 runs because
|
|
731
|
+
// runs beyond the window keep their keys and are matched by key, not node.
|
|
732
|
+
while (bandTo < oldRuns.length && newByNode.has(oldRuns[bandTo].node)) bandTo++
|
|
733
|
+
// Same for the head: a large insertion re-keys the runs BEFORE the edit
|
|
734
|
+
// (their keys shift backward), so walk left while old runs are twins too.
|
|
735
|
+
let bandFromFinal = Math.max(0, oldWinFrom - 3)
|
|
736
|
+
while (bandFromFinal > 0 && newByNode.has(oldRuns[bandFromFinal - 1].node)) bandFromFinal--
|
|
737
|
+
const oldNodeToIdx = new Map<RedNode, number>()
|
|
738
|
+
for (let i = bandFromFinal; i < bandTo; i++) {
|
|
739
|
+
if (!oldNodeToIdx.has(oldRuns[i].node)) oldNodeToIdx.set(oldRuns[i].node, i)
|
|
740
|
+
}
|
|
741
|
+
for (let i = 0; i < newWinRuns.length; i++) {
|
|
742
|
+
const run = newWinRuns[i]
|
|
743
|
+
if (oldWinKeySet.has(run.key)) continue
|
|
744
|
+
const oldIdx = oldNodeToIdx.get(run.node)
|
|
745
|
+
if (oldIdx === undefined) continue
|
|
746
|
+
if (oldIdx < oldWinFrom2) oldWinFrom2 = oldIdx
|
|
747
|
+
if (oldIdx + 1 > oldWinTo) oldWinTo = oldIdx + 1
|
|
748
|
+
}
|
|
749
|
+
const oldWinRunsFinal = oldRuns.slice(oldWinFrom2, oldWinTo)
|
|
750
|
+
const newWinRunsFinal = newWinRuns
|
|
751
|
+
|
|
752
|
+
dbg('change', { start: change.start, end: change.end, endOld: change.endOld })
|
|
753
|
+
dbg('oldWin', oldWinFrom2, oldWinTo, 'keys', oldWinRunsFinal.map((r) => `${r.key}:${r.node.kind}`).slice(0, 40))
|
|
754
|
+
dbg('newWin', fromNew, toNew, 'keys', newWinRunsFinal.map((r) => `${r.key}:${r.node.kind}`).slice(0, 40))
|
|
755
|
+
dbg('newWinKeySet', [...newWinRunsFinal.map((r) => r.key).slice(0, 40)])
|
|
756
|
+
|
|
757
|
+
// DOM anchor at the window start, and the window's old nodes.
|
|
758
|
+
let anchorNode: Node | null = container.firstChild
|
|
759
|
+
for (let k = 0; k < oldWinFrom2; k++) anchorNode = anchorNode?.nextSibling ?? null
|
|
760
|
+
const oldWinNodes: Node[] = []
|
|
761
|
+
let node: Node | null = anchorNode
|
|
762
|
+
for (let i = oldWinFrom2; i < oldWinTo; i++) {
|
|
763
|
+
if (node) oldWinNodes.push(node)
|
|
764
|
+
node = node?.nextSibling ?? null
|
|
765
|
+
}
|
|
766
|
+
const afterWindow = node
|
|
767
|
+
|
|
768
|
+
const oldRunByKey = new Map<string, PatchRun>()
|
|
769
|
+
const oldRunByNode = new Map<RedNode, PatchRun>()
|
|
770
|
+
for (let i = 0; i < oldWinRunsFinal.length; i++) {
|
|
771
|
+
const old = oldWinRunsFinal[i]
|
|
772
|
+
oldRunByKey.set(old.key, old)
|
|
773
|
+
// Re-keyed survivors: the walk may find the new run by NODE identity
|
|
774
|
+
// instead of key (a deleted/inserted block shifted every id after it).
|
|
775
|
+
if (!oldRunByNode.has(old.node)) oldRunByNode.set(old.node, old)
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const oldByKey = new Map<string, Node>()
|
|
779
|
+
const oldByNode = new Map<RedNode, Node>()
|
|
780
|
+
for (let i = 0; i < oldWinRunsFinal.length; i++) {
|
|
781
|
+
const el = oldWinNodes[i]
|
|
782
|
+
if (!el) continue
|
|
783
|
+
oldByKey.set(oldWinRunsFinal[i].key, el)
|
|
784
|
+
if (!oldByNode.has(oldWinRunsFinal[i].node)) oldByNode.set(oldWinRunsFinal[i].node, el)
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
let patched = 0
|
|
788
|
+
try {
|
|
789
|
+
// Orphan pre-pass (scoped): drop old-window nodes whose run key no longer
|
|
790
|
+
// exists BEFORE the anchor walk, so a pure shift moves zero nodes. A node
|
|
791
|
+
// whose RedNode lives on (re-keyed) is NOT an orphan — it is moved by the
|
|
792
|
+
// walk. The removed set doubles as the survival test for the anchor below
|
|
793
|
+
// — a node can be "in" the container yet report `isConnected === false`
|
|
794
|
+
// when the container itself is detached (tests, hidden panels), so
|
|
795
|
+
// connection state is tracked explicitly, never via `Node.isConnected`.
|
|
796
|
+
const newKeySet = new Set<string>()
|
|
797
|
+
for (let i = 0; i < newWinRunsFinal.length; i++) newKeySet.add(newWinRunsFinal[i].key)
|
|
798
|
+
const removed = new Set<Node>()
|
|
799
|
+
for (let i = 0; i < oldWinRunsFinal.length; i++) {
|
|
800
|
+
const k = oldWinRunsFinal[i].key
|
|
801
|
+
if (newKeySet.has(k) || newByNode.has(oldWinRunsFinal[i].node)) continue
|
|
802
|
+
const el = oldWinNodes[i]
|
|
803
|
+
dbg('orphan?', k, oldWinRunsFinal[i].node.kind, 'el=', el ? (el.nodeType === 1 ? (el as Element).tagName : `text:'${String((el as Text).data ?? '').slice(0, 20)}'`) : 'null')
|
|
804
|
+
if (el && !removed.has(el)) {
|
|
805
|
+
removed.add(el)
|
|
806
|
+
container.removeChild(el)
|
|
807
|
+
dbg(' REMOVED', k)
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Anchor: the first surviving node of the old window (insertions go
|
|
812
|
+
// before it), or the suffix when every old-window node was orphaned.
|
|
813
|
+
let walkAnchor: Node | null = null
|
|
814
|
+
for (let i = 0; i < oldWinRunsFinal.length; i++) {
|
|
815
|
+
const el = oldWinNodes[i]
|
|
816
|
+
if (el && !removed.has(el)) {
|
|
817
|
+
walkAnchor = el
|
|
818
|
+
break
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
if (walkAnchor === null) walkAnchor = afterWindow
|
|
822
|
+
|
|
823
|
+
// Anchor walk over the new window runs — same semantics as the full path.
|
|
824
|
+
let cursor = walkAnchor
|
|
825
|
+
for (let i = 0; i < newWinRunsFinal.length; i++) {
|
|
826
|
+
const run = newWinRunsFinal[i]
|
|
827
|
+
// By key first (same id), then by node identity (re-keyed survivor).
|
|
828
|
+
const prev = oldRunByKey.get(run.key) ?? oldRunByNode.get(run.node)
|
|
829
|
+
const element = oldByKey.get(run.key) ?? oldByNode.get(run.node)
|
|
830
|
+
|
|
831
|
+
dbg('WALK', run.key, run.node.kind, 'element=', element ? (element.nodeType === 1 ? (element as Element).tagName : 'text') : 'NEW', 'cursor=', cursor ? (cursor.nodeType === 1 ? (cursor as Element).tagName : 'text') : 'null')
|
|
832
|
+
|
|
833
|
+
if (!element) {
|
|
834
|
+
container.insertBefore(nodeFromHtml(run.html), cursor)
|
|
835
|
+
patched++
|
|
836
|
+
continue
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (element !== cursor) {
|
|
840
|
+
container.insertBefore(element, cursor)
|
|
841
|
+
} else {
|
|
842
|
+
cursor = cursor?.nextSibling ?? null
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (prev && prev.blockKeys.length === run.blockKeys.length && prev.html === run.html) {
|
|
846
|
+
continue
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (element.nodeType === 1 && shouldMorphInPlace(element as Element, run)) {
|
|
850
|
+
morphHTML(element as HTMLElement, renderer.renderChildren(run.node))
|
|
851
|
+
} else {
|
|
852
|
+
container.replaceChild(nodeFromHtml(run.html), element)
|
|
853
|
+
}
|
|
854
|
+
patched++
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Safety net (parser-merge edges): the window region must end exactly at
|
|
858
|
+
// the first suffix node. Leftovers of runs that rendered to a different
|
|
859
|
+
// node count sit between the walk's end and the suffix — drop them.
|
|
860
|
+
let tail = cursor
|
|
861
|
+
dbg('tail-safety: cursor=', cursor ? (cursor.nodeType === 1 ? (cursor as Element).tagName : `text:'${String((cursor as Text).data ?? '').slice(0, 15)}'`) : 'null', 'afterWindow=', afterWindow ? (afterWindow.nodeType === 1 ? (afterWindow as Element).tagName : `text:'${String((afterWindow as Text).data ?? '').slice(0, 15)}'`) : 'null')
|
|
862
|
+
let tailCount = 0
|
|
863
|
+
while (tail !== afterWindow) {
|
|
864
|
+
if (!tail) break
|
|
865
|
+
const next = tail.nextSibling
|
|
866
|
+
container.removeChild(tail)
|
|
867
|
+
tailCount++
|
|
868
|
+
tail = next
|
|
869
|
+
}
|
|
870
|
+
dbg('tail-safety: removed', tailCount)
|
|
871
|
+
} catch (err) {
|
|
872
|
+
options.onError?.(err)
|
|
873
|
+
return null
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// ── Cache ──────────────────────────────────────────────────────────────
|
|
877
|
+
// Only window keys can have died (everything outside is reference-identical
|
|
878
|
+
// and already cached), so prune just those.
|
|
879
|
+
const newWinKeySet = new Set(newWinRunsFinal.map((r) => r.key))
|
|
880
|
+
for (let i = 0; i < oldWinRunsFinal.length; i++) {
|
|
881
|
+
const k = oldWinRunsFinal[i].key
|
|
882
|
+
if (!newWinKeySet.has(k)) {
|
|
883
|
+
cache.lastNode.delete(k)
|
|
884
|
+
cache.lastHtml.delete(k)
|
|
885
|
+
cache.lastClass.delete(k)
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
// Keep the cumulative key set hot for the full path's `added` guard, and
|
|
889
|
+
// rebuild it once it dwarfs the live document (same policy as the full path).
|
|
890
|
+
for (let i = 0; i < newWinRunsFinal.length; i++) cache.lastKeySet.add(newWinRunsFinal[i].key)
|
|
891
|
+
if (cache.lastKeySet.size > keys.length * 2) {
|
|
892
|
+
cache.lastKeySet = new Set(keys)
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
cache.lastKeys = keys
|
|
896
|
+
cache.lastRuns = runs
|
|
897
|
+
return { mode: 'blocks', total: keys.length, patched, windowed: true }
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Reconcile `container`'s children against `rootNode`'s top-level blocks.
|
|
902
|
+
*
|
|
903
|
+
* Inline edits morph only the changed block; structural edits (insert, remove,
|
|
904
|
+
* reorder) are reconciled by key so untouched blocks keep their DOM identity.
|
|
905
|
+
* Returns what was done so callers/tests can assert the fast path fired.
|
|
906
|
+
*/
|
|
907
|
+
export function patchBlocksInto(
|
|
908
|
+
container: HTMLElement,
|
|
909
|
+
rootNode: RedNode | null,
|
|
910
|
+
options: PatchBlocksOptions = {},
|
|
911
|
+
): PatchBlocksStats {
|
|
912
|
+
const renderer = options.renderer ?? defaultRenderer
|
|
913
|
+
const cache = getCache(container)
|
|
914
|
+
|
|
915
|
+
if (!rootNode) {
|
|
916
|
+
container.innerHTML = ''
|
|
917
|
+
cache.lastHtml.clear()
|
|
918
|
+
cache.lastNode.clear()
|
|
919
|
+
cache.lastClass.clear()
|
|
920
|
+
cache.lastKeys = []
|
|
921
|
+
cache.lastKeySet = new Set()
|
|
922
|
+
cache.lastRuns = []
|
|
923
|
+
return { mode: 'full', total: 0, patched: 0 }
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// DOM out of sync with the cache (a host replaced the innerHTML behind our
|
|
927
|
+
// back, or a run rendered to a different node count than last time).
|
|
928
|
+
if (container.childNodes.length !== cache.lastRuns.length) {
|
|
929
|
+
return fullRebuild(container, rootNode, renderer, cache)
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// The edited region, when the model told us (explicitly, or attached to the
|
|
933
|
+
// root by `DocumentModel`). Windowed reconcile first — it falls back below
|
|
934
|
+
// when the edit is too big or the tree churned outside the window.
|
|
935
|
+
const change =
|
|
936
|
+
options.change ??
|
|
937
|
+
(rootNode as RedNode & { __changeRange?: TextChangeRange | null }).__changeRange ??
|
|
938
|
+
undefined
|
|
939
|
+
if (change) {
|
|
940
|
+
const windowed = reconcileWindowed(container, rootNode, change, renderer, cache, options)
|
|
941
|
+
if (windowed) return windowed
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const blocks = rootNode.children
|
|
945
|
+
const keys = blocks.map((n, i) => blockKey(n, i))
|
|
946
|
+
|
|
947
|
+
// Edit script bigger than the previous document (a whole new document in the
|
|
948
|
+
// same container, ids regenerated en masse): one full innerHTML parse beats
|
|
949
|
+
// inserting every block individually. Uses the cumulative key set instead of
|
|
950
|
+
// building a fresh Set from `lastKeys` on every keystroke (see PatchCache).
|
|
951
|
+
const oldSet = cache.lastKeySet
|
|
952
|
+
let added = 0
|
|
953
|
+
for (let i = 0; i < keys.length; i++) {
|
|
954
|
+
const k = keys[i]
|
|
955
|
+
if (!oldSet.has(k)) added++
|
|
956
|
+
oldSet.add(k)
|
|
957
|
+
}
|
|
958
|
+
if (added >= cache.lastKeys.length) {
|
|
959
|
+
return fullRebuild(container, rootNode, renderer, cache)
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
return reconcileKeyed(container, rootNode, keys, renderer, cache, options)
|
|
963
|
+
}
|