@bpmnkit/editor 0.0.8

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.
@@ -0,0 +1,450 @@
1
+ // ── Coordinate conversion ────────────────────────────────────────────────────
2
+ /**
3
+ * Converts client (screen) coordinates to diagram coordinates accounting for
4
+ * the current viewport transform.
5
+ */
6
+ export function screenToDiagram(screenX, screenY, viewport, svgRect) {
7
+ return {
8
+ x: (screenX - svgRect.left - viewport.tx) / viewport.scale,
9
+ y: (screenY - svgRect.top - viewport.ty) / viewport.scale,
10
+ };
11
+ }
12
+ /**
13
+ * Converts diagram coordinates to client (screen) coordinates.
14
+ */
15
+ export function diagramToScreen(diagX, diagY, viewport, svgRect) {
16
+ return {
17
+ x: diagX * viewport.scale + viewport.tx + svgRect.left,
18
+ y: diagY * viewport.scale + viewport.ty + svgRect.top,
19
+ };
20
+ }
21
+ // ── Hit testing ───────────────────────────────────────────────────────────────
22
+ /**
23
+ * Returns the topmost shape that contains the diagram-space point (x, y),
24
+ * or null if none. Iterates in reverse render order (last = top).
25
+ */
26
+ export function hitTestShape(shapes, x, y) {
27
+ for (let i = shapes.length - 1; i >= 0; i--) {
28
+ const shape = shapes[i];
29
+ if (!shape)
30
+ continue;
31
+ const b = shape.shape.bounds;
32
+ if (x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height) {
33
+ return shape;
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+ // ── Handle positions ──────────────────────────────────────────────────────────
39
+ /** Returns the 8 handle positions (diagram space) for a shape's bounding box. */
40
+ export function handlePositions(bounds) {
41
+ const { x, y, width, height } = bounds;
42
+ const cx = x + width / 2;
43
+ const cy = y + height / 2;
44
+ return {
45
+ nw: { x, y },
46
+ n: { x: cx, y },
47
+ ne: { x: x + width, y },
48
+ e: { x: x + width, y: cy },
49
+ se: { x: x + width, y: y + height },
50
+ s: { x: cx, y: y + height },
51
+ sw: { x, y: y + height },
52
+ w: { x, y: cy },
53
+ };
54
+ }
55
+ // ── Port positions ────────────────────────────────────────────────────────────
56
+ /** Returns the 4 connection port positions (diagram space) for a shape. */
57
+ export function portPositions(bounds) {
58
+ const { x, y, width, height } = bounds;
59
+ return [
60
+ { x: x + width / 2, y, dir: "top" },
61
+ { x: x + width, y: y + height / 2, dir: "right" },
62
+ { x: x + width / 2, y: y + height, dir: "bottom" },
63
+ { x, y: y + height / 2, dir: "left" },
64
+ ];
65
+ }
66
+ // ── Resize ────────────────────────────────────────────────────────────────────
67
+ const MIN_SIZE = 20;
68
+ /**
69
+ * Applies a resize handle drag to produce new bounds.
70
+ * `diagX` and `diagY` are the current cursor position in diagram space.
71
+ */
72
+ export function applyResize(original, handle, diagX, diagY) {
73
+ let { x, y, width, height } = original;
74
+ const right = x + width;
75
+ const bottom = y + height;
76
+ switch (handle) {
77
+ case "nw":
78
+ x = Math.min(diagX, right - MIN_SIZE);
79
+ y = Math.min(diagY, bottom - MIN_SIZE);
80
+ width = right - x;
81
+ height = bottom - y;
82
+ break;
83
+ case "n":
84
+ y = Math.min(diagY, bottom - MIN_SIZE);
85
+ height = bottom - y;
86
+ break;
87
+ case "ne":
88
+ y = Math.min(diagY, bottom - MIN_SIZE);
89
+ width = Math.max(diagX - x, MIN_SIZE);
90
+ height = bottom - y;
91
+ break;
92
+ case "e":
93
+ width = Math.max(diagX - x, MIN_SIZE);
94
+ break;
95
+ case "se":
96
+ width = Math.max(diagX - x, MIN_SIZE);
97
+ height = Math.max(diagY - y, MIN_SIZE);
98
+ break;
99
+ case "s":
100
+ height = Math.max(diagY - y, MIN_SIZE);
101
+ break;
102
+ case "sw":
103
+ x = Math.min(diagX, right - MIN_SIZE);
104
+ width = right - x;
105
+ height = Math.max(diagY - y, MIN_SIZE);
106
+ break;
107
+ case "w":
108
+ x = Math.min(diagX, right - MIN_SIZE);
109
+ width = right - x;
110
+ break;
111
+ }
112
+ return { x, y, width, height };
113
+ }
114
+ // ── Waypoints ─────────────────────────────────────────────────────────────────
115
+ /**
116
+ * Computes orthogonal (H/V only) waypoints between two shapes.
117
+ * Picks exit/entry ports based on relative position: prefers L-shaped (one-bend) routes
118
+ * over Z-shaped (two-bend) routes. For gateways or events below/above the source,
119
+ * uses the bottom/top port rather than always exiting right.
120
+ */
121
+ export function computeWaypoints(src, tgt) {
122
+ const srcCx = src.x + src.width / 2;
123
+ const srcCy = src.y + src.height / 2;
124
+ const tgtCx = tgt.x + tgt.width / 2;
125
+ const tgtCy = tgt.y + tgt.height / 2;
126
+ const dx = tgtCx - srcCx;
127
+ const dy = tgtCy - srcCy;
128
+ const absDx = Math.abs(dx);
129
+ const absDy = Math.abs(dy);
130
+ let srcPort;
131
+ let tgtPort;
132
+ if (absDx >= absDy) {
133
+ // Target is predominantly to the side
134
+ srcPort = dx >= 0 ? "right" : "left";
135
+ if (absDy < 2) {
136
+ // Same height → straight horizontal
137
+ tgtPort = dx >= 0 ? "left" : "right";
138
+ }
139
+ else {
140
+ // Vertical offset → L-style: enter from top or bottom
141
+ tgtPort = dy > 0 ? "top" : "bottom";
142
+ }
143
+ }
144
+ else {
145
+ // Target is predominantly above or below
146
+ srcPort = dy > 0 ? "bottom" : "top";
147
+ if (absDx < 2) {
148
+ // Same X → straight vertical
149
+ tgtPort = dy > 0 ? "top" : "bottom";
150
+ }
151
+ else {
152
+ // Horizontal offset → L-style: enter from left or right
153
+ tgtPort = dx > 0 ? "left" : "right";
154
+ }
155
+ }
156
+ return computeWaypointsWithPorts(src, srcPort, tgt, tgtPort);
157
+ }
158
+ // ── Label position ────────────────────────────────────────────────────────────
159
+ const LABEL_W = 80;
160
+ const LABEL_H = 20;
161
+ const LABEL_GAP = 6;
162
+ /**
163
+ * Computes the absolute diagram-space bounds for an external label given a
164
+ * position option and the shape it belongs to.
165
+ */
166
+ export function labelBoundsForPosition(shape, position) {
167
+ const cx = shape.x + shape.width / 2;
168
+ const cy = shape.y + shape.height / 2;
169
+ const right = shape.x + shape.width;
170
+ const bottom = shape.y + shape.height;
171
+ switch (position) {
172
+ case "bottom":
173
+ return { x: cx - LABEL_W / 2, y: bottom + LABEL_GAP, width: LABEL_W, height: LABEL_H };
174
+ case "top":
175
+ return {
176
+ x: cx - LABEL_W / 2,
177
+ y: shape.y - LABEL_GAP - LABEL_H,
178
+ width: LABEL_W,
179
+ height: LABEL_H,
180
+ };
181
+ case "left":
182
+ return {
183
+ x: shape.x - LABEL_GAP - LABEL_W,
184
+ y: cy - LABEL_H / 2,
185
+ width: LABEL_W,
186
+ height: LABEL_H,
187
+ };
188
+ case "right":
189
+ return { x: right + LABEL_GAP, y: cy - LABEL_H / 2, width: LABEL_W, height: LABEL_H };
190
+ case "bottom-left":
191
+ return {
192
+ x: shape.x - LABEL_GAP - LABEL_W,
193
+ y: bottom + LABEL_GAP,
194
+ width: LABEL_W,
195
+ height: LABEL_H,
196
+ };
197
+ case "bottom-right":
198
+ return { x: right + LABEL_GAP, y: bottom + LABEL_GAP, width: LABEL_W, height: LABEL_H };
199
+ case "top-left":
200
+ return {
201
+ x: shape.x - LABEL_GAP - LABEL_W,
202
+ y: shape.y - LABEL_GAP - LABEL_H,
203
+ width: LABEL_W,
204
+ height: LABEL_H,
205
+ };
206
+ case "top-right":
207
+ return {
208
+ x: right + LABEL_GAP,
209
+ y: shape.y - LABEL_GAP - LABEL_H,
210
+ width: LABEL_W,
211
+ height: LABEL_H,
212
+ };
213
+ }
214
+ }
215
+ // ── Port helpers ──────────────────────────────────────────────────────────────
216
+ /** Returns the midpoint of a specific port edge in diagram space. */
217
+ export function portPoint(bounds, port) {
218
+ const { x, y, width, height } = bounds;
219
+ switch (port) {
220
+ case "top":
221
+ return { x: x + width / 2, y };
222
+ case "right":
223
+ return { x: x + width, y: y + height / 2 };
224
+ case "bottom":
225
+ return { x: x + width / 2, y: y + height };
226
+ case "left":
227
+ return { x, y: y + height / 2 };
228
+ }
229
+ }
230
+ /** Returns which port of `bounds` is nearest to `pos` in diagram space. */
231
+ export function closestPort(pos, bounds) {
232
+ const dirs = ["top", "right", "bottom", "left"];
233
+ let best = "right";
234
+ let minDist = Number.POSITIVE_INFINITY;
235
+ for (const dir of dirs) {
236
+ const pt = portPoint(bounds, dir);
237
+ const d = Math.hypot(pos.x - pt.x, pos.y - pt.y);
238
+ if (d < minDist) {
239
+ minDist = d;
240
+ best = dir;
241
+ }
242
+ }
243
+ return best;
244
+ }
245
+ /**
246
+ * Derives which port of `bounds` a waypoint exits from / enters at.
247
+ * Uses the dominant axis between the waypoint and the shape centre.
248
+ */
249
+ export function portFromWaypoint(wp, bounds) {
250
+ const cx = bounds.x + bounds.width / 2;
251
+ const cy = bounds.y + bounds.height / 2;
252
+ const dx = wp.x - cx;
253
+ const dy = wp.y - cy;
254
+ const hw = bounds.width / 2;
255
+ const hh = bounds.height / 2;
256
+ // Normalise to unit aspect ratio so thin shapes behave correctly
257
+ if (Math.abs(dx / hw) >= Math.abs(dy / hh)) {
258
+ return dx >= 0 ? "right" : "left";
259
+ }
260
+ return dy >= 0 ? "bottom" : "top";
261
+ }
262
+ /**
263
+ * Computes orthogonal waypoints connecting two shapes via explicit exit/entry
264
+ * ports. All segments are horizontal or vertical.
265
+ */
266
+ /**
267
+ * Routes orthogonal waypoints between two explicit points given their exit/entry directions.
268
+ * All segments are horizontal or vertical.
269
+ */
270
+ export function routeOrthogonal(E, srcPort, P, tgtPort) {
271
+ if (Math.hypot(E.x - P.x, E.y - P.y) < 2)
272
+ return [E, P];
273
+ const srcH = srcPort === "left" || srcPort === "right";
274
+ const tgtH = tgtPort === "left" || tgtPort === "right";
275
+ if (srcH && tgtH) {
276
+ if (Math.abs(E.y - P.y) < 2)
277
+ return [E, P];
278
+ if (srcPort === tgtPort) {
279
+ // Same-direction ports → U-route
280
+ const loopX = srcPort === "right" ? Math.max(E.x, P.x) + 50 : Math.min(E.x, P.x) - 50;
281
+ return [E, { x: loopX, y: E.y }, { x: loopX, y: P.y }, P];
282
+ }
283
+ const midX = Math.round((E.x + P.x) / 2);
284
+ return [E, { x: midX, y: E.y }, { x: midX, y: P.y }, P];
285
+ }
286
+ if (!srcH && !tgtH) {
287
+ if (Math.abs(E.x - P.x) < 2)
288
+ return [E, P];
289
+ if (srcPort === tgtPort) {
290
+ const loopY = srcPort === "bottom" ? Math.max(E.y, P.y) + 50 : Math.min(E.y, P.y) - 50;
291
+ return [E, { x: E.x, y: loopY }, { x: P.x, y: loopY }, P];
292
+ }
293
+ const midY = Math.round((E.y + P.y) / 2);
294
+ return [E, { x: E.x, y: midY }, { x: P.x, y: midY }, P];
295
+ }
296
+ if (srcH && !tgtH) {
297
+ // Horizontal exit → vertical entry: L-route
298
+ return [E, { x: P.x, y: E.y }, P];
299
+ }
300
+ // Vertical exit → horizontal entry: L-route
301
+ return [E, { x: E.x, y: P.y }, P];
302
+ }
303
+ export function computeWaypointsWithPorts(src, srcPort, tgt, tgtPort) {
304
+ return routeOrthogonal(portPoint(src, srcPort), srcPort, portPoint(tgt, tgtPort), tgtPort);
305
+ }
306
+ // ── Obstacle-avoiding routing ─────────────────────────────────────────────────
307
+ function hSegIntersectsRect(x1, x2, y, r, m) {
308
+ if (y <= r.y + m || y >= r.y + r.height - m)
309
+ return false;
310
+ return Math.max(x1, x2) > r.x + m && Math.min(x1, x2) < r.x + r.width - m;
311
+ }
312
+ function vSegIntersectsRect(y1, y2, x, r, m) {
313
+ if (x <= r.x + m || x >= r.x + r.width - m)
314
+ return false;
315
+ return Math.max(y1, y2) > r.y + m && Math.min(y1, y2) < r.y + r.height - m;
316
+ }
317
+ export function waypointsIntersectObstacles(wps, obstacles) {
318
+ const m = 2;
319
+ for (let i = 0; i < wps.length - 1; i++) {
320
+ const a = wps[i];
321
+ const b = wps[i + 1];
322
+ if (!a || !b)
323
+ continue;
324
+ const isH = Math.abs(a.y - b.y) < 1;
325
+ for (const obs of obstacles) {
326
+ if (isH ? hSegIntersectsRect(a.x, b.x, a.y, obs, m) : vSegIntersectsRect(a.y, b.y, a.x, obs, m))
327
+ return true;
328
+ }
329
+ }
330
+ return false;
331
+ }
332
+ /**
333
+ * Returns true if any intermediate waypoint (not the first or last) lies strictly
334
+ * inside the shape's bounding box. This catches routes that enter the source or
335
+ * target shape's interior before reaching the connection point — a situation that
336
+ * waypointsIntersectObstacles cannot detect because src/tgt are excluded from the
337
+ * obstacles list.
338
+ */
339
+ export function routeEntersShape(wps, shape) {
340
+ const m = 2;
341
+ for (let i = 1; i < wps.length - 1; i++) {
342
+ const wp = wps[i];
343
+ if (!wp)
344
+ continue;
345
+ if (wp.x > shape.x + m &&
346
+ wp.x < shape.x + shape.width - m &&
347
+ wp.y > shape.y + m &&
348
+ wp.y < shape.y + shape.height - m)
349
+ return true;
350
+ }
351
+ return false;
352
+ }
353
+ // Port-pair iteration order for the 16-combo search.
354
+ // Ordered to try the most visually natural routes first:
355
+ // 1. Straight-through pairs (horizontal then vertical)
356
+ // 2. L-route pairs
357
+ // 3. U-route pairs (same-direction, widest detour)
358
+ const PORT_PAIRS = [
359
+ ["right", "left"],
360
+ ["left", "right"],
361
+ ["bottom", "top"],
362
+ ["top", "bottom"],
363
+ ["right", "top"],
364
+ ["right", "bottom"],
365
+ ["left", "top"],
366
+ ["left", "bottom"],
367
+ ["bottom", "right"],
368
+ ["bottom", "left"],
369
+ ["top", "right"],
370
+ ["top", "left"],
371
+ ["right", "right"],
372
+ ["left", "left"],
373
+ ["bottom", "bottom"],
374
+ ["top", "top"],
375
+ ];
376
+ /**
377
+ * Computes waypoints between two shapes, routing around obstacle shapes and
378
+ * never passing through the source or target shape's own interior.
379
+ *
380
+ * Strategy:
381
+ * 1. Try the default (most natural) route.
382
+ * 2. Try all 16 port-pair combinations in natural-first order.
383
+ * 3. Try explicit bypass corridors above/below/left/right of each obstacle.
384
+ * 4. Fall back to the default route as an absolute last resort.
385
+ */
386
+ export function computeWaypointsAvoiding(src, tgt, obstacles) {
387
+ // A route is invalid if it hits an external obstacle OR if it routes through
388
+ // the interior of the source or target shape itself.
389
+ const isBlocked = (wps) => waypointsIntersectObstacles(wps, obstacles) ||
390
+ routeEntersShape(wps, src) ||
391
+ routeEntersShape(wps, tgt);
392
+ const defaultWps = computeWaypoints(src, tgt);
393
+ if (!isBlocked(defaultWps))
394
+ return defaultWps;
395
+ for (const [srcPort, tgtPort] of PORT_PAIRS) {
396
+ const wps = computeWaypointsWithPorts(src, srcPort, tgt, tgtPort);
397
+ if (!isBlocked(wps))
398
+ return wps;
399
+ }
400
+ // All 16 midpoint combos failed — try explicit bypass corridors around each obstacle.
401
+ // The corridor route shape is: E → {E.x, bypassY} → {P.x, bypassY} → P (Y bypasses)
402
+ // or: E → {bypassX, E.y} → {bypassX, P.y} → P (X bypasses)
403
+ const PAD = 30;
404
+ for (const obs of obstacles) {
405
+ for (const bypassY of [obs.y - PAD, obs.y + obs.height + PAD]) {
406
+ for (const [sp, tp] of PORT_PAIRS) {
407
+ const E = portPoint(src, sp);
408
+ const P = portPoint(tgt, tp);
409
+ const wps = [E, { x: E.x, y: bypassY }, { x: P.x, y: bypassY }, P];
410
+ if (!isBlocked(wps))
411
+ return wps;
412
+ }
413
+ }
414
+ for (const bypassX of [obs.x - PAD, obs.x + obs.width + PAD]) {
415
+ for (const [sp, tp] of PORT_PAIRS) {
416
+ const E = portPoint(src, sp);
417
+ const P = portPoint(tgt, tp);
418
+ const wps = [E, { x: bypassX, y: E.y }, { x: bypassX, y: P.y }, P];
419
+ if (!isBlocked(wps))
420
+ return wps;
421
+ }
422
+ }
423
+ }
424
+ return defaultWps;
425
+ }
426
+ // ── Selection bounds ──────────────────────────────────────────────────────────
427
+ /**
428
+ * Returns the bounding box that encloses all selected shapes, or null if none.
429
+ */
430
+ export function selectionBounds(shapes, ids) {
431
+ let minX = Number.POSITIVE_INFINITY;
432
+ let minY = Number.POSITIVE_INFINITY;
433
+ let maxX = Number.NEGATIVE_INFINITY;
434
+ let maxY = Number.NEGATIVE_INFINITY;
435
+ let found = false;
436
+ for (const shape of shapes) {
437
+ if (!ids.includes(shape.id))
438
+ continue;
439
+ found = true;
440
+ const { x, y, width, height } = shape.shape.bounds;
441
+ minX = Math.min(minX, x);
442
+ minY = Math.min(minY, y);
443
+ maxX = Math.max(maxX, x + width);
444
+ maxY = Math.max(maxY, y + height);
445
+ }
446
+ if (!found)
447
+ return null;
448
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
449
+ }
450
+ //# sourceMappingURL=geometry.js.map
package/dist/hud.d.ts ADDED
@@ -0,0 +1,85 @@
1
+ import type { BpmnEditor } from "./editor.js";
2
+ export interface HudOptions {
3
+ /**
4
+ * Called when the user clicks the navigate link above a call activity that
5
+ * already has a process linked. Receives the `processId` from `zeebe:calledElement`.
6
+ */
7
+ openProcess?: (processId: string) => void;
8
+ /** Returns available BPMN processes for the "Link process ▾" dropdown on call activities. */
9
+ getAvailableProcesses?: () => Array<{
10
+ id: string;
11
+ name?: string;
12
+ }>;
13
+ /** Called when the user requests a new process from the cfg toolbar. */
14
+ createProcess?: (name: string, onCreated: (id: string) => void) => void;
15
+ /** Called when the user clicks the navigate link above a business rule task. */
16
+ openDecision?: (decisionId: string) => void;
17
+ /** Returns available DMN decisions for the "Link decision ▾" dropdown on business rule tasks. */
18
+ getAvailableDecisions?: () => Array<{
19
+ id: string;
20
+ name?: string;
21
+ }>;
22
+ /** Called when the user clicks the navigate link above a user task. */
23
+ openForm?: (formId: string) => void;
24
+ /** Returns available forms for the "Link form ▾" dropdown on user tasks. */
25
+ getAvailableForms?: () => Array<{
26
+ id: string;
27
+ name?: string;
28
+ }>;
29
+ /**
30
+ * Optional raw mode toggle button (from `tabsPlugin.api.rawModeButton`).
31
+ * When provided, it is styled as a HUD button and placed in the bottom-left panel.
32
+ */
33
+ rawModeButton?: HTMLButtonElement | null;
34
+ /**
35
+ * Optional optimize button (from `createOptimizePlugin(...).button`).
36
+ * When provided, it is styled as a HUD button and placed in the action bar.
37
+ */
38
+ optimizeButton?: HTMLButtonElement | null;
39
+ /**
40
+ * Optional AI assistant button (from `createAiBridgePlugin(...).button`).
41
+ * When provided, it is styled as a HUD button and placed in the action bar.
42
+ */
43
+ aiButton?: HTMLButtonElement | null;
44
+ /**
45
+ * Optional play mode button (from `createProcessRunnerPlugin(...).playButton`).
46
+ * When provided, it is styled as a HUD button and placed in the action bar.
47
+ */
48
+ playButton?: HTMLButtonElement | null;
49
+ /**
50
+ * Optional ASCII view button (from `createAsciiViewPlugin(...).button`).
51
+ * When provided, it is styled as a HUD button and placed in the bottom-left panel.
52
+ */
53
+ asciiButton?: HTMLButtonElement | null;
54
+ /**
55
+ * Called when the user clicks "Start from scratch" on the new-diagram overlay.
56
+ * The caller should load an empty diagram into the editor.
57
+ */
58
+ onStartFromScratch?: () => void;
59
+ /**
60
+ * Called when the user clicks "Generate example" on the new-diagram overlay.
61
+ * The caller should load a sample diagram into the editor.
62
+ */
63
+ onGenerateExample?: () => void;
64
+ /**
65
+ * Called when the user clicks "Ask AI" in the contextual element toolbar or
66
+ * the new-diagram overlay.
67
+ */
68
+ onAskAi?: () => void;
69
+ /**
70
+ * Called when a new sequence flow is created from an exclusive or inclusive
71
+ * gateway. Use to focus the condition expression field in the properties panel.
72
+ */
73
+ onGatewayEdgeCreated?: (edgeId: string) => void;
74
+ /**
75
+ * Called when the user clicks "Exit" in the simulation active banner.
76
+ */
77
+ onExitSimulation?: () => void;
78
+ }
79
+ export declare function initEditorHud(editor: BpmnEditor, options?: HudOptions): {
80
+ setActive(active: boolean): void;
81
+ showOnboarding(): void;
82
+ hideOnboarding(): void;
83
+ setSimulationActive(active: boolean): void;
84
+ };
85
+ //# sourceMappingURL=hud.d.ts.map