From 2a5ebcf73db00a156d4ff1b6575ca0816d0947a4 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 13 May 2026 12:42:37 +0200 Subject: [PATCH] fix: prevent whiteboard dialog crash --- src/components/TldrawWhiteboard.tsx | 133 ++++++++------------ tests/smoke/tldraw-whiteboard-notes.spec.ts | 22 ++++ 2 files changed, 72 insertions(+), 83 deletions(-) diff --git a/src/components/TldrawWhiteboard.tsx b/src/components/TldrawWhiteboard.tsx index b0843621..c5778fdf 100644 --- a/src/components/TldrawWhiteboard.tsx +++ b/src/components/TldrawWhiteboard.tsx @@ -1,21 +1,16 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type MutableRefObject, type PointerEvent as ReactPointerEvent } from 'react' import { getAssetUrlsByImport } from '@tldraw/assets/imports.vite' +import { Dialog as DialogPrimitive } from 'radix-ui' import { Box, - DefaultKeyboardShortcutsDialog, - DefaultKeyboardShortcutsDialogContent, - TldrawUiButton, - TldrawUiButtonIcon, - TldrawUiMenuContextProvider, Tldraw, createTLStore, defaultUserPreferences, getSnapshot, loadSnapshot, - react as reactToTldrawSignal, useDialogs, useTldrawUser, - useTranslation, + useValue, type Editor, type TLEventInfo, type TLUiDialog, @@ -35,7 +30,6 @@ function resolveTldrawAssetUrl(assetUrl: string | undefined): string { } const tldrawAssetUrls = getAssetUrlsByImport(resolveTldrawAssetUrl) -const tldrawUiComponents = { Dialogs: TolariaTldrawDialogs } interface TldrawWhiteboardProps { boardId: string @@ -221,43 +215,7 @@ interface TolariaTldrawDialogProps { } const DIALOG_OPEN_DISMISS_GRACE_MS = 250 - -function isKeyboardShortcutsDialog(dialog: TLUiDialog): boolean { - return dialog.component === DefaultKeyboardShortcutsDialog -} - -interface TolariaKeyboardShortcutsDialogProps { - onClose: () => void -} - -function TolariaKeyboardShortcutsDialog({ onClose }: TolariaKeyboardShortcutsDialogProps) { - const msg = useTranslation() - - return ( - <> -
-
{msg('shortcuts-dialog.title')}
-
- - - -
-
-
- - - -
-
- - ) -} +let retainedTolariaTldrawDialogs: TLUiDialog[] = [] function useDeferredDialogOpen() { const openedAtRef = useRef(0) @@ -292,14 +250,12 @@ function shouldCloseFromOverlayClick( interface TolariaTldrawDialogContentProps { dialog: TLUiDialog - dialogTitle: string | undefined mouseDownInsideContentRef: MutableRefObject onClose: () => void } function TolariaTldrawDialogContent({ dialog, - dialogTitle, mouseDownInsideContentRef, onClose, }: TolariaTldrawDialogContentProps) { @@ -314,14 +270,17 @@ function TolariaTldrawDialogContent({ dir="ltr" className="tlui-dialog__content" aria-describedby={undefined} - aria-label={dialogTitle} role="dialog" + onKeyDown={(event) => { + if (event.key !== 'Escape') return + event.preventDefault() + mouseDownInsideContentRef.current = false + onClose() + }} onMouseDown={() => { mouseDownInsideContentRef.current = true }} onMouseUp={() => { mouseDownInsideContentRef.current = false }} > - {isKeyboardShortcutsDialog(dialog) - ? - : } +
) } @@ -329,57 +288,64 @@ function TolariaTldrawDialogContent({ const TolariaTldrawDialog = memo(function TolariaTldrawDialog({ dialog, onClose }: TolariaTldrawDialogProps) { const mouseDownInsideContentRef = useRef(false) const { openedAtRef, readyToOpen } = useDeferredDialogOpen() - const msg = useTranslation() - const dialogTitle = isKeyboardShortcutsDialog(dialog) ? msg('shortcuts-dialog.title') : undefined - const closeDialog = useCallback(() => { + const closeDialogFromBackground = useCallback(() => { if (!canDismissDialog(openedAtRef.current)) return onClose(dialog.id) }, [dialog.id, onClose, openedAtRef]) const closeDialogNow = useCallback(() => { onClose(dialog.id) }, [dialog.id, onClose]) + const handleOpenChange = useCallback((isOpen: boolean) => { + if (!isOpen) closeDialogNow() + }, [closeDialogNow]) if (!readyToOpen) return null return ( -
{ - if (event.target === event.currentTarget) mouseDownInsideContentRef.current = false - }} - onClick={(event) => { - if (shouldCloseFromOverlayClick(event, dialog, mouseDownInsideContentRef.current)) closeDialog() - }} - > - -
+ +
{ + if (event.target === event.currentTarget) mouseDownInsideContentRef.current = false + }} + onClick={(event) => { + if (shouldCloseFromOverlayClick(event, dialog, mouseDownInsideContentRef.current)) { + closeDialogFromBackground() + } + }} + > + +
+
) }) function TolariaTldrawDialogs() { const { dialogs, removeDialog } = useDialogs() - const [visibleDialogs, setVisibleDialogs] = useState(() => dialogs.get()) + const requestedDialogs = useValue('tolaria tldraw dialogs', () => dialogs.get(), [dialogs]) + const [visibleDialogs, setVisibleDialogs] = useState(() => + retainedTolariaTldrawDialogs.length > 0 ? retainedTolariaTldrawDialogs : dialogs.get() + ) const closeVisibleDialog = useCallback((id: string) => { - setVisibleDialogs((current) => current.filter((dialog) => dialog.id !== id)) + const nextDialogs = retainedTolariaTldrawDialogs.filter((dialog) => dialog.id !== id) + retainedTolariaTldrawDialogs = nextDialogs + setVisibleDialogs(nextDialogs) removeDialog(id) }, [removeDialog]) - useEffect(() => reactToTldrawSignal( - 'tolaria tldraw dialogs', - () => { - const nextDialogs = dialogs.get() - if (nextDialogs.length > 0) { - // tldraw clears the dialog atom while Radix closes the menu; keep the last requested dialog mounted locally. - setVisibleDialogs(nextDialogs) - } - } - ), [dialogs]) + useEffect(() => { + if (requestedDialogs.length === 0) return + // tldraw clears the dialog atom while Radix closes the menu; keep the last requested dialog mounted locally. + retainedTolariaTldrawDialogs = requestedDialogs + queueMicrotask(() => { + setVisibleDialogs(requestedDialogs) + }) + }, [requestedDialogs]) return visibleDialogs.map((dialog) => ( ({ Dialogs: TolariaTldrawDialogs }), []) useEffect(() => { onSnapshotChangeRef.current = onSnapshotChange diff --git a/tests/smoke/tldraw-whiteboard-notes.spec.ts b/tests/smoke/tldraw-whiteboard-notes.spec.ts index ca537c42..a171defe 100644 --- a/tests/smoke/tldraw-whiteboard-notes.spec.ts +++ b/tests/smoke/tldraw-whiteboard-notes.spec.ts @@ -226,6 +226,28 @@ test('embedded tldraw dialogs appear and release focus when closed', async ({ pa await expectNoEditorNodeSelection(page) }) +test('embedded tldraw insert embed dialog opens without crashing the note', async ({ page }) => { + await openNote(page, 'Whiteboard Embed') + + const whiteboard = page.locator('.tldraw-whiteboard') + await expect(whiteboard).toBeVisible({ timeout: 20_000 }) + + await page.getByTestId('main-menu.button').click() + await page.getByTestId('main-menu.insert-embed').click() + + const embedDialog = page.locator('.tldraw-whiteboard .tlui-dialog__content') + await expect(embedDialog).toBeVisible({ timeout: 5_000 }) + await expect(embedDialog).toContainText('Insert embed') + await expect(page.locator('.error-boundary')).toHaveCount(0) + + const dialogBox = await embedDialog.boundingBox() + const boardBox = await whiteboard.boundingBox() + expect(dialogBox).not.toBeNull() + expect(boardBox).not.toBeNull() + expect(dialogBox!.x).toBeGreaterThanOrEqual(boardBox!.x) + expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(boardBox!.x + boardBox!.width) +}) + test(TAURI_CONTEXT_MENU_TEST, async ({ page }) => { await openNote(page, 'Whiteboard Embed')