@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,755 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — BBCodeToGreenNode
|
|
3
|
+
*
|
|
4
|
+
* Bridge between the existing BBCode Parser (BBBlock[]) and the
|
|
5
|
+
* DocumentEngine's GreenNode/RedNode syntax tree.
|
|
6
|
+
*
|
|
7
|
+
* This is how we integrate the mature, battle-tested BBCode parser
|
|
8
|
+
* with the new Language Platform architecture.
|
|
9
|
+
*
|
|
10
|
+
* The flow:
|
|
11
|
+
* BBCode text
|
|
12
|
+
* → parseBBCode() [existing] → BBBlock[]
|
|
13
|
+
* → convertToGreenNode() [this] → GreenNode
|
|
14
|
+
* → buildRedNode() [this] → RedNode
|
|
15
|
+
* → DocumentModel
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { GreenNode, greenNode, greenLeaf } from '../Syntax/GreenNode'
|
|
19
|
+
import { RedNode } from '../Syntax/RedNode'
|
|
20
|
+
import { RedNodeStore } from '../Syntax/RedNodeStore'
|
|
21
|
+
import type { NodeKind } from '../Types/core'
|
|
22
|
+
import { scanBBCode } from '../Lexer/BBCodeLexer'
|
|
23
|
+
import { parseTokensToGreen } from './Parser'
|
|
24
|
+
|
|
25
|
+
// ─── Dialect & Tag → Kind mapping ─────────────────────────────
|
|
26
|
+
|
|
27
|
+
export type BBCodeDialect = 'osu' | 'miliastry' | 'lyne'
|
|
28
|
+
|
|
29
|
+
const OSU_TAG_TO_KIND_ENTRIES: Record<string, NodeKind> = {
|
|
30
|
+
'b': 'bold',
|
|
31
|
+
'i': 'italic',
|
|
32
|
+
'u': 'underline',
|
|
33
|
+
's': 'strikethrough',
|
|
34
|
+
'strike': 'strikethrough',
|
|
35
|
+
'color': 'color',
|
|
36
|
+
'colour': 'color',
|
|
37
|
+
'size': 'font_size',
|
|
38
|
+
'c': 'inline_code',
|
|
39
|
+
'code': 'code',
|
|
40
|
+
'spoiler': 'spoiler',
|
|
41
|
+
'centre': 'center',
|
|
42
|
+
'center': 'center',
|
|
43
|
+
'right': 'right',
|
|
44
|
+
'left': 'left',
|
|
45
|
+
'url': 'url',
|
|
46
|
+
'email': 'email',
|
|
47
|
+
'profile': 'profile',
|
|
48
|
+
'img': 'image',
|
|
49
|
+
'youtube': 'video',
|
|
50
|
+
'audio': 'audio',
|
|
51
|
+
'imagemap': 'imagemap',
|
|
52
|
+
'quote': 'quote',
|
|
53
|
+
'notice': 'notice',
|
|
54
|
+
'spoilerbox': 'spoilerbox',
|
|
55
|
+
'box': 'box',
|
|
56
|
+
'list': 'list',
|
|
57
|
+
'*': 'list_item',
|
|
58
|
+
'heading': 'heading',
|
|
59
|
+
'empty_line': 'empty_line',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const MILIASTRY_TAG_TO_KIND_ENTRIES: Record<string, NodeKind> = {
|
|
63
|
+
...OSU_TAG_TO_KIND_ENTRIES,
|
|
64
|
+
// Extras de Miliastry: osu! real + cositas especiales propias. `font` vive
|
|
65
|
+
// aquí (y en Lyne), NO en osu: osu! solo tiene [size], no familia tipográfica.
|
|
66
|
+
'font': 'font',
|
|
67
|
+
'zalgo': 'zalgo',
|
|
68
|
+
'aesthetic': 'aesthetic',
|
|
69
|
+
'sparkle': 'sparkle',
|
|
70
|
+
'bubble': 'bubble',
|
|
71
|
+
'flower': 'flower',
|
|
72
|
+
'gradient': 'gradient',
|
|
73
|
+
'grow': 'grow',
|
|
74
|
+
'sinewave': 'sinewave',
|
|
75
|
+
'rainbow': 'rainbow',
|
|
76
|
+
'svg': 'svg',
|
|
77
|
+
'group': 'group',
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Tags canónicos de Lyne — la superficie REAL del dialecto. Cada familia tiene
|
|
82
|
+
* UNA sola forma: `effect`, `anim`, `container`, `style` (el tipo va como
|
|
83
|
+
* atributo: `[effect=glow:#hex]`, `[anim=typewriter]`, `[container=glass]`).
|
|
84
|
+
*
|
|
85
|
+
* Los tipos de efecto que no tienen tag propio (emboss, engrave, …) se
|
|
86
|
+
* alcanzan igualmente vía `[effect=emboss]`: no necesitan fila aquí.
|
|
87
|
+
*/
|
|
88
|
+
const LYNE_CANONICAL_TAG_TO_KIND: Record<string, NodeKind> = {
|
|
89
|
+
// Core & standard
|
|
90
|
+
'b': 'bold',
|
|
91
|
+
'i': 'italic',
|
|
92
|
+
'u': 'underline',
|
|
93
|
+
's': 'strikethrough',
|
|
94
|
+
'strike': 'strikethrough',
|
|
95
|
+
'color': 'color',
|
|
96
|
+
'colour': 'color',
|
|
97
|
+
'size': 'font_size',
|
|
98
|
+
'font': 'font',
|
|
99
|
+
'c': 'inline_code',
|
|
100
|
+
'code': 'code',
|
|
101
|
+
'url': 'url',
|
|
102
|
+
'email': 'email',
|
|
103
|
+
'profile': 'profile',
|
|
104
|
+
'guild': 'guild',
|
|
105
|
+
'map': 'map',
|
|
106
|
+
'img': 'image',
|
|
107
|
+
'youtube': 'video',
|
|
108
|
+
'audio': 'audio',
|
|
109
|
+
'video': 'video',
|
|
110
|
+
'imagemap': 'imagemap',
|
|
111
|
+
'heading': 'heading',
|
|
112
|
+
'notice': 'notice',
|
|
113
|
+
'wnotice': 'wnotice',
|
|
114
|
+
'quote': 'quote',
|
|
115
|
+
'box': 'box',
|
|
116
|
+
'boxw': 'boxw',
|
|
117
|
+
'spoilerbox': 'spoilerbox',
|
|
118
|
+
'spoiler': 'spoiler',
|
|
119
|
+
'list': 'list',
|
|
120
|
+
'*': 'list_item',
|
|
121
|
+
'centre': 'center',
|
|
122
|
+
'center': 'center',
|
|
123
|
+
'right': 'right',
|
|
124
|
+
'align': 'align',
|
|
125
|
+
'hr': 'separator',
|
|
126
|
+
'separator': 'separator',
|
|
127
|
+
'scroll': 'scroll',
|
|
128
|
+
'empty_line': 'empty_line',
|
|
129
|
+
|
|
130
|
+
// Tables
|
|
131
|
+
'tables': 'tables',
|
|
132
|
+
'row': 'table_row',
|
|
133
|
+
'col': 'table_col',
|
|
134
|
+
'th': 'table_th',
|
|
135
|
+
|
|
136
|
+
// Layout
|
|
137
|
+
'gallery': 'gallery',
|
|
138
|
+
'columns': 'columns',
|
|
139
|
+
|
|
140
|
+
// Inline / Typography
|
|
141
|
+
'sup': 'sup',
|
|
142
|
+
'sub': 'sub',
|
|
143
|
+
'abbr': 'abbr',
|
|
144
|
+
'mark': 'mark',
|
|
145
|
+
'kbd': 'kbd',
|
|
146
|
+
'tooltip': 'tooltip',
|
|
147
|
+
'flip': 'flip',
|
|
148
|
+
'gradient': 'gradient',
|
|
149
|
+
'raw': 'raw',
|
|
150
|
+
'noparse': 'raw',
|
|
151
|
+
'plain': 'plain',
|
|
152
|
+
|
|
153
|
+
// Consolidated
|
|
154
|
+
'effect': 'effect',
|
|
155
|
+
'anim': 'anim',
|
|
156
|
+
'container': 'container',
|
|
157
|
+
'style': 'style_tag',
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Grafías legacy que Lyne original aceptaba, mantenidas SOLO para que los
|
|
162
|
+
* posts antiguos sigan parseando. La forma canónica es la familia:
|
|
163
|
+
* `[effect=glow]`, `[anim=fade]`, `[container=glass]`, `[style=width:300px]`.
|
|
164
|
+
* El exporter ya normaliza a la forma canónica al re-exportar, así que el
|
|
165
|
+
* contenido legacy se auto-limpia con una edición. NO añadir más grafías
|
|
166
|
+
* aquí: un efecto o contenedor nuevo se usa con su familia (`[effect=tipo]`).
|
|
167
|
+
*/
|
|
168
|
+
const LYNE_LEGACY_ALIASES: Record<string, NodeKind> = {
|
|
169
|
+
// Effect legacy aliases
|
|
170
|
+
'glow': 'effect',
|
|
171
|
+
'neon': 'effect',
|
|
172
|
+
'outline': 'effect',
|
|
173
|
+
'shimmer': 'effect',
|
|
174
|
+
'ghost': 'effect',
|
|
175
|
+
'rainbow': 'effect',
|
|
176
|
+
'fire': 'effect',
|
|
177
|
+
'ice': 'effect',
|
|
178
|
+
|
|
179
|
+
// Anim legacy aliases
|
|
180
|
+
'typewriter': 'anim',
|
|
181
|
+
'wave': 'anim',
|
|
182
|
+
'sparkle': 'anim',
|
|
183
|
+
'glitch': 'anim',
|
|
184
|
+
'levitate': 'anim',
|
|
185
|
+
|
|
186
|
+
// Container legacy aliases
|
|
187
|
+
'stack': 'container',
|
|
188
|
+
'flex': 'container',
|
|
189
|
+
'grid': 'container',
|
|
190
|
+
'middle': 'container',
|
|
191
|
+
'circle': 'container',
|
|
192
|
+
'card': 'container',
|
|
193
|
+
'glass': 'container',
|
|
194
|
+
'neon-box': 'container',
|
|
195
|
+
'neonbox': 'container',
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Mapa completo de Lyne = canónicos + grafías legacy de compatibilidad.
|
|
200
|
+
*
|
|
201
|
+
* NOTA — podas realizadas (el texto queda literal, que es lo honesto):
|
|
202
|
+
* - `relief`, `gap`, `colspan`, `rowspan`: no hacían nada real (relief sin
|
|
203
|
+
* caso en el renderer, gap sin identidad, colspan/rowspan son atributos de
|
|
204
|
+
* `[col]`, no tags).
|
|
205
|
+
* - Los 18 aliases de estilo (`width`, `height`, `padding`, `margin`, …):
|
|
206
|
+
* rotos — pasaban el valor como CSS crudo (`[width=300]` → `style="300"`,
|
|
207
|
+
* inválido, el renderer lo descartaba). Solo `[style=…]` funciona.
|
|
208
|
+
* - `shadow`: el tipo de effect ignoraba el color y chocaba con el `shadow`
|
|
209
|
+
* de osu (significado distinto por dialecto). Eliminado de Lyne.
|
|
210
|
+
* - `fade`: duplicado exacto de `[anim=fade-in]`. Eliminado.
|
|
211
|
+
*/
|
|
212
|
+
const LYNE_TAG_TO_KIND_ENTRIES: Record<string, NodeKind> = {
|
|
213
|
+
...LYNE_CANONICAL_TAG_TO_KIND,
|
|
214
|
+
...LYNE_LEGACY_ALIASES,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const DIALECT_MAPS: Record<BBCodeDialect, Map<string, NodeKind>> = {
|
|
218
|
+
osu: new Map(Object.entries(OSU_TAG_TO_KIND_ENTRIES)),
|
|
219
|
+
miliastry: new Map(Object.entries(MILIASTRY_TAG_TO_KIND_ENTRIES)),
|
|
220
|
+
lyne: new Map(Object.entries(LYNE_TAG_TO_KIND_ENTRIES)),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const TAG_TO_KIND = DIALECT_MAPS.miliastry
|
|
224
|
+
|
|
225
|
+
const KIND_TO_TAG: Partial<Record<NodeKind, string>> = {}
|
|
226
|
+
for (const [tag, kind] of TAG_TO_KIND) {
|
|
227
|
+
KIND_TO_TAG[kind] = tag
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export const BBCODE_TAG_NAMES: readonly string[] = Object.freeze(
|
|
231
|
+
[...TAG_TO_KIND.keys()].sort(),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
export function getBBCodeTagNames(dialect: BBCodeDialect = 'miliastry'): readonly string[] {
|
|
235
|
+
// Para Lyne, la superficie visible es la CANÓNICA: los legacy aliases se
|
|
236
|
+
// siguen parseando (compatibilidad) pero no se listan — así un consumidor
|
|
237
|
+
// (autocomplete, docs) solo ofrece las formas canónicas.
|
|
238
|
+
if (dialect === 'lyne') return Object.freeze([...Object.keys(LYNE_CANONICAL_TAG_TO_KIND)].sort())
|
|
239
|
+
const map = DIALECT_MAPS[dialect] || DIALECT_MAPS.miliastry
|
|
240
|
+
return Object.freeze([...map.keys()].sort())
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function tagToNodeKind(tag: string | null, dialect: BBCodeDialect = 'miliastry'): NodeKind {
|
|
244
|
+
if (tag === null) return 'text'
|
|
245
|
+
const map = DIALECT_MAPS[dialect] || DIALECT_MAPS.miliastry
|
|
246
|
+
return map.get(tag) ?? 'custom'
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function nodeKindToTag(kind: NodeKind): string | null {
|
|
250
|
+
return KIND_TO_TAG[kind] ?? null
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─── Rendered kind — tags that produce inline or block HTML ──
|
|
254
|
+
|
|
255
|
+
const RENDERED_AS_BLOCK = new Set<NodeKind>([
|
|
256
|
+
'notice', 'wnotice', 'spoilerbox', 'box', 'boxw', 'list', 'quote', 'code', 'svg',
|
|
257
|
+
'heading', 'center', 'right', 'left', 'align', 'imagemap', 'document',
|
|
258
|
+
'list_item', 'spacing', 'empty_line', 'paragraph',
|
|
259
|
+
'tables', 'table_row', 'gallery', 'columns', 'separator', 'scroll',
|
|
260
|
+
'container',
|
|
261
|
+
])
|
|
262
|
+
|
|
263
|
+
export function isBlockKind(kind: NodeKind): boolean {
|
|
264
|
+
return RENDERED_AS_BLOCK.has(kind)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ─── BBBlock Interface (mirror of existing parser's type) ──
|
|
268
|
+
|
|
269
|
+
export interface BBBlock {
|
|
270
|
+
id: string
|
|
271
|
+
tag: string | null
|
|
272
|
+
attrs: string
|
|
273
|
+
content: string
|
|
274
|
+
rawStart: number
|
|
275
|
+
rawEnd: number
|
|
276
|
+
children: BBBlock[]
|
|
277
|
+
attrChildren?: BBBlock[]
|
|
278
|
+
html?: string
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ─── Converters ─────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Convert a single BBBlock to a GreenNode.
|
|
285
|
+
* Recursively converts children.
|
|
286
|
+
*/
|
|
287
|
+
export function bbBlockToGreenNode(block: BBBlock): GreenNode {
|
|
288
|
+
const kind = tagToNodeKind(block.tag)
|
|
289
|
+
const children: GreenNode[] = []
|
|
290
|
+
let text = ''
|
|
291
|
+
|
|
292
|
+
// For leaf blocks with a tag (self-closing like [*], or content like [img]src[/img])
|
|
293
|
+
if (block.tag === '*') {
|
|
294
|
+
// List item: content is in the attrs or in children
|
|
295
|
+
text = block.attrs || ''
|
|
296
|
+
} else if (block.tag === 'img' || block.tag === 'youtube' || block.tag === 'audio') {
|
|
297
|
+
// Media tags: content is in the text between tags
|
|
298
|
+
text = block.content
|
|
299
|
+
} else if (block.tag === null) {
|
|
300
|
+
// Text node
|
|
301
|
+
text = block.content
|
|
302
|
+
} else {
|
|
303
|
+
// Tag node with children
|
|
304
|
+
text = block.attrs || ''
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Convert children recursively
|
|
308
|
+
for (const child of block.children) {
|
|
309
|
+
children.push(bbBlockToGreenNode(child))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Handle attrChildren (nested BBCode inside attributes like [box=[color]Title[/color]])
|
|
313
|
+
if (block.attrChildren && block.attrChildren.length > 0) {
|
|
314
|
+
for (const attrChild of block.attrChildren) {
|
|
315
|
+
children.push(bbBlockToGreenNode(attrChild))
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// `rawStart`/`rawEnd` are gone: a green node's width comes from its children,
|
|
320
|
+
// or from its own text when it has none. This legacy bridge never fed the
|
|
321
|
+
// incremental parser, so nothing depended on the old absolute spans.
|
|
322
|
+
return children.length > 0
|
|
323
|
+
? greenNode(kind, text, children)
|
|
324
|
+
: greenLeaf(kind, text, block.rawEnd - block.rawStart)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Convert an array of BBBlock[] (the root of the existing parser's output)
|
|
329
|
+
* to a GreenNode tree.
|
|
330
|
+
*
|
|
331
|
+
* Note: This function is kept for backward compatibility with the old parser.
|
|
332
|
+
* The new DocumentEngine parser (Parser.ts + BBCodeLexer) produces GreenNode
|
|
333
|
+
* directly and does NOT go through BBBlock[].
|
|
334
|
+
*/
|
|
335
|
+
export function bbBlocksToGreenTree(blocks: BBBlock[], source: string): GreenNode {
|
|
336
|
+
const children: GreenNode[] = []
|
|
337
|
+
for (const block of blocks) {
|
|
338
|
+
children.push(bbBlockToGreenNode(block))
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return greenNode('document', '', children)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Extract metadata from BBCode attributes stored in a GreenNode's text.
|
|
346
|
+
*
|
|
347
|
+
* The GreenNode stores `block.attrs` in its `text` field for tag nodes.
|
|
348
|
+
* BBCode attrs have the format `=VALUE` (e.g. `=#61afef`, `="Author"`,
|
|
349
|
+
* `=https://osu.ppy.sh`). This function parses them into typed metadata.
|
|
350
|
+
*
|
|
351
|
+
* For media tags (image, video, audio), the content may be in children
|
|
352
|
+
* rather than attrs (e.g. `[img]url[/img]` vs `[img=url]`).
|
|
353
|
+
* When attrs are empty, we fall back to the first child text node.
|
|
354
|
+
*/
|
|
355
|
+
/**
|
|
356
|
+
* Get the URL from the first child text node (fallback for `[img]url[/img]`).
|
|
357
|
+
*
|
|
358
|
+
* Module-level on purpose: as a closure inside `extractGreenNodeMetadata` this
|
|
359
|
+
* was allocated for every node in the document, including the vast majority
|
|
360
|
+
* whose `switch` branch never calls it.
|
|
361
|
+
*/
|
|
362
|
+
function firstChildText(green: GreenNode): string {
|
|
363
|
+
if (green.children.length > 0) {
|
|
364
|
+
const first = green.children[0] as GreenNode
|
|
365
|
+
if (first.kind === 'text' && first.text) return first.text
|
|
366
|
+
}
|
|
367
|
+
return ''
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const BBCODE_TAG_RE = /\[\/?[a-zA-Z0-9_*-]+=?[^\]]*\]/g
|
|
371
|
+
|
|
372
|
+
/** Strip BBCode tags from text for clean display (e.g. `[b]title[/b]` → `title`) */
|
|
373
|
+
function stripBBCode(raw: string): string {
|
|
374
|
+
return raw.replace(BBCODE_TAG_RE, '').trim()
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function extractGreenNodeMetadata(green: GreenNode): Record<string, unknown> {
|
|
378
|
+
const kind = green.kind as NodeKind
|
|
379
|
+
if (kind === 'text') return {}
|
|
380
|
+
|
|
381
|
+
const rawText = green.text || ''
|
|
382
|
+
|
|
383
|
+
// Strip `=` prefix and surrounding quotes
|
|
384
|
+
let value = rawText
|
|
385
|
+
const eqIdx = value.indexOf('=')
|
|
386
|
+
if (eqIdx >= 0) {
|
|
387
|
+
value = value.slice(eqIdx + 1)
|
|
388
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
389
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
390
|
+
value = value.slice(1, -1)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
switch (kind) {
|
|
395
|
+
case 'color': return { color: value }
|
|
396
|
+
case 'font_size':return { size: value }
|
|
397
|
+
case 'font': return { font: value }
|
|
398
|
+
case 'url': return { href: value || firstChildText(green) }
|
|
399
|
+
case 'email': return { href: `mailto:${value}` }
|
|
400
|
+
case 'profile': return { username: value }
|
|
401
|
+
case 'quote': return { source: value }
|
|
402
|
+
case 'spoilerbox':
|
|
403
|
+
case 'box':
|
|
404
|
+
case 'boxw':
|
|
405
|
+
// boxw es el box con líneas y fondo (estilo Lyne): se marca `styled`
|
|
406
|
+
// para que el renderer emita la clase que dispara ese look. El box
|
|
407
|
+
// normal ([box]) queda limpio por defecto.
|
|
408
|
+
//
|
|
409
|
+
// Sufijo de color `:#hex` (como `[container=neon-box:#FF0055]`):
|
|
410
|
+
// `[box=Mi Caja:#FF0055]` separa el color del título; el color se guarda
|
|
411
|
+
// aparte para que el renderer lo aplique como `--box-accent` y el
|
|
412
|
+
// exporter lo vuelva a emitir en el round-trip.
|
|
413
|
+
const colorMatch = /:#[0-9a-fA-F]{3,8}$/.exec(value)
|
|
414
|
+
const color = colorMatch ? colorMatch[0].slice(1) : undefined
|
|
415
|
+
const titleValue = colorMatch ? value.slice(0, colorMatch.index) : value
|
|
416
|
+
return {
|
|
417
|
+
title: stripBBCode(titleValue) || (kind === 'box' || kind === 'boxw' ? 'Box' : 'Spoiler'),
|
|
418
|
+
rawTitle: titleValue,
|
|
419
|
+
...(color ? { color } : {}),
|
|
420
|
+
...(kind === 'boxw' ? { styled: true } : {}),
|
|
421
|
+
}
|
|
422
|
+
case 'list': return { ordered: value === '1' || value === 'a' }
|
|
423
|
+
case 'image': {
|
|
424
|
+
// En el dialecto Lyne el atributo del tag es el TAMAÑO o modificador
|
|
425
|
+
// (`[img=400x300]url[/img]`, `[img round]url[/img]`) y la URL va como
|
|
426
|
+
// contenido — igual que en el renderer original (parseImgAttr(node.attr)
|
|
427
|
+
// + textOf(node.children)). Guardar `value` como src rompía la imagen:
|
|
428
|
+
// con `[img=400x300]url[/img]` el src quedaba "400x300" y la URL se perdía.
|
|
429
|
+
// Si el atributo parece una URL, es la forma `[img=url]` (sin contenido)
|
|
430
|
+
// y entonces sí es el src.
|
|
431
|
+
const looksLikeUrl = /^https?:\/\//i.test(value)
|
|
432
|
+
const src = looksLikeUrl ? value : (firstChildText(green) || undefined)
|
|
433
|
+
// imgAttr conserva el tamaño/modificador original (`400x300`, `round`, …)
|
|
434
|
+
// para que el exporter pueda reproducir `[img=400x300]` en el round-trip.
|
|
435
|
+
const imgAttr = looksLikeUrl ? undefined : (value || undefined)
|
|
436
|
+
return { src, imgAttr }
|
|
437
|
+
}
|
|
438
|
+
case 'video': return { videoId: value || firstChildText(green) }
|
|
439
|
+
case 'audio': return { src: value || firstChildText(green) }
|
|
440
|
+
case 'gradient':
|
|
441
|
+
// Parse "=#ff0000,#00ff00" → { colors: ['#FF0000', '#00FF00'] }
|
|
442
|
+
const gradientColors = value.split(',').map(c => c.trim()).filter(c => c.startsWith('#'))
|
|
443
|
+
return gradientColors.length > 0 ? { colors: gradientColors } : {}
|
|
444
|
+
case 'notice':
|
|
445
|
+
case 'wnotice': return value ? { color: value } : {}
|
|
446
|
+
case 'tables':
|
|
447
|
+
case 'columns': {
|
|
448
|
+
// Sufijo de color `:#hex` (como en box): `[tables=striped:#FF0055]` y
|
|
449
|
+
// `[columns=2:#FF0055]`. De ese color se deriva toda la paleta (bordes,
|
|
450
|
+
// filas, encabezado) vía `--table-accent` / `--columns-accent`.
|
|
451
|
+
if (!value) return {}
|
|
452
|
+
const colorMatch = /:#[0-9a-fA-F]{3,8}$/.exec(value)
|
|
453
|
+
const clean = colorMatch ? value.slice(0, colorMatch.index) : value
|
|
454
|
+
const color = colorMatch ? colorMatch[0].slice(1) : undefined
|
|
455
|
+
const base = kind === 'tables' ? { variant: clean } : { columns: clean }
|
|
456
|
+
return color ? { ...base, color } : base
|
|
457
|
+
}
|
|
458
|
+
case 'separator':return value ? { variant: value } : {}
|
|
459
|
+
case 'scroll': return value ? { height: value } : {}
|
|
460
|
+
case 'abbr': return value ? { title: value } : {}
|
|
461
|
+
case 'tooltip': return value ? { tip: value } : {}
|
|
462
|
+
case 'guild': return value ? { tag: value } : {}
|
|
463
|
+
case 'map': return value ? { id: value } : {}
|
|
464
|
+
case 'align': return value ? { align: value } : {}
|
|
465
|
+
case 'effect': {
|
|
466
|
+
if (!value) return {}
|
|
467
|
+
if (value.includes(':')) {
|
|
468
|
+
const [effectType, ...rest] = value.split(':')
|
|
469
|
+
return { effectType, color: rest.join(':') }
|
|
470
|
+
}
|
|
471
|
+
if (value.startsWith('#')) return { effectType: 'glow', color: value }
|
|
472
|
+
return { effectType: value }
|
|
473
|
+
}
|
|
474
|
+
case 'anim': {
|
|
475
|
+
if (!value) return {}
|
|
476
|
+
if (value.includes(':')) {
|
|
477
|
+
const [animType, ...rest] = value.split(':')
|
|
478
|
+
return { animType, param: rest.join(':') }
|
|
479
|
+
}
|
|
480
|
+
return { animType: value }
|
|
481
|
+
}
|
|
482
|
+
case 'container':return value ? { containerType: value } : {}
|
|
483
|
+
case 'style_tag':return value ? { style: value } : {}
|
|
484
|
+
case 'list_item':return {} // No metadata for list items
|
|
485
|
+
default: return {}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Parse rich BBCode inside a container attribute (e.g. `[box=[b]Title[/b]]`).
|
|
491
|
+
* Produces a list of RedNode children for the title slot.
|
|
492
|
+
*/
|
|
493
|
+
function buildTitleNodes(
|
|
494
|
+
rawTitle: string,
|
|
495
|
+
parent: RedNode,
|
|
496
|
+
store?: RedNodeStore,
|
|
497
|
+
start: number = 0,
|
|
498
|
+
): RedNode[] {
|
|
499
|
+
if (!rawTitle || !rawTitle.includes('[')) return []
|
|
500
|
+
try {
|
|
501
|
+
const tokens = scanBBCode(rawTitle)
|
|
502
|
+
const greenTitle = parseTokensToGreen(tokens, rawTitle, { normalizeParagraphs: false })
|
|
503
|
+
const redTitle = greenToRedNode(greenTitle, null, store, start)
|
|
504
|
+
for (const child of redTitle.children) {
|
|
505
|
+
child.parent = parent
|
|
506
|
+
}
|
|
507
|
+
return redTitle.children
|
|
508
|
+
} catch {
|
|
509
|
+
return []
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Build a RedNode tree from a GreenNode.
|
|
515
|
+
* Similar to TreeBuilder.buildRed but uses actual kind from the green node
|
|
516
|
+
* and extracts BBCode metadata from attributes.
|
|
517
|
+
*
|
|
518
|
+
* When a RedNodeStore is provided, instance RedNodes are created with
|
|
519
|
+
* correct parent references copied from the canonical's metadata.
|
|
520
|
+
* The canonicalId (from green._hash) enables:
|
|
521
|
+
* - React.memo in BBCodeCanvas by canonicalId
|
|
522
|
+
* - HTMLRenderer cache by canonicalId
|
|
523
|
+
* - Diff optimization (same canonicalId = unchanged subtree)
|
|
524
|
+
* - Future PositionRef-based sharing
|
|
525
|
+
*/
|
|
526
|
+
export function greenToRedNode(
|
|
527
|
+
green: GreenNode,
|
|
528
|
+
parent?: RedNode | null,
|
|
529
|
+
store?: RedNodeStore,
|
|
530
|
+
start: number = 0,
|
|
531
|
+
): RedNode {
|
|
532
|
+
// Absolute offsets are woven in here, on the way down: a child begins where
|
|
533
|
+
// its parent's opening delimiter ends, and each sibling after the previous
|
|
534
|
+
// one. Green nodes carry only widths (see `GreenNode.ts`), so this walk is
|
|
535
|
+
// the single place a position comes into existence — and it costs one
|
|
536
|
+
// addition per node.
|
|
537
|
+
if (store) {
|
|
538
|
+
// Get/create canonical node (stores metadata extracted from green.text)
|
|
539
|
+
const canonical = store.getOrCreate(green)
|
|
540
|
+
|
|
541
|
+
// Create a NEW instance RedNode that:
|
|
542
|
+
// - Has correct parent reference (position-specific)
|
|
543
|
+
// - Has its OWN children array (position-specific)
|
|
544
|
+
// - Has the SAME canonicalId (enables identity-based optimizations)
|
|
545
|
+
// - Has the SAME metadata as canonical (copied)
|
|
546
|
+
const instance = new RedNode(green, {
|
|
547
|
+
parent: parent ?? null,
|
|
548
|
+
kind: green.kind as NodeKind,
|
|
549
|
+
metadata: { ...canonical.metadata },
|
|
550
|
+
start,
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
if ((green.kind === 'box' || green.kind === 'boxw' || green.kind === 'spoilerbox') && instance.metadata.rawTitle) {
|
|
554
|
+
const rawTitle = String(instance.metadata.rawTitle)
|
|
555
|
+
const tagName = green.kind
|
|
556
|
+
const rawText = green.text || ''
|
|
557
|
+
const isQuoted = (rawText.startsWith('="') && rawText.endsWith('"')) || (rawText.startsWith("='") && rawText.endsWith("'"))
|
|
558
|
+
const offsetToTitle = start + 1 + tagName.length + 1 + (isQuoted ? 1 : 0)
|
|
559
|
+
const titleNodes = buildTitleNodes(rawTitle, instance, store, offsetToTitle)
|
|
560
|
+
if (titleNodes.length > 0) {
|
|
561
|
+
instance.metadata.titleNodes = titleNodes
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const greenChildren = green.children as GreenNode[]
|
|
566
|
+
if (greenChildren.length > 0) {
|
|
567
|
+
const kids: RedNode[] = new Array(greenChildren.length)
|
|
568
|
+
let offset = start + green.leadingWidth
|
|
569
|
+
for (let i = 0; i < greenChildren.length; i++) {
|
|
570
|
+
kids[i] = greenToRedNode(greenChildren[i], instance, store, offset)
|
|
571
|
+
offset += greenChildren[i].width
|
|
572
|
+
}
|
|
573
|
+
instance.initChildren(kids)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return instance
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Legacy path without store (backward compat)
|
|
580
|
+
const red = new RedNode(green, {
|
|
581
|
+
parent: parent ?? null,
|
|
582
|
+
kind: green.kind as NodeKind,
|
|
583
|
+
metadata: extractGreenNodeMetadata(green),
|
|
584
|
+
start,
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
if ((green.kind === 'box' || green.kind === 'boxw' || green.kind === 'spoilerbox') && red.metadata.rawTitle) {
|
|
588
|
+
const rawTitle = String(red.metadata.rawTitle)
|
|
589
|
+
const tagName = green.kind
|
|
590
|
+
const rawText = green.text || ''
|
|
591
|
+
const isQuoted = (rawText.startsWith('="') && rawText.endsWith('"')) || (rawText.startsWith("='") && rawText.endsWith("'"))
|
|
592
|
+
const offsetToTitle = start + 1 + tagName.length + 1 + (isQuoted ? 1 : 0)
|
|
593
|
+
const titleNodes = buildTitleNodes(rawTitle, red, store, offsetToTitle)
|
|
594
|
+
if (titleNodes.length > 0) {
|
|
595
|
+
red.metadata.titleNodes = titleNodes
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const greenChildren = green.children as GreenNode[]
|
|
600
|
+
if (greenChildren.length > 0) {
|
|
601
|
+
const kids: RedNode[] = new Array(greenChildren.length)
|
|
602
|
+
let offset = start + green.leadingWidth
|
|
603
|
+
for (let i = 0; i < greenChildren.length; i++) {
|
|
604
|
+
kids[i] = greenToRedNode(greenChildren[i], red, store, offset)
|
|
605
|
+
offset += greenChildren[i].width
|
|
606
|
+
}
|
|
607
|
+
red.initChildren(kids)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return red
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ─── Red-tree reuse across incremental reparses ─────────────
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Build the red tree for `green`, adopting subtrees of the PREVIOUS red tree
|
|
617
|
+
* wherever the new green shares a green node by reference with the old one.
|
|
618
|
+
*
|
|
619
|
+
* The incremental splice (`spliceGreen`) rebuilds only the spine of ancestors
|
|
620
|
+
* around an edit; every untouched sibling keeps its exact green object. Yet
|
|
621
|
+
* `greenToRedNode` reconstructed all ~1700 red nodes on every keystroke —
|
|
622
|
+
* measured as the single largest phase of a keystroke (0.30 of 0.94 ms).
|
|
623
|
+
* Reference equality of greens is proof the subtree did not change, so its old
|
|
624
|
+
* red subtree — ids, metadata, diagnostics and all — can be adopted wholesale.
|
|
625
|
+
* Adoption is one `parent` reassignment (done by `initChildren`) plus, only
|
|
626
|
+
* when the subtree moved, a `setStart` walk that adds a delta to two ints per
|
|
627
|
+
* node. Both are far cheaper than re-extracting metadata and reallocating.
|
|
628
|
+
*
|
|
629
|
+
* ⚠ Contract: the OLD red tree is consumed. Adopted subtrees are reparented
|
|
630
|
+
* into the new tree, so the previous `redRoot` must not be used again after
|
|
631
|
+
* this returns. Nothing in the engine or the app reads a superseded red tree
|
|
632
|
+
* (verified), and `DocumentModelOptions.reuseRed` is the kill-switch if a
|
|
633
|
+
* future consumer ever needs the old tree to stay intact.
|
|
634
|
+
*
|
|
635
|
+
* The child walk mirrors `NodeMatcher` Phase 0 / `preserveNodeIds`: trim the
|
|
636
|
+
* common prefix and suffix by green reference, descend into the changed window
|
|
637
|
+
* only when it is the single-child shape a keystroke produces, and build the
|
|
638
|
+
* rest fresh. Positional lockstep also makes double-adoption impossible: each
|
|
639
|
+
* old red child is adopted at most once, even when interning makes distinct
|
|
640
|
+
* positions share one green object.
|
|
641
|
+
*/
|
|
642
|
+
export function greenToRedNodeReusing(
|
|
643
|
+
green: GreenNode,
|
|
644
|
+
oldRed: RedNode,
|
|
645
|
+
start: number = 0,
|
|
646
|
+
stats?: { adopted: number },
|
|
647
|
+
): RedNode {
|
|
648
|
+
if (green === oldRed.green) {
|
|
649
|
+
// `setStart` records the shift lazily (no subtree walk) and no-ops when the
|
|
650
|
+
// offset did not move, so the former `oldRed.range.start !== start` guard —
|
|
651
|
+
// which READ the range, forcing a lazy materialization — is unnecessary.
|
|
652
|
+
oldRed.setStart(start)
|
|
653
|
+
oldRed.parent = null
|
|
654
|
+
if (stats) stats.adopted++
|
|
655
|
+
return oldRed
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const red = new RedNode(green, {
|
|
659
|
+
parent: null,
|
|
660
|
+
kind: green.kind as NodeKind,
|
|
661
|
+
metadata: extractGreenNodeMetadata(green),
|
|
662
|
+
start,
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
if ((green.kind === 'box' || green.kind === 'boxw' || green.kind === 'spoilerbox') && red.metadata.rawTitle) {
|
|
666
|
+
const rawTitle = String(red.metadata.rawTitle)
|
|
667
|
+
const tagName = green.kind
|
|
668
|
+
const rawText = green.text || ''
|
|
669
|
+
const isQuoted = (rawText.startsWith('="') && rawText.endsWith('"')) || (rawText.startsWith("='") && rawText.endsWith("'"))
|
|
670
|
+
const offsetToTitle = start + 1 + tagName.length + 1 + (isQuoted ? 1 : 0)
|
|
671
|
+
const titleNodes = buildTitleNodes(rawTitle, red, undefined, offsetToTitle)
|
|
672
|
+
if (titleNodes.length > 0) {
|
|
673
|
+
red.metadata.titleNodes = titleNodes
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const greenKids = green.children as GreenNode[]
|
|
678
|
+
const oldKids = oldRed.children
|
|
679
|
+
if (greenKids.length === 0) return red
|
|
680
|
+
|
|
681
|
+
// Absolute start of every green child, needed by the suffix walk, which
|
|
682
|
+
// cannot accumulate forward.
|
|
683
|
+
const offsets: number[] = new Array(greenKids.length)
|
|
684
|
+
let offset = start + green.leadingWidth
|
|
685
|
+
for (let i = 0; i < greenKids.length; i++) {
|
|
686
|
+
offsets[i] = offset
|
|
687
|
+
offset += greenKids[i].width
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const kids: RedNode[] = new Array(greenKids.length)
|
|
691
|
+
const limit = Math.min(greenKids.length, oldKids.length)
|
|
692
|
+
|
|
693
|
+
// Common prefix: same green object, adopt.
|
|
694
|
+
let lo = 0
|
|
695
|
+
while (lo < limit && greenKids[lo] === oldKids[lo].green) {
|
|
696
|
+
kids[lo] = adoptShifted(oldKids[lo], offsets[lo], stats)
|
|
697
|
+
lo++
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Common suffix, stopping before the prefix already consumed.
|
|
701
|
+
let gHi = greenKids.length - 1
|
|
702
|
+
let oHi = oldKids.length - 1
|
|
703
|
+
while (gHi >= lo && oHi >= lo && greenKids[gHi] === oldKids[oHi].green) {
|
|
704
|
+
kids[gHi] = adoptShifted(oldKids[oHi], offsets[gHi], stats)
|
|
705
|
+
gHi--
|
|
706
|
+
oHi--
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (gHi === lo && oHi === lo) {
|
|
710
|
+
// The single changed child both sides — the keystroke shape. Descend so
|
|
711
|
+
// its own untouched children are still adopted.
|
|
712
|
+
kids[lo] = greenToRedNodeReusing(greenKids[lo], oldKids[lo], offsets[lo], stats)
|
|
713
|
+
} else {
|
|
714
|
+
// A wider window (multi-node paste, fallback rebuild): build it fresh.
|
|
715
|
+
for (let i = lo; i <= gHi; i++) {
|
|
716
|
+
kids[i] = greenToRedNode(greenKids[i], null, undefined, offsets[i])
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Sets parent and index cache on every child, adopted or fresh.
|
|
721
|
+
red.initChildren(kids)
|
|
722
|
+
return red
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** Adopt an old red subtree at (possibly) a new absolute offset. */
|
|
726
|
+
function adoptShifted(oldRed: RedNode, start: number, stats?: { adopted: number }): RedNode {
|
|
727
|
+
// Lazy shift: records the delta without walking the subtree (see
|
|
728
|
+
// `RedNode.setStart`). The former range read here would force a
|
|
729
|
+
// materialization, defeating the whole point.
|
|
730
|
+
oldRed.setStart(start)
|
|
731
|
+
if (stats) stats.adopted++
|
|
732
|
+
return oldRed
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* A `RedNodeStore` wired with BBCode metadata semantics.
|
|
737
|
+
*
|
|
738
|
+
* `RedNodeStore` used to carry its own verbatim copy of
|
|
739
|
+
* `extractGreenNodeMetadata` — its comment even said *"Mirrors
|
|
740
|
+
* extractGreenNodeMetadata"* — which meant two sources of truth for the
|
|
741
|
+
* attrs→metadata mapping, and they had already drifted apart. The store now
|
|
742
|
+
* takes the extractor as a dependency; this is the BBCode wiring.
|
|
743
|
+
*/
|
|
744
|
+
export function createBBCodeRedNodeStore(): RedNodeStore {
|
|
745
|
+
return new RedNodeStore(extractGreenNodeMetadata)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Convert a BBBlock array directly to a RedNode root.
|
|
750
|
+
* This is the main entry point for the bridge.
|
|
751
|
+
*/
|
|
752
|
+
export function bbBlocksToRedTree(blocks: BBBlock[], source: string): RedNode {
|
|
753
|
+
const greenTree = bbBlocksToGreenTree(blocks, source)
|
|
754
|
+
return greenToRedNode(greenTree)
|
|
755
|
+
}
|