UNPKG

tldraw

Version:

A tiny little drawing editor.

263 lines (216 loc) • 7.97 kB
import { Box, Mat, StateNode, TLCancelEventInfo, TLKeyboardEventInfo, TLPageId, TLPointerEventInfo, TLShape, TLShapeId, TLTickEventInfo, Vec, pointInPolygon, polygonsIntersect, react, } from '@tldraw/editor' export class Brushing extends StateNode { static override id = 'brushing' info = {} as TLPointerEventInfo & { target: 'canvas' } initialSelectedShapeIds: TLShapeId[] = [] excludedShapeIds = new Set<TLShapeId>() isWrapMode = false viewportDidChange = false cleanupViewportChangeReactor() { void null } // cleanup function for the viewport reactor override onEnter(info: TLPointerEventInfo & { target: 'canvas' }) { const { editor } = this const altKey = editor.inputs.getAltKey() this.isWrapMode = editor.user.getIsWrapMode() this.viewportDidChange = false let isInitialCheck = true this.cleanupViewportChangeReactor = react('viewport change while brushing', () => { editor.getViewportPageBounds() // capture the viewport change if (!isInitialCheck && !this.viewportDidChange) { this.viewportDidChange = true } }) if (altKey) { this.parent.transition('scribble_brushing', info) return } this.excludedShapeIds = new Set( editor .getCurrentPageShapes() .filter( (shape) => editor.isShapeOfType(shape, 'group') || editor.isShapeOrAncestorLocked(shape) ) .map((shape) => shape.id) ) this.info = info this.initialSelectedShapeIds = editor.getSelectedShapeIds().slice() this.hitTestShapes() isInitialCheck = false } override onExit() { this.initialSelectedShapeIds = [] this.editor.updateInstanceState({ brush: null }) this.cleanupViewportChangeReactor() } override onTick({ elapsed }: TLTickEventInfo) { const { editor } = this if (!editor.inputs.getIsDragging() || editor.inputs.getIsPanning()) return editor.edgeScrollManager.updateEdgeScrolling(elapsed) } override onPointerMove() { this.hitTestShapes() } override onPointerUp() { this.complete() } override onComplete() { this.complete() } override onCancel(info: TLCancelEventInfo) { this.editor.setSelectedShapes(this.initialSelectedShapeIds) this.parent.transition('idle', info) } override onKeyDown(info: TLKeyboardEventInfo) { if (this.editor.inputs.getAltKey()) { this.parent.transition('scribble_brushing', info) } else { this.hitTestShapes() } } override onKeyUp() { this.hitTestShapes() } private complete() { this.hitTestShapes() this.parent.transition('idle') } private hitTestShapes() { const { editor, excludedShapeIds, isWrapMode } = this const originPagePoint = editor.inputs.getOriginPagePoint() const currentPagePoint = editor.inputs.getCurrentPagePoint() const shiftKey = editor.inputs.getShiftKey() const ctrlKey = editor.inputs.getCtrlKey() // We'll be collecting shape ids of selected shapes; if we're holding shift key, we start from our initial shapes const results = new Set(shiftKey ? this.initialSelectedShapeIds : []) // In wrap mode, we need to completely enclose a shape to select it const isWrapping = isWrapMode ? !ctrlKey : ctrlKey // Set the brush to contain the current and origin points const brush = Box.FromPoints([originPagePoint, currentPagePoint]) // We'll be testing the corners of the brush against the shapes const { corners } = brush let A: Vec, B: Vec, shape: TLShape, pageBounds: Box | undefined, pageTransform: Mat | undefined, localCorners: Vec[] // Some notes on optimization. We could easily cache all of the shape positions at // the start of the interaction and then do very fast checks against them, but that // would mean changes introduced by other collaborators wouldn't be reflected—a user // could select a shape by selecting where it _used_ to be. // We still want to avoid hit tests as much as possible, however, so we test only the // shapes that are on screen UNLESS: the user has scrolled their viewpor; or the user // is dragging outside of the screen (e.g. in a window). In those cases, we need to // test all shapes. // On a page with ~5000 shapes, on-screen hit tests are about 2x faster than // testing all shapes. const brushBoxIsInsideViewport = editor.getViewportPageBounds().contains(brush) const currentPageId = editor.getCurrentPageId() // Use spatial index to filter candidates const candidateIds = editor.getShapeIdsInsideBounds(brush) // Early return if no candidates - avoid expensive getCurrentPageShapesSorted() // But still update brush visual and selection if (candidateIds.size === 0) { const currentBrush = editor.getInstanceState().brush if (!currentBrush || !brush.equals(currentBrush)) { editor.updateInstanceState({ brush: { ...brush.toJson() } }) } const current = editor.getSelectedShapeIds() if (current.length !== results.size || current.some((id) => !results.has(id))) { editor.setSelectedShapes(Array.from(results)) } return } const allShapes = brushBoxIsInsideViewport && !this.viewportDidChange ? editor.getCurrentPageRenderingShapesSorted() : editor.getCurrentPageShapesSorted() const shapesToHitTest = allShapes.filter((shape) => candidateIds.has(shape.id)) testAllShapes: for (let i = 0, n = shapesToHitTest.length; i < n; i++) { shape = shapesToHitTest[i] if (excludedShapeIds.has(shape.id) || results.has(shape.id)) continue testAllShapes pageBounds = editor.getShapePageBounds(shape) if (!pageBounds) continue testAllShapes // If the brush fully wraps a shape, it's almost certainly a hit if (brush.contains(pageBounds)) { this.handleHit(shape, currentPagePoint, currentPageId, results, corners) continue testAllShapes } // If we're in wrap mode and the brush did not fully encloses the shape, it's a miss // We also skip frames unless we've completely selected the frame. if (isWrapping || editor.isShapeOfType(shape, 'frame')) { continue testAllShapes } // If the brush collides the page bounds, then do hit tests against // each of the brush's four sides. if (brush.collides(pageBounds)) { // Shapes expect to hit test line segments in their own coordinate system, // so we first need to get the brush corners in the shape's local space. pageTransform = editor.getShapePageTransform(shape) if (!pageTransform) continue testAllShapes localCorners = pageTransform.clone().invert().applyToPoints(corners) // See if any of the edges intersect the shape's geometry const geometry = editor.getShapeGeometry(shape) hitTestBrushEdges: for (let i = 0; i < 4; i++) { A = localCorners[i] B = localCorners[(i + 1) % 4] if (geometry.hitTestLineSegment(A, B, 0)) { this.handleHit(shape, currentPagePoint, currentPageId, results, corners) break hitTestBrushEdges } } } } const currentBrush = editor.getInstanceState().brush if (!currentBrush || !brush.equals(currentBrush)) { editor.updateInstanceState({ brush: { ...brush.toJson() } }) } const current = editor.getSelectedShapeIds() if (current.length !== results.size || current.some((id) => !results.has(id))) { editor.setSelectedShapes(Array.from(results)) } } override onInterrupt() { this.editor.updateInstanceState({ brush: null }) } private handleHit( shape: TLShape, currentPagePoint: Vec, currentPageId: TLPageId, results: Set<TLShapeId>, corners: Vec[] ) { if (shape.parentId === currentPageId) { results.add(shape.id) return } // Find the outermost selectable shape, check to see if it has a // page mask; and if so, check to see if the brush intersects it const selectedShape = this.editor.getOutermostSelectableShape(shape) const pageMask = this.editor.getShapeMask(selectedShape.id) if ( pageMask && !polygonsIntersect(pageMask, corners) && !pointInPolygon(currentPagePoint, pageMask) ) { return } results.add(selectedShape.id) } }