Skip to content
Merged
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
8 changes: 4 additions & 4 deletions packages/studio/src/components/TimelineToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,12 @@ export function TimelineToolbar({
<Tooltip
label={
keyframeState === "active"
? "Remove keyframe at playhead"
? "Remove keyframe at playhead (K)"
: keyframeState === "inactive"
? keyframeWillExtend
? "Add keyframe at playhead (extends animation)"
: "Add keyframe at playhead"
: "Enable keyframes"
? "Add keyframe at playhead extends animation (K)"
: "Add keyframe at playhead (K)"
: "Add keyframe (K)"
}
>
<button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ export function GestureRecordPanelButton({
>
<GestureRecordIcon recording={recording} />
{recording
? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s press R`
: "Record gesture (R) move pointer to capture motion"}
? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s -- press R`
: "Record gesture (R) -- move pointer to capture motion"}
</button>
</div>
);
Expand Down
19 changes: 11 additions & 8 deletions packages/studio/src/components/editor/domEditingGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,15 @@ export function resolveGroupCapture(
for (let n: HTMLElement | null = startEl; n; n = n.parentElement) {
if (n.hasAttribute("data-hf-group")) groups.push(n);
}
if (!activeGroupElement) {
const outermost = groups[groups.length - 1];
return outermost ? { kind: "unit", element: outermost } : { kind: "child" };
}
const idx = groups.indexOf(activeGroupElement);
if (idx === -1) return { kind: "out-of-scope" };
const nestedInside = groups[idx - 1];
return nestedInside ? { kind: "unit", element: nestedInside } : { kind: "child" };
const result = ((): GroupCapture => {
if (!activeGroupElement) {
const outermost = groups[groups.length - 1];
return outermost ? { kind: "unit", element: outermost } : { kind: "child" };
}
const idx = groups.indexOf(activeGroupElement);
if (idx === -1) return { kind: "out-of-scope" };
const nestedInside = groups[idx - 1];
return nestedInside ? { kind: "unit", element: nestedInside } : { kind: "child" };
})();
return result;
}
20 changes: 17 additions & 3 deletions packages/studio/src/hooks/useAppHotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/tim
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
import { canSplitElement } from "../utils/timelineElementSplit";
import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
import { trackStudioEvent } from "../utils/studioTelemetry";

function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
try {
Expand Down Expand Up @@ -158,19 +159,27 @@ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallba
!shouldIgnoreHistoryShortcut(event.target) &&
handleUndoRedoKey(
event,
() => void cb.handleUndo(),
() => void cb.handleRedo(),
() => {
trackStudioEvent("keyboard_shortcut", { action: "undo" });
void cb.handleUndo();
},
() => {
trackStudioEvent("keyboard_shortcut", { action: "redo" });
void cb.handleRedo();
},
)
)
return true;

if (event.key === "1") {
event.preventDefault();
trackStudioEvent("keyboard_shortcut", { action: "tab_compositions" });
cb.leftSidebarRef.current?.selectTab("compositions");
return true;
}
if (event.key === "2") {
event.preventDefault();
trackStudioEvent("keyboard_shortcut", { action: "tab_assets" });
cb.leftSidebarRef.current?.selectTab("assets");
return true;
}
Expand All @@ -184,17 +193,22 @@ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallba

if (!event.shiftKey && !event.altKey && !isEditableTarget(event.target)) {
if (key === "c") {
if (cb.handleCopy()) event.preventDefault();
if (cb.handleCopy()) {
event.preventDefault();
trackStudioEvent("keyboard_shortcut", { action: "copy" });
}
return true;
}
if (key === "v") {
event.preventDefault();
trackStudioEvent("keyboard_shortcut", { action: "paste" });
void cb.handlePaste();
return true;
}
if (key === "x") {
if (usePlayerStore.getState().selectedElementId || cb.domEditSelectionRef.current) {
event.preventDefault();
trackStudioEvent("keyboard_shortcut", { action: "cut" });
void cb.handleCut();
}
return true;
Expand Down
3 changes: 3 additions & 0 deletions packages/studio/src/hooks/useDomEditSession.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback } from "react";
import { trackStudioEvent } from "../utils/studioTelemetry";
import type { TimelineElement } from "../player";
import type { ImportedFontAsset } from "../components/editor/fontAssets";
import type { EditHistoryKind } from "../utils/editHistory";
Expand Down Expand Up @@ -305,6 +306,7 @@ export function useDomEditSession({
showToast("Select at least 2 elements to group", "info");
return;
}
trackStudioEvent("group", { action: "create", count: members.length });
void groupSelection(members);
}, [domEditGroupSelectionsRef, domEditSelectionRef, groupSelection, showToast]);

Expand All @@ -315,6 +317,7 @@ export function useDomEditSession({
return;
}
// Dissolving the group exits any drill-in (the wrapper is about to vanish).
trackStudioEvent("group", { action: "ungroup" });
setActiveGroupElement(null);
void ungroupSelection(sel);
}, [domEditSelectionRef, ungroupSelection, setActiveGroupElement, showToast]);
Expand Down
4 changes: 4 additions & 0 deletions packages/studio/src/hooks/useGsapSelectionHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useRef } from "react";
import type { DomEditSelection } from "../components/editor/domEditing";
import { usePlayerStore } from "../player";
import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
import { trackStudioEvent } from "../utils/studioTelemetry";

/**
* Thin useCallback wrappers that guard on `domEditSelection` before
Expand Down Expand Up @@ -136,6 +137,7 @@ export function useGsapSelectionHandlers({
(targetSelector: string) => {
const sel = domEditSelection ?? lastSelectionRef.current;
if (!sel) return;
trackStudioEvent("keyframe", { action: "delete_all" });
deleteAllForSelector(sel, targetSelector);
},
[domEditSelection, deleteAllForSelector],
Expand Down Expand Up @@ -206,6 +208,7 @@ export function useGsapSelectionHandlers({
) => {
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
if (!sel) return;
trackStudioEvent("keyframe", { action: "add", property });
addKeyframe(sel, animId, percentage, property, value);
},
[domEditSelection, addKeyframe],
Expand All @@ -224,6 +227,7 @@ export function useGsapSelectionHandlers({
(animId: string, percentage: number, selectionOverride?: DomEditSelection | null) => {
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
if (!sel) return;
trackStudioEvent("keyframe", { action: "remove" });
removeKeyframe(sel, animId, percentage);
},
[domEditSelection, removeKeyframe],
Expand Down
36 changes: 34 additions & 2 deletions packages/studio/src/hooks/usePreviewInteraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { liveTime, usePlayerStore } from "../player";
import { pauseStudioPreviewPlayback } from "../utils/studioPreviewHelpers";
import { STUDIO_PREVIEW_SELECTION_ENABLED } from "../components/editor/manualEditingAvailability";
import { type DomEditSelection } from "../components/editor/domEditing";
import { trackStudioEvent } from "../utils/studioTelemetry";

// ── Types ──

Expand Down Expand Up @@ -47,6 +48,12 @@ interface ClickCycleState {

const CYCLE_RADIUS_PX = 6;
const CYCLE_WINDOW_MS = 600;
// Manual double-click window. `e.detail` can't be trusted here: the first click
// selects the group and re-renders the overlay, so the second click lands on a
// fresh element and the browser's native click-counter resets to 1 — drill-in
// (which keyed off `e.detail >= 2`) never fired. We track time+position instead.
const DOUBLE_CLICK_MS = 400;
const DOUBLE_CLICK_RADIUS_PX = 6;

// ── Hook ──

Expand All @@ -63,21 +70,34 @@ export function usePreviewInteraction({
onClickToSource,
}: UsePreviewInteractionParams) {
const cycleRef = useRef<ClickCycleState | null>(null);
const lastDownRef = useRef<{ t: number; x: number; y: number } | null>(null);

const handlePreviewCanvasMouseDown = useCallback(
// fallow-ignore-next-line complexity
async (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;

// Manual double-click detection (see DOUBLE_CLICK_MS): the first click
// re-renders the overlay so `e.detail` never reaches 2 on the canvas.
const downTs = Date.now();
const lastDown = lastDownRef.current;
const isDoubleClick =
e.detail >= 2 ||
(lastDown != null &&
downTs - lastDown.t < DOUBLE_CLICK_MS &&
Math.hypot(e.clientX - lastDown.x, e.clientY - lastDown.y) < DOUBLE_CLICK_RADIUS_PX);
lastDownRef.current = { t: downTs, x: e.clientX, y: e.clientY };

// Double-click a group → drill into it and select the child under the
// pointer (resolve with the group as the explicit drill-in scope, since the
// activeGroupElement state hasn't re-rendered yet within this handler).
if (e.detail >= 2 && !e.shiftKey) {
if (isDoubleClick && !e.shiftKey) {
const hit = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY);
if (hit?.element.hasAttribute("data-hf-group")) {
e.preventDefault();
e.stopPropagation();
cycleRef.current = null;
trackStudioEvent("group", { action: "drill_in" });
setActiveGroupElement(hit.element);
const child = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
activeGroupElement: hit.element,
Expand Down Expand Up @@ -121,9 +141,21 @@ export function usePreviewInteraction({
}

// Fresh click — resolve topmost element
const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
let nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
preferClipAncestor: options?.preferClipAncestor ?? false,
});
// A null result while drilled into a group means the click landed OUTSIDE that
// group (resolveGroupCapture → out-of-scope). Drill-in isn't sticky: exit it and
// re-resolve at the top level so this click selects whatever's there (or the
// group as a unit). Without this, a stale drill-in keeps selecting children and
// the "first click selects the group" expectation breaks.
if (!nextSelection) {
setActiveGroupElement(null);
nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
preferClipAncestor: options?.preferClipAncestor ?? false,
activeGroupElement: null,
});
}
if (!nextSelection) {
cycleRef.current = null;
applyDomSelection(null, { revealPanel: false });
Expand Down
Loading