tldraw
Version:
A tiny little drawing editor.
516 lines (515 loc) • 19.2 kB
JavaScript
"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