@pixerate/editor 0.32.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/dist/{bezier-DJPYiVbA.d.ts → bezier-BvqVmWMg.d.ts} +1 -1
- package/dist/{bezier-6KPx3w7Z.d.cts → bezier-D8bsO-L0.d.cts} +1 -1
- package/dist/canvas/index.cjs +93 -91
- package/dist/canvas/index.cjs.map +1 -1
- package/dist/canvas/index.d.cts +51 -3
- package/dist/canvas/index.d.ts +51 -3
- package/dist/canvas/index.js +3 -1
- package/dist/canvas/index.js.map +1 -1
- package/dist/{chunk-GQTXV2EQ.js → chunk-RK6HUKDI.js} +20 -1
- package/dist/{chunk-GQTXV2EQ.js.map → chunk-RK6HUKDI.js.map} +1 -1
- package/dist/{chunk-M4XE4UMT.cjs → chunk-UG5DVXJK.cjs} +21 -2
- package/dist/chunk-UG5DVXJK.cjs.map +1 -0
- package/dist/vector/index.cjs +1339 -205
- package/dist/vector/index.cjs.map +1 -1
- package/dist/vector/index.d.cts +408 -27
- package/dist/vector/index.d.ts +408 -27
- package/dist/vector/index.js +1256 -122
- package/dist/vector/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-M4XE4UMT.cjs.map +0 -1
package/dist/vector/index.js
CHANGED
|
@@ -10,12 +10,14 @@ import {
|
|
|
10
10
|
gradientToSvgDef,
|
|
11
11
|
pathBoolean,
|
|
12
12
|
pointInBezierPath,
|
|
13
|
+
segmentDerivative,
|
|
14
|
+
segmentIntersections,
|
|
13
15
|
segmentNearest,
|
|
14
16
|
segmentPoint,
|
|
15
17
|
splitSegment,
|
|
16
18
|
toBezierPath,
|
|
17
19
|
transformBezierPath
|
|
18
|
-
} from "../chunk-
|
|
20
|
+
} from "../chunk-RK6HUKDI.js";
|
|
19
21
|
|
|
20
22
|
// src/vector/types.ts
|
|
21
23
|
var VECTOR_TOOLS = ["select", "direct", "pen", "pencil", "rect", "ellipse", "polygon", "star", "line"];
|
|
@@ -189,9 +191,20 @@ function collectPathNodes(node) {
|
|
|
189
191
|
});
|
|
190
192
|
return out;
|
|
191
193
|
}
|
|
192
|
-
function
|
|
193
|
-
|
|
194
|
-
|
|
194
|
+
function nodeMatrix(node) {
|
|
195
|
+
return node.transform ?? IDENTITY_MATRIX;
|
|
196
|
+
}
|
|
197
|
+
function vectorPathWorldBezier(node, parent = IDENTITY_MATRIX) {
|
|
198
|
+
const m = multiplyMatrices(nodeMatrix(node), parent);
|
|
199
|
+
const path = subpathsToBezier(node.subpaths);
|
|
200
|
+
return isIdentityMatrix(m) ? path : transformBezierPath(path, m);
|
|
201
|
+
}
|
|
202
|
+
function vectorNodeBounds(node, parent = IDENTITY_MATRIX) {
|
|
203
|
+
if (node.type === "group") {
|
|
204
|
+
const m = multiplyMatrices(nodeMatrix(node), parent);
|
|
205
|
+
return unionBounds(node.children.map((c) => vectorNodeBounds(c, m)));
|
|
206
|
+
}
|
|
207
|
+
const b = bezierPathBounds(vectorPathWorldBezier(node, parent));
|
|
195
208
|
if (!b) return null;
|
|
196
209
|
return { x: b.minX, y: b.minY, width: b.maxX - b.minX, height: b.maxY - b.minY };
|
|
197
210
|
}
|
|
@@ -216,10 +229,13 @@ function boundsIntersect(a, b) {
|
|
|
216
229
|
function boundsFromPoints(a, b) {
|
|
217
230
|
return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), width: Math.abs(b.x - a.x), height: Math.abs(b.y - a.y) };
|
|
218
231
|
}
|
|
219
|
-
function hitTestVectorNode(node, p, tolerance = 0) {
|
|
232
|
+
function hitTestVectorNode(node, p, tolerance = 0, parent = IDENTITY_MATRIX) {
|
|
220
233
|
if (node.visible === false) return false;
|
|
221
|
-
if (node.type === "group")
|
|
222
|
-
|
|
234
|
+
if (node.type === "group") {
|
|
235
|
+
const m = multiplyMatrices(nodeMatrix(node), parent);
|
|
236
|
+
return node.children.some((c) => hitTestVectorNode(c, p, tolerance, m));
|
|
237
|
+
}
|
|
238
|
+
const path = vectorPathWorldBezier(node, parent);
|
|
223
239
|
if (path.length === 0) return false;
|
|
224
240
|
const point = [p.x, p.y];
|
|
225
241
|
if (hasPaint(node.fill) && pointInBezierPath(path, point, node.fillRule ?? "nonzero")) return true;
|
|
@@ -268,7 +284,38 @@ function rotateMatrix(degrees, origin = { x: 0, y: 0 }) {
|
|
|
268
284
|
function applyMatrix(m, p) {
|
|
269
285
|
return { x: m[0] * p.x + m[2] * p.y + m[4], y: m[1] * p.x + m[3] * p.y + m[5] };
|
|
270
286
|
}
|
|
271
|
-
function
|
|
287
|
+
function isIdentityMatrix(m, eps = 1e-12) {
|
|
288
|
+
return Math.abs(m[0] - 1) < eps && Math.abs(m[1]) < eps && Math.abs(m[2]) < eps && Math.abs(m[3] - 1) < eps && Math.abs(m[4]) < eps && Math.abs(m[5]) < eps;
|
|
289
|
+
}
|
|
290
|
+
function invertMatrix(m) {
|
|
291
|
+
const [a, b, c, d, e, f] = m;
|
|
292
|
+
const det = a * d - b * c;
|
|
293
|
+
if (!det || !Number.isFinite(det)) return IDENTITY_MATRIX;
|
|
294
|
+
return [d / det, -b / det, -c / det, a / det, (c * f - d * e) / det, (b * e - a * f) / det];
|
|
295
|
+
}
|
|
296
|
+
function applyMatrixLinear(m, v) {
|
|
297
|
+
return { x: m[0] * v.x + m[2] * v.y, y: m[1] * v.x + m[3] * v.y };
|
|
298
|
+
}
|
|
299
|
+
function matrixRotation(m) {
|
|
300
|
+
const [a, b, c, d] = m;
|
|
301
|
+
const mirrored = a * d - b * c < 0;
|
|
302
|
+
const deg = (mirrored ? Math.atan2(-b - c, d - a) : Math.atan2(b - c, a + d)) * 180 / Math.PI;
|
|
303
|
+
return Math.abs(deg) < 1e-9 ? 0 : deg;
|
|
304
|
+
}
|
|
305
|
+
function decomposeRigid(m) {
|
|
306
|
+
const [a, b, c, d, e, f] = m;
|
|
307
|
+
const deg = matrixRotation(m);
|
|
308
|
+
if (deg === 0) return { rigid: IDENTITY_MATRIX, residual: m };
|
|
309
|
+
const r = deg * Math.PI / 180;
|
|
310
|
+
const cos = Math.cos(r);
|
|
311
|
+
const sin = Math.sin(r);
|
|
312
|
+
return {
|
|
313
|
+
rigid: [cos, sin, -sin, cos, e, f],
|
|
314
|
+
residual: [cos * a + sin * b, -sin * a + cos * b, cos * c + sin * d, -sin * c + cos * d, 0, 0]
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function bakeIntoGeometry(node, m) {
|
|
318
|
+
if (isIdentityMatrix(m)) return node;
|
|
272
319
|
if (node.type === "group") return { ...node, children: node.children.map((c) => transformVectorNode(c, m)) };
|
|
273
320
|
const path = node;
|
|
274
321
|
return {
|
|
@@ -286,6 +333,36 @@ function transformVectorNode(node, m) {
|
|
|
286
333
|
}))
|
|
287
334
|
};
|
|
288
335
|
}
|
|
336
|
+
function transformVectorNode(node, m) {
|
|
337
|
+
const total = multiplyMatrices(nodeMatrix(node), m);
|
|
338
|
+
const { rigid, residual } = decomposeRigid(total);
|
|
339
|
+
const { transform: _old, ...rest } = node;
|
|
340
|
+
const baked = bakeIntoGeometry(rest, residual);
|
|
341
|
+
return isIdentityMatrix(rigid) ? baked : { ...baked, transform: rigid };
|
|
342
|
+
}
|
|
343
|
+
function vectorNodeRotation(node, parent = IDENTITY_MATRIX) {
|
|
344
|
+
return matrixRotation(multiplyMatrices(nodeMatrix(node), parent));
|
|
345
|
+
}
|
|
346
|
+
function vectorWorldMatrix(nodes, id) {
|
|
347
|
+
const search = (list, parent) => {
|
|
348
|
+
for (const n of list) {
|
|
349
|
+
const m = multiplyMatrices(nodeMatrix(n), parent);
|
|
350
|
+
if (n.id === id) return m;
|
|
351
|
+
if (n.type === "group") {
|
|
352
|
+
const found = search(n.children, m);
|
|
353
|
+
if (found) return found;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
};
|
|
358
|
+
return search(nodes, IDENTITY_MATRIX);
|
|
359
|
+
}
|
|
360
|
+
function vectorParentMatrix(nodes, id) {
|
|
361
|
+
const node = findVectorNode(nodes, id);
|
|
362
|
+
const world = vectorWorldMatrix(nodes, id);
|
|
363
|
+
if (!node || !world) return null;
|
|
364
|
+
return multiplyMatrices(invertMatrix(nodeMatrix(node)), world);
|
|
365
|
+
}
|
|
289
366
|
function translateAnchor(a, dx, dy) {
|
|
290
367
|
const next = cloneAnchor(a);
|
|
291
368
|
next.x += dx;
|
|
@@ -478,6 +555,7 @@ function vectorDocumentToSvg(doc, options = {}) {
|
|
|
478
555
|
const indent = (depth) => pretty ? " ".repeat(depth) : "";
|
|
479
556
|
const common = (node) => {
|
|
480
557
|
let out2 = ` id="${escapeAttr(node.id)}"`;
|
|
558
|
+
if (node.transform) out2 += ` transform="matrix(${node.transform.map((n) => formatNumber(n, 6)).join(" ")})"`;
|
|
481
559
|
if (node.opacity !== void 0 && node.opacity < 1) out2 += ` opacity="${num(node.opacity)}"`;
|
|
482
560
|
if (node.visible === false) out2 += ` display="none"`;
|
|
483
561
|
return out2;
|
|
@@ -774,7 +852,759 @@ function parseVectorSvg(svg, options = {}) {
|
|
|
774
852
|
return { width, height, nodes };
|
|
775
853
|
}
|
|
776
854
|
|
|
855
|
+
// src/vector/snap.ts
|
|
856
|
+
var VECTOR_CONSTRUCTION_ANGLES = [0, 45, 90, 135];
|
|
857
|
+
function constrainToLeave(leave, p) {
|
|
858
|
+
if (!leave?.constrain) return p;
|
|
859
|
+
const dx = p.x - leave.at.x;
|
|
860
|
+
const dy = p.y - leave.at.y;
|
|
861
|
+
switch (leave.constrain) {
|
|
862
|
+
case "axis":
|
|
863
|
+
return Math.abs(dx) > Math.abs(dy) ? { x: p.x, y: leave.at.y } : { x: leave.at.x, y: p.y };
|
|
864
|
+
case "square": {
|
|
865
|
+
const s = Math.max(Math.abs(dx), Math.abs(dy));
|
|
866
|
+
return { x: leave.at.x + Math.sign(dx || 1) * s, y: leave.at.y + Math.sign(dy || 1) * s };
|
|
867
|
+
}
|
|
868
|
+
default: {
|
|
869
|
+
const len = Math.hypot(dx, dy);
|
|
870
|
+
const a = Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) * (Math.PI / 4);
|
|
871
|
+
return { x: leave.at.x + Math.cos(a) * len, y: leave.at.y + Math.sin(a) * len };
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
var BUDGET = 2e4;
|
|
876
|
+
var CROSSING_PAIRS = 32;
|
|
877
|
+
var CURVE_SAMPLES = 48;
|
|
878
|
+
var BISECT = 50;
|
|
879
|
+
var EQUAL = 1e-6;
|
|
880
|
+
function bound(sorted, key, v, strict) {
|
|
881
|
+
let lo = 0;
|
|
882
|
+
let hi = sorted.length;
|
|
883
|
+
while (lo < hi) {
|
|
884
|
+
const mid = lo + hi >>> 1;
|
|
885
|
+
const k = key(sorted[mid]);
|
|
886
|
+
if (strict ? k <= v : k < v) lo = mid + 1;
|
|
887
|
+
else hi = mid;
|
|
888
|
+
}
|
|
889
|
+
return lo;
|
|
890
|
+
}
|
|
891
|
+
function within(sorted, key, v, tol) {
|
|
892
|
+
return sorted.slice(bound(sorted, key, v - tol, false), bound(sorted, key, v + tol, true));
|
|
893
|
+
}
|
|
894
|
+
var linesNear = (lines, v, tol) => within(lines, (l) => l.v, v, tol);
|
|
895
|
+
var distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
896
|
+
var toPoint = (p) => [p.x, p.y];
|
|
897
|
+
var toVec = (p) => ({ x: p[0], y: p[1] });
|
|
898
|
+
var LABELS = {
|
|
899
|
+
anchor: "anchor",
|
|
900
|
+
centre: "centre",
|
|
901
|
+
intersect: "intersect",
|
|
902
|
+
perpendicular: "perpendicular",
|
|
903
|
+
tangent: "tangent",
|
|
904
|
+
path: "path",
|
|
905
|
+
construction: "angle",
|
|
906
|
+
align: "align",
|
|
907
|
+
spacing: "spacing",
|
|
908
|
+
size: "size"
|
|
909
|
+
};
|
|
910
|
+
var fmtLength = (n) => String(Math.round(n * 100) / 100);
|
|
911
|
+
function nodeFrame(node, parent, aabb) {
|
|
912
|
+
const angle = matrixRotation(multiplyMatrices(nodeMatrix(node), parent));
|
|
913
|
+
const b = angle ? vectorNodeBounds(node, multiplyMatrices(parent, rotateMatrix(-angle))) : aabb !== void 0 ? aabb : vectorNodeBounds(node, parent);
|
|
914
|
+
if (!b) return null;
|
|
915
|
+
const c = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
|
|
916
|
+
return { width: b.width, height: b.height, centre: angle ? applyMatrix(rotateMatrix(angle), c) : c };
|
|
917
|
+
}
|
|
918
|
+
function contains(node, ids) {
|
|
919
|
+
if (ids.has(node.id)) return true;
|
|
920
|
+
return node.type === "group" && node.children.some((c) => contains(c, ids));
|
|
921
|
+
}
|
|
922
|
+
var VectorSnapTargets = class _VectorSnapTargets {
|
|
923
|
+
points = [];
|
|
924
|
+
xs = [];
|
|
925
|
+
ys = [];
|
|
926
|
+
segments = [];
|
|
927
|
+
/** Bounds of the objects a moved box spaces itself evenly with. */
|
|
928
|
+
boxes = [];
|
|
929
|
+
/** Side lengths of the other objects (in their own frames), which a resize may match. */
|
|
930
|
+
sizes = [];
|
|
931
|
+
constructor() {
|
|
932
|
+
}
|
|
933
|
+
/** How many targets of each sort were gathered. */
|
|
934
|
+
get counts() {
|
|
935
|
+
return { points: this.points.length, lines: this.xs.length + this.ys.length, segments: this.segments.length, boxes: this.boxes.length };
|
|
936
|
+
}
|
|
937
|
+
/** Targets for moving nodes `ids`: the other art's anchors, bounds and centres, the artboard, and boxes to space evenly with. */
|
|
938
|
+
static forMove(doc, ids) {
|
|
939
|
+
return _VectorSnapTargets.collect(doc, new Set(ids), "bounds");
|
|
940
|
+
}
|
|
941
|
+
/** Targets for resizing nodes `ids`: as for a move, plus the other objects' sizes. */
|
|
942
|
+
static forResize(doc, ids) {
|
|
943
|
+
return _VectorSnapTargets.collect(doc, new Set(ids), "bounds");
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Targets for a drawn point (pen, shape tools): the art but `exclude`, its anchors (as
|
|
947
|
+
* points and in line), centres, edges and segments. The anchors and segments of `exclude`
|
|
948
|
+
* (the path being drawn, whose bounds grow with it) stay targets.
|
|
949
|
+
*/
|
|
950
|
+
static forDrawing(doc, exclude = []) {
|
|
951
|
+
const skip = new Set(exclude);
|
|
952
|
+
const t = _VectorSnapTargets.collect(doc, skip, "drawing", false);
|
|
953
|
+
t.pushPaths(doc, skip, () => false, true);
|
|
954
|
+
return t.index();
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Targets for dragging anchors: the other art with its segments, and the anchors and
|
|
958
|
+
* segments that stay put on the paths being reshaped (their bounds move along, so they are
|
|
959
|
+
* no targets). The moving anchors are left out.
|
|
960
|
+
*/
|
|
961
|
+
static forAnchorDrag(doc, anchors) {
|
|
962
|
+
const moving = /* @__PURE__ */ new Map();
|
|
963
|
+
for (const r of anchors) {
|
|
964
|
+
const set = moving.get(r.nodeId) ?? /* @__PURE__ */ new Set();
|
|
965
|
+
set.add(`${r.subpath}:${r.index}`);
|
|
966
|
+
moving.set(r.nodeId, set);
|
|
967
|
+
}
|
|
968
|
+
const t = _VectorSnapTargets.collect(doc, new Set(moving.keys()), "segments", false);
|
|
969
|
+
t.pushPaths(doc, new Set(moving.keys()), (id, s, i) => !!moving.get(id)?.has(`${s}:${i}`), true);
|
|
970
|
+
return t.index();
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Targets for dragging a handle of path `nodeId`: the other art and that path's anchors
|
|
974
|
+
* (its bounds and the segments through the handle move with it, so they would chase it).
|
|
975
|
+
*/
|
|
976
|
+
static forHandle(doc, nodeId) {
|
|
977
|
+
const skip = /* @__PURE__ */ new Set([nodeId]);
|
|
978
|
+
const t = _VectorSnapTargets.collect(doc, skip, "bounds", false);
|
|
979
|
+
t.pushPaths(doc, skip, () => false, false);
|
|
980
|
+
return t.index();
|
|
981
|
+
}
|
|
982
|
+
/** The anchors of the paths `ids` as targets (on them and in line), and their segments when `segments`. */
|
|
983
|
+
pushPaths(doc, ids, moving, segments) {
|
|
984
|
+
let anchors = BUDGET;
|
|
985
|
+
let segmentBudget = segments ? BUDGET : 0;
|
|
986
|
+
const visit = (list, parent) => {
|
|
987
|
+
for (const n of list) {
|
|
988
|
+
const m = multiplyMatrices(nodeMatrix(n), parent);
|
|
989
|
+
if (n.type === "group") visit(n.children, m);
|
|
990
|
+
else if (ids.has(n.id) && n.visible !== false) {
|
|
991
|
+
n.subpaths.forEach((sp, s) => {
|
|
992
|
+
sp.anchors.forEach((a, i) => {
|
|
993
|
+
if (anchors <= 0 || moving(n.id, s, i)) return;
|
|
994
|
+
this.pushAnchor(applyMatrix(m, a));
|
|
995
|
+
anchors--;
|
|
996
|
+
});
|
|
997
|
+
segmentBudget = this.pushSegments(sp, m, (i) => moving(n.id, s, i), segmentBudget);
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
visit(doc.nodes, IDENTITY_MATRIX);
|
|
1003
|
+
}
|
|
1004
|
+
pushAnchor(p) {
|
|
1005
|
+
this.points.push({ p, kind: "anchor" });
|
|
1006
|
+
this.xs.push({ v: p.x, from: p, kind: "anchor" });
|
|
1007
|
+
this.ys.push({ v: p.y, from: p, kind: "anchor" });
|
|
1008
|
+
}
|
|
1009
|
+
/** The segments of `sp` (mapped by `m`) that stay put while the anchors `moving` accepts move. */
|
|
1010
|
+
pushSegments(sp, m, moving, budget) {
|
|
1011
|
+
const n = sp.anchors.length;
|
|
1012
|
+
const count = subpathSegmentCount(sp);
|
|
1013
|
+
for (let i = 0; i < count && budget > 0; i++) {
|
|
1014
|
+
if (moving(i) || moving((i + 1) % n)) continue;
|
|
1015
|
+
const seg = subpathSegment(sp, i).map((q) => toPoint(applyMatrix(m, toVec(q))));
|
|
1016
|
+
let minX = Infinity;
|
|
1017
|
+
let minY = Infinity;
|
|
1018
|
+
let maxX = -Infinity;
|
|
1019
|
+
let maxY = -Infinity;
|
|
1020
|
+
for (const [x, y] of seg) {
|
|
1021
|
+
minX = Math.min(minX, x);
|
|
1022
|
+
minY = Math.min(minY, y);
|
|
1023
|
+
maxX = Math.max(maxX, x);
|
|
1024
|
+
maxY = Math.max(maxY, y);
|
|
1025
|
+
}
|
|
1026
|
+
this.segments.push({ seg, minX, minY, maxX, maxY });
|
|
1027
|
+
budget--;
|
|
1028
|
+
}
|
|
1029
|
+
return budget;
|
|
1030
|
+
}
|
|
1031
|
+
pushRect(r, kind, centre) {
|
|
1032
|
+
const corners = [
|
|
1033
|
+
{ x: r.x, y: r.y },
|
|
1034
|
+
{ x: r.x + r.width, y: r.y },
|
|
1035
|
+
{ x: r.x + r.width, y: r.y + r.height },
|
|
1036
|
+
{ x: r.x, y: r.y + r.height }
|
|
1037
|
+
];
|
|
1038
|
+
for (const p of corners) {
|
|
1039
|
+
this.xs.push({ v: p.x, from: p, kind });
|
|
1040
|
+
this.ys.push({ v: p.y, from: p, kind });
|
|
1041
|
+
}
|
|
1042
|
+
if (centre) {
|
|
1043
|
+
this.points.push({ p: centre, kind: "centre" });
|
|
1044
|
+
this.xs.push({ v: centre.x, from: centre, kind: "centre" });
|
|
1045
|
+
this.ys.push({ v: centre.y, from: centre, kind: "centre" });
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
/** Sorts the targets for `within` (stable: of targets alike, the first gathered wins). */
|
|
1049
|
+
index() {
|
|
1050
|
+
this.points.sort((a, b) => a.p.x - b.p.x);
|
|
1051
|
+
this.xs.sort((a, b) => a.v - b.v);
|
|
1052
|
+
this.ys.sort((a, b) => a.v - b.v);
|
|
1053
|
+
return this;
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Gathers targets from the artboard and every visible node but `exclude` (and what is inside
|
|
1057
|
+
* those): each leaf's anchors (also as lines for `drawing`), its segments (`segments`,
|
|
1058
|
+
* `drawing`), its bounds' corners as lines and its centre; and the top-level objects' boxes and
|
|
1059
|
+
* sizes (inside a group holding an excluded node, that group's other members).
|
|
1060
|
+
*/
|
|
1061
|
+
static collect(doc, exclude, gather, sort = true) {
|
|
1062
|
+
const self = new _VectorSnapTargets();
|
|
1063
|
+
const artboard = { x: 0, y: 0, width: doc.width, height: doc.height };
|
|
1064
|
+
self.pushRect(artboard, "artboard", { x: doc.width / 2, y: doc.height / 2 });
|
|
1065
|
+
let anchors = BUDGET;
|
|
1066
|
+
let segments = BUDGET;
|
|
1067
|
+
const visit = (list, parent) => {
|
|
1068
|
+
for (const n of list) {
|
|
1069
|
+
if (exclude.has(n.id) || n.visible === false) continue;
|
|
1070
|
+
const m = multiplyMatrices(nodeMatrix(n), parent);
|
|
1071
|
+
if (n.type === "group") {
|
|
1072
|
+
visit(n.children, m);
|
|
1073
|
+
continue;
|
|
1074
|
+
}
|
|
1075
|
+
if (anchors <= 0) continue;
|
|
1076
|
+
for (const sp of n.subpaths) {
|
|
1077
|
+
for (const a of sp.anchors) {
|
|
1078
|
+
if (anchors-- <= 0) break;
|
|
1079
|
+
const p = applyMatrix(m, a);
|
|
1080
|
+
if (gather === "drawing") self.pushAnchor(p);
|
|
1081
|
+
else self.points.push({ p, kind: "anchor" });
|
|
1082
|
+
}
|
|
1083
|
+
if (gather !== "bounds") segments = self.pushSegments(sp, m, () => false, segments);
|
|
1084
|
+
}
|
|
1085
|
+
const b = vectorNodeBounds(n, parent);
|
|
1086
|
+
const f = nodeFrame(n, parent, b);
|
|
1087
|
+
if (b) self.pushRect(b, "edge", f?.centre ?? null);
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
visit(doc.nodes, IDENTITY_MATRIX);
|
|
1091
|
+
const objects = (list, parent) => {
|
|
1092
|
+
for (const n of list) {
|
|
1093
|
+
if (exclude.has(n.id) || n.visible === false || self.boxes.length >= BUDGET) continue;
|
|
1094
|
+
if (n.type === "group" && contains(n, exclude)) {
|
|
1095
|
+
objects(n.children, multiplyMatrices(nodeMatrix(n), parent));
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
const b = vectorNodeBounds(n, parent);
|
|
1099
|
+
if (b) self.boxes.push(b);
|
|
1100
|
+
const f = nodeFrame(n, parent, b);
|
|
1101
|
+
if (f) self.sizes.push(f.width, f.height);
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
objects(doc.nodes, IDENTITY_MATRIX);
|
|
1105
|
+
return sort ? self.index() : self;
|
|
1106
|
+
}
|
|
1107
|
+
// -------------------------------------------------------------------------
|
|
1108
|
+
// Points
|
|
1109
|
+
// -------------------------------------------------------------------------
|
|
1110
|
+
/** Snaps a single point (see `snapFrom`). */
|
|
1111
|
+
snapPoint(p, tol) {
|
|
1112
|
+
return this.snapFrom(p, null, tol);
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Snaps a point `p`, drawn or dragged from `leave` when given. With `leave.constrain` it keeps
|
|
1116
|
+
* to its angle round `leave.at`, sliding along that way into line with the nearest target.
|
|
1117
|
+
* Otherwise it lands, in order, on an anchor or centre, an intersection of two paths, the
|
|
1118
|
+
* perpendicular foot or a tangent point from `leave.at`, a path, a construction guide
|
|
1119
|
+
* through `leave.at` (where a target's line crosses it if one does nearby), or else lines up
|
|
1120
|
+
* with targets on x and y.
|
|
1121
|
+
*/
|
|
1122
|
+
snapFrom(p, leave, tol) {
|
|
1123
|
+
const none = (q) => ({ point: q, kind: null, x: false, y: false, lines: [], labels: [] });
|
|
1124
|
+
const at = (q, kind, lines2 = []) => ({
|
|
1125
|
+
point: q,
|
|
1126
|
+
kind,
|
|
1127
|
+
x: true,
|
|
1128
|
+
y: true,
|
|
1129
|
+
lines: lines2,
|
|
1130
|
+
labels: [this.label(q, kind)]
|
|
1131
|
+
});
|
|
1132
|
+
if (leave?.constrain) {
|
|
1133
|
+
const q = constrainToLeave(leave, p);
|
|
1134
|
+
const hit = this.slide(leave.at, { x: q.x - leave.at.x, y: q.y - leave.at.y }, q, tol, true);
|
|
1135
|
+
return hit ? { point: hit.point, x: true, y: true, ...this.linedUp(hit.point, hit.from) } : none(q);
|
|
1136
|
+
}
|
|
1137
|
+
const point = this.nearestPoint(p, tol);
|
|
1138
|
+
if (point) return at(point.p, point.kind);
|
|
1139
|
+
const near = this.segmentsNear(p, tol);
|
|
1140
|
+
const crossing = this.crossing(near, p, tol);
|
|
1141
|
+
if (crossing) return at(crossing, "intersect");
|
|
1142
|
+
if (leave && leave.curves !== false) {
|
|
1143
|
+
const foot = this.curvePoint(near, leave.at, p, tol);
|
|
1144
|
+
if (foot) return at(foot.point, foot.kind, [line("construction", leave.at, foot.point)]);
|
|
1145
|
+
}
|
|
1146
|
+
const on = this.onSegment(near);
|
|
1147
|
+
if (on) return at(on, "path");
|
|
1148
|
+
if (leave?.angles?.length) {
|
|
1149
|
+
const c = construction(leave, p, tol);
|
|
1150
|
+
if (c) {
|
|
1151
|
+
const hit = this.slide(leave.at, c.dir, c.point, tol, false);
|
|
1152
|
+
if (hit) {
|
|
1153
|
+
const lined = this.linedUp(hit.point, hit.from);
|
|
1154
|
+
return { point: hit.point, x: true, y: true, kind: lined.kind, lines: [line("construction", leave.at, hit.point), ...lined.lines], labels: lined.labels };
|
|
1155
|
+
}
|
|
1156
|
+
return { point: c.point, kind: "construction", x: true, y: true, lines: [line("construction", leave.at, c.point)], labels: [] };
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
const nearest = (lines2, v) => {
|
|
1160
|
+
let best = null;
|
|
1161
|
+
for (const l of linesNear(lines2, v, tol)) if (!best || Math.abs(l.v - v) < Math.abs(best.v - v)) best = l;
|
|
1162
|
+
return best;
|
|
1163
|
+
};
|
|
1164
|
+
const bx = nearest(this.xs, p.x);
|
|
1165
|
+
const by = nearest(this.ys, p.y);
|
|
1166
|
+
if (!bx && !by) return none(p);
|
|
1167
|
+
const out = { x: bx ? bx.v : p.x, y: by ? by.v : p.y };
|
|
1168
|
+
const lines = [];
|
|
1169
|
+
if (bx) lines.push(line("align", bx.from, { x: out.x, y: out.y }));
|
|
1170
|
+
if (by) lines.push(line("align", by.from, { x: out.x, y: out.y }));
|
|
1171
|
+
return { point: out, kind: "align", x: !!bx, y: !!by, lines, labels: [this.label(out, "align")] };
|
|
1172
|
+
}
|
|
1173
|
+
label(p, kind, text = LABELS[kind]) {
|
|
1174
|
+
return { kind, x: p.x, y: p.y, text };
|
|
1175
|
+
}
|
|
1176
|
+
/** The anchor or centre nearest `p`, within `tol`. */
|
|
1177
|
+
nearestPoint(p, tol) {
|
|
1178
|
+
let best = null;
|
|
1179
|
+
let bestD = Infinity;
|
|
1180
|
+
for (const t of within(this.points, (e) => e.p.x, p.x, tol)) {
|
|
1181
|
+
const d = distance(t.p, p);
|
|
1182
|
+
if (d <= tol && d < bestD) {
|
|
1183
|
+
best = t;
|
|
1184
|
+
bestD = d;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
return best;
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Slides `q`, on the line through `at` along `dir` (beyond `at` only for a `ray`), to where
|
|
1191
|
+
* the nearest target line crosses it, at most twice `tol` away: where it lands and the
|
|
1192
|
+
* target point that line comes from.
|
|
1193
|
+
*/
|
|
1194
|
+
slide(at, dir, q, tol, ray) {
|
|
1195
|
+
const len2 = dir.x * dir.x + dir.y * dir.y;
|
|
1196
|
+
if (!Number.isFinite(len2) || len2 <= 1e-18) return null;
|
|
1197
|
+
const reach = 2 * tol;
|
|
1198
|
+
let best = null;
|
|
1199
|
+
for (const vertical of [true, false]) {
|
|
1200
|
+
const d = vertical ? dir.x : dir.y;
|
|
1201
|
+
if (Math.abs(d) <= 1e-9) continue;
|
|
1202
|
+
const a = vertical ? at.x : at.y;
|
|
1203
|
+
for (const l of linesNear(vertical ? this.xs : this.ys, vertical ? q.x : q.y, reach)) {
|
|
1204
|
+
const s = (l.v - a) / d;
|
|
1205
|
+
const hit = vertical ? { x: l.v, y: at.y + dir.y * s } : { x: at.x + dir.x * s, y: l.v };
|
|
1206
|
+
const moved = distance(hit, q);
|
|
1207
|
+
if (moved <= reach && (!ray || s > 0) && (!best || moved < best.moved)) best = { moved, point: hit, from: l.from };
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
return best && { point: best.point, from: best.from };
|
|
1211
|
+
}
|
|
1212
|
+
/** The guides of a point `q` slid into line with the target at `from`: the line and "align", or the target's own label when `q` sits on it. */
|
|
1213
|
+
linedUp(q, from) {
|
|
1214
|
+
const on = this.nearestPoint(q, 1e-9);
|
|
1215
|
+
if (on) return { kind: on.kind, lines: [], labels: [this.label(on.p, on.kind)] };
|
|
1216
|
+
return { kind: "align", lines: [line("align", from, q)], labels: [this.label(q, "align")] };
|
|
1217
|
+
}
|
|
1218
|
+
/** Target segments whose control box reaches `p` and which pass within `tol` of it, nearest first. */
|
|
1219
|
+
segmentsNear(p, tol) {
|
|
1220
|
+
const out = [];
|
|
1221
|
+
const pt = toPoint(p);
|
|
1222
|
+
for (const s of this.segments) {
|
|
1223
|
+
if (p.x < s.minX - tol || p.x > s.maxX + tol || p.y < s.minY - tol || p.y > s.maxY + tol) continue;
|
|
1224
|
+
const hit = segmentNearest(s.seg, pt);
|
|
1225
|
+
if (hit.distance <= tol) out.push({ seg: s.seg, t: hit.t, distance: hit.distance });
|
|
1226
|
+
}
|
|
1227
|
+
return out.sort((a, b) => a.distance - b.distance);
|
|
1228
|
+
}
|
|
1229
|
+
onSegment(near) {
|
|
1230
|
+
return near.length ? toVec(segmentPoint(near[0].seg, near[0].t)) : null;
|
|
1231
|
+
}
|
|
1232
|
+
/** Where two of the target segments near `p` cross, the crossing nearest `p` within `tol`. */
|
|
1233
|
+
crossing(near, p, tol) {
|
|
1234
|
+
const list = near.slice(0, CROSSING_PAIRS);
|
|
1235
|
+
let best = null;
|
|
1236
|
+
let bestD = Infinity;
|
|
1237
|
+
for (let i = 0; i < list.length; i++) {
|
|
1238
|
+
for (let j = i + 1; j < list.length; j++) {
|
|
1239
|
+
for (const hit of segmentIntersections(list[i].seg, list[j].seg)) {
|
|
1240
|
+
const q = toVec(hit.point);
|
|
1241
|
+
const d = distance(q, p);
|
|
1242
|
+
if (d <= tol && d < bestD) {
|
|
1243
|
+
best = q;
|
|
1244
|
+
bestD = d;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
return best;
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* The foot of a perpendicular, or a tangent point, from `base` on a target segment near `p`
|
|
1253
|
+
* (within `tol` of it): roots of `(B(t) − base) · B′(t)` and `(B(t) − base) × B′(t)`
|
|
1254
|
+
* bracketed by sign changes of samples and narrowed by bisection.
|
|
1255
|
+
*/
|
|
1256
|
+
curvePoint(near, base, p, tol) {
|
|
1257
|
+
let best = null;
|
|
1258
|
+
for (const { seg } of near) {
|
|
1259
|
+
for (const kind of ["perpendicular", "tangent"]) {
|
|
1260
|
+
if (kind === "tangent" && seg.length === 2) continue;
|
|
1261
|
+
for (const q of solveOnSegment(seg, base, kind === "perpendicular" ? dotCondition : crossCondition)) {
|
|
1262
|
+
const d = distance(q, p);
|
|
1263
|
+
if (d <= tol && (!best || d < best.d)) best = { point: q, kind, d };
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
return best && { point: best.point, kind: best.kind };
|
|
1268
|
+
}
|
|
1269
|
+
// -------------------------------------------------------------------------
|
|
1270
|
+
// Boxes
|
|
1271
|
+
// -------------------------------------------------------------------------
|
|
1272
|
+
/**
|
|
1273
|
+
* Snaps a moving box `r` (the moved selection's bounds): its edge or centre nearest a target
|
|
1274
|
+
* line on each axis (`axes` limits which may move). With `spacing`, an axis that lines up
|
|
1275
|
+
* with nothing spaces the box evenly in its row or column. Returns the correction and guides.
|
|
1276
|
+
*/
|
|
1277
|
+
snapRect(r, tol, options = {}) {
|
|
1278
|
+
const axes = { x: options.axes?.x !== false, y: options.axes?.y !== false };
|
|
1279
|
+
const nearest = (lines, values) => {
|
|
1280
|
+
let best = null;
|
|
1281
|
+
for (const v of values) for (const l of linesNear(lines, v, tol)) if (!best || Math.abs(l.v - v) < Math.abs(best.d)) best = { d: l.v - v, from: l.from };
|
|
1282
|
+
return best;
|
|
1283
|
+
};
|
|
1284
|
+
const bx = axes.x ? nearest(this.xs, [r.x, r.x + r.width / 2, r.x + r.width]) : null;
|
|
1285
|
+
const by = axes.y ? nearest(this.ys, [r.y, r.y + r.height / 2, r.y + r.height]) : null;
|
|
1286
|
+
const out = { dx: bx?.d ?? 0, dy: by?.d ?? 0, x: !!bx, y: !!by, lines: [], labels: [] };
|
|
1287
|
+
const done = { ...r, x: r.x + out.dx, y: r.y + out.dy };
|
|
1288
|
+
if (bx) {
|
|
1289
|
+
const x = bx.from.x;
|
|
1290
|
+
out.lines.push({ kind: "align", x1: x, y1: Math.min(bx.from.y, done.y), x2: x, y2: Math.max(bx.from.y, done.y + done.height) });
|
|
1291
|
+
}
|
|
1292
|
+
if (by) {
|
|
1293
|
+
const y = by.from.y;
|
|
1294
|
+
out.lines.push({ kind: "align", x1: Math.min(by.from.x, done.x), y1: y, x2: Math.max(by.from.x, done.x + done.width), y2: y });
|
|
1295
|
+
}
|
|
1296
|
+
if (options.spacing && this.boxes.length) {
|
|
1297
|
+
for (const horizontal of [true, false]) {
|
|
1298
|
+
if (horizontal ? out.x || !axes.x : out.y || !axes.y) continue;
|
|
1299
|
+
const moved = { ...r, x: r.x + out.dx, y: r.y + out.dy };
|
|
1300
|
+
const even = this.evenSpacing(moved, horizontal, tol);
|
|
1301
|
+
if (!even) continue;
|
|
1302
|
+
if (horizontal) {
|
|
1303
|
+
out.dx += even.shift;
|
|
1304
|
+
out.x = true;
|
|
1305
|
+
} else {
|
|
1306
|
+
out.dy += even.shift;
|
|
1307
|
+
out.y = true;
|
|
1308
|
+
}
|
|
1309
|
+
for (const gap of even.gaps) this.gapMarks(gap, horizontal, tol, out);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
if (bx || by) out.labels.unshift(this.label({ x: r.x + out.dx + r.width / 2, y: r.y + out.dy + r.height / 2 }, "align"));
|
|
1313
|
+
return out;
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* Equal spacing along x (`horizontal`) or y for the moved box `r`, among the objects of its
|
|
1317
|
+
* row (column): the shift within `tol` that centres it between its neighbours, or leaves it
|
|
1318
|
+
* as far from the neighbour before or after it as two other objects of the row are apart;
|
|
1319
|
+
* and the gaps that are equal then, as (start, end, where across).
|
|
1320
|
+
*/
|
|
1321
|
+
evenSpacing(r, horizontal, tol) {
|
|
1322
|
+
const span = (b, x) => x ? [b.x, b.x + b.width] : [b.y, b.y + b.height];
|
|
1323
|
+
const [r0, r1] = span(r, horizontal);
|
|
1324
|
+
const len = r1 - r0;
|
|
1325
|
+
const [c0, c1] = span(r, !horizontal);
|
|
1326
|
+
const row = this.boxes.map((b) => ({ b, a0: span(b, horizontal)[0], a1: span(b, horizontal)[1] })).filter((e) => span(e.b, !horizontal)[0] < c1 && span(e.b, !horizontal)[1] > c0 && (e.a1 <= r0 + tol || e.a0 >= r1 - tol)).sort((a, b) => a.a0 - b.a0);
|
|
1327
|
+
if (!row.length) return null;
|
|
1328
|
+
const mid = (a, b) => (Math.max(span(a, !horizontal)[0], span(b, !horizontal)[0]) + Math.min(span(a, !horizontal)[1], span(b, !horizontal)[1])) / 2;
|
|
1329
|
+
const pairs = [];
|
|
1330
|
+
for (const p of row) {
|
|
1331
|
+
const next = bound(row, (e) => e.a0, p.a1, false);
|
|
1332
|
+
const q = row[next];
|
|
1333
|
+
if (!q) continue;
|
|
1334
|
+
const straddles = p.a1 <= r0 + tol && q.a0 >= r1 - tol;
|
|
1335
|
+
if (q.a0 > p.a1 && !straddles) pairs.push({ g: q.a0 - p.a1, gap: [p.a1, q.a0, mid(p.b, q.b)] });
|
|
1336
|
+
}
|
|
1337
|
+
let before = null;
|
|
1338
|
+
let after = null;
|
|
1339
|
+
for (const e of row) {
|
|
1340
|
+
if (e.a1 <= r0 + tol && (!before || e.a1 > before.a1)) before = e;
|
|
1341
|
+
if (e.a0 >= r1 - tol && (!after || e.a0 < after.a0)) after = e;
|
|
1342
|
+
}
|
|
1343
|
+
const starts = [];
|
|
1344
|
+
if (before && after && after.a0 - before.a1 > len) starts.push({ start: (before.a1 + after.a0 - len) / 2, repeats: null });
|
|
1345
|
+
for (const { g } of pairs) {
|
|
1346
|
+
if (before) starts.push({ start: before.a1 + g, repeats: g });
|
|
1347
|
+
if (after) starts.push({ start: after.a0 - g - len, repeats: g });
|
|
1348
|
+
}
|
|
1349
|
+
let pick = null;
|
|
1350
|
+
for (const s of starts) if (Math.abs(s.start - r0) <= tol && (!pick || Math.abs(s.start - r0) < Math.abs(pick.start - r0))) pick = s;
|
|
1351
|
+
if (!pick) return null;
|
|
1352
|
+
const { start, repeats } = pick;
|
|
1353
|
+
const at = horizontal ? { ...r, x: start } : { ...r, y: start };
|
|
1354
|
+
const end = start + len;
|
|
1355
|
+
const gaps = [];
|
|
1356
|
+
if (before && (repeats === null || Math.abs(start - before.a1 - repeats) <= EQUAL)) gaps.push([before.a1, start, mid(before.b, at)]);
|
|
1357
|
+
if (after && (repeats === null || Math.abs(after.a0 - end - repeats) <= EQUAL)) gaps.push([end, after.a0, mid(after.b, at)]);
|
|
1358
|
+
if (repeats !== null) {
|
|
1359
|
+
for (const p of pairs) if (Math.abs(p.g - repeats) <= EQUAL) gaps.push(p.gap);
|
|
1360
|
+
}
|
|
1361
|
+
return { shift: start - r0, gaps };
|
|
1362
|
+
}
|
|
1363
|
+
/** A gap mark: a line across the gap with a tick at each end, and its length as a label. */
|
|
1364
|
+
gapMarks([u0, u1, c], horizontal, tick, out) {
|
|
1365
|
+
const pt = (u, v) => horizontal ? { x: u, y: v } : { x: v, y: u };
|
|
1366
|
+
out.lines.push(line("spacing", pt(u0, c), pt(u1, c)), line("spacing", pt(u0, c - tick), pt(u0, c + tick)), line("spacing", pt(u1, c - tick), pt(u1, c + tick)));
|
|
1367
|
+
out.labels.push(this.label(pt((u0 + u1) / 2, c), "spacing", fmtLength(u1 - u0)));
|
|
1368
|
+
}
|
|
1369
|
+
/** The other objects' side length nearest `length`, within `tol`, or null. */
|
|
1370
|
+
matchSize(length2, tol) {
|
|
1371
|
+
let best = null;
|
|
1372
|
+
for (const s of this.sizes) if (Math.abs(s - length2) <= tol && (best === null || Math.abs(s - length2) < Math.abs(best - length2))) best = s;
|
|
1373
|
+
return best;
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Snaps a selection-box resize. `sx / sy` are the scale `scaleFor` gave for dragging
|
|
1377
|
+
* `handle` of `frame` (in the box's own, possibly rotated frame), `mods.shift` keeps the
|
|
1378
|
+
* proportions and `mods.alt` scales about the centre. The handle lands on an anchor or
|
|
1379
|
+
* centre within `tol`, else the corner or the side it moves lines up with the nearest
|
|
1380
|
+
* target, whatever the box's angle; a handle with one way to go (a side, or a proportional
|
|
1381
|
+
* corner) slides along it until one of them is on a target. An axis that snaps to nothing
|
|
1382
|
+
* may match another object's width or height instead.
|
|
1383
|
+
*/
|
|
1384
|
+
snapScale(frame, handle, sx, sy, mods, tol) {
|
|
1385
|
+
const toDoc = frame.angle ? rotateMatrix(frame.angle) : IDENTITY_MATRIX;
|
|
1386
|
+
const toLocal = frame.angle ? rotateMatrix(-frame.angle) : IDENTITY_MATRIX;
|
|
1387
|
+
const corner = handle.length === 2;
|
|
1388
|
+
const proportional = !!mods.shift;
|
|
1389
|
+
const centre = { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 };
|
|
1390
|
+
const origin = mods.alt ? centre : handlePos(opposite(handle), frame);
|
|
1391
|
+
const hp = handlePos(handle, frame);
|
|
1392
|
+
const d0 = { x: hp.x - origin.x, y: hp.y - origin.y };
|
|
1393
|
+
const valid = (n, s) => Number.isFinite(n) && n * s > 0;
|
|
1394
|
+
const nearest = (v, lines) => {
|
|
1395
|
+
let best = null;
|
|
1396
|
+
for (const l of linesNear(lines, v, tol)) if (!best || Math.abs(l.v - v) < Math.abs(best.v - v)) best = l;
|
|
1397
|
+
return best;
|
|
1398
|
+
};
|
|
1399
|
+
let nx = sx;
|
|
1400
|
+
let ny = sy;
|
|
1401
|
+
let snap = { hx: null, hy: null, on: null };
|
|
1402
|
+
if (corner && !proportional) {
|
|
1403
|
+
const p = applyMatrix(toDoc, { x: origin.x + d0.x * sx, y: origin.y + d0.y * sy });
|
|
1404
|
+
const hx = nearest(p.x, this.xs);
|
|
1405
|
+
const hy = nearest(p.y, this.ys);
|
|
1406
|
+
const on = this.nearestPoint(p, tol);
|
|
1407
|
+
const tries = [];
|
|
1408
|
+
if (on) tries.push({ tx: on.p.x, ty: on.p.y, s: { hx: null, hy: null, on } });
|
|
1409
|
+
tries.push({ tx: hx?.v ?? null, ty: hy?.v ?? null, s: { hx, hy, on: null } });
|
|
1410
|
+
tries.push({ tx: hx?.v ?? null, ty: null, s: { hx, hy: null, on: null } });
|
|
1411
|
+
tries.push({ tx: null, ty: hy?.v ?? null, s: { hx: null, hy, on: null } });
|
|
1412
|
+
for (const { tx, ty, s } of tries) {
|
|
1413
|
+
if (tx === null && ty === null) continue;
|
|
1414
|
+
const q = applyMatrix(toLocal, { x: tx ?? p.x, y: ty ?? p.y });
|
|
1415
|
+
let cx = (q.x - origin.x) / d0.x;
|
|
1416
|
+
let cy = (q.y - origin.y) / d0.y;
|
|
1417
|
+
if (!frame.angle) {
|
|
1418
|
+
if (tx === null) cx = sx;
|
|
1419
|
+
if (ty === null) cy = sy;
|
|
1420
|
+
}
|
|
1421
|
+
if (valid(cx, sx) && valid(cy, sy)) {
|
|
1422
|
+
nx = cx;
|
|
1423
|
+
ny = cy;
|
|
1424
|
+
snap = s;
|
|
1425
|
+
break;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
} else {
|
|
1429
|
+
const movesX2 = handle === "e" || handle === "w";
|
|
1430
|
+
let e;
|
|
1431
|
+
let c;
|
|
1432
|
+
let s0;
|
|
1433
|
+
if (corner) [e, c, s0] = [{ x: Math.sign(sx), y: Math.sign(sy) }, { x: 0, y: 0 }, Math.abs(sx)];
|
|
1434
|
+
else if (movesX2) [e, c, s0] = proportional ? [{ x: 1, y: Math.sign(sx) }, { x: 0, y: 0 }, sx] : [{ x: 1, y: 0 }, { x: 0, y: 1 }, sx];
|
|
1435
|
+
else [e, c, s0] = proportional ? [{ x: Math.sign(sy), y: 1 }, { x: 0, y: 0 }, sy] : [{ x: 0, y: 1 }, { x: 1, y: 0 }, sy];
|
|
1436
|
+
const i = RING.indexOf(handle);
|
|
1437
|
+
const ends = corner ? [] : [RING[(i + 1) % 8], RING[(i + 7) % 8]];
|
|
1438
|
+
const travel = Math.hypot(e.x * d0.x, e.y * d0.y);
|
|
1439
|
+
let best = null;
|
|
1440
|
+
const consider = (s, inLine, found2) => {
|
|
1441
|
+
const moved = Math.abs(s - s0) * travel;
|
|
1442
|
+
if (!valid(s, s0) || moved > 2 * tol) return;
|
|
1443
|
+
if (!best || (inLine === best.inLine ? moved < best.moved : !inLine)) best = { s, inLine, moved, snap: found2 };
|
|
1444
|
+
};
|
|
1445
|
+
for (const h of [handle, ...ends]) {
|
|
1446
|
+
const hv = handlePos(h, frame);
|
|
1447
|
+
const v = { x: hv.x - origin.x, y: hv.y - origin.y };
|
|
1448
|
+
const base = applyMatrix(toDoc, { x: origin.x + c.x * v.x, y: origin.y + c.y * v.y });
|
|
1449
|
+
const dir = applyMatrix(toDoc, { x: e.x * v.x, y: e.y * v.y });
|
|
1450
|
+
const p = { x: base.x + dir.x * s0, y: base.y + dir.y * s0 };
|
|
1451
|
+
const l2 = dir.x * dir.x + dir.y * dir.y;
|
|
1452
|
+
const on = l2 > 1e-18 ? this.nearestPoint(p, tol) : null;
|
|
1453
|
+
if (on) consider(((on.p.x - base.x) * dir.x + (on.p.y - base.y) * dir.y) / l2, false, { hx: null, hy: null, on });
|
|
1454
|
+
if (Math.abs(dir.x) > 1e-9) {
|
|
1455
|
+
const hit = nearest(p.x, this.xs);
|
|
1456
|
+
if (hit) consider((hit.v - base.x) / dir.x, true, { hx: hit, hy: null, on: null });
|
|
1457
|
+
}
|
|
1458
|
+
if (Math.abs(dir.y) > 1e-9) {
|
|
1459
|
+
const hit = nearest(p.y, this.ys);
|
|
1460
|
+
if (hit) consider((hit.v - base.y) / dir.y, true, { hx: null, hy: hit, on: null });
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
const found = best;
|
|
1464
|
+
if (found) {
|
|
1465
|
+
nx = c.x + found.s * e.x;
|
|
1466
|
+
ny = c.y + found.s * e.y;
|
|
1467
|
+
snap = found.snap;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
const out = { sx: nx, sy: ny, snapped: false, lines: [], labels: [] };
|
|
1471
|
+
const handleAt = () => applyMatrix(toDoc, { x: origin.x + d0.x * out.sx, y: origin.y + d0.y * out.sy });
|
|
1472
|
+
if (snap.on) {
|
|
1473
|
+
out.snapped = true;
|
|
1474
|
+
out.labels.push(this.label(snap.on.p, snap.on.kind));
|
|
1475
|
+
return out;
|
|
1476
|
+
}
|
|
1477
|
+
const movesX = handle !== "n" && handle !== "s";
|
|
1478
|
+
const movesY = handle !== "e" && handle !== "w";
|
|
1479
|
+
const sized = [];
|
|
1480
|
+
if (!proportional) {
|
|
1481
|
+
if (movesX && !snap.hx && !(frame.angle && snap.hy) && frame.width > 1e-9) {
|
|
1482
|
+
const len = this.matchSize(Math.abs(out.sx) * frame.width, tol);
|
|
1483
|
+
if (len !== null && len > 0) {
|
|
1484
|
+
out.sx = Math.sign(out.sx || 1) * (len / frame.width);
|
|
1485
|
+
sized.push("x");
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
if (movesY && !snap.hy && !(frame.angle && snap.hx) && frame.height > 1e-9) {
|
|
1489
|
+
const len = this.matchSize(Math.abs(out.sy) * frame.height, tol);
|
|
1490
|
+
if (len !== null && len > 0) {
|
|
1491
|
+
out.sy = Math.sign(out.sy || 1) * (len / frame.height);
|
|
1492
|
+
sized.push("y");
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
const x0 = origin.x + (frame.x - origin.x) * out.sx;
|
|
1497
|
+
const x1 = origin.x + (frame.x + frame.width - origin.x) * out.sx;
|
|
1498
|
+
const y0 = origin.y + (frame.y - origin.y) * out.sy;
|
|
1499
|
+
const y1 = origin.y + (frame.y + frame.height - origin.y) * out.sy;
|
|
1500
|
+
const box = { x0: Math.min(x0, x1), x1: Math.max(x0, x1), y0: Math.min(y0, y1), y1: Math.max(y0, y1) };
|
|
1501
|
+
const page = [
|
|
1502
|
+
{ x: box.x0, y: box.y0 },
|
|
1503
|
+
{ x: box.x1, y: box.y0 },
|
|
1504
|
+
{ x: box.x1, y: box.y1 },
|
|
1505
|
+
{ x: box.x0, y: box.y1 }
|
|
1506
|
+
].map((q) => applyMatrix(toDoc, q));
|
|
1507
|
+
const pminY = Math.min(...page.map((q) => q.y));
|
|
1508
|
+
const pmaxY = Math.max(...page.map((q) => q.y));
|
|
1509
|
+
const pminX = Math.min(...page.map((q) => q.x));
|
|
1510
|
+
const pmaxX = Math.max(...page.map((q) => q.x));
|
|
1511
|
+
if (snap.hx) out.lines.push({ kind: "align", x1: snap.hx.v, y1: Math.min(snap.hx.from.y, pminY), x2: snap.hx.v, y2: Math.max(snap.hx.from.y, pmaxY) });
|
|
1512
|
+
if (snap.hy) out.lines.push({ kind: "align", x1: Math.min(snap.hy.from.x, pminX), y1: snap.hy.v, x2: Math.max(snap.hy.from.x, pmaxX), y2: snap.hy.v });
|
|
1513
|
+
if (snap.hx || snap.hy) {
|
|
1514
|
+
out.snapped = true;
|
|
1515
|
+
out.labels.push(this.label(handleAt(), "align"));
|
|
1516
|
+
}
|
|
1517
|
+
for (const axis of sized) {
|
|
1518
|
+
out.snapped = true;
|
|
1519
|
+
const off = tol * 2;
|
|
1520
|
+
const [a, b] = axis === "x" ? [{ x: box.x0, y: box.y1 + off }, { x: box.x1, y: box.y1 + off }] : [{ x: box.x1 + off, y: box.y0 }, { x: box.x1 + off, y: box.y1 }];
|
|
1521
|
+
const pa = applyMatrix(toDoc, a);
|
|
1522
|
+
const pb = applyMatrix(toDoc, b);
|
|
1523
|
+
out.lines.push(line("size", pa, pb));
|
|
1524
|
+
out.labels.push(this.label({ x: (pa.x + pb.x) / 2, y: (pa.y + pb.y) / 2 }, "size", `${axis === "x" ? "W" : "H"} ${fmtLength(axis === "x" ? box.x1 - box.x0 : box.y1 - box.y0)}`));
|
|
1525
|
+
}
|
|
1526
|
+
return out;
|
|
1527
|
+
}
|
|
1528
|
+
};
|
|
1529
|
+
var RING = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
|
|
1530
|
+
function opposite(h) {
|
|
1531
|
+
return RING[(RING.indexOf(h) + 4) % 8];
|
|
1532
|
+
}
|
|
1533
|
+
function handlePos(h, r) {
|
|
1534
|
+
const x = h.includes("w") ? r.x : h.includes("e") ? r.x + r.width : r.x + r.width / 2;
|
|
1535
|
+
const y = h.startsWith("n") ? r.y : h.startsWith("s") ? r.y + r.height : r.y + r.height / 2;
|
|
1536
|
+
return { x, y };
|
|
1537
|
+
}
|
|
1538
|
+
function line(kind, a, b) {
|
|
1539
|
+
return { kind, x1: a.x, y1: a.y, x2: b.x, y2: b.y };
|
|
1540
|
+
}
|
|
1541
|
+
function construction(leave, p, tol) {
|
|
1542
|
+
const v = { x: p.x - leave.at.x, y: p.y - leave.at.y };
|
|
1543
|
+
if (Math.hypot(v.x, v.y) <= tol) return null;
|
|
1544
|
+
let best = null;
|
|
1545
|
+
for (const a of leave.angles ?? []) {
|
|
1546
|
+
const r = a * Math.PI / 180;
|
|
1547
|
+
const exact = (n) => Math.abs(n) < 1e-12 ? 0 : n;
|
|
1548
|
+
const dir = { x: exact(Math.cos(r)), y: exact(-Math.sin(r)) };
|
|
1549
|
+
const k = v.x * dir.x + v.y * dir.y;
|
|
1550
|
+
const point = { x: leave.at.x + dir.x * k, y: leave.at.y + dir.y * k };
|
|
1551
|
+
const d = distance(point, p);
|
|
1552
|
+
if (d <= tol && (!best || d < best.d)) best = { dir, point, d };
|
|
1553
|
+
}
|
|
1554
|
+
return best && { dir: best.dir, point: best.point };
|
|
1555
|
+
}
|
|
1556
|
+
var dotCondition = (r, d) => r[0] * d[0] + r[1] * d[1];
|
|
1557
|
+
var crossCondition = (r, d) => r[0] * d[1] - r[1] * d[0];
|
|
1558
|
+
function solveOnSegment(seg, base, cond) {
|
|
1559
|
+
const b = [base.x, base.y];
|
|
1560
|
+
const g = (t) => {
|
|
1561
|
+
const p = segmentPoint(seg, t);
|
|
1562
|
+
return cond([p[0] - b[0], p[1] - b[1]], segmentDerivative(seg, t));
|
|
1563
|
+
};
|
|
1564
|
+
const vals = [];
|
|
1565
|
+
let scale = 0;
|
|
1566
|
+
for (let i = 0; i <= CURVE_SAMPLES; i++) {
|
|
1567
|
+
const t = i / CURVE_SAMPLES;
|
|
1568
|
+
const p = segmentPoint(seg, t);
|
|
1569
|
+
const d = segmentDerivative(seg, t);
|
|
1570
|
+
const r = [p[0] - b[0], p[1] - b[1]];
|
|
1571
|
+
vals.push(cond(r, d));
|
|
1572
|
+
scale = Math.max(scale, Math.hypot(r[0], r[1]) * Math.hypot(d[0], d[1]));
|
|
1573
|
+
}
|
|
1574
|
+
if (!(scale > 0) || vals.every((v) => Math.abs(v) <= scale * 1e-12)) return [];
|
|
1575
|
+
const out = [];
|
|
1576
|
+
for (let i = 0; i < CURVE_SAMPLES; i++) {
|
|
1577
|
+
const ga = vals[i];
|
|
1578
|
+
const gb = vals[i + 1];
|
|
1579
|
+
if (!(Number.isFinite(ga) && Number.isFinite(gb)) || ga > 0 === gb > 0 && ga !== 0) continue;
|
|
1580
|
+
let lo = i / CURVE_SAMPLES;
|
|
1581
|
+
let hi = (i + 1) / CURVE_SAMPLES;
|
|
1582
|
+
let flo = ga;
|
|
1583
|
+
for (let k = 0; k < BISECT; k++) {
|
|
1584
|
+
const m = (lo + hi) / 2;
|
|
1585
|
+
const fm = g(m);
|
|
1586
|
+
if (fm === 0) {
|
|
1587
|
+
lo = hi = m;
|
|
1588
|
+
break;
|
|
1589
|
+
}
|
|
1590
|
+
if (fm > 0 === flo > 0) {
|
|
1591
|
+
lo = m;
|
|
1592
|
+
flo = fm;
|
|
1593
|
+
} else hi = m;
|
|
1594
|
+
}
|
|
1595
|
+
const t = (lo + hi) / 2;
|
|
1596
|
+
const p = segmentPoint(seg, t);
|
|
1597
|
+
const dv = segmentDerivative(seg, t);
|
|
1598
|
+
const r = Math.hypot(p[0] - b[0], p[1] - b[1]);
|
|
1599
|
+
const dl = Math.hypot(dv[0], dv[1]);
|
|
1600
|
+
if (!(r > 1e-9 && dl > 0) || Math.abs(cond([p[0] - b[0], p[1] - b[1]], dv)) > 1e-6 * r * dl) continue;
|
|
1601
|
+
if (!out.some((q) => Math.hypot(q.x - p[0], q.y - p[1]) <= 1e-9 * (1 + r))) out.push({ x: p[0], y: p[1] });
|
|
1602
|
+
}
|
|
1603
|
+
return out;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
777
1606
|
// src/vector/controller.ts
|
|
1607
|
+
var DEFAULT_VECTOR_SNAP = { grid: 0, objects: true, guides: true, threshold: 5, angles: VECTOR_CONSTRUCTION_ANGLES };
|
|
778
1608
|
var VECTOR_CLIPBOARD_TYPE = "pixerate/vector";
|
|
779
1609
|
var DEFAULT_DEFAULTS = {
|
|
780
1610
|
fill: "#d4d4d8",
|
|
@@ -855,6 +1685,24 @@ function getAnchor(doc, ref) {
|
|
|
855
1685
|
const node = findVectorNode(doc.nodes, ref.nodeId);
|
|
856
1686
|
return node?.type === "path" ? node.subpaths[ref.subpath]?.anchors[ref.index] : void 0;
|
|
857
1687
|
}
|
|
1688
|
+
function getAnchorWorld(doc, ref) {
|
|
1689
|
+
const a = getAnchor(doc, ref);
|
|
1690
|
+
if (!a) return void 0;
|
|
1691
|
+
const m = vectorWorldMatrix(doc.nodes, ref.nodeId) ?? IDENTITY_MATRIX;
|
|
1692
|
+
const p = applyMatrix(m, a);
|
|
1693
|
+
return { x: p.x, y: p.y, ...a.in ? { in: applyMatrix(m, a.in) } : {}, ...a.out ? { out: applyMatrix(m, a.out) } : {}, ...a.type ? { type: a.type } : {} };
|
|
1694
|
+
}
|
|
1695
|
+
function getVectorSelectionFrame(doc, ids) {
|
|
1696
|
+
const nodes = ids.map((id) => ({ node: findVectorNode(doc.nodes, id), parent: vectorParentMatrix(doc.nodes, id) ?? IDENTITY_MATRIX })).filter((n) => n.node);
|
|
1697
|
+
if (!nodes.length) return null;
|
|
1698
|
+
const angles = nodes.map((n) => matrixRotation(multiplyMatrices(nodeMatrix(n.node), n.parent)));
|
|
1699
|
+
const same = angles.every((a) => Math.abs(normalizeAngle(a - angles[0])) < 1e-6);
|
|
1700
|
+
const angle = same ? angles[0] : 0;
|
|
1701
|
+
const unrotate = rotateMatrix(-angle);
|
|
1702
|
+
const b = unionBounds(nodes.map((n) => vectorNodeBounds(n.node, multiplyMatrices(n.parent, unrotate))));
|
|
1703
|
+
return b ? { ...b, angle } : null;
|
|
1704
|
+
}
|
|
1705
|
+
var normalizeAngle = (a) => ((a + 180) % 360 + 360) % 360 - 180;
|
|
858
1706
|
function getTransformHandlePositions(box) {
|
|
859
1707
|
const { x, y, width: w, height: h } = box;
|
|
860
1708
|
return [
|
|
@@ -869,17 +1717,28 @@ function getTransformHandlePositions(box) {
|
|
|
869
1717
|
{ handle: "rotate", x: x + w / 2, y: y - VECTOR_ROTATE_HANDLE_OFFSET }
|
|
870
1718
|
];
|
|
871
1719
|
}
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
}
|
|
1720
|
+
function getVectorSelectionBoxGeometry(frame, viewport) {
|
|
1721
|
+
const z = viewport.zoom;
|
|
1722
|
+
const box = { x: frame.x * z + viewport.x, y: frame.y * z + viewport.y, width: frame.width * z, height: frame.height * z };
|
|
1723
|
+
const rot = rotateMatrix(frame.angle, { x: viewport.x, y: viewport.y });
|
|
1724
|
+
const toScreen2 = (p) => frame.angle ? applyMatrix(rot, p) : { x: p.x, y: p.y };
|
|
1725
|
+
const handles = getTransformHandlePositions(box).map((h) => ({ ...h, screen: toScreen2(h) }));
|
|
1726
|
+
const corners = [
|
|
1727
|
+
{ x: box.x, y: box.y },
|
|
1728
|
+
{ x: box.x + box.width, y: box.y },
|
|
1729
|
+
{ x: box.x + box.width, y: box.y + box.height },
|
|
1730
|
+
{ x: box.x, y: box.y + box.height }
|
|
1731
|
+
].map(toScreen2);
|
|
1732
|
+
const r = (n) => Math.round(n * 1e3) / 1e3;
|
|
1733
|
+
return { box, angle: frame.angle, transform: frame.angle ? `rotate(${r(frame.angle)} ${r(viewport.x)} ${r(viewport.y)})` : "", handles, corners };
|
|
1734
|
+
}
|
|
1735
|
+
var RESIZE_CURSOR_ORDER = ["ew-resize", "nwse-resize", "ns-resize", "nesw-resize"];
|
|
1736
|
+
var HANDLE_ANGLES = { e: 0, se: 45, s: 90, sw: 135, w: 180, nw: 225, n: 270, ne: 315 };
|
|
1737
|
+
function vectorResizeCursor(handle, angle = 0) {
|
|
1738
|
+
if (handle === "rotate") return "grab";
|
|
1739
|
+
const a = ((HANDLE_ANGLES[handle] + angle) % 180 + 180) % 180;
|
|
1740
|
+
return RESIZE_CURSOR_ORDER[Math.round(a / 45) % 4];
|
|
1741
|
+
}
|
|
883
1742
|
var TOOL_LABELS = {
|
|
884
1743
|
select: "Select",
|
|
885
1744
|
direct: "Direct Select",
|
|
@@ -938,6 +1797,7 @@ function mapTree(nodes, fn) {
|
|
|
938
1797
|
}
|
|
939
1798
|
return changed ? out : nodes;
|
|
940
1799
|
}
|
|
1800
|
+
var isRoot = (m) => m === IDENTITY_MATRIX || m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1 && m[4] === 0 && m[5] === 0;
|
|
941
1801
|
var idCounter = 0;
|
|
942
1802
|
var defaultCreateId = () => `vn-${Date.now().toString(36)}${(++idCounter).toString(36)}`;
|
|
943
1803
|
var VectorEditorController = class {
|
|
@@ -956,6 +1816,11 @@ var VectorEditorController = class {
|
|
|
956
1816
|
pointer = null;
|
|
957
1817
|
drag = null;
|
|
958
1818
|
interaction = null;
|
|
1819
|
+
guides = null;
|
|
1820
|
+
/** Whether the last `snapDrawn` landed on an object (guides may be hidden). */
|
|
1821
|
+
snappedToObject = false;
|
|
1822
|
+
/** Drawing targets for hovering the pen and shape tools, kept while the document and the excluded path stay the same. */
|
|
1823
|
+
hoverTargets = null;
|
|
959
1824
|
spaceHeld = false;
|
|
960
1825
|
clipboard = null;
|
|
961
1826
|
createId;
|
|
@@ -973,7 +1838,7 @@ var VectorEditorController = class {
|
|
|
973
1838
|
this.tool = options.tool ?? "select";
|
|
974
1839
|
this.defaults = { ...DEFAULT_DEFAULTS, ...options.defaults ?? {} };
|
|
975
1840
|
this.viewport = { x: 0, y: 0, zoom: 1, ...options.viewport ?? {} };
|
|
976
|
-
this.snap = {
|
|
1841
|
+
this.snap = { ...DEFAULT_VECTOR_SNAP, ...options.snap ?? {} };
|
|
977
1842
|
this.hitRadius = options.hitRadius ?? 6;
|
|
978
1843
|
this.createId = options.createId ?? defaultCreateId;
|
|
979
1844
|
this.onChange = options.onChange;
|
|
@@ -1018,9 +1883,11 @@ var VectorEditorController = class {
|
|
|
1018
1883
|
selectedAnchors: this.anchors,
|
|
1019
1884
|
editingId: this.editingId,
|
|
1020
1885
|
selectionBounds: this.boundsOf(this.selection),
|
|
1886
|
+
selectionFrame: this.frameOf(this.selectedRoots()),
|
|
1021
1887
|
viewport: this.viewport,
|
|
1022
1888
|
defaults: this.defaults,
|
|
1023
1889
|
snap: this.snap,
|
|
1890
|
+
guides: this.guides,
|
|
1024
1891
|
hitRadius: this.hitRadius,
|
|
1025
1892
|
hover: this.hover,
|
|
1026
1893
|
cursor: this.cursorFor(),
|
|
@@ -1048,7 +1915,23 @@ var VectorEditorController = class {
|
|
|
1048
1915
|
});
|
|
1049
1916
|
}
|
|
1050
1917
|
boundsOf(ids) {
|
|
1051
|
-
return unionBounds(
|
|
1918
|
+
return unionBounds(
|
|
1919
|
+
ids.map((id) => {
|
|
1920
|
+
const n = this.getNode(id);
|
|
1921
|
+
return n ? vectorNodeBounds(n, vectorParentMatrix(this.doc.nodes, id) ?? IDENTITY_MATRIX) : null;
|
|
1922
|
+
})
|
|
1923
|
+
);
|
|
1924
|
+
}
|
|
1925
|
+
frameOf(ids) {
|
|
1926
|
+
return getVectorSelectionFrame(this.doc, ids);
|
|
1927
|
+
}
|
|
1928
|
+
/** Local → document matrix of a node in `doc` (default: the current document). */
|
|
1929
|
+
worldOf(id, doc = this.doc) {
|
|
1930
|
+
return vectorWorldMatrix(doc.nodes, id) ?? IDENTITY_MATRIX;
|
|
1931
|
+
}
|
|
1932
|
+
/** A document point in node `id`'s own space. */
|
|
1933
|
+
toLocal(id, p, doc = this.doc) {
|
|
1934
|
+
return applyMatrix(invertMatrix(this.worldOf(id, doc)), p);
|
|
1052
1935
|
}
|
|
1053
1936
|
cursorFor() {
|
|
1054
1937
|
if (this.drag?.kind === "pan" || this.spaceHeld) return this.drag ? "grabbing" : "grab";
|
|
@@ -1057,7 +1940,7 @@ var VectorEditorController = class {
|
|
|
1057
1940
|
const h = this.hover;
|
|
1058
1941
|
switch (this.tool) {
|
|
1059
1942
|
case "select":
|
|
1060
|
-
if (h?.transformHandle) return
|
|
1943
|
+
if (h?.transformHandle) return vectorResizeCursor(h.transformHandle, this.frameOf(this.selectedRoots())?.angle ?? 0);
|
|
1061
1944
|
return h?.nodeId ? "move" : "default";
|
|
1062
1945
|
case "direct":
|
|
1063
1946
|
if (h?.anchor || h?.handle) return "pointer";
|
|
@@ -1189,6 +2072,7 @@ var VectorEditorController = class {
|
|
|
1189
2072
|
this.cancelDrag();
|
|
1190
2073
|
this.tool = tool;
|
|
1191
2074
|
this.hover = null;
|
|
2075
|
+
this.guides = null;
|
|
1192
2076
|
if (tool !== "direct") {
|
|
1193
2077
|
this.anchors = [];
|
|
1194
2078
|
this.editingId = null;
|
|
@@ -1201,18 +2085,74 @@ var VectorEditorController = class {
|
|
|
1201
2085
|
}
|
|
1202
2086
|
setSnap(snap) {
|
|
1203
2087
|
this.snap = { ...this.snap, ...snap };
|
|
2088
|
+
if (!this.snap.guides || !this.snap.objects) this.guides = null;
|
|
1204
2089
|
this.notify();
|
|
1205
2090
|
}
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
return { x: Math.round(p.x / g) * g, y: Math.round(p.y / g) * g };
|
|
2091
|
+
/** Turns object snapping on or off (the toolbar's snapping toggle). */
|
|
2092
|
+
toggleSnapping(on = !this.snap.objects) {
|
|
2093
|
+
this.setSnap({ objects: on });
|
|
1210
2094
|
}
|
|
1211
|
-
|
|
2095
|
+
gridValue(v) {
|
|
1212
2096
|
const g = this.snap.grid;
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
2097
|
+
return g > 0 ? Math.round(v / g) * g : v;
|
|
2098
|
+
}
|
|
2099
|
+
snapPoint(p) {
|
|
2100
|
+
return { x: this.gridValue(p.x), y: this.gridValue(p.y) };
|
|
2101
|
+
}
|
|
2102
|
+
/** The pull distance in document units. */
|
|
2103
|
+
snapTolerance() {
|
|
2104
|
+
return this.snap.threshold / this.viewport.zoom;
|
|
2105
|
+
}
|
|
2106
|
+
setGuides(guides) {
|
|
2107
|
+
this.guides = guides && this.snap.guides && (guides.lines.length || guides.labels.length) ? guides : null;
|
|
2108
|
+
}
|
|
2109
|
+
/**
|
|
2110
|
+
* Where a drawn or dragged point `p` goes: onto the smart guide `targets` (gathered by
|
|
2111
|
+
* `gather` when missing), else onto the grid, kept to `leave`'s constraint. Cmd / Ctrl
|
|
2112
|
+
* bypasses snapping.
|
|
2113
|
+
*/
|
|
2114
|
+
snapDrawn(p, leave, input, targets) {
|
|
2115
|
+
this.snappedToObject = false;
|
|
2116
|
+
if (input.mod) {
|
|
2117
|
+
this.setGuides(null);
|
|
2118
|
+
return constrainToLeave(leave, p);
|
|
2119
|
+
}
|
|
2120
|
+
if (this.snap.objects) {
|
|
2121
|
+
const withAngles = leave ? { ...leave, angles: this.snap.angles } : null;
|
|
2122
|
+
const res = targets().snapFrom(p, withAngles, this.snapTolerance());
|
|
2123
|
+
if (res.kind) {
|
|
2124
|
+
this.snappedToObject = true;
|
|
2125
|
+
this.setGuides(res);
|
|
2126
|
+
return leave?.constrain ? res.point : { x: res.x ? res.point.x : this.gridValue(res.point.x), y: res.y ? res.point.y : this.gridValue(res.point.y) };
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
this.setGuides(null);
|
|
2130
|
+
return constrainToLeave(leave, this.snapPoint(p));
|
|
2131
|
+
}
|
|
2132
|
+
/** Drawing targets for the pen and shape tools, reused while the document stays the same. */
|
|
2133
|
+
drawingTargets(exclude) {
|
|
2134
|
+
const key = exclude ?? "";
|
|
2135
|
+
const cached = this.hoverTargets;
|
|
2136
|
+
if (cached && cached.doc === this.doc && cached.exclude === key) return cached.targets;
|
|
2137
|
+
const targets = VectorSnapTargets.forDrawing(this.doc, exclude ? [exclude] : []);
|
|
2138
|
+
this.hoverTargets = { doc: this.doc, exclude: key, targets };
|
|
2139
|
+
return targets;
|
|
2140
|
+
}
|
|
2141
|
+
/** Where the pointer at `p` would place a point with the pen or a shape tool (and the guides showing why). */
|
|
2142
|
+
hoverSnap(p, input) {
|
|
2143
|
+
if (this.tool === "pen") {
|
|
2144
|
+
const sp = this.pen ? this.getNode(this.pen.nodeId)?.subpaths[this.pen.subpath] : void 0;
|
|
2145
|
+
if (this.pen && sp && this.hover?.anchor) {
|
|
2146
|
+
this.setGuides(null);
|
|
2147
|
+
return p;
|
|
2148
|
+
}
|
|
2149
|
+
const last = this.pen && sp ? getAnchorWorld(this.doc, { nodeId: this.pen.nodeId, subpath: this.pen.subpath, index: sp.anchors.length - 1 }) : void 0;
|
|
2150
|
+
const leave = last ? { at: { x: last.x, y: last.y }, constrain: input.shift ? "angle" : null } : null;
|
|
2151
|
+
return this.snapDrawn(p, leave, input, () => this.drawingTargets(this.pen?.nodeId ?? null));
|
|
2152
|
+
}
|
|
2153
|
+
if (this.tool !== "select" && this.tool !== "direct" && this.tool !== "pencil") return this.snapDrawn(p, null, input, () => this.drawingTargets(null));
|
|
2154
|
+
this.setGuides(null);
|
|
2155
|
+
return p;
|
|
1216
2156
|
}
|
|
1217
2157
|
// -------------------------------------------------------------------------
|
|
1218
2158
|
// Document
|
|
@@ -1454,6 +2394,11 @@ var VectorEditorController = class {
|
|
|
1454
2394
|
// -------------------------------------------------------------------------
|
|
1455
2395
|
// Transforms
|
|
1456
2396
|
// -------------------------------------------------------------------------
|
|
2397
|
+
/** `m`, given in a frame rotated by `angle`, as a document-space matrix. */
|
|
2398
|
+
inFrame(angle, m) {
|
|
2399
|
+
if (!angle) return m;
|
|
2400
|
+
return multiplyMatrices(multiplyMatrices(rotateMatrix(-angle), m), rotateMatrix(angle));
|
|
2401
|
+
}
|
|
1457
2402
|
/** Applies a matrix to the selected (or given) nodes as one undo step. */
|
|
1458
2403
|
transform(matrix, options = {}) {
|
|
1459
2404
|
const ids = new Set(options.ids ?? this.selectedRoots());
|
|
@@ -1463,8 +2408,25 @@ var VectorEditorController = class {
|
|
|
1463
2408
|
});
|
|
1464
2409
|
return { ok: true };
|
|
1465
2410
|
}
|
|
2411
|
+
/** `doc` with nodes `ids` moved by `m`, given in document space. */
|
|
1466
2412
|
transformed(doc, ids, m) {
|
|
1467
|
-
const
|
|
2413
|
+
const visit = (list, parent) => {
|
|
2414
|
+
let changed = false;
|
|
2415
|
+
const out = list.map((n) => {
|
|
2416
|
+
let next = n;
|
|
2417
|
+
if (ids.has(n.id) && !n.locked) {
|
|
2418
|
+
const local = isRoot(parent) ? m : multiplyMatrices(multiplyMatrices(parent, m), invertMatrix(parent));
|
|
2419
|
+
next = transformVectorNode(n, local);
|
|
2420
|
+
} else if (n.type === "group") {
|
|
2421
|
+
const children = visit(n.children, multiplyMatrices(nodeMatrix(n), parent));
|
|
2422
|
+
if (children !== n.children) next = { ...n, children };
|
|
2423
|
+
}
|
|
2424
|
+
if (next !== n) changed = true;
|
|
2425
|
+
return next;
|
|
2426
|
+
});
|
|
2427
|
+
return changed ? out : list;
|
|
2428
|
+
};
|
|
2429
|
+
const nodes = visit(doc.nodes, IDENTITY_MATRIX);
|
|
1468
2430
|
return nodes === doc.nodes ? doc : { ...doc, nodes };
|
|
1469
2431
|
}
|
|
1470
2432
|
translate(dx, dy, options = {}) {
|
|
@@ -1476,26 +2438,48 @@ var VectorEditorController = class {
|
|
|
1476
2438
|
}
|
|
1477
2439
|
return this.transform(translateMatrix(dx, dy), { ids: options.ids, label: "Move" });
|
|
1478
2440
|
}
|
|
1479
|
-
/**
|
|
2441
|
+
/** The selection frame's centre in document space. */
|
|
2442
|
+
frameCenter(frame) {
|
|
2443
|
+
return applyMatrix(rotateMatrix(frame.angle), { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 });
|
|
2444
|
+
}
|
|
2445
|
+
/**
|
|
2446
|
+
* Scales along the selection box's own axes (rotated with the selection when its nodes
|
|
2447
|
+
* share a rotation) around `origin` (default: the box centre, in document space).
|
|
2448
|
+
*/
|
|
1480
2449
|
scale(sx, sy = sx, options = {}) {
|
|
1481
|
-
const
|
|
1482
|
-
if (!
|
|
1483
|
-
const origin = options.origin ??
|
|
1484
|
-
return this.transform(scaleMatrix(sx, sy, origin), { ids: options.ids, label: "Scale" });
|
|
2450
|
+
const frame = this.frameOf(options.ids ?? this.selectedRoots());
|
|
2451
|
+
if (!frame) return { ok: false, message: "Nothing is selected." };
|
|
2452
|
+
const origin = applyMatrix(rotateMatrix(-frame.angle), options.origin ?? this.frameCenter(frame));
|
|
2453
|
+
return this.transform(this.inFrame(frame.angle, scaleMatrix(sx, sy, origin)), { ids: options.ids, label: "Scale" });
|
|
1485
2454
|
}
|
|
1486
2455
|
/** Rotates clockwise by `degrees` around `origin` (default: the selection centre). */
|
|
1487
2456
|
rotate(degrees, options = {}) {
|
|
1488
|
-
const
|
|
1489
|
-
if (!
|
|
1490
|
-
const origin = options.origin ??
|
|
2457
|
+
const frame = this.frameOf(options.ids ?? this.selectedRoots());
|
|
2458
|
+
if (!frame) return { ok: false, message: "Nothing is selected." };
|
|
2459
|
+
const origin = options.origin ?? this.frameCenter(frame);
|
|
1491
2460
|
return this.transform(rotateMatrix(degrees, origin), { ids: options.ids, label: "Rotate" });
|
|
1492
2461
|
}
|
|
2462
|
+
/** Sets each selected (or given) node's rotation to `degrees`, turning it around its own centre. */
|
|
2463
|
+
setRotation(degrees, options = {}) {
|
|
2464
|
+
const ids = options.ids ?? this.selectedRoots();
|
|
2465
|
+
if (!ids.length) return { ok: false, message: "Nothing is selected." };
|
|
2466
|
+
this.change("Set Rotation", () => {
|
|
2467
|
+
for (const id of ids) {
|
|
2468
|
+
const frame = this.frameOf([id]);
|
|
2469
|
+
if (!frame) continue;
|
|
2470
|
+
const delta = normalizeAngle(degrees - frame.angle);
|
|
2471
|
+
if (Math.abs(delta) > 1e-9) this.doc = this.transformed(this.doc, /* @__PURE__ */ new Set([id]), rotateMatrix(delta, this.frameCenter(frame)));
|
|
2472
|
+
}
|
|
2473
|
+
});
|
|
2474
|
+
return { ok: true };
|
|
2475
|
+
}
|
|
2476
|
+
/** Mirrors along the selection box's own axes. */
|
|
1493
2477
|
flip(axis, options = {}) {
|
|
1494
|
-
const
|
|
1495
|
-
if (!
|
|
1496
|
-
const c = { x:
|
|
2478
|
+
const frame = this.frameOf(options.ids ?? this.selectedRoots());
|
|
2479
|
+
if (!frame) return { ok: false, message: "Nothing is selected." };
|
|
2480
|
+
const c = { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 };
|
|
1497
2481
|
const m = axis === "horizontal" ? scaleMatrix(-1, 1, c) : scaleMatrix(1, -1, c);
|
|
1498
|
-
return this.transform(m, { ids: options.ids, label: axis === "horizontal" ? "Flip Horizontal" : "Flip Vertical" });
|
|
2482
|
+
return this.transform(this.inFrame(frame.angle, m), { ids: options.ids, label: axis === "horizontal" ? "Flip Horizontal" : "Flip Vertical" });
|
|
1499
2483
|
}
|
|
1500
2484
|
/**
|
|
1501
2485
|
* Aligns the selected nodes to their combined bounds, or to the artboard when one node is
|
|
@@ -1620,7 +2604,10 @@ var VectorEditorController = class {
|
|
|
1620
2604
|
if (set.has(n.id) && n.type === "group") {
|
|
1621
2605
|
changed = true;
|
|
1622
2606
|
const opacity = n.opacity;
|
|
1623
|
-
|
|
2607
|
+
const m = nodeMatrix(n);
|
|
2608
|
+
out.push(
|
|
2609
|
+
...n.children.map((c) => n.transform ? transformVectorNode(c, m) : c).map((c) => opacity !== void 0 && opacity < 1 ? { ...c, opacity: (c.opacity ?? 1) * opacity } : c)
|
|
2610
|
+
);
|
|
1624
2611
|
} else if (n.type === "group") {
|
|
1625
2612
|
const children = flatten(n.children);
|
|
1626
2613
|
if (children !== n.children) changed = true;
|
|
@@ -1668,15 +2655,22 @@ var VectorEditorController = class {
|
|
|
1668
2655
|
if (paths.length < 2) return null;
|
|
1669
2656
|
const result = pathBoolean(
|
|
1670
2657
|
BOOLEAN_OPS[op],
|
|
1671
|
-
paths.map((p) => ({
|
|
2658
|
+
paths.map((p) => ({
|
|
2659
|
+
path: vectorPathWorldBezier(
|
|
2660
|
+
{ ...p, subpaths: p.subpaths.filter((s) => s.anchors.length > 1).map((s) => ({ ...s, closed: true })) },
|
|
2661
|
+
vectorParentMatrix(this.doc.nodes, p.id) ?? IDENTITY_MATRIX
|
|
2662
|
+
),
|
|
2663
|
+
fillRule: p.fillRule ?? "nonzero"
|
|
2664
|
+
}))
|
|
1672
2665
|
);
|
|
1673
|
-
const subpaths = bezierToSubpaths(result.path);
|
|
1674
|
-
if (result.error || !subpaths.length) return null;
|
|
1675
2666
|
const base = paths[0];
|
|
2667
|
+
const anchorRoot = roots.find((r) => findVectorNode([this.getNode(r)], base.id)) ?? base.id;
|
|
2668
|
+
const subpaths = bezierToSubpaths(this.intoParentSpace(anchorRoot, result.path));
|
|
2669
|
+
if (result.error || !subpaths.length) return null;
|
|
1676
2670
|
const id = this.newId();
|
|
1677
|
-
const
|
|
2671
|
+
const { transform: _t, ...baseRest } = cloneVectorNode(base);
|
|
2672
|
+
const node = { ...baseRest, id, name: BOOLEAN_LABELS[op], subpaths };
|
|
1678
2673
|
const drop = /* @__PURE__ */ new Set([...paths.map((p) => p.id), ...roots]);
|
|
1679
|
-
const anchorRoot = roots.find((r) => findVectorNode([this.getNode(r)], base.id)) ?? base.id;
|
|
1680
2674
|
this.change(BOOLEAN_LABELS[op], () => {
|
|
1681
2675
|
this.setDoc(mapTree(this.doc.nodes, (n) => n.id === anchorRoot ? node : drop.has(n.id) ? null : n));
|
|
1682
2676
|
this.selection = [id];
|
|
@@ -1694,9 +2688,11 @@ var VectorEditorController = class {
|
|
|
1694
2688
|
});
|
|
1695
2689
|
if (paths.length < 2) return null;
|
|
1696
2690
|
const id = this.newId();
|
|
1697
|
-
const node = { ...cloneVectorNode(paths[0]), id, name: "Compound Path", subpaths: paths.flatMap((p) => cloneVectorNode(p).subpaths) };
|
|
1698
|
-
const drop = /* @__PURE__ */ new Set([...paths.map((p) => p.id), ...roots]);
|
|
1699
2691
|
const first = roots.find((r) => findVectorNode([this.getNode(r)], paths[0].id)) ?? paths[0].id;
|
|
2692
|
+
const subpaths = paths.flatMap((p) => bezierToSubpaths(this.intoParentSpace(first, vectorPathWorldBezier(p, vectorParentMatrix(this.doc.nodes, p.id) ?? IDENTITY_MATRIX))));
|
|
2693
|
+
const { transform: _t, ...baseRest } = cloneVectorNode(paths[0]);
|
|
2694
|
+
const node = { ...baseRest, id, name: "Compound Path", subpaths };
|
|
2695
|
+
const drop = /* @__PURE__ */ new Set([...paths.map((p) => p.id), ...roots]);
|
|
1700
2696
|
this.change("Combine", () => {
|
|
1701
2697
|
this.setDoc(mapTree(this.doc.nodes, (n) => n.id === first ? node : drop.has(n.id) ? null : n));
|
|
1702
2698
|
this.selection = [id];
|
|
@@ -1704,6 +2700,11 @@ var VectorEditorController = class {
|
|
|
1704
2700
|
});
|
|
1705
2701
|
return id;
|
|
1706
2702
|
}
|
|
2703
|
+
/** A document-space path mapped into the parent space of node `id`. */
|
|
2704
|
+
intoParentSpace(id, path) {
|
|
2705
|
+
const parent = vectorParentMatrix(this.doc.nodes, id) ?? IDENTITY_MATRIX;
|
|
2706
|
+
return isRoot(parent) ? path : transformBezierPath(path, invertMatrix(parent));
|
|
2707
|
+
}
|
|
1707
2708
|
/** Splits selected compound paths into one path per subpath. */
|
|
1708
2709
|
breakApart(options = {}) {
|
|
1709
2710
|
const paths = (options.ids ?? this.selection).map((id) => this.getNode(id)).filter((n) => n?.type === "path" && n.subpaths.length > 1);
|
|
@@ -1743,23 +2744,30 @@ var VectorEditorController = class {
|
|
|
1743
2744
|
});
|
|
1744
2745
|
return nodes === doc.nodes ? doc : { ...doc, nodes };
|
|
1745
2746
|
}
|
|
2747
|
+
/** Moves anchors by a document-space offset (converted into each node's own space). */
|
|
1746
2748
|
movedAnchors(doc, refs, dx, dy) {
|
|
1747
|
-
|
|
2749
|
+
const local = /* @__PURE__ */ new Map();
|
|
2750
|
+
for (const r of refs) if (!local.has(r.nodeId)) local.set(r.nodeId, applyMatrixLinear(invertMatrix(this.worldOf(r.nodeId, doc)), { x: dx, y: dy }));
|
|
2751
|
+
return this.updateAnchors(doc, refs, (a, ref) => {
|
|
2752
|
+
const d = local.get(ref.nodeId);
|
|
2753
|
+
return translateAnchor(a, d.x, d.y);
|
|
2754
|
+
});
|
|
1748
2755
|
}
|
|
1749
|
-
/** Moves one anchor to a position (handles follow). */
|
|
2756
|
+
/** Moves one anchor to a document position (handles follow). */
|
|
1750
2757
|
moveAnchor(ref, to) {
|
|
1751
|
-
const a =
|
|
2758
|
+
const a = getAnchorWorld(this.doc, ref);
|
|
1752
2759
|
if (!a) return { ok: false, message: "There is no such anchor." };
|
|
1753
2760
|
this.change("Move Point", () => {
|
|
1754
2761
|
this.doc = this.movedAnchors(this.doc, [ref], to.x - a.x, to.y - a.y);
|
|
1755
2762
|
});
|
|
1756
2763
|
return { ok: true };
|
|
1757
2764
|
}
|
|
1758
|
-
/** Moves one handle (see `moveAnchorHandle` for how the opposite handle follows). */
|
|
2765
|
+
/** Moves one handle to a document position (see `moveAnchorHandle` for how the opposite handle follows). */
|
|
1759
2766
|
moveHandle(ref, to, options = {}) {
|
|
1760
2767
|
if (!getAnchor(this.doc, ref)) return { ok: false, message: "There is no such anchor." };
|
|
2768
|
+
const local = this.toLocal(ref.nodeId, to);
|
|
1761
2769
|
this.change("Move Handle", () => {
|
|
1762
|
-
this.doc = this.updateAnchors(this.doc, [ref], (a) => moveAnchorHandle(a, ref.handle,
|
|
2770
|
+
this.doc = this.updateAnchors(this.doc, [ref], (a) => moveAnchorHandle(a, ref.handle, local, options.breakHandles));
|
|
1763
2771
|
});
|
|
1764
2772
|
return { ok: true };
|
|
1765
2773
|
}
|
|
@@ -1778,11 +2786,11 @@ var VectorEditorController = class {
|
|
|
1778
2786
|
});
|
|
1779
2787
|
return { ok: true };
|
|
1780
2788
|
}
|
|
1781
|
-
/** Adds an anchor on the nearest segment of `nodeId` to `point` (shape unchanged). */
|
|
2789
|
+
/** Adds an anchor on the nearest segment of `nodeId` to document point `point` (shape unchanged). */
|
|
1782
2790
|
insertAnchorAt(nodeId, point) {
|
|
1783
2791
|
const node = this.getNode(nodeId);
|
|
1784
2792
|
if (node?.type !== "path") return null;
|
|
1785
|
-
const hit = nearestSegment(node, point);
|
|
2793
|
+
const hit = nearestSegment(node, this.toLocal(nodeId, point));
|
|
1786
2794
|
if (!hit) return null;
|
|
1787
2795
|
const { subpath, index } = insertAnchor(node.subpaths[hit.subpath], hit.segment, hit.t);
|
|
1788
2796
|
const ref = { nodeId, subpath: hit.subpath, index };
|
|
@@ -1939,19 +2947,17 @@ var VectorEditorController = class {
|
|
|
1939
2947
|
}
|
|
1940
2948
|
hitTransformHandle(screen) {
|
|
1941
2949
|
if (this.tool !== "select" || !this.selection.length) return null;
|
|
1942
|
-
const
|
|
1943
|
-
if (!
|
|
1944
|
-
const
|
|
1945
|
-
const box = { x: tl.x, y: tl.y, width: b.width * this.viewport.zoom, height: b.height * this.viewport.zoom };
|
|
2950
|
+
const frame = this.frameOf(this.selectedRoots());
|
|
2951
|
+
if (!frame) return null;
|
|
2952
|
+
const { box, handles } = getVectorSelectionBoxGeometry(frame, this.viewport);
|
|
1946
2953
|
const r = this.hitRadius + 2;
|
|
1947
|
-
const handles = getTransformHandlePositions(box);
|
|
1948
2954
|
const usable = box.width < 2 * r || box.height < 2 * r ? handles.filter((h) => h.handle.length === 2 || h.handle === "rotate") : handles;
|
|
1949
|
-
for (const h of usable) if (Math.
|
|
2955
|
+
for (const h of usable) if (Math.hypot(h.screen.x - screen.x, h.screen.y - screen.y) <= r * Math.SQRT2) return h.handle;
|
|
1950
2956
|
return null;
|
|
1951
2957
|
}
|
|
1952
2958
|
hitHandle(screen) {
|
|
1953
2959
|
for (const ref of getVisibleHandles(this.view()).reverse()) {
|
|
1954
|
-
const a =
|
|
2960
|
+
const a = getAnchorWorld(this.doc, ref);
|
|
1955
2961
|
const h = a?.[ref.handle];
|
|
1956
2962
|
if (!h) continue;
|
|
1957
2963
|
const s = this.documentToScreen(h);
|
|
@@ -1963,7 +2969,7 @@ var VectorEditorController = class {
|
|
|
1963
2969
|
let best = null;
|
|
1964
2970
|
let bestD = Infinity;
|
|
1965
2971
|
for (const ref of refs) {
|
|
1966
|
-
const a =
|
|
2972
|
+
const a = getAnchorWorld(this.doc, ref);
|
|
1967
2973
|
if (!a) continue;
|
|
1968
2974
|
const s = this.documentToScreen(a);
|
|
1969
2975
|
const d = Math.hypot(s.x - screen.x, s.y - screen.y);
|
|
@@ -1977,18 +2983,18 @@ var VectorEditorController = class {
|
|
|
1977
2983
|
/** Deepest unlocked visible path under `p`. */
|
|
1978
2984
|
hitPath(p) {
|
|
1979
2985
|
const tol = this.tolerance();
|
|
1980
|
-
const search = (list) => {
|
|
2986
|
+
const search = (list, parent) => {
|
|
1981
2987
|
for (let i = list.length - 1; i >= 0; i--) {
|
|
1982
2988
|
const n = list[i];
|
|
1983
2989
|
if (n.visible === false || n.locked) continue;
|
|
1984
2990
|
if (n.type === "group") {
|
|
1985
|
-
const found = search(n.children);
|
|
2991
|
+
const found = search(n.children, multiplyMatrices(nodeMatrix(n), parent));
|
|
1986
2992
|
if (found) return found;
|
|
1987
|
-
} else if (hitTestVectorNode(n, p, tol)) return n;
|
|
2993
|
+
} else if (hitTestVectorNode(n, p, tol, parent)) return n;
|
|
1988
2994
|
}
|
|
1989
2995
|
return null;
|
|
1990
2996
|
};
|
|
1991
|
-
return search(this.doc.nodes);
|
|
2997
|
+
return search(this.doc.nodes, IDENTITY_MATRIX);
|
|
1992
2998
|
}
|
|
1993
2999
|
/** Endpoints of open subpaths of unlocked visible paths (pen tool resumes from them). */
|
|
1994
3000
|
openEndpoints() {
|
|
@@ -2019,8 +3025,11 @@ var VectorEditorController = class {
|
|
|
2019
3025
|
if (anchor) return { nodeId: anchor.nodeId, anchor };
|
|
2020
3026
|
for (const id of getEditablePathIds(this.view())) {
|
|
2021
3027
|
const node = this.getNode(id);
|
|
2022
|
-
const seg = nearestSegment(node, p);
|
|
2023
|
-
if (seg && seg.distance <= this.tolerance())
|
|
3028
|
+
const seg = nearestSegment(node, this.toLocal(id, p));
|
|
3029
|
+
if (seg && seg.distance <= this.tolerance()) {
|
|
3030
|
+
const point = applyMatrix(this.worldOf(id), seg.point);
|
|
3031
|
+
return { nodeId: id, segment: { nodeId: id, subpath: seg.subpath, segment: seg.segment, t: seg.t, point } };
|
|
3032
|
+
}
|
|
2024
3033
|
}
|
|
2025
3034
|
const path = this.hitPath(p);
|
|
2026
3035
|
return path ? { nodeId: path.id } : null;
|
|
@@ -2059,15 +3068,16 @@ var VectorEditorController = class {
|
|
|
2059
3068
|
case "pencil":
|
|
2060
3069
|
return this.downPencil(p);
|
|
2061
3070
|
default:
|
|
2062
|
-
return this.downShape(p);
|
|
3071
|
+
return this.downShape(p, input);
|
|
2063
3072
|
}
|
|
2064
3073
|
}
|
|
2065
3074
|
downSelect(screen, p, input) {
|
|
2066
3075
|
const handle = this.hitTransformHandle(screen);
|
|
2067
3076
|
if (handle) {
|
|
2068
|
-
const
|
|
3077
|
+
const frame = this.frameOf(this.selectedRoots());
|
|
2069
3078
|
const before = this.snapshot();
|
|
2070
|
-
|
|
3079
|
+
const center = applyMatrix(rotateMatrix(frame.angle), { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 });
|
|
3080
|
+
this.drag = handle === "rotate" ? { kind: "rotate", start: p, center, before, base: this.doc } : { kind: "scale", handle, start: p, frame, before, base: this.doc };
|
|
2071
3081
|
this.interaction = handle === "rotate" ? { type: "rotate", angle: 0 } : { type: "scale", handle, sx: 1, sy: 1 };
|
|
2072
3082
|
this.notify();
|
|
2073
3083
|
return;
|
|
@@ -2127,13 +3137,13 @@ var VectorEditorController = class {
|
|
|
2127
3137
|
return;
|
|
2128
3138
|
}
|
|
2129
3139
|
} else if (!selected) this.anchors = [anchor];
|
|
2130
|
-
this.drag = { kind: "anchors", start: p, before: this.snapshot(), base: this.doc, moved: false, clickRef: selected && !input.shift ? anchor : null };
|
|
3140
|
+
this.drag = { kind: "anchors", start: p, before: this.snapshot(), base: this.doc, moved: false, clickRef: selected && !input.shift ? anchor : null, lead: anchor };
|
|
2131
3141
|
this.notify();
|
|
2132
3142
|
return;
|
|
2133
3143
|
}
|
|
2134
3144
|
for (const id of getEditablePathIds(this.view())) {
|
|
2135
3145
|
const node = this.getNode(id);
|
|
2136
|
-
const seg = nearestSegment(node, p);
|
|
3146
|
+
const seg = nearestSegment(node, this.toLocal(id, p));
|
|
2137
3147
|
if (!seg || seg.distance > this.tolerance()) continue;
|
|
2138
3148
|
const sp = node.subpaths[seg.subpath];
|
|
2139
3149
|
if (input.alt) {
|
|
@@ -2143,7 +3153,7 @@ var VectorEditorController = class {
|
|
|
2143
3153
|
const ref = { nodeId: id, subpath: seg.subpath, index };
|
|
2144
3154
|
this.anchors = [ref];
|
|
2145
3155
|
this.editingId = id;
|
|
2146
|
-
this.drag = { kind: "anchors", start: p, before, base: this.doc, moved: true, clickRef: null };
|
|
3156
|
+
this.drag = { kind: "anchors", start: p, before, base: this.doc, moved: true, clickRef: null, lead: ref };
|
|
2147
3157
|
this.notify(true);
|
|
2148
3158
|
return;
|
|
2149
3159
|
}
|
|
@@ -2151,7 +3161,7 @@ var VectorEditorController = class {
|
|
|
2151
3161
|
const b = { nodeId: id, subpath: seg.subpath, index: (seg.segment + 1) % sp.anchors.length };
|
|
2152
3162
|
this.anchors = input.shift ? [...this.anchors.filter((r) => !sameRef(r, a) && !sameRef(r, b)), a, b] : [a, b];
|
|
2153
3163
|
this.editingId = id;
|
|
2154
|
-
this.drag = { kind: "anchors", start: p, before: this.snapshot(), base: this.doc, moved: false, clickRef: null };
|
|
3164
|
+
this.drag = { kind: "anchors", start: p, before: this.snapshot(), base: this.doc, moved: false, clickRef: null, lead: null };
|
|
2155
3165
|
this.notify();
|
|
2156
3166
|
return;
|
|
2157
3167
|
}
|
|
@@ -2161,7 +3171,7 @@ var VectorEditorController = class {
|
|
|
2161
3171
|
if (!input.shift) this.selection = [path.id];
|
|
2162
3172
|
else if (!this.selection.includes(path.id)) this.selection = [...this.selection, path.id];
|
|
2163
3173
|
this.anchors = path.subpaths.flatMap((sp, s) => sp.anchors.map((_, i) => ({ nodeId: path.id, subpath: s, index: i })));
|
|
2164
|
-
this.drag = { kind: "anchors", start: p, before: this.snapshot(), base: this.doc, moved: false, clickRef: null };
|
|
3174
|
+
this.drag = { kind: "anchors", start: p, before: this.snapshot(), base: this.doc, moved: false, clickRef: null, lead: null };
|
|
2165
3175
|
this.notify();
|
|
2166
3176
|
return;
|
|
2167
3177
|
}
|
|
@@ -2186,9 +3196,10 @@ var VectorEditorController = class {
|
|
|
2186
3196
|
this.notify(true);
|
|
2187
3197
|
return;
|
|
2188
3198
|
}
|
|
2189
|
-
|
|
2190
|
-
const
|
|
2191
|
-
|
|
3199
|
+
const last = getAnchorWorld(this.doc, { nodeId: pen.nodeId, subpath: pen.subpath, index: sp.anchors.length - 1 });
|
|
3200
|
+
const leave = { at: { x: last.x, y: last.y }, constrain: input.shift ? "angle" : null };
|
|
3201
|
+
let pt2 = this.snapDrawn(p, leave, input, () => this.drawingTargets(pen.nodeId));
|
|
3202
|
+
pt2 = this.toLocal(pen.nodeId, pt2);
|
|
2192
3203
|
const node2 = this.getNode(pen.nodeId);
|
|
2193
3204
|
const anchors = [...sp.anchors, { x: pt2.x, y: pt2.y }];
|
|
2194
3205
|
this.replaceNode(pen.nodeId, { ...node2, subpaths: node2.subpaths.map((s, i) => i === pen.subpath ? { ...s, anchors } : s) });
|
|
@@ -2218,7 +3229,7 @@ var VectorEditorController = class {
|
|
|
2218
3229
|
this.notify(this.doc !== before.doc);
|
|
2219
3230
|
return;
|
|
2220
3231
|
}
|
|
2221
|
-
const pt = this.
|
|
3232
|
+
const pt = this.snapDrawn(p, null, input, () => this.drawingTargets(null));
|
|
2222
3233
|
const node = { id: this.newId(), type: "path", name: "Path", ...this.styleFor(true), subpaths: [{ anchors: [{ x: pt.x, y: pt.y }], closed: false }] };
|
|
2223
3234
|
this.setDoc([...this.doc.nodes, node]);
|
|
2224
3235
|
this.pen = { nodeId: node.id, subpath: 0 };
|
|
@@ -2246,13 +3257,14 @@ var VectorEditorController = class {
|
|
|
2246
3257
|
this.interaction = { type: "draw", tool: "pencil", nodeId: node.id, bounds: { x: p.x, y: p.y, width: 0, height: 0 } };
|
|
2247
3258
|
this.notify(true);
|
|
2248
3259
|
}
|
|
2249
|
-
downShape(p) {
|
|
3260
|
+
downShape(p, input) {
|
|
2250
3261
|
const before = this.snapshot();
|
|
2251
|
-
const
|
|
3262
|
+
const targets = this.drawingTargets(null);
|
|
3263
|
+
const start = this.snapDrawn(p, null, input, () => targets);
|
|
2252
3264
|
const kind = this.tool;
|
|
2253
3265
|
const node = this.createShapeNode({ kind, x: start.x, y: start.y, width: 0, height: 0 });
|
|
2254
3266
|
this.setDoc([...this.doc.nodes, node]);
|
|
2255
|
-
this.drag = { kind: "shape", tool: this.tool, start, nodeId: node.id, before };
|
|
3267
|
+
this.drag = { kind: "shape", tool: this.tool, start, nodeId: node.id, before, targets };
|
|
2256
3268
|
this.interaction = { type: "draw", tool: this.tool, nodeId: node.id, bounds: { x: start.x, y: start.y, width: 0, height: 0 } };
|
|
2257
3269
|
this.notify(true);
|
|
2258
3270
|
}
|
|
@@ -2263,9 +3275,11 @@ var VectorEditorController = class {
|
|
|
2263
3275
|
if (!drag) {
|
|
2264
3276
|
const hover = this.computeHover(screen);
|
|
2265
3277
|
const pointerMatters = this.tool === "pen" && !!this.pen;
|
|
2266
|
-
|
|
3278
|
+
const guides = this.guides;
|
|
2267
3279
|
this.hover = hover;
|
|
2268
|
-
|
|
3280
|
+
const pointer = this.hoverSnap(p, input);
|
|
3281
|
+
if (this.sameHover(hover, this.state.hover) && !pointerMatters && JSON.stringify(guides) === JSON.stringify(this.guides)) return;
|
|
3282
|
+
this.pointer = pointer;
|
|
2269
3283
|
this.notify();
|
|
2270
3284
|
return;
|
|
2271
3285
|
}
|
|
@@ -2286,17 +3300,26 @@ var VectorEditorController = class {
|
|
|
2286
3300
|
const created = [];
|
|
2287
3301
|
drag.base = this.withDuplicates(drag.base, this.selectedRoots(), { x: 0, y: 0 }, created);
|
|
2288
3302
|
this.selection = created;
|
|
3303
|
+
drag.targets = void 0;
|
|
2289
3304
|
}
|
|
2290
|
-
({ dx, dy } = this.
|
|
3305
|
+
({ dx, dy } = this.snapMove(drag, dx, dy, input));
|
|
2291
3306
|
this.doc = this.transformed(drag.base, new Set(this.selectedRoots()), translateMatrix(dx, dy));
|
|
2292
3307
|
this.interaction = { type: "move", dx, dy };
|
|
2293
3308
|
this.notify(true);
|
|
2294
3309
|
return;
|
|
2295
3310
|
}
|
|
2296
3311
|
case "scale": {
|
|
2297
|
-
const
|
|
3312
|
+
const angle = drag.frame.angle;
|
|
3313
|
+
const q = angle ? applyMatrix(rotateMatrix(-angle), p) : p;
|
|
3314
|
+
let { sx, sy, origin } = scaleFor(drag.handle, drag.frame, q, input);
|
|
3315
|
+
if (this.snap.objects && !input.mod && drag.handle !== "rotate") {
|
|
3316
|
+
drag.targets ??= VectorSnapTargets.forResize(drag.base, this.selectedRoots());
|
|
3317
|
+
const res = drag.targets.snapScale(drag.frame, drag.handle, sx, sy, input, this.snapTolerance());
|
|
3318
|
+
({ sx, sy } = res);
|
|
3319
|
+
this.setGuides(res);
|
|
3320
|
+
} else this.setGuides(null);
|
|
2298
3321
|
const ids = new Set(this.selectedRoots());
|
|
2299
|
-
this.doc = this.transformed(drag.base, ids, scaleMatrix(sx, sy, origin));
|
|
3322
|
+
this.doc = this.transformed(drag.base, ids, this.inFrame(angle, scaleMatrix(sx, sy, origin)));
|
|
2300
3323
|
this.interaction = { type: "scale", handle: drag.handle, sx, sy };
|
|
2301
3324
|
this.notify(true);
|
|
2302
3325
|
return;
|
|
@@ -2316,7 +3339,7 @@ var VectorEditorController = class {
|
|
|
2316
3339
|
this.interaction = { type: "marquee", bounds };
|
|
2317
3340
|
if (drag.anchorsMode) {
|
|
2318
3341
|
const inside = getVisibleAnchors(this.view()).filter((r) => {
|
|
2319
|
-
const a =
|
|
3342
|
+
const a = getAnchorWorld(this.doc, r);
|
|
2320
3343
|
return a.x >= bounds.x && a.x <= bounds.x + bounds.width && a.y >= bounds.y && a.y <= bounds.y + bounds.height;
|
|
2321
3344
|
});
|
|
2322
3345
|
this.anchors = [...drag.baseAnchors, ...inside.filter((r) => !drag.baseAnchors.some((b) => sameRef(b, r)))];
|
|
@@ -2335,35 +3358,41 @@ var VectorEditorController = class {
|
|
|
2335
3358
|
let dy = p.y - drag.start.y;
|
|
2336
3359
|
if (!drag.moved && Math.hypot(dx, dy) * this.viewport.zoom < 2) return;
|
|
2337
3360
|
drag.moved = true;
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
3361
|
+
const lead = this.leadAnchor(drag);
|
|
3362
|
+
if (lead) {
|
|
3363
|
+
const leave = input.shift ? { at: lead, constrain: "axis", curves: false } : null;
|
|
3364
|
+
const q = this.snapDrawn({ x: lead.x + dx, y: lead.y + dy }, leave, input, () => drag.targets ??= VectorSnapTargets.forAnchorDrag(drag.base, this.anchors));
|
|
3365
|
+
dx = q.x - lead.x;
|
|
3366
|
+
dy = q.y - lead.y;
|
|
3367
|
+
} else if (input.shift) Math.abs(dx) > Math.abs(dy) ? dy = 0 : dx = 0;
|
|
2341
3368
|
this.doc = this.movedAnchors(drag.base, this.anchors, dx, dy);
|
|
2342
3369
|
this.interaction = { type: "anchors", dx, dy };
|
|
2343
3370
|
this.notify(true);
|
|
2344
3371
|
return;
|
|
2345
3372
|
}
|
|
2346
3373
|
case "handle": {
|
|
2347
|
-
const anchor =
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
3374
|
+
const anchor = getAnchorWorld(drag.base, drag.ref);
|
|
3375
|
+
const leave = anchor ? { at: { x: anchor.x, y: anchor.y }, constrain: input.shift ? "angle" : null } : null;
|
|
3376
|
+
const pt = this.snapDrawn(p, leave, input, () => drag.targets ??= VectorSnapTargets.forHandle(drag.base, drag.ref.nodeId));
|
|
3377
|
+
const local = this.toLocal(drag.ref.nodeId, pt, drag.base);
|
|
3378
|
+
this.doc = this.updateAnchors(drag.base, [drag.ref], (a) => moveAnchorHandle(a, drag.ref.handle, local, input.alt));
|
|
2351
3379
|
this.interaction = { type: "handle", ref: drag.ref };
|
|
2352
3380
|
this.notify(true);
|
|
2353
3381
|
return;
|
|
2354
3382
|
}
|
|
2355
3383
|
case "pull": {
|
|
2356
|
-
const anchor =
|
|
3384
|
+
const anchor = getAnchorWorld(drag.base, drag.ref);
|
|
2357
3385
|
if (!anchor) return;
|
|
2358
3386
|
if (Math.hypot(p.x - anchor.x, p.y - anchor.y) * this.viewport.zoom < 2) {
|
|
2359
3387
|
this.doc = drag.base;
|
|
2360
3388
|
this.notify(true);
|
|
2361
3389
|
return;
|
|
2362
3390
|
}
|
|
3391
|
+
this.guides = null;
|
|
2363
3392
|
let pt = p;
|
|
2364
3393
|
if (input.shift) pt = constrain45(anchor, pt);
|
|
2365
3394
|
const side = drag.closing ? "in" : "out";
|
|
2366
|
-
const pos = drag.closing ? { x: 2 * anchor.x - pt.x, y: 2 * anchor.y - pt.y } : pt;
|
|
3395
|
+
const pos = this.toLocal(drag.ref.nodeId, drag.closing ? { x: 2 * anchor.x - pt.x, y: 2 * anchor.y - pt.y } : pt, drag.base);
|
|
2367
3396
|
this.doc = this.updateAnchors(drag.base, [drag.ref], (a) => {
|
|
2368
3397
|
if (drag.outOnly || drag.altBreaks && input.alt) return moveAnchorHandle(a, side, pos, true);
|
|
2369
3398
|
return moveAnchorHandle({ ...a, type: "symmetric" }, side, pos);
|
|
@@ -2374,7 +3403,9 @@ var VectorEditorController = class {
|
|
|
2374
3403
|
case "shape": {
|
|
2375
3404
|
const node = this.getNode(drag.nodeId);
|
|
2376
3405
|
if (node?.type !== "path") return;
|
|
2377
|
-
|
|
3406
|
+
const leave = { at: drag.start, constrain: input.shift ? drag.tool === "line" ? "angle" : "square" : null, curves: false };
|
|
3407
|
+
let end = this.snapDrawn(p, leave, input, () => drag.targets ??= this.drawingTargets(null));
|
|
3408
|
+
if (!this.snappedToObject && drag.tool !== "line" && !input.shift && !input.mod && this.snap.objects && drag.targets) end = this.matchShapeSize(drag.targets, drag.start, end, input.alt);
|
|
2378
3409
|
let start = drag.start;
|
|
2379
3410
|
let bounds;
|
|
2380
3411
|
if (drag.tool === "line") {
|
|
@@ -2420,12 +3451,64 @@ var VectorEditorController = class {
|
|
|
2420
3451
|
}
|
|
2421
3452
|
}
|
|
2422
3453
|
}
|
|
3454
|
+
/** Snaps a move of the selection by (dx, dy): its bounds line up with targets or space evenly, else go to the grid. */
|
|
3455
|
+
snapMove(drag, dx, dy, input) {
|
|
3456
|
+
const origin = drag.origin;
|
|
3457
|
+
if (input.mod || !origin) {
|
|
3458
|
+
this.setGuides(null);
|
|
3459
|
+
return { dx, dy };
|
|
3460
|
+
}
|
|
3461
|
+
const axes = { x: !input.shift || dx !== 0, y: !input.shift || dy !== 0 };
|
|
3462
|
+
let res = null;
|
|
3463
|
+
if (this.snap.objects) {
|
|
3464
|
+
drag.targets ??= VectorSnapTargets.forMove(drag.base, this.selectedRoots());
|
|
3465
|
+
res = drag.targets.snapRect({ ...origin, x: origin.x + dx, y: origin.y + dy }, this.snapTolerance(), { spacing: true, axes });
|
|
3466
|
+
}
|
|
3467
|
+
this.setGuides(res);
|
|
3468
|
+
return {
|
|
3469
|
+
dx: res?.x ? dx + res.dx : axes.x ? this.gridValue(origin.x + dx) - origin.x : dx,
|
|
3470
|
+
dy: res?.y ? dy + res.dy : axes.y ? this.gridValue(origin.y + dy) - origin.y : dy
|
|
3471
|
+
};
|
|
3472
|
+
}
|
|
3473
|
+
/** The anchor a point drag snaps by, where it started: the grabbed one, else the moving one nearest the press. */
|
|
3474
|
+
leadAnchor(drag) {
|
|
3475
|
+
const ref = drag.lead && this.anchors.some((a) => sameRef(a, drag.lead)) ? drag.lead : null;
|
|
3476
|
+
if (ref) {
|
|
3477
|
+
const a = getAnchorWorld(drag.base, ref);
|
|
3478
|
+
return a ? { x: a.x, y: a.y } : null;
|
|
3479
|
+
}
|
|
3480
|
+
let best = null;
|
|
3481
|
+
for (const r of this.anchors) {
|
|
3482
|
+
const a = getAnchorWorld(drag.base, r);
|
|
3483
|
+
if (a && (!best || Math.hypot(a.x - drag.start.x, a.y - drag.start.y) < Math.hypot(best.x - drag.start.x, best.y - drag.start.y))) best = { x: a.x, y: a.y };
|
|
3484
|
+
}
|
|
3485
|
+
return best;
|
|
3486
|
+
}
|
|
3487
|
+
/** A shape's dragged corner moved so its width or height matches another object's, when one is within reach. */
|
|
3488
|
+
matchShapeSize(targets, start, end, fromCentre = false) {
|
|
3489
|
+
const k = fromCentre ? 2 : 1;
|
|
3490
|
+
const tol = this.snapTolerance() * k;
|
|
3491
|
+
const out = { ...end };
|
|
3492
|
+
const guides = { lines: [], labels: [] };
|
|
3493
|
+
for (const axis of ["x", "y"]) {
|
|
3494
|
+
const d = end[axis] - start[axis];
|
|
3495
|
+
const len = Math.abs(d) > 1e-9 ? targets.matchSize(Math.abs(d) * k, tol) : null;
|
|
3496
|
+
if (len === null || len <= 0) continue;
|
|
3497
|
+
out[axis] = start[axis] + Math.sign(d) * len / k;
|
|
3498
|
+
const from = { ...out, [axis]: fromCentre ? 2 * start[axis] - out[axis] : start[axis] };
|
|
3499
|
+
guides.lines.push({ kind: "size", x1: from.x, y1: from.y, x2: out.x, y2: out.y });
|
|
3500
|
+
guides.labels.push({ kind: "size", x: (from.x + out.x) / 2, y: (from.y + out.y) / 2, text: `${axis === "x" ? "W" : "H"} ${Math.round(len * 100) / 100}` });
|
|
3501
|
+
}
|
|
3502
|
+
this.setGuides(guides);
|
|
3503
|
+
return out;
|
|
3504
|
+
}
|
|
2423
3505
|
/** Primary button released. */
|
|
2424
3506
|
pointerUp(screen, input = {}) {
|
|
2425
3507
|
if (screen && this.drag && this.drag.kind !== "pan") this.pointerMove(screen, input);
|
|
2426
3508
|
const drag = this.drag;
|
|
2427
3509
|
this.drag = null;
|
|
2428
3510
|
this.interaction = null;
|
|
3511
|
+
this.guides = null;
|
|
2429
3512
|
if (!drag) return;
|
|
2430
3513
|
switch (drag.kind) {
|
|
2431
3514
|
case "pan":
|
|
@@ -2494,9 +3577,10 @@ var VectorEditorController = class {
|
|
|
2494
3577
|
/** The pointer left the canvas. */
|
|
2495
3578
|
pointerLeave() {
|
|
2496
3579
|
if (this.drag) return;
|
|
2497
|
-
if (!this.hover && !this.pointer) return;
|
|
3580
|
+
if (!this.hover && !this.pointer && !this.guides) return;
|
|
2498
3581
|
this.hover = null;
|
|
2499
3582
|
this.pointer = null;
|
|
3583
|
+
this.guides = null;
|
|
2500
3584
|
this.notify();
|
|
2501
3585
|
}
|
|
2502
3586
|
/** Abandons the drag in progress (Escape, lost capture). */
|
|
@@ -2505,6 +3589,7 @@ var VectorEditorController = class {
|
|
|
2505
3589
|
if (!drag) return;
|
|
2506
3590
|
this.drag = null;
|
|
2507
3591
|
this.interaction = null;
|
|
3592
|
+
this.guides = null;
|
|
2508
3593
|
if (drag.kind === "pan") {
|
|
2509
3594
|
this.notify();
|
|
2510
3595
|
return;
|
|
@@ -2540,7 +3625,7 @@ var VectorEditorController = class {
|
|
|
2540
3625
|
const id = this.hover?.segment?.nodeId ?? this.editingId;
|
|
2541
3626
|
if (id) {
|
|
2542
3627
|
const node = this.getNode(id);
|
|
2543
|
-
const seg = node ? nearestSegment(node, p) : null;
|
|
3628
|
+
const seg = node ? nearestSegment(node, this.toLocal(id, p)) : null;
|
|
2544
3629
|
if (seg && seg.distance <= this.tolerance()) this.insertAnchorAt(id, p);
|
|
2545
3630
|
}
|
|
2546
3631
|
return;
|
|
@@ -2692,11 +3777,12 @@ function scaleFor(handle, b, p, mods = {}) {
|
|
|
2692
3777
|
|
|
2693
3778
|
// src/vector/render.ts
|
|
2694
3779
|
var fmt = (n) => Math.round(n * 100) / 100;
|
|
3780
|
+
var fmtMatrix = (m) => `matrix(${m.map((n) => Math.round(n * 1e6) / 1e6).join(" ")})`;
|
|
2695
3781
|
function renderNode(node, defs, idPrefix) {
|
|
2696
3782
|
if (node.visible === false) return null;
|
|
2697
3783
|
if (node.type === "group") {
|
|
2698
3784
|
const children = node.children.map((c) => renderNode(c, defs, idPrefix)).filter((c) => !!c);
|
|
2699
|
-
return { kind: "group", id: node.id, opacity: node.opacity ?? 1, children };
|
|
3785
|
+
return { kind: "group", id: node.id, ...node.transform ? { transform: fmtMatrix(node.transform) } : {}, opacity: node.opacity ?? 1, children };
|
|
2700
3786
|
}
|
|
2701
3787
|
const fill = vectorPaintToSvg(node.fill, `${idPrefix}fill-${node.id}`);
|
|
2702
3788
|
const stroke = vectorPaintToSvg(node.stroke, `${idPrefix}stroke-${node.id}`);
|
|
@@ -2705,6 +3791,7 @@ function renderNode(node, defs, idPrefix) {
|
|
|
2705
3791
|
return {
|
|
2706
3792
|
kind: "path",
|
|
2707
3793
|
id: node.id,
|
|
3794
|
+
...node.transform ? { transform: fmtMatrix(node.transform) } : {},
|
|
2708
3795
|
d: vectorPathData(node),
|
|
2709
3796
|
fill: fill.value,
|
|
2710
3797
|
stroke: stroke.value,
|
|
@@ -2733,8 +3820,14 @@ function getVectorSceneRenderData(state, options = {}) {
|
|
|
2733
3820
|
var toScreen = (state, p) => ({ x: p.x * state.viewport.zoom + state.viewport.x, y: p.y * state.viewport.zoom + state.viewport.y });
|
|
2734
3821
|
function screenOutline(state, node) {
|
|
2735
3822
|
const v = state.viewport;
|
|
2736
|
-
const
|
|
2737
|
-
|
|
3823
|
+
const view = [v.zoom, 0, 0, v.zoom, v.x, v.y];
|
|
3824
|
+
const parent = vectorParentMatrix(state.document.nodes, node.id) ?? IDENTITY_MATRIX;
|
|
3825
|
+
return collectWithMatrix(node, parent).map(({ path, parent: m }) => bezierPathToSvg(transformBezierPath(vectorPathWorldBezier(path, m), view), { precision: 2 })).join(" ");
|
|
3826
|
+
}
|
|
3827
|
+
function collectWithMatrix(node, parent) {
|
|
3828
|
+
if (node.type === "path") return [{ path: node, parent }];
|
|
3829
|
+
const m = multiplyMatrices(nodeMatrix(node), parent);
|
|
3830
|
+
return node.children.flatMap((c) => collectWithMatrix(c, m));
|
|
2738
3831
|
}
|
|
2739
3832
|
function collect(node) {
|
|
2740
3833
|
if (node.type === "path") return [node];
|
|
@@ -2756,29 +3849,27 @@ function getVectorOverlayRenderData(state) {
|
|
|
2756
3849
|
if (state.editingId) outline(state.editingId, true);
|
|
2757
3850
|
if (state.hover?.nodeId && !state.interaction) outline(state.hover.nodeId, false);
|
|
2758
3851
|
let selectionBox = null;
|
|
2759
|
-
if (state.tool === "select" && state.
|
|
2760
|
-
const
|
|
2761
|
-
|
|
2762
|
-
const box = { x: tl.x, y: tl.y, width: b.width * z, height: b.height * z };
|
|
2763
|
-
selectionBox = { ...box, handles: getTransformHandlePositions(box) };
|
|
3852
|
+
if (state.tool === "select" && state.selectionFrame && state.interaction?.type !== "marquee") {
|
|
3853
|
+
const g = getVectorSelectionBoxGeometry(state.selectionFrame, state.viewport);
|
|
3854
|
+
selectionBox = { ...g.box, angle: g.angle, transform: g.transform, corners: g.corners, handles: g.handles };
|
|
2764
3855
|
}
|
|
2765
3856
|
const selected = (r) => state.selectedAnchors.some((a) => a.nodeId === r.nodeId && a.subpath === r.subpath && a.index === r.index);
|
|
2766
3857
|
const sameAnchor = (a, b) => !!a && a.nodeId === b.nodeId && a.subpath === b.subpath && a.index === b.index;
|
|
2767
3858
|
const anchors = getVisibleAnchors(state).map((ref) => {
|
|
2768
|
-
const a =
|
|
3859
|
+
const a = getAnchorWorld(doc, ref);
|
|
2769
3860
|
const s = toScreen(state, a);
|
|
2770
3861
|
const closes = !!state.pen && state.pen.nodeId === ref.nodeId && state.pen.subpath === ref.subpath && ref.index === 0;
|
|
2771
3862
|
return { ref, x: s.x, y: s.y, type: a.type ?? inferAnchorType(a), selected: selected(ref), hovered: sameAnchor(state.hover?.anchor, ref), closes };
|
|
2772
3863
|
});
|
|
2773
3864
|
if (state.tool === "pen" && !state.pen && state.hover?.anchor) {
|
|
2774
|
-
const a =
|
|
3865
|
+
const a = getAnchorWorld(doc, state.hover.anchor);
|
|
2775
3866
|
if (a) {
|
|
2776
3867
|
const s = toScreen(state, a);
|
|
2777
3868
|
anchors.push({ ref: state.hover.anchor, x: s.x, y: s.y, type: a.type ?? "corner", selected: false, hovered: true, closes: false });
|
|
2778
3869
|
}
|
|
2779
3870
|
}
|
|
2780
3871
|
const handles = getVisibleHandles(state).flatMap((ref) => {
|
|
2781
|
-
const a =
|
|
3872
|
+
const a = getAnchorWorld(doc, ref);
|
|
2782
3873
|
const h = a?.[ref.handle];
|
|
2783
3874
|
if (!a || !h) return [];
|
|
2784
3875
|
const s = toScreen(state, h);
|
|
@@ -2796,7 +3887,7 @@ function getVectorOverlayRenderData(state) {
|
|
|
2796
3887
|
if (state.tool === "pen" && state.pen && state.pointer && !state.interaction) {
|
|
2797
3888
|
const node = findVectorNode(doc.nodes, state.pen.nodeId);
|
|
2798
3889
|
const sp = node?.type === "path" ? node.subpaths[state.pen.subpath] : void 0;
|
|
2799
|
-
const last = sp
|
|
3890
|
+
const last = sp ? getAnchorWorld(doc, { nodeId: state.pen.nodeId, subpath: state.pen.subpath, index: sp.anchors.length - 1 }) : void 0;
|
|
2800
3891
|
if (last) {
|
|
2801
3892
|
const a = toScreen(state, last);
|
|
2802
3893
|
const p = toScreen(state, state.pointer);
|
|
@@ -2811,13 +3902,27 @@ function getVectorOverlayRenderData(state) {
|
|
|
2811
3902
|
if (it?.type === "rotate") status = `${Math.round(it.angle)}\xB0`;
|
|
2812
3903
|
else if (it?.type === "move" || it?.type === "anchors") status = `\u0394 ${fmt(it.dx)}, ${fmt(it.dy)}`;
|
|
2813
3904
|
else if (it?.type === "scale" || it?.type === "draw") {
|
|
2814
|
-
const b = it.type === "draw" ? findVectorNode(doc.nodes, it.nodeId) ? vectorNodeBounds(findVectorNode(doc.nodes, it.nodeId)) : null : state.
|
|
3905
|
+
const b = it.type === "draw" ? findVectorNode(doc.nodes, it.nodeId) ? vectorNodeBounds(findVectorNode(doc.nodes, it.nodeId)) : null : state.selectionFrame;
|
|
2815
3906
|
if (b) status = `W ${fmt(b.width)} \xD7 H ${fmt(b.height)}`;
|
|
2816
3907
|
}
|
|
2817
|
-
|
|
3908
|
+
const guides = { lines: [], labels: [] };
|
|
3909
|
+
if (state.guides) {
|
|
3910
|
+
for (const l of state.guides.lines) {
|
|
3911
|
+
const a = toScreen(state, { x: l.x1, y: l.y1 });
|
|
3912
|
+
const b = toScreen(state, { x: l.x2, y: l.y2 });
|
|
3913
|
+
guides.lines.push({ kind: l.kind, x1: fmt(a.x), y1: fmt(a.y), x2: fmt(b.x), y2: fmt(b.y) });
|
|
3914
|
+
}
|
|
3915
|
+
for (const l of state.guides.labels) {
|
|
3916
|
+
const p = toScreen(state, l);
|
|
3917
|
+
guides.labels.push({ kind: l.kind, x: fmt(p.x), y: fmt(p.y), text: l.text });
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
return { outlines, selectionBox, anchors, handles, marquee, penPreview, artboard, guides, status };
|
|
2818
3921
|
}
|
|
2819
3922
|
var VECTOR_OVERLAY_THEME = {
|
|
2820
3923
|
accent: "#0ea5e9",
|
|
3924
|
+
/** Smart guide lines and labels. */
|
|
3925
|
+
guide: "#ff3dfc",
|
|
2821
3926
|
handleFill: "#ffffff",
|
|
2822
3927
|
marqueeFill: "rgba(14, 165, 233, 0.08)",
|
|
2823
3928
|
transformHandleSize: 8,
|
|
@@ -2908,12 +4013,14 @@ function createVectorCommands(controller, options = {}) {
|
|
|
2908
4013
|
const out = [];
|
|
2909
4014
|
const visit = (nodes, depth) => {
|
|
2910
4015
|
for (const n of nodes) {
|
|
2911
|
-
const
|
|
4016
|
+
const parent = vectorParentMatrix(state.document.nodes, n.id) ?? IDENTITY_MATRIX;
|
|
4017
|
+
const b = vectorNodeBounds(n, parent);
|
|
2912
4018
|
out.push({
|
|
2913
4019
|
id: n.id,
|
|
2914
4020
|
type: n.type,
|
|
2915
4021
|
...n.name ? { name: n.name } : {},
|
|
2916
4022
|
depth,
|
|
4023
|
+
rotation: Math.round(vectorNodeRotation(n, parent) * 100) / 100,
|
|
2917
4024
|
bounds: b && { x: Math.round(b.x * 100) / 100, y: Math.round(b.y * 100) / 100, width: Math.round(b.width * 100) / 100, height: Math.round(b.height * 100) / 100 },
|
|
2918
4025
|
visible: n.visible !== false,
|
|
2919
4026
|
locked: !!n.locked,
|
|
@@ -3015,10 +4122,18 @@ function createVectorCommands(controller, options = {}) {
|
|
|
3015
4122
|
id: `${prefix}transform`,
|
|
3016
4123
|
label: "Transform\u2026",
|
|
3017
4124
|
keywords: ["move", "translate", "scale", "resize", "rotate", "flip"],
|
|
3018
|
-
description: "Moves (dx, dy), scales (sx, sy) and/or rotates (degrees clockwise
|
|
4125
|
+
description: "Moves (dx, dy), scales (sx, sy, along the selection box axes) and/or rotates (`rotate`: by degrees clockwise around the combined centre; `angle`: to an absolute rotation, each node around its own centre), in that order.",
|
|
3019
4126
|
params: {
|
|
3020
4127
|
type: "object",
|
|
3021
|
-
properties: {
|
|
4128
|
+
properties: {
|
|
4129
|
+
ids: IDS,
|
|
4130
|
+
dx: { type: "number" },
|
|
4131
|
+
dy: { type: "number" },
|
|
4132
|
+
sx: { type: "number" },
|
|
4133
|
+
sy: { type: "number" },
|
|
4134
|
+
rotate: { type: "number" },
|
|
4135
|
+
angle: { type: "number" }
|
|
4136
|
+
}
|
|
3022
4137
|
},
|
|
3023
4138
|
enabled: enabledFor,
|
|
3024
4139
|
undoable: true,
|
|
@@ -3027,6 +4142,7 @@ function createVectorCommands(controller, options = {}) {
|
|
|
3027
4142
|
if (params.dx || params.dy) check(controller.translate(params.dx ?? 0, params.dy ?? 0, { ids: target }));
|
|
3028
4143
|
if (params.sx !== void 0 || params.sy !== void 0) check(controller.scale(params.sx ?? 1, params.sy ?? params.sx ?? 1, { ids: target }));
|
|
3029
4144
|
if (params.rotate) check(controller.rotate(params.rotate, { ids: target }));
|
|
4145
|
+
if (params.angle !== void 0) check(controller.setRotation(params.angle, { ids: target }));
|
|
3030
4146
|
return { ok: true };
|
|
3031
4147
|
}
|
|
3032
4148
|
},
|
|
@@ -3168,9 +4284,11 @@ function createVectorCommands(controller, options = {}) {
|
|
|
3168
4284
|
];
|
|
3169
4285
|
}
|
|
3170
4286
|
export {
|
|
4287
|
+
DEFAULT_VECTOR_SNAP,
|
|
3171
4288
|
IDENTITY_MATRIX,
|
|
3172
4289
|
KAPPA,
|
|
3173
4290
|
VECTOR_CLIPBOARD_TYPE,
|
|
4291
|
+
VECTOR_CONSTRUCTION_ANGLES,
|
|
3174
4292
|
VECTOR_OVERLAY_THEME,
|
|
3175
4293
|
VECTOR_ROTATE_HANDLE_OFFSET,
|
|
3176
4294
|
VECTOR_TOOLS,
|
|
@@ -3178,7 +4296,9 @@ export {
|
|
|
3178
4296
|
VECTOR_TOOL_SHORTCUTS,
|
|
3179
4297
|
VECTOR_TRANSFORM_HANDLES,
|
|
3180
4298
|
VectorEditorController,
|
|
4299
|
+
VectorSnapTargets,
|
|
3181
4300
|
applyMatrix,
|
|
4301
|
+
applyMatrixLinear,
|
|
3182
4302
|
bezierToSubpaths,
|
|
3183
4303
|
boundsFromPoints,
|
|
3184
4304
|
boundsIntersect,
|
|
@@ -3187,15 +4307,20 @@ export {
|
|
|
3187
4307
|
cloneVectorDocument,
|
|
3188
4308
|
cloneVectorNode,
|
|
3189
4309
|
collectPathNodes,
|
|
4310
|
+
constrainToLeave,
|
|
3190
4311
|
convertAnchor,
|
|
3191
4312
|
createVectorCommands,
|
|
4313
|
+
decomposeRigid,
|
|
3192
4314
|
ellipseSubpath,
|
|
3193
4315
|
findVectorNode,
|
|
3194
4316
|
getAnchor,
|
|
4317
|
+
getAnchorWorld,
|
|
3195
4318
|
getEditablePathIds,
|
|
3196
4319
|
getTransformHandlePositions,
|
|
3197
4320
|
getVectorOverlayRenderData,
|
|
3198
4321
|
getVectorSceneRenderData,
|
|
4322
|
+
getVectorSelectionBoxGeometry,
|
|
4323
|
+
getVectorSelectionFrame,
|
|
3199
4324
|
getVectorSelectionStyle,
|
|
3200
4325
|
getVisibleAnchors,
|
|
3201
4326
|
getVisibleHandles,
|
|
@@ -3204,12 +4329,16 @@ export {
|
|
|
3204
4329
|
hitTestVectorNodes,
|
|
3205
4330
|
inferAnchorType,
|
|
3206
4331
|
insertAnchor,
|
|
4332
|
+
invertMatrix,
|
|
4333
|
+
isIdentityMatrix,
|
|
3207
4334
|
isVectorTextTarget,
|
|
3208
4335
|
lineSubpath,
|
|
3209
4336
|
locateVectorNode,
|
|
4337
|
+
matrixRotation,
|
|
3210
4338
|
moveAnchorHandle,
|
|
3211
4339
|
multiplyMatrices,
|
|
3212
4340
|
nearestSegment,
|
|
4341
|
+
nodeMatrix,
|
|
3213
4342
|
parseSvgTransform,
|
|
3214
4343
|
parseVectorSvg,
|
|
3215
4344
|
polygonSubpath,
|
|
@@ -3233,11 +4362,16 @@ export {
|
|
|
3233
4362
|
unionBounds,
|
|
3234
4363
|
vectorDocumentToSvg,
|
|
3235
4364
|
vectorNodeBounds,
|
|
4365
|
+
vectorNodeRotation,
|
|
3236
4366
|
vectorNodeToBezier,
|
|
3237
4367
|
vectorPaintToHex,
|
|
3238
4368
|
vectorPaintToSvg,
|
|
4369
|
+
vectorParentMatrix,
|
|
3239
4370
|
vectorPathData,
|
|
4371
|
+
vectorPathWorldBezier,
|
|
4372
|
+
vectorResizeCursor,
|
|
3240
4373
|
vectorToolLabel,
|
|
4374
|
+
vectorWorldMatrix,
|
|
3241
4375
|
walkVectorNodes
|
|
3242
4376
|
};
|
|
3243
4377
|
//# sourceMappingURL=index.js.map
|