import * as React from 'react' import { IdProvider } from '@radix-ui/react-id' import { Renderer } from '@tldraw/core' import { styled, dark } from '~styles' import { TDDocument, TDShape, TDBinding, TDStatus, TDUser } from '~types' import { TldrawApp, TDCallbacks } from '~state' import { TldrawContext, useStylesheet, useKeyboardShortcuts, useTldrawApp } from '~hooks' import { shapeUtils } from '~state/shapes' import { ToolsPanel } from '~components/ToolsPanel' import { TopPanel } from '~components/TopPanel' import { ContextMenu } from '~components/ContextMenu' import { FocusButton } from '~components/FocusButton' import { TLDR } from '~state/TLDR' import { GRID_SIZE } from '~constants' export interface TldrawProps extends TDCallbacks { /** * (optional) If provided, the component will load / persist state under this key. */ id?: string /** * (optional) The document to load or update from. */ document?: TDDocument /** * (optional) The current page id. */ currentPageId?: string /** * (optional) Whether the editor should immediately receive focus. Defaults to true. */ autofocus?: boolean /** * (optional) Whether to show the menu UI. */ showMenu?: boolean /** * (optional) Whether to show the pages UI. */ showPages?: boolean /** * (optional) Whether to show the styles UI. */ showStyles?: boolean /** * (optional) Whether to show the zoom UI. */ showZoom?: boolean /** * (optional) Whether to show the tools UI. */ showTools?: boolean /** * (optional) Whether to show a sponsor link for Tldraw. */ showSponsorLink?: boolean /** * (optional) Whether to show the UI. */ showUI?: boolean /** * (optional) Whether to the document should be read only. */ readOnly?: boolean /** * (optional) Whether to to show the app's dark mode UI. */ darkMode?: boolean /** * (optional) A callback to run when the component mounts. */ onMount?: (state: TldrawApp) => void /** * (optional) A callback to run when the user creates a new project through the menu or through a keyboard shortcut. */ onNewProject?: (state: TldrawApp, e?: KeyboardEvent) => void /** * (optional) A callback to run when the user saves a project through the menu or through a keyboard shortcut. */ onSaveProject?: (state: TldrawApp, e?: KeyboardEvent) => void /** * (optional) A callback to run when the user saves a project as a new project through the menu or through a keyboard shortcut. */ onSaveProjectAs?: (state: TldrawApp, e?: KeyboardEvent) => void /** * (optional) A callback to run when the user opens new project through the menu or through a keyboard shortcut. */ onOpenProject?: (state: TldrawApp, e?: KeyboardEvent) => void /** * (optional) A callback to run when the user signs in via the menu. */ onSignIn?: (state: TldrawApp) => void /** * (optional) A callback to run when the user signs out via the menu. */ onSignOut?: (state: TldrawApp) => void /** * (optional) A callback to run when the user creates a new project. */ onChangePresence?: (state: TldrawApp, user: TDUser) => void /** * (optional) A callback to run when the component's state changes. */ onChange?: (state: TldrawApp, reason?: string) => void /** * (optional) A callback to run when the state is patched. */ onPatch?: (state: TldrawApp, reason?: string) => void /** * (optional) A callback to run when the state is changed with a command. */ onCommand?: (state: TldrawApp, reason?: string) => void /** * (optional) A callback to run when the state is persisted. */ onPersist?: (state: TldrawApp) => void /** * (optional) A callback to run when the user undos. */ onUndo?: (state: TldrawApp) => void /** * (optional) A callback to run when the user redos. */ onRedo?: (state: TldrawApp) => void onChangePage?: ( app: TldrawApp, shapes: Record, bindings: Record ) => void } export function Tldraw({ id, document, currentPageId, darkMode = false, autofocus = true, showMenu = true, showPages = true, showTools = true, showZoom = true, showStyles = true, showUI = true, readOnly = false, showSponsorLink = false, onMount, onChange, onChangePresence, onNewProject, onSaveProject, onSaveProjectAs, onOpenProject, onSignOut, onSignIn, onUndo, onRedo, onPersist, onPatch, onCommand, onChangePage, }: TldrawProps) { const [sId, setSId] = React.useState(id) // Create a new app when the component mounts. const [app, setApp] = React.useState( () => new TldrawApp(id, { onMount, onChange, onChangePresence, onNewProject, onSaveProject, onSaveProjectAs, onOpenProject, onSignOut, onSignIn, onUndo, onRedo, onPersist, onPatch, onCommand, onChangePage, }) ) // Create a new app if the `id` prop changes. React.useEffect(() => { if (id === sId) return const newApp = new TldrawApp(id, { onMount, onChange, onChangePresence, onNewProject, onSaveProject, onSaveProjectAs, onOpenProject, onSignOut, onSignIn, onUndo, onRedo, onPersist, onPatch, onCommand, onChangePage, }) setSId(id) setApp(newApp) }, [sId, id]) // Update the document if the `document` prop changes but the ids, // are the same, or else load a new document if the ids are different. React.useEffect(() => { if (!document) return if (document.id === app.document.id) { app.updateDocument(document) } else { app.loadDocument(document) } }, [document, app]) // Change the page when the `currentPageId` prop changes React.useEffect(() => { if (!currentPageId) return app.changePage(currentPageId) }, [currentPageId, app]) // Toggle the app's readOnly mode when the `readOnly` prop changes React.useEffect(() => { app.readOnly = readOnly }, [app, readOnly]) // Toggle the app's readOnly mode when the `readOnly` prop changes React.useEffect(() => { if (darkMode && !app.settings.isDarkMode) { // app.toggleDarkMode() } }, [app, darkMode]) // Update the app's callbacks when any callback changes. React.useEffect(() => { app.callbacks = { onMount, onChange, onChangePresence, onNewProject, onSaveProject, onSaveProjectAs, onOpenProject, onSignOut, onSignIn, onUndo, onRedo, onPersist, onPatch, onCommand, onChangePage, } }, [ onMount, onChange, onChangePresence, onNewProject, onSaveProject, onSaveProjectAs, onOpenProject, onSignOut, onSignIn, onUndo, onRedo, onPersist, onPatch, onCommand, onChangePage, ]) // Use the `key` to ensure that new selector hooks are made when the id changes return ( ) } interface InnerTldrawProps { id?: string autofocus: boolean showPages: boolean showMenu: boolean showZoom: boolean showStyles: boolean showUI: boolean showTools: boolean showSponsorLink: boolean readOnly: boolean } const InnerTldraw = React.memo(function InnerTldraw({ id, autofocus, showPages, showMenu, showZoom, showStyles, showTools, showSponsorLink, readOnly, showUI, }: InnerTldrawProps) { const app = useTldrawApp() const rWrapper = React.useRef(null) const state = app.useStore() const { document, settings, appState, room } = state const isSelecting = state.appState.activeTool === 'select' const page = document.pages[appState.currentPageId] const pageState = document.pageStates[page.id] const { selectedIds } = state.document.pageStates[page.id] const isHideBoundsShape = pageState.selectedIds.length === 1 && TLDR.getShapeUtil(page.shapes[selectedIds[0]].type).hideBounds const isHideResizeHandlesShape = selectedIds.length === 1 && TLDR.getShapeUtil(page.shapes[selectedIds[0]].type).hideResizeHandles const isInSession = app.session !== undefined // Hide bounds when not using the select tool, or when the only selected shape has handles const hideBounds = (isInSession && app.session?.constructor.name !== 'BrushSession') || !isSelecting || isHideBoundsShape || !!pageState.editingId // Hide bounds when not using the select tool, or when in session const hideHandles = isInSession || !isSelecting // Hide indicators when not using the select tool, or when in session const hideIndicators = (isInSession && state.appState.status !== TDStatus.Brushing) || !isSelecting // Custom rendering meta, with dark mode for shapes const meta = React.useMemo(() => { return { isDarkMode: settings.isDarkMode } }, [settings.isDarkMode]) // Custom theme, based on darkmode const theme = React.useMemo(() => { if (settings.isDarkMode) { return { brushFill: 'rgba(180, 180, 180, .05)', brushStroke: 'rgba(180, 180, 180, .25)', selected: 'rgba(38, 150, 255, 1.000)', selectFill: 'rgba(38, 150, 255, 0.05)', background: '#212529', foreground: '#49555f', } } return {} }, [settings.isDarkMode]) // When the context menu is blurred, close the menu by sending pointer events // to the context menu's ref. This is a hack around the fact that certain shapes // stop event propagation, which causes the menu to stay open even when blurred. const handleMenuBlur = React.useCallback((e) => { const elm = rWrapper.current if (!elm) return if (!elm.contains(e.relatedTarget)) return elm.dispatchEvent(new Event('pointerdown', { bubbles: true })) elm.dispatchEvent(new Event('pointerup', { bubbles: true })) }, []) return ( {showUI && ( {settings.isFocusMode ? ( ) : ( <> {showTools && !readOnly && } )} )} ) }) const OneOff = React.memo(function OneOff({ focusableRef, autofocus, }: { autofocus?: boolean focusableRef: React.RefObject }) { useKeyboardShortcuts(focusableRef) useStylesheet() React.useEffect(() => { if (autofocus) { focusableRef.current?.focus() } }, [autofocus]) return null }) const StyledLayout = styled('div', { position: 'absolute', height: '100%', width: '100%', minHeight: 0, minWidth: 0, maxHeight: '100%', maxWidth: '100%', overflow: 'hidden', boxSizing: 'border-box', outline: 'none', '& .tl-container': { position: 'absolute', top: 0, left: 0, height: '100%', width: '100%', zIndex: 1, }, '& input, textarea, button, select, label, button': { webkitTouchCallout: 'none', webkitUserSelect: 'none', '-webkit-tap-highlight-color': 'transparent', 'tap-highlight-color': 'transparent', }, }) const StyledUI = styled('div', { position: 'absolute', top: 0, left: 0, height: '100%', width: '100%', padding: '8px 8px 0 8px', display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-start', pointerEvents: 'none', zIndex: 2, '& > *': { pointerEvents: 'all', }, }) const StyledSpacer = styled('div', { flexGrow: 2, })