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