@hyperframes/studio 0.6.73 → 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.
Files changed (63) hide show
  1. package/dist/assets/index-DcyZuBcU.css +1 -0
  2. package/dist/assets/index-uB_W2GDl.js +140 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +30 -24
  6. package/src/components/StudioPreviewArea.tsx +101 -26
  7. package/src/components/StudioRightPanel.tsx +3 -0
  8. package/src/components/StudioToast.tsx +18 -0
  9. package/src/components/TimelineToolbar.tsx +230 -4
  10. package/src/components/editor/AnimationCard.tsx +68 -4
  11. package/src/components/editor/DomEditOverlay.tsx +70 -1
  12. package/src/components/editor/GridOverlay.tsx +50 -0
  13. package/src/components/editor/KeyframeDiamond.tsx +49 -0
  14. package/src/components/editor/KeyframeNavigation.tsx +139 -0
  15. package/src/components/editor/LayersPanel.test.ts +135 -0
  16. package/src/components/editor/LayersPanel.tsx +151 -15
  17. package/src/components/editor/PropertyPanel.tsx +293 -140
  18. package/src/components/editor/SnapGuideOverlay.tsx +166 -0
  19. package/src/components/editor/SnapToolbar.tsx +163 -0
  20. package/src/components/editor/SpringEaseEditor.tsx +256 -0
  21. package/src/components/editor/domEditOverlayGestures.ts +7 -0
  22. package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
  23. package/src/components/editor/gsapAnimationConstants.ts +42 -0
  24. package/src/components/editor/gsapAnimationHelpers.ts +2 -1
  25. package/src/components/editor/manualEditingAvailability.ts +6 -0
  26. package/src/components/editor/manualEditsDom.ts +56 -2
  27. package/src/components/editor/manualOffsetDrag.ts +19 -3
  28. package/src/components/editor/propertyPanelHelpers.ts +90 -0
  29. package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
  30. package/src/components/editor/snapEngine.test.ts +657 -0
  31. package/src/components/editor/snapEngine.ts +575 -0
  32. package/src/components/editor/snapTargetCollection.ts +147 -0
  33. package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
  34. package/src/components/editor/useLayerDrag.ts +213 -0
  35. package/src/components/nle/NLELayout.tsx +18 -0
  36. package/src/contexts/DomEditContext.tsx +27 -0
  37. package/src/hooks/gsapRuntimeBridge.ts +585 -0
  38. package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
  39. package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
  40. package/src/hooks/useAppHotkeys.ts +63 -1
  41. package/src/hooks/useDomEditCommits.ts +88 -4
  42. package/src/hooks/useDomEditSession.ts +179 -65
  43. package/src/hooks/useGsapScriptCommits.ts +144 -7
  44. package/src/hooks/useGsapSelectionHandlers.ts +202 -0
  45. package/src/hooks/useGsapTweenCache.ts +174 -3
  46. package/src/hooks/useTimelineEditing.ts +93 -0
  47. package/src/icons/SystemIcons.tsx +2 -0
  48. package/src/player/components/ClipContextMenu.tsx +99 -0
  49. package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
  50. package/src/player/components/Timeline.test.ts +2 -1
  51. package/src/player/components/Timeline.tsx +108 -68
  52. package/src/player/components/TimelineCanvas.tsx +47 -1
  53. package/src/player/components/TimelineClip.tsx +8 -3
  54. package/src/player/components/TimelineClipDiamonds.tsx +174 -0
  55. package/src/player/components/timelineDragDrop.ts +103 -0
  56. package/src/player/components/timelineLayout.ts +1 -1
  57. package/src/player/store/playerStore.ts +42 -0
  58. package/src/utils/editHistory.ts +1 -1
  59. package/src/utils/optimisticUpdate.test.ts +53 -0
  60. package/src/utils/optimisticUpdate.ts +18 -0
  61. package/src/utils/studioUiPreferences.ts +17 -0
  62. package/dist/assets/index-CrxThtSJ.css +0 -1
  63. package/dist/assets/index-Dc2HfqON.js +0 -140
