diff --git a/packages/app/src/runtime/server/image.test.ts b/packages/app/src/runtime/server/image.test.ts new file mode 100644 index 00000000000..f9f18dfa016 --- /dev/null +++ b/packages/app/src/runtime/server/image.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test" +import { createApiForServer } from "./api" +import { readLocalImage } from "./image" + +function setup( + respond: (init?: RequestInit) => Response | Promise = () => new Response(new Uint8Array([0, 127, 255])), +) { + const requests: Array<{ url: URL; init?: RequestInit }> = [] + const api = createApiForServer({ + server: { url: "https://server.example:4096", username: "image-user", password: "secret" }, + fetch: (async (input: string | URL | Request, init?: RequestInit) => { + requests.push({ url: new URL(input instanceof Request ? input.url : input), init }) + return respond(init) + }) as typeof fetch, + }) + return { api, requests } +} + +describe("readLocalImage", () => { + test.each([ + ["images/screen #1 + 50% ?.PNG", "/workspace/project", "images/screen%20%231%20%2B%2050%25%20%3F.PNG"], + ["C:/tmp/opencode/screen #1.png", "C:/tmp/opencode/", "screen%20%231.png"], + ["d:/screen.png", "d:/", "screen.png"], + ["D:/charts/screen.png", "D:/charts/", "screen.png"], + ["Z:/charts/screen.png", "Z:/charts/", "screen.png"], + ["/tmp/opencode/screen #1.png", "/tmp/opencode/", "screen%20%231.png"], + ["/screen.png", "/", "screen.png"], + ])("reads %s with the correct location and authentication", async (path, directory, encoded) => { + const { api, requests } = setup() + const signal = new AbortController().signal + const blob = await readLocalImage(api, "/workspace/project", path, signal) + + expect(blob).toBeInstanceOf(Blob) + expect(blob?.type).toBe("image/png") + expect(new Uint8Array(await blob!.arrayBuffer())).toEqual(new Uint8Array([0, 127, 255])) + expect(requests).toHaveLength(1) + expect(requests[0].url.origin).toBe("https://server.example:4096") + expect(requests[0].url.pathname).toBe(`/api/fs/read/${encoded}`) + expect([...requests[0].url.searchParams]).toEqual([["location[directory]", directory]]) + expect(requests[0].init?.method).toBe("GET") + expect(new Headers(requests[0].init?.headers).get("authorization")).toBe(`Basic ${btoa("image-user:secret")}`) + expect(requests[0].init?.signal).toBe(signal) + }) + + test("preserves literal percent escapes and encodes the supplied relative location", async () => { + const { api, requests } = setup() + await readLocalImage(api, "C:/project #1 + 50%", "images/literal%20name.png", new AbortController().signal) + + expect(requests[0].url.href).toBe( + "https://server.example:4096/api/fs/read/images/literal%2520name.png?location%5Bdirectory%5D=C%3A%2Fproject+%231+%2B+50%25", + ) + }) + + test.each([ + ["png", "image/png"], + ["jpeg", "image/jpeg"], + ["JPG", "image/jpeg"], + ["gif", "image/gif"], + ["webp", "image/webp"], + ["SVG", "image/svg+xml"], + ["avif", "image/avif"], + ["bmp", "image/bmp"], + ["ico", "image/x-icon"], + ])("uses the MIME type for .%s rather than the response header", async (extension, type) => { + const { api } = setup( + () => new Response("image bytes", { headers: { "content-type": "application/octet-stream" } }), + ) + const blob = await readLocalImage(api, "/repo", `image.${extension}`, new AbortController().signal) + expect(blob?.type).toBe(type) + expect(await blob?.text()).toBe("image bytes") + }) + + test.each([ + "https://example.com/image.png", + "HTTP://example.com/image.png", + "file:///tmp/image.png", + "data:image/png;base64,image.png", + "blob:https://example.com/image.png", + "custom+scheme:image.png", + "C:image.png", + "//server/share/image.png", + "\\\\server\\share\\image.png", + "//?/C:/image.png", + "///server/share/image.png", + "image.txt", + "image.html", + "image.constructor", + "image.png.txt", + "image", + "images.png/image", + "image.png/", + "", + ])("does not request unsupported input %s", async (path) => { + const { api, requests } = setup() + expect(await readLocalImage(api, "/repo", path, new AbortController().signal)).toBeUndefined() + expect(requests).toHaveLength(0) + }) + + test.each([400, 401])("propagates declared API errors (%s)", async (status) => { + const error = { _tag: "RequestError", message: "Cannot read image" } + const { api } = setup(() => Response.json(error, { status })) + await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toEqual(error) + }) + + test.each([404, 500])("does not turn an unexpected HTTP status (%s) into a Blob", async (status) => { + const { api } = setup(() => new Response("Not an image", { status })) + await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject({ + reason: "UnexpectedStatus", + cause: { status }, + }) + }) + + test("propagates transport failures", async () => { + const error = new Error("Connection lost") + const { api } = setup(() => Promise.reject(error)) + await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject({ + reason: "Transport", + cause: error, + }) + }) + + test("forwards cancellation to an in-flight fetch", async () => { + const controller = new AbortController() + const { api, requests } = setup( + (init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }) + }), + ) + const result = readLocalImage(api, "/repo", "image.png", controller.signal) + expect(requests[0].init?.signal).toBe(controller.signal) + controller.abort() + await expect(result).rejects.toMatchObject({ reason: "Transport", cause: controller.signal.reason }) + }) +}) diff --git a/packages/app/src/runtime/server/image.ts b/packages/app/src/runtime/server/image.ts new file mode 100644 index 00000000000..c3046b24b4e --- /dev/null +++ b/packages/app/src/runtime/server/image.ts @@ -0,0 +1,36 @@ +import { getDirectory, getFilename } from "@opencode-ai/util/path" +import type { OpenCodeClient } from "@opencode-ai/client/promise" + +const types = new Map([ + ["png", "image/png"], + ["jpeg", "image/jpeg"], + ["jpg", "image/jpeg"], + ["gif", "image/gif"], + ["webp", "image/webp"], + ["svg", "image/svg+xml"], + ["avif", "image/avif"], + ["bmp", "image/bmp"], + ["ico", "image/x-icon"], +]) + +export async function readLocalImage( + api: Pick, + directory: string, + path: string, + signal: AbortSignal, +): Promise { + const drive = /^[a-z]:\//i.test(path) + if (/^[\\/]{2}/.test(path) || (!drive && /^[a-z][a-z\d+.-]*:/i.test(path))) return + const type = types.get(path.match(/\.([^./]+)$/)?.[1]?.toLowerCase() ?? "") + if (!type) return + const absolute = drive || path.startsWith("/") + // Scope absolute images to their parent, including files outside the project. + const bytes = await api.file.read( + { + path: absolute ? getFilename(path) : path, + location: { directory: absolute ? getDirectory(path) : directory }, + }, + { signal }, + ) + return new Blob([new Uint8Array(bytes)], { type }) +} diff --git a/packages/app/src/shell/routes/session-ui-provider.tsx b/packages/app/src/shell/routes/session-ui-provider.tsx index a1cdcde071a..6ab59b9f89c 100644 --- a/packages/app/src/shell/routes/session-ui-provider.tsx +++ b/packages/app/src/shell/routes/session-ui-provider.tsx @@ -1,4 +1,5 @@ import { DataProvider } from "@opencode-ai/session-ui/context" +import { MarkdownProvider, type ReadMarkdownImage } from "@opencode-ai/session-ui/context/markdown" import { useNavigate, useParams } from "@solidjs/router" import { createMemo, type ParentProps } from "solid-js" import { useProviders } from "@/providers/catalog/providers" @@ -8,6 +9,7 @@ import { sessionHref } from "@/shell/routes/session" import { useData } from "@/runtime/server/current" import { useServerSDK } from "@/runtime/server/client" import { useTabs } from "@/shell/tabs/tabs" +import { readLocalImage } from "@/runtime/server/image" export function SessionUIProvider( props: ParentProps<{ @@ -21,6 +23,10 @@ export function SessionUIProvider( const serverSDK = useServerSDK() const tabs = useTabs() const directory = () => props.directory + const readImage = createMemo(() => { + const dir = directory() + return (path, signal) => readLocalImage(serverSDK.api, dir, path, signal) + }) const href = (sessionID: string) => sessionHref(props.server, sessionID) const navigateToSession = async (sessionID: string) => { const tab = tabs.store.find( @@ -60,7 +66,9 @@ export function SessionUIProvider( onNavigateToSession={navigateToSession} onSessionHref={href} > - {props.children} + + {props.children} + ) } diff --git a/packages/session-ui/component-tests/markdown.fixture.tsx b/packages/session-ui/component-tests/markdown.fixture.tsx index 0b534470efe..ddaa1c4d786 100644 --- a/packages/session-ui/component-tests/markdown.fixture.tsx +++ b/packages/session-ui/component-tests/markdown.fixture.tsx @@ -2,16 +2,28 @@ import { createSignal, Show } from "solid-js" import { render } from "solid-js/web" import { Markdown } from "../src/components/markdown" import { preloadMarkdown } from "../src/components/markdown-cache" +import { MarkdownProvider } from "../src/context/markdown" +import { OpenCode } from "@opencode-ai/client/promise" +import { readLocalImage } from "../../app/src/runtime/server/image" export { sanitizeMarkdown } from "../src/components/markdown-cache" export { renderMermaidSvg } from "../src/components/markdown-mermaid" -export async function mountMarkdown(options: { text: string; streaming?: boolean; cached?: boolean }) { +export async function mountMarkdown(options: { + text: string + streaming?: boolean + cached?: boolean + images?: boolean +}) { if (options.cached) await preloadMarkdown(options.text, "markdown-test") const host = document.createElement("div") host.dataset.testid = "markdown-fixture" document.body.appendChild(host) render(() => { + const api = OpenCode.make({ + baseUrl: location.origin, + headers: { Authorization: `Basic ${btoa("opencode:fixture")}` }, + }) const [text, setText] = createSignal(options.text) const [streaming, setStreaming] = createSignal(options.streaming ?? false) const [visible, setVisible] = createSignal(true) @@ -25,14 +37,20 @@ export async function mountMarkdown(options: { text: string; streaming?: boolean onChange={(event) => setStreaming(event.currentTarget.checked)} /> - - - + + options.images ? readLocalImage(api, "C:/project", path, signal) : Promise.resolve(undefined) + } + > + + + + ) }, host) diff --git a/packages/session-ui/component-tests/markdown.spec.ts b/packages/session-ui/component-tests/markdown.spec.ts index 707f0581c40..1bf4d5eba4b 100644 --- a/packages/session-ui/component-tests/markdown.spec.ts +++ b/packages/session-ui/component-tests/markdown.spec.ts @@ -1,6 +1,11 @@ import { fileURLToPath } from "node:url" import { expect, story } from "../../storybook/playwright/story" +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a4ioAAAAASUVORK5CYII=", + "base64", +) + const fixture = `/@fs/${fileURLToPath(new URL("./markdown.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}` story.beforeEach(async ({ mount }) => { @@ -22,7 +27,7 @@ story("sanitizes raw HTML while preserving supported Markdown markup", async ({ }, fixture) expect(result).toEqual([ "

