@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,615 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// DOM-to-SVG Converter — high-fidelity layout snapshot
|
|
3
|
+
//
|
|
4
|
+
// Strategy:
|
|
5
|
+
// 1. Walk every DOM node depth-first.
|
|
6
|
+
// 2. For each element, read getBoundingClientRect + getComputedStyle.
|
|
7
|
+
// 3. Emit SVG primitives: rect (backgrounds/borders), text (all
|
|
8
|
+
// visual lines of each Text node), image, and g (groups).
|
|
9
|
+
// 4. Text nodes use Range character-by-character scan to map each
|
|
10
|
+
// visual line to its actual substring — this is what makes
|
|
11
|
+
// text wrap correctly in the SVG output.
|
|
12
|
+
//
|
|
13
|
+
// ============================================================
|
|
14
|
+
|
|
15
|
+
export interface DomToSVGOptions {
|
|
16
|
+
backgroundColor?: string
|
|
17
|
+
includeBackground?: boolean
|
|
18
|
+
/** Scale factor applied to all coordinates (default 1) */
|
|
19
|
+
scale?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SVGLayerInfo {
|
|
23
|
+
id: string
|
|
24
|
+
tag: string
|
|
25
|
+
depth: number
|
|
26
|
+
x: number
|
|
27
|
+
y: number
|
|
28
|
+
width: number
|
|
29
|
+
height: number
|
|
30
|
+
type: 'image' | 'iframe' | 'audio' | 'background' | 'border' | 'group' | 'text'
|
|
31
|
+
text?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DomToSVGResult {
|
|
35
|
+
svg: string
|
|
36
|
+
width: number
|
|
37
|
+
height: number
|
|
38
|
+
layerCount: number
|
|
39
|
+
layers: SVGLayerInfo[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ────────────────────────────────────────────────────────────
|
|
43
|
+
// Public API
|
|
44
|
+
// ────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export function domToSVG(root: HTMLElement, options: DomToSVGOptions = {}): string {
|
|
47
|
+
return domToSVGResult(root, options).svg
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function domToSVGResult(
|
|
51
|
+
root: HTMLElement,
|
|
52
|
+
options: DomToSVGOptions = {},
|
|
53
|
+
): DomToSVGResult {
|
|
54
|
+
const { backgroundColor = "#0d0d0d", includeBackground = true, scale = 1 } = options
|
|
55
|
+
|
|
56
|
+
// OPTIMIZATION & FIX: Remove visual interaction artifacts before measuring.
|
|
57
|
+
// We strip the cursor-highlight class synchronously so getComputedStyle reads the pure colors.
|
|
58
|
+
const highlightedElements = Array.from(root.querySelectorAll('.cursor-highlight'))
|
|
59
|
+
highlightedElements.forEach(el => el.classList.remove('cursor-highlight'))
|
|
60
|
+
|
|
61
|
+
// FIX: Force all spoilerboxes and boxes to be OPEN before taking rootRect so their content is measured.
|
|
62
|
+
// Otherwise, closed <details> elements will return garbage coordinates for their hidden children,
|
|
63
|
+
// causing overlapping text in the SVG output, AND the rootRect height will be too small.
|
|
64
|
+
const closedDetails = Array.from(root.querySelectorAll('details:not([open])'))
|
|
65
|
+
closedDetails.forEach(el => el.setAttribute('open', ''))
|
|
66
|
+
|
|
67
|
+
const rootRect = root.getBoundingClientRect()
|
|
68
|
+
const W = Math.ceil(rootRect.width * scale)
|
|
69
|
+
const H = Math.ceil(rootRect.height * scale)
|
|
70
|
+
|
|
71
|
+
if (W < 4 || H < 4) {
|
|
72
|
+
// RESTORE on error
|
|
73
|
+
highlightedElements.forEach(el => el.classList.add('cursor-highlight'))
|
|
74
|
+
closedDetails.forEach(el => el.removeAttribute('open'))
|
|
75
|
+
throw new Error(
|
|
76
|
+
"Preview element has no visible dimensions. Make sure it is rendered and visible.",
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const state: WalkState = { rootRect, scale, parts: [], layerCount: 0, layersList: [] }
|
|
82
|
+
|
|
83
|
+
const fontFamilies = collectFontFamilies(root)
|
|
84
|
+
|
|
85
|
+
const svgOpen = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
|
|
86
|
+
state.parts.push(svgOpen)
|
|
87
|
+
|
|
88
|
+
if (fontFamilies.size > 0) {
|
|
89
|
+
const families = Array.from(fontFamilies)
|
|
90
|
+
.map((f) => encodeURIComponent(f))
|
|
91
|
+
.join("|")
|
|
92
|
+
state.parts.push(
|
|
93
|
+
` <defs>`,
|
|
94
|
+
` <style>@import url('https://fonts.googleapis.com/css2?family=${families}&display=swap');</style>`,
|
|
95
|
+
` </defs>`,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (includeBackground) {
|
|
100
|
+
state.parts.push(
|
|
101
|
+
` <rect id="background" x="0" y="0" width="${W}" height="${H}" fill="${escapeXml(backgroundColor)}" />`,
|
|
102
|
+
)
|
|
103
|
+
state.layersList.push({
|
|
104
|
+
id: "background",
|
|
105
|
+
tag: "rect",
|
|
106
|
+
depth: 0,
|
|
107
|
+
x: 0,
|
|
108
|
+
y: 0,
|
|
109
|
+
width: W,
|
|
110
|
+
height: H,
|
|
111
|
+
type: "background",
|
|
112
|
+
})
|
|
113
|
+
state.layerCount++
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
walkElement(root, state, 1)
|
|
117
|
+
|
|
118
|
+
state.parts.push("</svg>")
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
svg: state.parts.join("\n"),
|
|
122
|
+
width: W,
|
|
123
|
+
height: H,
|
|
124
|
+
layerCount: state.layerCount,
|
|
125
|
+
layers: state.layersList,
|
|
126
|
+
}
|
|
127
|
+
} finally {
|
|
128
|
+
// RESTORE: Put the highlight and closed states back so the user doesn't see it blink
|
|
129
|
+
highlightedElements.forEach(el => el.classList.add('cursor-highlight'))
|
|
130
|
+
closedDetails.forEach(el => el.removeAttribute('open'))
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ────────────────────────────────────────────────────────────
|
|
135
|
+
// Internal walk state
|
|
136
|
+
// ────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
interface WalkState {
|
|
139
|
+
rootRect: DOMRect
|
|
140
|
+
scale: number
|
|
141
|
+
parts: string[]
|
|
142
|
+
layerCount: number
|
|
143
|
+
layersList: SVGLayerInfo[]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ────────────────────────────────────────────────────────────
|
|
147
|
+
// Helpers
|
|
148
|
+
// ────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function px(value: number): string {
|
|
151
|
+
return value.toFixed(2)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function ind(depth: number): string {
|
|
155
|
+
return " ".repeat(Math.min(depth, 12))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function escapeXml(str: string): string {
|
|
159
|
+
return str
|
|
160
|
+
.replace(/&/g, "&")
|
|
161
|
+
.replace(/</g, "<")
|
|
162
|
+
.replace(/>/g, ">")
|
|
163
|
+
.replace(/"/g, """)
|
|
164
|
+
.replace(/'/g, "'")
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function colorToHex(color: string): string {
|
|
168
|
+
if (!color) return "#000000"
|
|
169
|
+
if (color.startsWith("#")) return color
|
|
170
|
+
const m = color.match(/rgba?\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)/)
|
|
171
|
+
if (!m) return color
|
|
172
|
+
const r = Math.round(parseFloat(m[1]))
|
|
173
|
+
const g = Math.round(parseFloat(m[2]))
|
|
174
|
+
const b = Math.round(parseFloat(m[3]))
|
|
175
|
+
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function extractAlpha(color: string): number {
|
|
179
|
+
if (!color) return 1
|
|
180
|
+
const m = color.match(/rgba\(\d+(?:\.\d+)?,\s*\d+(?:\.\d+)?,\s*\d+(?:\.\d+)?,\s*(\d+(?:\.\d+)?)/)
|
|
181
|
+
if (m) return parseFloat(m[1])
|
|
182
|
+
return 1
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function collectFontFamilies(root: HTMLElement): Set<string> {
|
|
186
|
+
const fonts = new Set<string>()
|
|
187
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
|
|
188
|
+
let node: Node | null = walker.currentNode
|
|
189
|
+
while (node) {
|
|
190
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
191
|
+
// Mismo guard que walkElement: los overlays de edición del imagemap no
|
|
192
|
+
// deben colar su fuente en el @import del SVG (p. ej. un tooltip con
|
|
193
|
+
// font-family propia).
|
|
194
|
+
if (!isImagemapOverlay(node as Element)) {
|
|
195
|
+
const cs = window.getComputedStyle(node as Element)
|
|
196
|
+
const family = cs.fontFamily?.split(",")[0]?.replace(/['"]/g, "").trim()
|
|
197
|
+
if (family && family !== "sans-serif" && family !== "monospace" && family !== "serif") {
|
|
198
|
+
fonts.add(family)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
node = walker.nextNode()
|
|
203
|
+
}
|
|
204
|
+
return fonts
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ────────────────────────────────────────────────────────────
|
|
208
|
+
// Scan a text node into visual lines
|
|
209
|
+
// ────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
interface VisualLine {
|
|
212
|
+
text: string
|
|
213
|
+
rect: DOMRect
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Walk character-by-character through the text node, using Range to get
|
|
218
|
+
* the DOMRect of each character. Group consecutive characters that share
|
|
219
|
+
* the same top-Y (within 2px tolerance) into one line, then accumulate
|
|
220
|
+
* the text for that line.
|
|
221
|
+
*
|
|
222
|
+
* This is the key to correct text wrapping: instead of emitting the full
|
|
223
|
+
* text string on the first line's rect, we emit only the characters that
|
|
224
|
+
* actually appear on each visual line.
|
|
225
|
+
*/
|
|
226
|
+
function getVisualLines(textNode: Text): VisualLine[] {
|
|
227
|
+
const text = textNode.textContent ?? ""
|
|
228
|
+
if (!text.trim()) return [] // Skip purely empty/whitespace nodes
|
|
229
|
+
|
|
230
|
+
const range = document.createRange()
|
|
231
|
+
range.selectNode(textNode)
|
|
232
|
+
const fullRects = Array.from(range.getClientRects()).filter(r => r.width > 0 && r.height > 0)
|
|
233
|
+
|
|
234
|
+
// OPTIMIZATION 1: If the entire text node fits on a single line, return immediately!
|
|
235
|
+
// This skips the expensive scan for 90% of text nodes (short inline elements).
|
|
236
|
+
if (fullRects.length <= 1) {
|
|
237
|
+
if (fullRects.length === 1) {
|
|
238
|
+
return [{ text, rect: fullRects[0] }]
|
|
239
|
+
}
|
|
240
|
+
return []
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// OPTIMIZATION 2: Word-by-word scan instead of char-by-char for multi-line text.
|
|
244
|
+
// 6x-10x faster because we do getClientRects per word (or whitespace group).
|
|
245
|
+
const lines: VisualLine[] = []
|
|
246
|
+
let lineText = ""
|
|
247
|
+
let lineRect: DOMRect | null = null
|
|
248
|
+
const TOLERANCE = 4 // px
|
|
249
|
+
|
|
250
|
+
// Split into tokens: words and spaces
|
|
251
|
+
const tokens = text.match(/(\s+|\S+)/g) || []
|
|
252
|
+
let currentIndex = 0
|
|
253
|
+
|
|
254
|
+
for (const token of tokens) {
|
|
255
|
+
if (token === "\n") {
|
|
256
|
+
currentIndex++
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
range.setStart(textNode, currentIndex)
|
|
261
|
+
range.setEnd(textNode, currentIndex + token.length)
|
|
262
|
+
currentIndex += token.length
|
|
263
|
+
|
|
264
|
+
const rects = range.getClientRects()
|
|
265
|
+
if (!rects.length) continue
|
|
266
|
+
|
|
267
|
+
const tokenRect = rects[0]
|
|
268
|
+
if (tokenRect.width === 0 && tokenRect.height === 0) continue
|
|
269
|
+
|
|
270
|
+
if (!lineRect) {
|
|
271
|
+
lineRect = tokenRect
|
|
272
|
+
lineText = token
|
|
273
|
+
} else if (Math.abs(tokenRect.top - lineRect.top) <= TOLERANCE) {
|
|
274
|
+
lineText += token
|
|
275
|
+
const mergedRight = Math.max(lineRect.right, tokenRect.right)
|
|
276
|
+
lineRect = new DOMRect(
|
|
277
|
+
lineRect.left,
|
|
278
|
+
lineRect.top,
|
|
279
|
+
mergedRight - lineRect.left,
|
|
280
|
+
Math.max(lineRect.height, tokenRect.height),
|
|
281
|
+
)
|
|
282
|
+
} else {
|
|
283
|
+
lines.push({ text: lineText, rect: lineRect })
|
|
284
|
+
lineRect = tokenRect
|
|
285
|
+
lineText = token
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (lineText && lineRect) {
|
|
290
|
+
lines.push({ text: lineText, rect: lineRect })
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return lines
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ────────────────────────────────────────────────────────────
|
|
297
|
+
// Element walker
|
|
298
|
+
// ────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
const SKIP_TAGS = new Set(["script", "style", "noscript", "head", "meta", "title"])
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Overlays de EDICIÓN del imagemap que no deben salir en el vector exportado:
|
|
304
|
+
* las áreas clicables (rectángulos invisibles de la región) y su tooltip de
|
|
305
|
+
* hover. NO incluye el contenedor (`.imagemap-container`): ese guarda la
|
|
306
|
+
* imagen real, que sí forma parte del contenido.
|
|
307
|
+
*
|
|
308
|
+
* El HTMLRenderer genera las áreas como `imagemap-area bbcode-imap-area`;
|
|
309
|
+
* `bb-imagemap-area` es la clase legacy que también se salta por si algún
|
|
310
|
+
* render anterior la dejó en el DOM.
|
|
311
|
+
*/
|
|
312
|
+
const IMAGEMAP_OVERLAY_CLASSES = new Set([
|
|
313
|
+
"imagemap-area",
|
|
314
|
+
"bbcode-imap-area",
|
|
315
|
+
"bb-imagemap-area",
|
|
316
|
+
"bb-imagemap-tooltip",
|
|
317
|
+
])
|
|
318
|
+
|
|
319
|
+
function isImagemapOverlay(el: Element): boolean {
|
|
320
|
+
for (const c of el.classList) {
|
|
321
|
+
if (IMAGEMAP_OVERLAY_CLASSES.has(c)) return true
|
|
322
|
+
}
|
|
323
|
+
return false
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function walkElement(el: Element, state: WalkState, depth: number): void {
|
|
327
|
+
const tag = el.tagName.toLowerCase()
|
|
328
|
+
if (SKIP_TAGS.has(tag)) return
|
|
329
|
+
|
|
330
|
+
// Skip interactive overlays that shouldn't be part of the vector export
|
|
331
|
+
if (isImagemapOverlay(el)) return
|
|
332
|
+
|
|
333
|
+
const cs = window.getComputedStyle(el)
|
|
334
|
+
if (cs.display === "none" || cs.visibility === "hidden") return
|
|
335
|
+
const opacityVal = parseFloat(cs.opacity)
|
|
336
|
+
if (opacityVal === 0) return
|
|
337
|
+
|
|
338
|
+
const rect = el.getBoundingClientRect()
|
|
339
|
+
const { rootRect, scale } = state
|
|
340
|
+
|
|
341
|
+
const x = (rect.left - rootRect.left) * scale
|
|
342
|
+
const y = (rect.top - rootRect.top) * scale
|
|
343
|
+
const w = rect.width * scale
|
|
344
|
+
const h = rect.height * scale
|
|
345
|
+
const hasArea = w > 0.5 && h > 0.5
|
|
346
|
+
|
|
347
|
+
const rawId = (el.id || el.className?.toString() || tag)
|
|
348
|
+
.replace(/\s+/g, "-")
|
|
349
|
+
.replace(/[^a-zA-Z0-9_-]/g, "")
|
|
350
|
+
.substring(0, 48) || tag
|
|
351
|
+
const layerId = `${rawId}-${depth}-${state.layerCount}`
|
|
352
|
+
state.layerCount++
|
|
353
|
+
|
|
354
|
+
const opacityAttr = opacityVal < 1 ? ` opacity="${opacityVal.toFixed(2)}"` : ""
|
|
355
|
+
|
|
356
|
+
// ── Leaf tags ──────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
if (tag === "img") {
|
|
359
|
+
const src = (el as HTMLImageElement).src || el.getAttribute("src") || ""
|
|
360
|
+
if (src && hasArea) {
|
|
361
|
+
const rx = parseFloat(cs.borderRadius) || 0
|
|
362
|
+
state.parts.push(
|
|
363
|
+
`${ind(depth)}<image id="${layerId}"${opacityAttr} x="${px(x)}" y="${px(y)}" width="${px(w)}" height="${px(h)}" href="${escapeXml(src)}" preserveAspectRatio="xMidYMid meet"${rx > 0 ? ` clip-path="inset(0 round ${px(rx * scale)})"` : ""} />`,
|
|
364
|
+
)
|
|
365
|
+
state.layersList.push({
|
|
366
|
+
id: layerId,
|
|
367
|
+
tag: "img",
|
|
368
|
+
depth,
|
|
369
|
+
x,
|
|
370
|
+
y,
|
|
371
|
+
width: w,
|
|
372
|
+
height: h,
|
|
373
|
+
type: "image",
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (tag === "iframe") {
|
|
380
|
+
if (hasArea) {
|
|
381
|
+
state.parts.push(
|
|
382
|
+
`${ind(depth)}<rect id="${layerId}" x="${px(x)}" y="${px(y)}" width="${px(w)}" height="${px(h)}" fill="#0f0f1a" rx="6" />`,
|
|
383
|
+
`${ind(depth)}<text x="${px(x + w / 2)}" y="${px(y + h / 2 + 5)}" text-anchor="middle" fill="#ff66ab" font-size="${Math.round(14 * scale)}" font-family="sans-serif">YouTube</text>`,
|
|
384
|
+
)
|
|
385
|
+
state.layersList.push({
|
|
386
|
+
id: layerId,
|
|
387
|
+
tag: "iframe",
|
|
388
|
+
depth,
|
|
389
|
+
x,
|
|
390
|
+
y,
|
|
391
|
+
width: w,
|
|
392
|
+
height: h,
|
|
393
|
+
type: "iframe",
|
|
394
|
+
})
|
|
395
|
+
}
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (tag === "audio") {
|
|
400
|
+
if (hasArea) {
|
|
401
|
+
state.parts.push(
|
|
402
|
+
`${ind(depth)}<rect id="${layerId}" x="${px(x)}" y="${px(y)}" width="${px(w)}" height="${px(h)}" fill="#1c1520" rx="6" stroke="#2e2030" stroke-width="1" />`,
|
|
403
|
+
`${ind(depth)}<text x="${px(x + w / 2)}" y="${px(y + h / 2 + 5)}" text-anchor="middle" fill="#8a7a84" font-size="${Math.round(12 * scale)}" font-family="sans-serif">Audio Player</text>`,
|
|
404
|
+
)
|
|
405
|
+
state.layersList.push({
|
|
406
|
+
id: layerId,
|
|
407
|
+
tag: "audio",
|
|
408
|
+
depth,
|
|
409
|
+
x,
|
|
410
|
+
y,
|
|
411
|
+
width: w,
|
|
412
|
+
height: h,
|
|
413
|
+
type: "audio",
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── Background + borders ───────────────────────────────────
|
|
420
|
+
|
|
421
|
+
if (hasArea) {
|
|
422
|
+
const bg = cs.backgroundColor
|
|
423
|
+
const hasBg = bg && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent"
|
|
424
|
+
const rx = parseFloat(cs.borderRadius) || 0
|
|
425
|
+
|
|
426
|
+
if (hasBg) {
|
|
427
|
+
const hexBg = colorToHex(bg)
|
|
428
|
+
const alpha = extractAlpha(bg)
|
|
429
|
+
const fillAttr =
|
|
430
|
+
alpha < 1
|
|
431
|
+
? `fill="${hexBg}" fill-opacity="${alpha.toFixed(3)}"`
|
|
432
|
+
: `fill="${hexBg}"`
|
|
433
|
+
state.parts.push(
|
|
434
|
+
`${ind(depth)}<rect id="${layerId}-bg" x="${px(x)}" y="${px(y)}" width="${px(w)}" height="${px(h)}" ${fillAttr} rx="${px(rx * scale)}"${opacityAttr} />`,
|
|
435
|
+
)
|
|
436
|
+
state.layersList.push({
|
|
437
|
+
id: `${layerId}-bg`,
|
|
438
|
+
tag: "rect",
|
|
439
|
+
depth,
|
|
440
|
+
x,
|
|
441
|
+
y,
|
|
442
|
+
width: w,
|
|
443
|
+
height: h,
|
|
444
|
+
type: "background",
|
|
445
|
+
})
|
|
446
|
+
state.layerCount++
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Full border (all sides equal)
|
|
450
|
+
const bTopW = parseFloat(cs.borderTopWidth) || 0
|
|
451
|
+
const bLeftW = parseFloat(cs.borderLeftWidth) || 0
|
|
452
|
+
const bRightW = parseFloat(cs.borderRightWidth) || 0
|
|
453
|
+
const bBottomW = parseFloat(cs.borderBottomWidth) || 0
|
|
454
|
+
const allSidesEqual =
|
|
455
|
+
bTopW > 0 && bTopW === bLeftW && bTopW === bRightW && bTopW === bBottomW
|
|
456
|
+
|
|
457
|
+
if (allSidesEqual) {
|
|
458
|
+
const hexB = colorToHex(cs.borderTopColor || "#2e2030")
|
|
459
|
+
const strokeW = bTopW * scale
|
|
460
|
+
const ry = rx * scale
|
|
461
|
+
state.parts.push(
|
|
462
|
+
`${ind(depth)}<rect id="${layerId}-border" x="${px(x + strokeW / 2)}" y="${px(y + strokeW / 2)}" width="${px(w - strokeW)}" height="${px(h - strokeW)}" fill="none" stroke="${hexB}" stroke-width="${strokeW.toFixed(2)}" rx="${ry.toFixed(2)}" />`,
|
|
463
|
+
)
|
|
464
|
+
state.layersList.push({
|
|
465
|
+
id: `${layerId}-border`,
|
|
466
|
+
tag: "rect",
|
|
467
|
+
depth,
|
|
468
|
+
x: x + strokeW / 2,
|
|
469
|
+
y: y + strokeW / 2,
|
|
470
|
+
width: w - strokeW,
|
|
471
|
+
height: h - strokeW,
|
|
472
|
+
type: "border",
|
|
473
|
+
})
|
|
474
|
+
state.layerCount++
|
|
475
|
+
} else if (bLeftW > 0 && bTopW === 0 && bRightW === 0 && bBottomW === 0) {
|
|
476
|
+
// Left-only border (quote/notice blocks)
|
|
477
|
+
const hexB = colorToHex(cs.borderLeftColor || "#2e2030")
|
|
478
|
+
const lw = bLeftW * scale
|
|
479
|
+
state.parts.push(
|
|
480
|
+
`${ind(depth)}<line id="${layerId}-left-border" x1="${px(x + lw / 2)}" y1="${px(y)}" x2="${px(x + lw / 2)}" y2="${px(y + h)}" stroke="${hexB}" stroke-width="${lw.toFixed(2)}" />`,
|
|
481
|
+
)
|
|
482
|
+
state.layersList.push({
|
|
483
|
+
id: `${layerId}-left-border`,
|
|
484
|
+
tag: "line",
|
|
485
|
+
depth,
|
|
486
|
+
x: x + lw / 2,
|
|
487
|
+
y,
|
|
488
|
+
width: lw,
|
|
489
|
+
height: h,
|
|
490
|
+
type: "border",
|
|
491
|
+
})
|
|
492
|
+
state.layerCount++
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── Children ───────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
const hasChildren = el.childNodes.length > 0
|
|
499
|
+
if (hasChildren) {
|
|
500
|
+
state.parts.push(`${ind(depth)}<g id="${layerId}">`)
|
|
501
|
+
state.layersList.push({
|
|
502
|
+
id: layerId,
|
|
503
|
+
tag: "g",
|
|
504
|
+
depth,
|
|
505
|
+
x,
|
|
506
|
+
y,
|
|
507
|
+
width: w,
|
|
508
|
+
height: h,
|
|
509
|
+
type: "group",
|
|
510
|
+
})
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
for (const child of Array.from(el.childNodes)) {
|
|
514
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
515
|
+
walkTextNode(child as Text, state, depth + 1)
|
|
516
|
+
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
517
|
+
walkElement(child as Element, state, depth + 1)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (hasChildren) {
|
|
522
|
+
state.parts.push(`${ind(depth)}</g>`)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ────────────────────────────────────────────────────────────
|
|
527
|
+
// Text node walker — character-scan for correct line wrapping
|
|
528
|
+
// ────────────────────────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
function walkTextNode(textNode: Text, state: WalkState, depth: number): void {
|
|
531
|
+
const rawText = textNode.textContent ?? ""
|
|
532
|
+
if (!rawText.trim()) return
|
|
533
|
+
|
|
534
|
+
const parent = textNode.parentElement
|
|
535
|
+
if (!parent) return
|
|
536
|
+
|
|
537
|
+
const cs = window.getComputedStyle(parent)
|
|
538
|
+
if (cs.display === "none" || cs.visibility === "hidden") return
|
|
539
|
+
|
|
540
|
+
// Get correctly-split visual lines via character-scan
|
|
541
|
+
const lines = getVisualLines(textNode)
|
|
542
|
+
if (lines.length === 0) return
|
|
543
|
+
|
|
544
|
+
const { rootRect, scale } = state
|
|
545
|
+
|
|
546
|
+
const color = colorToHex(cs.color)
|
|
547
|
+
const colorAlpha = extractAlpha(cs.color)
|
|
548
|
+
const fillOpacityAttr = colorAlpha < 1 ? ` fill-opacity="${colorAlpha.toFixed(3)}"` : ""
|
|
549
|
+
|
|
550
|
+
const fontSize = parseFloat(cs.fontSize) || 14
|
|
551
|
+
const fontFamilyRaw =
|
|
552
|
+
cs.fontFamily?.split(",")[0]?.replace(/['"]/g, "").trim() || "sans-serif"
|
|
553
|
+
const fontFamily = escapeXml(fontFamilyRaw)
|
|
554
|
+
const fontWeight = cs.fontWeight || "400"
|
|
555
|
+
const fontStyleValue = cs.fontStyle || "normal"
|
|
556
|
+
const textDecoration = cs.textDecorationLine || ""
|
|
557
|
+
const textAlign = cs.textAlign || "left"
|
|
558
|
+
const letterSpacing =
|
|
559
|
+
cs.letterSpacing !== "normal" ? parseFloat(cs.letterSpacing) || 0 : 0
|
|
560
|
+
const lsAttr =
|
|
561
|
+
letterSpacing !== 0
|
|
562
|
+
? ` letter-spacing="${(letterSpacing * scale).toFixed(2)}"`
|
|
563
|
+
: ""
|
|
564
|
+
|
|
565
|
+
for (const line of lines) {
|
|
566
|
+
if (!line.text.trim()) continue
|
|
567
|
+
|
|
568
|
+
const lx = (line.rect.left - rootRect.left) * scale
|
|
569
|
+
const ly = (line.rect.top - rootRect.top) * scale
|
|
570
|
+
const lw = line.rect.width * scale
|
|
571
|
+
const lh = line.rect.height * scale
|
|
572
|
+
|
|
573
|
+
// Baseline: roughly 80% down the line box
|
|
574
|
+
const baselineY = ly + fontSize * scale * 0.82
|
|
575
|
+
|
|
576
|
+
// Anchor based on text-align
|
|
577
|
+
let anchor = "start"
|
|
578
|
+
let tx = lx
|
|
579
|
+
if (textAlign === "center") { anchor = "middle"; tx = lx + lw / 2 }
|
|
580
|
+
else if (textAlign === "right" || textAlign === "end") { anchor = "end"; tx = lx + lw }
|
|
581
|
+
|
|
582
|
+
const textLayerId = `text-${depth}-${state.layerCount}`
|
|
583
|
+
state.parts.push(
|
|
584
|
+
`${ind(depth)}<text id="${textLayerId}" x="${px(tx)}" y="${px(baselineY)}" fill="${color}"${fillOpacityAttr} font-size="${(fontSize * scale).toFixed(2)}" font-family="${fontFamily}" font-weight="${fontWeight}" font-style="${fontStyleValue}" text-anchor="${anchor}"${lsAttr}>${escapeXml(line.text)}</text>`,
|
|
585
|
+
)
|
|
586
|
+
state.layersList.push({
|
|
587
|
+
id: textLayerId,
|
|
588
|
+
tag: "text",
|
|
589
|
+
depth,
|
|
590
|
+
x: lx,
|
|
591
|
+
y: ly,
|
|
592
|
+
width: lw,
|
|
593
|
+
height: lh,
|
|
594
|
+
type: "text",
|
|
595
|
+
text: line.text,
|
|
596
|
+
})
|
|
597
|
+
state.layerCount++
|
|
598
|
+
|
|
599
|
+
// Underline
|
|
600
|
+
if (textDecoration.includes("underline")) {
|
|
601
|
+
const uly = ly + fontSize * scale * 0.92 + 2
|
|
602
|
+
state.parts.push(
|
|
603
|
+
`${ind(depth)}<line x1="${px(lx)}" y1="${px(uly)}" x2="${px(lx + lw)}" y2="${px(uly)}" stroke="${color}" stroke-width="1" />`,
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Line-through
|
|
608
|
+
if (textDecoration.includes("line-through")) {
|
|
609
|
+
const lty = ly + lh * 0.55
|
|
610
|
+
state.parts.push(
|
|
611
|
+
`${ind(depth)}<line x1="${px(lx)}" y1="${px(lty)}" x2="${px(lx + lw)}" y2="${px(lty)}" stroke="${color}" stroke-width="1" />`,
|
|
612
|
+
)
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|