Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 193 additions & 3 deletions packages/studio/src/components/TimelineToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,214 @@ import {
} from "../player/components/timelineZoom";
import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
import { usePlayerStore } from "../player";
import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
import { Tooltip } from "./ui";
import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "./editor/domEditingTypes";

function interpolateKeyframeProperties(
keyframes: GsapPercentageKeyframe[],
pct: number,
): Record<string, number> {
const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
const allProps = new Set<string>();
for (const kf of sorted) {
for (const p of Object.keys(kf.properties)) {
if (typeof kf.properties[p] === "number") allProps.add(p);
}
}
const result: Record<string, number> = {};
for (const prop of allProps) {
let prev: { pct: number; val: number } | null = null;
let next: { pct: number; val: number } | null = null;
for (const kf of sorted) {
const v = kf.properties[prop];
if (typeof v !== "number") continue;
if (kf.percentage <= pct) prev = { pct: kf.percentage, val: v };
if (kf.percentage >= pct && !next) next = { pct: kf.percentage, val: v };
}
if (prev && next && prev.pct !== next.pct) {
const t = (pct - prev.pct) / (next.pct - prev.pct);
result[prop] = Math.round(prev.val + t * (next.val - prev.val));
} else if (prev) {
result[prop] = Math.round(prev.val);
} else if (next) {
result[prop] = Math.round(next.val);
}
}
return result;
}

function readRuntimeKeyframeValues(
iframe: HTMLIFrameElement | null,
sel: DomEditSelection,
keyframes: GsapPercentageKeyframe[],
): Record<string, number> {
if (!iframe?.contentWindow) return {};
let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined;
try {
gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap;
} catch {
return {};
}
if (!gsap?.getProperty) return {};
const selector = sel.id ? `#${sel.id}` : sel.selector;
if (!selector) return {};
let doc: Document | null = null;
try {
doc = iframe.contentDocument;
} catch {
return {};
}
const element = doc?.querySelector(selector);
if (!element) return {};
const allProps = new Set<string>();
for (const kf of keyframes) {
for (const p of Object.keys(kf.properties)) {
if (typeof kf.properties[p] === "number") allProps.add(p);
}
}
const result: Record<string, number> = {};
for (const prop of allProps) {
const val = Number(gsap.getProperty(element, prop));
if (Number.isFinite(val)) result[prop] = Math.round(val);
}
return result;
}

interface DomEditSessionSlice {
domEditSelection: DomEditSelection | null;
selectedGsapAnimations: GsapAnimation[];
handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void;
handleGsapConvertToKeyframes: (animId: string) => void;
handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
}

interface TimelineToolbarProps {
toggleTimelineVisibility: () => void;
domEditSession?: DomEditSessionSlice;
}

export function TimelineToolbar({ toggleTimelineVisibility }: TimelineToolbarProps) {
// fallow-ignore-next-line complexity
function useKeyframeToggle(session?: DomEditSessionSlice) {
const currentTime = usePlayerStore((s) => s.currentTime);
if (!session) return { state: "none" as const, onToggle: undefined };

const sel = session.domEditSelection;
const anims = session.selectedGsapAnimations;
const kfAnim = anims.find((a) => a.keyframes);
const flatAnim = anims.find((a) => !a.keyframes);

let state: "active" | "inactive" | "none" = "none";
if (kfAnim?.keyframes && sel) {
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
const pct =
elDuration > 0
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
: 0;
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
? "active"
: "inactive";
}

// fallow-ignore-next-line complexity
const onToggle = sel
? () => {
const t = usePlayerStore.getState().currentTime;
if (kfAnim?.keyframes) {
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
const pct =
elDuration > 0
? Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10))
: 0;
const existing = kfAnim.keyframes.keyframes.find(
(k) => Math.abs(k.percentage - pct) <= 1,
);
if (existing) {
session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
} else {
const runtimeValues = readRuntimeKeyframeValues(
session.previewIframeRef?.current ?? null,
sel,
kfAnim.keyframes.keyframes,
);
const values =
Object.keys(runtimeValues).length > 0
? runtimeValues
: interpolateKeyframeProperties(kfAnim.keyframes.keyframes, pct);
for (const [prop, val] of Object.entries(values)) {
session.handleGsapAddKeyframe(kfAnim.id, pct, prop, val);
}
}
} else if (flatAnim) {
session.handleGsapConvertToKeyframes(flatAnim.id);
} else {
session.handleGsapAddAnimation("to");
}
}
: undefined;

return { state, onToggle };
}

export function TimelineToolbar({
toggleTimelineVisibility,
domEditSession,
}: TimelineToolbarProps) {
const zoomMode = usePlayerStore((s) => s.zoomMode);
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession);

