From 95bdbc90c722c9742ae0714f65ca39e18fa2f66e Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 5 Jun 2026 20:01:55 +0200 Subject: [PATCH] feat: add whiteboard fullscreen workspace --- docs/ABSTRACTIONS.md | 1 + lara.lock | 2 + src/components/EditorTheme.css | 33 +++++++ src/components/TldrawWhiteboard.test.tsx | 98 +++++++-------------- src/components/TldrawWhiteboard.tsx | 86 +++++++++++++++++- src/lib/locales/be-BY.json | 2 + src/lib/locales/be-Latn.json | 2 + src/lib/locales/de-DE.json | 2 + src/lib/locales/en.json | 2 + src/lib/locales/es-419.json | 2 + src/lib/locales/es-ES.json | 2 + src/lib/locales/fr-FR.json | 2 + src/lib/locales/id-ID.json | 2 + src/lib/locales/it-IT.json | 2 + src/lib/locales/ja-JP.json | 2 + src/lib/locales/ko-KR.json | 2 + src/lib/locales/pl-PL.json | 2 + src/lib/locales/pt-BR.json | 2 + src/lib/locales/pt-PT.json | 2 + src/lib/locales/ru-RU.json | 2 + src/lib/locales/vi.json | 2 + src/lib/locales/zh-CN.json | 2 + src/lib/locales/zh-TW.json | 2 + tests/smoke/tldraw-whiteboard-notes.spec.ts | 35 ++++++++ 24 files changed, 221 insertions(+), 70 deletions(-) diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 8b28d07f..27e28564 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -629,6 +629,7 @@ Defined in `src/utils/durableMarkdownBlocks.ts`, `src/utils/editorDurableMarkdow - The rich editor renders the block with the `tldraw` package and saves debounced document snapshot changes back into the block props, so normal Tolaria autosave writes the board into the `.md` file. - Whiteboard prop writes re-resolve the live BlockNote block by id before mutating it, and disappear as no-ops if a note reload or mode switch has already removed that block. - The tldraw runtime receives Tolaria's resolved light/dark mode as its user color scheme, so embedded whiteboards follow the app appearance and update while mounted. +- Embedded whiteboards expose a session-only full-window workspace that reuses the same tldraw store and Markdown snapshot; expanding or closing it does not persist camera, tool, or size state. - Mermaid and tldraw both register small codecs with the shared durable fenced-block pipeline; scanner, token, block injection, and mixed serialization mechanics live in one owner. - The `/whiteboard` slash command inserts an empty tldraw block using the same Markdown-durable storage path. Preview images are intentionally omitted; thumbnails can be added later as derived cache artifacts. diff --git a/lara.lock b/lara.lock index 6e17857a..f874f25e 100644 --- a/lara.lock +++ b/lara.lock @@ -534,6 +534,8 @@ files: editor.toolbar.exportPdf: 7a39acf8742a59d1a4f7ae064b6219a0 editor.toolbar.moreActions: 89e19353176b94068dec4c768c25e70b editor.toolbar.openProperties: 948b90b60b8ce827f179e8c5b85b112e + editor.whiteboard.enterFullscreen: 606bf766125ecff4b33ca98122222c72 + editor.whiteboard.exitFullscreen: afd0a0414d79d047f00319921e4d4944 editor.exportPdf.unavailable: 2fce9834984172b64b66767485d815e9 editor.exportPdf.failed: bf3fa78ff5725f5cbde8979b67ec2e2a filePreview.copyDeepLink: b75833669968adbef634495636e3fe50 diff --git a/src/components/EditorTheme.css b/src/components/EditorTheme.css index 42dcfc74..583ec102 100644 --- a/src/components/EditorTheme.css +++ b/src/components/EditorTheme.css @@ -423,6 +423,21 @@ user-select: none; } +body.tldraw-whiteboard-fullscreen-open { + overflow: hidden; +} + +.editor__blocknote-container .tldraw-whiteboard.tldraw-whiteboard--fullscreen { + position: fixed; + inset: 8px; + z-index: 1200; + width: auto; + max-width: none; + height: auto; + border-radius: 10px; + box-shadow: 0 18px 60px rgb(0 0 0 / 24%); +} + .editor__blocknote-container .tldraw-whiteboard .tl-container { --color-background: var(--card); --tl-layer-menu-click-capture: 25; @@ -434,6 +449,20 @@ --tl-layer-following-indicator: 80; } +.editor__blocknote-container .tldraw-whiteboard__fullscreen-button { + position: absolute; + top: 8px; + right: 8px; + z-index: 90; + background: var(--background); + box-shadow: 0 2px 8px rgb(0 0 0 / 12%); +} + +.editor__blocknote-container .tldraw-whiteboard--fullscreen .tldraw-whiteboard__fullscreen-button { + top: 12px; + right: 12px; +} + .editor__blocknote-container .tldraw-whiteboard [data-radix-popper-content-wrapper] { position: absolute !important; } @@ -451,6 +480,10 @@ touch-action: none; } +.editor__blocknote-container .tldraw-whiteboard--fullscreen .tldraw-whiteboard__resize-handle { + display: none; +} + .editor__blocknote-container .tldraw-whiteboard__resize-handle--width { top: 0; right: 0; diff --git a/src/components/TldrawWhiteboard.test.tsx b/src/components/TldrawWhiteboard.test.tsx index 63362ba1..083daacd 100644 --- a/src/components/TldrawWhiteboard.test.tsx +++ b/src/components/TldrawWhiteboard.test.tsx @@ -1,7 +1,9 @@ import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import type { ComponentProps } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Editor } from 'tldraw' import { TldrawWhiteboard } from './TldrawWhiteboard' +import { TooltipProvider } from './ui/tooltip' interface MockTldrawProps { assetUrls: MockAssetUrls @@ -157,6 +159,27 @@ function expectBundledTldrawAssetUrls(assetUrls: MockAssetUrls) { expectNoCdnUrls(assetUrls.translations) } +function whiteboardProps( + overrides: Partial> = {}, +): ComponentProps { + return { + boardId: 'board-1', + height: '520', + snapshot: '', + width: '', + onSizeChange: vi.fn(), + onSnapshotChange: vi.fn(), + ...overrides, + } +} + +function renderWhiteboard(overrides: Partial> = {}) { + return render( + , + { wrapper: TooltipProvider }, + ) +} + describe('TldrawWhiteboard', () => { afterEach(() => { cleanup() @@ -166,16 +189,7 @@ describe('TldrawWhiteboard', () => { }) it('uses bundled tldraw assets instead of CDN URLs', () => { - render( - - ) + renderWhiteboard() expect(screen.getByTestId('mock-tldraw')).toHaveAttribute('data-draw-font-url') expect(assetImportMock.getAssetUrlsByImport).toHaveBeenCalledWith(expect.any(Function)) @@ -186,16 +200,7 @@ describe('TldrawWhiteboard', () => { document.documentElement.setAttribute('data-theme', 'dark') document.documentElement.classList.add('dark') - render( - - ) + renderWhiteboard() expect(renderedTldrawProps().user?.userPreferences.get().colorScheme).toBe('dark') }) @@ -203,16 +208,7 @@ describe('TldrawWhiteboard', () => { it('updates the tldraw color scheme when Tolaria theme changes', async () => { document.documentElement.setAttribute('data-theme', 'light') - render( - - ) + renderWhiteboard() expect(renderedTldrawProps().user?.userPreferences.get().colorScheme).toBe('light') @@ -227,16 +223,7 @@ describe('TldrawWhiteboard', () => { }) it('installs the text measurement guard when the canvas mounts', () => { - render( - - ) + renderWhiteboard() const editor = mockEditor() const cleanup = renderedTldrawProps().onMount(editor) @@ -254,16 +241,7 @@ describe('TldrawWhiteboard', () => { }) it('suppresses whiteboard platform permission rejections while mounted', () => { - render( - - ) + renderWhiteboard() const cleanup = renderedTldrawProps().onMount(mockEditor()) const denied = { @@ -280,28 +258,12 @@ describe('TldrawWhiteboard', () => { it('resets the drawing store when switching to a blank board snapshot', () => { const boardASnapshot = { records: { shape: 'from-board-a' } } - const { rerender } = render( - - ) + const { rerender } = renderWhiteboard({ snapshot: JSON.stringify(boardASnapshot) }) expect(tldrawStoreMock.loadSnapshot).toHaveBeenLastCalledWith(expect.any(Object), boardASnapshot) rerender( - + ) expect(tldrawStoreMock.loadSnapshot).toHaveBeenLastCalledWith(expect.any(Object), { records: {} }) diff --git a/src/components/TldrawWhiteboard.tsx b/src/components/TldrawWhiteboard.tsx index f78a321f..077455b7 100644 --- a/src/components/TldrawWhiteboard.tsx +++ b/src/components/TldrawWhiteboard.tsx @@ -1,5 +1,6 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MutableRefObject, 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 { ArrowsIn, ArrowsOut } from '@phosphor-icons/react' import { Dialog as DialogPrimitive } from 'radix-ui' import { Box, @@ -19,11 +20,15 @@ import { } from 'tldraw' import 'tldraw/tldraw.css' import { useDocumentThemeMode } from '../hooks/useDocumentThemeMode' +import { resolveEffectiveLocale, translate, type AppLocale } from '../lib/i18n' import type { ResolvedThemeMode } from '../lib/themeMode' +import { Button } from './ui/button' +import { ActionTooltip } from './ui/action-tooltip' import { installTldrawTextMeasurementGuard } from './tldrawTextMeasurementGuard' const EMPTY_TLDRAW_TRANSLATION_URL = 'data:application/json;base64,e30K' const TOLARIA_TLDRAW_USER_ID = 'tolaria-whiteboard' +const WHITEBOARD_FULLSCREEN_BODY_CLASS = 'tldraw-whiteboard-fullscreen-open' function resolveTldrawAssetUrl(assetUrl: string | undefined): string { return assetUrl ?? EMPTY_TLDRAW_TRANSLATION_URL @@ -101,6 +106,28 @@ function ignoreTldrawUserPreferencesUpdate(preferences: TLUserPreferences) { void preferences } +function readDocumentLocale(): AppLocale { + if (typeof document === 'undefined') return 'en' + return resolveEffectiveLocale(document.documentElement.lang) +} + +function useDocumentLocale(): AppLocale { + const [locale, setLocale] = useState(readDocumentLocale) + + useEffect(() => { + if (typeof document === 'undefined') return + + const syncLocale = () => setLocale(readDocumentLocale()) + const observer = new MutationObserver(syncLocale) + observer.observe(document.documentElement, { attributeFilter: ['lang'], attributes: true }) + syncLocale() + + return () => observer.disconnect() + }, []) + + return locale +} + function rejectionName(error: unknown): string { if (error instanceof Error) return error.name if (typeof error !== 'object' || error === null || !('name' in error)) return '' @@ -425,6 +452,40 @@ function TolariaTldrawDialogs() { )) } +function useFullscreenWhiteboard() { + const [fullscreen, setFullscreen] = useState(false) + + useEffect(() => { + if (!fullscreen) return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setFullscreen(false) + } + document.body.classList.add(WHITEBOARD_FULLSCREEN_BODY_CLASS) + window.addEventListener('keydown', handleKeyDown) + + return () => { + document.body.classList.remove(WHITEBOARD_FULLSCREEN_BODY_CLASS) + window.removeEventListener('keydown', handleKeyDown) + } + }, [fullscreen]) + + useEffect(() => { + const animationFrameId = window.requestAnimationFrame(() => { + window.dispatchEvent(new Event('resize')) + }) + return () => { window.cancelAnimationFrame(animationFrameId) } + }, [fullscreen]) + + const toggleFullscreen = useCallback((event: ReactMouseEvent) => { + event.preventDefault() + event.stopPropagation() + setFullscreen((current) => !current) + }, []) + + return { fullscreen, toggleFullscreen } +} + export function TldrawWhiteboard({ boardId, height, @@ -441,6 +502,12 @@ export function TldrawWhiteboard({ const persistedSize = useMemo(() => normalizeSize({ height, width }), [height, width]) const [resizingSize, setResizingSize] = useState(null) const visibleSize = resizingSize ?? persistedSize + const { fullscreen, toggleFullscreen } = useFullscreenWhiteboard() + const locale = useDocumentLocale() + const fullscreenLabel = translate( + locale, + fullscreen ? 'editor.whiteboard.exitFullscreen' : 'editor.whiteboard.enterFullscreen', + ) const themeMode = useDocumentThemeMode() const userPreferences = useMemo(() => tldrawUserPreferences(themeMode), [themeMode]) const tldrawUser = useTldrawUser({ @@ -540,7 +607,7 @@ export function TldrawWhiteboard({ return (
+ + +