Safe formatting const x = 1

", - 'unsafe', + 'unsafe', 'externallocal', '
', "x+1", @@ -64,7 +69,7 @@ story("keeps Markdown sanitization and link protections after Mermaid renders", } }, fixture) expect(result.before).toBe( - 'external', + 'external', ) expect(result.renders).toEqual([ { root: "svg", text: expect.stringContaining("Start"), unsafe: 0, links: [], markdown: result.before }, @@ -211,3 +216,155 @@ story("replaces completed DOM before live rendering and retains streamed code co await harness.getByRole("button", { name: "Toggle Markdown" }).click() await expect(markdown).toHaveCount(0) }) + +for (const streaming of [false, true]) { + story( + `loads local images in ${streaming ? "streaming" : "cached"} Markdown and releases their URLs`, + async ({ page }) => { + const requests: string[] = [] + await page.route("**/api/fs/read/**", async (route) => { + expect(route.request().headers().authorization).toBe( + `Basic ${Buffer.from("opencode:fixture").toString("base64")}`, + ) + requests.push(route.request().url()) + await route.fulfill({ contentType: "image/png", body: png }) + }) + await page.evaluate( + async ({ fixture, streaming }) => { + const { mountMarkdown } = await import(fixture) + await mountMarkdown({ + text: "![Chart](C:/tmp/chart%20one.png)\n\n![Again](C:/tmp/chart%20one.png)", + streaming, + cached: !streaming, + images: true, + }) + }, + { fixture, streaming }, + ) + const harness = page.getByTestId("markdown-fixture") + const image = harness.getByRole("img", { name: "Chart", exact: true }) + await expect.poll(() => image.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1) + await expect(harness.getByRole("img", { name: "Again", exact: true })).toHaveAttribute("src", /^blob:/) + expect(requests).toHaveLength(1) + expect(new URL(requests[0]).searchParams.get("location[directory]")).toBe("C:/tmp/") + const url = await image.getAttribute("src") + await harness.getByLabel("Streaming").uncheck() + await expect(harness.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "") + await expect(image).toHaveAttribute("src", url!) + await harness.getByLabel("Markdown text").fill("![Replacement](./images/next.png)") + await expect + .poll(() => + harness.getByRole("img", { name: "Replacement" }).evaluate((image: HTMLImageElement) => image.naturalWidth), + ) + .toBe(1) + expect(requests).toHaveLength(2) + expect( + await page.evaluate( + (url) => + fetch(url!).then( + () => false, + () => true, + ), + url, + ), + ).toBe(true) + const next = await harness.getByRole("img", { name: "Replacement" }).getAttribute("src") + await harness.getByRole("button", { name: "Toggle Markdown" }).click() + await expect(harness.getByRole("img")).toHaveCount(0) + expect( + await page.evaluate( + (url) => + fetch(url!).then( + () => false, + () => true, + ), + next, + ), + ).toBe(true) + }, + ) +} + +story("keeps remote images browser-owned and rejects unsafe image sources", async ({ page }) => { + await page.route("https://images.example/chart.png", (route) => { + expect(route.request().headers().authorization).toBeUndefined() + return route.fulfill({ contentType: "image/png", body: png }) + }) + await page.evaluate(async (fixture) => { + const { mountMarkdown } = await import(fixture) + await mountMarkdown({ + images: true, + text: 'RemoteUnsafe', + }) + }, fixture) + const harness = page.getByTestId("markdown-fixture") + await expect + .poll(() => harness.getByRole("img", { name: "Remote" }).evaluate((image: HTMLImageElement) => image.naturalWidth)) + .toBe(1) + await expect(harness.getByRole("img", { name: "Remote" })).toHaveAttribute("src", "https://images.example/chart.png") + await expect(harness.locator("[onerror], [src^='javascript:'], [data-local-image]")).toHaveCount(0) +}) + +story("keeps scripts and external resources inactive inside local SVG images", async ({ page }) => { + const external: string[] = [] + await page.context().route("https://images.example/**", async (route) => { + external.push(route.request().url()) + await route.abort() + }) + await page.route("**/api/fs/read/chart.svg?*", (route) => + route.fulfill({ + contentType: "image/svg+xml", + body: ` + + + + `, + }), + ) + await page.evaluate(async (fixture) => { + const { mountMarkdown } = await import(fixture) + await mountMarkdown({ images: true, text: "![SVG](Z:/charts/chart.svg)" }) + }, fixture) + const image = page.getByTestId("markdown-fixture").getByRole("img", { name: "SVG" }) + await expect.poll(() => image.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(32) + await expect(image).toHaveAttribute("src", /^data:image\/svg\+xml;/) + await expect(page).not.toHaveTitle("image-script-ran") + expect(external).toEqual([]) + // Opening an image as a document must not give its contents the app's origin. + const preview = await page.context().newPage() + await preview.goto((await image.getAttribute("src"))!) + expect(await preview.evaluate(() => location.origin)).toBe("null") + await preview.close() +}) + +story("loads file URLs and leaves unreadable images as alt text", async ({ page }) => { + const requested = new Set() + await page.route("**/api/fs/read/**", async (route) => { + const url = new URL(route.request().url()) + requested.add(url.pathname) + await route.fulfill( + url.pathname.endsWith("missing.png") + ? { status: 404, body: "Not found" } + : { contentType: "image/png", body: png }, + ) + }) + await page.evaluate(async (fixture) => { + const { mountMarkdown } = await import(fixture) + await mountMarkdown({ + images: true, + text: "![Available](file:///C:/tmp/chart%25.png)\n\n![Unavailable](file:///tmp/missing.png)", + }) + }, fixture) + const harness = page.getByTestId("markdown-fixture") + await expect + .poll(() => + harness + .getByRole("img", { name: "Available", exact: true }) + .evaluate((image: HTMLImageElement) => image.naturalWidth), + ) + .toBe(1) + await expect.poll(() => [...requested].sort()).toEqual(["/api/fs/read/chart%25.png", "/api/fs/read/missing.png"]) + await expect(harness.getByRole("img", { name: "Unavailable" })).not.toHaveAttribute("src") + await harness.getByLabel("Markdown text").fill("Still usable") + await expect(harness.locator('[data-component="markdown"]')).toHaveText("Still usable") +}) diff --git a/packages/session-ui/src/components/markdown-cache.tsx b/packages/session-ui/src/components/markdown-cache.tsx index 9da44b82eee..a376fbd41bb 100644 --- a/packages/session-ui/src/components/markdown-cache.tsx +++ b/packages/session-ui/src/components/markdown-cache.tsx @@ -1,6 +1,7 @@ import { checksum } from "@opencode-ai/util/encode" import DOMPurify from "dompurify" import { parseMarkdown } from "./markdown-worker" +import { localImagePath } from "./markdown-image" export type MarkdownCacheEntry = { raw: string @@ -22,6 +23,16 @@ const config = { } if (typeof window !== "undefined" && purifier.isSupported) { + purifier.addHook("beforeSanitizeAttributes", (node) => { + if (!(node instanceof HTMLImageElement)) return + // Local paths are not browser URLs. Keep them inert until the host reads them. + node.removeAttribute("data-local-image") + const path = localImagePath(node.getAttribute("src") ?? "") + if (!path) return + node.setAttribute("data-local-image", path) + node.removeAttribute("src") + node.removeAttribute("srcset") + }) purifier.addHook("afterSanitizeAttributes", (node: Element) => { if (!(node instanceof HTMLAnchorElement)) return if (node.target !== "_blank") return diff --git a/packages/session-ui/src/components/markdown-image.test.ts b/packages/session-ui/src/components/markdown-image.test.ts new file mode 100644 index 00000000000..0abd48aef46 --- /dev/null +++ b/packages/session-ui/src/components/markdown-image.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test" +import { localImagePath } from "./markdown-image" + +test.each([ + ["C:/tmp/chart.png", "C:/tmp/chart.png"], + ["C:\\tmp\\chart.png", "C:/tmp/chart.png"], + ["D:/charts/chart.png", "D:/charts/chart.png"], + ["Z:\\charts\\chart.png", "Z:/charts/chart.png"], + ["file:///C:/tmp/chart%20one.png", "C:/tmp/chart one.png"], + ["file:///d:/charts/chart%20one.png", "d:/charts/chart one.png"], + ["file:///tmp/chart.png", "/tmp/chart.png"], + ["file://localhost/tmp/chart.png", "/tmp/chart.png"], + ["/tmp/chart.png", "/tmp/chart.png"], + ["./images/chart%20one.svg", "./images/chart one.svg"], + ["chart.png", "chart.png"], + ["./chart%25.png", "./chart%.png"], +])("recognizes local image %s", (source, path) => { + expect(localImagePath(source)).toBe(path) +}) + +test.each([ + "https://example.com/chart.png", + "http://example.com/chart.png", + "//example.com/chart.png", + "\\\\server\\share\\chart.png", + "file://server/share/chart.png", + "data:image/png;base64,AA==", + "blob:https://example.com/image", + "javascript:alert(1)", + "custom:image.png", + "C:chart.png", + "chart%00.png", + "chart%ZZ.png", + "", +])("does not read non-local or invalid source %s", (source) => { + expect(localImagePath(source)).toBeUndefined() +}) diff --git a/packages/session-ui/src/components/markdown-image.ts b/packages/session-ui/src/components/markdown-image.ts new file mode 100644 index 00000000000..1c64483cc44 --- /dev/null +++ b/packages/session-ui/src/components/markdown-image.ts @@ -0,0 +1,75 @@ +import type { ReadMarkdownImage } from "../context/markdown" + +export function localImagePath(source: string) { + const value = source.trim().replaceAll("\\", "/") + if (!value || /[\u0000-\u001f\u007f]/.test(value) || value.startsWith("//")) return + if (/^file:/i.test(value)) { + if (!URL.canParse(value)) return + const url = new URL(value) + if (url.hostname && url.hostname !== "localhost") return + return decodePath(url.pathname.replace(/^\/([a-z]:\/)/i, "$1")) + } + if (/^[a-z][a-z\d+.-]*:/i.test(value) && !/^[a-z]:\//i.test(value)) return + return decodePath(value) +} + +function decodePath(value: string) { + try { + const path = decodeURIComponent(value) + if (/[\u0000-\u001f\u007f]/.test(path) || path.startsWith("//") || path.startsWith("\\\\")) return + return path + } catch { + return + } +} + +export function createMarkdownImages(read: ReadMarkdownImage) { + const entries = new Map; url?: string }>() + return { + update(root: HTMLElement) { + const images = Array.from(root.querySelectorAll("img[data-local-image]")) + const paths = new Set(images.map((image) => image.dataset.localImage)) + entries.forEach((entry, path) => { + if (paths.has(path)) return + entry.controller.abort() + if (entry.url) URL.revokeObjectURL(entry.url) + entries.delete(path) + }) + images.forEach((image) => { + const path = image.dataset.localImage + if (!path) return + const existing = entries.get(path) + const entry = existing ?? { + controller: new AbortController(), + result: Promise.resolve(undefined), + url: undefined as string | undefined, + } + if (!existing) { + entries.set(path, entry) + entry.result = read(path, entry.controller.signal) + .then(async (blob) => { + if (!blob || entry.controller.signal.aborted) return + // SVG documents must not inherit the app origin if opened outside the image element. + if (blob.type === "image/svg+xml") + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(await blob.text())}` + entry.url = URL.createObjectURL(blob) + return entry.url + }) + .catch(() => undefined) + } + void entry.result.then((url) => { + if (!url || entry.controller.signal.aborted || !root.contains(image) || image.dataset.localImage !== path) + return + image.src = url + }) + }) + }, + dispose() { + entries.forEach((entry) => { + entry.controller.abort() + if (entry.url) URL.revokeObjectURL(entry.url) + }) + entries.clear() + }, + } +} diff --git a/packages/session-ui/src/components/markdown.tsx b/packages/session-ui/src/components/markdown.tsx index bdc71ff442e..7c3c160c010 100644 --- a/packages/session-ui/src/components/markdown.tsx +++ b/packages/session-ui/src/components/markdown.tsx @@ -32,6 +32,8 @@ import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type Markdown import { inlineCodeKind } from "./markdown-inline-code-kind" import { renderMermaidSvg } from "./markdown-mermaid" import { createMarkdownRenderer } from "./markdown-solid" +import { useMarkdown, type ReadMarkdownImage } from "../context/markdown" +import { createMarkdownImages } from "./markdown-image" type RenderedBlock = | (MarkdownCacheEntry & { key: string; mode: Exclude }) @@ -385,6 +387,7 @@ export function Markdown( ) { const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "deferUntilReady", "class", "classList"]) const i18n = useI18n() + const markdown = useMarkdown() const [root, setRoot] = createSignal() const owner = createUniqueId() const activeCodeKeys = new Set() @@ -507,6 +510,8 @@ export function Markdown( ) let copyCleanup: (() => void) | undefined + let readImage: ReadMarkdownImage | undefined + let images: ReturnType | undefined createEffect(() => { const container = root() @@ -515,11 +520,17 @@ export function Markdown( const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner, local.deferUntilReady) : [] if (!container) return if (isServer) return + if (readImage !== markdown?.readImage) { + images?.dispose() + readImage = markdown?.readImage + images = readImage ? createMarkdownImages(readImage) : undefined + } delete container.dataset.markdownReady if (content.length === 0) { disposeCopyButtons(container) disposeRenderedMarkdown(container) container.innerHTML = "" + images?.update(container) if (result?.ready && result.text === local.text) container.dataset.markdownReady = "" return } @@ -542,6 +553,7 @@ export function Markdown( disposeRenderedMarkdown(child) child.remove() } + images?.update(container) container .querySelectorAll('[data-slot="markdown-copy-button"]') .forEach((button) => setCopyState(button, labels, button.dataset.copied === "true")) @@ -554,6 +566,7 @@ export function Markdown( }) onCleanup(() => { + images?.dispose() if (copyCleanup) copyCleanup() const container = root() if (container) disposeRenderedMarkdown(container) diff --git a/packages/session-ui/src/context/markdown.tsx b/packages/session-ui/src/context/markdown.tsx new file mode 100644 index 00000000000..a8d5edcd451 --- /dev/null +++ b/packages/session-ui/src/context/markdown.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext, type ParentProps } from "solid-js" + +export type ReadMarkdownImage = (path: string, signal: AbortSignal) => Promise + +const context = createContext<{ readonly readImage: ReadMarkdownImage }>() + +export function MarkdownProvider(props: ParentProps<{ readImage: ReadMarkdownImage }>) { + return ( + + {props.children} + + ) +} + +export const useMarkdown = () => useContext(context)