fix: stabilize embedded diagrams

This commit is contained in:
lucaronin
2026-05-04 11:11:36 +02:00
parent ebf4545d46
commit 582d1d25d6
6 changed files with 241 additions and 7 deletions

View File

@@ -411,6 +411,7 @@
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card);
user-select: none;
}
.editor__blocknote-container .tldraw-whiteboard .tl-container {
@@ -423,6 +424,10 @@
--tl-layer-following-indicator: 80;
}
.editor__blocknote-container .tldraw-whiteboard [data-radix-popper-content-wrapper] {
position: absolute !important;
}
.editor__blocknote-container .mantine-Popover-dropdown,
.editor__blocknote-container .bn-menu,
.editor__blocknote-container .bn-suggestion-menu {

View File

@@ -71,6 +71,34 @@ describe('MermaidDiagram', () => {
expect(style?.getAttribute('nonce')).toBe(RUNTIME_STYLE_NONCE)
})
it('keeps Mermaid foreignObject labels visible after sanitizing the SVG', async () => {
mermaidMock.render.mockResolvedValueOnce({
svg: [
'<svg aria-label="Rendered Mermaid">',
'<g class="node">',
'<foreignObject width="200" height="40">',
'<div xmlns="http://www.w3.org/1999/xhtml">',
'<span class="nodeLabel" onclick="alert(1)">Employee<br>clocks in</span>',
'</div>',
'</foreignObject>',
'</g>',
'</svg>',
].join(''),
})
render(
<MermaidDiagram
diagram={'flowchart LR\nA(["Employee clocks in"]) --> B'}
source={'```mermaid\nflowchart LR\nA(["Employee clocks in"]) --> B\n```'}
/>,
)
await waitFor(() => {
expect(screen.getByTestId('mermaid-diagram-viewport')).toHaveTextContent('Employeeclocks in')
})
expect(screen.getByText('Employeeclocks in')).not.toHaveAttribute('onclick')
})
it('falls back to the original source when Mermaid cannot render', async () => {
mermaidMock.render.mockRejectedValueOnce(new Error('parse error'))

View File

@@ -10,6 +10,13 @@ interface SafeSvgDivProps extends HTMLAttributes<HTMLDivElement> {
svg: string
}
const MERMAID_SVG_SANITIZE_CONFIG = {
USE_PROFILES: { svg: true, svgFilters: true, html: true },
ADD_TAGS: ['foreignObject'],
ADD_ATTR: ['xmlns'],
HTML_INTEGRATION_POINTS: { foreignobject: true },
}
function importSanitizedHtmlNodes(html: string): Node[] {
const sanitized = DOMPurify.sanitize(html, {
USE_PROFILES: { html: true, mathMl: true },
@@ -19,13 +26,12 @@ function importSanitizedHtmlNodes(html: string): Node[] {
}
function importSanitizedSvgNode(svg: string): Node | null {
const sanitized = DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
})
const parsed = new DOMParser().parseFromString(sanitized, 'image/svg+xml')
if (parsed.querySelector('parsererror')) return null
const sanitized = DOMPurify.sanitize(svg, MERMAID_SVG_SANITIZE_CONFIG)
const parsed = new DOMParser().parseFromString(sanitized, 'text/html')
const parsedSvg = parsed.body.querySelector('svg')
if (!parsedSvg) return null
const svgNode = document.importNode(parsed.documentElement, true)
const svgNode = document.importNode(parsedSvg, true)
svgNode.querySelectorAll('style').forEach((style) => {
style.setAttribute('nonce', RUNTIME_STYLE_NONCE)
})

View File

@@ -1,9 +1,12 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react'
import {
Box,
Tldraw,
createTLStore,
getSnapshot,
loadSnapshot,
type Editor,
type TLEventInfo,
type TLStoreSnapshot,
} from 'tldraw'
import 'tldraw/tldraw.css'
@@ -80,6 +83,90 @@ function serializeSnapshot(snapshot: TLStoreSnapshot): string {
return `${JSON.stringify(snapshot, null, 2)}\n`
}
function documentZoom(): number {
const inlineZoom = document.documentElement.style.getPropertyValue('zoom')
const computedZoom = getComputedStyle(document.documentElement).zoom
const zoom = inlineZoom || computedZoom
const parsed = Number.parseFloat(zoom)
if (!Number.isFinite(parsed) || parsed <= 0) return 1
return zoom.endsWith('%') ? parsed / 100 : parsed
}
function viewportBounds(screenBounds: Box | HTMLElement): Box | HTMLElement {
if (screenBounds instanceof Box) return screenBounds
const zoom = documentZoom()
if (zoom === 1) return screenBounds
const rect = screenBounds.getBoundingClientRect()
return new Box(
(rect.left || rect.x) / zoom,
(rect.top || rect.y) / zoom,
Math.max(rect.width / zoom, 1),
Math.max(rect.height / zoom, 1),
)
}
function zoomAdjustedPoint<T extends { x: number; y: number; z?: number }>(point: T, zoom: number): T {
return {
...point,
x: point.x / zoom,
y: point.y / zoom,
}
}
function zoomAdjustedEvent(info: TLEventInfo): TLEventInfo {
const zoom = documentZoom()
if (zoom === 1) return info
switch (info.type) {
case 'click':
case 'pinch':
case 'pointer':
case 'wheel':
return {
...info,
point: zoomAdjustedPoint(info.point, zoom),
} as TLEventInfo
default:
return info
}
}
function installZoomAwareViewport(editor: Editor): () => void {
const updateViewportScreenBounds = editor.updateViewportScreenBounds.bind(editor)
const updateViewport: Editor['updateViewportScreenBounds'] = (screenBounds, center) =>
updateViewportScreenBounds(viewportBounds(screenBounds), center)
const dispatch = editor.dispatch.bind(editor)
const animationFrameIds: number[] = []
const timeoutIds: number[] = []
editor.updateViewportScreenBounds = updateViewport
editor.dispatch = (info: TLEventInfo) => dispatch(zoomAdjustedEvent(info))
const updateCurrentCanvas = () => {
const canvas = editor.getContainer().querySelector<HTMLElement>('.tl-canvas')
if (canvas) updateViewport(canvas)
}
const scheduleViewportUpdate = () => {
updateCurrentCanvas()
animationFrameIds.push(window.requestAnimationFrame(updateCurrentCanvas))
timeoutIds.push(window.setTimeout(updateCurrentCanvas, 150))
}
scheduleViewportUpdate()
window.addEventListener('laputa-zoom-change', scheduleViewportUpdate)
return () => {
window.removeEventListener('laputa-zoom-change', scheduleViewportUpdate)
animationFrameIds.forEach((id) => window.cancelAnimationFrame(id))
timeoutIds.forEach((id) => window.clearTimeout(id))
editor.updateViewportScreenBounds = updateViewportScreenBounds
editor.dispatch = dispatch
}
}
export function TldrawWhiteboard({
boardId,
height,
@@ -182,10 +269,14 @@ export function TldrawWhiteboard({
<div
ref={boardRef}
className="tldraw-whiteboard"
contentEditable={false}
data-board-id={boardId}
style={cssSize(visibleSize)}
>
<Tldraw store={store} />
<Tldraw
onMount={installZoomAwareViewport}
store={store}
/>
<div
aria-label="Resize whiteboard width"
className="tldraw-whiteboard__resize-handle tldraw-whiteboard__resize-handle--width"

View File

@@ -177,6 +177,7 @@ const TldrawBlock = createReactBlockSpec(
},
{
runsBefore: ['codeBlock'],
meta: { selectable: false },
render: (props) => (
<Suspense fallback={<div className="tldraw-whiteboard tldraw-whiteboard--loading" />}>
<TldrawWhiteboard

View File

@@ -60,6 +60,43 @@ async function getRawEditorContent(page: Page): Promise<string> {
})
}
async function hasSelectedEditorNode(page: Page): Promise<boolean> {
return page.evaluate(() => document.querySelector('.ProseMirror-selectednode') !== null)
}
async function expectNoEditorNodeSelection(page: Page): Promise<void> {
expect(await hasSelectedEditorNode(page)).toBe(false)
}
async function applyZoom(page: Page, percent: number): Promise<void> {
await page.evaluate((pct) => {
document.documentElement.style.setProperty('zoom', `${pct}%`)
window.dispatchEvent(new Event('laputa-zoom-change'))
}, percent)
await page.waitForTimeout(250)
}
async function firstTldrawShapeOrigin(page: Page): Promise<{ x: number, y: number } | null> {
return page.locator('.tl-shape').first().evaluate((element) => {
const whiteboard = element.closest('.tldraw-whiteboard')
if (!whiteboard) return null
const matrix = new DOMMatrixReadOnly(getComputedStyle(element).transform)
const boardBox = whiteboard.getBoundingClientRect()
const zoomStyle = document.documentElement.style.getPropertyValue('zoom')
|| getComputedStyle(document.documentElement).zoom
const parsedZoom = Number.parseFloat(zoomStyle)
const zoom = Number.isFinite(parsedZoom) && parsedZoom > 0
? zoomStyle.endsWith('%') ? parsedZoom / 100 : parsedZoom
: 1
return {
x: boardBox.x + matrix.m41 * zoom,
y: boardBox.y + matrix.m42 * zoom,
}
})
}
test('tldraw whiteboard fences render as embedded canvases and remain Markdown-durable', async ({ page }) => {
await openNote(page, 'Whiteboard Embed')
@@ -68,6 +105,7 @@ test('tldraw whiteboard fences render as embedded canvases and remain Markdown-d
await expect(page.locator('.bn-editor')).toContainText('Context before the board.')
await expect(page.locator('.bn-editor')).toContainText('Context after the board.')
await page.waitForTimeout(500)
await toggleRawMode(page, '.cm-content')
const rawAfterRichMode = await getRawEditorContent(page)
@@ -75,3 +113,68 @@ test('tldraw whiteboard fences render as embedded canvases and remain Markdown-d
expect(rawAfterRichMode).toContain('{}')
expect(rawAfterRichMode).not.toContain('@@TOLARIA_TLDRAW')
})
test('embedded tldraw interactions stay inside the whiteboard', async ({ page }) => {
await openNote(page, 'Whiteboard Embed')
const whiteboard = page.locator('.tldraw-whiteboard')
await expect(whiteboard).toBeVisible({ timeout: 20_000 })
const boardBox = await whiteboard.boundingBox()
expect(boardBox).not.toBeNull()
await page.mouse.click(boardBox!.x + boardBox!.width / 2, boardBox!.y + boardBox!.height / 2)
await expectNoEditorNodeSelection(page)
await page.getByTestId('tools.select').click()
await expectNoEditorNodeSelection(page)
const pageMenuButton = page.getByTestId('page-menu.button')
const buttonBox = await pageMenuButton.boundingBox()
expect(buttonBox).not.toBeNull()
await pageMenuButton.click()
const pageMenu = page.locator('.tlui-page-menu__wrapper')
await expect(pageMenu).toBeVisible({ timeout: 5_000 })
const menuBox = await pageMenu.boundingBox()
expect(menuBox).not.toBeNull()
expect(menuBox!.x).toBeGreaterThanOrEqual(boardBox!.x - 1)
expect(menuBox!.x).toBeLessThanOrEqual(buttonBox!.x + 1)
await expectNoEditorNodeSelection(page)
})
test('embedded tldraw drawing uses the clicked coordinates while zoomed', async ({ page }) => {
await openNote(page, 'Whiteboard Embed')
await applyZoom(page, 110)
const whiteboard = page.locator('.tldraw-whiteboard')
await expect(whiteboard).toBeVisible({ timeout: 20_000 })
const boardBox = await whiteboard.boundingBox()
expect(boardBox).not.toBeNull()
await page.getByTestId('tools.draw').click()
const start = {
x: boardBox!.x + 180,
y: boardBox!.y + 180,
}
const end = {
x: start.x + 120,
y: start.y + 90,
}
await page.mouse.move(start.x, start.y)
await page.mouse.down()
await page.mouse.move(end.x, end.y, { steps: 8 })
await page.mouse.up()
const shape = page.locator('.tl-shape').first()
await expect(shape).toBeVisible({ timeout: 5_000 })
const shapeOrigin = await firstTldrawShapeOrigin(page)
expect(shapeOrigin).not.toBeNull()
expect(Math.abs(shapeOrigin!.x - start.x)).toBeLessThan(30)
expect(Math.abs(shapeOrigin!.y - start.y)).toBeLessThan(30)
})