tldraw
Version:
A tiny little drawing editor.
263 lines (216 loc) • 7.97 kB
text/typescript
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)
}
}