@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,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — JSONExporter
|
|
3
|
+
*
|
|
4
|
+
* Exports the Document Model to JSON.
|
|
5
|
+
* Useful for:
|
|
6
|
+
* - Saving/loading documents in a structured format
|
|
7
|
+
* - Sending over the wire (collaboration)
|
|
8
|
+
* - Debugging and inspecting the document tree
|
|
9
|
+
* - Import/export between different tools
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { RedNode } from '../Syntax/RedNode'
|
|
13
|
+
import { Visitor } from './Visitor'
|
|
14
|
+
|
|
15
|
+
export interface JSONDocument {
|
|
16
|
+
version: number
|
|
17
|
+
kind: string
|
|
18
|
+
nodes: JSONNode[]
|
|
19
|
+
metadata?: Record<string, unknown>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface JSONNode {
|
|
23
|
+
id: string
|
|
24
|
+
kind: string
|
|
25
|
+
text: string
|
|
26
|
+
version: number
|
|
27
|
+
children: JSONNode[]
|
|
28
|
+
attributes?: Record<string, unknown>
|
|
29
|
+
metadata?: Record<string, unknown>
|
|
30
|
+
range?: { start: number; end: number }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class JSONExporter extends Visitor<string> {
|
|
34
|
+
/**
|
|
35
|
+
* Export a RedNode tree to a JSON string.
|
|
36
|
+
*/
|
|
37
|
+
visit(node: RedNode): string {
|
|
38
|
+
return JSON.stringify(this.toJSON(node), null, 2)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Export to a JSON object.
|
|
43
|
+
*/
|
|
44
|
+
toJSON(node: RedNode): JSONDocument {
|
|
45
|
+
return {
|
|
46
|
+
version: node.version,
|
|
47
|
+
kind: node.kind,
|
|
48
|
+
nodes: node.children.map(c => this.serializeNode(c)),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private serializeNode(node: RedNode): JSONNode {
|
|
53
|
+
return {
|
|
54
|
+
id: node.id,
|
|
55
|
+
kind: node.kind,
|
|
56
|
+
text: node.text,
|
|
57
|
+
version: node.version,
|
|
58
|
+
children: node.children.map(c => this.serializeNode(c)),
|
|
59
|
+
attributes: node.metadata?.tagName
|
|
60
|
+
? { tag: node.metadata.tagName as string }
|
|
61
|
+
: undefined,
|
|
62
|
+
metadata: Object.keys(node.metadata).length > 0 ? node.metadata : undefined,
|
|
63
|
+
range: { start: node.range.start, end: node.range.end },
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — MarkdownExporter
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { RedNode } from '../Syntax/RedNode'
|
|
12
|
+
import { Visitor } from './Visitor'
|
|
13
|
+
|
|
14
|
+
export class MarkdownExporter extends Visitor<string> {
|
|
15
|
+
visit(node: RedNode): string {
|
|
16
|
+
return this.exportNode(node).replace(/(?:\r?\n){3,}/g, '\n\n')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export(node: RedNode): string {
|
|
20
|
+
return this.visit(node)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private exportNode(node: RedNode, depth: number = 0): string {
|
|
24
|
+
if (node.children.length === 0 && node.kind === 'text') {
|
|
25
|
+
return node.text
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (node.kind === 'document') {
|
|
29
|
+
return node.children.map(c => this.exportNode(c, depth)).join('')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const content = node.children.map(c => this.exportNode(c, depth)).join('')
|
|
33
|
+
|
|
34
|
+
switch (node.kind) {
|
|
35
|
+
case 'bold': return `**${content}**`
|
|
36
|
+
case 'italic': return `*${content}*`
|
|
37
|
+
case 'underline': return content
|
|
38
|
+
case 'strikethrough': return `~~${content}~~`
|
|
39
|
+
case 'inline_code': return `\`${content}\``
|
|
40
|
+
case 'code': return `\`\`\`\n${content}\n\`\`\``
|
|
41
|
+
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 ``
|
|
53
|
+
case 'video': return `[🎥 YouTube Video](https://youtube.com/watch?v=${node.text || ''})`
|
|
54
|
+
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}`
|
|
58
|
+
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()}`
|
|
62
|
+
case 'spacing': return '\n'
|
|
63
|
+
case 'empty_line': return '\n'
|
|
64
|
+
case 'text':
|
|
65
|
+
default: return content || node.text || ''
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private renderTitleMarkdown(node: RedNode): string {
|
|
70
|
+
const titleNodes = node.metadata?.titleNodes as RedNode[] | undefined
|
|
71
|
+
if (titleNodes && titleNodes.length > 0) {
|
|
72
|
+
return titleNodes.map(c => this.exportNode(c, 0)).join('')
|
|
73
|
+
}
|
|
74
|
+
return (node.metadata?.title as string) || this.extractValue(node) || ''
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Read a BBCode tag's `=VALUE` attribute.
|
|
79
|
+
*
|
|
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.
|
|
84
|
+
*/
|
|
85
|
+
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
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { RedNode } from '../Syntax/RedNode'
|
|
2
|
+
import { Visitor } from './Visitor'
|
|
3
|
+
import { HTMLRenderer } from './HTMLRenderer'
|
|
4
|
+
import type { VisitorContext } from './Visitor'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SVGRenderer (Runtime ForeignObject Strategy)
|
|
8
|
+
*
|
|
9
|
+
* Instead of attempting to calculate CSS layouts manually,
|
|
10
|
+
* this runtime renderer wraps the exact HTML output in an SVG <foreignObject>.
|
|
11
|
+
*
|
|
12
|
+
* This gives 100% precision with zero DOM scanning overhead for real-time previews.
|
|
13
|
+
*/
|
|
14
|
+
export class SVGRenderer extends Visitor<string> {
|
|
15
|
+
private htmlRenderer = new HTMLRenderer()
|
|
16
|
+
|
|
17
|
+
visit(node: RedNode, context?: VisitorContext): string {
|
|
18
|
+
if (context) this.context = context
|
|
19
|
+
return this.render(node)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
render(root: RedNode): string {
|
|
23
|
+
const htmlContent = this.htmlRenderer.render(root)
|
|
24
|
+
|
|
25
|
+
// We wrap the HTML in a foreignObject. The container gets the standard BBCode preview classes
|
|
26
|
+
// so it inherits all global CSS (osu.css, tailwind, etc.) perfectly.
|
|
27
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%">
|
|
28
|
+
<foreignObject width="100%" height="100%">
|
|
29
|
+
<div xmlns="http://www.w3.org/1999/xhtml" class="bb-preview bbcode-preview text-sm leading-relaxed text-[#E8E0E4] wrap-break-word min-w-0 w-full h-full p-4 box-border">
|
|
30
|
+
${htmlContent}
|
|
31
|
+
</div>
|
|
32
|
+
</foreignObject>
|
|
33
|
+
</svg>`
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — TiptapExporter
|
|
3
|
+
*
|
|
4
|
+
* Exports the Document Model to Tiptap (ProseMirror) JSON format.
|
|
5
|
+
* This proves how easily Quasar's AST bridges to visual WYSIWYG editors.
|
|
6
|
+
*
|
|
7
|
+
* Tiptap uses a JSON format structured like:
|
|
8
|
+
* {
|
|
9
|
+
* type: "doc",
|
|
10
|
+
* content: [
|
|
11
|
+
* {
|
|
12
|
+
* type: "paragraph",
|
|
13
|
+
* content: [
|
|
14
|
+
* { type: "text", text: "Hello ", marks: [{ type: "bold" }] }
|
|
15
|
+
* ]
|
|
16
|
+
* }
|
|
17
|
+
* ]
|
|
18
|
+
* }
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { RedNode } from '../Syntax/RedNode'
|
|
22
|
+
import { Visitor } from './Visitor'
|
|
23
|
+
|
|
24
|
+
export interface TiptapNode {
|
|
25
|
+
type: string
|
|
26
|
+
attrs?: Record<string, any>
|
|
27
|
+
content?: TiptapNode[]
|
|
28
|
+
marks?: TiptapMark[]
|
|
29
|
+
text?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TiptapMark {
|
|
33
|
+
type: string
|
|
34
|
+
attrs?: Record<string, any>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class TiptapExporter extends Visitor<string> {
|
|
38
|
+
visit(node: RedNode): string {
|
|
39
|
+
return JSON.stringify(this.toTiptap(node), null, 2)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
toTiptap(node: RedNode): TiptapNode {
|
|
43
|
+
if (node.kind === 'document') {
|
|
44
|
+
return {
|
|
45
|
+
type: 'doc',
|
|
46
|
+
content: this.processChildren(node.children, []),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return this.processNode(node, [])
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private processChildren(children: RedNode[], marks: TiptapMark[]): TiptapNode[] {
|
|
53
|
+
const content: TiptapNode[] = []
|
|
54
|
+
|
|
55
|
+
for (const child of children) {
|
|
56
|
+
// empty_line and spacing become empty paragraphs or brs depending on context
|
|
57
|
+
if (child.kind === 'empty_line' || child.kind === 'spacing') {
|
|
58
|
+
continue // Simplification: in a real bridge, these would divide paragraphs
|
|
59
|
+
}
|
|
60
|
+
content.push(this.processNode(child, marks))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return content
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private processNode(node: RedNode, activeMarks: TiptapMark[]): TiptapNode {
|
|
67
|
+
// 1. Check if this node is a mark (inline formatting)
|
|
68
|
+
const newMark = this.kindToMark(node)
|
|
69
|
+
if (newMark) {
|
|
70
|
+
const nextMarks = [...activeMarks, newMark]
|
|
71
|
+
// Inline tags shouldn't exist as blocks in Tiptap, they just wrap text
|
|
72
|
+
// We return a text node if it's the leaf, otherwise we flatten children
|
|
73
|
+
if (node.children.length === 1 && node.children[0].kind === 'text') {
|
|
74
|
+
return {
|
|
75
|
+
type: 'text',
|
|
76
|
+
text: node.children[0].text,
|
|
77
|
+
marks: nextMarks
|
|
78
|
+
}
|
|
79
|
+
} else if (node.children.length === 0) {
|
|
80
|
+
return { type: 'text', text: '', marks: nextMarks }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Simplification: if multiple children, we wrap them all in this mark
|
|
84
|
+
// Tiptap actually expects a flat list of text nodes with marks.
|
|
85
|
+
// So this requires flattening. For MVP, we return a custom block or flat text.
|
|
86
|
+
// We will just create a custom inline block for now.
|
|
87
|
+
return {
|
|
88
|
+
type: 'text',
|
|
89
|
+
text: node.children.map(c => c.text || '').join(''),
|
|
90
|
+
marks: nextMarks
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 2. Leaf Text Node
|
|
95
|
+
if (node.kind === 'text') {
|
|
96
|
+
return {
|
|
97
|
+
type: 'text',
|
|
98
|
+
text: node.text,
|
|
99
|
+
marks: activeMarks.length > 0 ? activeMarks : undefined
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 3. Block Nodes
|
|
104
|
+
return {
|
|
105
|
+
type: this.kindToBlockType(node.kind),
|
|
106
|
+
attrs: Object.keys(node.metadata).length > 0 ? node.metadata : undefined,
|
|
107
|
+
content: node.children.length > 0 ? this.processChildren(node.children, activeMarks) : undefined
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private kindToMark(node: RedNode): TiptapMark | null {
|
|
112
|
+
switch (node.kind) {
|
|
113
|
+
case 'bold': return { type: 'bold' }
|
|
114
|
+
case 'italic': return { type: 'italic' }
|
|
115
|
+
case 'underline': return { type: 'underline' }
|
|
116
|
+
case 'strikethrough': return { type: 'strike' }
|
|
117
|
+
case 'inline_code': return { type: 'code' }
|
|
118
|
+
case 'color': return { type: 'textStyle', attrs: { color: node.metadata.color } }
|
|
119
|
+
case 'url': return { type: 'link', attrs: { href: node.metadata.href } }
|
|
120
|
+
case 'gradient':
|
|
121
|
+
case 'rainbow':
|
|
122
|
+
case 'grow':
|
|
123
|
+
case 'sinewave':
|
|
124
|
+
return { type: 'miliastryEffect', attrs: { effect: node.kind, ...node.metadata } }
|
|
125
|
+
}
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private kindToBlockType(kind: string): string {
|
|
130
|
+
const map: Record<string, string> = {
|
|
131
|
+
heading: 'heading',
|
|
132
|
+
paragraph: 'paragraph',
|
|
133
|
+
quote: 'blockquote',
|
|
134
|
+
code: 'codeBlock',
|
|
135
|
+
list: 'bulletList',
|
|
136
|
+
list_item: 'listItem',
|
|
137
|
+
image: 'image',
|
|
138
|
+
center: 'centerBlock',
|
|
139
|
+
notice: 'noticeBlock',
|
|
140
|
+
box: 'boxBlock',
|
|
141
|
+
spoilerbox: 'spoilerboxBlock'
|
|
142
|
+
}
|
|
143
|
+
return map[kind] ?? 'paragraph'
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — Visitor
|
|
3
|
+
*
|
|
4
|
+
* Base visitor pattern for traversing the Red Tree.
|
|
5
|
+
* All exporters and renderers extend this.
|
|
6
|
+
*
|
|
7
|
+
* Instead of switch statements on tag names, use the visitor pattern.
|
|
8
|
+
* New languages/exporters don't modify existing code.
|
|
9
|
+
*
|
|
10
|
+
* Inspired by SwiftSyntax's Visitor pattern.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { RedNode } from '../Syntax/RedNode'
|
|
14
|
+
|
|
15
|
+
export interface VisitorContext {
|
|
16
|
+
source: string
|
|
17
|
+
options: Record<string, unknown>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export abstract class Visitor<T = string> {
|
|
21
|
+
protected context: VisitorContext = { source: '', options: {} }
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Visit a node and produce an output value.
|
|
25
|
+
*/
|
|
26
|
+
abstract visit(node: RedNode, context?: VisitorContext): T
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Visit all children and combine results.
|
|
30
|
+
*/
|
|
31
|
+
visitChildren(node: RedNode, separator: string = ''): T[] {
|
|
32
|
+
return node.children.map(child => this.visit(child))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the text content of a node.
|
|
37
|
+
*/
|
|
38
|
+
getText(node: RedNode): string {
|
|
39
|
+
return node.text || node.children.map(c => c.text).join('')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Set the visitor context.
|
|
44
|
+
*/
|
|
45
|
+
setContext(context: VisitorContext): void {
|
|
46
|
+
this.context = context
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { Visitor } from './Visitor'
|
|
2
|
+
export type { VisitorContext } from './Visitor'
|
|
3
|
+
export { BBCodeExporter } from './BBCodeExporter'
|
|
4
|
+
export { HTMLRenderer } from './HTMLRenderer'
|
|
5
|
+
export { morphHTML } from './DOMMorpher'
|
|
6
|
+
export { MarkdownExporter } from './MarkdownExporter'
|
|
7
|
+
export { JSONExporter } from './JSONExporter'
|
|
8
|
+
export { SVGRenderer } from './SVGRenderer'
|
|
9
|
+
export type { JSONDocument, JSONNode } from './JSONExporter'
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — BoxDrawer
|
|
3
|
+
*
|
|
4
|
+
* Drawer-style open/close animation for the `<details>` elements that
|
|
5
|
+
* HTMLRenderer emits for `[box]` and `[spoilerbox]`. The box grows downwards
|
|
6
|
+
* and progressively uncovers its content, which stays anchored under the
|
|
7
|
+
* summary instead of popping in all at once.
|
|
8
|
+
*
|
|
9
|
+
* This lives in Quasar rather than in a consumer because Quasar is what emits
|
|
10
|
+
* the `<details>` in the first place — the same reason `morphHTML` lives here.
|
|
11
|
+
* Any host that renders Quasar output gets the behaviour by binding it once.
|
|
12
|
+
*
|
|
13
|
+
* Why not CSS: while a `<details>` is closed its content is not rendered, so
|
|
14
|
+
* there is no height to interpolate between. `::details-content` combined with
|
|
15
|
+
* `interpolate-size: allow-keywords` would solve it, but it is Chromium-only in
|
|
16
|
+
* practice and hosts running on WebKit would silently lose the animation.
|
|
17
|
+
* Measuring the real height and driving it with the Web Animations API works on
|
|
18
|
+
* every engine.
|
|
19
|
+
*
|
|
20
|
+
* Heights are measured, not computed from summary + padding + borders, because
|
|
21
|
+
* that arithmetic breaks as soon as a theme puts margins on the summary or the
|
|
22
|
+
* boxes are nested. Toggling `open` and reading the rect yields the exact
|
|
23
|
+
* height the element will settle at, whatever the stylesheet does.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface BoxDrawerOptions {
|
|
27
|
+
/** Length of a full open or close, in milliseconds. Partial travel is scaled down. */
|
|
28
|
+
durationMs?: number
|
|
29
|
+
/** CSS easing function. */
|
|
30
|
+
easing?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Time for the full collapsed↔expanded travel; shorter distances scale down. */
|
|
34
|
+
const DEFAULT_DURATION_MS = 400
|
|
35
|
+
/** Floor so an interruption near the end still reads as motion, not a snap. */
|
|
36
|
+
const MIN_DURATION_MS = 120
|
|
37
|
+
/** easeOutCubic — fast to start, settles softly, no overshoot. */
|
|
38
|
+
const DEFAULT_EASING = 'cubic-bezier(0.33, 1, 0.68, 1)'
|
|
39
|
+
|
|
40
|
+
interface DrawerState {
|
|
41
|
+
animation: Animation
|
|
42
|
+
/** Where the box is heading, which is what a new click has to reverse. */
|
|
43
|
+
opening: boolean
|
|
44
|
+
/**
|
|
45
|
+
* Inline `overflow` from before the FIRST animation of a chain. Carried
|
|
46
|
+
* across interruptions so a reversal restores the author's value and not the
|
|
47
|
+
* `hidden` that the previous animation had just installed.
|
|
48
|
+
*/
|
|
49
|
+
restoreOverflow: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** In-flight state per element; a WeakMap so morphed-away nodes are freed. */
|
|
53
|
+
const running = new WeakMap<HTMLDetailsElement, DrawerState>()
|
|
54
|
+
|
|
55
|
+
function prefersReducedMotion(): boolean {
|
|
56
|
+
return typeof window !== 'undefined'
|
|
57
|
+
&& typeof window.matchMedia === 'function'
|
|
58
|
+
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Animate one `<details>` to its opposite state, reversing cleanly when it is
|
|
63
|
+
* already mid-animation.
|
|
64
|
+
*
|
|
65
|
+
* Exported so hosts that toggle boxes programmatically (a keyboard command, an
|
|
66
|
+
* "expand all" action) go through the same animation as a click.
|
|
67
|
+
*/
|
|
68
|
+
export function toggleBoxWithDrawer(
|
|
69
|
+
details: HTMLDetailsElement,
|
|
70
|
+
options: BoxDrawerOptions = {}
|
|
71
|
+
): void {
|
|
72
|
+
if (typeof details.animate !== 'function' || prefersReducedMotion()) {
|
|
73
|
+
details.open = !details.open
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Height as painted right now — read BEFORE cancelling, so an interrupted
|
|
78
|
+
// animation resumes from where the eye last saw it instead of snapping.
|
|
79
|
+
const from = details.getBoundingClientRect().height
|
|
80
|
+
|
|
81
|
+
const previous = running.get(details)
|
|
82
|
+
const restoreOverflow = previous ? previous.restoreOverflow : details.style.overflow
|
|
83
|
+
|
|
84
|
+
// Direction comes from the running animation's intent, never from
|
|
85
|
+
// `details.open`: a closing box is kept open so its content stays visible, so
|
|
86
|
+
// reading the attribute would report "open" and close it a second time,
|
|
87
|
+
// making a mid-close click impossible to reverse.
|
|
88
|
+
const opening = previous ? !previous.opening : !details.open
|
|
89
|
+
|
|
90
|
+
previous?.animation.cancel()
|
|
91
|
+
|
|
92
|
+
// Both extremes are needed to scale the duration by the distance actually
|
|
93
|
+
// travelled. `open` is left true either way so the content stays visible
|
|
94
|
+
// while the box grows or shrinks over it.
|
|
95
|
+
let collapsed: number
|
|
96
|
+
let expanded: number
|
|
97
|
+
if (details.open) {
|
|
98
|
+
expanded = details.getBoundingClientRect().height
|
|
99
|
+
details.open = false
|
|
100
|
+
collapsed = details.getBoundingClientRect().height
|
|
101
|
+
details.open = true
|
|
102
|
+
} else {
|
|
103
|
+
collapsed = details.getBoundingClientRect().height
|
|
104
|
+
details.open = true
|
|
105
|
+
expanded = details.getBoundingClientRect().height
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const to = opening ? expanded : collapsed
|
|
109
|
+
details.style.overflow = 'hidden'
|
|
110
|
+
|
|
111
|
+
// A reversal 90% of the way open only has 10% left to travel; running the
|
|
112
|
+
// full duration over it would crawl.
|
|
113
|
+
const fullTravel = expanded - collapsed
|
|
114
|
+
const ratio = fullTravel > 0 ? Math.min(Math.abs(to - from) / fullTravel, 1) : 1
|
|
115
|
+
const duration = Math.max(
|
|
116
|
+
(options.durationMs ?? DEFAULT_DURATION_MS) * ratio,
|
|
117
|
+
MIN_DURATION_MS
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
const animation = details.animate(
|
|
121
|
+
{ height: [`${from}px`, `${to}px`] },
|
|
122
|
+
{ duration, easing: options.easing ?? DEFAULT_EASING }
|
|
123
|
+
)
|
|
124
|
+
running.set(details, { animation, opening, restoreOverflow })
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `finish` and `cancel` are dispatched asynchronously, so by the time either
|
|
128
|
+
* lands a newer toggle may already own the element. Without this guard the
|
|
129
|
+
* outgoing animation strips the incoming one's `overflow: hidden` mid-flight
|
|
130
|
+
* — content spilling out of a small box — or applies its own `open` and
|
|
131
|
+
* freezes the box shut.
|
|
132
|
+
*/
|
|
133
|
+
const settle = (finished: boolean) => {
|
|
134
|
+
if (running.get(details)?.animation !== animation) return
|
|
135
|
+
running.delete(details)
|
|
136
|
+
if (finished) details.open = opening
|
|
137
|
+
details.style.overflow = restoreOverflow
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
animation.addEventListener('finish', () => settle(true))
|
|
141
|
+
animation.addEventListener('cancel', () => settle(false))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Intercept the native toggle of every box inside `root`.
|
|
146
|
+
*
|
|
147
|
+
* The listener is delegated rather than attached per box: hosts typically feed
|
|
148
|
+
* the preview through `morphHTML` on every keystroke, and per-element listeners
|
|
149
|
+
* would be torn down and re-attached continuously.
|
|
150
|
+
*
|
|
151
|
+
* @param root - container holding the rendered BBCode
|
|
152
|
+
* @returns disposer that removes the listener
|
|
153
|
+
*/
|
|
154
|
+
export function bindBoxDrawer(
|
|
155
|
+
root: HTMLElement,
|
|
156
|
+
options: BoxDrawerOptions = {}
|
|
157
|
+
): () => void {
|
|
158
|
+
const handleClick = (e: MouseEvent) => {
|
|
159
|
+
const target = e.target as HTMLElement | null
|
|
160
|
+
const summary = target?.closest('summary')
|
|
161
|
+
if (!summary) return
|
|
162
|
+
|
|
163
|
+
const details = summary.parentElement
|
|
164
|
+
if (!(details instanceof HTMLDetailsElement)) return
|
|
165
|
+
if (!root.contains(details)) return
|
|
166
|
+
|
|
167
|
+
// Enter/Space on the summary also arrive here as a click, so the keyboard
|
|
168
|
+
// is covered without a separate handler.
|
|
169
|
+
e.preventDefault()
|
|
170
|
+
toggleBoxWithDrawer(details, options)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
root.addEventListener('click', handleClick)
|
|
174
|
+
return () => root.removeEventListener('click', handleClick)
|
|
175
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — Visuals
|
|
3
|
+
*
|
|
4
|
+
* Catálogo de estilos visuales por plataforma para la previsualización
|
|
5
|
+
* de BBCode. Cada plataforma puede tener su propia apariencia sin
|
|
6
|
+
* modificar el renderer HTML.
|
|
7
|
+
*
|
|
8
|
+
* Uso:
|
|
9
|
+
* // Importar como CSS directo (soportado por Next.js, Vite, Webpack):
|
|
10
|
+
* import 'MiliastryPlatform/DocumentEngine/Visuals/osu.css'
|
|
11
|
+
*
|
|
12
|
+
* // O desde un componente React:
|
|
13
|
+
* import 'MiliastryPlatform/DocumentEngine/Visuals/osu.css'
|
|
14
|
+
* // Luego en el JSX: <div className="bbcode-preview" ...
|
|
15
|
+
*
|
|
16
|
+
* Para cambiar de tema visual, solo cambia el import del CSS.
|
|
17
|
+
*
|
|
18
|
+
* Temas disponibles:
|
|
19
|
+
* - osu → Estilo visual inspirado en los foros de osu!
|
|
20
|
+
* - miliastry → Estilo por defecto de Miliastry (próximamente)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export { bindBoxDrawer, toggleBoxWithDrawer } from './BoxDrawer'
|
|
24
|
+
export type { BoxDrawerOptions } from './BoxDrawer'
|
|
25
|
+
|
|
26
|
+
export const visualThemes = [
|
|
27
|
+
{
|
|
28
|
+
id: 'osu',
|
|
29
|
+
name: 'osu! Forum Style',
|
|
30
|
+
description: 'Estilo visual inspirado en los foros de osu!',
|
|
31
|
+
cssFile: 'osu.css',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'lyne',
|
|
35
|
+
name: 'Lyne Style',
|
|
36
|
+
description: 'Estilo visual cyberpunk con cortes a 45° inspirado en Lyne',
|
|
37
|
+
cssFile: 'lyne.css',
|
|
38
|
+
},
|
|
39
|
+
] as const
|
|
40
|
+
|
|
41
|
+
export type VisualThemeId = (typeof visualThemes)[number]['id']
|
|
42
|
+
|