@miliastry/quasar 1.0.6 → 1.2.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.
Files changed (278) hide show
  1. package/README.md +1 -1
  2. package/dist/Visuals/lyne.css +374 -9
  3. package/dist/Visuals/osu.css +393 -11
  4. package/dist/index.js +10470 -3336
  5. package/dist/index.mjs +10336 -3337
  6. package/dist/types/Analysis/Contracts/AnalysisReport.d.ts +22 -0
  7. package/dist/types/Analysis/Contracts/Contribution.d.ts +68 -0
  8. package/dist/types/Analysis/Contracts/Pass.d.ts +59 -0
  9. package/dist/types/Analysis/Contracts/PipelineContext.d.ts +34 -0
  10. package/dist/types/Analysis/Passes/Analysis/ColorUsageAnalyzer.d.ts +56 -0
  11. package/dist/types/Analysis/Passes/Analysis/GradientAnalyzer.d.ts +111 -0
  12. package/dist/types/Analysis/Passes/Analysis/MergeableColorAnalyzer.d.ts +26 -0
  13. package/dist/types/Analysis/Passes/Analysis/RainbowAnalyzer.d.ts +47 -0
  14. package/dist/types/Analysis/Passes/Analysis/SymbolAnalyzer.d.ts +89 -0
  15. package/dist/types/Analysis/Passes/Analysis/WaveAnalyzer.d.ts +44 -0
  16. package/dist/types/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.d.ts +4 -0
  17. package/dist/types/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.d.ts +4 -0
  18. package/dist/types/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.d.ts +4 -0
  19. package/dist/types/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.d.ts +4 -0
  20. package/dist/types/Analysis/Passes/Decision/DefaultDecision.d.ts +30 -0
  21. package/dist/types/Analysis/Passes/Decision/PaletteRemapDecision.d.ts +169 -0
  22. package/dist/types/Analysis/Passes/Decision/__tests__/DefaultDecision.test.d.ts +4 -0
  23. package/dist/types/Analysis/Passes/Transform/CollapseGradientTransform.d.ts +37 -0
  24. package/dist/types/Analysis/Passes/Transform/MergeColorsTransform.d.ts +28 -0
  25. package/dist/types/Analysis/Passes/Transform/RainbowCollapseTransform.d.ts +19 -0
  26. package/dist/types/Analysis/Passes/Transform/WaveCollapseTransform.d.ts +20 -0
  27. package/dist/types/Analysis/Passes/Utility/CharacterCountAnalyzer.d.ts +19 -0
  28. package/dist/types/Analysis/Pipeline/Pipeline.d.ts +69 -0
  29. package/dist/types/Analysis/Pipeline/PipelineBuilder.d.ts +31 -0
  30. package/dist/types/Analysis/Pipeline/PipelineStage.d.ts +17 -0
  31. package/dist/types/Analysis/Utils/color-utils.d.ts +60 -0
  32. package/dist/types/Analysis/__tests__/Integration.test.d.ts +8 -0
  33. package/dist/types/Analysis/__tests__/PaletteRemap.test.d.ts +14 -0
  34. package/dist/types/Analysis/__tests__/Pipeline.test.d.ts +10 -0
  35. package/dist/types/Analysis/__tests__/RoundTrip.test.d.ts +30 -0
  36. package/dist/types/Analysis/__tests__/SymbolAnalyzer.test.d.ts +11 -0
  37. package/dist/types/Analysis/index.d.ts +40 -0
  38. package/dist/types/BBCode/BBCodeDocumentModel.d.ts +102 -0
  39. package/dist/types/BBCode/BBCodeToGreenNode.d.ts +111 -0
  40. package/dist/types/BBCode/Parser.d.ts +78 -0
  41. package/dist/types/BBCode/index.d.ts +4 -0
  42. package/dist/types/Collab/positions.d.ts +55 -0
  43. package/dist/types/Commands/Command.d.ts +37 -0
  44. package/dist/types/Commands/CommandRegistry.d.ts +48 -0
  45. package/dist/types/Commands/DeleteNode.d.ts +7 -0
  46. package/dist/types/Commands/InsertText.d.ts +8 -0
  47. package/dist/types/Commands/SplitMerge.d.ts +9 -0
  48. package/dist/types/Commands/WrapInTag.d.ts +8 -0
  49. package/dist/types/Commands/index.d.ts +6 -0
  50. package/dist/types/Diff/TreeDiffer.d.ts +52 -0
  51. package/dist/types/Diff/__tests__/TreeDiffer.test.d.ts +1 -0
  52. package/dist/types/Diff/index.d.ts +2 -0
  53. package/dist/types/Edits/EditPlan.d.ts +144 -0
  54. package/dist/types/Edits/Optimizer.d.ts +78 -0
  55. package/dist/types/Edits/Rules/Rule.d.ts +91 -0
  56. package/dist/types/Edits/Rules/dropEmptyTags.d.ts +40 -0
  57. package/dist/types/Edits/Rules/dropRedundantNesting.d.ts +59 -0
  58. package/dist/types/Edits/Rules/index.d.ts +9 -0
  59. package/dist/types/Edits/Rules/mergeAdjacent.d.ts +93 -0
  60. package/dist/types/Edits/Rules/reorderWrappers.d.ts +42 -0
  61. package/dist/types/Edits/Rules/shortenHex.d.ts +30 -0
  62. package/dist/types/Edits/Rules/tagValue.d.ts +56 -0
  63. package/dist/types/Edits/Rules/unwrapInvisibleColor.d.ts +47 -0
  64. package/dist/types/Edits/applyEdits.d.ts +36 -0
  65. package/dist/types/Edits/index.d.ts +6 -0
  66. package/dist/types/Events/EventBus.d.ts +103 -0
  67. package/dist/types/Events/index.d.ts +2 -0
  68. package/dist/types/Formatter/Formatter.d.ts +32 -0
  69. package/dist/types/Formatter/index.d.ts +2 -0
  70. package/dist/types/HTML/HTMLDocumentModel.d.ts +13 -0
  71. package/dist/types/HTML/HTMLToGreenNode.d.ts +4 -0
  72. package/dist/types/Incremental/BracketIndex.d.ts +101 -0
  73. package/dist/types/Incremental/ChangeTracker.d.ts +69 -0
  74. package/dist/types/Incremental/IncrementalParser.d.ts +246 -0
  75. package/dist/types/Incremental/__tests__/BracketIndex.test.d.ts +1 -0
  76. package/dist/types/Incremental/__tests__/IncrementalParser.test.d.ts +1 -0
  77. package/dist/types/Incremental/index.d.ts +4 -0
  78. package/dist/types/Lexer/BBCodeLexer.d.ts +74 -0
  79. package/dist/types/Lexer/Lexer.d.ts +54 -0
  80. package/dist/types/Lexer/index.d.ts +4 -0
  81. package/dist/types/Linter/Linter.d.ts +58 -0
  82. package/dist/types/Linter/index.d.ts +2 -0
  83. package/dist/types/Markdown/MarkdownAST.d.ts +122 -0
  84. package/dist/types/Markdown/MarkdownDocumentModel.d.ts +15 -0
  85. package/dist/types/Markdown/MarkdownLexer.d.ts +155 -0
  86. package/dist/types/Markdown/MarkdownParser.d.ts +42 -0
  87. package/dist/types/Markdown/MarkdownToGreenNode.d.ts +6 -0
  88. package/dist/types/MilHibri/MilHibriDocumentModel.d.ts +21 -0
  89. package/dist/types/Model/DocumentModel.d.ts +318 -0
  90. package/dist/types/Model/NodeFactory.d.ts +29 -0
  91. package/dist/types/Model/TagRegistry.d.ts +165 -0
  92. package/dist/types/Model/index.d.ts +5 -0
  93. package/dist/types/Plugins/PluginAPI.d.ts +44 -0
  94. package/dist/types/Plugins/PluginRegistry.d.ts +81 -0
  95. package/dist/types/Plugins/index.d.ts +3 -0
  96. package/dist/types/Queries/QueryEngine.d.ts +32 -0
  97. package/dist/types/Queries/index.d.ts +1 -0
  98. package/dist/types/Reconciler/SurgicalReconciler.d.ts +23 -0
  99. package/dist/types/RenderPipeline/RenderPipeline.d.ts +46 -0
  100. package/dist/types/RenderPipeline/RenderTree.d.ts +55 -0
  101. package/dist/types/RenderPipeline/index.d.ts +4 -0
  102. package/dist/types/Repair/NestingRepair.d.ts +48 -0
  103. package/dist/types/Repair/index.d.ts +1 -0
  104. package/dist/types/Semantic/SemanticAnalyzer.d.ts +321 -0
  105. package/dist/types/Semantic/index.d.ts +2 -0
  106. package/dist/types/Symbols/SymbolTable.d.ts +47 -0
  107. package/dist/types/Symbols/index.d.ts +1 -0
  108. package/dist/types/Syntax/GreenNode.d.ts +154 -0
  109. package/dist/types/Syntax/GreenNodePool.d.ts +134 -0
  110. package/dist/types/Syntax/NodeMatcher.d.ts +75 -0
  111. package/dist/types/Syntax/RedNode.d.ts +280 -0
  112. package/dist/types/Syntax/RedNodeStore.d.ts +116 -0
  113. package/dist/types/Syntax/TreeBuilder.d.ts +54 -0
  114. package/dist/types/Syntax/__tests__/GreenNode.test.d.ts +1 -0
  115. package/dist/types/Syntax/__tests__/RedNode.test.d.ts +1 -0
  116. package/dist/types/Syntax/__tests__/RedNodeStore.test.d.ts +1 -0
  117. package/dist/types/Syntax/greenEdit.d.ts +64 -0
  118. package/dist/types/Syntax/hash.d.ts +17 -0
  119. package/dist/types/Syntax/index.d.ts +8 -0
  120. package/dist/types/Syntax/nodeAttr.d.ts +63 -0
  121. package/dist/types/Syntax/partition.d.ts +67 -0
  122. package/dist/types/Syntax/preserveNodeIds.d.ts +47 -0
  123. package/dist/types/Tokens/TokenResolver.d.ts +25 -0
  124. package/dist/types/Tokens/index.d.ts +1 -0
  125. package/dist/types/Transactions/Transaction.d.ts +36 -0
  126. package/dist/types/Transactions/UndoManager.d.ts +63 -0
  127. package/dist/types/Transactions/index.d.ts +3 -0
  128. package/dist/types/Transformers/ASTOptimizer.d.ts +34 -0
  129. package/dist/types/Transformers/GradientTransformer.d.ts +18 -0
  130. package/dist/types/Transformers/GrowTransformer.d.ts +16 -0
  131. package/dist/types/Transformers/RainbowTransformer.d.ts +17 -0
  132. package/dist/types/Transformers/SineWaveTransformer.d.ts +17 -0
  133. package/dist/types/Transformers/Transformer.d.ts +17 -0
  134. package/dist/types/Types/core.d.ts +104 -0
  135. package/dist/types/Types/diagnostics.d.ts +85 -0
  136. package/dist/types/Types/index.d.ts +11 -0
  137. package/dist/types/Types/operations.d.ts +109 -0
  138. package/dist/types/Types/queries.d.ts +67 -0
  139. package/dist/types/Types/symbols.d.ts +42 -0
  140. package/dist/types/Types/tokens.d.ts +75 -0
  141. package/dist/types/Utils/BBCodeGenerator.d.ts +21 -0
  142. package/dist/types/Utils/ColorMath.d.ts +92 -0
  143. package/dist/types/Utils/EffectMath.d.ts +643 -0
  144. package/dist/types/Utils/TagVocabulary.d.ts +104 -0
  145. package/dist/types/Utils/TagVocabulary.test.d.ts +1 -0
  146. package/dist/types/Utils/color.d.ts +29 -0
  147. package/dist/types/Utils/dom-to-svg.d.ts +28 -0
  148. package/dist/types/Utils/dom-to-svg.test.d.ts +1 -0
  149. package/dist/types/Utils/treeTransformers.d.ts +160 -0
  150. package/dist/types/Visitors/BBBlocksExporter.d.ts +27 -0
  151. package/dist/types/Visitors/BBCodeExporter.d.ts +90 -0
  152. package/dist/types/Visitors/BlockPatcher.d.ts +91 -0
  153. package/dist/types/Visitors/DOMMorpher.d.ts +19 -0
  154. package/dist/types/Visitors/HTMLRenderer.d.ts +492 -0
  155. package/dist/types/Visitors/JSONExporter.d.ts +42 -0
  156. package/dist/types/Visitors/MarkdownExporter.d.ts +28 -0
  157. package/dist/types/Visitors/SVGRenderer.d.ts +16 -0
  158. package/dist/types/Visitors/TiptapExporter.d.ts +40 -0
  159. package/dist/types/Visitors/Visitor.d.ts +35 -0
  160. package/dist/types/Visitors/index.d.ts +9 -0
  161. package/dist/types/Visuals/BoxDrawer.d.ts +54 -0
  162. package/dist/types/Visuals/LyneAudio.d.ts +8 -0
  163. package/dist/types/Visuals/index.d.ts +39 -0
  164. package/dist/types/index.d.ts +155 -0
  165. package/package.json +14 -10
  166. package/src/Analysis/Passes/Analysis/ColorUsageAnalyzer.ts +103 -0
  167. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +294 -20
  168. package/src/Analysis/Passes/Analysis/SymbolAnalyzer.ts +257 -0
  169. package/src/Analysis/Passes/Decision/PaletteRemapDecision.ts +425 -0
  170. package/src/Analysis/Pipeline/Pipeline.ts +15 -2
  171. package/src/Analysis/index.ts +8 -2
  172. package/src/BBCode/BBCodeDocumentModel.ts +39 -1
  173. package/src/BBCode/BBCodeToGreenNode.ts +18 -12
  174. package/src/BBCode/Parser.ts +38 -1
  175. package/src/Edits/EditPlan.ts +290 -0
  176. package/src/Edits/Optimizer.ts +185 -0
  177. package/src/Edits/Rules/Rule.ts +145 -0
  178. package/src/Edits/Rules/dropEmptyTags.ts +110 -0
  179. package/src/Edits/Rules/dropRedundantNesting.ts +120 -0
  180. package/src/Edits/Rules/index.ts +28 -0
  181. package/src/Edits/Rules/mergeAdjacent.ts +224 -0
  182. package/src/Edits/Rules/reorderWrappers.ts +154 -0
  183. package/src/Edits/Rules/shortenHex.ts +79 -0
  184. package/src/Edits/Rules/tagValue.ts +105 -0
  185. package/src/Edits/Rules/unwrapInvisibleColor.ts +93 -0
  186. package/src/Edits/applyEdits.ts +54 -0
  187. package/src/Edits/index.ts +22 -0
  188. package/src/Events/EventBus.ts +18 -0
  189. package/src/HTML/HTMLToGreenNode.ts +193 -37
  190. package/src/Incremental/BracketIndex.ts +249 -0
  191. package/src/Incremental/IncrementalParser.ts +464 -41
  192. package/src/Lexer/BBCodeLexer.ts +25 -3
  193. package/src/Lexer/index.ts +1 -1
  194. package/src/Markdown/MarkdownAST.ts +65 -2
  195. package/src/Markdown/MarkdownLexer.ts +69 -14
  196. package/src/Markdown/MarkdownParser.ts +749 -62
  197. package/src/Markdown/MarkdownToGreenNode.ts +148 -27
  198. package/src/MilHibri/MilHibriDocumentModel.ts +56 -0
  199. package/src/Model/DocumentModel.ts +138 -5
  200. package/src/Model/TagRegistry.ts +129 -134
  201. package/src/Reconciler/SurgicalReconciler.ts +460 -0
  202. package/src/Repair/NestingRepair.ts +116 -0
  203. package/src/Repair/index.ts +1 -0
  204. package/src/Semantic/SemanticAnalyzer.ts +1845 -65
  205. package/src/Syntax/GreenNodePool.ts +12 -12
  206. package/src/Syntax/RedNode.ts +17 -3
  207. package/src/Syntax/RedNodeStore.ts +9 -1
  208. package/src/Syntax/nodeAttr.ts +172 -0
  209. package/src/Tokens/TokenResolver.ts +55 -0
  210. package/src/Tokens/index.ts +1 -0
  211. package/src/Types/core.ts +15 -0
  212. package/src/Utils/ColorMath.ts +276 -55
  213. package/src/Utils/EffectMath.ts +2462 -0
  214. package/src/Utils/TagVocabulary.ts +141 -0
  215. package/src/Utils/dom-to-svg.ts +28 -12
  216. package/src/Visitors/BBBlocksExporter.ts +15 -1
  217. package/src/Visitors/BBCodeExporter.ts +365 -32
  218. package/src/Visitors/BlockPatcher.ts +123 -22
  219. package/src/Visitors/DOMMorpher.ts +44 -28
  220. package/src/Visitors/HTMLRenderer.ts +771 -205
  221. package/src/Visitors/MarkdownExporter.ts +98 -41
  222. package/src/Visitors/TiptapExporter.ts +1 -0
  223. package/src/Visitors/index.ts +1 -1
  224. package/src/Visuals/BoxDrawer.ts +56 -10
  225. package/src/Visuals/LyneAudio.ts +346 -0
  226. package/src/Visuals/index.ts +8 -4
  227. package/src/Visuals/lyne.css +374 -9
  228. package/src/Visuals/osu.css +393 -11
  229. package/src/index.ts +178 -8
  230. package/dist/index.d.mts +0 -4612
  231. package/dist/index.d.ts +0 -4612
  232. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +0 -135
  233. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +0 -84
  234. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +0 -99
  235. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +0 -119
  236. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +0 -179
  237. package/src/Analysis/__tests__/Integration.test.ts +0 -162
  238. package/src/Analysis/__tests__/Pipeline.test.ts +0 -133
  239. package/src/Diff/__tests__/TreeDiffer.test.ts +0 -65
  240. package/src/Incremental/__tests__/IncrementalParser.test.ts +0 -164
  241. package/src/Syntax/__tests__/GreenNode.test.ts +0 -33
  242. package/src/Syntax/__tests__/RedNode.test.ts +0 -81
  243. package/src/Syntax/__tests__/RedNodeStore.test.ts +0 -104
  244. package/src/Tests/ASTOptimizerIdempotence.test.ts +0 -77
  245. package/src/Tests/BlockPatcher.test.ts +0 -437
  246. package/src/Tests/BlockPatcherWindowed.test.ts +0 -364
  247. package/src/Tests/BoxDrawer.test.ts +0 -217
  248. package/src/Tests/BoxRichTitle.test.ts +0 -105
  249. package/src/Tests/Chars500kBenchmark.test.ts +0 -151
  250. package/src/Tests/Chars500kEdits.test.ts +0 -321
  251. package/src/Tests/CollabPositions.test.ts +0 -146
  252. package/src/Tests/CompilerPathProfiling.test.ts +0 -186
  253. package/src/Tests/DOMMorpher.test.ts +0 -142
  254. package/src/Tests/DomPatchPerf.test.ts +0 -60
  255. package/src/Tests/EffectSegments.snapshot.json +0 -616
  256. package/src/Tests/EffectSegments.test.ts +0 -68
  257. package/src/Tests/FindNodeAtOffset.test.ts +0 -65
  258. package/src/Tests/Fuzzer.test.ts +0 -166
  259. package/src/Tests/GreenNodePool.test.ts +0 -153
  260. package/src/Tests/Lexer.test.ts +0 -238
  261. package/src/Tests/LyneMode.test.ts +0 -291
  262. package/src/Tests/ModelCoherence.test.ts +0 -180
  263. package/src/Tests/Partition.test.ts +0 -238
  264. package/src/Tests/PluginTags.test.ts +0 -150
  265. package/src/Tests/ProblematicSection.test.ts +0 -46
  266. package/src/Tests/ProblematicSectionHTML.test.ts +0 -58
  267. package/src/Tests/RedReuse.test.ts +0 -134
  268. package/src/Tests/ReproDelete20k.test.ts +0 -62
  269. package/src/Tests/SemanticValidators.test.ts +0 -136
  270. package/src/Tests/StableNodeIds.test.ts +0 -210
  271. package/src/Tests/StudioColorBloat.test.ts +0 -25
  272. package/src/Tests/StudioDebugText.test.ts +0 -27
  273. package/src/Tests/StudioTrailingChar.test.ts +0 -25
  274. package/src/Tests/StudioValidText.test.ts +0 -25
  275. package/src/Tests/UrlImgBug.test.ts +0 -23
  276. package/src/Tests/VisualBuilderFidelity.test.ts +0 -105
  277. package/src/Tests/referenceDocument.ts +0 -119
  278. package/src/Utils/dom-to-svg.test.ts +0 -86
