@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,717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar — Tree Transformers
|
|
3
|
+
*
|
|
4
|
+
* Apply effects (gradient, grow, rainbow, central, multi) across an entire RedNode tree.
|
|
5
|
+
* These functions walk the tree, track global text position,
|
|
6
|
+
* and wrap each text leaf in internal effect nodes
|
|
7
|
+
* with position metadata so the TagRegistry handlers produce
|
|
8
|
+
* correctly interpolated output.
|
|
9
|
+
*
|
|
10
|
+
* DESIGN:
|
|
11
|
+
* Two-pass approach:
|
|
12
|
+
* 1. Collect total text length (for normalization)
|
|
13
|
+
* 2. Walk again, wrapping text nodes with effect nodes
|
|
14
|
+
*
|
|
15
|
+
* Non-text nodes (img, video, audio, code blocks) are preserved unchanged.
|
|
16
|
+
* Structural nodes (box, quote, list) recurse into children.
|
|
17
|
+
* Formatting nodes (b, i, u, color) recurse — effects go INSIDE formatting.
|
|
18
|
+
*
|
|
19
|
+
* Example:
|
|
20
|
+
* Input: [box][b]Hello[/b] World[/box]
|
|
21
|
+
* Output: [box][b][gradient][b]H[/b][gradient][b]e[/b]...[/b]
|
|
22
|
+
* [gradient] W[/gradient]...[/gradient]
|
|
23
|
+
*
|
|
24
|
+
* Actually simpler — each text leaf gets its own gradient node with
|
|
25
|
+
* globalOffset metadata, and the handler does the interpolation.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { greenNode, greenLeaf } from '../Syntax/GreenNode'
|
|
29
|
+
import { RedNode } from '../Syntax/RedNode'
|
|
30
|
+
import type { Easing } from './color'
|
|
31
|
+
import { mixMultiple, hslToHex, ease, clamp } from './color'
|
|
32
|
+
|
|
33
|
+
// ─── Types ──────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export interface GradientEffect {
|
|
36
|
+
kind: 'gradient'
|
|
37
|
+
colors: string[]
|
|
38
|
+
unit?: 'character' | 'word' | 'line'
|
|
39
|
+
easing?: Easing
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface GrowEffect {
|
|
43
|
+
kind: 'grow'
|
|
44
|
+
min?: number
|
|
45
|
+
max?: number
|
|
46
|
+
cycles?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RainbowEffect {
|
|
50
|
+
kind: 'rainbow'
|
|
51
|
+
saturation?: number
|
|
52
|
+
lightness?: number
|
|
53
|
+
spread?: number
|
|
54
|
+
offset?: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CentralGradientEffect {
|
|
58
|
+
kind: 'central_gradient'
|
|
59
|
+
colors: string[]
|
|
60
|
+
unit?: 'character' | 'word' | 'line'
|
|
61
|
+
easing?: Easing
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface MultiGradientEffect {
|
|
65
|
+
kind: 'multi_gradient'
|
|
66
|
+
colors: string[]
|
|
67
|
+
mode?: 'wave' | 'pulse'
|
|
68
|
+
unit?: 'character' | 'word' | 'line'
|
|
69
|
+
easing?: Easing
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type TreeEffect = GradientEffect | GrowEffect | RainbowEffect | CentralGradientEffect | MultiGradientEffect
|
|
73
|
+
|
|
74
|
+
// ─── Kinds that should NOT be recursed into ─────────────────
|
|
75
|
+
// These are leaf containers that hold content but not text nodes
|
|
76
|
+
// we'd want to colorize.
|
|
77
|
+
|
|
78
|
+
const LEAF_KINDS = new Set([
|
|
79
|
+
'image', 'video', 'audio', 'imagemap',
|
|
80
|
+
'code', 'inline_code',
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
// ─── Helpers ────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Count total plain text length across all text leaves in a tree.
|
|
87
|
+
*/
|
|
88
|
+
export function countTextLength(node: RedNode): number {
|
|
89
|
+
if (node.kind === 'text') return node.text.length
|
|
90
|
+
if (LEAF_KINDS.has(node.kind)) return 0
|
|
91
|
+
return node.children.reduce((sum: number, child: RedNode) => sum + countTextLength(child), 0)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Deep-clone a RedNode tree (preserves kind and metadata, creates new nodes).
|
|
96
|
+
*/
|
|
97
|
+
function cloneTree(node: RedNode): RedNode {
|
|
98
|
+
const cloned = new RedNode(node.green, {
|
|
99
|
+
kind: node.kind,
|
|
100
|
+
metadata: { ...node.metadata },
|
|
101
|
+
})
|
|
102
|
+
for (const child of node.children) {
|
|
103
|
+
cloned.appendChild(cloneTree(child))
|
|
104
|
+
}
|
|
105
|
+
return cloned
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Wrap a text node's characters/words in effect nodes,
|
|
110
|
+
* returning an array of nodes that replace the original text node.
|
|
111
|
+
*
|
|
112
|
+
* For character unit: returns N gradient nodes (one per char)
|
|
113
|
+
* Each node: gradient(metadata: {globalOffset, documentLength}) → text(char)
|
|
114
|
+
*
|
|
115
|
+
* For word unit: returns N gradient nodes (one per word)
|
|
116
|
+
* Each node: gradient(metadata: {globalOffset, documentLength}) → text(word)
|
|
117
|
+
*/
|
|
118
|
+
function wrapTextWithGradient(
|
|
119
|
+
node: RedNode,
|
|
120
|
+
colors: string[],
|
|
121
|
+
globalOffset: number,
|
|
122
|
+
documentLength: number,
|
|
123
|
+
unit: string,
|
|
124
|
+
easing: Easing,
|
|
125
|
+
): RedNode {
|
|
126
|
+
const text = node.text
|
|
127
|
+
if (!text) return node
|
|
128
|
+
|
|
129
|
+
const segments = unit === 'word'
|
|
130
|
+
? text.split(/(\s+)/).filter((s: string) => s.length > 0)
|
|
131
|
+
: unit === 'line'
|
|
132
|
+
? text.split('\n')
|
|
133
|
+
: [...text]
|
|
134
|
+
|
|
135
|
+
if (segments.length === 0) return node
|
|
136
|
+
|
|
137
|
+
// Create a parent group node to hold all the segments
|
|
138
|
+
const groupGreen = greenLeaf('group', '')
|
|
139
|
+
const group = new RedNode(groupGreen, { kind: 'group' })
|
|
140
|
+
|
|
141
|
+
let pos = globalOffset
|
|
142
|
+
for (const seg of segments) {
|
|
143
|
+
const textGreen = greenLeaf('text', seg)
|
|
144
|
+
const textRed = new RedNode(textGreen, { kind: 'text' })
|
|
145
|
+
|
|
146
|
+
if (/^\s+$/.test(seg)) {
|
|
147
|
+
group.appendChild(textRed)
|
|
148
|
+
} else {
|
|
149
|
+
const gradGreen = greenLeaf('gradient', '')
|
|
150
|
+
const gradRed = new RedNode(gradGreen, {
|
|
151
|
+
kind: 'gradient',
|
|
152
|
+
metadata: {
|
|
153
|
+
colors,
|
|
154
|
+
unit,
|
|
155
|
+
easing,
|
|
156
|
+
globalOffset: pos,
|
|
157
|
+
documentLength,
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
gradRed.appendChild(textRed)
|
|
161
|
+
group.appendChild(gradRed)
|
|
162
|
+
}
|
|
163
|
+
pos += seg.length
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return group
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Wrap a text node's characters in grow nodes.
|
|
171
|
+
*/
|
|
172
|
+
function wrapTextWithGrow(
|
|
173
|
+
node: RedNode,
|
|
174
|
+
min: number,
|
|
175
|
+
max: number,
|
|
176
|
+
cycles: number,
|
|
177
|
+
globalOffset: number,
|
|
178
|
+
documentLength: number,
|
|
179
|
+
): RedNode {
|
|
180
|
+
const text = node.text
|
|
181
|
+
if (!text) return node
|
|
182
|
+
|
|
183
|
+
const chars = [...text]
|
|
184
|
+
if (chars.length === 0) return node
|
|
185
|
+
|
|
186
|
+
const groupGreen = greenLeaf('group', '')
|
|
187
|
+
const group = new RedNode(groupGreen, { kind: 'group' })
|
|
188
|
+
|
|
189
|
+
let pos = globalOffset
|
|
190
|
+
for (const ch of chars) {
|
|
191
|
+
const textGreen = greenLeaf('text', ch)
|
|
192
|
+
const textRed = new RedNode(textGreen, { kind: 'text' })
|
|
193
|
+
|
|
194
|
+
if (/^\s+$/.test(ch)) {
|
|
195
|
+
group.appendChild(textRed)
|
|
196
|
+
} else {
|
|
197
|
+
const growGreen = greenLeaf('grow', '')
|
|
198
|
+
const growRed = new RedNode(growGreen, {
|
|
199
|
+
kind: 'grow',
|
|
200
|
+
metadata: { min, max, cycles, globalOffset: pos, documentLength },
|
|
201
|
+
})
|
|
202
|
+
growRed.appendChild(textRed)
|
|
203
|
+
group.appendChild(growRed)
|
|
204
|
+
}
|
|
205
|
+
pos += ch.length
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return group
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Wrap a text node's characters in rainbow nodes.
|
|
213
|
+
*/
|
|
214
|
+
function wrapTextWithRainbow(
|
|
215
|
+
node: RedNode,
|
|
216
|
+
saturation: number,
|
|
217
|
+
lightness: number,
|
|
218
|
+
spread: number,
|
|
219
|
+
offset: number,
|
|
220
|
+
globalOffset: number,
|
|
221
|
+
documentLength: number,
|
|
222
|
+
): RedNode {
|
|
223
|
+
const text = node.text
|
|
224
|
+
if (!text) return node
|
|
225
|
+
|
|
226
|
+
const chars = [...text]
|
|
227
|
+
if (chars.length === 0) return node
|
|
228
|
+
|
|
229
|
+
const groupGreen = greenLeaf('group', '')
|
|
230
|
+
const group = new RedNode(groupGreen, { kind: 'group' })
|
|
231
|
+
|
|
232
|
+
let pos = globalOffset
|
|
233
|
+
for (const ch of chars) {
|
|
234
|
+
const textGreen = greenLeaf('text', ch)
|
|
235
|
+
const textRed = new RedNode(textGreen, { kind: 'text' })
|
|
236
|
+
|
|
237
|
+
if (/^\s+$/.test(ch)) {
|
|
238
|
+
group.appendChild(textRed)
|
|
239
|
+
} else {
|
|
240
|
+
const rainbowGreen = greenLeaf('rainbow', '')
|
|
241
|
+
const rainbowRed = new RedNode(rainbowGreen, {
|
|
242
|
+
kind: 'rainbow',
|
|
243
|
+
metadata: {
|
|
244
|
+
saturation,
|
|
245
|
+
lightness,
|
|
246
|
+
spread,
|
|
247
|
+
offset,
|
|
248
|
+
globalOffset: pos,
|
|
249
|
+
documentLength,
|
|
250
|
+
},
|
|
251
|
+
})
|
|
252
|
+
rainbowRed.appendChild(textRed)
|
|
253
|
+
group.appendChild(rainbowRed)
|
|
254
|
+
}
|
|
255
|
+
pos += ch.length
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return group
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Wrap a text node's characters in gradient nodes for central mode.
|
|
263
|
+
* Central mode: gradient from center outward (mirrored).
|
|
264
|
+
*/
|
|
265
|
+
function wrapTextWithCentralGradient(
|
|
266
|
+
node: RedNode,
|
|
267
|
+
colors: string[],
|
|
268
|
+
globalOffset: number,
|
|
269
|
+
documentLength: number,
|
|
270
|
+
unit: string,
|
|
271
|
+
easing: Easing,
|
|
272
|
+
): RedNode {
|
|
273
|
+
const text = node.text
|
|
274
|
+
if (!text) return node
|
|
275
|
+
|
|
276
|
+
const segments = unit === 'word'
|
|
277
|
+
? text.split(/(\s+)/).filter((s: string) => s.length > 0)
|
|
278
|
+
: unit === 'line'
|
|
279
|
+
? text.split('\n')
|
|
280
|
+
: [...text]
|
|
281
|
+
|
|
282
|
+
if (segments.length === 0) return node
|
|
283
|
+
|
|
284
|
+
const groupGreen = greenLeaf('group', '')
|
|
285
|
+
const group = new RedNode(groupGreen, { kind: 'group' })
|
|
286
|
+
|
|
287
|
+
// For central mode, we mirror from the center
|
|
288
|
+
const mid = Math.floor(segments.length / 2)
|
|
289
|
+
|
|
290
|
+
let pos = globalOffset
|
|
291
|
+
for (let i = 0; i < segments.length; i++) {
|
|
292
|
+
const seg = segments[i]
|
|
293
|
+
const textGreen = greenLeaf('text', seg)
|
|
294
|
+
const textRed = new RedNode(textGreen, { kind: 'text' })
|
|
295
|
+
|
|
296
|
+
if (/^\s+$/.test(seg)) {
|
|
297
|
+
group.appendChild(textRed)
|
|
298
|
+
} else {
|
|
299
|
+
// Calculate position from center (0 at center, 1 at edges)
|
|
300
|
+
let t: number
|
|
301
|
+
if (i <= mid) {
|
|
302
|
+
t = mid === 0 ? 0 : i / mid
|
|
303
|
+
} else {
|
|
304
|
+
t = segments.length - 1 - mid === 0 ? 0 : (i - mid) / (segments.length - 1 - mid)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const easedT = ease(clamp(t), easing)
|
|
308
|
+
// For central mode, we use the colors array in forward then reverse
|
|
309
|
+
const colorArr = i <= mid ? colors : [...colors].reverse()
|
|
310
|
+
const color = mixMultiple(colorArr, easedT)
|
|
311
|
+
|
|
312
|
+
const gradGreen = greenLeaf('gradient', '')
|
|
313
|
+
const gradRed = new RedNode(gradGreen, {
|
|
314
|
+
kind: 'gradient',
|
|
315
|
+
metadata: {
|
|
316
|
+
colors: [color, color], // Single color interpolation
|
|
317
|
+
unit,
|
|
318
|
+
easing,
|
|
319
|
+
globalOffset: pos,
|
|
320
|
+
documentLength,
|
|
321
|
+
},
|
|
322
|
+
})
|
|
323
|
+
gradRed.appendChild(textRed)
|
|
324
|
+
group.appendChild(gradRed)
|
|
325
|
+
}
|
|
326
|
+
pos += seg.length
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return group
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Wrap a text node's characters in gradient nodes for multi mode.
|
|
334
|
+
* Multi mode: wave or pulse patterns across the text.
|
|
335
|
+
*/
|
|
336
|
+
function wrapTextWithMultiGradient(
|
|
337
|
+
node: RedNode,
|
|
338
|
+
colors: string[],
|
|
339
|
+
mode: 'wave' | 'pulse',
|
|
340
|
+
globalOffset: number,
|
|
341
|
+
documentLength: number,
|
|
342
|
+
unit: string,
|
|
343
|
+
easing: Easing,
|
|
344
|
+
): RedNode {
|
|
345
|
+
const text = node.text
|
|
346
|
+
if (!text) return node
|
|
347
|
+
|
|
348
|
+
const segments = unit === 'word'
|
|
349
|
+
? text.split(/(\s+)/).filter((s: string) => s.length > 0)
|
|
350
|
+
: unit === 'line'
|
|
351
|
+
? text.split('\n')
|
|
352
|
+
: [...text]
|
|
353
|
+
|
|
354
|
+
if (segments.length === 0) return node
|
|
355
|
+
|
|
356
|
+
const groupGreen = greenLeaf('group', '')
|
|
357
|
+
const group = new RedNode(groupGreen, { kind: 'group' })
|
|
358
|
+
|
|
359
|
+
let pos = globalOffset
|
|
360
|
+
for (let i = 0; i < segments.length; i++) {
|
|
361
|
+
const seg = segments[i]
|
|
362
|
+
const textGreen = greenLeaf('text', seg)
|
|
363
|
+
const textRed = new RedNode(textGreen, { kind: 'text' })
|
|
364
|
+
|
|
365
|
+
if (/^\s+$/.test(seg)) {
|
|
366
|
+
group.appendChild(textRed)
|
|
367
|
+
} else {
|
|
368
|
+
let t: number
|
|
369
|
+
if (mode === 'wave') {
|
|
370
|
+
// Wave pattern: sine wave across the text
|
|
371
|
+
const wavePosition = Math.sin((i / segments.length) * Math.PI * 3) * 0.5 + 0.5
|
|
372
|
+
t = ease(wavePosition, easing)
|
|
373
|
+
} else {
|
|
374
|
+
// Pulse pattern: repeating gradient segments
|
|
375
|
+
const pulseLength = Math.max(5, Math.floor(segments.length / 4))
|
|
376
|
+
const pulsePosition = (i % pulseLength) / pulseLength
|
|
377
|
+
t = ease(pulsePosition, easing)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const color = mixMultiple(colors, t)
|
|
381
|
+
|
|
382
|
+
const gradGreen = greenLeaf('gradient', '')
|
|
383
|
+
const gradRed = new RedNode(gradGreen, {
|
|
384
|
+
kind: 'gradient',
|
|
385
|
+
metadata: {
|
|
386
|
+
colors: [color, color], // Single color interpolation
|
|
387
|
+
unit,
|
|
388
|
+
easing,
|
|
389
|
+
globalOffset: pos,
|
|
390
|
+
documentLength,
|
|
391
|
+
},
|
|
392
|
+
})
|
|
393
|
+
gradRed.appendChild(textRed)
|
|
394
|
+
group.appendChild(gradRed)
|
|
395
|
+
}
|
|
396
|
+
pos += seg.length
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return group
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ─── Public API ─────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Apply a gradient effect across an entire RedNode tree.
|
|
406
|
+
*
|
|
407
|
+
* The gradient is computed across ALL text content in the tree,
|
|
408
|
+
* so color transitions span across structural boundaries:
|
|
409
|
+
*
|
|
410
|
+
* [b]Hello[/b] World → [b][color=#FF0000]H[/color]...[/b][color=#00FF00]W[/color]...
|
|
411
|
+
*
|
|
412
|
+
* @param root The RedNode tree to transform
|
|
413
|
+
* @param colors Array of hex colors to interpolate across
|
|
414
|
+
* @param options.unit Split unit: 'character' (default), 'word', or 'line'
|
|
415
|
+
* @param options.easing Easing function: 'linear' (default), 'easeIn', 'easeOut', 'easeInOut'
|
|
416
|
+
* @returns A new RedNode tree with gradient applied
|
|
417
|
+
*/
|
|
418
|
+
export function applyGradient(
|
|
419
|
+
root: RedNode,
|
|
420
|
+
colors: string[],
|
|
421
|
+
options?: {
|
|
422
|
+
unit?: 'character' | 'word' | 'line'
|
|
423
|
+
easing?: Easing
|
|
424
|
+
},
|
|
425
|
+
): RedNode {
|
|
426
|
+
return RedNode.allowMutation(() => {
|
|
427
|
+
const unit = options?.unit || 'character'
|
|
428
|
+
const easing = options?.easing || 'linear'
|
|
429
|
+
|
|
430
|
+
// Pass 1: count total text length
|
|
431
|
+
const totalLength = countTextLength(root)
|
|
432
|
+
if (totalLength === 0) return cloneTree(root)
|
|
433
|
+
|
|
434
|
+
// Pass 2: walk and wrap text nodes
|
|
435
|
+
let globalPos = 0
|
|
436
|
+
|
|
437
|
+
function walk(node: RedNode): RedNode {
|
|
438
|
+
// Text leaf — wrap in gradient
|
|
439
|
+
if (node.kind === 'text') {
|
|
440
|
+
const result = wrapTextWithGradient(node, colors, globalPos, totalLength, unit, easing)
|
|
441
|
+
globalPos += node.text.length
|
|
442
|
+
return result
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Leaf containers (img, code, etc.) — preserve unchanged
|
|
446
|
+
if (LEAF_KINDS.has(node.kind)) {
|
|
447
|
+
return node
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Structural/formatting nodes — recurse into children
|
|
451
|
+
const cloned = cloneTree(node)
|
|
452
|
+
cloned.children = []
|
|
453
|
+
for (const child of node.children) {
|
|
454
|
+
cloned.appendChild(walk(child))
|
|
455
|
+
}
|
|
456
|
+
return cloned
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return walk(root)
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Apply a grow (size wave) effect across an entire RedNode tree.
|
|
465
|
+
*
|
|
466
|
+
* Size oscillates via sine wave across ALL text content:
|
|
467
|
+
*
|
|
468
|
+
* [b]Hello[/b] World → [b][size=90]H[/size]...[/b][size=160]W[/size]...
|
|
469
|
+
*
|
|
470
|
+
* @param root The RedNode tree to transform
|
|
471
|
+
* @param options.min Minimum size % (default: 90)
|
|
472
|
+
* @param options.max Maximum size % (default: 160)
|
|
473
|
+
* @param options.cycles Number of sine cycles across text (default: 1)
|
|
474
|
+
* @returns A new RedNode tree with grow applied
|
|
475
|
+
*/
|
|
476
|
+
export function applyGrow(
|
|
477
|
+
root: RedNode,
|
|
478
|
+
options?: {
|
|
479
|
+
min?: number
|
|
480
|
+
max?: number
|
|
481
|
+
cycles?: number
|
|
482
|
+
},
|
|
483
|
+
): RedNode {
|
|
484
|
+
return RedNode.allowMutation(() => {
|
|
485
|
+
const min = options?.min ?? 90
|
|
486
|
+
const max = options?.max ?? 160
|
|
487
|
+
const cycles = options?.cycles ?? 1
|
|
488
|
+
|
|
489
|
+
const totalLength = countTextLength(root)
|
|
490
|
+
if (totalLength === 0) return cloneTree(root)
|
|
491
|
+
|
|
492
|
+
let globalPos = 0
|
|
493
|
+
|
|
494
|
+
function walk(node: RedNode): RedNode {
|
|
495
|
+
if (node.kind === 'text') {
|
|
496
|
+
const result = wrapTextWithGrow(node, min, max, cycles, globalPos, totalLength)
|
|
497
|
+
globalPos += node.text.length
|
|
498
|
+
return result
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (LEAF_KINDS.has(node.kind)) {
|
|
502
|
+
return node
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const cloned = cloneTree(node)
|
|
506
|
+
cloned.children = []
|
|
507
|
+
for (const child of node.children) {
|
|
508
|
+
cloned.appendChild(walk(child))
|
|
509
|
+
}
|
|
510
|
+
return cloned
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return walk(root)
|
|
514
|
+
})
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Apply a rainbow effect across an entire RedNode tree.
|
|
519
|
+
*
|
|
520
|
+
* Hue rotates across ALL text content using HSL color space:
|
|
521
|
+
*
|
|
522
|
+
* [b]Hello[/b] World → [b][color=#FF0000]H[/color]...[/b][color=#00FF00]W[/color]...
|
|
523
|
+
*
|
|
524
|
+
* @param root The RedNode tree to transform
|
|
525
|
+
* @param options.saturation Saturation 0-100 (default: 80)
|
|
526
|
+
* @param options.lightness Lightness 0-100 (default: 60)
|
|
527
|
+
* @param options.spread Hue spread in degrees (default: 300)
|
|
528
|
+
* @param options.offset Starting hue offset in degrees (default: 0)
|
|
529
|
+
* @returns A new RedNode tree with rainbow applied
|
|
530
|
+
*/
|
|
531
|
+
export function applyRainbow(
|
|
532
|
+
root: RedNode,
|
|
533
|
+
options?: {
|
|
534
|
+
saturation?: number
|
|
535
|
+
lightness?: number
|
|
536
|
+
spread?: number
|
|
537
|
+
offset?: number
|
|
538
|
+
},
|
|
539
|
+
): RedNode {
|
|
540
|
+
return RedNode.allowMutation(() => {
|
|
541
|
+
const saturation = ((options?.saturation ?? 80) / 100)
|
|
542
|
+
const lightness = ((options?.lightness ?? 60) / 100)
|
|
543
|
+
const spread = options?.spread ?? 300
|
|
544
|
+
const offset = options?.offset ?? 0
|
|
545
|
+
|
|
546
|
+
const totalLength = countTextLength(root)
|
|
547
|
+
if (totalLength === 0) return cloneTree(root)
|
|
548
|
+
|
|
549
|
+
let globalPos = 0
|
|
550
|
+
|
|
551
|
+
function walk(node: RedNode): RedNode {
|
|
552
|
+
if (node.kind === 'text') {
|
|
553
|
+
const result = wrapTextWithRainbow(node, saturation, lightness, spread, offset, globalPos, totalLength)
|
|
554
|
+
globalPos += node.text.length
|
|
555
|
+
return result
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (LEAF_KINDS.has(node.kind)) {
|
|
559
|
+
return node
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const cloned = cloneTree(node)
|
|
563
|
+
cloned.children = []
|
|
564
|
+
for (const child of node.children) {
|
|
565
|
+
cloned.appendChild(walk(child))
|
|
566
|
+
}
|
|
567
|
+
return cloned
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return walk(root)
|
|
571
|
+
})
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Apply a central gradient across an entire RedNode tree.
|
|
576
|
+
*
|
|
577
|
+
* Gradient radiates from center outward (mirrored):
|
|
578
|
+
*
|
|
579
|
+
* [b]Hello[/b] World → [b][color=#FF0000]H[/color]...[/b][color=#00FF00]W[/color]...
|
|
580
|
+
*
|
|
581
|
+
* @param root The RedNode tree to transform
|
|
582
|
+
* @param colors Array of hex colors to interpolate across
|
|
583
|
+
* @param options.unit Split unit: 'character' (default), 'word', or 'line'
|
|
584
|
+
* @param options.easing Easing function: 'linear' (default), 'easeIn', 'easeOut', 'easeInOut'
|
|
585
|
+
* @returns A new RedNode tree with central gradient applied
|
|
586
|
+
*/
|
|
587
|
+
export function applyCentralGradient(
|
|
588
|
+
root: RedNode,
|
|
589
|
+
colors: string[],
|
|
590
|
+
options?: {
|
|
591
|
+
unit?: 'character' | 'word' | 'line'
|
|
592
|
+
easing?: Easing
|
|
593
|
+
},
|
|
594
|
+
): RedNode {
|
|
595
|
+
return RedNode.allowMutation(() => {
|
|
596
|
+
const unit = options?.unit || 'character'
|
|
597
|
+
const easing = options?.easing || 'linear'
|
|
598
|
+
|
|
599
|
+
const totalLength = countTextLength(root)
|
|
600
|
+
if (totalLength === 0) return cloneTree(root)
|
|
601
|
+
|
|
602
|
+
let globalPos = 0
|
|
603
|
+
|
|
604
|
+
function walk(node: RedNode): RedNode {
|
|
605
|
+
if (node.kind === 'text') {
|
|
606
|
+
const result = wrapTextWithCentralGradient(node, colors, globalPos, totalLength, unit, easing)
|
|
607
|
+
globalPos += node.text.length
|
|
608
|
+
return result
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (LEAF_KINDS.has(node.kind)) {
|
|
612
|
+
return node
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const cloned = cloneTree(node)
|
|
616
|
+
cloned.children = []
|
|
617
|
+
for (const child of node.children) {
|
|
618
|
+
cloned.appendChild(walk(child))
|
|
619
|
+
}
|
|
620
|
+
return cloned
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return walk(root)
|
|
624
|
+
})
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Apply a multi gradient (wave or pulse) across an entire RedNode tree.
|
|
629
|
+
*
|
|
630
|
+
* Wave mode: sine wave pattern across colors.
|
|
631
|
+
* Pulse mode: repeating gradient segments.
|
|
632
|
+
*
|
|
633
|
+
* @param root The RedNode tree to transform
|
|
634
|
+
* @param colors Array of hex colors to interpolate across
|
|
635
|
+
* @param options.mode 'wave' (default) or 'pulse'
|
|
636
|
+
* @param options.unit Split unit: 'character' (default), 'word', or 'line'
|
|
637
|
+
* @param options.easing Easing function: 'linear' (default), 'easeIn', 'easeOut', 'easeInOut'
|
|
638
|
+
* @returns A new RedNode tree with multi gradient applied
|
|
639
|
+
*/
|
|
640
|
+
export function applyMultiGradient(
|
|
641
|
+
root: RedNode,
|
|
642
|
+
colors: string[],
|
|
643
|
+
options?: {
|
|
644
|
+
mode?: 'wave' | 'pulse'
|
|
645
|
+
unit?: 'character' | 'word' | 'line'
|
|
646
|
+
easing?: Easing
|
|
647
|
+
},
|
|
648
|
+
): RedNode {
|
|
649
|
+
return RedNode.allowMutation(() => {
|
|
650
|
+
const mode = options?.mode || 'wave'
|
|
651
|
+
const unit = options?.unit || 'character'
|
|
652
|
+
const easing = options?.easing || 'linear'
|
|
653
|
+
|
|
654
|
+
const totalLength = countTextLength(root)
|
|
655
|
+
if (totalLength === 0) return cloneTree(root)
|
|
656
|
+
|
|
657
|
+
let globalPos = 0
|
|
658
|
+
|
|
659
|
+
function walk(node: RedNode): RedNode {
|
|
660
|
+
if (node.kind === 'text') {
|
|
661
|
+
const result = wrapTextWithMultiGradient(node, colors, mode, globalPos, totalLength, unit, easing)
|
|
662
|
+
globalPos += node.text.length
|
|
663
|
+
return result
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (LEAF_KINDS.has(node.kind)) {
|
|
667
|
+
return node
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const cloned = cloneTree(node)
|
|
671
|
+
cloned.children = []
|
|
672
|
+
for (const child of node.children) {
|
|
673
|
+
cloned.appendChild(walk(child))
|
|
674
|
+
}
|
|
675
|
+
return cloned
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return walk(root)
|
|
679
|
+
})
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Apply any supported effect across an entire RedNode tree.
|
|
684
|
+
*/
|
|
685
|
+
export function applyEffect(root: RedNode, effect: TreeEffect): RedNode {
|
|
686
|
+
switch (effect.kind) {
|
|
687
|
+
case 'gradient':
|
|
688
|
+
return applyGradient(root, effect.colors, {
|
|
689
|
+
unit: effect.unit,
|
|
690
|
+
easing: effect.easing,
|
|
691
|
+
})
|
|
692
|
+
case 'grow':
|
|
693
|
+
return applyGrow(root, {
|
|
694
|
+
min: effect.min,
|
|
695
|
+
max: effect.max,
|
|
696
|
+
cycles: effect.cycles,
|
|
697
|
+
})
|
|
698
|
+
case 'rainbow':
|
|
699
|
+
return applyRainbow(root, {
|
|
700
|
+
saturation: effect.saturation,
|
|
701
|
+
lightness: effect.lightness,
|
|
702
|
+
spread: effect.spread,
|
|
703
|
+
offset: effect.offset,
|
|
704
|
+
})
|
|
705
|
+
case 'central_gradient':
|
|
706
|
+
return applyCentralGradient(root, effect.colors, {
|
|
707
|
+
unit: effect.unit,
|
|
708
|
+
easing: effect.easing,
|
|
709
|
+
})
|
|
710
|
+
case 'multi_gradient':
|
|
711
|
+
return applyMultiGradient(root, effect.colors, {
|
|
712
|
+
mode: effect.mode,
|
|
713
|
+
unit: effect.unit,
|
|
714
|
+
easing: effect.easing,
|
|
715
|
+
})
|
|
716
|
+
}
|
|
717
|
+
}
|