feat: add whiteboard fullscreen workspace
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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: {} })
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user