@statelyai/flow-dom 3.0.1 → 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,7 +1,64 @@
1
- import { FlowColorMode, FlowEngine, FlowInputEffects, FlowInputEvent, FlowStore, FlowStoreContext, Rect } from "@statelyai/flow";
2
-
1
+ import { EdgePathData, FlowColorMode, FlowController, FlowEngine, FlowEntityChangeNotifierOptions, FlowInputEffects, FlowInputEvent, FlowResult, FlowStore, FlowStoreContext, Point, Rect } from "@statelyai/flow";
2
+ export * from "@statelyai/flow";
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
3
58
  //#region src/culling.d.ts
4
-
59
+ type ComputeRenderableIdsOptions = {
60
+ overscan?: number;
61
+ };
5
62
  /**
6
63
  * Node/edge ids worth mounting, or `null` meaning "render everything" — either
7
64
  * because culling is disabled, the viewport hasn't been measured yet, or the
@@ -39,7 +96,7 @@ declare function renderableIdsEqual(a: RenderableIds, b: RenderableIds): boolean
39
96
  * `getGraphBounds`/`getRenderableEntities` from the live store) — which holds
40
97
  * when called inside a store-subscription selector.
41
98
  */
42
- 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;
43
100
  //#endregion
44
101
  //#region src/render-styles.d.ts
