import { react, useQuickReactor, useValue } from '@tldraw/state' import { TLHandle, TLShapeId } from '@tldraw/tlschema' import { dedupe, modulate, objectMapValues } from '@tldraw/utils' import classNames from 'classnames' import { Fragment, JSX, useEffect, useRef, useState } from 'react' import { COARSE_HANDLE_RADIUS, HANDLE_RADIUS, TEXT_SHADOW_LOD } from '../../constants' import { useCanvasEvents } from '../../hooks/useCanvasEvents' import { useCoarsePointer } from '../../hooks/useCoarsePointer' import { useContainer } from '../../hooks/useContainer' import { useDocumentEvents } from '../../hooks/useDocumentEvents' import { useEditor } from '../../hooks/useEditor' import { useEditorComponents } from '../../hooks/useEditorComponents' import { useFixSafariDoubleTapZoomPencilEvents } from '../../hooks/useFixSafariDoubleTapZoomPencilEvents' import { useGestureEvents } from '../../hooks/useGestureEvents' import { useHandleEvents } from '../../hooks/useHandleEvents' import { useScreenBounds } from '../../hooks/useScreenBounds' import { Box } from '../../primitives/Box' import { Mat } from '../../primitives/Mat' import { Vec } from '../../primitives/Vec' import { toDomPrecision } from '../../primitives/utils' import { debugFlags } from '../../utils/debug-flags' import { setStyleProperty } from '../../utils/dom' import { nearestMultiple } from '../../utils/nearestMultiple' import { GeometryDebuggingView } from '../GeometryDebuggingView' import { LiveCollaborators } from '../LiveCollaborators' import { Shape } from '../Shape' /** @public */ export type TLCanvasComponentProps = { className?: string } /** @public */ export function DefaultCanvas({ className }: TLCanvasComponentProps) { const editor = useEditor() const { Background, SvgDefs } = useEditorComponents() const rCanvas = useRef(null) const rHtmlLayer = useRef(null) const rHtmlLayer2 = useRef(null) const container = useContainer() useScreenBounds(rCanvas) useDocumentEvents() useCoarsePointer() useGestureEvents(rCanvas) useFixSafariDoubleTapZoomPencilEvents(rCanvas) const rMemoizedStuff = useRef({ lodDisableTextOutline: false, allowTextOutline: true }) useQuickReactor( 'position layers', function positionLayersWhenCameraMoves() { const { x, y, z } = editor.getCamera() // This should only run once on first load if (rMemoizedStuff.current.allowTextOutline && editor.environment.isSafari) { container.style.setProperty('--tl-text-outline', 'none') rMemoizedStuff.current.allowTextOutline = false } // And this should only run if we're not in Safari; // If we're below the lod distance for text shadows, turn them off if ( rMemoizedStuff.current.allowTextOutline && z < TEXT_SHADOW_LOD !== rMemoizedStuff.current.lodDisableTextOutline ) { const lodDisableTextOutline = z < TEXT_SHADOW_LOD container.style.setProperty( '--tl-text-outline', lodDisableTextOutline ? 'none' : `0 var(--b) 0 var(--color-background), 0 var(--a) 0 var(--color-background), var(--b) var(--b) 0 var(--color-background), var(--a) var(--b) 0 var(--color-background), var(--a) var(--a) 0 var(--color-background), var(--b) var(--a) 0 var(--color-background)` ) rMemoizedStuff.current.lodDisableTextOutline = lodDisableTextOutline } // Because the html container has a width/height of 1px, we // need to create a small offset when zoomed to ensure that // the html container and svg container are lined up exactly. const offset = z >= 1 ? modulate(z, [1, 8], [0.125, 0.5], true) : modulate(z, [0.1, 1], [-2, 0.125], true) const transform = `scale(${toDomPrecision(z)}) translate(${toDomPrecision( x + offset )}px,${toDomPrecision(y + offset)}px)` setStyleProperty(rHtmlLayer.current, 'transform', transform) setStyleProperty(rHtmlLayer2.current, 'transform', transform) }, [editor, container] ) const events = useCanvasEvents() const shapeSvgDefs = useValue( 'shapeSvgDefs', () => { const shapeSvgDefsByKey = new Map() for (const util of objectMapValues(editor.shapeUtils)) { if (!util) return const defs = util.getCanvasSvgDefs() for (const { key, component: Component } of defs) { if (shapeSvgDefsByKey.has(key)) continue shapeSvgDefsByKey.set(key, ) } } return [...shapeSvgDefsByKey.values()] }, [editor] ) const hideShapes = useValue('debug_shapes', () => debugFlags.hideShapes.get(), [debugFlags]) const debugSvg = useValue('debug_svg', () => debugFlags.debugSvg.get(), [debugFlags]) const debugGeometry = useValue('debug_geometry', () => debugFlags.debugGeometry.get(), [ debugFlags, ]) return (
{shapeSvgDefs} {SvgDefs && } {Background && (
)}
{hideShapes ? null : debugSvg ? : }
{debugGeometry ? : null}
) } function GridWrapper() { const editor = useEditor() const gridSize = useValue('gridSize', () => editor.getDocumentSettings().gridSize, [editor]) const { x, y, z } = useValue('camera', () => editor.getCamera(), [editor]) const isGridMode = useValue('isGridMode', () => editor.getInstanceState().isGridMode, [editor]) const { Grid } = useEditorComponents() if (!(Grid && isGridMode)) return null return } function ScribbleWrapper() { const editor = useEditor() const scribbles = useValue('scribbles', () => editor.getInstanceState().scribbles, [editor]) const zoomLevel = useValue('zoomLevel', () => editor.getZoomLevel(), [editor]) const { Scribble } = useEditorComponents() if (!(Scribble && scribbles.length)) return null return ( <> {scribbles.map((scribble) => ( ))} ) } function BrushWrapper() { const editor = useEditor() const brush = useValue('brush', () => editor.getInstanceState().brush, [editor]) const { Brush } = useEditorComponents() if (!(Brush && brush)) return null return } function ZoomBrushWrapper() { const editor = useEditor() const zoomBrush = useValue('zoomBrush', () => editor.getInstanceState().zoomBrush, [editor]) const { ZoomBrush } = useEditorComponents() if (!(ZoomBrush && zoomBrush)) return null return } function SnapIndicatorWrapper() { const editor = useEditor() const lines = useValue('snapLines', () => editor.snaps.getIndicators(), [editor]) const zoomLevel = useValue('zoomLevel', () => editor.getZoomLevel(), [editor]) const { SnapIndicator } = useEditorComponents() if (!(SnapIndicator && lines.length > 0)) return null return ( <> {lines.map((line) => ( ))} ) } function HandlesWrapper() { const editor = useEditor() // We don't want this to update every time the shape changes const shapeIdWithHandles = useValue( 'handles shapeIdWithHandles', () => { const { isReadonly, isChangingStyle } = editor.getInstanceState() if (isReadonly || isChangingStyle) return false const onlySelectedShape = editor.getOnlySelectedShape() if (!onlySelectedShape) return false // slightly redundant but saves us from updating the handles every time the shape changes const handles = editor.getShapeHandles(onlySelectedShape) if (!handles) return false return onlySelectedShape.id }, [editor] ) if (!shapeIdWithHandles) return null return } function HandlesWrapperInner({ shapeId }: { shapeId: TLShapeId }) { const editor = useEditor() const { Handles } = useEditorComponents() const zoomLevel = useValue('zoomLevel', () => editor.getZoomLevel(), [editor]) const isCoarse = useValue('coarse pointer', () => editor.getInstanceState().isCoarsePointer, [ editor, ]) const transform = useValue('handles transform', () => editor.getShapePageTransform(shapeId), [ editor, shapeId, ]) const handles = useValue( 'handles', () => { const handles = editor.getShapeHandles(shapeId) if (!handles) return null const minDistBetweenVirtualHandlesAndRegularHandles = ((isCoarse ? COARSE_HANDLE_RADIUS : HANDLE_RADIUS) / zoomLevel) * 2 return ( handles .filter( (handle) => // if the handle isn't a virtual handle, we'll display it handle.type !== 'virtual' || // but for virtual handles, we'll only display them if they're far enough away from vertex handles !handles.some( (h) => // skip the handle we're checking against h !== handle && // only check against vertex handles h.type === 'vertex' && // and check that their distance isn't below the minimum distance Vec.Dist(handle, h) < minDistBetweenVirtualHandlesAndRegularHandles ) ) // We want vertex handles in front of all other handles .sort((a) => (a.type === 'vertex' ? 1 : -1)) ) }, [editor, zoomLevel, isCoarse, shapeId] ) if (!Handles || !handles || !transform) { return null } return ( {handles.map((handle) => { return ( ) })} ) } function HandleWrapper({ shapeId, handle, zoom, isCoarse, }: { shapeId: TLShapeId handle: TLHandle zoom: number isCoarse: boolean }) { const events = useHandleEvents(shapeId, handle.id) const { Handle } = useEditorComponents() if (!Handle) return null return ( ) } function ShapesWithSVGs() { const editor = useEditor() const renderingShapes = useValue('rendering shapes', () => editor.getRenderingShapes(), [editor]) const dprMultiple = useValue( 'dpr multiple', () => // dprMultiple is the smallest number we can multiply dpr by to get an integer // it's usually 1, 2, or 4 (for e.g. dpr of 2, 2.5 and 2.25 respectively) nearestMultiple(Math.floor(editor.getInstanceState().devicePixelRatio * 100) / 100), [editor] ) return ( <> {renderingShapes.map((result) => ( ))} ) } function ReflowIfNeeded() { const editor = useEditor() const culledShapesRef = useRef>(new Set()) useQuickReactor( 'reflow for culled shapes', () => { const culledShapes = editor.getCulledShapes() if ( culledShapesRef.current.size === culledShapes.size && [...culledShapes].every((id) => culledShapesRef.current.has(id)) ) return culledShapesRef.current = culledShapes const canvas = document.getElementsByClassName('tl-canvas') if (canvas.length === 0) return // This causes a reflow // https://gist.github.com/paulirish/5d52fb081b3570c81e3a const _height = (canvas[0] as HTMLDivElement).offsetHeight }, [editor] ) return null } function ShapesToDisplay() { const editor = useEditor() const renderingShapes = useValue('rendering shapes', () => editor.getRenderingShapes(), [editor]) const dprMultiple = useValue( 'dpr multiple', () => // dprMultiple is the smallest number we can multiply dpr by to get an integer // it's usually 1, 2, or 4 (for e.g. dpr of 2, 2.5 and 2.25 respectively) nearestMultiple(Math.floor(editor.getInstanceState().devicePixelRatio * 100) / 100), [editor] ) return ( <> {renderingShapes.map((result) => ( ))} {editor.environment.isSafari && } ) } function ShapeIndicators() { const editor = useEditor() const renderingShapes = useValue('rendering shapes', () => editor.getRenderingShapes(), [editor]) const rPreviousSelectedShapeIds = useRef>(new Set()) const idsToDisplay = useValue( 'should display selected ids', () => { // todo: move to tldraw selected ids wrappe const prev = rPreviousSelectedShapeIds.current const next = new Set() if ( editor.isInAny( 'select.idle', 'select.brushing', 'select.scribble_brushing', 'select.editing_shape', 'select.pointing_shape', 'select.pointing_selection', 'select.pointing_handle' ) && !editor.getInstanceState().isChangingStyle ) { const selected = editor.getSelectedShapeIds() for (const id of selected) { next.add(id) } if (editor.isInAny('select.idle', 'select.editing_shape')) { const instanceState = editor.getInstanceState() if (instanceState.isHoveringCanvas && !instanceState.isCoarsePointer) { const hovered = editor.getHoveredShapeId() if (hovered) next.add(hovered) } } } if (prev.size !== next.size) { rPreviousSelectedShapeIds.current = next return next } for (const id of next) { if (!prev.has(id)) { rPreviousSelectedShapeIds.current = next return next } } return prev }, [editor] ) const { ShapeIndicator } = useEditorComponents() if (!ShapeIndicator) return null return ( <> {renderingShapes.map(({ id }) => (