@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.
- package/API.md +143 -0
- package/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +54 -2
- package/RELEASING.md +27 -0
- package/dist/connectors.d.ts +38 -0
- package/dist/connectors.d.ts.map +1 -0
- package/dist/connectors.js +291 -0
- package/dist/connectors.js.map +1 -0
- package/dist/editor.d.ts +56 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +67 -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 +12 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +72 -0
- package/dist/metadata.js.map +1 -0
- package/dist/routing.d.ts +14 -0
- package/dist/routing.d.ts.map +1 -0
- package/dist/routing.js +130 -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 +47 -0
- package/dist/tool.d.ts.map +1 -0
- package/dist/tool.js +326 -0
- package/dist/tool.js.map +1 -0
- package/dist/types.d.ts +43 -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 +64 -6
- package/src/connectors.ts +324 -0
- package/src/editor.ts +105 -0
- package/src/index.ts +14 -0
- package/src/metadata.ts +88 -0
- package/src/routing.ts +153 -0
- package/src/tool-surface.ts +96 -0
- package/src/tool.ts +367 -0
- package/src/types.ts +38 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { Path } from '@rasamjs/core';
|
|
2
|
+
import type { Svg, AnyNode } from '@rasamjs/core';
|
|
3
|
+
import { Guards } from '@rasamjs/core/advanced';
|
|
4
|
+
import { beginChangeGroup, deriveChanges } from '@rasamjs/core/changes';
|
|
5
|
+
import {
|
|
6
|
+
endpointAttribute,
|
|
7
|
+
isConnector,
|
|
8
|
+
pathEnd,
|
|
9
|
+
readConnector,
|
|
10
|
+
unbound,
|
|
11
|
+
writeConnector,
|
|
12
|
+
} from './metadata.js';
|
|
13
|
+
import {
|
|
14
|
+
connectorPort,
|
|
15
|
+
endpointCenter,
|
|
16
|
+
findPort,
|
|
17
|
+
pathData,
|
|
18
|
+
routeConnector,
|
|
19
|
+
validateAnchor,
|
|
20
|
+
validatePoint,
|
|
21
|
+
} from './routing.js';
|
|
22
|
+
import type {
|
|
23
|
+
ConnectorEndpoint,
|
|
24
|
+
ConnectorOptions,
|
|
25
|
+
ConnectorState,
|
|
26
|
+
ConnectorTarget,
|
|
27
|
+
} from './types.js';
|
|
28
|
+
|
|
29
|
+
const owners = new WeakMap<Svg, Connectors>();
|
|
30
|
+
const drawableTags = new Set([
|
|
31
|
+
'rect',
|
|
32
|
+
'circle',
|
|
33
|
+
'ellipse',
|
|
34
|
+
'line',
|
|
35
|
+
'polyline',
|
|
36
|
+
'polygon',
|
|
37
|
+
'path',
|
|
38
|
+
'text',
|
|
39
|
+
'image',
|
|
40
|
+
'foreignObject',
|
|
41
|
+
'use',
|
|
42
|
+
'g',
|
|
43
|
+
'svg',
|
|
44
|
+
]);
|
|
45
|
+
export class Connectors {
|
|
46
|
+
private disposed = false;
|
|
47
|
+
private readonly cleanups: (() => void)[] = [];
|
|
48
|
+
private readonly disposals = new Set<() => void>();
|
|
49
|
+
constructor(readonly drawing: Svg) {
|
|
50
|
+
if (
|
|
51
|
+
!Guards.isTransformable(drawing) ||
|
|
52
|
+
drawing.isDestroyed ||
|
|
53
|
+
drawing !== drawing.context.root ||
|
|
54
|
+
drawing.el.localName !== 'svg'
|
|
55
|
+
)
|
|
56
|
+
throw new TypeError('Connectors needs a live SVG drawing root');
|
|
57
|
+
if (owners.has(drawing)) throw new Error('Connectors is already attached to this drawing');
|
|
58
|
+
// Validate saved attachments without changing stored routing during attachment or replay.
|
|
59
|
+
this.nodes.forEach(readConnector);
|
|
60
|
+
owners.set(drawing, this);
|
|
61
|
+
this.cleanups.push(
|
|
62
|
+
deriveChanges(drawing, (event) => {
|
|
63
|
+
if (event.source === 'viewport') return;
|
|
64
|
+
for (const path of this.affected(event.nodes)) {
|
|
65
|
+
let state = readConnector(path);
|
|
66
|
+
const loose = this.loosen(state);
|
|
67
|
+
if (loose !== state || unbound.has(state.start) || unbound.has(state.end))
|
|
68
|
+
writeConnector(path, (state = loose));
|
|
69
|
+
this.route(state);
|
|
70
|
+
}
|
|
71
|
+
}),
|
|
72
|
+
drawing.onDispose(() => this.destroy()),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
get isDestroyed(): boolean {
|
|
76
|
+
return this.disposed;
|
|
77
|
+
}
|
|
78
|
+
onDispose(listener: () => void): () => void {
|
|
79
|
+
this.assertLive();
|
|
80
|
+
if (typeof listener !== 'function') throw new TypeError('Disposal listener must be a function');
|
|
81
|
+
this.disposals.add(listener);
|
|
82
|
+
return () => {
|
|
83
|
+
this.disposals.delete(listener);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
get nodes(): readonly Path[] {
|
|
87
|
+
this.assertLive();
|
|
88
|
+
return Object.freeze(this.drawing.descendants().filter(isConnector));
|
|
89
|
+
}
|
|
90
|
+
read(path: Path): ConnectorState {
|
|
91
|
+
this.assertPath(path);
|
|
92
|
+
return readConnector(path);
|
|
93
|
+
}
|
|
94
|
+
/** Connectors with an end on `target`, or only on its port named `port`. */
|
|
95
|
+
links(target: ConnectorTarget, port?: string): readonly Path[] {
|
|
96
|
+
this.assertLive();
|
|
97
|
+
if (port !== undefined && (typeof port !== 'string' || !port))
|
|
98
|
+
throw new TypeError('Port names must be non-empty strings');
|
|
99
|
+
return Object.freeze(
|
|
100
|
+
this.attached(target)
|
|
101
|
+
.filter((path) => {
|
|
102
|
+
const { start, end } = readConnector(path);
|
|
103
|
+
return [start, end].some(
|
|
104
|
+
(endpoint) =>
|
|
105
|
+
'target' in endpoint &&
|
|
106
|
+
endpoint.target === target &&
|
|
107
|
+
(port === undefined || ('port' in endpoint && endpoint.port === port)),
|
|
108
|
+
);
|
|
109
|
+
})
|
|
110
|
+
.sort((a, b) => (a.el.compareDocumentPosition(b.el) & 4 ? -1 : 1)),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
create(options: ConnectorOptions): Path {
|
|
114
|
+
this.assertLive();
|
|
115
|
+
this.validate(options);
|
|
116
|
+
return this.edit('Create connector', () => {
|
|
117
|
+
const path = this.drawing.path({
|
|
118
|
+
d: '',
|
|
119
|
+
fill: 'none',
|
|
120
|
+
stroke: options.stroke ?? '#334155',
|
|
121
|
+
'stroke-width': options.strokeWidth ?? 2,
|
|
122
|
+
});
|
|
123
|
+
writeConnector(path, {
|
|
124
|
+
start: options.start,
|
|
125
|
+
end: options.end,
|
|
126
|
+
routing: options.routing ?? 'straight',
|
|
127
|
+
});
|
|
128
|
+
if ((options.arrows ?? 'end') !== 'none') {
|
|
129
|
+
const marker = this.drawing.createMarker({
|
|
130
|
+
viewBox: '0 0 10 10',
|
|
131
|
+
markerWidth: 6,
|
|
132
|
+
markerHeight: 6,
|
|
133
|
+
refX: 9,
|
|
134
|
+
refY: 5,
|
|
135
|
+
orient: 'auto-start-reverse',
|
|
136
|
+
});
|
|
137
|
+
marker.path({ d: 'M0 0 L10 5 L0 10 Z', fill: 'context-stroke', stroke: 'none' });
|
|
138
|
+
path.marker('end', marker);
|
|
139
|
+
if (options.arrows === 'both') path.marker('start', marker);
|
|
140
|
+
}
|
|
141
|
+
if (!this.route(readConnector(path)))
|
|
142
|
+
throw new Error('Connector needs available bounds and invertible transforms');
|
|
143
|
+
return path;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
update(path: Path, patch: Partial<Pick<ConnectorOptions, 'start' | 'end' | 'routing'>>): void {
|
|
147
|
+
this.assertPath(path);
|
|
148
|
+
const state = { ...readConnector(path), ...patch };
|
|
149
|
+
this.validate(state);
|
|
150
|
+
this.edit('Edit connector', () => {
|
|
151
|
+
writeConnector(path, state);
|
|
152
|
+
if (!this.route(state))
|
|
153
|
+
throw new Error('Connector needs available bounds and invertible transforms');
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
remove(path: Path): void {
|
|
157
|
+
this.assertPath(path);
|
|
158
|
+
this.edit('Delete connector', () => path.remove());
|
|
159
|
+
}
|
|
160
|
+
destroy(): void {
|
|
161
|
+
if (this.disposed) return;
|
|
162
|
+
this.disposed = true;
|
|
163
|
+
owners.delete(this.drawing);
|
|
164
|
+
const failures: unknown[] = [];
|
|
165
|
+
for (const stop of [...this.cleanups.splice(0).reverse(), ...this.disposals]) {
|
|
166
|
+
try {
|
|
167
|
+
stop();
|
|
168
|
+
} catch (error) {
|
|
169
|
+
failures.push(error);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
this.disposals.clear();
|
|
173
|
+
if (failures.length) throw new AggregateError(failures, 'Connector cleanup failed');
|
|
174
|
+
}
|
|
175
|
+
private assertLive(): void {
|
|
176
|
+
if (this.disposed) throw new Error('Connectors is destroyed');
|
|
177
|
+
}
|
|
178
|
+
private assertPath(path: Path): void {
|
|
179
|
+
this.assertLive();
|
|
180
|
+
if (
|
|
181
|
+
!isConnector(path) ||
|
|
182
|
+
path.isDestroyed ||
|
|
183
|
+
path.context !== this.drawing.context ||
|
|
184
|
+
!this.drawing.el.contains(path.el)
|
|
185
|
+
)
|
|
186
|
+
throw new TypeError('Expected a connector in this drawing');
|
|
187
|
+
}
|
|
188
|
+
private validate(options: ConnectorOptions): void {
|
|
189
|
+
if (!options || typeof options !== 'object')
|
|
190
|
+
throw new TypeError('Connector options must be an object');
|
|
191
|
+
for (const endpoint of [options.start, options.end]) this.validateEndpoint(endpoint);
|
|
192
|
+
if (options.routing !== undefined && !['straight', 'elbow'].includes(options.routing))
|
|
193
|
+
throw new TypeError('Unknown connector routing');
|
|
194
|
+
if (options.arrows !== undefined && !['none', 'end', 'both'].includes(options.arrows))
|
|
195
|
+
throw new TypeError('Unknown connector arrows');
|
|
196
|
+
if (
|
|
197
|
+
options.stroke !== undefined &&
|
|
198
|
+
(typeof options.stroke !== 'string' || !options.stroke.trim())
|
|
199
|
+
)
|
|
200
|
+
throw new TypeError('Connector stroke must be nonempty');
|
|
201
|
+
if (
|
|
202
|
+
options.strokeWidth !== undefined &&
|
|
203
|
+
(!Number.isFinite(options.strokeWidth) || options.strokeWidth < 0)
|
|
204
|
+
)
|
|
205
|
+
throw new TypeError('Connector stroke width must be nonnegative');
|
|
206
|
+
}
|
|
207
|
+
private validateEndpoint(endpoint: ConnectorEndpoint): void {
|
|
208
|
+
if (!endpoint || typeof endpoint !== 'object' || 'target' in endpoint === 'point' in endpoint)
|
|
209
|
+
throw new TypeError('Endpoint needs either a target or a point');
|
|
210
|
+
if ('point' in endpoint) {
|
|
211
|
+
validatePoint(endpoint.point);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const target = endpoint.target;
|
|
215
|
+
if (
|
|
216
|
+
!Guards.isTransformable(target) ||
|
|
217
|
+
target.isDestroyed ||
|
|
218
|
+
target === this.drawing ||
|
|
219
|
+
target.context !== this.drawing.context ||
|
|
220
|
+
!this.drawing.el.contains(target.el) ||
|
|
221
|
+
target.descendants().some(isConnector)
|
|
222
|
+
)
|
|
223
|
+
throw new TypeError(
|
|
224
|
+
'Endpoint target must be a drawable shape without connectors in this drawing',
|
|
225
|
+
);
|
|
226
|
+
for (let node: AnyNode | null = target; node && node !== this.drawing; node = node.parent)
|
|
227
|
+
if (!drawableTags.has(node.el.localName))
|
|
228
|
+
throw new TypeError('Connector targets cannot be inside definitions');
|
|
229
|
+
if (!('port' in endpoint)) return validateAnchor(endpoint.anchor ?? 'auto');
|
|
230
|
+
if ('anchor' in endpoint) throw new TypeError('Endpoint needs either a port or an anchor');
|
|
231
|
+
if (typeof endpoint.port !== 'string' || !findPort(target, endpoint.port))
|
|
232
|
+
throw new TypeError(`Unknown connector port: ${String(endpoint.port)}`);
|
|
233
|
+
}
|
|
234
|
+
/** An end whose target left the drawing, or whose port is gone, stays where it is. */
|
|
235
|
+
private loosen(state: ConnectorState): ConnectorState {
|
|
236
|
+
const loose = (end: 'start' | 'end'): ConnectorEndpoint => {
|
|
237
|
+
const endpoint = state[end];
|
|
238
|
+
if ('point' in endpoint) return endpoint;
|
|
239
|
+
const { target } = endpoint;
|
|
240
|
+
const gone =
|
|
241
|
+
target.isDestroyed ||
|
|
242
|
+
!this.drawing.el.contains(target.el) ||
|
|
243
|
+
('port' in endpoint && !findPort(target, endpoint.port));
|
|
244
|
+
const point = gone && pathEnd(state.path, end);
|
|
245
|
+
return point ? { point } : endpoint;
|
|
246
|
+
};
|
|
247
|
+
const start = loose('start'),
|
|
248
|
+
end = loose('end');
|
|
249
|
+
return start === state.start && end === state.end ? state : { ...state, start, end };
|
|
250
|
+
}
|
|
251
|
+
/** Connectors in this drawing with an end on `target`, from core's reference index. */
|
|
252
|
+
private attached(target: AnyNode): Path[] {
|
|
253
|
+
return this.drawing.context.defs
|
|
254
|
+
.consumersOf(target)
|
|
255
|
+
.filter(
|
|
256
|
+
(node): node is Path =>
|
|
257
|
+
isConnector(node) &&
|
|
258
|
+
this.drawing.el.contains(node.el) &&
|
|
259
|
+
(['start', 'end'] as const).some(
|
|
260
|
+
(end) => node.references.get(endpointAttribute(end)) === target,
|
|
261
|
+
),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Connectors to route after a change: those inside a changed subtree, and those attached
|
|
266
|
+
* to a changed node, its descendants or its ancestors, since parents move, resize or
|
|
267
|
+
* reparent targets and a descendant changes a group's bounds. The work follows the
|
|
268
|
+
* size of the change, not the number of connectors.
|
|
269
|
+
*/
|
|
270
|
+
private affected(changed: readonly AnyNode[]): Set<Path> {
|
|
271
|
+
const paths = new Set<Path>(),
|
|
272
|
+
seen = new Set<AnyNode>();
|
|
273
|
+
const visit = (node: AnyNode) => {
|
|
274
|
+
if (node.isDestroyed || seen.has(node)) return;
|
|
275
|
+
seen.add(node);
|
|
276
|
+
if (isConnector(node) && this.drawing.el.contains(node.el)) paths.add(node);
|
|
277
|
+
for (const path of this.attached(node)) paths.add(path);
|
|
278
|
+
};
|
|
279
|
+
for (const node of changed) {
|
|
280
|
+
node.descendants().forEach(visit);
|
|
281
|
+
for (let parent = node.parent; parent; parent = parent.parent) visit(parent);
|
|
282
|
+
}
|
|
283
|
+
return paths;
|
|
284
|
+
}
|
|
285
|
+
private route(state: ConnectorState): boolean {
|
|
286
|
+
const { path, start, end } = state;
|
|
287
|
+
for (const endpoint of [start, end])
|
|
288
|
+
if ('target' in endpoint) {
|
|
289
|
+
if (endpoint.target.isDestroyed || !this.drawing.el.contains(endpoint.target.el))
|
|
290
|
+
return false;
|
|
291
|
+
if (endpoint.target.el.contains(path.el))
|
|
292
|
+
throw new TypeError('A connector cannot be inside its endpoint target');
|
|
293
|
+
}
|
|
294
|
+
const a = endpointCenter(start),
|
|
295
|
+
b = endpointCenter(end);
|
|
296
|
+
const from = a && b ? connectorPort(start, b) : null;
|
|
297
|
+
const to = a && b ? connectorPort(end, a) : null;
|
|
298
|
+
const inverse = path.worldMatrix().inverse();
|
|
299
|
+
if (!from || !to || !inverse) return false;
|
|
300
|
+
const d = pathData(
|
|
301
|
+
routeConnector({ start: from, end: to, routing: state.routing }).map((point) =>
|
|
302
|
+
inverse.apply(point),
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
if (path.getAttributeValue('d') !== d) path.attrs({ d });
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
private edit<Result>(label: string, run: () => Result): Result {
|
|
309
|
+
const group = beginChangeGroup(this.drawing, { source: 'connectors', label });
|
|
310
|
+
let result: Result;
|
|
311
|
+
try {
|
|
312
|
+
result = group.run(run);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
try {
|
|
315
|
+
group.cancel();
|
|
316
|
+
} catch (cleanup) {
|
|
317
|
+
throw new AggregateError([error, cleanup], 'Connector edit and rollback failed');
|
|
318
|
+
}
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
group.end();
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
324
|
+
}
|
package/src/editor.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { AnyNode, Svg } from '@rasamjs/core';
|
|
2
|
+
import { Connectors } from './connectors.js';
|
|
3
|
+
import { ConnectorTool } from './tool.js';
|
|
4
|
+
import type { ConnectorOptions } from './types.js';
|
|
5
|
+
|
|
6
|
+
/** One registered connector tool; its id is also its command id. */
|
|
7
|
+
export interface ConnectorEditingTool extends Pick<ConnectorOptions, 'routing' | 'arrows'> {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly label: string;
|
|
10
|
+
readonly key: string;
|
|
11
|
+
readonly hint?: string;
|
|
12
|
+
}
|
|
13
|
+
const defaultTools: readonly ConnectorEditingTool[] = [
|
|
14
|
+
{ id: 'connector-straight', label: 'Connector', key: 'C', routing: 'straight' },
|
|
15
|
+
{ id: 'connector-elbow', label: 'Elbow connector', key: 'Shift+C', routing: 'elbow' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export interface ConnectorScope {
|
|
19
|
+
readonly drawing: Svg;
|
|
20
|
+
readonly editor: {
|
|
21
|
+
readonly paint: { readonly stroke: string; readonly strokeWidth: number };
|
|
22
|
+
readonly selection: { canSelect(node: AnyNode): boolean };
|
|
23
|
+
readonly layers: { isSelectable(node: AnyNode): boolean };
|
|
24
|
+
fail(action: string, error: unknown): void;
|
|
25
|
+
};
|
|
26
|
+
use<Value extends { destroy(): void }>(value: Value): Value;
|
|
27
|
+
registerTool(definition: {
|
|
28
|
+
id: string;
|
|
29
|
+
label: string;
|
|
30
|
+
key: string;
|
|
31
|
+
hint: string;
|
|
32
|
+
commandId: string;
|
|
33
|
+
activate(scope: ConnectorScope): { destroy(): void; key?(event: KeyboardEvent): boolean };
|
|
34
|
+
}): () => void;
|
|
35
|
+
cleanup(callback: () => void): void;
|
|
36
|
+
on(type: 'camera' | 'paint', callback: () => void): void;
|
|
37
|
+
gesture(): () => void;
|
|
38
|
+
created(node: AnyNode): void;
|
|
39
|
+
exit(): void;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The connectors service and its tools. `tools` replaces the default Connector (`C`)
|
|
43
|
+
* and Elbow connector (`Shift+C`), for example with a Wire tool without arrows.
|
|
44
|
+
*/
|
|
45
|
+
export function connectorEditing(
|
|
46
|
+
options: { readonly tools?: readonly ConnectorEditingTool[] } = {},
|
|
47
|
+
) {
|
|
48
|
+
const tools = options.tools ?? defaultTools;
|
|
49
|
+
if (!Array.isArray(tools)) throw new TypeError('Connector tools must be an array');
|
|
50
|
+
return {
|
|
51
|
+
id: 'connectors',
|
|
52
|
+
create(scope: ConnectorScope) {
|
|
53
|
+
const service = scope.use(new Connectors(scope.drawing));
|
|
54
|
+
for (const { id, label, key, routing = 'straight', arrows, hint } of tools)
|
|
55
|
+
scope.registerTool({
|
|
56
|
+
id,
|
|
57
|
+
commandId: id,
|
|
58
|
+
label,
|
|
59
|
+
key,
|
|
60
|
+
hint:
|
|
61
|
+
hint ?? 'Drag between shapes to connect; hover to choose an anchor; Escape cancels.',
|
|
62
|
+
activate(current) {
|
|
63
|
+
const editor = current.editor;
|
|
64
|
+
let release = () => {},
|
|
65
|
+
dragging = false;
|
|
66
|
+
current.cleanup(() => release());
|
|
67
|
+
const tool = current.use(
|
|
68
|
+
new ConnectorTool(service, {
|
|
69
|
+
routing,
|
|
70
|
+
arrows,
|
|
71
|
+
...editor.paint,
|
|
72
|
+
selectable: (node) =>
|
|
73
|
+
editor.selection.canSelect(node) && editor.layers.isSelectable(node),
|
|
74
|
+
onGesture(active) {
|
|
75
|
+
dragging = active;
|
|
76
|
+
release();
|
|
77
|
+
release = active ? current.gesture() : () => {};
|
|
78
|
+
},
|
|
79
|
+
onCreate: (path) => current.created(path),
|
|
80
|
+
onError: (error) => {
|
|
81
|
+
editor.fail('Draw connector', error);
|
|
82
|
+
current.exit();
|
|
83
|
+
},
|
|
84
|
+
onExit: () => current.exit(),
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
current.on('camera', () => tool.update());
|
|
88
|
+
current.on('paint', () => {
|
|
89
|
+
tool.paint = editor.paint;
|
|
90
|
+
});
|
|
91
|
+
tool.enabled = true;
|
|
92
|
+
return {
|
|
93
|
+
destroy() {},
|
|
94
|
+
key(event: KeyboardEvent) {
|
|
95
|
+
if (event.type !== 'keydown' || event.key !== 'Escape' || !dragging) return false;
|
|
96
|
+
tool.cancel();
|
|
97
|
+
return true;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
return service;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { Connectors } from './connectors.js';
|
|
2
|
+
export { isConnector } from './metadata.js';
|
|
3
|
+
export { connectorPort, routeConnector } from './routing.js';
|
|
4
|
+
export type {
|
|
5
|
+
ConnectorTarget,
|
|
6
|
+
ConnectorAnchor,
|
|
7
|
+
ConnectorSide,
|
|
8
|
+
ConnectorEndpoint,
|
|
9
|
+
ConnectorRouting,
|
|
10
|
+
ConnectorOptions,
|
|
11
|
+
ConnectorState,
|
|
12
|
+
ConnectorPort,
|
|
13
|
+
ConnectorRoute,
|
|
14
|
+
} from './types.js';
|
package/src/metadata.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Path } from '@rasamjs/core';
|
|
2
|
+
import type { AnyNode, Point } from '@rasamjs/core';
|
|
3
|
+
import { data } from '@rasamjs/core/data';
|
|
4
|
+
import type {
|
|
5
|
+
ConnectorAnchor,
|
|
6
|
+
ConnectorEndpoint,
|
|
7
|
+
ConnectorRouting,
|
|
8
|
+
ConnectorState,
|
|
9
|
+
} from './types.js';
|
|
10
|
+
import { validateAnchor, validatePoint } from './routing.js';
|
|
11
|
+
export const endpointAttribute = (end: 'start' | 'end') => `data-rasam-connector-${end}`;
|
|
12
|
+
const metadataKey = 'rasam-connector';
|
|
13
|
+
interface SavedEndpoint {
|
|
14
|
+
anchor?: ConnectorAnchor;
|
|
15
|
+
/** Readers without port support fall back to an automatic anchor on the target. */
|
|
16
|
+
port?: string;
|
|
17
|
+
point?: Point;
|
|
18
|
+
}
|
|
19
|
+
/** Loose ends read from a path whose saved target is missing, such as a pasted copy. */
|
|
20
|
+
export const unbound = new WeakSet<ConnectorEndpoint>();
|
|
21
|
+
/** Where the routed path ends now, in drawing units. */
|
|
22
|
+
export function pathEnd(path: Path, end: 'start' | 'end'): Point | null {
|
|
23
|
+
const values = String(path.getAttributeValue('d') ?? '')
|
|
24
|
+
.match(/[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi)
|
|
25
|
+
?.map(Number);
|
|
26
|
+
if (!values || values.length < 4) return null;
|
|
27
|
+
const index = end === 'start' ? 0 : values.length - 2;
|
|
28
|
+
return path.worldMatrix().apply({ x: values[index], y: values[index + 1] });
|
|
29
|
+
}
|
|
30
|
+
interface SavedConnector {
|
|
31
|
+
version: 1;
|
|
32
|
+
routing: ConnectorRouting;
|
|
33
|
+
start: SavedEndpoint;
|
|
34
|
+
end: SavedEndpoint;
|
|
35
|
+
}
|
|
36
|
+
export function isConnector(node: AnyNode): node is Path {
|
|
37
|
+
return node instanceof Path && node.getAttributeValue('data-rasam-connector') !== undefined;
|
|
38
|
+
}
|
|
39
|
+
export function readConnector(path: Path): ConnectorState {
|
|
40
|
+
const saved = data(path, metadataKey) as SavedConnector;
|
|
41
|
+
if (!saved || saved.version !== 1 || !['straight', 'elbow'].includes(saved.routing))
|
|
42
|
+
throw new TypeError('Invalid connector metadata');
|
|
43
|
+
const endpoint = (end: 'start' | 'end'): ConnectorEndpoint => {
|
|
44
|
+
const value = saved[end];
|
|
45
|
+
if (!value || typeof value !== 'object')
|
|
46
|
+
throw new TypeError('Invalid connector endpoint metadata');
|
|
47
|
+
if (value.point) {
|
|
48
|
+
validatePoint(value.point);
|
|
49
|
+
return { point: { ...value.point } };
|
|
50
|
+
}
|
|
51
|
+
const target = path.references.get(endpointAttribute(end));
|
|
52
|
+
if (!target || !('toRoot' in target)) {
|
|
53
|
+
const point = pathEnd(path, end);
|
|
54
|
+
if (!point) throw new TypeError('Connector endpoint has no bound target');
|
|
55
|
+
const loose = { point };
|
|
56
|
+
unbound.add(loose);
|
|
57
|
+
return loose;
|
|
58
|
+
}
|
|
59
|
+
if (value.port !== undefined) {
|
|
60
|
+
if (typeof value.port !== 'string' || !value.port)
|
|
61
|
+
throw new TypeError('Invalid connector port metadata');
|
|
62
|
+
return { target: target as import('./types.js').ConnectorTarget, port: value.port };
|
|
63
|
+
}
|
|
64
|
+
validateAnchor(value.anchor ?? 'auto');
|
|
65
|
+
return {
|
|
66
|
+
target: target as import('./types.js').ConnectorTarget,
|
|
67
|
+
anchor: typeof value.anchor === 'object' ? { ...value.anchor } : (value.anchor ?? 'auto'),
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
return { path, start: endpoint('start'), end: endpoint('end'), routing: saved.routing };
|
|
71
|
+
}
|
|
72
|
+
export function writeConnector(path: Path, state: Omit<ConnectorState, 'path'>): void {
|
|
73
|
+
const save = (end: 'start' | 'end'): SavedEndpoint => {
|
|
74
|
+
const endpoint = state[end];
|
|
75
|
+
path.reference(endpointAttribute(end), 'target' in endpoint ? endpoint.target : null, 'href');
|
|
76
|
+
return 'point' in endpoint
|
|
77
|
+
? { point: { ...endpoint.point } }
|
|
78
|
+
: 'port' in endpoint
|
|
79
|
+
? { port: endpoint.port }
|
|
80
|
+
: { anchor: endpoint.anchor ?? 'auto' };
|
|
81
|
+
};
|
|
82
|
+
data(path, metadataKey, {
|
|
83
|
+
version: 1,
|
|
84
|
+
routing: state.routing,
|
|
85
|
+
start: save('start'),
|
|
86
|
+
end: save('end'),
|
|
87
|
+
} satisfies SavedConnector);
|
|
88
|
+
}
|
package/src/routing.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { Point, PortDefinition } from '@rasamjs/core';
|
|
2
|
+
import type {
|
|
3
|
+
ConnectorAnchor,
|
|
4
|
+
ConnectorEndpoint,
|
|
5
|
+
ConnectorPort,
|
|
6
|
+
ConnectorRoute,
|
|
7
|
+
ConnectorSide,
|
|
8
|
+
ConnectorTarget,
|
|
9
|
+
} from './types.js';
|
|
10
|
+
|
|
11
|
+
/** Reads ports structurally from `portDefinitions`, the way tools read `editRules`. */
|
|
12
|
+
export function portsOf(target: ConnectorTarget): readonly PortDefinition[] {
|
|
13
|
+
const ports = (target as { readonly portDefinitions?: unknown }).portDefinitions;
|
|
14
|
+
return Array.isArray(ports) ? ports : [];
|
|
15
|
+
}
|
|
16
|
+
export const findPort = (target: ConnectorTarget, name: string): PortDefinition | undefined =>
|
|
17
|
+
portsOf(target).find((port) => port?.name === name);
|
|
18
|
+
const sideVector = (side?: ConnectorSide): Point => ({
|
|
19
|
+
x: side === 'left' ? -1 : side === 'right' ? 1 : 0,
|
|
20
|
+
y: side === 'top' ? -1 : side === 'bottom' ? 1 : 0,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export function validatePoint(point: Point, normalized = false): void {
|
|
24
|
+
if (
|
|
25
|
+
!point ||
|
|
26
|
+
!Number.isFinite(point.x) ||
|
|
27
|
+
!Number.isFinite(point.y) ||
|
|
28
|
+
(normalized && (point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1))
|
|
29
|
+
)
|
|
30
|
+
throw new TypeError(
|
|
31
|
+
normalized ? 'Anchor point must lie between 0 and 1' : 'Connector point must be finite',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
export function validateAnchor(anchor: ConnectorAnchor): void {
|
|
35
|
+
if (typeof anchor === 'string') {
|
|
36
|
+
if (!['auto', 'top', 'right', 'bottom', 'left'].includes(anchor))
|
|
37
|
+
throw new TypeError('Unknown connector anchor');
|
|
38
|
+
} else validatePoint(anchor, true);
|
|
39
|
+
}
|
|
40
|
+
export function endpointCenter(endpoint: ConnectorEndpoint): Point | null {
|
|
41
|
+
if ('point' in endpoint) return endpoint.point;
|
|
42
|
+
if ('port' in endpoint) {
|
|
43
|
+
const port = findPort(endpoint.target, endpoint.port);
|
|
44
|
+
return port ? endpoint.target.toRoot(port) : null;
|
|
45
|
+
}
|
|
46
|
+
const bounds = endpoint.target.bounds();
|
|
47
|
+
if (!('box' in bounds)) return null;
|
|
48
|
+
const { x, y, width, height } = bounds.box;
|
|
49
|
+
return endpoint.target.toRoot({ x: x + width / 2, y: y + height / 2 });
|
|
50
|
+
}
|
|
51
|
+
/** Resolve on the local box, then map into drawing units, retaining rotated anchors. */
|
|
52
|
+
export function connectorPort(endpoint: ConnectorEndpoint, toward: Point): ConnectorPort | null {
|
|
53
|
+
if ('point' in endpoint) return { point: endpoint.point, direction: { x: 0, y: 0 } };
|
|
54
|
+
const { target } = endpoint;
|
|
55
|
+
if ('port' in endpoint) {
|
|
56
|
+
// A port end sits exactly on the port and leaves in the direction of its side.
|
|
57
|
+
const port = findPort(target, endpoint.port);
|
|
58
|
+
return port ? place(target, port, sideVector(port.side)) : null;
|
|
59
|
+
}
|
|
60
|
+
const { anchor = 'auto' } = endpoint;
|
|
61
|
+
const bounds = target.bounds();
|
|
62
|
+
if (!('box' in bounds)) return null;
|
|
63
|
+
const { x, y, width, height } = bounds.box;
|
|
64
|
+
const center = { x: x + width / 2, y: y + height / 2 };
|
|
65
|
+
let side: ConnectorSide = 'right';
|
|
66
|
+
let point: Point;
|
|
67
|
+
if (typeof anchor === 'object') point = { x: x + anchor.x * width, y: y + anchor.y * height };
|
|
68
|
+
else if (anchor !== 'auto') {
|
|
69
|
+
side = anchor;
|
|
70
|
+
point = {
|
|
71
|
+
x: side === 'left' ? x : side === 'right' ? x + width : center.x,
|
|
72
|
+
y: side === 'top' ? y : side === 'bottom' ? y + height : center.y,
|
|
73
|
+
};
|
|
74
|
+
} else {
|
|
75
|
+
const local = target.fromRoot(toward);
|
|
76
|
+
if (!local) return null;
|
|
77
|
+
const dx = local.x - center.x,
|
|
78
|
+
dy = local.y - center.y;
|
|
79
|
+
const ratio = Math.max(
|
|
80
|
+
width ? Math.abs(dx) / (width / 2) : Infinity,
|
|
81
|
+
height ? Math.abs(dy) / (height / 2) : Infinity,
|
|
82
|
+
);
|
|
83
|
+
point =
|
|
84
|
+
ratio > 0 && Number.isFinite(ratio)
|
|
85
|
+
? { x: center.x + dx / ratio, y: center.y + dy / ratio }
|
|
86
|
+
: { x: x + width, y: center.y };
|
|
87
|
+
side =
|
|
88
|
+
Math.abs(point.x - center.x) / (width || 1) >= Math.abs(point.y - center.y) / (height || 1)
|
|
89
|
+
? dx < 0
|
|
90
|
+
? 'left'
|
|
91
|
+
: 'right'
|
|
92
|
+
: dy < 0
|
|
93
|
+
? 'top'
|
|
94
|
+
: 'bottom';
|
|
95
|
+
}
|
|
96
|
+
return place(
|
|
97
|
+
target,
|
|
98
|
+
point,
|
|
99
|
+
typeof anchor === 'object'
|
|
100
|
+
? { x: point.x - center.x, y: point.y - center.y }
|
|
101
|
+
: sideVector(side),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
/** Maps a local point and outward direction into drawing units, snapped to an axis. */
|
|
105
|
+
function place(target: ConnectorTarget, point: Point, local: Point): ConnectorPort {
|
|
106
|
+
const root = target.toRoot(point);
|
|
107
|
+
const offset = target.toRoot({ x: point.x + local.x, y: point.y + local.y });
|
|
108
|
+
const dx = offset.x - root.x,
|
|
109
|
+
dy = offset.y - root.y;
|
|
110
|
+
return {
|
|
111
|
+
point: root,
|
|
112
|
+
direction:
|
|
113
|
+
Math.abs(dx) >= Math.abs(dy) ? { x: Math.sign(dx), y: 0 } : { x: 0, y: Math.sign(dy) },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** Manhattan routing with short outward leads. It does not avoid other shapes. */
|
|
117
|
+
export function routeConnector({ start, end, routing }: ConnectorRoute): readonly Point[] {
|
|
118
|
+
if (routing === 'straight') return [start.point, end.point];
|
|
119
|
+
if (routing !== 'elbow') throw new TypeError('Unknown connector routing');
|
|
120
|
+
const lead = (port: ConnectorPort) => ({
|
|
121
|
+
x: port.point.x + port.direction.x * 16,
|
|
122
|
+
y: port.point.y + port.direction.y * 16,
|
|
123
|
+
});
|
|
124
|
+
const a = lead(start),
|
|
125
|
+
b = lead(end);
|
|
126
|
+
const horizontal =
|
|
127
|
+
start.direction.x !== 0 || (!start.direction.y && Math.abs(b.x - a.x) >= Math.abs(b.y - a.y));
|
|
128
|
+
const endHorizontal = end.direction.x !== 0 || (!end.direction.y && horizontal);
|
|
129
|
+
const bends =
|
|
130
|
+
horizontal !== endHorizontal
|
|
131
|
+
? [horizontal ? { x: b.x, y: a.y } : { x: a.x, y: b.y }]
|
|
132
|
+
: horizontal
|
|
133
|
+
? [
|
|
134
|
+
{ x: (a.x + b.x) / 2, y: a.y },
|
|
135
|
+
{ x: (a.x + b.x) / 2, y: b.y },
|
|
136
|
+
]
|
|
137
|
+
: [
|
|
138
|
+
{ x: a.x, y: (a.y + b.y) / 2 },
|
|
139
|
+
{ x: b.x, y: (a.y + b.y) / 2 },
|
|
140
|
+
];
|
|
141
|
+
return [start.point, a, ...bends, b, end.point].filter(
|
|
142
|
+
(point, index, points) =>
|
|
143
|
+
index === 0 || point.x !== points[index - 1].x || point.y !== points[index - 1].y,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
export function pathData(points: readonly Point[]): string {
|
|
147
|
+
return points
|
|
148
|
+
.map(
|
|
149
|
+
(point, index) =>
|
|
150
|
+
`${index ? 'L' : 'M'}${Number(point.x.toFixed(6))} ${Number(point.y.toFixed(6))}`,
|
|
151
|
+
)
|
|
152
|
+
.join(' ');
|
|
153
|
+
}
|