diff --git a/docs/markdown-renderer.md b/docs/markdown-renderer.md index 418095730a..a4d6a93b5e 100644 --- a/docs/markdown-renderer.md +++ b/docs/markdown-renderer.md @@ -8,7 +8,7 @@ Native selection belongs to the library's text view. Keep an assistant message i Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact. -Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. +Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline. ## Engine constraints diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index 218cdc8884..8791cf40de 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -1,5 +1,8 @@ import { fromMarkdown } from "mdast-util-from-markdown"; import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser"; +import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown"; +import { markInlineImages } from "react-native-enriched-markdown/src/web/utils"; +import type { ASTNode } from "react-native-enriched-markdown/src/web/types"; import { QueryClient } from "@tanstack/react-query"; import { decodeMarkdownImage, getMarkdownImageStatus } from "./image-decoding"; import { describe, expect, it } from "vitest"; @@ -31,6 +34,56 @@ describe("shared Markdown source adaptation", () => { "> - ![Image]()\n\n[picture]: ../a.png", ); }); + it("gives an image alone on its line its own paragraph", () => { + expect( + prepareMarkdownSource("Before the shot:\n![Shot](https://x/a.png)\nAfter it.").render(), + ).toBe("Before the shot:\n\n![Shot](https://x/a.png)\n\nAfter it."); + expect(prepareMarkdownSource("![A](https://x/a.png)\n![B](https://x/b.png)").render()).toBe( + "![A](https://x/a.png)\n\n![B](https://x/b.png)", + ); + expect( + prepareMarkdownSource( + "Line one\\\n[![Linked](https://x/a.png)](https://x)\nLine three", + ).render(), + ).toBe("Line one\n\n[![Linked](https://x/a.png)](https://x)\n\nLine three"); + }); + it("keeps images that share a line with text inline", () => { + const source = "Icon ![i](https://x/i.png) in text\n![a](https://x/a.png) caption\nNext line"; + expect(prepareMarkdownSource(source).render()).toBe(source); + }); + it("splits own-line images inside quotes and list items without ending the container", () => { + expect(prepareMarkdownSource("> Quote text\n> ![Shot](https://x/a.png)\n> More").render()).toBe( + "> Quote text\n>\n> ![Shot](https://x/a.png)\n>\n> More", + ); + expect( + prepareMarkdownSource("- Item text\n ![Shot](https://x/a.png)\n More\n- Next").render(), + ).toBe("- Item text\n\n ![Shot](https://x/a.png)\n\n More\n- Next"); + expect(prepareMarkdownSource("- ![Shot](https://x/a.png)\n Caption line").render()).toBe( + "- ![Shot](https://x/a.png)\n\n Caption line", + ); + }); + it("renders own-line images as blocks through the library parser", async () => { + const source = + "Before:\n![Shot](https://x/a.png)\nIcon ![i](https://x/i.png) in text\n> Quoted\n> ![Q](https://x/q.png)"; + const tree = await parseMarkdown(prepareMarkdownSource(source).render(), { + hardSoftBreaks: true, + latexMath: false, + }); + markInlineImages(tree); + const images: ASTNode[] = []; + const collect = (node: ASTNode) => { + if (node.type === "Image") images.push(node); + node.children?.forEach(collect); + }; + collect(tree); + expect( + images.map((image) => [image.attributes?.url, image.attributes?.isInline === true]), + ).toEqual([ + ["https://x/a.png", false], + ["https://x/i.png", true], + ["https://x/q.png", false], + ]); + }); it("escapes spoiler delimiters only in prose", () => { expect(prepareMarkdownSource("Show ||words|| and `||code||`.").render()).toBe( "Show \\|\\|words\\|\\| and `||code||`.", diff --git a/packages/app/src/components/markdown/source.ts b/packages/app/src/components/markdown/source.ts index fc6f7463a5..9fda25b728 100644 --- a/packages/app/src/components/markdown/source.ts +++ b/packages/app/src/components/markdown/source.ts @@ -5,7 +5,7 @@ import { gfmTableFromMarkdown } from "mdast-util-gfm-table"; import type { AssistantFileLinkSource } from "@/assistant-file-links"; import { decodeString } from "micromark-util-decode-string"; import { fromMarkdown } from "mdast-util-from-markdown"; -import type { Link, Nodes, Root } from "mdast"; +import type { Link, Nodes, Paragraph, PhrasingContent, Root } from "mdast"; // Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex. // The Unicode flag makes Metro expand it to escapes rejected by native engines. @@ -130,10 +130,50 @@ export function prepareMarkdownSource( end, }); } + // Line boundaries already turned into paragraph breaks, by offset of their newline. + const splitBoundaries = new Set(); + function isImageLike(node: PhrasingContent): boolean { + if (node.type === "image" || node.type === "imageReference") return true; + return ( + (node.type === "link" || node.type === "linkReference") && + node.children.length === 1 && + isImageLike(node.children[0]) + ); + } + // An image alone on its line becomes its own paragraph, so every platform renders + // it as a block. The library only treats sole-child paragraph images as blocks; + // with hardSoftBreaks every newline is a break node, which made these inline. + function splitStandaloneImages(paragraph: Paragraph, start: number, end: number) { + for (const child of paragraph.children) { + const childStart = child.position?.start.offset; + const childEnd = child.position?.end.offset; + if (childStart === undefined || childEnd === undefined || !isImageLike(child)) continue; + const lineStart = source.lastIndexOf("\n", childStart - 1) + 1; + const newline = source.indexOf("\n", childEnd); + const lineEnd = newline === -1 ? source.length : newline; + const prefix = source.slice(lineStart, childStart); + const suffix = source.slice(childEnd, lineEnd); + const aloneBefore = childStart === start || /^[\s>]*$/.test(prefix); + const aloneAfter = /^\s*\\?$/.test(suffix); + if (!aloneBefore || !aloneAfter) continue; + // Continuation lines inside quotes and lists keep their quote markers on the blank line. + const blank = prefix.replace(/[^>]/g, " ").trimEnd(); + const split = (boundary: number) => { + if (splitBoundaries.has(boundary)) return; + splitBoundaries.add(boundary); + // A trailing backslash was a hard break; at a paragraph end it would render literally. + const cut = source[boundary - 1] === "\\" ? boundary - 1 : boundary; + edits.push({ start: cut, end: boundary + 1, text: `\n${blank}\n` }); + }; + if (lineStart > start) split(lineStart - 1); + if (lineEnd < end) split(lineEnd); + } + } function visit(node: Nodes) { const start = node.position?.start.offset; const end = node.position?.end.offset; if (start !== undefined && end !== undefined) { + if (node.type === "paragraph") splitStandaloneImages(node, start, end); if (node.type === "link") { const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot); if (title !== null)