fix: stabilize embedded diagrams
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -177,6 +177,7 @@ const TldrawBlock = createReactBlockSpec(
|
||||
},
|
||||
{
|
||||
runsBefore: ['codeBlock'],
|
||||
meta: { selectable: false },
|
||||
render: (props) => (
|
||||
<Suspense fallback={<div className="tldraw-whiteboard tldraw-whiteboard--loading" />}>
|
||||
<TldrawWhiteboard
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user