opentui-markdown-render 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +27 -0
- package/README.md +223 -2
- package/bin/otmd.tsx +99 -0
- 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 +67 -4
- package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
- package/src/app.tsx +180 -0
- package/src/cli.ts +63 -0
- package/src/config/index.ts +46 -0
- package/src/config/load.ts +337 -0
- package/src/config/resolve.ts +36 -0
- package/src/config/theme.ts +110 -0
- package/src/config/types.ts +105 -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 +107 -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 +7 -0
- package/src/theme/load.ts +39 -0
- package/src/theme/resolve.ts +167 -0
- package/src/theme/syntax.ts +96 -0
- package/src/theme/types.ts +151 -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
- package/themes/dark.json +32 -0
- package/themes/example.jsonc +98 -0
- package/themes/light.json +32 -0
- package/themes/solarized-dark.json +32 -0
- package/themes/solarized-light.json +32 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
|
|
2
|
+
import { defaultHeadingStyles, headingColor, headingLevels, type HeadingStyles } from "../headings"
|
|
3
|
+
import type { HeadingLevel, Theme } from "./types"
|
|
4
|
+
|
|
5
|
+
export function generateSyntax(theme: Theme, headings: HeadingStyles = defaultHeadingStyles, defaultColor: RGBA = theme.text.base) {
|
|
6
|
+
const { markdown, syntax, text } = theme
|
|
7
|
+
const feedback = text.feedback
|
|
8
|
+
|
|
9
|
+
// A heading level is one colour and one set of attributes, so the levels
|
|
10
|
+
// read as a hierarchy. The scope names are the ones the Markdown parser
|
|
11
|
+
// reports for a heading.
|
|
12
|
+
const headingStyle = (level: HeadingLevel) => {
|
|
13
|
+
const style = headings[level]
|
|
14
|
+
return {
|
|
15
|
+
bold: style.bold,
|
|
16
|
+
italic: style.italic,
|
|
17
|
+
underline: style.underline,
|
|
18
|
+
dim: style.dim,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const headingRules = headingLevels.map((level) =>
|
|
22
|
+
rule([`markup.heading.${level}`], headingColor(theme, level), headingStyle(level)),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
return SyntaxStyle.fromTheme([
|
|
26
|
+
rule(["default"], defaultColor),
|
|
27
|
+
rule(["prompt"], theme.accent),
|
|
28
|
+
rule(["comment", "comment.documentation"], syntax.comment, { italic: true }),
|
|
29
|
+
rule(["string", "symbol", "character.special", "character"], syntax.string),
|
|
30
|
+
rule(["number", "boolean", "constant", "float"], syntax.number),
|
|
31
|
+
rule(["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"], syntax.keyword, {
|
|
32
|
+
italic: true,
|
|
33
|
+
}),
|
|
34
|
+
rule(["keyword.type"], syntax.type, { bold: true, italic: true }),
|
|
35
|
+
rule(["keyword.function", "function.method"], syntax.function),
|
|
36
|
+
rule(["keyword"], syntax.keyword, { italic: true }),
|
|
37
|
+
rule(["keyword.import", "string.escape", "string.regexp", "tag.attribute", "keyword.export"], syntax.keyword),
|
|
38
|
+
rule(["operator", "keyword.operator", "punctuation.delimiter", "keyword.conditional.ternary"], syntax.operator),
|
|
39
|
+
rule(
|
|
40
|
+
["variable", "variable.parameter", "function.method.call", "function.call", "property", "parameter", "field"],
|
|
41
|
+
syntax.variable,
|
|
42
|
+
),
|
|
43
|
+
rule(["variable.member", "function", "constructor"], syntax.function),
|
|
44
|
+
rule(["type", "module", "class", "namespace"], syntax.type),
|
|
45
|
+
rule(["type.definition"], syntax.type, { bold: true }),
|
|
46
|
+
rule(["punctuation", "punctuation.bracket"], syntax.punctuation),
|
|
47
|
+
rule(
|
|
48
|
+
["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin", "variable.super"],
|
|
49
|
+
feedback.error,
|
|
50
|
+
),
|
|
51
|
+
rule(["keyword.directive", "keyword.modifier", "keyword.exception"], syntax.keyword, { italic: true }),
|
|
52
|
+
rule(["punctuation.special", "tag.delimiter"], syntax.operator),
|
|
53
|
+
// Every level has its own rule, so the scope the parser reports for a
|
|
54
|
+
// heading decides how it looks. The generic scope is the fallback for a
|
|
55
|
+
// heading whose level the parser did not report.
|
|
56
|
+
rule(["markup.heading"], headingColor(theme, 1), headingStyle(1)),
|
|
57
|
+
...headingRules,
|
|
58
|
+
rule(["markup.bold", "markup.strong"], markdown.strong, { bold: true }),
|
|
59
|
+
rule(["markup.italic"], markdown.emphasis, { italic: true }),
|
|
60
|
+
// OpenTUI emits markup.list for both bullet and numbered markers. A
|
|
61
|
+
// dedicated node renderer handles numbered markers when their colour differs.
|
|
62
|
+
rule(["markup.list"], markdown.listItem),
|
|
63
|
+
rule(["markup.list.enumeration"], markdown.listEnumeration),
|
|
64
|
+
rule(["markup.quote"], markdown.blockQuote, { italic: true }),
|
|
65
|
+
rule(["markup.raw"], markdown.code),
|
|
66
|
+
rule(["markup.raw.block"], markdown.codeBlock),
|
|
67
|
+
rule(["markup.raw.inline"], markdown.code, { background: theme.background.base }),
|
|
68
|
+
rule(["markup.link", "markup.link.url", "string.special", "string.special.url"], markdown.link, {
|
|
69
|
+
underline: true,
|
|
70
|
+
}),
|
|
71
|
+
rule(["markup.link.label"], markdown.linkText, { underline: true }),
|
|
72
|
+
rule(["label"], markdown.linkText),
|
|
73
|
+
rule(["markup.underline"], text.base, { underline: true }),
|
|
74
|
+
rule(["markup.strikethrough", "markup.list.unchecked", "debug"], text.muted),
|
|
75
|
+
rule(["markup.list.checked"], feedback.success),
|
|
76
|
+
rule(["comment.error"], feedback.error, { italic: true, bold: true }),
|
|
77
|
+
rule(["comment.warning"], feedback.warning, { italic: true, bold: true }),
|
|
78
|
+
rule(["comment.todo", "comment.note"], feedback.info, { italic: true, bold: true }),
|
|
79
|
+
rule(["attribute", "annotation"], feedback.warning),
|
|
80
|
+
rule(["tag"], feedback.error),
|
|
81
|
+
rule(["diff.plus"], theme.diff.text.added, { background: theme.diff.background.added }),
|
|
82
|
+
rule(["diff.minus"], theme.diff.text.removed, { background: theme.diff.background.removed }),
|
|
83
|
+
rule(["diff.delta"], theme.diff.text.context, { background: theme.diff.background.context }),
|
|
84
|
+
rule(["error"], feedback.error, { bold: true }),
|
|
85
|
+
rule(["warning"], feedback.warning, { bold: true }),
|
|
86
|
+
rule(["info"], feedback.info),
|
|
87
|
+
])
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function rule(
|
|
91
|
+
scope: string[],
|
|
92
|
+
foreground: RGBA,
|
|
93
|
+
style: Omit<ThemeTokenStyle["style"], "foreground"> = {},
|
|
94
|
+
): ThemeTokenStyle {
|
|
95
|
+
return { scope, style: { foreground, ...style } }
|
|
96
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { ColorInput, RGBA } from "@opentui/core"
|
|
2
|
+
|
|
3
|
+
export type ThemeMode = "light" | "dark"
|
|
4
|
+
// A palette selects the colours; mode on the resolved Theme remains the
|
|
5
|
+
// light/dark semantic used by components such as the status line.
|
|
6
|
+
export type ThemePreset = ThemeMode | "solarized-light" | "solarized-dark"
|
|
7
|
+
|
|
8
|
+
// The six heading levels a Markdown document can have.
|
|
9
|
+
export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6
|
|
10
|
+
|
|
11
|
+
export type Theme = {
|
|
12
|
+
readonly mode: ThemeMode
|
|
13
|
+
// When present, generated diagram colours are snapped to these fixed swatches.
|
|
14
|
+
readonly palette?: readonly RGBA[]
|
|
15
|
+
readonly accent: RGBA
|
|
16
|
+
readonly categorical: readonly RGBA[]
|
|
17
|
+
readonly text: {
|
|
18
|
+
readonly base: RGBA
|
|
19
|
+
readonly muted: RGBA
|
|
20
|
+
readonly feedback: {
|
|
21
|
+
readonly error: RGBA
|
|
22
|
+
readonly warning: RGBA
|
|
23
|
+
readonly success: RGBA
|
|
24
|
+
readonly info: RGBA
|
|
25
|
+
}
|
|
26
|
+
readonly action: {
|
|
27
|
+
readonly primary: { readonly focused: RGBA }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
readonly background: {
|
|
31
|
+
readonly base: RGBA
|
|
32
|
+
}
|
|
33
|
+
readonly border: {
|
|
34
|
+
readonly base: RGBA
|
|
35
|
+
}
|
|
36
|
+
// The frame (viewer window) colours. Optional so a theme built by hand keeps
|
|
37
|
+
// working; the frame falls back to the border and the muted text.
|
|
38
|
+
readonly frame?: FrameTheme
|
|
39
|
+
// Colours of the status line bar. Optional so a host that builds a theme by
|
|
40
|
+
// hand keeps working; the bar falls back to colours derived from the theme.
|
|
41
|
+
readonly statusline?: StatusLineTheme
|
|
42
|
+
readonly markdown: {
|
|
43
|
+
readonly text: RGBA
|
|
44
|
+
readonly heading: RGBA
|
|
45
|
+
// The colour of each heading level. `heading` is the base colour every
|
|
46
|
+
// level falls back to, so a theme that only knows one heading colour keeps
|
|
47
|
+
// working.
|
|
48
|
+
readonly headingLevels: readonly [RGBA, RGBA, RGBA, RGBA, RGBA, RGBA]
|
|
49
|
+
readonly link: RGBA
|
|
50
|
+
readonly linkText: RGBA
|
|
51
|
+
readonly code: RGBA
|
|
52
|
+
readonly blockQuote: RGBA
|
|
53
|
+
readonly emphasis: RGBA
|
|
54
|
+
readonly strong: RGBA
|
|
55
|
+
readonly horizontalRule: RGBA
|
|
56
|
+
readonly listItem: RGBA
|
|
57
|
+
readonly listEnumeration: RGBA
|
|
58
|
+
readonly image: RGBA
|
|
59
|
+
readonly imageText: RGBA
|
|
60
|
+
readonly codeBlock: RGBA
|
|
61
|
+
}
|
|
62
|
+
readonly syntax: {
|
|
63
|
+
readonly comment: RGBA
|
|
64
|
+
readonly keyword: RGBA
|
|
65
|
+
readonly function: RGBA
|
|
66
|
+
readonly variable: RGBA
|
|
67
|
+
readonly string: RGBA
|
|
68
|
+
readonly number: RGBA
|
|
69
|
+
readonly type: RGBA
|
|
70
|
+
readonly operator: RGBA
|
|
71
|
+
readonly punctuation: RGBA
|
|
72
|
+
}
|
|
73
|
+
readonly diff: {
|
|
74
|
+
readonly text: { readonly added: RGBA; readonly removed: RGBA; readonly context: RGBA }
|
|
75
|
+
readonly background: { readonly added: RGBA; readonly removed: RGBA; readonly context: RGBA }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The status line bar has its own background so it reads as a separate strip
|
|
80
|
+
// instead of another line of the page.
|
|
81
|
+
export type StatusLineTheme = {
|
|
82
|
+
readonly background: RGBA
|
|
83
|
+
readonly foreground: RGBA
|
|
84
|
+
readonly title: RGBA
|
|
85
|
+
readonly muted: RGBA
|
|
86
|
+
readonly border: RGBA
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type StatusLineThemeOverride = {
|
|
90
|
+
readonly background?: ColorInput
|
|
91
|
+
readonly foreground?: ColorInput
|
|
92
|
+
readonly title?: ColorInput
|
|
93
|
+
readonly muted?: ColorInput
|
|
94
|
+
readonly border?: ColorInput
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The frame is the window around the document: its border and the label carved
|
|
98
|
+
// into the top edge.
|
|
99
|
+
export type FrameTheme = {
|
|
100
|
+
readonly border: RGBA
|
|
101
|
+
readonly title: RGBA
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type FrameThemeOverride = {
|
|
105
|
+
readonly border?: ColorInput
|
|
106
|
+
readonly title?: ColorInput
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The colours of the Markdown surface. `heading1`..`heading6` override the
|
|
110
|
+
// colour of a single heading level; `heading` overrides the colour of every
|
|
111
|
+
// level that has no colour of its own.
|
|
112
|
+
export type MarkdownThemeOverride = {
|
|
113
|
+
readonly [K in Exclude<keyof Theme["markdown"], "headingLevels">]?: ColorInput
|
|
114
|
+
} & {
|
|
115
|
+
readonly heading1?: ColorInput
|
|
116
|
+
readonly heading2?: ColorInput
|
|
117
|
+
readonly heading3?: ColorInput
|
|
118
|
+
readonly heading4?: ColorInput
|
|
119
|
+
readonly heading5?: ColorInput
|
|
120
|
+
readonly heading6?: ColorInput
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Host-side theme APIs accept a partial color-input override. JSON/JSONC
|
|
124
|
+
// application config selects a complete theme file by name instead.
|
|
125
|
+
export type ThemeOverride = {
|
|
126
|
+
readonly mode?: ThemePreset
|
|
127
|
+
readonly palette?: readonly ColorInput[]
|
|
128
|
+
readonly accent?: ColorInput
|
|
129
|
+
readonly categorical?: readonly ColorInput[]
|
|
130
|
+
readonly text?: {
|
|
131
|
+
readonly base?: ColorInput
|
|
132
|
+
readonly muted?: ColorInput
|
|
133
|
+
readonly feedback?: {
|
|
134
|
+
readonly error?: ColorInput
|
|
135
|
+
readonly warning?: ColorInput
|
|
136
|
+
readonly success?: ColorInput
|
|
137
|
+
readonly info?: ColorInput
|
|
138
|
+
}
|
|
139
|
+
readonly action?: { readonly primary?: { readonly focused?: ColorInput } }
|
|
140
|
+
}
|
|
141
|
+
readonly background?: { readonly base?: ColorInput }
|
|
142
|
+
readonly border?: { readonly base?: ColorInput }
|
|
143
|
+
readonly frame?: FrameThemeOverride
|
|
144
|
+
readonly statusline?: StatusLineThemeOverride
|
|
145
|
+
readonly markdown?: MarkdownThemeOverride
|
|
146
|
+
readonly syntax?: { readonly [K in keyof Theme["syntax"]]?: ColorInput }
|
|
147
|
+
readonly diff?: {
|
|
148
|
+
readonly text?: { readonly added?: ColorInput; readonly removed?: ColorInput; readonly context?: ColorInput }
|
|
149
|
+
readonly background?: { readonly added?: ColorInput; readonly removed?: ColorInput; readonly context?: ColorInput }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { infoStringToFiletype } from "@opentui/core"
|
|
2
|
+
import { generateSyntax } from "../theme/syntax"
|
|
3
|
+
import type { Theme } from "../theme/types"
|
|
4
|
+
import type { HeadingStyles } from "../headings"
|
|
5
|
+
|
|
6
|
+
export type CodeBlockProps = {
|
|
7
|
+
content: string
|
|
8
|
+
language: string | null
|
|
9
|
+
theme: Theme
|
|
10
|
+
headings: HeadingStyles
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function CodeBlock(props: CodeBlockProps) {
|
|
14
|
+
return (
|
|
15
|
+
<code
|
|
16
|
+
content={props.content}
|
|
17
|
+
filetype={infoStringToFiletype(props.language ?? "")}
|
|
18
|
+
syntaxStyle={generateSyntax(props.theme, props.headings, props.theme.markdown.codeBlock)}
|
|
19
|
+
fg={props.theme.markdown.codeBlock}
|
|
20
|
+
bg={props.theme.background.base}
|
|
21
|
+
conceal={false}
|
|
22
|
+
drawUnstyledText={true}
|
|
23
|
+
streaming={false}
|
|
24
|
+
width="100%"
|
|
25
|
+
/>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createSignal } from "solid-js"
|
|
2
|
+
import type { Block, SourceRange } from "../core/document"
|
|
3
|
+
|
|
4
|
+
export type Slot = {
|
|
5
|
+
kind: Block["type"]
|
|
6
|
+
// Rendering observes block; unit metadata observes latest. Moving a source
|
|
7
|
+
// range must not recreate a stateful rendered block.
|
|
8
|
+
block: () => Block
|
|
9
|
+
update: (b: Block) => void
|
|
10
|
+
latest: () => Block
|
|
11
|
+
updateLatest: (b: Block) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sameKind(old: Block, next: Block): boolean {
|
|
15
|
+
if (old.type !== next.type) return false
|
|
16
|
+
return old.type !== "mermaid" || (next.type === "mermaid" && old.language === next.language)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sameValue(a: unknown, b: unknown): boolean {
|
|
20
|
+
if (Object.is(a, b)) return true
|
|
21
|
+
if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) return false
|
|
22
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false
|
|
23
|
+
if (Array.isArray(a)) {
|
|
24
|
+
const other = b as unknown[]
|
|
25
|
+
return a.length === other.length && a.every((value, index) => sameValue(value, other[index]))
|
|
26
|
+
}
|
|
27
|
+
const left = a as Record<string, unknown>
|
|
28
|
+
const right = b as Record<string, unknown>
|
|
29
|
+
const keys = Object.keys(left)
|
|
30
|
+
const otherKeys = Object.keys(right)
|
|
31
|
+
return keys.filter((key) => key !== "source").length === otherKeys.filter((key) => key !== "source").length &&
|
|
32
|
+
keys.every((key) => key === "source" || (Object.prototype.hasOwnProperty.call(right, key) && sameValue(left[key], right[key])))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sameBlock(old: Block, next: Block): boolean {
|
|
36
|
+
return sameValue(old, next)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sameRange(old: SourceRange | null | undefined, next: SourceRange | null | undefined): boolean {
|
|
40
|
+
return old?.start === next?.start && old?.end === next?.end
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sameUnitRanges(old: Block, next: Block): boolean {
|
|
44
|
+
if (!sameRange(old.source, next.source)) return false
|
|
45
|
+
if (old.type !== "list" || next.type !== "list") return true
|
|
46
|
+
return old.items.length === next.items.length &&
|
|
47
|
+
old.items.every((item, index) => sameRange(item.source, next.items[index]?.source))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function refresh(slot: Slot, next: Block): void {
|
|
51
|
+
const changed = !sameBlock(slot.block(), next)
|
|
52
|
+
if (changed) slot.update(next)
|
|
53
|
+
// Even when the render tree is identical, the host needs current offsets.
|
|
54
|
+
if (changed || !sameUnitRanges(slot.latest(), next)) slot.updateLatest(next)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function createSlot(block: Block): Slot {
|
|
58
|
+
const [get, set] = createSignal(block)
|
|
59
|
+
const [latest, updateLatest] = createSignal(block)
|
|
60
|
+
return { kind: block.type, block: get, update: set, latest, updateLatest }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function reconcileSlots(prev: Slot[], next: Block[]): Slot[] {
|
|
64
|
+
if (prev.length === 0) {
|
|
65
|
+
return next.map(createSlot)
|
|
66
|
+
}
|
|
67
|
+
// prefix
|
|
68
|
+
let prefix = 0
|
|
69
|
+
while (prefix < prev.length && prefix < next.length) {
|
|
70
|
+
const old = prev[prefix].block()
|
|
71
|
+
const incoming = next[prefix]
|
|
72
|
+
if (!sameKind(old, incoming) || old.raw !== incoming.raw) break
|
|
73
|
+
refresh(prev[prefix], incoming)
|
|
74
|
+
prefix++
|
|
75
|
+
}
|
|
76
|
+
// suffix
|
|
77
|
+
let suffix = 0
|
|
78
|
+
while (
|
|
79
|
+
suffix < prev.length - prefix &&
|
|
80
|
+
suffix < next.length - prefix
|
|
81
|
+
) {
|
|
82
|
+
const old = prev[prev.length - 1 - suffix].block()
|
|
83
|
+
const incoming = next[next.length - 1 - suffix]
|
|
84
|
+
if (!sameKind(old, incoming) || old.raw !== incoming.raw) break
|
|
85
|
+
refresh(prev[prev.length - 1 - suffix], incoming)
|
|
86
|
+
suffix++
|
|
87
|
+
}
|
|
88
|
+
const result: Slot[] = []
|
|
89
|
+
// prefix
|
|
90
|
+
for (let i = 0; i < prefix; i++) result.push(prev[i])
|
|
91
|
+
// middle
|
|
92
|
+
if (prev.length - prefix - suffix === 1 && next.length - prefix - suffix === 1) {
|
|
93
|
+
// reuse slot
|
|
94
|
+
const slot = prev[prefix]
|
|
95
|
+
const incoming = next[prefix]
|
|
96
|
+
if (sameKind(slot.block(), incoming)) {
|
|
97
|
+
refresh(slot, incoming)
|
|
98
|
+
result.push(slot)
|
|
99
|
+
} else {
|
|
100
|
+
result.push(createSlot(incoming))
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
for (let i = prefix; i < next.length - suffix; i++) {
|
|
104
|
+
const b = next[i]
|
|
105
|
+
result.push(createSlot(b))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// suffix
|
|
109
|
+
for (let i = prev.length - suffix; i < prev.length; i++) result.push(prev[i])
|
|
110
|
+
return result
|
|
111
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { TextAttributes, TextTableRenderable, type RenderContext, type RGBA, type TextChunk, type TextTableContent } from "@opentui/core"
|
|
2
|
+
import { extend } from "@opentui/solid"
|
|
3
|
+
import { resolveTableOptions, type ResolvedTableOptions } from "../core/tables"
|
|
4
|
+
import type { Block } from "../core/document"
|
|
5
|
+
import { ScrollableTableRenderable } from "./table"
|
|
6
|
+
import { toTableChunks } from "./inline-style"
|
|
7
|
+
import type { HeadingStyles } from "../headings"
|
|
8
|
+
import type { Theme } from "../theme/types"
|
|
9
|
+
|
|
10
|
+
export type DocumentTableOptions = {
|
|
11
|
+
tableData: TextTableContent
|
|
12
|
+
resolved: ResolvedTableOptions
|
|
13
|
+
foreground: RGBA
|
|
14
|
+
background: RGBA
|
|
15
|
+
tableBorderColor: RGBA
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class MarkdownTableRenderable extends ScrollableTableRenderable {
|
|
19
|
+
constructor(ctx: RenderContext, _options?: Partial<DocumentTableOptions> & { id?: string }) {
|
|
20
|
+
const defaults = resolveTableOptions()
|
|
21
|
+
const table = new TextTableRenderable(ctx, {
|
|
22
|
+
content: [],
|
|
23
|
+
columnWidthMode: defaults.widthMode,
|
|
24
|
+
columnFitter: defaults.columnFitter,
|
|
25
|
+
cellPaddingX: defaults.cellPaddingX,
|
|
26
|
+
wrapMode: defaults.wrapMode,
|
|
27
|
+
showBorders: true, border: true, outerBorder: true, borderStyle: "single",
|
|
28
|
+
selectable: true,
|
|
29
|
+
})
|
|
30
|
+
super(ctx, table)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The Solid reconciler stringifies every `content` prop. Use a different
|
|
34
|
+
// public setter so TextChunk[][] reaches the OpenTUI table intact.
|
|
35
|
+
get tableData(): TextTableContent { return this.table.content }
|
|
36
|
+
set tableData(value: TextTableContent) {
|
|
37
|
+
this.table.content = value
|
|
38
|
+
this.syncOverflowState()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get resolved(): ResolvedTableOptions {
|
|
42
|
+
return {
|
|
43
|
+
widthMode: this.table.columnWidthMode,
|
|
44
|
+
columnFitter: this.table.columnFitter,
|
|
45
|
+
cellPaddingX: this.table.cellPaddingX,
|
|
46
|
+
wrapMode: this.table.wrapMode === "none" ? "none" : "word",
|
|
47
|
+
overflow: this.panEnabled ? "scroll" : "wrap",
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
set resolved(value: ResolvedTableOptions) {
|
|
51
|
+
this.table.columnWidthMode = value.widthMode
|
|
52
|
+
this.table.columnFitter = value.columnFitter
|
|
53
|
+
this.table.cellPaddingX = value.cellPaddingX
|
|
54
|
+
this.table.wrapMode = value.wrapMode
|
|
55
|
+
this.panEnabled = value.widthMode === "content" && value.overflow === "scroll"
|
|
56
|
+
this.syncOverflowState()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get foreground(): RGBA { return this.table.fg }
|
|
60
|
+
set foreground(value: RGBA) { this.table.fg = value; this.syncOverflowState() }
|
|
61
|
+
get tableBorderColor(): RGBA { return this.table.borderColor }
|
|
62
|
+
set tableBorderColor(value: RGBA) { this.table.borderColor = value }
|
|
63
|
+
get background(): RGBA { return this.backgroundColor }
|
|
64
|
+
set background(value: RGBA) {
|
|
65
|
+
this.backgroundColor = value
|
|
66
|
+
this.syncOverflowState()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Register once, through the public Solid extension API.
|
|
71
|
+
extend({ md_text_table: MarkdownTableRenderable })
|
|
72
|
+
|
|
73
|
+
declare module "@opentui/solid" {
|
|
74
|
+
interface OpenTUIComponents {
|
|
75
|
+
md_text_table: typeof MarkdownTableRenderable
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function TableBlock(props: {
|
|
80
|
+
block: Extract<Block, { type: "table" }>
|
|
81
|
+
options: ResolvedTableOptions
|
|
82
|
+
theme: Theme
|
|
83
|
+
headings: HeadingStyles
|
|
84
|
+
hyperlinks?: boolean
|
|
85
|
+
}) {
|
|
86
|
+
const cell = (inlines: readonly import("../core/document").Inline[], header: boolean): TextChunk[] => {
|
|
87
|
+
const chunks = toTableChunks(inlines, props.theme, props.headings, props.hyperlinks)
|
|
88
|
+
if (!header) return chunks
|
|
89
|
+
return chunks.map((chunk) => ({ ...chunk, fg: props.theme.markdown.heading, attributes: TextAttributes.BOLD }))
|
|
90
|
+
}
|
|
91
|
+
const content = (): TextTableContent => [
|
|
92
|
+
props.block.header.map((value) => cell(value.children, true)),
|
|
93
|
+
...props.block.rows.map((row) => row.map((value) => cell(value.children, false))),
|
|
94
|
+
]
|
|
95
|
+
return <md_text_table tableData={content()} resolved={props.options}
|
|
96
|
+
foreground={props.theme.markdown.text} background={props.theme.background.base}
|
|
97
|
+
tableBorderColor={props.theme.border.base} />
|
|
98
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { createMemo, For, type Accessor, type JSX } from "solid-js"
|
|
2
|
+
import stringWidth from "string-width"
|
|
3
|
+
import { type TextChunk } from "@opentui/core"
|
|
4
|
+
import { assertNever, type Block, type Document, type ListItem, type UnitInfo } from "../core/document"
|
|
5
|
+
import type { ResolvedTableOptions } from "../core/tables"
|
|
6
|
+
import type { ImagesConfig, MermaidConfig } from "../config/types"
|
|
7
|
+
import type { CanvasGraphics } from "../graphics"
|
|
8
|
+
import { headingLevels, type HeadingStyles } from "../headings"
|
|
9
|
+
import { LocalImage } from "../media"
|
|
10
|
+
import type { Theme } from "../theme/types"
|
|
11
|
+
import { CodeBlock } from "./code"
|
|
12
|
+
import { reconcileSlots, type Slot } from "./document-slots"
|
|
13
|
+
import { TableBlock } from "./document-table"
|
|
14
|
+
import { InlineFlow } from "./inline-flow"
|
|
15
|
+
import { DisplayMath } from "./math"
|
|
16
|
+
import { MermaidDiagramView } from "./mermaid"
|
|
17
|
+
|
|
18
|
+
type ListBlock = Extract<Block, { type: "list" }>
|
|
19
|
+
|
|
20
|
+
export type RenderUnit = (info: Accessor<UnitInfo>, content: JSX.Element) => JSX.Element
|
|
21
|
+
|
|
22
|
+
export type DocumentViewProps = {
|
|
23
|
+
document: Document
|
|
24
|
+
columns: number
|
|
25
|
+
theme: Theme
|
|
26
|
+
headings: HeadingStyles
|
|
27
|
+
graphics: CanvasGraphics
|
|
28
|
+
surface: () => string
|
|
29
|
+
assetBase: () => string | undefined
|
|
30
|
+
imageLimits?: ImagesConfig
|
|
31
|
+
tableOptions: ResolvedTableOptions
|
|
32
|
+
mermaidOptions?: MermaidConfig
|
|
33
|
+
onDiagnostic?: (diagnostic: { level: "warning" | "error"; message: string }) => void
|
|
34
|
+
renderUnit?: RenderUnit
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function listMarker(list: ListBlock, index: number, item: ListItem): string {
|
|
38
|
+
const bullet = list.ordered ? `${list.start + index}.` : "-"
|
|
39
|
+
return item.task ? `${bullet} ${item.checked ? "[x]" : "[ ]"}` : bullet
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function DocumentView(props: DocumentViewProps) {
|
|
43
|
+
let previous: Slot[] = []
|
|
44
|
+
const slots = createMemo(() => {
|
|
45
|
+
previous = reconcileSlots(previous, [...props.document.blocks])
|
|
46
|
+
return previous
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
function flow(
|
|
50
|
+
children: Extract<Block, { type: "paragraph" }>["children"],
|
|
51
|
+
columns: number,
|
|
52
|
+
quote: boolean | undefined,
|
|
53
|
+
prefix?: (index: number) => string,
|
|
54
|
+
prefixColor?: () => Theme["text"]["base"],
|
|
55
|
+
prefixChunks?: (index: number) => TextChunk[],
|
|
56
|
+
) {
|
|
57
|
+
return <InlineFlow inlines={children} columns={columns} theme={props.theme} headings={props.headings}
|
|
58
|
+
graphics={props.graphics} surface={props.surface} quote={quote} prefix={prefix}
|
|
59
|
+
prefixColor={prefixColor} prefixChunks={prefixChunks} />
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function BlockView(input: { block: () => Block; columns: number; quote?: boolean }) {
|
|
63
|
+
// A reused Mermaid slot must retain this component's local last-good state
|
|
64
|
+
// while its fenced source changes. The other variants can remount on edit.
|
|
65
|
+
if (input.block().type === "mermaid") {
|
|
66
|
+
return <MermaidDiagramView source={(input.block() as Extract<Block, { type: "mermaid" }>).content}
|
|
67
|
+
theme={props.theme} columns={input.columns} mermaidOptions={props.mermaidOptions}
|
|
68
|
+
headings={props.headings} onDiagnostic={props.onDiagnostic} />
|
|
69
|
+
}
|
|
70
|
+
const view = createMemo(() => {
|
|
71
|
+
const block = input.block()
|
|
72
|
+
const columns = Math.max(1, input.columns)
|
|
73
|
+
switch (block.type) {
|
|
74
|
+
case "paragraph": return flow(block.children, columns, input.quote)
|
|
75
|
+
case "heading": {
|
|
76
|
+
const level = headingLevels[block.level - 1]
|
|
77
|
+
const style = props.headings[level]
|
|
78
|
+
const children = style.uppercase ? block.children.map((child) => child.type === "text" ? { ...child, text: child.text.toUpperCase() } : child) : block.children
|
|
79
|
+
return <box width="100%" flexDirection="column" flexShrink={0} marginTop={style.marginTop} marginBottom={style.marginBottom}>
|
|
80
|
+
<InlineFlow inlines={children} columns={columns} theme={props.theme} headings={props.headings}
|
|
81
|
+
graphics={props.graphics} surface={props.surface} heading={{ level }}
|
|
82
|
+
prefix={(index) => index === 0 ? `${"#".repeat(level)} ` : " ".repeat(level + 1)} prefixColor={() => props.theme.text.muted} />
|
|
83
|
+
</box>
|
|
84
|
+
}
|
|
85
|
+
case "rule": return <box height={1} width="100%" border={["top"]} borderColor={props.theme.markdown.horizontalRule} />
|
|
86
|
+
case "image": return <LocalImage href={block.href} alt={block.alt} base={props.assetBase}
|
|
87
|
+
columns={() => columns} cell={props.graphics.cell} background={props.surface}
|
|
88
|
+
labelColor={() => props.theme.markdown.image} textColor={() => props.theme.markdown.imageText}
|
|
89
|
+
limits={props.imageLimits} />
|
|
90
|
+
case "display-math": return <DisplayMath content={block.content} open={block.open} close={block.close}
|
|
91
|
+
maxWidth={() => columns} theme={() => props.theme} graphics={props.graphics} surface={props.surface} />
|
|
92
|
+
case "quote": return <box border={columns > 2 ? ["left"] : []} borderColor={props.theme.markdown.blockQuote}
|
|
93
|
+
paddingLeft={columns > 2 ? 1 : 0} width="100%" flexDirection="column">
|
|
94
|
+
<For each={block.blocks}>{(child) => <BlockView block={() => child}
|
|
95
|
+
columns={columns > 2 ? columns - 2 : columns} quote />}</For>
|
|
96
|
+
</box>
|
|
97
|
+
case "list": return <ListBlockView block={() => block} columns={columns} quote={input.quote} wrapItems={false} />
|
|
98
|
+
case "code": return <CodeBlock content={block.content} language={block.language} theme={props.theme} headings={props.headings} />
|
|
99
|
+
case "mermaid": return <MermaidDiagramView source={block.content} theme={props.theme} columns={columns}
|
|
100
|
+
mermaidOptions={props.mermaidOptions} headings={props.headings} onDiagnostic={props.onDiagnostic} />
|
|
101
|
+
case "table": return <TableBlock block={block} options={props.tableOptions} theme={props.theme} headings={props.headings}
|
|
102
|
+
hyperlinks={props.graphics.hyperlinks?.() ?? true} />
|
|
103
|
+
case "literal": return <text fg={props.theme.markdown.text} wrapMode="word" content={block.text} />
|
|
104
|
+
default: return assertNever(block)
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
return <box width="100%" flexDirection="column" flexShrink={0}>{view()}</box>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function ListItemView(input: {
|
|
111
|
+
block: () => ListBlock
|
|
112
|
+
latest: () => ListBlock
|
|
113
|
+
item: () => ListItem
|
|
114
|
+
index: Accessor<number>
|
|
115
|
+
markerWidth: Accessor<number>
|
|
116
|
+
columns: number
|
|
117
|
+
quote?: boolean
|
|
118
|
+
wrapUnit: boolean
|
|
119
|
+
}) {
|
|
120
|
+
const list = input.block
|
|
121
|
+
const item = input.item
|
|
122
|
+
const markerWidth = input.markerWidth
|
|
123
|
+
const hanging = () => markerWidth() + 1
|
|
124
|
+
const marker = () => listMarker(list(), input.index(), item())
|
|
125
|
+
|
|
126
|
+
const content = <box flexDirection="column" width="100%" flexShrink={0}
|
|
127
|
+
marginBottom={list().loose && input.index() < list().items.length - 1 ? 1 : 0}>
|
|
128
|
+
<For each={item().blocks}>{(child, childIndex) => childIndex() === 0 && child.type === "paragraph"
|
|
129
|
+
? flow(child.children, input.columns, input.quote, (row) => row === 0
|
|
130
|
+
? `${marker().padEnd(markerWidth())} ` : " ".repeat(hanging()),
|
|
131
|
+
() => list().ordered ? props.theme.markdown.listEnumeration : props.theme.markdown.listItem,
|
|
132
|
+
item().task ? (row) => row === 0
|
|
133
|
+
? [
|
|
134
|
+
{ __isChunk: true, text: `${list().ordered ? `${list().start + input.index()}.` : "-"} `,
|
|
135
|
+
fg: list().ordered ? props.theme.markdown.listEnumeration : props.theme.markdown.listItem },
|
|
136
|
+
{ __isChunk: true, text: `${item().checked ? "[x]" : "[ ]"}${" ".repeat(markerWidth() - stringWidth(marker()) + 1)}`,
|
|
137
|
+
fg: item().checked ? props.theme.text.feedback.success : props.theme.text.muted },
|
|
138
|
+
] : [{ __isChunk: true, text: " ".repeat(hanging()), fg: props.theme.text.muted }]
|
|
139
|
+
: undefined)
|
|
140
|
+
: childIndex() === 0
|
|
141
|
+
? <box flexDirection="row" width="100%" flexShrink={0}>
|
|
142
|
+
<text width={hanging()} flexShrink={0}
|
|
143
|
+
fg={list().ordered ? props.theme.markdown.listEnumeration : props.theme.markdown.listItem}>
|
|
144
|
+
{`${marker().padEnd(markerWidth())} `}
|
|
145
|
+
</text>
|
|
146
|
+
<BlockView block={() => child} columns={Math.max(1, input.columns - hanging())} quote={input.quote} />
|
|
147
|
+
</box>
|
|
148
|
+
: <box marginLeft={hanging()} width={Math.max(1, input.columns - hanging())} flexDirection="column">
|
|
149
|
+
<BlockView block={() => child} columns={Math.max(1, input.columns - hanging())} quote={input.quote} />
|
|
150
|
+
</box>}
|
|
151
|
+
</For>
|
|
152
|
+
{item().blocks.length === 0 && <text fg={props.theme.markdown.listItem}>{marker()}</text>}
|
|
153
|
+
</box>
|
|
154
|
+
|
|
155
|
+
const info = () => {
|
|
156
|
+
const current = item()
|
|
157
|
+
const source = input.latest().items[input.index()]?.source ?? null
|
|
158
|
+
return { kind: "list-item" as const, item: current.source === source ? current : { ...current, source }, source }
|
|
159
|
+
}
|
|
160
|
+
return <>{input.wrapUnit && props.renderUnit ? props.renderUnit(info, content) : content}</>
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function ListBlockView(input: { block: () => ListBlock; latest?: () => ListBlock; columns: number; quote?: boolean; wrapItems: boolean }) {
|
|
164
|
+
const markerWidth = createMemo(() => {
|
|
165
|
+
const list = input.block()
|
|
166
|
+
return Math.max(1, ...list.items.map((entry, index) => stringWidth(listMarker(list, index, entry))))
|
|
167
|
+
})
|
|
168
|
+
return <box flexDirection="column" width="100%" flexShrink={0}>
|
|
169
|
+
<For each={input.block().items}>{(item, index) => <ListItemView block={input.block}
|
|
170
|
+
latest={input.latest ?? input.block} markerWidth={markerWidth}
|
|
171
|
+
item={() => input.block().items[index()] ?? item} index={index} columns={input.columns}
|
|
172
|
+
quote={input.quote} wrapUnit={input.wrapItems} />}</For>
|
|
173
|
+
</box>
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function TopLevelSlot(input: { slot: Slot; columns: number }) {
|
|
177
|
+
const block = input.slot.block
|
|
178
|
+
if (block().type === "list") {
|
|
179
|
+
return <ListBlockView block={() => block() as ListBlock} latest={() => input.slot.latest() as ListBlock}
|
|
180
|
+
columns={input.columns} wrapItems />
|
|
181
|
+
}
|
|
182
|
+
const info = () => {
|
|
183
|
+
const current = block()
|
|
184
|
+
if (current.type === "list") throw new Error("A top-level list is rendered as list items, not as a block unit")
|
|
185
|
+
const source = input.slot.latest().source ?? null
|
|
186
|
+
return { kind: "block" as const, block: current.source === source ? current : { ...current, source }, source }
|
|
187
|
+
}
|
|
188
|
+
const content = <BlockView block={block} columns={input.columns} />
|
|
189
|
+
return <>{props.renderUnit ? props.renderUnit(info, content) : content}</>
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return <box width="100%" flexDirection="column" flexShrink={0}>
|
|
193
|
+
<For each={slots()}>{(slot) => <TopLevelSlot slot={slot} columns={props.columns} />}</For>
|
|
194
|
+
</box>
|
|
195
|
+
}
|