@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,694 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — DocumentModel
|
|
3
|
+
*
|
|
4
|
+
* THE CORE of the Language Platform.
|
|
5
|
+
*
|
|
6
|
+
* The DocumentModel is the single source of truth for the document.
|
|
7
|
+
* BBCode is just ONE representation of this model.
|
|
8
|
+
* The model is language-agnostic and format-agnostic.
|
|
9
|
+
*
|
|
10
|
+
* Responsibilities:
|
|
11
|
+
* - Hold the current Red Tree
|
|
12
|
+
* - Manage incremental parsing
|
|
13
|
+
* - Coordinate transactions (undo/redo)
|
|
14
|
+
* - Emit change events
|
|
15
|
+
* - Provide query/find capabilities
|
|
16
|
+
* - Manage diagnostics
|
|
17
|
+
*
|
|
18
|
+
* Inspired by ProseMirror's EditorState and Roslyn's Document.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { RedNode } from '../Syntax/RedNode'
|
|
22
|
+
import { GreenNode } from '../Syntax/GreenNode'
|
|
23
|
+
import { preserveNodeIds } from '../Syntax/preserveNodeIds'
|
|
24
|
+
import { NodeMatcher } from '../Syntax/NodeMatcher'
|
|
25
|
+
import type { NodeMatch } from '../Syntax/NodeMatcher'
|
|
26
|
+
import {
|
|
27
|
+
IncrementalParser,
|
|
28
|
+
type ReparseParseOptions,
|
|
29
|
+
type FallbackReason,
|
|
30
|
+
} from '../Incremental/IncrementalParser'
|
|
31
|
+
import { ChangeTracker } from '../Incremental/ChangeTracker'
|
|
32
|
+
import type { TextChange, TextChangeRange } from '../Incremental/ChangeTracker'
|
|
33
|
+
import { SemanticAnalyzer } from '../Semantic/SemanticAnalyzer'
|
|
34
|
+
import type { AnalyzeResult, Validator } from '../Semantic/SemanticAnalyzer'
|
|
35
|
+
import { Transaction } from '../Transactions/Transaction'
|
|
36
|
+
import type { Operation } from '../Types/operations'
|
|
37
|
+
import { UndoManager } from '../Transactions/UndoManager'
|
|
38
|
+
import { TreeBuilder } from '../Syntax/TreeBuilder'
|
|
39
|
+
import { ArrayTokenStream } from '../Types/tokens'
|
|
40
|
+
import { QueryEngine } from '../Queries/QueryEngine'
|
|
41
|
+
import type { Query, QueryResult } from '../Types/queries'
|
|
42
|
+
import type {
|
|
43
|
+
DocumentNode,
|
|
44
|
+
DocumentChangeEvent,
|
|
45
|
+
DocumentChangeKind,
|
|
46
|
+
NodeId,
|
|
47
|
+
NodeKind,
|
|
48
|
+
} from '../Types/core'
|
|
49
|
+
import { findById, walkPreOrder } from '../Types/core'
|
|
50
|
+
import type { DiagnosticCollection } from '../Types/diagnostics'
|
|
51
|
+
import { DocumentEventBus } from '../Events/EventBus'
|
|
52
|
+
import type { DocumentEvent } from '../Events/EventBus'
|
|
53
|
+
import { NodeFactory } from './NodeFactory'
|
|
54
|
+
import { TagRegistry } from './TagRegistry'
|
|
55
|
+
|
|
56
|
+
export interface DocumentModelOptions {
|
|
57
|
+
/** Initial source text */
|
|
58
|
+
source?: string
|
|
59
|
+
/** Language identifier */
|
|
60
|
+
language?: string
|
|
61
|
+
/** Maximum undo stack size */
|
|
62
|
+
maxUndo?: number
|
|
63
|
+
/** Whether to auto-analyze on change */
|
|
64
|
+
autoAnalyze?: boolean
|
|
65
|
+
/**
|
|
66
|
+
* Attempt incremental reparse on `applyChange`. Default `true`.
|
|
67
|
+
*
|
|
68
|
+
* Setting this to `false` makes every change a full rebuild. The result is
|
|
69
|
+
* identical either way — that is the incremental parser's contract, checked
|
|
70
|
+
* differentially over 22.100 edits — so this is a performance switch and a
|
|
71
|
+
* debugging aid, not a correctness one.
|
|
72
|
+
*/
|
|
73
|
+
incremental?: boolean
|
|
74
|
+
/**
|
|
75
|
+
* Reuse red subtrees across incremental reparses. Default `true`.
|
|
76
|
+
*
|
|
77
|
+
* When the splice shares a green subtree by reference, the old red subtree
|
|
78
|
+
* is adopted into the new tree instead of being rebuilt — building red was
|
|
79
|
+
* the largest phase of a keystroke. The trade is a contract: the previous
|
|
80
|
+
* `redRoot` is consumed by the adoption and must not be walked afterwards.
|
|
81
|
+
* No engine or app code does; this switch exists for any future consumer
|
|
82
|
+
* that needs the superseded tree to stay intact.
|
|
83
|
+
*/
|
|
84
|
+
reuseRed?: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class DocumentModel {
|
|
88
|
+
// ── Core State ──
|
|
89
|
+
private _source: string
|
|
90
|
+
private _language: string
|
|
91
|
+
private _redRoot: RedNode | null = null
|
|
92
|
+
private _greenRoot: GreenNode | null = null
|
|
93
|
+
private _version: number = 0
|
|
94
|
+
|
|
95
|
+
// ── Subsystems ──
|
|
96
|
+
readonly tagRegistry: TagRegistry
|
|
97
|
+
readonly nodeFactory: NodeFactory
|
|
98
|
+
readonly treeBuilder: TreeBuilder
|
|
99
|
+
readonly nodeMatcher: NodeMatcher
|
|
100
|
+
readonly semanticAnalyzer: SemanticAnalyzer
|
|
101
|
+
readonly incrementalParser: IncrementalParser
|
|
102
|
+
readonly changeTracker: ChangeTracker
|
|
103
|
+
readonly undoManager: UndoManager
|
|
104
|
+
readonly queryEngine: QueryEngine
|
|
105
|
+
readonly events: DocumentEventBus
|
|
106
|
+
|
|
107
|
+
// ── State ──
|
|
108
|
+
private _diagnostics: DiagnosticCollection | null = null
|
|
109
|
+
private _lastAnalyzeResult: AnalyzeResult | null = null
|
|
110
|
+
/**
|
|
111
|
+
* Source range of the last applied edit — see {@link TextChangeRange}.
|
|
112
|
+
* `null` after a full rebuild, which is not an edit.
|
|
113
|
+
*/
|
|
114
|
+
private _lastChangeRange: TextChangeRange | null = null
|
|
115
|
+
private _options: Required<DocumentModelOptions>
|
|
116
|
+
private _analyzeTimeout: ReturnType<typeof setTimeout> | null = null
|
|
117
|
+
/** The debounced post-edit work, kept so `ensureAnalyzed` can run it early. */
|
|
118
|
+
private _pendingAnalyze: (() => void) | null = null
|
|
119
|
+
|
|
120
|
+
// ── Diagnostics info ──
|
|
121
|
+
/** Last reparse timings breakdown (exposed for debugging) */
|
|
122
|
+
lastReparsePath: string = ''
|
|
123
|
+
/** Why the last reparse fell back to a full rebuild, if it did. */
|
|
124
|
+
lastReparseFallbackReason: FallbackReason | null = null
|
|
125
|
+
lastReparseTimings: { findAffected: number; safeBoundary: number; parse: number; buildRed: number; mutate: number; other: number } | null = null
|
|
126
|
+
|
|
127
|
+
constructor(options: DocumentModelOptions = {}) {
|
|
128
|
+
this._options = {
|
|
129
|
+
source: options.source ?? '',
|
|
130
|
+
language: options.language ?? 'bbcode',
|
|
131
|
+
maxUndo: options.maxUndo ?? 50,
|
|
132
|
+
autoAnalyze: options.autoAnalyze ?? true,
|
|
133
|
+
incremental: options.incremental ?? true,
|
|
134
|
+
reuseRed: options.reuseRed ?? true,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this._source = this._options.source
|
|
138
|
+
this._language = this._options.language
|
|
139
|
+
|
|
140
|
+
// Initialize subsystems
|
|
141
|
+
this.tagRegistry = new TagRegistry()
|
|
142
|
+
this.nodeFactory = new NodeFactory(this.tagRegistry)
|
|
143
|
+
this.treeBuilder = new TreeBuilder()
|
|
144
|
+
this.nodeMatcher = new NodeMatcher()
|
|
145
|
+
this.semanticAnalyzer = new SemanticAnalyzer()
|
|
146
|
+
this.incrementalParser = new IncrementalParser()
|
|
147
|
+
this.changeTracker = new ChangeTracker()
|
|
148
|
+
this.undoManager = new UndoManager(this._options.maxUndo)
|
|
149
|
+
this.queryEngine = new QueryEngine()
|
|
150
|
+
this.events = new DocumentEventBus()
|
|
151
|
+
|
|
152
|
+
// Bootstrap: parse initial source
|
|
153
|
+
if (this._source) {
|
|
154
|
+
this.rebuild(this._source)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Properties ──
|
|
159
|
+
|
|
160
|
+
get source(): string {
|
|
161
|
+
return this._source
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
get language(): string {
|
|
165
|
+
return this._language
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
get version(): number {
|
|
169
|
+
return this._version
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
get redRoot(): RedNode | null {
|
|
173
|
+
return this._redRoot
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
get greenRoot(): GreenNode | null {
|
|
177
|
+
return this._greenRoot
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
get diagnostics(): DiagnosticCollection | null {
|
|
181
|
+
return this._diagnostics
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
get lastAnalyze(): AnalyzeResult | null {
|
|
185
|
+
return this._lastAnalyzeResult
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The source range of the last applied edit, in new-source coordinates.
|
|
190
|
+
*
|
|
191
|
+
* `null` after a `rebuild` (there is no incremental edit to point at). The
|
|
192
|
+
* BlockPatcher reads this to reconcile only the blocks around the edit
|
|
193
|
+
* instead of walking the whole document. The same range is also attached to
|
|
194
|
+
* the current `redRoot` (`__changeRange`), so a consumer that only holds the
|
|
195
|
+
* AST — like the live preview — can find it without plumbing the model.
|
|
196
|
+
*/
|
|
197
|
+
get lastChangeRange(): TextChangeRange | null {
|
|
198
|
+
return this._lastChangeRange
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Document Operations ──
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Full rebuild from source text.
|
|
205
|
+
* Used for initial load or when incremental parsing isn't possible.
|
|
206
|
+
*/
|
|
207
|
+
rebuild(source: string): void {
|
|
208
|
+
const oldRoot = this._redRoot
|
|
209
|
+
|
|
210
|
+
// Parse via the tree builder (overridable by language-specific parser)
|
|
211
|
+
this._source = source
|
|
212
|
+
this._greenRoot = this.parseToGreen(source)
|
|
213
|
+
this._redRoot = this.buildRedFromGreen(this._greenRoot)
|
|
214
|
+
// A rebuild is not an edit: there is no "changed region" to reconcile
|
|
215
|
+
// incrementally, so the range is cleared and the preview falls back to its
|
|
216
|
+
// full reconcile (which is what it must do for undo/redo/load anyway).
|
|
217
|
+
this._lastChangeRange = null
|
|
218
|
+
this._attachChangeRange(this._redRoot)
|
|
219
|
+
// Unchanged nodes keep the identity they had before the rebuild, so the
|
|
220
|
+
// HTML of untouched subtrees stays byte-identical between renders and the
|
|
221
|
+
// DOMMorpher's isEqualNode fast path actually fires.
|
|
222
|
+
if (oldRoot) {
|
|
223
|
+
preserveNodeIds(oldRoot, this._redRoot)
|
|
224
|
+
}
|
|
225
|
+
this._version++
|
|
226
|
+
|
|
227
|
+
// Run semantic analysis
|
|
228
|
+
if (this._options.autoAnalyze) {
|
|
229
|
+
this.analyze()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Emit event — building the payload (and the lazy nodeMatch accessor) is
|
|
233
|
+
// pointless when nobody subscribed, which is the common case. An opted-in
|
|
234
|
+
// history counts as a subscriber.
|
|
235
|
+
if (this.events.hasListeners('document_changed') || this.events.recordHistory) {
|
|
236
|
+
const event = {
|
|
237
|
+
type: 'document_changed' as const,
|
|
238
|
+
kind: 'full_rebuild' as const,
|
|
239
|
+
version: this._version,
|
|
240
|
+
source: this._source,
|
|
241
|
+
timestamp: Date.now(),
|
|
242
|
+
}
|
|
243
|
+
this.defineLazyNodeMatch(event, oldRoot, this._redRoot)
|
|
244
|
+
this.events.emit(event as DocumentEvent)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Install `nodeMatch` on an event as an accessor, so the match is only
|
|
250
|
+
* computed if a subscriber actually reads it.
|
|
251
|
+
*
|
|
252
|
+
* Matching two 1736-node trees costs ~3.5 ms and allocates a result entry per
|
|
253
|
+
* node plus five Maps/Sets — and it ran on *every* keystroke to populate a
|
|
254
|
+
* field that no subscriber in this codebase reads. The capability is real
|
|
255
|
+
* (preserving selection and scroll across re-parses is what it is for), so it
|
|
256
|
+
* stays available; it just stops running speculatively.
|
|
257
|
+
*
|
|
258
|
+
* Callers still write `event.nodeMatch` and see a `NodeMatch | null`. Note the
|
|
259
|
+
* closure keeps `oldRoot` alive for as long as the event object is retained.
|
|
260
|
+
*/
|
|
261
|
+
private defineLazyNodeMatch(
|
|
262
|
+
event: object,
|
|
263
|
+
oldRoot: RedNode | null,
|
|
264
|
+
newRoot: RedNode | null,
|
|
265
|
+
): void {
|
|
266
|
+
if (!oldRoot || !newRoot) {
|
|
267
|
+
Object.defineProperty(event, 'nodeMatch', { value: null, enumerable: true })
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const matcher = this.nodeMatcher
|
|
272
|
+
let memo: NodeMatch | null = null
|
|
273
|
+
|
|
274
|
+
Object.defineProperty(event, 'nodeMatch', {
|
|
275
|
+
enumerable: true,
|
|
276
|
+
configurable: true,
|
|
277
|
+
get(): NodeMatch {
|
|
278
|
+
if (memo === null) memo = matcher.match(oldRoot, newRoot)
|
|
279
|
+
return memo
|
|
280
|
+
},
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Attach the last change range to a root node.
|
|
286
|
+
*
|
|
287
|
+
* The range travels with the AST so the preview can read it from the root it
|
|
288
|
+
* is about to patch — no prop drilling through workspace → window → preview.
|
|
289
|
+
* `__changeRange` is deliberately not part of RedNode's API; it is a runtime
|
|
290
|
+
* marker owned by the model (the BlockPatcher reads it with a cast).
|
|
291
|
+
*/
|
|
292
|
+
private _attachChangeRange(root: RedNode | null): void {
|
|
293
|
+
if (root) {
|
|
294
|
+
;(root as RedNode & { __changeRange?: TextChangeRange | null }).__changeRange = this._lastChangeRange
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Apply a source text change (e.g. from Monaco editor input).
|
|
300
|
+
* Uses incremental parsing when possible.
|
|
301
|
+
*
|
|
302
|
+
* `origin` tags where the change came from — `'local'` (default) for the
|
|
303
|
+
* user's own editing, anything else for programmatic or synced sources
|
|
304
|
+
* (`'remote'`, `'sync'`, a peer id…). It travels on the emitted events, so
|
|
305
|
+
* a collaboration layer can tell its own echo apart from user edits — the
|
|
306
|
+
* classic infinite-loop bug when wiring a CRDT. See `QuasarCollab.MD`.
|
|
307
|
+
*/
|
|
308
|
+
applyChange(change: TextChange, origin: string = 'local'): void {
|
|
309
|
+
const oldRoot = this._redRoot
|
|
310
|
+
|
|
311
|
+
// Track the change
|
|
312
|
+
this.changeTracker.track(change)
|
|
313
|
+
|
|
314
|
+
// The edit range, in both coordinate systems. `change.end` is the OLD
|
|
315
|
+
// end; `start + change.text.length` is the NEW end after the splice.
|
|
316
|
+
this._lastChangeRange = {
|
|
317
|
+
start: change.start,
|
|
318
|
+
end: change.start + change.text.length,
|
|
319
|
+
endOld: change.end,
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Apply to source
|
|
323
|
+
const before = this._source.slice(0, change.start)
|
|
324
|
+
const after = this._source.slice(change.end)
|
|
325
|
+
this._source = before + change.text + after
|
|
326
|
+
|
|
327
|
+
// Note: this._source is later overwritten with the flat `newSource`
|
|
328
|
+
// from the textarea (in applyTextUpdate) to prevent ConsString build-up.
|
|
329
|
+
|
|
330
|
+
// Incremental parse (or fall back to full rebuild)
|
|
331
|
+
if (this._redRoot && this._greenRoot && this._options.incremental) {
|
|
332
|
+
try {
|
|
333
|
+
// With `reuseRed`, subtrees whose green is shared by reference are
|
|
334
|
+
// adopted from the old red tree instead of rebuilt. This CONSUMES
|
|
335
|
+
// `oldRoot` (adopted nodes are reparented into the new tree) — safe
|
|
336
|
+
// because nothing walks a superseded red tree, and `oldRoot` is only
|
|
337
|
+
// used below for id preservation and the lazy nodeMatch, both of
|
|
338
|
+
// which tolerate shared nodes.
|
|
339
|
+
// `adopted` counts nodes carried over from the old tree. It decides
|
|
340
|
+
// below which mechanism preserved identity — the two must never both
|
|
341
|
+
// run over the same tree.
|
|
342
|
+
const reuse = { adopted: 0 }
|
|
343
|
+
const buildRed = this._options.reuseRed && oldRoot
|
|
344
|
+
? (green: GreenNode) => this.buildRedFromGreenReusing(green, oldRoot, reuse)
|
|
345
|
+
: (green: GreenNode) => this.buildRedFromGreen(green)
|
|
346
|
+
const result = this.incrementalParser.reparse(
|
|
347
|
+
this._redRoot,
|
|
348
|
+
this._greenRoot,
|
|
349
|
+
change,
|
|
350
|
+
this._source,
|
|
351
|
+
(text: string, opts?: ReparseParseOptions) => this.parseToGreen(text, opts),
|
|
352
|
+
buildRed,
|
|
353
|
+
)
|
|
354
|
+
// `reparse` always returns a result now: when it cannot splice safely
|
|
355
|
+
// it does the full rebuild itself rather than handing back a null the
|
|
356
|
+
// caller has to interpret.
|
|
357
|
+
this.lastReparsePath = result.path
|
|
358
|
+
this.lastReparseFallbackReason = result.reason ?? null
|
|
359
|
+
this.lastReparseTimings = result.timings
|
|
360
|
+
this._greenRoot = result.green
|
|
361
|
+
this._redRoot = result.red
|
|
362
|
+
// Identity across the edit, by whichever mechanism applies.
|
|
363
|
+
//
|
|
364
|
+
// When subtrees were reused, they ARE the previous nodes and already
|
|
365
|
+
// carry their ids — and running the id walk on top would actively
|
|
366
|
+
// corrupt them: it pairs positionally, so inserting a block makes it
|
|
367
|
+
// pair each survivor with its neighbour and overwrite a live node's
|
|
368
|
+
// id with a different node's (duplicating ids, and changing the
|
|
369
|
+
// `data-node-id` of blocks that never changed). The nodes it would
|
|
370
|
+
// renumber are the re-parsed ones, which are genuinely new.
|
|
371
|
+
//
|
|
372
|
+
// Without reuse every node is fresh, so the walk is what carries
|
|
373
|
+
// identity across — same as in `rebuild`. Note `reparse` can decide
|
|
374
|
+
// on a full rebuild internally, and then nothing is adopted even
|
|
375
|
+
// though reuse was enabled: the count, not the option, is what tells
|
|
376
|
+
// the two situations apart.
|
|
377
|
+
if (oldRoot && reuse.adopted === 0) {
|
|
378
|
+
preserveNodeIds(oldRoot, this._redRoot)
|
|
379
|
+
}
|
|
380
|
+
this._version++
|
|
381
|
+
// The incremental path resolved: the range stays attached to the new
|
|
382
|
+
// root so the preview can find the edited region.
|
|
383
|
+
this._attachChangeRange(this._redRoot)
|
|
384
|
+
} catch {
|
|
385
|
+
this.lastReparsePath = 'full_rebuild'
|
|
386
|
+
this.lastReparseTimings = null
|
|
387
|
+
this.rebuild(this._source)
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
} else {
|
|
391
|
+
this.rebuild(this._source)
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Emit event immediately
|
|
396
|
+
if (this.events.hasListeners('document_changed') || this.events.recordHistory) {
|
|
397
|
+
this.events.emit({
|
|
398
|
+
type: 'document_changed',
|
|
399
|
+
kind: 'text_changed',
|
|
400
|
+
version: this._version,
|
|
401
|
+
source: this._source,
|
|
402
|
+
nodeMatch: null,
|
|
403
|
+
change,
|
|
404
|
+
origin,
|
|
405
|
+
timestamp: Date.now(),
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Debounced background task for heavy phases
|
|
410
|
+
if (this._analyzeTimeout) {
|
|
411
|
+
clearTimeout(this._analyzeTimeout)
|
|
412
|
+
}
|
|
413
|
+
const runPendingAnalyze = () => {
|
|
414
|
+
this._analyzeTimeout = null
|
|
415
|
+
this._pendingAnalyze = null
|
|
416
|
+
|
|
417
|
+
// Background Phase 1: Semantic Analysis
|
|
418
|
+
if (this._options.autoAnalyze) {
|
|
419
|
+
this.analyze()
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Background Phase 2: Notify UI that semantics are ready — if some UI
|
|
423
|
+
// is listening. `nodeMatch` is lazy here too — see `defineLazyNodeMatch`.
|
|
424
|
+
if (this.events.hasListeners('diagnostics_updated') || this.events.recordHistory) {
|
|
425
|
+
const diagnosticsEvent = {
|
|
426
|
+
type: 'diagnostics_updated' as const,
|
|
427
|
+
version: this._version,
|
|
428
|
+
source: this._source,
|
|
429
|
+
diagnostics: this._diagnostics,
|
|
430
|
+
timestamp: Date.now()
|
|
431
|
+
}
|
|
432
|
+
this.defineLazyNodeMatch(diagnosticsEvent, oldRoot, this._redRoot)
|
|
433
|
+
this.events.emit(diagnosticsEvent as DocumentEvent)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
this._pendingAnalyze = runPendingAnalyze
|
|
437
|
+
this._analyzeTimeout = setTimeout(runPendingAnalyze, 10)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Flush the debounced post-edit analysis, so `diagnostics` describes the
|
|
442
|
+
* *current* tree.
|
|
443
|
+
*
|
|
444
|
+
* `applyChange` defers analysis by a few milliseconds to keep the keystroke
|
|
445
|
+
* path lean. A caller that reads `diagnostics` synchronously right after an
|
|
446
|
+
* edit would otherwise be looking at the previous document's errors — which
|
|
447
|
+
* is exactly what the editor's error panel did.
|
|
448
|
+
*/
|
|
449
|
+
ensureAnalyzed(): void {
|
|
450
|
+
const pending = this._pendingAnalyze
|
|
451
|
+
if (!pending) return
|
|
452
|
+
if (this._analyzeTimeout) {
|
|
453
|
+
clearTimeout(this._analyzeTimeout)
|
|
454
|
+
}
|
|
455
|
+
pending()
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Calculate a simple diff between the current source and the new source,
|
|
460
|
+
* then apply the change.
|
|
461
|
+
*/
|
|
462
|
+
applyTextUpdate(newSource: string, origin: string = 'local'): void {
|
|
463
|
+
if (this._source === newSource) return
|
|
464
|
+
|
|
465
|
+
// One forward pass and one backward pass, and that is the whole diff.
|
|
466
|
+
//
|
|
467
|
+
// There used to be two `startsWith` fast paths in front of this, for "pure
|
|
468
|
+
// append" and "pure delete at the end". They were not shortcuts: both are
|
|
469
|
+
// just this scan with a particular answer, and reaching that answer took a
|
|
470
|
+
// full comparison of the shared prefix — which the general case then
|
|
471
|
+
// repeated from scratch. An edit in the middle of a document paid for the
|
|
472
|
+
// same prefix twice, once to rule out the fast path and once to compute
|
|
473
|
+
// the diff it was going to compute anyway.
|
|
474
|
+
//
|
|
475
|
+
// The two cases still come out right, they are simply not special: an
|
|
476
|
+
// append leaves `start === oldLen` and the backward pass stops on the spot,
|
|
477
|
+
// a delete at the end leaves `start === newLen` and likewise.
|
|
478
|
+
const oldSource = this._source
|
|
479
|
+
const oldLen = oldSource.length
|
|
480
|
+
const newLen = newSource.length
|
|
481
|
+
const shared = oldLen < newLen ? oldLen : newLen
|
|
482
|
+
|
|
483
|
+
let start = 0
|
|
484
|
+
while (start < shared && oldSource.charCodeAt(start) === newSource.charCodeAt(start)) {
|
|
485
|
+
start++
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
let oldEnd = oldLen
|
|
489
|
+
let newEnd = newLen
|
|
490
|
+
while (
|
|
491
|
+
oldEnd > start &&
|
|
492
|
+
newEnd > start &&
|
|
493
|
+
oldSource.charCodeAt(oldEnd - 1) === newSource.charCodeAt(newEnd - 1)
|
|
494
|
+
) {
|
|
495
|
+
oldEnd--
|
|
496
|
+
newEnd--
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
this.applyChange({ start, end: oldEnd, text: newSource.slice(start, newEnd) }, origin)
|
|
500
|
+
|
|
501
|
+
// The diff computed here is authoritative: `newEnd`/`oldEnd` are the exact
|
|
502
|
+
// boundary in each coordinate system (applyChange derived the same values
|
|
503
|
+
// from the TextChange — this just re-states them and re-attaches, since
|
|
504
|
+
// the range must ride the current root).
|
|
505
|
+
this._lastChangeRange = { start, end: newEnd, endOld: oldEnd }
|
|
506
|
+
this._attachChangeRange(this._redRoot)
|
|
507
|
+
|
|
508
|
+
// Overwrite with the flat source from the textarea to prevent
|
|
509
|
+
// deep ConsStrings from accumulating across edits.
|
|
510
|
+
this._source = newSource
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Execute operations within a transaction (undoable).
|
|
515
|
+
*
|
|
516
|
+
* Coherence contract: after a `transact` the model's `source`, green tree
|
|
517
|
+
* and red tree all describe the same document. The transaction mutates the
|
|
518
|
+
* red tree, the result is serialized back to text via `exportSource`, and
|
|
519
|
+
* the model rebuilds from that text — parse stays the single source of
|
|
520
|
+
* truth, exactly as in `applyChange`. (The previous implementation swapped
|
|
521
|
+
* `_redRoot` and left `_source`/`_greenRoot` describing the old document,
|
|
522
|
+
* so the next text edit diffed against a source the tree no longer matched.)
|
|
523
|
+
*
|
|
524
|
+
* Returns `true` if the document changed.
|
|
525
|
+
*/
|
|
526
|
+
transact(operations: Operation[], label?: string): boolean {
|
|
527
|
+
if (!this._redRoot) return false
|
|
528
|
+
|
|
529
|
+
const before = this._source
|
|
530
|
+
const tx = new Transaction(operations)
|
|
531
|
+
const applied = tx.apply(this._redRoot)
|
|
532
|
+
if (!applied) return false
|
|
533
|
+
|
|
534
|
+
// The transaction mutated the red tree in place, so even a no-op result
|
|
535
|
+
// must go through export + rebuild to restore red/green coherence.
|
|
536
|
+
const after = this.exportSource(applied)
|
|
537
|
+
this.rebuild(after)
|
|
538
|
+
|
|
539
|
+
if (after === before) return false
|
|
540
|
+
this.undoManager.push({ before, after }, label ?? tx.getLabel())
|
|
541
|
+
return true
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Undo the last transaction.
|
|
546
|
+
*
|
|
547
|
+
* Undo/redo replay *source snapshots*, not stored tree mutations: a rebuild
|
|
548
|
+
* replaces every node id, so a retained `Transaction` could never be
|
|
549
|
+
* re-applied against the current tree — and its `invert()` was only defined
|
|
550
|
+
* for one of the six operation kinds anyway. Text is always replayable.
|
|
551
|
+
*/
|
|
552
|
+
undo(): boolean {
|
|
553
|
+
const entry = this.undoManager.undo()
|
|
554
|
+
if (!entry) return false
|
|
555
|
+
this.rebuild(entry.before)
|
|
556
|
+
return true
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Redo the last undone transaction. See `undo` for why this replays text.
|
|
561
|
+
*/
|
|
562
|
+
redo(): boolean {
|
|
563
|
+
const entry = this.undoManager.redo()
|
|
564
|
+
if (!entry) return false
|
|
565
|
+
this.rebuild(entry.after)
|
|
566
|
+
return true
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Run semantic analysis on the current tree.
|
|
571
|
+
*/
|
|
572
|
+
analyze(): AnalyzeResult {
|
|
573
|
+
if (!this._redRoot) {
|
|
574
|
+
throw new Error('Cannot analyze: no document loaded')
|
|
575
|
+
}
|
|
576
|
+
const result = this.semanticAnalyzer.analyze(this._redRoot, this._source)
|
|
577
|
+
this._diagnostics = result.diagnostics
|
|
578
|
+
|
|
579
|
+
// Keep the id index out of the retained result: holding it would pin every
|
|
580
|
+
// RedNode of the *previous* tree alive until the next analyze — a leak in
|
|
581
|
+
// all but name, and measured at +1.3 ms per rebuild in GC pressure alone.
|
|
582
|
+
// It is a getter now, so simply not reading it also means never building it.
|
|
583
|
+
this._lastAnalyzeResult = {
|
|
584
|
+
diagnostics: result.diagnostics,
|
|
585
|
+
duration: result.duration,
|
|
586
|
+
nodesAnalyzed: result.nodesAnalyzed,
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Attaching diagnostics to nodes used to happen here, and needed an id→node
|
|
590
|
+
// Map of the whole document to do it — a second full walk plus 1736
|
|
591
|
+
// `Map.set` calls, 23% of `analyze()`. The analyzer attaches them during
|
|
592
|
+
// its own walk instead: it has the node in hand at the moment the
|
|
593
|
+
// diagnostic is produced, so both the index and the walk that built it were
|
|
594
|
+
// solving a problem created by separating the two steps.
|
|
595
|
+
|
|
596
|
+
return this._lastAnalyzeResult
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Find a node by its ID.
|
|
601
|
+
*/
|
|
602
|
+
findNode(id: NodeId): RedNode | null {
|
|
603
|
+
return this._redRoot?.findById(id) ?? null
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Query the document tree.
|
|
608
|
+
*/
|
|
609
|
+
query(q: Query): QueryResult {
|
|
610
|
+
if (!this._redRoot) {
|
|
611
|
+
return { matches: [], total: 0, time: 0 }
|
|
612
|
+
}
|
|
613
|
+
return this.queryEngine.execute(q, this._redRoot)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Register a semantic validator.
|
|
618
|
+
*/
|
|
619
|
+
registerValidator(validator: Validator): void {
|
|
620
|
+
this.semanticAnalyzer.register(validator)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ── Serialization ──
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Get the current document as a DocumentNode tree.
|
|
627
|
+
*/
|
|
628
|
+
toDocumentNode(): DocumentNode | null {
|
|
629
|
+
return this._redRoot?.toDocumentNode() ?? null
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Get the current source text.
|
|
634
|
+
*/
|
|
635
|
+
toString(): string {
|
|
636
|
+
return this._source
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ── Internal ──
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Parse source text to a GreenNode tree.
|
|
643
|
+
* Override this for language-specific parsing.
|
|
644
|
+
*
|
|
645
|
+
* `options.normalizeParagraphs === false` means the text is the inner span
|
|
646
|
+
* of a container rather than a whole document, and root-level grouping must
|
|
647
|
+
* be skipped. Languages without such grouping can ignore it.
|
|
648
|
+
*/
|
|
649
|
+
protected parseToGreen(source: string, options?: ReparseParseOptions): GreenNode {
|
|
650
|
+
// Default: use the TreeBuilder
|
|
651
|
+
// Language-specific overrides (BBCodeParser) will provide
|
|
652
|
+
// proper tag-aware parsing.
|
|
653
|
+
return this.treeBuilder.build(
|
|
654
|
+
new ArrayTokenStream([]),
|
|
655
|
+
).green
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Build a RedNode tree from a GreenNode.
|
|
660
|
+
*/
|
|
661
|
+
protected buildRedFromGreen(green: GreenNode): RedNode {
|
|
662
|
+
return this.treeBuilder.buildRed(green)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Build a red tree for `green`, reusing subtrees of `oldRed` where the green
|
|
667
|
+
* is shared by reference. The base model has no reuse-aware builder, so it
|
|
668
|
+
* falls back to a full build; language models override this
|
|
669
|
+
* (BBCodeDocumentModel uses `greenToRedNodeReusing`).
|
|
670
|
+
*/
|
|
671
|
+
protected buildRedFromGreenReusing(
|
|
672
|
+
green: GreenNode,
|
|
673
|
+
oldRed: RedNode,
|
|
674
|
+
stats?: { adopted: number },
|
|
675
|
+
): RedNode {
|
|
676
|
+
return this.buildRedFromGreen(green)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Serialize a red tree back to source text. `transact`/`undo`/`redo` rebuild
|
|
681
|
+
* from this text to keep source, green and red coherent.
|
|
682
|
+
*
|
|
683
|
+
* The base model has no syntax to serialize to, so this throws; language
|
|
684
|
+
* models override it (BBCodeDocumentModel uses the BBCodeExporter).
|
|
685
|
+
* Failing loudly here beats the alternative: silently desynchronizing the
|
|
686
|
+
* model's three representations.
|
|
687
|
+
*/
|
|
688
|
+
protected exportSource(root: RedNode): string {
|
|
689
|
+
throw new Error(
|
|
690
|
+
`DocumentModel.exportSource is not implemented for language '${this._language}' — ` +
|
|
691
|
+
'override it in the language-specific model to enable transact/undo/redo',
|
|
692
|
+
)
|
|
693
|
+
}
|
|
694
|
+
}
|