Skip to content

Commit 6e88dcd

Browse files
feat(chat): add Mermaid diagram zoom controls (openchamber#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>
1 parent 375f4e1 commit 6e88dcd

18 files changed

Lines changed: 1069 additions & 115 deletions

File tree

packages/ui/src/components/chat/MarkdownRendererImpl.tsx

Lines changed: 94 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ import {
2929
syncMarkdownCodeLineNumbers,
3030
type DecorateContext,
3131
type DecorateLabels,
32+
type MermaidControlOptions,
3233
type MermaidRender,
3334
} from './markdown/decorate';
35+
import { createMermaidViewerRegistry, MERMAID_BLOCK_SELECTOR, shouldRefreshMermaidViewers } from './markdown/mermaidViewer';
3436
import {
3537
BLOCK_PATH_TOKEN_RE,
3638
isAbsoluteReferencePath,
@@ -103,27 +105,12 @@ const useExternalLinkInteractions = ({
103105
}, [containerRef, enabled]);
104106
};
105107

106-
type MermaidControlOptions = {
107-
download: boolean;
108-
copy: boolean;
109-
fullscreen: boolean;
110-
panZoom: boolean;
111-
};
112-
113-
const extractMermaidBlocks = (markdown: string): string[] => {
114-
if (!markdown.includes('mermaid')) return [];
115-
const blocks: string[] = [];
116-
const regex = /(?:^|\r?\n)(`{3,}|~{3,})mermaid[^\n\r]*\r?\n([\s\S]*?)\r?\n\1(?=\r?\n|$)/gi;
117-
let match: RegExpExecArray | null = regex.exec(markdown);
118-
119-
while (match) {
120-
const block = (match[2] ?? '').replace(/\s+$/, '');
121-
blocks.push(block);
122-
match = regex.exec(markdown);
123-
}
124-
125-
return blocks;
108+
const DEFAULT_MERMAID_CONTROLS: MermaidControlOptions = {
109+
download: true,
110+
copy: true,
111+
showPanZoomControls: true,
126112
};
113+
const DEFAULT_MERMAID_FULLSCREEN_ENABLED = true;
127114

128115
const stripLeadingFrontmatter = (markdown: string): string => {
129116
const frontmatterMatch = markdown.match(
@@ -153,7 +140,6 @@ interface MarkdownRendererProps {
153140
enableFileReferences?: boolean;
154141
}
155142

156-
const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]';
157143
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
158144
const BLOCK_PATH_TOKEN_ATTR = 'data-openchamber-block-path-token';
159145
const BLOCK_PATH_TOKEN_SELECTOR = `[${BLOCK_PATH_TOKEN_ATTR}]`;
@@ -666,14 +652,16 @@ const useFileReferenceInteractions = ({
666652

667653
const useMermaidInlineInteractions = ({
668654
containerRef,
669-
mermaidBlocks,
670655
onShowPopup,
671-
allowWheelZoom,
656+
enableFullscreen,
657+
enablePanZoom,
658+
allowMermaidWheelEvents,
672659
}: {
673660
containerRef: React.RefObject<HTMLDivElement | null>;
674-
mermaidBlocks: string[];
675661
onShowPopup?: (content: ToolPopupContent) => void;
676-
allowWheelZoom?: boolean;
662+
enableFullscreen?: boolean;
663+
enablePanZoom?: boolean;
664+
allowMermaidWheelEvents?: boolean;
677665
}) => {
678666
React.useEffect(() => {
679667
const container = containerRef.current;
@@ -682,7 +670,7 @@ const useMermaidInlineInteractions = ({
682670
}
683671

684672
const handleMermaidClick = (event: MouseEvent) => {
685-
if (!onShowPopup) {
673+
if (!enableFullscreen || !onShowPopup) {
686674
return;
687675
}
688676

@@ -700,13 +688,18 @@ const useMermaidInlineInteractions = ({
700688
return;
701689
}
702690

703-
const renderedBlocks = Array.from(container.querySelectorAll(MERMAID_BLOCK_SELECTOR));
704-
const blockIndex = renderedBlocks.indexOf(block);
691+
if (block instanceof HTMLElement && block.hasAttribute('data-mermaid-suppress-click')) {
692+
block.removeAttribute('data-mermaid-suppress-click');
693+
return;
694+
}
695+
696+
const renderedBlocks = Array.from(container.querySelectorAll<HTMLElement>(MERMAID_BLOCK_SELECTOR));
697+
const blockIndex = renderedBlocks.indexOf(block as HTMLElement);
705698
if (blockIndex < 0) {
706699
return;
707700
}
708701

709-
const source = mermaidBlocks[blockIndex];
702+
const source = block instanceof HTMLElement ? block.getAttribute('data-md-source') : null;
710703
if (!source || source.trim().length === 0) {
711704
return;
712705
}
@@ -729,7 +722,7 @@ const useMermaidInlineInteractions = ({
729722
};
730723

731724
const handleInlineWheel = (event: WheelEvent) => {
732-
if (allowWheelZoom) {
725+
if (allowMermaidWheelEvents || ((event.ctrlKey || event.metaKey) && enablePanZoom)) {
733726
return;
734727
}
735728

@@ -754,7 +747,7 @@ const useMermaidInlineInteractions = ({
754747
container.removeEventListener('click', handleMermaidClick);
755748
container.removeEventListener('wheel', handleInlineWheel, true);
756749
};
757-
}, [allowWheelZoom, containerRef, mermaidBlocks, onShowPopup]);
750+
}, [allowMermaidWheelEvents, containerRef, enableFullscreen, enablePanZoom, onShowPopup]);
758751
};
759752

760753
// ---------------------------------------------------------------------------
@@ -862,17 +855,21 @@ const useDecorateContext = (
862855
currentTheme: Theme,
863856
deferCodeLineNumberSync: boolean,
864857
onPreviewLoopback?: (url: string) => void,
858+
mermaidControls: MermaidControlOptions = DEFAULT_MERMAID_CONTROLS,
865859
): DecorateContext => {
866860
const { t } = useI18n();
867861
const labels: DecorateLabels = React.useMemo(() => ({
868-
copy: 'Copy code',
869-
copied: 'Copied',
862+
copy: t('markdownRenderer.code.actions.copyTitle'),
863+
copied: t('markdownRenderer.code.actions.copiedTitle'),
870864
enableCodeWrap: t('markdownRenderer.code.actions.enableWrapTitle'),
871865
disableCodeWrap: t('markdownRenderer.code.actions.disableWrapTitle'),
872866
copyTable: t('markdownRenderer.table.actions.copyTitle'),
873867
downloadTable: t('markdownRenderer.table.actions.downloadTitle'),
874868
copyDiagram: t('markdownRenderer.mermaid.actions.copySourceTitle'),
875869
downloadDiagram: t('markdownRenderer.mermaid.actions.downloadSvgTitle'),
870+
zoomInDiagram: t('markdownRenderer.mermaid.actions.zoomInTitle'),
871+
zoomOutDiagram: t('markdownRenderer.mermaid.actions.zoomOutTitle'),
872+
resetDiagramView: t('markdownRenderer.mermaid.actions.resetViewTitle'),
876873
previewLabel: t('terminalView.preview.open'),
877874
previewTitle: t('terminalView.preview.openTitle'),
878875
}), [t]);
@@ -896,8 +893,8 @@ const useDecorateContext = (
896893
return {};
897894
}
898895
});
899-
return { labels, codeBlockLineWrap, deferCodeLineNumberSync, onToggleCodeBlockLineWrap: toggleCodeBlockLineWrap, renderMermaid, onPreviewLoopback };
900-
}, [currentTheme, labels, codeBlockLineWrap, deferCodeLineNumberSync, toggleCodeBlockLineWrap, onPreviewLoopback]);
896+
return { labels, mermaidControls, codeBlockLineWrap, deferCodeLineNumberSync, onToggleCodeBlockLineWrap: toggleCodeBlockLineWrap, renderMermaid, onPreviewLoopback };
897+
}, [currentTheme, labels, mermaidControls, codeBlockLineWrap, deferCodeLineNumberSync, toggleCodeBlockLineWrap, onPreviewLoopback]);
901898
};
902899

903900
// Runs the async render pipeline into the container and keeps a stable
@@ -921,6 +918,22 @@ const useMorphdomMarkdown = ({
921918
ensureMarkdownShikiTheme();
922919
}, []);
923920

921+
const mermaidViewerRef = React.useRef<ReturnType<typeof createMermaidViewerRegistry> | null>(null);
922+
const refreshMermaidViewers = React.useCallback(() => {
923+
const container = containerRef.current;
924+
if (!container) {
925+
return;
926+
}
927+
if (!mermaidViewerRef.current) {
928+
if (!shouldRefreshMermaidViewers(container)) {
929+
return;
930+
}
931+
mermaidViewerRef.current = createMermaidViewerRegistry(container);
932+
return;
933+
}
934+
mermaidViewerRef.current.refresh();
935+
}, [containerRef]);
936+
924937
// Synchronous first paint: while the async parse is in-flight, show escaped
925938
// plain text immediately so there is no blank frame on initial mount. Only
926939
// runs when the target is empty — subsequent updates keep the prior rich DOM
@@ -944,8 +957,16 @@ const useMorphdomMarkdown = ({
944957
// the structure here keeps the async morph to syntax colors only.
945958
decorateMarkdown(block, ctx);
946959
target.appendChild(block);
960+
if (shouldRefreshMermaidViewers(block)) {
961+
refreshMermaidViewers();
962+
}
947963
}
948-
}, [containerRef, text, ctx]);
964+
}, [containerRef, text, ctx, refreshMermaidViewers]);
965+
966+
React.useEffect(() => () => {
967+
mermaidViewerRef.current?.cleanup();
968+
mermaidViewerRef.current = null;
969+
}, []);
949970

950971
React.useEffect(() => {
951972
const container = containerRef.current;
@@ -973,16 +994,30 @@ const useMorphdomMarkdown = ({
973994
const temp = document.createElement('div');
974995
temp.innerHTML = block.html;
975996
decorateMarkdown(temp, ctx);
997+
const hadMermaidBlock = shouldRefreshMermaidViewers(el);
998+
const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp);
976999
morphdom(el, temp, {
9771000
childrenOnly: true,
9781001
onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
9791002
});
9801003
el.setAttribute('data-md-id', block.id);
1004+
if (hadMermaidBlock || tempHasMermaidBlock || shouldRefreshMermaidViewers(el)) {
1005+
refreshMermaidViewers();
1006+
}
9811007
});
9821008

9831009
// Remove any trailing block elements no longer present.
1010+
const hadMermaidBeforeTrailingCleanup = shouldRefreshMermaidViewers(target);
1011+
let removedMermaidBlock = false;
9841012
for (let i = existing.length - 1; i >= blocks.length; i -= 1) {
985-
existing[i]?.remove();
1013+
const removed = existing[i];
1014+
if (removed && shouldRefreshMermaidViewers(removed)) {
1015+
removedMermaidBlock = true;
1016+
}
1017+
removed?.remove();
1018+
}
1019+
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
1020+
refreshMermaidViewers();
9861021
}
9871022

9881023
if (!ctx.deferCodeLineNumberSync) {
@@ -993,7 +1028,7 @@ const useMorphdomMarkdown = ({
9931028
return () => {
9941029
active = false;
9951030
};
996-
}, [containerRef, text, streaming, cacheKey, ctx]);
1031+
}, [containerRef, text, streaming, cacheKey, ctx, refreshMermaidViewers]);
9971032

9981033
React.useEffect(() => {
9991034
const container = containerRef.current;
@@ -1073,8 +1108,12 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
10731108
const live = isStreaming && !disableStreamAnimation;
10741109
const pacedText = usePacedText(content, live);
10751110

1076-
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]);
1077-
useMermaidInlineInteractions({ containerRef, mermaidBlocks, onShowPopup });
1111+
useMermaidInlineInteractions({
1112+
containerRef,
1113+
onShowPopup,
1114+
enableFullscreen: DEFAULT_MERMAID_FULLSCREEN_ENABLED,
1115+
enablePanZoom: DEFAULT_MERMAID_CONTROLS.showPanZoomControls,
1116+
});
10781117
useFileReferenceInteractions({
10791118
containerRef,
10801119
effectiveDirectory,
@@ -1085,7 +1124,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
10851124
useExternalLinkInteractions({ containerRef });
10861125

10871126
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
1088-
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined);
1127+
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
10891128
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
10901129

10911130
useMorphdomMarkdown({ containerRef, text: pacedText, streaming: live, cacheKey, syntaxVars, ctx });
@@ -1129,7 +1168,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
11291168
stripFrontmatter?: boolean;
11301169
onShowPopup?: (content: ToolPopupContent) => void;
11311170
mermaidControls?: MermaidControlOptions;
1132-
allowMermaidWheelZoom?: boolean;
1171+
allowMermaidWheelEvents?: boolean;
11331172
enableFileReferences?: boolean;
11341173
}> = ({
11351174
content,
@@ -1138,7 +1177,8 @@ const SimpleMarkdownRendererImpl: React.FC<{
11381177
disableLinkSafety,
11391178
stripFrontmatter = false,
11401179
onShowPopup,
1141-
allowMermaidWheelZoom = false,
1180+
mermaidControls = DEFAULT_MERMAID_CONTROLS,
1181+
allowMermaidWheelEvents = false,
11421182
enableFileReferences = true,
11431183
}) => {
11441184
const { editor, runtime } = useRuntimeAPIs();
@@ -1151,12 +1191,12 @@ const SimpleMarkdownRendererImpl: React.FC<{
11511191
[content, stripFrontmatter],
11521192
);
11531193

1154-
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(renderedContent), [renderedContent]);
11551194
useMermaidInlineInteractions({
11561195
containerRef,
1157-
mermaidBlocks,
11581196
onShowPopup,
1159-
allowWheelZoom: allowMermaidWheelZoom,
1197+
enableFullscreen: DEFAULT_MERMAID_FULLSCREEN_ENABLED,
1198+
enablePanZoom: mermaidControls.showPanZoomControls,
1199+
allowMermaidWheelEvents,
11601200
});
11611201
useFileReferenceInteractions({
11621202
containerRef,
@@ -1168,7 +1208,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
11681208
useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety });
11691209

11701210
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
1171-
const ctx = useDecorateContext(currentTheme, false);
1211+
const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls);
11721212

11731213
useMorphdomMarkdown({
11741214
containerRef,
@@ -1187,12 +1227,18 @@ const SimpleMarkdownRendererImpl: React.FC<{
11871227
};
11881228

11891229
export const SimpleMarkdownRenderer = React.memo(SimpleMarkdownRendererImpl, (prev, next) => {
1230+
const prevMermaidControls = prev.mermaidControls ?? DEFAULT_MERMAID_CONTROLS;
1231+
const nextMermaidControls = next.mermaidControls ?? DEFAULT_MERMAID_CONTROLS;
1232+
11901233
return prev.content === next.content
11911234
&& prev.variant === next.variant
11921235
&& prev.className === next.className
11931236
&& prev.disableLinkSafety === next.disableLinkSafety
11941237
&& prev.stripFrontmatter === next.stripFrontmatter
11951238
&& prev.onShowPopup === next.onShowPopup
1196-
&& prev.allowMermaidWheelZoom === next.allowMermaidWheelZoom
1239+
&& prevMermaidControls.download === nextMermaidControls.download
1240+
&& prevMermaidControls.copy === nextMermaidControls.copy
1241+
&& prevMermaidControls.showPanZoomControls === nextMermaidControls.showPanZoomControls
1242+
&& prev.allowMermaidWheelEvents === next.allowMermaidWheelEvents
11971243
&& prev.enableFileReferences === next.enableFileReferences;
11981244
});

0 commit comments

Comments
 (0)