@hyperframes/studio 0.6.72 → 0.6.74
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-BcJO6Ej5.js +140 -0
- package/dist/assets/index-C2gBZ2km.css +1 -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/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/nle/NLELayout.tsx +18 -0
- package/src/contexts/DomEditContext.tsx +24 -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 +39 -4
- package/src/hooks/useDomEditSession.ts +177 -63
- 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-CveQve6o.js +0 -140
|
@@ -3,25 +3,251 @@ import {
|
|
|
3
3
|
getTimelineZoomPercent,
|
|
4
4
|
} from "../player/components/timelineZoom";
|
|
5
5
|
import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
|
|
6
|
-
import { usePlayerStore } from "../player";
|
|
6
|
+
import { usePlayerStore, type TimelineElement } from "../player";
|
|
7
|
+
import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
|
|
7
8
|
import { Tooltip } from "./ui";
|
|
9
|
+
import { Scissors } from "../icons/SystemIcons";
|
|
10
|
+
import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
|
11
|
+
import type { DomEditSelection } from "./editor/domEditingTypes";
|
|
12
|
+
|
|
13
|
+
function interpolateKeyframeProperties(
|
|
14
|
+
keyframes: GsapPercentageKeyframe[],
|
|
15
|
+
pct: number,
|
|
16
|
+
): Record<string, number> {
|
|
17
|
+
const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
|
|
18
|
+
const allProps = new Set<string>();
|
|
19
|
+
for (const kf of sorted) {
|
|
20
|
+
for (const p of Object.keys(kf.properties)) {
|
|
21
|
+
if (typeof kf.properties[p] === "number") allProps.add(p);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const result: Record<string, number> = {};
|
|
25
|
+
for (const prop of allProps) {
|
|
26
|
+
let prev: { pct: number; val: number } | null = null;
|
|
27
|
+
let next: { pct: number; val: number } | null = null;
|
|
28
|
+
for (const kf of sorted) {
|
|
29
|
+
const v = kf.properties[prop];
|
|
30
|
+
if (typeof v !== "number") continue;
|
|
31
|
+
if (kf.percentage <= pct) prev = { pct: kf.percentage, val: v };
|
|
32
|
+
if (kf.percentage >= pct && !next) next = { pct: kf.percentage, val: v };
|
|
33
|
+
}
|
|
34
|
+
if (prev && next && prev.pct !== next.pct) {
|
|
35
|
+
const t = (pct - prev.pct) / (next.pct - prev.pct);
|
|
36
|
+
result[prop] = Math.round(prev.val + t * (next.val - prev.val));
|
|
37
|
+
} else if (prev) {
|
|
38
|
+
result[prop] = Math.round(prev.val);
|
|
39
|
+
} else if (next) {
|
|
40
|
+
result[prop] = Math.round(next.val);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readRuntimeKeyframeValues(
|
|
47
|
+
iframe: HTMLIFrameElement | null,
|
|
48
|
+
sel: DomEditSelection,
|
|
49
|
+
keyframes: GsapPercentageKeyframe[],
|
|
50
|
+
): Record<string, number> {
|
|
51
|
+
if (!iframe?.contentWindow) return {};
|
|
52
|
+
let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined;
|
|
53
|
+
try {
|
|
54
|
+
gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap;
|
|
55
|
+
} catch {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
if (!gsap?.getProperty) return {};
|
|
59
|
+
const selector = sel.id ? `#${sel.id}` : sel.selector;
|
|
60
|
+
if (!selector) return {};
|
|
61
|
+
let doc: Document | null = null;
|
|
62
|
+
try {
|
|
63
|
+
doc = iframe.contentDocument;
|
|
64
|
+
} catch {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
const element = doc?.querySelector(selector);
|
|
68
|
+
if (!element) return {};
|
|
69
|
+
const allProps = new Set<string>();
|
|
70
|
+
for (const kf of keyframes) {
|
|
71
|
+
for (const p of Object.keys(kf.properties)) {
|
|
72
|
+
if (typeof kf.properties[p] === "number") allProps.add(p);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const result: Record<string, number> = {};
|
|
76
|
+
for (const prop of allProps) {
|
|
77
|
+
const val = Number(gsap.getProperty(element, prop));
|
|
78
|
+
if (Number.isFinite(val)) result[prop] = Math.round(val);
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface DomEditSessionSlice {
|
|
84
|
+
domEditSelection: DomEditSelection | null;
|
|
85
|
+
selectedGsapAnimations: GsapAnimation[];
|
|
86
|
+
handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
|
|
87
|
+
handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void;
|
|
88
|
+
handleGsapConvertToKeyframes: (animId: string) => void;
|
|
89
|
+
handleGsapMaterializeKeyframes?: (animId: string) => Promise<void>;
|
|
90
|
+
handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
|
91
|
+
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
|
|
92
|
+
}
|
|
8
93
|
|
|
9
94
|
interface TimelineToolbarProps {
|
|
10
95
|
toggleTimelineVisibility: () => void;
|
|
96
|
+
domEditSession?: DomEditSessionSlice;
|
|
97
|
+
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
|
|
11
98
|
}
|
|
12
99
|
|
|
13
|
-
|
|
100
|
+
// fallow-ignore-next-line complexity
|
|
101
|
+
function useKeyframeToggle(session?: DomEditSessionSlice) {
|
|
102
|
+
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
103
|
+
if (!session) return { state: "none" as const, onToggle: undefined };
|
|
104
|
+
|
|
105
|
+
const sel = session.domEditSelection;
|
|
106
|
+
const anims = session.selectedGsapAnimations;
|
|
107
|
+
const kfAnim = anims.find((a) => a.keyframes);
|
|
108
|
+
const flatAnim = anims.find((a) => !a.keyframes);
|
|
109
|
+
|
|
110
|
+
let state: "active" | "inactive" | "none" = "none";
|
|
111
|
+
if (kfAnim?.keyframes && sel) {
|
|
112
|
+
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
|
|
113
|
+
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
|
|
114
|
+
const pct =
|
|
115
|
+
elDuration > 0
|
|
116
|
+
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
|
|
117
|
+
: 0;
|
|
118
|
+
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
|
|
119
|
+
? "active"
|
|
120
|
+
: "inactive";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// fallow-ignore-next-line complexity
|
|
124
|
+
const onToggle = sel
|
|
125
|
+
? async () => {
|
|
126
|
+
const t = usePlayerStore.getState().currentTime;
|
|
127
|
+
if (kfAnim?.keyframes) {
|
|
128
|
+
if (kfAnim.hasUnresolvedKeyframes) {
|
|
129
|
+
await session.handleGsapMaterializeKeyframes?.(kfAnim.id);
|
|
130
|
+
}
|
|
131
|
+
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
|
|
132
|
+
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
|
|
133
|
+
const pct =
|
|
134
|
+
elDuration > 0
|
|
135
|
+
? Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10))
|
|
136
|
+
: 0;
|
|
137
|
+
const existing = kfAnim.keyframes.keyframes.find(
|
|
138
|
+
(k) => Math.abs(k.percentage - pct) <= 1,
|
|
139
|
+
);
|
|
140
|
+
if (existing) {
|
|
141
|
+
session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
|
|
142
|
+
} else {
|
|
143
|
+
const runtimeValues = readRuntimeKeyframeValues(
|
|
144
|
+
session.previewIframeRef?.current ?? null,
|
|
145
|
+
sel,
|
|
146
|
+
kfAnim.keyframes.keyframes,
|
|
147
|
+
);
|
|
148
|
+
const values =
|
|
149
|
+
Object.keys(runtimeValues).length > 0
|
|
150
|
+
? runtimeValues
|
|
151
|
+
: interpolateKeyframeProperties(kfAnim.keyframes.keyframes, pct);
|
|
152
|
+
for (const [prop, val] of Object.entries(values)) {
|
|
153
|
+
session.handleGsapAddKeyframe(kfAnim.id, pct, prop, val);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} else if (flatAnim) {
|
|
157
|
+
session.handleGsapConvertToKeyframes(flatAnim.id);
|
|
158
|
+
} else {
|
|
159
|
+
session.handleGsapAddAnimation("to");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
: undefined;
|
|
163
|
+
|
|
164
|
+
return { state, onToggle };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function TimelineToolbar({
|
|
168
|
+
toggleTimelineVisibility,
|
|
169
|
+
domEditSession,
|
|
170
|
+
onSplitElement,
|
|
171
|
+
}: TimelineToolbarProps) {
|
|
14
172
|
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
|
15
173
|
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
16
174
|
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
|
17
175
|
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
|
18
176
|
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
|
177
|
+
const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession);
|
|
19
178
|
|
|
20
179
|
return (
|
|
21
180
|
<div className="border-b border-neutral-800/40 bg-neutral-950/96">
|
|
22
181
|
<div className="flex items-center justify-between px-3 py-2">
|
|
23
|
-
<div className="
|
|
24
|
-
|
|
182
|
+
<div className="flex items-center gap-3">
|
|
183
|
+
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
|
184
|
+
Timeline
|
|
185
|
+
</div>
|
|
186
|
+
{STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && (
|
|
187
|
+
<Tooltip
|
|
188
|
+
label={
|
|
189
|
+
keyframeState === "active"
|
|
190
|
+
? "Remove keyframe at playhead"
|
|
191
|
+
: keyframeState === "inactive"
|
|
192
|
+
? "Add keyframe at playhead"
|
|
193
|
+
: "Enable keyframes"
|
|
194
|
+
}
|
|
195
|
+
>
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
onClick={onToggleKeyframe}
|
|
199
|
+
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
|
200
|
+
keyframeState === "active"
|
|
201
|
+
? "text-studio-accent"
|
|
202
|
+
: keyframeState === "inactive"
|
|
203
|
+
? "text-neutral-400 hover:text-studio-accent"
|
|
204
|
+
: "text-neutral-600 hover:text-neutral-400"
|
|
205
|
+
}`}
|
|
206
|
+
>
|
|
207
|
+
<svg width="18" height="18" viewBox="0 0 10 10" fill="currentColor">
|
|
208
|
+
{keyframeState === "active" ? (
|
|
209
|
+
<path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
|
|
210
|
+
) : (
|
|
211
|
+
<path
|
|
212
|
+
d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
|
|
213
|
+
fill="none"
|
|
214
|
+
stroke="currentColor"
|
|
215
|
+
strokeWidth="1.2"
|
|
216
|
+
/>
|
|
217
|
+
)}
|
|
218
|
+
</svg>
|
|
219
|
+
</button>
|
|
220
|
+
</Tooltip>
|
|
221
|
+
)}
|
|
222
|
+
{onSplitElement &&
|
|
223
|
+
(() => {
|
|
224
|
+
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
|
|
225
|
+
const el = selectedElementId
|
|
226
|
+
? elements.find((e) => (e.key ?? e.id) === selectedElementId)
|
|
227
|
+
: null;
|
|
228
|
+
const splittable =
|
|
229
|
+
el && !el.compositionSrc && ["video", "audio", "img"].includes(el.tag);
|
|
230
|
+
if (!splittable) return null;
|
|
231
|
+
const canSplit = currentTime > el.start && currentTime < el.start + el.duration;
|
|
232
|
+
return (
|
|
233
|
+
<Tooltip label="Split clip at playhead (S)">
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
disabled={!canSplit}
|
|
237
|
+
onClick={() => {
|
|
238
|
+
if (canSplit) onSplitElement(el, currentTime);
|
|
239
|
+
}}
|
|
240
|
+
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
|
241
|
+
canSplit
|
|
242
|
+
? "text-neutral-500 hover:text-neutral-200"
|
|
243
|
+
: "text-neutral-700 cursor-not-allowed"
|
|
244
|
+
}`}
|
|
245
|
+
>
|
|
246
|
+
<Scissors size={15} />
|
|
247
|
+
</button>
|
|
248
|
+
</Tooltip>
|
|
249
|
+
);
|
|
250
|
+
})()}
|
|
25
251
|
</div>
|
|
26
252
|
<div className="flex items-center gap-1">
|
|
27
253
|
<Tooltip label="Fit timeline to width">
|
|
@@ -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
|
+
});
|