@statelyai/flow-dom 3.1.0 → 3.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.
@@ -1,8 +1,64 @@
1
- import { FlowColorMode, FlowEngine, FlowInputEffects, FlowInputEvent, FlowStore, FlowStoreContext, Rect } from "@statelyai/flow";
1
+ import { EdgePathData, FlowColorMode, FlowController, FlowEngine, FlowEntityChangeNotifierOptions, FlowInputEffects, FlowInputEvent, FlowResult, FlowStore, FlowStoreContext, Point, Rect } from "@statelyai/flow";
2
2
  export * from "@statelyai/flow";
3
-
3
+ //#region src/clipboard.d.ts
4
+ declare const FLOW_CONTENT_MIME = "application/vnd.stately.flow+json";
5
+ type ClipboardTransport = {
6
+ writeText?: (value: string) => Promise<void>;
7
+ readText?: () => Promise<string>;
8
+ write?: (items: unknown[]) => Promise<void>;
9
+ read?: () => Promise<Array<{
10
+ types: readonly string[];
11
+ getType(type: string): Promise<Blob>;
12
+ }>>;
13
+ };
14
+ type FlowClipboard = {
15
+ copy: (targets?: Iterable<string>) => Promise<boolean>;
16
+ cut: (targets?: Iterable<string>) => Promise<boolean>;
17
+ paste: (options?: {
18
+ point?: Point;
19
+ preservePosition?: boolean;
20
+ }) => Promise<FlowResult<unknown, Error>>;
21
+ };
22
+ declare function createFlowClipboard(flow: FlowController, options?: {
23
+ clipboard?: ClipboardTransport;
24
+ ClipboardItem?: new (items: Record<string, Blob>) => unknown;
25
+ }): FlowClipboard;
26
+ //#endregion
27
+ //#region src/exportImage.d.ts
28
+ type FlowImageExportOptions = {
29
+ format?: 'svg' | 'png' | 'webp';
30
+ region?: 'graph' | 'viewport' | 'selection' | Rect;
31
+ padding?: number;
32
+ scale?: number;
33
+ pixelRatio?: number;
34
+ background?: string;
35
+ styles?: 'computed' | 'none';
36
+ include?: (element: Element) => boolean;
37
+ signal?: {
38
+ readonly aborted: boolean;
39
+ addEventListener(type: 'abort', listener: () => void, options?: {
40
+ once?: boolean;
41
+ }): void;
42
+ };
43
+ };
44
+ type FlowImageExportResult = {
45
+ blob: Blob;
46
+ width: number;
47
+ height: number;
48
+ };
49
+ type FlowView = {
50
+ exportImage: (options?: FlowImageExportOptions) => Promise<FlowImageExportResult>;
51
+ refreshMeasurements: (targets?: Iterable<string | Element>) => void;
52
+ };
53
+ declare function createFlowView(container: HTMLElement, options?: {
54
+ measurements?: MeasurementObserver;
55
+ }): FlowView;
56
+ declare function exportFlowImage(container: HTMLElement, options?: FlowImageExportOptions): Promise<FlowImageExportResult>;
57
+ //#endregion
4
58
  //#region src/culling.d.ts
5
-
59
+ type ComputeRenderableIdsOptions = {
60
+ overscan?: number;
61
+ };
6
62
  /**
7
63
  * Node/edge ids worth mounting, or `null` meaning "render everything" — either
8
64
  * because culling is disabled, the viewport hasn't been measured yet, or the
@@ -40,7 +96,7 @@ declare function renderableIdsEqual(a: RenderableIds, b: RenderableIds): boolean
40
96
  * `getGraphBounds`/`getRenderableEntities` from the live store) — which holds
41
97
  * when called inside a store-subscription selector.
42
98
  */
43
- declare function computeRenderableIds(engine: FlowEngine, context: FlowStoreContext, enabled: boolean | undefined): RenderableIds;
99
+ declare function computeRenderableIds(engine: FlowEngine, context: FlowStoreContext, enabled: boolean | undefined, options?: ComputeRenderableIdsOptions): RenderableIds;
44
100
  //#endregion
45
101
  //#region src/render-styles.d.ts
