From 582d1d25d61ca49b1da16f3ee19554e6bffffa87 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 4 May 2026 11:11:36 +0200 Subject: [PATCH] fix: stabilize embedded diagrams --- src/components/EditorTheme.css | 5 + src/components/MermaidDiagram.test.tsx | 28 ++++++ src/components/SafeMarkup.tsx | 18 ++-- src/components/TldrawWhiteboard.tsx | 93 +++++++++++++++++- src/components/editorSchema.tsx | 1 + tests/smoke/tldraw-whiteboard-notes.spec.ts | 103 ++++++++++++++++++++ 6 files changed, 241 insertions(+), 7 deletions(-) diff --git a/src/components/EditorTheme.css b/src/components/EditorTheme.css index ee3b4743..f27ffe9e 100644 --- a/src/components/EditorTheme.css +++ b/src/components/EditorTheme.css @@ -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 { diff --git a/src/components/MermaidDiagram.test.tsx b/src/components/MermaidDiagram.test.tsx index fd85d6f6..2529e3ea 100644 --- a/src/components/MermaidDiagram.test.tsx +++ b/src/components/MermaidDiagram.test.tsx @@ -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: [ + '', + '', + '', + '
', + 'Employee
clocks in
', + '
', + '
', + '
', + '
', + ].join(''), + }) + + render( + 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')) diff --git a/src/components/SafeMarkup.tsx b/src/components/SafeMarkup.tsx index e7bab783..800318bf 100644 --- a/src/components/SafeMarkup.tsx +++ b/src/components/SafeMarkup.tsx @@ -10,6 +10,13 @@ interface SafeSvgDivProps extends HTMLAttributes { 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) }) diff --git a/src/components/TldrawWhiteboard.tsx b/src/components/TldrawWhiteboard.tsx index 6a99eeb2..23cda4e9 100644 --- a/src/components/TldrawWhiteboard.tsx +++ b/src/components/TldrawWhiteboard.tsx @@ -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(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('.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({
- +
( }> { }) } +async function hasSelectedEditorNode(page: Page): Promise { + return page.evaluate(() => document.querySelector('.ProseMirror-selectednode') !== null) +} + +async function expectNoEditorNodeSelection(page: Page): Promise { + expect(await hasSelectedEditorNode(page)).toBe(false) +} + +async function applyZoom(page: Page, percent: number): Promise { + 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) +})