opentui-markdown-render 0.0.0-stage → 0.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.
- package/LICENSE +21 -0
- package/NOTICE +27 -0
- package/README.md +173 -2
- package/native/Cargo.lock +3708 -0
- package/native/Cargo.toml +28 -0
- package/native/build.rs +3 -0
- package/native/src/lib.rs +880 -0
- package/package.json +62 -4
- package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
- package/src/app.tsx +180 -0
- package/src/config/index.ts +45 -0
- package/src/config/load.ts +308 -0
- package/src/config/resolve.ts +37 -0
- package/src/config/theme.ts +88 -0
- package/src/config/types.ts +102 -0
- package/src/core/document.ts +44 -0
- package/src/core/frame.ts +144 -0
- package/src/core/image.ts +9 -0
- package/src/core/index.ts +6 -0
- package/src/core/layout.ts +27 -0
- package/src/core/math.ts +170 -0
- package/src/core/parse.ts +208 -0
- package/src/core/tables.ts +58 -0
- package/src/graphics.ts +85 -0
- package/src/headings.ts +77 -0
- package/src/index.ts +98 -0
- package/src/math/index.ts +86 -0
- package/src/math/native.ts +39 -0
- package/src/math/renderer.ts +84 -0
- package/src/math/validate.ts +44 -0
- package/src/media.tsx +267 -0
- package/src/merman/class/dagre-layout.ts +171 -0
- package/src/merman/class/diagram.ts +8 -0
- package/src/merman/class/drawing.ts +343 -0
- package/src/merman/class/labels.ts +38 -0
- package/src/merman/class/layout.ts +531 -0
- package/src/merman/class/parser.ts +308 -0
- package/src/merman/class/render-grid.ts +18 -0
- package/src/merman/class/style.ts +27 -0
- package/src/merman/class/types.ts +57 -0
- package/src/merman/core/canvas.ts +228 -0
- package/src/merman/core/color/style.ts +56 -0
- package/src/merman/core/drawing.ts +307 -0
- package/src/merman/core/geometry.ts +347 -0
- package/src/merman/core/mermaid.ts +38 -0
- package/src/merman/core/render-grid.ts +51 -0
- package/src/merman/core/spatial.ts +243 -0
- package/src/merman/core/text-lines.ts +32 -0
- package/src/merman/core/text.ts +44 -0
- package/src/merman/detect.ts +19 -0
- package/src/merman/diagnostics.ts +16 -0
- package/src/merman/diagram.ts +268 -0
- package/src/merman/flowchart/drawing.ts +433 -0
- package/src/merman/flowchart/labels.ts +144 -0
- package/src/merman/flowchart/layout.ts +1057 -0
- package/src/merman/flowchart/options.ts +12 -0
- package/src/merman/flowchart/parser.ts +477 -0
- package/src/merman/flowchart/render.ts +10 -0
- package/src/merman/flowchart/routing.ts +1429 -0
- package/src/merman/flowchart/style.ts +79 -0
- package/src/merman/flowchart/types.ts +63 -0
- package/src/merman/gantt/diagram.ts +8 -0
- package/src/merman/gantt/drawing.ts +193 -0
- package/src/merman/gantt/parser.ts +198 -0
- package/src/merman/gantt/render-grid.ts +17 -0
- package/src/merman/gantt/style.ts +36 -0
- package/src/merman/gantt/types.ts +45 -0
- package/src/merman/gitgraph/diagram.ts +8 -0
- package/src/merman/gitgraph/drawing.ts +234 -0
- package/src/merman/gitgraph/parser.ts +234 -0
- package/src/merman/gitgraph/render-grid.ts +17 -0
- package/src/merman/gitgraph/style.ts +37 -0
- package/src/merman/gitgraph/types.ts +36 -0
- package/src/merman/palette.ts +60 -0
- package/src/merman/sequence/diagram.ts +8 -0
- package/src/merman/sequence/drawing.ts +288 -0
- package/src/merman/sequence/options.ts +8 -0
- package/src/merman/sequence/parser.ts +303 -0
- package/src/merman/sequence/placement.ts +692 -0
- package/src/merman/sequence/render-grid.ts +22 -0
- package/src/merman/sequence/style.ts +81 -0
- package/src/merman/sequence/types.ts +74 -0
- package/src/merman/state/diagram.ts +8 -0
- package/src/merman/state/drawing.ts +363 -0
- package/src/merman/state/endpoint.ts +15 -0
- package/src/merman/state/layout.ts +1134 -0
- package/src/merman/state/note.ts +59 -0
- package/src/merman/state/options.ts +11 -0
- package/src/merman/state/parser.ts +245 -0
- package/src/merman/state/render-grid.ts +22 -0
- package/src/merman/state/routing.ts +1522 -0
- package/src/merman/state/search.ts +103 -0
- package/src/merman/state/style.ts +52 -0
- package/src/merman/state/types.ts +63 -0
- package/src/merman/state/visible-model.ts +77 -0
- package/src/merman/timeline/diagram.ts +8 -0
- package/src/merman/timeline/drawing.ts +109 -0
- package/src/merman/timeline/parser.ts +119 -0
- package/src/merman/timeline/render-grid.ts +17 -0
- package/src/merman/timeline/style.ts +36 -0
- package/src/merman/timeline/types.ts +31 -0
- package/src/merman.ts +3 -0
- package/src/statusline.tsx +183 -0
- package/src/theme/colors.ts +11 -0
- package/src/theme/defaults.ts +190 -0
- package/src/theme/index.ts +5 -0
- package/src/theme/resolve.ts +166 -0
- package/src/theme/syntax.ts +96 -0
- package/src/theme/types.ts +150 -0
- package/src/view/code.tsx +27 -0
- package/src/view/document-slots.ts +111 -0
- package/src/view/document-table.tsx +98 -0
- package/src/view/document.tsx +195 -0
- package/src/view/frame.tsx +83 -0
- package/src/view/inline-flow.tsx +113 -0
- package/src/view/inline-layout.ts +149 -0
- package/src/view/inline-math.ts +42 -0
- package/src/view/inline-style.ts +103 -0
- package/src/view/math.tsx +85 -0
- package/src/view/mermaid.tsx +128 -0
- package/src/view/table.ts +100 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createEffect, createMemo, For, Show } from "solid-js"
|
|
2
|
+
import { StyledText, type RGBA, type TextChunk, type TextRenderable } from "@opentui/core"
|
|
3
|
+
import { mosaicScale, type CanvasGraphics } from "../graphics"
|
|
4
|
+
import { mathImageID, rgbaToHex } from "../math"
|
|
5
|
+
import { layoutAtoms, type LayoutRun, type MathVariant } from "./inline-layout"
|
|
6
|
+
import { flattenInline, type InlineStyleContext } from "./inline-style"
|
|
7
|
+
import { useInlineMathImages } from "./inline-math"
|
|
8
|
+
import type { Inline } from "../core/document"
|
|
9
|
+
import type { Theme, HeadingLevel } from "../theme/types"
|
|
10
|
+
import { headingColor, type HeadingStyles } from "../headings"
|
|
11
|
+
|
|
12
|
+
export type InlineFlowProps = {
|
|
13
|
+
inlines: readonly Inline[]
|
|
14
|
+
columns: number
|
|
15
|
+
theme: Theme
|
|
16
|
+
headings: HeadingStyles
|
|
17
|
+
graphics: CanvasGraphics
|
|
18
|
+
surface: () => string
|
|
19
|
+
quote?: boolean
|
|
20
|
+
heading?: { level: HeadingLevel }
|
|
21
|
+
prefix?: (index: number) => string
|
|
22
|
+
prefixColor?: () => RGBA
|
|
23
|
+
prefixChunks?: (index: number) => TextChunk[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function PrefixSegment(props: { chunks: () => TextChunk[]; width: number }) {
|
|
27
|
+
let ref: TextRenderable | undefined
|
|
28
|
+
createEffect(() => {
|
|
29
|
+
if (ref) ref.content = new StyledText(props.chunks())
|
|
30
|
+
})
|
|
31
|
+
return <text ref={ref} wrapMode="none" width={props.width} flexShrink={0} />
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function StyledSegment(props: { entry: LayoutRun; textRow: number }) {
|
|
35
|
+
let ref: TextRenderable | undefined
|
|
36
|
+
createEffect(() => {
|
|
37
|
+
const atom = props.entry.atom
|
|
38
|
+
if (ref && atom.type === "text") {
|
|
39
|
+
ref.content = new StyledText([{
|
|
40
|
+
__isChunk: true, text: atom.text, fg: atom.fg, attributes: atom.attributes,
|
|
41
|
+
...(atom.href ? { link: { url: atom.href } } : {}),
|
|
42
|
+
}])
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
return <text ref={ref} width={props.entry.columns} flexShrink={0} wrapMode="none" marginTop={props.textRow} />
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function InlineFlow(props: InlineFlowProps) {
|
|
49
|
+
const atoms = createMemo(() => {
|
|
50
|
+
const ctx: InlineStyleContext = { theme: props.theme, headings: props.headings, quote: props.quote, heading: props.heading,
|
|
51
|
+
hyperlinks: props.graphics.hyperlinks?.() ?? true }
|
|
52
|
+
return flattenInline(props.inlines, ctx)
|
|
53
|
+
})
|
|
54
|
+
const contents = createMemo(() => [...new Set(atoms().flatMap((atom) => atom.type === "math" ? [atom.content] : []))])
|
|
55
|
+
const foreground = () => rgbaToHex(props.heading ? headingColor(props.theme, props.heading.level) : props.theme.text.base)
|
|
56
|
+
const images = useInlineMathImages(contents, foreground, props.surface, props.graphics.cell)
|
|
57
|
+
const scale = createMemo(() => mosaicScale(props.graphics.protocol()))
|
|
58
|
+
const prefixWidth = () => props.prefix?.(0).length ?? 0
|
|
59
|
+
const columns = () => Math.max(1, props.columns - prefixWidth())
|
|
60
|
+
const lines = createMemo(() => layoutAtoms(atoms(), columns(), (content) => {
|
|
61
|
+
const image = images().get(content)
|
|
62
|
+
if (!image) return undefined
|
|
63
|
+
return {
|
|
64
|
+
normal: { columns: Math.max(1, Math.round(image.normal.columns * scale())), rows: Math.max(1, Math.round(image.normal.rows * scale())), baseline: image.normal.baseline * scale() },
|
|
65
|
+
compact: { columns: Math.max(1, Math.round(image.compact.columns * scale())), rows: image.compact.rows === 1 ? 1 : Math.max(1, Math.round(image.compact.rows * scale())), baseline: image.compact.baseline * scale() },
|
|
66
|
+
standard: { columns: Math.max(1, Math.round(image.standard.columns * scale())), rows: Math.max(1, Math.round(image.standard.rows * scale())), baseline: image.standard.baseline * scale() },
|
|
67
|
+
}
|
|
68
|
+
}, props.graphics.protocol() !== "blocks"))
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<box width="100%" flexDirection="column" flexShrink={0}>
|
|
72
|
+
<For each={lines()}>
|
|
73
|
+
{(line, index) => (
|
|
74
|
+
<box flexDirection="row" alignItems="flex-start" width="100%" height={line.height} flexShrink={0} backgroundColor={props.theme.background.base}>
|
|
75
|
+
<Show when={props.prefix}>
|
|
76
|
+
<Show when={props.prefixChunks} fallback={<text fg={props.prefixColor?.() ?? props.theme.markdown.text}
|
|
77
|
+
wrapMode="none" width={prefixWidth()} flexShrink={0}>{props.prefix?.(index()) ?? ""}</text>}>
|
|
78
|
+
{(chunks) => <PrefixSegment chunks={() => chunks()(index())} width={prefixWidth()} />}
|
|
79
|
+
</Show>
|
|
80
|
+
</Show>
|
|
81
|
+
<For each={line.runs}>
|
|
82
|
+
{(entry, position) => {
|
|
83
|
+
const atom = entry.atom
|
|
84
|
+
if (atom.type === "text") {
|
|
85
|
+
const visible = position() === line.runs.length - 1 ? atom.text.trimEnd() : atom.text
|
|
86
|
+
return <StyledSegment entry={{ ...entry, atom: { ...atom, text: visible } }} textRow={line.textRow} />
|
|
87
|
+
}
|
|
88
|
+
const variant: MathVariant = entry.variant ?? "normal"
|
|
89
|
+
return (
|
|
90
|
+
<Show when={images().get(atom.content)?.[variant]} fallback={
|
|
91
|
+
<text fg={props.theme.text.muted} wrapMode="none" width={entry.columns} flexShrink={0}>{atom.raw}</text>
|
|
92
|
+
}>
|
|
93
|
+
{(image) => (
|
|
94
|
+
<image
|
|
95
|
+
id={`opentui-markdown-math-${mathImageID(atom.content, false, foreground(), props.graphics.cell(), 1, variant === "compact", variant === "standard")}-${props.surface()}`}
|
|
96
|
+
source={image().png}
|
|
97
|
+
fit="fit"
|
|
98
|
+
protocol="auto"
|
|
99
|
+
width={entry.columns}
|
|
100
|
+
height={entry.rows}
|
|
101
|
+
flexShrink={0}
|
|
102
|
+
/>
|
|
103
|
+
)}
|
|
104
|
+
</Show>
|
|
105
|
+
)
|
|
106
|
+
}}
|
|
107
|
+
</For>
|
|
108
|
+
</box>
|
|
109
|
+
)}
|
|
110
|
+
</For>
|
|
111
|
+
</box>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import stringWidth from "string-width"
|
|
2
|
+
import type { StyledAtom } from "./inline-style"
|
|
3
|
+
|
|
4
|
+
export type MathMeasure = { columns: number; rows: number; baseline: number }
|
|
5
|
+
export type MathVariant = "normal" | "compact" | "standard"
|
|
6
|
+
export type LayoutRun = { atom: Extract<StyledAtom, { type: "text" | "math" }>; columns: number; rows: number; variant?: MathVariant }
|
|
7
|
+
export type LayoutLine = { runs: LayoutRun[]; columns: number; height: number; textRow: number }
|
|
8
|
+
|
|
9
|
+
function hardChunks(content: string, columns: number) {
|
|
10
|
+
const chunks: string[] = []
|
|
11
|
+
let current = ""
|
|
12
|
+
for (const character of content) {
|
|
13
|
+
if (current && stringWidth(current + character) > columns) {
|
|
14
|
+
chunks.push(current)
|
|
15
|
+
current = character
|
|
16
|
+
continue
|
|
17
|
+
}
|
|
18
|
+
current += character
|
|
19
|
+
}
|
|
20
|
+
if (current) chunks.push(current)
|
|
21
|
+
return chunks
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Measurements, compact collision checks, and standard-size choices use the
|
|
25
|
+
// shared styled AST.
|
|
26
|
+
export function layoutAtoms(
|
|
27
|
+
atoms: readonly StyledAtom[],
|
|
28
|
+
columns: number,
|
|
29
|
+
measureOf: (content: string) => { normal: MathMeasure; compact: MathMeasure; standard: MathMeasure } | undefined,
|
|
30
|
+
compactOverflow: boolean,
|
|
31
|
+
): LayoutLine[] {
|
|
32
|
+
columns = Math.max(1, columns)
|
|
33
|
+
const lines: LayoutLine[] = []
|
|
34
|
+
let current: LayoutLine = { runs: [], columns: 0, height: 1, textRow: 0 }
|
|
35
|
+
const flush = (force = false) => {
|
|
36
|
+
if (!current.runs.length && !force) return
|
|
37
|
+
lines.push(current)
|
|
38
|
+
current = { runs: [], columns: 0, height: 1, textRow: 0 }
|
|
39
|
+
}
|
|
40
|
+
const place = (run: LayoutRun, textRow = 0) => {
|
|
41
|
+
const add = (entry: LayoutRun) => {
|
|
42
|
+
current.runs.push(entry)
|
|
43
|
+
current.columns += entry.columns
|
|
44
|
+
current.height = Math.max(current.height, entry.rows, textRow + 1)
|
|
45
|
+
current.textRow = Math.max(current.textRow, textRow)
|
|
46
|
+
}
|
|
47
|
+
if (current.columns + run.columns <= columns || !current.runs.length) {
|
|
48
|
+
add(run)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
if (run.atom.type === "text") {
|
|
52
|
+
const text = run.atom.text.trimStart()
|
|
53
|
+
const trimmed = { ...run, atom: { ...run.atom, text }, columns: stringWidth(text) }
|
|
54
|
+
if (current.columns + trimmed.columns <= columns) {
|
|
55
|
+
add(trimmed)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
flush()
|
|
59
|
+
add(trimmed)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
flush()
|
|
63
|
+
add(run)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const atom of atoms) {
|
|
67
|
+
if (atom.type === "break") {
|
|
68
|
+
flush(true)
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
if (atom.type === "math") {
|
|
72
|
+
const natural = measureOf(atom.content)?.normal
|
|
73
|
+
const squeezed = natural ? natural.columns > columns : false
|
|
74
|
+
const entry: LayoutRun = natural
|
|
75
|
+
? { atom, columns: squeezed ? columns : natural.columns, rows: Math.max(1, Math.round(squeezed ? natural.rows * columns / natural.columns : natural.rows)) }
|
|
76
|
+
: { atom, columns: Math.min(columns, Math.max(3, atom.raw.length)), rows: 1 }
|
|
77
|
+
place(entry, natural ? Math.max(0, Math.ceil(natural.baseline) - 1) : 0)
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
// Soft newlines in Markdown prose are spaces. A hard break is its own atom.
|
|
81
|
+
const normalized = atom.text.replace(/\r?\n/g, " ")
|
|
82
|
+
const words = normalized.match(/\s*\S+\s*/g) ?? (normalized ? [normalized] : [])
|
|
83
|
+
for (const word of words) {
|
|
84
|
+
if (stringWidth(word) > columns) {
|
|
85
|
+
for (const chunk of hardChunks(word.trimEnd(), columns)) {
|
|
86
|
+
place({ atom: { ...atom, text: chunk }, columns: stringWidth(chunk), rows: 1 })
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
place({ atom: { ...atom, text: word }, columns: stringWidth(word), rows: 1 })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
flush()
|
|
94
|
+
|
|
95
|
+
if (compactOverflow) {
|
|
96
|
+
let changed = true
|
|
97
|
+
while (changed) {
|
|
98
|
+
changed = false
|
|
99
|
+
const occupied = lines.flatMap((line, row) => {
|
|
100
|
+
let column = 0
|
|
101
|
+
return line.runs.map((entry) => {
|
|
102
|
+
const area = {
|
|
103
|
+
entry, column,
|
|
104
|
+
row: row + (entry.atom.type === "text" ? line.textRow : 0),
|
|
105
|
+
rows: entry.atom.type === "math" ? entry.rows : 1,
|
|
106
|
+
}
|
|
107
|
+
column += entry.columns
|
|
108
|
+
return area
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
for (const area of occupied) {
|
|
112
|
+
if (area.entry.atom.type !== "math" || area.entry.variant === "compact" || area.rows <= 1) continue
|
|
113
|
+
const collision = occupied.some((other) =>
|
|
114
|
+
other.entry !== area.entry &&
|
|
115
|
+
other.column < area.column + area.entry.columns &&
|
|
116
|
+
area.column < other.column + other.entry.columns &&
|
|
117
|
+
other.row < area.row + area.rows &&
|
|
118
|
+
area.row + 1 < other.row + other.rows,
|
|
119
|
+
)
|
|
120
|
+
if (!collision) continue
|
|
121
|
+
const compact = measureOf(area.entry.atom.content)?.compact
|
|
122
|
+
if (!compact) continue
|
|
123
|
+
area.entry.variant = "compact"
|
|
124
|
+
area.entry.columns = compact.columns
|
|
125
|
+
area.entry.rows = compact.rows
|
|
126
|
+
changed = true
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const line of lines) {
|
|
131
|
+
const entry = line.runs.at(-1)
|
|
132
|
+
if (!entry || entry.atom.type !== "math" || entry.variant === "compact") continue
|
|
133
|
+
const standard = measureOf(entry.atom.content)?.standard
|
|
134
|
+
if (!standard || line.columns - entry.columns + standard.columns > columns) continue
|
|
135
|
+
entry.variant = "standard"
|
|
136
|
+
entry.columns = standard.columns
|
|
137
|
+
entry.rows = standard.rows
|
|
138
|
+
}
|
|
139
|
+
for (const line of lines) {
|
|
140
|
+
line.columns = line.runs.reduce((total, entry) => total + entry.columns, 0)
|
|
141
|
+
line.textRow = Math.max(0, ...line.runs.flatMap((entry) => {
|
|
142
|
+
if (entry.atom.type !== "math") return []
|
|
143
|
+
const measured = measureOf(entry.atom.content)
|
|
144
|
+
return measured ? [Math.max(0, Math.ceil(measured[entry.variant ?? "normal"].baseline) - 1)] : []
|
|
145
|
+
}))
|
|
146
|
+
line.height = Math.max(1, line.textRow + 1, ...line.runs.map((entry) => entry.rows))
|
|
147
|
+
}
|
|
148
|
+
return lines
|
|
149
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createMemo, createResource, type Accessor } from "solid-js"
|
|
2
|
+
import { renderMathImage, type MathImage } from "../math"
|
|
3
|
+
import type { CellSize } from "../graphics"
|
|
4
|
+
|
|
5
|
+
export type InlineMathImages = Map<string, { normal: MathImage; compact: MathImage; standard: MathImage }>
|
|
6
|
+
|
|
7
|
+
export function useInlineMathImages(
|
|
8
|
+
contents: Accessor<readonly string[]>,
|
|
9
|
+
foreground: Accessor<string>,
|
|
10
|
+
background: Accessor<string>,
|
|
11
|
+
cell: Accessor<CellSize | null>,
|
|
12
|
+
): Accessor<InlineMathImages> {
|
|
13
|
+
const [rendered] = createResource(
|
|
14
|
+
() => JSON.stringify({ contents: contents(), color: foreground(), surface: background(), cell: cell() }),
|
|
15
|
+
async (key) => {
|
|
16
|
+
const input = JSON.parse(key) as { contents: string[]; color: string; surface: string; cell: CellSize | null }
|
|
17
|
+
return Promise.all(input.contents.map(async (content) => {
|
|
18
|
+
try {
|
|
19
|
+
const normal = await renderMathImage(content, false, input.color, input.surface, input.cell)
|
|
20
|
+
if (!normal) return undefined
|
|
21
|
+
const compact = normal.rows > 1
|
|
22
|
+
? await renderMathImage(content, false, input.color, input.surface, input.cell, 1, true)
|
|
23
|
+
: normal
|
|
24
|
+
const standard = normal.clamped
|
|
25
|
+
? await renderMathImage(content, false, input.color, input.surface, input.cell, 1, false, true)
|
|
26
|
+
: normal
|
|
27
|
+
return { normal, compact: compact ?? normal, standard: standard ?? normal }
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
}))
|
|
32
|
+
},
|
|
33
|
+
)
|
|
34
|
+
return createMemo(() => {
|
|
35
|
+
const values = rendered()
|
|
36
|
+
if (!values) return new Map()
|
|
37
|
+
return new Map(contents().flatMap((content, index) => {
|
|
38
|
+
const image = values[index]
|
|
39
|
+
return image ? [[content, image] as const] : []
|
|
40
|
+
}))
|
|
41
|
+
})
|
|
42
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { TextAttributes, type RGBA, type TextChunk } from "@opentui/core"
|
|
2
|
+
import type { HeadingLevel, Theme } from "../theme/types"
|
|
3
|
+
import type { HeadingStyles } from "../headings"
|
|
4
|
+
import { headingAttributes, headingColor } from "../headings"
|
|
5
|
+
import type { Inline } from "../core/document"
|
|
6
|
+
|
|
7
|
+
export type StyledAtom =
|
|
8
|
+
| { type: "text"; text: string; fg: RGBA; attributes: number; href?: string }
|
|
9
|
+
| { type: "math"; content: string; raw: string; open: string; close: string }
|
|
10
|
+
| { type: "break"; hard: true }
|
|
11
|
+
|
|
12
|
+
export type InlineStyleContext = {
|
|
13
|
+
theme: Theme
|
|
14
|
+
headings: HeadingStyles
|
|
15
|
+
heading?: { level: HeadingLevel }
|
|
16
|
+
quote?: boolean
|
|
17
|
+
link?: { href: string; title?: string | null }
|
|
18
|
+
attributes?: number
|
|
19
|
+
hyperlinks?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function flattenInline(inlines: readonly Inline[], ctx: InlineStyleContext): StyledAtom[] {
|
|
23
|
+
const out: StyledAtom[] = []
|
|
24
|
+
const baseAttr = (ctx.attributes ?? 0) |
|
|
25
|
+
(ctx.heading ? headingAttributes(ctx.headings[ctx.heading.level]) : 0) |
|
|
26
|
+
(ctx.quote ? TextAttributes.ITALIC : 0)
|
|
27
|
+
const quote = !!ctx.quote
|
|
28
|
+
const linkCtx = ctx.link
|
|
29
|
+
|
|
30
|
+
function walk(list: readonly Inline[], attr: number, link: { href: string; title?: string | null } | undefined) {
|
|
31
|
+
for (const node of list) {
|
|
32
|
+
if (node.type === "text") {
|
|
33
|
+
let fg = ctx.theme.markdown.text
|
|
34
|
+
if (quote && !link && !ctx.heading) fg = ctx.theme.markdown.blockQuote
|
|
35
|
+
if (ctx.heading) fg = headingColor(ctx.theme, ctx.heading.level)
|
|
36
|
+
if (link) fg = ctx.theme.markdown.linkText
|
|
37
|
+
else if (attr & TextAttributes.BOLD && !ctx.heading) fg = ctx.theme.markdown.strong
|
|
38
|
+
else if (attr & TextAttributes.ITALIC && !ctx.heading && !quote) fg = ctx.theme.markdown.emphasis
|
|
39
|
+
if (node.text) out.push({ type: "text", text: node.text, fg, attributes: baseAttr | attr | (link ? TextAttributes.UNDERLINE : 0), href: link?.href })
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (node.type === "code") {
|
|
43
|
+
// code has priority styling
|
|
44
|
+
const fg = ctx.theme.markdown.code
|
|
45
|
+
out.push({ type: "text", text: node.text, fg, attributes: TextAttributes.NONE, href: link?.href })
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
if (node.type === "math") {
|
|
49
|
+
out.push({ type: "math", content: node.content, raw: node.raw, open: node.open, close: node.close })
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
if (node.type === "break") {
|
|
53
|
+
out.push({ type: "break", hard: true })
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
if (node.type === "strong") {
|
|
57
|
+
walk(node.children, attr | TextAttributes.BOLD, link)
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if (node.type === "em") {
|
|
61
|
+
walk(node.children, attr | TextAttributes.ITALIC, link)
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
if (node.type === "del") {
|
|
65
|
+
walk(node.children, attr | TextAttributes.STRIKETHROUGH, link)
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
if (node.type === "link") {
|
|
69
|
+
const l = { href: node.href, title: node.title }
|
|
70
|
+
walk(node.children, attr, l)
|
|
71
|
+
const label = node.children.map((child) => child.type === "text" || child.type === "code" ? child.text : "").join("")
|
|
72
|
+
if (ctx.hyperlinks === false && label !== node.href) {
|
|
73
|
+
out.push({ type: "text", text: ` (${node.href})`, fg: ctx.theme.markdown.link, attributes: baseAttr, href: node.href })
|
|
74
|
+
}
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
if (node.type === "inline-image") {
|
|
78
|
+
const l = link
|
|
79
|
+
// render alt as text with link style
|
|
80
|
+
out.push({ type: "text", text: node.alt || node.href || "image", fg: ctx.theme.markdown.linkText, attributes: baseAttr | attr, href: l?.href || node.href })
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
walk(inlines, baseAttr, linkCtx)
|
|
87
|
+
return out
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function toTableChunks(inlines: readonly Inline[], theme: Theme, headings: HeadingStyles, hyperlinks = true): TextChunk[] {
|
|
91
|
+
const chunks: TextChunk[] = []
|
|
92
|
+
for (const atom of flattenInline(inlines, { theme, headings, hyperlinks })) {
|
|
93
|
+
if (atom.type === "text") {
|
|
94
|
+
chunks.push({ __isChunk: true, text: atom.text, fg: atom.fg, attributes: atom.attributes,
|
|
95
|
+
...(atom.href ? { link: { url: atom.href } } : {}) })
|
|
96
|
+
} else if (atom.type === "math") {
|
|
97
|
+
chunks.push({ __isChunk: true, text: atom.raw, fg: theme.markdown.text })
|
|
98
|
+
} else {
|
|
99
|
+
chunks.push({ __isChunk: true, text: "\n", fg: theme.markdown.text })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return chunks.length ? chunks : [{ __isChunk: true, text: " ", fg: theme.markdown.text }]
|
|
103
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createResource, createSignal, Match, Switch } from "solid-js"
|
|
2
|
+
import { mosaicScale, type CanvasGraphics } from "../graphics"
|
|
3
|
+
import { mathImageID, renderMathImage, rgbaToHex, type CellSize, type MathImage } from "../math"
|
|
4
|
+
import type { Theme } from "../theme/types"
|
|
5
|
+
|
|
6
|
+
// Display math owns its own block. Inline math flows with the text around it.
|
|
7
|
+
export function DisplayMath(props: {
|
|
8
|
+
content: string
|
|
9
|
+
open: string
|
|
10
|
+
close: string
|
|
11
|
+
maxWidth: () => number
|
|
12
|
+
surface: () => string
|
|
13
|
+
theme: () => Theme
|
|
14
|
+
graphics: CanvasGraphics
|
|
15
|
+
}) {
|
|
16
|
+
const [failed, setFailed] = createSignal(false)
|
|
17
|
+
const color = () => rgbaToHex(props.theme().text.base)
|
|
18
|
+
// The block-character mosaic packs two vertical pixels per cell, so its
|
|
19
|
+
// cell box (not its raster) is scaled separately from font rasterization.
|
|
20
|
+
const mosaic = () => mosaicScale(props.graphics.protocol())
|
|
21
|
+
// Unrenderable TeX is a value here, not a rejected resource: reading a rejected
|
|
22
|
+
// resource throws, which would abort the tree on the next update.
|
|
23
|
+
const [rendered] = createResource(
|
|
24
|
+
() =>
|
|
25
|
+
JSON.stringify({
|
|
26
|
+
content: props.content,
|
|
27
|
+
color: color(),
|
|
28
|
+
background: props.surface(),
|
|
29
|
+
cell: props.graphics.cell(),
|
|
30
|
+
}),
|
|
31
|
+
async (key): Promise<{ raster: MathImage } | { error: string }> => {
|
|
32
|
+
const input = JSON.parse(key) as { content: string; color: string; background: string; cell: CellSize | null }
|
|
33
|
+
try {
|
|
34
|
+
const raster = await renderMathImage(input.content, true, input.color, input.background, input.cell)
|
|
35
|
+
return raster ? { raster } : { error: "Renderer returned no image" }
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return { error: error instanceof Error ? error.message : String(error) }
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
const raster = () => {
|
|
42
|
+
const value = rendered()
|
|
43
|
+
return value && "raster" in value ? value.raster : undefined
|
|
44
|
+
}
|
|
45
|
+
const source = () => `${props.open}${props.content}${props.close}`
|
|
46
|
+
const box = () => {
|
|
47
|
+
const image = raster()
|
|
48
|
+
const columns = (image ? image.columns : Math.min(24, props.maxWidth())) * mosaic()
|
|
49
|
+
const rows = (image ? image.rows : 3) * mosaic()
|
|
50
|
+
const max = props.maxWidth()
|
|
51
|
+
if (columns <= max) return { columns, rows: Math.max(1, rows) }
|
|
52
|
+
return { columns: max, rows: Math.max(1, Math.round((rows * max) / columns)) }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<box width="100%" flexShrink={0} flexDirection="column" marginTop={1} marginBottom={1}>
|
|
57
|
+
<Switch>
|
|
58
|
+
<Match when={rendered.loading}>
|
|
59
|
+
<text fg={props.theme().text.muted} alignSelf="center">Rendering math...</text>
|
|
60
|
+
</Match>
|
|
61
|
+
<Match when={failed() || (rendered() && "error" in rendered()!)}>
|
|
62
|
+
<box flexDirection="column" border={["left"]} borderColor={props.theme().text.feedback.error} paddingLeft={1} alignSelf="center">
|
|
63
|
+
<text fg={props.theme().text.feedback.error}>Could not render TeX; showing source</text>
|
|
64
|
+
<text fg={props.theme().text.base} wrapMode="word">
|
|
65
|
+
{source()}
|
|
66
|
+
</text>
|
|
67
|
+
</box>
|
|
68
|
+
</Match>
|
|
69
|
+
<Match when={raster()}>
|
|
70
|
+
<box width="100%" height={box().rows} flexShrink={0} alignItems="center" justifyContent="center">
|
|
71
|
+
<image
|
|
72
|
+
id={`opentui-markdown-math-${mathImageID(props.content, true, color(), props.graphics.cell())}-${props.surface()}`}
|
|
73
|
+
source={raster()!.png}
|
|
74
|
+
fit="fit"
|
|
75
|
+
protocol="auto"
|
|
76
|
+
width={box().columns}
|
|
77
|
+
height={box().rows}
|
|
78
|
+
onError={() => setFailed(true)}
|
|
79
|
+
/>
|
|
80
|
+
</box>
|
|
81
|
+
</Match>
|
|
82
|
+
</Switch>
|
|
83
|
+
</box>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createEffect, createMemo, Show, type Accessor } from "solid-js"
|
|
2
|
+
import { type MouseEvent, type TextRenderable } from "@opentui/core"
|
|
3
|
+
import { measureMermaidDiagram, type PreparedDiagram } from "../merman/diagram"
|
|
4
|
+
import { detectMermaidDiagram } from "../merman/detect"
|
|
5
|
+
import { MermaidSyntaxError } from "../merman/diagnostics"
|
|
6
|
+
import { DiagramCanvasSizeError } from "../merman/core/canvas"
|
|
7
|
+
import { resolveOpenCodeDiagramPalette } from "../merman/palette"
|
|
8
|
+
import type { Theme } from "../theme/types"
|
|
9
|
+
import type { MermaidConfig } from "../config/types"
|
|
10
|
+
import { defaultHeadingStyles, type HeadingStyles } from "../headings"
|
|
11
|
+
import { CodeBlock } from "./code"
|
|
12
|
+
|
|
13
|
+
export type MermaidDiagramViewProps = {
|
|
14
|
+
source: string
|
|
15
|
+
theme: Theme
|
|
16
|
+
columns: number
|
|
17
|
+
mermaidOptions?: MermaidConfig
|
|
18
|
+
headings?: HeadingStyles
|
|
19
|
+
onDiagnostic?: (diagnostic: { level: "error"; message: string }) => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function DiagramText(props: { prepared: Accessor<PreparedDiagram> }) {
|
|
23
|
+
let ref: TextRenderable | undefined
|
|
24
|
+
let dragX: number | undefined
|
|
25
|
+
createEffect(() => {
|
|
26
|
+
if (!ref) return
|
|
27
|
+
const prepared = props.prepared()
|
|
28
|
+
ref.content = prepared.text
|
|
29
|
+
ref.height = prepared.height
|
|
30
|
+
ref.width = "100%"
|
|
31
|
+
ref.wrapMode = "none"
|
|
32
|
+
ref.selectable = false
|
|
33
|
+
ref.marginTop = 1
|
|
34
|
+
})
|
|
35
|
+
return <text ref={(renderable) => {
|
|
36
|
+
ref = renderable
|
|
37
|
+
if (!ref) return
|
|
38
|
+
ref.onMouseDown = (event: MouseEvent) => {
|
|
39
|
+
if (event.button !== 0) return
|
|
40
|
+
ref?.ctx.clearSelection()
|
|
41
|
+
dragX = event.x
|
|
42
|
+
event.preventDefault()
|
|
43
|
+
event.stopPropagation()
|
|
44
|
+
}
|
|
45
|
+
ref.onMouseDrag = (event: MouseEvent) => {
|
|
46
|
+
if (dragX === undefined) return
|
|
47
|
+
const dx = event.x - dragX
|
|
48
|
+
dragX = event.x
|
|
49
|
+
if (ref && dx) ref.scrollX -= dx
|
|
50
|
+
event.preventDefault()
|
|
51
|
+
event.stopPropagation()
|
|
52
|
+
}
|
|
53
|
+
ref.onMouseDragEnd = () => { dragX = undefined }
|
|
54
|
+
ref.onMouseUp = () => { dragX = undefined }
|
|
55
|
+
ref.onMouseScroll = (event: MouseEvent) => {
|
|
56
|
+
const direction = event.scroll?.direction
|
|
57
|
+
if (direction !== "left" && direction !== "right") return
|
|
58
|
+
event.preventDefault()
|
|
59
|
+
event.stopPropagation()
|
|
60
|
+
}
|
|
61
|
+
}} />
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function MermaidDiagramView(props: MermaidDiagramViewProps) {
|
|
65
|
+
let lastGood: PreparedDiagram | undefined
|
|
66
|
+
const prepared = createMemo(() => {
|
|
67
|
+
const source = props.source
|
|
68
|
+
const kind = detectMermaidDiagram(source)
|
|
69
|
+
if (kind && lastGood && lastGood.kind !== kind) lastGood = undefined
|
|
70
|
+
const options = {
|
|
71
|
+
compact: props.mermaidOptions?.compact,
|
|
72
|
+
classLayout: props.mermaidOptions?.classLayout,
|
|
73
|
+
layoutMaxWidth: Math.min(props.mermaidOptions?.layoutMaxWidth ?? 120, Math.max(1, props.columns)),
|
|
74
|
+
colors: resolveOpenCodeDiagramPalette(props.theme),
|
|
75
|
+
palette: props.theme.palette,
|
|
76
|
+
}
|
|
77
|
+
const restore = () => {
|
|
78
|
+
if (!lastGood) return undefined
|
|
79
|
+
try {
|
|
80
|
+
// Reprepare with the current width and palette, never display an old
|
|
81
|
+
// StyledText raster carrying stale colors or geometry.
|
|
82
|
+
return measureMermaidDiagram(lastGood.source, options)
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const result = measureMermaidDiagram(source, options)
|
|
89
|
+
if (result && result.height > 0) {
|
|
90
|
+
lastGood = result
|
|
91
|
+
return result
|
|
92
|
+
}
|
|
93
|
+
if (lastGood && (!kind || lastGood.kind === kind)) {
|
|
94
|
+
return restore()
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error instanceof MermaidSyntaxError && lastGood && lastGood.kind === kind) {
|
|
98
|
+
return restore()
|
|
99
|
+
}
|
|
100
|
+
if (error instanceof MermaidSyntaxError && kind) {
|
|
101
|
+
const lines = source.split("\n")
|
|
102
|
+
if (error.lineNumber > 2 && !lines.slice(error.lineNumber).some((line) => line.trim())) {
|
|
103
|
+
try {
|
|
104
|
+
const result = measureMermaidDiagram(lines.slice(0, error.lineNumber - 1).join("\n"), options)
|
|
105
|
+
if (result?.height) {
|
|
106
|
+
lastGood = result
|
|
107
|
+
return result
|
|
108
|
+
}
|
|
109
|
+
} catch (prefixError) {
|
|
110
|
+
if (!(prefixError instanceof MermaidSyntaxError || prefixError instanceof DiagramCanvasSizeError)) {
|
|
111
|
+
props.onDiagnostic?.({ level: "error", message: prefixError instanceof Error ? prefixError.message : String(prefixError) })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!(error instanceof MermaidSyntaxError || error instanceof DiagramCanvasSizeError)) {
|
|
117
|
+
props.onDiagnostic?.({ level: "error", message: error instanceof Error ? error.message : String(error) })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return undefined
|
|
121
|
+
})
|
|
122
|
+
return <box width="100%" flexDirection="column" flexShrink={0}>
|
|
123
|
+
<Show when={prepared()} fallback={<CodeBlock content={props.source} language="mermaid"
|
|
124
|
+
theme={props.theme} headings={props.headings ?? defaultHeadingStyles} />}>
|
|
125
|
+
{(value) => <DiagramText prepared={value} />}
|
|
126
|
+
</Show>
|
|
127
|
+
</box>
|
|
128
|
+
}
|