feat(chat): add Mermaid diagram zoom controls (#2100)
* feat(chat): add mermaid diagram zoom controls * fix(chat): handle malformed mermaid data urls * fix(chat): preserve mermaid load error stack --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
dfed121bf1
commit
3d32ac7989
@@ -29,8 +29,10 @@ import {
|
||||
syncMarkdownCodeLineNumbers,
|
||||
type DecorateContext,
|
||||
type DecorateLabels,
|
||||
type MermaidControlOptions,
|
||||
type MermaidRender,
|
||||
} from './markdown/decorate';
|
||||
import { createMermaidViewerRegistry, MERMAID_BLOCK_SELECTOR, shouldRefreshMermaidViewers } from './markdown/mermaidViewer';
|
||||
import {
|
||||
BLOCK_PATH_TOKEN_RE,
|
||||
isAbsoluteReferencePath,
|
||||
@@ -103,27 +105,12 @@ const useExternalLinkInteractions = ({
|
||||
}, [containerRef, enabled]);
|
||||
};
|
||||
|
||||
type MermaidControlOptions = {
|
||||
download: boolean;
|
||||
copy: boolean;
|
||||
fullscreen: boolean;
|
||||
panZoom: boolean;
|
||||
};
|
||||
|
||||
const extractMermaidBlocks = (markdown: string): string[] => {
|
||||
if (!markdown.includes('mermaid')) return [];
|
||||
const blocks: string[] = [];
|
||||
const regex = /(?:^|\r?\n)(`{3,}|~{3,})mermaid[^\n\r]*\r?\n([\s\S]*?)\r?\n\1(?=\r?\n|$)/gi;
|
||||
let match: RegExpExecArray | null = regex.exec(markdown);
|
||||
|
||||
while (match) {
|
||||
const block = (match[2] ?? '').replace(/\s+$/, '');
|
||||
blocks.push(block);
|
||||
match = regex.exec(markdown);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
const DEFAULT_MERMAID_CONTROLS: MermaidControlOptions = {
|
||||
download: true,
|
||||
copy: true,
|
||||
showPanZoomControls: true,
|
||||
};
|
||||
const DEFAULT_MERMAID_FULLSCREEN_ENABLED = true;
|
||||
|
||||
const stripLeadingFrontmatter = (markdown: string): string => {
|
||||
const frontmatterMatch = markdown.match(
|
||||
@@ -153,7 +140,6 @@ interface MarkdownRendererProps {
|
||||
enableFileReferences?: boolean;
|
||||
}
|
||||
|
||||
const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]';
|
||||
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
|
||||
const BLOCK_PATH_TOKEN_ATTR = 'data-openchamber-block-path-token';
|
||||
const BLOCK_PATH_TOKEN_SELECTOR = `[${BLOCK_PATH_TOKEN_ATTR}]`;
|
||||
@@ -666,14 +652,16 @@ const useFileReferenceInteractions = ({
|
||||
|
||||
const useMermaidInlineInteractions = ({
|
||||
containerRef,
|
||||
mermaidBlocks,
|
||||
onShowPopup,
|
||||
allowWheelZoom,
|
||||
enableFullscreen,
|
||||
enablePanZoom,
|
||||
allowMermaidWheelEvents,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
mermaidBlocks: string[];
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
allowWheelZoom?: boolean;
|
||||
enableFullscreen?: boolean;
|
||||
enablePanZoom?: boolean;
|
||||
allowMermaidWheelEvents?: boolean;
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -682,7 +670,7 @@ const useMermaidInlineInteractions = ({
|
||||
}
|
||||
|
||||
const handleMermaidClick = (event: MouseEvent) => {
|
||||
if (!onShowPopup) {
|
||||
if (!enableFullscreen || !onShowPopup) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -700,13 +688,18 @@ const useMermaidInlineInteractions = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const renderedBlocks = Array.from(container.querySelectorAll(MERMAID_BLOCK_SELECTOR));
|
||||
const blockIndex = renderedBlocks.indexOf(block);
|
||||
if (block instanceof HTMLElement && block.hasAttribute('data-mermaid-suppress-click')) {
|
||||
block.removeAttribute('data-mermaid-suppress-click');
|
||||
return;
|
||||
}
|
||||
|
||||
const renderedBlocks = Array.from(container.querySelectorAll<HTMLElement>(MERMAID_BLOCK_SELECTOR));
|
||||
const blockIndex = renderedBlocks.indexOf(block as HTMLElement);
|
||||
if (blockIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = mermaidBlocks[blockIndex];
|
||||
const source = block instanceof HTMLElement ? block.getAttribute('data-md-source') : null;
|
||||
if (!source || source.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -729,7 +722,7 @@ const useMermaidInlineInteractions = ({
|
||||
};
|
||||
|
||||
const handleInlineWheel = (event: WheelEvent) => {
|
||||
if (allowWheelZoom) {
|
||||
if (allowMermaidWheelEvents || ((event.ctrlKey || event.metaKey) && enablePanZoom)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -754,7 +747,7 @@ const useMermaidInlineInteractions = ({
|
||||
container.removeEventListener('click', handleMermaidClick);
|
||||
container.removeEventListener('wheel', handleInlineWheel, true);
|
||||
};
|
||||
}, [allowWheelZoom, containerRef, mermaidBlocks, onShowPopup]);
|
||||
}, [allowMermaidWheelEvents, containerRef, enableFullscreen, enablePanZoom, onShowPopup]);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -862,17 +855,21 @@ const useDecorateContext = (
|
||||
currentTheme: Theme,
|
||||
deferCodeLineNumberSync: boolean,
|
||||
onPreviewLoopback?: (url: string) => void,
|
||||
mermaidControls: MermaidControlOptions = DEFAULT_MERMAID_CONTROLS,
|
||||
): DecorateContext => {
|
||||
const { t } = useI18n();
|
||||
const labels: DecorateLabels = React.useMemo(() => ({
|
||||
copy: 'Copy code',
|
||||
copied: 'Copied',
|
||||
copy: t('markdownRenderer.code.actions.copyTitle'),
|
||||
copied: t('markdownRenderer.code.actions.copiedTitle'),
|
||||
enableCodeWrap: t('markdownRenderer.code.actions.enableWrapTitle'),
|
||||
disableCodeWrap: t('markdownRenderer.code.actions.disableWrapTitle'),
|
||||
copyTable: t('markdownRenderer.table.actions.copyTitle'),
|
||||
downloadTable: t('markdownRenderer.table.actions.downloadTitle'),
|
||||
copyDiagram: t('markdownRenderer.mermaid.actions.copySourceTitle'),
|
||||
downloadDiagram: t('markdownRenderer.mermaid.actions.downloadSvgTitle'),
|
||||
zoomInDiagram: t('markdownRenderer.mermaid.actions.zoomInTitle'),
|
||||
zoomOutDiagram: t('markdownRenderer.mermaid.actions.zoomOutTitle'),
|
||||
resetDiagramView: t('markdownRenderer.mermaid.actions.resetViewTitle'),
|
||||
previewLabel: t('terminalView.preview.open'),
|
||||
previewTitle: t('terminalView.preview.openTitle'),
|
||||
}), [t]);
|
||||
@@ -896,8 +893,8 @@ const useDecorateContext = (
|
||||
return {};
|
||||
}
|
||||
});
|
||||
return { labels, codeBlockLineWrap, deferCodeLineNumberSync, onToggleCodeBlockLineWrap: toggleCodeBlockLineWrap, renderMermaid, onPreviewLoopback };
|
||||
}, [currentTheme, labels, codeBlockLineWrap, deferCodeLineNumberSync, toggleCodeBlockLineWrap, onPreviewLoopback]);
|
||||
return { labels, mermaidControls, codeBlockLineWrap, deferCodeLineNumberSync, onToggleCodeBlockLineWrap: toggleCodeBlockLineWrap, renderMermaid, onPreviewLoopback };
|
||||
}, [currentTheme, labels, mermaidControls, codeBlockLineWrap, deferCodeLineNumberSync, toggleCodeBlockLineWrap, onPreviewLoopback]);
|
||||
};
|
||||
|
||||
// Runs the async render pipeline into the container and keeps a stable
|
||||
@@ -921,6 +918,22 @@ const useMorphdomMarkdown = ({
|
||||
ensureMarkdownShikiTheme();
|
||||
}, []);
|
||||
|
||||
const mermaidViewerRef = React.useRef<ReturnType<typeof createMermaidViewerRegistry> | null>(null);
|
||||
const refreshMermaidViewers = React.useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
if (!mermaidViewerRef.current) {
|
||||
if (!shouldRefreshMermaidViewers(container)) {
|
||||
return;
|
||||
}
|
||||
mermaidViewerRef.current = createMermaidViewerRegistry(container);
|
||||
return;
|
||||
}
|
||||
mermaidViewerRef.current.refresh();
|
||||
}, [containerRef]);
|
||||
|
||||
// Synchronous first paint: while the async parse is in-flight, show escaped
|
||||
// plain text immediately so there is no blank frame on initial mount. Only
|
||||
// runs when the target is empty — subsequent updates keep the prior rich DOM
|
||||
@@ -944,8 +957,16 @@ const useMorphdomMarkdown = ({
|
||||
// the structure here keeps the async morph to syntax colors only.
|
||||
decorateMarkdown(block, ctx);
|
||||
target.appendChild(block);
|
||||
if (shouldRefreshMermaidViewers(block)) {
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
}
|
||||
}, [containerRef, text, ctx]);
|
||||
}, [containerRef, text, ctx, refreshMermaidViewers]);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
mermaidViewerRef.current?.cleanup();
|
||||
mermaidViewerRef.current = null;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -973,16 +994,30 @@ const useMorphdomMarkdown = ({
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = block.html;
|
||||
decorateMarkdown(temp, ctx);
|
||||
const hadMermaidBlock = shouldRefreshMermaidViewers(el);
|
||||
const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp);
|
||||
morphdom(el, temp, {
|
||||
childrenOnly: true,
|
||||
onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
|
||||
});
|
||||
el.setAttribute('data-md-id', block.id);
|
||||
if (hadMermaidBlock || tempHasMermaidBlock || shouldRefreshMermaidViewers(el)) {
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
});
|
||||
|
||||
// Remove any trailing block elements no longer present.
|
||||
const hadMermaidBeforeTrailingCleanup = shouldRefreshMermaidViewers(target);
|
||||
let removedMermaidBlock = false;
|
||||
for (let i = existing.length - 1; i >= blocks.length; i -= 1) {
|
||||
existing[i]?.remove();
|
||||
const removed = existing[i];
|
||||
if (removed && shouldRefreshMermaidViewers(removed)) {
|
||||
removedMermaidBlock = true;
|
||||
}
|
||||
removed?.remove();
|
||||
}
|
||||
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
|
||||
if (!ctx.deferCodeLineNumberSync) {
|
||||
@@ -993,7 +1028,7 @@ const useMorphdomMarkdown = ({
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [containerRef, text, streaming, cacheKey, ctx]);
|
||||
}, [containerRef, text, streaming, cacheKey, ctx, refreshMermaidViewers]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -1073,8 +1108,12 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
const live = isStreaming && !disableStreamAnimation;
|
||||
const pacedText = usePacedText(content, live);
|
||||
|
||||
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]);
|
||||
useMermaidInlineInteractions({ containerRef, mermaidBlocks, onShowPopup });
|
||||
useMermaidInlineInteractions({
|
||||
containerRef,
|
||||
onShowPopup,
|
||||
enableFullscreen: DEFAULT_MERMAID_FULLSCREEN_ENABLED,
|
||||
enablePanZoom: DEFAULT_MERMAID_CONTROLS.showPanZoomControls,
|
||||
});
|
||||
useFileReferenceInteractions({
|
||||
containerRef,
|
||||
effectiveDirectory,
|
||||
@@ -1085,7 +1124,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
useExternalLinkInteractions({ containerRef });
|
||||
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined);
|
||||
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
|
||||
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||
|
||||
useMorphdomMarkdown({ containerRef, text: pacedText, streaming: live, cacheKey, syntaxVars, ctx });
|
||||
@@ -1129,7 +1168,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
stripFrontmatter?: boolean;
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
mermaidControls?: MermaidControlOptions;
|
||||
allowMermaidWheelZoom?: boolean;
|
||||
allowMermaidWheelEvents?: boolean;
|
||||
enableFileReferences?: boolean;
|
||||
}> = ({
|
||||
content,
|
||||
@@ -1138,7 +1177,8 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
disableLinkSafety,
|
||||
stripFrontmatter = false,
|
||||
onShowPopup,
|
||||
allowMermaidWheelZoom = false,
|
||||
mermaidControls = DEFAULT_MERMAID_CONTROLS,
|
||||
allowMermaidWheelEvents = false,
|
||||
enableFileReferences = true,
|
||||
}) => {
|
||||
const { editor, runtime } = useRuntimeAPIs();
|
||||
@@ -1151,12 +1191,12 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
[content, stripFrontmatter],
|
||||
);
|
||||
|
||||
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(renderedContent), [renderedContent]);
|
||||
useMermaidInlineInteractions({
|
||||
containerRef,
|
||||
mermaidBlocks,
|
||||
onShowPopup,
|
||||
allowWheelZoom: allowMermaidWheelZoom,
|
||||
enableFullscreen: DEFAULT_MERMAID_FULLSCREEN_ENABLED,
|
||||
enablePanZoom: mermaidControls.showPanZoomControls,
|
||||
allowMermaidWheelEvents,
|
||||
});
|
||||
useFileReferenceInteractions({
|
||||
containerRef,
|
||||
@@ -1168,7 +1208,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety });
|
||||
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
const ctx = useDecorateContext(currentTheme, false);
|
||||
const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls);
|
||||
|
||||
useMorphdomMarkdown({
|
||||
containerRef,
|
||||
@@ -1187,12 +1227,18 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
};
|
||||
|
||||
export const SimpleMarkdownRenderer = React.memo(SimpleMarkdownRendererImpl, (prev, next) => {
|
||||
const prevMermaidControls = prev.mermaidControls ?? DEFAULT_MERMAID_CONTROLS;
|
||||
const nextMermaidControls = next.mermaidControls ?? DEFAULT_MERMAID_CONTROLS;
|
||||
|
||||
return prev.content === next.content
|
||||
&& prev.variant === next.variant
|
||||
&& prev.className === next.className
|
||||
&& prev.disableLinkSafety === next.disableLinkSafety
|
||||
&& prev.stripFrontmatter === next.stripFrontmatter
|
||||
&& prev.onShowPopup === next.onShowPopup
|
||||
&& prev.allowMermaidWheelZoom === next.allowMermaidWheelZoom
|
||||
&& prevMermaidControls.download === nextMermaidControls.download
|
||||
&& prevMermaidControls.copy === nextMermaidControls.copy
|
||||
&& prevMermaidControls.showPanZoomControls === nextMermaidControls.showPanZoomControls
|
||||
&& prev.allowMermaidWheelEvents === next.allowMermaidWheelEvents
|
||||
&& prev.enableFileReferences === next.enableFileReferences;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { getExternalFaviconUrl, isExternalHttpUrl, isLoopbackHttpUrl } from '@/lib/url';
|
||||
import { dropdownMenuItemClass, dropdownMenuPopupClass } from '@/components/ui/dropdown-menu.styles';
|
||||
import type { IconName } from '@/components/icon/icons';
|
||||
import { getMermaidViewerController } from './mermaidViewer';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared decoration context
|
||||
@@ -17,12 +19,22 @@ export type DecorateLabels = {
|
||||
downloadTable: string;
|
||||
copyDiagram: string;
|
||||
downloadDiagram: string;
|
||||
zoomInDiagram: string;
|
||||
zoomOutDiagram: string;
|
||||
resetDiagramView: string;
|
||||
previewLabel: string;
|
||||
previewTitle: string;
|
||||
};
|
||||
|
||||
export type MermaidControlOptions = {
|
||||
download: boolean;
|
||||
copy: boolean;
|
||||
showPanZoomControls: boolean;
|
||||
};
|
||||
|
||||
export type DecorateContext = {
|
||||
labels: DecorateLabels;
|
||||
mermaidControls: MermaidControlOptions;
|
||||
codeBlockLineWrap: boolean;
|
||||
deferCodeLineNumberSync?: boolean;
|
||||
onToggleCodeBlockLineWrap?: () => void;
|
||||
@@ -34,20 +46,23 @@ export type DecorateContext = {
|
||||
// Reference the app's icon sprite (injected into <body> by the shared Icon
|
||||
// component) so DOM-built controls use the same themed icons as the rest of
|
||||
// the app. Sprite symbols are registered under `#oc-<name>`.
|
||||
const spriteIcon = (name: string): string =>
|
||||
const spriteIcon = (name: IconName): string =>
|
||||
`<svg class="remixicon size-3.5" viewBox="0 0 24 24" aria-hidden="true"><use href="#oc-${name}"></use></svg>`;
|
||||
|
||||
const ICONS = {
|
||||
copy: spriteIcon('file-copy'),
|
||||
check: spriteIcon('check'),
|
||||
download: spriteIcon('download'),
|
||||
zoomIn: spriteIcon('add'),
|
||||
zoomOut: spriteIcon('subtract'),
|
||||
fit: spriteIcon('refresh'),
|
||||
textWrap: spriteIcon('text-wrap'),
|
||||
} as const;
|
||||
|
||||
const ICON_BTN_CLASS =
|
||||
'p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors';
|
||||
'p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--interactive-focus-ring)]';
|
||||
|
||||
const setHtml = (el: Element, html: string): void => {
|
||||
const setIconHtml = (el: Element, html: string): void => {
|
||||
el.innerHTML = html;
|
||||
};
|
||||
|
||||
@@ -58,7 +73,7 @@ const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string):
|
||||
button.setAttribute('data-md-action', slot);
|
||||
button.setAttribute('title', title);
|
||||
button.setAttribute('aria-label', title);
|
||||
setHtml(button, ICONS[icon]);
|
||||
setIconHtml(button, ICONS[icon]);
|
||||
return button;
|
||||
};
|
||||
|
||||
@@ -207,11 +222,13 @@ export const applyMarkdownCodeBlockWrapState = (root: HTMLElement, enabled: bool
|
||||
};
|
||||
|
||||
const flashCopied = (button: HTMLButtonElement, copiedTitle: string, restore: keyof typeof ICONS, restoreTitle: string): void => {
|
||||
setHtml(button, ICONS.check);
|
||||
setIconHtml(button, ICONS.check);
|
||||
button.setAttribute('title', copiedTitle);
|
||||
button.setAttribute('aria-label', copiedTitle);
|
||||
window.setTimeout(() => {
|
||||
setHtml(button, ICONS[restore]);
|
||||
setIconHtml(button, ICONS[restore]);
|
||||
button.setAttribute('title', restoreTitle);
|
||||
button.setAttribute('aria-label', restoreTitle);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
@@ -410,33 +427,52 @@ const decorateMermaid = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
|
||||
const block = document.createElement('div');
|
||||
block.setAttribute('data-markdown', 'mermaid-block');
|
||||
block.setAttribute('data-md-source', source);
|
||||
block.className = 'group relative';
|
||||
|
||||
const scroll = document.createElement('div');
|
||||
scroll.setAttribute('data-markdown', 'mermaid-scroll');
|
||||
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'absolute top-1 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity';
|
||||
toolbar.setAttribute('data-markdown', 'mermaid-toolbar');
|
||||
toolbar.className = 'absolute top-1 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity';
|
||||
|
||||
if (rendered.svg) {
|
||||
block.setAttribute('data-mermaid-render', 'svg');
|
||||
const viewport = document.createElement('div');
|
||||
viewport.setAttribute('data-markdown', 'mermaid-viewport');
|
||||
const svgHost = document.createElement('div');
|
||||
svgHost.setAttribute('data-markdown', 'mermaid');
|
||||
setHtml(svgHost, rendered.svg);
|
||||
scroll.appendChild(svgHost);
|
||||
const copy = makeIconButton('copy', ctx.labels.copyDiagram, 'mermaid-copy');
|
||||
copy.setAttribute('data-md-source', source);
|
||||
const download = makeIconButton('download', ctx.labels.downloadDiagram, 'mermaid-download');
|
||||
download.setAttribute('data-md-svg', '1');
|
||||
toolbar.appendChild(copy);
|
||||
toolbar.appendChild(download);
|
||||
svgHost.setAttribute('data-md-original-svg', rendered.svg);
|
||||
svgHost.innerHTML = rendered.svg;
|
||||
viewport.appendChild(svgHost);
|
||||
scroll.appendChild(viewport);
|
||||
if (ctx.mermaidControls.showPanZoomControls) {
|
||||
toolbar.appendChild(makeIconButton('zoomIn', ctx.labels.zoomInDiagram, 'mermaid-zoom-in'));
|
||||
toolbar.appendChild(makeIconButton('zoomOut', ctx.labels.zoomOutDiagram, 'mermaid-zoom-out'));
|
||||
toolbar.appendChild(makeIconButton('fit', ctx.labels.resetDiagramView, 'mermaid-fit'));
|
||||
}
|
||||
if (ctx.mermaidControls.copy) {
|
||||
const copy = makeIconButton('copy', ctx.labels.copyDiagram, 'mermaid-copy');
|
||||
copy.setAttribute('data-md-source', source);
|
||||
toolbar.appendChild(copy);
|
||||
}
|
||||
if (ctx.mermaidControls.download) {
|
||||
const download = makeIconButton('download', ctx.labels.downloadDiagram, 'mermaid-download');
|
||||
download.setAttribute('data-md-svg', '1');
|
||||
toolbar.appendChild(download);
|
||||
}
|
||||
} else {
|
||||
block.setAttribute('data-mermaid-render', 'ascii');
|
||||
const asciiPre = document.createElement('pre');
|
||||
asciiPre.setAttribute('data-markdown', 'mermaid-ascii');
|
||||
asciiPre.textContent = rendered.ascii || source;
|
||||
scroll.appendChild(asciiPre);
|
||||
const copy = makeIconButton('copy', ctx.labels.copyDiagram, 'mermaid-copy');
|
||||
copy.setAttribute('data-md-source', rendered.ascii || source);
|
||||
toolbar.appendChild(copy);
|
||||
if (ctx.mermaidControls.copy) {
|
||||
const copy = makeIconButton('copy', ctx.labels.copyDiagram, 'mermaid-copy');
|
||||
copy.setAttribute('data-md-source', rendered.ascii || source);
|
||||
toolbar.appendChild(copy);
|
||||
}
|
||||
}
|
||||
|
||||
block.appendChild(scroll);
|
||||
@@ -486,7 +522,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
preview.setAttribute('data-md-url', href);
|
||||
preview.setAttribute('title', ctx.labels.previewTitle);
|
||||
preview.setAttribute('aria-label', ctx.labels.previewLabel);
|
||||
setHtml(preview, ICONS.download);
|
||||
setIconHtml(preview, ICONS.download);
|
||||
anchor.parentNode?.insertBefore(preview, anchor.nextSibling);
|
||||
}
|
||||
}
|
||||
@@ -600,10 +636,25 @@ export const attachMarkdownInteractions = (
|
||||
return;
|
||||
}
|
||||
|
||||
// Mermaid local pan/zoom controls
|
||||
if (action === 'mermaid-zoom-in' || action === 'mermaid-zoom-out' || action === 'mermaid-fit') {
|
||||
event.preventDefault();
|
||||
const block = actionEl.closest('[data-markdown="mermaid-block"]');
|
||||
const controller = getMermaidViewerController(block);
|
||||
if (action === 'mermaid-zoom-in') {
|
||||
controller?.zoomIn();
|
||||
} else if (action === 'mermaid-zoom-out') {
|
||||
controller?.zoomOut();
|
||||
} else {
|
||||
controller?.fit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Mermaid download svg
|
||||
if (action === 'mermaid-download') {
|
||||
const svgHost = actionEl.closest('[data-markdown="mermaid-block"]')?.querySelector('[data-markdown="mermaid"]');
|
||||
const svg = svgHost?.innerHTML ?? '';
|
||||
const svg = svgHost?.getAttribute('data-md-original-svg') ?? svgHost?.innerHTML ?? '';
|
||||
if (svg) downloadBlob('diagram.svg', svg, 'image/svg+xml;charset=utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
fitMermaidViewBox,
|
||||
formatMermaidViewBox,
|
||||
getMermaidSvgContentBox,
|
||||
getMermaidViewerSignature,
|
||||
hasMermaidPointerDragMoved,
|
||||
MERMAID_BLOCK_SELECTOR,
|
||||
panMermaidViewBox,
|
||||
shouldRefreshMermaidViewers,
|
||||
zoomMermaidViewBoxAtPoint,
|
||||
} from './mermaidViewer';
|
||||
|
||||
describe('mermaidViewer', () => {
|
||||
test('extracts content bounds from the root SVG viewBox', () => {
|
||||
expect(getMermaidSvgContentBox({ viewBox: '10 20 400 200', width: '999', height: '999' })).toEqual({
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 400,
|
||||
height: 200,
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to numeric SVG width and height when viewBox is missing', () => {
|
||||
expect(getMermaidSvgContentBox({ width: '640', height: '320' })).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 640,
|
||||
height: 320,
|
||||
});
|
||||
});
|
||||
|
||||
test('accepts bare and px SVG width and height values without parsing unresolved units', () => {
|
||||
expect(getMermaidSvgContentBox({ width: '1e3', height: '2.5e2px' })).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1000,
|
||||
height: 250,
|
||||
});
|
||||
|
||||
expect(getMermaidSvgContentBox({ width: '100%', height: '200' })).toBeNull();
|
||||
expect(getMermaidSvgContentBox({ width: '100em', height: '200' })).toBeNull();
|
||||
});
|
||||
|
||||
test('fits content into a viewport while preserving aspect ratio', () => {
|
||||
expect(fitMermaidViewBox({ x: 0, y: 0, width: 400, height: 200 }, { width: 300, height: 300 })).toEqual({
|
||||
x: 0,
|
||||
y: -100,
|
||||
width: 400,
|
||||
height: 400,
|
||||
});
|
||||
});
|
||||
|
||||
test('zooms around a pointer so the SVG point under the pointer stays stable', () => {
|
||||
const current = { x: 0, y: 0, width: 400, height: 400 };
|
||||
const next = zoomMermaidViewBoxAtPoint({
|
||||
currentBox: current,
|
||||
contentBox: { x: 0, y: 0, width: 400, height: 200 },
|
||||
viewport: { width: 300, height: 300 },
|
||||
pointer: { x: 75, y: 150 },
|
||||
zoomFactor: 2,
|
||||
minScale: 0.5,
|
||||
maxScale: 4,
|
||||
});
|
||||
|
||||
const before = {
|
||||
x: current.x + (75 / 300) * current.width,
|
||||
y: current.y + (150 / 300) * current.height,
|
||||
};
|
||||
const after = {
|
||||
x: next.x + (75 / 300) * next.width,
|
||||
y: next.y + (150 / 300) * next.height,
|
||||
};
|
||||
|
||||
expect(Math.abs(after.x - before.x) < 1e-6).toBe(true);
|
||||
expect(Math.abs(after.y - before.y) < 1e-6).toBe(true);
|
||||
expect(next).toEqual({ x: 50, y: 100, width: 200, height: 200 });
|
||||
});
|
||||
|
||||
test('clamps zoom to the configured viewBox scale bounds', () => {
|
||||
const next = zoomMermaidViewBoxAtPoint({
|
||||
currentBox: { x: 0, y: 0, width: 400, height: 400 },
|
||||
contentBox: { x: 0, y: 0, width: 400, height: 200 },
|
||||
viewport: { width: 300, height: 300 },
|
||||
pointer: { x: 150, y: 150 },
|
||||
zoomFactor: 100,
|
||||
minScale: 0.5,
|
||||
maxScale: 4,
|
||||
});
|
||||
|
||||
expect(next.width).toBe(100);
|
||||
expect(next.height).toBe(100);
|
||||
expect(next.x).toBe(150);
|
||||
expect(next.y).toBe(150);
|
||||
});
|
||||
|
||||
test('clamps zoom scale relative to the fitted viewport box', () => {
|
||||
const next = zoomMermaidViewBoxAtPoint({
|
||||
currentBox: { x: -100, y: 0, width: 400, height: 400 },
|
||||
contentBox: { x: 0, y: 0, width: 200, height: 400 },
|
||||
viewport: { width: 300, height: 300 },
|
||||
pointer: { x: 150, y: 150 },
|
||||
zoomFactor: 100,
|
||||
minScale: 0.5,
|
||||
maxScale: 4,
|
||||
});
|
||||
|
||||
expect(next).toEqual({ x: 50, y: 150, width: 100, height: 100 });
|
||||
});
|
||||
|
||||
test('returns the current viewBox for invalid zoom scale bounds', () => {
|
||||
const current = { x: 0, y: 0, width: 400, height: 400 };
|
||||
|
||||
expect(zoomMermaidViewBoxAtPoint({
|
||||
currentBox: current,
|
||||
contentBox: { x: 0, y: 0, width: 400, height: 200 },
|
||||
viewport: { width: 300, height: 300 },
|
||||
pointer: { x: 150, y: 150 },
|
||||
zoomFactor: 2,
|
||||
minScale: 0,
|
||||
maxScale: 4,
|
||||
})).toBe(current);
|
||||
|
||||
expect(zoomMermaidViewBoxAtPoint({
|
||||
currentBox: current,
|
||||
contentBox: { x: 0, y: 0, width: 400, height: 200 },
|
||||
viewport: { width: 300, height: 300 },
|
||||
pointer: { x: 150, y: 150 },
|
||||
zoomFactor: 2,
|
||||
minScale: 0.5,
|
||||
maxScale: Number.POSITIVE_INFINITY,
|
||||
})).toBe(current);
|
||||
});
|
||||
|
||||
test('formats viewBox numbers without noisy floating point tails', () => {
|
||||
expect(formatMermaidViewBox({
|
||||
x: 1 / 3,
|
||||
y: -2.5,
|
||||
width: 100.0000001,
|
||||
height: 40,
|
||||
})).toBe('0.333333 -2.5 100 40');
|
||||
});
|
||||
|
||||
test('pans viewBox by viewport pixel deltas in SVG coordinates', () => {
|
||||
expect(panMermaidViewBox({
|
||||
currentBox: { x: 10, y: 20, width: 400, height: 200 },
|
||||
viewport: { width: 200, height: 100 },
|
||||
delta: { x: 25, y: -10 },
|
||||
})).toEqual({
|
||||
x: -40,
|
||||
y: 40,
|
||||
width: 400,
|
||||
height: 200,
|
||||
});
|
||||
});
|
||||
|
||||
test('distinguishes real pointer drag from click jitter', () => {
|
||||
expect(hasMermaidPointerDragMoved({ x: 10, y: 10 }, { x: 12, y: 11 })).toBe(false);
|
||||
expect(hasMermaidPointerDragMoved({ x: 10, y: 10 }, { x: 14, y: 10 })).toBe(true);
|
||||
});
|
||||
|
||||
test('viewer signature changes when SVG identity changes within an existing block', () => {
|
||||
const first = getMermaidViewerSignature({
|
||||
renderMode: 'svg',
|
||||
svgMarkup: '<svg viewBox="0 0 100 50"></svg>',
|
||||
viewBox: '0 0 100 50',
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
const second = getMermaidViewerSignature({
|
||||
renderMode: 'svg',
|
||||
svgMarkup: '<svg viewBox="0 0 240 120"></svg>',
|
||||
viewBox: '0 0 240 120',
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
test('viewer signature distinguishes render mode flips and missing SVGs', () => {
|
||||
expect(getMermaidViewerSignature({ renderMode: 'ascii' })).toBe('ascii:no-svg');
|
||||
expect(getMermaidViewerSignature({ renderMode: 'svg' })).toBe('svg:no-svg');
|
||||
});
|
||||
|
||||
test('only requests renderer refresh work for existing mermaid DOM blocks', () => {
|
||||
const withoutMermaidBlock = { querySelector: () => null };
|
||||
const withMermaidBlock = { querySelector: () => ({}) as Element };
|
||||
|
||||
expect(shouldRefreshMermaidViewers(withoutMermaidBlock)).toBe(false);
|
||||
expect(shouldRefreshMermaidViewers(withMermaidBlock)).toBe(true);
|
||||
});
|
||||
|
||||
test('exports the shared Mermaid block selector', () => {
|
||||
expect(MERMAID_BLOCK_SELECTOR).toBe('[data-markdown="mermaid-block"]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,467 @@
|
||||
type MermaidViewBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type MermaidViewport = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type MermaidPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type MermaidViewerController = {
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
fit: () => void;
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
type MermaidSvgBoundsSource = {
|
||||
viewBox?: string | null;
|
||||
width?: string | number | null;
|
||||
height?: string | number | null;
|
||||
};
|
||||
|
||||
type MermaidViewerSignatureSource = MermaidSvgBoundsSource & {
|
||||
renderMode?: string | null;
|
||||
svgMarkup?: string | null;
|
||||
};
|
||||
|
||||
const isPositiveFinite = (value: number): boolean => Number.isFinite(value) && value > 0;
|
||||
|
||||
const parseSvgNumber = (value: string | number | null | undefined): number | null => {
|
||||
if (typeof value === 'number') {
|
||||
return isPositiveFinite(value) ? value : null;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const match = value.trim().match(/^([+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?)(?:px)?$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(match[1]);
|
||||
return isPositiveFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value));
|
||||
|
||||
const VIEW_BOX_PRECISION = 6;
|
||||
const ZOOM_STEP = 1.25;
|
||||
const WHEEL_ZOOM_BASE = 1.0015;
|
||||
const MIN_SCALE = 0.5;
|
||||
const MAX_SCALE = 12;
|
||||
const DRAG_CLICK_SUPPRESSION_THRESHOLD_PX = 3;
|
||||
const DRAG_CLICK_SUPPRESSION_CLEAR_MS = 400;
|
||||
export const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]';
|
||||
|
||||
export const shouldRefreshMermaidViewers = (container: Pick<HTMLElement, 'querySelector'>): boolean => (
|
||||
container.querySelector(MERMAID_BLOCK_SELECTOR) !== null
|
||||
);
|
||||
|
||||
export const formatMermaidViewBox = (box: MermaidViewBox): string => (
|
||||
[box.x, box.y, box.width, box.height]
|
||||
.map((value) => {
|
||||
const rounded = Number(value.toFixed(VIEW_BOX_PRECISION));
|
||||
return Object.is(rounded, -0) ? '0' : String(rounded);
|
||||
})
|
||||
.join(' ')
|
||||
);
|
||||
|
||||
export const getMermaidSvgContentBox = (source: MermaidSvgBoundsSource): MermaidViewBox | null => {
|
||||
const viewBoxParts = source.viewBox
|
||||
?.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map((part) => Number.parseFloat(part));
|
||||
if (viewBoxParts?.length === 4) {
|
||||
const [x, y, width, height] = viewBoxParts;
|
||||
if (
|
||||
Number.isFinite(x)
|
||||
&& Number.isFinite(y)
|
||||
&& isPositiveFinite(width)
|
||||
&& isPositiveFinite(height)
|
||||
) {
|
||||
return { x, y, width, height };
|
||||
}
|
||||
}
|
||||
|
||||
const width = parseSvgNumber(source.width);
|
||||
const height = parseSvgNumber(source.height);
|
||||
if (width === null || height === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { x: 0, y: 0, width, height };
|
||||
};
|
||||
|
||||
const hashMermaidSignaturePart = (value: string): string => {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
};
|
||||
|
||||
export const getMermaidViewerSignature = (source: MermaidViewerSignatureSource): string => {
|
||||
const renderMode = source.renderMode || 'unknown';
|
||||
const svgMarkup = source.svgMarkup ?? '';
|
||||
if (!svgMarkup) {
|
||||
const bounds = [source.viewBox ?? '', source.width ?? '', source.height ?? ''];
|
||||
return bounds.some((part) => part !== '') ? `${renderMode}:bounds:${bounds.join(':')}` : `${renderMode}:no-svg`;
|
||||
}
|
||||
|
||||
return [
|
||||
renderMode,
|
||||
hashMermaidSignaturePart(svgMarkup),
|
||||
].join(':');
|
||||
};
|
||||
|
||||
export const fitMermaidViewBox = (contentBox: MermaidViewBox, viewport: MermaidViewport): MermaidViewBox => {
|
||||
if (!isPositiveFinite(viewport.width) || !isPositiveFinite(viewport.height)) {
|
||||
return contentBox;
|
||||
}
|
||||
|
||||
const contentAspect = contentBox.width / contentBox.height;
|
||||
const viewportAspect = viewport.width / viewport.height;
|
||||
if (contentAspect > viewportAspect) {
|
||||
const height = contentBox.width / viewportAspect;
|
||||
return {
|
||||
x: contentBox.x,
|
||||
y: contentBox.y - (height - contentBox.height) / 2,
|
||||
width: contentBox.width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
const width = contentBox.height * viewportAspect;
|
||||
return {
|
||||
x: contentBox.x - (width - contentBox.width) / 2,
|
||||
y: contentBox.y,
|
||||
width,
|
||||
height: contentBox.height,
|
||||
};
|
||||
};
|
||||
|
||||
export const panMermaidViewBox = ({
|
||||
currentBox,
|
||||
viewport,
|
||||
delta,
|
||||
}: {
|
||||
currentBox: MermaidViewBox;
|
||||
viewport: MermaidViewport;
|
||||
delta: MermaidPoint;
|
||||
}): MermaidViewBox => {
|
||||
if (
|
||||
!isPositiveFinite(viewport.width)
|
||||
|| !isPositiveFinite(viewport.height)
|
||||
|| !isPositiveFinite(currentBox.width)
|
||||
|| !isPositiveFinite(currentBox.height)
|
||||
) {
|
||||
return currentBox;
|
||||
}
|
||||
|
||||
return {
|
||||
x: currentBox.x - (delta.x / viewport.width) * currentBox.width,
|
||||
y: currentBox.y - (delta.y / viewport.height) * currentBox.height,
|
||||
width: currentBox.width,
|
||||
height: currentBox.height,
|
||||
};
|
||||
};
|
||||
|
||||
export const hasMermaidPointerDragMoved = (start: MermaidPoint, current: MermaidPoint): boolean => {
|
||||
const deltaX = current.x - start.x;
|
||||
const deltaY = current.y - start.y;
|
||||
return (deltaX * deltaX) + (deltaY * deltaY) > (DRAG_CLICK_SUPPRESSION_THRESHOLD_PX * DRAG_CLICK_SUPPRESSION_THRESHOLD_PX);
|
||||
};
|
||||
|
||||
export const zoomMermaidViewBoxAtPoint = ({
|
||||
currentBox,
|
||||
contentBox,
|
||||
viewport,
|
||||
pointer,
|
||||
zoomFactor,
|
||||
minScale,
|
||||
maxScale,
|
||||
}: {
|
||||
currentBox: MermaidViewBox;
|
||||
contentBox: MermaidViewBox;
|
||||
viewport: MermaidViewport;
|
||||
pointer: MermaidPoint;
|
||||
zoomFactor: number;
|
||||
minScale: number;
|
||||
maxScale: number;
|
||||
}): MermaidViewBox => {
|
||||
if (
|
||||
!isPositiveFinite(viewport.width)
|
||||
|| !isPositiveFinite(viewport.height)
|
||||
|| !isPositiveFinite(zoomFactor)
|
||||
|| !isPositiveFinite(currentBox.width)
|
||||
|| !isPositiveFinite(currentBox.height)
|
||||
|| !isPositiveFinite(contentBox.width)
|
||||
|| !isPositiveFinite(contentBox.height)
|
||||
|| !isPositiveFinite(minScale)
|
||||
|| !isPositiveFinite(maxScale)
|
||||
) {
|
||||
return currentBox;
|
||||
}
|
||||
|
||||
const min = Math.min(minScale, maxScale);
|
||||
const max = Math.max(minScale, maxScale);
|
||||
const fittedBox = fitMermaidViewBox(contentBox, viewport);
|
||||
const currentScale = fittedBox.width / currentBox.width;
|
||||
const nextScale = clamp(currentScale * zoomFactor, min, max);
|
||||
const nextWidth = fittedBox.width / nextScale;
|
||||
const nextHeight = nextWidth / (currentBox.width / currentBox.height);
|
||||
const pointerRatioX = clamp(pointer.x / viewport.width, 0, 1);
|
||||
const pointerRatioY = clamp(pointer.y / viewport.height, 0, 1);
|
||||
const svgPointX = currentBox.x + pointerRatioX * currentBox.width;
|
||||
const svgPointY = currentBox.y + pointerRatioY * currentBox.height;
|
||||
|
||||
return {
|
||||
x: svgPointX - pointerRatioX * nextWidth,
|
||||
y: svgPointY - pointerRatioY * nextHeight,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
};
|
||||
};
|
||||
|
||||
const controllerByBlock = new WeakMap<HTMLElement, MermaidViewerController>();
|
||||
|
||||
export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => (
|
||||
block instanceof HTMLElement ? controllerByBlock.get(block) ?? null : null
|
||||
);
|
||||
|
||||
const getSvgViewport = (block: HTMLElement): HTMLElement | null => (
|
||||
block.querySelector<HTMLElement>('[data-markdown="mermaid-viewport"]')
|
||||
?? block.querySelector<HTMLElement>('[data-markdown="mermaid"]')
|
||||
);
|
||||
|
||||
const getBlockViewerSignature = (block: HTMLElement): string => {
|
||||
const svg = block.querySelector<SVGSVGElement>('[data-markdown="mermaid"] svg');
|
||||
const svgHost = block.querySelector<HTMLElement>('[data-markdown="mermaid"]');
|
||||
return getMermaidViewerSignature({
|
||||
renderMode: block.getAttribute('data-mermaid-render'),
|
||||
svgMarkup: svgHost?.getAttribute('data-md-original-svg') ?? svg?.outerHTML ?? null,
|
||||
viewBox: svg?.getAttribute('viewBox'),
|
||||
width: svg?.getAttribute('width'),
|
||||
height: svg?.getAttribute('height'),
|
||||
});
|
||||
};
|
||||
|
||||
const getViewportSize = (viewport: HTMLElement): MermaidViewport => {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
return { width: rect.width, height: rect.height };
|
||||
};
|
||||
|
||||
const getPointerInViewport = (event: Pick<PointerEvent | WheelEvent, 'clientX' | 'clientY'>, viewport: HTMLElement): MermaidPoint => {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
return {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
};
|
||||
};
|
||||
|
||||
const isPanExcludedTarget = (target: EventTarget | null): boolean => (
|
||||
target instanceof Element && Boolean(target.closest('button, a, [role="button"]'))
|
||||
);
|
||||
|
||||
const createMermaidViewerController = (block: HTMLElement): MermaidViewerController | null => {
|
||||
const viewport = getSvgViewport(block);
|
||||
const svg = block.querySelector<SVGSVGElement>('[data-markdown="mermaid"] svg');
|
||||
if (!viewport || !svg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentBox = getMermaidSvgContentBox({
|
||||
viewBox: svg.getAttribute('viewBox'),
|
||||
width: svg.getAttribute('width'),
|
||||
height: svg.getAttribute('height'),
|
||||
});
|
||||
if (!contentBox) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentBox = contentBox;
|
||||
let activePointerId: number | null = null;
|
||||
let dragStartPointer: MermaidPoint | null = null;
|
||||
let lastPointer: MermaidPoint | null = null;
|
||||
let clearClickSuppressionTimer: number | null = null;
|
||||
|
||||
const applyViewBox = (box: MermaidViewBox): void => {
|
||||
currentBox = box;
|
||||
svg.setAttribute('viewBox', formatMermaidViewBox(box));
|
||||
svg.removeAttribute('width');
|
||||
svg.removeAttribute('height');
|
||||
};
|
||||
|
||||
const fit = (): void => {
|
||||
applyViewBox(fitMermaidViewBox(contentBox, getViewportSize(viewport)));
|
||||
};
|
||||
|
||||
const zoomAt = (pointer: MermaidPoint, zoomFactor: number): void => {
|
||||
applyViewBox(zoomMermaidViewBoxAtPoint({
|
||||
currentBox,
|
||||
contentBox,
|
||||
viewport: getViewportSize(viewport),
|
||||
pointer,
|
||||
zoomFactor,
|
||||
minScale: MIN_SCALE,
|
||||
maxScale: MAX_SCALE,
|
||||
}));
|
||||
};
|
||||
|
||||
const zoomIn = (): void => {
|
||||
const size = getViewportSize(viewport);
|
||||
zoomAt({ x: size.width / 2, y: size.height / 2 }, ZOOM_STEP);
|
||||
};
|
||||
|
||||
const zoomOut = (): void => {
|
||||
const size = getViewportSize(viewport);
|
||||
zoomAt({ x: size.width / 2, y: size.height / 2 }, 1 / ZOOM_STEP);
|
||||
};
|
||||
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
zoomAt(getPointerInViewport(event, viewport), Math.pow(WHEEL_ZOOM_BASE, -event.deltaY));
|
||||
};
|
||||
|
||||
const onPointerDown = (event: PointerEvent): void => {
|
||||
if (event.button !== 0 || isPanExcludedTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
activePointerId = event.pointerId;
|
||||
dragStartPointer = { x: event.clientX, y: event.clientY };
|
||||
lastPointer = dragStartPointer;
|
||||
if (clearClickSuppressionTimer !== null) {
|
||||
window.clearTimeout(clearClickSuppressionTimer);
|
||||
clearClickSuppressionTimer = null;
|
||||
}
|
||||
block.removeAttribute('data-mermaid-suppress-click');
|
||||
viewport.setPointerCapture?.(event.pointerId);
|
||||
block.setAttribute('data-mermaid-panning', 'true');
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent): void => {
|
||||
if (activePointerId !== event.pointerId || !lastPointer) {
|
||||
return;
|
||||
}
|
||||
const nextPointer = { x: event.clientX, y: event.clientY };
|
||||
applyViewBox(panMermaidViewBox({
|
||||
currentBox,
|
||||
viewport: getViewportSize(viewport),
|
||||
delta: {
|
||||
x: nextPointer.x - lastPointer.x,
|
||||
y: nextPointer.y - lastPointer.y,
|
||||
},
|
||||
}));
|
||||
lastPointer = nextPointer;
|
||||
if (dragStartPointer && hasMermaidPointerDragMoved(dragStartPointer, nextPointer)) {
|
||||
block.setAttribute('data-mermaid-suppress-click', 'true');
|
||||
}
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const stopPan = (event: PointerEvent): void => {
|
||||
if (activePointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
viewport.releasePointerCapture?.(event.pointerId);
|
||||
activePointerId = null;
|
||||
dragStartPointer = null;
|
||||
lastPointer = null;
|
||||
block.removeAttribute('data-mermaid-panning');
|
||||
if (block.hasAttribute('data-mermaid-suppress-click')) {
|
||||
clearClickSuppressionTimer = window.setTimeout(() => {
|
||||
block.removeAttribute('data-mermaid-suppress-click');
|
||||
clearClickSuppressionTimer = null;
|
||||
}, DRAG_CLICK_SUPPRESSION_CLEAR_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const onResize = (): void => {
|
||||
fit();
|
||||
};
|
||||
|
||||
viewport.addEventListener('wheel', onWheel, { passive: false });
|
||||
viewport.addEventListener('pointerdown', onPointerDown);
|
||||
viewport.addEventListener('pointermove', onPointerMove);
|
||||
viewport.addEventListener('pointerup', stopPan);
|
||||
viewport.addEventListener('pointercancel', stopPan);
|
||||
window.addEventListener('resize', onResize);
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onResize);
|
||||
observer?.observe(viewport);
|
||||
fit();
|
||||
|
||||
return {
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
fit,
|
||||
cleanup: () => {
|
||||
viewport.removeEventListener('wheel', onWheel);
|
||||
viewport.removeEventListener('pointerdown', onPointerDown);
|
||||
viewport.removeEventListener('pointermove', onPointerMove);
|
||||
viewport.removeEventListener('pointerup', stopPan);
|
||||
viewport.removeEventListener('pointercancel', stopPan);
|
||||
window.removeEventListener('resize', onResize);
|
||||
observer?.disconnect();
|
||||
if (clearClickSuppressionTimer !== null) {
|
||||
window.clearTimeout(clearClickSuppressionTimer);
|
||||
}
|
||||
block.removeAttribute('data-mermaid-panning');
|
||||
block.removeAttribute('data-mermaid-suppress-click');
|
||||
controllerByBlock.delete(block);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const createMermaidViewerRegistry = (container: HTMLElement): { refresh: () => void; cleanup: () => void } => {
|
||||
const controllers = new Map<HTMLElement, MermaidViewerController>();
|
||||
const signatures = new Map<HTMLElement, string>();
|
||||
|
||||
const refresh = (): void => {
|
||||
for (const [block, controller] of Array.from(controllers.entries())) {
|
||||
const signature = getBlockViewerSignature(block);
|
||||
if (!container.contains(block) || signature !== signatures.get(block)) {
|
||||
controller.cleanup();
|
||||
controllers.delete(block);
|
||||
signatures.delete(block);
|
||||
}
|
||||
}
|
||||
|
||||
for (const block of Array.from(container.querySelectorAll<HTMLElement>(MERMAID_BLOCK_SELECTOR))) {
|
||||
if (controllers.has(block) || block.querySelector('[data-markdown="mermaid"] svg') === null) {
|
||||
continue;
|
||||
}
|
||||
const controller = createMermaidViewerController(block);
|
||||
if (!controller) {
|
||||
continue;
|
||||
}
|
||||
controllers.set(block, controller);
|
||||
signatures.set(block, getBlockViewerSignature(block));
|
||||
controllerByBlock.set(block, controller);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = (): void => {
|
||||
for (const controller of controllers.values()) {
|
||||
controller.cleanup();
|
||||
}
|
||||
controllers.clear();
|
||||
signatures.clear();
|
||||
};
|
||||
|
||||
refresh();
|
||||
return { refresh, cleanup };
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
|
||||
|
||||
describe('getMermaidDataUrlSourcePromise', () => {
|
||||
test('turns malformed data URLs into rejected promises', async () => {
|
||||
const sourcePromise = getMermaidDataUrlSourcePromise('data:text/plain;base64');
|
||||
|
||||
await sourcePromise.then(
|
||||
() => {
|
||||
throw new Error('expected malformed data URL to reject');
|
||||
},
|
||||
(error) => {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(MermaidLoadFailure);
|
||||
expect(error.key).toBe('chat.toolOutputDialog.mermaid.dataUrlMalformed');
|
||||
expect(error.params).toBe(undefined);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mermaid load request ids', () => {
|
||||
test('invalidates stale async loads when a newer load starts', () => {
|
||||
const firstRequest = nextMermaidLoadRequestId(0);
|
||||
const secondRequest = nextMermaidLoadRequestId(firstRequest);
|
||||
|
||||
expect(isCurrentMermaidLoadRequest(secondRequest, firstRequest)).toBe(false);
|
||||
expect(isCurrentMermaidLoadRequest(secondRequest, secondRequest)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -26,8 +26,9 @@ import { DiffViewToggle } from './DiffViewToggle';
|
||||
import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock';
|
||||
import { JsonTreeView } from '@/components/ui/JsonTreeView';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useI18n, type I18nKey, type I18nParams } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, isMermaidLoadFailure, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
|
||||
|
||||
interface ToolOutputDialogProps {
|
||||
popup: ToolPopupContent;
|
||||
@@ -35,6 +36,8 @@ interface ToolOutputDialogProps {
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
const mermaidLoadFailure = (key: I18nKey, params?: I18nParams): MermaidLoadFailure => new MermaidLoadFailure(key, params);
|
||||
|
||||
const getToolIcon = (toolName: string) => {
|
||||
const iconClass = 'h-3.5 w-3.5 flex-shrink-0';
|
||||
const tool = toolName.toLowerCase();
|
||||
@@ -97,7 +100,7 @@ const MERMAID_ASPECT_MAX_RETRIES = 3;
|
||||
|
||||
const DIALOG_CODE_TAG_PROPS = { style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } };
|
||||
|
||||
const MERMAID_CONTROLS = { download: false, copy: false, fullscreen: false, panZoom: true };
|
||||
const MERMAID_CONTROLS = { download: false, copy: false, showPanZoomControls: true };
|
||||
|
||||
type PierreThemeConfig = {
|
||||
theme: { light: string; dark: string };
|
||||
@@ -694,22 +697,11 @@ const MermaidPreviewDialog: React.FC<{
|
||||
return isSafeLocalPath(decoded) ? decoded : (isSafeLocalPath(stripped) ? stripped : null);
|
||||
}, []);
|
||||
|
||||
const decodeDataUrl = React.useCallback((value: string): string => {
|
||||
const commaIndex = value.indexOf(',');
|
||||
if (commaIndex < 0) {
|
||||
throw new Error('Malformed data URL');
|
||||
}
|
||||
|
||||
const metadata = value.slice(0, commaIndex).toLowerCase();
|
||||
const payload = value.slice(commaIndex + 1);
|
||||
if (metadata.includes(';base64')) {
|
||||
return atob(payload);
|
||||
}
|
||||
return decodeURIComponent(payload);
|
||||
}, []);
|
||||
|
||||
const loadMermaidSource = React.useCallback(async () => {
|
||||
const target = popup.mermaid;
|
||||
const requestId = nextMermaidLoadRequestId(requestIdRef.current);
|
||||
requestIdRef.current = requestId;
|
||||
|
||||
if (!target?.url) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('chat.toolOutputDialog.mermaid.missingSource'));
|
||||
@@ -723,24 +715,21 @@ const MermaidPreviewDialog: React.FC<{
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
|
||||
setStatus('loading');
|
||||
setErrorMessage('');
|
||||
|
||||
let sourcePromise: Promise<string>;
|
||||
if (target.url.startsWith('data:')) {
|
||||
sourcePromise = Promise.resolve(decodeDataUrl(target.url));
|
||||
sourcePromise = getMermaidDataUrlSourcePromise(target.url);
|
||||
} else if (target.url.toLowerCase().startsWith('file://')) {
|
||||
const normalizedPath = normalizeFilePath(target.url);
|
||||
if (!normalizedPath) {
|
||||
sourcePromise = Promise.reject(new Error('Invalid local file path for Mermaid preview.'));
|
||||
sourcePromise = Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.invalidLocalPath'));
|
||||
} else {
|
||||
sourcePromise = runtimeFetch('/api/fs/raw', { query: { path: normalizedPath } })
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(new Error(`Failed to read diagram file (${response.status})`));
|
||||
return Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.readFileFailedWithStatus', { status: response.status }));
|
||||
}
|
||||
return response.text();
|
||||
});
|
||||
@@ -752,12 +741,12 @@ const MermaidPreviewDialog: React.FC<{
|
||||
const resolvedUrl = canParse ? new URL(target.url, window.location.origin) : null;
|
||||
|
||||
if (!resolvedUrl || (resolvedUrl.protocol !== 'http:' && resolvedUrl.protocol !== 'https:')) {
|
||||
sourcePromise = Promise.reject(new Error('Unsupported Mermaid URL protocol.'));
|
||||
sourcePromise = Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.unsupportedUrlProtocol'));
|
||||
} else {
|
||||
sourcePromise = fetch(resolvedUrl.toString())
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(new Error(`Failed to load diagram (${response.status})`));
|
||||
return Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.loadFailedWithStatus', { status: response.status }));
|
||||
}
|
||||
return response.text();
|
||||
});
|
||||
@@ -766,7 +755,7 @@ const MermaidPreviewDialog: React.FC<{
|
||||
|
||||
await sourcePromise
|
||||
.then((resolvedSource) => {
|
||||
if (requestIdRef.current !== requestId) {
|
||||
if (!isCurrentMermaidLoadRequest(requestIdRef.current, requestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -774,13 +763,13 @@ const MermaidPreviewDialog: React.FC<{
|
||||
setStatus('ready');
|
||||
})
|
||||
.catch((error) => {
|
||||
if (requestIdRef.current !== requestId) {
|
||||
if (!isCurrentMermaidLoadRequest(requestIdRef.current, requestId)) {
|
||||
return;
|
||||
}
|
||||
setStatus('error');
|
||||
setErrorMessage(error instanceof Error ? error.message : t('chat.toolOutputDialog.mermaid.loadFailed'));
|
||||
setErrorMessage(isMermaidLoadFailure(error) ? t(error.key, error.params) : t('chat.toolOutputDialog.mermaid.loadFailed'));
|
||||
});
|
||||
}, [decodeDataUrl, normalizeFilePath, popup.mermaid, t]);
|
||||
}, [normalizeFilePath, popup.mermaid, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!popup.open || !popup.mermaid) {
|
||||
@@ -896,10 +885,11 @@ const MermaidPreviewDialog: React.FC<{
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute inset-0 bg-black/40',
|
||||
'absolute inset-0',
|
||||
isTransitioning && 'transition-opacity duration-150 ease-out',
|
||||
isVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-background) 70%, transparent)' }}
|
||||
onMouseDown={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
@@ -941,7 +931,13 @@ const MermaidPreviewDialog: React.FC<{
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="rounded-xl border border-border/30 bg-muted/20 p-3 space-y-3">
|
||||
<div
|
||||
className="rounded-xl border p-3 space-y-3"
|
||||
style={{
|
||||
backgroundColor: 'var(--status-error-background)',
|
||||
borderColor: 'var(--status-error-border)',
|
||||
}}
|
||||
>
|
||||
<p className="typography-markdown" style={{ color: 'var(--status-error)' }}>
|
||||
{errorMessage || t('chat.toolOutputDialog.mermaid.renderFailed')}
|
||||
</p>
|
||||
@@ -966,8 +962,8 @@ const MermaidPreviewDialog: React.FC<{
|
||||
<SimpleMarkdownRenderer
|
||||
content={mermaidMarkdown}
|
||||
variant="tool"
|
||||
allowMermaidWheelZoom
|
||||
className="markdown-mermaid-fullscreen h-full [&_[data-markdown='mermaid-block']_button]:hidden"
|
||||
allowMermaidWheelEvents
|
||||
className="markdown-mermaid-fullscreen h-full"
|
||||
mermaidControls={MERMAID_CONTROLS}
|
||||
enableFileReferences={false}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { I18nKey, I18nParams } from '@/lib/i18n';
|
||||
|
||||
export class MermaidLoadFailure extends Error {
|
||||
key: I18nKey;
|
||||
params?: I18nParams;
|
||||
|
||||
constructor(key: I18nKey, params?: I18nParams) {
|
||||
super(key);
|
||||
this.name = 'MermaidLoadFailure';
|
||||
this.key = key;
|
||||
this.params = params;
|
||||
}
|
||||
}
|
||||
|
||||
const mermaidLoadFailure = (key: I18nKey, params?: I18nParams): MermaidLoadFailure => new MermaidLoadFailure(key, params);
|
||||
|
||||
export const isMermaidLoadFailure = (value: unknown): value is MermaidLoadFailure => value instanceof MermaidLoadFailure;
|
||||
|
||||
export const nextMermaidLoadRequestId = (current: number): number => current + 1;
|
||||
|
||||
export const isCurrentMermaidLoadRequest = (current: number, requestId: number): boolean => current === requestId;
|
||||
|
||||
const decodeMermaidDataUrl = (value: string): string => {
|
||||
const commaIndex = value.indexOf(',');
|
||||
if (commaIndex < 0) {
|
||||
throw mermaidLoadFailure('chat.toolOutputDialog.mermaid.dataUrlMalformed');
|
||||
}
|
||||
|
||||
const metadata = value.slice(0, commaIndex).toLowerCase();
|
||||
const payload = value.slice(commaIndex + 1);
|
||||
if (metadata.includes(';base64')) {
|
||||
return atob(payload);
|
||||
}
|
||||
return decodeURIComponent(payload);
|
||||
};
|
||||
|
||||
export const getMermaidDataUrlSourcePromise = (value: string): Promise<string> => Promise.resolve().then(() => decodeMermaidDataUrl(value));
|
||||
+43
-15
@@ -1318,32 +1318,49 @@ html:not(.dark) .chat-scroll {
|
||||
|
||||
[data-markdown="mermaid-block"] [data-markdown="mermaid-scroll"] {
|
||||
overflow: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-markdown="mermaid-block"][data-mermaid-render="svg"] [data-markdown="mermaid-scroll"] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-markdown="mermaid-block"] [data-markdown="mermaid-viewport"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: min(24rem, 70vh);
|
||||
min-height: 12rem;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
[data-markdown="mermaid-block"] [data-markdown="mermaid"] {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-markdown="mermaid-block"][data-mermaid-panning="true"] [data-markdown="mermaid-viewport"] {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
[data-markdown="mermaid-block"] [data-markdown="mermaid-ascii"] {
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--surface-foreground);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-markdown="mermaid-block"] [data-markdown="mermaid"] svg {
|
||||
width: auto !important;
|
||||
min-width: 100%;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
min-width: 0;
|
||||
max-width: none !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
[data-markdown="mermaid-block"] svg {
|
||||
display: block;
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
@@ -1375,10 +1392,21 @@ html:not(.dark) .chat-scroll {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.markdown-mermaid-fullscreen [data-markdown="mermaid-block"] [data-markdown="mermaid"] {
|
||||
width: max-content;
|
||||
.markdown-mermaid-fullscreen [data-markdown="mermaid-block"][data-mermaid-render="svg"] [data-markdown="mermaid-scroll"] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown-mermaid-fullscreen [data-markdown="mermaid-block"] [data-markdown="mermaid-viewport"] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown-mermaid-fullscreen [data-markdown="mermaid-block"] [data-markdown="mermaid"] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -1388,14 +1416,14 @@ html:not(.dark) .chat-scroll {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--surface-foreground);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.markdown-mermaid-fullscreen [data-markdown="mermaid-block"] svg {
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
.markdown-mermaid-fullscreen [data-markdown="mermaid-block"] [data-markdown="mermaid"] svg {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
min-width: 100%;
|
||||
|
||||
@@ -1962,6 +1962,11 @@ export const dict = {
|
||||
'chat.toolOutputDialog.image.closeAria': 'Close image preview',
|
||||
'chat.toolOutputDialog.mermaid.missingSource': 'Missing Mermaid source URL.',
|
||||
'chat.toolOutputDialog.mermaid.loadFailed': 'Unable to load Mermaid diagram.',
|
||||
'chat.toolOutputDialog.mermaid.dataUrlMalformed': 'The Mermaid data URL is malformed.',
|
||||
'chat.toolOutputDialog.mermaid.invalidLocalPath': 'The local Mermaid file path is invalid.',
|
||||
'chat.toolOutputDialog.mermaid.readFileFailedWithStatus': 'Unable to read Mermaid file. Status: {status}.',
|
||||
'chat.toolOutputDialog.mermaid.unsupportedUrlProtocol': 'The Mermaid URL protocol is unsupported.',
|
||||
'chat.toolOutputDialog.mermaid.loadFailedWithStatus': 'Unable to load Mermaid diagram. Status: {status}.',
|
||||
'chat.toolOutputDialog.mermaid.closeAria': 'Close diagram preview',
|
||||
'chat.toolOutputDialog.mermaid.loading': 'Loading diagram...',
|
||||
'chat.toolOutputDialog.mermaid.renderFailed': 'Unable to render Mermaid diagram.',
|
||||
@@ -2669,6 +2674,8 @@ export const dict = {
|
||||
'mainLayout.mobile.closeDrawerAria': 'Close drawer',
|
||||
'sortableTabsStrip.aria.tabs': 'Tabs',
|
||||
'openChamberLogo.aria.logo': 'OpenChamber logo',
|
||||
'markdownRenderer.code.actions.copyTitle': 'Copy code',
|
||||
'markdownRenderer.code.actions.copiedTitle': 'Copied',
|
||||
'markdownRenderer.table.actions.copyTitle': 'Copy table',
|
||||
'markdownRenderer.code.actions.enableWrapTitle': 'Enable line wrap',
|
||||
'markdownRenderer.code.actions.disableWrapTitle': 'Disable line wrap',
|
||||
@@ -2677,6 +2684,9 @@ export const dict = {
|
||||
'markdownRenderer.mermaid.actions.copyTitle': 'Copy',
|
||||
'markdownRenderer.mermaid.actions.copySourceTitle': 'Copy source',
|
||||
'markdownRenderer.mermaid.actions.downloadSvgTitle': 'Download SVG',
|
||||
'markdownRenderer.mermaid.actions.zoomInTitle': 'Zoom in',
|
||||
'markdownRenderer.mermaid.actions.zoomOutTitle': 'Zoom out',
|
||||
'markdownRenderer.mermaid.actions.resetViewTitle': 'Reset view',
|
||||
'markdownRenderer.mermaid.toast.downloadFailed': 'Failed to download diagram',
|
||||
'common.date.today': 'Today',
|
||||
'common.date.yesterday': 'Yesterday',
|
||||
|
||||
@@ -1928,6 +1928,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.toolOutputDialog.image.closeAria": "Cerrar vista previa de imagen",
|
||||
"chat.toolOutputDialog.mermaid.missingSource": "Falta la URL de origen de Mermaid.",
|
||||
"chat.toolOutputDialog.mermaid.loadFailed": "No se pudo cargar el diagrama de Mermaid.",
|
||||
"chat.toolOutputDialog.mermaid.dataUrlMalformed": "La URL de datos de Mermaid no tiene un formato válido.",
|
||||
"chat.toolOutputDialog.mermaid.invalidLocalPath": "La ruta del archivo local de Mermaid no es válida.",
|
||||
"chat.toolOutputDialog.mermaid.readFileFailedWithStatus": "No se pudo leer el archivo de Mermaid. Estado: {status}.",
|
||||
"chat.toolOutputDialog.mermaid.unsupportedUrlProtocol": "El protocolo de la URL de Mermaid no es compatible.",
|
||||
"chat.toolOutputDialog.mermaid.loadFailedWithStatus": "No se pudo cargar el diagrama de Mermaid. Estado: {status}.",
|
||||
"chat.toolOutputDialog.mermaid.closeAria": "Cerrar vista previa del diagrama",
|
||||
"chat.toolOutputDialog.mermaid.loading": "Cargando diagrama...",
|
||||
"chat.toolOutputDialog.mermaid.renderFailed": "No se pudo renderizar el diagrama de Mermaid.",
|
||||
@@ -2635,6 +2640,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"mainLayout.mobile.closeDrawerAria": "Cerrar drawer",
|
||||
"sortableTabsStrip.aria.tabs": "Pestañas",
|
||||
"openChamberLogo.aria.logo": "Logo de OpenChamber",
|
||||
"markdownRenderer.code.actions.copyTitle": "Copiar código",
|
||||
"markdownRenderer.code.actions.copiedTitle": "Copiado",
|
||||
"markdownRenderer.table.actions.copyTitle": "Copiar tabla",
|
||||
"markdownRenderer.code.actions.enableWrapTitle": "Activar ajuste de línea",
|
||||
"markdownRenderer.code.actions.disableWrapTitle": "Desactivar ajuste de línea",
|
||||
@@ -2643,6 +2650,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"markdownRenderer.mermaid.actions.copyTitle": "Copiar",
|
||||
"markdownRenderer.mermaid.actions.copySourceTitle": "Copiar fuente",
|
||||
"markdownRenderer.mermaid.actions.downloadSvgTitle": "Descargar SVG",
|
||||
"markdownRenderer.mermaid.actions.zoomInTitle": "Acercar",
|
||||
"markdownRenderer.mermaid.actions.zoomOutTitle": "Alejar",
|
||||
"markdownRenderer.mermaid.actions.resetViewTitle": "Restablecer vista",
|
||||
"markdownRenderer.mermaid.toast.downloadFailed": "No se pudo descargar el diagrama",
|
||||
"contextPanel.preview.title": "Vista previa",
|
||||
"contextPanel.preview.description": "Usa Acciones del proyecto o el botón Preview del terminal para abrir una vista previa.",
|
||||
|
||||
@@ -1749,6 +1749,11 @@ export const dict = {
|
||||
'chat.toolOutputDialog.image.closeAria': 'Fermer l\'aperçu de l\'image',
|
||||
'chat.toolOutputDialog.mermaid.missingSource': 'URL de source Mermaid manquante.',
|
||||
'chat.toolOutputDialog.mermaid.loadFailed': 'Impossible de charger le diagramme Mermaid.',
|
||||
'chat.toolOutputDialog.mermaid.dataUrlMalformed': 'L\'URL de données Mermaid est mal formée.',
|
||||
'chat.toolOutputDialog.mermaid.invalidLocalPath': 'Le chemin du fichier Mermaid local est invalide.',
|
||||
'chat.toolOutputDialog.mermaid.readFileFailedWithStatus': 'Impossible de lire le fichier Mermaid. Statut : {status}.',
|
||||
'chat.toolOutputDialog.mermaid.unsupportedUrlProtocol': 'Le protocole de l\'URL Mermaid n\'est pas pris en charge.',
|
||||
'chat.toolOutputDialog.mermaid.loadFailedWithStatus': 'Impossible de charger le diagramme Mermaid. Statut : {status}.',
|
||||
'chat.toolOutputDialog.mermaid.closeAria': 'Fermer l\'aperçu du diagramme',
|
||||
'chat.toolOutputDialog.mermaid.loading': 'Diagramme de chargement...',
|
||||
'chat.toolOutputDialog.mermaid.renderFailed': 'Impossible d\'afficher le diagramme Mermaid.',
|
||||
@@ -2439,6 +2444,8 @@ export const dict = {
|
||||
'mainLayout.mobile.closeDrawerAria': 'Fermer le tiroir',
|
||||
'sortableTabsStrip.aria.tabs': 'Onglets',
|
||||
'openChamberLogo.aria.logo': 'Logo OpenChamber',
|
||||
'markdownRenderer.code.actions.copyTitle': 'Copier le code',
|
||||
'markdownRenderer.code.actions.copiedTitle': 'Copié',
|
||||
'markdownRenderer.table.actions.copyTitle': 'Copier le tableau',
|
||||
'markdownRenderer.code.actions.enableWrapTitle': 'Activer le retour à la ligne',
|
||||
'markdownRenderer.code.actions.disableWrapTitle': 'Désactiver le retour à la ligne',
|
||||
@@ -2447,6 +2454,9 @@ export const dict = {
|
||||
'markdownRenderer.mermaid.actions.copyTitle': 'Copie',
|
||||
'markdownRenderer.mermaid.actions.copySourceTitle': 'Copier la source',
|
||||
'markdownRenderer.mermaid.actions.downloadSvgTitle': 'Télécharger SVG',
|
||||
'markdownRenderer.mermaid.actions.zoomInTitle': 'Zoom avant',
|
||||
'markdownRenderer.mermaid.actions.zoomOutTitle': 'Zoom arrière',
|
||||
'markdownRenderer.mermaid.actions.resetViewTitle': 'Réinitialiser la vue',
|
||||
'markdownRenderer.mermaid.toast.downloadFailed': 'Échec du téléchargement du diagramme',
|
||||
'common.date.today': 'Aujourd’hui',
|
||||
'common.date.yesterday': 'Hier',
|
||||
|
||||
@@ -1961,6 +1961,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.toolOutputDialog.image.closeAria': '画像プレビューを閉じる',
|
||||
'chat.toolOutputDialog.mermaid.missingSource': 'MermaidソースURLがありません。',
|
||||
'chat.toolOutputDialog.mermaid.loadFailed': 'Mermaidダイアグラムを読み込めません。',
|
||||
'chat.toolOutputDialog.mermaid.dataUrlMalformed': 'MermaidのデータURLの形式が正しくありません。',
|
||||
'chat.toolOutputDialog.mermaid.invalidLocalPath': 'Mermaidのローカルファイルパスが無効です。',
|
||||
'chat.toolOutputDialog.mermaid.readFileFailedWithStatus': 'Mermaidファイルを読み込めません。ステータス: {status}。',
|
||||
'chat.toolOutputDialog.mermaid.unsupportedUrlProtocol': 'Mermaid URLのプロトコルはサポートされていません。',
|
||||
'chat.toolOutputDialog.mermaid.loadFailedWithStatus': 'Mermaidダイアグラムを読み込めません。ステータス: {status}。',
|
||||
'chat.toolOutputDialog.mermaid.closeAria': 'ダイアグラムプレビューを閉じる',
|
||||
'chat.toolOutputDialog.mermaid.loading': 'ダイアグラムを読み込み中...',
|
||||
'chat.toolOutputDialog.mermaid.renderFailed': 'Mermaidダイアグラムをレンダリングできません。',
|
||||
@@ -2665,6 +2670,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'mainLayout.mobile.closeDrawerAria': 'ドロワーを閉じる',
|
||||
'sortableTabsStrip.aria.tabs': 'タブ',
|
||||
'openChamberLogo.aria.logo': 'OpenChamberロゴ',
|
||||
'markdownRenderer.code.actions.copyTitle': 'コードをコピー',
|
||||
'markdownRenderer.code.actions.copiedTitle': 'コピーしました',
|
||||
'markdownRenderer.table.actions.copyTitle': 'テーブルをコピー',
|
||||
'markdownRenderer.code.actions.enableWrapTitle': '行折り返しを有効にする',
|
||||
'markdownRenderer.code.actions.disableWrapTitle': '行折り返しを無効にする',
|
||||
@@ -2673,6 +2680,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'markdownRenderer.mermaid.actions.copyTitle': 'コピー',
|
||||
'markdownRenderer.mermaid.actions.copySourceTitle': 'ソースをコピー',
|
||||
'markdownRenderer.mermaid.actions.downloadSvgTitle': 'SVGをダウンロード',
|
||||
'markdownRenderer.mermaid.actions.zoomInTitle': '拡大',
|
||||
'markdownRenderer.mermaid.actions.zoomOutTitle': '縮小',
|
||||
'markdownRenderer.mermaid.actions.resetViewTitle': '表示をリセット',
|
||||
'markdownRenderer.mermaid.toast.downloadFailed': 'ダイアグラムのダウンロードに失敗しました',
|
||||
'common.date.today': '今日',
|
||||
'common.date.yesterday': '昨日',
|
||||
|
||||
@@ -1962,6 +1962,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.toolOutputDialog.image.closeAria': '이미지 미리보기 닫기',
|
||||
'chat.toolOutputDialog.mermaid.missingSource': 'Mermaid 소스 URL이 없습니다.',
|
||||
'chat.toolOutputDialog.mermaid.loadFailed': 'Mermaid 다이어그램을 불러올 수 없습니다',
|
||||
'chat.toolOutputDialog.mermaid.dataUrlMalformed': 'Mermaid 데이터 URL 형식이 올바르지 않습니다.',
|
||||
'chat.toolOutputDialog.mermaid.invalidLocalPath': 'Mermaid 로컬 파일 경로가 올바르지 않습니다.',
|
||||
'chat.toolOutputDialog.mermaid.readFileFailedWithStatus': 'Mermaid 파일을 읽을 수 없습니다. 상태: {status}.',
|
||||
'chat.toolOutputDialog.mermaid.unsupportedUrlProtocol': 'Mermaid URL 프로토콜은 지원되지 않습니다.',
|
||||
'chat.toolOutputDialog.mermaid.loadFailedWithStatus': 'Mermaid 다이어그램을 불러올 수 없습니다. 상태: {status}.',
|
||||
'chat.toolOutputDialog.mermaid.closeAria': '다이어그램 미리보기 닫기',
|
||||
'chat.toolOutputDialog.mermaid.loading': '다이어그램 로드 중…',
|
||||
'chat.toolOutputDialog.mermaid.renderFailed': 'Mermaid 다이어그램을 렌더링할 수 없습니다.',
|
||||
@@ -2669,6 +2674,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'mainLayout.mobile.closeDrawerAria': '드로어 닫기',
|
||||
'sortableTabsStrip.aria.tabs': '탭',
|
||||
'openChamberLogo.aria.logo': 'OpenChamber 로고',
|
||||
'markdownRenderer.code.actions.copyTitle': '코드 복사',
|
||||
'markdownRenderer.code.actions.copiedTitle': '복사됨',
|
||||
'markdownRenderer.table.actions.copyTitle': '표 복사',
|
||||
'markdownRenderer.code.actions.enableWrapTitle': '줄 바꿈 켜기',
|
||||
'markdownRenderer.code.actions.disableWrapTitle': '줄 바꿈 끄기',
|
||||
@@ -2677,6 +2684,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'markdownRenderer.mermaid.actions.copyTitle': '복사',
|
||||
'markdownRenderer.mermaid.actions.copySourceTitle': '소스 복사',
|
||||
'markdownRenderer.mermaid.actions.downloadSvgTitle': 'SVG 다운로드',
|
||||
'markdownRenderer.mermaid.actions.zoomInTitle': '확대',
|
||||
'markdownRenderer.mermaid.actions.zoomOutTitle': '축소',
|
||||
'markdownRenderer.mermaid.actions.resetViewTitle': '보기 초기화',
|
||||
'markdownRenderer.mermaid.toast.downloadFailed': '다이어그램 다운로드 실패',
|
||||
'common.date.today': 'Today',
|
||||
'common.date.yesterday': 'Yesterday',
|
||||
|
||||
@@ -1281,6 +1281,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.toolOutputDialog.image.previousAria': 'Poprzedni obraz',
|
||||
'chat.toolOutputDialog.mermaid.closeAria': 'Zamknij podgląd diagramu',
|
||||
'chat.toolOutputDialog.mermaid.loadFailed': 'Nie udało się wczytać diagramu Mermaid.',
|
||||
'chat.toolOutputDialog.mermaid.dataUrlMalformed': 'Adres URL danych Mermaid ma nieprawidłowy format.',
|
||||
'chat.toolOutputDialog.mermaid.invalidLocalPath': 'Lokalna ścieżka pliku Mermaid jest nieprawidłowa.',
|
||||
'chat.toolOutputDialog.mermaid.readFileFailedWithStatus': 'Nie udało się odczytać pliku Mermaid. Status: {status}.',
|
||||
'chat.toolOutputDialog.mermaid.unsupportedUrlProtocol': 'Protokół adresu URL Mermaid nie jest obsługiwany.',
|
||||
'chat.toolOutputDialog.mermaid.loadFailedWithStatus': 'Nie udało się wczytać diagramu Mermaid. Status: {status}.',
|
||||
'chat.toolOutputDialog.mermaid.loading': 'Ładowanie diagramu...',
|
||||
'chat.toolOutputDialog.mermaid.missingSource': 'Brakuje adresu URL źródła Mermaid.',
|
||||
'chat.toolOutputDialog.mermaid.renderFailed': 'Nie udało się wyrenderować diagramu Mermaid.',
|
||||
@@ -2159,7 +2164,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'markdownRenderer.mermaid.actions.copySourceTitle': 'Kopiuj źródło',
|
||||
'markdownRenderer.mermaid.actions.copyTitle': 'Kopiuj',
|
||||
'markdownRenderer.mermaid.actions.downloadSvgTitle': 'Pobierz SVG',
|
||||
'markdownRenderer.mermaid.actions.zoomInTitle': 'Powiększ',
|
||||
'markdownRenderer.mermaid.actions.zoomOutTitle': 'Pomniejsz',
|
||||
'markdownRenderer.mermaid.actions.resetViewTitle': 'Resetuj widok',
|
||||
'markdownRenderer.mermaid.toast.downloadFailed': 'Nie udało się pobrać diagramu',
|
||||
'markdownRenderer.code.actions.copyTitle': 'Kopiuj kod',
|
||||
'markdownRenderer.code.actions.copiedTitle': 'Skopiowano',
|
||||
'markdownRenderer.table.actions.copyTitle': 'Kopiuj tabelę',
|
||||
'markdownRenderer.code.actions.enableWrapTitle': 'Włącz zawijanie wierszy',
|
||||
'markdownRenderer.code.actions.disableWrapTitle': 'Wyłącz zawijanie wierszy',
|
||||
|
||||
@@ -1928,6 +1928,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.toolOutputDialog.image.closeAria": "Fechar prévia de imagem",
|
||||
"chat.toolOutputDialog.mermaid.missingSource": "Falta a URL de origem do Mermaid.",
|
||||
"chat.toolOutputDialog.mermaid.loadFailed": "Não foi possível carregar o diagrama de Mermaid.",
|
||||
"chat.toolOutputDialog.mermaid.dataUrlMalformed": "A URL de dados do Mermaid está malformada.",
|
||||
"chat.toolOutputDialog.mermaid.invalidLocalPath": "O caminho do arquivo local do Mermaid é inválido.",
|
||||
"chat.toolOutputDialog.mermaid.readFileFailedWithStatus": "Não foi possível ler o arquivo Mermaid. Status: {status}.",
|
||||
"chat.toolOutputDialog.mermaid.unsupportedUrlProtocol": "O protocolo da URL do Mermaid não é compatível.",
|
||||
"chat.toolOutputDialog.mermaid.loadFailedWithStatus": "Não foi possível carregar o diagrama Mermaid. Status: {status}.",
|
||||
"chat.toolOutputDialog.mermaid.closeAria": "Fechar prévia do diagrama",
|
||||
"chat.toolOutputDialog.mermaid.loading": "Carregando diagrama...",
|
||||
"chat.toolOutputDialog.mermaid.renderFailed": "Não foi possível renderizar o diagrama de Mermaid.",
|
||||
@@ -2635,6 +2640,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"mainLayout.mobile.closeDrawerAria": "Fechar gaveta",
|
||||
"sortableTabsStrip.aria.tabs": "Abas",
|
||||
"openChamberLogo.aria.logo": "Logo do OpenChamber",
|
||||
"markdownRenderer.code.actions.copyTitle": "Copiar código",
|
||||
"markdownRenderer.code.actions.copiedTitle": "Copiado",
|
||||
"markdownRenderer.table.actions.copyTitle": "Copiar tabela",
|
||||
"markdownRenderer.code.actions.enableWrapTitle": "Ativar quebra de linha",
|
||||
"markdownRenderer.code.actions.disableWrapTitle": "Desativar quebra de linha",
|
||||
@@ -2643,6 +2650,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"markdownRenderer.mermaid.actions.copyTitle": "Copiar",
|
||||
"markdownRenderer.mermaid.actions.copySourceTitle": "Copiar origem",
|
||||
"markdownRenderer.mermaid.actions.downloadSvgTitle": "Baixar SVG",
|
||||
"markdownRenderer.mermaid.actions.zoomInTitle": "Aumentar zoom",
|
||||
"markdownRenderer.mermaid.actions.zoomOutTitle": "Diminuir zoom",
|
||||
"markdownRenderer.mermaid.actions.resetViewTitle": "Redefinir visualização",
|
||||
"markdownRenderer.mermaid.toast.downloadFailed": "Não foi possível baixar o diagrama",
|
||||
"contextPanel.preview.title": "Visualização",
|
||||
"contextPanel.preview.description": "Use Ações do projeto ou o botão Preview do terminal para abrir uma visualização.",
|
||||
|
||||
@@ -1928,6 +1928,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.toolOutputDialog.image.closeAria": "Закрити попередній перегляд зображення",
|
||||
"chat.toolOutputDialog.mermaid.missingSource": "Відсутнє джерело Mermaid URL.",
|
||||
"chat.toolOutputDialog.mermaid.loadFailed": "Не вдалося завантажити діаграму Mermaid.",
|
||||
"chat.toolOutputDialog.mermaid.dataUrlMalformed": "URL даних Mermaid має неправильний формат.",
|
||||
"chat.toolOutputDialog.mermaid.invalidLocalPath": "Локальний шлях до файлу Mermaid недійсний.",
|
||||
"chat.toolOutputDialog.mermaid.readFileFailedWithStatus": "Не вдалося прочитати файл Mermaid. Статус: {status}.",
|
||||
"chat.toolOutputDialog.mermaid.unsupportedUrlProtocol": "Протокол URL Mermaid не підтримується.",
|
||||
"chat.toolOutputDialog.mermaid.loadFailedWithStatus": "Не вдалося завантажити діаграму Mermaid. Статус: {status}.",
|
||||
"chat.toolOutputDialog.mermaid.closeAria": "Закрити попередній перегляд діаграми",
|
||||
"chat.toolOutputDialog.mermaid.loading": "Завантаження діаграми...",
|
||||
"chat.toolOutputDialog.mermaid.renderFailed": "Неможливо відобразити діаграму Mermaid.",
|
||||
@@ -2635,6 +2640,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"mainLayout.mobile.closeDrawerAria": "Закрити панель",
|
||||
"sortableTabsStrip.aria.tabs": "Вкладки",
|
||||
"openChamberLogo.aria.logo": "Логотип OpenChamber",
|
||||
"markdownRenderer.code.actions.copyTitle": "Скопіювати код",
|
||||
"markdownRenderer.code.actions.copiedTitle": "Скопійовано",
|
||||
"markdownRenderer.table.actions.copyTitle": "Скопіювати таблицю",
|
||||
"markdownRenderer.code.actions.enableWrapTitle": "Увімкнути перенесення рядків",
|
||||
"markdownRenderer.code.actions.disableWrapTitle": "Вимкнути перенесення рядків",
|
||||
@@ -2643,6 +2650,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"markdownRenderer.mermaid.actions.copyTitle": "Копіювати",
|
||||
"markdownRenderer.mermaid.actions.copySourceTitle": "Копіювати джерело",
|
||||
"markdownRenderer.mermaid.actions.downloadSvgTitle": "Завантажити SVG",
|
||||
"markdownRenderer.mermaid.actions.zoomInTitle": "Збільшити",
|
||||
"markdownRenderer.mermaid.actions.zoomOutTitle": "Зменшити",
|
||||
"markdownRenderer.mermaid.actions.resetViewTitle": "Скинути вигляд",
|
||||
"markdownRenderer.mermaid.toast.downloadFailed": "Не вдалося завантажити діаграму",
|
||||
"contextPanel.preview.title": "Попередній перегляд",
|
||||
"contextPanel.preview.description": "Використайте дії проєкту або кнопку Preview у терміналі, щоб відкрити перегляд.",
|
||||
|
||||
@@ -1928,6 +1928,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.toolOutputDialog.image.closeAria': '关闭图片预览',
|
||||
'chat.toolOutputDialog.mermaid.missingSource': '缺少 Mermaid 源 URL。',
|
||||
'chat.toolOutputDialog.mermaid.loadFailed': '无法加载 Mermaid 图表。',
|
||||
'chat.toolOutputDialog.mermaid.dataUrlMalformed': 'Mermaid 数据 URL 格式不正确。',
|
||||
'chat.toolOutputDialog.mermaid.invalidLocalPath': 'Mermaid 本地文件路径无效。',
|
||||
'chat.toolOutputDialog.mermaid.readFileFailedWithStatus': '无法读取 Mermaid 文件。状态:{status}。',
|
||||
'chat.toolOutputDialog.mermaid.unsupportedUrlProtocol': '不支持该 Mermaid URL 协议。',
|
||||
'chat.toolOutputDialog.mermaid.loadFailedWithStatus': '无法加载 Mermaid 图表。状态:{status}。',
|
||||
'chat.toolOutputDialog.mermaid.closeAria': '关闭图表预览',
|
||||
'chat.toolOutputDialog.mermaid.loading': '正在加载图表...',
|
||||
'chat.toolOutputDialog.mermaid.renderFailed': '无法渲染 Mermaid 图表。',
|
||||
@@ -2635,6 +2640,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'mainLayout.mobile.closeDrawerAria': '关闭抽屉',
|
||||
'sortableTabsStrip.aria.tabs': '标签页',
|
||||
'openChamberLogo.aria.logo': 'OpenChamber 标志',
|
||||
'markdownRenderer.code.actions.copyTitle': '复制代码',
|
||||
'markdownRenderer.code.actions.copiedTitle': '已复制',
|
||||
'markdownRenderer.table.actions.copyTitle': '复制表格',
|
||||
'markdownRenderer.code.actions.enableWrapTitle': '启用自动换行',
|
||||
'markdownRenderer.code.actions.disableWrapTitle': '禁用自动换行',
|
||||
@@ -2643,6 +2650,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'markdownRenderer.mermaid.actions.copyTitle': '复制',
|
||||
'markdownRenderer.mermaid.actions.copySourceTitle': '复制源码',
|
||||
'markdownRenderer.mermaid.actions.downloadSvgTitle': '下载 SVG',
|
||||
'markdownRenderer.mermaid.actions.zoomInTitle': '放大',
|
||||
'markdownRenderer.mermaid.actions.zoomOutTitle': '缩小',
|
||||
'markdownRenderer.mermaid.actions.resetViewTitle': '重置视图',
|
||||
'markdownRenderer.mermaid.toast.downloadFailed': '下载图表失败',
|
||||
'contextPanel.preview.title': '预览',
|
||||
'contextPanel.preview.description': '使用项目操作或终端 Preview 按钮打开预览。',
|
||||
|
||||
@@ -1932,6 +1932,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.toolOutputDialog.image.closeAria': '關閉圖片預覽',
|
||||
'chat.toolOutputDialog.mermaid.missingSource': '缺少 Mermaid 來源 URL。',
|
||||
'chat.toolOutputDialog.mermaid.loadFailed': '無法載入 Mermaid 圖表。',
|
||||
'chat.toolOutputDialog.mermaid.dataUrlMalformed': 'Mermaid 資料 URL 格式不正確。',
|
||||
'chat.toolOutputDialog.mermaid.invalidLocalPath': 'Mermaid 本機檔案路徑無效。',
|
||||
'chat.toolOutputDialog.mermaid.readFileFailedWithStatus': '無法讀取 Mermaid 檔案。狀態:{status}。',
|
||||
'chat.toolOutputDialog.mermaid.unsupportedUrlProtocol': '不支援此 Mermaid URL 通訊協定。',
|
||||
'chat.toolOutputDialog.mermaid.loadFailedWithStatus': '無法載入 Mermaid 圖表。狀態:{status}。',
|
||||
'chat.toolOutputDialog.mermaid.closeAria': '關閉圖表預覽',
|
||||
'chat.toolOutputDialog.mermaid.loading': '正在載入圖表...',
|
||||
'chat.toolOutputDialog.mermaid.renderFailed': '無法渲染 Mermaid 圖表。',
|
||||
@@ -2632,6 +2637,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'mainLayout.mobile.closeDrawerAria': '關閉抽屜',
|
||||
'sortableTabsStrip.aria.tabs': '分頁',
|
||||
'openChamberLogo.aria.logo': 'OpenChamber 標誌',
|
||||
'markdownRenderer.code.actions.copyTitle': '複製程式碼',
|
||||
'markdownRenderer.code.actions.copiedTitle': '已複製',
|
||||
'markdownRenderer.table.actions.copyTitle': '複製表格',
|
||||
'markdownRenderer.code.actions.enableWrapTitle': '啟用自動換行',
|
||||
'markdownRenderer.code.actions.disableWrapTitle': '停用自動換行',
|
||||
@@ -2640,6 +2647,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'markdownRenderer.mermaid.actions.copyTitle': '複製',
|
||||
'markdownRenderer.mermaid.actions.copySourceTitle': '複製原始碼',
|
||||
'markdownRenderer.mermaid.actions.downloadSvgTitle': '下載 SVG',
|
||||
'markdownRenderer.mermaid.actions.zoomInTitle': '放大',
|
||||
'markdownRenderer.mermaid.actions.zoomOutTitle': '縮小',
|
||||
'markdownRenderer.mermaid.actions.resetViewTitle': '重置檢視',
|
||||
'markdownRenderer.mermaid.toast.downloadFailed': '下載圖表失敗',
|
||||
'contextPanel.preview.title': '預覽',
|
||||
'contextPanel.preview.description': '使用專案操作或終端機 Preview 按鈕開啟預覽。',
|
||||
|
||||
Reference in New Issue
Block a user