@rasamjs/tools 0.0.0-stage → 0.1.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 +188 -0
- package/CHANGELOG.md +45 -0
- package/LICENSE +21 -0
- package/README.md +101 -2
- package/RELEASING.md +75 -0
- package/dist/snapping/guides.d.ts +14 -0
- package/dist/snapping/guides.d.ts.map +1 -0
- package/dist/snapping/guides.js +109 -0
- package/dist/snapping/guides.js.map +1 -0
- package/dist/snapping/lines.d.ts +43 -0
- package/dist/snapping/lines.d.ts.map +1 -0
- package/dist/snapping/lines.js +74 -0
- package/dist/snapping/lines.js.map +1 -0
- package/dist/snapping/options.d.ts +18 -0
- package/dist/snapping/options.d.ts.map +1 -0
- package/dist/snapping/options.js +44 -0
- package/dist/snapping/options.js.map +1 -0
- package/dist/snapping/overlay.d.ts +22 -0
- package/dist/snapping/overlay.d.ts.map +1 -0
- package/dist/snapping/overlay.js +165 -0
- package/dist/snapping/overlay.js.map +1 -0
- package/dist/snapping/session.d.ts +25 -0
- package/dist/snapping/session.d.ts.map +1 -0
- package/dist/snapping/session.js +89 -0
- package/dist/snapping/session.js.map +1 -0
- package/dist/snapping/snapper.d.ts +22 -0
- package/dist/snapping/snapper.d.ts.map +1 -0
- package/dist/snapping/snapper.js +140 -0
- package/dist/snapping/snapper.js.map +1 -0
- package/dist/snapping/spacing.d.ts +27 -0
- package/dist/snapping/spacing.d.ts.map +1 -0
- package/dist/snapping/spacing.js +127 -0
- package/dist/snapping/spacing.js.map +1 -0
- package/dist/snapping/targets.d.ts +6 -0
- package/dist/snapping/targets.d.ts.map +1 -0
- package/dist/snapping/targets.js +67 -0
- package/dist/snapping/targets.js.map +1 -0
- package/dist/snapping/types.d.ts +35 -0
- package/dist/snapping/types.d.ts.map +1 -0
- package/dist/snapping/types.js +2 -0
- package/dist/snapping/types.js.map +1 -0
- package/dist/snapping.d.ts +4 -0
- package/dist/snapping.d.ts.map +1 -0
- package/dist/snapping.js +3 -0
- package/dist/snapping.js.map +1 -0
- package/package.json +53 -4
- package/src/snapping/guides.ts +132 -0
- package/src/snapping/lines.ts +108 -0
- package/src/snapping/options.ts +47 -0
- package/src/snapping/overlay.ts +186 -0
- package/src/snapping/session.ts +116 -0
- package/src/snapping/snapper.ts +147 -0
- package/src/snapping/spacing.ts +157 -0
- package/src/snapping/targets.ts +79 -0
- package/src/snapping/types.ts +27 -0
- package/src/snapping.ts +3 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { AnyNode, Box, Point, Svg } from '@rasamjs/core';
|
|
2
|
+
import { Guards } from '@rasamjs/core/advanced';
|
|
3
|
+
import { movingBox } from './targets.js';
|
|
4
|
+
import type { Match } from './lines.js';
|
|
5
|
+
import type { SnapRequest } from './types.js';
|
|
6
|
+
|
|
7
|
+
export interface Guide {
|
|
8
|
+
readonly axis: 'x' | 'y';
|
|
9
|
+
readonly position: number;
|
|
10
|
+
readonly from: number;
|
|
11
|
+
readonly to: number;
|
|
12
|
+
readonly marks: readonly number[];
|
|
13
|
+
readonly gap?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function rootMatrix(drawing: Svg, node: AnyNode) {
|
|
17
|
+
return Guards.isTransformable(node)
|
|
18
|
+
? drawing.worldMatrix().inverse()?.multiply(node.worldMatrix())
|
|
19
|
+
: null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Follow the dragged anchor, including group flips that exchange the outer edges. */
|
|
23
|
+
function draggedAnchor(drawing: Svg, request: SnapRequest): () => Point | null {
|
|
24
|
+
if (request.kind !== 'resize') return () => null;
|
|
25
|
+
const nodes = [...new Set(request.nodes)];
|
|
26
|
+
const anchor = { ...request.anchor! };
|
|
27
|
+
if (nodes.length === 1)
|
|
28
|
+
return () => {
|
|
29
|
+
const matrix = rootMatrix(drawing, nodes[0]),
|
|
30
|
+
bounds = nodes[0].bounds();
|
|
31
|
+
return matrix && 'box' in bounds
|
|
32
|
+
? matrix.apply({
|
|
33
|
+
x: bounds.box.x + bounds.box.width * anchor.x,
|
|
34
|
+
y: bounds.box.y + bounds.box.height * anchor.y,
|
|
35
|
+
})
|
|
36
|
+
: null;
|
|
37
|
+
};
|
|
38
|
+
const inverse = rootMatrix(drawing, nodes[0])?.inverse();
|
|
39
|
+
const corners = request.corners.map((point) => drawing.fromClient(point));
|
|
40
|
+
if (!inverse || corners.some((point) => !point)) return () => null;
|
|
41
|
+
const [a, b, , d] = corners as Point[];
|
|
42
|
+
const initial = {
|
|
43
|
+
x: a.x + (b.x - a.x) * anchor.x + (d.x - a.x) * anchor.y,
|
|
44
|
+
y: a.y + (b.y - a.y) * anchor.x + (d.y - a.y) * anchor.y,
|
|
45
|
+
};
|
|
46
|
+
return () => rootMatrix(drawing, nodes[0])?.multiply(inverse).apply(initial) ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Read accepted geometry after the transformer has applied its own constraints. */
|
|
50
|
+
export function guideReader(drawing: Svg, request: SnapRequest, boxes: readonly Box[]) {
|
|
51
|
+
const pointOnly = request.kind === 'point',
|
|
52
|
+
resizing = request.kind === 'resize';
|
|
53
|
+
const nodes = pointOnly ? [] : [...new Set(request.nodes)];
|
|
54
|
+
const dragged = draggedAnchor(drawing, request);
|
|
55
|
+
return (matches: readonly (Match | null)[], point: Point | null): Guide[] => {
|
|
56
|
+
if (
|
|
57
|
+
!point ||
|
|
58
|
+
drawing.isDestroyed ||
|
|
59
|
+
nodes.some((node) => node.isDestroyed || !drawing.el.contains(node.el))
|
|
60
|
+
)
|
|
61
|
+
return [];
|
|
62
|
+
const box = pointOnly ? { ...point, width: 0, height: 0 } : movingBox(nodes, drawing);
|
|
63
|
+
if (!box) return [];
|
|
64
|
+
const active = dragged();
|
|
65
|
+
const guides: Guide[] = [];
|
|
66
|
+
for (const [index, axis] of (['x', 'y'] as const).entries()) {
|
|
67
|
+
const match = matches[index];
|
|
68
|
+
if (!match) continue;
|
|
69
|
+
const other = axis === 'x' ? 'y' : 'x',
|
|
70
|
+
size = axis === 'x' ? 'width' : 'height',
|
|
71
|
+
span = other === 'x' ? 'width' : 'height';
|
|
72
|
+
const values = resizing
|
|
73
|
+
? active
|
|
74
|
+
? [active[axis]]
|
|
75
|
+
: []
|
|
76
|
+
: [box[axis], box[axis] + box[size] / 2, box[axis] + box[size]];
|
|
77
|
+
const value = values.reduce(
|
|
78
|
+
(best, next) =>
|
|
79
|
+
Math.abs(next - match.target) < Math.abs(best - match.target) ? next : best,
|
|
80
|
+
Infinity,
|
|
81
|
+
);
|
|
82
|
+
if (!Number.isFinite(value)) continue;
|
|
83
|
+
const from = drawing.toClient({ ...point, [axis]: value }),
|
|
84
|
+
to = drawing.toClient({ ...point, [axis]: match.target });
|
|
85
|
+
if (!from || !to || Math.hypot(to.x - from.x, to.y - from.y) > 0.5) continue;
|
|
86
|
+
if (match.gaps) {
|
|
87
|
+
let valid = true;
|
|
88
|
+
const gaps = match.gaps.map<Guide>((gap) => {
|
|
89
|
+
const from = gap.moving === 'from' ? box[axis] + box[size] : gap.from;
|
|
90
|
+
const to = gap.moving === 'to' ? box[axis] : gap.to;
|
|
91
|
+
const expected = drawing.toClient({ ...point, [axis]: gap.to - gap.from });
|
|
92
|
+
const accepted = drawing.toClient({ ...point, [axis]: to - from });
|
|
93
|
+
const neighbor = gap.neighbor === undefined ? null : boxes[gap.neighbor];
|
|
94
|
+
const low = neighbor ? Math.max(box[other], neighbor[other]) : gap.at;
|
|
95
|
+
const high = neighbor
|
|
96
|
+
? Math.min(box[other] + box[span], neighbor[other] + neighbor[span])
|
|
97
|
+
: gap.at;
|
|
98
|
+
if (
|
|
99
|
+
!expected ||
|
|
100
|
+
!accepted ||
|
|
101
|
+
to <= from ||
|
|
102
|
+
low > high ||
|
|
103
|
+
Math.hypot(accepted.x - expected.x, accepted.y - expected.y) > 0.5
|
|
104
|
+
)
|
|
105
|
+
valid = false;
|
|
106
|
+
return {
|
|
107
|
+
axis: other,
|
|
108
|
+
position: (low + high) / 2,
|
|
109
|
+
from,
|
|
110
|
+
to,
|
|
111
|
+
marks: [from, to],
|
|
112
|
+
gap: true,
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
if (valid) guides.push(...gaps);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const target = !match.grid && boxes[match.order];
|
|
119
|
+
guides.push({
|
|
120
|
+
axis,
|
|
121
|
+
position: match.target,
|
|
122
|
+
from: Math.min(box[other], target ? target[other] : box[other]),
|
|
123
|
+
to: Math.max(
|
|
124
|
+
box[other] + box[span],
|
|
125
|
+
target ? target[other] + target[span] : box[other] + box[span],
|
|
126
|
+
),
|
|
127
|
+
marks: [box[other] + box[span] / 2, ...(target ? [target[other] + target[span] / 2] : [])],
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return guides;
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Box } from '@rasamjs/core';
|
|
2
|
+
import type { Gap } from './spacing.js';
|
|
3
|
+
|
|
4
|
+
export interface MovingLine {
|
|
5
|
+
readonly value: number;
|
|
6
|
+
readonly edge: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface TargetLine {
|
|
9
|
+
readonly value: number;
|
|
10
|
+
readonly order: number;
|
|
11
|
+
}
|
|
12
|
+
export interface Features {
|
|
13
|
+
readonly x: readonly MovingLine[];
|
|
14
|
+
readonly y: readonly MovingLine[];
|
|
15
|
+
}
|
|
16
|
+
export interface Targets {
|
|
17
|
+
readonly x: readonly TargetLine[];
|
|
18
|
+
readonly y: readonly TargetLine[];
|
|
19
|
+
readonly boxes: readonly Box[];
|
|
20
|
+
}
|
|
21
|
+
export interface Match {
|
|
22
|
+
readonly delta: number;
|
|
23
|
+
readonly target: number;
|
|
24
|
+
readonly order: number;
|
|
25
|
+
readonly grid: boolean;
|
|
26
|
+
readonly gaps?: readonly Gap[];
|
|
27
|
+
}
|
|
28
|
+
interface AxisRequest {
|
|
29
|
+
readonly moving: readonly MovingLine[];
|
|
30
|
+
readonly targets: readonly TargetLine[];
|
|
31
|
+
readonly offset: number;
|
|
32
|
+
readonly tolerance: number;
|
|
33
|
+
readonly grid: { readonly size: number; readonly origin: number } | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function axisLines(value: number, size: number): MovingLine[] {
|
|
37
|
+
return [
|
|
38
|
+
{ value, edge: true },
|
|
39
|
+
{ value: value + size / 2, edge: false },
|
|
40
|
+
{ value: value + size, edge: true },
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function boxLines(box: Box): Features {
|
|
45
|
+
return { x: axisLines(box.x, box.width), y: axisLines(box.y, box.height) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sorted(lines: TargetLine[]): TargetLine[] {
|
|
49
|
+
return lines
|
|
50
|
+
.sort((a, b) => a.value - b.value || a.order - b.order)
|
|
51
|
+
.filter((line, index, all) => index === 0 || line.value !== all[index - 1].value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function targetLines(boxes: readonly Box[]): Targets {
|
|
55
|
+
const x: TargetLine[] = [],
|
|
56
|
+
y: TargetLine[] = [];
|
|
57
|
+
boxes.forEach((box, order) => {
|
|
58
|
+
const lines = boxLines(box);
|
|
59
|
+
for (const line of lines.x) x.push({ value: line.value, order });
|
|
60
|
+
for (const line of lines.y) y.push({ value: line.value, order });
|
|
61
|
+
});
|
|
62
|
+
return { x: sorted(x), y: sorted(y), boxes: boxes.map((box) => ({ ...box })) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function prefer(current: Match | null, candidate: Match): Match {
|
|
66
|
+
if (!current) return candidate;
|
|
67
|
+
const distance = Math.abs(candidate.delta) - Math.abs(current.delta);
|
|
68
|
+
if (distance !== 0) return distance < 0 ? candidate : current;
|
|
69
|
+
if (!!candidate.gaps !== !!current.gaps) return candidate.gaps ? current : candidate;
|
|
70
|
+
if (candidate.grid !== current.grid) return candidate.grid ? current : candidate;
|
|
71
|
+
return candidate.order < current.order ? candidate : current;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function lowerBound(lines: readonly TargetLine[], value: number): number {
|
|
75
|
+
let low = 0,
|
|
76
|
+
high = lines.length;
|
|
77
|
+
while (low < high) {
|
|
78
|
+
const middle = Math.floor((low + high) / 2);
|
|
79
|
+
if (lines[middle].value < value) low = middle + 1;
|
|
80
|
+
else high = middle;
|
|
81
|
+
}
|
|
82
|
+
return low;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function snapAxis(request: AxisRequest): Match | null {
|
|
86
|
+
let best: Match | null = null;
|
|
87
|
+
for (const line of request.moving) {
|
|
88
|
+
const value = line.value + request.offset;
|
|
89
|
+
const index = lowerBound(request.targets, value);
|
|
90
|
+
for (const neighbor of [index - 1, index]) {
|
|
91
|
+
const target = request.targets[neighbor];
|
|
92
|
+
if (target && Math.abs(target.value - value) <= request.tolerance)
|
|
93
|
+
best = prefer(best, {
|
|
94
|
+
delta: target.value - value,
|
|
95
|
+
target: target.value,
|
|
96
|
+
order: target.order,
|
|
97
|
+
grid: false,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (request.grid && line.edge) {
|
|
101
|
+
const { size, origin } = request.grid;
|
|
102
|
+
const target = origin + Math.round((value - origin) / size) * size;
|
|
103
|
+
if (Math.abs(target - value) <= request.tolerance)
|
|
104
|
+
best = prefer(best, { delta: target - value, target, order: Infinity, grid: true });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return best;
|
|
108
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Point } from '@rasamjs/core';
|
|
2
|
+
import type { SnapperOptions } from './types.js';
|
|
3
|
+
|
|
4
|
+
export function assertPoint(point: Point, name = 'Point'): void {
|
|
5
|
+
if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y))
|
|
6
|
+
throw new TypeError(`${name} must have finite x and y coordinates`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function resolveOptions(options: SnapperOptions = {}) {
|
|
10
|
+
const tolerance = options.tolerance ?? 6;
|
|
11
|
+
if (!Number.isFinite(tolerance) || tolerance < 0)
|
|
12
|
+
throw new RangeError('Snapping tolerance must be nonnegative and finite');
|
|
13
|
+
const shapes = options.shapes ?? true;
|
|
14
|
+
const spacing = options.spacing ?? true;
|
|
15
|
+
if (typeof spacing !== 'boolean') throw new TypeError('spacing must be a boolean');
|
|
16
|
+
if (typeof shapes !== 'boolean') throw new TypeError('shapes must be a boolean');
|
|
17
|
+
if (options.targets !== undefined && typeof options.targets !== 'function')
|
|
18
|
+
throw new TypeError('targets must be a function');
|
|
19
|
+
const modifier = options.modifier ?? 'ctrl';
|
|
20
|
+
if (modifier !== 'ctrl' && modifier !== 'none') throw new TypeError('Invalid snapping modifier');
|
|
21
|
+
const stroke = options.style?.stroke ?? '#f00';
|
|
22
|
+
const gapStroke = options.style?.gapStroke ?? stroke;
|
|
23
|
+
if ([stroke, gapStroke].some((value) => typeof value !== 'string' || !value.trim()))
|
|
24
|
+
throw new TypeError('Guide stroke must be a nonempty string');
|
|
25
|
+
let grid: { readonly size: number; readonly origin: Point } | null = null;
|
|
26
|
+
if (options.grid !== undefined && options.grid !== null) {
|
|
27
|
+
const size = typeof options.grid === 'number' ? options.grid : options.grid.size;
|
|
28
|
+
if (!Number.isFinite(size) || size <= 0)
|
|
29
|
+
throw new RangeError('Grid size must be positive and finite');
|
|
30
|
+
const origin =
|
|
31
|
+
typeof options.grid === 'number' ? { x: 0, y: 0 } : (options.grid.origin ?? { x: 0, y: 0 });
|
|
32
|
+
assertPoint(origin, 'Grid origin');
|
|
33
|
+
grid = { size, origin: { x: origin.x, y: origin.y } };
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
tolerance,
|
|
37
|
+
shapes,
|
|
38
|
+
spacing,
|
|
39
|
+
grid,
|
|
40
|
+
targets: options.targets,
|
|
41
|
+
modifier,
|
|
42
|
+
stroke,
|
|
43
|
+
gapStroke,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ResolvedOptions = ReturnType<typeof resolveOptions>;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { Matrix } from '@rasamjs/core';
|
|
2
|
+
import type { Point, Svg } from '@rasamjs/core';
|
|
3
|
+
import type { Guide } from './guides.js';
|
|
4
|
+
|
|
5
|
+
const namespace = 'http://www.w3.org/2000/svg';
|
|
6
|
+
const guidePoint = (guide: Guide, value: number): Point =>
|
|
7
|
+
guide.axis === 'x' ? { x: guide.position, y: value } : { x: value, y: guide.position };
|
|
8
|
+
|
|
9
|
+
/** Native SVG outside the managed drawing; geometry is projected through client pixels. */
|
|
10
|
+
export class GuideOverlay {
|
|
11
|
+
private readonly element: SVGSVGElement;
|
|
12
|
+
private readonly lines: SVGLineElement[];
|
|
13
|
+
private readonly marks: SVGPathElement;
|
|
14
|
+
private readonly gaps: SVGPathElement;
|
|
15
|
+
private readonly view: Window;
|
|
16
|
+
private pending: number | null = null;
|
|
17
|
+
private read: (() => readonly Guide[]) | null = null;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly drawing: Svg,
|
|
21
|
+
stroke: string,
|
|
22
|
+
gapStroke = stroke,
|
|
23
|
+
) {
|
|
24
|
+
const document = drawing.el.ownerDocument;
|
|
25
|
+
this.view = document.defaultView!;
|
|
26
|
+
let outer = drawing.el;
|
|
27
|
+
while (outer.ownerSVGElement) outer = outer.ownerSVGElement;
|
|
28
|
+
if (!outer.parentElement) throw new TypeError('Guides need a mounted drawing container');
|
|
29
|
+
this.element = document.createElementNS(namespace, 'svg');
|
|
30
|
+
for (const [key, value] of Object.entries({
|
|
31
|
+
class: 'rasam-snapping',
|
|
32
|
+
width: '100%',
|
|
33
|
+
height: '100%',
|
|
34
|
+
'aria-hidden': 'true',
|
|
35
|
+
focusable: 'false',
|
|
36
|
+
fill: 'none',
|
|
37
|
+
stroke,
|
|
38
|
+
}))
|
|
39
|
+
this.element.setAttribute(key, value);
|
|
40
|
+
Object.assign(this.element.style, {
|
|
41
|
+
position: 'absolute',
|
|
42
|
+
inset: '0',
|
|
43
|
+
overflow: 'visible',
|
|
44
|
+
pointerEvents: 'none',
|
|
45
|
+
display: 'none',
|
|
46
|
+
});
|
|
47
|
+
this.lines = ['x', 'y'].map((axis) => {
|
|
48
|
+
const line = this.create('line');
|
|
49
|
+
line.setAttribute('data-axis', axis);
|
|
50
|
+
return line;
|
|
51
|
+
});
|
|
52
|
+
this.marks = this.create('path');
|
|
53
|
+
this.gaps = this.create('path');
|
|
54
|
+
this.gaps.setAttribute('data-gaps', '');
|
|
55
|
+
this.gaps.setAttribute('stroke', gapStroke);
|
|
56
|
+
outer.parentElement.append(this.element);
|
|
57
|
+
this.view.addEventListener('scroll', this.schedule, true);
|
|
58
|
+
this.view.addEventListener('resize', this.schedule);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private create<Tag extends 'line' | 'path'>(tag: Tag): SVGElementTagNameMap[Tag] {
|
|
62
|
+
const element = this.element.ownerDocument.createElementNS(namespace, tag);
|
|
63
|
+
element.setAttribute('stroke-width', '1');
|
|
64
|
+
element.setAttribute('vector-effect', 'non-scaling-stroke');
|
|
65
|
+
this.element.append(element);
|
|
66
|
+
return element;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
show(read: () => readonly Guide[]): void {
|
|
70
|
+
this.read = read;
|
|
71
|
+
this.schedule();
|
|
72
|
+
}
|
|
73
|
+
private readonly schedule = (): void => {
|
|
74
|
+
if (this.read && this.pending === null)
|
|
75
|
+
this.pending = this.view.requestAnimationFrame(() => {
|
|
76
|
+
this.pending = null;
|
|
77
|
+
this.update();
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
update(): void {
|
|
82
|
+
if (this.pending !== null) this.view.cancelAnimationFrame(this.pending);
|
|
83
|
+
this.pending = null;
|
|
84
|
+
const guides = this.read?.() ?? [];
|
|
85
|
+
if (!guides.length || !this.element.isConnected) {
|
|
86
|
+
this.element.style.display = 'none';
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
this.element.style.display = '';
|
|
90
|
+
const screen = this.element.getScreenCTM?.();
|
|
91
|
+
const inverse =
|
|
92
|
+
screen && new Matrix(screen.a, screen.b, screen.c, screen.d, screen.e, screen.f).inverse();
|
|
93
|
+
if (!inverse) {
|
|
94
|
+
this.element.style.display = 'none';
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const marks: string[] = [];
|
|
98
|
+
for (const [index, axis] of (['x', 'y'] as const).entries()) {
|
|
99
|
+
const line = this.lines[index],
|
|
100
|
+
guide = guides.find((guide) => !guide.gap && guide.axis === axis);
|
|
101
|
+
line.style.display = guide ? '' : 'none';
|
|
102
|
+
if (!guide) continue;
|
|
103
|
+
const point = (value: number) => guidePoint(guide, value);
|
|
104
|
+
const a = this.drawing.toClient(point(guide.from)),
|
|
105
|
+
b = this.drawing.toClient(point(guide.to)),
|
|
106
|
+
next = this.drawing.toClient(point(guide.from + 1));
|
|
107
|
+
if (!a || !b || !next) {
|
|
108
|
+
this.element.style.display = 'none';
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const length = Math.hypot(next.x - a.x, next.y - a.y);
|
|
112
|
+
if (!length) {
|
|
113
|
+
line.style.display = 'none';
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const pad = { x: (8 * (next.x - a.x)) / length, y: (8 * (next.y - a.y)) / length };
|
|
117
|
+
const start = inverse.apply({ x: a.x - pad.x, y: a.y - pad.y }),
|
|
118
|
+
end = inverse.apply({ x: b.x + pad.x, y: b.y + pad.y });
|
|
119
|
+
for (const [name, value] of Object.entries({
|
|
120
|
+
x1: start.x,
|
|
121
|
+
y1: start.y,
|
|
122
|
+
x2: end.x,
|
|
123
|
+
y2: end.y,
|
|
124
|
+
}))
|
|
125
|
+
line.setAttribute(name, String(value));
|
|
126
|
+
for (const value of guide.marks) {
|
|
127
|
+
const client = this.drawing.toClient(point(value));
|
|
128
|
+
if (!client) continue;
|
|
129
|
+
for (const [a, b] of [
|
|
130
|
+
[-3, 3],
|
|
131
|
+
[3, -3],
|
|
132
|
+
]) {
|
|
133
|
+
const start = inverse.apply({ x: client.x - 3, y: client.y + a }),
|
|
134
|
+
end = inverse.apply({ x: client.x + 3, y: client.y + b });
|
|
135
|
+
marks.push(`M${start.x},${start.y}L${end.x},${end.y}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
this.marks.setAttribute('d', marks.join(''));
|
|
140
|
+
this.gaps.setAttribute(
|
|
141
|
+
'd',
|
|
142
|
+
guides
|
|
143
|
+
.filter((guide) => guide.gap)
|
|
144
|
+
.map((guide) => this.gapPath(guide, inverse))
|
|
145
|
+
.join(''),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private gapPath(guide: Guide, inverse: Matrix): string {
|
|
150
|
+
const a = this.drawing.toClient(guidePoint(guide, guide.from));
|
|
151
|
+
const b = this.drawing.toClient(guidePoint(guide, guide.to));
|
|
152
|
+
if (!a || !b) return '';
|
|
153
|
+
const length = Math.hypot(b.x - a.x, b.y - a.y);
|
|
154
|
+
if (!length) return '';
|
|
155
|
+
const cap = { x: (3 * (b.y - a.y)) / length, y: (3 * (b.x - a.x)) / length };
|
|
156
|
+
const segment = (from: Point, to: Point) => {
|
|
157
|
+
const start = inverse.apply(from),
|
|
158
|
+
end = inverse.apply(to);
|
|
159
|
+
return `M${start.x},${start.y}L${end.x},${end.y}`;
|
|
160
|
+
};
|
|
161
|
+
return (
|
|
162
|
+
segment(a, b) +
|
|
163
|
+
[a, b]
|
|
164
|
+
.map((point) =>
|
|
165
|
+
segment(
|
|
166
|
+
{ x: point.x + cap.x, y: point.y - cap.y },
|
|
167
|
+
{ x: point.x - cap.x, y: point.y + cap.y },
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
.join('')
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
clear(): void {
|
|
175
|
+
if (this.pending !== null) this.view.cancelAnimationFrame(this.pending);
|
|
176
|
+
this.pending = null;
|
|
177
|
+
this.read = null;
|
|
178
|
+
this.element.style.display = 'none';
|
|
179
|
+
}
|
|
180
|
+
destroy(): void {
|
|
181
|
+
this.clear();
|
|
182
|
+
this.element.remove();
|
|
183
|
+
this.view.removeEventListener('scroll', this.schedule, true);
|
|
184
|
+
this.view.removeEventListener('resize', this.schedule);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { Point } from '@rasamjs/core';
|
|
2
|
+
import type { Features, Match, Targets } from './lines.js';
|
|
3
|
+
import { prefer, snapAxis } from './lines.js';
|
|
4
|
+
import type { Spacing } from './spacing.js';
|
|
5
|
+
import type { ResolvedOptions } from './options.js';
|
|
6
|
+
import { assertPoint } from './options.js';
|
|
7
|
+
import type { SnapSession } from './types.js';
|
|
8
|
+
|
|
9
|
+
interface Mapping {
|
|
10
|
+
fromClient(point: Point): Point | null;
|
|
11
|
+
toClient(point: Point): Point | null;
|
|
12
|
+
}
|
|
13
|
+
interface SessionConfig {
|
|
14
|
+
readonly start: Point;
|
|
15
|
+
readonly features: Features;
|
|
16
|
+
readonly targets: Targets;
|
|
17
|
+
readonly mapping: Mapping;
|
|
18
|
+
readonly options: ResolvedOptions;
|
|
19
|
+
readonly isEnabled: () => boolean;
|
|
20
|
+
readonly onEnd: () => void;
|
|
21
|
+
readonly onSnap?: (matches: readonly (Match | null)[], point: Point | null) => void;
|
|
22
|
+
readonly spacing?: Spacing | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Unit-frame anchors can map to either root axis after a quarter turn or reflection. */
|
|
26
|
+
export function resizeLines(corners: readonly Point[], anchor: Point): Features | null {
|
|
27
|
+
if (corners.length !== 4) return null;
|
|
28
|
+
const [a, b, c, d] = corners;
|
|
29
|
+
const u = { x: b.x - a.x, y: b.y - a.y },
|
|
30
|
+
v = { x: d.x - a.x, y: d.y - a.y };
|
|
31
|
+
const epsilon = 1e-7 * Math.max(Math.abs(u.x), Math.abs(u.y), Math.abs(v.x), Math.abs(v.y));
|
|
32
|
+
const horizontal = (edge: Point) => Math.abs(edge.x) > 1e-7 * Math.hypot(edge.x, edge.y);
|
|
33
|
+
const vertical = (edge: Point) => Math.abs(edge.y) > 1e-7 * Math.hypot(edge.x, edge.y);
|
|
34
|
+
if (
|
|
35
|
+
[u, v].some((edge) => horizontal(edge) && vertical(edge)) ||
|
|
36
|
+
(horizontal(u) && horizontal(v)) ||
|
|
37
|
+
(vertical(u) && vertical(v)) ||
|
|
38
|
+
Math.abs(c.x - b.x - d.x + a.x) > epsilon ||
|
|
39
|
+
Math.abs(c.y - b.y - d.y + a.y) > epsilon
|
|
40
|
+
)
|
|
41
|
+
return null;
|
|
42
|
+
const point = {
|
|
43
|
+
x: a.x + u.x * anchor.x + v.x * anchor.y,
|
|
44
|
+
y: a.y + u.y * anchor.x + v.y * anchor.y,
|
|
45
|
+
};
|
|
46
|
+
const x = (anchor.x !== 0.5 && horizontal(u)) || (anchor.y !== 0.5 && horizontal(v));
|
|
47
|
+
const y = (anchor.x !== 0.5 && vertical(u)) || (anchor.y !== 0.5 && vertical(v));
|
|
48
|
+
return x || y
|
|
49
|
+
? { x: x ? [{ value: point.x, edge: true }] : [], y: y ? [{ value: point.y, edge: true }] : [] }
|
|
50
|
+
: null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function clientScale(mapping: Mapping): Point | null {
|
|
54
|
+
const origin = mapping.toClient({ x: 0, y: 0 }),
|
|
55
|
+
x = mapping.toClient({ x: 1, y: 0 }),
|
|
56
|
+
y = mapping.toClient({ x: 0, y: 1 });
|
|
57
|
+
if (!origin || !x || !y) return null;
|
|
58
|
+
const scale = {
|
|
59
|
+
x: Math.hypot(x.x - origin.x, x.y - origin.y),
|
|
60
|
+
y: Math.hypot(y.x - origin.x, y.y - origin.y),
|
|
61
|
+
};
|
|
62
|
+
return Number.isFinite(scale.x) && Number.isFinite(scale.y) && scale.x > 0 && scale.y > 0
|
|
63
|
+
? scale
|
|
64
|
+
: null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function correctedClient(client: Point, config: SessionConfig, centered?: boolean): Point {
|
|
68
|
+
assertPoint(client, 'Client pointer');
|
|
69
|
+
const point = config.mapping.fromClient(client),
|
|
70
|
+
scale = point && clientScale(config.mapping);
|
|
71
|
+
if (!point || !scale || !Number.isFinite(point.x) || !Number.isFinite(point.y)) {
|
|
72
|
+
config.onSnap?.([], null);
|
|
73
|
+
return client;
|
|
74
|
+
}
|
|
75
|
+
const offset = { x: point.x - config.start.x, y: point.y - config.start.y };
|
|
76
|
+
const correction = (axis: 'x' | 'y') => {
|
|
77
|
+
const tolerance = config.options.tolerance / scale[axis];
|
|
78
|
+
const alignment = snapAxis({
|
|
79
|
+
moving: config.features[axis],
|
|
80
|
+
targets: config.options.shapes ? config.targets[axis] : [],
|
|
81
|
+
offset: offset[axis],
|
|
82
|
+
tolerance,
|
|
83
|
+
grid: config.options.grid && {
|
|
84
|
+
size: config.options.grid.size,
|
|
85
|
+
origin: config.options.grid.origin[axis],
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
const spacing = config.spacing?.({ axis, offset, tolerance, centered });
|
|
89
|
+
return spacing ? prefer(alignment, spacing) : alignment;
|
|
90
|
+
};
|
|
91
|
+
const x = correction('x'),
|
|
92
|
+
y = correction('y');
|
|
93
|
+
const corrected = { x: point.x + (x?.delta ?? 0), y: point.y + (y?.delta ?? 0) };
|
|
94
|
+
const result = x?.delta || y?.delta ? config.mapping.toClient(corrected) : client;
|
|
95
|
+
config.onSnap?.(result ? [x, y] : [], result ? corrected : null);
|
|
96
|
+
return result ?? client;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createSession(config: SessionConfig): SnapSession {
|
|
100
|
+
let ended = false;
|
|
101
|
+
return {
|
|
102
|
+
snap(client, modifiers) {
|
|
103
|
+
if (ended) return client;
|
|
104
|
+
if (!config.isEnabled() || (modifiers.ignore && config.options.modifier !== 'none')) {
|
|
105
|
+
config.onSnap?.([], null);
|
|
106
|
+
return client;
|
|
107
|
+
}
|
|
108
|
+
return correctedClient(client, config, modifiers.centered);
|
|
109
|
+
},
|
|
110
|
+
end() {
|
|
111
|
+
if (ended) return;
|
|
112
|
+
ended = true;
|
|
113
|
+
config.onEnd();
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|