@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,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — Transaction
|
|
3
|
+
*
|
|
4
|
+
* Groups operations into an atomic, undoable unit.
|
|
5
|
+
* Every modification to the document MUST go through a Transaction.
|
|
6
|
+
*
|
|
7
|
+
* Inspired by ProseMirror's Transaction system.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { RedNode } from '../Syntax/RedNode'
|
|
11
|
+
import { GreenNode } from '../Syntax/GreenNode'
|
|
12
|
+
import type { Operation } from '../Types/operations'
|
|
13
|
+
import { createOperationId } from '../Types/operations'
|
|
14
|
+
|
|
15
|
+
export class Transaction {
|
|
16
|
+
readonly operations: Operation[]
|
|
17
|
+
readonly id: string
|
|
18
|
+
readonly timestamp: number
|
|
19
|
+
private _applied: boolean = false
|
|
20
|
+
|
|
21
|
+
constructor(operations: Operation[]) {
|
|
22
|
+
this.operations = operations.map(op => ({
|
|
23
|
+
...op,
|
|
24
|
+
id: op.id || createOperationId(),
|
|
25
|
+
timestamp: Date.now(),
|
|
26
|
+
}))
|
|
27
|
+
this.id = createOperationId()
|
|
28
|
+
this.timestamp = Date.now()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Apply the transaction to a RedNode tree.
|
|
33
|
+
* Returns the updated tree or null if application failed.
|
|
34
|
+
*/
|
|
35
|
+
apply(root: RedNode): RedNode | null {
|
|
36
|
+
if (this._applied) return null
|
|
37
|
+
this._applied = true
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
RedNode.allowMutation(() => {
|
|
41
|
+
for (const op of this.operations) {
|
|
42
|
+
this.applyOperation(root, op)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
return root
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.warn('[Transaction] Apply failed:', error)
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create the inverse transaction (for undo).
|
|
54
|
+
*/
|
|
55
|
+
invert(root: RedNode): Transaction {
|
|
56
|
+
const invertedOps: Operation[] = [...this.operations].reverse().map(op => {
|
|
57
|
+
if (op.kind === 'replace_node') {
|
|
58
|
+
return {
|
|
59
|
+
...op,
|
|
60
|
+
newNode: op.oldNode,
|
|
61
|
+
oldNode: op.newNode
|
|
62
|
+
} as Operation
|
|
63
|
+
}
|
|
64
|
+
return op
|
|
65
|
+
})
|
|
66
|
+
return new Transaction(invertedOps)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get a human-readable label for this transaction.
|
|
71
|
+
*/
|
|
72
|
+
getLabel(): string {
|
|
73
|
+
if (this.operations.length === 0) return 'Empty transaction'
|
|
74
|
+
if (this.operations.length === 1) {
|
|
75
|
+
const op = this.operations[0]
|
|
76
|
+
const labels: Record<string, string> = {
|
|
77
|
+
insert_node: 'Insert',
|
|
78
|
+
delete_node: 'Delete',
|
|
79
|
+
replace_node: 'Replace',
|
|
80
|
+
move_node: 'Move',
|
|
81
|
+
update_attributes: 'Update attributes',
|
|
82
|
+
set_text: 'Edit text',
|
|
83
|
+
insert_text: 'Type',
|
|
84
|
+
delete_text: 'Delete',
|
|
85
|
+
replace_text: 'Replace text',
|
|
86
|
+
wrap_in_tag: 'Wrap in tag',
|
|
87
|
+
unwrap_node: 'Unwrap',
|
|
88
|
+
split_node: 'Split',
|
|
89
|
+
merge_nodes: 'Merge',
|
|
90
|
+
}
|
|
91
|
+
return labels[op.kind] || 'Edit'
|
|
92
|
+
}
|
|
93
|
+
return `${this.operations.length} operations`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private applyOperation(root: RedNode, operation: Operation): void {
|
|
97
|
+
switch (operation.kind) {
|
|
98
|
+
case 'insert_node':
|
|
99
|
+
this.applyInsert(root, operation)
|
|
100
|
+
break
|
|
101
|
+
case 'delete_node':
|
|
102
|
+
this.applyDelete(root, operation)
|
|
103
|
+
break
|
|
104
|
+
case 'replace_node':
|
|
105
|
+
this.applyReplace(root, operation)
|
|
106
|
+
break
|
|
107
|
+
case 'move_node':
|
|
108
|
+
this.applyMove(root, operation)
|
|
109
|
+
break
|
|
110
|
+
case 'update_attributes':
|
|
111
|
+
// Attributes are stored in metadata; update in place
|
|
112
|
+
break
|
|
113
|
+
case 'set_text':
|
|
114
|
+
this.applySetText(root, operation)
|
|
115
|
+
break
|
|
116
|
+
default:
|
|
117
|
+
break
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private applyInsert(root: RedNode, op: Operation & { kind: 'insert_node' }): void {
|
|
122
|
+
const parent = root.findById(op.parentId)
|
|
123
|
+
if (!parent) return
|
|
124
|
+
const childNode = new RedNode(
|
|
125
|
+
new GreenNode(op.node.kind, op.node.text),
|
|
126
|
+
{ kind: op.node.kind },
|
|
127
|
+
)
|
|
128
|
+
parent.insertChildAt(Math.min(op.index, parent.children.length), childNode)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private applyDelete(root: RedNode, op: Operation & { kind: 'delete_node' }): void {
|
|
132
|
+
const parent = root.findById(op.parentId)
|
|
133
|
+
if (!parent) return
|
|
134
|
+
parent.removeChild(op.nodeId)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private applyReplace(root: RedNode, op: Operation & { kind: 'replace_node' }): void {
|
|
138
|
+
const newNode = op.newNode as RedNode
|
|
139
|
+
|
|
140
|
+
if (op.nodeId === root.id) {
|
|
141
|
+
// Cannot replace the root instance itself because the model holds a reference to it.
|
|
142
|
+
// Instead, we clear its children and append the children of the replacement node.
|
|
143
|
+
while (root.children.length > 0) {
|
|
144
|
+
root.removeChild(root.children[0].id)
|
|
145
|
+
}
|
|
146
|
+
for (const child of newNode.children) {
|
|
147
|
+
root.appendChild(child)
|
|
148
|
+
}
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const parent = root.findById(op.nodeId)?.parent
|
|
153
|
+
if (!parent) return
|
|
154
|
+
|
|
155
|
+
parent.replaceChild(op.nodeId, newNode)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private applyMove(root: RedNode, op: Operation & { kind: 'move_node' }): void {
|
|
159
|
+
const fromParent = root.findById(op.fromParentId)
|
|
160
|
+
const toParent = root.findById(op.toParentId)
|
|
161
|
+
if (!fromParent || !toParent) return
|
|
162
|
+
|
|
163
|
+
const node = fromParent.removeChild(op.nodeId)
|
|
164
|
+
if (node) {
|
|
165
|
+
toParent.insertChildAt(Math.min(op.toIndex, toParent.children.length), node)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private applySetText(root: RedNode, op: Operation & { kind: 'set_text' }): void {
|
|
170
|
+
const node = root.findById(op.nodeId)
|
|
171
|
+
if (!node) return
|
|
172
|
+
// Text is stored in GreenNode which is immutable.
|
|
173
|
+
// For mutable text, we should use the RedNode's text or metadata.
|
|
174
|
+
node.bumpVersion()
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — UndoManager
|
|
3
|
+
*
|
|
4
|
+
* Manages the undo/redo history stack.
|
|
5
|
+
*
|
|
6
|
+
* Entries are source-text snapshots, not stored tree mutations: the model
|
|
7
|
+
* rebuilds from text on undo/redo, so an entry stays replayable even after
|
|
8
|
+
* intervening rebuilds have replaced every node id it could have referenced.
|
|
9
|
+
* (The previous design stored `Transaction`s, whose `invert()` was only
|
|
10
|
+
* defined for one of the six operation kinds and whose one-shot `_applied`
|
|
11
|
+
* flag made redo a silent no-op.)
|
|
12
|
+
*
|
|
13
|
+
* Inspired by VSCode's undo stack.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface UndoEntry {
|
|
17
|
+
/** Source text before the change */
|
|
18
|
+
before: string
|
|
19
|
+
/** Source text after the change */
|
|
20
|
+
after: string
|
|
21
|
+
label: string
|
|
22
|
+
timestamp: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class UndoManager {
|
|
26
|
+
private undoStack: UndoEntry[] = []
|
|
27
|
+
private redoStack: UndoEntry[] = []
|
|
28
|
+
private maxSize: number
|
|
29
|
+
|
|
30
|
+
constructor(maxSize: number = 50) {
|
|
31
|
+
this.maxSize = maxSize
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Push a change (as before/after source snapshots) onto the undo stack.
|
|
36
|
+
*/
|
|
37
|
+
push(snapshot: { before: string; after: string }, label?: string): void {
|
|
38
|
+
this.undoStack.push({
|
|
39
|
+
before: snapshot.before,
|
|
40
|
+
after: snapshot.after,
|
|
41
|
+
label: label ?? 'Edit',
|
|
42
|
+
timestamp: Date.now(),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// Clear redo stack on new action
|
|
46
|
+
this.redoStack = []
|
|
47
|
+
|
|
48
|
+
// Enforce stack size limit
|
|
49
|
+
if (this.undoStack.length > this.maxSize) {
|
|
50
|
+
this.undoStack.shift()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Undo the last change.
|
|
56
|
+
*/
|
|
57
|
+
undo(): UndoEntry | null {
|
|
58
|
+
const entry = this.undoStack.pop()
|
|
59
|
+
if (!entry) return null
|
|
60
|
+
|
|
61
|
+
this.redoStack.push(entry)
|
|
62
|
+
return entry
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Redo the last undone change.
|
|
67
|
+
*/
|
|
68
|
+
redo(): UndoEntry | null {
|
|
69
|
+
const entry = this.redoStack.pop()
|
|
70
|
+
if (!entry) return null
|
|
71
|
+
|
|
72
|
+
this.undoStack.push(entry)
|
|
73
|
+
return entry
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Peek at the top of the undo stack without removing.
|
|
78
|
+
*/
|
|
79
|
+
peekUndo(): UndoEntry | null {
|
|
80
|
+
return this.undoStack[this.undoStack.length - 1] ?? null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Peek at the top of the redo stack without removing.
|
|
85
|
+
*/
|
|
86
|
+
peekRedo(): UndoEntry | null {
|
|
87
|
+
return this.redoStack[this.redoStack.length - 1] ?? null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Clear both stacks.
|
|
92
|
+
*/
|
|
93
|
+
clear(): void {
|
|
94
|
+
this.undoStack = []
|
|
95
|
+
this.redoStack = []
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get the number of undoable actions.
|
|
100
|
+
*/
|
|
101
|
+
get undoCount(): number {
|
|
102
|
+
return this.undoStack.length
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get the number of redoable actions.
|
|
107
|
+
*/
|
|
108
|
+
get redoCount(): number {
|
|
109
|
+
return this.redoStack.length
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { DocumentModel } from '../Model/DocumentModel'
|
|
2
|
+
import { RedNode } from '../Syntax/RedNode'
|
|
3
|
+
import { greenNode } from '../Syntax/GreenNode'
|
|
4
|
+
import { Transformer, TransformResult } from './Transformer'
|
|
5
|
+
import { Transaction } from '../Transactions/Transaction'
|
|
6
|
+
import { createOperationId } from '../Types/operations'
|
|
7
|
+
import type { Operation } from '../Types/operations'
|
|
8
|
+
import type { NodeMetadata } from '../Types/core'
|
|
9
|
+
|
|
10
|
+
// ─── Metadata helpers ──────────────────────────────────────────
|
|
11
|
+
//
|
|
12
|
+
// This file used to deep-clone with `JSON.parse(JSON.stringify(x))` at four
|
|
13
|
+
// sites and compare with `JSON.stringify(a) !== JSON.stringify(b)` at one —
|
|
14
|
+
// inside a transformer that walks the entire tree, to a fixpoint.
|
|
15
|
+
//
|
|
16
|
+
// Serialising to compare was also a latent correctness bug: `JSON.stringify` is
|
|
17
|
+
// key-order sensitive, so `{a:1,b:2}` and `{b:2,a:1}` compared as different and
|
|
18
|
+
// two genuinely mergeable nodes would refuse to merge depending on the order
|
|
19
|
+
// their metadata happened to be built in.
|
|
20
|
+
|
|
21
|
+
function cloneValue(value: unknown): unknown {
|
|
22
|
+
if (Array.isArray(value)) return value.map(cloneValue)
|
|
23
|
+
if (value !== null && typeof value === 'object') {
|
|
24
|
+
const out: Record<string, unknown> = {}
|
|
25
|
+
for (const key of Object.keys(value as Record<string, unknown>)) {
|
|
26
|
+
out[key] = cloneValue((value as Record<string, unknown>)[key])
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
return value
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Deep copy of a metadata bag. */
|
|
34
|
+
function cloneMetadata(metadata: NodeMetadata | undefined): NodeMetadata {
|
|
35
|
+
return cloneValue(metadata ?? {}) as NodeMetadata
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function valueEquals(a: unknown, b: unknown): boolean {
|
|
39
|
+
if (a === b) return true
|
|
40
|
+
|
|
41
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
42
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
|
|
43
|
+
return a.every((item, i) => valueEquals(item, b[i]))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') {
|
|
47
|
+
const aKeys = Object.keys(a as Record<string, unknown>)
|
|
48
|
+
const bKeys = Object.keys(b as Record<string, unknown>)
|
|
49
|
+
if (aKeys.length !== bKeys.length) return false
|
|
50
|
+
return aKeys.every(key =>
|
|
51
|
+
Object.prototype.hasOwnProperty.call(b, key) &&
|
|
52
|
+
valueEquals((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Structural equality of two metadata bags, independent of key order. */
|
|
60
|
+
function metadataEquals(a: NodeMetadata | undefined, b: NodeMetadata | undefined): boolean {
|
|
61
|
+
return valueEquals(a ?? {}, b ?? {})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class ASTOptimizer implements Transformer {
|
|
65
|
+
transform(document: DocumentModel): TransformResult {
|
|
66
|
+
if (!document.redRoot) return { document }
|
|
67
|
+
|
|
68
|
+
let maxIters = 10
|
|
69
|
+
const totalOps: Operation[] = []
|
|
70
|
+
|
|
71
|
+
// Fixpoint iteration: keep optimizing until the tree is stable
|
|
72
|
+
while (maxIters > 0) {
|
|
73
|
+
const operations: Operation[] = []
|
|
74
|
+
|
|
75
|
+
const walk = (node: RedNode) => {
|
|
76
|
+
// 1. Optimize children first
|
|
77
|
+
const childrenToWalk = [...node.children]
|
|
78
|
+
for (const child of childrenToWalk) {
|
|
79
|
+
walk(child)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 2. Apply rules on the current node
|
|
83
|
+
this.applyRules(node, operations)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
walk(document.redRoot)
|
|
87
|
+
|
|
88
|
+
if (operations.length === 0) break
|
|
89
|
+
|
|
90
|
+
const transaction = new Transaction(operations)
|
|
91
|
+
transaction.apply(document.redRoot)
|
|
92
|
+
|
|
93
|
+
totalOps.push(...operations)
|
|
94
|
+
maxIters--
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const finalTx = new Transaction(totalOps)
|
|
98
|
+
return { document, transaction: finalTx }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private applyRules(node: RedNode, operations: Operation[]) {
|
|
102
|
+
if (node.children.length === 0) return
|
|
103
|
+
|
|
104
|
+
let changed = false
|
|
105
|
+
const newChildren: RedNode[] = []
|
|
106
|
+
|
|
107
|
+
// Pass 1: Remove empty non-void tags
|
|
108
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
109
|
+
const current = node.children[i]
|
|
110
|
+
if (this.isEmptyNode(current)) {
|
|
111
|
+
changed = true
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
newChildren.push(current)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Pass 2: Merge adjacent siblings separated only by spacing
|
|
118
|
+
const finalChildren: RedNode[] = []
|
|
119
|
+
for (let i = 0; i < newChildren.length; i++) {
|
|
120
|
+
const current = newChildren[i]
|
|
121
|
+
|
|
122
|
+
if (finalChildren.length > 0) {
|
|
123
|
+
let prevIdx = finalChildren.length - 1
|
|
124
|
+
|
|
125
|
+
let spaces: RedNode[] = []
|
|
126
|
+
while (prevIdx >= 0 && (finalChildren[prevIdx].kind === 'spacing' || finalChildren[prevIdx].kind === 'empty_line')) {
|
|
127
|
+
spaces.unshift(finalChildren[prevIdx])
|
|
128
|
+
prevIdx--
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (prevIdx >= 0) {
|
|
132
|
+
const previous = finalChildren[prevIdx]
|
|
133
|
+
if (this.canMerge(previous, current) || this.isOrphanedParagraphPair(previous, current, spaces)) {
|
|
134
|
+
changed = true
|
|
135
|
+
|
|
136
|
+
// Build the green node FROM the children it will hold, so its width
|
|
137
|
+
// is theirs. The shell used to declare a span and hold no children
|
|
138
|
+
// at all, which made the resulting red node's range describe nothing.
|
|
139
|
+
const mergedKids = [...previous.children, ...spaces, ...current.children]
|
|
140
|
+
const mergedGreen = greenNode(
|
|
141
|
+
previous.kind,
|
|
142
|
+
previous.text,
|
|
143
|
+
mergedKids.map(k => k.green),
|
|
144
|
+
previous.green.leadingWidth,
|
|
145
|
+
previous.green.trailingWidth,
|
|
146
|
+
)
|
|
147
|
+
const mergedNode = new RedNode(mergedGreen, {
|
|
148
|
+
kind: previous.kind,
|
|
149
|
+
metadata: cloneMetadata(previous.metadata)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
RedNode.allowMutation(() => {
|
|
153
|
+
for (const c of previous.children) mergedNode.appendChild(c)
|
|
154
|
+
for (const s of spaces) mergedNode.appendChild(s)
|
|
155
|
+
for (const c of current.children) mergedNode.appendChild(c)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
finalChildren[prevIdx] = mergedNode
|
|
159
|
+
finalChildren.splice(prevIdx + 1, spaces.length)
|
|
160
|
+
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
finalChildren.push(current)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Pass 3: Canonical Ordering (Invert out-of-order single child wrappers)
|
|
170
|
+
// Example: [color][b]text[/b][/color] -> [b][color]text[/color][/b]
|
|
171
|
+
if (finalChildren.length === 1 && !changed) {
|
|
172
|
+
const child = finalChildren[0]
|
|
173
|
+
const parentRank = this.getRank(node.kind)
|
|
174
|
+
const childRank = this.getRank(child.kind)
|
|
175
|
+
|
|
176
|
+
// If parent rank > child rank, it means the parent should be INSIDE the child
|
|
177
|
+
if (parentRank !== 99 && childRank !== 99 && parentRank > childRank) {
|
|
178
|
+
// Swap node and child!
|
|
179
|
+
const innerGreen = greenNode(
|
|
180
|
+
node.kind,
|
|
181
|
+
node.text,
|
|
182
|
+
(child.children as RedNode[]).map(g => g.green),
|
|
183
|
+
node.green.leadingWidth,
|
|
184
|
+
node.green.trailingWidth,
|
|
185
|
+
)
|
|
186
|
+
const innerNode = new RedNode(innerGreen, {
|
|
187
|
+
kind: node.kind,
|
|
188
|
+
metadata: cloneMetadata(node.metadata)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const swappedGreen = greenNode(
|
|
192
|
+
child.kind,
|
|
193
|
+
child.text,
|
|
194
|
+
[innerGreen],
|
|
195
|
+
child.green.leadingWidth,
|
|
196
|
+
child.green.trailingWidth,
|
|
197
|
+
)
|
|
198
|
+
const replacementNode = new RedNode(swappedGreen, {
|
|
199
|
+
kind: child.kind,
|
|
200
|
+
metadata: cloneMetadata(child.metadata)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
RedNode.allowMutation(() => {
|
|
204
|
+
for (const grandchild of child.children) {
|
|
205
|
+
innerNode.appendChild(grandchild)
|
|
206
|
+
}
|
|
207
|
+
replacementNode.appendChild(innerNode)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
operations.push({
|
|
211
|
+
kind: 'replace_node',
|
|
212
|
+
id: createOperationId(),
|
|
213
|
+
nodeId: node.id,
|
|
214
|
+
newNode: replacementNode,
|
|
215
|
+
oldNode: node,
|
|
216
|
+
undoable: true,
|
|
217
|
+
timestamp: Date.now()
|
|
218
|
+
})
|
|
219
|
+
return // We replaced the current node entirely, stop applying rules
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// If we only merged/cleaned children, we replace the node with its optimized children
|
|
224
|
+
if (changed) {
|
|
225
|
+
const groupGreen = greenNode(
|
|
226
|
+
node.kind,
|
|
227
|
+
node.text,
|
|
228
|
+
finalChildren.map(c => c.green),
|
|
229
|
+
node.green.leadingWidth,
|
|
230
|
+
node.green.trailingWidth,
|
|
231
|
+
)
|
|
232
|
+
const replacementNode = new RedNode(groupGreen, {
|
|
233
|
+
kind: node.kind,
|
|
234
|
+
metadata: cloneMetadata(node.metadata)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
RedNode.allowMutation(() => {
|
|
238
|
+
for (const child of finalChildren) {
|
|
239
|
+
replacementNode.appendChild(child)
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
operations.push({
|
|
244
|
+
kind: 'replace_node',
|
|
245
|
+
id: createOperationId(),
|
|
246
|
+
nodeId: node.id,
|
|
247
|
+
newNode: replacementNode,
|
|
248
|
+
oldNode: node,
|
|
249
|
+
undoable: true,
|
|
250
|
+
timestamp: Date.now()
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private static VOID_TAGS = new Set(['img', 'image', 'url', 'empty_line', 'spacing'])
|
|
256
|
+
private static UNMERGEABLE = new Set(['document', 'group', 'paragraph', 'empty_line', 'spacing', 'text', 'code', 'inline_code', 'notice', 'box', 'boxw', 'spoilerbox', 'quote', 'list', 'center', 'heading', 'right', 'left'])
|
|
257
|
+
|
|
258
|
+
private isEmptyNode(node: RedNode): boolean {
|
|
259
|
+
if (ASTOptimizer.VOID_TAGS.has(node.kind)) return false
|
|
260
|
+
|
|
261
|
+
if (node.kind === 'text') return node.text === ''
|
|
262
|
+
|
|
263
|
+
// Group or style nodes with no children
|
|
264
|
+
if (node.children.length === 0) return true
|
|
265
|
+
|
|
266
|
+
// Check if all children are also empty
|
|
267
|
+
return node.children.every(c => this.isEmptyNode(c))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Two `paragraph` siblings with *nothing* between them.
|
|
272
|
+
*
|
|
273
|
+
* `paragraph` is in {@link UNMERGEABLE} for good reason — paragraphs are block
|
|
274
|
+
* separators and collapsing them normally would change the document. But this
|
|
275
|
+
* particular shape is one the optimizer creates itself and the parser never
|
|
276
|
+
* does: paragraphs exist because a block element split the inline content, so
|
|
277
|
+
* when a rule deletes that block (an empty `[notice]`, say) the two halves are
|
|
278
|
+
* left as adjacent paragraphs that no longer have anything separating them.
|
|
279
|
+
*
|
|
280
|
+
* That was the whole non-idempotence bug. Exporting such a tree emits no
|
|
281
|
+
* separator, so re-parsing folds the two paragraphs back into one and a second
|
|
282
|
+
* optimizer pass finds more work to do. Merging here makes the tree agree with
|
|
283
|
+
* its own round-trip.
|
|
284
|
+
*
|
|
285
|
+
* Verified empirically before relying on it: across 202.764 nodes (both real
|
|
286
|
+
* fixtures plus 20.000 seeded random documents) the parser produced this shape
|
|
287
|
+
* **zero** times, so the rule can only ever fire on optimizer-made trees.
|
|
288
|
+
*
|
|
289
|
+
* `spaces` must be empty — paragraphs separated by real spacing or blank lines
|
|
290
|
+
* are a genuine authored break and must survive.
|
|
291
|
+
*/
|
|
292
|
+
private isOrphanedParagraphPair(a: RedNode, b: RedNode, spaces: RedNode[]): boolean {
|
|
293
|
+
return spaces.length === 0 && a.kind === 'paragraph' && b.kind === 'paragraph'
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private canMerge(a: RedNode, b: RedNode): boolean {
|
|
297
|
+
if (a.kind !== b.kind) return false
|
|
298
|
+
|
|
299
|
+
if (ASTOptimizer.UNMERGEABLE.has(a.kind)) return false
|
|
300
|
+
|
|
301
|
+
if (!metadataEquals(a.metadata, b.metadata)) return false
|
|
302
|
+
|
|
303
|
+
return true
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private getRank(kind: string): number {
|
|
307
|
+
const ranks: Record<string, number> = {
|
|
308
|
+
'url': 1, 'email': 1, 'profile': 1,
|
|
309
|
+
'font_size': 2, 'font': 2,
|
|
310
|
+
'bold': 3, 'italic': 3, 'underline': 3, 'strikethrough': 3,
|
|
311
|
+
'color': 4
|
|
312
|
+
}
|
|
313
|
+
return ranks[kind] ?? 99
|
|
314
|
+
}
|
|
315
|
+
}
|