@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,290 @@
1
+ import { GreenNode, greenNode, greenLeaf } from '../Syntax/GreenNode';
2
+ import { RedNode } from '../Syntax/RedNode';
3
+ import type { NodeKind } from '../Types/core';
4
+ import { isBlockKind } from '../BBCode/BBCodeToGreenNode';
5
+
6
+ function domToGreenTree(root: HTMLElement): GreenNode {
7
+ let currentOffset = 0;
8
+
9
+ function convert(node: globalThis.Node): GreenNode {
10
+ const start = currentOffset;
11
+ const children: GreenNode[] = [];
12
+ let kind: string = 'document';
13
+ let text = '';
14
+
15
+ if (node.nodeType === 3) { // Text Node
16
+ text = node.nodeValue || '';
17
+
18
+ // Convert pure whitespace text nodes into spacing/empty_line directly
19
+ if (text.trim() === '') {
20
+ const parentTag = node.parentNode ? node.parentNode.nodeName.toLowerCase() : '';
21
+ if (['ul', 'ol', 'table', 'tbody', 'tr'].includes(parentTag)) {
22
+ return [] as any; // Ignore structural whitespace
23
+ }
24
+
25
+ const newlines = (text.match(/\n/g) || []).length;
26
+ if (newlines >= 2) {
27
+ kind = 'empty_line';
28
+ text = '\n\n';
29
+ currentOffset += 2;
30
+ } else if (newlines === 1) {
31
+ kind = 'spacing';
32
+ text = '\n';
33
+ currentOffset += 1;
34
+ } else {
35
+ kind = 'text'; // Fallback for 0 newlines
36
+ }
37
+ } else {
38
+ kind = 'text';
39
+ currentOffset += text.length;
40
+ }
41
+ } else if (node.nodeType === 1) { // Element Node
42
+ const el = node as HTMLElement;
43
+ const tag = el.tagName.toLowerCase();
44
+
45
+ switch (tag) {
46
+ case 'b':
47
+ case 'strong': kind = 'bold'; break;
48
+ case 'i':
49
+ case 'em': kind = 'italic'; break;
50
+ case 'u': kind = 'underline'; break;
51
+ case 's':
52
+ case 'strike':
53
+ case 'del': kind = 'strikethrough'; break;
54
+ case 'a':
55
+ kind = 'url';
56
+ text = `=${el.getAttribute('href') || ''}`;
57
+ break;
58
+ case 'img':
59
+ kind = 'image';
60
+ text = el.getAttribute('src') || '';
61
+ break;
62
+ case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6':
63
+ kind = 'heading';
64
+ const level = parseInt(tag.charAt(1));
65
+ text = `=${level}`;
66
+ break;
67
+ case 'br':
68
+ kind = 'spacing';
69
+ text = '\n';
70
+ currentOffset += 1;
71
+ break;
72
+ case 'div':
73
+ if (el.classList.contains('notice')) kind = 'notice';
74
+ else if (el.classList.contains('bbcode-sp')) kind = 'spacing';
75
+ else if (el.classList.contains('bbcode-para')) kind = 'empty_line';
76
+ else if (el.classList.contains('bbcode-imagemap') || el.classList.contains('imagemap-container')) {
77
+ kind = 'imagemap';
78
+ const img = el.querySelector('img');
79
+ if (img) children.push(greenLeaf('text', img.getAttribute('src') || ''));
80
+ children.push(greenLeaf('spacing', ''));
81
+
82
+ const areas = el.querySelectorAll('.bbcode-imap-area, .imagemap-area');
83
+ areas.forEach((a, idx) => {
84
+ const href = a.getAttribute('href') || '';
85
+ const title = a.getAttribute('title') || '';
86
+ const style = (a as HTMLElement).style;
87
+ const x = parseFloat(style.left || '0');
88
+ const y = parseFloat(style.top || '0');
89
+ const w = parseFloat(style.width || '0');
90
+ const h = parseFloat(style.height || '0');
91
+
92
+ const line = `${x} ${y} ${w} ${h} ${href} ${title}`.trim();
93
+ children.push(greenLeaf('text', line));
94
+ if (idx < areas.length - 1) {
95
+ children.push(greenLeaf('spacing', ''));
96
+ }
97
+ });
98
+ }
99
+ else if (el.style.textAlign === 'center') kind = 'center';
100
+ else if (el.style.textAlign === 'right') kind = 'right';
101
+ else if (el.style.textAlign === 'left') kind = 'left';
102
+ else kind = 'group';
103
+ break;
104
+ case 'span':
105
+ if (el.classList.contains('spoiler')) kind = 'spoiler';
106
+ else if (el.classList.contains('aesthetic')) kind = 'aesthetic';
107
+ else if (el.style.color) { kind = 'color'; text = `=${el.style.color}`; }
108
+ else if (el.style.fontSize) { kind = 'font_size'; text = `=${el.style.fontSize.replace('%', '')}`; }
109
+ // If it's just a text wrapper generated by HTMLRenderer, we should just unwrap it
110
+ else if (el.classList.contains('bb-text')) {
111
+ // We can use a transparent group or a specialized inline node,
112
+ // but flattening it is best. We can use a temporary kind that gets flattened,
113
+ // or just use 'group' but wait, 'group' is block-like.
114
+ // Actually, if we just set it to 'text_wrapper', we can filter it out, but for now
115
+ // let's just make it a 'group' but ideally we shouldn't create a node at all.
116
+ // Wait, the simplest fix is to not create a node for bb-text and just append its children!
117
+ kind = 'bb_text_wrapper';
118
+ }
119
+ else kind = 'group';
120
+ break;
121
+ case 'iframe':
122
+ if (el.classList.contains('bb-youtube') || el.hasAttribute('data-youtube')) {
123
+ kind = 'youtube';
124
+ text = `=${el.getAttribute('data-youtube') || (el as HTMLIFrameElement).src.split('/embed/')[1]?.split('?')[0] || ''}`;
125
+ } else {
126
+ kind = 'group';
127
+ }
128
+ break;
129
+ case 'audio':
130
+ kind = 'audio';
131
+ text = `=${el.getAttribute('src') || ''}`;
132
+ break;
133
+ case 'details':
134
+ kind = el.classList.contains('box') ? 'box' : 'spoilerbox';
135
+ const summary = el.querySelector('summary');
136
+ if (summary) text = `=${summary.textContent || ''}`;
137
+ break;
138
+ case 'p':
139
+ case 'section':
140
+ kind = 'group';
141
+ break;
142
+ case 'ul':
143
+ case 'ol':
144
+ kind = 'list';
145
+ text = tag === 'ol' ? '=1' : '';
146
+ break;
147
+ case 'li':
148
+ kind = 'list_item';
149
+ break;
150
+ case 'blockquote':
151
+ kind = 'quote';
152
+ const firstElem = el.firstElementChild as HTMLElement;
153
+ if (firstElem && firstElem.tagName.toLowerCase() === 'div') {
154
+ const strong = firstElem.querySelector('strong');
155
+ if (strong && strong.textContent && strong.textContent.endsWith(' wrote:')) {
156
+ const author = strong.textContent.slice(0, -7);
157
+ text = `=${author}`;
158
+ (firstElem as any).__quasar_extracted = true;
159
+ }
160
+ }
161
+ break;
162
+ case 'pre':
163
+ kind = 'code';
164
+ // Extract raw text as a child text node (DocumentEngine expects code content in children)
165
+ const codeText = el.textContent || '';
166
+ children.push(greenLeaf('text', codeText));
167
+ currentOffset += codeText.length;
168
+ break;
169
+ case 'code':
170
+ kind = el.classList.contains('inline') ? 'inline_code' : 'group';
171
+ break;
172
+ default:
173
+ kind = 'group';
174
+ break;
175
+ }
176
+
177
+ // Convert children recursively unless it's a raw block like pre or imagemap
178
+ if (tag !== 'pre' && !(tag === 'div' && (el.classList.contains('bbcode-imagemap') || el.classList.contains('imagemap-container')))) {
179
+ for (let i = 0; i < node.childNodes.length; i++) {
180
+ const child = node.childNodes[i];
181
+ if ((child as any).__quasar_extracted) continue;
182
+ // Skip summary if we already extracted it for details
183
+ if (tag === 'details' && child.nodeName.toLowerCase() === 'summary') continue;
184
+ const parsedChild = convert(child);
185
+ if (Array.isArray(parsedChild)) {
186
+ children.push(...parsedChild);
187
+ } else {
188
+ children.push(parsedChild);
189
+ }
190
+ }
191
+ }
192
+ }
193
+
194
+ const end = currentOffset;
195
+ if (kind === 'bb_text_wrapper') {
196
+ // Just return the children array to be flattened by the parent
197
+ return children as any;
198
+ }
199
+ return greenNode(kind as string, text, children);
200
+ }
201
+
202
+ const rootChildren: GreenNode[] = [];
203
+ for (let i = 0; i < root.childNodes.length; i++) {
204
+ const parsed = convert(root.childNodes[i]);
205
+ if (Array.isArray(parsed)) {
206
+ rootChildren.push(...parsed);
207
+ } else {
208
+ rootChildren.push(parsed);
209
+ }
210
+ }
211
+
212
+ // Normalize root inline nodes into paragraphs to match BBCode Parser behavior
213
+ const normalizedRoot: GreenNode[] = [];
214
+ let currentParagraph: GreenNode[] = [];
215
+
216
+ const flushParagraph = () => {
217
+ if (currentParagraph.length > 0) {
218
+ normalizedRoot.push(greenNode('paragraph', '', currentParagraph));
219
+ currentParagraph = [];
220
+ }
221
+ };
222
+
223
+ for (const child of rootChildren) {
224
+ // If it's a completely empty whitespace text node (0 newlines), ignore it
225
+ if (child.kind === 'text' && child.text.trim() === '') {
226
+ continue;
227
+ }
228
+
229
+ if (isBlockKind(child.kind as NodeKind) || child.kind === 'empty_line') {
230
+ flushParagraph();
231
+ normalizedRoot.push(child);
232
+ } else {
233
+ currentParagraph.push(child);
234
+ }
235
+ }
236
+ flushParagraph();
237
+
238
+ return greenNode('document', '', normalizedRoot);
239
+ }
240
+
241
+ export function htmlStringToGreenTree(html: string): GreenNode {
242
+ if (typeof DOMParser === 'undefined') {
243
+ // If run on server, just wrap as text (or we could use a server-side DOM lib)
244
+ return greenLeaf('text', html);
245
+ }
246
+ const parser = new DOMParser();
247
+ const doc = parser.parseFromString(html, 'text/html');
248
+ return domToGreenTree(doc.body);
249
+ }
250
+
251
+ export function greenToRedNode(green: GreenNode, parent?: RedNode | null): RedNode {
252
+ // Extract metadata based on HTML tag footprints
253
+ let metadata: Record<string, unknown> = {};
254
+ if (green.kind === 'heading' && green.text.startsWith('=')) {
255
+ metadata = { level: parseInt(green.text.slice(1)) || 1 };
256
+ } else if (green.kind === 'url' && green.text.startsWith('=')) {
257
+ metadata = { href: green.text.slice(1) };
258
+ } else if (green.kind === 'color' && green.text.startsWith('=')) {
259
+ metadata = { color: green.text.slice(1) };
260
+ } else if (green.kind === 'font_size' && green.text.startsWith('=')) {
261
+ metadata = { size: green.text.slice(1) };
262
+ } else if (green.kind === 'quote' && green.text.startsWith('=')) {
263
+ metadata = { author: green.text.slice(1) };
264
+ } else if ((green.kind === 'box' || green.kind === 'spoilerbox') && green.text.startsWith('=')) {
265
+ metadata = { title: green.text.slice(1) };
266
+ } else if (green.kind === 'image') {
267
+ metadata = { src: green.text };
268
+ } else if ((green.kind === 'youtube' || green.kind === 'audio') && green.text.startsWith('=')) {
269
+ metadata = green.kind === 'youtube' ? { videoId: green.text.slice(1) } : { src: green.text.slice(1) };
270
+ } else if (green.kind === 'list') {
271
+ metadata = { ordered: green.text === '=1' };
272
+ }
273
+
274
+ const red = new RedNode(green, {
275
+ parent: parent ?? null,
276
+ kind: (green.kind as NodeKind) || 'text',
277
+ metadata
278
+ });
279
+
280
+ const greenChildren = green.children as GreenNode[];
281
+ if (greenChildren.length > 0) {
282
+ const kids: RedNode[] = new Array(greenChildren.length);
283
+ for (let i = 0; i < greenChildren.length; i++) {
284
+ kids[i] = greenToRedNode(greenChildren[i], red);
285
+ }
286
+ red.initChildren(kids);
287
+ }
288
+
289
+ return red;
290
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * DocumentEngine — ChangeTracker
3
+ *
4
+ * Tracks text changes made to the document source.
5
+ * Used by IncrementalParser to determine what needs to be re-parsed.
6
+ */
7
+
8
+ export interface TextChange {
9
+ start: number
10
+ end: number
11
+ text: string
12
+ }
13
+
14
+ /**
15
+ * Source range of a text edit, in BOTH coordinate systems.
16
+ *
17
+ * An edit sits between two documents: `start`/`end` describe the changed
18
+ * region in NEW-source coordinates (the current document), while `endOld` is
19
+ * the end of the replaced region in OLD-source coordinates. The incremental
20
+ * preview (`BlockPatcher`) uses `start`/`endOld` to locate the affected blocks
21
+ * in the PREVIOUS tree (whose offsets are pre-edit) and `start`/`end` in the
22
+ * new one — insertions or deletions between the two make the naive single-`end`
23
+ * wrong, which is exactly why both ends are kept.
24
+ */
25
+ export interface TextChangeRange {
26
+ /** Start of the edited region (identical in both coordinate systems). */
27
+ start: number
28
+ /** End of the edited region, in new-source coordinates. */
29
+ end: number
30
+ /** End of the replaced region, in old-source coordinates. */
31
+ endOld: number
32
+ }
33
+
34
+ export interface TextChangeStats {
35
+ totalChanges: number
36
+ totalInserted: number
37
+ totalDeleted: number
38
+ lastChange: TextChange | null
39
+ }
40
+
41
+ export class ChangeTracker {
42
+ private changes: TextChange[] = []
43
+ private maxHistory: number = 100
44
+
45
+ constructor(maxHistory?: number) {
46
+ if (maxHistory) this.maxHistory = maxHistory
47
+ }
48
+
49
+ /**
50
+ * Track a text change.
51
+ */
52
+ track(change: TextChange): void {
53
+ this.changes.push(change)
54
+ if (this.changes.length > this.maxHistory) {
55
+ this.changes.shift()
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Get all tracked changes.
61
+ */
62
+ getAll(): TextChange[] {
63
+ return [...this.changes]
64
+ }
65
+
66
+ /**
67
+ * Get the most recent change.
68
+ */
69
+ getLast(): TextChange | null {
70
+ return this.changes.length > 0 ? this.changes[this.changes.length - 1] : null
71
+ }
72
+
73
+ /**
74
+ * Get the affected range for the last N changes.
75
+ * Returns null if no changes have been tracked.
76
+ */
77
+ getAffectedRange(count: number = 1): { start: number; end: number } | null {
78
+ if (this.changes.length === 0) return null
79
+
80
+ const relevant = this.changes.slice(-count)
81
+ return {
82
+ start: Math.min(...relevant.map(c => c.start)),
83
+ end: Math.max(...relevant.map(c => c.end)),
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Clear change history.
89
+ */
90
+ clear(): void {
91
+ this.changes = []
92
+ }
93
+
94
+ /**
95
+ * Get statistics about tracked changes.
96
+ */
97
+ getStats(): TextChangeStats {
98
+ return {
99
+ totalChanges: this.changes.length,
100
+ totalInserted: this.changes.reduce((sum, c) => sum + c.text.length, 0),
101
+ totalDeleted: this.changes.reduce((sum, c) => sum + (c.end - c.start), 0),
102
+ lastChange: this.getLast(),
103
+ }
104
+ }
105
+ }