@miliastry/quasar 1.0.6 → 1.1.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 (273) hide show
  1. package/dist/Visuals/lyne.css +117 -6
  2. package/dist/Visuals/osu.css +36 -5
  3. package/dist/index.js +9664 -3282
  4. package/dist/index.mjs +9535 -3283
  5. package/dist/types/Analysis/Contracts/AnalysisReport.d.ts +22 -0
  6. package/dist/types/Analysis/Contracts/Contribution.d.ts +68 -0
  7. package/dist/types/Analysis/Contracts/Pass.d.ts +59 -0
  8. package/dist/types/Analysis/Contracts/PipelineContext.d.ts +34 -0
  9. package/dist/types/Analysis/Passes/Analysis/ColorUsageAnalyzer.d.ts +56 -0
  10. package/dist/types/Analysis/Passes/Analysis/GradientAnalyzer.d.ts +111 -0
  11. package/dist/types/Analysis/Passes/Analysis/MergeableColorAnalyzer.d.ts +26 -0
  12. package/dist/types/Analysis/Passes/Analysis/RainbowAnalyzer.d.ts +47 -0
  13. package/dist/types/Analysis/Passes/Analysis/SymbolAnalyzer.d.ts +89 -0
  14. package/dist/types/Analysis/Passes/Analysis/WaveAnalyzer.d.ts +44 -0
  15. package/dist/types/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.d.ts +4 -0
  16. package/dist/types/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.d.ts +4 -0
  17. package/dist/types/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.d.ts +4 -0
  18. package/dist/types/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.d.ts +4 -0
  19. package/dist/types/Analysis/Passes/Decision/DefaultDecision.d.ts +30 -0
  20. package/dist/types/Analysis/Passes/Decision/PaletteRemapDecision.d.ts +169 -0
  21. package/dist/types/Analysis/Passes/Decision/__tests__/DefaultDecision.test.d.ts +4 -0
  22. package/dist/types/Analysis/Passes/Transform/CollapseGradientTransform.d.ts +37 -0
  23. package/dist/types/Analysis/Passes/Transform/MergeColorsTransform.d.ts +28 -0
  24. package/dist/types/Analysis/Passes/Transform/RainbowCollapseTransform.d.ts +19 -0
  25. package/dist/types/Analysis/Passes/Transform/WaveCollapseTransform.d.ts +20 -0
  26. package/dist/types/Analysis/Passes/Utility/CharacterCountAnalyzer.d.ts +19 -0
  27. package/dist/types/Analysis/Pipeline/Pipeline.d.ts +69 -0
  28. package/dist/types/Analysis/Pipeline/PipelineBuilder.d.ts +31 -0
  29. package/dist/types/Analysis/Pipeline/PipelineStage.d.ts +17 -0
  30. package/dist/types/Analysis/Utils/color-utils.d.ts +60 -0
  31. package/dist/types/Analysis/__tests__/Integration.test.d.ts +8 -0
  32. package/dist/types/Analysis/__tests__/PaletteRemap.test.d.ts +14 -0
  33. package/dist/types/Analysis/__tests__/Pipeline.test.d.ts +10 -0
  34. package/dist/types/Analysis/__tests__/RoundTrip.test.d.ts +30 -0
  35. package/dist/types/Analysis/__tests__/SymbolAnalyzer.test.d.ts +11 -0
  36. package/dist/types/Analysis/index.d.ts +40 -0
  37. package/dist/types/BBCode/BBCodeDocumentModel.d.ts +92 -0
  38. package/dist/types/BBCode/BBCodeToGreenNode.d.ts +111 -0
  39. package/dist/types/BBCode/Parser.d.ts +78 -0
  40. package/dist/types/BBCode/index.d.ts +4 -0
  41. package/dist/types/Collab/positions.d.ts +55 -0
  42. package/dist/types/Commands/Command.d.ts +37 -0
  43. package/dist/types/Commands/CommandRegistry.d.ts +48 -0
  44. package/dist/types/Commands/DeleteNode.d.ts +7 -0
  45. package/dist/types/Commands/InsertText.d.ts +8 -0
  46. package/dist/types/Commands/SplitMerge.d.ts +9 -0
  47. package/dist/types/Commands/WrapInTag.d.ts +8 -0
  48. package/dist/types/Commands/index.d.ts +6 -0
  49. package/dist/types/Diff/TreeDiffer.d.ts +52 -0
  50. package/dist/types/Diff/__tests__/TreeDiffer.test.d.ts +1 -0
  51. package/dist/types/Diff/index.d.ts +2 -0
  52. package/dist/types/Edits/EditPlan.d.ts +144 -0
  53. package/dist/types/Edits/Optimizer.d.ts +78 -0
  54. package/dist/types/Edits/Rules/Rule.d.ts +91 -0
  55. package/dist/types/Edits/Rules/dropEmptyTags.d.ts +40 -0
  56. package/dist/types/Edits/Rules/dropRedundantNesting.d.ts +59 -0
  57. package/dist/types/Edits/Rules/index.d.ts +9 -0
  58. package/dist/types/Edits/Rules/mergeAdjacent.d.ts +93 -0
  59. package/dist/types/Edits/Rules/reorderWrappers.d.ts +42 -0
  60. package/dist/types/Edits/Rules/shortenHex.d.ts +30 -0
  61. package/dist/types/Edits/Rules/tagValue.d.ts +56 -0
  62. package/dist/types/Edits/Rules/unwrapInvisibleColor.d.ts +47 -0
  63. package/dist/types/Edits/applyEdits.d.ts +36 -0
  64. package/dist/types/Edits/index.d.ts +6 -0
  65. package/dist/types/Events/EventBus.d.ts +103 -0
  66. package/dist/types/Events/index.d.ts +2 -0
  67. package/dist/types/Formatter/Formatter.d.ts +32 -0
  68. package/dist/types/Formatter/index.d.ts +2 -0
  69. package/dist/types/HTML/HTMLDocumentModel.d.ts +13 -0
  70. package/dist/types/HTML/HTMLToGreenNode.d.ts +4 -0
  71. package/dist/types/Incremental/BracketIndex.d.ts +101 -0
  72. package/dist/types/Incremental/ChangeTracker.d.ts +69 -0
  73. package/dist/types/Incremental/IncrementalParser.d.ts +246 -0
  74. package/dist/types/Incremental/__tests__/BracketIndex.test.d.ts +1 -0
  75. package/dist/types/Incremental/__tests__/IncrementalParser.test.d.ts +1 -0
  76. package/dist/types/Incremental/index.d.ts +4 -0
  77. package/dist/types/Lexer/BBCodeLexer.d.ts +74 -0
  78. package/dist/types/Lexer/Lexer.d.ts +54 -0
  79. package/dist/types/Lexer/index.d.ts +4 -0
  80. package/dist/types/Linter/Linter.d.ts +58 -0
  81. package/dist/types/Linter/index.d.ts +2 -0
  82. package/dist/types/Markdown/MarkdownAST.d.ts +122 -0
  83. package/dist/types/Markdown/MarkdownDocumentModel.d.ts +15 -0
  84. package/dist/types/Markdown/MarkdownLexer.d.ts +155 -0
  85. package/dist/types/Markdown/MarkdownParser.d.ts +42 -0
  86. package/dist/types/Markdown/MarkdownToGreenNode.d.ts +6 -0
  87. package/dist/types/MilHibri/MilHibriDocumentModel.d.ts +21 -0
  88. package/dist/types/Model/DocumentModel.d.ts +318 -0
  89. package/dist/types/Model/NodeFactory.d.ts +29 -0
  90. package/dist/types/Model/TagRegistry.d.ts +165 -0
  91. package/dist/types/Model/index.d.ts +5 -0
  92. package/dist/types/Plugins/PluginAPI.d.ts +44 -0
  93. package/dist/types/Plugins/PluginRegistry.d.ts +81 -0
  94. package/dist/types/Plugins/index.d.ts +3 -0
  95. package/dist/types/Queries/QueryEngine.d.ts +32 -0
  96. package/dist/types/Queries/index.d.ts +1 -0
  97. package/dist/types/Reconciler/SurgicalReconciler.d.ts +23 -0
  98. package/dist/types/RenderPipeline/RenderPipeline.d.ts +46 -0
  99. package/dist/types/RenderPipeline/RenderTree.d.ts +55 -0
  100. package/dist/types/RenderPipeline/index.d.ts +4 -0
  101. package/dist/types/Repair/NestingRepair.d.ts +48 -0
  102. package/dist/types/Repair/index.d.ts +1 -0
  103. package/dist/types/Semantic/SemanticAnalyzer.d.ts +321 -0
  104. package/dist/types/Semantic/index.d.ts +2 -0
  105. package/dist/types/Symbols/SymbolTable.d.ts +47 -0
  106. package/dist/types/Symbols/index.d.ts +1 -0
  107. package/dist/types/Syntax/GreenNode.d.ts +154 -0
  108. package/dist/types/Syntax/GreenNodePool.d.ts +134 -0
  109. package/dist/types/Syntax/NodeMatcher.d.ts +75 -0
  110. package/dist/types/Syntax/RedNode.d.ts +280 -0
  111. package/dist/types/Syntax/RedNodeStore.d.ts +116 -0
  112. package/dist/types/Syntax/TreeBuilder.d.ts +54 -0
  113. package/dist/types/Syntax/__tests__/GreenNode.test.d.ts +1 -0
  114. package/dist/types/Syntax/__tests__/RedNode.test.d.ts +1 -0
  115. package/dist/types/Syntax/__tests__/RedNodeStore.test.d.ts +1 -0
  116. package/dist/types/Syntax/greenEdit.d.ts +64 -0
  117. package/dist/types/Syntax/hash.d.ts +17 -0
  118. package/dist/types/Syntax/index.d.ts +8 -0
  119. package/dist/types/Syntax/nodeAttr.d.ts +63 -0
  120. package/dist/types/Syntax/partition.d.ts +67 -0
  121. package/dist/types/Syntax/preserveNodeIds.d.ts +47 -0
  122. package/dist/types/Tokens/TokenResolver.d.ts +25 -0
  123. package/dist/types/Tokens/index.d.ts +1 -0
  124. package/dist/types/Transactions/Transaction.d.ts +36 -0
  125. package/dist/types/Transactions/UndoManager.d.ts +63 -0
  126. package/dist/types/Transactions/index.d.ts +3 -0
  127. package/dist/types/Transformers/ASTOptimizer.d.ts +34 -0
  128. package/dist/types/Transformers/GradientTransformer.d.ts +18 -0
  129. package/dist/types/Transformers/GrowTransformer.d.ts +16 -0
  130. package/dist/types/Transformers/RainbowTransformer.d.ts +17 -0
  131. package/dist/types/Transformers/SineWaveTransformer.d.ts +17 -0
  132. package/dist/types/Transformers/Transformer.d.ts +17 -0
  133. package/dist/types/Types/core.d.ts +104 -0
  134. package/dist/types/Types/diagnostics.d.ts +85 -0
  135. package/dist/types/Types/index.d.ts +11 -0
  136. package/dist/types/Types/operations.d.ts +109 -0
  137. package/dist/types/Types/queries.d.ts +67 -0
  138. package/dist/types/Types/symbols.d.ts +42 -0
  139. package/dist/types/Types/tokens.d.ts +75 -0
  140. package/dist/types/Utils/BBCodeGenerator.d.ts +21 -0
  141. package/dist/types/Utils/ColorMath.d.ts +92 -0
  142. package/dist/types/Utils/EffectMath.d.ts +643 -0
  143. package/dist/types/Utils/TagVocabulary.d.ts +104 -0
  144. package/dist/types/Utils/TagVocabulary.test.d.ts +1 -0
  145. package/dist/types/Utils/color.d.ts +29 -0
  146. package/dist/types/Utils/dom-to-svg.d.ts +28 -0
  147. package/dist/types/Utils/dom-to-svg.test.d.ts +1 -0
  148. package/dist/types/Utils/treeTransformers.d.ts +160 -0
  149. package/dist/types/Visitors/BBBlocksExporter.d.ts +27 -0
  150. package/dist/types/Visitors/BBCodeExporter.d.ts +90 -0
  151. package/dist/types/Visitors/BlockPatcher.d.ts +91 -0
  152. package/dist/types/Visitors/DOMMorpher.d.ts +13 -0
  153. package/dist/types/Visitors/HTMLRenderer.d.ts +380 -0
  154. package/dist/types/Visitors/JSONExporter.d.ts +42 -0
  155. package/dist/types/Visitors/MarkdownExporter.d.ts +28 -0
  156. package/dist/types/Visitors/SVGRenderer.d.ts +16 -0
  157. package/dist/types/Visitors/TiptapExporter.d.ts +40 -0
  158. package/dist/types/Visitors/Visitor.d.ts +35 -0
  159. package/dist/types/Visitors/index.d.ts +9 -0
  160. package/dist/types/Visuals/BoxDrawer.d.ts +49 -0
  161. package/dist/types/Visuals/index.d.ts +38 -0
  162. package/dist/types/index.d.ts +154 -0
  163. package/package.json +14 -10
  164. package/src/Analysis/Passes/Analysis/ColorUsageAnalyzer.ts +103 -0
  165. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +294 -20
  166. package/src/Analysis/Passes/Analysis/SymbolAnalyzer.ts +257 -0
  167. package/src/Analysis/Passes/Decision/PaletteRemapDecision.ts +425 -0
  168. package/src/Analysis/Pipeline/Pipeline.ts +15 -2
  169. package/src/Analysis/index.ts +8 -2
  170. package/src/BBCode/BBCodeDocumentModel.ts +3 -0
  171. package/src/BBCode/BBCodeToGreenNode.ts +18 -12
  172. package/src/BBCode/Parser.ts +38 -1
  173. package/src/Edits/EditPlan.ts +290 -0
  174. package/src/Edits/Optimizer.ts +185 -0
  175. package/src/Edits/Rules/Rule.ts +145 -0
  176. package/src/Edits/Rules/dropEmptyTags.ts +110 -0
  177. package/src/Edits/Rules/dropRedundantNesting.ts +120 -0
  178. package/src/Edits/Rules/index.ts +28 -0
  179. package/src/Edits/Rules/mergeAdjacent.ts +224 -0
  180. package/src/Edits/Rules/reorderWrappers.ts +154 -0
  181. package/src/Edits/Rules/shortenHex.ts +79 -0
  182. package/src/Edits/Rules/tagValue.ts +105 -0
  183. package/src/Edits/Rules/unwrapInvisibleColor.ts +93 -0
  184. package/src/Edits/applyEdits.ts +54 -0
  185. package/src/Edits/index.ts +22 -0
  186. package/src/Events/EventBus.ts +18 -0
  187. package/src/HTML/HTMLToGreenNode.ts +185 -37
  188. package/src/Incremental/BracketIndex.ts +249 -0
  189. package/src/Incremental/IncrementalParser.ts +464 -41
  190. package/src/Lexer/BBCodeLexer.ts +25 -3
  191. package/src/Lexer/index.ts +1 -1
  192. package/src/Markdown/MarkdownAST.ts +65 -2
  193. package/src/Markdown/MarkdownLexer.ts +69 -14
  194. package/src/Markdown/MarkdownParser.ts +749 -62
  195. package/src/Markdown/MarkdownToGreenNode.ts +148 -27
  196. package/src/MilHibri/MilHibriDocumentModel.ts +56 -0
  197. package/src/Model/DocumentModel.ts +138 -5
  198. package/src/Model/TagRegistry.ts +129 -134
  199. package/src/Reconciler/SurgicalReconciler.ts +460 -0
  200. package/src/Repair/NestingRepair.ts +116 -0
  201. package/src/Repair/index.ts +1 -0
  202. package/src/Semantic/SemanticAnalyzer.ts +1802 -65
  203. package/src/Syntax/GreenNodePool.ts +12 -12
  204. package/src/Syntax/RedNode.ts +17 -3
  205. package/src/Syntax/RedNodeStore.ts +9 -1
  206. package/src/Syntax/nodeAttr.ts +172 -0
  207. package/src/Tokens/TokenResolver.ts +55 -0
  208. package/src/Tokens/index.ts +1 -0
  209. package/src/Types/core.ts +15 -0
  210. package/src/Utils/ColorMath.ts +276 -55
  211. package/src/Utils/EffectMath.ts +2462 -0
  212. package/src/Utils/TagVocabulary.ts +141 -0
  213. package/src/Utils/dom-to-svg.ts +28 -12
  214. package/src/Visitors/BBBlocksExporter.ts +15 -1
  215. package/src/Visitors/BBCodeExporter.ts +319 -32
  216. package/src/Visitors/BlockPatcher.ts +87 -11
  217. package/src/Visitors/DOMMorpher.ts +15 -5
  218. package/src/Visitors/HTMLRenderer.ts +374 -151
  219. package/src/Visitors/MarkdownExporter.ts +98 -41
  220. package/src/Visitors/TiptapExporter.ts +1 -0
  221. package/src/Visuals/index.ts +7 -4
  222. package/src/Visuals/lyne.css +117 -6
  223. package/src/Visuals/osu.css +36 -5
  224. package/src/index.ts +172 -5
  225. package/dist/index.d.mts +0 -4612
  226. package/dist/index.d.ts +0 -4612
  227. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +0 -135
  228. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +0 -84
  229. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +0 -99
  230. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +0 -119
  231. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +0 -179
  232. package/src/Analysis/__tests__/Integration.test.ts +0 -162
  233. package/src/Analysis/__tests__/Pipeline.test.ts +0 -133
  234. package/src/Diff/__tests__/TreeDiffer.test.ts +0 -65
  235. package/src/Incremental/__tests__/IncrementalParser.test.ts +0 -164
  236. package/src/Syntax/__tests__/GreenNode.test.ts +0 -33
  237. package/src/Syntax/__tests__/RedNode.test.ts +0 -81
  238. package/src/Syntax/__tests__/RedNodeStore.test.ts +0 -104
  239. package/src/Tests/ASTOptimizerIdempotence.test.ts +0 -77
  240. package/src/Tests/BlockPatcher.test.ts +0 -437
  241. package/src/Tests/BlockPatcherWindowed.test.ts +0 -364
  242. package/src/Tests/BoxDrawer.test.ts +0 -217
  243. package/src/Tests/BoxRichTitle.test.ts +0 -105
  244. package/src/Tests/Chars500kBenchmark.test.ts +0 -151
  245. package/src/Tests/Chars500kEdits.test.ts +0 -321
  246. package/src/Tests/CollabPositions.test.ts +0 -146
  247. package/src/Tests/CompilerPathProfiling.test.ts +0 -186
  248. package/src/Tests/DOMMorpher.test.ts +0 -142
  249. package/src/Tests/DomPatchPerf.test.ts +0 -60
  250. package/src/Tests/EffectSegments.snapshot.json +0 -616
  251. package/src/Tests/EffectSegments.test.ts +0 -68
  252. package/src/Tests/FindNodeAtOffset.test.ts +0 -65
  253. package/src/Tests/Fuzzer.test.ts +0 -166
  254. package/src/Tests/GreenNodePool.test.ts +0 -153
  255. package/src/Tests/Lexer.test.ts +0 -238
  256. package/src/Tests/LyneMode.test.ts +0 -291
  257. package/src/Tests/ModelCoherence.test.ts +0 -180
  258. package/src/Tests/Partition.test.ts +0 -238
  259. package/src/Tests/PluginTags.test.ts +0 -150
  260. package/src/Tests/ProblematicSection.test.ts +0 -46
  261. package/src/Tests/ProblematicSectionHTML.test.ts +0 -58
  262. package/src/Tests/RedReuse.test.ts +0 -134
  263. package/src/Tests/ReproDelete20k.test.ts +0 -62
  264. package/src/Tests/SemanticValidators.test.ts +0 -136
  265. package/src/Tests/StableNodeIds.test.ts +0 -210
  266. package/src/Tests/StudioColorBloat.test.ts +0 -25
  267. package/src/Tests/StudioDebugText.test.ts +0 -27
  268. package/src/Tests/StudioTrailingChar.test.ts +0 -25
  269. package/src/Tests/StudioValidText.test.ts +0 -25
  270. package/src/Tests/UrlImgBug.test.ts +0 -23
  271. package/src/Tests/VisualBuilderFidelity.test.ts +0 -105
  272. package/src/Tests/referenceDocument.ts +0 -119
  273. package/src/Utils/dom-to-svg.test.ts +0 -86
