UNPKG

tldraw

Version:

A tiny little drawing editor.

516 lines (515 loc) • 19.2 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); var PathBuilder_exports = {}; __export(PathBuilder_exports, { PathBuilder: () => PathBuilder }); module.exports = __toCommonJS(PathBuilder_exports); var import_jsx_runtime = require("react/jsx-runtime"); var import_editor = require("@tldraw/editor"); function getVerticesCountForLength(length, spacing = 20) { return Math.max(8, Math.ceil(length / spacing)); } class PathBuilder { static throughPoints(points, opts) { const path = new PathBuilder(); path.moveTo(points[0].x, points[0].y, opts); for (let i = 1; i < points.length; i++) { path.lineTo(points[i].x, points[i].y); } return path; } constructor() { } lines = []; currentLine() { const lastLine = this.lines[this.lines.length - 1]; (0, import_editor.assert)(lastLine, "Start an SVGPathBuilder with `.moveTo()`"); (0, import_editor.assert)(!lastLine.closed, "Cannot work on a closed line"); return lastLine; } moveTo(x, y, opts) { this.lines.push({ initial: { type: "moveTo", x, y, opts }, segments: [], closed: false }); return this; } lineTo(x, y, opts) { this.currentLine().segments.push({ type: "lineTo", x, y, opts }); return this; } arcTo(radius, largeArcFlag, sweepFlag, x, y, opts) { this.currentLine().segments.push({ type: "arcTo", radius, largeArcFlag, sweepFlag, x, y, opts }); return this; } close() { this.currentLine().closed = true; return this; } toD(opts = {}) { const closedOnly = opts.closedOnly ?? false; const parts = []; for (const { initial, segments, closed } of this.lines) { if (closedOnly && !closed) continue; parts.push("M", (0, import_editor.toDomPrecision)(initial.x), (0, import_editor.toDomPrecision)(initial.y)); for (const segment of segments) { switch (segment.type) { case "lineTo": parts.push("L", (0, import_editor.toDomPrecision)(segment.x), (0, import_editor.toDomPrecision)(segment.y)); break; case "arcTo": parts.push( "A", segment.radius, segment.radius, 0, segment.largeArcFlag ? "1" : "0", segment.sweepFlag ? "1" : "0", (0, import_editor.toDomPrecision)(segment.x), (0, import_editor.toDomPrecision)(segment.y) ); break; } } if (closed) { parts.push("Z"); } } return parts.join(" "); } toSvg(opts) { if (opts.forceSolid) { return this.toSolidSvg(opts); } switch (opts.style) { case "solid": return this.toSolidSvg(opts); case "dashed": case "dotted": return this.toDashedSvg(opts); case "draw": return this.toDrawSvg(opts); default: (0, import_editor.exhaustiveSwitchError)(opts, "style"); } } toGeometry() { const geometries = []; for (const { initial, segments, closed } of this.lines) { if (initial.opts?.geometry === false) continue; const vertices = [new import_editor.Vec(initial.x, initial.y)]; for (const segment of segments) { switch (segment.type) { case "lineTo": { vertices.push(new import_editor.Vec(segment.x, segment.y)); break; } case "arcTo": { const info = getArcSegmentInfo(vertices[vertices.length - 1], segment); if (info === null) break; if (info === "straight-line") { vertices.push(new import_editor.Vec(segment.x, segment.y)); break; } const verticesCount = getVerticesCountForLength(info.length); for (let i = 0; i < verticesCount + 1; i++) { const t = i / verticesCount * info.sweepAngle; const point = import_editor.Vec.Rot(info.startVector, t).mul(info.radius).add(info.center); vertices.push(point); } break; } default: (0, import_editor.exhaustiveSwitchError)(segment, "type"); } } const geometry = closed ? new import_editor.Polygon2d({ points: vertices, isFilled: false, ...initial.opts?.geometry }) : new import_editor.Polyline2d({ points: vertices, ...initial.opts?.geometry }); geometries.push(geometry); } if (geometries.length === 0) return null; if (geometries.length === 1) return geometries[0]; return new import_editor.Group2d({ children: geometries }); } toSolidSvg(opts) { const { strokeWidth, props } = opts; return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { strokeWidth, d: this.toD(), ...props }); } toDashedSvg(opts) { const { style, strokeWidth, snap, end, start, lengthRatio, props: { markerStart, markerEnd, ...props } = {} } = opts; const parts = []; for (const { initial, segments, closed } of this.lines) { let lastPoint = initial; for (let i = 0; i < segments.length; i++) { const segment = segments[i]; const isFirst = i === 0; const isLast = i === segments.length - 1 && !closed; const segmentLength = this.segmentLength(lastPoint, segment); const { strokeDasharray, strokeDashoffset } = (0, import_editor.getPerfectDashProps)( segmentLength, strokeWidth, { style, snap, lengthRatio, start: isFirst ? closed ? "none" : start : "outset", end: isLast ? closed ? "none" : end : "outset" } ); switch (segment.type) { case "lineTo": parts.push( /* @__PURE__ */ (0, import_jsx_runtime.jsx)( "line", { x1: (0, import_editor.toDomPrecision)(lastPoint.x), y1: (0, import_editor.toDomPrecision)(lastPoint.y), x2: (0, import_editor.toDomPrecision)(segment.x), y2: (0, import_editor.toDomPrecision)(segment.y), strokeDasharray, strokeDashoffset, markerStart: isFirst ? markerStart : void 0, markerEnd: isLast ? markerEnd : void 0 }, i ) ); break; case "arcTo": parts.push( /* @__PURE__ */ (0, import_jsx_runtime.jsx)( "path", { d: [ "M", (0, import_editor.toDomPrecision)(lastPoint.x), (0, import_editor.toDomPrecision)(lastPoint.y), "A", segment.radius, segment.radius, 0, segment.largeArcFlag ? "1" : "0", segment.sweepFlag ? "1" : "0", (0, import_editor.toDomPrecision)(segment.x), (0, import_editor.toDomPrecision)(segment.y) ].join(" "), strokeDasharray, strokeDashoffset, markerStart: isFirst ? markerStart : void 0, markerEnd: isLast ? markerEnd : void 0 }, i ) ); break; default: (0, import_editor.exhaustiveSwitchError)(segment, "type"); } lastPoint = segment; } if (closed && lastPoint !== initial) { const dist = import_editor.Vec.Dist(lastPoint, initial); const { strokeDasharray, strokeDashoffset } = (0, import_editor.getPerfectDashProps)(dist, strokeWidth, { style, snap, lengthRatio, start: "outset", end: "none" }); parts.push( /* @__PURE__ */ (0, import_jsx_runtime.jsx)( "line", { x1: (0, import_editor.toDomPrecision)(lastPoint.x), y1: (0, import_editor.toDomPrecision)(lastPoint.y), x2: (0, import_editor.toDomPrecision)(initial.x), y2: (0, import_editor.toDomPrecision)(initial.y), strokeDasharray, strokeDashoffset, markerEnd }, "last" ) ); } } return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("g", { strokeWidth, ...props, children: parts }); } toDrawSvg(opts) { const { strokeWidth, randomSeed, offset: defaultOffset = strokeWidth / 3, roundness: defaultRoundness = strokeWidth * 2, passes = 2, props } = opts; const parts = []; const tangents = this.lines.map(({ initial, segments, closed }) => { const tangents2 = []; const segmentCount = closed ? segments.length + 1 : segments.length; for (let i = 0; i < segmentCount; i++) { let previous = segments[i - 1]; let current = segments[i]; let next = segments[i + 1]; if (!previous) previous = initial; if (!current) { current = initial; next = segments[0]; } if (!next) { next = initial; } let tangentBefore, tangentAfter; switch (current.type) { case "lineTo": case "moveTo": { tangentBefore = import_editor.Vec.Sub(previous, current).norm(); break; } case "arcTo": { const info = getArcSegmentInfo(previous, current); if (info === null || info === "straight-line") { tangentBefore = import_editor.Vec.Sub(current, previous).norm().per(); break; } tangentBefore = import_editor.Vec.Per(info.endVector).mul(Math.sign(info.sweepAngle)); break; } default: (0, import_editor.exhaustiveSwitchError)(current, "type"); } switch (next.type) { case "lineTo": case "moveTo": { tangentAfter = import_editor.Vec.Sub(next, current).norm(); break; } case "arcTo": { const info = getArcSegmentInfo(current, next); if (info === null || info === "straight-line") { tangentAfter = import_editor.Vec.Sub(next, current).norm().per(); break; } tangentAfter = import_editor.Vec.Per(info.startVector).mul(Math.sign(info.sweepAngle)); break; } default: (0, import_editor.exhaustiveSwitchError)(next, "type"); } tangents2.push({ tangentBefore, tangentAfter }); } return tangents2; }); for (let pass = 0; pass < passes; pass++) { for (let lineIdx = 0; lineIdx < this.lines.length; lineIdx++) { const { initial, segments, closed } = this.lines[lineIdx]; const random = (0, import_editor.rng)(randomSeed + pass + lineIdx); const initialOffset = initial.opts?.offset ?? defaultOffset; const initialPOffset = { x: initial.x + random() * initialOffset, y: initial.y + random() * initialOffset }; const offsetPoints = []; let lastDistance = import_editor.Vec.Dist(initialPOffset, segments[0]); for (let i = 0; i < segments.length; i++) { const segment = segments[i]; const nextSegment = i === segments.length - 1 ? closed ? segments[0] : null : segments[i + 1]; const nextDistance = nextSegment ? import_editor.Vec.Dist(segment, nextSegment) : Infinity; const shortestDistance = Math.min(lastDistance, nextDistance) - (segment.opts?.roundness ?? defaultRoundness); const offset = (0, import_editor.clamp)(segment.opts?.offset ?? defaultOffset, 0, shortestDistance / 10); const offsetPoint = { x: segment.x + random() * offset, y: segment.y + random() * offset }; offsetPoints.push(offsetPoint); lastDistance = nextDistance; } if (closed) { const roundness = initial.opts?.roundness ?? defaultRoundness; offsetPoints.push(initialPOffset); const next = offsetPoints[0]; const nudgeAmount = Math.min(import_editor.Vec.Dist(initialPOffset, next) / 2, roundness); const nudged = import_editor.Vec.Nudge(initialPOffset, next, nudgeAmount); parts.push("M", (0, import_editor.toDomPrecision)(nudged.x), (0, import_editor.toDomPrecision)(nudged.y)); } else { parts.push("M", (0, import_editor.toDomPrecision)(initialPOffset.x), (0, import_editor.toDomPrecision)(initialPOffset.y)); } const segmentCount = closed ? segments.length + 1 : segments.length; for (let i = 0; i < segmentCount; i++) { const segment = i === segments.length ? initial : segments[i]; const roundness = segment.opts?.roundness ?? defaultRoundness; const offsetP = offsetPoints[i]; const { tangentBefore, tangentAfter } = tangents[lineIdx][i]; const previousOffsetP = i === 0 ? initialPOffset : offsetPoints[i - 1]; const nextOffsetP = i === segments.length - 1 && !closed ? null : offsetPoints[(i + 1) % offsetPoints.length]; switch (segment.type) { case "lineTo": case "moveTo": { if (!nextOffsetP || roundness === 0) { parts.push("L", (0, import_editor.toDomPrecision)(offsetP.x), (0, import_editor.toDomPrecision)(offsetP.y)); break; } const clampedRoundness = (0, import_editor.lerp)( roundness, 0, (0, import_editor.clamp)( (0, import_editor.invLerp)( Math.PI / 2, Math.PI, Math.abs(import_editor.Vec.AngleBetween(tangentBefore, tangentAfter)) ), 0, 1 ) ); const nudgeBeforeAmount = Math.min( import_editor.Vec.Dist(previousOffsetP, offsetP) / 2, clampedRoundness ); const nudgeBefore = import_editor.Vec.Mul(tangentBefore, nudgeBeforeAmount).add(offsetP); const nudgeAfterAmount = Math.min( import_editor.Vec.Dist(nextOffsetP, offsetP) / 2, clampedRoundness ); const nudgeAfter = import_editor.Vec.Mul(tangentAfter, nudgeAfterAmount).add(offsetP); parts.push( "L", (0, import_editor.toDomPrecision)(nudgeBefore.x), (0, import_editor.toDomPrecision)(nudgeBefore.y), "Q", (0, import_editor.toDomPrecision)(offsetP.x), (0, import_editor.toDomPrecision)(offsetP.y), (0, import_editor.toDomPrecision)(nudgeAfter.x), (0, import_editor.toDomPrecision)(nudgeAfter.y) ); break; } case "arcTo": parts.push( "A", segment.radius, segment.radius, 0, segment.largeArcFlag ? "1" : "0", segment.sweepFlag ? "1" : "0", (0, import_editor.toDomPrecision)(offsetP.x), (0, import_editor.toDomPrecision)(offsetP.y) ); break; default: (0, import_editor.exhaustiveSwitchError)(segment, "type"); } } if (closed) { parts.push("Z"); } } } return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { strokeWidth, d: parts.join(" "), ...props }); } segmentLength(lastPoint, segment) { switch (segment.type) { case "lineTo": return import_editor.Vec.Dist(lastPoint, segment); case "arcTo": { const info = getArcSegmentInfo(lastPoint, segment); if (info === null) return 0; if (info === "straight-line") return import_editor.Vec.Dist(lastPoint, segment); return info.length; } default: (0, import_editor.exhaustiveSwitchError)(segment, "type"); } } } /*! * Adapted from https://github.com/rveciana/svg-path-properties * MIT License: https://github.com/rveciana/svg-path-properties/blob/master/LICENSE * https://github.com/rveciana/svg-path-properties/blob/74d850d14998274f6eae279424bdc2194f156490/src/arc.ts#L121 */ function getArcSegmentInfo(lastPoint, { radius, largeArcFlag, sweepFlag, x, y }) { radius = Math.abs(radius); if (lastPoint.x === x && lastPoint.y === y) { return null; } if (radius === 0) { return "straight-line"; } const dx = (lastPoint.x - x) / 2; const dy = (lastPoint.y - y) / 2; const radiiCheck = Math.pow(dx, 2) / Math.pow(radius, 2) + Math.pow(dy, 2) / Math.pow(radius, 2); if (radiiCheck > 1) { radius = Math.sqrt(radiiCheck) * radius; } const cSquareNumerator = Math.pow(radius, 2) * Math.pow(radius, 2) - Math.pow(radius, 2) * Math.pow(dy, 2) - Math.pow(radius, 2) * Math.pow(dx, 2); const cSquareRootDenom = Math.pow(radius, 2) * Math.pow(dy, 2) + Math.pow(radius, 2) * Math.pow(dx, 2); let cRadicand = cSquareNumerator / cSquareRootDenom; cRadicand = cRadicand < 0 ? 0 : cRadicand; const cCoef = (largeArcFlag !== sweepFlag ? 1 : -1) * Math.sqrt(cRadicand); const transformedCenter = { x: cCoef * (radius * dy / radius), y: cCoef * (-(radius * dx) / radius) }; const center = { x: transformedCenter.x + (lastPoint.x + x) / 2, y: transformedCenter.y + (lastPoint.y + y) / 2 }; const startVector = { x: (dx - transformedCenter.x) / radius, y: (dy - transformedCenter.y) / radius }; const endVector = { x: (-dx - transformedCenter.x) / radius, y: (-dy - transformedCenter.y) / radius }; let sweepAngle = import_editor.Vec.AngleBetween(startVector, endVector); if (!sweepFlag && sweepAngle > 0) { sweepAngle -= 2 * Math.PI; } else if (sweepFlag && sweepAngle < 0) { sweepAngle += 2 * Math.PI; } sweepAngle %= 2 * Math.PI; return { length: Math.abs(sweepAngle * radius), radius, sweepAngle, startVector, endVector, center }; } //# sourceMappingURL=PathBuilder.js.map