@@ -9,13 +9,29 @@ import {
9
9
  METHOD_LABELS,
10
10
  METHOD_TOOLTIPS,
11
11
  PERCENT_PROPS,
12
+ PROP_CONSTRAINTS,
12
13
  PROP_LABELS,
13
14
  PROP_TOOLTIPS,
14
15
  PROP_UNITS,
16
+ clampPropertyValue,
15
17
  } from "./gsapAnimationConstants";
16
18
  import { buildTweenSummary } from "./gsapAnimationHelpers";
17
19
  import { EaseCurveSection } from "./EaseCurveSection";
18
20
  const BOOLEAN_PROPS = new Set(["visibility"]);
21
+ const STRING_PROPS = new Set(["filter", "clipPath"]);
22
+
23
+ const FILTER_PRESETS = [
24
+ { label: "Blur", value: "blur(4px)" },
25
+ { label: "Bright", value: "brightness(1.5)" },
26
+ { label: "Gray", value: "grayscale(1)" },
27
+ { label: "None", value: "none" },
28
+ ];
29
+
30
+ const CLIP_PATH_PRESETS = [
31
+ { label: "Circle", value: "circle(50% at 50% 50%)" },
32
+ { label: "Inset", value: "inset(10%)" },
33
+ { label: "None", value: "none" },
34
+ ];
19
35
 
