@miliastry/quasar 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +119 -0
- package/README.md +45 -0
- package/dist/Visuals/lyne.css +1027 -0
- package/dist/Visuals/osu.css +257 -0
- package/dist/index.d.mts +4563 -0
- package/dist/index.d.ts +4563 -0
- package/dist/index.js +11291 -0
- package/dist/index.mjs +11197 -0
- package/package.json +51 -0
- package/src/Analysis/Contracts/AnalysisReport.ts +26 -0
- package/src/Analysis/Contracts/Contribution.ts +76 -0
- package/src/Analysis/Contracts/Pass.ts +76 -0
- package/src/Analysis/Contracts/PipelineContext.ts +48 -0
- package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +292 -0
- package/src/Analysis/Passes/Analysis/MergeableColorAnalyzer.ts +112 -0
- package/src/Analysis/Passes/Analysis/RainbowAnalyzer.ts +233 -0
- package/src/Analysis/Passes/Analysis/WaveAnalyzer.ts +211 -0
- package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +135 -0
- package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +84 -0
- package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +99 -0
- package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +119 -0
- package/src/Analysis/Passes/Decision/DefaultDecision.ts +139 -0
- package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +179 -0
- package/src/Analysis/Passes/Transform/CollapseGradientTransform.ts +176 -0
- package/src/Analysis/Passes/Transform/MergeColorsTransform.ts +126 -0
- package/src/Analysis/Passes/Transform/RainbowCollapseTransform.ts +83 -0
- package/src/Analysis/Passes/Transform/WaveCollapseTransform.ts +88 -0
- package/src/Analysis/Passes/Utility/CharacterCountAnalyzer.ts +45 -0
- package/src/Analysis/Pipeline/Pipeline.ts +133 -0
- package/src/Analysis/Pipeline/PipelineBuilder.ts +55 -0
- package/src/Analysis/Pipeline/PipelineStage.ts +19 -0
- package/src/Analysis/Utils/color-utils.ts +132 -0
- package/src/Analysis/__tests__/Integration.test.ts +162 -0
- package/src/Analysis/__tests__/Pipeline.test.ts +133 -0
- package/src/Analysis/index.ts +52 -0
- package/src/BBCode/BBCodeDocumentModel.ts +175 -0
- package/src/BBCode/BBCodeToGreenNode.ts +755 -0
- package/src/BBCode/Parser.ts +384 -0
- package/src/BBCode/index.ts +12 -0
- package/src/Collab/positions.ts +91 -0
- package/src/Commands/Command.ts +44 -0
- package/src/Commands/CommandRegistry.ts +78 -0
- package/src/Commands/DeleteNode.ts +20 -0
- package/src/Commands/InsertText.ts +21 -0
- package/src/Commands/SplitMerge.ts +28 -0
- package/src/Commands/WrapInTag.ts +21 -0
- package/src/Commands/index.ts +6 -0
- package/src/Diff/TreeDiffer.ts +264 -0
- package/src/Diff/__tests__/TreeDiffer.test.ts +65 -0
- package/src/Diff/index.ts +2 -0
- package/src/Events/EventBus.ts +160 -0
- package/src/Events/index.ts +2 -0
- package/src/Formatter/Formatter.ts +54 -0
- package/src/Formatter/index.ts +2 -0
- package/src/HTML/HTMLDocumentModel.ts +35 -0
- package/src/HTML/HTMLToGreenNode.ts +290 -0
- package/src/Incremental/ChangeTracker.ts +105 -0
- package/src/Incremental/IncrementalParser.ts +591 -0
- package/src/Incremental/__tests__/IncrementalParser.test.ts +164 -0
- package/src/Incremental/index.ts +4 -0
- package/src/Lexer/BBCodeLexer.ts +382 -0
- package/src/Lexer/Lexer.ts +181 -0
- package/src/Lexer/index.ts +10 -0
- package/src/Linter/Linter.ts +193 -0
- package/src/Linter/index.ts +2 -0
- package/src/Markdown/MarkdownAST.ts +112 -0
- package/src/Markdown/MarkdownDocumentModel.ts +55 -0
- package/src/Markdown/MarkdownLexer.ts +203 -0
- package/src/Markdown/MarkdownParser.ts +455 -0
- package/src/Markdown/MarkdownToGreenNode.ts +153 -0
- package/src/Model/DocumentModel.ts +694 -0
- package/src/Model/NodeFactory.ts +117 -0
- package/src/Model/TagRegistry.ts +495 -0
- package/src/Model/index.ts +5 -0
- package/src/Plugins/PluginAPI.ts +119 -0
- package/src/Plugins/PluginRegistry.ts +132 -0
- package/src/Plugins/index.ts +3 -0
- package/src/Queries/QueryEngine.ts +152 -0
- package/src/Queries/index.ts +1 -0
- package/src/RenderPipeline/RenderPipeline.ts +125 -0
- package/src/RenderPipeline/RenderTree.ts +134 -0
- package/src/RenderPipeline/index.ts +4 -0
- package/src/Semantic/SemanticAnalyzer.ts +506 -0
- package/src/Semantic/index.ts +2 -0
- package/src/Symbols/SymbolTable.ts +124 -0
- package/src/Symbols/index.ts +1 -0
- package/src/Syntax/GreenNode.ts +324 -0
- package/src/Syntax/GreenNodePool.ts +269 -0
- package/src/Syntax/NodeMatcher.ts +370 -0
- package/src/Syntax/RedNode.ts +569 -0
- package/src/Syntax/RedNodeStore.ts +184 -0
- package/src/Syntax/TreeBuilder.ts +214 -0
- package/src/Syntax/__tests__/GreenNode.test.ts +33 -0
- package/src/Syntax/__tests__/RedNode.test.ts +81 -0
- package/src/Syntax/__tests__/RedNodeStore.test.ts +104 -0
- package/src/Syntax/greenEdit.ts +110 -0
- package/src/Syntax/hash.ts +30 -0
- package/src/Syntax/index.ts +12 -0
- package/src/Syntax/partition.ts +161 -0
- package/src/Syntax/preserveNodeIds.ts +201 -0
- package/src/Tests/ASTOptimizerIdempotence.test.ts +77 -0
- package/src/Tests/BlockPatcher.test.ts +437 -0
- package/src/Tests/BlockPatcherWindowed.test.ts +364 -0
- package/src/Tests/BoxDrawer.test.ts +217 -0
- package/src/Tests/BoxRichTitle.test.ts +105 -0
- package/src/Tests/Chars500kBenchmark.test.ts +151 -0
- package/src/Tests/Chars500kEdits.test.ts +321 -0
- package/src/Tests/CollabPositions.test.ts +146 -0
- package/src/Tests/CompilerPathProfiling.test.ts +186 -0
- package/src/Tests/DOMMorpher.test.ts +142 -0
- package/src/Tests/DomPatchPerf.test.ts +60 -0
- package/src/Tests/EffectSegments.snapshot.json +616 -0
- package/src/Tests/EffectSegments.test.ts +68 -0
- package/src/Tests/FindNodeAtOffset.test.ts +65 -0
- package/src/Tests/Fuzzer.test.ts +166 -0
- package/src/Tests/GreenNodePool.test.ts +153 -0
- package/src/Tests/Lexer.test.ts +238 -0
- package/src/Tests/LyneMode.test.ts +187 -0
- package/src/Tests/ModelCoherence.test.ts +180 -0
- package/src/Tests/Partition.test.ts +238 -0
- package/src/Tests/PluginTags.test.ts +150 -0
- package/src/Tests/ProblematicSection.test.ts +46 -0
- package/src/Tests/ProblematicSectionHTML.test.ts +58 -0
- package/src/Tests/RedReuse.test.ts +134 -0
- package/src/Tests/ReproDelete20k.test.ts +62 -0
- package/src/Tests/SemanticValidators.test.ts +136 -0
- package/src/Tests/StableNodeIds.test.ts +210 -0
- package/src/Tests/StudioColorBloat.test.ts +25 -0
- package/src/Tests/StudioDebugText.test.ts +27 -0
- package/src/Tests/StudioTrailingChar.test.ts +25 -0
- package/src/Tests/StudioValidText.test.ts +25 -0
- package/src/Tests/UrlImgBug.test.ts +23 -0
- package/src/Tests/VisualBuilderFidelity.test.ts +105 -0
- package/src/Tests/referenceDocument.ts +119 -0
- package/src/Transactions/Transaction.ts +176 -0
- package/src/Transactions/UndoManager.ts +111 -0
- package/src/Transactions/index.ts +3 -0
- package/src/Transformers/ASTOptimizer.ts +315 -0
- package/src/Transformers/GradientTransformer.ts +143 -0
- package/src/Transformers/GrowTransformer.ts +115 -0
- package/src/Transformers/RainbowTransformer.ts +121 -0
- package/src/Transformers/SineWaveTransformer.ts +130 -0
- package/src/Transformers/Transformer.ts +22 -0
- package/src/Types/core.ts +270 -0
- package/src/Types/diagnostics.ts +156 -0
- package/src/Types/index.ts +23 -0
- package/src/Types/operations.ts +180 -0
- package/src/Types/queries.ts +121 -0
- package/src/Types/symbols.ts +69 -0
- package/src/Types/tokens.ts +186 -0
- package/src/Utils/BBCodeGenerator.ts +126 -0
- package/src/Utils/ColorMath.ts +276 -0
- package/src/Utils/color.ts +112 -0
- package/src/Utils/dom-to-svg.test.ts +86 -0
- package/src/Utils/dom-to-svg.ts +615 -0
- package/src/Utils/treeTransformers.ts +717 -0
- package/src/Visitors/BBBlocksExporter.ts +69 -0
- package/src/Visitors/BBCodeExporter.ts +318 -0
- package/src/Visitors/BlockPatcher.ts +963 -0
- package/src/Visitors/DOMMorpher.ts +134 -0
- package/src/Visitors/HTMLRenderer.ts +1077 -0
- package/src/Visitors/JSONExporter.ts +66 -0
- package/src/Visitors/MarkdownExporter.ts +99 -0
- package/src/Visitors/SVGRenderer.ts +35 -0
- package/src/Visitors/TiptapExporter.ts +145 -0
- package/src/Visitors/Visitor.ts +48 -0
- package/src/Visitors/index.ts +9 -0
- package/src/Visuals/BoxDrawer.ts +175 -0
- package/src/Visuals/index.ts +42 -0
- package/src/Visuals/lyne.css +1027 -0
- package/src/Visuals/osu.css +257 -0
- package/src/index.ts +197 -0
|
@@ -0,0 +1,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,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,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,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
|
+
}
|