@rasamjs/tools 0.1.0 → 0.2.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/API.md +122 -2
- package/CHANGELOG.md +9 -1
- package/README.md +37 -0
- package/RELEASING.md +16 -3
- package/dist/advanced/context.d.ts +15 -0
- package/dist/advanced/context.d.ts.map +1 -0
- package/dist/advanced/context.js +2 -0
- package/dist/advanced/context.js.map +1 -0
- package/dist/advanced/curves.d.ts +5 -0
- package/dist/advanced/curves.d.ts.map +1 -0
- package/dist/advanced/curves.js +65 -0
- package/dist/advanced/curves.js.map +1 -0
- package/dist/advanced/geometry.d.ts +17 -0
- package/dist/advanced/geometry.d.ts.map +1 -0
- package/dist/advanced/geometry.js +61 -0
- package/dist/advanced/geometry.js.map +1 -0
- package/dist/advanced/rotation.d.ts +4 -0
- package/dist/advanced/rotation.d.ts.map +1 -0
- package/dist/advanced/rotation.js +86 -0
- package/dist/advanced/rotation.js.map +1 -0
- package/dist/advanced/session.d.ts +5 -0
- package/dist/advanced/session.d.ts.map +1 -0
- package/dist/advanced/session.js +240 -0
- package/dist/advanced/session.js.map +1 -0
- package/dist/advanced/snapper.d.ts +29 -0
- package/dist/advanced/snapper.d.ts.map +1 -0
- package/dist/advanced/snapper.js +118 -0
- package/dist/advanced/snapper.js.map +1 -0
- package/dist/advanced/types.d.ts +30 -0
- package/dist/advanced/types.d.ts.map +1 -0
- package/dist/advanced/types.js +2 -0
- package/dist/advanced/types.js.map +1 -0
- package/dist/drawing.d.ts +5 -0
- package/dist/drawing.d.ts.map +1 -0
- package/dist/drawing.js +3 -0
- package/dist/drawing.js.map +1 -0
- package/dist/editor/draw-tool.d.ts +35 -0
- package/dist/editor/draw-tool.d.ts.map +1 -0
- package/dist/editor/draw-tool.js +174 -0
- package/dist/editor/draw-tool.js.map +1 -0
- package/dist/editor/path-editor.d.ts +34 -0
- package/dist/editor/path-editor.d.ts.map +1 -0
- package/dist/editor/path-editor.js +208 -0
- package/dist/editor/path-editor.js.map +1 -0
- package/dist/editor/pointer.d.ts +21 -0
- package/dist/editor/pointer.d.ts.map +1 -0
- package/dist/editor/pointer.js +86 -0
- package/dist/editor/pointer.js.map +1 -0
- package/dist/editor/surface.d.ts +31 -0
- package/dist/editor/surface.d.ts.map +1 -0
- package/dist/editor/surface.js +138 -0
- package/dist/editor/surface.js.map +1 -0
- package/dist/grid.d.ts +23 -0
- package/dist/grid.d.ts.map +1 -0
- package/dist/grid.js +84 -0
- package/dist/grid.js.map +1 -0
- package/dist/rulers.d.ts +30 -0
- package/dist/rulers.d.ts.map +1 -0
- package/dist/rulers.js +234 -0
- package/dist/rulers.js.map +1 -0
- package/dist/snapping/advanced.d.ts +3 -0
- package/dist/snapping/advanced.d.ts.map +1 -0
- package/dist/snapping/advanced.js +2 -0
- package/dist/snapping/advanced.js.map +1 -0
- package/dist/snapping/targets.d.ts +1 -0
- package/dist/snapping/targets.d.ts.map +1 -1
- package/dist/snapping/targets.js +5 -1
- package/dist/snapping/targets.js.map +1 -1
- package/package.json +24 -4
- package/src/advanced/context.ts +15 -0
- package/src/advanced/curves.ts +65 -0
- package/src/advanced/geometry.ts +74 -0
- package/src/advanced/rotation.ts +93 -0
- package/src/advanced/session.ts +276 -0
- package/src/advanced/snapper.ts +124 -0
- package/src/advanced/types.ts +31 -0
- package/src/drawing.ts +4 -0
- package/src/editor/draw-tool.ts +190 -0
- package/src/editor/path-editor.ts +224 -0
- package/src/editor/pointer.ts +85 -0
- package/src/editor/surface.ts +141 -0
- package/src/grid.ts +99 -0
- package/src/rulers.ts +245 -0
- package/src/snapping/advanced.ts +8 -0
- package/src/snapping/targets.ts +11 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rasamjs/tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Opt-in editing tools for rasam SVG drawings.",
|
|
@@ -23,6 +23,26 @@
|
|
|
23
23
|
"rasam-source": "./src/snapping.ts",
|
|
24
24
|
"types": "./dist/snapping.d.ts",
|
|
25
25
|
"import": "./dist/snapping.js"
|
|
26
|
+
},
|
|
27
|
+
"./snapping/advanced": {
|
|
28
|
+
"rasam-source": "./src/snapping/advanced.ts",
|
|
29
|
+
"types": "./dist/snapping/advanced.d.ts",
|
|
30
|
+
"import": "./dist/snapping/advanced.js"
|
|
31
|
+
},
|
|
32
|
+
"./grid": {
|
|
33
|
+
"rasam-source": "./src/grid.ts",
|
|
34
|
+
"types": "./dist/grid.d.ts",
|
|
35
|
+
"import": "./dist/grid.js"
|
|
36
|
+
},
|
|
37
|
+
"./rulers": {
|
|
38
|
+
"rasam-source": "./src/rulers.ts",
|
|
39
|
+
"types": "./dist/rulers.d.ts",
|
|
40
|
+
"import": "./dist/rulers.js"
|
|
41
|
+
},
|
|
42
|
+
"./drawing": {
|
|
43
|
+
"rasam-source": "./src/drawing.ts",
|
|
44
|
+
"types": "./dist/drawing.d.ts",
|
|
45
|
+
"import": "./dist/drawing.js"
|
|
26
46
|
}
|
|
27
47
|
},
|
|
28
48
|
"sideEffects": false,
|
|
@@ -30,10 +50,10 @@
|
|
|
30
50
|
"@rasamjs/core": "^0.1.0"
|
|
31
51
|
},
|
|
32
52
|
"devDependencies": {
|
|
33
|
-
"@rasamjs/core": "0.1.
|
|
53
|
+
"@rasamjs/core": "0.1.2",
|
|
34
54
|
"@rasamjs/history": "0.1.0",
|
|
35
|
-
"@rasamjs/selection": "0.
|
|
36
|
-
"@rasamjs/transformer": "0.
|
|
55
|
+
"@rasamjs/selection": "0.3.0",
|
|
56
|
+
"@rasamjs/transformer": "0.4.0",
|
|
37
57
|
"@rasamjs/viewport": "0.1.0"
|
|
38
58
|
},
|
|
39
59
|
"scripts": {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Svg } from '@rasamjs/core';
|
|
2
|
+
import type { ResolvedOptions } from '../snapping/options.js';
|
|
3
|
+
import type { GuideOverlay } from '../snapping/overlay.js';
|
|
4
|
+
import type { AdvancedSnapperOptions } from './types.js';
|
|
5
|
+
|
|
6
|
+
export interface SessionContext<Request> {
|
|
7
|
+
readonly drawing: Svg;
|
|
8
|
+
readonly options: ResolvedOptions;
|
|
9
|
+
readonly advanced: AdvancedSnapperOptions;
|
|
10
|
+
readonly overlay: GuideOverlay;
|
|
11
|
+
readonly request: Request;
|
|
12
|
+
readonly isEnabled: () => boolean;
|
|
13
|
+
readonly isCurrent: () => boolean;
|
|
14
|
+
readonly onEnd: () => void;
|
|
15
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { PathArray } from '@rasamjs/core';
|
|
2
|
+
import type { AnyNode, Svg, PathCommand } from '@rasamjs/core';
|
|
3
|
+
import { Guards } from '@rasamjs/core/advanced';
|
|
4
|
+
import { clientMatrix } from './geometry.js';
|
|
5
|
+
import type { Segment } from './geometry.js';
|
|
6
|
+
|
|
7
|
+
function subpaths(geometry: SVGGeometryElement): SVGGeometryElement[] {
|
|
8
|
+
if (geometry.localName !== 'path') return [geometry];
|
|
9
|
+
const parts: PathCommand[][] = [];
|
|
10
|
+
for (const command of new PathArray(geometry.getAttribute('d') ?? '').toArray()) {
|
|
11
|
+
if (command[0] === 'M') parts.push([]);
|
|
12
|
+
parts.at(-1)!.push(command);
|
|
13
|
+
}
|
|
14
|
+
return parts.map((commands) => {
|
|
15
|
+
const path = geometry.cloneNode(false) as SVGGeometryElement;
|
|
16
|
+
path.setAttribute('d', new PathArray(commands).toString());
|
|
17
|
+
return path;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Detached geometry is sampled once: target edits never change an active session. */
|
|
22
|
+
export function curveSegments(
|
|
23
|
+
drawing: Svg,
|
|
24
|
+
nodes: readonly AnyNode[],
|
|
25
|
+
resolution: number,
|
|
26
|
+
): Segment[] {
|
|
27
|
+
const root = drawing.worldMatrix().inverse(),
|
|
28
|
+
screen = clientMatrix(drawing);
|
|
29
|
+
if (!root || !screen) return [];
|
|
30
|
+
const segments: Segment[] = [];
|
|
31
|
+
for (const node of nodes) {
|
|
32
|
+
if (!Guards.isTransformable(node)) continue;
|
|
33
|
+
if (!['path', 'line', 'polyline', 'polygon', 'circle', 'ellipse'].includes(node.el.localName))
|
|
34
|
+
continue;
|
|
35
|
+
node.flush();
|
|
36
|
+
const geometry = node.el.cloneNode(false) as SVGGeometryElement;
|
|
37
|
+
if (typeof geometry.getTotalLength !== 'function') continue;
|
|
38
|
+
const matrix = root.multiply(node.worldMatrix());
|
|
39
|
+
const pixels = screen.multiply(matrix);
|
|
40
|
+
const stretch = Math.hypot(pixels.a, pixels.b, pixels.c, pixels.d);
|
|
41
|
+
let parts: SVGGeometryElement[];
|
|
42
|
+
try {
|
|
43
|
+
parts = subpaths(geometry);
|
|
44
|
+
} catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
for (const part of parts) {
|
|
48
|
+
let length: number;
|
|
49
|
+
try {
|
|
50
|
+
length = part.getTotalLength();
|
|
51
|
+
} catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!Number.isFinite(length) || length <= 0) continue;
|
|
55
|
+
const count = Math.max(1, Math.min(8192, Math.ceil((length * stretch) / resolution)));
|
|
56
|
+
let previous = matrix.apply(part.getPointAtLength(0));
|
|
57
|
+
for (let index = 1; index <= count; index++) {
|
|
58
|
+
const next = matrix.apply(part.getPointAtLength((length * index) / count));
|
|
59
|
+
segments.push([previous, next]);
|
|
60
|
+
previous = next;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return segments;
|
|
65
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Matrix } from '@rasamjs/core';
|
|
2
|
+
import type { Point, Svg } from '@rasamjs/core';
|
|
3
|
+
|
|
4
|
+
export type Segment = readonly [Point, Point];
|
|
5
|
+
export const add = (a: Point, b: Point): Point => ({ x: a.x + b.x, y: a.y + b.y });
|
|
6
|
+
export const subtract = (a: Point, b: Point): Point => ({ x: a.x - b.x, y: a.y - b.y });
|
|
7
|
+
export const scale = (p: Point, value: number): Point => ({ x: p.x * value, y: p.y * value });
|
|
8
|
+
const cross = (a: Point, b: Point) => a.x * b.y - a.y * b.x;
|
|
9
|
+
|
|
10
|
+
/** The closest point on cached segments, optionally restricted to a resize line. */
|
|
11
|
+
export function nearestOnSegments(
|
|
12
|
+
point: Point,
|
|
13
|
+
segments: readonly Segment[],
|
|
14
|
+
direction?: Point,
|
|
15
|
+
): Point | null {
|
|
16
|
+
let best: Point | null = null,
|
|
17
|
+
distance = Infinity;
|
|
18
|
+
for (const [a, b] of segments) {
|
|
19
|
+
const edge = subtract(b, a),
|
|
20
|
+
relative = subtract(a, point);
|
|
21
|
+
let candidate: Point;
|
|
22
|
+
const determinant = direction && cross(direction, edge);
|
|
23
|
+
if (direction && Math.abs(determinant!) > 1e-12) {
|
|
24
|
+
const along = cross(relative, direction) / determinant!;
|
|
25
|
+
if (along < 0 || along > 1) continue;
|
|
26
|
+
candidate = add(point, scale(direction, cross(relative, edge) / determinant!));
|
|
27
|
+
} else {
|
|
28
|
+
if (direction && Math.abs(cross(relative, direction)) > 1e-9) continue;
|
|
29
|
+
const length = edge.x * edge.x + edge.y * edge.y;
|
|
30
|
+
const t = length
|
|
31
|
+
? Math.max(0, Math.min(1, ((point.x - a.x) * edge.x + (point.y - a.y) * edge.y) / length))
|
|
32
|
+
: 0;
|
|
33
|
+
candidate = add(a, scale(edge, t));
|
|
34
|
+
}
|
|
35
|
+
const next = Math.hypot(candidate.x - point.x, candidate.y - point.y);
|
|
36
|
+
if (next < distance) {
|
|
37
|
+
best = candidate;
|
|
38
|
+
distance = next;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return best;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Root-to-client mapping is read once per sample, never once per target segment. */
|
|
45
|
+
export function clientMatrix(drawing: Svg): Matrix | null {
|
|
46
|
+
const a = drawing.toClient({ x: 0, y: 0 });
|
|
47
|
+
const b = drawing.toClient({ x: 1, y: 0 });
|
|
48
|
+
const c = drawing.toClient({ x: 0, y: 1 });
|
|
49
|
+
return a && b && c ? new Matrix(b.x - a.x, b.y - a.y, c.x - a.x, c.y - a.y, a.x, a.y) : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function frameConstraint(corners: readonly Point[], anchor: Point) {
|
|
53
|
+
const [a, b, , d] = corners;
|
|
54
|
+
const u = subtract(b, a),
|
|
55
|
+
v = subtract(d, a);
|
|
56
|
+
const inverse = new Matrix(u.x, u.y, v.x, v.y, 0, 0).inverse();
|
|
57
|
+
if (!inverse) return null;
|
|
58
|
+
const point = add(a, add(scale(u, anchor.x), scale(v, anchor.y)));
|
|
59
|
+
const direction = anchor.x === 0.5 ? v : anchor.y === 0.5 ? u : undefined;
|
|
60
|
+
return {
|
|
61
|
+
point,
|
|
62
|
+
direction,
|
|
63
|
+
project(delta: Point): Point {
|
|
64
|
+
const unit = inverse.apply(delta);
|
|
65
|
+
return add(
|
|
66
|
+
point,
|
|
67
|
+
add(scale(u, anchor.x === 0.5 ? 0 : unit.x), scale(v, anchor.y === 0.5 ? 0 : unit.y)),
|
|
68
|
+
);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const angleDifference = (a: number, b: number, period = 360): number =>
|
|
74
|
+
((((a - b + period * 1.5) % period) + period) % period) - period / 2;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Matrix } from '@rasamjs/core';
|
|
2
|
+
import type { Point } from '@rasamjs/core';
|
|
3
|
+
import { Guards } from '@rasamjs/core/advanced';
|
|
4
|
+
import { assertPoint } from '../snapping/options.js';
|
|
5
|
+
import { targetNodes, validateNodes } from '../snapping/targets.js';
|
|
6
|
+
import { add, subtract, angleDifference } from './geometry.js';
|
|
7
|
+
import type { SessionContext } from './context.js';
|
|
8
|
+
import type { RotationSnapRequest, RotationSnapSession } from './types.js';
|
|
9
|
+
|
|
10
|
+
export function createRotationSession(
|
|
11
|
+
context: SessionContext<RotationSnapRequest>,
|
|
12
|
+
): RotationSnapSession | null {
|
|
13
|
+
const { request } = context;
|
|
14
|
+
validateNodes(request.nodes, context.drawing);
|
|
15
|
+
assertPoint(request.client);
|
|
16
|
+
assertPoint(request.pivot);
|
|
17
|
+
if (request.corners.length !== 4) throw new TypeError('Rotation needs four corners');
|
|
18
|
+
const root = context.drawing.worldMatrix().inverse();
|
|
19
|
+
const pivot = context.drawing.fromClient(request.pivot),
|
|
20
|
+
start = context.drawing.fromClient(request.client);
|
|
21
|
+
const corners = request.corners.map((p) => context.drawing.fromClient(p));
|
|
22
|
+
if (!root || !pivot || !start || corners.some((p) => !p)) return null;
|
|
23
|
+
const parent =
|
|
24
|
+
request.nodes.length === 1 && Guards.isTransformable(request.nodes[0].parent)
|
|
25
|
+
? root.multiply(request.nodes[0].parent.worldMatrix())
|
|
26
|
+
: Matrix.identity();
|
|
27
|
+
const inverse = parent.inverse();
|
|
28
|
+
if (!inverse) return null;
|
|
29
|
+
const vector = (p: Point) => subtract(inverse.apply(add(p, pivot)), inverse.apply(pivot));
|
|
30
|
+
const first = vector(subtract(corners[1]!, corners[0]!));
|
|
31
|
+
const startAngle = Math.atan2(vector(subtract(start, pivot)).y, vector(subtract(start, pivot)).x);
|
|
32
|
+
const frameAngle = Math.atan2(first.y, first.x);
|
|
33
|
+
const angles = targetNodes(context.drawing, request.nodes, context.options).flatMap((node) => {
|
|
34
|
+
if (!Guards.isTransformable(node)) return [];
|
|
35
|
+
const matrix = root.multiply(node.worldMatrix());
|
|
36
|
+
return [Math.atan2(matrix.b, matrix.a)];
|
|
37
|
+
});
|
|
38
|
+
if (!angles.length || !context.isCurrent() || !context.isEnabled()) return null;
|
|
39
|
+
let ended = false,
|
|
40
|
+
snapped = false;
|
|
41
|
+
const session: RotationSnapSession = {
|
|
42
|
+
get angleSnapped() {
|
|
43
|
+
return snapped;
|
|
44
|
+
},
|
|
45
|
+
snap: (client, modifiers) => {
|
|
46
|
+
assertPoint(client);
|
|
47
|
+
snapped = false;
|
|
48
|
+
if (
|
|
49
|
+
ended ||
|
|
50
|
+
!context.isEnabled() ||
|
|
51
|
+
(modifiers.ignore && context.options.modifier !== 'none')
|
|
52
|
+
)
|
|
53
|
+
return client;
|
|
54
|
+
const current = context.drawing.fromClient(client);
|
|
55
|
+
if (!current) return client;
|
|
56
|
+
const local = vector(subtract(current, pivot));
|
|
57
|
+
const proposed = frameAngle + Math.atan2(local.y, local.x) - startAngle;
|
|
58
|
+
const mapped = subtract(
|
|
59
|
+
parent.apply({ x: Math.cos(proposed), y: Math.sin(proposed) }),
|
|
60
|
+
parent.apply({ x: 0, y: 0 }),
|
|
61
|
+
);
|
|
62
|
+
const visual = Math.atan2(mapped.y, mapped.x);
|
|
63
|
+
let best: number | null = null,
|
|
64
|
+
distance = ((context.advanced.angleTolerance ?? 5) * Math.PI) / 180;
|
|
65
|
+
for (const target of angles) {
|
|
66
|
+
const next = Math.abs(angleDifference(target, visual, Math.PI));
|
|
67
|
+
if (next <= distance) {
|
|
68
|
+
distance = next;
|
|
69
|
+
best = target;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (best === null) return client;
|
|
73
|
+
const target = vector({ x: Math.cos(best), y: Math.sin(best) });
|
|
74
|
+
const correction = angleDifference(Math.atan2(target.y, target.x), proposed, Math.PI);
|
|
75
|
+
const rotated = {
|
|
76
|
+
x: local.x * Math.cos(correction) - local.y * Math.sin(correction),
|
|
77
|
+
y: local.x * Math.sin(correction) + local.y * Math.cos(correction),
|
|
78
|
+
};
|
|
79
|
+
const offset = subtract(parent.apply(rotated), parent.apply({ x: 0, y: 0 }));
|
|
80
|
+
const result = context.drawing.toClient(add(pivot, offset));
|
|
81
|
+
snapped = !!result;
|
|
82
|
+
return result ?? client;
|
|
83
|
+
},
|
|
84
|
+
end: () => {
|
|
85
|
+
if (!ended) {
|
|
86
|
+
ended = true;
|
|
87
|
+
snapped = false;
|
|
88
|
+
context.onEnd();
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
return session;
|
|
93
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import type { AnyNode, Point } from '@rasamjs/core';
|
|
2
|
+
import { Matrix } from '@rasamjs/core';
|
|
3
|
+
import { boundsIn } from '@rasamjs/core/viewport';
|
|
4
|
+
import { boxLines, snapAxis, targetLines } from '../snapping/lines.js';
|
|
5
|
+
import type { Features, Match, Targets } from '../snapping/lines.js';
|
|
6
|
+
import { assertPoint } from '../snapping/options.js';
|
|
7
|
+
import { createSession, resizeLines } from '../snapping/session.js';
|
|
8
|
+
import { movingBox, targetNodes, validateNodes } from '../snapping/targets.js';
|
|
9
|
+
import { createSpacing } from '../snapping/spacing.js';
|
|
10
|
+
import { guideReader } from '../snapping/guides.js';
|
|
11
|
+
import type { SnapSession } from '../snapping/types.js';
|
|
12
|
+
import type { AdvancedSnapRequest } from './types.js';
|
|
13
|
+
import type { SessionContext } from './context.js';
|
|
14
|
+
import {
|
|
15
|
+
add,
|
|
16
|
+
clientMatrix,
|
|
17
|
+
frameConstraint,
|
|
18
|
+
nearestOnSegments,
|
|
19
|
+
scale,
|
|
20
|
+
subtract,
|
|
21
|
+
} from './geometry.js';
|
|
22
|
+
import { curveSegments } from './curves.js';
|
|
23
|
+
|
|
24
|
+
export function createAdvancedSession(
|
|
25
|
+
context: SessionContext<AdvancedSnapRequest>,
|
|
26
|
+
): SnapSession | null {
|
|
27
|
+
const { request } = context;
|
|
28
|
+
if (!request || !['move', 'resize', 'point'].includes(request.kind))
|
|
29
|
+
throw new TypeError('Unknown snap request');
|
|
30
|
+
assertPoint(request.client, 'Start pointer');
|
|
31
|
+
const nodes =
|
|
32
|
+
request.kind === 'point' ? ('nodes' in request ? (request.nodes ?? []) : []) : request.nodes;
|
|
33
|
+
validateNodes(nodes, context.drawing);
|
|
34
|
+
const start = context.drawing.fromClient(request.client);
|
|
35
|
+
if (!start) return null;
|
|
36
|
+
const geometry = readGeometry(context, start, nodes);
|
|
37
|
+
if (!geometry) return null;
|
|
38
|
+
const { box, features, constraint, oblique, rotated } = geometry;
|
|
39
|
+
const { boxes, targets, curves, guides } = collectTargets(context, nodes);
|
|
40
|
+
const spacing =
|
|
41
|
+
!rotated && context.options.spacing
|
|
42
|
+
? createSpacing({
|
|
43
|
+
box,
|
|
44
|
+
features,
|
|
45
|
+
targets: boxes,
|
|
46
|
+
kind: request.kind,
|
|
47
|
+
centered: request.kind === 'resize' && request.centered,
|
|
48
|
+
})
|
|
49
|
+
: null;
|
|
50
|
+
if (!context.isCurrent() || !context.isEnabled()) return null;
|
|
51
|
+
if (
|
|
52
|
+
!context.options.grid &&
|
|
53
|
+
!guides.length &&
|
|
54
|
+
!curves.length &&
|
|
55
|
+
!spacing &&
|
|
56
|
+
!(context.options.shapes && boxes.length)
|
|
57
|
+
)
|
|
58
|
+
return null;
|
|
59
|
+
const read = guideReader(context.drawing, request, boxes);
|
|
60
|
+
const publish = (matches: readonly (Match | null)[], point: Point | null): void => {
|
|
61
|
+
if (!context.isCurrent()) return;
|
|
62
|
+
if (point && matches.some(Boolean)) context.overlay.show(() => read(matches, point));
|
|
63
|
+
else context.overlay.clear();
|
|
64
|
+
};
|
|
65
|
+
let ended = false;
|
|
66
|
+
const axisSession = createSession({
|
|
67
|
+
start,
|
|
68
|
+
features,
|
|
69
|
+
targets,
|
|
70
|
+
mapping: context.drawing,
|
|
71
|
+
options: { ...context.options, shapes: context.options.shapes || !!guides.length },
|
|
72
|
+
spacing,
|
|
73
|
+
isEnabled: () => context.isEnabled(),
|
|
74
|
+
onEnd: () => context.onEnd(),
|
|
75
|
+
onSnap: publish,
|
|
76
|
+
});
|
|
77
|
+
const session: SnapSession = {
|
|
78
|
+
snap: (client, modifiers) => {
|
|
79
|
+
assertPoint(client);
|
|
80
|
+
if (
|
|
81
|
+
ended ||
|
|
82
|
+
!context.isEnabled() ||
|
|
83
|
+
(modifiers.ignore && context.options.modifier !== 'none')
|
|
84
|
+
) {
|
|
85
|
+
context.overlay.clear();
|
|
86
|
+
return client;
|
|
87
|
+
}
|
|
88
|
+
const matrix = clientMatrix(context.drawing),
|
|
89
|
+
raw = context.drawing.fromClient(client);
|
|
90
|
+
if (!matrix || !raw) return client;
|
|
91
|
+
const projected = constraint?.project(subtract(raw, start));
|
|
92
|
+
let result: Point;
|
|
93
|
+
if (oblique && projected) {
|
|
94
|
+
const correction = obliqueCorrection(context, {
|
|
95
|
+
client,
|
|
96
|
+
raw,
|
|
97
|
+
point: projected,
|
|
98
|
+
direction: constraint!.direction,
|
|
99
|
+
targets,
|
|
100
|
+
matrix,
|
|
101
|
+
});
|
|
102
|
+
result = correction.client;
|
|
103
|
+
publish(correction.matches, context.drawing.fromClient(result));
|
|
104
|
+
} else result = axisSession.snap(client, modifiers);
|
|
105
|
+
const forward = (value: Point): Point => {
|
|
106
|
+
if (!request.keyboard) return value;
|
|
107
|
+
const movement = subtract(client, request.client),
|
|
108
|
+
accepted = subtract(value, request.client);
|
|
109
|
+
if (accepted.x * movement.x + accepted.y * movement.y <= 0) {
|
|
110
|
+
context.overlay.clear();
|
|
111
|
+
return client;
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
};
|
|
115
|
+
if (!curves.length) return forward(result);
|
|
116
|
+
const references = projected
|
|
117
|
+
? [projected]
|
|
118
|
+
: request.kind === 'point'
|
|
119
|
+
? [raw]
|
|
120
|
+
: features.x.flatMap((x) =>
|
|
121
|
+
features.y.map((y) => ({
|
|
122
|
+
x: x.value + raw.x - start.x,
|
|
123
|
+
y: y.value + raw.y - start.y,
|
|
124
|
+
})),
|
|
125
|
+
);
|
|
126
|
+
const segments = curves.map(([a, b]) => [matrix.apply(a), matrix.apply(b)] as const);
|
|
127
|
+
let distance =
|
|
128
|
+
result === client ? Infinity : Math.hypot(result.x - client.x, result.y - client.y);
|
|
129
|
+
for (const point of references) {
|
|
130
|
+
const screen = matrix.apply(point);
|
|
131
|
+
const direction =
|
|
132
|
+
constraint?.direction && subtract(matrix.apply(add(point, constraint.direction)), screen);
|
|
133
|
+
const closest = nearestOnSegments(screen, segments, direction);
|
|
134
|
+
if (!closest) continue;
|
|
135
|
+
const next = Math.hypot(closest.x - screen.x, closest.y - screen.y);
|
|
136
|
+
if (next <= context.options.tolerance && next < distance) {
|
|
137
|
+
distance = next;
|
|
138
|
+
result = add(client, subtract(closest, screen));
|
|
139
|
+
const root = context.drawing.fromClient(closest)!;
|
|
140
|
+
publish(
|
|
141
|
+
(['x', 'y'] as const).map((axis) => ({
|
|
142
|
+
target: root[axis],
|
|
143
|
+
delta: 0,
|
|
144
|
+
order: -1,
|
|
145
|
+
grid: false,
|
|
146
|
+
})),
|
|
147
|
+
context.drawing.fromClient(result),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return forward(result);
|
|
152
|
+
},
|
|
153
|
+
end: () => {
|
|
154
|
+
if (!ended) {
|
|
155
|
+
ended = true;
|
|
156
|
+
axisSession.end();
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
return session;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
interface ObliqueSample {
|
|
164
|
+
readonly client: Point;
|
|
165
|
+
readonly raw: Point;
|
|
166
|
+
readonly point: Point;
|
|
167
|
+
readonly direction: Point | undefined;
|
|
168
|
+
readonly targets: Targets;
|
|
169
|
+
readonly matrix: Matrix;
|
|
170
|
+
}
|
|
171
|
+
function obliqueCorrection(
|
|
172
|
+
context: SessionContext<AdvancedSnapRequest>,
|
|
173
|
+
sample: ObliqueSample,
|
|
174
|
+
): { client: Point; matches: (Match | null)[] } {
|
|
175
|
+
const { client, raw, point, direction, targets, matrix } = sample;
|
|
176
|
+
let result = client,
|
|
177
|
+
distance = Infinity;
|
|
178
|
+
const matches: (Match | null)[] = [null, null];
|
|
179
|
+
const origin = matrix.apply(point);
|
|
180
|
+
for (const [index, axis] of (['x', 'y'] as const).entries()) {
|
|
181
|
+
const unit = axis === 'x' ? { x: 1, y: 0 } : { x: 0, y: 1 };
|
|
182
|
+
const vector = subtract(matrix.apply(add(point, unit)), origin);
|
|
183
|
+
const pixels = Math.hypot(vector.x, vector.y);
|
|
184
|
+
const match = snapAxis({
|
|
185
|
+
moving: [{ value: point[axis], edge: true }],
|
|
186
|
+
targets: context.options.shapes || context.advanced.guides ? targets[axis] : [],
|
|
187
|
+
offset: 0,
|
|
188
|
+
tolerance: context.options.tolerance / pixels,
|
|
189
|
+
grid: context.options.grid && {
|
|
190
|
+
size: context.options.grid.size,
|
|
191
|
+
origin: context.options.grid.origin[axis],
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
if (!match || (direction && Math.abs(direction[axis]) < 1e-12)) continue;
|
|
195
|
+
const correction = direction
|
|
196
|
+
? scale(direction, match.delta / direction[axis])
|
|
197
|
+
: scale(unit, match.delta);
|
|
198
|
+
const screen = matrix.apply(add(point, correction));
|
|
199
|
+
const next = Math.hypot(screen.x - origin.x, screen.y - origin.y);
|
|
200
|
+
if (next <= context.options.tolerance && next < distance) {
|
|
201
|
+
distance = next;
|
|
202
|
+
result = matrix.apply(add(raw, correction));
|
|
203
|
+
matches[0] = matches[1] = null;
|
|
204
|
+
matches[index] = match;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { client: result, matches };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function readGeometry(
|
|
211
|
+
context: SessionContext<AdvancedSnapRequest>,
|
|
212
|
+
start: Point,
|
|
213
|
+
nodes: readonly AnyNode[],
|
|
214
|
+
) {
|
|
215
|
+
const { request } = context;
|
|
216
|
+
const box =
|
|
217
|
+
request.kind === 'point'
|
|
218
|
+
? { ...start, width: 0, height: 0 }
|
|
219
|
+
: movingBox(nodes, context.drawing);
|
|
220
|
+
if (!box) return null;
|
|
221
|
+
let features: Features = boxLines(box);
|
|
222
|
+
let constraint: ReturnType<typeof frameConstraint> = null;
|
|
223
|
+
let oblique = false;
|
|
224
|
+
let rotated = false;
|
|
225
|
+
if (request.kind === 'resize') {
|
|
226
|
+
if (
|
|
227
|
+
!request.anchor ||
|
|
228
|
+
![0, 0.5, 1].includes(request.anchor.x) ||
|
|
229
|
+
![0, 0.5, 1].includes(request.anchor.y) ||
|
|
230
|
+
(request.anchor.x === 0.5 && request.anchor.y === 0.5) ||
|
|
231
|
+
request.corners.length !== 4
|
|
232
|
+
)
|
|
233
|
+
throw new TypeError('Resize needs four corners and an edge/corner anchor');
|
|
234
|
+
const corners = request.corners.map((p) => {
|
|
235
|
+
assertPoint(p);
|
|
236
|
+
return context.drawing.fromClient(p);
|
|
237
|
+
});
|
|
238
|
+
if (corners.some((p) => !p)) return null;
|
|
239
|
+
constraint = frameConstraint(corners as Point[], request.anchor);
|
|
240
|
+
const aligned = resizeLines(corners as Point[], request.anchor);
|
|
241
|
+
if (!aligned && !constraint) return null;
|
|
242
|
+
rotated = !aligned;
|
|
243
|
+
oblique = rotated && !!constraint?.direction;
|
|
244
|
+
features = aligned ?? {
|
|
245
|
+
x: [{ value: constraint!.point.x, edge: true }],
|
|
246
|
+
y: [{ value: constraint!.point.y, edge: true }],
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return { box, features, constraint, oblique, rotated };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function collectTargets(context: SessionContext<AdvancedSnapRequest>, nodes: readonly AnyNode[]) {
|
|
253
|
+
const candidates =
|
|
254
|
+
context.options.shapes || context.options.spacing || context.advanced.curves !== false
|
|
255
|
+
? targetNodes(context.drawing, nodes, context.options)
|
|
256
|
+
: [];
|
|
257
|
+
const boxes = candidates.flatMap((node) => {
|
|
258
|
+
const b = boundsIn(node, context.drawing);
|
|
259
|
+
return 'box' in b ? [b.box] : [];
|
|
260
|
+
});
|
|
261
|
+
const initial = targetLines(context.options.shapes ? boxes : []);
|
|
262
|
+
const targets = { ...initial, x: [...initial.x], y: [...initial.y] };
|
|
263
|
+
const curves =
|
|
264
|
+
context.advanced.curves === false
|
|
265
|
+
? []
|
|
266
|
+
: curveSegments(context.drawing, candidates, context.advanced.curveResolution ?? 0.25);
|
|
267
|
+
const guides = [...(context.advanced.guides?.() ?? [])].map((guide) => {
|
|
268
|
+
if (!guide || !['x', 'y'].includes(guide.axis) || !Number.isFinite(guide.position))
|
|
269
|
+
throw new TypeError('Ruler guides need a valid axis and finite position');
|
|
270
|
+
return { ...guide };
|
|
271
|
+
});
|
|
272
|
+
for (const guide of guides) targets[guide.axis].push({ value: guide.position, order: -1 });
|
|
273
|
+
for (const axis of ['x', 'y'] as const)
|
|
274
|
+
targets[axis].sort((a, b) => a.value - b.value || a.order - b.order);
|
|
275
|
+
return { boxes, targets, curves, guides };
|
|
276
|
+
}
|