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
21 changes: 21 additions & 0 deletions packages/studio/src/components/TimelineToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { useTimelineZoom } from "../player/components/useTimelineZoom";
import { usePlayerStore, type TimelineElement } from "../player";
import { Tooltip } from "./ui";
import { Scissors } from "../icons/SystemIcons";
import { TRIM_TOOL_ICONS } from "../icons/TrimToolIcons";
import { TIMELINE_TRIM_TOOLS } from "../player/components/timelineTrimTools";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "./editor/domEditingTypes";
import { canSplitElement } from "../utils/timelineElementSplit";
Expand Down Expand Up @@ -215,6 +217,25 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
<Scissors size={16} />
</button>
</Tooltip>
{/* Trim tools: one per NLE edit operation, so grabbing the same pixel
can mean ripple or roll without a hidden modifier. */}
{TIMELINE_TRIM_TOOLS.map((tool) => {
const Icon = TRIM_TOOL_ICONS[tool.mode];
const active = activeTool === tool.mode;
return (
<Tooltip key={tool.mode} label={`${tool.label} (${tool.shortcut}) — ${tool.hint}`}>
<button
type="button"
onClick={() => setActiveTool(active ? "select" : tool.mode)}
aria-label={tool.label}
aria-pressed={active}
className={active ? flatActive : flatIdle}
>
<Icon size={16} />
</button>
</Tooltip>
);
})}
{/* Divider: tool-mode | editing-actions */}
<div aria-hidden="true" className="mx-1 h-4 w-px bg-neutral-800" />
<Tooltip label={timelineSnapEnabled ? "Snapping on (N)" : "Snapping off (N)"}>
Expand Down
34 changes: 34 additions & 0 deletions packages/studio/src/hooks/timelineEditingHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
return true;
}
// ── Types ──
import { studioWriteHeaders } from "../utils/studioFileVersion";
export type { RecordEditInput } from "../utils/studioFileHistory";
export function buildPatchTarget(element: {
domId?: string;
Expand All @@ -129,6 +130,39 @@
return null;
}
export type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;

/**
* How the server's remove-element route locates an element. Looser than
* {@link PatchTarget} on purpose: the two delete paths build their locator
* differently (one from a timeline element, one from a DOM-edit selection) and
* the route accepts any of the three keys.
*/
export interface RemoveElementTarget {
id?: string | null;
hfId?: string;
selector?: string;
selectorIndex?: number;
}

/**
* POST the server's remove-element mutation. One owner for the route, the
* headers and the body shape; callers keep their own error handling, which is
* the only part that differs between the timeline and lifecycle delete paths.
*/
export function postRemoveElement(
projectId: string,
targetPath: string,
target: RemoveElementTarget,
): Promise<Response> {
return fetch(
`/api/projects/${projectId}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
body: JSON.stringify({ target }),
},
);
Comment on lines +157 to +164
}
// The runtime re-reads data-start/data-duration from the DOM on each sync tick
// (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are
// picked up automatically on the next frame without a rebind call.
Expand Down
12 changes: 12 additions & 0 deletions packages/studio/src/hooks/useAppHotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { isTypingTarget } from "../utils/typingTarget";
import { isEditableTarget } from "../utils/timelineDiscovery";
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
import { canSplitElement } from "../utils/timelineElementSplit";
import { TRIM_TOOL_KEYS } from "../player/components/timelineTrimTools";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";

Expand Down Expand Up @@ -290,6 +291,17 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
return;
}

// Trim tools, paired by what they act on: T/⇧T move an edit point (ripple,
// roll), Y/⇧Y move the media inside one (slip, slide). Pressing the active
// tool's own key returns to Select, so a tool is never a trap.
const trimTool = TRIM_TOOL_KEYS[`${event.shiftKey ? "shift+" : ""}${key}`];
if (trimTool && !event.altKey && !event.metaKey && !event.ctrlKey) {
event.preventDefault();
const { activeTool, setActiveTool } = usePlayerStore.getState();
setActiveTool(activeTool === trimTool ? "select" : trimTool);
return;
}

if (event.key === "Escape") {
const { activeTool, selectedElementId, setActiveTool, setSelectedElementId } =
usePlayerStore.getState();
Expand Down
11 changes: 2 additions & 9 deletions packages/studio/src/hooks/useElementLifecycleOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
} from "../components/editor/useLayerRevealOverride";
import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes";
import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
import { studioWriteHeaders } from "../utils/studioFileVersion";
import { postRemoveElement } from "./timelineEditingHelpers";

interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams {
/** Route delete through SDK when session resolves the hf-id. */
Expand Down Expand Up @@ -112,14 +112,7 @@ export function useElementLifecycleOps({
}

domEditSaveTimestampRef.current = Date.now();
const removeResponse = await fetch(
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
body: JSON.stringify({ target: patchTarget }),
},
);
const removeResponse = await postRemoveElement(pid, targetPath, patchTarget);
if (!removeResponse.ok) {
throw await createStudioSaveHttpError(
removeResponse,
Expand Down
19 changes: 8 additions & 11 deletions packages/studio/src/hooks/useTimelineEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
extendRootDurationIfNeeded,
buildTimelineMoveTimingPatch,
buildTimelineResizeTimingPatch,
postRemoveElement,
} from "./timelineEditingHelpers";
import {
captureDurationRollback,
Expand All @@ -27,6 +28,10 @@ import {
} from "./timelineTimingSync";
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
import {
blockedTimelineEditMessage,
type BlockedTimelineEditIntent,
} from "../player/components/timelineBlockedEdits";
import {
useTimelineElementVisibilityEditing,
useTimelineTrackVisibilityEditing,
Expand All @@ -36,7 +41,6 @@ import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
import { studioWriteHeaders } from "../utils/studioFileVersion";

type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
stackingReorder?: TimelineStackingReorderIntent | null;
Expand Down Expand Up @@ -409,14 +413,7 @@ export function useTimelineEditing({
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
}

const removeResponse = await fetch(
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
body: JSON.stringify({ target: patchTarget }),
},
);
const removeResponse = await postRemoveElement(pid, targetPath, patchTarget);
if (!removeResponse.ok) {
throw new Error(`Failed to delete ${element.id} from ${targetPath}`);
}
Expand Down Expand Up @@ -505,11 +502,11 @@ export function useTimelineEditing({
});

const handleBlockedTimelineEdit = useCallback(
(_element: TimelineElement) => {
(_element: TimelineElement, intent: BlockedTimelineEditIntent) => {
const now = Date.now();
if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
lastBlockedTimelineToastAtRef.current = now;
showToast("This clip can't be moved or resized from the timeline yet.", "info");
showToast(blockedTimelineEditMessage(intent), "info");
},
[showToast],
);
Expand Down
71 changes: 71 additions & 0 deletions packages/studio/src/icons/TrimToolIcons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { ReactElement } from "react";
import type { TimelineTrimMode } from "../player/components/timelineTrimOps";

/**
* Glyphs for the four trim tools. Hand-drawn rather than pulled from Phosphor:
* the set has no marks for these operations, and what matters here is that the
* four read as *different* at 16px — each one shows what moves.
*
* Shared grammar: a solid block is a clip, a vertical rule is an edit point,
* an arrow is what the drag moves.
*/

const STROKE = {
fill: "none",
stroke: "currentColor",
strokeWidth: 1.4,
strokeLinecap: "round",
strokeLinejoin: "round",
} as const;

interface TrimToolIconProps {
size?: number;
}

/** Ripple: an edit point, and the rest of the track pushed along behind it. */
function RippleTrimIcon({ size = 16 }: TrimToolIconProps) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" aria-hidden="true">
<rect x="1" y="4.5" width="3.5" height="7" rx="1" fill="currentColor" />
<path d="M6.5 8h6.5M10.5 5.5 13 8l-2.5 2.5" {...STROKE} />
</svg>
);
}

/** Roll: one edit point, movable either way; the clips around it stay put. */
function RollEditIcon({ size = 16 }: TrimToolIconProps) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 2.5v11" {...STROKE} strokeWidth={1.6} />
<path d="M5 5.5 2.5 8 5 10.5M11 5.5 13.5 8 11 10.5" {...STROKE} />
</svg>
);
}

/** Slip: the clip's outline stays; the media inside it slides. */
function SlipEditIcon({ size = 16 }: TrimToolIconProps) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" aria-hidden="true">
<rect x="1.7" y="3.7" width="12.6" height="8.6" rx="1.6" {...STROKE} />
<path d="M5 8h6M6.6 6.4 5 8l1.6 1.6M9.4 6.4 11 8 9.4 9.6" {...STROKE} />
</svg>
);
}

/** Slide: the clip travels; the walls either side of it absorb the travel. */
function SlideEditIcon({ size = 16 }: TrimToolIconProps) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" aria-hidden="true">
<path d="M1.6 2.8v7.4M14.4 2.8v7.4" {...STROKE} />
<rect x="5" y="2.8" width="6" height="7.4" rx="1" fill="currentColor" />
<path d="M3 13.2h10M4.6 11.6 3 13.2l1.6 1.6M11.4 11.6 13 13.2l-1.6 1.6" {...STROKE} />
</svg>
);
}

export const TRIM_TOOL_ICONS: Record<TimelineTrimMode, (p: TrimToolIconProps) => ReactElement> = {
ripple: RippleTrimIcon,
roll: RollEditIcon,
slip: SlipEditIcon,
slide: SlideEditIcon,
};
9 changes: 9 additions & 0 deletions packages/studio/src/player/components/ShortcutsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useCallback, useId, memo } from "react";
import { formatTime, frameToSeconds } from "../lib/time";
import { Tooltip } from "../../components/ui";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
import { TIMELINE_TRIM_TOOLS } from "./timelineTrimTools";

const SHORTCUT_SECTIONS = [
{
Expand Down Expand Up @@ -42,6 +43,14 @@ const SHORTCUT_SECTIONS = [
{ key: "Del", label: "Delete selected element" },
],
},
{
title: "Timeline tools",
hints: [
{ key: "V", label: "Selection tool" },
{ key: "B", label: "Razor tool" },
...TIMELINE_TRIM_TOOLS.map((tool) => ({ key: tool.shortcut, label: tool.label })),
],
},
{
title: "Gesture recording modifiers",
hints: [
Expand Down
11 changes: 4 additions & 7 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
import { defaultTimelineTheme } from "./timelineTheme";
import { defaultTimelineTheme, timelineShellStyle } from "./timelineTheme";
import { activeTrimMode } from "./timelineTrimTools";
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
import { useTimelinePlayhead } from "./useTimelinePlayhead";
import { useTimelineZoom } from "./useTimelineZoom";
Expand Down Expand Up @@ -445,14 +446,10 @@ export const Timeline = memo(function Timeline({
ref={setContainerRef}
aria-label="Timeline"
data-timeline-element-count={expandedElements.length}
className={`relative border-t select-none h-full overflow-hidden ${assetDrop.isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
className={`relative border-t select-none h-full overflow-hidden ${assetDrop.isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : activeTrimMode(activeTool) ? "cursor-ew-resize" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
onMouseMove={updateRazorGuide}
onMouseLeave={clearRazorGuide}
style={{
touchAction: "pan-x pan-y",
background: theme.shellBackground,
borderColor: theme.shellBorder,
}}
style={timelineShellStyle(theme)}
>
<div
ref={setScrollRef}
Expand Down
28 changes: 28 additions & 0 deletions packages/studio/src/player/components/timelineBlockedEdits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { TimelineTrimMode } from "./timelineTrimOps";

/**
* Why an attempted timeline edit did not start. The three original values mean
* "the clip cannot take this edit at all"; the four trim-tool values mean "this
* clip is the wrong shape for that operation" (see timelineTrimOps), which is a
* different thing to tell the user.
*
* The intent and its message live together so a new blocked case cannot be
* added without deciding what it says.
*/
export type BlockedTimelineEditIntent = "move" | "resize-start" | "resize-end" | TimelineTrimMode;

/** What to tell the user when a {@link BlockedTimelineEditIntent} is reported. */
export function blockedTimelineEditMessage(intent: BlockedTimelineEditIntent): string {
switch (intent) {
case "ripple":
return "This clip can't be ripple-trimmed — it or a clip after it is locked.";
case "roll":
return "Rolling needs a clip butted against this edit point.";
case "slip":
return "Only clips with source media can be slipped.";
case "slide":
return "This clip can't be slid — it or a neighbour is locked.";
default:
return "This clip can't be moved or resized from the timeline yet.";
}
}
24 changes: 19 additions & 5 deletions packages/studio/src/player/components/timelineClipDragPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,24 @@ export interface ResizePreviewContext {
buildSnapTargets: BuildSnapTargets;
}

/**
* Fold the gesture's scroll delta into the pointer x: edge auto-scroll moves
* the content while the pointer stays put, so the raw clientX under-reports the
* drag. Mirrors resolveTimelineMove's originScrollLeft handling, and is shared
* by the plain resize and the trim tools so both read the same pointer.
*/
export function compensateResizeScroll(
resize: Pick<ResizingClipState, "originScrollLeft">,
clientX: number,
scroll: HTMLDivElement | null,
): { originScrollLeft: number; effectiveClientX: number } {
const originScrollLeft = resize.originScrollLeft ?? scroll?.scrollLeft ?? 0;
return {
originScrollLeft,
effectiveClientX: clientX + ((scroll?.scrollLeft ?? originScrollLeft) - originScrollLeft),
};
}

export interface ResizePreviewResult {
originScrollLeft: number;
previewStart: number;
Expand All @@ -237,11 +255,7 @@ export function computeResizePreview(
ctx: ResizePreviewContext,
): ResizePreviewResult {
const { scroll, pps, buildSnapTargets } = ctx;
// Scroll compensation: auto-scroll moves the content while the pointer stays
// put, so fold the scroll delta into the pointer x (mirrors
// resolveTimelineMove's originScrollLeft handling).
const originScrollLeft = resize.originScrollLeft ?? scroll?.scrollLeft ?? 0;
const effectiveClientX = clientX + ((scroll?.scrollLeft ?? originScrollLeft) - originScrollLeft);
const { originScrollLeft, effectiveClientX } = compensateResizeScroll(resize, clientX, scroll);

const sourceRemaining =
resize.element.sourceDuration != null
Expand Down
Loading
Loading