UNPKG

tldraw

Version:

A tiny little drawing editor.

744 lines (600 loc) • 21.9 kB
import { Mat, StateNode, TLDefaultSizeStyle, TLDrawShape, TLDrawShapeSegment, TLHighlightShape, TLKeyboardEventInfo, TLPointerEventInfo, TLShapePartial, Vec, VecModel, b64Vecs, createShapeId, last, snapAngle, structuredClone, toFixed, uniqueId, } from '@tldraw/editor' import { HighlightShapeUtil } from '../../highlight/HighlightShapeUtil' import { STROKE_SIZES } from '../../shared/default-shape-constants' import { DrawShapeUtil } from '../DrawShapeUtil' type DrawableShape = TLDrawShape | TLHighlightShape export class Drawing extends StateNode { static override id = 'drawing' info = {} as TLPointerEventInfo initialShape?: DrawableShape override shapeType = this.parent.id === 'highlight' ? ('highlight' as const) : ('draw' as const) util = this.editor.getShapeUtil(this.shapeType) as DrawShapeUtil | HighlightShapeUtil isPen = false isPenOrStylus = false segmentMode = 'free' as 'free' | 'straight' | 'starting_straight' | 'starting_free' didJustShiftClickToExtendPreviousShapeLine = false pagePointWhereCurrentSegmentChanged = {} as Vec pagePointWhereNextSegmentChanged = null as Vec | null lastRecordedPoint = {} as Vec mergeNextPoint = false currentLineLength = 0 // Cache for current segment's points to avoid repeated b64 decode/encode currentSegmentPoints: Vec[] = [] markId = null as null | string override onEnter(info: TLPointerEventInfo) { this.markId = null this.info = info this.lastRecordedPoint = this.editor.inputs.getCurrentPagePoint().clone() this.startShape() } override onPointerMove() { const { inputs } = this.editor const isPen = inputs.getIsPen() if (this.isPen && !isPen) { // The user made a palm gesture before starting a pen gesture; // ideally we'd start the new shape here but we could also just bail // as the next interaction will work correctly if (this.markId) { this.editor.bailToMark(this.markId) this.startShape() return } } if (this.isPenOrStylus) { // Don't update the shape if we haven't moved far enough from the last time we recorded a point const currentPagePoint = inputs.getCurrentPagePoint() if (Vec.Dist(currentPagePoint, this.lastRecordedPoint) >= 1 / this.editor.getZoomLevel()) { this.lastRecordedPoint = currentPagePoint.clone() this.mergeNextPoint = false } else { this.mergeNextPoint = true } } else { this.mergeNextPoint = false } this.updateDrawingShape() } override onKeyDown(info: TLKeyboardEventInfo) { if (info.key === 'Shift') { switch (this.segmentMode) { case 'free': { // We've just entered straight mode, go to straight mode this.segmentMode = 'starting_straight' this.pagePointWhereNextSegmentChanged = this.editor.inputs.getCurrentPagePoint().clone() break } case 'starting_free': { this.segmentMode = 'starting_straight' } } } this.updateDrawingShape() } override onKeyUp(info: TLKeyboardEventInfo) { if (info.key === 'Shift') { this.editor.snaps.clearIndicators() switch (this.segmentMode) { case 'straight': { // We've just exited straight mode, go back to free mode this.segmentMode = 'starting_free' this.pagePointWhereNextSegmentChanged = this.editor.inputs.getCurrentPagePoint().clone() break } case 'starting_straight': { this.pagePointWhereNextSegmentChanged = null this.segmentMode = 'free' break } } } this.updateDrawingShape() } override onExit() { this.editor.snaps.clearIndicators() this.pagePointWhereCurrentSegmentChanged = this.editor.inputs.getCurrentPagePoint().clone() } canClose() { return this.shapeType !== 'highlight' } getIsClosed(segments: TLDrawShapeSegment[], size: TLDefaultSizeStyle, scale: number) { if (!this.canClose()) return false const strokeWidth = STROKE_SIZES[size] const firstPoint = b64Vecs.decodeFirstPoint(segments[0].path) const lastSegment = segments[segments.length - 1] const lastPoint = b64Vecs.decodeLastPoint(lastSegment.path) return ( firstPoint !== null && lastPoint !== null && firstPoint !== lastPoint && this.currentLineLength > strokeWidth * 4 * scale && Vec.DistMin(firstPoint, lastPoint, strokeWidth * 2 * scale) ) } private startShape() { const inputs = this.editor.inputs const originPagePoint = inputs.getOriginPagePoint() const isPen = inputs.getIsPen() this.markId = this.editor.markHistoryStoppingPoint('draw start') // If the pressure is weird, then it's probably a stylus reporting as a mouse // We treat pen/stylus inputs differently in the drawing tool, so we need to // have our own value for this. The inputs.isPen is only if the input is a regular // pen, like an iPad pen, which needs to trigger "pen mode" in order to avoid // accidental palm touches. We don't have to worry about that with styluses though. const { z = 0.5 } = this.info.point this.isPen = isPen // if z === 0 on the initial point, treat this pen as a mouse because it's likely a broken pen // or a broken OS. this.isPenOrStylus = (isPen && z !== 0) || (z > 0 && z < 0.5) || (z > 0.5 && z < 1) const pressure = this.isPenOrStylus ? z * 1.25 : 0.5 this.segmentMode = this.editor.inputs.getShiftKey() ? 'straight' : 'free' this.didJustShiftClickToExtendPreviousShapeLine = false this.lastRecordedPoint = originPagePoint.clone() if (this.initialShape) { const shape = this.editor.getShape<DrawableShape>(this.initialShape.id) if (shape && this.segmentMode === 'straight') { // Connect dots this.didJustShiftClickToExtendPreviousShapeLine = true const prevSegment = last(shape.props.segments) if (!prevSegment) throw Error('Expected a previous segment!') const prevPoint = b64Vecs.decodeLastPoint(prevSegment.path) if (!prevPoint) throw Error('Expected a previous point!') const { x, y } = this.editor.getPointInShapeSpace(shape, originPagePoint).toFixed() const newSegment: TLDrawShapeSegment = { type: this.segmentMode, path: b64Vecs.encodePoints([ { x: prevPoint.x, y: prevPoint.y, z: +pressure.toFixed(2) }, { x, y, z: +pressure.toFixed(2) }, ]), } // Convert prevPoint to page space const prevPointPageSpace = Mat.applyToPoint( this.editor.getShapePageTransform(shape.id)!, prevPoint ) this.pagePointWhereCurrentSegmentChanged = prevPointPageSpace this.pagePointWhereNextSegmentChanged = null const segments = [...shape.props.segments, newSegment] if (this.currentLineLength < STROKE_SIZES[shape.props.size] * 4) { this.currentLineLength = this.getLineLength(segments) } const shapePartial: TLShapePartial<DrawableShape> = { id: shape.id, type: this.shapeType, props: { segments, }, } if (this.canClose()) { ;(shapePartial as TLShapePartial<TLDrawShape>).props!.isClosed = this.getIsClosed( segments, shape.props.size, shape.props.scale ) } this.editor.updateShapes([shapePartial]) return } } // Create a new shape this.pagePointWhereCurrentSegmentChanged = originPagePoint.clone() const id = createShapeId() // Initialize the segment points cache const initialPoint = new Vec(0, 0, +pressure.toFixed(2)) this.currentSegmentPoints = [initialPoint] // Allow this to trigger the max shapes reached alert this.editor.createShape({ id, type: this.shapeType, x: originPagePoint.x, y: originPagePoint.y, props: { isPen: this.isPenOrStylus, scale: this.editor.getResizeScaleFactor(), segments: [ { type: this.segmentMode, path: b64Vecs.encodePoints([initialPoint]), }, ], }, }) const shape = this.editor.getShape<DrawableShape>(id) if (!shape) { this.cancel() return } this.currentLineLength = 0 this.initialShape = this.editor.getShape<DrawableShape>(id) } private updateDrawingShape() { const { initialShape } = this const { inputs } = this.editor if (!initialShape) return const { id, props: { size, scale }, } = initialShape const shape = this.editor.getShape<DrawableShape>(id)! if (!shape) return const { segments } = shape.props const currentPagePoint = inputs.getCurrentPagePoint() const { x, y, z } = this.editor.getPointInShapeSpace(shape, currentPagePoint).toFixed() const pressure = this.isPenOrStylus ? +(currentPagePoint.z! * 1.25).toFixed(2) : 0.5 const newPoint = { x, y, z: pressure } switch (this.segmentMode) { case 'starting_straight': { const { pagePointWhereNextSegmentChanged } = this if (pagePointWhereNextSegmentChanged === null) { throw Error('We should have a point where the segment changed') } const hasMovedFarEnough = Vec.Dist2(pagePointWhereNextSegmentChanged, inputs.getCurrentPagePoint()) > this.editor.options.dragDistanceSquared // Find the distance from where the pointer was when shift was released and // where it is now; if it's far enough away, then update the page point where // the current segment changed (to match the pagepoint where next segment changed) // and set the pagepoint where next segment changed to null. if (hasMovedFarEnough) { this.pagePointWhereCurrentSegmentChanged = this.pagePointWhereNextSegmentChanged!.clone() this.pagePointWhereNextSegmentChanged = null // Set the new mode this.segmentMode = 'straight' const prevSegment = last(segments) if (!prevSegment) throw Error('Expected a previous segment!') const prevLastPoint = b64Vecs.decodeLastPoint(prevSegment.path) if (!prevLastPoint) throw Error('Expected a previous last point!') let newSegment: TLDrawShapeSegment const newLastPoint = this.editor .getPointInShapeSpace(shape, this.pagePointWhereCurrentSegmentChanged) .toFixed() .toJson() if (prevSegment.type === 'straight') { this.currentLineLength += Vec.Dist(prevLastPoint, newLastPoint) newSegment = { type: 'straight', path: b64Vecs.encodePoints([prevLastPoint, newLastPoint]), } const transform = this.editor.getShapePageTransform(shape)! this.pagePointWhereCurrentSegmentChanged = Mat.applyToPoint(transform, prevLastPoint) } else { newSegment = { type: 'straight', path: b64Vecs.encodePoints([newLastPoint, newPoint]), } } const shapePartial: TLShapePartial<DrawableShape> = { id, type: this.shapeType, props: { segments: [...segments, newSegment], }, } if (this.canClose()) { ;(shapePartial as TLShapePartial<TLDrawShape>).props!.isClosed = this.getIsClosed( segments, size, scale ) } this.editor.updateShapes([shapePartial]) } break } case 'starting_free': { const { pagePointWhereNextSegmentChanged } = this if (pagePointWhereNextSegmentChanged === null) { throw Error('We should have a point where the segment changed') } const hasMovedFarEnough = Vec.Dist2(pagePointWhereNextSegmentChanged, inputs.getCurrentPagePoint()) > this.editor.options.dragDistanceSquared // Find the distance from where the pointer was when shift was released and // where it is now; if it's far enough away, then update the page point where // the current segment changed (to match the pagepoint where next segment changed) // and set the pagepoint where next segment changed to null. if (hasMovedFarEnough) { this.pagePointWhereCurrentSegmentChanged = this.pagePointWhereNextSegmentChanged!.clone() this.pagePointWhereNextSegmentChanged = null // Set the new mode this.segmentMode = 'free' const newSegments = segments.slice() const prevStraightSegment = newSegments[newSegments.length - 1] const prevPoint = b64Vecs.decodeLastPoint(prevStraightSegment.path) if (!prevPoint) { throw Error('No previous point!') } // Create the new free segment and interpolate the points between where the last line // ended and where the pointer is now const interpolatedPoints = Vec.PointsBetween(prevPoint, newPoint, 6).map( (p) => new Vec(toFixed(p.x), toFixed(p.y), toFixed(p.z)) ) // Initialize cache for the new free segment this.currentSegmentPoints = interpolatedPoints const newFreeSegment: TLDrawShapeSegment = { type: 'free', path: b64Vecs.encodePoints(interpolatedPoints), } const finalSegments = [...newSegments, newFreeSegment] if (this.currentLineLength < STROKE_SIZES[shape.props.size] * 4) { this.currentLineLength = this.getLineLength(finalSegments) } const shapePartial: TLShapePartial<DrawableShape> = { id, type: this.shapeType, props: { segments: finalSegments, }, } if (this.canClose()) { ;(shapePartial as TLShapePartial<TLDrawShape>).props!.isClosed = this.getIsClosed( finalSegments, size, scale ) } this.editor.updateShapes([shapePartial]) } break } case 'straight': { const newSegments = segments.slice() const newSegment = newSegments[newSegments.length - 1] const { pagePointWhereCurrentSegmentChanged } = this const inputs = this.editor.inputs const ctrlKey = inputs.getCtrlKey() const currentPagePoint = inputs.getCurrentPagePoint() if (!pagePointWhereCurrentSegmentChanged) throw Error('We should have a point where the segment changed') let pagePoint: VecModel let shouldSnapToAngle = false if (this.didJustShiftClickToExtendPreviousShapeLine) { if (this.editor.inputs.getIsDragging()) { // If we've just shift clicked to extend a line, only snap once we've started dragging shouldSnapToAngle = !ctrlKey this.didJustShiftClickToExtendPreviousShapeLine = false } else { // noop } } else { // If we're not shift clicking to extend a line, but we're holding shift, then we should snap shouldSnapToAngle = !ctrlKey // don't snap angle while snapping line } let newPoint = this.editor.getPointInShapeSpace(shape, currentPagePoint).toFixed().toJson() let didSnap = false let snapSegment: TLDrawShapeSegment | undefined = undefined const shouldSnap = this.editor.user.getIsSnapMode() ? !ctrlKey : ctrlKey if (shouldSnap) { if (newSegments.length > 2) { let nearestPoint: VecModel | undefined = undefined let minDistance = 8 / this.editor.getZoomLevel() // Don't try to snap to the last two segments for (let i = 0, n = segments.length - 2; i < n; i++) { const segment = segments[i] if (!segment) break if (segment.type === 'free') continue const first = b64Vecs.decodeFirstPoint(segment.path) const lastPoint = b64Vecs.decodeLastPoint(segment.path) if (!(first && lastPoint)) continue // Snap to the nearest point on the segment, if it's closer than the previous snapped point const nearestPointOnSegment = Vec.NearestPointOnLineSegment( first, lastPoint, newPoint ) if (Vec.DistMin(nearestPointOnSegment, newPoint, minDistance)) { nearestPoint = nearestPointOnSegment.toFixed().toJson() minDistance = Vec.Dist(nearestPointOnSegment, newPoint) snapSegment = segment break } } if (nearestPoint) { didSnap = true newPoint = nearestPoint } } } if (didSnap && snapSegment) { const transform = this.editor.getShapePageTransform(shape)! const first = b64Vecs.decodeFirstPoint(snapSegment.path) const lastPoint = b64Vecs.decodeLastPoint(snapSegment.path) if (!first || !lastPoint) throw Error('Expected a last point!') const A = Mat.applyToPoint(transform, first) const B = Mat.applyToPoint(transform, lastPoint) const snappedPoint = Mat.applyToPoint(transform, newPoint) this.editor.snaps.setIndicators([ { id: uniqueId(), type: 'points', points: [A, snappedPoint, B], }, ]) } else { this.editor.snaps.clearIndicators() if (shouldSnapToAngle) { // Snap line angle to nearest 15 degrees const currentAngle = Vec.Angle(pagePointWhereCurrentSegmentChanged, currentPagePoint) const snappedAngle = snapAngle(currentAngle, 24) const angleDiff = snappedAngle - currentAngle pagePoint = Vec.RotWith( currentPagePoint, pagePointWhereCurrentSegmentChanged, angleDiff ) } else { pagePoint = currentPagePoint.clone() } newPoint = this.editor.getPointInShapeSpace(shape, pagePoint).toFixed().toJson() } // If the previous segment is a one point free shape and is the first segment of the line, // then the user just did a click-and-immediately-press-shift to create a new straight line // without continuing the previous line. In this case, we want to remove the previous segment. this.currentLineLength += newSegments.length && b64Vecs.decodeFirstPoint(newSegment.path) ? Vec.Dist(b64Vecs.decodeFirstPoint(newSegment.path)!, Vec.From(newPoint)) : 0 newSegments[newSegments.length - 1] = { ...newSegment, type: 'straight', path: b64Vecs.encodePoints([ b64Vecs.decodeFirstPoint(newSegment.path)!, Vec.From(newPoint), ]), } const shapePartial: TLShapePartial<DrawableShape> = { id, type: this.shapeType, props: { segments: newSegments, }, } if (this.canClose()) { ;(shapePartial as TLShapePartial<TLDrawShape>).props!.isClosed = this.getIsClosed( segments, size, scale ) } this.editor.updateShapes([shapePartial]) break } case 'free': { // Use cached points instead of decoding from b64 on every update const cachedPoints = this.currentSegmentPoints if (cachedPoints.length && this.mergeNextPoint) { const lastPoint = cachedPoints[cachedPoints.length - 1] lastPoint.x = newPoint.x lastPoint.y = newPoint.y lastPoint.z = lastPoint.z ? Math.max(lastPoint.z, newPoint.z) : newPoint.z // Note: we could recompute the line length here, but it's not really necessary // this.currentLineLength = this.getLineLength(newSegments) } else { this.currentLineLength += cachedPoints.length ? Vec.Dist(cachedPoints[cachedPoints.length - 1], newPoint) : 0 cachedPoints.push(new Vec(newPoint.x, newPoint.y, newPoint.z)) } const newSegments = segments.slice() const newSegment = newSegments[newSegments.length - 1] newSegments[newSegments.length - 1] = { ...newSegment, path: b64Vecs.encodePoints(cachedPoints), } if (this.currentLineLength < STROKE_SIZES[shape.props.size] * 4) { this.currentLineLength = this.getLineLength(newSegments) } const shapePartial: TLShapePartial<DrawableShape> = { id, type: this.shapeType, props: { segments: newSegments, }, } if (this.canClose()) { ;(shapePartial as TLShapePartial<TLDrawShape>).props!.isClosed = this.getIsClosed( newSegments, size, scale ) } this.editor.updateShapes([shapePartial]) // Set a maximum length for the lines array; after 200 points, complete the line. if (cachedPoints.length > this.util.options.maxPointsPerShape) { this.editor.updateShapes([{ id, type: this.shapeType, props: { isComplete: true } }]) const newShapeId = createShapeId() const props = this.editor.getShape<DrawableShape>(id)!.props if (!this.editor.canCreateShapes([newShapeId])) return this.cancel() const currentPagePoint = inputs.getCurrentPagePoint() // Reset cache for the new shape's segment const initialPoint = new Vec(0, 0, this.isPenOrStylus ? +(z! * 1.25).toFixed() : 0.5) this.currentSegmentPoints = [initialPoint] this.editor.createShape({ id: newShapeId, type: this.shapeType, x: toFixed(currentPagePoint.x), y: toFixed(currentPagePoint.y), props: { isPen: this.isPenOrStylus, scale: props.scale, segments: [ { type: 'free', path: b64Vecs.encodePoints([initialPoint]), }, ], }, }) const shape = this.editor.getShape<DrawableShape>(newShapeId) if (!shape) { // This would only happen if the page is full and no more shapes can be created. The bug would manifest as a crash when we try to clone the shape. // todo: handle this type of thing better return this.cancel() } this.initialShape = structuredClone(shape) this.mergeNextPoint = false this.lastRecordedPoint = currentPagePoint.clone() this.currentLineLength = 0 } break } } } private getLineLength(segments: TLDrawShapeSegment[]) { let length = 0 for (let j = 0; j < segments.length; j++) { const points = b64Vecs.decodePoints(segments[j].path) for (let i = 0; i < points.length - 1; i++) { length += Vec.Dist2(points[i], points[i + 1]) } } return Math.sqrt(length) } override onPointerUp() { this.complete() } override onCancel() { this.cancel() } override onComplete() { this.complete() } override onInterrupt() { if (this.editor.inputs.getIsDragging()) { return } if (this.markId) { this.editor.bailToMark(this.markId) } this.cancel() } complete() { const { initialShape } = this if (!initialShape) return this.editor.updateShapes([ { id: initialShape.id, type: initialShape.type, props: { isComplete: true } }, ]) this.parent.transition('idle') } cancel() { this.parent.transition('idle', this.info) } }