46
102
  type NodePositionStyle = {
@@ -83,6 +139,34 @@ type ViewportStyle = {
83
139
  */
84
140
  declare function viewportStyle(): ViewportStyle;
85
141
  //#endregion
142
+ //#region src/entity-geometry.d.ts
143
+ type EntityRectTarget = {
144
+ id: string;
145
+ getRect: () => Rect | null;
146
+ autoSize?: boolean;
147
+ };
148
+ type EntityEdgeTarget = {
149
+ id: string;
150
+ getPathData: () => EdgePathData | null;
151
+ };
152
+ type EntityGeometryObserverOptions = FlowEntityChangeNotifierOptions & {
153
+ requestFrame?: (callback: FrameRequestCallback) => number;
154
+ cancelFrame?: (handle: number) => void;
155
+ };
156
+ type EntityGeometryObserver = {
157
+ observeRect(element: HTMLElement, target: EntityRectTarget): () => void;
158
+ observeEdge(element: SVGElement, target: EntityEdgeTarget): () => void;
159
+ /** Flush pending writes synchronously. Primarily useful for deterministic hosts/tests. */
160
+ flush(): void;
161
+ disconnect(): void;
162
+ };
163
+ /**
164
+ * Batches transient entity geometry into imperative DOM writes. One core
165
+ * entity-change notifier keeps dispatch O(changed); framework adapters retain
166
+ * ownership of content, selection, and other durable rendering.
167
+ */
168
+ declare function createEntityGeometryObserver(store: FlowStore, options?: EntityGeometryObserverOptions): EntityGeometryObserver;
169
+ //#endregion
86
170
  //#region src/index.d.ts
87
171
  type ResolvedFlowColorMode = "light" | "dark";
88
172
  declare function resolveFlowColorMode(colorMode: FlowColorMode, targetWindow?: Pick<Window, "matchMedia"> | undefined): ResolvedFlowColorMode;
@@ -99,17 +183,25 @@ type MeasurementObserver = {
99
183
  observe(element: Element, target: MeasurementTarget): void;
100
184
  /** Re-measure now (position-only changes don't fire ResizeObserver). */
101
185
  measure(element: Element): void;
186
+ refresh(targets?: Iterable<string | Element>): void;
102
187
  unobserve(element: Element): void;
103
188
  disconnect(): void;
104
189
  };
105
190
  type MeasurementObserverOptions = {
106
191
  container?: Element | (() => Element | null | undefined) | null;
192
+ equalityThreshold?: number;
107
193
  };
108
194
  declare function createMeasurementObserver(store: FlowStore, options?: MeasurementObserverOptions): MeasurementObserver;
109
195
  /** The container element registered for this store via `attachContainer`. */
110
196
  declare function getAttachedContainer(store: FlowStore): Element | undefined;
197
+ declare function clientToCanvas(store: FlowStore, point: Point, container?: Element | null | undefined): Point;
198
+ declare function canvasToClient(store: FlowStore, point: Point, container?: Element | null | undefined): Point;
111
199
  declare function attachContainer(store: FlowStore, element: Element): () => void;
112
- declare function attachViewportTransform(store: FlowStore, element: HTMLElement): () => void;
200
+ type ViewportTransformOptions = {
201
+ /** Idle delay (ms) before dropping the GPU-layer hint after interaction stops. */
202
+ promoteIdleMs?: number;
203
+ };
204
+ declare function attachViewportTransform(store: FlowStore, element: HTMLElement, options?: ViewportTransformOptions): () => void;
113
205
  type AttachInputOptions = {
114
206
  container: HTMLElement;
115
207
  store: FlowStore;
@@ -117,6 +209,11 @@ type AttachInputOptions = {
117
209
  send(event: FlowInputEvent): FlowInputEffects;
118
210
  cancelSession(): void;
119
211
  };
212
+ /**
213
+ * Where keyboard events are listened for. Defaults to the flow container so
214
+ * multiple flow instances do not all react to global Delete/undo/arrow keys.
215
+ */
216
+ keyboardTarget?: "window" | "container" | HTMLElement;
120
217
  /**
121
218
  * Auto-pan policy, evaluated once per animation frame while a gesture
122
219
  * session holds the pointer near the container edge. Pass
@@ -135,4 +232,4 @@ type AttachInputOptions = {
135
232
  };
136
233
  declare function attachInput(options: AttachInputOptions): () => void;
137
234
  //#endregion
138
- export { AttachInputOptions, MeasurementObserver, MeasurementObserverOptions, MeasurementTarget, type NodePositionStyle, type RenderableIds, ResolvedFlowColorMode, type ViewportStyle, applyFlowColorMode, attachContainer, attachInput, attachViewportTransform, computeRenderableIds, createMeasurementObserver, getAttachedContainer, nodePositionStyle, renderableIdsEqual, resolveFlowColorMode, viewportStyle };
235
+ export { AttachInputOptions, type ClipboardTransport, type ComputeRenderableIdsOptions, type EntityEdgeTarget, type EntityGeometryObserver, type EntityGeometryObserverOptions, type EntityRectTarget, FLOW_CONTENT_MIME, type FlowClipboard, type FlowImageExportOptions, type FlowImageExportResult, type FlowView, MeasurementObserver, MeasurementObserverOptions, MeasurementTarget, type NodePositionStyle, type RenderableIds, ResolvedFlowColorMode, type ViewportStyle, ViewportTransformOptions, applyFlowColorMode, attachContainer, attachInput, attachViewportTransform, canvasToClient, clientToCanvas, computeRenderableIds, createEntityGeometryObserver, createFlowClipboard, createFlowView, createMeasurementObserver, exportFlowImage, getAttachedContainer, nodePositionStyle, renderableIdsEqual, resolveFlowColorMode, viewportStyle };
package/dist/esm/index.js CHANGED
@@ -1,4 +1,237 @@
1
+ import { createEntityChangeNotifier, pathToSVG } from "@statelyai/flow";
1
2
  export * from "@statelyai/flow";
3
+ //#region src/clipboard.ts
4
+ const FLOW_CONTENT_MIME = "application/vnd.stately.flow+json";
5
+ function createFlowClipboard(flow, options = {}) {
6
+ const clipboard = options.clipboard ?? globalThis.navigator?.clipboard;
7
+ let fallback = null;
8
+ async function write(content) {
9
+ fallback = content;
10
+ const json = JSON.stringify(content);
11
+ try {
12
+ if (clipboard?.write && options.ClipboardItem) {
13
+ const html = `<div data-stately-flow>${escapeHtml(json)}</div>`;
14
+ await clipboard.write([new options.ClipboardItem({
15
+ [FLOW_CONTENT_MIME]: new Blob([json], { type: FLOW_CONTENT_MIME }),
16
+ "text/plain": new Blob([json], { type: "text/plain" }),
17
+ "text/html": new Blob([html], { type: "text/html" })
18
+ })]);
19
+ } else if (clipboard?.writeText) await clipboard.writeText(json);
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+ async function read() {
26
+ let readSucceeded = false;
27
+ try {
28
+ if (clipboard?.read) {
29
+ const items = await clipboard.read();
30
+ readSucceeded = true;
31
+ for (const item of items) {
32
+ const type = item.types.includes("application/vnd.stately.flow+json") ? FLOW_CONTENT_MIME : item.types.includes("text/plain") ? "text/plain" : null;
33
+ if (type) return parseJson(await (await item.getType(type)).text());
34
+ }
35
+ }
36
+ if (clipboard?.readText) {
37
+ const text = await clipboard.readText();
38
+ readSucceeded = true;
39
+ if (text) return parseJson(text);
40
+ }
41
+ } catch {
42
+ if (readSucceeded) return null;
43
+ return fallback;
44
+ }
45
+ return readSucceeded ? null : fallback;
46
+ }
47
+ function canRun(command, targets) {
48
+ if (!targets) return flow.can(command);
49
+ return targets.length > 0 && targets.every((id) => flow.can(command, { id }));
50
+ }
51
+ return {
52
+ async copy(targets) {
53
+ const ids = targets ? [...targets] : void 0;
54
+ if (!canRun("copy", ids)) return false;
55
+ const content = flow.content.extract(ids);
56
+ if (!content) return false;
57
+ await write(content);
58
+ return true;
59
+ },
60
+ async cut(targets) {
61
+ const ids = targets ? [...targets] : [...flow.getSnapshot().selection];
62
+ if (!canRun("cut", ids)) return false;
63
+ const content = flow.content.extract(ids);
64
+ if (!content) return false;
65
+ if (!await write(content)) return false;
66
+ flow.transaction((draft) => {
67
+ for (const id of ids) if (!draft.edges.delete(id)) draft.nodes.delete(id);
68
+ }, { origin: "clipboard.cut" });
69
+ return true;
70
+ },
71
+ async paste(insertOptions) {
72
+ const content = await read();
73
+ if (!content) return {
74
+ ok: false,
75
+ error: /* @__PURE__ */ new Error("Clipboard does not contain Flow content.")
76
+ };
77
+ return flow.content.insert(content, insertOptions);
78
+ }
79
+ };
80
+ }
81
+ function parseJson(value) {
82
+ try {
83
+ return JSON.parse(value);
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+ function escapeHtml(value) {
89
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
90
+ }
91
+ //#endregion
92
+ //#region src/exportImage.ts
93
+ function createFlowView(container, options = {}) {
94
+ return {
95
+ exportImage: (options) => exportFlowImage(container, options),
96
+ refreshMeasurements: (targets) => options.measurements?.refresh(targets)
97
+ };
98
+ }
99
+ async function exportFlowImage(container, options = {}) {
100
+ throwIfAborted(options.signal);
101
+ const win = container.ownerDocument.defaultView;
102
+ if (!win) throw new Error("Flow export requires a window.");
103
+ await nextFrame(win, options.signal);
104
+ const format = options.format ?? "svg";
105
+ const padding = Math.max(0, options.padding ?? 0);
106
+ const scale = Math.max(.01, options.scale ?? 1);
107
+ const pixelRatio = Math.max(.01, options.pixelRatio ?? win.devicePixelRatio ?? 1);
108
+ const region = resolveRegion(container, options.region ?? "graph");
109
+ const width = Math.max(1, Math.ceil((region.width + padding * 2) * scale));
110
+ const height = Math.max(1, Math.ceil((region.height + padding * 2) * scale));
111
+ const clone = container.cloneNode(true);
112
+ if ((options.styles ?? "computed") === "computed") inlineComputedStyles(win, container, clone);
113
+ if (options.include) {
114
+ const originals = [container, ...Array.from(container.querySelectorAll("*"))];
115
+ const clones = [clone, ...Array.from(clone.querySelectorAll("*"))];
116
+ for (let index = originals.length - 1; index >= 0; index--) if (!options.include(originals[index])) clones[index]?.remove();
117
+ }
118
+ const markup = new win.XMLSerializer().serializeToString(clone);
119
+ const background = options.background ? `<rect width="100%" height="100%" fill="${escapeXml(options.background)}"/>` : "";
120
+ const containerRect = container.getBoundingClientRect();
121
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="${region.x - padding} ${region.y - padding} ${region.width + padding * 2} ${region.height + padding * 2}">${background}<foreignObject x="0" y="0" width="${containerRect.width}" height="${containerRect.height}">${markup}</foreignObject></svg>`;
122
+ const svgBlob = new Blob([svg], { type: "image/svg+xml" });
123
+ if (format === "svg") return {
124
+ blob: svgBlob,
125
+ width,
126
+ height
127
+ };
128
+ throwIfAborted(options.signal);
129
+ const image = await loadImage(win, await blobDataUrl(win, svgBlob, options.signal), options.signal);
130
+ const canvas = container.ownerDocument.createElement("canvas");
131
+ canvas.width = Math.ceil(width * pixelRatio);
132
+ canvas.height = Math.ceil(height * pixelRatio);
133
+ const context = canvas.getContext("2d");
134
+ if (!context) throw new Error("Canvas 2D context is unavailable.");
135
+ context.scale(pixelRatio, pixelRatio);
136
+ context.drawImage(image, 0, 0, width, height);
137
+ return {
138
+ blob: await canvasBlob(canvas, `image/${format}`, options.signal),
139
+ width,
140
+ height
141
+ };
142
+ }
143
+ function inlineComputedStyles(win, originalRoot, cloneRoot) {
144
+ const originals = [originalRoot, ...Array.from(originalRoot.querySelectorAll("*"))];
145
+ const clones = [cloneRoot, ...Array.from(cloneRoot.querySelectorAll("*"))];
146
+ for (let index = 0; index < originals.length; index++) {
147
+ const clone = clones[index];
148
+ if (!clone?.style) continue;
149
+ const computed = win.getComputedStyle(originals[index]);
150
+ for (let propertyIndex = 0; propertyIndex < computed.length; propertyIndex++) {
151
+ const property = computed.item(propertyIndex);
152
+ clone.style.setProperty(property, computed.getPropertyValue(property), computed.getPropertyPriority(property));
153
+ }
154
+ }
155
+ }
156
+ function blobDataUrl(win, blob, signal) {
157
+ return new Promise((resolve, reject) => {
158
+ const Reader = win.FileReader;
159
+ const reader = new Reader();
160
+ reader.onload = () => resolve(String(reader.result));
161
+ reader.onerror = () => reject(reader.error ?? /* @__PURE__ */ new Error("Unable to encode Flow SVG."));
162
+ signal?.addEventListener("abort", () => {
163
+ reader.abort();
164
+ reject(abortError());
165
+ }, { once: true });
166
+ reader.readAsDataURL(blob);
167
+ });
168
+ }
169
+ function resolveRegion(container, region) {
170
+ if (typeof region === "object") return region;
171
+ const selector = region === "selection" ? "[data-flow-selected=\"true\"], [aria-selected=\"true\"]" : region === "graph" ? "[data-entity-id]" : null;
172
+ const elements = selector ? Array.from(container.querySelectorAll(selector)) : [container];
173
+ const containerRect = container.getBoundingClientRect();
174
+ if (elements.length === 0) return {
175
+ x: 0,
176
+ y: 0,
177
+ width: containerRect.width,
178
+ height: containerRect.height
179
+ };
180
+ const rects = elements.map((element) => element.getBoundingClientRect());
181
+ const left = Math.min(...rects.map((rect) => rect.left));
182
+ const top = Math.min(...rects.map((rect) => rect.top));
183
+ const right = Math.max(...rects.map((rect) => rect.right));
184
+ const bottom = Math.max(...rects.map((rect) => rect.bottom));
185
+ return {
186
+ x: left - containerRect.left,
187
+ y: top - containerRect.top,
188
+ width: right - left,
189
+ height: bottom - top
190
+ };
191
+ }
192
+ function nextFrame(win, signal) {
193
+ return new Promise((resolve, reject) => {
194
+ const id = win.requestAnimationFrame(() => {
195
+ if (signal?.aborted) reject(abortError());
196
+ else resolve();
197
+ });
198
+ signal?.addEventListener("abort", () => {
199
+ win.cancelAnimationFrame(id);
200
+ reject(abortError());
201
+ }, { once: true });
202
+ });
203
+ }
204
+ function loadImage(win, url, signal) {
205
+ return new Promise((resolve, reject) => {
206
+ const ImageCtor = win.Image;
207
+ const image = new ImageCtor();
208
+ image.onload = () => resolve(image);
209
+ image.onerror = () => reject(/* @__PURE__ */ new Error("Unable to render Flow SVG."));
210
+ signal?.addEventListener("abort", () => reject(abortError()), { once: true });
211
+ image.src = url;
212
+ });
213
+ }
214
+ function canvasBlob(canvas, type, signal) {
215
+ return new Promise((resolve, reject) => {
216
+ canvas.toBlob((blob) => {
217
+ if (signal?.aborted) reject(abortError());
218
+ else if (blob) resolve(blob);
219
+ else reject(/* @__PURE__ */ new Error(`Unable to encode ${type}.`));
220
+ }, type);
221
+ });
222
+ }
223
+ function throwIfAborted(signal) {
224
+ if (signal?.aborted) throw abortError();
225
+ }
226
+ function abortError() {
227
+ const error = /* @__PURE__ */ new Error("The operation was aborted.");
228
+ error.name = "AbortError";
229
+ return error;
230
+ }
231
+ function escapeXml(value) {
232
+ return value.replaceAll("&", "&amp;").replaceAll("\"", "&quot;").replaceAll("<", "&lt;");
233
+ }
234
+ //#endregion
2
235
  //#region src/culling.ts
3
236
  /** Overscan (canvas px) added around the viewport so entities just off-screen
4
237
  * stay mounted and don't pop in/out at the edges. */
@@ -37,20 +270,21 @@ function renderableIdsEqual(a, b) {
37
270
  * `getGraphBounds`/`getRenderableEntities` from the live store) — which holds
38
271
  * when called inside a store-subscription selector.
39
272
  */
40
- function computeRenderableIds(engine, context, enabled) {
273
+ function computeRenderableIds(engine, context, enabled, options = {}) {
41
274
  if (!enabled) return null;
275
+ const overscan = options.overscan ?? CULL_OVERSCAN;
42
276
  const viewportSize = context.viewportSize;
43
277
  if (viewportSize.width === 0 || viewportSize.height === 0) return null;
44
278
  const viewport = context.viewport;
45
- const left = -viewport.x / viewport.zoom - CULL_OVERSCAN;
46
- const top = -viewport.y / viewport.zoom - CULL_OVERSCAN;
47
- const right = left + viewportSize.width / viewport.zoom + CULL_OVERSCAN * 2;
48
- const bottom = top + viewportSize.height / viewport.zoom + CULL_OVERSCAN * 2;
279
+ const left = -viewport.x / viewport.zoom - overscan;
280
+ const top = -viewport.y / viewport.zoom - overscan;
281
+ const right = left + viewportSize.width / viewport.zoom + overscan * 2;
282
+ const bottom = top + viewportSize.height / viewport.zoom + overscan * 2;
49
283
  const bounds = engine.getGraphBounds();
50
284
  if (left <= bounds.x && top <= bounds.y && right >= bounds.x + bounds.width && bottom >= bounds.y + bounds.height) return null;
51
285
  const renderable = engine.getRenderableEntities({
52
286
  cull: "viewport",
53
- overscan: CULL_OVERSCAN
287
+ overscan
54
288
  });
55
289
  return {
56
290
  nodeIds: new Set(renderable.nodes.map((node) => node.id)),
@@ -98,6 +332,107 @@ function viewportStyle() {
98
332
  };
99
333
  }
100
334
  //#endregion
335
+ //#region src/entity-geometry.ts
336
+ /**
337
+ * Batches transient entity geometry into imperative DOM writes. One core
338
+ * entity-change notifier keeps dispatch O(changed); framework adapters retain
339
+ * ownership of content, selection, and other durable rendering.
340
+ */
341
+ function createEntityGeometryObserver(store, options = {}) {
342
+ const requestFrame = options.requestFrame ?? ((callback) => requestAnimationFrame(callback));
343
+ const cancelFrame = options.cancelFrame ?? ((handle) => cancelAnimationFrame(handle));
344
+ const createNotifier = () => createEntityChangeNotifier(store, { getEdgeDependents: options.getEdgeDependents });
345
+ let notifier = createNotifier();
346
+ const getNotifier = () => notifier ??= createNotifier();
347
+ const observations = /* @__PURE__ */ new Set();
348
+ const pending = /* @__PURE__ */ new Set();
349
+ let frame = null;
350
+ const flush = () => {
351
+ if (frame !== null) {
352
+ cancelFrame(frame);
353
+ frame = null;
354
+ }
355
+ const current = [...pending];
356
+ pending.clear();
357
+ for (const observation of current) observation.write();
358
+ };
359
+ const schedule = (observation) => {
360
+ pending.add(observation);
361
+ if (frame === null) frame = requestFrame(() => {
362
+ frame = null;
363
+ const current = [...pending];
364
+ pending.clear();
365
+ for (const pendingObservation of current) pendingObservation.write();
366
+ });
367
+ };
368
+ const observe = (id, write) => {
369
+ const observation = {
370
+ write,
371
+ unsubscribe: () => {}
372
+ };
373
+ observation.unsubscribe = getNotifier().subscribe(id, () => schedule(observation));
374
+ observations.add(observation);
375
+ write();
376
+ return () => {
377
+ observation.unsubscribe();
378
+ observations.delete(observation);
379
+ pending.delete(observation);
380
+ };
381
+ };
382
+ return {
383
+ observeRect(element, target) {
384
+ return observe(target.id, () => {
385
+ const rect = target.getRect();
386
+ if (!rect) return;
387
+ const context = store.getSnapshot().context;
388
+ const dragging = context.canvasState === "dragging" && context.selection.has(target.id);
389
+ const resizing = context.resizingEntityId === target.id;
390
+ const layoutAnimating = context.layoutAnimation.status === "running" && context.layoutAnimation.offsets.has(target.id);
391
+ element.style.transform = `translate(${rect.x}px, ${rect.y}px)`;
392
+ if (!target.autoSize || resizing) {
393
+ element.style.width = `${rect.width}px`;
394
+ element.style.height = `${rect.height}px`;
395
+ }
396
+ if (dragging || resizing || layoutAnimating) element.style.willChange = resizing ? "transform, width, height" : "transform";
397
+ else element.style.removeProperty("will-change");
398
+ });
399
+ },
400
+ observeEdge(element, target) {
401
+ return observe(target.id, () => {
402
+ const pathData = target.getPathData();
403
+ if (!pathData) return;
404
+ element.querySelectorAll("[data-flow-edge-segment]").forEach((path) => {
405
+ const index = Number(path.dataset.flowEdgeSegment);
406
+ const segment = pathData.segments[index];
407
+ if (segment) path.setAttribute("d", pathToSVG(segment));
408
+ });
409
+ const first = pathData.segments[0]?.points[0];
410
+ const last = pathData.segments.at(-1)?.points.at(-1);
411
+ if (first) {
412
+ const source = element.querySelector("[data-flow-edge-endpoint=\"source\"]");
413
+ source?.setAttribute("cx", String(first.x));
414
+ source?.setAttribute("cy", String(first.y));
415
+ }
416
+ if (last) {
417
+ const targetElement = element.querySelector("[data-flow-edge-endpoint=\"target\"]");
418
+ targetElement?.setAttribute("cx", String(last.x));
419
+ targetElement?.setAttribute("cy", String(last.y));
420
+ }
421
+ });
422
+ },
423
+ flush,
424
+ disconnect() {
425
+ if (frame !== null) cancelFrame(frame);
426
+ frame = null;
427
+ pending.clear();
428
+ for (const observation of observations) observation.unsubscribe();
429
+ observations.clear();
430
+ notifier?.destroy();
431
+ notifier = null;
432
+ }
433
+ };
434
+ }
435
+ //#endregion
101
436
  //#region src/index.ts
102
437
  function resolveFlowColorMode(colorMode, targetWindow = typeof window === "undefined" ? void 0 : window) {
103
438
  if (colorMode !== "system") return colorMode;
@@ -112,6 +447,7 @@ function applyFlowColorMode(element, colorMode, targetWindow = element.ownerDocu
112
447
  }
113
448
  const containers = /* @__PURE__ */ new WeakMap();
114
449
  function createMeasurementObserver(store, options = {}) {
450
+ const equalityThreshold = options.equalityThreshold ?? .5;
115
451
  const elementMap = /* @__PURE__ */ new Map();
116
452
  let pendingMeasurements = [];
117
453
  let rafId = null;
@@ -138,14 +474,14 @@ function createMeasurementObserver(store, options = {}) {
138
474
  };
139
475
  const id = "portName" in target ? `${target.nodeId}:${target.portName}` : "nodeId" in target ? target.nodeId : target.edgeId;
140
476
  const existing = store.getSnapshot().context.measurements.get(id);
141
- if (existing && Math.abs(existing.x - measurement.x) < .5 && Math.abs(existing.y - measurement.y) < .5 && Math.abs(existing.width - measurement.width) < .5 && Math.abs(existing.height - measurement.height) < .5) return;
477
+ if (existing && Math.abs(existing.x - measurement.x) < equalityThreshold && Math.abs(existing.y - measurement.y) < equalityThreshold && Math.abs(existing.width - measurement.width) < equalityThreshold && Math.abs(existing.height - measurement.height) < equalityThreshold) return;
142
478
  pendingMeasurements.push({
143
479
  id,
144
480
  rect: measurement
145
481
  });
146
482
  scheduleFlush();
147
483
  }
148
- const observer = new ResizeObserver((entries) => {
484
+ const handleResize = (entries) => {
149
485
  for (const entry of entries) {
150
486
  const target = elementMap.get(entry.target);
151
487
  if (!target) continue;
@@ -159,11 +495,17 @@ function createMeasurementObserver(store, options = {}) {
159
495
  } : void 0;
160
496
  pushMeasurement(entry.target, target, layoutSize);
161
497
  }
162
- });
498
+ };
499
+ let observer = null;
500
+ function getObserver(element) {
501
+ if (observer) return observer;
502
+ observer = new ((element.ownerDocument.defaultView?.ResizeObserver) ?? ResizeObserver)(handleResize);
503
+ return observer;
504
+ }
163
505
  return {
164
506
  observe(element, target) {
165
507
  elementMap.set(element, target);
166
- observer.observe(element);
508
+ getObserver(element).observe(element);
167
509
  },
168
510
  /**
169
511
  * Re-measure an observed element immediately. ResizeObserver only fires
@@ -174,13 +516,22 @@ function createMeasurementObserver(store, options = {}) {
174
516
  const target = elementMap.get(element);
175
517
  if (target) pushMeasurement(element, target);
176
518
  },
519
+ refresh(targets) {
520
+ const requested = targets ? new Set(targets) : null;
521
+ for (const [element, target] of elementMap) {
522
+ const id = "portName" in target ? `${target.nodeId}:${target.portName}` : "nodeId" in target ? target.nodeId : target.edgeId;
523
+ if (requested && !requested.has(element) && !requested.has(id)) continue;
524
+ pushMeasurement(element, target);
525
+ }
526
+ },
177
527
  unobserve(element) {
178
528
  elementMap.delete(element);
179
- observer.unobserve(element);
529
+ observer?.unobserve(element);
180
530
  },
181
531
  disconnect() {
182
532
  elementMap.clear();
183
- observer.disconnect();
533
+ observer?.disconnect();
534
+ observer = null;
184
535
  if (rafId !== null) {
185
536
  cancelAnimationFrame(rafId);
186
537
  rafId = null;
@@ -196,6 +547,23 @@ function getContainer(container) {
196
547
  function getAttachedContainer(store) {
197
548
  return containers.get(store);
198
549
  }
550
+ function clientToCanvas(store, point, container = containers.get(store)) {
551
+ const rect = container?.getBoundingClientRect();
552
+ const viewport = store.getSnapshot().context.viewport;
553
+ const zoom = viewport.zoom || 1;
554
+ return {
555
+ x: (point.x - (rect?.left ?? 0) - viewport.x) / zoom,
556
+ y: (point.y - (rect?.top ?? 0) - viewport.y) / zoom
557
+ };
558
+ }
559
+ function canvasToClient(store, point, container = containers.get(store)) {
560
+ const rect = container?.getBoundingClientRect();
561
+ const viewport = store.getSnapshot().context.viewport;
562
+ return {
563
+ x: point.x * viewport.zoom + viewport.x + (rect?.left ?? 0),
564
+ y: point.y * viewport.zoom + viewport.y + (rect?.top ?? 0)
565
+ };
566
+ }
199
567
  function attachContainer(store, element) {
200
568
  containers.set(store, element);
201
569
  const update = () => {
@@ -206,21 +574,22 @@ function attachContainer(store, element) {
206
574
  });
207
575
  };
208
576
  update();
209
- const observer = new ResizeObserver(update);
577
+ const observer = new ((element.ownerDocument.defaultView?.ResizeObserver) ?? ResizeObserver)(update);
210
578
  observer.observe(element);
211
579
  return () => {
212
580
  if (containers.get(store) === element) containers.delete(store);
213
581
  observer.disconnect();
214
582
  };
215
583
  }
216
- /** Idle delay (ms) before dropping the GPU-layer hint after interaction stops. */
217
- const VIEWPORT_PROMOTE_IDLE_MS = 200;
218
- function attachViewportTransform(store, element) {
584
+ function attachViewportTransform(store, element, options = {}) {
585
+ const promoteIdleMs = options.promoteIdleMs ?? 200;
219
586
  let prevViewport = store.getSnapshot().context.viewport;
220
587
  let prevState = store.getSnapshot().context.canvasState;
221
588
  let idle;
222
589
  const writeTransform = (viewport) => {
223
590
  element.style.transform = `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`;
591
+ element.style.setProperty("--flow-zoom", String(viewport.zoom));
592
+ element.style.setProperty("--flow-inverse-zoom", String(1 / viewport.zoom));
224
593
  };
225
594
  const promote = () => {
226
595
  element.style.willChange = "transform";
@@ -230,7 +599,7 @@ function attachViewportTransform(store, element) {
230
599
  if (idle !== void 0) clearTimeout(idle);
231
600
  idle = setTimeout(() => {
232
601
  element.style.willChange = "auto";
233
- }, VIEWPORT_PROMOTE_IDLE_MS);
602
+ }, promoteIdleMs);
234
603
  };
235
604
  writeTransform(prevViewport);
236
605
  if (prevState !== "idle") promote();
@@ -257,9 +626,20 @@ function attachInput(options) {
257
626
  const { container, store, runtime } = options;
258
627
  const doc = container.ownerDocument;
259
628
  const win = doc.defaultView;
629
+ const ElementCtor = win?.Element;
630
+ const HTMLElementCtor = win?.HTMLElement;
631
+ const keyboardTarget = options.keyboardTarget ?? "container";
632
+ const keyboardElement = keyboardTarget === "window" ? win : keyboardTarget === "container" ? container : keyboardTarget;
260
633
  let capturedPointerId = null;
261
634
  let lastClient = null;
262
635
  let autoPanFrame = null;
636
+ const activeTouchPointers = /* @__PURE__ */ new Map();
637
+ let pinchActive = false;
638
+ const DOUBLE_CLICK_MS = 500;
639
+ const DOUBLE_CLICK_DISTANCE = 4;
640
+ let lastClickDown = null;
641
+ let clickCount = 0;
642
+ let lastPinch = null;
263
643
  function stopAutoPan() {
264
644
  if (autoPanFrame !== null) {
265
645
  win?.cancelAnimationFrame(autoPanFrame);
@@ -305,6 +685,18 @@ function attachInput(options) {
305
685
  alt: event.altKey
306
686
  };
307
687
  }
688
+ function computeClickDetail(event) {
689
+ if (event.pointerType === "touch") return event.detail || 0;
690
+ const now = event.timeStamp || (win ? win.performance.now() : Date.now());
691
+ if (lastClickDown && now - lastClickDown.time <= DOUBLE_CLICK_MS && Math.abs(event.clientX - lastClickDown.x) <= DOUBLE_CLICK_DISTANCE && Math.abs(event.clientY - lastClickDown.y) <= DOUBLE_CLICK_DISTANCE) clickCount += 1;
692
+ else clickCount = 1;
693
+ lastClickDown = {
694
+ time: now,
695
+ x: event.clientX,
696
+ y: event.clientY
697
+ };
698
+ return clickCount;
699
+ }
308
700
  function toPoints(event) {
309
701
  const rect = container.getBoundingClientRect();
310
702
  const screenPoint = {
@@ -322,24 +714,28 @@ function attachInput(options) {
322
714
  };
323
715
  }
324
716
  function resolveTarget(event) {
325
- const element = capturedPointerId !== null ? doc.elementFromPoint(event.clientX, event.clientY) : event.target instanceof Element ? event.target : null;
717
+ const element = capturedPointerId !== null ? doc.elementFromPoint(event.clientX, event.clientY) : ElementCtor && event.target instanceof ElementCtor ? event.target : null;
326
718
  if (!element) return { kind: "none" };
719
+ if (!container.contains(element)) return { kind: "none" };
720
+ const owningRoot = element.closest("[data-flow-root]");
721
+ if (owningRoot && owningRoot !== container) return { kind: "none" };
327
722
  const portElement = element.closest("[data-flow-port-name]");
328
723
  if (portElement?.dataset.flowPortName) return {
329
724
  kind: "port",
330
725
  nodeId: portElement.dataset.flowNodeId ?? "",
331
726
  portName: portElement.dataset.flowPortName
332
727
  };
333
- if (element.closest("[data-flow-interactive]")) return { kind: "none" };
728
+ if (element.closest("[data-flow-interactive], .nodrag, input, textarea, select, button, a[href], [contenteditable]:not([contenteditable=\"false\"])")) return { kind: "none" };
334
729
  const entityElement = element.closest("[data-entity-id]");
335
730
  if (entityElement?.dataset.entityId) {
336
731
  const isLabel = element.closest("[data-flow-edge-label]") !== null;
732
+ const part = element.closest("[data-flow-part]")?.dataset.flowPart ?? (isLabel ? "label" : void 0);
337
733
  const entityHasDragHandle = entityElement.querySelector("[data-flow-drag-handle]") !== null;
338
734
  const pressedDragHandle = entityHasDragHandle && element.closest("[data-flow-drag-handle]") !== null;
339
735
  return {
340
736
  kind: "entity",
341
737
  id: entityElement.dataset.entityId,
342
- ...isLabel ? { part: "label" } : {},
738
+ ...part ? { part } : {},
343
739
  ...entityHasDragHandle ? {
344
740
  entityHasDragHandle,
345
741
  pressedDragHandle
@@ -361,7 +757,9 @@ function attachInput(options) {
361
757
  const effects = runtime.send({
362
758
  kind,
363
759
  pointerId: info.pointerId,
760
+ pointerType: info.pointerType ?? "mouse",
364
761
  button: info.button,
762
+ detail: info.detail,
365
763
  point,
366
764
  screenPoint,
367
765
  target: resolveTarget({
@@ -388,16 +786,116 @@ function attachInput(options) {
388
786
  }
389
787
  return effects;
390
788
  }
789
+ function touchPoints() {
790
+ return [...activeTouchPointers.values()];
791
+ }
792
+ function pinchGeometry() {
793
+ const touches = touchPoints();
794
+ if (touches.length < 2) return null;
795
+ const [a, b] = touches;
796
+ const screenCenter = {
797
+ x: (a.clientX + b.clientX) / 2 - container.getBoundingClientRect().left,
798
+ y: (a.clientY + b.clientY) / 2 - container.getBoundingClientRect().top
799
+ };
800
+ const { point: center } = toPoints({
801
+ clientX: (a.clientX + b.clientX) / 2,
802
+ clientY: (a.clientY + b.clientY) / 2
803
+ });
804
+ return {
805
+ screenCenter,
806
+ center,
807
+ distance: Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY),
808
+ modifiers: a.modifiers
809
+ };
810
+ }
811
+ function sendPinch(kind) {
812
+ const fallback = (kind === "pinch.end" ? lastPinch : pinchGeometry()) ?? lastPinch;
813
+ if (!fallback) return makeNoEffects();
814
+ lastPinch = fallback;
815
+ return runtime.send({
816
+ kind,
817
+ center: fallback.center,
818
+ screenCenter: fallback.screenCenter,
819
+ distance: fallback.distance,
820
+ pointerCount: activeTouchPointers.size,
821
+ modifiers: fallback.modifiers
822
+ });
823
+ }
824
+ function makeNoEffects() {
825
+ return {
826
+ preventDefault: false,
827
+ stopPropagation: false,
828
+ sessionStarted: false,
829
+ sessionEnded: false,
830
+ handledBy: null
831
+ };
832
+ }
833
+ function releaseCapturedPointer() {
834
+ if (capturedPointerId === null) return;
835
+ try {
836
+ if (container.hasPointerCapture(capturedPointerId)) container.releasePointerCapture(capturedPointerId);
837
+ } catch {}
838
+ capturedPointerId = null;
839
+ lastClient = null;
840
+ stopAutoPan();
841
+ }
391
842
  function handlePointer(kind) {
392
843
  return (event) => {
844
+ if (kind === "pointer.down") focusContainerForPanePress(event);
845
+ const pointerType = event.pointerType === "touch" || event.pointerType === "pen" || event.pointerType === "mouse" ? event.pointerType : "mouse";
846
+ if (pointerType === "touch") {
847
+ if (kind === "pointer.down") {
848
+ activeTouchPointers.set(event.pointerId, {
849
+ clientX: event.clientX,
850
+ clientY: event.clientY,
851
+ modifiers: modifiers(event)
852
+ });
853
+ if (activeTouchPointers.size === 2) {
854
+ runtime.cancelSession();
855
+ releaseCapturedPointer();
856
+ pinchActive = true;
857
+ lastPinch = pinchGeometry();
858
+ applyEffects(event, sendPinch("pinch.start"));
859
+ return;
860
+ }
861
+ if (activeTouchPointers.size > 2) {
862
+ applyEffects(event, sendPinch("pinch.move"));
863
+ return;
864
+ }
865
+ } else if (activeTouchPointers.has(event.pointerId)) {
866
+ if (kind === "pointer.move") {
867
+ activeTouchPointers.set(event.pointerId, {
868
+ clientX: event.clientX,
869
+ clientY: event.clientY,
870
+ modifiers: modifiers(event)
871
+ });
872
+ if (pinchActive) {
873
+ applyEffects(event, sendPinch("pinch.move"));
874
+ return;
875
+ }
876
+ } else if (kind === "pointer.up" || kind === "pointer.cancel") {
877
+ activeTouchPointers.delete(event.pointerId);
878
+ if (pinchActive) {
879
+ const effects = sendPinch("pinch.end");
880
+ pinchActive = false;
881
+ lastPinch = null;
882
+ applyEffects(event, effects);
883
+ return;
884
+ }
885
+ }
886
+ }
887
+ }
393
888
  if (kind === "pointer.down" || capturedPointerId !== null && event.pointerId === capturedPointerId) lastClient = {
394
889
  x: event.clientX,
395
890
  y: event.clientY,
396
891
  pointerId: event.pointerId
397
892
  };
893
+ const detail = kind === "pointer.down" && event.button === 0 ? computeClickDetail(event) : event.detail;
398
894
  applyEffects(event, sendPointer(kind, event.clientX, event.clientY, {
399
895
  pointerId: event.pointerId,
896
+ pointerType,
400
897
  button: event.button,
898
+ detail,
401
899
  modifiers: modifiers(event),
402
900
  target: event.target
403
901
  }));
@@ -416,10 +914,33 @@ function attachInput(options) {
416
914
  modifiers: modifiers(event)
417
915
  }));
418
916
  }
917
+ function handleContextMenu(event) {
918
+ applyEffects(event, sendPointer("pointer.down", event.clientX, event.clientY, {
919
+ pointerId: -1,
920
+ pointerType: "mouse",
921
+ button: 2,
922
+ detail: event.detail,
923
+ modifiers: modifiers(event),
924
+ target: event.target
925
+ }));
926
+ }
419
927
  function isEditableTarget(target) {
420
- if (!(target instanceof HTMLElement)) return false;
928
+ if (!HTMLElementCtor || !(target instanceof HTMLElementCtor)) return false;
421
929
  return target.isContentEditable || target.matches("input, textarea, select, [contenteditable='true']");
422
930
  }
931
+ function isInteractiveTarget(target) {
932
+ return !!ElementCtor && target instanceof ElementCtor && target.closest("[data-flow-interactive]") !== null;
933
+ }
934
+ function focusContainerForPanePress(event) {
935
+ if (keyboardTarget !== "container") return;
936
+ if (isEditableTarget(event.target) || isInteractiveTarget(event.target)) return;
937
+ if (resolveTarget({
938
+ clientX: event.clientX,
939
+ clientY: event.clientY,
940
+ target: event.target
941
+ }).kind !== "pane") return;
942
+ container.focus({ preventScroll: true });
943
+ }
423
944
  function handleKey(kind) {
424
945
  return (event) => {
425
946
  applyEffects(event, runtime.send({
@@ -434,26 +955,49 @@ function attachInput(options) {
434
955
  const onPointerMove = handlePointer("pointer.move");
435
956
  const onPointerUp = handlePointer("pointer.up");
436
957
  const onPointerCancel = handlePointer("pointer.cancel");
958
+ const onLostPointerCapture = () => {
959
+ stopAutoPan();
960
+ runtime.cancelSession();
961
+ capturedPointerId = null;
962
+ lastClient = null;
963
+ };
964
+ const onWindowBlur = () => {
965
+ stopAutoPan();
966
+ runtime.cancelSession();
967
+ activeTouchPointers.clear();
968
+ capturedPointerId = null;
969
+ lastClient = null;
970
+ };
437
971
  const onKeyDown = handleKey("key.down");
438
972
  const onKeyUp = handleKey("key.up");
973
+ if (keyboardTarget === "container" && !container.hasAttribute("tabindex")) container.tabIndex = 0;
439
974
  container.addEventListener("pointerdown", onPointerDown);
440
975
  container.addEventListener("pointermove", onPointerMove);
441
976
  container.addEventListener("pointerup", onPointerUp);
442
977
  container.addEventListener("pointercancel", onPointerCancel);
978
+ container.addEventListener("lostpointercapture", onLostPointerCapture);
979
+ container.addEventListener("contextmenu", handleContextMenu);
443
980
  container.addEventListener("wheel", handleWheel, { passive: false });
444
- win?.addEventListener("keydown", onKeyDown);
445
- win?.addEventListener("keyup", onKeyUp);
981
+ keyboardElement?.addEventListener("keydown", onKeyDown);
982
+ keyboardElement?.addEventListener("keyup", onKeyUp);
983
+ win?.addEventListener("blur", onWindowBlur);
446
984
  return () => {
447
985
  stopAutoPan();
448
986
  runtime.cancelSession();
987
+ activeTouchPointers.clear();
988
+ pinchActive = false;
989
+ lastPinch = null;
449
990
  container.removeEventListener("pointerdown", onPointerDown);
450
991
  container.removeEventListener("pointermove", onPointerMove);
451
992
  container.removeEventListener("pointerup", onPointerUp);
452
993
  container.removeEventListener("pointercancel", onPointerCancel);
994
+ container.removeEventListener("lostpointercapture", onLostPointerCapture);
995
+ container.removeEventListener("contextmenu", handleContextMenu);
453
996
  container.removeEventListener("wheel", handleWheel);
454
- win?.removeEventListener("keydown", onKeyDown);
455
- win?.removeEventListener("keyup", onKeyUp);
997
+ keyboardElement?.removeEventListener("keydown", onKeyDown);
998
+ keyboardElement?.removeEventListener("keyup", onKeyUp);
999
+ win?.removeEventListener("blur", onWindowBlur);
456
1000
  };
457
1001
  }
458
1002
  //#endregion
459
- export { applyFlowColorMode, attachContainer, attachInput, attachViewportTransform, computeRenderableIds, createMeasurementObserver, getAttachedContainer, nodePositionStyle, renderableIdsEqual, resolveFlowColorMode, viewportStyle };
1003
+ export { FLOW_CONTENT_MIME, applyFlowColorMode, attachContainer, attachInput, attachViewportTransform, canvasToClient, clientToCanvas, computeRenderableIds, createEntityGeometryObserver, createFlowClipboard, createFlowView, createMeasurementObserver, exportFlowImage, getAttachedContainer, nodePositionStyle, renderableIdsEqual, resolveFlowColorMode, viewportStyle };
package/package.json CHANGED
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "name": "@statelyai/flow-dom",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "DOM-specific helpers for @statelyai/flow with core re-exports.",
5
+ "type": "module",
6
+ "sideEffects": false,
5
7
  "keywords": [
6
8
  "flow",
7
9
  "diagram",
@@ -25,11 +27,12 @@
25
27
  },
26
28
  "license": "MIT",
27
29
  "dependencies": {
28
- "@statelyai/flow": "0.4.2"
30
+ "@statelyai/flow": "0.5.0"
29
31
  },
30
32
  "devDependencies": {
33
+ "@vitest/coverage-v8": "^4.1.0",
31
34
  "jsdom": "^25.0.0",
32
- "tsdown": "^0.12.0",
35
+ "tsdown": "^0.22.13",
33
36
  "typescript": "^5.8.0",
34
37
  "vitest": "^4.1.0"
35
38
  },
@@ -37,6 +40,7 @@
37
40
  "dev": "tsdown --watch",
38
41
  "build": "tsdown",
39
42
  "typecheck": "tsc --noEmit",
40
- "test": "vitest run"
43
+ "test": "vitest run",
44
+ "test:coverage": "vitest run --coverage"
41
45
  }
42
46
  }