@rasamjs/connectors 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 +114 -0
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +44 -2
- package/RELEASING.md +25 -0
- package/dist/connectors.d.ts +26 -0
- package/dist/connectors.d.ts.map +1 -0
- package/dist/connectors.js +231 -0
- package/dist/connectors.js.map +1 -0
- package/dist/editor.d.ts +42 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +55 -0
- package/dist/editor.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/metadata.d.ts +8 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +47 -0
- package/dist/metadata.js.map +1 -0
- package/dist/routing.d.ts +11 -0
- package/dist/routing.d.ts.map +1 -0
- package/dist/routing.js +110 -0
- package/dist/routing.js.map +1 -0
- package/dist/tool-surface.d.ts +21 -0
- package/dist/tool-surface.d.ts.map +1 -0
- package/dist/tool-surface.js +98 -0
- package/dist/tool-surface.js.map +1 -0
- package/dist/tool.d.ts +44 -0
- package/dist/tool.d.ts.map +1 -0
- package/dist/tool.js +305 -0
- package/dist/tool.js.map +1 -0
- package/dist/types.d.ts +36 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +61 -4
- package/src/connectors.ts +248 -0
- package/src/editor.ts +82 -0
- package/src/index.ts +14 -0
- package/src/metadata.ts +63 -0
- package/src/routing.ts +127 -0
- package/src/tool-surface.ts +96 -0
- package/src/tool.ts +344 -0
- package/src/types.ts +33 -0
package/src/tool.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import type { Path, Point } from '@rasamjs/core';
|
|
2
|
+
import { Guards, Registry } from '@rasamjs/core/advanced';
|
|
3
|
+
import { observeChanges } from '@rasamjs/core/changes';
|
|
4
|
+
import type { Connectors } from './connectors.js';
|
|
5
|
+
import { isConnector } from './metadata.js';
|
|
6
|
+
import { connectorPort, endpointCenter, pathData, routeConnector } from './routing.js';
|
|
7
|
+
import { ToolSurface, svg } from './tool-surface.js';
|
|
8
|
+
import type {
|
|
9
|
+
ConnectorEndpoint,
|
|
10
|
+
ConnectorOptions,
|
|
11
|
+
ConnectorSide,
|
|
12
|
+
ConnectorTarget,
|
|
13
|
+
} from './types.js';
|
|
14
|
+
|
|
15
|
+
export interface ConnectorToolOptions extends Pick<
|
|
16
|
+
ConnectorOptions,
|
|
17
|
+
'routing' | 'arrows' | 'stroke' | 'strokeWidth'
|
|
18
|
+
> {
|
|
19
|
+
readonly selectable?: (node: ConnectorTarget) => boolean;
|
|
20
|
+
readonly onCreate?: (path: Path) => void;
|
|
21
|
+
readonly onGesture?: (active: boolean) => void;
|
|
22
|
+
readonly onError?: (error: unknown) => void;
|
|
23
|
+
readonly onExit?: () => void;
|
|
24
|
+
}
|
|
25
|
+
interface Press {
|
|
26
|
+
id: number;
|
|
27
|
+
start: ConnectorEndpoint;
|
|
28
|
+
end: ConnectorEndpoint;
|
|
29
|
+
}
|
|
30
|
+
interface Hover {
|
|
31
|
+
target: ConnectorTarget;
|
|
32
|
+
endpoint: ConnectorEndpoint;
|
|
33
|
+
}
|
|
34
|
+
const sides: readonly ConnectorSide[] = ['top', 'right', 'bottom', 'left'];
|
|
35
|
+
|
|
36
|
+
/** Drag from a hinted anchor or free point. The document is written only on release. */
|
|
37
|
+
export class ConnectorTool {
|
|
38
|
+
private readonly surface: ToolSurface;
|
|
39
|
+
private active = false;
|
|
40
|
+
private press: Press | null = null;
|
|
41
|
+
private hover: Hover | null = null;
|
|
42
|
+
private options: ConnectorToolOptions;
|
|
43
|
+
constructor(
|
|
44
|
+
readonly connectors: Connectors,
|
|
45
|
+
options: ConnectorToolOptions = {},
|
|
46
|
+
) {
|
|
47
|
+
if (connectors.isDestroyed) throw new Error('Connectors is destroyed');
|
|
48
|
+
for (const name of ['selectable', 'onCreate', 'onGesture', 'onError', 'onExit'] as const)
|
|
49
|
+
if (options[name] !== undefined && typeof options[name] !== 'function')
|
|
50
|
+
throw new TypeError(`${name} must be a function`);
|
|
51
|
+
if (options.routing !== undefined && !['straight', 'elbow'].includes(options.routing))
|
|
52
|
+
throw new TypeError('Unknown connector routing');
|
|
53
|
+
if (options.arrows !== undefined && !['none', 'end', 'both'].includes(options.arrows))
|
|
54
|
+
throw new TypeError('Unknown connector arrows');
|
|
55
|
+
if (
|
|
56
|
+
options.stroke !== undefined &&
|
|
57
|
+
(typeof options.stroke !== 'string' || !options.stroke.trim())
|
|
58
|
+
)
|
|
59
|
+
throw new TypeError('Connector stroke must be nonempty');
|
|
60
|
+
if (
|
|
61
|
+
options.strokeWidth !== undefined &&
|
|
62
|
+
(!Number.isFinite(options.strokeWidth) || options.strokeWidth < 0)
|
|
63
|
+
)
|
|
64
|
+
throw new TypeError('Connector stroke width must be nonnegative');
|
|
65
|
+
this.options = { ...options };
|
|
66
|
+
this.surface = new ToolSurface(connectors.drawing, () => this.render());
|
|
67
|
+
this.surface.cleanup(connectors.onDispose(() => this.destroy()));
|
|
68
|
+
this.surface.cleanup(
|
|
69
|
+
observeChanges(connectors.drawing, () => {
|
|
70
|
+
const attached = (endpoint: ConnectorEndpoint) =>
|
|
71
|
+
'point' in endpoint ||
|
|
72
|
+
(!endpoint.target.isDestroyed && connectors.drawing.el.contains(endpoint.target.el));
|
|
73
|
+
if (this.press && (!attached(this.press.start) || !attached(this.press.end))) this.cancel();
|
|
74
|
+
if (this.hover && !attached(this.hover.endpoint)) this.hover = null;
|
|
75
|
+
this.surface.schedule();
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
this.surface.listen(this.surface.element, 'pointerdown', this.down as EventListener);
|
|
79
|
+
this.surface.listen(this.surface.element, 'pointermove', this.over as EventListener);
|
|
80
|
+
this.surface.listen(this.surface.element, 'pointerleave', () => {
|
|
81
|
+
if (!this.press) {
|
|
82
|
+
this.hover = null;
|
|
83
|
+
this.update();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
this.surface.listen(this.surface.view, 'pointermove', this.move as EventListener);
|
|
87
|
+
this.surface.listen(this.surface.view, 'pointerup', this.up as EventListener);
|
|
88
|
+
this.surface.listen(this.surface.view, 'pointercancel', this.lost as EventListener);
|
|
89
|
+
this.surface.listen(this.surface.element, 'lostpointercapture', this.lost as EventListener);
|
|
90
|
+
this.surface.listen(this.surface.view, 'blur', () => this.cancel());
|
|
91
|
+
this.surface.listen(
|
|
92
|
+
this.surface.view,
|
|
93
|
+
'keydown',
|
|
94
|
+
((event: KeyboardEvent) => {
|
|
95
|
+
if (
|
|
96
|
+
!this.enabled ||
|
|
97
|
+
event.key !== 'Escape' ||
|
|
98
|
+
event.defaultPrevented ||
|
|
99
|
+
!this.acceptsKeyboard(event)
|
|
100
|
+
)
|
|
101
|
+
return;
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
event.stopPropagation();
|
|
104
|
+
if (this.press) this.cancel();
|
|
105
|
+
else {
|
|
106
|
+
this.enabled = false;
|
|
107
|
+
this.options.onExit?.();
|
|
108
|
+
}
|
|
109
|
+
}) as EventListener,
|
|
110
|
+
true,
|
|
111
|
+
);
|
|
112
|
+
this.surface.cleanup(() => {
|
|
113
|
+
this.active = false;
|
|
114
|
+
this.cancel();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
get isDestroyed(): boolean {
|
|
118
|
+
return this.surface.isDestroyed;
|
|
119
|
+
}
|
|
120
|
+
get enabled(): boolean {
|
|
121
|
+
return this.active;
|
|
122
|
+
}
|
|
123
|
+
get paint(): { readonly stroke: string; readonly strokeWidth: number } {
|
|
124
|
+
return Object.freeze({
|
|
125
|
+
stroke: this.options.stroke ?? '#334155',
|
|
126
|
+
strokeWidth: this.options.strokeWidth ?? 2,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
set paint(paint: { readonly stroke: string; readonly strokeWidth: number }) {
|
|
130
|
+
if (this.isDestroyed) throw new Error('Connector tool is destroyed');
|
|
131
|
+
if (
|
|
132
|
+
!paint ||
|
|
133
|
+
typeof paint.stroke !== 'string' ||
|
|
134
|
+
!paint.stroke.trim() ||
|
|
135
|
+
!Number.isFinite(paint.strokeWidth) ||
|
|
136
|
+
paint.strokeWidth < 0
|
|
137
|
+
)
|
|
138
|
+
throw new TypeError('Invalid connector paint');
|
|
139
|
+
this.options = { ...this.options, stroke: paint.stroke, strokeWidth: paint.strokeWidth };
|
|
140
|
+
this.update();
|
|
141
|
+
}
|
|
142
|
+
set enabled(value: boolean) {
|
|
143
|
+
if (this.isDestroyed || this.connectors.isDestroyed)
|
|
144
|
+
throw new Error('Connector tool is destroyed');
|
|
145
|
+
if (typeof value !== 'boolean') throw new TypeError('Connector tool enabled must be boolean');
|
|
146
|
+
if (this.active === value) return;
|
|
147
|
+
this.cancel();
|
|
148
|
+
this.active = value;
|
|
149
|
+
this.hover = null;
|
|
150
|
+
this.surface.element.style.pointerEvents = value ? 'all' : 'none';
|
|
151
|
+
this.surface.element.style.cursor = value ? 'crosshair' : '';
|
|
152
|
+
this.update();
|
|
153
|
+
}
|
|
154
|
+
update(): this {
|
|
155
|
+
this.surface.update();
|
|
156
|
+
return this;
|
|
157
|
+
}
|
|
158
|
+
destroy(): void {
|
|
159
|
+
this.surface.destroy();
|
|
160
|
+
}
|
|
161
|
+
cancel(): void {
|
|
162
|
+
const press = this.press;
|
|
163
|
+
this.press = null;
|
|
164
|
+
if (press) {
|
|
165
|
+
if (this.surface.element.hasPointerCapture(press.id))
|
|
166
|
+
this.surface.element.releasePointerCapture(press.id);
|
|
167
|
+
this.options.onGesture?.(false);
|
|
168
|
+
}
|
|
169
|
+
this.update();
|
|
170
|
+
}
|
|
171
|
+
private acceptsKeyboard(event: KeyboardEvent): boolean {
|
|
172
|
+
const document = this.surface.element.ownerDocument,
|
|
173
|
+
active = document.activeElement;
|
|
174
|
+
return (
|
|
175
|
+
!document.querySelector('dialog[open],[role="dialog"][aria-modal="true"]') &&
|
|
176
|
+
!(event.target as Element)?.closest?.(
|
|
177
|
+
'input,textarea,select,[contenteditable]:not([contenteditable="false"])',
|
|
178
|
+
) &&
|
|
179
|
+
(active === document.body ||
|
|
180
|
+
(!!active && this.surface.element.parentElement!.contains(active)))
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
private attempt(run: () => void): void {
|
|
184
|
+
try {
|
|
185
|
+
run();
|
|
186
|
+
} catch (error) {
|
|
187
|
+
try {
|
|
188
|
+
this.cancel();
|
|
189
|
+
} catch (cleanup) {
|
|
190
|
+
error = new AggregateError([error, cleanup], 'Connector tool and cleanup failed');
|
|
191
|
+
}
|
|
192
|
+
if (this.options.onError) this.options.onError(error);
|
|
193
|
+
else throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
private pick(event: PointerEvent): ConnectorEndpoint | null {
|
|
197
|
+
const drawing = this.connectors.drawing;
|
|
198
|
+
const point = drawing.fromClient({ x: event.clientX, y: event.clientY });
|
|
199
|
+
if (!point) return null;
|
|
200
|
+
const targets = drawing.el.ownerDocument
|
|
201
|
+
.elementsFromPoint(event.clientX, event.clientY)
|
|
202
|
+
.map((element) => Registry.wrapper(element as SVGElement));
|
|
203
|
+
let target = targets.find(
|
|
204
|
+
(node): node is ConnectorTarget =>
|
|
205
|
+
Guards.isTransformable(node) &&
|
|
206
|
+
node !== drawing &&
|
|
207
|
+
drawing.el.contains(node.el) &&
|
|
208
|
+
!isConnector(node) &&
|
|
209
|
+
!node.descendants().some(isConnector) &&
|
|
210
|
+
this.options.selectable?.(node as ConnectorTarget) !== false,
|
|
211
|
+
);
|
|
212
|
+
// The outer half of an anchor hint can lie just outside its shape's hit area.
|
|
213
|
+
const previous = this.hover?.target;
|
|
214
|
+
if (
|
|
215
|
+
!target &&
|
|
216
|
+
previous &&
|
|
217
|
+
!previous.isDestroyed &&
|
|
218
|
+
drawing.el.contains(previous.el) &&
|
|
219
|
+
this.options.selectable?.(previous) !== false
|
|
220
|
+
) {
|
|
221
|
+
const nearHint = sides.some((anchor) => {
|
|
222
|
+
const port = connectorPort({ target: previous, anchor }, point);
|
|
223
|
+
const client = port && drawing.toClient(port.point);
|
|
224
|
+
return client && Math.hypot(client.x - event.clientX, client.y - event.clientY) < 12;
|
|
225
|
+
});
|
|
226
|
+
if (nearHint) target = previous;
|
|
227
|
+
}
|
|
228
|
+
this.hover = null;
|
|
229
|
+
if (!target) return { point };
|
|
230
|
+
let endpoint: ConnectorEndpoint = { target, anchor: 'auto' };
|
|
231
|
+
let nearest = 12;
|
|
232
|
+
for (const anchor of sides) {
|
|
233
|
+
const port = connectorPort({ target, anchor }, point);
|
|
234
|
+
const client = port && drawing.toClient(port.point);
|
|
235
|
+
if (!client) continue;
|
|
236
|
+
const distance = Math.hypot(client.x - event.clientX, client.y - event.clientY);
|
|
237
|
+
if (distance < nearest) {
|
|
238
|
+
nearest = distance;
|
|
239
|
+
endpoint = { target, anchor };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
this.hover = { target, endpoint };
|
|
243
|
+
return endpoint;
|
|
244
|
+
}
|
|
245
|
+
private readonly over = (event: PointerEvent): void => {
|
|
246
|
+
if (!this.enabled || this.press || !event.isPrimary || event.buttons) return;
|
|
247
|
+
this.attempt(() => {
|
|
248
|
+
this.pick(event);
|
|
249
|
+
this.update();
|
|
250
|
+
});
|
|
251
|
+
};
|
|
252
|
+
private readonly down = (event: PointerEvent): void => {
|
|
253
|
+
if (
|
|
254
|
+
!this.enabled ||
|
|
255
|
+
this.press ||
|
|
256
|
+
!event.isPrimary ||
|
|
257
|
+
event.button !== 0 ||
|
|
258
|
+
event.defaultPrevented
|
|
259
|
+
)
|
|
260
|
+
return;
|
|
261
|
+
this.attempt(() => {
|
|
262
|
+
const endpoint = this.pick(event);
|
|
263
|
+
if (!endpoint) return;
|
|
264
|
+
this.surface.element.setPointerCapture(event.pointerId);
|
|
265
|
+
this.press = { id: event.pointerId, start: endpoint, end: endpoint };
|
|
266
|
+
event.preventDefault();
|
|
267
|
+
event.stopPropagation();
|
|
268
|
+
this.options.onGesture?.(true);
|
|
269
|
+
this.update();
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
private readonly move = (event: PointerEvent): void => {
|
|
273
|
+
if (event.pointerId !== this.press?.id) return;
|
|
274
|
+
this.attempt(() => {
|
|
275
|
+
const endpoint = this.pick(event);
|
|
276
|
+
if (endpoint && this.press) this.press.end = endpoint;
|
|
277
|
+
this.update();
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
private readonly up = (event: PointerEvent): void => {
|
|
281
|
+
if (event.pointerId !== this.press?.id) return;
|
|
282
|
+
this.attempt(() => {
|
|
283
|
+
const press = this.press!,
|
|
284
|
+
end = this.pick(event) ?? press.end;
|
|
285
|
+
this.cancel();
|
|
286
|
+
const a = endpointCenter(press.start),
|
|
287
|
+
b = endpointCenter(end);
|
|
288
|
+
if (!a || !b || Math.hypot(a.x - b.x, a.y - b.y) < 1e-6) return;
|
|
289
|
+
const path = this.connectors.create({ ...this.options, start: press.start, end });
|
|
290
|
+
this.options.onCreate?.(path);
|
|
291
|
+
});
|
|
292
|
+
};
|
|
293
|
+
private readonly lost = (event: PointerEvent): void => {
|
|
294
|
+
if (event.pointerId === this.press?.id) this.cancel();
|
|
295
|
+
};
|
|
296
|
+
private render(): void {
|
|
297
|
+
const overlay = this.surface.element;
|
|
298
|
+
overlay.replaceChildren();
|
|
299
|
+
if (!this.enabled) return;
|
|
300
|
+
const document = overlay.ownerDocument;
|
|
301
|
+
if (this.hover)
|
|
302
|
+
for (const anchor of sides) {
|
|
303
|
+
const port = connectorPort({ target: this.hover.target, anchor }, { x: 0, y: 0 });
|
|
304
|
+
const point = port && this.surface.fromRoot(port.point);
|
|
305
|
+
if (point)
|
|
306
|
+
overlay.append(
|
|
307
|
+
svg(document, 'circle', {
|
|
308
|
+
cx: point.x,
|
|
309
|
+
cy: point.y,
|
|
310
|
+
r: 5,
|
|
311
|
+
fill: 'white',
|
|
312
|
+
stroke: '#2563eb',
|
|
313
|
+
'stroke-width': 1.5,
|
|
314
|
+
'pointer-events': 'none',
|
|
315
|
+
'data-connector-anchor': anchor,
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
if (!this.press) return;
|
|
320
|
+
const { start, end } = this.press;
|
|
321
|
+
const a = endpointCenter(start),
|
|
322
|
+
b = endpointCenter(end);
|
|
323
|
+
const from = a && b ? connectorPort(start, b) : null,
|
|
324
|
+
to = a && b ? connectorPort(end, a) : null;
|
|
325
|
+
if (!from || !to) return;
|
|
326
|
+
const points = routeConnector({
|
|
327
|
+
start: from,
|
|
328
|
+
end: to,
|
|
329
|
+
routing: this.options.routing ?? 'straight',
|
|
330
|
+
}).map((point) => this.surface.fromRoot(point));
|
|
331
|
+
if (points.some((point) => !point)) return;
|
|
332
|
+
overlay.append(
|
|
333
|
+
svg(document, 'path', {
|
|
334
|
+
d: pathData(points as Point[]),
|
|
335
|
+
fill: 'none',
|
|
336
|
+
stroke: this.options.stroke ?? '#334155',
|
|
337
|
+
'stroke-width': this.options.strokeWidth ?? 2,
|
|
338
|
+
'stroke-dasharray': '5 4',
|
|
339
|
+
'pointer-events': 'none',
|
|
340
|
+
'data-connector-preview': '',
|
|
341
|
+
}),
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Point, Shape, Group, Svg, Path } from '@rasamjs/core';
|
|
2
|
+
|
|
3
|
+
export type ConnectorTarget = Shape | Group | Svg;
|
|
4
|
+
export type ConnectorSide = 'top' | 'right' | 'bottom' | 'left';
|
|
5
|
+
/** A point is normalized within the target's local geometric bounds. */
|
|
6
|
+
export type ConnectorAnchor = 'auto' | ConnectorSide | Point;
|
|
7
|
+
export type ConnectorEndpoint =
|
|
8
|
+
| { readonly target: ConnectorTarget; readonly anchor?: ConnectorAnchor }
|
|
9
|
+
| { readonly point: Point };
|
|
10
|
+
export type ConnectorRouting = 'straight' | 'elbow';
|
|
11
|
+
export interface ConnectorOptions {
|
|
12
|
+
readonly start: ConnectorEndpoint;
|
|
13
|
+
readonly end: ConnectorEndpoint;
|
|
14
|
+
readonly routing?: ConnectorRouting;
|
|
15
|
+
readonly arrows?: 'none' | 'end' | 'both';
|
|
16
|
+
readonly stroke?: string;
|
|
17
|
+
readonly strokeWidth?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface ConnectorState {
|
|
20
|
+
readonly path: Path;
|
|
21
|
+
readonly start: ConnectorEndpoint;
|
|
22
|
+
readonly end: ConnectorEndpoint;
|
|
23
|
+
readonly routing: ConnectorRouting;
|
|
24
|
+
}
|
|
25
|
+
export interface ConnectorPort {
|
|
26
|
+
readonly point: Point;
|
|
27
|
+
readonly direction: Point;
|
|
28
|
+
}
|
|
29
|
+
export interface ConnectorRoute {
|
|
30
|
+
readonly start: ConnectorPort;
|
|
31
|
+
readonly end: ConnectorPort;
|
|
32
|
+
readonly routing: ConnectorRouting;
|
|
33
|
+
}
|