feat: add whiteboard fullscreen workspace

This commit is contained in:
lucaronin
2026-06-05 20:01:55 +02:00
parent 12a229e604
commit 95bdbc90c7
24 changed files with 221 additions and 70 deletions

View File

@@ -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;

View File

@@ -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<typeof TldrawWhiteboard>> = {},
): ComponentProps<typeof TldrawWhiteboard> {
return {
boardId: 'board-1',
height: '520',
snapshot: '',
width: '',
onSizeChange: vi.fn(),
onSnapshotChange: vi.fn(),
...overrides,
}
}
function renderWhiteboard(overrides: Partial<ComponentProps<typeof TldrawWhiteboard>> = {}) {
return render(
<TldrawWhiteboard {...whiteboardProps(overrides)} />,
{ wrapper: TooltipProvider },
)
}
describe('TldrawWhiteboard', () => {
afterEach(() => {
cleanup()
@@ -166,16 +189,7 @@ describe('TldrawWhiteboard', () => {
})
it('uses bundled tldraw assets instead of CDN URLs', () => {
render(
<TldrawWhiteboard
boardId="board-1"
height="520"
snapshot=""
width=""
onSizeChange={vi.fn()}
onSnapshotChange={vi.fn()}
/>
)
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(
<TldrawWhiteboard
boardId="board-1"
height="520"
snapshot=""
width=""
onSizeChange={vi.fn()}
onSnapshotChange={vi.fn()}
/>
)
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(
<TldrawWhiteboard
boardId="board-1"
height="520"
snapshot=""
width=""
onSizeChange={vi.fn()}
onSnapshotChange={vi.fn()}
/>
)
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(
<TldrawWhiteboard
boardId="board-1"
height="520"
snapshot=""
width=""
onSizeChange={vi.fn()}
onSnapshotChange={vi.fn()}
/>
)
renderWhiteboard()
const editor = mockEditor()
const cleanup = renderedTldrawProps().onMount(editor)
@@ -254,16 +241,7 @@ describe('TldrawWhiteboard', () => {
})
it('suppresses whiteboard platform permission rejections while mounted', () => {
render(
<TldrawWhiteboard
boardId="board-1"
height="520"
snapshot=""
width=""
onSizeChange={vi.fn()}
onSnapshotChange={vi.fn()}
/>
)
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(
<TldrawWhiteboard
boardId="board-1"
height="520"
snapshot={JSON.stringify(boardASnapshot)}
width=""
onSizeChange={vi.fn()}
onSnapshotChange={vi.fn()}
/>
)
const { rerender } = renderWhiteboard({ snapshot: JSON.stringify(boardASnapshot) })
expect(tldrawStoreMock.loadSnapshot).toHaveBeenLastCalledWith(expect.any(Object), boardASnapshot)
rerender(
<TldrawWhiteboard
boardId="board-2"
height="520"
snapshot=""
width=""
onSizeChange={vi.fn()}
onSnapshotChange={vi.fn()}
/>
<TldrawWhiteboard {...whiteboardProps({ boardId: 'board-2' })} />
)
expect(tldrawStoreMock.loadSnapshot).toHaveBeenLastCalledWith(expect.any(Object), { records: {} })

View File

@@ -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<HTMLButtonElement>) => {
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<PixelSize | null>(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 (
<div
ref={boardRef}
className="tldraw-whiteboard"
className={fullscreen ? 'tldraw-whiteboard tldraw-whiteboard--fullscreen' : 'tldraw-whiteboard'}
contentEditable={false}
data-board-id={boardId}
style={cssSize(visibleSize)}
@@ -553,6 +620,21 @@ export function TldrawWhiteboard({
store={store}
user={tldrawUser}
/>
<ActionTooltip copy={{ label: fullscreenLabel }} side="left">
<Button
type="button"
variant="outline"
size="icon-xs"
aria-label={fullscreenLabel}
aria-pressed={fullscreen}
className="tldraw-whiteboard__fullscreen-button"
data-testid="tldraw-whiteboard-fullscreen-toggle"
title={fullscreenLabel}
onClick={toggleFullscreen}
>
{fullscreen ? <ArrowsIn aria-hidden="true" /> : <ArrowsOut aria-hidden="true" />}
</Button>
</ActionTooltip>
<button
type="button"
aria-label="Resize whiteboard width"