45
102
  type NodePositionStyle = {
@@ -82,6 +139,34 @@ type ViewportStyle = {
82
139
  */
83
140
  declare function viewportStyle(): ViewportStyle;
84
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
85
170
  //#region src/index.d.ts
86
171
  type ResolvedFlowColorMode = "light" | "dark";
87
172
  declare function resolveFlowColorMode(colorMode: FlowColorMode, targetWindow?: Pick<Window, "matchMedia"> | undefined): ResolvedFlowColorMode;
@@ -98,17 +183,25 @@ type MeasurementObserver = {
98
183
  observe(element: Element, target: MeasurementTarget): void;
99
184
  /** Re-measure now (position-only changes don't fire ResizeObserver). */
100
185
  measure(element: Element): void;
186
+ refresh(targets?: Iterable<string | Element>): void;
101
187
  unobserve(element: Element): void;
102
188
  disconnect(): void;
103
189
  };
104
190
  type MeasurementObserverOptions = {
105
191
  container?: Element | (() => Element | null | undefined) | null;
192
+ equalityThreshold?: number;
106
193
  };
107
194
  declare function createMeasurementObserver(store: FlowStore, options?: MeasurementObserverOptions): MeasurementObserver;
108
195
  /** The container element registered for this store via `attachContainer`. */
109
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;
110
199
  declare function attachContainer(store: FlowStore, element: Element): () => void;
111
- 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;
112
205
  type AttachInputOptions = {
113
206
  container: HTMLElement;
114
207
  store: FlowStore;
@@ -116,6 +209,11 @@ type AttachInputOptions = {
116
209
  send(event: FlowInputEvent): FlowInputEffects;
117
210
  cancelSession(): void;
118
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;
119
217
  /**
120
218
  * Auto-pan policy, evaluated once per animation frame while a gesture
121
219
  * session holds the pointer near the container edge. Pass
@@ -134,4 +232,4 @@ type AttachInputOptions = {
134
232
  };
135
233
  declare function attachInput(options: AttachInputOptions): () => void;
136
234
  //#endregion
137
- 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,3 +1,237 @@
1
+ import { createEntityChangeNotifier, pathToSVG } from "@statelyai/flow";
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
1
235
  //#region src/culling.ts
2
236
  /** Overscan (canvas px) added around the viewport so entities just off-screen
3
237
  * stay mounted and don't pop in/out at the edges. */
@@ -36,20 +270,21 @@ function renderableIdsEqual(a, b) {
36
270
  * `getGraphBounds`/`getRenderableEntities` from the live store) — which holds
37
271
  * when called inside a store-subscription selector.
38
272
  */
39
- function computeRenderableIds(engine, context, enabled) {
273
+ function computeRenderableIds(engine, context, enabled, options = {}) {
40
274
  if (!enabled) return null;
275
+ const overscan = options.overscan ?? CULL_OVERSCAN;
41
276
  const viewportSize = context.viewportSize;
42
277
  if (viewportSize.width === 0 || viewportSize.height === 0) return null;
43
278
  const viewport = context.viewport;
44
- const left = -viewport.x / viewport.zoom - CULL_OVERSCAN;
45
- const top = -viewport.y / viewport.zoom - CULL_OVERSCAN;
46
- const right = left + viewportSize.width / viewport.zoom + CULL_OVERSCAN * 2;
47
- 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;
48
283
  const bounds = engine.getGraphBounds();
49
284
  if (left <= bounds.x && top <= bounds.y && right >= bounds.x + bounds.width && bottom >= bounds.y + bounds.height) return null;
50
285
  const renderable = engine.getRenderableEntities({
51
286
  cull: "viewport",
52
- overscan: CULL_OVERSCAN
287
+ overscan
53
288
  });
54
289
  return {
55
290
  nodeIds: new Set(renderable.nodes.map((node) => node.id)),
@@ -97,6 +332,107 @@ function viewportStyle() {
97
332
  };
98
333
  }
99
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
100
436
  //#region src/index.ts
101
437
  function resolveFlowColorMode(colorMode, targetWindow = typeof window === "undefined" ? void 0 : window) {
102
438
  if (colorMode !== "system") return colorMode;
@@ -111,6 +447,7 @@ function applyFlowColorMode(element, colorMode, targetWindow = element.ownerDocu
111
447
  }
112
448
  const containers = /* @__PURE__ */ new WeakMap();
113
449
  function createMeasurementObserver(store, options = {}) {
450
+ const equalityThreshold = options.equalityThreshold ?? .5;
114
451
  const elementMap = /* @__PURE__ */ new Map();
115
452
  let pendingMeasurements = [];
116
453
  let rafId = null;
@@ -137,14 +474,14 @@ function createMeasurementObserver(store, options = {}) {
137
474
  };
138
475
  const id = "portName" in target ? `${target.nodeId}:${target.portName}` : "nodeId" in target ? target.nodeId : target.edgeId;
139
476
  const existing = store.getSnapshot().context.measurements.get(id);
140
- 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;
141
478
  pendingMeasurements.push({
142
479
  id,
143
480
  rect: measurement
144
481
  });
145
482
  scheduleFlush();
146
483
  }
147
- const observer = new ResizeObserver((entries) => {
484
+ const handleResize = (entries) => {
148
485
  for (const entry of entries) {
149
486
  const target = elementMap.get(entry.target);
150
487
  if (!target) continue;
@@ -158,11 +495,17 @@ function createMeasurementObserver(store, options = {}) {
158
495
  } : void 0;
159
496
  pushMeasurement(entry.target, target, layoutSize);
160
497
  }
161
- });
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
+ }
162
505
  return {
163
506
  observe(element, target) {
164
507
  elementMap.set(element, target);
165
- observer.observe(element);
508
+ getObserver(element).observe(element);
166
509
  },
167
510
  /**
168
511
  * Re-measure an observed element immediately. ResizeObserver only fires
@@ -173,13 +516,22 @@ function createMeasurementObserver(store, options = {}) {
173
516
  const target = elementMap.get(element);
174
517
  if (target) pushMeasurement(element, target);
175
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
+ },
176
527
  unobserve(element) {
177
528
  elementMap.delete(element);
178
- observer.unobserve(element);
529
+ observer?.unobserve(element);
179
530
  },
180
531
  disconnect() {
181
532
  elementMap.clear();
182
- observer.disconnect();
533
+ observer?.disconnect();
534
+ observer = null;
183
535
  if (rafId !== null) {
184
536
  cancelAnimationFrame(rafId);
185
537
  rafId = null;
@@ -195,6 +547,23 @@ function getContainer(container) {
195
547
  function getAttachedContainer(store) {
196
548
  return containers.get(store);
197
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
+ }
198
567
  function attachContainer(store, element) {
199
568
  containers.set(store, element);
200
569
  const update = () => {
@@ -205,21 +574,22 @@ function attachContainer(store, element) {
205
574
  });
206
575
  };
207
576
  update();
208
- const observer = new ResizeObserver(update);
577
+ const observer = new ((element.ownerDocument.defaultView?.ResizeObserver) ?? ResizeObserver)(update);
209
578
  observer.observe(element);
210
579
  return () => {
211
580
  if (containers.get(store) === element) containers.delete(store);
212
581
  observer.disconnect();
213
582
  };
214
583
  }
215
- /** Idle delay (ms) before dropping the GPU-layer hint after interaction stops. */
216
- const VIEWPORT_PROMOTE_IDLE_MS = 200;
217
- function attachViewportTransform(store, element) {
584
+ function attachViewportTransform(store, element, options = {}) {
585
+ const promoteIdleMs = options.promoteIdleMs ?? 200;
218
586
  let prevViewport = store.getSnapshot().context.viewport;
219
587
  let prevState = store.getSnapshot().context.canvasState;
220
588
  let idle;
221
589
  const writeTransform = (viewport) => {
222
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));
223
593
  };
224
594
  const promote = () => {
225
595
  element.style.willChange = "transform";
@@ -229,7 +599,7 @@ function attachViewportTransform(store, element) {
229
599
  if (idle !== void 0) clearTimeout(idle);
230
600
  idle = setTimeout(() => {
231
601
  element.style.willChange = "auto";
232
- }, VIEWPORT_PROMOTE_IDLE_MS);
602
+ }, promoteIdleMs);
233
603
  };
234
604
  writeTransform(prevViewport);
235
605
  if (prevState !== "idle") promote();
@@ -256,9 +626,20 @@ function attachInput(options) {
256
626
  const { container, store, runtime } = options;
257
627
  const doc = container.ownerDocument;
258
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;
259
633
  let capturedPointerId = null;
260
634
  let lastClient = null;
261
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;
262
643
  function stopAutoPan() {
263
644
  if (autoPanFrame !== null) {
264
645
  win?.cancelAnimationFrame(autoPanFrame);
@@ -304,6 +685,18 @@ function attachInput(options) {
304
685
  alt: event.altKey
305
686
  };
306
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
+ }
307
700
  function toPoints(event) {
308
701
  const rect = container.getBoundingClientRect();
309
702
  const screenPoint = {
@@ -321,24 +714,28 @@ function attachInput(options) {
321
714
  };
322
715
  }
323
716
  function resolveTarget(event) {
324
- 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;
325
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" };
326
722
  const portElement = element.closest("[data-flow-port-name]");
327
723
  if (portElement?.dataset.flowPortName) return {
328
724
  kind: "port",
329
725
  nodeId: portElement.dataset.flowNodeId ?? "",
330
726
  portName: portElement.dataset.flowPortName
331
727
  };
332
- 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" };
333
729
  const entityElement = element.closest("[data-entity-id]");
334
730
  if (entityElement?.dataset.entityId) {
335
731
  const isLabel = element.closest("[data-flow-edge-label]") !== null;
732
+ const part = element.closest("[data-flow-part]")?.dataset.flowPart ?? (isLabel ? "label" : void 0);
336
733
  const entityHasDragHandle = entityElement.querySelector("[data-flow-drag-handle]") !== null;
337
734
  const pressedDragHandle = entityHasDragHandle && element.closest("[data-flow-drag-handle]") !== null;
338
735
  return {
339
736
  kind: "entity",
340
737
  id: entityElement.dataset.entityId,
341
- ...isLabel ? { part: "label" } : {},
738
+ ...part ? { part } : {},
342
739
  ...entityHasDragHandle ? {
343
740
  entityHasDragHandle,
344
741
  pressedDragHandle
@@ -360,7 +757,9 @@ function attachInput(options) {
360
757
  const effects = runtime.send({
361
758
  kind,
362
759
  pointerId: info.pointerId,
760
+ pointerType: info.pointerType ?? "mouse",
363
761
  button: info.button,
762
+ detail: info.detail,
364
763
  point,
365
764
  screenPoint,
366
765
  target: resolveTarget({
@@ -387,16 +786,116 @@ function attachInput(options) {
387
786
  }
388
787
  return effects;
389
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
+ }
390
842
  function handlePointer(kind) {
391
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
+ }
392
888
  if (kind === "pointer.down" || capturedPointerId !== null && event.pointerId === capturedPointerId) lastClient = {
393
889
  x: event.clientX,
394
890
  y: event.clientY,
395
891
  pointerId: event.pointerId
396
892
  };
893
+ const detail = kind === "pointer.down" && event.button === 0 ? computeClickDetail(event) : event.detail;
397
894
  applyEffects(event, sendPointer(kind, event.clientX, event.clientY, {
398
895
  pointerId: event.pointerId,
896
+ pointerType,
399
897
  button: event.button,
898
+ detail,
400
899
  modifiers: modifiers(event),
401
900
  target: event.target
402
901
  }));
@@ -415,10 +914,33 @@ function attachInput(options) {
415
914
  modifiers: modifiers(event)
416
915
  }));
417
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
+ }
418
927
  function isEditableTarget(target) {
419
- if (!(target instanceof HTMLElement)) return false;
928
+ if (!HTMLElementCtor || !(target instanceof HTMLElementCtor)) return false;
420
929
  return target.isContentEditable || target.matches("input, textarea, select, [contenteditable='true']");
421
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
+ }
422
944
  function handleKey(kind) {
423
945
  return (event) => {
424
946
  applyEffects(event, runtime.send({
@@ -433,26 +955,49 @@ function attachInput(options) {
433
955
  const onPointerMove = handlePointer("pointer.move");
434
956
  const onPointerUp = handlePointer("pointer.up");
435
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
+ };
436
971
  const onKeyDown = handleKey("key.down");
437
972
  const onKeyUp = handleKey("key.up");
973
+ if (keyboardTarget === "container" && !container.hasAttribute("tabindex")) container.tabIndex = 0;
438
974
  container.addEventListener("pointerdown", onPointerDown);
439
975
  container.addEventListener("pointermove", onPointerMove);
440
976
  container.addEventListener("pointerup", onPointerUp);
441
977
  container.addEventListener("pointercancel", onPointerCancel);
978
+ container.addEventListener("lostpointercapture", onLostPointerCapture);
979
+ container.addEventListener("contextmenu", handleContextMenu);
442
980
  container.addEventListener("wheel", handleWheel, { passive: false });
443
- win?.addEventListener("keydown", onKeyDown);
444
- win?.addEventListener("keyup", onKeyUp);
981
+ keyboardElement?.addEventListener("keydown", onKeyDown);
982
+ keyboardElement?.addEventListener("keyup", onKeyUp);
983
+ win?.addEventListener("blur", onWindowBlur);
445
984
  return () => {
446
985
  stopAutoPan();
447
986
  runtime.cancelSession();
987
+ activeTouchPointers.clear();
988
+ pinchActive = false;
989
+ lastPinch = null;
448
990
  container.removeEventListener("pointerdown", onPointerDown);
449
991
  container.removeEventListener("pointermove", onPointerMove);
450
992
  container.removeEventListener("pointerup", onPointerUp);
451
993
  container.removeEventListener("pointercancel", onPointerCancel);
994
+ container.removeEventListener("lostpointercapture", onLostPointerCapture);
995
+ container.removeEventListener("contextmenu", handleContextMenu);
452
996
  container.removeEventListener("wheel", handleWheel);
453
- win?.removeEventListener("keydown", onKeyDown);
454
- win?.removeEventListener("keyup", onKeyUp);
997
+ keyboardElement?.removeEventListener("keydown", onKeyDown);
998
+ keyboardElement?.removeEventListener("keyup", onKeyUp);
999
+ win?.removeEventListener("blur", onWindowBlur);
455
1000
  };
456
1001
  }
457
1002
  //#endregion
458
- 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.0.1",
4
- "description": "DOM-specific helpers for @statelyai/flow.",
3
+ "version": "3.2.0",
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",
@@ -24,25 +26,21 @@
24
26
  }
25
27
  },
26
28
  "license": "MIT",
27
- "peerDependencies": {
28
- "@statelyai/flow": ">=0.4.2"
29
- },
30
- "peerDependenciesMeta": {
31
- "@statelyai/flow": {
32
- "optional": true
33
- }
29
+ "dependencies": {
30
+ "@statelyai/flow": "0.5.0"
34
31
  },
35
32
  "devDependencies": {
33
+ "@vitest/coverage-v8": "^4.1.0",
36
34
  "jsdom": "^25.0.0",
37
- "tsdown": "^0.12.0",
35
+ "tsdown": "^0.22.13",
38
36
  "typescript": "^5.8.0",
39
- "vitest": "^4.1.0",
40
- "@statelyai/flow": "0.4.2"
37
+ "vitest": "^4.1.0"
41
38
  },
42
39
  "scripts": {
43
40
  "dev": "tsdown --watch",
44
41
  "build": "tsdown",
45
42
  "typecheck": "tsc --noEmit",
46
- "test": "vitest run"
43
+ "test": "vitest run",
44
+ "test:coverage": "vitest run --coverage"
47
45
  }
48
46
  }