@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.
Files changed (172) hide show
  1. package/LICENSE +119 -0
  2. package/README.md +45 -0
  3. package/dist/Visuals/lyne.css +1027 -0
  4. package/dist/Visuals/osu.css +257 -0
  5. package/dist/index.d.mts +4563 -0
  6. package/dist/index.d.ts +4563 -0
  7. package/dist/index.js +11291 -0
  8. package/dist/index.mjs +11197 -0
  9. package/package.json +51 -0
  10. package/src/Analysis/Contracts/AnalysisReport.ts +26 -0
  11. package/src/Analysis/Contracts/Contribution.ts +76 -0
  12. package/src/Analysis/Contracts/Pass.ts +76 -0
  13. package/src/Analysis/Contracts/PipelineContext.ts +48 -0
  14. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +292 -0
  15. package/src/Analysis/Passes/Analysis/MergeableColorAnalyzer.ts +112 -0
  16. package/src/Analysis/Passes/Analysis/RainbowAnalyzer.ts +233 -0
  17. package/src/Analysis/Passes/Analysis/WaveAnalyzer.ts +211 -0
  18. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +135 -0
  19. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +84 -0
  20. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +99 -0
  21. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +119 -0
  22. package/src/Analysis/Passes/Decision/DefaultDecision.ts +139 -0
  23. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +179 -0
  24. package/src/Analysis/Passes/Transform/CollapseGradientTransform.ts +176 -0
  25. package/src/Analysis/Passes/Transform/MergeColorsTransform.ts +126 -0
  26. package/src/Analysis/Passes/Transform/RainbowCollapseTransform.ts +83 -0
  27. package/src/Analysis/Passes/Transform/WaveCollapseTransform.ts +88 -0
  28. package/src/Analysis/Passes/Utility/CharacterCountAnalyzer.ts +45 -0
  29. package/src/Analysis/Pipeline/Pipeline.ts +133 -0
  30. package/src/Analysis/Pipeline/PipelineBuilder.ts +55 -0
  31. package/src/Analysis/Pipeline/PipelineStage.ts +19 -0
  32. package/src/Analysis/Utils/color-utils.ts +132 -0
  33. package/src/Analysis/__tests__/Integration.test.ts +162 -0
  34. package/src/Analysis/__tests__/Pipeline.test.ts +133 -0
  35. package/src/Analysis/index.ts +52 -0
  36. package/src/BBCode/BBCodeDocumentModel.ts +175 -0
  37. package/src/BBCode/BBCodeToGreenNode.ts +755 -0
  38. package/src/BBCode/Parser.ts +384 -0
  39. package/src/BBCode/index.ts +12 -0
  40. package/src/Collab/positions.ts +91 -0
  41. package/src/Commands/Command.ts +44 -0
  42. package/src/Commands/CommandRegistry.ts +78 -0
  43. package/src/Commands/DeleteNode.ts +20 -0
  44. package/src/Commands/InsertText.ts +21 -0
  45. package/src/Commands/SplitMerge.ts +28 -0
  46. package/src/Commands/WrapInTag.ts +21 -0
  47. package/src/Commands/index.ts +6 -0
  48. package/src/Diff/TreeDiffer.ts +264 -0
  49. package/src/Diff/__tests__/TreeDiffer.test.ts +65 -0
  50. package/src/Diff/index.ts +2 -0
  51. package/src/Events/EventBus.ts +160 -0
  52. package/src/Events/index.ts +2 -0
  53. package/src/Formatter/Formatter.ts +54 -0
  54. package/src/Formatter/index.ts +2 -0
  55. package/src/HTML/HTMLDocumentModel.ts +35 -0
  56. package/src/HTML/HTMLToGreenNode.ts +290 -0
  57. package/src/Incremental/ChangeTracker.ts +105 -0
  58. package/src/Incremental/IncrementalParser.ts +591 -0
  59. package/src/Incremental/__tests__/IncrementalParser.test.ts +164 -0
  60. package/src/Incremental/index.ts +4 -0
  61. package/src/Lexer/BBCodeLexer.ts +382 -0
  62. package/src/Lexer/Lexer.ts +181 -0
  63. package/src/Lexer/index.ts +10 -0
  64. package/src/Linter/Linter.ts +193 -0
  65. package/src/Linter/index.ts +2 -0
  66. package/src/Markdown/MarkdownAST.ts +112 -0
  67. package/src/Markdown/MarkdownDocumentModel.ts +55 -0
  68. package/src/Markdown/MarkdownLexer.ts +203 -0
  69. package/src/Markdown/MarkdownParser.ts +455 -0
  70. package/src/Markdown/MarkdownToGreenNode.ts +153 -0
  71. package/src/Model/DocumentModel.ts +694 -0
  72. package/src/Model/NodeFactory.ts +117 -0
  73. package/src/Model/TagRegistry.ts +495 -0
  74. package/src/Model/index.ts +5 -0
  75. package/src/Plugins/PluginAPI.ts +119 -0
  76. package/src/Plugins/PluginRegistry.ts +132 -0
  77. package/src/Plugins/index.ts +3 -0
  78. package/src/Queries/QueryEngine.ts +152 -0
  79. package/src/Queries/index.ts +1 -0
  80. package/src/RenderPipeline/RenderPipeline.ts +125 -0
  81. package/src/RenderPipeline/RenderTree.ts +134 -0
  82. package/src/RenderPipeline/index.ts +4 -0
  83. package/src/Semantic/SemanticAnalyzer.ts +506 -0
  84. package/src/Semantic/index.ts +2 -0
  85. package/src/Symbols/SymbolTable.ts +124 -0
  86. package/src/Symbols/index.ts +1 -0
  87. package/src/Syntax/GreenNode.ts +324 -0
  88. package/src/Syntax/GreenNodePool.ts +269 -0
  89. package/src/Syntax/NodeMatcher.ts +370 -0
  90. package/src/Syntax/RedNode.ts +569 -0
  91. package/src/Syntax/RedNodeStore.ts +184 -0
  92. package/src/Syntax/TreeBuilder.ts +214 -0
  93. package/src/Syntax/__tests__/GreenNode.test.ts +33 -0
  94. package/src/Syntax/__tests__/RedNode.test.ts +81 -0
  95. package/src/Syntax/__tests__/RedNodeStore.test.ts +104 -0
  96. package/src/Syntax/greenEdit.ts +110 -0
  97. package/src/Syntax/hash.ts +30 -0
  98. package/src/Syntax/index.ts +12 -0
  99. package/src/Syntax/partition.ts +161 -0
  100. package/src/Syntax/preserveNodeIds.ts +201 -0
  101. package/src/Tests/ASTOptimizerIdempotence.test.ts +77 -0
  102. package/src/Tests/BlockPatcher.test.ts +437 -0
  103. package/src/Tests/BlockPatcherWindowed.test.ts +364 -0
  104. package/src/Tests/BoxDrawer.test.ts +217 -0
  105. package/src/Tests/BoxRichTitle.test.ts +105 -0
  106. package/src/Tests/Chars500kBenchmark.test.ts +151 -0
  107. package/src/Tests/Chars500kEdits.test.ts +321 -0
  108. package/src/Tests/CollabPositions.test.ts +146 -0
  109. package/src/Tests/CompilerPathProfiling.test.ts +186 -0
  110. package/src/Tests/DOMMorpher.test.ts +142 -0
  111. package/src/Tests/DomPatchPerf.test.ts +60 -0
  112. package/src/Tests/EffectSegments.snapshot.json +616 -0
  113. package/src/Tests/EffectSegments.test.ts +68 -0
  114. package/src/Tests/FindNodeAtOffset.test.ts +65 -0
  115. package/src/Tests/Fuzzer.test.ts +166 -0
  116. package/src/Tests/GreenNodePool.test.ts +153 -0
  117. package/src/Tests/Lexer.test.ts +238 -0
  118. package/src/Tests/LyneMode.test.ts +187 -0
  119. package/src/Tests/ModelCoherence.test.ts +180 -0
  120. package/src/Tests/Partition.test.ts +238 -0
  121. package/src/Tests/PluginTags.test.ts +150 -0
  122. package/src/Tests/ProblematicSection.test.ts +46 -0
  123. package/src/Tests/ProblematicSectionHTML.test.ts +58 -0
  124. package/src/Tests/RedReuse.test.ts +134 -0
  125. package/src/Tests/ReproDelete20k.test.ts +62 -0
  126. package/src/Tests/SemanticValidators.test.ts +136 -0
  127. package/src/Tests/StableNodeIds.test.ts +210 -0
  128. package/src/Tests/StudioColorBloat.test.ts +25 -0
  129. package/src/Tests/StudioDebugText.test.ts +27 -0
  130. package/src/Tests/StudioTrailingChar.test.ts +25 -0
  131. package/src/Tests/StudioValidText.test.ts +25 -0
  132. package/src/Tests/UrlImgBug.test.ts +23 -0
  133. package/src/Tests/VisualBuilderFidelity.test.ts +105 -0
  134. package/src/Tests/referenceDocument.ts +119 -0
  135. package/src/Transactions/Transaction.ts +176 -0
  136. package/src/Transactions/UndoManager.ts +111 -0
  137. package/src/Transactions/index.ts +3 -0
  138. package/src/Transformers/ASTOptimizer.ts +315 -0
  139. package/src/Transformers/GradientTransformer.ts +143 -0
  140. package/src/Transformers/GrowTransformer.ts +115 -0
  141. package/src/Transformers/RainbowTransformer.ts +121 -0
  142. package/src/Transformers/SineWaveTransformer.ts +130 -0
  143. package/src/Transformers/Transformer.ts +22 -0
  144. package/src/Types/core.ts +270 -0
  145. package/src/Types/diagnostics.ts +156 -0
  146. package/src/Types/index.ts +23 -0
  147. package/src/Types/operations.ts +180 -0
  148. package/src/Types/queries.ts +121 -0
  149. package/src/Types/symbols.ts +69 -0
  150. package/src/Types/tokens.ts +186 -0
  151. package/src/Utils/BBCodeGenerator.ts +126 -0
  152. package/src/Utils/ColorMath.ts +276 -0
  153. package/src/Utils/color.ts +112 -0
  154. package/src/Utils/dom-to-svg.test.ts +86 -0
  155. package/src/Utils/dom-to-svg.ts +615 -0
  156. package/src/Utils/treeTransformers.ts +717 -0
  157. package/src/Visitors/BBBlocksExporter.ts +69 -0
  158. package/src/Visitors/BBCodeExporter.ts +318 -0
  159. package/src/Visitors/BlockPatcher.ts +963 -0
  160. package/src/Visitors/DOMMorpher.ts +134 -0
  161. package/src/Visitors/HTMLRenderer.ts +1077 -0
  162. package/src/Visitors/JSONExporter.ts +66 -0
  163. package/src/Visitors/MarkdownExporter.ts +99 -0
  164. package/src/Visitors/SVGRenderer.ts +35 -0
  165. package/src/Visitors/TiptapExporter.ts +145 -0
  166. package/src/Visitors/Visitor.ts +48 -0
  167. package/src/Visitors/index.ts +9 -0
  168. package/src/Visuals/BoxDrawer.ts +175 -0
  169. package/src/Visuals/index.ts +42 -0
  170. package/src/Visuals/lyne.css +1027 -0
  171. package/src/Visuals/osu.css +257 -0
  172. 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
+ }