import * as React from 'react' import { Renderer } from '@tldraw/core' import { IntlConfig, IntlProvider } from 'react-intl' import { styled, dark } from '~styles' import { TDDocument, TDStatus } 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' import { Loading } from '~components/Loading' import { ErrorBoundary as _Errorboundary } from 'react-error-boundary' import { ErrorFallback } from '~components/ErrorFallback' import messages_en from './translations/en.json' import messages_fr from './translations/fr.json' import messages_it from './translations/it.json' const ErrorBoundary = _Errorboundary as any 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 multiplayer menu. */ showMultiplayerMenu?: 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) If provided, image/video componnets will be disabled. * * Warning: Keeping this enabled for multiplayer applications without provifing a storage * bucket based solution will cause massive base64 string to be written to the liveblocks room. */ disableAssets?: boolean } export function Tldraw({ id, document, currentPageId, autofocus = true, showMenu = true, showMultiplayerMenu = true, showPages = true, showTools = true, showZoom = true, showStyles = true, showUI = true, readOnly = false, disableAssets = false, darkMode = false, showSponsorLink, onMount, onChange, onChangePresence, onNewProject, onSaveProject, onSaveProjectAs, onOpenProject, onOpenMedia, onSignOut, onSignIn, onUndo, onRedo, onPersist, onPatch, onCommand, onChangePage, onAssetCreate, onAssetDelete, onAssetUpload, onExport, }: TldrawProps) { const [sId, setSId] = React.useState(id) // Create a new app when the component mounts. const [app, setApp] = React.useState(() => { const app = new TldrawApp(id, { onMount, onChange, onChangePresence, onNewProject, onSaveProject, onSaveProjectAs, onOpenProject, onOpenMedia, onSignOut, onSignIn, onUndo, onRedo, onPersist, onPatch, onCommand, onChangePage, onAssetDelete, onAssetCreate, onAssetUpload, }) return app }) // Create a new app if the `id` prop changes. React.useLayoutEffect(() => { if (id === sId) return const newApp = new TldrawApp(id, { onMount, onChange, onChangePresence, onNewProject, onSaveProject, onSaveProjectAs, onOpenProject, onOpenMedia, onSignOut, onSignIn, onUndo, onRedo, onPersist, onPatch, onCommand, onChangePage, onAssetDelete, onAssetCreate, onAssetUpload, onExport, }) 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]) // Disable assets when the `disableAssets` prop changes. React.useEffect(() => { app.setDisableAssets(disableAssets) }, [app, disableAssets]) // 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 darkMode when the `darkMode` 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, onOpenMedia, onSignOut, onSignIn, onUndo, onRedo, onPersist, onPatch, onCommand, onChangePage, onAssetDelete, onAssetCreate, onAssetUpload, onExport, } }, [ onMount, onChange, onChangePresence, onNewProject, onSaveProject, onSaveProjectAs, onOpenProject, onOpenMedia, onSignOut, onSignIn, onUndo, onRedo, onPersist, onPatch, onCommand, onChangePage, onAssetDelete, onAssetCreate, onAssetUpload, onExport, ]) React.useLayoutEffect(() => { if (typeof window === 'undefined') return if (!window.document?.fonts) return function refreshBoundingBoxes() { app.refreshBoundingBoxes() } window.document.fonts.addEventListener('loadingdone', refreshBoundingBoxes) return () => { window.document.fonts.removeEventListener('loadingdone', refreshBoundingBoxes) } }, [app]) // Use the `key` to ensure that new selector hooks are made when the id changes return ( ) } interface InnerTldrawProps { id?: string autofocus: boolean readOnly: boolean showPages: boolean showMenu: boolean showMultiplayerMenu: boolean showZoom: boolean showStyles: boolean showUI: boolean showTools: boolean showSponsorLink?: boolean } const InnerTldraw = React.memo(function InnerTldraw({ id, autofocus, showPages, showMenu, showMultiplayerMenu, 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 assets = document.assets const { selectedIds } = pageState const isHideBoundsShape = selectedIds.length === 1 && page.shapes[selectedIds[0]] && TLDR.getShapeUtil(page.shapes[selectedIds[0]].type).hideBounds const isHideResizeHandlesShape = selectedIds.length === 1 && page.shapes[selectedIds[0]] && TLDR.getShapeUtil(page.shapes[selectedIds[0]].type).hideResizeHandles // Custom rendering meta, with dark mode for shapes const meta = React.useMemo(() => { return { isDarkMode: settings.isDarkMode } }, [settings.isDarkMode]) const showDashedBrush = settings.isCadSelectMode ? !appState.selectByContain : appState.selectByContain // Custom theme, based on darkmode const theme = React.useMemo(() => { const { selectByContain } = appState const { isDarkMode, isCadSelectMode } = settings if (isDarkMode) { const brushBase = isCadSelectMode ? selectByContain ? '69, 155, 255' : '105, 209, 73' : '180, 180, 180' return { brushFill: `rgba(${brushBase}, ${isCadSelectMode ? 0.08 : 0.05})`, brushStroke: `rgba(${brushBase}, ${isCadSelectMode ? 0.5 : 0.25})`, brushDashStroke: `rgba(${brushBase}, .6)`, selected: 'rgba(38, 150, 255, 1.000)', selectFill: 'rgba(38, 150, 255, 0.05)', background: '#212529', foreground: '#49555f', } } const brushBase = isCadSelectMode ? (selectByContain ? '0, 89, 242' : '51, 163, 23') : '0,0,0' return { brushFill: `rgba(${brushBase}, ${isCadSelectMode ? 0.08 : 0.05})`, brushStroke: `rgba(${brushBase}, ${isCadSelectMode ? 0.4 : 0.25})`, brushDashStroke: `rgba(${brushBase}, .6)`, } }, [settings.isDarkMode, settings.isCadSelectMode, appState.selectByContain]) 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 const hideCloneHandles = isInSession || !isSelecting || !settings.showCloneHandles || pageState.camera.zoom < 0.2 const messages = { en: messages_en, fr: messages_fr, it: messages_it, } const defaultLanguage = settings.language ?? navigator.language.split(/[-_]/)[0] 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, })