mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(web-shell): keep the mermaid canvas fixed while zooming out (#12177)
Zoom was a transform on the box that also carried the canvas background and the diagram's own horizontal scrollbar. A transform moves paint, not layout, so zooming out scaled the scroll viewport along with the diagram and left the pan range computed from the unscaled width: at half size a 3223px diagram painted 1612px inside a 489px sliding strip in the middle of a 978px row, while the scrollable range stayed 2277px and matched nothing on screen. Panning was a second translate on the same box on top of that — unbounded horizontally, so dragging past the content origin pushed the leading edge of the diagram out of the scrollable region, where no scroll brought it back. Give the three roles their own boxes: the wrapper is the canvas and the only scroller, the inner box carries nothing but the diagram, scaling uses `zoom` so layout moves with the paint and the reachable range tracks the diagram rather than its full-size box, and dragging drives the scroll position instead of a transform. The canvas takes the diagram's own size, read from its svg: mermaid marks everything outside flowchart `width="100%"`, so those diagrams keep shrinking to the canvas width while the rest sit at their design size — a `fit-content` box cannot resolve that percentage and would fall back to the 300x150 default object size. The canvas holds the diagram's unscaled height so zooming never reflows the transcript, and it offers the grab cursor only while part of the diagram is out of view: a diagram that already fits cannot be moved by dragging, and the cursor should not promise otherwise.
This commit is contained in:
1 parent
c2902ae70f
commit
1ef024ebf1
4 files changed
+341
-44
No files matched your search
@@ -0,0 +1,31 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// jsdom neither computes the cascade nor lays anything out, so the pairing that
|
||||
// holds the canvas together is pinned at the source. The canvas height is the
|
||||
// diagram's own height plus `MERMAID_CANVAS_PADDING_PX` on both sides, and that
|
||||
// constant is a second copy of the stylesheet's padding: change the stylesheet
|
||||
// without it and a diagram that exactly fills the canvas is clipped — nothing in
|
||||
// the jsdom suite can see that, because every test mocks the geometry. Strip
|
||||
// comments so the guard matches declarations, not prose about them.
|
||||
const markdownCss = readFileSync(
|
||||
fileURLToPath(new URL('./Markdown.module.css', import.meta.url)),
|
||||
'utf8',
|
||||
).replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const markdownSource = readFileSync(
|
||||
fileURLToPath(new URL('./Markdown.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('Mermaid canvas stylesheet', () => {
|
||||
it('keeps the canvas padding constant equal to the stylesheet padding', () => {
|
||||
const constant = markdownSource.match(
|
||||
/MERMAID_CANVAS_PADDING_PX\s*=\s*(\d+)/,
|
||||
)?.[1];
|
||||
const wrapper = markdownCss.match(/\.mermaidZoomWrapper\s*\{[^}]*\}/)?.[0];
|
||||
expect(constant).toBeTruthy();
|
||||
expect(wrapper).toBeTruthy();
|
||||
expect(wrapper).toMatch(new RegExp(`padding:\\s*${constant}px;`));
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,9 @@ import { TranscriptRenderModeProvider } from '../../transcriptRenderMode';
|
||||
|
||||
const mermaidMock = vi.hoisted(() => ({
|
||||
initialize: vi.fn(),
|
||||
render: vi.fn(() => Promise.resolve({ svg: '<svg>diagram</svg>' })),
|
||||
render: vi.fn(() =>
|
||||
Promise.resolve({ svg: '<svg width="300" height="150">diagram</svg>' }),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('mermaid', () => ({ default: mermaidMock }));
|
||||
@@ -70,7 +72,9 @@ beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mermaidMock.initialize.mockClear();
|
||||
mermaidMock.render.mockReset();
|
||||
mermaidMock.render.mockResolvedValue({ svg: '<svg>diagram</svg>' });
|
||||
mermaidMock.render.mockResolvedValue({
|
||||
svg: '<svg width="300" height="150">diagram</svg>',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -198,3 +202,125 @@ describe('Markdown Mermaid render modes', () => {
|
||||
expect(container.querySelector('pre code')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Markdown mermaid zoom', () => {
|
||||
async function mountZoomableDiagram() {
|
||||
const view = mountMermaid('interactive');
|
||||
await startMermaidRender();
|
||||
const stage = view.container.querySelector('svg')?.parentElement;
|
||||
if (!(stage instanceof HTMLElement)) throw new Error('no diagram mounted');
|
||||
const viewport = stage.parentElement;
|
||||
if (!(viewport instanceof HTMLElement)) throw new Error('no viewport');
|
||||
// No i18n provider here, so `t()` returns the key and that is the title.
|
||||
const button = (title: string) => {
|
||||
const found = view.container.querySelector<HTMLButtonElement>(
|
||||
`button[title="${title}"]`,
|
||||
);
|
||||
if (!found) throw new Error(`no ${title} button`);
|
||||
return found;
|
||||
};
|
||||
return {
|
||||
stage,
|
||||
viewport,
|
||||
zoomIn: button('mermaid.zoomIn'),
|
||||
zoomOut: button('mermaid.zoomOut'),
|
||||
zoomReset: button('mermaid.zoomReset'),
|
||||
};
|
||||
}
|
||||
|
||||
// The scaling itself is a layout effect of the `zoom` property, which jsdom
|
||||
// does not model; it is covered by a browser-side probe instead.
|
||||
it('offers the reset only while the view is zoomed or panned', async () => {
|
||||
const { zoomOut, zoomReset } = await mountZoomableDiagram();
|
||||
|
||||
expect(zoomReset.disabled).toBe(true);
|
||||
|
||||
act(() => zoomOut.click());
|
||||
expect(zoomReset.disabled).toBe(false);
|
||||
|
||||
act(() => zoomReset.click());
|
||||
expect(zoomReset.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('holds the canvas at the diagram height while zooming', async () => {
|
||||
const { stage, viewport, zoomOut, zoomReset } =
|
||||
await mountZoomableDiagram();
|
||||
|
||||
// 150 (the rendered svg) + the canvas padding on both sides, and the box the
|
||||
// zoom scales is the diagram's own 300.
|
||||
expect(viewport.style.height).toBe('182px');
|
||||
expect(stage.style.width).toBe('300px');
|
||||
|
||||
act(() => zoomOut.click());
|
||||
expect(viewport.style.height).toBe('182px');
|
||||
|
||||
act(() => zoomReset.click());
|
||||
expect(viewport.style.height).toBe('182px');
|
||||
});
|
||||
|
||||
it('offers the grab cursor only while the diagram reaches past the canvas', async () => {
|
||||
const { viewport, zoomIn } = await mountZoomableDiagram();
|
||||
|
||||
// jsdom lays nothing out, so the canvas reports no overflow to begin with.
|
||||
expect(viewport.className).not.toContain('mermaidPannable');
|
||||
|
||||
// Give it geometry, then let the zoom re-measure (that is the same path a
|
||||
// real zoom takes).
|
||||
Object.defineProperty(viewport, 'scrollWidth', {
|
||||
value: 1000,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(viewport, 'clientWidth', {
|
||||
value: 100,
|
||||
configurable: true,
|
||||
});
|
||||
act(() => zoomIn.click());
|
||||
expect(viewport.className).toContain('mermaidPannable');
|
||||
});
|
||||
|
||||
it('pans the viewport instead of moving the diagram box', async () => {
|
||||
const { stage, viewport, zoomReset } = await mountZoomableDiagram();
|
||||
|
||||
act(() => {
|
||||
viewport.dispatchEvent(
|
||||
new MouseEvent('mousedown', { bubbles: true, clientX: 500 }),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new MouseEvent('mousemove', { bubbles: true, clientX: 200 }),
|
||||
);
|
||||
});
|
||||
// Dragging left reveals what is to the right of the fold. jsdom has no
|
||||
// layout, so this pins the delta wiring; the browser-side clamp that makes
|
||||
// the same drag a no-op on a diagram that already fits is not visible here.
|
||||
expect(viewport.scrollLeft).toBe(300);
|
||||
expect(zoomReset.disabled).toBe(false);
|
||||
// Panning is the viewport's scroll position, not a shifted box.
|
||||
expect(stage.style.transform).toBe('');
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||
});
|
||||
act(() => zoomReset.click());
|
||||
expect(viewport.scrollLeft).toBe(0);
|
||||
});
|
||||
|
||||
it('takes a percentage-width diagram size from its viewBox', async () => {
|
||||
// Outside flowchart mermaid emits `width="100%"` and no height at all, and a
|
||||
// `fit-content` box cannot resolve that percentage: it fell back to the
|
||||
// 300x150 default object size. The viewBox is what has to size it.
|
||||
mermaidMock.render.mockResolvedValueOnce({
|
||||
svg: '<svg width="100%" viewBox="0 0 400 200">diagram</svg>',
|
||||
});
|
||||
const view = mountMermaid('interactive');
|
||||
await startMermaidRender();
|
||||
const stage = view.container.querySelector('svg')?.parentElement;
|
||||
if (!(stage instanceof HTMLElement)) throw new Error('no diagram mounted');
|
||||
const viewport = stage.parentElement;
|
||||
if (!(viewport instanceof HTMLElement)) throw new Error('no viewport');
|
||||
|
||||
expect(stage.style.width).toBe('400px');
|
||||
expect(viewport.style.height).toBe('232px');
|
||||
});
|
||||
});
|
||||
@@ -297,6 +297,24 @@
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* The box that carries the diagram. The zoom wrapper is the canvas and the only
|
||||
scroller, so this one holds no chrome and no scroll region of its own: it is
|
||||
only scaled, and its overflow therefore reaches the wrapper.
|
||||
Its width comes from the rendered svg (see the measurement in Markdown.tsx);
|
||||
`fit-content` only covers the moments before that lands. Hugging the diagram is
|
||||
what keeps the scroll range honest: a box as wide as the row would scale its
|
||||
own empty width into the range, letting a diagram that still fits be scrolled
|
||||
away. */
|
||||
.mermaidBlock.mermaidStage {
|
||||
width: fit-content;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.mermaidBlock :global(svg) {
|
||||
max-width: none;
|
||||
height: auto;
|
||||
@@ -323,13 +341,24 @@
|
||||
}
|
||||
|
||||
.mermaidZoomWrapper {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 8px 0;
|
||||
cursor: grab;
|
||||
/* The canvas: the row's full width, the diagram's unscaled height, and the
|
||||
only scroller. The diagram keeps its own layout size and is scaled inside,
|
||||
so the scroll range follows what scaling paints — zooming out shrinks the
|
||||
diagram, not the area the viewer can pan in. */
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
background: var(--popover);
|
||||
border-radius: var(--radius);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Panning moves the scroll position, so the grab cursor is only honest while
|
||||
part of the diagram is out of view: a diagram that already fits cannot be
|
||||
moved, and offering to drag it reads as broken. */
|
||||
.mermaidPannable {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.mermaidDragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -201,6 +202,9 @@ let mermaidRenderId = 0;
|
||||
const MAX_MERMAID_TEXT_CHARS = 50_000;
|
||||
const MAX_MERMAID_EDGES = 500;
|
||||
const MERMAID_RENDER_TIMEOUT_MS = 10_000;
|
||||
// Must match `.mermaidZoomWrapper`'s padding in Markdown.module.css: the canvas
|
||||
// height is set from the diagram's own height plus this both sides.
|
||||
const MERMAID_CANVAS_PADDING_PX = 16;
|
||||
|
||||
function MermaidBlock({ code }: { code: string }) {
|
||||
const { t } = useI18n();
|
||||
@@ -216,13 +220,27 @@ function MermaidBlock({ code }: { code: string }) {
|
||||
const [viewMode, setViewMode] = useState<'diagram' | 'code'>('diagram');
|
||||
const [copied, flashCopied] = useCopiedFlash();
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
// The canvas is the viewport: panning moves its scroll position, so a drag
|
||||
// and the scrollbar are one mechanism, and the reachable range is the one the
|
||||
// browser already reports for the scaled diagram.
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isPanned, setIsPanned] = useState(false);
|
||||
// Whether the diagram reaches past the canvas, i.e. whether dragging it has
|
||||
// anywhere to go.
|
||||
const [canPan, setCanPan] = useState(false);
|
||||
// The box the zoom scales, and the height the canvas is held at so that
|
||||
// zooming never reflows the transcript. Both are read off the rendered svg.
|
||||
const [diagramBox, setDiagramBox] = useState<{
|
||||
stageWidth: number;
|
||||
canvasHeight: number;
|
||||
} | null>(null);
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragRef = useRef<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
origX: number;
|
||||
origY: number;
|
||||
startLeft: number;
|
||||
startTop: number;
|
||||
} | null>(null);
|
||||
const mermaidTheme = appTheme === 'light' ? 'default' : 'dark';
|
||||
|
||||
@@ -240,40 +258,103 @@ function MermaidBlock({ code }: { code: string }) {
|
||||
dragRef.current = null;
|
||||
setIsDragging(false);
|
||||
setZoom(1);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport) {
|
||||
viewport.scrollLeft = 0;
|
||||
viewport.scrollTop = 0;
|
||||
}
|
||||
setIsPanned(false);
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origX: offset.x,
|
||||
origY: offset.y,
|
||||
};
|
||||
},
|
||||
[offset],
|
||||
);
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startLeft: viewport.scrollLeft,
|
||||
startTop: viewport.scrollTop,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollLeft, scrollTop } = e.currentTarget;
|
||||
setIsPanned(scrollLeft !== 0 || scrollTop !== 0);
|
||||
}, []);
|
||||
|
||||
const syncViewport = useCallback(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
setCanPan(
|
||||
viewport.scrollWidth > viewport.clientWidth ||
|
||||
viewport.scrollHeight > viewport.clientHeight,
|
||||
);
|
||||
setIsPanned(viewport.scrollLeft !== 0 || viewport.scrollTop !== 0);
|
||||
}, []);
|
||||
|
||||
// Mermaid sizes every diagram it renders in a `viewBox`, and that is the size
|
||||
// it designed: the box the zoom scales, and the height the canvas keeps.
|
||||
// Outside `flowchart` — where this component asks for `useMaxWidth: false` —
|
||||
// mermaid also marks the svg `width="100%"`, so those diagrams shrink to the
|
||||
// canvas instead of sitting at their design size. Measuring them here is what
|
||||
// keeps both cases working: a `fit-content` box cannot resolve that percentage
|
||||
// and falls back to the 300x150 default object size.
|
||||
const measureDiagram = useCallback(() => {
|
||||
const viewport = viewportRef.current;
|
||||
const svgEl = stageRef.current?.querySelector('svg');
|
||||
if (!viewport || !svgEl) return;
|
||||
const [, , boxWidth, boxHeight] = (svgEl.getAttribute('viewBox') ?? '')
|
||||
.split(/[\s,]+/)
|
||||
.map(Number);
|
||||
const declaredWidth = (svgEl.getAttribute('width') ?? '').trim();
|
||||
const declaredHeight = Number.parseFloat(
|
||||
svgEl.getAttribute('height') ?? '',
|
||||
);
|
||||
const hasViewBox = boxWidth > 0 && boxHeight > 0;
|
||||
const width = hasViewBox ? boxWidth : Number.parseFloat(declaredWidth);
|
||||
const height = hasViewBox ? boxHeight : declaredHeight;
|
||||
if (!(width > 0) || !(height > 0)) {
|
||||
setDiagramBox(null);
|
||||
return;
|
||||
}
|
||||
const available = viewport.clientWidth - MERMAID_CANVAS_PADDING_PX * 2;
|
||||
const stageWidth =
|
||||
declaredWidth.endsWith('%') && available > 0
|
||||
? Math.min(width, available)
|
||||
: width;
|
||||
// A classic (space-taking) scrollbar would otherwise be taken out of the
|
||||
// height the canvas is held at, and hide the bottom of a diagram that
|
||||
// exactly fills it. Overlay scrollbars measure zero.
|
||||
const scrollbar = Math.max(
|
||||
0,
|
||||
viewport.offsetHeight - viewport.clientHeight,
|
||||
);
|
||||
setDiagramBox({
|
||||
stageWidth,
|
||||
canvasHeight:
|
||||
(stageWidth * height) / width +
|
||||
MERMAID_CANVAS_PADDING_PX * 2 +
|
||||
scrollbar,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const dx = e.clientX - dragRef.current.startX;
|
||||
const dy = e.clientY - dragRef.current.startY;
|
||||
// Clamp Y to prevent dragging into overflow-y: hidden clipped area.
|
||||
// X is unclamped — overflow-x: auto provides native horizontal scroll.
|
||||
const PAN_LIMIT = 1500;
|
||||
setOffset({
|
||||
x: dragRef.current.origX + dx,
|
||||
y: Math.max(
|
||||
-PAN_LIMIT,
|
||||
Math.min(PAN_LIMIT, dragRef.current.origY + dy),
|
||||
),
|
||||
});
|
||||
const viewport = viewportRef.current;
|
||||
const drag = dragRef.current;
|
||||
if (!viewport || !drag) return;
|
||||
// Grab semantics: the diagram follows the pointer, so the scroll position
|
||||
// moves against it. The browser clamps at both ends, which is what keeps
|
||||
// the leading edge of an over-wide diagram reachable.
|
||||
viewport.scrollLeft = drag.startLeft - (e.clientX - drag.startX);
|
||||
viewport.scrollTop = drag.startTop - (e.clientY - drag.startY);
|
||||
// Scroll events arrive asynchronously, so report the position the browser
|
||||
// just settled on (assignments are clamped) instead of waiting for one.
|
||||
setIsPanned(viewport.scrollLeft !== 0 || viewport.scrollTop !== 0);
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
@@ -292,9 +373,29 @@ function MermaidBlock({ code }: { code: string }) {
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
setZoom(1);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
}, [code]);
|
||||
resetZoomAndPan();
|
||||
}, [code, resetZoomAndPan]);
|
||||
|
||||
// Both the box and the drag affordance follow the rendered diagram, so they are
|
||||
// re-read when it arrives, when the zoom changes (a diagram can reach past the
|
||||
// canvas at one zoom and not another), and when the view is remounted.
|
||||
useLayoutEffect(() => {
|
||||
measureDiagram();
|
||||
syncViewport();
|
||||
}, [svg, zoom, viewMode, measureDiagram, syncViewport]);
|
||||
|
||||
// A narrower column changes both a `width="100%"` diagram's size and whether
|
||||
// there is anything to pan, without either of those React values changing.
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
measureDiagram();
|
||||
syncViewport();
|
||||
});
|
||||
observer.observe(viewport);
|
||||
return () => observer.disconnect();
|
||||
}, [svg, viewMode, measureDiagram, syncViewport]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -409,7 +510,7 @@ function MermaidBlock({ code }: { code: string }) {
|
||||
className={styles.codeBlockCopy}
|
||||
onClick={resetZoomAndPan}
|
||||
title={t('mermaid.zoomReset')}
|
||||
disabled={zoom === 1 && offset.x === 0 && offset.y === 0}
|
||||
disabled={zoom === 1 && !isPanned}
|
||||
>
|
||||
{t('mermaid.zoomReset')}
|
||||
</button>
|
||||
@@ -450,15 +551,25 @@ function MermaidBlock({ code }: { code: string }) {
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`${styles.mermaidZoomWrapper} ${isDragging ? styles.mermaidDragging : ''}`}
|
||||
className={`${styles.mermaidZoomWrapper} ${canPan ? styles.mermaidPannable : ''} ${
|
||||
canPan && isDragging ? styles.mermaidDragging : ''
|
||||
}`}
|
||||
ref={viewportRef}
|
||||
style={diagramBox ? { height: diagramBox.canvasHeight } : undefined}
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={resetZoomAndPan}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div
|
||||
className={`${styles.mermaidBlock} ${styles.mermaidInline}`}
|
||||
className={`${styles.mermaidBlock} ${styles.mermaidStage}`}
|
||||
ref={stageRef}
|
||||
style={{
|
||||
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
|
||||
transformOrigin: 'top center',
|
||||
...(diagramBox ? { width: diagramBox.stageWidth } : {}),
|
||||
// `zoom` rather than a transform: it resizes the layout, so the
|
||||
// scroll range follows the scaled diagram. Scaling by transform
|
||||
// leaves Chrome computing that range from the full-size box, which
|
||||
// lets a zoomed-out diagram be scrolled off its own canvas.
|
||||
...(zoom === 1 ? {} : { zoom }),
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user