tldraw
Version:
A tiny little drawing editor.
509 lines (508 loc) • 17 kB
JavaScript
import { jsx } from "react/jsx-runtime";
import {
assert,
clamp,
exhaustiveSwitchError,
getPerfectDashProps,
Group2d,
invLerp,
lerp,
Polygon2d,
Polyline2d,
rng,
toDomPrecision,
Vec
} from "@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];
assert(lastLine, "Start an SVGPathBuilder with `.moveTo()`");
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", toDomPrecision(initial.x), toDomPrecision(initial.y));
for (const segment of segments) {
switch (segment.type) {
case "lineTo":
parts.push("L", toDomPrecision(segment.x), toDomPrecision(segment.y));
break;
case "arcTo":
parts.push(
"A",
segment.radius,
segment.radius,
0,
segment.largeArcFlag ? "1" : "0",
segment.sweepFlag ? "1" : "0",
toDomPrecision(segment.x),
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:
exhaustiveSwitchError(opts, "style");
}
}
toGeometry() {
const geometries = [];
for (const { initial, segments, closed } of this.lines) {
if (initial.opts?.geometry === false) continue;
const vertices = [new Vec(initial.x, initial.y)];
for (const segment of segments) {
switch (segment.type) {
case "lineTo": {
vertices.push(new 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 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 = Vec.Rot(info.startVector, t).mul(info.radius).add(info.center);
vertices.push(point);
}
break;
}
default:
exhaustiveSwitchError(segment, "type");
}
}
const geometry = closed ? new Polygon2d({ points: vertices, isFilled: false, ...initial.opts?.geometry }) : new Polyline2d({ points: vertices, ...initial.opts?.geometry });
geometries.push(geometry);
}
if (geometries.length === 0) return null;
if (geometries.length === 1) return geometries[0];
return new Group2d({ children: geometries });
}
toSolidSvg(opts) {
const { strokeWidth, props } = opts;
return /* @__PURE__ */ 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 } = 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__ */ jsx(
"line",
{
x1: toDomPrecision(lastPoint.x),
y1: toDomPrecision(lastPoint.y),
x2: toDomPrecision(segment.x),
y2: toDomPrecision(segment.y),
strokeDasharray,
strokeDashoffset,
markerStart: isFirst ? markerStart : void 0,
markerEnd: isLast ? markerEnd : void 0
},
i
)
);
break;
case "arcTo":
parts.push(
/* @__PURE__ */ jsx(
"path",
{
d: [
"M",
toDomPrecision(lastPoint.x),
toDomPrecision(lastPoint.y),
"A",
segment.radius,
segment.radius,
0,
segment.largeArcFlag ? "1" : "0",
segment.sweepFlag ? "1" : "0",
toDomPrecision(segment.x),
toDomPrecision(segment.y)
].join(" "),
strokeDasharray,
strokeDashoffset,
markerStart: isFirst ? markerStart : void 0,
markerEnd: isLast ? markerEnd : void 0
},
i
)
);
break;
default:
exhaustiveSwitchError(segment, "type");
}
lastPoint = segment;
}
if (closed && lastPoint !== initial) {
const dist = Vec.Dist(lastPoint, initial);
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(dist, strokeWidth, {
style,
snap,
lengthRatio,
start: "outset",
end: "none"
});
parts.push(
/* @__PURE__ */ jsx(
"line",
{
x1: toDomPrecision(lastPoint.x),
y1: toDomPrecision(lastPoint.y),
x2: toDomPrecision(initial.x),
y2: toDomPrecision(initial.y),
strokeDasharray,
strokeDashoffset,
markerEnd
},
"last"
)
);
}
}
return /* @__PURE__ */ 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 = Vec.Sub(previous, current).norm();
break;
}
case "arcTo": {
const info = getArcSegmentInfo(previous, current);
if (info === null || info === "straight-line") {
tangentBefore = Vec.Sub(current, previous).norm().per();
break;
}
tangentBefore = Vec.Per(info.endVector).mul(Math.sign(info.sweepAngle));
break;
}
default:
exhaustiveSwitchError(current, "type");
}
switch (next.type) {
case "lineTo":
case "moveTo": {
tangentAfter = Vec.Sub(next, current).norm();
break;
}
case "arcTo": {
const info = getArcSegmentInfo(current, next);
if (info === null || info === "straight-line") {
tangentAfter = Vec.Sub(next, current).norm().per();
break;
}
tangentAfter = Vec.Per(info.startVector).mul(Math.sign(info.sweepAngle));
break;
}
default:
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 = 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 = 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 ? Vec.Dist(segment, nextSegment) : Infinity;
const shortestDistance = Math.min(lastDistance, nextDistance) - (segment.opts?.roundness ?? defaultRoundness);
const offset = 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(Vec.Dist(initialPOffset, next) / 2, roundness);
const nudged = Vec.Nudge(initialPOffset, next, nudgeAmount);
parts.push("M", toDomPrecision(nudged.x), toDomPrecision(nudged.y));
} else {
parts.push("M", toDomPrecision(initialPOffset.x), 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", toDomPrecision(offsetP.x), toDomPrecision(offsetP.y));
break;
}
const clampedRoundness = lerp(
roundness,
0,
clamp(
invLerp(
Math.PI / 2,
Math.PI,
Math.abs(Vec.AngleBetween(tangentBefore, tangentAfter))
),
0,
1
)
);
const nudgeBeforeAmount = Math.min(
Vec.Dist(previousOffsetP, offsetP) / 2,
clampedRoundness
);
const nudgeBefore = Vec.Mul(tangentBefore, nudgeBeforeAmount).add(offsetP);
const nudgeAfterAmount = Math.min(
Vec.Dist(nextOffsetP, offsetP) / 2,
clampedRoundness
);
const nudgeAfter = Vec.Mul(tangentAfter, nudgeAfterAmount).add(offsetP);
parts.push(
"L",
toDomPrecision(nudgeBefore.x),
toDomPrecision(nudgeBefore.y),
"Q",
toDomPrecision(offsetP.x),
toDomPrecision(offsetP.y),
toDomPrecision(nudgeAfter.x),
toDomPrecision(nudgeAfter.y)
);
break;
}
case "arcTo":
parts.push(
"A",
segment.radius,
segment.radius,
0,
segment.largeArcFlag ? "1" : "0",
segment.sweepFlag ? "1" : "0",
toDomPrecision(offsetP.x),
toDomPrecision(offsetP.y)
);
break;
default:
exhaustiveSwitchError(segment, "type");
}
}
if (closed) {
parts.push("Z");
}
}
}
return /* @__PURE__ */ jsx("path", { strokeWidth, d: parts.join(" "), ...props });
}
segmentLength(lastPoint, segment) {
switch (segment.type) {
case "lineTo":
return Vec.Dist(lastPoint, segment);
case "arcTo": {
const info = getArcSegmentInfo(lastPoint, segment);
if (info === null) return 0;
if (info === "straight-line") return Vec.Dist(lastPoint, segment);
return info.length;
}
default:
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 = 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
};
}
export {
PathBuilder
};
//# sourceMappingURL=PathBuilder.mjs.map