return (
<div className="border-b border-neutral-800/40 bg-neutral-950/96">
<div className="flex items-center justify-between px-3 py-2">
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
Timeline
<div className="flex items-center gap-3">
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
Timeline
</div>
{STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && (
<Tooltip
label={
keyframeState === "active"
? "Remove keyframe at playhead"
: keyframeState === "inactive"
? "Add keyframe at playhead"
: "Enable keyframes"
}
>
<button
type="button"
onClick={onToggleKeyframe}
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
keyframeState === "active"
? "text-studio-accent"
: keyframeState === "inactive"
? "text-neutral-400 hover:text-studio-accent"
: "text-neutral-600 hover:text-neutral-400"
}`}
>
<svg width="18" height="18" viewBox="0 0 10 10" fill="currentColor">
{keyframeState === "active" ? (
<path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
) : (
<path
d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
/>
)}
</svg>
</button>
</Tooltip>
)}
</div>
<div className="flex items-center gap-1">
<Tooltip label="Fit timeline to width">
Expand Down
36 changes: 35 additions & 1 deletion packages/studio/src/hooks/useAppHotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ interface UseAppHotkeysParams {
handleCopy: () => boolean;
handlePaste: () => Promise<void>;
handleCut: () => Promise<boolean>;
onResetKeyframes: () => boolean;
onDeleteSelectedKeyframes: () => void;
onAfterUndoRedo?: () => void;
}

// ── Hook ──
Expand All @@ -98,6 +101,9 @@ export function useAppHotkeys({
handleCopy,
handlePaste,
handleCut,
onResetKeyframes,
onDeleteSelectedKeyframes,
onAfterUndoRedo,
}: UseAppHotkeysParams) {
const previewHotkeyWindowRef = useRef<Window | null>(null);
const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined);
Expand Down Expand Up @@ -144,6 +150,7 @@ export function useAppHotkeys({
return;
}
if (result.ok && result.label) {
onAfterUndoRedo?.();
await syncHistoryPreviewAfterApply(result.paths);
showToast(`Undid ${result.label}`, "info");
}
Expand All @@ -154,6 +161,7 @@ export function useAppHotkeys({
syncHistoryPreviewAfterApply,
waitForPendingDomEditSaves,
writeHistoryProjectFile,
onAfterUndoRedo,
]);

const handleRedo = useCallback(async () => {
Expand All @@ -167,6 +175,7 @@ export function useAppHotkeys({
return;
}
if (result.ok && result.label) {
onAfterUndoRedo?.();
await syncHistoryPreviewAfterApply(result.paths);
showToast(`Redid ${result.label}`, "info");
}
Expand All @@ -177,6 +186,7 @@ export function useAppHotkeys({
syncHistoryPreviewAfterApply,
waitForPendingDomEditSaves,
writeHistoryProjectFile,
onAfterUndoRedo,
]);

// ── Stable refs for the consolidated keydown handler ──
Expand All @@ -197,6 +207,10 @@ export function useAppHotkeys({
handlePasteRef.current = handlePaste;
const handleCutRef = useRef(handleCut);
handleCutRef.current = handleCut;
const onResetKeyframesRef = useRef(onResetKeyframes);
onResetKeyframesRef.current = onResetKeyframes;
const onDeleteSelectedKeyframesRef = useRef(onDeleteSelectedKeyframes);
onDeleteSelectedKeyframesRef.current = onDeleteSelectedKeyframes;

// ── Consolidated keydown handler ──

Expand Down Expand Up @@ -292,14 +306,34 @@ export function useAppHotkeys({
return;
}

// Delete / Backspace — remove selected element (timeline clip or preview selection)
// Delete / Backspace — remove selected keyframes > reset keyframes > remove element
if (
(event.key === "Delete" || event.key === "Backspace") &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!isEditableTarget(event.target)
) {
// Priority: selected keyframes take precedence over clip deletion
const { selectedKeyframes } = usePlayerStore.getState();
if (selectedKeyframes.size > 0) {
onDeleteSelectedKeyframesRef.current();
usePlayerStore.getState().clearSelectedKeyframes();
event.preventDefault();
return;
}

// Backspace: try resetting keyframes first; fall through to delete if none found
if (event.key === "Backspace") {
const { selectedElementId, keyframeCache } = usePlayerStore.getState();
if (selectedElementId && keyframeCache.has(selectedElementId)) {
if (onResetKeyframesRef.current()) {
event.preventDefault();
return;
}
}
}

const { selectedElementId, elements } = usePlayerStore.getState();
if (selectedElementId) {
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
Expand Down
37 changes: 35 additions & 2 deletions packages/studio/src/hooks/useDomEditCommits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,37 @@ import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditO
import type { EditHistoryKind } from "../utils/editHistory";
import { useDomEditTextCommits } from "./useDomEditTextCommits";

// ── Helpers ──

type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };

function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean {
if (!iframe?.contentWindow) return false;
let timelines: Record<string, TimelineLike> | undefined;
try {
timelines = (iframe.contentWindow as Window & { __timelines?: Record<string, TimelineLike> })
.__timelines;
} catch {
return false;
}
if (!timelines) return false;
const id = element.id;
for (const tl of Object.values(timelines)) {
if (!tl?.getChildren) continue;
try {
for (const child of tl.getChildren(true)) {
if (!child.targets) continue;
for (const t of child.targets()) {
if (t === element || (id && t.id === id)) return true;
}
}
} catch {
continue;
}
}
return false;
}

// ── Types ──

interface RecordEditInput {
Expand Down Expand Up @@ -290,12 +321,13 @@ export function useDomEditCommits({
const handleDomPathOffsetCommit = useCallback(
(selection: DomEditSelection, next: { x: number; y: number }) => {
applyStudioPathOffset(selection.element, next);
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return;
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
label: "Move layer",
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
});
},
[commitPositionPatchToHtml],
[commitPositionPatchToHtml, previewIframeRef],
);

const handleDomGroupPathOffsetCommit = useCallback(
Expand All @@ -307,13 +339,14 @@ export function useDomEditCommits({
.join(":");
for (const { selection, next } of updates) {
applyStudioPathOffset(selection.element, next);
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) continue;
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
label: `Move ${updates.length} layers`,
coalesceKey: `group-path-offset:${coalesceKey}`,
});
}
},
[commitPositionPatchToHtml],
[commitPositionPatchToHtml, previewIframeRef],
);

const handleDomBoxSizeCommit = useCallback(
Expand Down
Loading
Loading