mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 13:42:50 +00:00
fix(markdown): restore diagrams and preserve link origin
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
closeDiagramFullscreen,
|
||||
closeDiagramFullscreenFromOutside,
|
||||
expectCompletedDiagram,
|
||||
expectFullscreenDiagram,
|
||||
expectFullscreenDiagramClosed,
|
||||
openDiagramFullscreen,
|
||||
requestDiagram,
|
||||
waitForDiagramTurnToComplete,
|
||||
zoomAndPanFullscreenDiagram,
|
||||
} from "../support/helpers/diagram";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const MERMAID_DIAGRAM = [
|
||||
"```mermaid",
|
||||
"flowchart LR",
|
||||
" Start --> Middle",
|
||||
" Middle --> Review",
|
||||
" Review --> Ship",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
test("opens a Mermaid diagram fullscreen and closes it again", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "mermaid-fullscreen-",
|
||||
title: "Fullscreen Mermaid",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: MERMAID_DIAGRAM,
|
||||
mockStreamingAssistantIntervalMs: 10,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await test.step("Open the conversation and wait for the rendered diagram", async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await requestDiagram(agent);
|
||||
await waitForDiagramTurnToComplete(agent);
|
||||
await expectCompletedDiagram(page, ["Start", "Ship"]);
|
||||
});
|
||||
|
||||
await test.step("The toolbar opens the diagram fullscreen", async () => {
|
||||
await openDiagramFullscreen(page);
|
||||
await expectFullscreenDiagram(page, ["Start", "Review", "Ship"]);
|
||||
});
|
||||
|
||||
await test.step("The fullscreen diagram zooms with the wheel and pans by dragging", async () => {
|
||||
await zoomAndPanFullscreenDiagram(page);
|
||||
});
|
||||
|
||||
await test.step("The close action returns to the conversation", async () => {
|
||||
await closeDiagramFullscreen(page);
|
||||
await expectFullscreenDiagramClosed(page);
|
||||
await expectCompletedDiagram(page, ["Start", "Ship"]);
|
||||
});
|
||||
|
||||
await test.step("Escape closes the fullscreen diagram", async () => {
|
||||
await openDiagramFullscreen(page);
|
||||
await expectFullscreenDiagram(page, ["Start", "Ship"]);
|
||||
await page.keyboard.press("Escape");
|
||||
await expectFullscreenDiagramClosed(page);
|
||||
});
|
||||
|
||||
await test.step("Clicking outside the diagram closes the fullscreen viewer", async () => {
|
||||
await openDiagramFullscreen(page);
|
||||
await expectFullscreenDiagram(page, ["Start", "Ship"]);
|
||||
await closeDiagramFullscreenFromOutside(page);
|
||||
await expectFullscreenDiagramClosed(page);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
expectCompletedDiagram,
|
||||
expectDiagramRemainsRenderedWhileStreaming,
|
||||
expectDiagramWithLabels,
|
||||
reloadConversation,
|
||||
requestDiagram,
|
||||
waitForDiagramTurnToComplete,
|
||||
} from "../support/helpers/diagram";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const STREAMED_MERMAID = [
|
||||
"```mermaid",
|
||||
"flowchart LR",
|
||||
" Start --> Middle",
|
||||
' Middle --> Done["<i>Done</i>"]',
|
||||
" Middle --> Review",
|
||||
" Review --> Verify",
|
||||
" Verify --> Ship",
|
||||
" Ship --> Package",
|
||||
" Package --> Sign",
|
||||
" Sign --> Upload",
|
||||
" Upload --> Publish",
|
||||
" Publish --> Deploy",
|
||||
" Deploy --> Observe",
|
||||
" Observe --> Validate",
|
||||
" Validate --> Announce",
|
||||
" Announce --> Document",
|
||||
" Document --> Archive",
|
||||
" Archive --> Release",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
test("keeps a Mermaid diagram rendered while its message streams, completes, and reloads", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
let diagramTurnCompleted!: Promise<void>;
|
||||
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "mermaid-streaming-",
|
||||
title: "Streaming Mermaid",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: STREAMED_MERMAID,
|
||||
mockStreamingAssistantIntervalMs: 150,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await test.step("Open the conversation and request a diagram", async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await requestDiagram(agent);
|
||||
diagramTurnCompleted = waitForDiagramTurnToComplete(agent);
|
||||
});
|
||||
|
||||
await test.step("The diagram renders and stays rendered while tokens arrive", async () => {
|
||||
await expectDiagramWithLabels(page, ["Start", "Middle"]);
|
||||
await expectDiagramRemainsRenderedWhileStreaming(page, diagramTurnCompleted);
|
||||
});
|
||||
|
||||
await test.step("The completed diagram shows the final streamed content", async () => {
|
||||
await diagramTurnCompleted;
|
||||
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
|
||||
});
|
||||
|
||||
await test.step("The completed diagram remains rendered after reload", async () => {
|
||||
await reloadConversation(page);
|
||||
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -35,7 +35,7 @@ test("chat and file preview share the library renderer and resolve file links",
|
||||
}, testInfo) => {
|
||||
const { writeFile, mkdir } = await import("node:fs/promises");
|
||||
const source =
|
||||
"First paragraph.\n\nSecond paragraph.\n\n```ts\nconst answer = 42;\n```\n\n> A quote\n\n| Key || Value |\n| --- | --- | --- |\n| Answer || 42 |\n\nOpen `README.md` or `src/说明.md:2-3`.";
|
||||
"First paragraph.\n\nSecond paragraph.\n\n```ts\nconst answer = 42;\n```\n\n> A quote\n\n| Key || Value |\n| --- | --- | --- |\n| Answer || 42 |\n\nOpen `README.md` or `src/说明.md:2-3`.\n\n[Space file](my%20file.ts) and `my%20file.ts`.";
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "shared-markdown-",
|
||||
title: "Shared Markdown",
|
||||
@@ -44,6 +44,11 @@ test("chat and file preview share the library renderer and resolve file links",
|
||||
});
|
||||
try {
|
||||
await writeFile(`${session.cwd}/README.md`, source);
|
||||
await writeFile(`${session.cwd}/my file.ts`, "Explicit link opened the space filename.");
|
||||
await writeFile(
|
||||
`${session.cwd}/my%20file.ts`,
|
||||
"Inline code opened the literal percent filename.",
|
||||
);
|
||||
await mkdir(`${session.cwd}/packages/app/src`, { recursive: true });
|
||||
await writeFile(
|
||||
`${session.cwd}/packages/app/src/说明.md`,
|
||||
@@ -67,6 +72,16 @@ test("chat and file preview share the library renderer and resolve file links",
|
||||
await page.getByRole("button", { name: "Shared Markdown", exact: true }).click();
|
||||
await message.getByRole("link", { name: "src/说明.md:2-3" }).click();
|
||||
await expect(page.getByText("Unicode suffix lookup succeeded.", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Shared Markdown", exact: true }).click();
|
||||
await message.getByRole("link", { name: "Space file", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText("Explicit link opened the space filename.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Shared Markdown", exact: true }).click();
|
||||
await message.getByRole("link", { name: "my%20file.ts", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText("Inline code opened the literal percent filename.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await testInfo.attach("shared-renderer", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { MockAgentWorkspace } from "./mock-agent";
|
||||
|
||||
const DIAGRAM_NAME = "Diagram";
|
||||
|
||||
function renderedDiagram(page: Page) {
|
||||
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
|
||||
const svg = diagram.locator("iframe").contentFrame().locator("#diagram svg");
|
||||
return { diagram, svg };
|
||||
}
|
||||
|
||||
export async function requestDiagram(agent: MockAgentWorkspace): Promise<void> {
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Render the requested Mermaid diagram.");
|
||||
}
|
||||
|
||||
export async function expectDiagramWithLabels(
|
||||
page: Page,
|
||||
labels: readonly string[],
|
||||
): Promise<void> {
|
||||
const { diagram, svg } = renderedDiagram(page);
|
||||
await expect(diagram).toBeVisible({ timeout: 30_000 });
|
||||
await expect(svg).toBeVisible({ timeout: 30_000 });
|
||||
for (const label of labels) {
|
||||
await expect(svg).toContainText(label);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectDiagramRemainsRenderedWhileStreaming(
|
||||
page: Page,
|
||||
completion: Promise<void>,
|
||||
): Promise<void> {
|
||||
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
|
||||
let turnCompleted = false;
|
||||
const completed = completion.then(() => {
|
||||
turnCompleted = true;
|
||||
return true;
|
||||
});
|
||||
for (;;) {
|
||||
expect(await diagram.isVisible()).toBe(true);
|
||||
if (turnCompleted) return completion;
|
||||
const box = await diagram.boundingBox();
|
||||
if (turnCompleted) return completion;
|
||||
expect(box, "the inline diagram should have measurable layout").not.toBeNull();
|
||||
expect(
|
||||
box?.height,
|
||||
"the inline diagram should remain tall enough to read",
|
||||
).toBeGreaterThanOrEqual(56);
|
||||
const didComplete = await Promise.race([completed, page.waitForTimeout(16).then(() => false)]);
|
||||
if (didComplete) return completion;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForDiagramTurnToComplete(agent: MockAgentWorkspace): Promise<void> {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
}
|
||||
|
||||
export async function expectCompletedDiagram(page: Page, labels: readonly string[]): Promise<void> {
|
||||
await expectDiagramWithLabels(page, labels);
|
||||
}
|
||||
|
||||
export async function reloadConversation(page: Page): Promise<void> {
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
}
|
||||
|
||||
const FULLSCREEN_VIEWPORT = "mermaid-fullscreen-viewport";
|
||||
|
||||
function diagramSvg(page: Page, viewportTestId: string) {
|
||||
return page
|
||||
.getByTestId(`${viewportTestId}-canvas`)
|
||||
.last()
|
||||
.locator("iframe")
|
||||
.contentFrame()
|
||||
.locator("#diagram svg");
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewport toolbar only reveals itself on hover, so the diagram is hovered first. The
|
||||
* pointer is parked elsewhere beforehand so the hover transition fires even when a previous
|
||||
* step left the cursor inside the viewport.
|
||||
*/
|
||||
export async function openDiagramFullscreen(page: Page): Promise<void> {
|
||||
await page.mouse.move(0, 0);
|
||||
await page.getByTestId("mermaid-viewport-canvas").last().hover();
|
||||
await page.getByTestId("mermaid-fullscreen").last().click();
|
||||
}
|
||||
|
||||
export async function closeDiagramFullscreen(page: Page): Promise<void> {
|
||||
await page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`).hover();
|
||||
await page.getByTestId("mermaid-fullscreen-close").click();
|
||||
}
|
||||
|
||||
export async function zoomAndPanFullscreenDiagram(page: Page): Promise<void> {
|
||||
const canvas = page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`);
|
||||
const svg = diagramSvg(page, FULLSCREEN_VIEWPORT);
|
||||
const initial = await svg.boundingBox();
|
||||
expect(initial, "the fullscreen diagram should have measurable layout").not.toBeNull();
|
||||
|
||||
await page.mouse.move(initial!.x + initial!.width / 2, initial!.y + initial!.height / 2);
|
||||
await page.mouse.wheel(0, -100);
|
||||
|
||||
const zoomed = await svg.boundingBox();
|
||||
expect(zoomed, "the zoomed diagram should have measurable layout").not.toBeNull();
|
||||
expect(zoomed!.width).toBeGreaterThan(initial!.width * 1.1);
|
||||
|
||||
const viewport = await canvas.boundingBox();
|
||||
expect(viewport, "the fullscreen viewport should have measurable layout").not.toBeNull();
|
||||
await page.mouse.move(viewport!.x + viewport!.width / 2, viewport!.y + viewport!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
viewport!.x + viewport!.width / 2 - 120,
|
||||
viewport!.y + viewport!.height / 2,
|
||||
);
|
||||
await page.mouse.up();
|
||||
|
||||
const panned = await svg.boundingBox();
|
||||
expect(panned, "the panned diagram should have measurable layout").not.toBeNull();
|
||||
expect(Math.abs(panned!.x - zoomed!.x)).toBeGreaterThan(100);
|
||||
}
|
||||
|
||||
export async function closeDiagramFullscreenFromOutside(page: Page): Promise<void> {
|
||||
const canvas = page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`);
|
||||
const viewport = await canvas.boundingBox();
|
||||
const svg = await diagramSvg(page, FULLSCREEN_VIEWPORT).boundingBox();
|
||||
expect(viewport, "the fullscreen viewport should have measurable layout").not.toBeNull();
|
||||
expect(svg, "the fullscreen diagram should have measurable layout").not.toBeNull();
|
||||
|
||||
const x = viewport!.x + viewport!.width / 2;
|
||||
const y =
|
||||
svg!.y + svg!.height < viewport!.y + viewport!.height - 8
|
||||
? viewport!.y + viewport!.height - 4
|
||||
: viewport!.y + 4;
|
||||
await page.mouse.click(x, y);
|
||||
}
|
||||
|
||||
export async function expectFullscreenDiagram(
|
||||
page: Page,
|
||||
labels: readonly string[],
|
||||
): Promise<void> {
|
||||
const viewport = page.getByTestId(FULLSCREEN_VIEWPORT);
|
||||
await expect(viewport).toBeVisible({ timeout: 30_000 });
|
||||
const svg = diagramSvg(page, FULLSCREEN_VIEWPORT);
|
||||
await expect(svg).toBeVisible({ timeout: 30_000 });
|
||||
for (const label of labels) {
|
||||
await expect(svg).toContainText(label);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectFullscreenDiagramClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId(FULLSCREEN_VIEWPORT)).toHaveCount(0);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ export {
|
||||
classifyAssistantFileLink,
|
||||
normalizeInlinePathTarget,
|
||||
type InlinePathTarget,
|
||||
} from "./paths";
|
||||
} from "./parse";
|
||||
export {
|
||||
AssistantFileLinkResolverProvider,
|
||||
useAssistantFileLinkEnvironment,
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
||||
parseAssistantFileLink,
|
||||
parseFileProtocolUrl,
|
||||
parseInlinePathToken,
|
||||
} from "./index";
|
||||
} from "./parse";
|
||||
|
||||
describe("parseInlinePathToken", () => {
|
||||
it("returns null for plain paths without a line number", () => {
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import React from "react";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import type { InlinePathTarget } from "./paths";
|
||||
import type { InlinePathTarget } from "./parse";
|
||||
import type { AssistantFileLinkContext, GetDirectorySuggestions } from "./resolver";
|
||||
|
||||
export interface AssistantFileLinkDaemonClient {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
isFileLookingAssistantToken,
|
||||
type AssistantFileLinkClassification,
|
||||
type InlinePathTarget,
|
||||
} from "./paths";
|
||||
} from "./parse";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export interface AssistantFileLinkSource {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import React, { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import type { InlinePathTarget } from "./paths";
|
||||
import type { InlinePathTarget } from "./parse";
|
||||
import { AssistantFileLinkResolverProvider } from "./provider";
|
||||
import type { DirectorySuggestionResult } from "./resolver";
|
||||
import { useFileLink } from "./use-file-link";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import type { InlinePathTarget } from "./paths";
|
||||
import type { InlinePathTarget } from "./parse";
|
||||
import {
|
||||
useAssistantFileLinkResolverContext,
|
||||
type AssistantFileLinkResolverContextValue,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import esbuild from "esbuild";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = path.resolve(__dirname, "../../../../..");
|
||||
const repoRoot = path.resolve(appRoot, "../..");
|
||||
const entry = path.join(__dirname, "runtime/entry.ts");
|
||||
const output = path.join(__dirname, "runtime/html.gen.ts");
|
||||
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
target: ["ios15", "chrome100"],
|
||||
minify: true,
|
||||
// The app's deployment floor is iOS 15.1 but structuredClone (used by
|
||||
// mermaid) only exists from iOS 15.4; esbuild targets transform syntax,
|
||||
// not runtime APIs. Mermaid only clones plain config objects, so a
|
||||
// JSON-based fallback is sufficient.
|
||||
banner: {
|
||||
js: 'if(typeof globalThis.structuredClone!=="function"){globalThis.structuredClone=function(v){return v===undefined?v:JSON.parse(JSON.stringify(v));};}',
|
||||
},
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
const rawJs = result.outputFiles[0]?.text;
|
||||
if (!rawJs) {
|
||||
throw new Error("Mermaid runtime bundle produced no JavaScript");
|
||||
}
|
||||
// JSON.stringify below escapes the TypeScript representation, not the runtime
|
||||
// HTML — an embedded closing-script sequence would still terminate the inline
|
||||
// <script>. `<\/script` is identical to `</script` inside JS strings/regex.
|
||||
const js = rawJs.replace(/<\/script/gi, "<\\/script");
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'none'; img-src data: blob:; font-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'"
|
||||
/>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
}
|
||||
/* The host sizes this frame to the diagram, so the diagram fills the frame. */
|
||||
#diagram {
|
||||
height: 100%;
|
||||
}
|
||||
#diagram svg {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="diagram"></div>
|
||||
<script>${js}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const contents = `// Generated by packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs.
|
||||
// Do not edit by hand.
|
||||
|
||||
export const mermaidRuntimeHtml = ${JSON.stringify(html)};
|
||||
`;
|
||||
|
||||
await fs.writeFile(output, contents);
|
||||
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Modal, View } from "react-native";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ZoomableViewport } from "@/components/zoomable-viewport";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
||||
import { MermaidIframeRuntime, type MermaidRenderedMessage } from "./iframe-runtime.web";
|
||||
import type { DiagramColorScheme } from "./render-model";
|
||||
import { useMermaidRenderModel } from "./use-render-model";
|
||||
|
||||
interface MermaidFullscreenViewerProps {
|
||||
source: string;
|
||||
colorScheme: DiagramColorScheme;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// The diagram is already in the render cache, so the viewport knows its size before the
|
||||
// fullscreen iframe has re-rendered it.
|
||||
const FALLBACK_SIZE = { width: 240, height: 240 };
|
||||
const VIEWER_FIT = { padding: SPACING[6] };
|
||||
|
||||
export function MermaidFullscreenViewer({
|
||||
source,
|
||||
colorScheme,
|
||||
onClose,
|
||||
}: MermaidFullscreenViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { state, request, rendered, renderFailed } = useMermaidRenderModel({
|
||||
source,
|
||||
phase: "complete",
|
||||
colorScheme,
|
||||
});
|
||||
const handleRendered = useCallback(
|
||||
(message: MermaidRenderedMessage) => {
|
||||
rendered({
|
||||
revision: message.revision,
|
||||
source: message.source,
|
||||
colorScheme: message.colorScheme,
|
||||
dimensions: { height: message.height, width: message.width },
|
||||
});
|
||||
},
|
||||
[rendered],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => window.removeEventListener("keydown", handleKeydown);
|
||||
}, [onClose]);
|
||||
|
||||
// The Modal focus trap walks descendants and focuses the first one that takes it. Focus the
|
||||
// content layer ourselves (this effect runs after the trap's) so keystrokes stay in this
|
||||
// document instead of disappearing into a focused diagram iframe.
|
||||
const contentLayerRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
contentLayerRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const visible = state.visible;
|
||||
const contentSize = useMemo(
|
||||
() => (visible ? { width: visible.width, height: visible.height } : FALLBACK_SIZE),
|
||||
[visible],
|
||||
);
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: X,
|
||||
label: t("common.actions.close"),
|
||||
onPress: onClose,
|
||||
testID: "mermaid-fullscreen-close",
|
||||
},
|
||||
],
|
||||
[onClose, t],
|
||||
);
|
||||
const contentLayerStyle = useMemo(
|
||||
() => [
|
||||
styles.contentLayer,
|
||||
{
|
||||
paddingTop: insets.top,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left,
|
||||
},
|
||||
],
|
||||
[insets.bottom, insets.left, insets.right, insets.top],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View style={styles.root}>
|
||||
<View style={styles.backdrop} />
|
||||
<View style={contentLayerStyle}>
|
||||
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: focus holder for the Escape shortcut */}
|
||||
<div ref={contentLayerRef} style={focusLayerDomStyle} tabIndex={-1}>
|
||||
<ZoomableViewport
|
||||
accessibilityLabel={t("message.diagram.diagram")}
|
||||
actions={actions}
|
||||
contentSize={contentSize}
|
||||
fit={VIEWER_FIT}
|
||||
minScale={1}
|
||||
onPressOutsideContent={onClose}
|
||||
style={styles.viewport}
|
||||
testID="mermaid-fullscreen-viewport"
|
||||
wheelActivation="always"
|
||||
>
|
||||
<MermaidIframeRuntime
|
||||
request={request}
|
||||
onRendered={handleRendered}
|
||||
onRenderFailed={renderFailed}
|
||||
/>
|
||||
</ZoomableViewport>
|
||||
</div>
|
||||
</View>
|
||||
</View>
|
||||
</WindowChromeRootRegion>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const focusLayerDomStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
outline: "none",
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: { flex: 1, minHeight: 0, minWidth: 0 },
|
||||
backdrop: { position: "absolute", inset: 0, backgroundColor: theme.colors.surface0 },
|
||||
contentLayer: { position: "absolute", inset: 0 },
|
||||
viewport: { flex: 1 },
|
||||
}));
|
||||
@@ -0,0 +1,379 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
StyleSheet as RNStyleSheet,
|
||||
ScrollView,
|
||||
View,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { WebView, type WebViewMessageEvent } from "react-native-webview";
|
||||
import { Code, Workflow, X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { MarkdownFenceRendererProps } from "./types";
|
||||
import type { MermaidRenderRequest } from "./render-model";
|
||||
import { mermaidRuntimeHtml } from "./runtime/html.gen";
|
||||
import {
|
||||
parseMermaidRuntimeMessage,
|
||||
serializeMermaidRuntimeRenderMessage,
|
||||
type MermaidRuntimeRenderMessage,
|
||||
} from "./runtime/messages";
|
||||
import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
|
||||
import { useMermaidRenderModel } from "./use-render-model";
|
||||
import { getDiagramBoxStyle } from "./presentation";
|
||||
|
||||
interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
|
||||
colorScheme?: "light" | "dark";
|
||||
}
|
||||
|
||||
const WEBVIEW_SOURCE = { html: mermaidRuntimeHtml };
|
||||
const WEBVIEW_ORIGIN_WHITELIST = ["*"];
|
||||
const MAX_PREVIEW_HEIGHT = 480;
|
||||
|
||||
interface MermaidWebViewProps {
|
||||
request: MermaidRenderRequest | null;
|
||||
interactive: boolean;
|
||||
onRendered: (message: {
|
||||
revision: number;
|
||||
source: string;
|
||||
colorScheme: "light" | "dark";
|
||||
height: number;
|
||||
width: number;
|
||||
}) => void;
|
||||
onRenderFailed: (revision: number) => void;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
function MermaidWebView({
|
||||
request,
|
||||
interactive,
|
||||
onRendered,
|
||||
onRenderFailed,
|
||||
style,
|
||||
}: MermaidWebViewProps) {
|
||||
const webViewRef = useRef<WebView | null>(null);
|
||||
const driverRef = useRef<MermaidRuntimeRequestDriver | null>(null);
|
||||
driverRef.current ??= new MermaidRuntimeRequestDriver();
|
||||
|
||||
const sendRequest = useCallback(
|
||||
(current: MermaidRenderRequest | null) => {
|
||||
if (!current || !webViewRef.current) {
|
||||
return;
|
||||
}
|
||||
const message: MermaidRuntimeRenderMessage = {
|
||||
type: "render",
|
||||
revision: current.revision,
|
||||
source: current.source,
|
||||
colorScheme: current.colorScheme,
|
||||
interactive,
|
||||
};
|
||||
const payload = serializeMermaidRuntimeRenderMessage(message);
|
||||
webViewRef.current.injectJavaScript(
|
||||
`window.__PASEO_MERMAID_RUNTIME_RECEIVE__ && window.__PASEO_MERMAID_RUNTIME_RECEIVE__(${payload}); true;`,
|
||||
);
|
||||
},
|
||||
[interactive],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
sendRequest(driverRef.current?.update(request) ?? null);
|
||||
}, [request, sendRequest]);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(event: WebViewMessageEvent) => {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(event.nativeEvent.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const message = parseMermaidRuntimeMessage(value);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "bridgeReady") {
|
||||
sendRequest(driverRef.current?.ready() ?? null);
|
||||
return;
|
||||
}
|
||||
if (message.type === "renderError") {
|
||||
onRenderFailed(message.revision);
|
||||
sendRequest(driverRef.current?.settled(message.revision, false) ?? null);
|
||||
return;
|
||||
}
|
||||
onRendered(message);
|
||||
sendRequest(driverRef.current?.settled(message.revision, true) ?? null);
|
||||
},
|
||||
[onRenderFailed, onRendered, sendRequest],
|
||||
);
|
||||
const handleShouldStartLoad = useCallback(
|
||||
(load: { url: string }) => load.url === "about:blank" || load.url.startsWith("data:"),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={WEBVIEW_SOURCE}
|
||||
originWhitelist={WEBVIEW_ORIGIN_WHITELIST}
|
||||
style={[webViewStyles.webView, style]}
|
||||
onMessage={handleMessage}
|
||||
onShouldStartLoadWithRequest={handleShouldStartLoad}
|
||||
scrollEnabled={interactive}
|
||||
bounces={false}
|
||||
scalesPageToFit={interactive}
|
||||
setSupportMultipleWindows={false}
|
||||
allowsLinkPreview={false}
|
||||
javaScriptEnabled
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const webViewStyles = RNStyleSheet.create({
|
||||
webView: { backgroundColor: "transparent" },
|
||||
});
|
||||
|
||||
interface MermaidDiagramViewerProps {
|
||||
code: string;
|
||||
colorScheme: "light" | "dark";
|
||||
onClose: () => void;
|
||||
inheritedStyles: TextStyle;
|
||||
textStyle: TextStyle;
|
||||
}
|
||||
|
||||
function MermaidDiagramViewer({
|
||||
code,
|
||||
colorScheme,
|
||||
onClose,
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
}: MermaidDiagramViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const toggleSource = useCallback(() => setShowSource((current) => !current), []);
|
||||
const { request, rendered, renderFailed } = useMermaidRenderModel({
|
||||
source: code,
|
||||
phase: "complete",
|
||||
colorScheme,
|
||||
});
|
||||
const handleRendered = useCallback(
|
||||
(message: {
|
||||
revision: number;
|
||||
source: string;
|
||||
colorScheme: "light" | "dark";
|
||||
height: number;
|
||||
width: number;
|
||||
}) => {
|
||||
rendered({
|
||||
revision: message.revision,
|
||||
source: message.source,
|
||||
colorScheme: message.colorScheme,
|
||||
dimensions: { height: message.height, width: message.width },
|
||||
});
|
||||
},
|
||||
[rendered],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||
<View style={viewerStyles.backdrop}>
|
||||
<View style={viewerStyles.webView}>
|
||||
<MermaidWebView
|
||||
request={request}
|
||||
interactive
|
||||
onRendered={handleRendered}
|
||||
onRenderFailed={renderFailed}
|
||||
style={viewerStyles.runtime}
|
||||
/>
|
||||
{showSource ? (
|
||||
<ScrollView
|
||||
style={viewerStyles.sourceOverlay}
|
||||
contentContainerStyle={viewerStyles.source}
|
||||
>
|
||||
<HighlightedCodeBlock
|
||||
code={code}
|
||||
language="mermaid"
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={textStyle}
|
||||
/>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={viewerStyles.actions}>
|
||||
<Pressable
|
||||
onPress={toggleSource}
|
||||
style={viewerStyles.actionButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
showSource ? t("message.diagram.viewDiagram") : t("message.diagram.viewSource")
|
||||
}
|
||||
hitSlop={12}
|
||||
>
|
||||
{showSource ? (
|
||||
<ThemedDiagramIcon size={20} uniProps={closeIconColor} />
|
||||
) : (
|
||||
<ThemedSourceIcon size={20} uniProps={closeIconColor} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={viewerStyles.actionButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("common.actions.close")}
|
||||
hitSlop={12}
|
||||
>
|
||||
<ThemedCloseIcon size={20} uniProps={closeIconColor} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedCloseIcon = withUnistyles(X);
|
||||
const ThemedSourceIcon = withUnistyles(Code);
|
||||
const ThemedDiagramIcon = withUnistyles(Workflow);
|
||||
const closeIconColor = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
const viewerStyles = StyleSheet.create((theme, runtime) => ({
|
||||
backdrop: { flex: 1, backgroundColor: theme.colors.surface0 },
|
||||
webView: {
|
||||
flex: 1,
|
||||
marginTop: runtime.insets.top,
|
||||
marginBottom: runtime.insets.bottom,
|
||||
},
|
||||
runtime: { flex: 1 },
|
||||
sourceOverlay: {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
source: {
|
||||
paddingTop: theme.spacing[12],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
actions: {
|
||||
position: "absolute",
|
||||
top: runtime.insets.top + theme.spacing[3],
|
||||
right: theme.spacing[4],
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
actionButton: {
|
||||
padding: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
|
||||
function MermaidFenceHostImpl({
|
||||
code,
|
||||
phase,
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
colorScheme = "dark",
|
||||
}: MermaidFenceHostImplProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state, request, rendered, renderFailed } = useMermaidRenderModel({
|
||||
source: code,
|
||||
phase,
|
||||
colorScheme,
|
||||
});
|
||||
const [hasRuntimeContent, setHasRuntimeContent] = useState(false);
|
||||
const handleRendered = useCallback(
|
||||
(message: {
|
||||
revision: number;
|
||||
source: string;
|
||||
colorScheme: "light" | "dark";
|
||||
height: number;
|
||||
width: number;
|
||||
}) => {
|
||||
setHasRuntimeContent(true);
|
||||
rendered({
|
||||
revision: message.revision,
|
||||
source: message.source,
|
||||
colorScheme: message.colorScheme,
|
||||
dimensions: { height: message.height, width: message.width },
|
||||
});
|
||||
},
|
||||
[rendered],
|
||||
);
|
||||
const [viewerOpen, setViewerOpen] = useState(false);
|
||||
const openViewer = useCallback(() => setViewerOpen(true), []);
|
||||
const closeViewer = useCallback(() => setViewerOpen(false), []);
|
||||
const visible = state.visible;
|
||||
const canShowDiagram = visible !== null && hasRuntimeContent;
|
||||
const previewInnerStyle = useMemo(
|
||||
() =>
|
||||
canShowDiagram && visible
|
||||
? { height: Math.min(visible.height, MAX_PREVIEW_HEIGHT) }
|
||||
: previewStyles.measuringInner,
|
||||
[canShowDiagram, visible],
|
||||
);
|
||||
const diagramBoxStyle = getDiagramBoxStyle(textStyle);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!canShowDiagram ? (
|
||||
<HighlightedCodeBlock
|
||||
code={code}
|
||||
language="mermaid"
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={textStyle}
|
||||
/>
|
||||
) : null}
|
||||
<Pressable
|
||||
onPress={openViewer}
|
||||
disabled={!canShowDiagram}
|
||||
accessibilityRole={canShowDiagram ? "imagebutton" : undefined}
|
||||
accessibilityLabel={t("message.diagram.diagram")}
|
||||
style={
|
||||
canShowDiagram
|
||||
? [diagramBoxStyle, previewStyles.preview]
|
||||
: [diagramBoxStyle, previewStyles.measuring]
|
||||
}
|
||||
>
|
||||
<View style={previewInnerStyle} pointerEvents="none">
|
||||
<MermaidWebView
|
||||
request={request}
|
||||
interactive={false}
|
||||
onRendered={handleRendered}
|
||||
onRenderFailed={renderFailed}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
{viewerOpen && canShowDiagram && visible ? (
|
||||
<MermaidDiagramViewer
|
||||
code={visible.source}
|
||||
colorScheme={visible.colorScheme}
|
||||
onClose={closeViewer}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={textStyle}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const previewStyles = RNStyleSheet.create({
|
||||
measuring: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
measuringInner: { height: 240 },
|
||||
preview: { overflow: "hidden" },
|
||||
});
|
||||
|
||||
const mapColorScheme = (theme: Theme) => ({ colorScheme: theme.colorScheme });
|
||||
const ThemedMermaidFenceHost = withUnistyles(MermaidFenceHostImpl);
|
||||
|
||||
export function MermaidFenceHost(props: MarkdownFenceRendererProps) {
|
||||
return <ThemedMermaidFenceHost {...props} uniProps={mapColorScheme} />;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import type { MarkdownFenceRendererProps } from "./types";
|
||||
|
||||
export function MermaidFenceHost({ code, inheritedStyles, textStyle }: MarkdownFenceRendererProps) {
|
||||
return (
|
||||
<HighlightedCodeBlock
|
||||
code={code}
|
||||
language="mermaid"
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={textStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Pressable, View, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { Code, Maximize2, Workflow } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { ZoomableViewport } from "@/components/zoomable-viewport";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { MarkdownFenceRendererProps } from "./types";
|
||||
import { MermaidFullscreenViewer } from "./fullscreen-viewer.web";
|
||||
import { MermaidIframeRuntime, type MermaidRenderedMessage } from "./iframe-runtime.web";
|
||||
import { useMermaidRenderModel } from "./use-render-model";
|
||||
import { getDiagramBoxStyle } from "./presentation";
|
||||
|
||||
interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
|
||||
colorScheme?: "light" | "dark";
|
||||
}
|
||||
|
||||
function MermaidFenceHostImpl({
|
||||
code,
|
||||
phase,
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
colorScheme = "dark",
|
||||
}: MermaidFenceHostImplProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state, request, rendered, renderFailed } = useMermaidRenderModel({
|
||||
source: code,
|
||||
phase,
|
||||
colorScheme,
|
||||
});
|
||||
const [hasRuntimeContent, setHasRuntimeContent] = useState(false);
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const showSourcePress = useCallback(() => setShowSource(true), []);
|
||||
const showDiagramPress = useCallback(() => setShowSource(false), []);
|
||||
const openFullscreen = useCallback(() => setIsFullscreen(true), []);
|
||||
const closeFullscreen = useCallback(() => setIsFullscreen(false), []);
|
||||
const handleRendered = useCallback(
|
||||
(message: MermaidRenderedMessage) => {
|
||||
setHasRuntimeContent(true);
|
||||
rendered({
|
||||
revision: message.revision,
|
||||
source: message.source,
|
||||
colorScheme: message.colorScheme,
|
||||
dimensions: { height: message.height, width: message.width },
|
||||
});
|
||||
},
|
||||
[rendered],
|
||||
);
|
||||
const visible = state.visible;
|
||||
const canShowDiagram = visible !== null && hasRuntimeContent;
|
||||
const diagramVisible = canShowDiagram && !showSource;
|
||||
const runtimeHeight = Math.max(visible?.height ?? 240, 1);
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: Code,
|
||||
label: t("message.diagram.viewSource"),
|
||||
onPress: showSourcePress,
|
||||
},
|
||||
{
|
||||
icon: Maximize2,
|
||||
label: t("message.diagram.fullscreen"),
|
||||
onPress: openFullscreen,
|
||||
testID: "mermaid-fullscreen",
|
||||
},
|
||||
],
|
||||
[openFullscreen, showSourcePress, t],
|
||||
);
|
||||
const sourceView = useMemo(() => {
|
||||
const { marginTop, marginBottom, marginVertical, ...sourceTextStyle } = textStyle;
|
||||
const margins: ViewStyle = {
|
||||
marginTop: marginTop ?? marginVertical,
|
||||
marginBottom: marginBottom ?? marginVertical,
|
||||
};
|
||||
const text: TextStyle = sourceTextStyle;
|
||||
return { container: [margins, sourceContainerStyle], text };
|
||||
}, [textStyle]);
|
||||
const diagramStyle = useMemo(
|
||||
() => [
|
||||
getDiagramBoxStyle(textStyle),
|
||||
containerStyle,
|
||||
{ height: runtimeHeight, minHeight: MIN_DIAGRAM_BOX_HEIGHT },
|
||||
],
|
||||
[runtimeHeight, textStyle],
|
||||
);
|
||||
const diagramSize = useMemo(
|
||||
() => (visible ? { width: visible.width, height: visible.height } : MEASURING_SIZE),
|
||||
[visible],
|
||||
);
|
||||
const sourceVisible = !diagramVisible;
|
||||
const sourceContainer = showSource ? sourceView.container : sourceContainerStyle;
|
||||
const sourceTextStyle = showSource ? sourceView.text : textStyle;
|
||||
const viewportStyle = diagramVisible ? diagramStyle : measuringStyle;
|
||||
|
||||
return (
|
||||
<>
|
||||
{sourceVisible ? (
|
||||
<View style={sourceContainer}>
|
||||
<HighlightedCodeBlock
|
||||
code={code}
|
||||
language="mermaid"
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={sourceTextStyle}
|
||||
/>
|
||||
{showSource && canShowDiagram ? (
|
||||
<Pressable
|
||||
accessibilityLabel={t("message.diagram.viewDiagram")}
|
||||
accessibilityRole="button"
|
||||
hitSlop={4}
|
||||
onPress={showDiagramPress}
|
||||
style={controlStyles.sourceButton}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Workflow
|
||||
size={14}
|
||||
color={hovered ? controlStyles.iconHovered.color : controlStyles.icon.color}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<ZoomableViewport
|
||||
accessibilityLabel={t("message.diagram.diagram")}
|
||||
actions={actions}
|
||||
contentSize={diagramSize}
|
||||
style={viewportStyle}
|
||||
testID="mermaid-viewport"
|
||||
wheelActivation="modifier"
|
||||
>
|
||||
<MermaidIframeRuntime
|
||||
request={request}
|
||||
onRendered={handleRendered}
|
||||
onRenderFailed={renderFailed}
|
||||
/>
|
||||
</ZoomableViewport>
|
||||
{isFullscreen && visible ? (
|
||||
<MermaidFullscreenViewer
|
||||
source={visible.source}
|
||||
colorScheme={visible.colorScheme}
|
||||
onClose={closeFullscreen}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const MEASURING_SIZE = { width: 240, height: 240 };
|
||||
/**
|
||||
* The toolbar overlays the top of the box (8px offset + 32px compact buttons) and the box clips
|
||||
* with `overflow: hidden`, so a shorter box leaves the buttons half-clipped and unclickable.
|
||||
*/
|
||||
const MIN_DIAGRAM_BOX_HEIGHT = 56;
|
||||
const sourceContainerStyle: ViewStyle = { position: "relative" };
|
||||
/**
|
||||
* The viewport's own root is `flex: 1`, so as a flex item it has `flex-basis: 0%` and that basis
|
||||
* replaces the height below. In the markdown column there is no free space to grow into, so the
|
||||
* box collapses and the diagram is scaled down to fit a couple of pixels. Size it from the height.
|
||||
*/
|
||||
const containerStyle: ViewStyle = {
|
||||
flexBasis: "auto",
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
};
|
||||
const measuringStyle: ViewStyle = {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: 240,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
};
|
||||
const controlStyles = StyleSheet.create((theme) => ({
|
||||
sourceButton: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[2],
|
||||
right: theme.spacing[2],
|
||||
padding: theme.spacing[1],
|
||||
},
|
||||
icon: { color: theme.colors.foregroundMuted },
|
||||
iconHovered: { color: theme.colors.foreground },
|
||||
}));
|
||||
const mapColorScheme = (theme: Theme) => ({ colorScheme: theme.colorScheme });
|
||||
const ThemedMermaidFenceHost = withUnistyles(MermaidFenceHostImpl);
|
||||
|
||||
export function MermaidFenceHost(props: MarkdownFenceRendererProps) {
|
||||
return <ThemedMermaidFenceHost {...props} uniProps={mapColorScheme} />;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { DiagramColorScheme, MermaidRenderRequest } from "./render-model";
|
||||
import { mermaidRuntimeHtml } from "./runtime/html.gen";
|
||||
import { parseMermaidRuntimeMessage, type MermaidRuntimeRenderMessage } from "./runtime/messages";
|
||||
import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
|
||||
|
||||
export interface MermaidRenderedMessage {
|
||||
revision: number;
|
||||
source: string;
|
||||
colorScheme: DiagramColorScheme;
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface MermaidIframeRuntimeProps {
|
||||
request: MermaidRenderRequest | null;
|
||||
onRendered: (message: MermaidRenderedMessage) => void;
|
||||
onRenderFailed: (revision: number) => void;
|
||||
}
|
||||
|
||||
/** Sandboxed Mermaid renderer. Sizing and gestures belong to the surrounding viewport. */
|
||||
export function MermaidIframeRuntime({
|
||||
request,
|
||||
onRendered,
|
||||
onRenderFailed,
|
||||
}: MermaidIframeRuntimeProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const driverRef = useRef<MermaidRuntimeRequestDriver | null>(null);
|
||||
driverRef.current ??= new MermaidRuntimeRequestDriver();
|
||||
|
||||
const sendRequest = useCallback((current: MermaidRenderRequest | null) => {
|
||||
const target = iframeRef.current?.contentWindow;
|
||||
if (!current || !target) return;
|
||||
const message: MermaidRuntimeRenderMessage = {
|
||||
type: "render",
|
||||
revision: current.revision,
|
||||
source: current.source,
|
||||
colorScheme: current.colorScheme,
|
||||
interactive: false,
|
||||
};
|
||||
target.postMessage(message, "*");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
sendRequest(driverRef.current?.update(request) ?? null);
|
||||
}, [request, sendRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
function receiveMessage(event: MessageEvent): void {
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
const message = parseMermaidRuntimeMessage(event.data);
|
||||
if (!message) return;
|
||||
if (message.type === "bridgeReady") {
|
||||
sendRequest(driverRef.current?.ready() ?? null);
|
||||
return;
|
||||
}
|
||||
if (message.type === "renderError") {
|
||||
onRenderFailed(message.revision);
|
||||
sendRequest(driverRef.current?.settled(message.revision, false) ?? null);
|
||||
return;
|
||||
}
|
||||
onRendered(message);
|
||||
sendRequest(driverRef.current?.settled(message.revision, true) ?? null);
|
||||
}
|
||||
window.addEventListener("message", receiveMessage);
|
||||
return () => window.removeEventListener("message", receiveMessage);
|
||||
}, [onRenderFailed, onRendered, sendRequest]);
|
||||
|
||||
// `inert` (not just tabIndex) because the Modal focus trap focuses descendants
|
||||
// programmatically; a focused iframe swallows every keystroke, including Escape.
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title=""
|
||||
aria-hidden
|
||||
inert
|
||||
sandbox="allow-scripts"
|
||||
srcDoc={mermaidRuntimeHtml}
|
||||
tabIndex={-1}
|
||||
style={iframeStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const iframeStyle: React.CSSProperties = {
|
||||
display: "block",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: 0,
|
||||
pointerEvents: "none",
|
||||
background: "transparent",
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { MermaidFenceHost } from "./host";
|
||||
export { splitMermaidFences } from "./segments";
|
||||
export const MermaidStreamingContext = createContext(false);
|
||||
const ThemedHost = withUnistyles(MermaidFenceHost);
|
||||
const styles = (theme: Theme) => ({
|
||||
inheritedStyles: {
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
},
|
||||
textStyle: { backgroundColor: theme.colors.surface2, padding: 12, borderRadius: 8 },
|
||||
});
|
||||
export function MermaidFence({ code }: { code: string }) {
|
||||
const streaming = useContext(MermaidStreamingContext);
|
||||
return <ThemedHost code={code} phase={streaming ? "streaming" : "complete"} uniProps={styles} />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { TextStyle, ViewStyle } from "react-native";
|
||||
|
||||
export function getDiagramBoxStyle(style: TextStyle): ViewStyle {
|
||||
return {
|
||||
backgroundColor: style.backgroundColor,
|
||||
borderColor: style.borderColor,
|
||||
borderRadius: style.borderRadius,
|
||||
borderWidth: style.borderWidth,
|
||||
marginBottom: style.marginBottom,
|
||||
marginTop: style.marginTop,
|
||||
marginVertical: style.marginVertical,
|
||||
padding: style.padding,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMermaidRenderModel,
|
||||
getMermaidRenderRequest,
|
||||
reduceMermaidRenderModel,
|
||||
type MermaidRenderInput,
|
||||
type MermaidRenderModel,
|
||||
} from "./render-model";
|
||||
|
||||
function input(overrides: Partial<MermaidRenderInput> = {}): MermaidRenderInput {
|
||||
return {
|
||||
source: "flowchart TD\nA --> B",
|
||||
phase: "streaming",
|
||||
colorScheme: "dark",
|
||||
rejected: false,
|
||||
cached: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderCurrent(state: MermaidRenderModel): MermaidRenderModel {
|
||||
return reduceMermaidRenderModel(state, {
|
||||
type: "rendered",
|
||||
revision: state.revision,
|
||||
source: state.source,
|
||||
colorScheme: state.colorScheme,
|
||||
dimensions: { height: 120, width: 240 },
|
||||
});
|
||||
}
|
||||
|
||||
describe("Mermaid render model", () => {
|
||||
it("shows each valid streamed revision", () => {
|
||||
const first = renderCurrent(createMermaidRenderModel(input()));
|
||||
const secondPending = reduceMermaidRenderModel(first, {
|
||||
type: "inputChanged",
|
||||
input: input({ source: "flowchart TD\nA --> B\nB --> C" }),
|
||||
});
|
||||
const second = renderCurrent(secondPending);
|
||||
|
||||
expect(first.visible?.source).toBe("flowchart TD\nA --> B");
|
||||
expect(second.visible?.source).toBe("flowchart TD\nA --> B\nB --> C");
|
||||
});
|
||||
|
||||
it("keeps the last valid preview across a transient streaming failure", () => {
|
||||
const rendered = renderCurrent(createMermaidRenderModel(input()));
|
||||
const pending = reduceMermaidRenderModel(rendered, {
|
||||
type: "inputChanged",
|
||||
input: input({ source: "flowchart TD\nA --" }),
|
||||
});
|
||||
const failed = reduceMermaidRenderModel(pending, {
|
||||
type: "renderFailed",
|
||||
revision: pending.revision,
|
||||
});
|
||||
|
||||
expect(failed.status).toBe("failed");
|
||||
expect(failed.visible?.source).toBe("flowchart TD\nA --> B");
|
||||
});
|
||||
|
||||
it("clears a stale preview when failed source becomes final", () => {
|
||||
const rendered = renderCurrent(createMermaidRenderModel(input()));
|
||||
const pending = reduceMermaidRenderModel(rendered, {
|
||||
type: "inputChanged",
|
||||
input: input({ source: "flowchart TD\nA --" }),
|
||||
});
|
||||
const failed = reduceMermaidRenderModel(pending, {
|
||||
type: "renderFailed",
|
||||
revision: pending.revision,
|
||||
});
|
||||
const complete = reduceMermaidRenderModel(failed, {
|
||||
type: "inputChanged",
|
||||
input: input({ source: "flowchart TD\nA --", phase: "complete" }),
|
||||
});
|
||||
|
||||
expect(complete.status).toBe("failed");
|
||||
expect(complete.visible).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the completion phase when an in-flight final render fails", () => {
|
||||
const rendered = renderCurrent(createMermaidRenderModel(input()));
|
||||
const pending = reduceMermaidRenderModel(rendered, {
|
||||
type: "inputChanged",
|
||||
input: input({ source: "flowchart TD\nA --" }),
|
||||
});
|
||||
const completePending = reduceMermaidRenderModel(pending, {
|
||||
type: "inputChanged",
|
||||
input: input({ source: "flowchart TD\nA --", phase: "complete" }),
|
||||
});
|
||||
const failed = reduceMermaidRenderModel(completePending, {
|
||||
type: "renderFailed",
|
||||
revision: completePending.revision,
|
||||
});
|
||||
|
||||
expect(failed.visible).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores obsolete render results", () => {
|
||||
const initial = createMermaidRenderModel(input());
|
||||
const newer = reduceMermaidRenderModel(initial, {
|
||||
type: "inputChanged",
|
||||
input: input({ source: "flowchart TD\nA --> C" }),
|
||||
});
|
||||
const obsolete = reduceMermaidRenderModel(newer, {
|
||||
type: "rendered",
|
||||
revision: initial.revision,
|
||||
source: initial.source,
|
||||
colorScheme: initial.colorScheme,
|
||||
dimensions: { height: 90, width: 180 },
|
||||
});
|
||||
|
||||
expect(obsolete).toBe(newer);
|
||||
expect(getMermaidRenderRequest(obsolete)).toEqual({
|
||||
revision: newer.revision,
|
||||
source: "flowchart TD\nA --> C",
|
||||
colorScheme: "dark",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an obsolete successful prefix as the first streaming preview", () => {
|
||||
const initial = createMermaidRenderModel(input());
|
||||
const newer = reduceMermaidRenderModel(initial, {
|
||||
type: "inputChanged",
|
||||
input: input({ source: "flowchart TD\nA --> B\nB --" }),
|
||||
});
|
||||
const preview = reduceMermaidRenderModel(newer, {
|
||||
type: "rendered",
|
||||
revision: initial.revision,
|
||||
source: initial.source,
|
||||
colorScheme: initial.colorScheme,
|
||||
dimensions: { height: 90, width: 180 },
|
||||
});
|
||||
|
||||
expect(preview.status).toBe("pending");
|
||||
expect(preview.visible?.source).toBe(initial.source);
|
||||
expect(getMermaidRenderRequest(preview)?.source).toBe("flowchart TD\nA --> B\nB --");
|
||||
});
|
||||
|
||||
it("keeps the last preview when source policy rejects a streaming revision", () => {
|
||||
const rendered = renderCurrent(createMermaidRenderModel(input()));
|
||||
const rejected = reduceMermaidRenderModel(rendered, {
|
||||
type: "inputChanged",
|
||||
input: input({ source: 'flowchart TD\nA@{ img: "https://example.com/x" }', rejected: true }),
|
||||
});
|
||||
|
||||
expect(rejected.status).toBe("rejected");
|
||||
expect(rejected.visible).toBe(rendered.visible);
|
||||
expect(getMermaidRenderRequest(rejected)).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the last preview when the rejected revision is complete", () => {
|
||||
const rendered = renderCurrent(createMermaidRenderModel(input()));
|
||||
const rejected = reduceMermaidRenderModel(rendered, {
|
||||
type: "inputChanged",
|
||||
input: input({
|
||||
source: 'flowchart TD\nA@{ img: "https://example.com/x" }',
|
||||
phase: "complete",
|
||||
rejected: true,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(rejected.status).toBe("rejected");
|
||||
expect(rejected.visible).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { MarkdownPhase } from "./types";
|
||||
|
||||
export type DiagramColorScheme = "light" | "dark";
|
||||
|
||||
export interface DiagramDimensions {
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface RenderedDiagram extends DiagramDimensions {
|
||||
source: string;
|
||||
colorScheme: DiagramColorScheme;
|
||||
}
|
||||
|
||||
export interface MermaidRenderInput {
|
||||
source: string;
|
||||
phase: MarkdownPhase;
|
||||
colorScheme: DiagramColorScheme;
|
||||
rejected: boolean;
|
||||
cached: RenderedDiagram | null;
|
||||
}
|
||||
|
||||
export interface MermaidRenderRequest {
|
||||
revision: number;
|
||||
source: string;
|
||||
colorScheme: DiagramColorScheme;
|
||||
}
|
||||
|
||||
export interface MermaidRenderModel {
|
||||
revision: number;
|
||||
source: string;
|
||||
phase: MarkdownPhase;
|
||||
colorScheme: DiagramColorScheme;
|
||||
status: "pending" | "rendered" | "failed" | "rejected";
|
||||
visible: RenderedDiagram | null;
|
||||
}
|
||||
|
||||
export type MermaidRenderAction =
|
||||
| { type: "inputChanged"; input: MermaidRenderInput }
|
||||
| {
|
||||
type: "rendered";
|
||||
revision: number;
|
||||
source: string;
|
||||
colorScheme: DiagramColorScheme;
|
||||
dimensions: DiagramDimensions;
|
||||
}
|
||||
| { type: "renderFailed"; revision: number };
|
||||
|
||||
function reduceRendered(
|
||||
state: MermaidRenderModel,
|
||||
action: Extract<MermaidRenderAction, { type: "rendered" }>,
|
||||
): MermaidRenderModel {
|
||||
if (action.colorScheme !== state.colorScheme) {
|
||||
return state;
|
||||
}
|
||||
if (action.revision !== state.revision || action.source !== state.source) {
|
||||
const isStreamingPrefix =
|
||||
state.phase === "streaming" &&
|
||||
action.revision < state.revision &&
|
||||
state.source.startsWith(action.source);
|
||||
const isNewerPreview =
|
||||
state.visible === null || action.source.length > state.visible.source.length;
|
||||
if (!isStreamingPrefix || !isNewerPreview) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
visible: {
|
||||
source: action.source,
|
||||
colorScheme: action.colorScheme,
|
||||
...action.dimensions,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
status: "rendered",
|
||||
visible: {
|
||||
source: action.source,
|
||||
colorScheme: action.colorScheme,
|
||||
...action.dimensions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMermaidRenderModel(input: MermaidRenderInput): MermaidRenderModel {
|
||||
if (input.rejected) {
|
||||
return {
|
||||
revision: 1,
|
||||
source: input.source,
|
||||
phase: input.phase,
|
||||
colorScheme: input.colorScheme,
|
||||
status: "rejected",
|
||||
visible: input.phase === "complete" ? null : input.cached,
|
||||
};
|
||||
}
|
||||
return {
|
||||
revision: 1,
|
||||
source: input.source,
|
||||
phase: input.phase,
|
||||
colorScheme: input.colorScheme,
|
||||
status: "pending",
|
||||
visible: input.cached,
|
||||
};
|
||||
}
|
||||
|
||||
export function reduceMermaidRenderModel(
|
||||
state: MermaidRenderModel,
|
||||
action: MermaidRenderAction,
|
||||
): MermaidRenderModel {
|
||||
if (action.type === "rendered") {
|
||||
return reduceRendered(state, action);
|
||||
}
|
||||
|
||||
if (action.type === "renderFailed") {
|
||||
if (action.revision !== state.revision) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
status: "failed",
|
||||
visible: state.phase === "complete" ? null : state.visible,
|
||||
};
|
||||
}
|
||||
|
||||
const { input } = action;
|
||||
const isSameRender = input.source === state.source && input.colorScheme === state.colorScheme;
|
||||
if (isSameRender) {
|
||||
if (input.phase === state.phase) {
|
||||
return state;
|
||||
}
|
||||
const finalFailure = input.phase === "complete" && state.status === "failed";
|
||||
return {
|
||||
...state,
|
||||
phase: input.phase,
|
||||
visible: finalFailure ? null : state.visible,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.rejected) {
|
||||
return {
|
||||
revision: state.revision + 1,
|
||||
source: input.source,
|
||||
phase: input.phase,
|
||||
colorScheme: input.colorScheme,
|
||||
status: "rejected",
|
||||
visible: input.phase === "complete" ? null : (input.cached ?? state.visible),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
revision: state.revision + 1,
|
||||
source: input.source,
|
||||
phase: input.phase,
|
||||
colorScheme: input.colorScheme,
|
||||
status: "pending",
|
||||
visible: input.cached ?? state.visible,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMermaidRenderRequest(state: MermaidRenderModel): MermaidRenderRequest | null {
|
||||
if (state.status !== "pending") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
revision: state.revision,
|
||||
source: state.source,
|
||||
colorScheme: state.colorScheme,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import mermaid from "mermaid";
|
||||
import {
|
||||
parseMermaidRuntimeRenderMessage,
|
||||
type MermaidRuntimeMessage,
|
||||
type MermaidRuntimeRenderMessage,
|
||||
} from "./messages";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ReactNativeWebView?: {
|
||||
postMessage?: (data: string) => void;
|
||||
};
|
||||
__PASEO_MERMAID_RUNTIME_RECEIVE__?: (message: unknown) => void;
|
||||
}
|
||||
}
|
||||
|
||||
function sendToHost(message: MermaidRuntimeMessage): void {
|
||||
window.ReactNativeWebView?.postMessage?.(JSON.stringify(message));
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage(message, "*");
|
||||
}
|
||||
}
|
||||
|
||||
function initializeMermaid(colorScheme: "light" | "dark"): void {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
suppressErrorRendering: true,
|
||||
theme: colorScheme === "dark" ? "dark" : "default",
|
||||
secure: [
|
||||
"secure",
|
||||
"securityLevel",
|
||||
"startOnLoad",
|
||||
"maxTextSize",
|
||||
"suppressErrorRendering",
|
||||
"maxEdges",
|
||||
"theme",
|
||||
"themeVariables",
|
||||
"themeCSS",
|
||||
],
|
||||
flowchart: { htmlLabels: false },
|
||||
class: { htmlLabels: false },
|
||||
});
|
||||
}
|
||||
|
||||
function setViewport(interactive: boolean): void {
|
||||
document
|
||||
.querySelector('meta[name="viewport"]')
|
||||
?.setAttribute(
|
||||
"content",
|
||||
interactive
|
||||
? "width=device-width, initial-scale=1, maximum-scale=8"
|
||||
: "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no",
|
||||
);
|
||||
}
|
||||
|
||||
let latestRevision = 0;
|
||||
let pendingRender: MermaidRuntimeRenderMessage | null = null;
|
||||
let isRendering = false;
|
||||
let isDrainScheduled = false;
|
||||
|
||||
interface DiagramSize {
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mermaid emits `width="100%"` with an inline `max-width` in pixels, so the diagram renders at
|
||||
* its natural width and never fills a larger box. The host sizes this frame, so let the diagram
|
||||
* follow the frame in both directions instead.
|
||||
*/
|
||||
function stretchToFrame(svg: SVGSVGElement): void {
|
||||
svg.style.maxWidth = "100%";
|
||||
svg.style.maxHeight = "100%";
|
||||
svg.style.width = "100%";
|
||||
svg.style.height = "100%";
|
||||
}
|
||||
|
||||
/**
|
||||
* The reported size is the host's content size, and the host feeds it back as this frame's size.
|
||||
* Measuring the rendered box would make that a loop: every re-render lands in a frame the last
|
||||
* measurement shrank by the container's padding, so a streaming diagram ratchets down to nothing.
|
||||
* The viewBox is the diagram's own size and doesn't move.
|
||||
*/
|
||||
function measureDiagram(host: HTMLElement, svg: SVGSVGElement | null): DiagramSize {
|
||||
const viewBox = svg?.viewBox.baseVal;
|
||||
if (viewBox && viewBox.width > 0 && viewBox.height > 0) {
|
||||
return { height: Math.ceil(viewBox.height), width: Math.ceil(viewBox.width) };
|
||||
}
|
||||
const rect = svg?.getBoundingClientRect();
|
||||
return {
|
||||
height: Math.ceil(rect?.height ?? host.scrollHeight),
|
||||
width: Math.ceil(rect?.width ?? host.scrollWidth),
|
||||
};
|
||||
}
|
||||
|
||||
async function render(message: MermaidRuntimeRenderMessage): Promise<void> {
|
||||
try {
|
||||
initializeMermaid(message.colorScheme);
|
||||
const { svg } = await mermaid.render(`paseo-mermaid-${message.revision}`, message.source);
|
||||
if (message.revision !== latestRevision) {
|
||||
return;
|
||||
}
|
||||
setViewport(message.interactive);
|
||||
const host = document.getElementById("diagram");
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
host.innerHTML = svg;
|
||||
const element = host.querySelector("svg");
|
||||
if (element) {
|
||||
stretchToFrame(element);
|
||||
}
|
||||
const size = measureDiagram(host, element);
|
||||
sendToHost({
|
||||
type: "rendered",
|
||||
revision: message.revision,
|
||||
source: message.source,
|
||||
colorScheme: message.colorScheme,
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
});
|
||||
} catch {
|
||||
if (message.revision === latestRevision) {
|
||||
sendToHost({ type: "renderError", revision: message.revision });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function drainRenderQueue(): Promise<void> {
|
||||
if (isRendering) {
|
||||
return;
|
||||
}
|
||||
isRendering = true;
|
||||
try {
|
||||
while (pendingRender) {
|
||||
const next = pendingRender;
|
||||
pendingRender = null;
|
||||
await render(next);
|
||||
}
|
||||
} finally {
|
||||
isRendering = false;
|
||||
}
|
||||
}
|
||||
|
||||
function receiveRender(value: unknown): void {
|
||||
const message = parseMermaidRuntimeRenderMessage(value);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
latestRevision = message.revision;
|
||||
pendingRender = message;
|
||||
if (isRendering || isDrainScheduled) {
|
||||
return;
|
||||
}
|
||||
isDrainScheduled = true;
|
||||
window.setTimeout(() => {
|
||||
isDrainScheduled = false;
|
||||
void drainRenderQueue();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
window.__PASEO_MERMAID_RUNTIME_RECEIVE__ = receiveRender;
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source === window.parent) {
|
||||
receiveRender(event.data);
|
||||
}
|
||||
});
|
||||
|
||||
sendToHost({ type: "bridgeReady" });
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,91 @@
|
||||
import type { DiagramColorScheme, DiagramDimensions } from "../render-model";
|
||||
|
||||
export interface MermaidRuntimeRenderMessage {
|
||||
type: "render";
|
||||
revision: number;
|
||||
source: string;
|
||||
colorScheme: DiagramColorScheme;
|
||||
interactive: boolean;
|
||||
}
|
||||
|
||||
export type MermaidRuntimeMessage =
|
||||
| { type: "bridgeReady" }
|
||||
| ({
|
||||
type: "rendered";
|
||||
revision: number;
|
||||
source: string;
|
||||
colorScheme: DiagramColorScheme;
|
||||
} & DiagramDimensions)
|
||||
| { type: "renderError"; revision: number };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isColorScheme(value: unknown): value is DiagramColorScheme {
|
||||
return value === "light" || value === "dark";
|
||||
}
|
||||
|
||||
export function parseMermaidRuntimeRenderMessage(
|
||||
value: unknown,
|
||||
): MermaidRuntimeRenderMessage | null {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
value.type !== "render" ||
|
||||
typeof value.revision !== "number" ||
|
||||
!Number.isInteger(value.revision) ||
|
||||
typeof value.source !== "string" ||
|
||||
!isColorScheme(value.colorScheme) ||
|
||||
typeof value.interactive !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "render",
|
||||
revision: value.revision,
|
||||
source: value.source,
|
||||
colorScheme: value.colorScheme,
|
||||
interactive: value.interactive,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMermaidRuntimeMessage(value: unknown): MermaidRuntimeMessage | null {
|
||||
if (!isRecord(value) || typeof value.type !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (value.type === "bridgeReady") {
|
||||
return { type: "bridgeReady" };
|
||||
}
|
||||
if (
|
||||
value.type === "renderError" &&
|
||||
typeof value.revision === "number" &&
|
||||
Number.isInteger(value.revision)
|
||||
) {
|
||||
return { type: "renderError", revision: value.revision };
|
||||
}
|
||||
if (
|
||||
value.type === "rendered" &&
|
||||
typeof value.revision === "number" &&
|
||||
Number.isInteger(value.revision) &&
|
||||
typeof value.source === "string" &&
|
||||
isColorScheme(value.colorScheme) &&
|
||||
typeof value.height === "number" &&
|
||||
Number.isFinite(value.height) &&
|
||||
typeof value.width === "number" &&
|
||||
Number.isFinite(value.width)
|
||||
) {
|
||||
return {
|
||||
type: "rendered",
|
||||
revision: value.revision,
|
||||
source: value.source,
|
||||
colorScheme: value.colorScheme,
|
||||
height: value.height,
|
||||
width: value.width,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function serializeMermaidRuntimeRenderMessage(message: MermaidRuntimeRenderMessage): string {
|
||||
return JSON.stringify(message).replace(/<\/script/gi, "<\\/script");
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MermaidRenderRequest } from "../render-model";
|
||||
import { MermaidRuntimeRequestDriver } from "./request-driver";
|
||||
|
||||
function request(revision: number, source: string): MermaidRenderRequest {
|
||||
return { revision, source, colorScheme: "dark" };
|
||||
}
|
||||
|
||||
describe("MermaidRuntimeRequestDriver", () => {
|
||||
it("tries queued streaming prefixes in order until the runtime renders one", () => {
|
||||
const driver = new MermaidRuntimeRequestDriver();
|
||||
const first = request(1, "flow");
|
||||
const valid = request(2, "flowchart TD\nA --> B\n");
|
||||
const transient = request(3, "flowchart TD\nA --> B\nB --");
|
||||
|
||||
expect(driver.update(first)).toBeNull();
|
||||
expect(driver.update(valid)).toBeNull();
|
||||
expect(driver.update(transient)).toBeNull();
|
||||
expect(driver.ready()).toBe(valid);
|
||||
expect(driver.settled(valid.revision, true)).toBe(transient);
|
||||
});
|
||||
|
||||
it("keeps completed lines but collapses incomplete token revisions before readiness", () => {
|
||||
const driver = new MermaidRuntimeRequestDriver();
|
||||
const partial = request(1, "flowchart TD\nA --");
|
||||
const completed = request(2, "flowchart TD\nA --> B\n");
|
||||
const nextPartial = request(3, "flowchart TD\nA --> B\nB --");
|
||||
const latestPartial = request(4, "flowchart TD\nA --> B\nB -->");
|
||||
|
||||
driver.update(partial);
|
||||
driver.update(completed);
|
||||
driver.update(nextPartial);
|
||||
driver.update(latestPartial);
|
||||
|
||||
expect(driver.ready()).toBe(completed);
|
||||
expect(driver.settled(completed.revision, true)).toBe(latestPartial);
|
||||
});
|
||||
|
||||
it("collapses later updates to the newest request after the first render", () => {
|
||||
const driver = new MermaidRuntimeRequestDriver();
|
||||
const first = request(1, "flowchart TD\nA --> B");
|
||||
const second = request(2, "flowchart TD\nA --> B\nB --> C");
|
||||
const latest = request(3, "flowchart TD\nA --> B\nB --> D");
|
||||
|
||||
driver.update(first);
|
||||
expect(driver.ready()).toBe(first);
|
||||
expect(driver.settled(first.revision, true)).toBeNull();
|
||||
expect(driver.update(second)).toBe(second);
|
||||
expect(driver.update(latest)).toBeNull();
|
||||
expect(driver.settled(second.revision, false)).toBe(latest);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { MermaidRenderRequest } from "../render-model";
|
||||
|
||||
function isCompletedLineCandidate(source: string): boolean {
|
||||
return source.endsWith("\n") && source.trim().includes("\n");
|
||||
}
|
||||
|
||||
export class MermaidRuntimeRequestDriver {
|
||||
private readyState = false;
|
||||
private renderedOnce = false;
|
||||
private inFlight: MermaidRenderRequest | null = null;
|
||||
private queued: MermaidRenderRequest[] = [];
|
||||
|
||||
update(request: MermaidRenderRequest | null): MermaidRenderRequest | null {
|
||||
if (!request || this.hasRevision(request.revision)) {
|
||||
return null;
|
||||
}
|
||||
if (this.renderedOnce) {
|
||||
this.queued = [request];
|
||||
} else {
|
||||
const lastIndex = this.queued.length - 1;
|
||||
const last = this.queued[lastIndex];
|
||||
if (last && !isCompletedLineCandidate(last.source)) {
|
||||
this.queued[lastIndex] = request;
|
||||
} else {
|
||||
this.queued.push(request);
|
||||
}
|
||||
}
|
||||
return this.takeNext();
|
||||
}
|
||||
|
||||
ready(): MermaidRenderRequest | null {
|
||||
this.readyState = true;
|
||||
return this.takeNext();
|
||||
}
|
||||
|
||||
settled(revision: number, rendered: boolean): MermaidRenderRequest | null {
|
||||
if (this.inFlight?.revision !== revision) {
|
||||
return null;
|
||||
}
|
||||
this.inFlight = null;
|
||||
if (rendered) {
|
||||
this.renderedOnce = true;
|
||||
const latest = this.queued.at(-1);
|
||||
this.queued = latest ? [latest] : [];
|
||||
}
|
||||
return this.takeNext();
|
||||
}
|
||||
|
||||
private hasRevision(revision: number): boolean {
|
||||
return (
|
||||
this.inFlight?.revision === revision ||
|
||||
this.queued.some((request) => request.revision === revision)
|
||||
);
|
||||
}
|
||||
|
||||
private takeNext(): MermaidRenderRequest | null {
|
||||
if (!this.readyState || this.inFlight || this.queued.length === 0) {
|
||||
return null;
|
||||
}
|
||||
this.inFlight = this.queued.shift() ?? null;
|
||||
return this.inFlight;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mermaidRuntimeHtml } from "./html.gen";
|
||||
|
||||
describe("Mermaid runtime document", () => {
|
||||
it("ships a closed network policy", () => {
|
||||
expect(mermaidRuntimeHtml).toContain("default-src 'none'");
|
||||
expect(mermaidRuntimeHtml).toContain("connect-src 'none'");
|
||||
expect(mermaidRuntimeHtml).toContain("img-src data: blob:");
|
||||
expect(mermaidRuntimeHtml).toContain("font-src 'none'");
|
||||
expect(mermaidRuntimeHtml).toContain("frame-src 'none'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mermaidRuntimeHtml } from "./html.gen";
|
||||
import { parseMermaidRuntimeMessage, type MermaidRuntimeMessage } from "./messages";
|
||||
|
||||
const mountedFrames: HTMLIFrameElement[] = [];
|
||||
|
||||
function waitForRuntimeMessage(
|
||||
frame: HTMLIFrameElement,
|
||||
predicate: (message: MermaidRuntimeMessage) => boolean,
|
||||
): Promise<MermaidRuntimeMessage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
window.removeEventListener("message", receive);
|
||||
reject(new Error("Timed out waiting for Mermaid runtime"));
|
||||
}, 10_000);
|
||||
function receive(event: MessageEvent): void {
|
||||
if (event.source !== frame.contentWindow) {
|
||||
return;
|
||||
}
|
||||
const message = parseMermaidRuntimeMessage(event.data);
|
||||
if (!message || !predicate(message)) {
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(timeout);
|
||||
window.removeEventListener("message", receive);
|
||||
resolve(message);
|
||||
}
|
||||
window.addEventListener("message", receive);
|
||||
});
|
||||
}
|
||||
|
||||
async function mountRuntime(size?: { width: number; height: number }): Promise<HTMLIFrameElement> {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.sandbox.add("allow-scripts");
|
||||
if (size) {
|
||||
frame.style.width = `${size.width}px`;
|
||||
frame.style.height = `${size.height}px`;
|
||||
}
|
||||
const ready = waitForRuntimeMessage(frame, (message) => message.type === "bridgeReady");
|
||||
frame.srcdoc = mermaidRuntimeHtml;
|
||||
document.body.append(frame);
|
||||
mountedFrames.push(frame);
|
||||
await ready;
|
||||
return frame;
|
||||
}
|
||||
|
||||
function renderedSize(message: MermaidRuntimeMessage): { height: number; width: number } {
|
||||
if (message.type !== "rendered") {
|
||||
throw new Error(`Expected a rendered diagram, got ${message.type}`);
|
||||
}
|
||||
return { height: message.height, width: message.width };
|
||||
}
|
||||
|
||||
function render(
|
||||
frame: HTMLIFrameElement,
|
||||
input: { revision: number; source: string },
|
||||
): Promise<MermaidRuntimeMessage> {
|
||||
const response = waitForRuntimeMessage(
|
||||
frame,
|
||||
(message) =>
|
||||
message.type !== "bridgeReady" &&
|
||||
"revision" in message &&
|
||||
message.revision === input.revision,
|
||||
);
|
||||
frame.contentWindow?.postMessage(
|
||||
{
|
||||
type: "render",
|
||||
revision: input.revision,
|
||||
source: input.source,
|
||||
colorScheme: "dark",
|
||||
interactive: false,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const frame of mountedFrames.splice(0)) {
|
||||
frame.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("Mermaid sandbox runtime", () => {
|
||||
it("renders successive valid streaming prefixes and reports an invalid prefix", async () => {
|
||||
const frame = await mountRuntime();
|
||||
const firstSource = "flowchart TD\nA --> B";
|
||||
const secondSource = `${firstSource}\nB --> C`;
|
||||
|
||||
const first = await render(frame, { revision: 1, source: firstSource });
|
||||
const invalid = await render(frame, { revision: 2, source: "not a mermaid diagram" });
|
||||
const second = await render(frame, { revision: 3, source: secondSource });
|
||||
|
||||
expect(first).toMatchObject({ type: "rendered", revision: 1, source: firstSource });
|
||||
expect(invalid).toEqual({ type: "renderError", revision: 2 });
|
||||
expect(second).toMatchObject({ type: "rendered", revision: 3, source: secondSource });
|
||||
});
|
||||
|
||||
/**
|
||||
* The host sizes this frame from the reported size, so a size that depends on the frame feeds
|
||||
* back: every re-render measures inside a frame the previous measurement already shrank by the
|
||||
* container's padding, and a streaming diagram ratchets down to a few pixels.
|
||||
*/
|
||||
it("reports the same size whatever frame the host gives it", async () => {
|
||||
const source = "flowchart TD\nA[Start] --> B[Middle]\nB --> C[Ship]";
|
||||
const narrowFrame = await mountRuntime({ height: 60, width: 60 });
|
||||
const wideFrame = await mountRuntime({ height: 600, width: 900 });
|
||||
|
||||
const narrow = await render(narrowFrame, { revision: 1, source });
|
||||
const wide = await render(wideFrame, { revision: 1, source });
|
||||
|
||||
expect(renderedSize(narrow)).toEqual(renderedSize(wide));
|
||||
});
|
||||
|
||||
it("coalesces queued input and never reports an obsolete result", async () => {
|
||||
const frame = await mountRuntime();
|
||||
const obsoleteSource = `flowchart TD\n${Array.from({ length: 250 }, (_, index) => `A${index} --> A${index + 1}`).join("\n")}`;
|
||||
const currentSource = "flowchart TD\nCurrent --> Result";
|
||||
const obsoleteResponses: MermaidRuntimeMessage[] = [];
|
||||
function collect(event: MessageEvent): void {
|
||||
if (event.source !== frame.contentWindow) {
|
||||
return;
|
||||
}
|
||||
const message = parseMermaidRuntimeMessage(event.data);
|
||||
if (message && message.type !== "bridgeReady" && message.revision === 10) {
|
||||
obsoleteResponses.push(message);
|
||||
}
|
||||
}
|
||||
window.addEventListener("message", collect);
|
||||
frame.contentWindow?.postMessage(
|
||||
{
|
||||
type: "render",
|
||||
revision: 10,
|
||||
source: obsoleteSource,
|
||||
colorScheme: "dark",
|
||||
interactive: false,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
const current = await render(frame, { revision: 11, source: currentSource });
|
||||
window.removeEventListener("message", collect);
|
||||
|
||||
expect(current).toMatchObject({ type: "rendered", revision: 11, source: currentSource });
|
||||
expect(obsoleteResponses).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import type { RootContent } from "mdast";
|
||||
|
||||
interface Segment {
|
||||
kind: "markdown" | "mermaid";
|
||||
offset: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/** Native enriched-markdown has no fenced-block override. Only diagrams leave its engine. */
|
||||
export function splitMermaidFences(markdown: string): Segment[] {
|
||||
if (!/mermaid/i.test(markdown)) return [{ kind: "markdown", offset: 0, source: markdown }];
|
||||
const segments: Segment[] = [];
|
||||
let cursor = 0;
|
||||
function visit(node: RootContent): void {
|
||||
if (node.type === "code" && node.lang?.toLowerCase() === "mermaid" && node.position) {
|
||||
const start = node.position.start.offset!;
|
||||
const end = node.position.end.offset!;
|
||||
if (start > cursor)
|
||||
segments.push({ kind: "markdown", offset: cursor, source: markdown.slice(cursor, start) });
|
||||
segments.push({ kind: "mermaid", offset: start, source: node.value });
|
||||
cursor = end;
|
||||
} else if ("children" in node) node.children.forEach(visit);
|
||||
}
|
||||
const tree = fromMarkdown(markdown);
|
||||
tree.children.forEach(visit);
|
||||
if (cursor < markdown.length || segments.length === 0)
|
||||
segments.push({ kind: "markdown", offset: cursor, source: markdown.slice(cursor) });
|
||||
// Reference links keep their document-wide scope on both sides of a diagram.
|
||||
if (segments.some((segment) => segment.kind === "mermaid")) {
|
||||
const definitions = tree.children
|
||||
.filter((node) => node.type === "definition")
|
||||
.map((node) => markdown.slice(node.position!.start.offset, node.position!.end.offset))
|
||||
.join("\n");
|
||||
if (definitions)
|
||||
for (const segment of segments) {
|
||||
if (segment.kind === "markdown") segment.source += `\n\n${definitions}`;
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { containsUnsafeMermaidSource } from "./source-policy";
|
||||
|
||||
describe("containsUnsafeMermaidSource", () => {
|
||||
it("rejects resource-bearing constructs", () => {
|
||||
expect(containsUnsafeMermaidSource('flowchart TD\n A@{ img: "https://x/y.png" }')).toBe(true);
|
||||
expect(containsUnsafeMermaidSource("flowchart TD\n A@{ icon: 'pack:name' }")).toBe(true);
|
||||
expect(
|
||||
containsUnsafeMermaidSource('%%{init: {"themeCSS": "a { color: red }"}}%%\ngraph TD'),
|
||||
).toBe(true);
|
||||
expect(containsUnsafeMermaidSource("graph TD\n A[url(http://x)]")).toBe(true);
|
||||
expect(containsUnsafeMermaidSource("graph TD\n A[@import 'x']")).toBe(true);
|
||||
expect(containsUnsafeMermaidSource('graph TD\n A["<img src=x>"]')).toBe(true);
|
||||
expect(containsUnsafeMermaidSource('graph TD\n A["<i class=x>styled</i>"]')).toBe(true);
|
||||
expect(containsUnsafeMermaidSource('graph TD\n A["<img src=x>"]')).toBe(true);
|
||||
expect(containsUnsafeMermaidSource('graph TD\n A["</b>"]')).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects all shape-data constructs, including yaml key evasions", () => {
|
||||
expect(containsUnsafeMermaidSource('flowchart TD\n A@{ "img": "https://x/y.png" }')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(containsUnsafeMermaidSource("flowchart TD\n A@{ 'img': 'https://x/y.png' }")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
containsUnsafeMermaidSource('flowchart TD\n A@{ "\\u0069mg": "https://x/y.png" }'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
containsUnsafeMermaidSource('flowchart TD\n A@{ "\\u{69}mg": "https://x/y.png" }'),
|
||||
).toBe(true);
|
||||
expect(containsUnsafeMermaidSource('flowchart TD\n A@{ "\\x69mg": "https://x/y.png" }')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
containsUnsafeMermaidSource('flowchart TD\n A@{ "\\U00000069mg": "https://attacker/x" }'),
|
||||
).toBe(true);
|
||||
expect(containsUnsafeMermaidSource('flowchart TD\n A@{ "icon": "pack:name" }')).toBe(true);
|
||||
expect(
|
||||
containsUnsafeMermaidSource(
|
||||
'flowchart TD\n A@{ ? img # comment\n : "https://attacker/x" }',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
containsUnsafeMermaidSource(
|
||||
'flowchart TD\n A@{ dummy: &k img\n ? *k # comment\n : "https://attacker/x" }',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails closed for malformed or out-of-range escapes without throwing", () => {
|
||||
expect(() =>
|
||||
containsUnsafeMermaidSource('graph TD\n A["\\u{110000} disguised"]'),
|
||||
).not.toThrow();
|
||||
expect(containsUnsafeMermaidSource('graph TD\n A["\\u{110000} disguised"]')).toBe(true);
|
||||
expect(() =>
|
||||
containsUnsafeMermaidSource('graph TD\n A["\\u{FFFFFF} disguised"]'),
|
||||
).not.toThrow();
|
||||
expect(containsUnsafeMermaidSource('graph TD\n A["\\u{FFFFFF} disguised"]')).toBe(true);
|
||||
});
|
||||
|
||||
it("allows ordinary diagrams including formatting-only labels", () => {
|
||||
expect(containsUnsafeMermaidSource("flowchart TD\n A[Start] --> B{Choice}")).toBe(false);
|
||||
expect(containsUnsafeMermaidSource('graph TD\n A["line one<br>line two"]')).toBe(false);
|
||||
expect(containsUnsafeMermaidSource('graph TD\n A["line one<br/>line two"]')).toBe(false);
|
||||
expect(containsUnsafeMermaidSource('graph TD\n A["<i>formatted</i>"]')).toBe(false);
|
||||
expect(containsUnsafeMermaidSource("sequenceDiagram\n Alice->>Bob: a < b and x > y")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(containsUnsafeMermaidSource("flowchart TD\n A@{ shape: rect }")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// Mermaid can fetch external resources while *rendering* — image shapes
|
||||
// (`A@{ img: "url" }`) construct an Image and await decode before any output
|
||||
// sanitization runs, and CSS can pull url()/@import — so a prompt-injected
|
||||
// diagram could exfiltrate data in a request URL. securityLevel "strict" does
|
||||
// not prevent this (mermaid-js/mermaid#7645). Mermaid 11.16 parses the shape
|
||||
// metadata object with yaml.JSON_SCHEMA semantics, including aliases and
|
||||
// explicit keys; matching those keys safely would require a real parser. We
|
||||
// therefore reject any `@{ ... }` shape-data construct up front and fall back
|
||||
// to the source code block. Formatting-only `<br>` and `<i>` tags are common
|
||||
// in generated labels and carry no resource-bearing attributes; all other
|
||||
// tags (and entity-encoded text that could smuggle one) are rejected.
|
||||
const UNSAFE_MERMAID_SOURCE =
|
||||
/@\s*\{|url\s*\(|@import\b|themeCSS|&#|<(?!\/?(?:br|i)\s*\/?>)[a-z!/]/i;
|
||||
|
||||
// Mermaid labels can contain escaped text. Decode the escape forms we care
|
||||
// about before running the denylist so disguised HTML still gets caught.
|
||||
// Invalid escape sequences fail closed and fall back to the source block.
|
||||
function normalizeMermaidSource(code: string): string | null {
|
||||
try {
|
||||
return code
|
||||
.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (_, hex: string) =>
|
||||
decodeCodePointEscape(Number.parseInt(hex, 16)),
|
||||
)
|
||||
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) =>
|
||||
String.fromCharCode(Number.parseInt(hex, 16)),
|
||||
)
|
||||
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex: string) =>
|
||||
String.fromCharCode(Number.parseInt(hex, 16)),
|
||||
)
|
||||
.replace(/["'`\\]/g, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeCodePointEscape(value: number): string {
|
||||
if (!Number.isInteger(value) || value < 0 || value > 0x10ffff) {
|
||||
throw new RangeError("Invalid Unicode code point");
|
||||
}
|
||||
return String.fromCodePoint(value);
|
||||
}
|
||||
|
||||
export function containsUnsafeMermaidSource(code: string): boolean {
|
||||
if (UNSAFE_MERMAID_SOURCE.test(code)) {
|
||||
return true;
|
||||
}
|
||||
const normalized = normalizeMermaidSource(code);
|
||||
if (normalized === null) {
|
||||
return true;
|
||||
}
|
||||
return UNSAFE_MERMAID_SOURCE.test(normalized);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { TextStyle } from "react-native";
|
||||
export type MarkdownPhase = "streaming" | "complete";
|
||||
export interface MarkdownFenceRendererProps {
|
||||
code: string;
|
||||
phase: MarkdownPhase;
|
||||
inheritedStyles: TextStyle;
|
||||
textStyle: TextStyle;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer } from "react";
|
||||
import {
|
||||
createMermaidRenderModel,
|
||||
getMermaidRenderRequest,
|
||||
reduceMermaidRenderModel,
|
||||
type DiagramColorScheme,
|
||||
type DiagramDimensions,
|
||||
type RenderedDiagram,
|
||||
} from "./render-model";
|
||||
import { containsUnsafeMermaidSource } from "./source-policy";
|
||||
import type { MarkdownPhase } from "./types";
|
||||
|
||||
const renderCache = new Map<string, RenderedDiagram>();
|
||||
const RENDER_CACHE_LIMIT = 50;
|
||||
|
||||
function cacheKey(source: string, colorScheme: DiagramColorScheme): string {
|
||||
return `${colorScheme}\u0000${source}`;
|
||||
}
|
||||
|
||||
function readCachedRender(source: string, colorScheme: DiagramColorScheme): RenderedDiagram | null {
|
||||
return renderCache.get(cacheKey(source, colorScheme)) ?? null;
|
||||
}
|
||||
|
||||
function cacheRender(rendered: RenderedDiagram): void {
|
||||
if (renderCache.size >= RENDER_CACHE_LIMIT) {
|
||||
const oldest = renderCache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
renderCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
renderCache.set(cacheKey(rendered.source, rendered.colorScheme), rendered);
|
||||
}
|
||||
|
||||
export function useMermaidRenderModel({
|
||||
source,
|
||||
phase,
|
||||
colorScheme,
|
||||
}: {
|
||||
source: string;
|
||||
phase: MarkdownPhase;
|
||||
colorScheme: DiagramColorScheme;
|
||||
}) {
|
||||
const renderInput = useMemo(
|
||||
() => ({
|
||||
source,
|
||||
phase,
|
||||
colorScheme,
|
||||
rejected: containsUnsafeMermaidSource(source),
|
||||
cached: readCachedRender(source, colorScheme),
|
||||
}),
|
||||
[colorScheme, phase, source],
|
||||
);
|
||||
const [state, dispatch] = useReducer(
|
||||
reduceMermaidRenderModel,
|
||||
renderInput,
|
||||
createMermaidRenderModel,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: "inputChanged", input: renderInput });
|
||||
}, [renderInput]);
|
||||
|
||||
const rendered = useCallback(
|
||||
(response: {
|
||||
revision: number;
|
||||
source: string;
|
||||
colorScheme: DiagramColorScheme;
|
||||
dimensions: DiagramDimensions;
|
||||
}) => {
|
||||
const cached: RenderedDiagram = {
|
||||
source: response.source,
|
||||
colorScheme: response.colorScheme,
|
||||
...response.dimensions,
|
||||
};
|
||||
cacheRender(cached);
|
||||
dispatch({
|
||||
type: "rendered",
|
||||
revision: response.revision,
|
||||
source: response.source,
|
||||
colorScheme: response.colorScheme,
|
||||
dimensions: response.dimensions,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const renderFailed = useCallback((revision: number) => {
|
||||
dispatch({ type: "renderFailed", revision });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
request: getMermaidRenderRequest(state),
|
||||
rendered,
|
||||
renderFailed,
|
||||
};
|
||||
}
|
||||
@@ -34,10 +34,7 @@ export interface MarkdownImageContext {
|
||||
|
||||
export function useMarkdownSource(text: string, context: MarkdownImageContext) {
|
||||
const occurrenceKey = useId();
|
||||
const prepared = useMemo(
|
||||
() => prepareMarkdownSource(text, context.workspaceRoot),
|
||||
[text, context.workspaceRoot],
|
||||
);
|
||||
const prepared = useMemo(() => prepareMarkdownSource(text), [text]);
|
||||
const sources = [...new Set(prepared.images.map((image) => image.source))];
|
||||
const acquisitions = sources.map((source) => {
|
||||
const resolved = context.filePath
|
||||
@@ -95,7 +92,6 @@ export function useMarkdownSource(text: string, context: MarkdownImageContext) {
|
||||
return {
|
||||
markdown: prepared.render(resolved),
|
||||
linkRecognition: prepared.linkRecognition,
|
||||
linkPillContent: prepared.linkPillContent,
|
||||
linkSource: prepared.linkSource,
|
||||
imageSources: Object.fromEntries([...resolved].map(([source, url]) => [url, source])),
|
||||
};
|
||||
|
||||
@@ -48,18 +48,64 @@ it.each([
|
||||
"src/components/message.tsx",
|
||||
"C:\\repo\\file.ts",
|
||||
"docs/说明.md",
|
||||
"my%20file.ts",
|
||||
"src/file.ts:10-20",
|
||||
"src/file.ts(10,2)",
|
||||
])("recognizes inline paths through the file resolver: %s", (path) => {
|
||||
const prepared = prepareMarkdownSource(`Open \`${path}\`.`, "/workspace");
|
||||
const prepared = prepareMarkdownSource(`Open \`${path}\`.`);
|
||||
expect(prepared.linkRecognition.inlineCode?.test(path)).toBe(true);
|
||||
expect(prepared.linkSource(path)).toEqual({ href: path, text: path, sourceType: "inline-code" });
|
||||
expect(prepared.linkSource(path, "inline-code")).toEqual({
|
||||
href: path,
|
||||
text: path,
|
||||
sourceType: "inline-code",
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes bare Unicode filenames and preserves inline-code URLs", () => {
|
||||
const prepared = prepareMarkdownSource("Read 说明.md, or `https://example.com`.", "/workspace");
|
||||
const prepared = prepareMarkdownSource("Read 说明.md, or `https://example.com`.");
|
||||
expect([..."说明.md,".matchAll(prepared.linkRecognition.text)].map(([text]) => text)).toEqual([
|
||||
"说明.md",
|
||||
]);
|
||||
expect(prepared.linkRecognition.inlineCode?.test("https://example.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps explicit and inline-code links distinct when their URL strings match", () => {
|
||||
const url = "/tmp/my%20file.ts";
|
||||
const prepared = prepareMarkdownSource(`[open](${url}) and \`${url}\``);
|
||||
expect(prepared.linkSource(url, "markdown")).toEqual({ href: url, text: url });
|
||||
expect(prepared.linkSource(url, "inline-code")).toEqual({
|
||||
href: url,
|
||||
text: url,
|
||||
sourceType: "inline-code",
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts only Mermaid fences for the native diagram host", async () => {
|
||||
const { splitMermaidFences } = await import("./fence/mermaid/segments");
|
||||
const source =
|
||||
"First.\n\n```ts\nconst n = 1;\n```\n\n```mermaid\ngraph TD\nA-->B\n```\n\n> Last.";
|
||||
expect(splitMermaidFences(source)).toEqual([
|
||||
{ kind: "markdown", offset: 0, source: "First.\n\n```ts\nconst n = 1;\n```\n\n" },
|
||||
{ kind: "mermaid", offset: 32, source: "graph TD\nA-->B" },
|
||||
{ kind: "markdown", offset: 61, source: "\n\n> Last." },
|
||||
]);
|
||||
expect(splitMermaidFences("```md\n```mermaid\n```")).toEqual([
|
||||
{ kind: "markdown", offset: 0, source: "```md\n```mermaid\n```" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps reference links available across native Mermaid boundaries", async () => {
|
||||
const { splitMermaidFences } = await import("./fence/mermaid/segments");
|
||||
const parts = splitMermaidFences(
|
||||
"[Before][file]\n\n```mermaid\ngraph TD\nA-->B\n```\n\n[After][file]\n\n[file]: src/file.ts",
|
||||
);
|
||||
for (const part of parts.filter((candidate) => candidate.kind === "markdown"))
|
||||
expect(part.source).toContain("[file]: src/file.ts");
|
||||
});
|
||||
|
||||
it("recognizes mixed-case Mermaid fence info and incomplete streaming fences", async () => {
|
||||
const { splitMermaidFences } = await import("./fence/mermaid/segments");
|
||||
expect(splitMermaidFences("```Mermaid title\ngraph TD\nA-->B")).toEqual([
|
||||
{ kind: "mermaid", offset: 0, source: "graph TD\nA-->B" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { View } from "react-native";
|
||||
import { markdownCopyImageSourcesDataSet } from "@/assistant-selection-copy/markup";
|
||||
import { memo, useState } from "react";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import {
|
||||
EnrichedMarkdownText,
|
||||
type MarkdownStyle,
|
||||
type LinkPressEvent,
|
||||
type CodeBlockRendererProps,
|
||||
} from "react-native-enriched-markdown";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
@@ -18,6 +19,8 @@ import type { Theme } from "@/styles/theme";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useMarkdownSource, type MarkdownImageContext } from "./images";
|
||||
|
||||
import { MermaidFence, MermaidStreamingContext, splitMermaidFences } from "./fence/mermaid";
|
||||
|
||||
const ThemedMarkdown = withUnistyles(EnrichedMarkdownText);
|
||||
const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true };
|
||||
|
||||
@@ -109,6 +112,7 @@ const codeStyle = (theme: Theme) => ({
|
||||
textStyle: { backgroundColor: theme.colors.surface2, padding: 12, borderRadius: 8 },
|
||||
});
|
||||
function WebCodeBlock({ code, language }: CodeBlockRendererProps) {
|
||||
if (language?.toLowerCase() === "mermaid") return <MermaidFence code={code} />;
|
||||
return <ThemedCodeBlock code={code} language={language} uniProps={codeStyle} />;
|
||||
}
|
||||
const WEB_CONTAINER = { userSelect: "text" as const };
|
||||
@@ -117,6 +121,7 @@ const WEB_RENDERERS = { codeBlock: WebCodeBlock };
|
||||
export interface MarkdownRendererProps extends MarkdownImageContext {
|
||||
text: string;
|
||||
compact?: boolean;
|
||||
streaming?: boolean;
|
||||
onLinkPress?: (url: string) => unknown;
|
||||
}
|
||||
|
||||
@@ -124,6 +129,7 @@ export interface MarkdownRendererProps extends MarkdownImageContext {
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({
|
||||
text,
|
||||
compact = false,
|
||||
streaming = false,
|
||||
onLinkPress,
|
||||
...imageContext
|
||||
}: MarkdownRendererProps) {
|
||||
@@ -134,16 +140,15 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
|
||||
(environment.client?.readFile
|
||||
? { readFile: environment.client.readFile.bind(environment.client) }
|
||||
: undefined);
|
||||
const { markdown, imageSources, linkRecognition, linkPillContent, linkSource } =
|
||||
useMarkdownSource(text, {
|
||||
workspaceRoot: environment.workspaceRoot,
|
||||
serverId: environment.serverId,
|
||||
...imageContext,
|
||||
client,
|
||||
});
|
||||
const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(text, {
|
||||
workspaceRoot: environment.workspaceRoot,
|
||||
serverId: environment.serverId,
|
||||
...imageContext,
|
||||
client,
|
||||
});
|
||||
const [image, setImage] = useState<ImageLightboxSource | null>(null);
|
||||
const pressLink = useStableEvent(({ url }: { url: string }) => {
|
||||
const source = linkSource(url);
|
||||
const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => {
|
||||
const source = linkSource(url, sourceType);
|
||||
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
|
||||
else actions.open(source, "preferred");
|
||||
});
|
||||
@@ -151,27 +156,38 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
|
||||
setImage({ type: "uri", uri: url }),
|
||||
);
|
||||
const closeImage = useStableEvent(() => setImage(null));
|
||||
const segments = useMemo(
|
||||
() =>
|
||||
isWeb ? [{ kind: "markdown", offset: 0, source: markdown }] : splitMermaidFences(markdown),
|
||||
[markdown],
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
<ThemedMarkdown
|
||||
markdown={markdown}
|
||||
containerStyle={isWeb ? WEB_CONTAINER : undefined}
|
||||
uniProps={compact ? compactStyle : regularStyle}
|
||||
flavor="github"
|
||||
selectable
|
||||
md4cFlags={MD4C_FLAGS}
|
||||
linkRecognition={linkRecognition}
|
||||
linkPillContent={linkPillContent}
|
||||
enableTaskListItemToggle={false}
|
||||
enableLinkPreview={false}
|
||||
enableBlockContextMenu={false}
|
||||
streamingAnimation={false}
|
||||
onLinkPress={pressLink}
|
||||
onImagePress={pressImage}
|
||||
renderers={isWeb ? WEB_RENDERERS : undefined}
|
||||
/>
|
||||
<MermaidStreamingContext.Provider value={streaming}>
|
||||
{segments.map((segment) =>
|
||||
segment.kind === "mermaid" ? (
|
||||
<MermaidFence key={segment.offset} code={segment.source} />
|
||||
) : (
|
||||
<ThemedMarkdown
|
||||
key={segment.offset}
|
||||
markdown={segment.source}
|
||||
containerStyle={isWeb ? WEB_CONTAINER : undefined}
|
||||
uniProps={compact ? compactStyle : regularStyle}
|
||||
flavor="github"
|
||||
selectable
|
||||
md4cFlags={MD4C_FLAGS}
|
||||
linkRecognition={linkRecognition}
|
||||
enableTaskListItemToggle={false}
|
||||
enableLinkPreview={false}
|
||||
enableBlockContextMenu={false}
|
||||
streamingAnimation={false}
|
||||
onLinkPress={pressLink}
|
||||
onImagePress={pressImage}
|
||||
renderers={isWeb ? WEB_RENDERERS : undefined}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{image ? <AttachmentLightbox source={image} onClose={closeImage} /> : null}
|
||||
</>
|
||||
</MermaidStreamingContext.Provider>
|
||||
);
|
||||
return isWeb && Object.keys(imageSources).length > 0 ? (
|
||||
<View dataSet={markdownCopyImageSourcesDataSet(imageSources)}>{content}</View>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { gfmTable } from "micromark-extension-gfm-table";
|
||||
import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
|
||||
import { classifyAssistantFileLink } from "@/assistant-file-links/paths";
|
||||
import type { AssistantFileLinkSource } from "@/assistant-file-links";
|
||||
import { decodeString } from "micromark-util-decode-string";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
@@ -9,35 +8,23 @@ import type { Nodes, 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.
|
||||
const FILE_PATTERN =
|
||||
/(?:https?:\/\/[^\s<>]+|(?:[A-Za-z]:[\\/]|\.{0,2}\/|~\/)?[a-zA-Z0-9\u0080-\uFFFF_@.%+-]+(?:[\\/][a-zA-Z0-9\u0080-\uFFFF_@.%+-]+)+(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?|[a-zA-Z0-9\u0080-\uFFFF_@-]+\.[a-zA-Z0-9\u0080-\uFFFF_-]+(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?)/g;
|
||||
/(?:https?:\/\/[^\s<>]+|(?:[A-Za-z]:[\\/]|\.{0,2}\/|~\/)?[a-zA-Z0-9\u0080-\uFFFF_@.%+-]+(?:[\\/][a-zA-Z0-9\u0080-\uFFFF_@.%+-]+)+(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?|[a-zA-Z0-9\u0080-\uFFFF_@%-]+\.[a-zA-Z0-9\u0080-\uFFFF_%-]+(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?)/g;
|
||||
|
||||
function linkMetadata(source: string, getTree: () => Root, workspaceRoot?: string) {
|
||||
function linkMetadata() {
|
||||
return {
|
||||
linkPillContent: Object.fromEntries(
|
||||
[...source.matchAll(FILE_PATTERN)]
|
||||
.filter(([url]) => !/^https?:/i.test(url))
|
||||
.map(([url]) => [url, { label: url }]),
|
||||
),
|
||||
linkRecognition: {
|
||||
text: FILE_PATTERN,
|
||||
inlineCode: new RegExp(
|
||||
String.raw`^(?:${FILE_PATTERN.source})(?:\(\d+(?:,\d+)?(?:-\d+(?:,\d+)?)?\)| lines? \d+(?:-\d+)?)?$`,
|
||||
),
|
||||
},
|
||||
linkSource(url: string): AssistantFileLinkSource {
|
||||
function containsInlinePath(node: Nodes): boolean {
|
||||
return (
|
||||
(node.type === "inlineCode" && node.value === url) ||
|
||||
("children" in node && node.children.some(containsInlinePath))
|
||||
);
|
||||
}
|
||||
// The library emits only the URL. Recover raw inline-code semantics at tap time,
|
||||
// without parsing every reveal frame merely to configure link recognition.
|
||||
return source.includes("`") &&
|
||||
classifyAssistantFileLink(url, { workspaceRoot }) &&
|
||||
containsInlinePath(getTree())
|
||||
? { href: url, text: url, sourceType: "inline-code" }
|
||||
: { href: url, text: url };
|
||||
linkSource(
|
||||
url: string,
|
||||
sourceType?: "markdown" | "text" | "inline-code",
|
||||
): AssistantFileLinkSource {
|
||||
if (sourceType === "inline-code") return { href: url, text: url, sourceType };
|
||||
if (sourceType === "text") return { href: url, text: url, markup: "linkify" };
|
||||
return { href: url, text: url };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -51,7 +38,7 @@ export interface MarkdownImage {
|
||||
}
|
||||
|
||||
/** Preserve source spelling; adapt workspace images, spoiler delimiters, and entity text. */
|
||||
export function prepareMarkdownSource(source: string, workspaceRoot?: string) {
|
||||
export function prepareMarkdownSource(source: string) {
|
||||
const images: MarkdownImage[] = [];
|
||||
let tree: Root | undefined;
|
||||
const getTree = () =>
|
||||
@@ -61,7 +48,7 @@ export function prepareMarkdownSource(source: string, workspaceRoot?: string) {
|
||||
}));
|
||||
const edits: { start: number; end: number; text: string }[] = [];
|
||||
if (!source.includes("![") && !source.includes("||") && !source.includes("&")) {
|
||||
return { images, ...linkMetadata(source, getTree, workspaceRoot), render: () => source };
|
||||
return { images, ...linkMetadata(), render: () => source };
|
||||
}
|
||||
|
||||
const definitions = new Map<string, { url: string; title?: string | null }>();
|
||||
@@ -109,7 +96,7 @@ export function prepareMarkdownSource(source: string, workspaceRoot?: string) {
|
||||
visit(getTree());
|
||||
return {
|
||||
images,
|
||||
...linkMetadata(source, getTree, workspaceRoot),
|
||||
...linkMetadata(),
|
||||
render(urls: ReadonlyMap<string, string> = new Map()) {
|
||||
const replacements = [...edits];
|
||||
for (const image of images) {
|
||||
|
||||
@@ -1185,6 +1185,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
>
|
||||
<MarkdownRenderer
|
||||
text={revealedMessage}
|
||||
streaming={phase !== "complete" || revealedMessage.length < renderedMessage.text.length}
|
||||
client={client}
|
||||
workspaceRoot={workspaceRoot}
|
||||
serverId={serverId}
|
||||
|
||||
Reference in New Issue
Block a user