@@ -3,6 +3,38 @@ import { RedNode } from '../Syntax/RedNode';
3
3
  import type { NodeKind } from '../Types/core';
4
4
  import { isBlockKind } from '../BBCode/BBCodeToGreenNode';
5
5
 
6
+ /**
7
+ * The colour exactly as the author wrote it in the `style` attribute.
8
+ *
9
+ * `el.style.color` goes through CSSOM, which re-spells the value: `#FFE6F0`
10
+ * comes back lowercased. Re-serialising a block then rewrote every hex in it,
11
+ * a change the author never made.
12
+ */
13
+ function rawStyleValue(el: HTMLElement, prop: string): string {
14
+ const attr = el.getAttribute('style')
15
+ if (!attr) return ''
16
+ const match = new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`, 'i').exec(attr)
17
+ return match ? match[1].trim() : ''
18
+ }
19
+
20
+ function normalizeColorToHex(color: string): string {
21
+ if (!color) return color;
22
+ const trimmed = color.trim();
23
+ // Se respeta la caja que escribió el autor: pasarlo a minúsculas convertía
24
+ // `[color=#FF00AA]` en `[color=#ff00aa]` en cada ida y vuelta por el lienzo.
25
+ if (trimmed.startsWith('#')) return trimmed;
26
+
27
+ const rgbMatch = trimmed.match(/^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)$/i);
28
+ if (rgbMatch) {
29
+ const r = parseInt(rgbMatch[1], 10).toString(16).padStart(2, '0');
30
+ const g = parseInt(rgbMatch[2], 10).toString(16).padStart(2, '0');
31
+ const b = parseInt(rgbMatch[3], 10).toString(16).padStart(2, '0');
32
+ return `#${r}${g}${b}`.toLowerCase();
33
+ }
34
+
35
+ return trimmed;
36
+ }
37
+
6
38
  function domToGreenTree(root: HTMLElement): GreenNode {
7
39
  let currentOffset = 0;
8
40
 
@@ -44,25 +76,74 @@ function domToGreenTree(root: HTMLElement): GreenNode {
44
76
 
45
77
  switch (tag) {
46
78
  case 'b':
47
- case 'strong': kind = 'bold'; break;
79
+ case 'strong': {
80
+ // El renderer envuelve las entidades en `<strong><a>`; sin mirar la
81
+ // marca volvían desazucaradas como `[b][url=…]…[/url][/b]`.
82
+ const entity = el.getAttribute('data-entity');
83
+ if (entity === 'profile' || entity === 'guild' || entity === 'map') {
84
+ kind = entity;
85
+ const label = el.textContent || '';
86
+ if (label) {
87
+ children.push(greenLeaf('text', label));
88
+ currentOffset += label.length;
89
+ }
90
+ // `[profile=5458323]ElMick33[/profile]` lleva el id en el atributo y
91
+ // el nombre en el contenido; sin el primero volvía como `[profile]`.
92
+ const valor = el.getAttribute('data-entity-value') || '';
93
+ return greenNode(kind, valor ? `=${valor}` : '', children);
94
+ }
95
+ kind = 'bold';
96
+ break;
97
+ }
48
98
  case 'i':
49
99
  case 'em': kind = 'italic'; break;
50
100
  case 'u': kind = 'underline'; break;
51
101
  case 's':
52
102
  case 'strike':
53
103
  case 'del': kind = 'strikethrough'; break;
54
- case 'a':
55
- kind = 'url';
56
- text = `=${el.getAttribute('href') || ''}`;
104
+ case 'a': {
105
+ const rawHref = el.getAttribute('href') || '';
106
+ if (rawHref.startsWith('mailto:')) {
107
+ // `[email]` guarda la dirección en el hijo de texto y sólo el prefijo
108
+ // en el atributo; el exporter reconstruye el resto.
109
+ kind = 'email';
110
+ text = '=mailto:';
111
+ } else {
112
+ kind = 'url';
113
+ text = `=${rawHref}`;
114
+ }
57
115
  break;
58
- case 'img':
116
+ }
117
+ case 'img': {
118
+ // El parser de BBCode deja la URL en un hijo de texto y el modificador
119
+ // en el atributo. Copiando esa forma el exporter la vuelve a emitir;
120
+ // cuando la URL vivía sólo en `text` salía `[img][/img]`.
59
121
  kind = 'image';
60
- text = el.getAttribute('src') || '';
122
+ const imgAttr = el.getAttribute('data-img-attr') || '';
123
+ text = imgAttr ? `=${imgAttr}` : '';
124
+ const imgSrc = el.getAttribute('src') || '';
125
+ if (imgSrc) {
126
+ children.push(greenLeaf('text', imgSrc));
127
+ currentOffset += imgSrc.length;
128
+ }
61
129
  break;
130
+ }
62
131
  case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6':
63
132
  kind = 'heading';
64
- const level = parseInt(tag.charAt(1));
65
- text = `=${level}`;
133
+ // Un `[heading]` pelado se pinta como h2 por convención del renderer;
134
+ // devolverlo como `[heading=2]` reescribía el source del autor.
135
+ text = el.hasAttribute('data-bare-level') ? '' : `=${parseInt(tag.charAt(1))}`;
136
+ break;
137
+ case 'font':
138
+ if (el.getAttribute('color')) {
139
+ kind = 'color';
140
+ text = `=${normalizeColorToHex(el.getAttribute('color') || '')}`;
141
+ } else if (el.getAttribute('size')) {
142
+ kind = 'font_size';
143
+ text = `=${el.getAttribute('size') || ''}`;
144
+ } else {
145
+ kind = 'group';
146
+ }
66
147
  break;
67
148
  case 'br':
68
149
  kind = 'spacing';
@@ -70,9 +151,21 @@ function domToGreenTree(root: HTMLElement): GreenNode {
70
151
  currentOffset += 1;
71
152
  break;
72
153
  case 'div':
154
+ case 'p': {
155
+ // Un aviso de medio vacío vuelve a ser su etiqueta, no la frase que
156
+ // el renderer puso en su sitio. El atributo lleva el nombre BBCode;
157
+ // aquí hace falta el `NodeKind`, que no siempre coincide.
158
+ const vacio = el.getAttribute('data-bb-empty');
159
+ if (vacio) {
160
+ const KIND_DE_TAG: Record<string, string> = { img: 'image', youtube: 'video', imagemap: 'imagemap' };
161
+ return greenNode(KIND_DE_TAG[vacio] ?? vacio, '', []);
162
+ }
73
163
  if (el.classList.contains('notice')) kind = 'notice';
164
+ else if (el.classList.contains('bb-empty-line') || el.classList.contains('bbcode-para')) {
165
+ const hasText = (el.textContent || '').replace(/\u200B/g, '').trim().length > 0;
166
+ kind = hasText ? 'paragraph' : 'empty_line';
167
+ }
74
168
  else if (el.classList.contains('bbcode-sp')) kind = 'spacing';
75
- else if (el.classList.contains('bbcode-para')) kind = 'empty_line';
76
169
  else if (el.classList.contains('bbcode-imagemap') || el.classList.contains('imagemap-container')) {
77
170
  kind = 'imagemap';
78
171
  const img = el.querySelector('img');
@@ -101,41 +194,56 @@ function domToGreenTree(root: HTMLElement): GreenNode {
101
194
  else if (el.style.textAlign === 'left') kind = 'left';
102
195
  else kind = 'group';
103
196
  break;
197
+ }
104
198
  case 'span':
105
- if (el.classList.contains('spoiler')) kind = 'spoiler';
199
+ if (el.classList.contains('bb-empty-line')) {
200
+ const hasText = (el.textContent || '').replace(/\u200B/g, '').trim().length > 0;
201
+ kind = hasText ? 'paragraph' : 'empty_line';
202
+ }
203
+ else if (el.classList.contains('spoiler')) kind = 'spoiler';
106
204
  else if (el.classList.contains('aesthetic')) kind = 'aesthetic';
107
- else if (el.style.color) { kind = 'color'; text = `=${el.style.color}`; }
108
- else if (el.style.fontSize) { kind = 'font_size'; text = `=${el.style.fontSize.replace('%', '')}`; }
109
- // If it's just a text wrapper generated by HTMLRenderer, we should just unwrap it
110
- else if (el.classList.contains('bb-text')) {
111
- // We can use a transparent group or a specialized inline node,
112
- // but flattening it is best. We can use a temporary kind that gets flattened,
113
- // or just use 'group' but wait, 'group' is block-like.
114
- // Actually, if we just set it to 'text_wrapper', we can filter it out, but for now
115
- // let's just make it a 'group' but ideally we shouldn't create a node at all.
116
- // Wait, the simplest fix is to not create a node for bb-text and just append its children!
117
- kind = 'bb_text_wrapper';
205
+ else if (el.style.color) { kind = 'color'; text = `=${normalizeColorToHex(rawStyleValue(el, 'color') || el.style.color)}`; }
206
+ else if (el.style.fontSize) { kind = 'font_size'; text = `=${(rawStyleValue(el, 'font-size') || el.style.fontSize).replace('%', '')}`; }
207
+ else if (el.classList.contains('bb-text') || el.classList.contains('bb-paragraph')) {
208
+ kind = 'bb_text_wrapper';
118
209
  }
119
210
  else kind = 'group';
120
211
  break;
121
212
  case 'iframe':
122
213
  if (el.classList.contains('bb-youtube') || el.hasAttribute('data-youtube')) {
123
- kind = 'youtube';
124
- text = `=${el.getAttribute('data-youtube') || (el as HTMLIFrameElement).src.split('/embed/')[1]?.split('?')[0] || ''}`;
214
+ // El parser produce `video`, no `youtube`, y con el id en un hijo de
215
+ // texto. Emitir otra forma hacía que el exporter no encontrara el tag.
216
+ kind = 'video';
217
+ const videoId = el.getAttribute('data-youtube')
218
+ || (el as HTMLIFrameElement).src.split('/embed/')[1]?.split('?')[0]
219
+ || '';
220
+ if (videoId) {
221
+ children.push(greenLeaf('text', videoId));
222
+ currentOffset += videoId.length;
223
+ }
125
224
  } else {
126
225
  kind = 'group';
127
226
  }
128
227
  break;
129
- case 'audio':
228
+ case 'audio': {
130
229
  kind = 'audio';
131
- text = `=${el.getAttribute('src') || ''}`;
230
+ const audioSrc = el.getAttribute('src') || '';
231
+ if (audioSrc) {
232
+ children.push(greenLeaf('text', audioSrc));
233
+ currentOffset += audioSrc.length;
234
+ }
132
235
  break;
133
- case 'details':
236
+ }
237
+ case 'details': {
134
238
  kind = el.classList.contains('box') ? 'box' : 'spoilerbox';
135
- const summary = el.querySelector('summary');
136
- if (summary) text = `=${summary.textContent || ''}`;
239
+ // `data-bare-title` dice que el texto del summary lo puso el renderer.
240
+ // Leerlo como título del autor devolvía `[box=Box]` desde un `[box]`.
241
+ if (!el.hasAttribute('data-bare-title')) {
242
+ const summary = el.querySelector('summary');
243
+ if (summary) text = `=${summary.textContent || ''}`;
244
+ }
137
245
  break;
138
- case 'p':
246
+ }
139
247
  case 'section':
140
248
  kind = 'group';
141
249
  break;
@@ -154,6 +262,8 @@ function domToGreenTree(root: HTMLElement): GreenNode {
154
262
  const strong = firstElem.querySelector('strong');
155
263
  if (strong && strong.textContent && strong.textContent.endsWith(' wrote:')) {
156
264
  const author = strong.textContent.slice(0, -7);
265
+ // Clave `source`: es la que lee `BBCodeExporter.getTagAttributes`,
266
+ // que además vuelve a poner las comillas de `[quote="Nombre Largo"]`.
157
267
  text = `=${author}`;
158
268
  (firstElem as any).__quasar_extracted = true;
159
269
  }
@@ -162,7 +272,16 @@ function domToGreenTree(root: HTMLElement): GreenNode {
162
272
  case 'pre':
163
273
  kind = 'code';
164
274
  // Extract raw text as a child text node (DocumentEngine expects code content in children)
165
- const codeText = el.textContent || '';
275
+ let codeText = el.textContent || '';
276
+ const padAttr = el.getAttribute('data-code-pad');
277
+ if (padAttr) {
278
+ try {
279
+ const [lead, trail] = (JSON.parse(padAttr) as string).split('\u0000');
280
+ codeText = `${lead}${codeText}${trail}`;
281
+ } catch {
282
+ // Atributo corrupto: mejor el contenido pelado que perder el bloque.
283
+ }
284
+ }
166
285
  children.push(greenLeaf('text', codeText));
167
286
  currentOffset += codeText.length;
168
287
  break;
@@ -213,21 +332,43 @@ function domToGreenTree(root: HTMLElement): GreenNode {
213
332
  const normalizedRoot: GreenNode[] = [];
214
333
  let currentParagraph: GreenNode[] = [];
215
334
 
335
+ /**
336
+ * Separa dos nodos de raíz con un solo salto.
337
+ *
338
+ * Nunca inventa una línea en blanco: una línea vacía del documento sólo
339
+ * existe si el autor la escribió, y el lienzo no puede añadir ninguna por su
340
+ * cuenta al traducir de vuelta.
341
+ */
342
+ const pushSeparator = () => {
343
+ if (normalizedRoot.length === 0) return;
344
+ const prev = normalizedRoot[normalizedRoot.length - 1];
345
+ if (prev.kind === 'empty_line' || prev.kind === 'spacing') return;
346
+ normalizedRoot.push(greenLeaf('spacing', '\n'));
347
+ };
348
+
216
349
  const flushParagraph = () => {
217
350
  if (currentParagraph.length > 0) {
351
+ pushSeparator();
218
352
  normalizedRoot.push(greenNode('paragraph', '', currentParagraph));
219
353
  currentParagraph = [];
220
354
  }
221
355
  };
222
356
 
223
357
  for (const child of rootChildren) {
224
- // If it's a completely empty whitespace text node (0 newlines), ignore it
225
358
  if (child.kind === 'text' && child.text.trim() === '') {
359
+ // Un espacio suelto entre dos tags inline (`[s]a[/s] [spoiler]b[/spoiler]`)
360
+ // es contenido del autor y se conserva; el resto es sangría del HTML.
361
+ const isInlineGap = !child.text.includes('\n') && currentParagraph.length > 0;
362
+ if (!isInlineGap) continue;
363
+ currentParagraph.push(child);
226
364
  continue;
227
365
  }
228
366
 
229
- if (isBlockKind(child.kind as NodeKind) || child.kind === 'empty_line') {
367
+ if (isBlockKind(child.kind as NodeKind) || child.kind === 'empty_line' || child.kind === 'paragraph') {
230
368
  flushParagraph();
369
+ if (child.kind !== 'empty_line' && child.kind !== 'spacing') {
370
+ pushSeparator();
371
+ }
231
372
  normalizedRoot.push(child);
232
373
  } else {
233
374
  currentParagraph.push(child);
@@ -253,20 +394,27 @@ export function greenToRedNode(green: GreenNode, parent?: RedNode | null): RedNo
253
394
  let metadata: Record<string, unknown> = {};
254
395
  if (green.kind === 'heading' && green.text.startsWith('=')) {
255
396
  metadata = { level: parseInt(green.text.slice(1)) || 1 };
256
- } else if (green.kind === 'url' && green.text.startsWith('=')) {
397
+ } else if ((green.kind === 'url' || green.kind === 'email') && green.text.startsWith('=')) {
257
398
  metadata = { href: green.text.slice(1) };
258
399
  } else if (green.kind === 'color' && green.text.startsWith('=')) {
259
400
  metadata = { color: green.text.slice(1) };
260
401
  } else if (green.kind === 'font_size' && green.text.startsWith('=')) {
261
402
  metadata = { size: green.text.slice(1) };
262
403
  } else if (green.kind === 'quote' && green.text.startsWith('=')) {
263
- metadata = { author: green.text.slice(1) };
404
+ metadata = { source: green.text.slice(1) };
264
405
  } else if ((green.kind === 'box' || green.kind === 'spoilerbox') && green.text.startsWith('=')) {
265
406
  metadata = { title: green.text.slice(1) };
266
407
  } else if (green.kind === 'image') {
267
- metadata = { src: green.text };
268
- } else if ((green.kind === 'youtube' || green.kind === 'audio') && green.text.startsWith('=')) {
269
- metadata = green.kind === 'youtube' ? { videoId: green.text.slice(1) } : { src: green.text.slice(1) };
408
+ const src = green.children.map(c => (c as GreenNode).text || '').join('');
409
+ metadata = green.text.startsWith('=')
410
+ ? { src, imgAttr: green.text.slice(1) }
411
+ : { src };
412
+ } else if (green.kind === 'video' || green.kind === 'audio') {
413
+ const value = green.children.map(c => (c as GreenNode).text || '').join('');
414
+ metadata = green.kind === 'video' ? { videoId: value } : { src: value };
415
+ } else if (green.kind === 'profile' || green.kind === 'guild' || green.kind === 'map') {
416
+ const key = green.kind === 'profile' ? 'username' : green.kind === 'guild' ? 'tag' : 'id';
417
+ metadata = { [key]: green.text.startsWith('=') ? green.text.slice(1) : '' };
270
418
  } else if (green.kind === 'list') {
271
419
  metadata = { ordered: green.text === '=1' };
272
420
  }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * DocumentEngine — BracketDepthIndex
3
+ *
4
+ * Answers the incremental parser's one question about the text OUTSIDE its
5
+ * window — "does every `[` before this offset find its `]` before it too?" —
6
+ * without reading the text before the offset.
7
+ *
8
+ * ─── Why the scan had to go ─────────────────────────────────────────────────
9
+ *
10
+ * `bracketsCloseBefore` walked the whole prefix on every keystroke: with the
11
+ * caret at the end of the 547 KB fixture that is 547.000 `charCodeAt` calls
12
+ * per key, measured at 2.2 ms — more than the parse, the splice and the red
13
+ * build of that same keystroke put together. Caching was declined once on the
14
+ * grounds that the reasoning is subtle: an edit can expose a `]` that a
15
+ * deleted `[` used to claim, so any cache keyed on "the prefix has not
16
+ * changed" is wrong the moment it is asked about a prefix that has.
17
+ *
18
+ * ─── Why this is not that cache ─────────────────────────────────────────────
19
+ *
20
+ * The scan computes a CLAMPED running depth — `d ← max(0, d + a)` where `a`
21
+ * is +1 for `[`, −1 for `]`, 0 otherwise — and a clamped running sum has an
22
+ * exact, composable summary. Over any stretch of text with prefix sums
23
+ * P₁…Pₙ, the depth after the stretch is a function of the depth before it:
24
+ *
25
+ * d' = Pₙ + max(d, −min(0, P₁, …, Pₙ))
26
+ *
27
+ * so a stretch is fully described by two integers, its net sum and its lowest
28
+ * dip. The index keeps the source as a sequence of PIECES (a few KB each),
29
+ * each carrying that pair. An edit re-summarises only the piece(s) it touched,
30
+ * from the NEW text, and every other piece keeps a summary that is still
31
+ * exactly true of its (unchanged, merely displaced) characters. A query walks
32
+ * the piece summaries up to the offset and scans only the tail of the piece
33
+ * the offset falls in. Nothing here is a guess about what the edit exposed:
34
+ * the touched piece is re-read verbatim, and composition is arithmetic.
35
+ *
36
+ * Cost per keystroke: one summary of ≤ 8 KB plus one walk over ~140 piece
37
+ * summaries at 547 KB — a few microseconds, and independent of where the
38
+ * caret is. Verified against the plain scan by `BracketIndex.test.ts`: 2.000
39
+ * random edits over documents full of stray brackets, every offset agreeing.
40
+ *
41
+ * The index does not know which document it describes; the parser keys it on
42
+ * the green root it was last synchronised with and rebuilds it (one scan) when
43
+ * the root it is handed is not that one. See `IncrementalParser.reparse`.
44
+ */
45
+
46
+ /** Pieces are re-cut to this size when an edit grows one past `MAX_PIECE`. */
47
+ const TARGET_PIECE = 4096
48
+ /** A piece may grow to this before it is split. */
49
+ const MAX_PIECE = TARGET_PIECE * 2
50
+ /** A piece that shrinks below this merges into a neighbour when that fits. */
51
+ const MIN_PIECE = TARGET_PIECE / 4
52
+
53
+ export class BracketDepthIndex {
54
+ /** Length of each piece, in characters. Pieces partition `[0, length)`. */
55
+ private lengths: number[] = []
56
+ /** Net bracket sum of each piece: `[` counts +1, `]` counts −1. */
57
+ private sums: number[] = []
58
+ /** Lowest prefix sum inside each piece, capped at 0 (see the header). */
59
+ private dips: number[] = []
60
+ private _length = 0
61
+
62
+ /**
63
+ * Characters read by the last `depthAt` — the tail of one piece, never the
64
+ * prefix. Exposed so a regression back to an O(prefix) scan is something a
65
+ * test can assert on, rather than something a profile has to notice.
66
+ */
67
+ lastScanned = 0
68
+
69
+ /** The length of the text this index currently describes. */
70
+ get length(): number {
71
+ return this._length
72
+ }
73
+
74
+ /** Number of pieces — for tests; it should stay ~`length / TARGET_PIECE`. */
75
+ get pieceCount(): number {
76
+ return this.lengths.length
77
+ }
78
+
79
+ /** Describe `source` from scratch. O(n) — one scan, the same the query used to be. */
80
+ rebuild(source: string): void {
81
+ this.lengths.length = 0
82
+ this.sums.length = 0
83
+ this.dips.length = 0
84
+ this._length = source.length
85
+ for (let at = 0; at < source.length; at += TARGET_PIECE) {
86
+ this.pushPiece(source, at, Math.min(source.length, at + TARGET_PIECE))
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Bring the index from the text before an edit to `newSource`, the text
92
+ * after it. `start`/`endOld` bound the replaced region in OLD coordinates;
93
+ * `insertedLength` is the length of the text that replaced it.
94
+ *
95
+ * Only the pieces overlapping the replaced region are re-read. They are
96
+ * re-summarised as one piece over the same span in NEW coordinates, and
97
+ * that piece is split if it has grown past `MAX_PIECE` — so a long paste
98
+ * ends up as several normal pieces, not one giant one that every later
99
+ * query near it would have to scan.
100
+ */
101
+ applyChange(newSource: string, start: number, endOld: number, insertedLength: number): void {
102
+ const delta = insertedLength - (endOld - start)
103
+ const oldLength = this._length
104
+ if (endOld > oldLength || start > endOld || newSource.length !== oldLength + delta) {
105
+ // The caller's picture of the previous text and ours disagree. There is
106
+ // no way to reconcile them locally; describe the new text outright.
107
+ this.rebuild(newSource)
108
+ return
109
+ }
110
+
111
+ // The piece containing `start` — the LAST piece when the edit sits at the
112
+ // very end — and the piece whose closed span `[pieceStart, pieceEnd]`
113
+ // contains `endOld`, so an edit that ends exactly on a boundary does not
114
+ // reach into the piece after it.
115
+ const lengths = this.lengths
116
+ let i = 0
117
+ let pieceStart = 0
118
+ while (i < lengths.length - 1 && pieceStart + lengths[i] <= start) {
119
+ pieceStart += lengths[i]
120
+ i++
121
+ }
122
+ let j = i
123
+ let pieceEnd = pieceStart + (lengths.length === 0 ? 0 : lengths[i])
124
+ while (j < lengths.length - 1 && pieceEnd < endOld) {
125
+ j++
126
+ pieceEnd += lengths[j]
127
+ }
128
+
129
+ const newEnd = pieceEnd + delta
130
+ const removed = lengths.length === 0 ? 0 : j - i + 1
131
+ this.spliceSummaries(i, removed, newSource, pieceStart, newEnd)
132
+ this._length = newSource.length
133
+
134
+ // Keep the pieces around the edit from dwindling: a piece that lost most
135
+ // of its text merges into a neighbour so the piece count stays bounded by
136
+ // the text length, not by the number of edits it has seen.
137
+ this.mergeSmallAround(i)
138
+ }
139
+
140
+ /**
141
+ * The clamped bracket depth just before `end` — what the plain scan of
142
+ * `[0, end)` would return. `end === 0` is depth 0; `end > length` is
143
+ * clamped to the text.
144
+ */
145
+ depthAt(source: string, end: number): number {
146
+ if (end > this._length) end = this._length
147
+ const lengths = this.lengths
148
+ let depth = 0
149
+ let pos = 0
150
+ let i = 0
151
+ // Whole pieces before `end`: compose their summaries.
152
+ for (; i < lengths.length && pos + lengths[i] <= end; i++) {
153
+ const dip = this.dips[i]
154
+ depth = this.sums[i] + (depth > -dip ? depth : -dip)
155
+ pos += lengths[i]
156
+ }
157
+ // The tail of the piece `end` falls in: scan it.
158
+ this.lastScanned = end - pos
159
+ for (let k = pos; k < end; k++) {
160
+ const c = source.charCodeAt(k)
161
+ if (c === 91 /* [ */) depth++
162
+ else if (c === 93 /* ] */ && depth > 0) depth--
163
+ }
164
+ return depth
165
+ }
166
+
167
+ /** Summarise `[from, to)` of `source` and append it as a piece. */
168
+ private pushPiece(source: string, from: number, to: number): void {
169
+ let sum = 0
170
+ let dip = 0
171
+ for (let k = from; k < to; k++) {
172
+ const c = source.charCodeAt(k)
173
+ if (c === 91 /* [ */) sum++
174
+ else if (c === 93 /* ] */) {
175
+ sum--
176
+ if (sum < dip) dip = sum
177
+ }
178
+ }
179
+ this.lengths.push(to - from)
180
+ this.sums.push(sum)
181
+ this.dips.push(dip)
182
+ }
183
+
184
+ /**
185
+ * Replace `count` pieces at `index` with the summaries of `[from, to)` of
186
+ * `source`, cut into pieces of at most `MAX_PIECE`.
187
+ */
188
+ private spliceSummaries(index: number, count: number, source: string, from: number, to: number): void {
189
+ const savedLengths = this.lengths
190
+ const savedSums = this.sums
191
+ const savedDips = this.dips
192
+ this.lengths = []
193
+ this.sums = []
194
+ this.dips = []
195
+ const span = to - from
196
+ if (span > MAX_PIECE) {
197
+ const parts = Math.ceil(span / TARGET_PIECE)
198
+ const step = Math.ceil(span / parts)
199
+ for (let at = from; at < to; at += step) this.pushPiece(source, at, Math.min(to, at + step))
200
+ } else if (span > 0 || savedLengths.length === 0) {
201
+ // An empty document keeps one empty piece so `applyChange` always has
202
+ // a piece to anchor on; anywhere else an emptied piece simply goes.
203
+ this.pushPiece(source, from, to)
204
+ }
205
+ const fresh = this.lengths
206
+ const freshSums = this.sums
207
+ const freshDips = this.dips
208
+ savedLengths.splice(index, count, ...fresh)
209
+ savedSums.splice(index, count, ...freshSums)
210
+ savedDips.splice(index, count, ...freshDips)
211
+ this.lengths = savedLengths
212
+ this.sums = savedSums
213
+ this.dips = savedDips
214
+ }
215
+
216
+ /**
217
+ * Merge the piece at `index` (and the one after it) into a neighbour when
218
+ * it has shrunk below `MIN_PIECE` and the pair stays under `MAX_PIECE`.
219
+ *
220
+ * Merging two summaries is the composition from the header applied to the
221
+ * pair: the sum adds, and the dip of the second is measured from the end of
222
+ * the first. No text is read.
223
+ */
224
+ private mergeSmallAround(index: number): void {
225
+ for (let pass = 0; pass < 2; pass++) {
226
+ const i = Math.min(index, this.lengths.length - 1)
227
+ if (i < 0) return
228
+ if (this.lengths[i] >= MIN_PIECE || this.lengths.length < 2) return
229
+ // Prefer the previous neighbour (it keeps `index` valid for the next pass).
230
+ const left = i > 0 && this.lengths[i - 1] + this.lengths[i] <= MAX_PIECE
231
+ const right = i + 1 < this.lengths.length && this.lengths[i] + this.lengths[i + 1] <= MAX_PIECE
232
+ if (left) this.mergePair(i - 1)
233
+ else if (right) this.mergePair(i)
234
+ else return
235
+ }
236
+ }
237
+
238
+ /** Fold piece `i + 1` into piece `i`. */
239
+ private mergePair(i: number): void {
240
+ const sumA = this.sums[i]
241
+ const dipB = this.dips[i + 1] + sumA
242
+ this.dips[i] = Math.min(this.dips[i], dipB)
243
+ this.sums[i] = sumA + this.sums[i + 1]
244
+ this.lengths[i] += this.lengths[i + 1]
245
+ this.lengths.splice(i + 1, 1)
246
+ this.sums.splice(i + 1, 1)
247
+ this.dips.splice(i + 1, 1)
248
+ }
249
+ }