@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/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
- /** Drag from a hinted anchor or free point. The document is written only on release. */
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 targets = drawing.el.ownerDocument
201
- .elementsFromPoint(event.clientX, event.clientY)
202
- .map((element) => Registry.wrapper(element as SVGElement));
203
- let target = targets.find(
204
- (node): node is ConnectorTarget =>
205
- Guards.isTransformable(node) &&
206
- node !== drawing &&
207
- drawing.el.contains(node.el) &&
208
- !isConnector(node) &&
209
- !node.descendants().some(isConnector) &&
210
- this.options.selectable?.(node as ConnectorTarget) !== false,
211
- );
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 = sides.some((anchor) => {
222
- const port = connectorPort({ target: previous, anchor }, point);
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 anchor of sides) {
233
- const port = connectorPort({ target, anchor }, point);
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 = { target, anchor };
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 anchor of sides) {
303
- const port = connectorPort({ target: this.hover.target, anchor }, { x: 0, y: 0 });
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
- 'data-connector-anchor': anchor,
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 {
@@ -22,6 +27,11 @@ export interface ConnectorState {
22
27
  readonly end: ConnectorEndpoint;
23
28
  readonly routing: ConnectorRouting;
24
29
  }
30
+ /** Return at least two finite points in drawing units, or null to retain stored geometry. */
31
+ export type ConnectorRouter = (state: ConnectorState) => readonly Point[] | null;
32
+ export interface ConnectorsOptions {
33
+ readonly route?: ConnectorRouter;
34
+ }
25
35
  export interface ConnectorPort {
26
36
  readonly point: Point;
27
37
  readonly direction: Point;