@rasamjs/tools 0.0.0-stage → 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 +308 -0
- package/CHANGELOG.md +53 -0
- package/LICENSE +21 -0
- package/README.md +138 -2
- package/RELEASING.md +88 -0
- 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/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 +7 -0
- package/dist/snapping/targets.d.ts.map +1 -0
- package/dist/snapping/targets.js +71 -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 +73 -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/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 +88 -0
- package/src/snapping/types.ts +27 -0
- package/src/snapping.ts +3 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { Svg } from '@rasamjs/core';
|
|
2
|
+
import { Guards } from '@rasamjs/core/advanced';
|
|
3
|
+
import { resolveOptions } from '../snapping/options.js';
|
|
4
|
+
import type { ResolvedOptions } from '../snapping/options.js';
|
|
5
|
+
import { GuideOverlay } from '../snapping/overlay.js';
|
|
6
|
+
import type { SnapSession } from '../snapping/types.js';
|
|
7
|
+
import type {
|
|
8
|
+
AdvancedSnapperOptions,
|
|
9
|
+
AdvancedSnapRequest,
|
|
10
|
+
RotationSnapRequest,
|
|
11
|
+
RotationSnapSession,
|
|
12
|
+
} from './types.js';
|
|
13
|
+
import type { SessionContext } from './context.js';
|
|
14
|
+
import { createAdvancedSession } from './session.js';
|
|
15
|
+
import { createRotationSession } from './rotation.js';
|
|
16
|
+
|
|
17
|
+
/** Owns enhanced sessions while sharing the original engine's geometry and guides. */
|
|
18
|
+
export class AdvancedSnapper {
|
|
19
|
+
readonly rotatedResize = true;
|
|
20
|
+
readonly keyboard: boolean;
|
|
21
|
+
private readonly options: ResolvedOptions;
|
|
22
|
+
private readonly advanced: AdvancedSnapperOptions;
|
|
23
|
+
private readonly overlay: GuideOverlay;
|
|
24
|
+
private readonly dispose: () => void;
|
|
25
|
+
private active: SnapSession | null = null;
|
|
26
|
+
private version = 0;
|
|
27
|
+
private disposed = false;
|
|
28
|
+
private enabledState = true;
|
|
29
|
+
constructor(
|
|
30
|
+
private readonly drawing: Svg,
|
|
31
|
+
advanced: AdvancedSnapperOptions = {},
|
|
32
|
+
) {
|
|
33
|
+
if (
|
|
34
|
+
!Guards.isTransformable(drawing) ||
|
|
35
|
+
drawing.isDestroyed ||
|
|
36
|
+
drawing.el.localName !== 'svg' ||
|
|
37
|
+
!drawing.el.isConnected ||
|
|
38
|
+
!drawing.el.ownerDocument.defaultView
|
|
39
|
+
)
|
|
40
|
+
throw new TypeError('AdvancedSnapper needs a live, mounted drawing');
|
|
41
|
+
this.advanced = { ...advanced };
|
|
42
|
+
this.options = resolveOptions(advanced);
|
|
43
|
+
this.keyboard = advanced.keyboard ?? true;
|
|
44
|
+
if (
|
|
45
|
+
typeof this.keyboard !== 'boolean' ||
|
|
46
|
+
(advanced.curves !== undefined && typeof advanced.curves !== 'boolean')
|
|
47
|
+
)
|
|
48
|
+
throw new TypeError('Keyboard and curve snapping options must be booleans');
|
|
49
|
+
if (advanced.guides !== undefined && typeof advanced.guides !== 'function')
|
|
50
|
+
throw new TypeError('guides must be a function');
|
|
51
|
+
for (const [name, value] of [
|
|
52
|
+
['curveResolution', advanced.curveResolution ?? 0.25],
|
|
53
|
+
['angleTolerance', advanced.angleTolerance ?? 5],
|
|
54
|
+
] as const)
|
|
55
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
56
|
+
throw new RangeError(`${name} must be positive and finite`);
|
|
57
|
+
this.overlay = new GuideOverlay(drawing, this.options.stroke, this.options.gapStroke);
|
|
58
|
+
this.dispose = drawing.onDispose(() => this.destroy());
|
|
59
|
+
}
|
|
60
|
+
get isDestroyed(): boolean {
|
|
61
|
+
return this.disposed;
|
|
62
|
+
}
|
|
63
|
+
get enabled(): boolean {
|
|
64
|
+
return this.enabledState;
|
|
65
|
+
}
|
|
66
|
+
set enabled(value: boolean) {
|
|
67
|
+
if (typeof value !== 'boolean') throw new TypeError('enabled must be a boolean');
|
|
68
|
+
this.enabledState = value;
|
|
69
|
+
if (!value) this.overlay.clear();
|
|
70
|
+
}
|
|
71
|
+
update(): this {
|
|
72
|
+
if (!this.disposed) this.overlay.update();
|
|
73
|
+
return this;
|
|
74
|
+
}
|
|
75
|
+
destroy(): void {
|
|
76
|
+
if (this.disposed) return;
|
|
77
|
+
this.disposed = true;
|
|
78
|
+
try {
|
|
79
|
+
this.active?.end();
|
|
80
|
+
} finally {
|
|
81
|
+
this.overlay.destroy();
|
|
82
|
+
this.dispose();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
private reset(): number {
|
|
86
|
+
const version = ++this.version;
|
|
87
|
+
const previous = this.active;
|
|
88
|
+
this.active = null;
|
|
89
|
+
previous?.end();
|
|
90
|
+
return version;
|
|
91
|
+
}
|
|
92
|
+
private ended(version: number): void {
|
|
93
|
+
if (version !== this.version) return;
|
|
94
|
+
this.active = null;
|
|
95
|
+
this.overlay.clear();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
begin(request: AdvancedSnapRequest): SnapSession | null {
|
|
99
|
+
const version = this.reset();
|
|
100
|
+
if (!this.enabled || this.disposed) return null;
|
|
101
|
+
const session = createAdvancedSession(this.context(request, version));
|
|
102
|
+
if (session) this.active = session;
|
|
103
|
+
return session;
|
|
104
|
+
}
|
|
105
|
+
rotate(request: RotationSnapRequest): RotationSnapSession | null {
|
|
106
|
+
const version = this.reset();
|
|
107
|
+
if (!this.enabled || this.disposed) return null;
|
|
108
|
+
const session = createRotationSession(this.context(request, version));
|
|
109
|
+
if (session) this.active = session;
|
|
110
|
+
return session;
|
|
111
|
+
}
|
|
112
|
+
private context<Request>(request: Request, version: number): SessionContext<Request> {
|
|
113
|
+
return {
|
|
114
|
+
drawing: this.drawing,
|
|
115
|
+
options: this.options,
|
|
116
|
+
advanced: this.advanced,
|
|
117
|
+
overlay: this.overlay,
|
|
118
|
+
request,
|
|
119
|
+
isEnabled: () => this.enabled && !this.disposed,
|
|
120
|
+
isCurrent: () => version === this.version && !this.disposed,
|
|
121
|
+
onEnd: () => this.ended(version),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AnyNode, Point } from '@rasamjs/core';
|
|
2
|
+
import type { SnapperOptions, SnapRequest, SnapSession } from '../snapping/types.js';
|
|
3
|
+
|
|
4
|
+
export interface RulerGuide {
|
|
5
|
+
readonly axis: 'x' | 'y';
|
|
6
|
+
readonly position: number;
|
|
7
|
+
}
|
|
8
|
+
export interface AdvancedSnapperOptions extends SnapperOptions {
|
|
9
|
+
readonly guides?: () => Iterable<RulerGuide>;
|
|
10
|
+
readonly curves?: boolean;
|
|
11
|
+
readonly curveResolution?: number;
|
|
12
|
+
readonly angleTolerance?: number;
|
|
13
|
+
readonly keyboard?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface RotationSnapRequest {
|
|
16
|
+
readonly nodes: readonly AnyNode[];
|
|
17
|
+
readonly client: Point;
|
|
18
|
+
readonly pivot: Point;
|
|
19
|
+
readonly corners: readonly Point[];
|
|
20
|
+
}
|
|
21
|
+
export type AdvancedSnapRequest = (
|
|
22
|
+
| SnapRequest
|
|
23
|
+
| {
|
|
24
|
+
readonly kind: 'point';
|
|
25
|
+
readonly client: Point;
|
|
26
|
+
readonly nodes?: readonly AnyNode[];
|
|
27
|
+
}
|
|
28
|
+
) & { readonly keyboard?: boolean };
|
|
29
|
+
export interface RotationSnapSession extends SnapSession {
|
|
30
|
+
readonly angleSnapped: boolean;
|
|
31
|
+
}
|
package/src/drawing.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { DrawTool } from './editor/draw-tool.js';
|
|
2
|
+
export type { DrawToolOptions, DrawingKind, PointSnapper } from './editor/draw-tool.js';
|
|
3
|
+
export { PathEditor } from './editor/path-editor.js';
|
|
4
|
+
export type { PathEditorOptions, EditablePath, PathHandle } from './editor/path-editor.js';
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { AnyNode, Point, Svg } from '@rasamjs/core';
|
|
2
|
+
import { beginChangeGroup } from '@rasamjs/core/changes';
|
|
3
|
+
import type { SnapSession } from '../snapping/types.js';
|
|
4
|
+
import { assertPoint } from '../snapping/options.js';
|
|
5
|
+
import { Surface, svg } from './surface.js';
|
|
6
|
+
import { PointerDrag } from './pointer.js';
|
|
7
|
+
|
|
8
|
+
export interface PointSnapper {
|
|
9
|
+
begin(request: {
|
|
10
|
+
readonly kind: 'point';
|
|
11
|
+
readonly client: Point;
|
|
12
|
+
readonly nodes?: readonly AnyNode[];
|
|
13
|
+
}): SnapSession | null;
|
|
14
|
+
}
|
|
15
|
+
export type DrawingKind = 'rect' | 'ellipse' | 'line' | 'path';
|
|
16
|
+
export interface DrawToolOptions {
|
|
17
|
+
readonly snapper: PointSnapper;
|
|
18
|
+
readonly kind?: DrawingKind;
|
|
19
|
+
readonly paint?: Readonly<Record<string, string | number>>;
|
|
20
|
+
readonly onCreate?: (node: AnyNode) => void;
|
|
21
|
+
}
|
|
22
|
+
export class DrawTool {
|
|
23
|
+
private readonly surface: Surface;
|
|
24
|
+
private readonly hit: SVGRectElement;
|
|
25
|
+
private enabledState = true;
|
|
26
|
+
private active: {
|
|
27
|
+
start: Point;
|
|
28
|
+
session: SnapSession | null;
|
|
29
|
+
attributes: Record<string, string | number>;
|
|
30
|
+
preview: SVGElement;
|
|
31
|
+
} | null = null;
|
|
32
|
+
constructor(
|
|
33
|
+
private readonly drawing: Svg,
|
|
34
|
+
private readonly options: DrawToolOptions,
|
|
35
|
+
) {
|
|
36
|
+
if (!options?.snapper || typeof options.snapper.begin !== 'function')
|
|
37
|
+
throw new TypeError('Drawing needs a point snapper');
|
|
38
|
+
if (!['rect', 'ellipse', 'line', 'path'].includes(options.kind ?? 'rect'))
|
|
39
|
+
throw new TypeError('Unknown drawing tool');
|
|
40
|
+
if (options.onCreate !== undefined && typeof options.onCreate !== 'function')
|
|
41
|
+
throw new TypeError('onCreate must be a function');
|
|
42
|
+
this.options = { ...options, paint: { ...options.paint } };
|
|
43
|
+
this.surface = new Surface(drawing, 'rasam-draw-tool', () => this.render());
|
|
44
|
+
this.surface.element.style.zIndex = '10';
|
|
45
|
+
this.hit = svg(drawing.el.ownerDocument, 'rect', {
|
|
46
|
+
width: '100%',
|
|
47
|
+
height: '100%',
|
|
48
|
+
fill: 'transparent',
|
|
49
|
+
});
|
|
50
|
+
this.hit.style.pointerEvents = 'all';
|
|
51
|
+
this.hit.style.cursor = 'crosshair';
|
|
52
|
+
this.surface.element.append(this.hit);
|
|
53
|
+
new PointerDrag(this.surface, {
|
|
54
|
+
start: (event) => {
|
|
55
|
+
if (!this.enabled) return false;
|
|
56
|
+
this.begin({ x: event.clientX, y: event.clientY }, event.ctrlKey || event.metaKey);
|
|
57
|
+
return true;
|
|
58
|
+
},
|
|
59
|
+
move: (event) =>
|
|
60
|
+
this.move({ x: event.clientX, y: event.clientY }, event.ctrlKey || event.metaKey),
|
|
61
|
+
end: (cancelled, event) => {
|
|
62
|
+
if (event && !cancelled)
|
|
63
|
+
this.move({ x: event.clientX, y: event.clientY }, event.ctrlKey || event.metaKey);
|
|
64
|
+
this.end(cancelled);
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
this.surface.cleanup(() => this.end(true));
|
|
68
|
+
}
|
|
69
|
+
get enabled(): boolean {
|
|
70
|
+
return this.enabledState;
|
|
71
|
+
}
|
|
72
|
+
get isDestroyed(): boolean {
|
|
73
|
+
return this.surface.isDestroyed;
|
|
74
|
+
}
|
|
75
|
+
set enabled(value: boolean) {
|
|
76
|
+
if (typeof value !== 'boolean') throw new TypeError('enabled must be boolean');
|
|
77
|
+
this.enabledState = value;
|
|
78
|
+
if (!value) this.end(true);
|
|
79
|
+
this.render();
|
|
80
|
+
}
|
|
81
|
+
begin(client: Point, ignore = false): void {
|
|
82
|
+
if (this.isDestroyed) throw new Error('Drawing tool is destroyed');
|
|
83
|
+
this.end(true);
|
|
84
|
+
assertPoint(client);
|
|
85
|
+
const session = this.options.snapper.begin({ kind: 'point', client });
|
|
86
|
+
let start: Point | null;
|
|
87
|
+
try {
|
|
88
|
+
start = this.drawing.fromClient(session?.snap(client, { ignore }) ?? client);
|
|
89
|
+
if (!start) throw new Error('Drawing mapping is unavailable');
|
|
90
|
+
} catch (error) {
|
|
91
|
+
session?.end();
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
const kind = this.options.kind ?? 'rect';
|
|
95
|
+
const preview = svg(this.drawing.el.ownerDocument, kind);
|
|
96
|
+
preview.style.pointerEvents = 'none';
|
|
97
|
+
this.surface.element.append(preview);
|
|
98
|
+
this.active = { start, session, preview, attributes: {} };
|
|
99
|
+
this.move(client, ignore);
|
|
100
|
+
}
|
|
101
|
+
move(client: Point, ignore = false): void {
|
|
102
|
+
assertPoint(client);
|
|
103
|
+
const active = this.active;
|
|
104
|
+
if (!active) return;
|
|
105
|
+
try {
|
|
106
|
+
const end = this.drawing.fromClient(active.session?.snap(client, { ignore }) ?? client);
|
|
107
|
+
if (!end) return;
|
|
108
|
+
const { start } = active,
|
|
109
|
+
x = Math.min(start.x, end.x),
|
|
110
|
+
y = Math.min(start.y, end.y);
|
|
111
|
+
const width = Math.abs(end.x - start.x),
|
|
112
|
+
height = Math.abs(end.y - start.y);
|
|
113
|
+
const paint = { fill: '#8bbbf4', stroke: '#2563a6', ...(this.options.paint ?? {}) };
|
|
114
|
+
const kind = this.options.kind ?? 'rect';
|
|
115
|
+
active.attributes =
|
|
116
|
+
kind === 'ellipse'
|
|
117
|
+
? { ...paint, cx: x + width / 2, cy: y + height / 2, rx: width / 2, ry: height / 2 }
|
|
118
|
+
: kind === 'line'
|
|
119
|
+
? { ...paint, x1: start.x, y1: start.y, x2: end.x, y2: end.y }
|
|
120
|
+
: kind === 'path'
|
|
121
|
+
? { ...paint, d: `M${start.x} ${start.y} L${end.x} ${end.y}` }
|
|
122
|
+
: { ...paint, x, y, width, height };
|
|
123
|
+
for (const [key, value] of Object.entries(active.attributes))
|
|
124
|
+
active.preview.setAttribute(key, String(value));
|
|
125
|
+
this.update();
|
|
126
|
+
} catch (error) {
|
|
127
|
+
this.end(true);
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
end(cancelled = false): AnyNode | null {
|
|
132
|
+
const active = this.active;
|
|
133
|
+
this.active = null;
|
|
134
|
+
if (!active) return null;
|
|
135
|
+
try {
|
|
136
|
+
active.preview.remove();
|
|
137
|
+
if (cancelled) return null;
|
|
138
|
+
const group = beginChangeGroup(this.drawing, { source: 'drawing-tool' });
|
|
139
|
+
let node: AnyNode;
|
|
140
|
+
try {
|
|
141
|
+
node = group.run(() => {
|
|
142
|
+
switch (this.options.kind ?? 'rect') {
|
|
143
|
+
case 'ellipse':
|
|
144
|
+
return this.drawing.ellipse({
|
|
145
|
+
...active.attributes,
|
|
146
|
+
rx: Number(active.attributes.rx),
|
|
147
|
+
ry: Number(active.attributes.ry),
|
|
148
|
+
});
|
|
149
|
+
case 'line':
|
|
150
|
+
return this.drawing.line({
|
|
151
|
+
...active.attributes,
|
|
152
|
+
x2: Number(active.attributes.x2),
|
|
153
|
+
y2: Number(active.attributes.y2),
|
|
154
|
+
});
|
|
155
|
+
case 'path':
|
|
156
|
+
return this.drawing.path({ ...active.attributes, d: String(active.attributes.d) });
|
|
157
|
+
default:
|
|
158
|
+
return this.drawing.rect(active.attributes);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
} finally {
|
|
162
|
+
group.end();
|
|
163
|
+
}
|
|
164
|
+
this.options.onCreate?.(node);
|
|
165
|
+
return node;
|
|
166
|
+
} finally {
|
|
167
|
+
active.session?.end();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
update(): this {
|
|
172
|
+
this.surface.update();
|
|
173
|
+
return this;
|
|
174
|
+
}
|
|
175
|
+
destroy(): void {
|
|
176
|
+
this.surface.destroy();
|
|
177
|
+
}
|
|
178
|
+
private render(): void {
|
|
179
|
+
this.surface.element.style.display = this.enabled ? '' : 'none';
|
|
180
|
+
if (!this.active) return;
|
|
181
|
+
const origin = this.surface.fromRoot({ x: 0, y: 0 }),
|
|
182
|
+
u = this.surface.fromRoot({ x: 1, y: 0 }),
|
|
183
|
+
v = this.surface.fromRoot({ x: 0, y: 1 });
|
|
184
|
+
if (origin && u && v)
|
|
185
|
+
this.active.preview.setAttribute(
|
|
186
|
+
'transform',
|
|
187
|
+
`matrix(${u.x - origin.x} ${u.y - origin.y} ${v.x - origin.x} ${v.y - origin.y} ${origin.x} ${origin.y})`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|