@zoneflow/editor-dom 0.0.1

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,300 @@
1
+ import { getZoneDepth, getZoneLayout, updateZoneLayout, } from "@zoneflow/core";
2
+ import { projectWorldRectToScreenRect, resolveSnappedMove, roundCoordinate, snapCoordinate, typedValues, } from "./moveEditorShared";
3
+ import { applyPathMovePosition, resolvePathMoveOriginSnapshot, } from "./pathMoveEditor";
4
+ import { applyZoneOriginsDelta, resolveZoneGroupOrigins, } from "./zoneGeometry";
5
+ export { alignPathsByMode, distributePathsByMode, resolveGroupPathDragOrigin, resolvePathResizeOrigin, resizePathNodeByScreenDelta, } from "./pathMoveEditor";
6
+ export { commitZoneGroupReparentAtCurrentPosition, commitZoneReparentAtCurrentPosition, reparentZoneAtCurrentPosition, resolveZonePlacementAtWorldRect, resolveZoneReparentCandidate, } from "./zoneReparent";
7
+ const DEFAULT_MIN_VISIBLE_SIZE = 18;
8
+ const DEFAULT_MIN_ZONE_WIDTH = 140;
9
+ const DEFAULT_MIN_ZONE_HEIGHT = 96;
10
+ function resolveResizedAnchor(params) {
11
+ const { kind, width, height, current } = params;
12
+ const rectWidth = current?.rect?.width;
13
+ const rectHeight = current?.rect?.height;
14
+ const nextCenterY = roundCoordinate(height / 2);
15
+ const nextRectY = rectHeight !== undefined
16
+ ? roundCoordinate(nextCenterY - rectHeight / 2)
17
+ : undefined;
18
+ return {
19
+ point: {
20
+ x: kind === "inlet" ? 0 : roundCoordinate(width),
21
+ y: nextCenterY,
22
+ },
23
+ rect: current?.rect
24
+ ? {
25
+ ...current.rect,
26
+ x: kind === "inlet"
27
+ ? 0
28
+ : roundCoordinate(width - (rectWidth ?? current.rect.width ?? 0)),
29
+ y: nextRectY ?? current.rect.y,
30
+ width: rectWidth,
31
+ height: rectHeight,
32
+ }
33
+ : undefined,
34
+ };
35
+ }
36
+ export function getMoveEditorTargets(params) {
37
+ const { model, frame, camera, options, } = params;
38
+ const includeRoot = options?.includeRoot ?? true;
39
+ const minVisibleSize = options?.minVisibleSize ?? DEFAULT_MIN_VISIBLE_SIZE;
40
+ const zoneTargets = [];
41
+ const pathTargets = [];
42
+ for (const zoneVisual of typedValues(frame.pipeline.graphLayout.zonesById)) {
43
+ const zone = model.zonesById[zoneVisual.zoneId];
44
+ const visibility = frame.pipeline.visibility.zoneVisibilityById[zoneVisual.zoneId];
45
+ if (!zone || !visibility?.isVisible)
46
+ continue;
47
+ if (!includeRoot && model.rootZoneIds.includes(zone.id))
48
+ continue;
49
+ const rect = projectWorldRectToScreenRect(zoneVisual.rect, camera);
50
+ if (rect.width < minVisibleSize || rect.height < minVisibleSize) {
51
+ continue;
52
+ }
53
+ zoneTargets.push({
54
+ key: `zone:${zoneVisual.zoneId}`,
55
+ kind: "zone",
56
+ zoneId: zoneVisual.zoneId,
57
+ label: zone.name,
58
+ rect,
59
+ depth: getZoneDepth(model, zoneVisual.zoneId),
60
+ });
61
+ }
62
+ for (const pathVisual of typedValues(frame.pipeline.graphLayout.pathsById)) {
63
+ const visibility = frame.pipeline.visibility.pathVisibilityById[pathVisual.pathId];
64
+ if (!visibility?.shouldRenderNode || !pathVisual.rect)
65
+ continue;
66
+ const rect = projectWorldRectToScreenRect(pathVisual.rect, camera);
67
+ if (rect.width < minVisibleSize || rect.height < minVisibleSize) {
68
+ continue;
69
+ }
70
+ pathTargets.push({
71
+ key: `path:${pathVisual.pathId}`,
72
+ kind: "path",
73
+ pathId: pathVisual.pathId,
74
+ label: pathVisual.path.name,
75
+ rect,
76
+ });
77
+ }
78
+ return [
79
+ ...zoneTargets
80
+ .sort((a, b) => a.depth - b.depth)
81
+ .map(({ depth: _depth, ...target }) => target),
82
+ ...pathTargets,
83
+ ];
84
+ }
85
+ export function resolveMoveEditorDragOrigin(layoutModel, target, frame) {
86
+ if (target.kind === "zone") {
87
+ const zoneLayout = getZoneLayout(layoutModel, target.zoneId);
88
+ if (!zoneLayout)
89
+ return undefined;
90
+ return {
91
+ kind: "zone",
92
+ zoneId: target.zoneId,
93
+ originX: zoneLayout.x,
94
+ originY: zoneLayout.y,
95
+ };
96
+ }
97
+ return {
98
+ kind: "path",
99
+ pathId: target.pathId,
100
+ origin: resolvePathMoveOriginSnapshot({
101
+ frame,
102
+ layoutModel,
103
+ pathId: target.pathId,
104
+ }),
105
+ };
106
+ }
107
+ export function resolveGroupZoneDragOrigin(params) {
108
+ const resolved = resolveZoneGroupOrigins(params);
109
+ if (!resolved)
110
+ return undefined;
111
+ return {
112
+ kind: "zone-group",
113
+ primaryZoneId: resolved.primaryZoneId,
114
+ originsByZoneId: resolved.originsByZoneId,
115
+ };
116
+ }
117
+ export function moveEditorTargetByScreenDelta(params) {
118
+ const { layoutModel, camera, origin, deltaX, deltaY, gridSnap, } = params;
119
+ if (origin.kind === "zone") {
120
+ const { nextX, nextY } = resolveSnappedMove({
121
+ originX: origin.originX,
122
+ originY: origin.originY,
123
+ deltaX,
124
+ deltaY,
125
+ camera,
126
+ gridSnap,
127
+ });
128
+ return updateZoneLayout(layoutModel, origin.zoneId, {
129
+ x: nextX,
130
+ y: nextY,
131
+ });
132
+ }
133
+ if (origin.kind === "zone-group") {
134
+ const primaryOrigin = origin.originsByZoneId[origin.primaryZoneId];
135
+ if (!primaryOrigin)
136
+ return layoutModel;
137
+ const { effectiveDeltaX, effectiveDeltaY } = resolveSnappedMove({
138
+ originX: primaryOrigin.x,
139
+ originY: primaryOrigin.y,
140
+ deltaX,
141
+ deltaY,
142
+ camera,
143
+ gridSnap,
144
+ });
145
+ return applyZoneOriginsDelta({
146
+ layoutModel,
147
+ originsByZoneId: origin.originsByZoneId,
148
+ deltaX: effectiveDeltaX,
149
+ deltaY: effectiveDeltaY,
150
+ });
151
+ }
152
+ if (origin.kind === "path-group") {
153
+ const primaryOrigin = origin.originsByPathId[origin.primaryPathId];
154
+ if (!primaryOrigin)
155
+ return layoutModel;
156
+ const { effectiveDeltaX, effectiveDeltaY } = resolveSnappedMove({
157
+ originX: primaryOrigin.x,
158
+ originY: primaryOrigin.y,
159
+ deltaX,
160
+ deltaY,
161
+ camera,
162
+ gridSnap,
163
+ });
164
+ let nextLayoutModel = layoutModel;
165
+ for (const [pathId, pathOrigin] of Object.entries(origin.originsByPathId)) {
166
+ nextLayoutModel = applyPathMovePosition({
167
+ layoutModel: nextLayoutModel,
168
+ pathId,
169
+ origin: pathOrigin,
170
+ x: roundCoordinate(pathOrigin.x + effectiveDeltaX),
171
+ y: roundCoordinate(pathOrigin.y + effectiveDeltaY),
172
+ });
173
+ }
174
+ return nextLayoutModel;
175
+ }
176
+ const { nextX, nextY } = resolveSnappedMove({
177
+ originX: origin.origin.x,
178
+ originY: origin.origin.y,
179
+ deltaX,
180
+ deltaY,
181
+ camera,
182
+ gridSnap,
183
+ });
184
+ return applyPathMovePosition({
185
+ layoutModel,
186
+ pathId: origin.pathId,
187
+ origin: origin.origin,
188
+ x: nextX,
189
+ y: nextY,
190
+ });
191
+ }
192
+ export function resolveZoneResizeOrigin(layoutModel, zoneId) {
193
+ const zoneLayout = getZoneLayout(layoutModel, zoneId);
194
+ if (!zoneLayout)
195
+ return undefined;
196
+ return {
197
+ zoneId,
198
+ originWidth: zoneLayout.width ?? 0,
199
+ originHeight: zoneLayout.height ?? 0,
200
+ };
201
+ }
202
+ export function resizeZoneByScreenDelta(params) {
203
+ const { layoutModel, camera, origin, deltaX, deltaY, minWidth = DEFAULT_MIN_ZONE_WIDTH, minHeight = DEFAULT_MIN_ZONE_HEIGHT, gridSnap, } = params;
204
+ const currentLayout = getZoneLayout(layoutModel, origin.zoneId);
205
+ if (!currentLayout)
206
+ return layoutModel;
207
+ const nextWidth = Math.max(minWidth, snapCoordinate(origin.originWidth + deltaX / camera.zoom, gridSnap));
208
+ const nextHeight = Math.max(minHeight, snapCoordinate(origin.originHeight + deltaY / camera.zoom, gridSnap));
209
+ return updateZoneLayout(layoutModel, origin.zoneId, {
210
+ width: nextWidth,
211
+ height: nextHeight,
212
+ anchors: {
213
+ inlet: resolveResizedAnchor({
214
+ kind: "inlet",
215
+ width: nextWidth,
216
+ height: nextHeight,
217
+ current: currentLayout.anchors.inlet,
218
+ }),
219
+ outlet: resolveResizedAnchor({
220
+ kind: "outlet",
221
+ width: nextWidth,
222
+ height: nextHeight,
223
+ current: currentLayout.anchors.outlet,
224
+ }),
225
+ },
226
+ });
227
+ }
228
+ export function alignZonesByMode(params) {
229
+ const { layoutModel, zoneIds, mode, gridSnap } = params;
230
+ const entries = zoneIds
231
+ .map((zoneId) => ({
232
+ zoneId,
233
+ layout: getZoneLayout(layoutModel, zoneId),
234
+ }))
235
+ .filter((entry) => Boolean(entry.layout));
236
+ if (entries.length < 2)
237
+ return layoutModel;
238
+ const reference = mode === "left"
239
+ ? Math.min(...entries.map((entry) => entry.layout.x))
240
+ : mode === "right"
241
+ ? Math.max(...entries.map((entry) => entry.layout.x + (entry.layout.width ?? 0)))
242
+ : mode === "top"
243
+ ? Math.min(...entries.map((entry) => entry.layout.y))
244
+ : mode === "bottom"
245
+ ? Math.max(...entries.map((entry) => entry.layout.y + (entry.layout.height ?? 0)))
246
+ : mode === "center-horizontal"
247
+ ? entries.reduce((sum, entry) => sum + entry.layout.x + (entry.layout.width ?? 0) / 2, 0) / entries.length
248
+ : entries.reduce((sum, entry) => sum + entry.layout.y + (entry.layout.height ?? 0) / 2, 0) / entries.length;
249
+ const snappedReference = snapCoordinate(reference, gridSnap);
250
+ let nextLayoutModel = layoutModel;
251
+ for (const entry of entries) {
252
+ nextLayoutModel = updateZoneLayout(nextLayoutModel, entry.zoneId, {
253
+ x: mode === "left"
254
+ ? snappedReference
255
+ : mode === "right"
256
+ ? snapCoordinate(snappedReference - (entry.layout.width ?? 0), gridSnap)
257
+ : mode === "center-horizontal"
258
+ ? snapCoordinate(snappedReference - (entry.layout.width ?? 0) / 2, gridSnap)
259
+ : entry.layout.x,
260
+ y: mode === "top"
261
+ ? snappedReference
262
+ : mode === "bottom"
263
+ ? snapCoordinate(snappedReference - (entry.layout.height ?? 0), gridSnap)
264
+ : mode === "center-vertical"
265
+ ? snapCoordinate(snappedReference - (entry.layout.height ?? 0) / 2, gridSnap)
266
+ : entry.layout.y,
267
+ });
268
+ }
269
+ return nextLayoutModel;
270
+ }
271
+ export function distributeZonesByMode(params) {
272
+ const { layoutModel, zoneIds, mode, gridSnap } = params;
273
+ const axis = mode === "horizontal" ? "x" : "y";
274
+ const sizeKey = mode === "horizontal" ? "width" : "height";
275
+ const entries = zoneIds
276
+ .map((zoneId) => ({
277
+ zoneId,
278
+ layout: getZoneLayout(layoutModel, zoneId),
279
+ }))
280
+ .filter((entry) => Boolean(entry.layout))
281
+ .sort((a, b) => a.layout[axis] - b.layout[axis]);
282
+ if (entries.length < 3)
283
+ return layoutModel;
284
+ const first = entries[0];
285
+ const last = entries[entries.length - 1];
286
+ const span = last.layout[axis] + (last.layout[sizeKey] ?? 0) - first.layout[axis];
287
+ const occupied = entries.reduce((sum, entry) => sum + (entry.layout[sizeKey] ?? 0), 0);
288
+ const gap = (span - occupied) / (entries.length - 1);
289
+ let cursor = first.layout[axis] + (first.layout[sizeKey] ?? 0) + gap;
290
+ let nextLayoutModel = layoutModel;
291
+ for (const entry of entries.slice(1, -1)) {
292
+ const snapped = snapCoordinate(cursor, gridSnap);
293
+ nextLayoutModel = updateZoneLayout(nextLayoutModel, entry.zoneId, {
294
+ x: mode === "horizontal" ? snapped : entry.layout.x,
295
+ y: mode === "vertical" ? snapped : entry.layout.y,
296
+ });
297
+ cursor += (entry.layout[sizeKey] ?? 0) + gap;
298
+ }
299
+ return nextLayoutModel;
300
+ }
@@ -0,0 +1,46 @@
1
+ import { type UniverseLayoutModel, type UniverseModel, type ZoneId } from "@zoneflow/core";
2
+ import type { Rect } from "@zoneflow/renderer-dom";
3
+ export declare function resolveZoneReparentCandidate(params: {
4
+ model: UniverseModel;
5
+ layoutModel: UniverseLayoutModel;
6
+ zoneId: ZoneId;
7
+ }): {
8
+ candidateParentZoneId: string | null;
9
+ currentParentZoneId: string | null;
10
+ worldRect: undefined;
11
+ } | {
12
+ candidateParentZoneId: string | null;
13
+ currentParentZoneId: string | null;
14
+ worldRect: Rect;
15
+ };
16
+ export declare function resolveZonePlacementAtWorldRect(params: {
17
+ model: UniverseModel;
18
+ layoutModel: UniverseLayoutModel;
19
+ worldRect: Rect;
20
+ }): {
21
+ parentZoneId: ZoneId | null;
22
+ x: number;
23
+ y: number;
24
+ worldRect: Rect;
25
+ };
26
+ export declare function commitZoneReparentAtCurrentPosition(params: {
27
+ model: UniverseModel;
28
+ layoutModel: UniverseLayoutModel;
29
+ zoneId: ZoneId;
30
+ }): {
31
+ model: UniverseModel;
32
+ layoutModel: UniverseLayoutModel;
33
+ nextParentZoneId: ZoneId | null;
34
+ didReparent: boolean;
35
+ };
36
+ export declare const reparentZoneAtCurrentPosition: typeof commitZoneReparentAtCurrentPosition;
37
+ export declare function commitZoneGroupReparentAtCurrentPosition(params: {
38
+ model: UniverseModel;
39
+ layoutModel: UniverseLayoutModel;
40
+ zoneIds: ZoneId[];
41
+ }): {
42
+ model: UniverseModel;
43
+ layoutModel: UniverseLayoutModel;
44
+ reparentedZoneIds: ZoneId[];
45
+ didReparent: boolean;
46
+ };
@@ -0,0 +1,173 @@
1
+ import { canZoneContainChildren, collectSubtreeZoneIds, getZoneDepth, getZoneLayout, moveZone, updateZoneLayout, } from "@zoneflow/core";
2
+ import { containsPoint, getRectArea, roundCoordinate, typedValues, } from "./moveEditorShared";
3
+ import { ROOT_WORLD_ORIGIN, resolveWorldZoneOrigin, resolveWorldZoneRect, } from "./zoneGeometry";
4
+ function resolveContainingParentZoneId(params) {
5
+ const { model, layoutModel, centerPoint, cache, invalidZoneIds, } = params;
6
+ let parentZoneId = null;
7
+ let bestDepth = -1;
8
+ let bestArea = Number.POSITIVE_INFINITY;
9
+ for (const candidateZone of typedValues(model.zonesById)) {
10
+ if (invalidZoneIds?.has(candidateZone.id))
11
+ continue;
12
+ if (!canZoneContainChildren(candidateZone))
13
+ continue;
14
+ const candidateRect = resolveWorldZoneRect({
15
+ model,
16
+ layoutModel,
17
+ zoneId: candidateZone.id,
18
+ cache,
19
+ });
20
+ if (!candidateRect || !containsPoint(candidateRect, centerPoint)) {
21
+ continue;
22
+ }
23
+ const candidateDepth = getZoneDepth(model, candidateZone.id);
24
+ const candidateArea = getRectArea(candidateRect);
25
+ if (candidateDepth > bestDepth ||
26
+ (candidateDepth === bestDepth && candidateArea < bestArea)) {
27
+ parentZoneId = candidateZone.id;
28
+ bestDepth = candidateDepth;
29
+ bestArea = candidateArea;
30
+ }
31
+ }
32
+ return parentZoneId;
33
+ }
34
+ export function resolveZoneReparentCandidate(params) {
35
+ const { model, layoutModel, zoneId, } = params;
36
+ const zone = model.zonesById[zoneId];
37
+ const zoneLayout = getZoneLayout(layoutModel, zoneId);
38
+ if (!zone || !zoneLayout) {
39
+ return {
40
+ candidateParentZoneId: null,
41
+ currentParentZoneId: null,
42
+ worldRect: undefined,
43
+ };
44
+ }
45
+ const cache = new Map();
46
+ const worldRect = resolveWorldZoneRect({
47
+ model,
48
+ layoutModel,
49
+ zoneId,
50
+ cache,
51
+ });
52
+ if (!worldRect) {
53
+ return {
54
+ candidateParentZoneId: zone.parentZoneId,
55
+ currentParentZoneId: zone.parentZoneId,
56
+ worldRect: undefined,
57
+ };
58
+ }
59
+ const centerPoint = {
60
+ x: worldRect.x + worldRect.width / 2,
61
+ y: worldRect.y + worldRect.height / 2,
62
+ };
63
+ const invalidZoneIds = new Set(collectSubtreeZoneIds(model, zoneId));
64
+ const nextParentZoneId = resolveContainingParentZoneId({
65
+ model,
66
+ layoutModel,
67
+ centerPoint,
68
+ cache,
69
+ invalidZoneIds,
70
+ });
71
+ return {
72
+ candidateParentZoneId: nextParentZoneId,
73
+ currentParentZoneId: zone.parentZoneId,
74
+ worldRect,
75
+ };
76
+ }
77
+ export function resolveZonePlacementAtWorldRect(params) {
78
+ const { model, layoutModel, worldRect } = params;
79
+ const centerPoint = {
80
+ x: worldRect.x + worldRect.width / 2,
81
+ y: worldRect.y + worldRect.height / 2,
82
+ };
83
+ const cache = new Map();
84
+ const parentZoneId = resolveContainingParentZoneId({
85
+ model,
86
+ layoutModel,
87
+ centerPoint,
88
+ cache,
89
+ });
90
+ const parentOrigin = parentZoneId
91
+ ? resolveWorldZoneOrigin({
92
+ model,
93
+ layoutModel,
94
+ zoneId: parentZoneId,
95
+ })
96
+ : ROOT_WORLD_ORIGIN;
97
+ return {
98
+ parentZoneId,
99
+ x: roundCoordinate(worldRect.x - parentOrigin.x),
100
+ y: roundCoordinate(worldRect.y - parentOrigin.y),
101
+ worldRect,
102
+ };
103
+ }
104
+ export function commitZoneReparentAtCurrentPosition(params) {
105
+ const { model, layoutModel, zoneId, } = params;
106
+ const resolved = resolveZoneReparentCandidate({
107
+ model,
108
+ layoutModel,
109
+ zoneId,
110
+ });
111
+ if (!resolved.worldRect ||
112
+ resolved.candidateParentZoneId === resolved.currentParentZoneId) {
113
+ return {
114
+ model,
115
+ layoutModel,
116
+ nextParentZoneId: resolved.candidateParentZoneId,
117
+ didReparent: false,
118
+ };
119
+ }
120
+ const nextModel = moveZone(model, zoneId, resolved.candidateParentZoneId);
121
+ const nextParentOrigin = resolved.candidateParentZoneId
122
+ ? resolveWorldZoneOrigin({
123
+ model,
124
+ layoutModel,
125
+ zoneId: resolved.candidateParentZoneId,
126
+ })
127
+ : ROOT_WORLD_ORIGIN;
128
+ const nextLayoutModel = updateZoneLayout(layoutModel, zoneId, {
129
+ x: roundCoordinate(resolved.worldRect.x - nextParentOrigin.x),
130
+ y: roundCoordinate(resolved.worldRect.y - nextParentOrigin.y),
131
+ });
132
+ return {
133
+ model: nextModel,
134
+ layoutModel: nextLayoutModel,
135
+ nextParentZoneId: resolved.candidateParentZoneId,
136
+ didReparent: true,
137
+ };
138
+ }
139
+ export const reparentZoneAtCurrentPosition = commitZoneReparentAtCurrentPosition;
140
+ export function commitZoneGroupReparentAtCurrentPosition(params) {
141
+ const { model, layoutModel, zoneIds } = params;
142
+ const uniqueZoneIds = Array.from(new Set(zoneIds)).filter((zoneId) => Boolean(model.zonesById[zoneId]));
143
+ if (uniqueZoneIds.length === 0) {
144
+ return {
145
+ model,
146
+ layoutModel,
147
+ reparentedZoneIds: [],
148
+ didReparent: false,
149
+ };
150
+ }
151
+ const sortedZoneIds = [...uniqueZoneIds].sort((a, b) => getZoneDepth(model, a) - getZoneDepth(model, b));
152
+ let nextModel = model;
153
+ let nextLayoutModel = layoutModel;
154
+ const reparentedZoneIds = [];
155
+ for (const zoneId of sortedZoneIds) {
156
+ const result = commitZoneReparentAtCurrentPosition({
157
+ model: nextModel,
158
+ layoutModel: nextLayoutModel,
159
+ zoneId,
160
+ });
161
+ nextModel = result.model;
162
+ nextLayoutModel = result.layoutModel;
163
+ if (result.didReparent) {
164
+ reparentedZoneIds.push(zoneId);
165
+ }
166
+ }
167
+ return {
168
+ model: nextModel,
169
+ layoutModel: nextLayoutModel,
170
+ reparentedZoneIds,
171
+ didReparent: reparentedZoneIds.length > 0,
172
+ };
173
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@zoneflow/editor-dom",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git@github-groobee:groobee/zoneflow.git",
10
+ "directory": "packages/editor-dom"
11
+ },
12
+ "publishConfig": {
13
+ "registry": "https://registry.npmjs.org"
14
+ },
15
+ "files": ["dist"],
16
+ "dependencies": {
17
+ "@zoneflow/core": "workspace:*",
18
+ "@zoneflow/renderer-dom": "workspace:*"
19
+ },
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.json",
22
+ "type-check": "tsc -p tsconfig.json --noEmit"
23
+ }
24
+ }