@rasamjs/connectors 0.1.0 → 0.3.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 +72 -21
- package/CHANGELOG.md +36 -0
- package/README.md +14 -1
- package/RELEASING.md +12 -2
- package/dist/connectors.d.ts +17 -3
- package/dist/connectors.d.ts.map +1 -1
- package/dist/connectors.js +106 -27
- package/dist/connectors.js.map +1 -1
- package/dist/editor.d.ts +15 -1
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +20 -8
- package/dist/editor.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/metadata.d.ts +6 -2
- package/dist/metadata.d.ts.map +1 -1
- package/dist/metadata.js +28 -3
- package/dist/metadata.js.map +1 -1
- package/dist/routing.d.ts +5 -2
- package/dist/routing.d.ts.map +1 -1
- package/dist/routing.js +28 -8
- package/dist/routing.js.map +1 -1
- package/dist/tool.d.ts +4 -1
- package/dist/tool.d.ts.map +1 -1
- package/dist/tool.js +37 -16
- package/dist/tool.js.map +1 -1
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +64 -63
- package/src/connectors.ts +132 -36
- package/src/editor.ts +31 -8
- package/src/index.ts +2 -0
- package/src/metadata.ts +30 -5
- package/src/routing.ts +35 -9
- package/src/tool.ts +47 -24
- package/src/types.ts +10 -0
package/package.json
CHANGED
|
@@ -1,63 +1,64 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@rasamjs/connectors",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"type": "module",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"description": "Attached straight and elbow connectors for rasam SVG drawings.",
|
|
7
|
-
"files": [
|
|
8
|
-
"dist",
|
|
9
|
-
"src",
|
|
10
|
-
"README.md",
|
|
11
|
-
"API.md",
|
|
12
|
-
"CHANGELOG.md",
|
|
13
|
-
"RELEASING.md",
|
|
14
|
-
"LICENSE"
|
|
15
|
-
],
|
|
16
|
-
"types": "./dist/index.d.ts",
|
|
17
|
-
"exports": {
|
|
18
|
-
".": {
|
|
19
|
-
"rasam-source": "./src/index.ts",
|
|
20
|
-
"types": "./dist/index.d.ts",
|
|
21
|
-
"import": "./dist/index.js"
|
|
22
|
-
},
|
|
23
|
-
"./tool": {
|
|
24
|
-
"rasam-source": "./src/tool.ts",
|
|
25
|
-
"types": "./dist/tool.d.ts",
|
|
26
|
-
"import": "./dist/tool.js"
|
|
27
|
-
},
|
|
28
|
-
"./editor": {
|
|
29
|
-
"rasam-source": "./src/editor.ts",
|
|
30
|
-
"types": "./dist/editor.d.ts",
|
|
31
|
-
"import": "./dist/editor.js"
|
|
32
|
-
}
|
|
33
|
-
},
|
|
34
|
-
"sideEffects": false,
|
|
35
|
-
"peerDependencies": {
|
|
36
|
-
"@rasamjs/core": "^0.
|
|
37
|
-
},
|
|
38
|
-
"devDependencies": {
|
|
39
|
-
"@rasamjs/core": "0.
|
|
40
|
-
"@rasamjs/history": "0.2.0",
|
|
41
|
-
"@rasamjs/transformer": "0.5.0"
|
|
42
|
-
},
|
|
43
|
-
"scripts": {
|
|
44
|
-
"build": "node scripts/build.mjs",
|
|
45
|
-
"typecheck": "tsc -p tsconfig.check.json",
|
|
46
|
-
"test": "vitest run",
|
|
47
|
-
"test:browser": "playwright test",
|
|
48
|
-
"dev": "vite --host 127.0.0.1",
|
|
49
|
-
"format": "prettier --write src tests scripts fixtures *.json *.ts *.md",
|
|
50
|
-
"format:check": "prettier --check src tests scripts fixtures *.json *.ts *.md",
|
|
51
|
-
"check:consumer": "node scripts/check-consumer.mjs",
|
|
52
|
-
"check:docs": "node scripts/check-docs.mjs",
|
|
53
|
-
"check:size": "node scripts/check-size.mjs",
|
|
54
|
-
"api:check": "node scripts/check-api.mjs",
|
|
55
|
-
"api:update": "node scripts/check-api.mjs --update",
|
|
56
|
-
"release:check": "node scripts/check-release.mjs"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@rasamjs/connectors",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "Attached straight and elbow connectors for rasam SVG drawings.",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"src",
|
|
10
|
+
"README.md",
|
|
11
|
+
"API.md",
|
|
12
|
+
"CHANGELOG.md",
|
|
13
|
+
"RELEASING.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"rasam-source": "./src/index.ts",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./tool": {
|
|
24
|
+
"rasam-source": "./src/tool.ts",
|
|
25
|
+
"types": "./dist/tool.d.ts",
|
|
26
|
+
"import": "./dist/tool.js"
|
|
27
|
+
},
|
|
28
|
+
"./editor": {
|
|
29
|
+
"rasam-source": "./src/editor.ts",
|
|
30
|
+
"types": "./dist/editor.d.ts",
|
|
31
|
+
"import": "./dist/editor.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@rasamjs/core": "^0.3.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@rasamjs/core": "0.3.0",
|
|
40
|
+
"@rasamjs/history": "0.2.0",
|
|
41
|
+
"@rasamjs/transformer": "0.5.0"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "node scripts/build.mjs",
|
|
45
|
+
"typecheck": "tsc -p tsconfig.check.json",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"test:browser": "playwright test",
|
|
48
|
+
"dev": "vite --host 127.0.0.1",
|
|
49
|
+
"format": "prettier --write src tests scripts fixtures *.json *.ts *.md",
|
|
50
|
+
"format:check": "prettier --check src tests scripts fixtures *.json *.ts *.md",
|
|
51
|
+
"check:consumer": "node scripts/check-consumer.mjs",
|
|
52
|
+
"check:docs": "node scripts/check-docs.mjs",
|
|
53
|
+
"check:size": "node scripts/check-size.mjs",
|
|
54
|
+
"api:check": "node scripts/check-api.mjs",
|
|
55
|
+
"api:update": "node scripts/check-api.mjs --update",
|
|
56
|
+
"release:check": "node scripts/check-release.mjs",
|
|
57
|
+
"bench": "node scripts/benchmark.mjs"
|
|
58
|
+
},
|
|
59
|
+
"repository": {
|
|
60
|
+
"type": "git",
|
|
61
|
+
"url": "git+https://github.com/hdevlop/rasam.git",
|
|
62
|
+
"directory": "packages/connectors"
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/connectors.ts
CHANGED
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
import { Path } from '@rasamjs/core';
|
|
2
|
-
import type { Svg, AnyNode } from '@rasamjs/core';
|
|
2
|
+
import type { Svg, AnyNode, Point } from '@rasamjs/core';
|
|
3
3
|
import { Guards } from '@rasamjs/core/advanced';
|
|
4
4
|
import { beginChangeGroup, deriveChanges } from '@rasamjs/core/changes';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
endpointAttribute,
|
|
7
|
+
isConnector,
|
|
8
|
+
pathEnd,
|
|
9
|
+
readConnector,
|
|
10
|
+
unbound,
|
|
11
|
+
writeConnector,
|
|
12
|
+
} from './metadata.js';
|
|
6
13
|
import {
|
|
7
14
|
connectorPort,
|
|
8
15
|
endpointCenter,
|
|
16
|
+
findPort,
|
|
9
17
|
pathData,
|
|
10
18
|
routeConnector,
|
|
11
19
|
validateAnchor,
|
|
12
20
|
validatePoint,
|
|
13
21
|
} from './routing.js';
|
|
14
|
-
import type {
|
|
22
|
+
import type {
|
|
23
|
+
ConnectorEndpoint,
|
|
24
|
+
ConnectorOptions,
|
|
25
|
+
ConnectorState,
|
|
26
|
+
ConnectorTarget,
|
|
27
|
+
ConnectorRouter,
|
|
28
|
+
ConnectorsOptions,
|
|
29
|
+
} from './types.js';
|
|
15
30
|
|
|
16
31
|
const owners = new WeakMap<Svg, Connectors>();
|
|
17
32
|
const drawableTags = new Set([
|
|
@@ -33,7 +48,11 @@ export class Connectors {
|
|
|
33
48
|
private disposed = false;
|
|
34
49
|
private readonly cleanups: (() => void)[] = [];
|
|
35
50
|
private readonly disposals = new Set<() => void>();
|
|
36
|
-
|
|
51
|
+
private router?: ConnectorRouter;
|
|
52
|
+
constructor(
|
|
53
|
+
readonly drawing: Svg,
|
|
54
|
+
options: ConnectorsOptions = {},
|
|
55
|
+
) {
|
|
37
56
|
if (
|
|
38
57
|
!Guards.isTransformable(drawing) ||
|
|
39
58
|
drawing.isDestroyed ||
|
|
@@ -42,16 +61,25 @@ export class Connectors {
|
|
|
42
61
|
)
|
|
43
62
|
throw new TypeError('Connectors needs a live SVG drawing root');
|
|
44
63
|
if (owners.has(drawing)) throw new Error('Connectors is already attached to this drawing');
|
|
64
|
+
if (
|
|
65
|
+
!options ||
|
|
66
|
+
typeof options !== 'object' ||
|
|
67
|
+
(options.route !== undefined && typeof options.route !== 'function')
|
|
68
|
+
)
|
|
69
|
+
throw new TypeError('Connector route must be a function');
|
|
70
|
+
this.router = options.route;
|
|
45
71
|
// Validate saved attachments without changing stored routing during attachment or replay.
|
|
46
72
|
this.nodes.forEach(readConnector);
|
|
47
73
|
owners.set(drawing, this);
|
|
48
74
|
this.cleanups.push(
|
|
49
75
|
deriveChanges(drawing, (event) => {
|
|
50
76
|
if (event.source === 'viewport') return;
|
|
51
|
-
for (const path of this.nodes) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
77
|
+
for (const path of this.affected(event.nodes)) {
|
|
78
|
+
let state = readConnector(path);
|
|
79
|
+
const loose = this.loosen(state);
|
|
80
|
+
if (loose !== state || unbound.has(state.start) || unbound.has(state.end))
|
|
81
|
+
writeConnector(path, (state = loose));
|
|
82
|
+
this.route(state);
|
|
55
83
|
}
|
|
56
84
|
}),
|
|
57
85
|
drawing.onDispose(() => this.destroy()),
|
|
@@ -76,6 +104,25 @@ export class Connectors {
|
|
|
76
104
|
this.assertPath(path);
|
|
77
105
|
return readConnector(path);
|
|
78
106
|
}
|
|
107
|
+
/** Connectors with an end on `target`, or only on its port named `port`. */
|
|
108
|
+
links(target: ConnectorTarget, port?: string): readonly Path[] {
|
|
109
|
+
this.assertLive();
|
|
110
|
+
if (port !== undefined && (typeof port !== 'string' || !port))
|
|
111
|
+
throw new TypeError('Port names must be non-empty strings');
|
|
112
|
+
return Object.freeze(
|
|
113
|
+
this.attached(target)
|
|
114
|
+
.filter((path) => {
|
|
115
|
+
const { start, end } = readConnector(path);
|
|
116
|
+
return [start, end].some(
|
|
117
|
+
(endpoint) =>
|
|
118
|
+
'target' in endpoint &&
|
|
119
|
+
endpoint.target === target &&
|
|
120
|
+
(port === undefined || ('port' in endpoint && endpoint.port === port)),
|
|
121
|
+
);
|
|
122
|
+
})
|
|
123
|
+
.sort((a, b) => (a.el.compareDocumentPosition(b.el) & 4 ? -1 : 1)),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
79
126
|
create(options: ConnectorOptions): Path {
|
|
80
127
|
this.assertLive();
|
|
81
128
|
this.validate(options);
|
|
@@ -105,7 +152,7 @@ export class Connectors {
|
|
|
105
152
|
if (options.arrows === 'both') path.marker('start', marker);
|
|
106
153
|
}
|
|
107
154
|
if (!this.route(readConnector(path)))
|
|
108
|
-
throw new Error('Connector
|
|
155
|
+
throw new Error('Connector route is unavailable or its transform is not invertible');
|
|
109
156
|
return path;
|
|
110
157
|
});
|
|
111
158
|
}
|
|
@@ -116,7 +163,7 @@ export class Connectors {
|
|
|
116
163
|
this.edit('Edit connector', () => {
|
|
117
164
|
writeConnector(path, state);
|
|
118
165
|
if (!this.route(state))
|
|
119
|
-
throw new Error('Connector
|
|
166
|
+
throw new Error('Connector route is unavailable or its transform is not invertible');
|
|
120
167
|
});
|
|
121
168
|
}
|
|
122
169
|
remove(path: Path): void {
|
|
@@ -126,6 +173,7 @@ export class Connectors {
|
|
|
126
173
|
destroy(): void {
|
|
127
174
|
if (this.disposed) return;
|
|
128
175
|
this.disposed = true;
|
|
176
|
+
this.router = undefined;
|
|
129
177
|
owners.delete(this.drawing);
|
|
130
178
|
const failures: unknown[] = [];
|
|
131
179
|
for (const stop of [...this.cleanups.splice(0).reverse(), ...this.disposals]) {
|
|
@@ -147,7 +195,7 @@ export class Connectors {
|
|
|
147
195
|
!isConnector(path) ||
|
|
148
196
|
path.isDestroyed ||
|
|
149
197
|
path.context !== this.drawing.context ||
|
|
150
|
-
!this.drawing.
|
|
198
|
+
!this.drawing.el.contains(path.el)
|
|
151
199
|
)
|
|
152
200
|
throw new TypeError('Expected a connector in this drawing');
|
|
153
201
|
}
|
|
@@ -183,7 +231,7 @@ export class Connectors {
|
|
|
183
231
|
target.isDestroyed ||
|
|
184
232
|
target === this.drawing ||
|
|
185
233
|
target.context !== this.drawing.context ||
|
|
186
|
-
!this.drawing.
|
|
234
|
+
!this.drawing.el.contains(target.el) ||
|
|
187
235
|
target.descendants().some(isConnector)
|
|
188
236
|
)
|
|
189
237
|
throw new TypeError(
|
|
@@ -192,42 +240,90 @@ export class Connectors {
|
|
|
192
240
|
for (let node: AnyNode | null = target; node && node !== this.drawing; node = node.parent)
|
|
193
241
|
if (!drawableTags.has(node.el.localName))
|
|
194
242
|
throw new TypeError('Connector targets cannot be inside definitions');
|
|
195
|
-
validateAnchor(endpoint.anchor ?? 'auto');
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
243
|
+
if (!('port' in endpoint)) return validateAnchor(endpoint.anchor ?? 'auto');
|
|
244
|
+
if ('anchor' in endpoint) throw new TypeError('Endpoint needs either a port or an anchor');
|
|
245
|
+
if (typeof endpoint.port !== 'string' || !findPort(target, endpoint.port))
|
|
246
|
+
throw new TypeError(`Unknown connector port: ${String(endpoint.port)}`);
|
|
247
|
+
}
|
|
248
|
+
/** An end whose target left the drawing, or whose port is gone, stays where it is. */
|
|
249
|
+
private loosen(state: ConnectorState): ConnectorState {
|
|
250
|
+
const loose = (end: 'start' | 'end'): ConnectorEndpoint => {
|
|
251
|
+
const endpoint = state[end];
|
|
252
|
+
if ('point' in endpoint) return endpoint;
|
|
253
|
+
const { target } = endpoint;
|
|
254
|
+
const gone =
|
|
255
|
+
target.isDestroyed ||
|
|
256
|
+
!this.drawing.el.contains(target.el) ||
|
|
257
|
+
('port' in endpoint && !findPort(target, endpoint.port));
|
|
258
|
+
const point = gone && pathEnd(state.path, end);
|
|
259
|
+
return point ? { point } : endpoint;
|
|
260
|
+
};
|
|
261
|
+
const start = loose('start'),
|
|
262
|
+
end = loose('end');
|
|
263
|
+
return start === state.start && end === state.end ? state : { ...state, start, end };
|
|
264
|
+
}
|
|
265
|
+
/** Connectors in this drawing with an end on `target`, from core's reference index. */
|
|
266
|
+
private attached(target: AnyNode): Path[] {
|
|
267
|
+
return this.drawing.context.defs
|
|
268
|
+
.consumersOf(target)
|
|
269
|
+
.filter(
|
|
270
|
+
(node): node is Path =>
|
|
271
|
+
isConnector(node) &&
|
|
272
|
+
this.drawing.el.contains(node.el) &&
|
|
273
|
+
(['start', 'end'] as const).some(
|
|
274
|
+
(end) => node.references.get(endpointAttribute(end)) === target,
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Connectors to route after a change: those inside a changed subtree, and those attached
|
|
280
|
+
* to a changed node, its descendants or its ancestors, since parents move, resize or
|
|
281
|
+
* reparent targets and a descendant changes a group's bounds. The work follows the
|
|
282
|
+
* size of the change, not the number of connectors.
|
|
283
|
+
*/
|
|
284
|
+
private affected(changed: readonly AnyNode[]): Set<Path> {
|
|
285
|
+
const paths = new Set<Path>(),
|
|
286
|
+
seen = new Set<AnyNode>();
|
|
287
|
+
const visit = (node: AnyNode) => {
|
|
288
|
+
if (node.isDestroyed || seen.has(node)) return;
|
|
289
|
+
seen.add(node);
|
|
290
|
+
if (isConnector(node) && this.drawing.el.contains(node.el)) paths.add(node);
|
|
291
|
+
for (const path of this.attached(node)) paths.add(path);
|
|
292
|
+
};
|
|
293
|
+
for (const node of changed) {
|
|
294
|
+
node.descendants().forEach(visit);
|
|
295
|
+
for (let parent = node.parent; parent; parent = parent.parent) visit(parent);
|
|
296
|
+
}
|
|
297
|
+
return paths;
|
|
208
298
|
}
|
|
209
299
|
private route(state: ConnectorState): boolean {
|
|
210
300
|
const { path, start, end } = state;
|
|
211
301
|
for (const endpoint of [start, end])
|
|
212
302
|
if ('target' in endpoint) {
|
|
213
|
-
if (endpoint.target.isDestroyed || !this.drawing.
|
|
303
|
+
if (endpoint.target.isDestroyed || !this.drawing.el.contains(endpoint.target.el))
|
|
214
304
|
return false;
|
|
215
|
-
if (endpoint.target.
|
|
305
|
+
if (endpoint.target.el.contains(path.el))
|
|
216
306
|
throw new TypeError('A connector cannot be inside its endpoint target');
|
|
217
307
|
}
|
|
308
|
+
const inverse = path.worldMatrix().inverse();
|
|
309
|
+
if (!inverse) return false;
|
|
310
|
+
const points = this.router ? this.router(state) : this.defaultRoute(state);
|
|
311
|
+
if (points === null) return false;
|
|
312
|
+
if (!Array.isArray(points) || points.length < 2)
|
|
313
|
+
throw new TypeError('Connector route must return at least two finite points or null');
|
|
314
|
+
for (const point of points) validatePoint(point);
|
|
315
|
+
const local = points.map((point) => inverse.apply(point));
|
|
316
|
+
local.forEach((point) => validatePoint(point));
|
|
317
|
+
const d = pathData(local);
|
|
318
|
+
if (path.getAttributeValue('d') !== d) path.attrs({ d });
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
private defaultRoute({ start, end, routing }: ConnectorState): readonly Point[] | null {
|
|
218
322
|
const a = endpointCenter(start),
|
|
219
323
|
b = endpointCenter(end);
|
|
220
324
|
const from = a && b ? connectorPort(start, b) : null;
|
|
221
325
|
const to = a && b ? connectorPort(end, a) : null;
|
|
222
|
-
|
|
223
|
-
if (!from || !to || !inverse) return false;
|
|
224
|
-
const d = pathData(
|
|
225
|
-
routeConnector({ start: from, end: to, routing: state.routing }).map((point) =>
|
|
226
|
-
inverse.apply(point),
|
|
227
|
-
),
|
|
228
|
-
);
|
|
229
|
-
if (path.getAttributeValue('d') !== d) path.attrs({ d });
|
|
230
|
-
return true;
|
|
326
|
+
return from && to ? routeConnector({ start: from, end: to, routing }) : null;
|
|
231
327
|
}
|
|
232
328
|
private edit<Result>(label: string, run: () => Result): Result {
|
|
233
329
|
const group = beginChangeGroup(this.drawing, { source: 'connectors', label });
|
package/src/editor.ts
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import type { AnyNode, Svg } from '@rasamjs/core';
|
|
2
2
|
import { Connectors } from './connectors.js';
|
|
3
3
|
import { ConnectorTool } from './tool.js';
|
|
4
|
+
import type { ConnectorOptions, ConnectorsOptions } 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
|
+
];
|
|
4
17
|
|
|
5
18
|
export interface ConnectorScope {
|
|
6
19
|
readonly drawing: Svg;
|
|
@@ -25,18 +38,27 @@ export interface ConnectorScope {
|
|
|
25
38
|
created(node: AnyNode): void;
|
|
26
39
|
exit(): void;
|
|
27
40
|
}
|
|
28
|
-
|
|
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: ConnectorsOptions & { 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');
|
|
29
50
|
return {
|
|
30
51
|
id: 'connectors',
|
|
31
52
|
create(scope: ConnectorScope) {
|
|
32
|
-
const service = scope.use(new Connectors(scope.drawing));
|
|
33
|
-
for (const routing
|
|
53
|
+
const service = scope.use(new Connectors(scope.drawing, options));
|
|
54
|
+
for (const { id, label, key, routing = 'straight', arrows, hint } of tools)
|
|
34
55
|
scope.registerTool({
|
|
35
|
-
id
|
|
36
|
-
commandId:
|
|
37
|
-
label
|
|
38
|
-
key
|
|
39
|
-
hint:
|
|
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.',
|
|
40
62
|
activate(current) {
|
|
41
63
|
const editor = current.editor;
|
|
42
64
|
let release = () => {},
|
|
@@ -45,6 +67,7 @@ export function connectorEditing() {
|
|
|
45
67
|
const tool = current.use(
|
|
46
68
|
new ConnectorTool(service, {
|
|
47
69
|
routing,
|
|
70
|
+
arrows,
|
|
48
71
|
...editor.paint,
|
|
49
72
|
selectable: (node) =>
|
|
50
73
|
editor.selection.canSelect(node) && editor.layers.isSelectable(node),
|
package/src/index.ts
CHANGED
package/src/metadata.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Path } from '@rasamjs/core';
|
|
2
|
-
import type { AnyNode } from '@rasamjs/core';
|
|
2
|
+
import type { AnyNode, Point } from '@rasamjs/core';
|
|
3
3
|
import { data } from '@rasamjs/core/data';
|
|
4
4
|
import type {
|
|
5
5
|
ConnectorAnchor,
|
|
@@ -12,7 +12,20 @@ export const endpointAttribute = (end: 'start' | 'end') => `data-rasam-connector
|
|
|
12
12
|
const metadataKey = 'rasam-connector';
|
|
13
13
|
interface SavedEndpoint {
|
|
14
14
|
anchor?: ConnectorAnchor;
|
|
15
|
-
|
|
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] });
|
|
16
29
|
}
|
|
17
30
|
interface SavedConnector {
|
|
18
31
|
version: 1;
|
|
@@ -36,8 +49,18 @@ export function readConnector(path: Path): ConnectorState {
|
|
|
36
49
|
return { point: { ...value.point } };
|
|
37
50
|
}
|
|
38
51
|
const target = path.references.get(endpointAttribute(end));
|
|
39
|
-
if (!target || !('toRoot' in target))
|
|
40
|
-
|
|
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
|
+
}
|
|
41
64
|
validateAnchor(value.anchor ?? 'auto');
|
|
42
65
|
return {
|
|
43
66
|
target: target as import('./types.js').ConnectorTarget,
|
|
@@ -52,7 +75,9 @@ export function writeConnector(path: Path, state: Omit<ConnectorState, 'path'>):
|
|
|
52
75
|
path.reference(endpointAttribute(end), 'target' in endpoint ? endpoint.target : null, 'href');
|
|
53
76
|
return 'point' in endpoint
|
|
54
77
|
? { point: { ...endpoint.point } }
|
|
55
|
-
:
|
|
78
|
+
: 'port' in endpoint
|
|
79
|
+
? { port: endpoint.port }
|
|
80
|
+
: { anchor: endpoint.anchor ?? 'auto' };
|
|
56
81
|
};
|
|
57
82
|
data(path, metadataKey, {
|
|
58
83
|
version: 1,
|
package/src/routing.ts
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
|
-
import type { Point } from '@rasamjs/core';
|
|
1
|
+
import type { Point, PortDefinition } from '@rasamjs/core';
|
|
2
2
|
import type {
|
|
3
3
|
ConnectorAnchor,
|
|
4
4
|
ConnectorEndpoint,
|
|
5
5
|
ConnectorPort,
|
|
6
6
|
ConnectorRoute,
|
|
7
7
|
ConnectorSide,
|
|
8
|
+
ConnectorTarget,
|
|
8
9
|
} from './types.js';
|
|
9
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
|
+
|
|
10
23
|
export function validatePoint(point: Point, normalized = false): void {
|
|
11
24
|
if (
|
|
12
25
|
!point ||
|
|
@@ -26,6 +39,10 @@ export function validateAnchor(anchor: ConnectorAnchor): void {
|
|
|
26
39
|
}
|
|
27
40
|
export function endpointCenter(endpoint: ConnectorEndpoint): Point | null {
|
|
28
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
|
+
}
|
|
29
46
|
const bounds = endpoint.target.bounds();
|
|
30
47
|
if (!('box' in bounds)) return null;
|
|
31
48
|
const { x, y, width, height } = bounds.box;
|
|
@@ -34,7 +51,13 @@ export function endpointCenter(endpoint: ConnectorEndpoint): Point | null {
|
|
|
34
51
|
/** Resolve on the local box, then map into drawing units, retaining rotated anchors. */
|
|
35
52
|
export function connectorPort(endpoint: ConnectorEndpoint, toward: Point): ConnectorPort | null {
|
|
36
53
|
if ('point' in endpoint) return { point: endpoint.point, direction: { x: 0, y: 0 } };
|
|
37
|
-
const { target
|
|
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;
|
|
38
61
|
const bounds = target.bounds();
|
|
39
62
|
if (!('box' in bounds)) return null;
|
|
40
63
|
const { x, y, width, height } = bounds.box;
|
|
@@ -70,15 +93,18 @@ export function connectorPort(endpoint: ConnectorEndpoint, toward: Point): Conne
|
|
|
70
93
|
? 'top'
|
|
71
94
|
: 'bottom';
|
|
72
95
|
}
|
|
73
|
-
|
|
74
|
-
|
|
96
|
+
return place(
|
|
97
|
+
target,
|
|
98
|
+
point,
|
|
75
99
|
typeof anchor === 'object'
|
|
76
100
|
? { x: point.x - center.x, y: point.y - center.y }
|
|
77
|
-
:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 });
|
|
82
108
|
const dx = offset.x - root.x,
|
|
83
109
|
dy = offset.y - root.y;
|
|
84
110
|
return {
|