@@ -1,14 +1,13 @@
1
1
  /**
2
2
  * DocumentEngine — MarkdownExporter
3
3
  *
4
- * Exports the Document Model to Markdown.
5
- * Demonstrates the format-agnostic power of the engine:
6
- * BBCode in → DocumentModel → Markdown out.
7
- *
8
- * A single visitor, no parser changes needed.
4
+ * Exports the Document Model to Markdown with hybrid BBCode embedding
5
+ * for features not natively supported in standard CommonMark (color, size,
6
+ * underline, alignment, collapsible boxes).
9
7
  */
10
8
 
11
9
  import { RedNode } from '../Syntax/RedNode'
10
+ import { nodeAttrValue } from '../Syntax/nodeAttr'
12
11
  import { Visitor } from './Visitor'
13
12
 
14
13
  export class MarkdownExporter extends Visitor<string> {
@@ -34,35 +33,99 @@ export class MarkdownExporter extends Visitor<string> {
34
33
  switch (node.kind) {
35
34
  case 'bold': return `**${content}**`
36
35
  case 'italic': return `*${content}*`
37
- case 'underline': return content
36
+ case 'underline': return `++${content}++`
38
37
  case 'strikethrough': return `~~${content}~~`
39
38
  case 'inline_code': return `\`${content}\``
40
39
  case 'code': return `\`\`\`\n${content}\n\`\`\``
41
40
  case 'spoiler': return `||${content}||`
42
- case 'color': return content
43
- case 'font_size': return content
44
- case 'font': return content
45
- case 'heading': return `${'#'.repeat(Math.min(depth + 1, 6))} ${content}`
46
- case 'center': return content
47
- case 'right': return content
48
- case 'left': return content
49
- case 'url': return `[${content}](${this.extractValue(node) || content})`
50
- case 'email': return `[${content}](mailto:${this.extractValue(node) || content})`
51
- case 'profile': return `[${content}](https://osu.ppy.sh/users/${this.extractValue(node) || content})`
52
- case 'image': return `![Image](${node.text || ''})`
41
+ case 'color': {
42
+ const c = (node.metadata?.color as string) || this.extractValue(node) || '#ffffff'
43
+ return `[${content}]{${c}}`
44
+ }
45
+ case 'font_size': {
46
+ const s = (node.metadata?.size as string) || this.extractValue(node) || '100'
47
+ return `[${content}]{size="${s}"}`
48
+ }
49
+ case 'font': {
50
+ const f = (node.metadata?.font as string) || this.extractValue(node) || 'sans-serif'
51
+ return `[${content}]{font="${f}"}`
52
+ }
53
+ case 'heading': {
54
+ const level = (node.metadata?.level as number) || depth + 1
55
+ return `${'#'.repeat(Math.min(level, 6))} ${content}`
56
+ }
57
+ case 'center': return `-> ${content.trim()} <-`
58
+ case 'right': return `-> ${content.trim()} ->`
59
+ case 'left': return `[left]${content}[/left]`
60
+ case 'align': {
61
+ const a = (node.metadata?.align as string) || this.extractValue(node) || 'center'
62
+ if (a === 'center') return `-> ${content.trim()} <-`
63
+ if (a === 'right') return `-> ${content.trim()} ->`
64
+ return `[align=${a}]${content}[/align]`
65
+ }
66
+ case 'url': return `[${content}](${node.metadata?.href || this.extractValue(node) || content})`
67
+ case 'email': {
68
+ const addr = (node.metadata?.href as string)?.replace('mailto:', '') || this.extractValue(node) || content
69
+ return `[${content}](mailto:${addr})`
70
+ }
71
+ case 'profile': return `[${content}](https://osu.ppy.sh/users/${node.metadata?.username || this.extractValue(node) || content})`
72
+ case 'image': {
73
+ const src = (node.metadata?.src as string) || (node.text && !node.text.startsWith('=') ? node.text : '') || content
74
+ const alt = (node.metadata?.alt as string) || (content && content !== src ? content : 'Image')
75
+ return `![${alt}](${src})`
76
+ }
53
77
  case 'video': return `[🎥 YouTube Video](https://youtube.com/watch?v=${node.text || ''})`
54
78
  case 'audio': return `[🎵 Audio](${node.text || ''})`
55
- case 'quote': return `> **${this.extractValue(node) || 'Quote'}**\n> ${content.replace(/\\n/g, '\n> ')}`
56
- case 'notice': return `> [!NOTE]\n> ${content.replace(/\\n/g, '\n> ')}`
57
- case 'spoilerbox': return `**${this.renderTitleMarkdown(node) || 'Spoiler'}**\n${content}`
79
+ case 'quote': {
80
+ const source = (node.metadata?.source as string) || this.extractValue(node)
81
+ const header = source ? `**${source}**\n> ` : ''
82
+ return `> ${header}${content.replace(/\n/g, '\n> ')}`
83
+ }
84
+ case 'notice': {
85
+ const color = (node.metadata?.color as string) || this.extractValue(node)
86
+ const colorAttr = color ? `=${color}` : ''
87
+ return `[notice${colorAttr}]\n${content}\n[/notice]`
88
+ }
89
+ case 'wnotice': {
90
+ const color = (node.metadata?.color as string) || this.extractValue(node)
91
+ const colorAttr = color ? `=${color}` : ''
92
+ return `[wnotice${colorAttr}]\n${content}\n[/wnotice]`
93
+ }
94
+ case 'spoilerbox':
58
95
  case 'box':
59
- case 'boxw': return `**${this.renderTitleMarkdown(node) || 'Box'}**\n${content}`
60
- case 'list': return content
61
- case 'list_item': return `${' '.repeat(Math.max(0, depth - 1))}- ${content.trim()}`
96
+ case 'boxw': {
97
+ const title = this.renderTitleMarkdown(node)
98
+ const header = title ? `::: details ${title}` : '::: details'
99
+ return `${header}\n${content}\n:::`
100
+ }
101
+ case 'list': {
102
+ const lines: string[] = []
103
+ let orderIdx = 1
104
+ for (const child of node.children) {
105
+ if (child.kind === 'list_item') {
106
+ const itemContent = child.children.map(c => this.exportNode(c, depth)).join('')
107
+ const isOrdered = node.metadata?.ordered
108
+ const prefix = isOrdered ? `${orderIdx++}. ` : '- '
109
+ lines.push(`${' '.repeat(Math.max(0, depth))}${prefix}${itemContent.trim()}`)
110
+ }
111
+ }
112
+ return lines.length > 0 ? lines.join('\n') + '\n' : content
113
+ }
114
+ case 'list_item': {
115
+ const isOrdered = node.parent?.metadata?.ordered
116
+ const prefix = isOrdered ? '1. ' : '- '
117
+ return `${' '.repeat(Math.max(0, depth - 1))}${prefix}${content.trim()}`
118
+ }
119
+ case 'separator': return '---'
62
120
  case 'spacing': return '\n'
63
121
  case 'empty_line': return '\n'
64
- case 'text':
65
- default: return content || node.text || ''
122
+ case 'paragraph': return content
123
+ case 'text': return content || node.text || ''
124
+ default: {
125
+ const val = this.extractValue(node) || (node.metadata?.value as string) || (node.metadata?.raw as string)
126
+ const attr = val !== undefined && val !== null && String(val) !== '' ? `=${val}` : ''
127
+ return `[${node.kind}${attr}]${content}[/${node.kind}]`
128
+ }
66
129
  }
67
130
  }
68
131
 
@@ -76,24 +139,18 @@ export class MarkdownExporter extends Visitor<string> {
76
139
 
77
140
  /**
78
141
  * Read a BBCode tag's `=VALUE` attribute.
142
+ */
143
+ /**
144
+ * El atributo del nodo, o `undefined` si no lleva.
79
145
  *
80
- * This previously read `node.properties` / `node.attributes`, neither of
81
- * which exists on RedNode — so it silently returned `undefined` for every
82
- * node and every call site fell through to its placeholder. That is why
83
- * Markdown export dropped link targets and quote/box titles.
146
+ * El recorte lo hace `nodeAttrValue`, el mismo lector del `HTMLRenderer`;
147
+ * aquí sólo se cambia el contrato del caso vacío. Este exportador encadena
148
+ * `metadata ?? atributo ?? contenido`, así que necesita un `undefined`
149
+ * explícito donde `nodeAttrValue` devuelve el texto crudo — es la única
150
+ * diferencia, y por eso no se llama directamente.
84
151
  */
85
152
  private extractValue(node: RedNode): string | undefined {
86
- const text = node.text || ''
87
- const eqIdx = text.indexOf('=')
88
- if (eqIdx < 0) return undefined
89
-
90
- let value = text.slice(eqIdx + 1)
91
- if (
92
- (value.startsWith('"') && value.endsWith('"')) ||
93
- (value.startsWith("'") && value.endsWith("'"))
94
- ) {
95
- value = value.slice(1, -1)
96
- }
97
- return value || undefined
153
+ if (!(node.text || '').includes('=')) return undefined
154
+ return nodeAttrValue(node) || undefined
98
155
  }
99
156
  }
@@ -121,6 +121,7 @@ export class TiptapExporter extends Visitor<string> {
121
121
  case 'rainbow':
122
122
  case 'grow':
123
123
  case 'sinewave':
124
+ case 'paint':
124
125
  return { type: 'miliastryEffect', attrs: { effect: node.kind, ...node.metadata } }
125
126
  }
126
127
  return null
@@ -2,7 +2,7 @@ export { Visitor } from './Visitor'
2
2
  export type { VisitorContext } from './Visitor'
3
3
  export { BBCodeExporter } from './BBCodeExporter'
4
4
  export { HTMLRenderer } from './HTMLRenderer'
5
- export { morphHTML } from './DOMMorpher'
5
+ export { morphHTML, morphElement } from './DOMMorpher'
6
6
  export { MarkdownExporter } from './MarkdownExporter'
7
7
  export { JSONExporter } from './JSONExporter'
8
8
  export { SVGRenderer } from './SVGRenderer'
@@ -34,6 +34,9 @@ export interface BoxDrawerOptions {
34
34
  const DEFAULT_DURATION_MS = 400
35
35
  /** Floor so an interruption near the end still reads as motion, not a snap. */
36
36
  const MIN_DURATION_MS = 120
37
+ /** Clase de estado abierto del spoilerbox de osu, puesta en el contenedor. */
38
+ const OSU_OPEN_CLASS = 'js-spoilerbox--open'
39
+
37
40
  /** easeOutCubic — fast to start, settles softly, no overshoot. */
38
41
  const DEFAULT_EASING = 'cubic-bezier(0.33, 1, 0.68, 1)'
39
42
 
@@ -50,7 +53,7 @@ interface DrawerState {
50
53
  }
51
54
 
52
55
  /** In-flight state per element; a WeakMap so morphed-away nodes are freed. */
53
- const running = new WeakMap<HTMLDetailsElement, DrawerState>()
56
+ const running = new WeakMap<Element, DrawerState>()
54
57
 
55
58
  function prefersReducedMotion(): boolean {
56
59
  return typeof window !== 'undefined'
@@ -65,12 +68,14 @@ function prefersReducedMotion(): boolean {
65
68
  * Exported so hosts that toggle boxes programmatically (a keyboard command, an
66
69
  * "expand all" action) go through the same animation as a click.
67
70
  */
68
- export function toggleBoxWithDrawer(
69
- details: HTMLDetailsElement,
71
+ function animateDrawer(
72
+ details: HTMLElement,
73
+ isOpen: () => boolean,
74
+ setOpen: (v: boolean) => void,
70
75
  options: BoxDrawerOptions = {}
71
76
  ): void {
72
77
  if (typeof details.animate !== 'function' || prefersReducedMotion()) {
73
- details.open = !details.open
78
+ setOpen(!isOpen())
74
79
  return
75
80
  }
76
81
 
@@ -85,7 +90,7 @@ export function toggleBoxWithDrawer(
85
90
  // `details.open`: a closing box is kept open so its content stays visible, so
86
91
  // reading the attribute would report "open" and close it a second time,
87
92
  // making a mid-close click impossible to reverse.
88
- const opening = previous ? !previous.opening : !details.open
93
+ const opening = previous ? !previous.opening : !isOpen()
89
94
 
90
95
  previous?.animation.cancel()
91
96
 
@@ -94,14 +99,14 @@ export function toggleBoxWithDrawer(
94
99
  // while the box grows or shrinks over it.
95
100
  let collapsed: number
96
101
  let expanded: number
97
- if (details.open) {
102
+ if (isOpen()) {
98
103
  expanded = details.getBoundingClientRect().height
99
- details.open = false
104
+ setOpen(false)
100
105
  collapsed = details.getBoundingClientRect().height
101
- details.open = true
106
+ setOpen(true)
102
107
  } else {
103
108
  collapsed = details.getBoundingClientRect().height
104
- details.open = true
109
+ setOpen(true)
105
110
  expanded = details.getBoundingClientRect().height
106
111
  }
107
112
 
@@ -133,7 +138,7 @@ export function toggleBoxWithDrawer(
133
138
  const settle = (finished: boolean) => {
134
139
  if (running.get(details)?.animation !== animation) return
135
140
  running.delete(details)
136
- if (finished) details.open = opening
141
+ if (finished) setOpen(opening)
137
142
  details.style.overflow = restoreOverflow
138
143
  }
139
144
 
@@ -141,6 +146,36 @@ export function toggleBoxWithDrawer(
141
146
  animation.addEventListener('cancel', () => settle(false))
142
147
  }
143
148
 
149
+ /**
150
+ * Animate one `<details>` to its opposite state. Exported so hosts that toggle
151
+ * boxes programmatically go through the same animation as a click.
152
+ */
153
+ export function toggleBoxWithDrawer(
154
+ details: HTMLDetailsElement,
155
+ options: BoxDrawerOptions = {}
156
+ ): void {
157
+ animateDrawer(details, () => details.open, (v) => { details.open = v }, options)
158
+ }
159
+
160
+ /**
161
+ * Lo mismo para la caja del dialecto osu, que no es un `<details>`: es
162
+ * `div.js-spoilerbox > a.js-spoilerbox__link + div.js-spoilerbox__body`, y su
163
+ * estado abierto es la clase `js-spoilerbox--open` en el contenedor. Marcado
164
+ * distinto, misma animación — y la misma razón para vivir aquí: Quasar es
165
+ * quien emite el marcado, así que también define cómo se abre.
166
+ */
167
+ export function toggleSpoilerboxWithDrawer(
168
+ box: HTMLElement,
169
+ options: BoxDrawerOptions = {}
170
+ ): void {
171
+ animateDrawer(
172
+ box,
173
+ () => box.classList.contains(OSU_OPEN_CLASS),
174
+ (v) => box.classList.toggle(OSU_OPEN_CLASS, v),
175
+ options
176
+ )
177
+ }
178
+
144
179
  /**
145
180
  * Intercept the native toggle of every box inside `root`.
146
181
  *
@@ -157,6 +192,17 @@ export function bindBoxDrawer(
157
192
  ): () => void {
158
193
  const handleClick = (e: MouseEvent) => {
159
194
  const target = e.target as HTMLElement | null
195
+
196
+ // Dialecto osu: el marcado es div > a + div, sin <details> que interceptar.
197
+ const osuLink = target?.closest('.js-spoilerbox__link')
198
+ if (osuLink) {
199
+ const box = osuLink.closest('.js-spoilerbox')
200
+ if (!(box instanceof HTMLElement) || !root.contains(box)) return
201
+ e.preventDefault() // el <a href="#"> saltaria al principio del documento
202
+ toggleSpoilerboxWithDrawer(box, options)
203
+ return
204
+ }
205
+
160
206
  const summary = target?.closest('summary')
161
207
  if (!summary) return
162
208
 
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Quasar Document Engine — Visuals / LyneAudio.ts
3
+ *
4
+ * Interactive runtime for Quasar's Lyne / SYNE custom audio player (.lx-audio).
5
+ * Supports both scoped root binding (bindLyneAudio) and global document delegation.
6
+ */
7
+
8
+ export function bindLyneAudio(
9
+ root: HTMLElement = (typeof document !== 'undefined' ? document.documentElement : (undefined as any))
10
+ ): () => void {
11
+ if (!root || typeof root.addEventListener !== 'function') {
12
+ return () => {}
13
+ }
14
+
15
+ const formatTime = (seconds: number): string => {
16
+ if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return '–:––'
17
+ const m = Math.floor(seconds / 60)
18
+ const s = Math.floor(seconds % 60)
19
+ return `${m}:${s < 10 ? '0' : ''}${s}`
20
+ }
21
+
22
+ const PLAY_SVG = '<svg viewBox="0 0 16 16"><path d="M3 1.5 14 8 3 14.5z"/></svg>'
23
+ const PAUSE_SVG = '<svg viewBox="0 0 16 16"><path d="M3 2h3.6v12H3zM9.4 2H13v12H9.4z"/></svg>'
24
+ const RETRY_SVG = '<svg viewBox="0 0 16 16"><path d="M8 2a6 6 0 1 0 6 6h-2a4 4 0 1 1-4-4v3l5-4-5-4z"/></svg>'
25
+ const VOL_LOW_SVG = '<svg viewBox="0 0 16 16"><path d="M10.707 11.182A4.5 4.5 0 0 0 12.025 8a4.5 4.5 0 0 0-1.318-3.182L10 5.525A3.5 3.5 0 0 1 11.025 8 3.5 3.5 0 0 1 10 10.475zM6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06"/></svg>'
26
+ const VOL_HIGH_SVG = '<svg viewBox="0 0 16 16"><path d="M11.536 14.01A8.47 8.47 0 0 0 14.026 8a8.47 8.47 0 0 0-2.49-6.01l-.708.707A7.48 7.48 0 0 1 13.025 8c0 2.071-.84 3.946-2.197 5.303z"/><path d="M10.121 12.596A6.48 6.48 0 0 0 12.025 8a6.48 6.48 0 0 0-1.904-4.596l-.707.707A5.48 5.48 0 0 1 11.025 8a5.48 5.48 0 0 1-1.61 3.89z"/><path d="M8.707 11.182A4.5 4.5 0 0 0 10.025 8a4.5 4.5 0 0 0-1.318-3.182L8 5.525A3.5 3.5 0 0 1 9.025 8 3.5 3.5 0 0 1 8 10.475zM6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06"/></svg>'
27
+ const VOL_MUTE_SVG = '<svg viewBox="0 0 16 16"><path d="M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06m7.137 2.096a.5.5 0 0 1 0 .708L12.207 8l1.647 1.646a.5.5 0 0 1-.708.708L11.5 8.707l-1.646 1.647a.5.5 0 0 1-.708-.708L10.793 8 9.146 6.354a.5.5 0 1 1 .708-.708L11.5 7.293l1.646-1.647a.5.5 0 0 1 .708 0"/></svg>'
28
+
29
+ const DEFAULT_VOLUME = 0.2
30
+
31
+ const applyDefaultVolume = (audio: HTMLAudioElement) => {
32
+ if (audio && audio.dataset.volSet !== 'true') {
33
+ audio.volume = DEFAULT_VOLUME
34
+ audio.dataset.volSet = 'true'
35
+ }
36
+ }
37
+
38
+ const updateVolUI = (player: HTMLElement, vol: number) => {
39
+ const btn = player.querySelector<HTMLButtonElement>('.lx-vol-btn')
40
+ const slider = player.querySelector<HTMLInputElement>('.lx-vol-slider')
41
+ if (slider) {
42
+ if (Math.abs(parseFloat(slider.value) - vol) > 0.005) {
43
+ slider.value = String(vol)
44
+ }
45
+ const pct = (vol * 100).toFixed(1)
46
+ slider.style.background = `linear-gradient(to right, var(--color-accent, #2EE6E2) ${pct}%, var(--color-inset-well, #080D20) ${pct}%)`
47
+ }
48
+ if (btn) {
49
+ if (vol <= 0.001) {
50
+ btn.innerHTML = VOL_MUTE_SVG
51
+ btn.setAttribute('aria-label', 'Unmute')
52
+ } else if (vol > 0.5) {
53
+ btn.innerHTML = VOL_HIGH_SVG
54
+ btn.setAttribute('aria-label', 'Mute')
55
+ } else {
56
+ btn.innerHTML = VOL_LOW_SVG
57
+ btn.setAttribute('aria-label', 'Mute')
58
+ }
59
+ }
60
+ }
61
+
62
+ const onClick = (e: MouseEvent) => {
63
+ const target = e.target as HTMLElement | null
64
+ if (!target) return
65
+
66
+ // 1. Play / Pause button
67
+ const btn = target.closest<HTMLButtonElement>('.lx-btn')
68
+ if (btn) {
69
+ const player = btn.closest<HTMLElement>('.lx-audio')
70
+ const audio = player?.querySelector<HTMLAudioElement>('audio')
71
+ if (audio && player) {
72
+ e.preventDefault()
73
+ e.stopPropagation()
74
+
75
+ if (player.classList.contains('is-error')) {
76
+ player.classList.remove('is-error')
77
+ player.classList.add('is-loading')
78
+ audio.load()
79
+ const p = audio.play()
80
+ if (p !== undefined) {
81
+ p.catch((err) => {
82
+ console.warn('[LyneAudio] retry playback failed:', err)
83
+ player.classList.remove('is-loading')
84
+ player.classList.add('is-error')
85
+ })
86
+ }
87
+ } else if (audio.paused) {
88
+ // Pause any other playing audio on the page
89
+ const doc = root.ownerDocument || document
90
+ doc.querySelectorAll<HTMLElement>('.lx-audio.is-playing').forEach((other) => {
91
+ if (other !== player) {
92
+ const otherAudio = other.querySelector<HTMLAudioElement>('audio')
93
+ if (otherAudio && !otherAudio.paused) {
94
+ otherAudio.pause()
95
+ }
96
+ }
97
+ })
98
+
99
+ player.classList.add('is-loading')
100
+ const p = audio.play()
101
+ if (p !== undefined) {
102
+ p.then(() => {
103
+ player.classList.remove('is-loading')
104
+ }).catch((err) => {
105
+ console.warn('[LyneAudio] play failed:', err)
106
+ player.classList.remove('is-loading')
107
+ player.classList.add('is-error')
108
+ })
109
+ }
110
+ } else {
111
+ audio.pause()
112
+ }
113
+ }
114
+ return
115
+ }
116
+
117
+ // 2. Track seeking
118
+ const track = target.closest<HTMLElement>('.lx-track')
119
+ if (track) {
120
+ const player = track.closest<HTMLElement>('.lx-audio')
121
+ const audio = player?.querySelector<HTMLAudioElement>('audio')
122
+ if (audio) {
123
+ e.preventDefault()
124
+ e.stopPropagation()
125
+ const rect = track.getBoundingClientRect()
126
+ const pct = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1)
127
+ if (audio.duration && isFinite(audio.duration)) {
128
+ audio.currentTime = pct * audio.duration
129
+ }
130
+ const fill = track.querySelector<HTMLElement>('.fill')
131
+ if (fill) fill.style.width = `${pct * 100}%`
132
+ }
133
+ return
134
+ }
135
+
136
+ // 3. Playback speed cycling
137
+ const speed = target.closest<HTMLButtonElement>('.lx-speed')
138
+ if (speed) {
139
+ const player = speed.closest<HTMLElement>('.lx-audio')
140
+ const audio = player?.querySelector<HTMLAudioElement>('audio')
141
+ if (audio) {
142
+ e.preventDefault()
143
+ e.stopPropagation()
144
+ const rates = [1.0, 1.25, 1.5, 0.75]
145
+ const curRate = audio.playbackRate || 1.0
146
+ const curIdx = rates.indexOf(curRate)
147
+ const nextRate = rates[(curIdx + 1) % rates.length]
148
+ audio.playbackRate = nextRate
149
+ speed.textContent = `${nextRate}×`
150
+ speed.classList.toggle('active', nextRate !== 1.0)
151
+ }
152
+ return
153
+ }
154
+
155
+ // 4. Volume mute toggle button
156
+ const volBtn = target.closest<HTMLButtonElement>('.lx-vol-btn')
157
+ if (volBtn) {
158
+ const player = volBtn.closest<HTMLElement>('.lx-audio')
159
+ const audio = player?.querySelector<HTMLAudioElement>('audio')
160
+ if (audio && player) {
161
+ e.preventDefault()
162
+ e.stopPropagation()
163
+ audio.dataset.volSet = 'true'
164
+ if (audio.muted || audio.volume <= 0.001) {
165
+ const prev = parseFloat(audio.dataset.prevVol || String(DEFAULT_VOLUME)) || DEFAULT_VOLUME
166
+ audio.muted = false
167
+ audio.volume = prev
168
+ updateVolUI(player, prev)
169
+ } else {
170
+ audio.dataset.prevVol = String(audio.volume)
171
+ audio.muted = true
172
+ updateVolUI(player, 0)
173
+ }
174
+ }
175
+ return
176
+ }
177
+ }
178
+
179
+ const onInput = (e: Event) => {
180
+ const target = e.target as HTMLElement | null
181
+ if (!target) return
182
+ const slider = target.closest<HTMLInputElement>('.lx-vol-slider')
183
+ if (slider) {
184
+ const player = slider.closest<HTMLElement>('.lx-audio')
185
+ const audio = player?.querySelector<HTMLAudioElement>('audio')
186
+ if (audio) {
187
+ const val = parseFloat(slider.value)
188
+ const clamped = isNaN(val) ? DEFAULT_VOLUME : Math.max(0, Math.min(1, val))
189
+ audio.dataset.volSet = 'true'
190
+ audio.muted = (clamped === 0)
191
+ audio.volume = clamped
192
+ if (player) {
193
+ updateVolUI(player, clamped)
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ // Media events (capture phase)
200
+ const onPlay = (e: Event) => {
201
+ const audio = e.target as HTMLAudioElement
202
+ if (audio?.tagName !== 'AUDIO') return
203
+ applyDefaultVolume(audio)
204
+ const player = audio.closest<HTMLElement>('.lx-audio')
205
+ if (!player) return
206
+ player.classList.remove('is-loading')
207
+ player.classList.add('is-playing')
208
+ const btn = player.querySelector<HTMLButtonElement>('.lx-btn')
209
+ if (btn) {
210
+ btn.innerHTML = PAUSE_SVG
211
+ btn.setAttribute('aria-label', 'Pause')
212
+ }
213
+ const label = player.querySelector<HTMLElement>('.lx-title .label .status-text')
214
+ if (label) label.textContent = 'playing'
215
+ updateVolUI(player, audio.muted ? 0 : audio.volume)
216
+ }
217
+
218
+ const onPause = (e: Event) => {
219
+ const audio = e.target as HTMLAudioElement
220
+ if (audio?.tagName !== 'AUDIO') return
221
+ const player = audio.closest<HTMLElement>('.lx-audio')
222
+ if (!player) return
223
+ player.classList.remove('is-playing', 'is-loading')
224
+ const btn = player.querySelector<HTMLButtonElement>('.lx-btn')
225
+ if (btn) {
226
+ btn.innerHTML = PLAY_SVG
227
+ btn.setAttribute('aria-label', 'Play')
228
+ }
229
+ const label = player.querySelector<HTMLElement>('.lx-title .label .status-text')
230
+ if (label) label.textContent = 'audio'
231
+ }
232
+
233
+ const onTimeUpdate = (e: Event) => {
234
+ const audio = e.target as HTMLAudioElement
235
+ if (audio?.tagName !== 'AUDIO') return
236
+ const player = audio.closest<HTMLElement>('.lx-audio')
237
+ if (!player) return
238
+ const cur = audio.currentTime || 0
239
+ const dur = audio.duration || 0
240
+ const fill = player.querySelector<HTMLElement>('.lx-track .fill')
241
+ const curTime = player.querySelector<HTMLElement>('.lx-time .cur')
242
+ if (fill && dur > 0 && isFinite(dur)) {
243
+ fill.style.width = `${(cur / dur) * 100}%`
244
+ }
245
+ if (curTime) {
246
+ curTime.textContent = formatTime(cur)
247
+ }
248
+ }
249
+
250
+ const onLoadedMetadata = (e: Event) => {
251
+ const audio = e.target as HTMLAudioElement
252
+ if (audio?.tagName !== 'AUDIO') return
253
+ applyDefaultVolume(audio)
254
+ const player = audio.closest<HTMLElement>('.lx-audio')
255
+ if (!player) return
256
+ const dur = audio.duration || 0
257
+ const totalTime = player.querySelector<HTMLElement>('.lx-time .total')
258
+ if (totalTime && dur > 0 && isFinite(dur)) {
259
+ totalTime.textContent = formatTime(dur)
260
+ }
261
+ updateVolUI(player, audio.muted ? 0 : audio.volume)
262
+ }
263
+
264
+ const onWaiting = (e: Event) => {
265
+ const audio = e.target as HTMLAudioElement
266
+ if (audio?.tagName !== 'AUDIO') return
267
+ const player = audio.closest<HTMLElement>('.lx-audio')
268
+ if (!player) return
269
+ player.classList.add('is-loading')
270
+ const label = player.querySelector<HTMLElement>('.lx-title .label .status-text')
271
+ if (label) label.textContent = 'buffering…'
272
+ }
273
+
274
+ const onError = (e: Event) => {
275
+ const audio = e.target as HTMLAudioElement
276
+ if (audio?.tagName !== 'AUDIO') return
277
+ const player = audio.closest<HTMLElement>('.lx-audio')
278
+ if (!player) return
279
+ player.classList.remove('is-playing', 'is-loading')
280
+ player.classList.add('is-error')
281
+ const label = player.querySelector<HTMLElement>('.lx-title .label .status-text')
282
+ if (label) label.textContent = 'unavailable — file error'
283
+ const btn = player.querySelector<HTMLButtonElement>('.lx-btn')
284
+ if (btn) {
285
+ btn.innerHTML = RETRY_SVG
286
+ btn.setAttribute('aria-label', 'Retry')
287
+ btn.style.background = 'var(--color-danger, #FF3B5C)'
288
+ }
289
+ const curTime = player.querySelector<HTMLElement>('.lx-time .cur')
290
+ const totalTime = player.querySelector<HTMLElement>('.lx-time .total')
291
+ if (curTime) curTime.textContent = '–:––'
292
+ if (totalTime) totalTime.textContent = '–:––'
293
+ }
294
+
295
+ const onVolumeChange = (e: Event) => {
296
+ const audio = e.target as HTMLAudioElement
297
+ if (audio?.tagName !== 'AUDIO') return
298
+ const player = audio.closest<HTMLElement>('.lx-audio')
299
+ if (!player) return
300
+ updateVolUI(player, audio.muted ? 0 : audio.volume)
301
+ }
302
+
303
+ // Apply 20% default volume to all audio elements within root
304
+ try {
305
+ root.querySelectorAll<HTMLAudioElement>('audio').forEach((audio) => {
306
+ applyDefaultVolume(audio)
307
+ const player = audio.closest<HTMLElement>('.lx-audio')
308
+ if (player) {
309
+ updateVolUI(player, audio.muted ? 0 : audio.volume)
310
+ }
311
+ })
312
+ } catch {}
313
+
314
+ // Use capture phase so parent block selection cannot swallow clicks
315
+ root.addEventListener('click', onClick, true)
316
+ root.addEventListener('input', onInput, true)
317
+ root.addEventListener('change', onInput, true)
318
+ root.addEventListener('play', onPlay, true)
319
+ root.addEventListener('pause', onPause, true)
320
+ root.addEventListener('timeupdate', onTimeUpdate, true)
321
+ root.addEventListener('loadedmetadata', onLoadedMetadata, true)
322
+ root.addEventListener('durationchange', onLoadedMetadata, true)
323
+ root.addEventListener('waiting', onWaiting, true)
324
+ root.addEventListener('error', onError, true)
325
+ root.addEventListener('volumechange', onVolumeChange, true)
326
+
327
+ return () => {
328
+ root.removeEventListener('click', onClick, true)
329
+ root.removeEventListener('input', onInput, true)
330
+ root.removeEventListener('change', onInput, true)
331
+ root.removeEventListener('play', onPlay, true)
332
+ root.removeEventListener('pause', onPause, true)
333
+ root.removeEventListener('timeupdate', onTimeUpdate, true)
334
+ root.removeEventListener('loadedmetadata', onLoadedMetadata, true)
335
+ root.removeEventListener('durationchange', onLoadedMetadata, true)
336
+ root.removeEventListener('waiting', onWaiting, true)
337
+ root.removeEventListener('error', onError, true)
338
+ root.removeEventListener('volumechange', onVolumeChange, true)
339
+ }
340
+ }
341
+
342
+ export const setupLyneAudioRuntime = bindLyneAudio
343
+
344
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
345
+ bindLyneAudio(document.documentElement)
346
+ }