@rasamjs/connectors 0.1.0 → 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 +49 -20
- package/CHANGELOG.md +27 -0
- package/README.md +11 -1
- package/RELEASING.md +4 -2
- package/dist/connectors.d.ts +14 -2
- package/dist/connectors.d.ts.map +1 -1
- package/dist/connectors.js +79 -19
- 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 +19 -7
- package/dist/editor.js.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 +7 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +64 -63
- package/src/connectors.ts +99 -23
- package/src/editor.ts +30 -7
- package/src/metadata.ts +30 -5
- package/src/routing.ts +35 -9
- package/src/tool.ts +47 -24
- package/src/types.ts +5 -0
package/src/connectors.ts
CHANGED
|
@@ -2,16 +2,29 @@ import { Path } from '@rasamjs/core';
|
|
|
2
2
|
import type { Svg, AnyNode } 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
|
+
} from './types.js';
|
|
15
28
|
|
|
16
29
|
const owners = new WeakMap<Svg, Connectors>();
|
|
17
30
|
const drawableTags = new Set([
|
|
@@ -48,10 +61,12 @@ export class Connectors {
|
|
|
48
61
|
this.cleanups.push(
|
|
49
62
|
deriveChanges(drawing, (event) => {
|
|
50
63
|
if (event.source === 'viewport') return;
|
|
51
|
-
for (const path of this.nodes) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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);
|
|
55
70
|
}
|
|
56
71
|
}),
|
|
57
72
|
drawing.onDispose(() => this.destroy()),
|
|
@@ -76,6 +91,25 @@ export class Connectors {
|
|
|
76
91
|
this.assertPath(path);
|
|
77
92
|
return readConnector(path);
|
|
78
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
|
+
}
|
|
79
113
|
create(options: ConnectorOptions): Path {
|
|
80
114
|
this.assertLive();
|
|
81
115
|
this.validate(options);
|
|
@@ -147,7 +181,7 @@ export class Connectors {
|
|
|
147
181
|
!isConnector(path) ||
|
|
148
182
|
path.isDestroyed ||
|
|
149
183
|
path.context !== this.drawing.context ||
|
|
150
|
-
!this.drawing.
|
|
184
|
+
!this.drawing.el.contains(path.el)
|
|
151
185
|
)
|
|
152
186
|
throw new TypeError('Expected a connector in this drawing');
|
|
153
187
|
}
|
|
@@ -183,7 +217,7 @@ export class Connectors {
|
|
|
183
217
|
target.isDestroyed ||
|
|
184
218
|
target === this.drawing ||
|
|
185
219
|
target.context !== this.drawing.context ||
|
|
186
|
-
!this.drawing.
|
|
220
|
+
!this.drawing.el.contains(target.el) ||
|
|
187
221
|
target.descendants().some(isConnector)
|
|
188
222
|
)
|
|
189
223
|
throw new TypeError(
|
|
@@ -192,27 +226,69 @@ export class Connectors {
|
|
|
192
226
|
for (let node: AnyNode | null = target; node && node !== this.drawing; node = node.parent)
|
|
193
227
|
if (!drawableTags.has(node.el.localName))
|
|
194
228
|
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
|
-
|
|
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;
|
|
208
284
|
}
|
|
209
285
|
private route(state: ConnectorState): boolean {
|
|
210
286
|
const { path, start, end } = state;
|
|
211
287
|
for (const endpoint of [start, end])
|
|
212
288
|
if ('target' in endpoint) {
|
|
213
|
-
if (endpoint.target.isDestroyed || !this.drawing.
|
|
289
|
+
if (endpoint.target.isDestroyed || !this.drawing.el.contains(endpoint.target.el))
|
|
214
290
|
return false;
|
|
215
|
-
if (endpoint.target.
|
|
291
|
+
if (endpoint.target.el.contains(path.el))
|
|
216
292
|
throw new TypeError('A connector cannot be inside its endpoint target');
|
|
217
293
|
}
|
|
218
294
|
const a = endpointCenter(start),
|
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 } 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: { 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
53
|
const service = scope.use(new Connectors(scope.drawing));
|
|
33
|
-
for (const routing
|
|
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/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 {
|
package/src/tool.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { Path, Point } from '@rasamjs/core';
|
|
1
|
+
import type { AnyNode, Path, Point } from '@rasamjs/core';
|
|
2
2
|
import { Guards, Registry } from '@rasamjs/core/advanced';
|
|
3
3
|
import { observeChanges } from '@rasamjs/core/changes';
|
|
4
4
|
import type { Connectors } from './connectors.js';
|
|
5
5
|
import { isConnector } from './metadata.js';
|
|
6
|
-
import { connectorPort, endpointCenter, pathData, routeConnector } from './routing.js';
|
|
6
|
+
import { connectorPort, endpointCenter, pathData, portsOf, routeConnector } from './routing.js';
|
|
7
7
|
import { ToolSurface, svg } from './tool-surface.js';
|
|
8
8
|
import type {
|
|
9
9
|
ConnectorEndpoint,
|
|
@@ -32,8 +32,22 @@ interface Hover {
|
|
|
32
32
|
endpoint: ConnectorEndpoint;
|
|
33
33
|
}
|
|
34
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;
|
|
35
46
|
|
|
36
|
-
/**
|
|
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
|
+
*/
|
|
37
51
|
export class ConnectorTool {
|
|
38
52
|
private readonly surface: ToolSurface;
|
|
39
53
|
private active = false;
|
|
@@ -197,18 +211,23 @@ export class ConnectorTool {
|
|
|
197
211
|
const drawing = this.connectors.drawing;
|
|
198
212
|
const point = drawing.fromClient({ x: event.clientX, y: event.clientY });
|
|
199
213
|
if (!point) return null;
|
|
200
|
-
const
|
|
201
|
-
.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
(node)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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;
|
|
212
231
|
// The outer half of an anchor hint can lie just outside its shape's hit area.
|
|
213
232
|
const previous = this.hover?.target;
|
|
214
233
|
if (
|
|
@@ -218,8 +237,8 @@ export class ConnectorTool {
|
|
|
218
237
|
drawing.el.contains(previous.el) &&
|
|
219
238
|
this.options.selectable?.(previous) !== false
|
|
220
239
|
) {
|
|
221
|
-
const nearHint =
|
|
222
|
-
const port = connectorPort(
|
|
240
|
+
const nearHint = hints(previous).some((hint) => {
|
|
241
|
+
const port = connectorPort(hint, point);
|
|
223
242
|
const client = port && drawing.toClient(port.point);
|
|
224
243
|
return client && Math.hypot(client.x - event.clientX, client.y - event.clientY) < 12;
|
|
225
244
|
});
|
|
@@ -229,14 +248,14 @@ export class ConnectorTool {
|
|
|
229
248
|
if (!target) return { point };
|
|
230
249
|
let endpoint: ConnectorEndpoint = { target, anchor: 'auto' };
|
|
231
250
|
let nearest = 12;
|
|
232
|
-
for (const
|
|
233
|
-
const port = connectorPort(
|
|
251
|
+
for (const hint of hints(target)) {
|
|
252
|
+
const port = connectorPort(hint, point);
|
|
234
253
|
const client = port && drawing.toClient(port.point);
|
|
235
254
|
if (!client) continue;
|
|
236
255
|
const distance = Math.hypot(client.x - event.clientX, client.y - event.clientY);
|
|
237
256
|
if (distance < nearest) {
|
|
238
257
|
nearest = distance;
|
|
239
|
-
endpoint =
|
|
258
|
+
endpoint = hint;
|
|
240
259
|
}
|
|
241
260
|
}
|
|
242
261
|
this.hover = { target, endpoint };
|
|
@@ -299,20 +318,24 @@ export class ConnectorTool {
|
|
|
299
318
|
if (!this.enabled) return;
|
|
300
319
|
const document = overlay.ownerDocument;
|
|
301
320
|
if (this.hover)
|
|
302
|
-
for (const
|
|
303
|
-
const port = connectorPort(
|
|
321
|
+
for (const hint of hints(this.hover.target)) {
|
|
322
|
+
const port = connectorPort(hint, { x: 0, y: 0 });
|
|
304
323
|
const point = port && this.surface.fromRoot(port.point);
|
|
324
|
+
const active = same(hint, this.hover.endpoint);
|
|
305
325
|
if (point)
|
|
306
326
|
overlay.append(
|
|
307
327
|
svg(document, 'circle', {
|
|
308
328
|
cx: point.x,
|
|
309
329
|
cy: point.y,
|
|
310
330
|
r: 5,
|
|
311
|
-
fill: 'white',
|
|
331
|
+
fill: active ? '#2563eb' : 'white',
|
|
312
332
|
stroke: '#2563eb',
|
|
313
333
|
'stroke-width': 1.5,
|
|
314
334
|
'pointer-events': 'none',
|
|
315
|
-
'
|
|
335
|
+
...('port' in hint
|
|
336
|
+
? { 'data-connector-port': hint.port }
|
|
337
|
+
: { 'data-connector-anchor': (hint as { anchor: ConnectorSide }).anchor }),
|
|
338
|
+
...(active ? { 'data-connector-active': '' } : {}),
|
|
316
339
|
}),
|
|
317
340
|
);
|
|
318
341
|
}
|
package/src/types.ts
CHANGED
|
@@ -4,8 +4,13 @@ export type ConnectorTarget = Shape | Group | Svg;
|
|
|
4
4
|
export type ConnectorSide = 'top' | 'right' | 'bottom' | 'left';
|
|
5
5
|
/** A point is normalized within the target's local geometric bounds. */
|
|
6
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
|
+
*/
|
|
7
11
|
export type ConnectorEndpoint =
|
|
8
12
|
| { readonly target: ConnectorTarget; readonly anchor?: ConnectorAnchor }
|
|
13
|
+
| { readonly target: ConnectorTarget; readonly port: string }
|
|
9
14
|
| { readonly point: Point };
|
|
10
15
|
export type ConnectorRouting = 'straight' | 'elbow';
|
|
11
16
|
export interface ConnectorOptions {
|