@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.
- 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/App.tsx +30 -24
- package/src/components/StudioPreviewArea.tsx +101 -26
- package/src/components/StudioRightPanel.tsx +3 -0
- package/src/components/StudioToast.tsx +18 -0
- package/src/components/TimelineToolbar.tsx +230 -4
- package/src/components/editor/AnimationCard.tsx +68 -4
- package/src/components/editor/DomEditOverlay.tsx +70 -1
- package/src/components/editor/GridOverlay.tsx +50 -0
- package/src/components/editor/KeyframeDiamond.tsx +49 -0
- package/src/components/editor/KeyframeNavigation.tsx +139 -0
- package/src/components/editor/LayersPanel.test.ts +135 -0
- package/src/components/editor/LayersPanel.tsx +151 -15
- package/src/components/editor/PropertyPanel.tsx +293 -140
- package/src/components/editor/SnapGuideOverlay.tsx +166 -0
- package/src/components/editor/SnapToolbar.tsx +163 -0
- package/src/components/editor/SpringEaseEditor.tsx +256 -0
- package/src/components/editor/domEditOverlayGestures.ts +7 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
- package/src/components/editor/gsapAnimationConstants.ts +42 -0
- package/src/components/editor/gsapAnimationHelpers.ts +2 -1
- package/src/components/editor/manualEditingAvailability.ts +6 -0
- package/src/components/editor/manualEditsDom.ts +56 -2
- package/src/components/editor/manualOffsetDrag.ts +19 -3
- package/src/components/editor/propertyPanelHelpers.ts +90 -0
- package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
- package/src/components/editor/snapEngine.test.ts +657 -0
- package/src/components/editor/snapEngine.ts +575 -0
- package/src/components/editor/snapTargetCollection.ts +147 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
- package/src/components/editor/useLayerDrag.ts +213 -0
- package/src/components/nle/NLELayout.tsx +18 -0
- package/src/contexts/DomEditContext.tsx +27 -0
- package/src/hooks/gsapRuntimeBridge.ts +585 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
- package/src/hooks/useAppHotkeys.ts +63 -1
- package/src/hooks/useDomEditCommits.ts +88 -4
- package/src/hooks/useDomEditSession.ts +179 -65
- package/src/hooks/useGsapScriptCommits.ts +144 -7
- package/src/hooks/useGsapSelectionHandlers.ts +202 -0
- package/src/hooks/useGsapTweenCache.ts +174 -3
- package/src/hooks/useTimelineEditing.ts +93 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/ClipContextMenu.tsx +99 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
- package/src/player/components/Timeline.test.ts +2 -1
- package/src/player/components/Timeline.tsx +108 -68
- package/src/player/components/TimelineCanvas.tsx +47 -1
- package/src/player/components/TimelineClip.tsx +8 -3
- package/src/player/components/TimelineClipDiamonds.tsx +174 -0
- package/src/player/components/timelineDragDrop.ts +103 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/store/playerStore.ts +42 -0
- package/src/utils/editHistory.ts +1 -1
- package/src/utils/optimisticUpdate.test.ts +53 -0
- package/src/utils/optimisticUpdate.ts +18 -0
- package/src/utils/studioUiPreferences.ts +17 -0
- package/dist/assets/index-CrxThtSJ.css +0 -1
- 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(
|
|
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"
|
|
296
|
-
|
|
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
|
+
});
|