@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,21 @@
1
+ /**
2
+ * DocumentEngine — WrapInTag Command
3
+ *
4
+ * Wraps the current selection in a BBCode tag.
5
+ * E.g. selected text → [b]selected text[/b]
6
+ */
7
+
8
+ import type { Command, CommandContext, CommandResult } from './Command'
9
+
10
+ export const WrapInTag: Command = {
11
+ id: 'wrap-in-tag',
12
+ label: 'Wrap in Tag',
13
+ description: 'Wrap the current selection in a BBCode tag',
14
+
15
+ execute(ctx: CommandContext): CommandResult {
16
+ return {
17
+ success: true,
18
+ message: 'Wrapped in tag',
19
+ }
20
+ },
21
+ }
@@ -0,0 +1,6 @@
1
+ export { CommandRegistry } from './CommandRegistry'
2
+ export type { Command, CommandContext, CommandResult } from './Command'
3
+ export { InsertText } from './InsertText'
4
+ export { DeleteNode } from './DeleteNode'
5
+ export { WrapInTag } from './WrapInTag'
6
+ export { SplitNode, MergeNode } from './SplitMerge'
@@ -0,0 +1,264 @@
1
+ /**
2
+ * DocumentEngine — TreeDiffer
3
+ *
4
+ * Computes structural differences between two Red Trees.
5
+ * Produces a list of DiffOperations that can be used for:
6
+ * - Updating the UI with minimal DOM changes
7
+ * - Syncing with collaboration servers
8
+ * - Animation interpolation
9
+ * - Undo/redo granularity
10
+ *
11
+ * Inspired by React's reconciliation and ProseMirror's diff.
12
+ */
13
+
14
+ import { RedNode } from '../Syntax/RedNode'
15
+
16
+ // ─── Diff Kinds ────────────────────────────────────────────────
17
+
18
+ export type DiffKind =
19
+ | 'insert'
20
+ | 'delete'
21
+ | 'update'
22
+ | 'move'
23
+ | 'preserve'
24
+
25
+ export interface DiffOperation {
26
+ kind: DiffKind
27
+ node: RedNode
28
+ oldNode?: RedNode
29
+ /** Index in parent */
30
+ oldIndex?: number
31
+ newIndex?: number
32
+ /** Parent node ID */
33
+ parentId?: string
34
+ }
35
+
36
+ export interface DiffResult {
37
+ operations: DiffOperation[]
38
+ /** Count by kind */
39
+ stats: {
40
+ inserts: number
41
+ deletes: number
42
+ updates: number
43
+ moves: number
44
+ preserves: number
45
+ }
46
+ /** Time taken in ms */
47
+ duration: number
48
+ }
49
+
50
+ // ─── TreeDiffer ────────────────────────────────────────────────
51
+
52
+ export class TreeDiffer {
53
+ /**
54
+ * Compute the diff between two Red Trees.
55
+ */
56
+ diff(oldRoot: RedNode, newRoot: RedNode): DiffResult {
57
+ const startTime = performance.now()
58
+ const operations: DiffOperation[] = []
59
+ const stats = { inserts: 0, deletes: 0, updates: 0, moves: 0, preserves: 0 }
60
+
61
+ this.diffChildren(
62
+ oldRoot.children,
63
+ newRoot.children,
64
+ oldRoot,
65
+ operations,
66
+ stats,
67
+ )
68
+
69
+ return {
70
+ operations,
71
+ stats,
72
+ duration: performance.now() - startTime,
73
+ }
74
+ }
75
+
76
+ private diffChildren(
77
+ oldChildren: RedNode[],
78
+ newChildren: RedNode[],
79
+ parent: RedNode,
80
+ ops: DiffOperation[],
81
+ stats: { inserts: number; deletes: number; updates: number; moves: number; preserves: number },
82
+ ): void {
83
+ // Build maps for quick lookup
84
+ const oldMap = new Map<string, RedNode>()
85
+ const oldIndexMap = new Map<string, number>()
86
+ oldChildren.forEach((child, i) => {
87
+ oldMap.set(child.id, child)
88
+ oldIndexMap.set(child.id, i)
89
+ })
90
+
91
+ const newMap = new Map<string, RedNode>()
92
+ newChildren.forEach((child, i) => {
93
+ newMap.set(child.id, child)
94
+ })
95
+
96
+ // Find deleted nodes (in old but not in new)
97
+ const deletedNodes = new Map<string, RedNode>()
98
+ for (const [id, node] of oldMap) {
99
+ if (!newMap.has(id)) {
100
+ deletedNodes.set(id, node)
101
+ }
102
+ }
103
+
104
+ const unmatchedNewNodes: { newChild: RedNode, newIdx: number }[] = []
105
+
106
+ // Process nodes that kept their ID
107
+ newChildren.forEach((newChild, newIdx) => {
108
+ const oldChild = oldMap.get(newChild.id)
109
+
110
+ if (!oldChild) {
111
+ unmatchedNewNodes.push({ newChild, newIdx })
112
+ } else {
113
+ const oldIdx = oldIndexMap.get(newChild.id)!
114
+
115
+ if (oldIdx !== newIdx) {
116
+ // Node moved
117
+ // Check content for update
118
+ const contentChanged = this.nodeContentChanged(oldChild, newChild)
119
+ if (contentChanged) {
120
+ ops.push({
121
+ kind: 'update',
122
+ node: newChild,
123
+ oldNode: oldChild,
124
+ oldIndex: oldIdx,
125
+ newIndex: newIdx,
126
+ parentId: parent.id,
127
+ })
128
+ stats.updates++
129
+ } else {
130
+ ops.push({
131
+ kind: 'move',
132
+ node: newChild,
133
+ oldNode: oldChild,
134
+ oldIndex: oldIdx,
135
+ newIndex: newIdx,
136
+ parentId: parent.id,
137
+ })
138
+ stats.moves++
139
+ }
140
+ } else {
141
+ // Same position, check content
142
+ const contentChanged = this.nodeContentChanged(oldChild, newChild)
143
+ if (contentChanged) {
144
+ ops.push({
145
+ kind: 'update',
146
+ node: newChild,
147
+ oldNode: oldChild,
148
+ oldIndex: oldIdx,
149
+ newIndex: newIdx,
150
+ parentId: parent.id,
151
+ })
152
+ stats.updates++
153
+ } else {
154
+ ops.push({
155
+ kind: 'preserve',
156
+ node: newChild,
157
+ oldNode: oldChild,
158
+ oldIndex: oldIdx,
159
+ newIndex: newIdx,
160
+ parentId: parent.id,
161
+ })
162
+ stats.preserves++
163
+ }
164
+
165
+ // Recurse into children
166
+ if (oldChild.children.length > 0 || newChild.children.length > 0) {
167
+ this.diffChildren(oldChild.children, newChild.children, newChild, ops, stats)
168
+ }
169
+ }
170
+ }
171
+ })
172
+
173
+ // Attempt to pair unmatched inserts and deletes based on content
174
+ const finalDeletes = new Set<string>(deletedNodes.keys())
175
+
176
+ for (const { newChild, newIdx } of unmatchedNewNodes) {
177
+ let matchedOldNode: RedNode | null = null
178
+ let matchedOldId: string | null = null
179
+
180
+ for (const [deletedId, deletedNode] of deletedNodes.entries()) {
181
+ if (finalDeletes.has(deletedId) && this.nodesAreEquivalent(deletedNode, newChild)) {
182
+ matchedOldNode = deletedNode
183
+ matchedOldId = deletedId
184
+ break
185
+ }
186
+ }
187
+
188
+ if (matchedOldNode && matchedOldId) {
189
+ finalDeletes.delete(matchedOldId)
190
+ const oldIdx = oldIndexMap.get(matchedOldId)!
191
+ if (oldIdx !== newIdx) {
192
+ ops.push({
193
+ kind: 'move',
194
+ node: newChild,
195
+ oldNode: matchedOldNode,
196
+ oldIndex: oldIdx,
197
+ newIndex: newIdx,
198
+ parentId: parent.id,
199
+ })
200
+ stats.moves++
201
+ } else {
202
+ ops.push({
203
+ kind: 'update', // Treat as update since ID changed
204
+ node: newChild,
205
+ oldNode: matchedOldNode,
206
+ oldIndex: oldIdx,
207
+ newIndex: newIdx,
208
+ parentId: parent.id,
209
+ })
210
+ stats.updates++
211
+ }
212
+
213
+ // Recurse into children (though they are equivalent, we might need to emit preserves)
214
+ if (matchedOldNode.children.length > 0 || newChild.children.length > 0) {
215
+ this.diffChildren(matchedOldNode.children, newChild.children, newChild, ops, stats)
216
+ }
217
+ } else {
218
+ ops.push({
219
+ kind: 'insert',
220
+ node: newChild,
221
+ newIndex: newIdx,
222
+ parentId: parent.id,
223
+ })
224
+ stats.inserts++
225
+ }
226
+ }
227
+
228
+ // Unmatched deletes remain deletes
229
+ for (const deletedId of finalDeletes) {
230
+ ops.push({
231
+ kind: 'delete',
232
+ node: deletedNodes.get(deletedId)!,
233
+ oldIndex: oldIndexMap.get(deletedId),
234
+ parentId: parent.id,
235
+ })
236
+ stats.deletes++
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Check if a node's content has changed.
242
+ */
243
+ private nodeContentChanged(oldNode: RedNode, newNode: RedNode): boolean {
244
+ if (oldNode.kind !== newNode.kind) return true
245
+ if (oldNode.text !== newNode.text) return true
246
+ if (oldNode.children.length !== newNode.children.length) return true
247
+ return false
248
+ }
249
+
250
+ /**
251
+ * Deep equality check for content equivalence when pairing unmatched nodes.
252
+ */
253
+ private nodesAreEquivalent(oldNode: RedNode, newNode: RedNode): boolean {
254
+ if (oldNode.kind !== newNode.kind) return false
255
+ if (oldNode.text !== newNode.text) return false
256
+ if (oldNode.children.length !== newNode.children.length) return false
257
+ for (let i = 0; i < oldNode.children.length; i++) {
258
+ if (!this.nodesAreEquivalent(oldNode.children[i], newNode.children[i])) {
259
+ return false
260
+ }
261
+ }
262
+ return true
263
+ }
264
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { TreeDiffer } from '../TreeDiffer'
3
+ import { RedNode } from '../../Syntax/RedNode'
4
+ import { GreenNode } from '../../Syntax/GreenNode'
5
+ import type { NodeId } from '../../Types/core'
6
+
7
+ /** Fixed, readable NodeIds. `NodeId` is branded, so literals need the cast. */
8
+ const nid = (s: string) => s as unknown as NodeId
9
+
10
+ describe('TreeDiffer', () => {
11
+ it('should detect moves/updates by content when IDs differ', () => {
12
+ // Create old tree
13
+ const oldChild = new RedNode(new GreenNode('paragraph', 'hello', [], 0, 0, 5), { id: nid('old-id'), kind: 'paragraph' })
14
+ const oldRoot = new RedNode(new GreenNode('document', '', [], 0, 0, 5), { id: nid('root'), kind: 'document' })
15
+ oldRoot.children.push(oldChild)
16
+ oldChild.parent = oldRoot
17
+
18
+ // Create new tree with different ID but same content
19
+ const newChild = new RedNode(new GreenNode('paragraph', 'hello', [], 0, 0, 5), { id: nid('new-id'), kind: 'paragraph' })
20
+ const newRoot = new RedNode(new GreenNode('document', '', [], 0, 0, 5), { id: nid('root'), kind: 'document' })
21
+ newRoot.children.push(newChild)
22
+ newChild.parent = newRoot
23
+
24
+ const differ = new TreeDiffer()
25
+ const result = differ.diff(oldRoot, newRoot)
26
+
27
+ // Should detect as 1 preserve/update since index is same, not a delete and insert
28
+ // Because the position didn't change it's treated as update when IDs change but content is same.
29
+ expect(result.stats.inserts).toBe(0)
30
+ expect(result.stats.deletes).toBe(0)
31
+ expect(result.stats.updates).toBe(1)
32
+
33
+ expect(result.operations[0].kind).toBe('update')
34
+ expect(result.operations[0].node.id).toBe(nid('new-id'))
35
+ expect(result.operations[0].oldNode?.id).toBe(nid('old-id'))
36
+ })
37
+
38
+ it('should detect moves when content matches and index changes', () => {
39
+ const oldChild1 = new RedNode(new GreenNode('paragraph', 'a', [], 0, 0, 1), { id: nid('id1'), kind: 'paragraph' })
40
+ const oldChild2 = new RedNode(new GreenNode('paragraph', 'b', [], 0, 0, 1), { id: nid('id2'), kind: 'paragraph' })
41
+ const oldRoot = new RedNode(new GreenNode('document', '', [], 0, 0, 2), { id: nid('root'), kind: 'document' })
42
+ oldRoot.children.push(oldChild1, oldChild2)
43
+ oldChild1.parent = oldRoot
44
+ oldChild2.parent = oldRoot
45
+
46
+ const newChild1 = new RedNode(new GreenNode('paragraph', 'a', [], 0, 0, 1), { id: nid('id1'), kind: 'paragraph' }) // same id
47
+ const newChild2 = new RedNode(new GreenNode('paragraph', 'b', [], 0, 0, 1), { id: nid('id3'), kind: 'paragraph' }) // different id, same content
48
+ const newRoot = new RedNode(new GreenNode('document', '', [], 0, 0, 2), { id: nid('root'), kind: 'document' })
49
+ // Swap order to trigger move
50
+ newRoot.children.push(newChild2, newChild1)
51
+ newChild1.parent = newRoot
52
+ newChild2.parent = newRoot
53
+
54
+ const differ = new TreeDiffer()
55
+ const result = differ.diff(oldRoot, newRoot)
56
+
57
+ // id2 moved to index 0 (and changed id to id3), id1 moved to index 1
58
+ expect(result.stats.inserts).toBe(0)
59
+ expect(result.stats.deletes).toBe(0)
60
+
61
+ // We should have ops for moves/updates
62
+ const hasMoveOrUpdateId3 = result.operations.some(op => op.node.id === nid('id3') && (op.kind === 'move' || op.kind === 'update'))
63
+ expect(hasMoveOrUpdateId3).toBe(true)
64
+ })
65
+ })
@@ -0,0 +1,2 @@
1
+ export { TreeDiffer } from './TreeDiffer'
2
+ export type { DiffOperation, DiffKind, DiffResult } from './TreeDiffer'
@@ -0,0 +1,160 @@
1
+ /**
2
+ * DocumentEngine — EventBus
3
+ *
4
+ * Publish/subscribe event system for document changes.
5
+ * Each DocumentModel has its own EventBus instance.
6
+ *
7
+ * Events include:
8
+ * - document_changed (rebuild, edit, undo/redo)
9
+ * - node_changed (insert, delete, update, move)
10
+ * - diagnostic_updated
11
+ * - transaction_applied
12
+ * - cursor_moved
13
+ * - selection_changed
14
+ */
15
+
16
+ import type { DocumentChangeKind, NodeId } from '../Types/core'
17
+ import type { Operation } from '../Types/operations'
18
+ import type { NodeMatch } from '../Syntax/NodeMatcher'
19
+ import type { TextChange } from '../Incremental/ChangeTracker'
20
+
21
+ export type DocumentEventType =
22
+ | 'document_changed'
23
+ | 'node_inserted'
24
+ | 'node_deleted'
25
+ | 'node_updated'
26
+ | 'diagnostics_updated'
27
+ | 'transaction_applied'
28
+ | 'undo_performed'
29
+ | 'redo_performed'
30
+ | 'cursor_moved'
31
+ | 'selection_changed'
32
+
33
+ export interface DocumentEvent {
34
+ type: DocumentEventType
35
+ kind?: DocumentChangeKind
36
+ version?: number
37
+ source?: string
38
+ nodeId?: NodeId
39
+ nodeMatch?: NodeMatch | null
40
+ change?: TextChange
41
+ operations?: Operation[]
42
+ /**
43
+ * Where the change came from: `'local'` for user editing, anything else for
44
+ * programmatic/synced sources. Lets a collaboration layer ignore the echo
45
+ * of changes it applied itself. Absent on events with no single cause.
46
+ */
47
+ origin?: string
48
+ timestamp: number
49
+ [key: string]: unknown
50
+ }
51
+
52
+ export type DocumentEventHandler = (event: DocumentEvent) => void
53
+
54
+ export class DocumentEventBus {
55
+ private listeners: Map<DocumentEventType, Set<DocumentEventHandler>> = new Map()
56
+ private history: DocumentEvent[] = []
57
+ private maxHistory: number = 50
58
+
59
+ /**
60
+ * Record emitted events so `getHistory()` returns them. Off by default:
61
+ * nothing reads the history today, and a retained event pins its `source`
62
+ * string — and, through the lazy `nodeMatch` accessor, the entire previous
63
+ * red/green tree — so an always-on history held megabytes per document
64
+ * for nobody. Turn it on for debugging or replay tooling.
65
+ */
66
+ recordHistory: boolean = false
67
+
68
+ /**
69
+ * Subscribe to an event type.
70
+ */
71
+ on(type: DocumentEventType, handler: DocumentEventHandler): () => void {
72
+ if (!this.listeners.has(type)) {
73
+ this.listeners.set(type, new Set())
74
+ }
75
+ this.listeners.get(type)!.add(handler)
76
+
77
+ // Return unsubscribe function
78
+ return () => {
79
+ this.listeners.get(type)?.delete(handler)
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Whether any handler is subscribed to `type` (or to anything, if omitted).
85
+ *
86
+ * Emitters use this to skip building event payloads nobody will see — the
87
+ * common case, since a headless model has no subscribers at all.
88
+ */
89
+ hasListeners(type?: DocumentEventType): boolean {
90
+ if (type !== undefined) {
91
+ return (this.listeners.get(type)?.size ?? 0) > 0
92
+ }
93
+ for (const handlers of this.listeners.values()) {
94
+ if (handlers.size > 0) return true
95
+ }
96
+ return false
97
+ }
98
+
99
+ /**
100
+ * Subscribe to all events.
101
+ */
102
+ onAny(handler: DocumentEventHandler): () => void {
103
+ const unsubscribers: (() => void)[] = []
104
+ const types: DocumentEventType[] = [
105
+ 'document_changed', 'node_inserted', 'node_deleted', 'node_updated',
106
+ 'diagnostics_updated', 'transaction_applied', 'undo_performed', 'redo_performed',
107
+ 'cursor_moved', 'selection_changed',
108
+ ]
109
+ for (const type of types) {
110
+ unsubscribers.push(this.on(type, handler))
111
+ }
112
+ return () => unsubscribers.forEach(u => u())
113
+ }
114
+
115
+ /**
116
+ * Emit an event.
117
+ */
118
+ emit(event: DocumentEvent): void {
119
+ const handlers = this.listeners.get(event.type)
120
+ if (handlers) {
121
+ for (const handler of handlers) {
122
+ try {
123
+ handler(event)
124
+ } catch (error) {
125
+ console.warn(`[EventBus] Handler error for ${event.type}:`, error)
126
+ }
127
+ }
128
+ }
129
+
130
+ // Store in history — only when someone opted in (see `recordHistory`).
131
+ if (this.recordHistory) {
132
+ this.history.push(event)
133
+ if (this.history.length > this.maxHistory) {
134
+ this.history.shift()
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Remove a specific handler.
141
+ */
142
+ off(type: DocumentEventType, handler: DocumentEventHandler): void {
143
+ this.listeners.get(type)?.delete(handler)
144
+ }
145
+
146
+ /**
147
+ * Get event history.
148
+ */
149
+ getHistory(): DocumentEvent[] {
150
+ return [...this.history]
151
+ }
152
+
153
+ /**
154
+ * Clear all listeners and history.
155
+ */
156
+ clear(): void {
157
+ this.listeners.clear()
158
+ this.history = []
159
+ }
160
+ }
@@ -0,0 +1,2 @@
1
+ export { DocumentEventBus } from './EventBus'
2
+ export type { DocumentEvent, DocumentEventType, DocumentEventHandler } from './EventBus'
@@ -0,0 +1,54 @@
1
+ /**
2
+ * DocumentEngine — Formatter
3
+ *
4
+ * Formats the document tree into "pretty" BBCode.
5
+ * Normalizes indentation, spacing, and whitespace.
6
+ *
7
+ * Uses the TagRegistry and Visitor pattern.
8
+ * Plugins can register custom formatters for custom tags.
9
+ */
10
+
11
+ import { RedNode } from '../Syntax/RedNode'
12
+ import { BBCodeExporter } from '../Visitors/BBCodeExporter'
13
+ import type { TagRegistry } from '../Model/TagRegistry'
14
+
15
+ export interface FormatOptions {
16
+ /** Whether to add newlines between block elements */
17
+ blockNewlines?: boolean
18
+ /** Whether to normalize whitespace inside inline elements */
19
+ normalizeInlineWhitespace?: boolean
20
+ /** Maximum line width (0 = no limit) */
21
+ maxLineWidth?: number
22
+ /** Whether to collapse consecutive empty lines */
23
+ collapseEmptyLines?: boolean
24
+ /** Indentation string */
25
+ indent?: string
26
+ }
27
+
28
+ export class Formatter {
29
+ /**
30
+ * Format a RedNode tree (extracts text while preserving exact layout/spacing).
31
+ */
32
+ format(root: RedNode): string {
33
+ return this.formatNode(root)
34
+ }
35
+
36
+ /**
37
+ * Format a single node at a given depth.
38
+ */
39
+ private formatNode(node: RedNode): string {
40
+ if (node.kind === 'text') {
41
+ return node.text
42
+ }
43
+
44
+ if (node.kind === 'spacing' || node.kind === 'empty_line') {
45
+ return '\n'
46
+ }
47
+
48
+ if (node.children) {
49
+ return node.children.map(c => this.formatNode(c)).join('')
50
+ }
51
+
52
+ return ''
53
+ }
54
+ }
@@ -0,0 +1,2 @@
1
+ export { Formatter } from './Formatter'
2
+ export type { FormatOptions } from './Formatter'
@@ -0,0 +1,35 @@
1
+ import { DocumentModel, type DocumentModelOptions } from '../Model/DocumentModel';
2
+ import { GreenNode, greenLeaf } from '../Syntax/GreenNode';
3
+ import { RedNode } from '../Syntax/RedNode';
4
+ import { htmlStringToGreenTree, greenToRedNode } from './HTMLToGreenNode';
5
+
6
+ export interface HTMLDocumentModelOptions extends DocumentModelOptions {
7
+ source?: string;
8
+ language?: string;
9
+ }
10
+
11
+ export class HTMLDocumentModel extends DocumentModel {
12
+ constructor(options: HTMLDocumentModelOptions = {}) {
13
+ super({ source: '', language: options.language ?? 'html' });
14
+ if (options.source) {
15
+ this.rebuild(options.source);
16
+ }
17
+ }
18
+
19
+ static fromHTML(source: string): HTMLDocumentModel {
20
+ return new HTMLDocumentModel({ source });
21
+ }
22
+
23
+ protected parseToGreen(source: string): GreenNode {
24
+ try {
25
+ return htmlStringToGreenTree(source);
26
+ } catch (error) {
27
+ console.warn('[HTMLDocumentModel] Parse error:', error);
28
+ return greenLeaf('text', source);
29
+ }
30
+ }
31
+
32
+ protected buildRedFromGreen(green: GreenNode): RedNode {
33
+ return greenToRedNode(green);
34
+ }
35
+ }