@hyperframes/studio 0.6.74 → 0.6.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-DcyZuBcU.css +1 -0
- package/dist/assets/index-uB_W2GDl.js +140 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/components/editor/LayersPanel.test.ts +135 -0
- package/src/components/editor/LayersPanel.tsx +151 -15
- package/src/components/editor/useLayerDrag.ts +213 -0
- package/src/contexts/DomEditContext.tsx +3 -0
- package/src/hooks/useDomEditCommits.ts +49 -0
- package/src/hooks/useDomEditSession.ts +2 -2
- package/dist/assets/index-BcJO6Ej5.js +0 -140
- package/dist/assets/index-C2gBZ2km.css +0 -1
package/dist/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<title>HyperFrames Studio</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-uB_W2GDl.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DcyZuBcU.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.75",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"@codemirror/view": "6.40.0",
|
|
32
32
|
"@phosphor-icons/react": "^2.1.10",
|
|
33
33
|
"mediabunny": "^1.45.3",
|
|
34
|
-
"@hyperframes/core": "0.6.
|
|
35
|
-
"@hyperframes/player": "0.6.
|
|
34
|
+
"@hyperframes/core": "0.6.75",
|
|
35
|
+
"@hyperframes/player": "0.6.75"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/react": "19",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"vite": "^6.4.2",
|
|
47
47
|
"vitest": "^3.2.4",
|
|
48
48
|
"zustand": "^5.0.0",
|
|
49
|
-
"@hyperframes/producer": "0.6.
|
|
49
|
+
"@hyperframes/producer": "0.6.75"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"react": "19",
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { Window } from "happy-dom";
|
|
5
|
+
import type { DomEditLayerItem } from "./domEditingTypes";
|
|
6
|
+
import { sortLayersByZIndex } from "./LayersPanel";
|
|
7
|
+
import { isLayerDraggable } from "./useLayerDrag";
|
|
8
|
+
|
|
9
|
+
function makeLayer(
|
|
10
|
+
overrides: Partial<DomEditLayerItem> & { zIndex?: string; locked?: boolean },
|
|
11
|
+
): DomEditLayerItem {
|
|
12
|
+
const win = new Window();
|
|
13
|
+
const doc = win.document;
|
|
14
|
+
const parent = doc.createElement("div") as unknown as HTMLElement;
|
|
15
|
+
if (overrides.locked) {
|
|
16
|
+
(parent as unknown as Element).setAttribute("data-timeline-locked", "true");
|
|
17
|
+
}
|
|
18
|
+
const el = doc.createElement("div") as unknown as HTMLElement;
|
|
19
|
+
parent.appendChild(el as unknown as Node);
|
|
20
|
+
if (overrides.zIndex != null) {
|
|
21
|
+
(el as unknown as { style: { zIndex: string } }).style.zIndex = overrides.zIndex;
|
|
22
|
+
}
|
|
23
|
+
if (overrides.id) {
|
|
24
|
+
(el as unknown as Element).setAttribute("id", overrides.id);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
key: overrides.key ?? `layer-${Math.random()}`,
|
|
28
|
+
element: el,
|
|
29
|
+
label: overrides.label ?? "div",
|
|
30
|
+
tagName: overrides.tagName ?? "div",
|
|
31
|
+
depth: overrides.depth ?? 0,
|
|
32
|
+
childCount: overrides.childCount ?? 0,
|
|
33
|
+
id: overrides.id,
|
|
34
|
+
selector: overrides.selector,
|
|
35
|
+
selectorIndex: overrides.selectorIndex,
|
|
36
|
+
sourceFile: overrides.sourceFile ?? "index.html",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("sortLayersByZIndex", () => {
|
|
41
|
+
it("sorts siblings by z-index descending", () => {
|
|
42
|
+
const a = makeLayer({ key: "a", zIndex: "1", depth: 0 });
|
|
43
|
+
const b = makeLayer({ key: "b", zIndex: "3", depth: 0 });
|
|
44
|
+
const c = makeLayer({ key: "c", zIndex: "2", depth: 0 });
|
|
45
|
+
|
|
46
|
+
const sorted = sortLayersByZIndex([a, b, c]);
|
|
47
|
+
expect(sorted.map((l) => l.key)).toEqual(["b", "c", "a"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("preserves DOM order (reversed) for siblings with auto z-index", () => {
|
|
51
|
+
const a = makeLayer({ key: "a", depth: 0 });
|
|
52
|
+
const b = makeLayer({ key: "b", depth: 0 });
|
|
53
|
+
const c = makeLayer({ key: "c", depth: 0 });
|
|
54
|
+
|
|
55
|
+
const sorted = sortLayersByZIndex([a, b, c]);
|
|
56
|
+
expect(sorted.map((l) => l.key)).toEqual(["c", "b", "a"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("sorts explicit z-index above auto, auto elements maintain reversed DOM order", () => {
|
|
60
|
+
const a = makeLayer({ key: "a", depth: 0 });
|
|
61
|
+
const b = makeLayer({ key: "b", zIndex: "5", depth: 0 });
|
|
62
|
+
const c = makeLayer({ key: "c", depth: 0 });
|
|
63
|
+
|
|
64
|
+
const sorted = sortLayersByZIndex([a, b, c]);
|
|
65
|
+
expect(sorted.map((l) => l.key)).toEqual(["b", "c", "a"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("sorts children independently of their parent's siblings", () => {
|
|
69
|
+
const parent1 = makeLayer({ key: "p1", zIndex: "1", depth: 0, childCount: 2 });
|
|
70
|
+
const child1a = makeLayer({ key: "c1a", zIndex: "3", depth: 1 });
|
|
71
|
+
const child1b = makeLayer({ key: "c1b", zIndex: "1", depth: 1 });
|
|
72
|
+
const parent2 = makeLayer({ key: "p2", zIndex: "2", depth: 0, childCount: 1 });
|
|
73
|
+
const child2a = makeLayer({ key: "c2a", zIndex: "1", depth: 1 });
|
|
74
|
+
|
|
75
|
+
const sorted = sortLayersByZIndex([parent1, child1a, child1b, parent2, child2a]);
|
|
76
|
+
expect(sorted.map((l) => l.key)).toEqual(["p2", "c2a", "p1", "c1a", "c1b"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("handles single-element groups without crash", () => {
|
|
80
|
+
const single = makeLayer({ key: "only", zIndex: "5", depth: 0 });
|
|
81
|
+
const sorted = sortLayersByZIndex([single]);
|
|
82
|
+
expect(sorted).toEqual([single]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns empty array for empty input", () => {
|
|
86
|
+
expect(sortLayersByZIndex([])).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("handles duplicate z-index values with reverse DOM order tiebreak", () => {
|
|
90
|
+
const a = makeLayer({ key: "a", zIndex: "2", depth: 0 });
|
|
91
|
+
const b = makeLayer({ key: "b", zIndex: "1", depth: 0 });
|
|
92
|
+
const c = makeLayer({ key: "c", zIndex: "2", depth: 0 });
|
|
93
|
+
|
|
94
|
+
const sorted = sortLayersByZIndex([a, b, c]);
|
|
95
|
+
expect(sorted.map((l) => l.key)).toEqual(["c", "a", "b"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("preserves deeply nested structure with sorting at each level", () => {
|
|
99
|
+
const root = makeLayer({ key: "root", depth: 0, childCount: 2 });
|
|
100
|
+
const a = makeLayer({ key: "a", zIndex: "1", depth: 1, childCount: 2 });
|
|
101
|
+
const a1 = makeLayer({ key: "a1", zIndex: "10", depth: 2 });
|
|
102
|
+
const a2 = makeLayer({ key: "a2", zIndex: "20", depth: 2 });
|
|
103
|
+
const b = makeLayer({ key: "b", zIndex: "2", depth: 1 });
|
|
104
|
+
|
|
105
|
+
const sorted = sortLayersByZIndex([root, a, a1, a2, b]);
|
|
106
|
+
expect(sorted.map((l) => l.key)).toEqual(["root", "b", "a", "a2", "a1"]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("isLayerDraggable", () => {
|
|
111
|
+
it("returns false for layers without id or selector", () => {
|
|
112
|
+
const layer = makeLayer({ key: "anon" });
|
|
113
|
+
expect(isLayerDraggable(layer)).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns true for layers with an id", () => {
|
|
117
|
+
const layer = makeLayer({ key: "with-id", id: "my-el" });
|
|
118
|
+
expect(isLayerDraggable(layer)).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns true for layers with a selector", () => {
|
|
122
|
+
const layer = makeLayer({ key: "with-sel", selector: ".my-class" });
|
|
123
|
+
expect(isLayerDraggable(layer)).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns false for layers inside a locked composition", () => {
|
|
127
|
+
const layer = makeLayer({ key: "locked", id: "locked-el", locked: true });
|
|
128
|
+
expect(isLayerDraggable(layer)).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns true for layers with id and no locked ancestor", () => {
|
|
132
|
+
const layer = makeLayer({ key: "free", id: "free-el" });
|
|
133
|
+
expect(isLayerDraggable(layer)).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
resolveTimelineSelectionSeekTime,
|
|
14
14
|
} from "../../utils/studioHelpers";
|
|
15
15
|
import { Layers } from "../../icons/SystemIcons";
|
|
16
|
+
import { useLayerDrag, isLayerDraggable, type LayerReorderEvent } from "./useLayerDrag";
|
|
16
17
|
|
|
17
18
|
const TAG_ICONS: Record<string, string> = {
|
|
18
19
|
video: "Vi",
|
|
@@ -51,6 +52,7 @@ interface CollapsedState {
|
|
|
51
52
|
[key: string]: boolean;
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
// fallow-ignore-next-line complexity
|
|
54
56
|
export const LayersPanel = memo(function LayersPanel() {
|
|
55
57
|
const {
|
|
56
58
|
previewIframeRef,
|
|
@@ -59,12 +61,19 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
59
61
|
compositionLoading,
|
|
60
62
|
timelineElements,
|
|
61
63
|
currentTime,
|
|
64
|
+
showToast,
|
|
62
65
|
} = useStudioContext();
|
|
63
|
-
const {
|
|
66
|
+
const {
|
|
67
|
+
domEditSelection,
|
|
68
|
+
applyDomSelection,
|
|
69
|
+
updateDomEditHoverSelection,
|
|
70
|
+
handleDomZIndexReorderCommit,
|
|
71
|
+
} = useDomEditContext();
|
|
64
72
|
|
|
65
73
|
const [layers, setLayers] = useState<DomEditLayerItem[]>([]);
|
|
66
74
|
const [collapsed, setCollapsed] = useState<CollapsedState>({});
|
|
67
75
|
const prevDocVersionRef = useRef(0);
|
|
76
|
+
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
68
77
|
|
|
69
78
|
const isMasterView = !activeCompPath || activeCompPath === "index.html";
|
|
70
79
|
|
|
@@ -87,7 +96,7 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
87
96
|
activeCompositionPath: activeCompPath,
|
|
88
97
|
isMasterView,
|
|
89
98
|
});
|
|
90
|
-
setLayers(items);
|
|
99
|
+
setLayers(sortLayersByZIndex(items));
|
|
91
100
|
}, [previewIframeRef, activeCompPath, isMasterView]);
|
|
92
101
|
|
|
93
102
|
useEffect(() => {
|
|
@@ -119,7 +128,6 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
119
128
|
isMasterView,
|
|
120
129
|
preferClipAncestor: false,
|
|
121
130
|
}),
|
|
122
|
-
// LayersPanel has no projectId; probe is skipped when projectId is absent
|
|
123
131
|
[activeCompPath, isMasterView],
|
|
124
132
|
);
|
|
125
133
|
|
|
@@ -130,8 +138,6 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
130
138
|
|
|
131
139
|
let matchedId = findMatchingTimelineElementId(selection, timelineElements);
|
|
132
140
|
|
|
133
|
-
// No direct match — walk up DOM ancestors to find the nearest element
|
|
134
|
-
// that has a timeline entry (e.g. a child of scene1 seeks to scene1.start)
|
|
135
141
|
if (!matchedId) {
|
|
136
142
|
const sourceFile = selection.sourceFile ?? "index.html";
|
|
137
143
|
let ancestor = layer.element.parentElement;
|
|
@@ -185,10 +191,52 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
185
191
|
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
186
192
|
}, []);
|
|
187
193
|
|
|
188
|
-
const
|
|
194
|
+
const handleReorder = useCallback(
|
|
195
|
+
(event: LayerReorderEvent) => {
|
|
196
|
+
const { siblingLayers, fromIndex, toIndex } = event;
|
|
197
|
+
const reordered = [...siblingLayers];
|
|
198
|
+
const [moved] = reordered.splice(fromIndex, 1);
|
|
199
|
+
reordered.splice(toIndex, 0, moved);
|
|
200
|
+
|
|
201
|
+
const existingValues = siblingLayers.map((l) => getElementZIndex(l.element));
|
|
202
|
+
const sorted = [...existingValues].sort((a, b) => b - a);
|
|
203
|
+
const hasDupes = sorted.some((v, i) => i > 0 && v === sorted[i - 1]);
|
|
204
|
+
const zValues = hasDupes ? reordered.map((_, i) => reordered.length - i) : sorted;
|
|
205
|
+
|
|
206
|
+
const entries = reordered.map((layer, i) => ({
|
|
207
|
+
element: layer.element,
|
|
208
|
+
zIndex: zValues[i],
|
|
209
|
+
id: layer.id,
|
|
210
|
+
selector: layer.selector,
|
|
211
|
+
selectorIndex: layer.selectorIndex,
|
|
212
|
+
sourceFile: layer.sourceFile,
|
|
213
|
+
}));
|
|
214
|
+
|
|
215
|
+
handleDomZIndexReorderCommit(entries);
|
|
216
|
+
},
|
|
217
|
+
[handleDomZIndexReorderCommit],
|
|
218
|
+
);
|
|
189
219
|
|
|
220
|
+
const selectedKey = domEditSelection ? getDomEditLayerKey(domEditSelection) : null;
|
|
190
221
|
const visibleLayers = getVisibleLayers(layers, collapsed);
|
|
191
222
|
|
|
223
|
+
const handleSingleSibling = useCallback(() => {
|
|
224
|
+
showToast("Only one layer at this level", "info");
|
|
225
|
+
}, [showToast]);
|
|
226
|
+
|
|
227
|
+
const {
|
|
228
|
+
dragKey,
|
|
229
|
+
insertionLineY,
|
|
230
|
+
handleRowPointerDown,
|
|
231
|
+
handleContainerPointerMove,
|
|
232
|
+
handleContainerPointerUp,
|
|
233
|
+
} = useLayerDrag({
|
|
234
|
+
visibleLayers,
|
|
235
|
+
scrollContainerRef,
|
|
236
|
+
onReorder: handleReorder,
|
|
237
|
+
onSingleSibling: handleSingleSibling,
|
|
238
|
+
});
|
|
239
|
+
|
|
192
240
|
if (layers.length === 0) {
|
|
193
241
|
return (
|
|
194
242
|
<div className="flex h-full flex-col items-center justify-center bg-neutral-900 px-6 text-center">
|
|
@@ -207,9 +255,17 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
207
255
|
<div className="border-b border-white/10 px-3 py-2 text-[11px] text-neutral-500">
|
|
208
256
|
{layers.length} layer{layers.length === 1 ? "" : "s"}
|
|
209
257
|
</div>
|
|
210
|
-
<div
|
|
211
|
-
{
|
|
258
|
+
<div
|
|
259
|
+
ref={scrollContainerRef}
|
|
260
|
+
className="relative min-h-0 flex-1 overflow-y-auto py-1"
|
|
261
|
+
onPointerMove={handleContainerPointerMove}
|
|
262
|
+
onPointerUp={handleContainerPointerUp}
|
|
263
|
+
onPointerCancel={handleContainerPointerUp}
|
|
264
|
+
>
|
|
265
|
+
{visibleLayers.map((layer, index) => {
|
|
212
266
|
const selected = layer.key === selectedKey;
|
|
267
|
+
const isDragged = layer.key === dragKey;
|
|
268
|
+
const draggable = isLayerDraggable(layer);
|
|
213
269
|
const isCollapsed = collapsed[layer.key] ?? false;
|
|
214
270
|
const hasChildren = layer.childCount > 0;
|
|
215
271
|
const isCompHost = isCompositionHost(layer.element);
|
|
@@ -217,21 +273,25 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
217
273
|
return (
|
|
218
274
|
<div
|
|
219
275
|
key={layer.key}
|
|
276
|
+
data-layer-index={index}
|
|
220
277
|
role="button"
|
|
221
278
|
tabIndex={0}
|
|
222
|
-
onClick={() => handleSelectLayer(layer)}
|
|
223
|
-
|
|
279
|
+
onClick={() => !dragKey && handleSelectLayer(layer)}
|
|
280
|
+
onPointerDown={(e) => handleRowPointerDown(index, e)}
|
|
281
|
+
onPointerEnter={() => !dragKey && handleLayerHover(layer)}
|
|
224
282
|
onKeyDown={(e) => {
|
|
225
283
|
if (e.key === "Enter" || e.key === " ") {
|
|
226
284
|
e.preventDefault();
|
|
227
285
|
handleSelectLayer(layer);
|
|
228
286
|
}
|
|
229
287
|
}}
|
|
230
|
-
className={`group flex w-full
|
|
231
|
-
|
|
232
|
-
? "
|
|
233
|
-
:
|
|
234
|
-
|
|
288
|
+
className={`group flex w-full items-center gap-1.5 px-2 py-1 text-left transition-colors ${
|
|
289
|
+
isDragged
|
|
290
|
+
? "opacity-40"
|
|
291
|
+
: selected
|
|
292
|
+
? "bg-studio-accent/14 text-studio-accent"
|
|
293
|
+
: "text-neutral-300 hover:bg-white/[0.04] hover:text-neutral-100"
|
|
294
|
+
} ${dragKey ? "cursor-grabbing" : draggable ? "cursor-pointer" : "cursor-not-allowed opacity-50"}`}
|
|
235
295
|
style={{ paddingLeft: 8 + layer.depth * 16 }}
|
|
236
296
|
>
|
|
237
297
|
{hasChildren ? (
|
|
@@ -271,11 +331,87 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
271
331
|
</div>
|
|
272
332
|
);
|
|
273
333
|
})}
|
|
334
|
+
{insertionLineY != null && (
|
|
335
|
+
<div
|
|
336
|
+
className="pointer-events-none absolute left-2 right-2 h-0.5 bg-studio-accent"
|
|
337
|
+
style={{ top: insertionLineY }}
|
|
338
|
+
/>
|
|
339
|
+
)}
|
|
274
340
|
</div>
|
|
275
341
|
</div>
|
|
276
342
|
);
|
|
277
343
|
});
|
|
278
344
|
|
|
345
|
+
// ── Pure helpers ──────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
// fallow-ignore-next-line complexity
|
|
348
|
+
function getElementZIndex(element: HTMLElement): number {
|
|
349
|
+
try {
|
|
350
|
+
const inline = element.style?.zIndex;
|
|
351
|
+
if (inline && inline !== "auto") {
|
|
352
|
+
const parsed = parseInt(inline, 10);
|
|
353
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
354
|
+
}
|
|
355
|
+
const win = element.ownerDocument?.defaultView;
|
|
356
|
+
if (!win) return 0;
|
|
357
|
+
const value = win.getComputedStyle(element).zIndex;
|
|
358
|
+
if (value === "auto" || value === "") return 0;
|
|
359
|
+
const parsed = parseInt(value, 10);
|
|
360
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
361
|
+
} catch {
|
|
362
|
+
return 0;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// fallow-ignore-next-line complexity
|
|
367
|
+
export function sortLayersByZIndex(layers: DomEditLayerItem[]): DomEditLayerItem[] {
|
|
368
|
+
if (layers.length <= 1) return layers;
|
|
369
|
+
|
|
370
|
+
const minDepth = layers[0].depth;
|
|
371
|
+
for (let i = 1; i < layers.length; i++) {
|
|
372
|
+
if (layers[i].depth < minDepth) return layers;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const chunks: Array<{ root: DomEditLayerItem; children: DomEditLayerItem[]; domIndex: number }> =
|
|
376
|
+
[];
|
|
377
|
+
|
|
378
|
+
for (let i = 0; i < layers.length; i++) {
|
|
379
|
+
if (layers[i].depth === minDepth) {
|
|
380
|
+
const children: DomEditLayerItem[] = [];
|
|
381
|
+
let j = i + 1;
|
|
382
|
+
while (j < layers.length && layers[j].depth > minDepth) {
|
|
383
|
+
children.push(layers[j]);
|
|
384
|
+
j++;
|
|
385
|
+
}
|
|
386
|
+
chunks.push({ root: layers[i], children, domIndex: chunks.length });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (chunks.length <= 1) {
|
|
391
|
+
if (chunks.length === 1 && chunks[0].children.length > 0) {
|
|
392
|
+
const sorted = sortLayersByZIndex(chunks[0].children);
|
|
393
|
+
return [chunks[0].root, ...sorted];
|
|
394
|
+
}
|
|
395
|
+
return layers;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
chunks.sort((a, b) => {
|
|
399
|
+
const zA = getElementZIndex(a.root.element);
|
|
400
|
+
const zB = getElementZIndex(b.root.element);
|
|
401
|
+
if (zA !== zB) return zB - zA;
|
|
402
|
+
return b.domIndex - a.domIndex;
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const result: DomEditLayerItem[] = [];
|
|
406
|
+
for (const chunk of chunks) {
|
|
407
|
+
result.push(chunk.root);
|
|
408
|
+
if (chunk.children.length > 0) {
|
|
409
|
+
result.push(...sortLayersByZIndex(chunk.children));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return result;
|
|
413
|
+
}
|
|
414
|
+
|
|
279
415
|
function getVisibleLayers(
|
|
280
416
|
layers: DomEditLayerItem[],
|
|
281
417
|
collapsed: CollapsedState,
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { useRef, useState, useCallback } from "react";
|
|
2
|
+
import type { DomEditLayerItem } from "./domEditingTypes";
|
|
3
|
+
|
|
4
|
+
const DRAG_THRESHOLD_PX = 4;
|
|
5
|
+
|
|
6
|
+
interface DragState {
|
|
7
|
+
pointerId: number;
|
|
8
|
+
startY: number;
|
|
9
|
+
dragLayerIndex: number;
|
|
10
|
+
siblingIndices: number[];
|
|
11
|
+
fromSiblingPos: number;
|
|
12
|
+
insertSiblingPos: number;
|
|
13
|
+
siblingRects: DOMRect[];
|
|
14
|
+
activated: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LayerReorderEvent {
|
|
18
|
+
siblingLayers: DomEditLayerItem[];
|
|
19
|
+
fromIndex: number;
|
|
20
|
+
toIndex: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UseLayerDragOptions {
|
|
24
|
+
visibleLayers: DomEditLayerItem[];
|
|
25
|
+
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
|
26
|
+
onReorder: (event: LayerReorderEvent) => void;
|
|
27
|
+
onSingleSibling?: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UseLayerDragReturn {
|
|
31
|
+
dragKey: string | null;
|
|
32
|
+
insertionLineY: number | null;
|
|
33
|
+
handleRowPointerDown: (layerIndex: number, e: React.PointerEvent) => void;
|
|
34
|
+
handleContainerPointerMove: (e: React.PointerEvent) => void;
|
|
35
|
+
handleContainerPointerUp: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isLayerDraggable(layer: DomEditLayerItem): boolean {
|
|
39
|
+
if (!(layer.selector || layer.id)) return false;
|
|
40
|
+
let el: HTMLElement | null = layer.element;
|
|
41
|
+
while (el) {
|
|
42
|
+
if (el.hasAttribute("data-timeline-locked")) return false;
|
|
43
|
+
el = el.parentElement;
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findSiblingIndices(visibleLayers: DomEditLayerItem[], layerIndex: number): number[] {
|
|
49
|
+
const depth = visibleLayers[layerIndex].depth;
|
|
50
|
+
const indices: number[] = [];
|
|
51
|
+
|
|
52
|
+
let start = layerIndex;
|
|
53
|
+
while (start > 0) {
|
|
54
|
+
start--;
|
|
55
|
+
if (visibleLayers[start].depth < depth) {
|
|
56
|
+
start++;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (let i = start; i < visibleLayers.length; i++) {
|
|
62
|
+
const d = visibleLayers[i].depth;
|
|
63
|
+
if (d < depth) break;
|
|
64
|
+
if (d === depth) indices.push(i);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return indices;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function measureSiblingRects(container: HTMLDivElement, siblingIndices: number[]): DOMRect[] {
|
|
71
|
+
const rows = container.querySelectorAll<HTMLElement>("[data-layer-index]");
|
|
72
|
+
const rects: DOMRect[] = [];
|
|
73
|
+
for (const idx of siblingIndices) {
|
|
74
|
+
for (const row of rows) {
|
|
75
|
+
if (row.dataset.layerIndex === String(idx)) {
|
|
76
|
+
rects.push(row.getBoundingClientRect());
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return rects;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function computeInsertionPos(clientY: number, siblingRects: DOMRect[]): number {
|
|
85
|
+
if (siblingRects.length === 0) return 0;
|
|
86
|
+
|
|
87
|
+
if (clientY <= siblingRects[0].top + siblingRects[0].height / 2) return 0;
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < siblingRects.length - 1; i++) {
|
|
90
|
+
const midpoint = (siblingRects[i].bottom + siblingRects[i + 1].top) / 2;
|
|
91
|
+
if (clientY <= midpoint) return i + 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const last = siblingRects[siblingRects.length - 1];
|
|
95
|
+
if (clientY <= last.top + last.height / 2) return siblingRects.length - 1;
|
|
96
|
+
|
|
97
|
+
return siblingRects.length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function computeInsertionLineY(
|
|
101
|
+
insertPos: number,
|
|
102
|
+
siblingRects: DOMRect[],
|
|
103
|
+
containerRect: DOMRect,
|
|
104
|
+
): number | null {
|
|
105
|
+
if (siblingRects.length === 0) return null;
|
|
106
|
+
if (insertPos <= 0) return siblingRects[0].top - containerRect.top;
|
|
107
|
+
if (insertPos >= siblingRects.length) {
|
|
108
|
+
return siblingRects[siblingRects.length - 1].bottom - containerRect.top;
|
|
109
|
+
}
|
|
110
|
+
return siblingRects[insertPos].top - containerRect.top;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function useLayerDrag({
|
|
114
|
+
visibleLayers,
|
|
115
|
+
scrollContainerRef,
|
|
116
|
+
onReorder,
|
|
117
|
+
onSingleSibling,
|
|
118
|
+
}: UseLayerDragOptions): UseLayerDragReturn {
|
|
119
|
+
const dragRef = useRef<DragState | null>(null);
|
|
120
|
+
const [dragKey, setDragKey] = useState<string | null>(null);
|
|
121
|
+
const [insertionLineY, setInsertionLineY] = useState<number | null>(null);
|
|
122
|
+
|
|
123
|
+
const handleRowPointerDown = useCallback(
|
|
124
|
+
(layerIndex: number, e: React.PointerEvent) => {
|
|
125
|
+
if (e.button !== 0) return;
|
|
126
|
+
const layer = visibleLayers[layerIndex];
|
|
127
|
+
if (!layer || !isLayerDraggable(layer)) return;
|
|
128
|
+
|
|
129
|
+
const siblingIndices = findSiblingIndices(visibleLayers, layerIndex);
|
|
130
|
+
if (siblingIndices.length <= 1) {
|
|
131
|
+
onSingleSibling?.();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const fromSiblingPos = siblingIndices.indexOf(layerIndex);
|
|
136
|
+
if (fromSiblingPos === -1) return;
|
|
137
|
+
|
|
138
|
+
const container = scrollContainerRef.current;
|
|
139
|
+
if (!container) return;
|
|
140
|
+
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
container.setPointerCapture(e.pointerId);
|
|
143
|
+
|
|
144
|
+
dragRef.current = {
|
|
145
|
+
pointerId: e.pointerId,
|
|
146
|
+
startY: e.clientY,
|
|
147
|
+
dragLayerIndex: layerIndex,
|
|
148
|
+
siblingIndices,
|
|
149
|
+
fromSiblingPos,
|
|
150
|
+
insertSiblingPos: fromSiblingPos,
|
|
151
|
+
siblingRects: measureSiblingRects(container, siblingIndices),
|
|
152
|
+
activated: false,
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
[visibleLayers, scrollContainerRef, onSingleSibling],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const handleContainerPointerMove = useCallback(
|
|
159
|
+
(e: React.PointerEvent) => {
|
|
160
|
+
const drag = dragRef.current;
|
|
161
|
+
if (!drag) return;
|
|
162
|
+
|
|
163
|
+
if (!drag.activated) {
|
|
164
|
+
if (Math.abs(e.clientY - drag.startY) < DRAG_THRESHOLD_PX) return;
|
|
165
|
+
drag.activated = true;
|
|
166
|
+
setDragKey(visibleLayers[drag.dragLayerIndex]?.key ?? null);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const insertPos = computeInsertionPos(e.clientY, drag.siblingRects);
|
|
170
|
+
drag.insertSiblingPos = insertPos;
|
|
171
|
+
|
|
172
|
+
const container = scrollContainerRef.current;
|
|
173
|
+
if (container) {
|
|
174
|
+
const containerRect = container.getBoundingClientRect();
|
|
175
|
+
setInsertionLineY(computeInsertionLineY(insertPos, drag.siblingRects, containerRect));
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
[visibleLayers, scrollContainerRef],
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const handleContainerPointerUp = useCallback(() => {
|
|
182
|
+
const drag = dragRef.current;
|
|
183
|
+
dragRef.current = null;
|
|
184
|
+
setDragKey(null);
|
|
185
|
+
setInsertionLineY(null);
|
|
186
|
+
|
|
187
|
+
if (!drag || !drag.activated) return;
|
|
188
|
+
|
|
189
|
+
const container = scrollContainerRef.current;
|
|
190
|
+
if (container) {
|
|
191
|
+
try {
|
|
192
|
+
container.releasePointerCapture(drag.pointerId);
|
|
193
|
+
} catch {
|
|
194
|
+
// already released
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let toPos = drag.insertSiblingPos;
|
|
199
|
+
if (toPos > drag.fromSiblingPos) toPos--;
|
|
200
|
+
if (toPos === drag.fromSiblingPos) return;
|
|
201
|
+
|
|
202
|
+
const siblingLayers = drag.siblingIndices.map((i) => visibleLayers[i]);
|
|
203
|
+
onReorder({ siblingLayers, fromIndex: drag.fromSiblingPos, toIndex: toPos });
|
|
204
|
+
}, [visibleLayers, scrollContainerRef, onReorder]);
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
dragKey,
|
|
208
|
+
insertionLineY,
|
|
209
|
+
handleRowPointerDown,
|
|
210
|
+
handleContainerPointerMove,
|
|
211
|
+
handleContainerPointerUp,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -32,6 +32,7 @@ export function DomEditProvider({
|
|
|
32
32
|
handleDomHtmlAttributeCommit,
|
|
33
33
|
handleDomPathOffsetCommit,
|
|
34
34
|
handleDomGroupPathOffsetCommit,
|
|
35
|
+
handleDomZIndexReorderCommit,
|
|
35
36
|
handleDomBoxSizeCommit,
|
|
36
37
|
handleDomRotationCommit,
|
|
37
38
|
handleDomManualEditsReset,
|
|
@@ -100,6 +101,7 @@ export function DomEditProvider({
|
|
|
100
101
|
handleDomHtmlAttributeCommit,
|
|
101
102
|
handleDomPathOffsetCommit,
|
|
102
103
|
handleDomGroupPathOffsetCommit,
|
|
104
|
+
handleDomZIndexReorderCommit,
|
|
103
105
|
handleDomBoxSizeCommit,
|
|
104
106
|
handleDomRotationCommit,
|
|
105
107
|
handleDomManualEditsReset,
|
|
@@ -162,6 +164,7 @@ export function DomEditProvider({
|
|
|
162
164
|
handleDomHtmlAttributeCommit,
|
|
163
165
|
handleDomPathOffsetCommit,
|
|
164
166
|
handleDomGroupPathOffsetCommit,
|
|
167
|
+
handleDomZIndexReorderCommit,
|
|
165
168
|
handleDomBoxSizeCommit,
|
|
166
169
|
handleDomRotationCommit,
|
|
167
170
|
handleDomManualEditsReset,
|