UNPKG

tldraw

Version:

A tiny little drawing editor.

534 lines (533 loc) • 19.5 kB
import { Mat, StateNode, Vec, b64Vecs, createShapeId, last, snapAngle, structuredClone, toFixed, uniqueId } from "@tldraw/editor"; import { STROKE_SIZES } from "../../shared/default-shape-constants.mjs"; class Drawing extends StateNode { static id = "drawing"; info = {}; initialShape; shapeType = this.parent.id === "highlight" ? "highlight" : "draw"; util = this.editor.getShapeUtil(this.shapeType); isPen = false; isPenOrStylus = false; segmentMode = "free"; didJustShiftClickToExtendPreviousShapeLine = false; pagePointWhereCurrentSegmentChanged = {}; pagePointWhereNextSegmentChanged = null; lastRecordedPoint = {}; mergeNextPoint = false; currentLineLength = 0; // Cache for current segment's points to avoid repeated b64 decode/encode currentSegmentPoints = []; markId = null; onEnter(info) { this.markId = null; this.info = info; this.lastRecordedPoint = this.editor.inputs.getCurrentPagePoint().clone(); this.startShape(); } onPointerMove() { const { inputs } = this.editor; const isPen = inputs.getIsPen(); if (this.isPen && !isPen) { if (this.markId) { this.editor.bailToMark(this.markId); this.startShape(); return; } } if (this.isPenOrStylus) { 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(); } onKeyDown(info) { if (info.key === "Shift") { switch (this.segmentMode) { case "free": { this.segmentMode = "starting_straight"; this.pagePointWhereNextSegmentChanged = this.editor.inputs.getCurrentPagePoint().clone(); break; } case "starting_free": { this.segmentMode = "starting_straight"; } } } this.updateDrawingShape(); } onKeyUp(info) { if (info.key === "Shift") { this.editor.snaps.clearIndicators(); switch (this.segmentMode) { case "straight": { this.segmentMode = "starting_free"; this.pagePointWhereNextSegmentChanged = this.editor.inputs.getCurrentPagePoint().clone(); break; } case "starting_straight": { this.pagePointWhereNextSegmentChanged = null; this.segmentMode = "free"; break; } } } this.updateDrawingShape(); } onExit() { this.editor.snaps.clearIndicators(); this.pagePointWhereCurrentSegmentChanged = this.editor.inputs.getCurrentPagePoint().clone(); } canClose() { return this.shapeType !== "highlight"; } getIsClosed(segments, size, scale) { 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); } startShape() { const inputs = this.editor.inputs; const originPagePoint = inputs.getOriginPagePoint(); const isPen = inputs.getIsPen(); this.markId = this.editor.markHistoryStoppingPoint("draw start"); const { z = 0.5 } = this.info.point; this.isPen = isPen; 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 shape2 = this.editor.getShape(this.initialShape.id); if (shape2 && this.segmentMode === "straight") { this.didJustShiftClickToExtendPreviousShapeLine = true; const prevSegment = last(shape2.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(shape2, originPagePoint).toFixed(); const newSegment = { type: this.segmentMode, path: b64Vecs.encodePoints([ { x: prevPoint.x, y: prevPoint.y, z: +pressure.toFixed(2) }, { x, y, z: +pressure.toFixed(2) } ]) }; const prevPointPageSpace = Mat.applyToPoint( this.editor.getShapePageTransform(shape2.id), prevPoint ); this.pagePointWhereCurrentSegmentChanged = prevPointPageSpace; this.pagePointWhereNextSegmentChanged = null; const segments = [...shape2.props.segments, newSegment]; if (this.currentLineLength < STROKE_SIZES[shape2.props.size] * 4) { this.currentLineLength = this.getLineLength(segments); } const shapePartial = { id: shape2.id, type: this.shapeType, props: { segments } }; if (this.canClose()) { ; shapePartial.props.isClosed = this.getIsClosed( segments, shape2.props.size, shape2.props.scale ); } this.editor.updateShapes([shapePartial]); return; } } this.pagePointWhereCurrentSegmentChanged = originPagePoint.clone(); const id = createShapeId(); const initialPoint = new Vec(0, 0, +pressure.toFixed(2)); this.currentSegmentPoints = [initialPoint]; 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(id); if (!shape) { this.cancel(); return; } this.currentLineLength = 0; this.initialShape = this.editor.getShape(id); } updateDrawingShape() { const { initialShape } = this; const { inputs } = this.editor; if (!initialShape) return; const { id, props: { size, scale } } = initialShape; const shape = this.editor.getShape(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; if (hasMovedFarEnough) { this.pagePointWhereCurrentSegmentChanged = this.pagePointWhereNextSegmentChanged.clone(); this.pagePointWhereNextSegmentChanged = null; 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; 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 = { id, type: this.shapeType, props: { segments: [...segments, newSegment] } }; if (this.canClose()) { ; shapePartial.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; if (hasMovedFarEnough) { this.pagePointWhereCurrentSegmentChanged = this.pagePointWhereNextSegmentChanged.clone(); this.pagePointWhereNextSegmentChanged = null; 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!"); } const interpolatedPoints = Vec.PointsBetween(prevPoint, newPoint, 6).map( (p) => new Vec(toFixed(p.x), toFixed(p.y), toFixed(p.z)) ); this.currentSegmentPoints = interpolatedPoints; const newFreeSegment = { 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 = { id, type: this.shapeType, props: { segments: finalSegments } }; if (this.canClose()) { ; shapePartial.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 inputs2 = this.editor.inputs; const ctrlKey = inputs2.getCtrlKey(); const currentPagePoint2 = inputs2.getCurrentPagePoint(); if (!pagePointWhereCurrentSegmentChanged) throw Error("We should have a point where the segment changed"); let pagePoint; let shouldSnapToAngle = false; if (this.didJustShiftClickToExtendPreviousShapeLine) { if (this.editor.inputs.getIsDragging()) { shouldSnapToAngle = !ctrlKey; this.didJustShiftClickToExtendPreviousShapeLine = false; } else { } } else { shouldSnapToAngle = !ctrlKey; } let newPoint2 = this.editor.getPointInShapeSpace(shape, currentPagePoint2).toFixed().toJson(); let didSnap = false; let snapSegment = void 0; const shouldSnap = this.editor.user.getIsSnapMode() ? !ctrlKey : ctrlKey; if (shouldSnap) { if (newSegments.length > 2) { let nearestPoint = void 0; let minDistance = 8 / this.editor.getZoomLevel(); 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; const nearestPointOnSegment = Vec.NearestPointOnLineSegment( first, lastPoint, newPoint2 ); if (Vec.DistMin(nearestPointOnSegment, newPoint2, minDistance)) { nearestPoint = nearestPointOnSegment.toFixed().toJson(); minDistance = Vec.Dist(nearestPointOnSegment, newPoint2); snapSegment = segment; break; } } if (nearestPoint) { didSnap = true; newPoint2 = 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, newPoint2); this.editor.snaps.setIndicators([ { id: uniqueId(), type: "points", points: [A, snappedPoint, B] } ]); } else { this.editor.snaps.clearIndicators(); if (shouldSnapToAngle) { const currentAngle = Vec.Angle(pagePointWhereCurrentSegmentChanged, currentPagePoint2); const snappedAngle = snapAngle(currentAngle, 24); const angleDiff = snappedAngle - currentAngle; pagePoint = Vec.RotWith( currentPagePoint2, pagePointWhereCurrentSegmentChanged, angleDiff ); } else { pagePoint = currentPagePoint2.clone(); } newPoint2 = this.editor.getPointInShapeSpace(shape, pagePoint).toFixed().toJson(); } this.currentLineLength += newSegments.length && b64Vecs.decodeFirstPoint(newSegment.path) ? Vec.Dist(b64Vecs.decodeFirstPoint(newSegment.path), Vec.From(newPoint2)) : 0; newSegments[newSegments.length - 1] = { ...newSegment, type: "straight", path: b64Vecs.encodePoints([ b64Vecs.decodeFirstPoint(newSegment.path), Vec.From(newPoint2) ]) }; const shapePartial = { id, type: this.shapeType, props: { segments: newSegments } }; if (this.canClose()) { ; shapePartial.props.isClosed = this.getIsClosed( segments, size, scale ); } this.editor.updateShapes([shapePartial]); break; } case "free": { 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; } 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 = { id, type: this.shapeType, props: { segments: newSegments } }; if (this.canClose()) { ; shapePartial.props.isClosed = this.getIsClosed( newSegments, size, scale ); } this.editor.updateShapes([shapePartial]); 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(id).props; if (!this.editor.canCreateShapes([newShapeId])) return this.cancel(); const currentPagePoint2 = inputs.getCurrentPagePoint(); 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(currentPagePoint2.x), y: toFixed(currentPagePoint2.y), props: { isPen: this.isPenOrStylus, scale: props.scale, segments: [ { type: "free", path: b64Vecs.encodePoints([initialPoint]) } ] } }); const shape2 = this.editor.getShape(newShapeId); if (!shape2) { return this.cancel(); } this.initialShape = structuredClone(shape2); this.mergeNextPoint = false; this.lastRecordedPoint = currentPagePoint2.clone(); this.currentLineLength = 0; } break; } } } getLineLength(segments) { 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); } onPointerUp() { this.complete(); } onCancel() { this.cancel(); } onComplete() { this.complete(); } 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); } } export { Drawing }; //# sourceMappingURL=Drawing.mjs.map