diff --git a/src/components/TldrawWhiteboard.tsx b/src/components/TldrawWhiteboard.tsx index 5b76f1bf..b5825387 100644 --- a/src/components/TldrawWhiteboard.tsx +++ b/src/components/TldrawWhiteboard.tsx @@ -1,13 +1,22 @@ -import { useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react' +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 { Box, + DefaultKeyboardShortcutsDialog, + DefaultKeyboardShortcutsDialogContent, + TldrawUiButton, + TldrawUiButtonIcon, + TldrawUiMenuContextProvider, Tldraw, createTLStore, getSnapshot, loadSnapshot, + react as reactToTldrawSignal, + useDialogs, + useTranslation, type Editor, type TLEventInfo, + type TLUiDialog, type TLStoreSnapshot, } from 'tldraw' import 'tldraw/tldraw.css' @@ -20,6 +29,7 @@ function resolveTldrawAssetUrl(assetUrl: string | undefined): string { } const tldrawAssetUrls = getAssetUrlsByImport(resolveTldrawAssetUrl) +const tldrawUiComponents = { Dialogs: TolariaTldrawDialogs } interface TldrawWhiteboardProps { boardId: string @@ -187,6 +197,181 @@ function installWhiteboardRuntimeGuards(editor: Editor): () => void { } } +interface TolariaTldrawDialogProps { + dialog: TLUiDialog + onClose: (id: string) => void +} + +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')}
+
+ + + +
+
+
+ + + +
+
+ + ) +} + +function useDeferredDialogOpen() { + const openedAtRef = useRef(0) + const [readyToOpen, setReadyToOpen] = useState(false) + + useEffect(() => { + const animationFrameId = window.requestAnimationFrame(() => { + openedAtRef.current = performance.now() + setReadyToOpen(true) + }) + return () => { window.cancelAnimationFrame(animationFrameId) } + }, []) + + return { openedAtRef, readyToOpen } +} + +function canDismissDialog(openedAt: number): boolean { + return performance.now() - openedAt >= DIALOG_OPEN_DISMISS_GRACE_MS +} + +function isOverlayEvent(event: ReactMouseEvent): boolean { + return event.target === event.currentTarget +} + +function shouldCloseFromOverlayClick( + event: ReactMouseEvent, + dialog: TLUiDialog, + mouseDownInsideContent: boolean +): boolean { + return isOverlayEvent(event) && !dialog.preventBackgroundClose && !mouseDownInsideContent +} + +interface TolariaTldrawDialogContentProps { + dialog: TLUiDialog + dialogTitle: string | undefined + mouseDownInsideContentRef: MutableRefObject + onClose: () => void +} + +function TolariaTldrawDialogContent({ + dialog, + dialogTitle, + mouseDownInsideContentRef, + onClose, +}: TolariaTldrawDialogContentProps) { + const ModalContent = dialog.component + const handleClose = () => { + mouseDownInsideContentRef.current = false + onClose() + } + + return ( +
{ mouseDownInsideContentRef.current = true }} + onMouseUp={() => { mouseDownInsideContentRef.current = false }} + > + {isKeyboardShortcutsDialog(dialog) + ? + : } +
+ ) +} + +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(() => { + if (!canDismissDialog(openedAtRef.current)) return + onClose(dialog.id) + }, [dialog.id, onClose, openedAtRef]) + const closeDialogNow = useCallback(() => { onClose(dialog.id) }, [dialog.id, onClose]) + + if (!readyToOpen) return null + + return ( +
{ + if (event.target === event.currentTarget) mouseDownInsideContentRef.current = false + }} + onClick={(event) => { + if (shouldCloseFromOverlayClick(event, dialog, mouseDownInsideContentRef.current)) closeDialog() + }} + > + +
+ ) +}) + +function TolariaTldrawDialogs() { + const { dialogs, removeDialog } = useDialogs() + const [visibleDialogs, setVisibleDialogs] = useState(() => dialogs.get()) + + const closeVisibleDialog = useCallback((id: string) => { + setVisibleDialogs((current) => current.filter((dialog) => dialog.id !== id)) + 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]) + + return visibleDialogs.map((dialog) => ( + + )) +} + export function TldrawWhiteboard({ boardId, height, @@ -295,6 +480,7 @@ export function TldrawWhiteboard({ > diff --git a/tests/smoke/tldraw-whiteboard-notes.spec.ts b/tests/smoke/tldraw-whiteboard-notes.spec.ts index 35b82f69..930c6ee6 100644 --- a/tests/smoke/tldraw-whiteboard-notes.spec.ts +++ b/tests/smoke/tldraw-whiteboard-notes.spec.ts @@ -152,6 +152,34 @@ test('embedded tldraw interactions stay inside the whiteboard', async ({ page }) await expectNoEditorNodeSelection(page) }) +test('embedded tldraw dialogs appear and release focus when closed', 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.keyboard-shortcuts-button').click() + + const shortcutsDialog = page.locator('.tldraw-whiteboard .tlui-dialog__content') + await expect(shortcutsDialog).toBeVisible({ timeout: 5_000 }) + await expect(shortcutsDialog).toContainText('Keyboard shortcuts') + + const dialogBox = await shortcutsDialog.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) + + await page.getByTestId('dialog.close').click() + await expect(shortcutsDialog).toHaveCount(0) + + await page.getByTestId('tools.select').click() + await expect(page.getByTestId('tools.select')).toHaveAttribute('aria-pressed', 'true') + await expectNoEditorNodeSelection(page) +}) + test('embedded tldraw drawing uses the clicked coordinates while zoomed', async ({ page }) => { await openNote(page, 'Whiteboard Embed') await applyZoom(page, 110)