opentui-markdown-render 0.2.2 → 0.2.3
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/package.json +1 -1
- package/src/app.tsx +7 -7
- package/src/media.tsx +36 -21
- package/src/view/code.tsx +9 -2
- package/src/view/document.tsx +1 -1
- package/src/view/inline-flow.tsx +23 -13
package/package.json
CHANGED
package/src/app.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { CliRenderer
|
|
2
|
-
import { createMemo, createSignal, onCleanup } from "solid-js"
|
|
1
|
+
import type { CliRenderer } from "@opentui/core"
|
|
2
|
+
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
|
3
3
|
import { useCanvasGraphics } from "./graphics"
|
|
4
4
|
import { resolveHeadingStyles } from "./headings"
|
|
5
5
|
import { rgbaToHex } from "./math"
|
|
@@ -155,14 +155,14 @@ export function MarkdownRenderer(props: MarkdownRendererProps) {
|
|
|
155
155
|
// The look of the heading levels is a configuration of its own: the colours
|
|
156
156
|
// come from the theme, the attributes and the spacing from the config file.
|
|
157
157
|
const headings = createMemo(() => resolveHeadingStyles(config().headings))
|
|
158
|
-
let currentSyntax: SyntaxStyle | undefined
|
|
159
158
|
const syntaxStyle = createMemo(() => {
|
|
160
|
-
currentSyntax?.destroy()
|
|
161
159
|
const nativeScene = (renderer as CliRenderer & { readonly nativeScene?: object }).nativeScene
|
|
162
|
-
|
|
163
|
-
|
|
160
|
+
return generateSyntax(theme(), headings(), theme().markdown.codeBlock, nativeScene)
|
|
161
|
+
})
|
|
162
|
+
createEffect(() => {
|
|
163
|
+
const style = syntaxStyle()
|
|
164
|
+
onCleanup(() => void renderer.idle().then(() => style.destroy()))
|
|
164
165
|
})
|
|
165
|
-
onCleanup(() => currentSyntax?.destroy())
|
|
166
166
|
return (
|
|
167
167
|
<box
|
|
168
168
|
width="100%"
|
package/src/media.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { NativeImage, imageInfo, parseColor, type ColorInput, type RGBA } from "@opentui/core"
|
|
2
|
-
import {
|
|
2
|
+
import { useRenderer } from "@opentui/solid"
|
|
3
|
+
import { decompressFrame, parseGIF, type ParsedGif } from "gifuct-js"
|
|
3
4
|
import path from "node:path"
|
|
4
5
|
import { createEffect, createResource, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
|
5
6
|
|
|
@@ -44,8 +45,16 @@ export function rgbToHex(color: Rgb) {
|
|
|
44
45
|
return `#${channel(color[0])}${channel(color[1])}${channel(color[2])}`
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
type
|
|
48
|
-
type
|
|
48
|
+
type GifFrame = Parameters<typeof decompressFrame>[0]
|
|
49
|
+
type GifAnimation = {
|
|
50
|
+
readonly width: number
|
|
51
|
+
readonly height: number
|
|
52
|
+
readonly frames: readonly GifFrame[]
|
|
53
|
+
readonly colorTable: ParsedGif["gct"]
|
|
54
|
+
readonly target: { width: number; height: number }
|
|
55
|
+
readonly background: Rgb
|
|
56
|
+
}
|
|
57
|
+
type Media = { readonly box: { columns: number; rows: number }; readonly image?: NativeImage; readonly animation?: GifAnimation }
|
|
49
58
|
|
|
50
59
|
export { standaloneImage, type MarkdownImage } from "./core/image"
|
|
51
60
|
|
|
@@ -88,6 +97,7 @@ export function LocalImage(props: {
|
|
|
88
97
|
readonly textColor?: () => RGBA
|
|
89
98
|
readonly limits?: ImageLimits
|
|
90
99
|
}) {
|
|
100
|
+
const renderer = useRenderer()
|
|
91
101
|
const [failure, setFailure] = createSignal<string>()
|
|
92
102
|
// The cell size is part of the key so the image is reloaded and re-fitted once
|
|
93
103
|
// the terminal reports its pixel resolution (kitty queries it after startup).
|
|
@@ -145,26 +155,40 @@ export function LocalImage(props: {
|
|
|
145
155
|
|
|
146
156
|
createEffect(() => {
|
|
147
157
|
const value = media()
|
|
148
|
-
if (!value?.
|
|
158
|
+
if (!value?.animation) {
|
|
149
159
|
setFrame(value?.image)
|
|
150
160
|
return
|
|
151
161
|
}
|
|
162
|
+
const animation = value.animation
|
|
163
|
+
const canvas = new Uint8Array(animation.width * animation.height * 4)
|
|
152
164
|
let index = 0
|
|
165
|
+
let current: NativeImage | undefined
|
|
153
166
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
154
167
|
const next = () => {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
168
|
+
const decoded = decompressFrame(animation.frames[index]!, animation.colorTable, true)
|
|
169
|
+
const before = decoded.disposalType === 3 ? canvas.slice() : undefined
|
|
170
|
+
drawPatch(canvas, animation.width, animation.height, decoded.patch, decoded.dims.left, decoded.dims.top, decoded.dims.width, decoded.dims.height)
|
|
171
|
+
const image = prepareImage(NativeImage.fromRgba(canvas.slice(), animation.width, animation.height), animation.target, animation.background)
|
|
172
|
+
const previous = current
|
|
173
|
+
current = image
|
|
174
|
+
setFrame(image)
|
|
175
|
+
if (previous) void renderer.idle().then(() => previous.dispose())
|
|
176
|
+
if (decoded.disposalType === 2) clearPatch(canvas, animation.width, decoded.dims.left, decoded.dims.top, decoded.dims.width, decoded.dims.height)
|
|
177
|
+
if (before) canvas.set(before)
|
|
178
|
+
index = (index + 1) % animation.frames.length
|
|
179
|
+
timer = setTimeout(next, Math.max(50, decoded.delay || 100))
|
|
159
180
|
}
|
|
160
181
|
next()
|
|
161
182
|
onCleanup(() => {
|
|
162
183
|
if (timer) clearTimeout(timer)
|
|
184
|
+
const image = current
|
|
185
|
+
if (image) void renderer.idle().then(() => image.dispose())
|
|
186
|
+
setFrame()
|
|
163
187
|
})
|
|
164
188
|
})
|
|
165
189
|
createEffect(() => {
|
|
166
190
|
const value = media()
|
|
167
|
-
onCleanup(() => disposeMedia(value))
|
|
191
|
+
onCleanup(() => void renderer.idle().then(() => disposeMedia(value)))
|
|
168
192
|
})
|
|
169
193
|
|
|
170
194
|
return (
|
|
@@ -197,21 +221,13 @@ export function LocalImage(props: {
|
|
|
197
221
|
)
|
|
198
222
|
}
|
|
199
223
|
|
|
200
|
-
function loadGif(bytes: Uint8Array, box: { columns: number; rows: number }, target: { width: number; height: number }, background: Rgb) {
|
|
224
|
+
export function loadGif(bytes: Uint8Array, box: { columns: number; rows: number }, target: { width: number; height: number }, background: Rgb) {
|
|
201
225
|
const gif = parseGIF(bytes.slice().buffer as ArrayBuffer)
|
|
202
226
|
const width = gif.lsd.width
|
|
203
227
|
const height = gif.lsd.height
|
|
204
|
-
const
|
|
205
|
-
const frames = decompressFrames(gif, true).map((frame) => {
|
|
206
|
-
const before = canvas.slice()
|
|
207
|
-
drawPatch(canvas, width, height, frame.patch, frame.dims.left, frame.dims.top, frame.dims.width, frame.dims.height)
|
|
208
|
-
const image = prepareImage(NativeImage.fromRgba(canvas.slice(), width, height), target, background)
|
|
209
|
-
if (frame.disposalType === 2) clearPatch(canvas, width, frame.dims.left, frame.dims.top, frame.dims.width, frame.dims.height)
|
|
210
|
-
if (frame.disposalType === 3) canvas.set(before)
|
|
211
|
-
return { image, delay: Math.max(50, frame.delay || 100) }
|
|
212
|
-
})
|
|
228
|
+
const frames = gif.frames.filter((frame): frame is GifFrame => "image" in frame)
|
|
213
229
|
if (!frames.length) throw new Error("GIF contains no frames")
|
|
214
|
-
return { box, frames }
|
|
230
|
+
return { box, animation: { width, height, frames, colorTable: gif.gct, target, background } }
|
|
215
231
|
}
|
|
216
232
|
|
|
217
233
|
function drawPatch(canvas: Uint8Array, width: number, height: number, patch: Uint8ClampedArray, left: number, top: number, patchWidth: number, patchHeight: number) {
|
|
@@ -263,5 +279,4 @@ function prepareImage(image: NativeImage, target: { width: number; height: numbe
|
|
|
263
279
|
|
|
264
280
|
function disposeMedia(media: Media | undefined) {
|
|
265
281
|
media?.image?.dispose()
|
|
266
|
-
media?.frames?.forEach((frame) => frame.image.dispose())
|
|
267
282
|
}
|
package/src/view/code.tsx
CHANGED
|
@@ -13,15 +13,22 @@ export type CodeBlockProps = {
|
|
|
13
13
|
syntaxStyle?: SyntaxStyle
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export function resolveCodeFiletype(language: string | null) {
|
|
17
|
+
const token = language?.trim().split(/\s+/, 1)[0]?.toLowerCase() ?? ""
|
|
18
|
+
return token === "jsonc" ? "json" : infoStringToFiletype(language ?? "")
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
export function CodeBlock(props: CodeBlockProps) {
|
|
17
22
|
const renderer = useRenderer()
|
|
18
23
|
const nativeScene = (renderer as typeof renderer & { readonly nativeScene?: object }).nativeScene
|
|
19
24
|
const owned = props.syntaxStyle ? undefined : generateSyntax(props.theme, props.headings, props.theme.markdown.codeBlock, nativeScene)
|
|
20
|
-
onCleanup(() =>
|
|
25
|
+
onCleanup(() => {
|
|
26
|
+
if (owned) void renderer.idle().then(() => owned.destroy())
|
|
27
|
+
})
|
|
21
28
|
return (
|
|
22
29
|
<code
|
|
23
30
|
content={props.content}
|
|
24
|
-
filetype={
|
|
31
|
+
filetype={resolveCodeFiletype(props.language)}
|
|
25
32
|
syntaxStyle={props.syntaxStyle ?? owned!}
|
|
26
33
|
fg={props.theme.markdown.codeBlock}
|
|
27
34
|
bg={props.theme.background.base}
|
package/src/view/document.tsx
CHANGED
|
@@ -67,7 +67,7 @@ export function DocumentView(props: DocumentViewProps) {
|
|
|
67
67
|
if (input.block().type === "mermaid") {
|
|
68
68
|
return <MermaidDiagramView source={(input.block() as Extract<Block, { type: "mermaid" }>).content}
|
|
69
69
|
theme={props.theme} columns={input.columns} mermaidOptions={props.mermaidOptions}
|
|
70
|
-
headings={props.headings} onDiagnostic={props.onDiagnostic} />
|
|
70
|
+
headings={props.headings} syntaxStyle={props.syntaxStyle} onDiagnostic={props.onDiagnostic} />
|
|
71
71
|
}
|
|
72
72
|
const view = createMemo(() => {
|
|
73
73
|
const block = input.block()
|
package/src/view/inline-flow.tsx
CHANGED
|
@@ -25,24 +25,34 @@ export type InlineFlowProps = {
|
|
|
25
25
|
|
|
26
26
|
function PrefixSegment(props: { chunks: () => TextChunk[]; width: number }) {
|
|
27
27
|
let ref: TextRenderable | undefined
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
const update = () => {
|
|
29
|
+
const content = new StyledText(props.chunks())
|
|
30
|
+
if (ref) ref.content = content
|
|
31
|
+
return content
|
|
32
|
+
}
|
|
33
|
+
createEffect(update)
|
|
34
|
+
return <text ref={(renderable) => {
|
|
35
|
+
ref = renderable
|
|
36
|
+
renderable.content = update()
|
|
37
|
+
}} wrapMode="none" width={props.width} flexShrink={0} />
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
function StyledSegment(props: { entry: LayoutRun; textRow: number }) {
|
|
35
41
|
let ref: TextRenderable | undefined
|
|
36
|
-
|
|
42
|
+
const update = () => {
|
|
37
43
|
const atom = props.entry.atom
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
44
|
+
const content = atom.type === "text" ? new StyledText([{
|
|
45
|
+
__isChunk: true, text: atom.text, fg: atom.fg, attributes: atom.attributes,
|
|
46
|
+
...(atom.href ? { link: { url: atom.href } } : {}),
|
|
47
|
+
}]) : new StyledText([])
|
|
48
|
+
if (ref) ref.content = content
|
|
49
|
+
return content
|
|
50
|
+
}
|
|
51
|
+
createEffect(update)
|
|
52
|
+
return <text ref={(renderable) => {
|
|
53
|
+
ref = renderable
|
|
54
|
+
renderable.content = update()
|
|
55
|
+
}} width={props.entry.columns} flexShrink={0} wrapMode="none" marginTop={props.textRow} />
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
export function InlineFlow(props: InlineFlowProps) {
|