@hyperframes/studio 0.6.74 → 0.6.76

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/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-BcJO6Ej5.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-C2gBZ2km.css">
8
+ <script type="module" crossorigin src="/assets/index-IDqTMz4S.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.74",
3
+ "version": "0.6.76",
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.74",
35
- "@hyperframes/player": "0.6.74"
34
+ "@hyperframes/core": "0.6.76",
35
+ "@hyperframes/player": "0.6.76"
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.74"
49
+ "@hyperframes/producer": "0.6.76"
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 { domEditSelection, applyDomSelection, updateDomEditHoverSelection } = useDomEditContext();
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 selectedKey = domEditSelection ? getDomEditLayerKey(domEditSelection) : null;
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 className="min-h-0 flex-1 overflow-y-auto py-1">
211
- {visibleLayers.map((layer) => {
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
- onPointerEnter={() => handleLayerHover(layer)}
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 cursor-pointer items-center gap-1.5 px-2 py-1 text-left transition-colors ${
231
- selected
232
- ? "bg-studio-accent/14 text-studio-accent"
233
- : "text-neutral-300 hover:bg-white/[0.04] hover:text-neutral-100"
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,