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)