20
36
  function isPercentProp(prop: string): boolean {
21
37
  return PERCENT_PROPS.has(prop);
@@ -27,7 +43,11 @@ function displayValue(prop: string, val: number | string): string {
27
43
  }
28
44
 
29
45
  function adjustedValue(prop: string, raw: string): string {
30
- if (isPercentProp(prop)) return String(Math.max(0, Math.min(1, Number(raw) / 100)));
46
+ if (isPercentProp(prop)) return String(clampPropertyValue(prop, Number(raw) / 100));
47
+ const num = Number(raw);
48
+ if (!Number.isNaN(num) && PROP_CONSTRAINTS[prop]) {
49
+ return String(clampPropertyValue(prop, num));
50
+ }
31
51
  return raw;
32
52
  }
33
53
 
@@ -90,6 +110,48 @@ function PropertyRow({
90
110
  );
91
111
  }
92
112
 
113
+ if (STRING_PROPS.has(prop)) {
114
+ const presets =
115
+ prop === "filter" ? FILTER_PRESETS : prop === "clipPath" ? CLIP_PATH_PRESETS : [];
116
+ return (
117
+ <div className="flex flex-col gap-1">
118
+ <div className="flex items-center gap-1">
119
+ <div className="min-w-0 flex-1 flex items-center gap-2 px-2 py-1 rounded-lg bg-neutral-900 border border-neutral-800">
120
+ <span className="flex-shrink-0 text-[11px] font-medium text-neutral-500">
121
+ {PROP_LABELS[prop] ?? prop}
122
+ </span>
123
+ <input
124
+ type="text"
125
+ defaultValue={String(val)}
126
+ className="flex-1 bg-transparent text-[11px] text-neutral-200 outline-none"
127
+ onBlur={(e) => onCommit(e.currentTarget.value)}
128
+ onKeyDown={(e) => {
129
+ if (e.key === "Enter") {
130
+ e.currentTarget.blur();
131
+ }
132
+ }}
133
+ />
134
+ </div>
135
+ <RemoveButton onClick={onRemove} title={removeTitle} />
136
+ </div>
137
+ {presets.length > 0 && (
138
+ <div className="flex gap-1 pl-1">
139
+ {presets.map((p) => (
140
+ <button
141
+ key={p.value}
142
+ type="button"
143
+ onClick={() => onCommit(p.value)}
144
+ className="px-1.5 py-0.5 rounded text-[9px] font-medium text-neutral-500 bg-neutral-800/50 hover:bg-neutral-800 hover:text-neutral-300 transition-colors"
145
+ >
146
+ {p.label}
147
+ </button>
148
+ ))}
149
+ </div>
150
+ )}
151
+ </div>
152
+ );
153
+ }
154
+
93
155
  return (
94
156
  <div className="flex items-center gap-1">
95
157
  <div className="min-w-0 flex-1">
@@ -292,8 +354,10 @@ export const AnimationCard = memo(function AnimationCard({
292
354
  {methodLabel}
293
355
  </span>
294
356
  <span className="text-[11px] font-medium text-neutral-400" title="When this effect plays">
295
- {typeof animation.position === "number" ? `${animation.position}s` : animation.position} –{" "}
296
- {typeof endTime === "number" ? `${endTime.toFixed(1)}s` : endTime}
357
+ {typeof animation.position === "number"
358
+ ? `${parseFloat(animation.position.toFixed(3))}s`
359
+ : animation.position}{" "}
360
+ – {typeof endTime === "number" ? `${parseFloat(endTime.toFixed(3))}s` : endTime}
297
361
  </span>
298
362
  <span className="ml-auto text-[10px] text-neutral-500" title={easeName}>
299
363
  {easeLabel}
@@ -344,7 +408,7 @@ export const AnimationCard = memo(function AnimationCard({
344
408
  value={
345
409
  typeof animation.position === "string"
346
410
  ? animation.position
347
- : String(Math.max(0, animation.position))
411
+ : String(parseFloat(Math.max(0, animation.position).toFixed(3)))
348
412
  }
349
413
  suffix={typeof animation.position === "number" ? "s" : undefined}
350
414
  tooltip="When this effect begins on the timeline"
@@ -1,4 +1,5 @@
1
- import { memo, useMemo, useRef, type RefObject } from "react";
1
+ import { memo, useMemo, useRef, useState, type RefObject } from "react";
2
+ import { useMountEffect } from "../../hooks/useMountEffect";
2
3
  import { type DomEditSelection } from "./domEditing";
3
4
  import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
4
5
  import {
@@ -10,6 +11,8 @@ import {
10
11
  } from "./domEditOverlayGestures";
11
12
  import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
12
13
  import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
14
+ import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
15
+ import { GridOverlay } from "./GridOverlay";
13
16
 
14
17
  // Re-exports for external consumers — preserving existing import paths.
15
18
  export {
@@ -61,6 +64,8 @@ interface DomEditOverlayProps {
61
64
  next: { width: number; height: number },
62
65
  ) => Promise<void> | void;
63
66
  onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
67
+ gridVisible?: boolean;
68
+ gridSpacing?: number;
64
69
  }
65
70
 
66
71
  export const DomEditOverlay = memo(function DomEditOverlay({
@@ -75,6 +80,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
75
80
  onCanvasPointerLeave,
76
81
  onSelectionChange,
77
82
  onBlockedMove,
83
+ gridVisible = false,
84
+ gridSpacing = 50,
78
85
  onManualDragStart,
79
86
  onPathOffsetCommit,
80
87
  onGroupPathOffsetCommit,
@@ -89,6 +96,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
89
96
  const suppressNextBoxClickRef = useRef(false);
90
97
  const suppressNextBoxMouseDownRef = useRef(false);
91
98
  const suppressNextOverlayMouseDownRef = useRef(false);
99
+ const snapGuidesRef = useRef<SnapGuidesState | null>(null);
92
100
  const rafPausedRef = useRef(false);
93
101
 
94
102
  const selectionRef = useRef(selection);
@@ -136,6 +144,50 @@ export const DomEditOverlay = memo(function DomEditOverlay({
136
144
  rafPausedRef,
137
145
  });
138
146
 
147
+ const [compRect, setCompRect] = useState({
148
+ left: 0,
149
+ top: 0,
150
+ width: 0,
151
+ height: 0,
152
+ scaleX: 1,
153
+ scaleY: 1,
154
+ });
155
+ useMountEffect(() => {
156
+ let frame = 0;
157
+ // fallow-ignore-next-line complexity
158
+ const update = () => {
159
+ frame = requestAnimationFrame(update);
160
+ const iframe = iframeRef.current;
161
+ const overlayEl = overlayRef.current;
162
+ if (!iframe || !overlayEl) return;
163
+ const iRect = iframe.getBoundingClientRect();
164
+ const oRect = overlayEl.getBoundingClientRect();
165
+ const left = iRect.left - oRect.left;
166
+ const top = iRect.top - oRect.top;
167
+ if (iRect.width <= 0 || iRect.height <= 0) return;
168
+ const doc = iframe.contentDocument;
169
+ const root = doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement;
170
+ const dw = Number.parseFloat(root?.getAttribute("data-width") ?? "");
171
+ const dh = Number.parseFloat(root?.getAttribute("data-height") ?? "");
172
+ const scaleX = dw > 0 ? iRect.width / dw : 1;
173
+ const scaleY = dh > 0 ? iRect.height / dh : 1;
174
+ setCompRect((prev) => {
175
+ if (
176
+ Math.abs(prev.left - left) < 0.5 &&
177
+ Math.abs(prev.top - top) < 0.5 &&
178
+ Math.abs(prev.width - iRect.width) < 0.5 &&
179
+ Math.abs(prev.height - iRect.height) < 0.5 &&
180
+ Math.abs(prev.scaleX - scaleX) < 0.001 &&
181
+ Math.abs(prev.scaleY - scaleY) < 0.001
182
+ )
183
+ return prev;
184
+ return { left, top, width: iRect.width, height: iRect.height, scaleX, scaleY };
185
+ });
186
+ };
187
+ frame = requestAnimationFrame(update);
188
+ return () => cancelAnimationFrame(frame);
189
+ });
190
+
139
191
  const gestures = createDomEditOverlayGestureHandlers({
140
192
  overlayRef,
141
193
  iframeRef,
@@ -158,6 +210,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
158
210
  onRotationCommitRef,
159
211
  onCanvasPointerMoveRef,
160
212
  onCanvasMouseDown,
213
+ snapGuidesRef,
161
214
  });
162
215
 
163
216
  const selectionKey = useMemo(() => {
@@ -192,6 +245,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
192
245
  }
193
246
  };
194
247
 
248
+ // fallow-ignore-next-line complexity
195
249
  const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
196
250
  if (!allowCanvasMovement || event.button !== 0) return;
197
251
  if (event.shiftKey) {
@@ -387,6 +441,21 @@ export const DomEditOverlay = memo(function DomEditOverlay({
387
441
  </div>
388
442
  </>
389
443
  )}
444
+ <GridOverlay
445
+ visible={gridVisible}
446
+ spacing={gridSpacing}
447
+ scaleX={compRect.scaleX}
448
+ scaleY={compRect.scaleY}
449
+ compositionLeft={compRect.left}
450
+ compositionTop={compRect.top}
451
+ compositionWidth={compRect.width}
452
+ compositionHeight={compRect.height}
453
+ />
454
+ <SnapGuideOverlay
455
+ snapGuidesRef={snapGuidesRef}
456
+ overlayWidth={compRect.width}
457
+ overlayHeight={compRect.height}
458
+ />
390
459
  </div>
391
460
  );
392
461
  });
@@ -0,0 +1,50 @@
1
+ // fallow-ignore-file unused-file
2
+ import { memo } from "react";
3
+
4
+ interface GridOverlayProps {
5
+ visible: boolean;
6
+ spacing: number;
7
+ scaleX: number;
8
+ scaleY: number;
9
+ compositionLeft: number;
10
+ compositionTop: number;
11
+ compositionWidth: number;
12
+ compositionHeight: number;
13
+ }
14
+
15
+ // fallow-ignore-next-line complexity
16
+ export const GridOverlay = memo(function GridOverlay({
17
+ visible,
18
+ spacing,
19
+ scaleX,
20
+ scaleY,
21
+ compositionLeft,
22
+ compositionTop,
23
+ compositionWidth,
24
+ compositionHeight,
25
+ }: GridOverlayProps) {
26
+ if (!visible || spacing <= 0) return null;
27
+
28
+ const overlaySpacingX = spacing * scaleX;
29
+ const overlaySpacingY = spacing * scaleY;
30
+
31
+ if (overlaySpacingX < 4 || overlaySpacingY < 4) return null;
32
+
33
+ return (
34
+ <div
35
+ aria-hidden="true"
36
+ className="pointer-events-none absolute"
37
+ style={{
38
+ left: compositionLeft,
39
+ top: compositionTop,
40
+ width: compositionWidth,
41
+ height: compositionHeight,
42
+ backgroundImage: [
43
+ `repeating-linear-gradient(90deg, rgba(255,255,255,0.12) 0px, rgba(255,255,255,0.12) 1px, transparent 1px, transparent ${overlaySpacingX}px)`,
44
+ `repeating-linear-gradient(0deg, rgba(255,255,255,0.12) 0px, rgba(255,255,255,0.12) 1px, transparent 1px, transparent ${overlaySpacingY}px)`,
45
+ ].join(", "),
46
+ backgroundSize: `${overlaySpacingX}px ${overlaySpacingY}px`,
47
+ }}
48
+ />
49
+ );
50
+ });
@@ -0,0 +1,49 @@
1
+ import { memo } from "react";
2
+
3
+ export type DiamondState = "active" | "inactive" | "ghost";
4
+
5
+ interface KeyframeDiamondProps {
6
+ state: DiamondState;
7
+ onClick: () => void;
8
+ title?: string;
9
+ size?: number;
10
+ }
11
+
12
+ // fallow-ignore-next-line complexity
13
+ export const KeyframeDiamond = memo(function KeyframeDiamond({
14
+ state,
15
+ onClick,
16
+ title,
17
+ size = 10,
18
+ }: KeyframeDiamondProps) {
19
+ const isFilled = state === "active";
20
+ const opacity = state === "ghost" ? 0.25 : state === "inactive" ? 0.6 : 1;
21
+ const color = state === "active" ? "#3b82f6" : "#a3a3a3";
22
+
23
+ return (
24
+ <button
25
+ type="button"
26
+ onClick={(e) => {
27
+ e.stopPropagation();
28
+ onClick();
29
+ }}
30
+ className="flex-shrink-0 p-0.5 transition-opacity hover:opacity-100"
31
+ style={{ color, opacity }}
32
+ title={title}
33
+ >
34
+ <svg width={size} height={size} viewBox="0 0 10 10">
35
+ <rect
36
+ x="5"
37
+ y="0.7"
38
+ width="6"
39
+ height="6"
40
+ rx="1"
41
+ transform="rotate(45 5 0.7)"
42
+ fill={isFilled ? "currentColor" : "none"}
43
+ stroke="currentColor"
44
+ strokeWidth="1.2"
45
+ />
46
+ </svg>
47
+ </button>
48
+ );
49
+ });
@@ -0,0 +1,139 @@
1
+ import { memo } from "react";
2
+ import { KeyframeDiamond, type DiamondState } from "./KeyframeDiamond";
3
+
4
+ interface KeyframeNavigationProps {
5
+ property: string;
6
+ /** All keyframes for this element's tween, or null if no keyframes exist */
7
+ keyframes: Array<{
8
+ percentage: number;
9
+ properties: Record<string, number | string>;
10
+ ease?: string;
11
+ }> | null;
12
+ /** Current playhead percentage within the element's lifetime (0-100) */
13
+ currentPercentage: number;
14
+ onSeek: (percentage: number) => void;
15
+ onAddKeyframe: (percentage: number) => void;
16
+ onRemoveKeyframe: (percentage: number) => void;
17
+ onConvertToKeyframes: () => void;
18
+ }
19
+
20
+ const TOLERANCE = 0.5;
21
+
22
+ function ArrowLeft({ disabled }: { disabled: boolean }) {
23
+ return (
24
+ <svg
25
+ width="6"
26
+ height="10"
27
+ viewBox="0 0 6 10"
28
+ fill="none"
29
+ style={{ opacity: disabled ? 0.25 : 1 }}
30
+ >
31
+ <path
32
+ d="M5 1L1 5L5 9"
33
+ stroke="#a3a3a3"
34
+ strokeWidth="1.4"
35
+ strokeLinecap="round"
36
+ strokeLinejoin="round"
37
+ />
38
+ </svg>
39
+ );
40
+ }
41
+
42
+ function ArrowRight({ disabled }: { disabled: boolean }) {
43
+ return (
44
+ <svg
45
+ width="6"
46
+ height="10"
47
+ viewBox="0 0 6 10"
48
+ fill="none"
49
+ style={{ opacity: disabled ? 0.25 : 1 }}
50
+ >
51
+ <path
52
+ d="M1 1L5 5L1 9"
53
+ stroke="#a3a3a3"
54
+ strokeWidth="1.4"
55
+ strokeLinecap="round"
56
+ strokeLinejoin="round"
57
+ />
58
+ </svg>
59
+ );
60
+ }
61
+
62
+ // fallow-ignore-next-line complexity
63
+ export const KeyframeNavigation = memo(function KeyframeNavigation({
64
+ property,
65
+ keyframes,
66
+ currentPercentage,
67
+ onSeek,
68
+ onAddKeyframe,
69
+ onRemoveKeyframe,
70
+ onConvertToKeyframes,
71
+ }: KeyframeNavigationProps) {
72
+ // Find keyframes that contain this property
73
+ const propertyKeyframes = keyframes?.filter((kf) => property in kf.properties) ?? [];
74
+
75
+ const prevKf =
76
+ propertyKeyframes.filter((kf) => kf.percentage < currentPercentage - TOLERANCE).at(-1) ?? null;
77
+
78
+ const nextKf =
79
+ propertyKeyframes.find((kf) => kf.percentage > currentPercentage + TOLERANCE) ?? null;
80
+
81
+ const atCurrent =
82
+ propertyKeyframes.find((kf) => Math.abs(kf.percentage - currentPercentage) <= TOLERANCE) ??
83
+ null;
84
+
85
+ // Diamond state
86
+ let diamondState: DiamondState;
87
+ if (!keyframes || keyframes.length === 0) {
88
+ diamondState = "ghost";
89
+ } else if (atCurrent) {
90
+ diamondState = "active";
91
+ } else if (propertyKeyframes.length > 0) {
92
+ diamondState = "inactive";
93
+ } else {
94
+ diamondState = "ghost";
95
+ }
96
+
97
+ const handleDiamondClick = () => {
98
+ if (diamondState === "ghost") {
99
+ onConvertToKeyframes();
100
+ } else if (diamondState === "active") {
101
+ onRemoveKeyframe(currentPercentage);
102
+ } else {
103
+ onAddKeyframe(currentPercentage);
104
+ }
105
+ };
106
+
107
+ return (
108
+ <div className="flex h-5 items-center gap-0.5">
109
+ <button
110
+ type="button"
111
+ disabled={!prevKf}
112
+ onClick={() => prevKf && onSeek(prevKf.percentage)}
113
+ className="flex h-5 w-3 items-center justify-center disabled:cursor-default"
114
+ >
115
+ <ArrowLeft disabled={!prevKf} />
116
+ </button>
117
+ <KeyframeDiamond
118
+ state={diamondState}
119
+ onClick={handleDiamondClick}
120
+ size={9}
121
+ title={
122
+ diamondState === "ghost"
123
+ ? `Convert ${property} to keyframes`
124
+ : diamondState === "active"
125
+ ? `Remove ${property} keyframe`
126
+ : `Add ${property} keyframe`
127
+ }
128
+ />
129
+ <button
130
+ type="button"
131
+ disabled={!nextKf}
132
+ onClick={() => nextKf && onSeek(nextKf.percentage)}
133
+ className="flex h-5 w-3 items-center justify-center disabled:cursor-default"
134
+ >
135
+ <ArrowRight disabled={!nextKf} />
136
+ </button>
137
+ </div>
138
+ );
139
+ });
@@ -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
+ });