mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 16:42:27 +00:00
feat(studio): timeline revamp with active-clip highlighting and hide controls (#2017)
Timeline UI - Highlight clips visible at the playhead in the primary color; others share one neutral color - Minimalist rounded clips, single-color track rows, no gutter icons or superscript labels - Per-track eye toggle and a per-element hide button in the design panel - Ruler zoom fixes: sub-second tick intervals and correct label formatting at high zoom - Sticky gutter so track controls stay visible while scrolling WYSIWYG visibility (data-hidden) - Runtime honors data-hidden (display:none), so hiding affects the render, not just the preview - HTML stays the source of truth; hide state persists and round-trips on reload Split several studio files to stay under the 600-line cap; pure relocations, no behavior change.
This commit is contained in:
@@ -360,7 +360,6 @@ export function StudioApp() {
|
||||
resetErrors: resetConsoleErrors,
|
||||
} = useConsoleErrorCapture(previewIframe);
|
||||
const dragOverlay = useDragOverlay(fileManager.handleImportFiles);
|
||||
// Gesture recording
|
||||
const handleToggleRecordingRef = useRef<() => void>(() => {});
|
||||
const domEditSessionRef = useRef(domEditSession);
|
||||
domEditSessionRef.current = domEditSession;
|
||||
@@ -531,6 +530,7 @@ export function StudioApp() {
|
||||
handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
|
||||
handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
|
||||
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
|
||||
handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
|
||||
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
|
||||
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
|
||||
handleRazorSplit={timelineEditing.handleRazorSplit}
|
||||
@@ -572,6 +572,7 @@ export function StudioApp() {
|
||||
domEditSaveTimestampRef={domEditSaveTimestampRef}
|
||||
recordEdit={editHistory.recordEdit}
|
||||
{...cropModeProps}
|
||||
onToggleElementHidden={timelineEditing.handleToggleElementHidden}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface StudioPreviewAreaProps {
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
) => Promise<void> | void;
|
||||
handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
|
||||
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
@@ -85,6 +86,7 @@ export function StudioPreviewArea({
|
||||
handleTimelineFileDrop,
|
||||
handleTimelineElementMove,
|
||||
handleTimelineElementResize,
|
||||
handleToggleTrackHidden,
|
||||
handleBlockedTimelineEdit,
|
||||
handleTimelineElementSplit,
|
||||
handleRazorSplit,
|
||||
@@ -165,6 +167,7 @@ export function StudioPreviewArea({
|
||||
// diamond reports a clip-% but the script ops key on the tween-%. Prefers the
|
||||
// anim in the keyframe's property group, falling back to the first keyframed one.
|
||||
const resolveKeyframeTarget = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
(pct: number): { animId: string; tweenPct: number } | null => {
|
||||
const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? "");
|
||||
const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
|
||||
@@ -182,6 +185,7 @@ export function StudioPreviewArea({
|
||||
() => ({
|
||||
onMoveElement: handleTimelineElementMove,
|
||||
onResizeElement: handleTimelineElementResize,
|
||||
onToggleTrackHidden: handleToggleTrackHidden,
|
||||
onBlockedEditAttempt: handleBlockedTimelineEdit,
|
||||
onSplitElement: handleTimelineElementSplit,
|
||||
onRazorSplit: handleRazorSplit,
|
||||
@@ -210,6 +214,7 @@ export function StudioPreviewArea({
|
||||
// drop past the boundary (last keyframe past the end, first before the start)
|
||||
// resizes the tween — position/duration grow so the dragged keyframe lands at
|
||||
// the drop while every other keyframe keeps its absolute time (value+ease too).
|
||||
// fallow-ignore-next-line complexity
|
||||
onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
|
||||
const target = resolveKeyframeTarget(fromClipPct);
|
||||
const sel = domEditSelection;
|
||||
@@ -280,6 +285,7 @@ export function StudioPreviewArea({
|
||||
[
|
||||
handleTimelineElementMove,
|
||||
handleTimelineElementResize,
|
||||
handleToggleTrackHidden,
|
||||
handleBlockedTimelineEdit,
|
||||
handleTimelineElementSplit,
|
||||
handleRazorSplit,
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface StudioRightPanelProps {
|
||||
kind: EditHistoryKind;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}) => Promise<void>;
|
||||
onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -78,6 +79,7 @@ export function StudioRightPanel({
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
recordEdit,
|
||||
onToggleElementHidden,
|
||||
}: StudioRightPanelProps) {
|
||||
const {
|
||||
rightWidth,
|
||||
@@ -350,6 +352,7 @@ export function StudioRightPanel({
|
||||
multiSelectCount={domEditGroupSelections.length}
|
||||
copiedAgentPrompt={copiedAgentPrompt}
|
||||
onClearSelection={clearDomSelection}
|
||||
onToggleElementHidden={onToggleElementHidden}
|
||||
onUngroup={handleUngroupSelection}
|
||||
onSetStyle={handleDomStyleCommit}
|
||||
onSetAttribute={handleDomAttributeCommit}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TimelineElement } from "../../player";
|
||||
import {
|
||||
buildInsetClipPathSides,
|
||||
buildStrokeStyleUpdates,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
parseInsetClipPathSides,
|
||||
setCssFilterFunctionPx,
|
||||
} from "./PropertyPanel";
|
||||
import { isSelectedElementHidden } from "./propertyPanelHelpers";
|
||||
|
||||
describe("PropertyPanel style helpers", () => {
|
||||
it("normalizes bounded pixel values without accepting incompatible units", () => {
|
||||
@@ -113,3 +115,26 @@ describe("PropertyPanel style helpers", () => {
|
||||
expect(buildStrokeStyleUpdates("solid", "4px")).toEqual([["border-style", "solid"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSelectedElementHidden", () => {
|
||||
it("reads hidden state by selected timeline id or key", () => {
|
||||
const elements: TimelineElement[] = [
|
||||
{ id: "visible", tag: "div", start: 0, duration: 1, track: 0 },
|
||||
{ id: "hidden", tag: "div", start: 0, duration: 1, track: 0, hidden: true },
|
||||
{
|
||||
id: "keyed-hidden",
|
||||
key: "scene.html:#keyed-hidden",
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 1,
|
||||
track: 0,
|
||||
hidden: true,
|
||||
},
|
||||
];
|
||||
|
||||
expect(isSelectedElementHidden(elements, null)).toBe(false);
|
||||
expect(isSelectedElementHidden(elements, "visible")).toBe(false);
|
||||
expect(isSelectedElementHidden(elements, "hidden")).toBe(true);
|
||||
expect(isSelectedElementHidden(elements, "scene.html:#keyed-hidden")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Move } from "../../icons/SystemIcons";
|
||||
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
||||
import { InspectorHeaderActions } from "./InspectorHeaderActions";
|
||||
import { useStudioShellContext } from "../../contexts/StudioContext";
|
||||
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
RESPONSIVE_GRID,
|
||||
readGsapRuntimeValuesForPanel,
|
||||
readGsapBorderRadiusForPanel,
|
||||
isSelectedElementHidden,
|
||||
} from "./propertyPanelHelpers";
|
||||
import { MetricField, Section } from "./propertyPanelPrimitives";
|
||||
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
|
||||
@@ -67,6 +69,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onAddTextField,
|
||||
onRemoveTextField,
|
||||
onAskAgent: _onAskAgent,
|
||||
onToggleElementHidden,
|
||||
onImportAssets,
|
||||
fontAssets = [],
|
||||
onImportFonts,
|
||||
@@ -106,6 +109,10 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
const clipboardTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const storeTime = usePlayerStore((s) => s.currentTime);
|
||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||
const timelineElements = usePlayerStore((s) => s.elements);
|
||||
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
||||
const selectedElementHidden = isSelectedElementHidden(timelineElements, selectedElementId);
|
||||
const visibilityToggleLabel = selectedElementHidden ? "Show element" : "Hide element";
|
||||
const liveTimeRef = useRef(storeTime);
|
||||
const [, forceRender] = useState(0);
|
||||
useEffect(() => {
|
||||
@@ -288,13 +295,32 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
|
||||
</div>
|
||||
<InspectorHeaderActions
|
||||
element={element}
|
||||
copied={clipboardCopied}
|
||||
onCopy={handleCopyElementInfo}
|
||||
onClear={onClearSelection}
|
||||
onUngroup={onUngroup}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
{selectedElementId && onToggleElementHidden && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={visibilityToggleLabel}
|
||||
title={visibilityToggleLabel}
|
||||
onClick={() => {
|
||||
void onToggleElementHidden(selectedElementId, !selectedElementHidden);
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
|
||||
>
|
||||
{selectedElementHidden ? (
|
||||
<EyeSlash size={13} weight="bold" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye size={13} weight="bold" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<InspectorHeaderActions
|
||||
element={element}
|
||||
copied={clipboardCopied}
|
||||
onCopy={handleCopyElementInfo}
|
||||
onClear={onClearSelection}
|
||||
onUngroup={onUngroup}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
|
||||
@@ -28,6 +28,7 @@ export function isElementComputedVisible(el: HTMLElement): boolean {
|
||||
|
||||
const VISUAL_LEAF_TAGS = new Set(["img", "video", "canvas", "svg", "audio"]);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function hasVisualPresence(el: HTMLElement): boolean {
|
||||
const win = el.ownerDocument.defaultView;
|
||||
if (!win) return false;
|
||||
@@ -236,9 +237,13 @@ export function isLargeRasterDomEditSelection(
|
||||
|
||||
// ─── Element finders ──────────────────────────────────────────────────────────
|
||||
|
||||
type FindElementSelection = Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex"> & {
|
||||
sourceFile?: string;
|
||||
};
|
||||
|
||||
export function findElementForSelection(
|
||||
doc: Document,
|
||||
selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile">,
|
||||
selection: FindElementSelection,
|
||||
activeCompositionPath: string | null = null,
|
||||
): HTMLElement | null {
|
||||
if (selection.hfId) {
|
||||
@@ -259,6 +264,7 @@ export function findElementForSelection(
|
||||
|
||||
if (!selection.selector) return null;
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
if (selection.selector.startsWith(".") && selection.selectorIndex != null) {
|
||||
const matches = querySelectorAllSafely(doc, selection.selector).filter(
|
||||
(candidate): candidate is HTMLElement =>
|
||||
@@ -270,6 +276,7 @@ export function findElementForSelection(
|
||||
return matches[selection.selectorIndex] ?? null;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
const matches = querySelectorAllSafely(doc, selection.selector).filter(
|
||||
(candidate): candidate is HTMLElement =>
|
||||
isHtmlElement(candidate) &&
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { roundRotationAngle } from "./manualEditsParsing";
|
||||
import { applyStudioMotionFromDom } from "./studioMotion";
|
||||
import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
|
||||
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
|
||||
|
||||
/* ── Gesture tracking ─────────────────────────────────────────────── */
|
||||
let studioManualEditGestureId = 0;
|
||||
@@ -162,24 +163,6 @@ export function restoreInlineDisplay(element: HTMLElement): void {
|
||||
}
|
||||
|
||||
/* ── Translate helpers ────────────────────────────────────────────── */
|
||||
function splitTopLevelWhitespace(value: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let current = "";
|
||||
for (const char of value.trim()) {
|
||||
if (char === "(") depth += 1;
|
||||
if (char === ")") depth = Math.max(0, depth - 1);
|
||||
if (/\s/.test(char) && depth === 0) {
|
||||
if (current) parts.push(current);
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current) parts.push(current);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function composeTranslateValue(element: HTMLElement, x: string, y: string): string {
|
||||
const original = element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR)?.trim();
|
||||
if (!original || original === "none") return `${x} ${y}`;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export function splitTopLevelWhitespace(value: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
// fallow-ignore-next-line code-duplication
|
||||
let current = "";
|
||||
for (const char of value.trim()) {
|
||||
if (char === "(") depth += 1;
|
||||
if (char === ")") depth = Math.max(0, depth - 1);
|
||||
if (/\s/.test(char) && depth === 0) {
|
||||
if (current) parts.push(current);
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current) parts.push(current);
|
||||
return parts;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { parseCssColor, type ParsedColor } from "./colorValue";
|
||||
import { COMMON_LOCAL_FONT_FAMILIES } from "./fontCatalog";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
|
||||
import type { TimelineElement } from "../../player";
|
||||
import { roundToCenti } from "../../utils/rounding";
|
||||
|
||||
export type {
|
||||
@@ -18,6 +19,16 @@ export function stripQueryAndHash(value: string): string {
|
||||
return value.slice(0, Math.min(queryIndex, hashIndex));
|
||||
}
|
||||
|
||||
export function isSelectedElementHidden(
|
||||
elements: readonly TimelineElement[],
|
||||
selectedElementId: string | null,
|
||||
): boolean {
|
||||
if (!selectedElementId) return false;
|
||||
return (
|
||||
elements.find((element) => (element.key ?? element.id) === selectedElementId)?.hidden === true
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Font types & constants (shared by font and section modules) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface PropertyPanelProps {
|
||||
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
|
||||
onRemoveTextField: (fieldKey: string) => void;
|
||||
onAskAgent: () => void;
|
||||
onToggleElementHidden?: (elementKey: string, hidden: boolean) => void | Promise<void>;
|
||||
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
|
||||
fontAssets?: ImportedFontAsset[];
|
||||
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
||||
|
||||
@@ -32,6 +32,7 @@ export function TimelineEditProvider({
|
||||
[
|
||||
value.onMoveElement,
|
||||
value.onResizeElement,
|
||||
value.onToggleTrackHidden,
|
||||
value.onBlockedEditAttempt,
|
||||
value.onSplitElement,
|
||||
value.onRazorSplit,
|
||||
|
||||
@@ -41,19 +41,30 @@ export type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
|
||||
// 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.
|
||||
export function findTimelineElementInIframe(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
element: TimelineElement,
|
||||
): Element | null {
|
||||
try {
|
||||
const doc = iframe?.contentDocument;
|
||||
if (!doc) return null;
|
||||
return element.domId
|
||||
? doc.getElementById(element.domId)
|
||||
: element.selector
|
||||
? (doc.querySelectorAll(element.selector)[element.selectorIndex ?? 0] ?? null)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function patchIframeDomTiming(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
element: TimelineElement,
|
||||
attrs: Array<[string, string]>,
|
||||
): void {
|
||||
try {
|
||||
const doc = iframe?.contentDocument;
|
||||
if (!doc) return;
|
||||
const el = element.domId
|
||||
? doc.getElementById(element.domId)
|
||||
: element.selector
|
||||
? (doc.querySelectorAll(element.selector)[element.selectorIndex ?? 0] ?? null)
|
||||
: null;
|
||||
const el = findTimelineElementInIframe(iframe, element);
|
||||
if (!el) return;
|
||||
for (const [name, value] of attrs) el.setAttribute(name, value);
|
||||
} catch {
|
||||
@@ -61,6 +72,7 @@ export function patchIframeDomTiming(
|
||||
}
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function resolveResizePlaybackStart(
|
||||
original: string,
|
||||
target: PatchTarget,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { usePlayerStore, type TimelineElement } from "../player";
|
||||
import { toggleTimelineElementHidden, toggleTimelineTrackHidden } from "./timelineTrackVisibility";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
function element(overrides: Partial<TimelineElement>): TimelineElement {
|
||||
return {
|
||||
id: "clip",
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 2,
|
||||
track: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function stubProjectFiles(files: Map<string, string>) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const encodedPath = url.slice(url.lastIndexOf("/") + 1);
|
||||
const path = decodeURIComponent(encodedPath);
|
||||
const content = files.get(path);
|
||||
return new Response(JSON.stringify({ content }), {
|
||||
status: content === undefined ? 404 : 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("toggleTimelineTrackHidden", () => {
|
||||
it("patches iframe DOM and persists all track elements as one edit-history entry", async () => {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
if (iframe.contentDocument) {
|
||||
iframe.contentDocument.body.innerHTML = `
|
||||
<div id="hero"></div>
|
||||
<div id="subtitle"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
const files = new Map([
|
||||
[
|
||||
"index.html",
|
||||
`<div id="hero" data-start="0" data-duration="2"></div>
|
||||
<div id="skip" data-start="0" data-duration="2"></div>`,
|
||||
],
|
||||
["scene.html", `<div id="subtitle" data-start="1" data-duration="2"></div>`],
|
||||
]);
|
||||
stubProjectFiles(files);
|
||||
|
||||
const writes = new Map<string, string>();
|
||||
const recordEdit = vi.fn();
|
||||
const timestampRef = { current: 0 };
|
||||
const pendingRef = { current: new Set<string>() };
|
||||
|
||||
await toggleTimelineTrackHidden({
|
||||
projectId: "project-1",
|
||||
activeCompPath: "index.html",
|
||||
timelineElements: [
|
||||
element({ id: "hero", domId: "hero", track: 0 }),
|
||||
element({ id: "skip", domId: "skip", track: 1 }),
|
||||
element({ id: "subtitle", domId: "subtitle", track: 0, sourceFile: "scene.html" }),
|
||||
],
|
||||
track: 0,
|
||||
hidden: true,
|
||||
previewIframe: iframe,
|
||||
writeProjectFile: async (path, content) => {
|
||||
writes.set(path, content);
|
||||
},
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef: timestampRef,
|
||||
pendingTimelineEditPathRef: pendingRef,
|
||||
});
|
||||
|
||||
expect(iframe.contentDocument?.getElementById("hero")?.hasAttribute("data-hidden")).toBe(true);
|
||||
expect(iframe.contentDocument?.getElementById("subtitle")?.hasAttribute("data-hidden")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(writes.get("index.html")).toContain('id="hero" data-start="0" data-duration="2"');
|
||||
expect(writes.get("index.html")).toContain('data-hidden=""');
|
||||
expect(writes.get("index.html")).toContain('id="skip" data-start="0" data-duration="2"');
|
||||
expect(writes.get("scene.html")).toContain('data-hidden=""');
|
||||
expect(pendingRef.current).toEqual(new Set(["index.html", "scene.html"]));
|
||||
expect(timestampRef.current).toBeGreaterThan(0);
|
||||
expect(recordEdit).toHaveBeenCalledTimes(1);
|
||||
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide track 0");
|
||||
expect(Object.keys(recordEdit.mock.calls[0]?.[0]?.files ?? {}).sort()).toEqual([
|
||||
"index.html",
|
||||
"scene.html",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes data-hidden from every element on the track", async () => {
|
||||
const files = new Map([
|
||||
[
|
||||
"index.html",
|
||||
`<div id="hero" data-start="0" data-duration="2" data-hidden=""></div>
|
||||
<div id="caption" data-start="2" data-duration="2" data-hidden=""></div>`,
|
||||
],
|
||||
]);
|
||||
stubProjectFiles(files);
|
||||
|
||||
const writes = new Map<string, string>();
|
||||
|
||||
await toggleTimelineTrackHidden({
|
||||
projectId: "project-1",
|
||||
activeCompPath: "index.html",
|
||||
timelineElements: [
|
||||
element({ id: "hero", domId: "hero", track: 0, hidden: true }),
|
||||
element({ id: "caption", domId: "caption", track: 0, hidden: true }),
|
||||
],
|
||||
track: 0,
|
||||
hidden: false,
|
||||
previewIframe: null,
|
||||
writeProjectFile: async (path, content) => {
|
||||
writes.set(path, content);
|
||||
},
|
||||
recordEdit: vi.fn(),
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
pendingTimelineEditPathRef: { current: new Set() },
|
||||
});
|
||||
|
||||
expect(writes.get("index.html")).not.toContain("data-hidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleTimelineElementHidden", () => {
|
||||
it("persists data-hidden for only the selected element and updates the player store", async () => {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
const seek = vi.fn();
|
||||
const win = iframe.contentWindow;
|
||||
if (!win) throw new Error("Expected iframe contentWindow");
|
||||
const playerWindow: Window & { __player?: { seek?: (time: number) => void } } = win;
|
||||
playerWindow.__player = { seek };
|
||||
|
||||
const files = new Map([
|
||||
[
|
||||
"index.html",
|
||||
`<div id="hero" data-start="0" data-duration="2"></div>
|
||||
<div id="track-mate" data-start="1" data-duration="2"></div>`,
|
||||
],
|
||||
]);
|
||||
stubProjectFiles(files);
|
||||
|
||||
const hero = element({ id: "hero", key: "index.html:#hero", domId: "hero", track: 0 });
|
||||
const trackMate = element({
|
||||
id: "track-mate",
|
||||
key: "index.html:#track-mate",
|
||||
domId: "track-mate",
|
||||
track: 0,
|
||||
});
|
||||
usePlayerStore.getState().setElements([hero, trackMate]);
|
||||
usePlayerStore.getState().setCurrentTime(1.25);
|
||||
|
||||
const writes = new Map<string, string>();
|
||||
const recordEdit = vi.fn();
|
||||
|
||||
const changedPaths = await toggleTimelineElementHidden({
|
||||
projectId: "project-1",
|
||||
activeCompPath: "index.html",
|
||||
timelineElements: [hero, trackMate],
|
||||
elementKey: "index.html:#hero",
|
||||
hidden: true,
|
||||
previewIframe: iframe,
|
||||
writeProjectFile: async (path, content) => {
|
||||
writes.set(path, content);
|
||||
},
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
pendingTimelineEditPathRef: { current: new Set() },
|
||||
});
|
||||
|
||||
expect(changedPaths).toEqual(["index.html"]);
|
||||
expect(writes.get("index.html")).toContain('id="hero" data-start="0" data-duration="2"');
|
||||
expect(writes.get("index.html")).toContain(
|
||||
'id="hero" data-start="0" data-duration="2" data-hidden=""',
|
||||
);
|
||||
expect(writes.get("index.html")).toContain(
|
||||
'id="track-mate" data-start="1" data-duration="2"></div>',
|
||||
);
|
||||
expect(recordEdit).toHaveBeenCalledTimes(1);
|
||||
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide element");
|
||||
expect(seek).toHaveBeenCalledWith(1.25);
|
||||
expect(
|
||||
usePlayerStore.getState().elements.find((el) => el.key === "index.html:#hero")?.hidden,
|
||||
).toBe(true);
|
||||
expect(
|
||||
usePlayerStore.getState().elements.find((el) => el.key === "index.html:#track-mate")?.hidden,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
import { useCallback } from "react";
|
||||
import { usePlayerStore, type TimelineElement } from "../player";
|
||||
import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements";
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
|
||||
import {
|
||||
applyPatchByTarget,
|
||||
buildPatchTarget,
|
||||
findTimelineElementInIframe,
|
||||
readFileContent,
|
||||
type RecordEditInput,
|
||||
} from "./timelineEditingHelpers";
|
||||
|
||||
interface MutableRef<T> {
|
||||
current: T;
|
||||
}
|
||||
|
||||
interface ReadonlyRef<T> {
|
||||
readonly current: T;
|
||||
}
|
||||
|
||||
interface ToggleTimelineTrackHiddenInput {
|
||||
projectId: string;
|
||||
activeCompPath: string | null;
|
||||
timelineElements: readonly TimelineElement[];
|
||||
track: number;
|
||||
hidden: boolean;
|
||||
previewIframe: HTMLIFrameElement | null;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: MutableRef<number>;
|
||||
pendingTimelineEditPathRef: MutableRef<Set<string>>;
|
||||
}
|
||||
|
||||
interface ToggleTimelineElementHiddenInput extends Omit<ToggleTimelineTrackHiddenInput, "track"> {
|
||||
elementKey: string;
|
||||
}
|
||||
|
||||
interface SetElementsHiddenInput {
|
||||
projectId: string;
|
||||
activeCompPath: string | null;
|
||||
elements: readonly TimelineElement[];
|
||||
hidden: boolean;
|
||||
label: string;
|
||||
previewIframe: HTMLIFrameElement | null;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: MutableRef<number>;
|
||||
pendingTimelineEditPathRef: MutableRef<Set<string>>;
|
||||
}
|
||||
|
||||
interface UseTimelineTrackVisibilityEditingInput extends Omit<
|
||||
ToggleTimelineTrackHiddenInput,
|
||||
"projectId" | "track" | "hidden" | "previewIframe"
|
||||
> {
|
||||
projectIdRef: ReadonlyRef<string | null>;
|
||||
previewIframeRef: ReadonlyRef<HTMLIFrameElement | null>;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
isRecordingRef?: ReadonlyRef<boolean>;
|
||||
forceReloadSdkSession?: () => void;
|
||||
}
|
||||
|
||||
interface UseTimelineElementVisibilityEditingInput extends Omit<
|
||||
ToggleTimelineElementHiddenInput,
|
||||
"projectId" | "elementKey" | "hidden" | "previewIframe"
|
||||
> {
|
||||
projectIdRef: ReadonlyRef<string | null>;
|
||||
previewIframeRef: ReadonlyRef<HTMLIFrameElement | null>;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
isRecordingRef?: ReadonlyRef<boolean>;
|
||||
forceReloadSdkSession?: () => void;
|
||||
}
|
||||
|
||||
function getTimelineElementTargetPath(
|
||||
element: TimelineElement,
|
||||
activeCompPath: string | null,
|
||||
): string {
|
||||
return element.sourceFile || activeCompPath || "index.html";
|
||||
}
|
||||
|
||||
function patchLiveHiddenState(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
elements: readonly TimelineElement[],
|
||||
hidden: boolean,
|
||||
): void {
|
||||
for (const element of elements) {
|
||||
const target = findTimelineElementInIframe(iframe, element);
|
||||
if (!target) continue;
|
||||
if (hidden) {
|
||||
target.setAttribute("data-hidden", "");
|
||||
} else {
|
||||
target.removeAttribute("data-hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reseekPreviewRuntime(iframe: HTMLIFrameElement | null): void {
|
||||
try {
|
||||
const win: (Window & { __player?: { seek?: (time: number) => void } }) | null =
|
||||
iframe?.contentWindow ?? null;
|
||||
win?.__player?.seek?.(usePlayerStore.getState().currentTime);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function groupElementsByTargetPath(
|
||||
elements: readonly TimelineElement[],
|
||||
activeCompPath: string | null,
|
||||
): Map<string, TimelineElement[]> {
|
||||
const byPath = new Map<string, TimelineElement[]>();
|
||||
for (const element of elements) {
|
||||
const targetPath = getTimelineElementTargetPath(element, activeCompPath);
|
||||
const existing = byPath.get(targetPath);
|
||||
if (existing) {
|
||||
existing.push(element);
|
||||
} else {
|
||||
byPath.set(targetPath, [element]);
|
||||
}
|
||||
}
|
||||
return byPath;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
async function setElementsHidden({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
elements,
|
||||
hidden,
|
||||
label,
|
||||
previewIframe,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
}: SetElementsHiddenInput): Promise<string[]> {
|
||||
if (elements.length === 0) return [];
|
||||
|
||||
patchLiveHiddenState(previewIframe, elements, hidden);
|
||||
reseekPreviewRuntime(previewIframe);
|
||||
|
||||
const hiddenOperation: PatchOperation = {
|
||||
type: "attribute",
|
||||
property: "hidden",
|
||||
value: hidden ? "" : null,
|
||||
};
|
||||
const originalByPath = new Map<string, string>();
|
||||
const files: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
|
||||
let patchedContent = await readFileContent(projectId, targetPath);
|
||||
originalByPath.set(targetPath, patchedContent);
|
||||
|
||||
for (const element of fileElements) {
|
||||
const patchTarget = buildPatchTarget(element);
|
||||
if (!patchTarget) {
|
||||
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
||||
}
|
||||
if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) {
|
||||
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
||||
}
|
||||
patchedContent = applyPatchByTarget(patchedContent, patchTarget, hiddenOperation);
|
||||
}
|
||||
|
||||
files[targetPath] = patchedContent;
|
||||
pendingTimelineEditPathRef.current.add(targetPath);
|
||||
}
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
const changedPaths = await saveProjectFilesWithHistory({
|
||||
projectId,
|
||||
label,
|
||||
kind: "timeline",
|
||||
files,
|
||||
readFile: async (path) => {
|
||||
const original = originalByPath.get(path);
|
||||
if (original !== undefined) return original;
|
||||
return readFileContent(projectId, path);
|
||||
},
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
for (const element of elements) {
|
||||
usePlayerStore.getState().updateElement(element.key ?? element.id, { hidden });
|
||||
}
|
||||
return changedPaths;
|
||||
} catch (error) {
|
||||
// The optimistic live patch already ran; a patch-target/save failure here would
|
||||
// otherwise leave the preview showing the wrong visibility until a reload. Revert
|
||||
// the live DOM to the prior state so what's on screen matches what persisted.
|
||||
patchLiveHiddenState(previewIframe, elements, !hidden);
|
||||
reseekPreviewRuntime(previewIframe);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleTimelineTrackHidden({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
track,
|
||||
hidden,
|
||||
previewIframe,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
}: ToggleTimelineTrackHiddenInput): Promise<string[]> {
|
||||
return setElementsHidden({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
elements: timelineElements.filter((element) => element.track === track),
|
||||
hidden,
|
||||
label: hidden ? `Hide track ${track}` : `Show track ${track}`,
|
||||
previewIframe,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
});
|
||||
}
|
||||
|
||||
export async function toggleTimelineElementHidden({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
elementKey,
|
||||
hidden,
|
||||
previewIframe,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
}: ToggleTimelineElementHiddenInput): Promise<string[]> {
|
||||
const element = timelineElements.find((item) => (item.key ?? item.id) === elementKey);
|
||||
return setElementsHidden({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
elements: element ? [element] : [],
|
||||
hidden,
|
||||
label: hidden ? "Hide element" : "Show element",
|
||||
previewIframe,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTimelineTrackVisibilityEditing({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
previewIframeRef,
|
||||
pendingTimelineEditPathRef,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
}: UseTimelineTrackVisibilityEditingInput): (track: number, hidden: boolean) => Promise<void> {
|
||||
// Resolve the eye toggle against the EXPANDED rows the canvas actually renders:
|
||||
// virtual sub-comp children carry their own (display.track + idx) track numbers,
|
||||
// so filtering the raw store list by a virtual track number would hide the wrong
|
||||
// outer-scene sibling sharing that index.
|
||||
const expandedElements = useExpandedTimelineElements();
|
||||
return useCallback(
|
||||
async (track: number, hidden: boolean) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
try {
|
||||
await toggleTimelineTrackHidden({
|
||||
projectId: pid,
|
||||
activeCompPath,
|
||||
timelineElements: expandedElements,
|
||||
track,
|
||||
hidden,
|
||||
previewIframe: previewIframeRef.current,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
});
|
||||
forceReloadSdkSession?.();
|
||||
} catch (error) {
|
||||
console.error("[Timeline] Failed to toggle track visibility", error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to toggle track visibility";
|
||||
showToast(message);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
expandedElements,
|
||||
previewIframeRef,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
isRecordingRef,
|
||||
showToast,
|
||||
forceReloadSdkSession,
|
||||
projectIdRef,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export function useTimelineElementVisibilityEditing({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
previewIframeRef,
|
||||
pendingTimelineEditPathRef,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
}: UseTimelineElementVisibilityEditingInput): (
|
||||
elementKey: string,
|
||||
hidden: boolean,
|
||||
) => Promise<void> {
|
||||
return useCallback(
|
||||
async (elementKey: string, hidden: boolean) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
try {
|
||||
await toggleTimelineElementHidden({
|
||||
projectId: pid,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
elementKey,
|
||||
hidden,
|
||||
previewIframe: previewIframeRef.current,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
});
|
||||
forceReloadSdkSession?.();
|
||||
} catch (error) {
|
||||
console.error("[Timeline] Failed to toggle element visibility", error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to toggle element visibility";
|
||||
showToast(message);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
previewIframeRef,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
isRecordingRef,
|
||||
showToast,
|
||||
forceReloadSdkSession,
|
||||
projectIdRef,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { act, isValidElement, type ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
||||
import { AudioWaveform } from "../player/components/AudioWaveform";
|
||||
import type { TimelineElement } from "../player/store/playerStore";
|
||||
import { normalizeCompositionSrc } from "./useRenderClipContent";
|
||||
@@ -63,7 +64,10 @@ describe("normalizeCompositionSrc", () => {
|
||||
});
|
||||
|
||||
describe("useRenderClipContent", () => {
|
||||
function renderClipContent(el: TimelineElement): ReactNode {
|
||||
function renderClipContent(
|
||||
el: TimelineElement,
|
||||
activePreviewUrl: string | null = "/api/projects/my-project/preview",
|
||||
): ReactNode {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
@@ -73,7 +77,7 @@ describe("useRenderClipContent", () => {
|
||||
const render = useRenderClipContent({
|
||||
projectIdRef: { current: "my-project" },
|
||||
compIdToSrc: new Map(),
|
||||
activePreviewUrl: "/api/projects/my-project/preview",
|
||||
activePreviewUrl,
|
||||
effectiveTimelineDuration: 12,
|
||||
});
|
||||
content = render(el, { clip: "#222", label: "#fff" });
|
||||
@@ -100,4 +104,66 @@ describe("useRenderClipContent", () => {
|
||||
expect(isValidElement(content)).toBe(true);
|
||||
if (isValidElement(content)) expect(content.type).toBe(AudioWaveform);
|
||||
});
|
||||
|
||||
it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => {
|
||||
const cases: Array<{ content: ReactNode; type: unknown }> = [
|
||||
{
|
||||
content: renderClipContent({
|
||||
id: "voiceover",
|
||||
tag: "audio",
|
||||
start: 1,
|
||||
duration: 4,
|
||||
track: 1,
|
||||
src: "assets/voiceover.mp3",
|
||||
}),
|
||||
type: AudioWaveform,
|
||||
},
|
||||
{
|
||||
content: renderClipContent({
|
||||
id: "nested",
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 4,
|
||||
track: 0,
|
||||
compositionSrc: "compositions/nested.html",
|
||||
}),
|
||||
type: CompositionThumbnail,
|
||||
},
|
||||
{
|
||||
content: renderClipContent(
|
||||
{
|
||||
id: "clip-video",
|
||||
tag: "video",
|
||||
start: 0,
|
||||
duration: 4,
|
||||
track: 0,
|
||||
src: "assets/clip.mp4",
|
||||
},
|
||||
null,
|
||||
),
|
||||
type: VideoThumbnail,
|
||||
},
|
||||
{
|
||||
content: renderClipContent(
|
||||
{
|
||||
id: "headline",
|
||||
tag: "div",
|
||||
start: 1,
|
||||
duration: 4,
|
||||
track: 0,
|
||||
},
|
||||
null,
|
||||
),
|
||||
type: CompositionThumbnail,
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
expect(isValidElement<{ label: string }>(item.content)).toBe(true);
|
||||
if (isValidElement<{ label: string }>(item.content)) {
|
||||
expect(item.content.type).toBe(item.type);
|
||||
expect(item.content.props.label).toBe("");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createElement } from "react";
|
||||
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { AudioWaveform } from "../player/components/AudioWaveform";
|
||||
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
||||
|
||||
export function normalizeCompositionSrc(
|
||||
compSrc: string,
|
||||
@@ -58,7 +57,7 @@ function renderAudioClip(el: TimelineElement, pid: string, labelColor: string):
|
||||
return createElement(AudioWaveform, {
|
||||
audioUrl,
|
||||
waveformUrl,
|
||||
label: getTimelineElementLabel(el),
|
||||
label: "",
|
||||
labelColor,
|
||||
trimStartFraction: start,
|
||||
trimEndFraction: end,
|
||||
@@ -102,7 +101,7 @@ export function useRenderClipContent({
|
||||
if (compSrc) {
|
||||
return createElement(CompositionThumbnail, {
|
||||
previewUrl: `/api/projects/${pid}/preview/comp/${compSrc}`,
|
||||
label: getTimelineElementLabel(el),
|
||||
label: "",
|
||||
labelColor: style.label,
|
||||
|
||||
seekTime: 0,
|
||||
@@ -122,7 +121,7 @@ export function useRenderClipContent({
|
||||
if (activePreviewUrl && el.duration > 0) {
|
||||
return createElement(CompositionThumbnail, {
|
||||
previewUrl: activePreviewUrl,
|
||||
label: getTimelineElementLabel(el),
|
||||
label: "",
|
||||
labelColor: style.label,
|
||||
|
||||
selector: el.selector,
|
||||
@@ -144,7 +143,7 @@ export function useRenderClipContent({
|
||||
: `/api/projects/${pid}/preview/${el.src}`;
|
||||
return createElement(VideoThumbnail, {
|
||||
videoSrc: mediaSrc,
|
||||
label: getTimelineElementLabel(el),
|
||||
label: "",
|
||||
labelColor: style.label,
|
||||
duration: el.duration,
|
||||
});
|
||||
@@ -153,7 +152,7 @@ export function useRenderClipContent({
|
||||
if (htmlPreviewEligible) {
|
||||
return createElement(CompositionThumbnail, {
|
||||
previewUrl: `/api/projects/${pid}/preview`,
|
||||
label: getTimelineElementLabel(el),
|
||||
label: "",
|
||||
labelColor: style.label,
|
||||
|
||||
selector: el.selector,
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
collectHtmlIds,
|
||||
resolveDroppedAssetDuration,
|
||||
} from "../utils/studioHelpers";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
import {
|
||||
buildPatchTarget,
|
||||
patchIframeDomTiming,
|
||||
@@ -33,36 +32,12 @@ import {
|
||||
scaleGsapPositions,
|
||||
} from "./timelineEditingHelpers";
|
||||
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
||||
import {
|
||||
useTimelineElementVisibilityEditing,
|
||||
useTimelineTrackVisibilityEditing,
|
||||
} from "./timelineTrackVisibility";
|
||||
import { sdkTimingPersist } from "../utils/sdkCutover";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}
|
||||
|
||||
interface UseTimelineEditingOptions {
|
||||
projectId: string | null;
|
||||
activeCompPath: string | null;
|
||||
timelineElements: TimelineElement[];
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
reloadPreview: () => void;
|
||||
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
||||
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
||||
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
||||
isRecordingRef?: React.RefObject<boolean>;
|
||||
/** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */
|
||||
sdkSession?: Composition | null;
|
||||
/** Resync the SDK session after a server-authoritative timeline write. */
|
||||
forceReloadSdkSession?: () => void;
|
||||
}
|
||||
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
@@ -323,6 +298,34 @@ export function useTimelineEditing({
|
||||
],
|
||||
);
|
||||
|
||||
const handleToggleTrackHidden = useTimelineTrackVisibilityEditing({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
previewIframeRef,
|
||||
pendingTimelineEditPathRef,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
});
|
||||
|
||||
const handleToggleElementHidden = useTimelineElementVisibilityEditing({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
previewIframeRef,
|
||||
pendingTimelineEditPathRef,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleTimelineElementDelete = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -562,6 +565,8 @@ export function useTimelineEditing({
|
||||
return {
|
||||
handleTimelineElementMove,
|
||||
handleTimelineElementResize,
|
||||
handleToggleTrackHidden,
|
||||
handleToggleElementHidden,
|
||||
handleTimelineElementDelete,
|
||||
handleTimelineElementSplit: handleRazorSplit,
|
||||
handleRazorSplit,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import type { TimelineElement } from "../player";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
|
||||
interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}
|
||||
|
||||
export interface UseTimelineEditingOptions {
|
||||
projectId: string | null;
|
||||
activeCompPath: string | null;
|
||||
timelineElements: TimelineElement[];
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: MutableRefObject<number>;
|
||||
reloadPreview: () => void;
|
||||
previewIframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
|
||||
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
||||
isRecordingRef?: RefObject<boolean>;
|
||||
/** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */
|
||||
sdkSession?: Composition | null;
|
||||
/** Resync the SDK session after a server-authoritative timeline write. */
|
||||
forceReloadSdkSession?: () => void;
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Eye as PhEye,
|
||||
FilmStrip,
|
||||
Stack,
|
||||
ChatCenteredText,
|
||||
ArrowsOutCardinal,
|
||||
MusicNote,
|
||||
Palette as PhPalette,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
TextT,
|
||||
X as PhX,
|
||||
Lightning,
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
ClipboardText,
|
||||
ArrowCounterClockwise,
|
||||
@@ -41,7 +39,6 @@ export const Clock = makeIcon(PhClock);
|
||||
export const Eye = makeIcon(PhEye);
|
||||
export const Film = makeIcon(FilmStrip);
|
||||
export const Layers = makeIcon(Stack);
|
||||
export const MessageSquare = makeIcon(ChatCenteredText);
|
||||
export const Move = makeIcon(ArrowsOutCardinal);
|
||||
export const Music = makeIcon(MusicNote);
|
||||
export const Palette = makeIcon(PhPalette);
|
||||
@@ -53,7 +50,18 @@ export const Type = makeIcon(TextT);
|
||||
export const X = makeIcon(PhX);
|
||||
export const Zap = makeIcon(Lightning);
|
||||
// Extra icons used in this project (not in lucide's default mapping above)
|
||||
export const ChevronDown = makeIcon(CaretDown);
|
||||
export const ChevronDown = ({ title, style, ...props }: IconProps) => {
|
||||
const transform = style?.transform ? `${style.transform} rotate(90deg)` : "rotate(90deg)";
|
||||
return (
|
||||
<CaretRight
|
||||
alt={title}
|
||||
aria-label={title}
|
||||
aria-hidden={title ? undefined : true}
|
||||
style={{ ...style, transform }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export const ChevronRight = makeIcon(CaretRight);
|
||||
export const ClipboardList = makeIcon(ClipboardText);
|
||||
export const RotateCcw = makeIcon(ArrowCounterClockwise);
|
||||
|
||||
@@ -32,6 +32,7 @@ function extractPeaks(channelData: Float32Array, barCount: number): number[] {
|
||||
const start = i * samplesPerBar;
|
||||
const end = Math.min(start + samplesPerBar, channelData.length);
|
||||
for (let j = start; j < end; j++) {
|
||||
// fallow-ignore-next-line code-duplication
|
||||
const abs = Math.abs(channelData[j] ?? 0);
|
||||
if (abs > max) max = abs;
|
||||
}
|
||||
@@ -195,14 +196,16 @@ export const AudioWaveform = memo(function AudioWaveform({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute top-0 left-0 right-0 px-1.5 py-0.5 z-10">
|
||||
<span
|
||||
className="text-[9px] font-semibold truncate block leading-tight"
|
||||
style={{ color: labelColor, textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{label && (
|
||||
<div className="absolute top-0 left-0 right-0 px-1.5 py-0.5 z-10">
|
||||
<span
|
||||
className="text-[9px] font-semibold truncate block leading-tight"
|
||||
style={{ color: labelColor, textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -66,6 +66,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
if (!el) return;
|
||||
|
||||
const measured = el.parentElement?.clientWidth || el.clientWidth;
|
||||
// fallow-ignore-next-line code-duplication
|
||||
setContainerWidth(measured);
|
||||
|
||||
const target = el.parentElement || el;
|
||||
@@ -130,17 +131,19 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute left-3 top-0 bottom-0 flex items-center" style={{ zIndex: 10 }}>
|
||||
<span
|
||||
className="block max-w-full truncate text-[10px] font-semibold leading-none"
|
||||
style={{
|
||||
color: labelColor,
|
||||
textShadow: loaded ? "0 1px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.6)" : "none",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{label && (
|
||||
<div className="absolute left-3 top-0 bottom-0 flex items-center" style={{ zIndex: 10 }}>
|
||||
<span
|
||||
className="block max-w-full truncate text-[10px] font-semibold leading-none"
|
||||
style={{
|
||||
color: labelColor,
|
||||
textShadow: loaded ? "0 1px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.6)" : "none",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,29 +12,39 @@ interface PlayheadIndicatorProps {
|
||||
|
||||
export function PlayheadIndicator({
|
||||
color = "var(--hf-accent, #3CE6AC)",
|
||||
glowColor = "rgba(60,230,172,0.5)",
|
||||
glowColor = "rgba(60,230,172,0.14)",
|
||||
}: PlayheadIndicatorProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute top-0 bottom-0"
|
||||
style={{
|
||||
left: "50%",
|
||||
width: 2,
|
||||
marginLeft: -1,
|
||||
background: color,
|
||||
boxShadow: `0 0 8px ${glowColor}`,
|
||||
width: 13,
|
||||
transform: "translateX(-50%)",
|
||||
background: `radial-gradient(closest-side, ${glowColor}, transparent)`,
|
||||
}}
|
||||
/>
|
||||
<div className="absolute" style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}>
|
||||
<div
|
||||
className="absolute top-0 bottom-0"
|
||||
style={{
|
||||
left: "50%",
|
||||
width: 1,
|
||||
marginLeft: -0.5,
|
||||
background: color,
|
||||
boxShadow: `0 0 6px ${glowColor}`,
|
||||
}}
|
||||
/>
|
||||
<div className="absolute" style={{ left: "50%", top: 1, transform: "translateX(-50%)" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: "6px solid transparent",
|
||||
borderRight: "6px solid transparent",
|
||||
borderTop: `8px solid ${color}`,
|
||||
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRadius: 2,
|
||||
background: color,
|
||||
boxShadow: `0 1px 3px rgba(0,0,0,0.55), 0 0 5px ${glowColor}`,
|
||||
transform: "rotate(45deg)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { RULER_H, TRACK_H } from "./timelineLayout";
|
||||
import { formatTime } from "../lib/time";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -30,6 +31,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("Timeline provider boundary", () => {
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("renders the public Timeline export without TimelineEditProvider", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
@@ -55,6 +57,99 @@ describe("Timeline provider boundary", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("renders the gutter without legacy icons or hue dots", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
Object.defineProperty(host, "clientWidth", {
|
||||
configurable: true,
|
||||
value: 640,
|
||||
});
|
||||
|
||||
usePlayerStore.setState({
|
||||
duration: 4,
|
||||
timelineReady: true,
|
||||
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }],
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(React.createElement(Timeline));
|
||||
});
|
||||
|
||||
const hueDot = Array.from(host.querySelectorAll("div")).find(
|
||||
(node) =>
|
||||
node.style.width === "6px" &&
|
||||
node.style.height === "6px" &&
|
||||
node.style.borderRadius === "9999px",
|
||||
);
|
||||
|
||||
expect(host.querySelector('img[src^="/icons/timeline/"]')).toBeNull();
|
||||
expect(hueDot).toBeUndefined();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("requests persisted track visibility from the gutter without seeking", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
Object.defineProperty(host, "clientWidth", {
|
||||
configurable: true,
|
||||
value: 640,
|
||||
});
|
||||
|
||||
usePlayerStore.setState({
|
||||
duration: 4,
|
||||
timelineReady: true,
|
||||
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0, hidden: true }],
|
||||
});
|
||||
|
||||
const onSeek = vi.fn();
|
||||
const onToggleTrackHidden = vi.fn();
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
TimelineEditProvider,
|
||||
{ value: { onToggleTrackHidden } },
|
||||
React.createElement(Timeline, { onSeek }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Show track 0"]');
|
||||
expect(button).not.toBeNull();
|
||||
if (!button) throw new Error("Expected a track visibility toggle");
|
||||
|
||||
act(() => {
|
||||
button.dispatchEvent(
|
||||
new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
clientX: 120,
|
||||
clientY: 40,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(onSeek).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
button.click();
|
||||
});
|
||||
|
||||
const row = button.parentElement?.parentElement;
|
||||
const trackContent = row?.children.item(1);
|
||||
expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false);
|
||||
expect(trackContent).toBeInstanceOf(HTMLElement);
|
||||
if (!(trackContent instanceof HTMLElement)) {
|
||||
throw new Error("Expected track content element");
|
||||
}
|
||||
expect(trackContent.style.opacity).toBe("0.35");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("opens the keyframe context menu without seeking to that keyframe", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
@@ -177,10 +272,10 @@ describe("generateTicks", () => {
|
||||
it("uses denser major labels as timeline zoom increases", () => {
|
||||
const fitTicks = generateTicks(180, 10);
|
||||
const zoomedTicks = generateTicks(180, 48);
|
||||
expect(fitTicks.major[1] - fitTicks.major[0]).toBe(15);
|
||||
expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(5);
|
||||
expect(fitTicks.major[1] - fitTicks.major[0]).toBe(10);
|
||||
expect(fitTicks.minor).toContain(5);
|
||||
expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(2);
|
||||
expect(zoomedTicks.minor).toContain(1);
|
||||
expect(zoomedTicks.minor).toContain(4);
|
||||
});
|
||||
|
||||
it("keeps labels readable instead of placing one at every tiny tick", () => {
|
||||
@@ -194,6 +289,7 @@ describe("formatTime", () => {
|
||||
expect(formatTime(0)).toBe("0:00");
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("formats seconds below a minute", () => {
|
||||
expect(formatTime(5)).toBe("0:05");
|
||||
expect(formatTime(30)).toBe("0:30");
|
||||
@@ -261,8 +357,12 @@ describe("getTimelineScrollLeftForZoomTransition", () => {
|
||||
expect(getTimelineScrollLeftForZoomTransition("manual", "fit", 480)).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves the current scroll offset for other zoom transitions", () => {
|
||||
expect(getTimelineScrollLeftForZoomTransition("fit", "fit", 480)).toBe(480);
|
||||
it("resets horizontal scroll whenever the next zoom mode is fit", () => {
|
||||
expect(getTimelineScrollLeftForZoomTransition("fit", "fit", 480)).toBe(0);
|
||||
expect(getTimelineScrollLeftForZoomTransition(null, "fit", 480)).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves the current scroll offset for manual zoom transitions", () => {
|
||||
expect(getTimelineScrollLeftForZoomTransition("fit", "manual", 480)).toBe(480);
|
||||
expect(getTimelineScrollLeftForZoomTransition("manual", "manual", 480)).toBe(480);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useMemo, useCallback, useState, useEffect, memo, type ReactNode } from "react";
|
||||
import { useRef, useMemo, useCallback, useState, useEffect, memo } from "react";
|
||||
import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
|
||||
import { isMusicTrack } from "../../utils/timelineInspector";
|
||||
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
|
||||
@@ -6,9 +6,10 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { EditPopover } from "./EditModal";
|
||||
import { defaultTimelineTheme, type TimelineTheme } from "./timelineTheme";
|
||||
import { defaultTimelineTheme } from "./timelineTheme";
|
||||
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
||||
import { useTimelinePlayhead } from "./useTimelinePlayhead";
|
||||
import { useTimelineActiveClips } from "./useTimelineActiveClips";
|
||||
import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons";
|
||||
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
||||
import { useTimelineZoom } from "./useTimelineZoom";
|
||||
@@ -21,17 +22,15 @@ import {
|
||||
} from "./KeyframeDiamondContextMenu";
|
||||
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
||||
import { ClipContextMenu } from "./ClipContextMenu";
|
||||
import { TimelineShortcutHint } from "./TimelineShortcutHint";
|
||||
import {
|
||||
GUTTER,
|
||||
generateTicks,
|
||||
getTimelineCanvasHeight,
|
||||
shouldShowTimelineShortcutHint,
|
||||
} from "./timelineLayout";
|
||||
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
||||
import {
|
||||
useResolvedTimelineEditCallbacks,
|
||||
type TimelineEditOverrides,
|
||||
} from "./useResolvedTimelineEditCallbacks";
|
||||
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
|
||||
import type { TimelineProps } from "./TimelineTypes";
|
||||
|
||||
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
|
||||
export {
|
||||
@@ -48,19 +47,6 @@ export {
|
||||
getDefaultDroppedTrack,
|
||||
} from "./timelineLayout";
|
||||
|
||||
interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
|
||||
onSeek?: (time: number) => void;
|
||||
onDrillDown?: (element: TimelineElement) => void;
|
||||
renderClipContent?: (
|
||||
element: TimelineElement,
|
||||
style: { clip: string; label: string },
|
||||
) => ReactNode;
|
||||
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
||||
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
||||
onSelectElement?: (element: TimelineElement | null) => void;
|
||||
theme?: Partial<TimelineTheme>;
|
||||
}
|
||||
|
||||
export const Timeline = memo(function Timeline({
|
||||
onSeek,
|
||||
onDrillDown,
|
||||
@@ -162,20 +148,26 @@ export const Timeline = memo(function Timeline({
|
||||
});
|
||||
}, [syncShortcutHintVisibility]);
|
||||
|
||||
const setContainerRef = useCallback(
|
||||
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
containerRef.current = el;
|
||||
}, []);
|
||||
|
||||
const setScrollRef = useCallback(
|
||||
(el: HTMLDivElement | null) => {
|
||||
if (roRef.current) {
|
||||
roRef.current.disconnect();
|
||||
roRef.current = null;
|
||||
}
|
||||
containerRef.current = el;
|
||||
scrollRef.current = el;
|
||||
if (!el) return;
|
||||
setViewportWidth(el.clientWidth);
|
||||
scheduleShortcutHintVisibilitySync();
|
||||
roRef.current = new ResizeObserver(([entry]) => {
|
||||
setViewportWidth(entry.contentRect.width);
|
||||
|
||||
const syncScrollViewport = () => {
|
||||
setViewportWidth(el.clientWidth);
|
||||
scheduleShortcutHintVisibilitySync();
|
||||
});
|
||||
};
|
||||
|
||||
syncScrollViewport();
|
||||
roRef.current = new ResizeObserver(syncScrollViewport);
|
||||
roRef.current.observe(el);
|
||||
},
|
||||
[scheduleShortcutHintVisibilitySync],
|
||||
@@ -273,6 +265,13 @@ export const Timeline = memo(function Timeline({
|
||||
const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent);
|
||||
ppsRef.current = pps;
|
||||
const trackContentWidth = Math.max(0, effectiveDuration * pps);
|
||||
const clipStateVersion = useMemo(
|
||||
() =>
|
||||
expandedElements
|
||||
.map((el) => `${el.key ?? el.id}:${el.start}:${el.duration}:${el.track}`)
|
||||
.join("|"),
|
||||
[expandedElements],
|
||||
);
|
||||
const zoomModeRef = useRef(zoomMode);
|
||||
zoomModeRef.current = zoomMode;
|
||||
const manualZoomPercentRef = useRef(manualZoomPercent);
|
||||
@@ -301,6 +300,11 @@ export const Timeline = memo(function Timeline({
|
||||
setManualZoomPercent,
|
||||
onSeek,
|
||||
});
|
||||
useTimelineActiveClips({
|
||||
scrollRef,
|
||||
currentTime,
|
||||
clipStateVersion,
|
||||
});
|
||||
|
||||
const {
|
||||
rangeSelection,
|
||||
@@ -340,8 +344,7 @@ export const Timeline = memo(function Timeline({
|
||||
() => generateTicks(effectiveDuration, pps),
|
||||
[effectiveDuration, pps],
|
||||
);
|
||||
const majorTickInterval =
|
||||
major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
|
||||
const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
|
||||
|
||||
useEffect(() => {
|
||||
syncShortcutHintVisibility();
|
||||
@@ -406,7 +409,7 @@ export const Timeline = memo(function Timeline({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
ref={setScrollRef}
|
||||
tabIndex={-1}
|
||||
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`}
|
||||
onDragOver={handleAssetDragOver}
|
||||
@@ -434,7 +437,6 @@ export const Timeline = memo(function Timeline({
|
||||
totalH={totalH}
|
||||
effectiveDuration={effectiveDuration}
|
||||
majorTickInterval={majorTickInterval}
|
||||
shiftHeld={shiftHeld}
|
||||
rangeSelection={rangeSelection}
|
||||
theme={theme}
|
||||
displayTrackOrder={displayTrackOrder}
|
||||
@@ -472,6 +474,9 @@ export const Timeline = memo(function Timeline({
|
||||
const elKey = el.key ?? el.id;
|
||||
setSelectedElementId(elKey);
|
||||
onSelectElement?.(el);
|
||||
// Visually select the clicked diamond (matches shift-click / motion-path
|
||||
// selection); cleared above so this single-selects it.
|
||||
toggleSelectedKeyframe(`${elKey}:${pct}`);
|
||||
const absTime = el.start + (pct / 100) * el.duration;
|
||||
onSeek?.(absTime);
|
||||
const kfData = keyframeCache?.get(elKey);
|
||||
@@ -519,22 +524,7 @@ export const Timeline = memo(function Timeline({
|
||||
</div>
|
||||
|
||||
{showShortcutHint && !showPopover && !rangeSelection && (
|
||||
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
|
||||
style={{ background: "rgba(17,23,35,0.84)", borderColor: theme.gutterBorder }}
|
||||
>
|
||||
<kbd
|
||||
className="text-[9px] font-mono px-1 py-0.5 rounded"
|
||||
style={{ color: theme.textSecondary, background: "rgba(255,255,255,0.06)" }}
|
||||
>
|
||||
Shift
|
||||
</kbd>
|
||||
<span className="text-[9px]" style={{ color: theme.textSecondary }}>
|
||||
+ drag/click to edit range
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<TimelineShortcutHint theme={theme} />
|
||||
)}
|
||||
|
||||
{showPopover && rangeSelection && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, type ReactNode } from "react";
|
||||
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
||||
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
|
||||
import { TimelineClip } from "./TimelineClip";
|
||||
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
||||
@@ -24,21 +25,15 @@ import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
||||
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
||||
import { isMusicTrack } from "../../utils/timelineInspector";
|
||||
|
||||
function ClipLabel({ element, color }: { element: TimelineElement; color: string }) {
|
||||
function ClipLintDot({ element }: { element: TimelineElement }) {
|
||||
const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
|
||||
if (!lint || lint.count === 0) return null;
|
||||
return (
|
||||
<span
|
||||
className="flex items-center gap-1 truncate text-[10px] font-medium leading-none"
|
||||
style={{ color }}
|
||||
>
|
||||
{element.label || element.id || element.tag}
|
||||
{lint && lint.count > 0 && (
|
||||
<span
|
||||
className="flex-shrink-0 w-1.5 h-1.5 rounded-full bg-amber-400"
|
||||
title={lint.messages.join("\n")}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
className="absolute w-1.5 h-1.5 rounded-full bg-amber-400"
|
||||
style={{ top: 7, right: 7 }}
|
||||
title={lint.messages.join("\n")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +45,6 @@ interface TimelineCanvasProps {
|
||||
totalH: number;
|
||||
effectiveDuration: number;
|
||||
majorTickInterval: number;
|
||||
shiftHeld: boolean;
|
||||
rangeSelection: TimelineRangeSelection | null;
|
||||
theme: TimelineTheme;
|
||||
displayTrackOrder: number[];
|
||||
@@ -109,7 +103,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
totalH,
|
||||
effectiveDuration,
|
||||
majorTickInterval,
|
||||
shiftHeld,
|
||||
rangeSelection,
|
||||
theme,
|
||||
displayTrackOrder,
|
||||
@@ -148,7 +141,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
onContextMenuClip,
|
||||
beatAnalysis,
|
||||
}: TimelineCanvasProps) {
|
||||
const { onResizeElement, onMoveElement, onRazorSplit, onRazorSplitAll } =
|
||||
const { onResizeElement, onMoveElement, onToggleTrackHidden, onRazorSplit, onRazorSplitAll } =
|
||||
useTimelineEditContextOptional();
|
||||
const beatDragging = usePlayerStore((s) => s.beatDragging);
|
||||
const draggedElement = draggedClip?.element ?? null;
|
||||
@@ -180,17 +173,12 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
const renderClipChildren = (element: TimelineElement, clipStyle: TrackVisualStyle) => (
|
||||
<>
|
||||
{renderClipOverlay?.(element)}
|
||||
<div
|
||||
className={
|
||||
renderClipContent
|
||||
? "absolute inset-0 overflow-hidden"
|
||||
: "flex items-center overflow-hidden flex-1 min-w-0 px-3 gap-2"
|
||||
}
|
||||
>
|
||||
{renderClipContent?.(element, clipStyle) ?? (
|
||||
<ClipLabel element={element} color={clipStyle.label} />
|
||||
)}
|
||||
</div>
|
||||
{!renderClipContent && <ClipLintDot element={element} />}
|
||||
{renderClipContent && (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{renderClipContent(element, clipStyle)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -204,258 +192,290 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
totalH={totalH}
|
||||
effectiveDuration={effectiveDuration}
|
||||
majorTickInterval={majorTickInterval}
|
||||
shiftHeld={shiftHeld}
|
||||
rangeSelection={rangeSelection}
|
||||
theme={theme}
|
||||
beatAnalysis={beatAnalysis}
|
||||
/>
|
||||
|
||||
{displayTrackOrder.map((trackNum) => {
|
||||
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
|
||||
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
|
||||
const isPendingTrack =
|
||||
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
|
||||
// The beat-dot strip occupies the top of this track's lane (active track,
|
||||
// or the music track when nothing is selected). When shown, keyframe
|
||||
// diamonds shrink + drop to the bottom half so they don't collide with it.
|
||||
const beatStripOnTrack =
|
||||
(beatAnalysis?.beatTimes?.length ?? 0) >= 2 &&
|
||||
(selectedElementId
|
||||
? els.some((e) => (e.key ?? e.id) === selectedElementId)
|
||||
: els.some(isMusicTrack));
|
||||
return (
|
||||
<div
|
||||
key={trackNum}
|
||||
className="relative flex"
|
||||
style={{
|
||||
height: TRACK_H,
|
||||
background: theme.rowBackground,
|
||||
borderBottom: `1px solid ${theme.rowBorder}`,
|
||||
}}
|
||||
>
|
||||
{
|
||||
// fallow-ignore-next-line complexity
|
||||
displayTrackOrder.map((trackNum) => {
|
||||
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
|
||||
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
|
||||
const isPendingTrack =
|
||||
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
|
||||
const rowBackground =
|
||||
displayTrackOrder.indexOf(trackNum) % 2 === 0 ? theme.rowBackground : "#0D0E12";
|
||||
// The beat-dot strip occupies the top of this track's lane (active track,
|
||||
// or the music track when nothing is selected). When shown, keyframe
|
||||
// diamonds shrink + drop to the bottom half so they don't collide with it.
|
||||
const beatStripOnTrack =
|
||||
(beatAnalysis?.beatTimes?.length ?? 0) >= 2 &&
|
||||
(selectedElementId
|
||||
? els.some((e) => (e.key ?? e.id) === selectedElementId)
|
||||
: els.some(isMusicTrack));
|
||||
const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center justify-center"
|
||||
key={trackNum}
|
||||
className="relative flex"
|
||||
style={{
|
||||
width: GUTTER,
|
||||
background: theme.gutterBackground,
|
||||
borderRight: `1px solid ${theme.gutterBorder}`,
|
||||
height: TRACK_H,
|
||||
background: rowBackground,
|
||||
borderBottom: `1px solid ${theme.rowBorder}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
className="sticky left-0 z-[12] flex-shrink-0 flex items-center justify-center"
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 6,
|
||||
backgroundColor: ts.iconBackground,
|
||||
border: `1px solid ${theme.gutterBorder}`,
|
||||
color: "#fff",
|
||||
width: GUTTER,
|
||||
background: theme.gutterBackground,
|
||||
borderRight: `1px solid ${theme.gutterBorder}`,
|
||||
}}
|
||||
>
|
||||
{ts.icon}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
|
||||
title={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
||||
isTrackHidden
|
||||
? "text-[#3CE6AC] hover:text-white"
|
||||
: "text-white/35 hover:text-white/75"
|
||||
}`}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void onToggleTrackHidden?.(trackNum, !isTrackHidden);
|
||||
}}
|
||||
>
|
||||
{isTrackHidden ? (
|
||||
<EyeSlash size={14} weight="bold" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye size={14} weight="bold" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: trackContentWidth }} className="relative">
|
||||
{/* Faint beat lines in every track's background (behind the clips);
|
||||
the active move-snap target is highlighted. */}
|
||||
<BeatBackgroundLines
|
||||
beatTimes={beatAnalysis?.beatTimes}
|
||||
beatStrengths={beatAnalysis?.beatStrengths}
|
||||
pps={pps}
|
||||
highlightTime={draggedClip?.started ? draggedClip.snapBeatTime : null}
|
||||
/>
|
||||
{/* Beat dots on the active track (the one holding the selection),
|
||||
falling back to the music track when nothing is selected. */}
|
||||
{beatStripOnTrack && (
|
||||
<BeatStrip
|
||||
<div
|
||||
style={{
|
||||
width: trackContentWidth,
|
||||
opacity: isTrackHidden ? 0.35 : 1,
|
||||
transition: "opacity 120ms ease",
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
{/* Faint beat lines in every track's background (behind the clips);
|
||||
the active move-snap target is highlighted. */}
|
||||
<BeatBackgroundLines
|
||||
beatTimes={beatAnalysis?.beatTimes}
|
||||
beatStrengths={beatAnalysis?.beatStrengths}
|
||||
pps={pps}
|
||||
highlightTime={draggedClip?.started ? draggedClip.snapBeatTime : null}
|
||||
/>
|
||||
)}
|
||||
{isPendingTrack && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
style={{
|
||||
paddingLeft: 16,
|
||||
color: ts.label,
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
New track
|
||||
</div>
|
||||
)}
|
||||
{els.map((el, i) => {
|
||||
const clipStyle = getTrackStyle(el.tag);
|
||||
const elementKey = el.key ?? el.id;
|
||||
const capabilities = getTimelineEditCapabilities(el);
|
||||
const isSelected = selectedElementId === elementKey;
|
||||
const isComposition = !!el.compositionSrc;
|
||||
const clipKey = `${elementKey}-${i}`;
|
||||
const isDraggingClip =
|
||||
draggedClip?.started === true &&
|
||||
(draggedElement?.key ?? draggedElement?.id) === elementKey;
|
||||
if (isDraggingClip) return null;
|
||||
const previewElement = getPreviewElement(el);
|
||||
return (
|
||||
<TimelineClip
|
||||
key={clipKey}
|
||||
onContextMenu={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onContextMenuClip?.(e, el);
|
||||
}}
|
||||
el={previewElement}
|
||||
{/* Beat dots on the active track (the one holding the selection),
|
||||
falling back to the music track when nothing is selected. */}
|
||||
{beatStripOnTrack && (
|
||||
<BeatStrip
|
||||
beatTimes={beatAnalysis?.beatTimes}
|
||||
beatStrengths={beatAnalysis?.beatStrengths}
|
||||
pps={pps}
|
||||
clipY={CLIP_Y}
|
||||
isSelected={isSelected}
|
||||
isHovered={hoveredClip === clipKey}
|
||||
isDragging={false}
|
||||
hasCustomContent={!!renderClipContent}
|
||||
capabilities={capabilities}
|
||||
theme={theme}
|
||||
trackStyle={clipStyle}
|
||||
isComposition={isComposition}
|
||||
onHoverStart={() => setHoveredClip(clipKey)}
|
||||
onHoverEnd={() => setHoveredClip(null)}
|
||||
onResizeStart={(edge, e) => {
|
||||
if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
|
||||
if (edge === "start" && !capabilities.canTrimStart) return;
|
||||
if (edge === "end" && !capabilities.canTrimEnd) return;
|
||||
e.stopPropagation();
|
||||
blockedClipRef.current = null;
|
||||
setShowPopover(false);
|
||||
setRangeSelection(null);
|
||||
setResizingClip({
|
||||
element: el,
|
||||
edge,
|
||||
originClientX: e.clientX,
|
||||
previewStart: el.start,
|
||||
previewDuration: el.duration,
|
||||
previewPlaybackStart: el.playbackStart,
|
||||
started: false,
|
||||
});
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
if (usePlayerStore.getState().activeTool === "razor") return;
|
||||
if (e.shiftKey) {
|
||||
shiftClickClipRef.current = {
|
||||
element: el,
|
||||
anchorX: e.clientX,
|
||||
anchorY: e.clientY,
|
||||
};
|
||||
return;
|
||||
}
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const blockedIntent = resolveBlockedTimelineEditIntent({
|
||||
width: rect.width,
|
||||
offsetX: e.clientX - rect.left,
|
||||
handleWidth: CLIP_HANDLE_W,
|
||||
capabilities,
|
||||
});
|
||||
if (
|
||||
blockedIntent &&
|
||||
((blockedIntent === "move" && onMoveElement) ||
|
||||
(blockedIntent !== "move" && onResizeElement))
|
||||
) {
|
||||
blockedClipRef.current = {
|
||||
element: el,
|
||||
intent: blockedIntent,
|
||||
originClientX: e.clientX,
|
||||
originClientY: e.clientY,
|
||||
started: false,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (!onMoveElement || !capabilities.canMove) return;
|
||||
blockedClipRef.current = null;
|
||||
setShowPopover(false);
|
||||
setRangeSelection(null);
|
||||
setDraggedClip({
|
||||
element: el,
|
||||
originClientX: e.clientX,
|
||||
originClientY: e.clientY,
|
||||
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
|
||||
originScrollTop: scrollRef.current?.scrollTop ?? 0,
|
||||
pointerClientX: e.clientX,
|
||||
pointerClientY: e.clientY,
|
||||
pointerOffsetX: e.clientX - rect.left,
|
||||
pointerOffsetY: e.clientY - rect.top,
|
||||
previewStart: el.start,
|
||||
previewTrack: el.track,
|
||||
snapBeatTime: null,
|
||||
started: false,
|
||||
});
|
||||
syncClipDragAutoScroll(e.clientX, e.clientY);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (suppressClickRef.current) return;
|
||||
const { activeTool } = usePlayerStore.getState();
|
||||
if (activeTool === "razor" && onRazorSplit) {
|
||||
const clipRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const clickOffsetX = e.clientX - clipRect.left;
|
||||
const splitTime = previewElement.start + clickOffsetX / pps;
|
||||
const clampedTime = Math.max(
|
||||
previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
|
||||
Math.min(
|
||||
previewElement.start +
|
||||
previewElement.duration -
|
||||
SPLIT_BOUNDARY_EPSILON_S,
|
||||
splitTime,
|
||||
),
|
||||
);
|
||||
if (e.shiftKey && onRazorSplitAll) {
|
||||
onRazorSplitAll(clampedTime);
|
||||
} else {
|
||||
onRazorSplit(el, clampedTime);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const nextElement = isSelected ? null : el;
|
||||
setSelectedElementId(nextElement ? elementKey : null);
|
||||
onSelectElement?.(nextElement);
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (suppressClickRef.current) return;
|
||||
if (isComposition && onDrillDown) onDrillDown(el);
|
||||
/>
|
||||
)}
|
||||
{isPendingTrack && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
style={{
|
||||
paddingLeft: 16,
|
||||
color: ts.label,
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
{renderClipChildren(previewElement, clipStyle)}
|
||||
{STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && (
|
||||
<TimelineClipDiamonds
|
||||
keyframesData={keyframeCache.get(elementKey)!}
|
||||
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
||||
clipHeightPx={TRACK_H - 2 * CLIP_Y}
|
||||
beatsActive={beatStripOnTrack}
|
||||
accentColor={clipStyle.accent}
|
||||
New track
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
// fallow-ignore-next-line complexity
|
||||
els.map((el) => {
|
||||
const clipStyle = getTrackStyle(el.tag);
|
||||
const elementKey = el.key ?? el.id;
|
||||
const capabilities = getTimelineEditCapabilities(el);
|
||||
const isSelected = selectedElementId === elementKey;
|
||||
const isComposition = !!el.compositionSrc;
|
||||
// elementKey (el.key ?? el.id) is already unique per clip; do NOT
|
||||
// fold in the map index, or a splice/reorder remounts every clip
|
||||
// at/after the change (DOM flash, drag interruption).
|
||||
const clipKey = elementKey;
|
||||
const isDraggingClip =
|
||||
draggedClip?.started === true &&
|
||||
(draggedElement?.key ?? draggedElement?.id) === elementKey;
|
||||
if (isDraggingClip) return null;
|
||||
const previewElement = getPreviewElement(el);
|
||||
return (
|
||||
<TimelineClip
|
||||
key={clipKey}
|
||||
onContextMenu={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onContextMenuClip?.(e, el);
|
||||
}}
|
||||
el={previewElement}
|
||||
pps={pps}
|
||||
clipY={CLIP_Y}
|
||||
isSelected={isSelected}
|
||||
currentPercentage={
|
||||
previewElement.duration > 0
|
||||
? ((currentTime - previewElement.start) / previewElement.duration) * 100
|
||||
: 0
|
||||
isHovered={hoveredClip === clipKey}
|
||||
isDragging={false}
|
||||
hasCustomContent={!!renderClipContent}
|
||||
capabilities={capabilities}
|
||||
theme={theme}
|
||||
isComposition={isComposition}
|
||||
onHoverStart={() => setHoveredClip(clipKey)}
|
||||
onHoverEnd={() => setHoveredClip(null)}
|
||||
onResizeStart={(edge, e) => {
|
||||
if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
|
||||
if (edge === "start" && !capabilities.canTrimStart) return;
|
||||
if (edge === "end" && !capabilities.canTrimEnd) return;
|
||||
e.stopPropagation();
|
||||
blockedClipRef.current = null;
|
||||
setShowPopover(false);
|
||||
setRangeSelection(null);
|
||||
setResizingClip({
|
||||
element: el,
|
||||
edge,
|
||||
originClientX: e.clientX,
|
||||
previewStart: el.start,
|
||||
previewDuration: el.duration,
|
||||
previewPlaybackStart: el.playbackStart,
|
||||
started: false,
|
||||
});
|
||||
}}
|
||||
onPointerDown={
|
||||
// fallow-ignore-next-line complexity
|
||||
(e) => {
|
||||
if (e.button !== 0) return;
|
||||
if (usePlayerStore.getState().activeTool === "razor") return;
|
||||
if (e.shiftKey) {
|
||||
shiftClickClipRef.current = {
|
||||
element: el,
|
||||
anchorX: e.clientX,
|
||||
anchorY: e.clientY,
|
||||
};
|
||||
return;
|
||||
}
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const blockedIntent = resolveBlockedTimelineEditIntent({
|
||||
width: rect.width,
|
||||
offsetX: e.clientX - rect.left,
|
||||
handleWidth: CLIP_HANDLE_W,
|
||||
capabilities,
|
||||
});
|
||||
if (
|
||||
blockedIntent &&
|
||||
((blockedIntent === "move" && onMoveElement) ||
|
||||
(blockedIntent !== "move" && onResizeElement))
|
||||
) {
|
||||
blockedClipRef.current = {
|
||||
element: el,
|
||||
intent: blockedIntent,
|
||||
originClientX: e.clientX,
|
||||
originClientY: e.clientY,
|
||||
started: false,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (!onMoveElement || !capabilities.canMove) return;
|
||||
blockedClipRef.current = null;
|
||||
setShowPopover(false);
|
||||
setRangeSelection(null);
|
||||
setDraggedClip({
|
||||
element: el,
|
||||
originClientX: e.clientX,
|
||||
originClientY: e.clientY,
|
||||
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
|
||||
originScrollTop: scrollRef.current?.scrollTop ?? 0,
|
||||
pointerClientX: e.clientX,
|
||||
pointerClientY: e.clientY,
|
||||
pointerOffsetX: e.clientX - rect.left,
|
||||
pointerOffsetY: e.clientY - rect.top,
|
||||
previewStart: el.start,
|
||||
previewTrack: el.track,
|
||||
snapBeatTime: null,
|
||||
started: false,
|
||||
});
|
||||
syncClipDragAutoScroll(e.clientX, e.clientY);
|
||||
}
|
||||
}
|
||||
elementId={elementKey}
|
||||
selectedKeyframes={selectedKeyframes}
|
||||
onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
|
||||
onShiftClickKeyframe={onShiftClickKeyframe}
|
||||
onContextMenuKeyframe={onContextMenuKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
suppressClickRef={suppressClickRef}
|
||||
/>
|
||||
)}
|
||||
</TimelineClip>
|
||||
);
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (suppressClickRef.current) return;
|
||||
const { activeTool } = usePlayerStore.getState();
|
||||
if (activeTool === "razor" && onRazorSplit) {
|
||||
const clipRect = (
|
||||
e.currentTarget as HTMLElement
|
||||
).getBoundingClientRect();
|
||||
const clickOffsetX = e.clientX - clipRect.left;
|
||||
const splitTime = previewElement.start + clickOffsetX / pps;
|
||||
const clampedTime = Math.max(
|
||||
previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
|
||||
Math.min(
|
||||
previewElement.start +
|
||||
previewElement.duration -
|
||||
SPLIT_BOUNDARY_EPSILON_S,
|
||||
splitTime,
|
||||
),
|
||||
);
|
||||
if (e.shiftKey && onRazorSplitAll) {
|
||||
onRazorSplitAll(clampedTime);
|
||||
} else {
|
||||
onRazorSplit(el, clampedTime);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const nextElement = isSelected ? null : el;
|
||||
setSelectedElementId(nextElement ? elementKey : null);
|
||||
onSelectElement?.(nextElement);
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (suppressClickRef.current) return;
|
||||
if (isComposition && onDrillDown) onDrillDown(el);
|
||||
}}
|
||||
>
|
||||
{renderClipChildren(previewElement, clipStyle)}
|
||||
{STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && (
|
||||
<TimelineClipDiamonds
|
||||
keyframesData={keyframeCache.get(elementKey)!}
|
||||
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
||||
clipHeightPx={TRACK_H - 2 * CLIP_Y}
|
||||
beatsActive={beatStripOnTrack}
|
||||
accentColor={clipStyle.accent}
|
||||
isSelected={isSelected}
|
||||
currentPercentage={
|
||||
previewElement.duration > 0
|
||||
? ((currentTime - previewElement.start) / previewElement.duration) *
|
||||
100
|
||||
: 0
|
||||
}
|
||||
elementId={elementKey}
|
||||
selectedKeyframes={selectedKeyframes}
|
||||
onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
|
||||
onShiftClickKeyframe={onShiftClickKeyframe}
|
||||
onContextMenuKeyframe={onContextMenuKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
suppressClickRef={suppressClickRef}
|
||||
/>
|
||||
)}
|
||||
</TimelineClip>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
{/* Drag ghost */}
|
||||
{activeDraggedElement && activeDraggedPosition && (
|
||||
@@ -479,7 +499,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
hasCustomContent={!!renderClipContent}
|
||||
capabilities={getTimelineEditCapabilities(activeDraggedElement)}
|
||||
theme={theme}
|
||||
trackStyle={getTrackStyle(activeDraggedElement.tag)}
|
||||
isComposition={!!activeDraggedElement.compositionSrc}
|
||||
onHoverStart={() => {}}
|
||||
onHoverEnd={() => {}}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { TimelineClip } from "./TimelineClip";
|
||||
import type { TimelineEditCapabilities } from "./timelineEditing";
|
||||
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const capabilities: TimelineEditCapabilities = {
|
||||
canMove: true,
|
||||
canTrimStart: true,
|
||||
canTrimEnd: true,
|
||||
};
|
||||
|
||||
function renderClip({
|
||||
element,
|
||||
pps = 100,
|
||||
isSelected = false,
|
||||
hasCustomContent = true,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
pps?: number;
|
||||
isSelected?: boolean;
|
||||
hasCustomContent?: boolean;
|
||||
}) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineClip
|
||||
el={element}
|
||||
pps={pps}
|
||||
clipY={0}
|
||||
isSelected={isSelected}
|
||||
isHovered={false}
|
||||
hasCustomContent={hasCustomContent}
|
||||
capabilities={capabilities}
|
||||
isComposition={false}
|
||||
onHoverStart={vi.fn()}
|
||||
onHoverEnd={vi.fn()}
|
||||
onClick={vi.fn()}
|
||||
onDoubleClick={vi.fn()}
|
||||
>
|
||||
<div data-custom-content="true" />
|
||||
</TimelineClip>,
|
||||
);
|
||||
});
|
||||
|
||||
return { host, root };
|
||||
}
|
||||
|
||||
describe("TimelineClip", () => {
|
||||
it("renders the clip label above custom content without showing default timecode", () => {
|
||||
const { host, root } = renderClip({
|
||||
element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 0.5, track: 0 },
|
||||
});
|
||||
|
||||
expect(host.querySelector(".timeline-clip__label")?.textContent).toBe("Hero");
|
||||
expect(host.querySelector(".timeline-clip__timecode")).toBeNull();
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps selected narrow clips labeled even when they render custom content", () => {
|
||||
const { host, root } = renderClip({
|
||||
element: { id: "fx", label: "FX", tag: "div", start: 0, duration: 0.1, track: 0 },
|
||||
isSelected: true,
|
||||
});
|
||||
|
||||
expect(host.querySelector(".timeline-clip__label")?.textContent).toBe("FX");
|
||||
expect(host.querySelector(".timeline-clip__timecode")).toBeNull();
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("marks hidden clips for active-state suppression", () => {
|
||||
const { host, root } = renderClip({
|
||||
element: {
|
||||
id: "hidden",
|
||||
label: "Hidden",
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 1,
|
||||
track: 0,
|
||||
hidden: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(host.querySelector(".timeline-clip")?.getAttribute("data-clip-hidden")).toBe("true");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { TimelineTrackStyle } from "./timelineTheme";
|
||||
|
||||
import { memo, type ReactNode } from "react";
|
||||
import { memo, type CSSProperties, type ReactNode } from "react";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme";
|
||||
import type { TimelineEditCapabilities } from "./timelineEditing";
|
||||
@@ -15,7 +13,6 @@ interface TimelineClipProps {
|
||||
hasCustomContent: boolean;
|
||||
capabilities: TimelineEditCapabilities;
|
||||
theme?: TimelineTheme;
|
||||
trackStyle: TimelineTrackStyle;
|
||||
isComposition: boolean;
|
||||
onHoverStart: () => void;
|
||||
onHoverEnd: () => void;
|
||||
@@ -27,6 +24,7 @@ interface TimelineClipProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export const TimelineClip = memo(function TimelineClip({
|
||||
el,
|
||||
pps,
|
||||
@@ -37,7 +35,6 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
hasCustomContent,
|
||||
capabilities,
|
||||
theme = defaultTimelineTheme,
|
||||
trackStyle,
|
||||
isComposition,
|
||||
onHoverStart,
|
||||
onHoverEnd,
|
||||
@@ -51,45 +48,43 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
const leftPx = el.start * pps;
|
||||
const widthPx = Math.max(el.duration * pps, 4);
|
||||
const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
|
||||
|
||||
const borderColor = isSelected
|
||||
? trackStyle.accent
|
||||
: isHovered
|
||||
? theme.clipBorderHover
|
||||
: theme.clipBorder;
|
||||
const boxShadow = isDragging
|
||||
? theme.clipShadowDragging
|
||||
: isSelected
|
||||
? `0 0 0 1px ${trackStyle.accent}80, 0 0 8px ${trackStyle.accent}25`
|
||||
: isHovered
|
||||
? theme.clipShadowHover
|
||||
: theme.clipShadow;
|
||||
const displayLabel = el.label || el.id || el.tag;
|
||||
const showHandles = handleOpacity > 0.01;
|
||||
const showHandles = handleOpacity > 0.01 && (widthPx >= 32 || isSelected);
|
||||
const showLabel = widthPx >= 40 || isSelected;
|
||||
const showDefaultText = !hasCustomContent && (widthPx >= 40 || isSelected);
|
||||
const startLabel = el.start.toFixed(1);
|
||||
const endLabel = (el.start + el.duration).toFixed(1);
|
||||
const clipClassName = [
|
||||
"timeline-clip",
|
||||
"absolute",
|
||||
hasCustomContent ? "overflow-visible" : "overflow-hidden",
|
||||
isSelected ? "is-selected" : "",
|
||||
isHovered ? "is-hovered" : "",
|
||||
isDragging ? "is-dragging" : "",
|
||||
showDefaultText ? "" : "is-micro",
|
||||
]
|
||||
.filter((className) => className.length > 0)
|
||||
.join(" ");
|
||||
const style: CSSProperties = {
|
||||
left: leftPx,
|
||||
width: widthPx,
|
||||
top: clipY,
|
||||
bottom: clipY,
|
||||
borderRadius: theme.clipRadius,
|
||||
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
|
||||
cursor: capabilities.canMove ? "grab" : "default",
|
||||
transform: isDragging ? "translateY(-1px)" : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-clip="true"
|
||||
className={
|
||||
hasCustomContent
|
||||
? "absolute overflow-visible"
|
||||
: "absolute flex items-center overflow-visible"
|
||||
}
|
||||
style={{
|
||||
left: leftPx,
|
||||
width: widthPx,
|
||||
top: clipY,
|
||||
bottom: clipY,
|
||||
borderRadius: theme.clipRadius,
|
||||
background: trackStyle.clip,
|
||||
border: `1px solid ${borderColor}`,
|
||||
boxShadow,
|
||||
transition: "border-color 100ms, box-shadow 100ms",
|
||||
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
|
||||
cursor: capabilities.canMove ? "grab" : "default",
|
||||
transform: isDragging ? "translateY(-1px)" : undefined,
|
||||
opacity: isDragging ? 0.92 : 1,
|
||||
}}
|
||||
data-el-id={el.key ?? el.id}
|
||||
data-clip-start={el.start}
|
||||
data-clip-end={el.start + el.duration}
|
||||
data-clip-hidden={el.hidden ? "true" : undefined}
|
||||
className={clipClassName}
|
||||
style={style}
|
||||
title={
|
||||
isComposition
|
||||
? `${el.compositionSrc} • Double-click to open`
|
||||
@@ -102,22 +97,6 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
{/* Left accent stripe — wider + brighter for expanded sub-comp children */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: el.expandedParentStart !== undefined ? 4 : 3,
|
||||
background: trackStyle.accent,
|
||||
opacity: el.expandedParentStart !== undefined ? 0.8 : isSelected ? 0.7 : 0.3,
|
||||
borderRadius: `${theme.clipRadius} 0 0 ${theme.clipRadius}`,
|
||||
zIndex: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
{/* Left trim handle */}
|
||||
{showHandles && capabilities.canTrimStart && (
|
||||
<div
|
||||
@@ -134,6 +113,7 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="timeline-clip__handle-bar"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 4,
|
||||
@@ -141,7 +121,7 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
bottom: 6,
|
||||
width: 2,
|
||||
borderRadius: 1,
|
||||
background: trackStyle.accent,
|
||||
background: "rgba(255, 255, 255, 0.55)",
|
||||
opacity: handleOpacity * 0.6,
|
||||
}}
|
||||
/>
|
||||
@@ -163,6 +143,7 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="timeline-clip__handle-bar"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 4,
|
||||
@@ -170,12 +151,18 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
bottom: 6,
|
||||
width: 2,
|
||||
borderRadius: 1,
|
||||
background: trackStyle.accent,
|
||||
background: "rgba(255, 255, 255, 0.55)",
|
||||
opacity: handleOpacity * 0.6,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showLabel && <span className="timeline-clip__label">{displayLabel}</span>}
|
||||
{showDefaultText && (
|
||||
<span className="timeline-clip__timecode">
|
||||
{startLabel}-{endLabel}s
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { memo } from "react";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import type { TimelineRangeSelection } from "./timelineEditing";
|
||||
import { GUTTER, RULER_H, formatTimelineTickLabel } from "./timelineLayout";
|
||||
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
||||
|
||||
@@ -12,8 +11,6 @@ interface TimelineRulerProps {
|
||||
totalH: number;
|
||||
effectiveDuration: number;
|
||||
majorTickInterval: number;
|
||||
shiftHeld: boolean;
|
||||
rangeSelection: TimelineRangeSelection | null;
|
||||
theme: TimelineTheme;
|
||||
beatAnalysis?: MusicBeatAnalysis | null;
|
||||
}
|
||||
@@ -26,8 +23,6 @@ export const TimelineRuler = memo(function TimelineRuler({
|
||||
totalH,
|
||||
effectiveDuration,
|
||||
majorTickInterval,
|
||||
shiftHeld,
|
||||
rangeSelection,
|
||||
theme,
|
||||
beatAnalysis,
|
||||
}: TimelineRulerProps) {
|
||||
@@ -87,35 +82,34 @@ export const TimelineRuler = memo(function TimelineRuler({
|
||||
{/* Ruler */}
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
style={{ height: RULER_H, marginLeft: GUTTER, width: trackContentWidth }}
|
||||
style={{
|
||||
height: RULER_H,
|
||||
marginLeft: GUTTER,
|
||||
width: trackContentWidth,
|
||||
background: theme.gutterBackground,
|
||||
borderBottom: `1px solid ${theme.rulerBorder}`,
|
||||
}}
|
||||
>
|
||||
{shiftHeld && !rangeSelection && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||
<span className="text-[9px] font-medium" style={{ color: theme.textSecondary }}>
|
||||
Drag or click a clip to edit range
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{minor.map((t) => (
|
||||
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
|
||||
<div className="w-px h-[3px]" style={{ background: theme.tickMinor }} />
|
||||
<div className="w-px h-2" style={{ background: theme.tickMinor }} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{major.map((t) => (
|
||||
<div
|
||||
key={`M-${t}`}
|
||||
className="absolute bottom-0 flex flex-col items-center"
|
||||
style={{ left: t * pps }}
|
||||
>
|
||||
<div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps }}>
|
||||
<span
|
||||
className="text-[9px] font-mono tabular-nums leading-none mb-0.5"
|
||||
style={{ color: theme.tickText }}
|
||||
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
|
||||
style={{
|
||||
color: theme.tickText,
|
||||
left: 5,
|
||||
top: 5,
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
{formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
|
||||
</span>
|
||||
<div className="w-px h-[5px]" style={{ background: theme.tickMajor }} />
|
||||
<div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
|
||||
interface TimelineShortcutHintProps {
|
||||
theme: TimelineTheme;
|
||||
}
|
||||
|
||||
export function TimelineShortcutHint({ theme }: TimelineShortcutHintProps) {
|
||||
return (
|
||||
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
|
||||
style={{ background: "rgba(17,23,35,0.84)", borderColor: theme.gutterBorder }}
|
||||
>
|
||||
<kbd
|
||||
className="text-[9px] font-mono px-1 py-0.5 rounded"
|
||||
style={{ color: theme.textSecondary, background: "rgba(255,255,255,0.06)" }}
|
||||
>
|
||||
Shift
|
||||
</kbd>
|
||||
<span className="text-[9px]" style={{ color: theme.textSecondary }}>
|
||||
+ drag/click to edit range
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
|
||||
|
||||
export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
|
||||
onSeek?: (time: number) => void;
|
||||
onDrillDown?: (element: TimelineElement) => void;
|
||||
renderClipContent?: (
|
||||
element: TimelineElement,
|
||||
style: { clip: string; label: string },
|
||||
) => ReactNode;
|
||||
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
||||
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
||||
onSelectElement?: (element: TimelineElement | null) => void;
|
||||
theme?: Partial<TimelineTheme>;
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
// fallow-ignore-next-line code-duplication
|
||||
ioRef.current.observe(el);
|
||||
|
||||
const target = el.parentElement || el;
|
||||
@@ -177,20 +178,22 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-[9px] font-semibold truncate block leading-tight"
|
||||
style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
|
||||
{label && (
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-[9px] font-semibold truncate block leading-tight"
|
||||
style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface TimelineEditCallbacks {
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
) => Promise<void> | void;
|
||||
onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
|
||||
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
|
||||
@@ -1,52 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { getTimelineTrackStyle, type TimelineTrackStyle } from "./timelineTheme";
|
||||
|
||||
export interface TrackVisualStyle extends TimelineTrackStyle {
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
const ICON_BASE = "/icons/timeline";
|
||||
function TimelineIcon({ src }: { src: string }) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
width={12}
|
||||
height={12}
|
||||
style={{ filter: "brightness(0) invert(1)" }}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const IconCaptions = <TimelineIcon src={`${ICON_BASE}/captions.svg`} />;
|
||||
const IconImage = <TimelineIcon src={`${ICON_BASE}/image.svg`} />;
|
||||
const IconMusic = <TimelineIcon src={`${ICON_BASE}/music.svg`} />;
|
||||
const IconText = <TimelineIcon src={`${ICON_BASE}/text.svg`} />;
|
||||
const IconComposition = <TimelineIcon src={`${ICON_BASE}/composition.svg`} />;
|
||||
const IconAudio = <TimelineIcon src={`${ICON_BASE}/audio.svg`} />;
|
||||
|
||||
const ICONS: Record<string, ReactNode> = {
|
||||
video: IconImage,
|
||||
audio: IconMusic,
|
||||
img: IconImage,
|
||||
div: IconComposition,
|
||||
span: IconCaptions,
|
||||
p: IconText,
|
||||
h1: IconText,
|
||||
section: IconComposition,
|
||||
sfx: IconAudio,
|
||||
};
|
||||
export type TrackVisualStyle = TimelineTrackStyle;
|
||||
|
||||
export function getTrackStyle(tag: string): TrackVisualStyle {
|
||||
// Defensive: callers may pass an empty/undefined tag; fall back to "div"
|
||||
// (restores the #1679 null-guard that a restack had dropped).
|
||||
const safeTag = tag || "div";
|
||||
const trackStyle = getTimelineTrackStyle(safeTag);
|
||||
const normalized = safeTag.toLowerCase();
|
||||
const icon =
|
||||
normalized.startsWith("h") && normalized.length === 2 && "123456".includes(normalized[1] ?? "")
|
||||
? ICONS.h1
|
||||
: (ICONS[normalized] ?? IconComposition);
|
||||
return { ...trackStyle, icon };
|
||||
return getTimelineTrackStyle(safeTag);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ const TIMELINE_SCROLL_BUFFER = 20;
|
||||
|
||||
/* ── Tick generation ──────────────────────────────────────────────── */
|
||||
function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
|
||||
const zoomIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
|
||||
const zoomIntervals = [0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
|
||||
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
|
||||
const targetMajorPx = 128;
|
||||
const targetMajorPx = 88;
|
||||
return (
|
||||
zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
|
||||
);
|
||||
@@ -24,20 +24,14 @@ function getMajorTickInterval(duration: number, pixelsPerSecond?: number): numbe
|
||||
}
|
||||
|
||||
function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number {
|
||||
let interval = majorInterval / 2;
|
||||
if (majorInterval >= 30) interval = majorInterval / 6;
|
||||
else if (majorInterval >= 15) interval = majorInterval / 3;
|
||||
else if (majorInterval >= 5) interval = majorInterval / 5;
|
||||
else if (majorInterval >= 1) interval = majorInterval / 4;
|
||||
|
||||
if (
|
||||
Number.isFinite(pixelsPerSecond) &&
|
||||
(pixelsPerSecond ?? 0) > 0 &&
|
||||
interval * (pixelsPerSecond ?? 0) < 20
|
||||
(majorInterval / 2) * (pixelsPerSecond ?? 0) < 12
|
||||
) {
|
||||
return Math.max(0.25, majorInterval / 2);
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0.25, interval);
|
||||
return majorInterval / 2;
|
||||
}
|
||||
|
||||
export function generateTicks(
|
||||
@@ -51,17 +45,13 @@ export function generateTicks(
|
||||
const major: number[] = [];
|
||||
const minor: number[] = [];
|
||||
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
|
||||
for (
|
||||
let t = 0;
|
||||
t <= duration + 0.001 && major.length + minor.length < maxTicks;
|
||||
t += minorInterval
|
||||
) {
|
||||
for (let t = 0; t <= duration + 0.001 && major.length < maxTicks; t += majorInterval) {
|
||||
const rounded = Math.round(t * 100) / 100;
|
||||
const isMajor =
|
||||
Math.abs(rounded % majorInterval) < 0.01 ||
|
||||
Math.abs((rounded % majorInterval) - majorInterval) < 0.01;
|
||||
if (isMajor) major.push(rounded);
|
||||
else minor.push(rounded);
|
||||
major.push(rounded);
|
||||
if (minorInterval > 0 && major.length + minor.length < maxTicks) {
|
||||
const midpoint = Math.round((t + minorInterval) * 100) / 100;
|
||||
if (midpoint <= duration + 0.001) minor.push(midpoint);
|
||||
}
|
||||
}
|
||||
return { major, minor };
|
||||
}
|
||||
@@ -69,6 +59,12 @@ export function generateTicks(
|
||||
export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
|
||||
if (!Number.isFinite(time)) return "0:00";
|
||||
const safeTime = Math.max(0, time);
|
||||
if (majorInterval < 0.1) {
|
||||
const totalHundredths = Math.round(safeTime * 100);
|
||||
const wholeSeconds = Math.floor(totalHundredths / 100);
|
||||
const hundredth = totalHundredths % 100;
|
||||
return `${formatTime(wholeSeconds)}.${hundredth.toString().padStart(2, "0")}`;
|
||||
}
|
||||
if (majorInterval < 1) {
|
||||
const totalTenths = Math.round(safeTime * 10);
|
||||
const wholeSeconds = Math.floor(totalTenths / 10);
|
||||
@@ -101,7 +97,7 @@ export function getTimelineScrollLeftForZoomTransition(
|
||||
nextZoomMode: ZoomMode,
|
||||
currentScrollLeft: number,
|
||||
): number {
|
||||
if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0;
|
||||
if (nextZoomMode === "fit") return 0;
|
||||
return currentScrollLeft;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const studioCss = readFileSync(new URL("../../styles/studio.css", import.meta.url), "utf8");
|
||||
const timelineClipSource = readFileSync(new URL("./TimelineClip.tsx", import.meta.url), "utf8");
|
||||
const playheadSource = readFileSync(new URL("./PlayheadIndicator.tsx", import.meta.url), "utf8");
|
||||
|
||||
const allowedTimelineTransitionProperties = [
|
||||
"background-color",
|
||||
"border-color",
|
||||
"box-shadow",
|
||||
"color",
|
||||
"opacity",
|
||||
];
|
||||
|
||||
function expectRule(css: string, selector: string): string {
|
||||
const selectorStart = css.indexOf(`${selector} {`);
|
||||
expect(selectorStart).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const bodyStart = css.indexOf("{", selectorStart);
|
||||
const bodyEnd = css.indexOf("}", bodyStart);
|
||||
expect(bodyStart).toBeGreaterThanOrEqual(0);
|
||||
expect(bodyEnd).toBeGreaterThan(bodyStart);
|
||||
|
||||
return css.slice(bodyStart + 1, bodyEnd).trim();
|
||||
}
|
||||
|
||||
function expectDeclaration(ruleBody: string, property: string): string {
|
||||
const declarationMatch = new RegExp(`${property}:\\s*([^;]+);`).exec(ruleBody);
|
||||
expect(declarationMatch?.[1]).toBeDefined();
|
||||
return declarationMatch?.[1].trim() ?? "";
|
||||
}
|
||||
|
||||
function transitionProperties(transitionDeclaration: string): string[] {
|
||||
const items: string[] = [];
|
||||
let depth = 0;
|
||||
let item = "";
|
||||
|
||||
for (const char of transitionDeclaration) {
|
||||
if (char === "(") depth += 1;
|
||||
if (char === ")") depth -= 1;
|
||||
if (char === "," && depth === 0) {
|
||||
items.push(item.trim());
|
||||
item = "";
|
||||
continue;
|
||||
}
|
||||
item += char;
|
||||
}
|
||||
|
||||
if (item.trim().length > 0) items.push(item.trim());
|
||||
|
||||
return items.map((transition) => transition.split(/\s+/)[0]);
|
||||
}
|
||||
|
||||
describe("timeline motion styles", () => {
|
||||
it("keeps clip motion reduced-motion gated and layout safe", () => {
|
||||
const mediaStart = studioCss.indexOf("@media (prefers-reduced-motion: no-preference)");
|
||||
expect(mediaStart).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const beforeMotionMedia = studioCss.slice(0, mediaStart);
|
||||
const baseTimelineClipRule = expectRule(beforeMotionMedia, ".timeline-clip");
|
||||
expect(baseTimelineClipRule).not.toContain("transition");
|
||||
|
||||
const motionMediaCss = studioCss.slice(mediaStart);
|
||||
const timelineClipMotionRule = expectRule(motionMediaCss, ".timeline-clip");
|
||||
const clipTransition = expectDeclaration(timelineClipMotionRule, "transition");
|
||||
|
||||
expect(transitionProperties(clipTransition)).toEqual(allowedTimelineTransitionProperties);
|
||||
expect(clipTransition).not.toMatch(/\b(?:all|left|width|top|bottom|transform)\b/);
|
||||
});
|
||||
|
||||
it("layers the active mint bloom through opacity instead of a gradient background swap", () => {
|
||||
const baseTimelineClipRule = expectRule(studioCss, ".timeline-clip");
|
||||
const activeTimelineClipRule = expectRule(studioCss, ".timeline-clip[data-active]");
|
||||
const bloomOverlayRule = expectRule(studioCss, ".timeline-clip::before");
|
||||
const activeBloomOverlayRule = expectRule(studioCss, ".timeline-clip[data-active]::before");
|
||||
|
||||
expect(baseTimelineClipRule).toContain("background-color: rgba(255, 255, 255, 0.055)");
|
||||
expect(activeTimelineClipRule).not.toContain("background: linear-gradient");
|
||||
expect(activeTimelineClipRule).toContain("border-color: rgba(60, 230, 172, 0.55)");
|
||||
expect(activeTimelineClipRule).not.toContain("box-shadow");
|
||||
expect(bloomOverlayRule).toContain("background: rgba(60, 230, 172, 0.2)");
|
||||
expect(bloomOverlayRule).not.toContain("linear-gradient");
|
||||
expect(bloomOverlayRule).toContain("opacity: 0");
|
||||
expect(activeBloomOverlayRule).toContain("opacity: 1");
|
||||
});
|
||||
|
||||
it("targets trim handle bars without changing drag geometry", () => {
|
||||
const handleClassMatches = timelineClipSource.match(/className="timeline-clip__handle-bar"/g);
|
||||
|
||||
expect(handleClassMatches).toHaveLength(2);
|
||||
expect(timelineClipSource).toContain('transform: isDragging ? "translateY(-1px)" : undefined');
|
||||
expect(timelineClipSource).not.toContain("scale(");
|
||||
});
|
||||
|
||||
it("keeps the playhead polish static, without transition-driven positioning", () => {
|
||||
expect(playheadSource).toContain("boxShadow");
|
||||
expect(playheadSource).toContain("rotate(45deg)");
|
||||
expect(playheadSource).not.toContain("transition");
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,31 @@ import {
|
||||
getRenderedTimelineElement,
|
||||
getTimelineTrackStyle,
|
||||
} from "./timelineTheme";
|
||||
import { getTrackStyle } from "./timelineIcons";
|
||||
|
||||
describe("getTimelineTrackStyle", () => {
|
||||
it("reuses heading styles for heading tags", () => {
|
||||
expect(getTimelineTrackStyle("h2").accent).toBe(getTimelineTrackStyle("h1").accent);
|
||||
});
|
||||
it("uses one neutral clip style for every timeline tag", () => {
|
||||
const expectedStyle = {
|
||||
clip: "rgba(255,255,255,0.055)",
|
||||
clipActive: "rgba(60,230,172,0.16)",
|
||||
accent: "#3CE6AC",
|
||||
label: "rgba(255,255,255,0.5)",
|
||||
};
|
||||
|
||||
it("falls back for unknown tags", () => {
|
||||
expect(getTimelineTrackStyle("custom-tag").accent).toBe("#3CE6AC");
|
||||
expect(getTimelineTrackStyle("video")).toEqual(expectedStyle);
|
||||
expect(getTimelineTrackStyle("audio")).toEqual(expectedStyle);
|
||||
expect(getTimelineTrackStyle("custom-tag")).toEqual(expectedStyle);
|
||||
expect(getTimelineTrackStyle("video")).toEqual(getTimelineTrackStyle("audio"));
|
||||
expect(getTimelineTrackStyle("video")).toEqual(getTimelineTrackStyle("custom-tag"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTrackStyle", () => {
|
||||
it("returns the timeline style only and preserves the empty tag fallback", () => {
|
||||
const style = getTrackStyle("");
|
||||
expect(style).toEqual(getTimelineTrackStyle("div"));
|
||||
expect(Object.keys(style)).not.toContain("icon");
|
||||
expect(Object.keys(style)).not.toContain("iconBackground");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface TimelineTrackStyle {
|
||||
clip: string;
|
||||
accent: string;
|
||||
label: string;
|
||||
iconBackground: string;
|
||||
clipActive?: string;
|
||||
}
|
||||
|
||||
export interface TimelineTheme {
|
||||
@@ -36,25 +36,25 @@ export interface TimelineTheme {
|
||||
}
|
||||
|
||||
const TRACK_STYLE: TimelineTrackStyle = {
|
||||
clip: "#1c2028",
|
||||
clip: "rgba(255,255,255,0.055)",
|
||||
clipActive: "rgba(60,230,172,0.16)",
|
||||
accent: "#3CE6AC",
|
||||
label: "#dde1e8",
|
||||
iconBackground: "rgba(255,255,255,0.06)",
|
||||
label: "rgba(255,255,255,0.5)",
|
||||
};
|
||||
|
||||
export const defaultTimelineTheme: TimelineTheme = {
|
||||
shellBackground: "#0A0A0B",
|
||||
shellBorder: "rgba(255,255,255,0.05)",
|
||||
rulerBorder: "rgba(255,255,255,0.045)",
|
||||
rowBackground: "#0A0A0B",
|
||||
rowBorder: "rgba(255,255,255,0.05)",
|
||||
gutterBackground: "#0A0A0B",
|
||||
gutterBorder: "rgba(255,255,255,0.05)",
|
||||
textPrimary: "#E8EDF5",
|
||||
textSecondary: "#8391A8",
|
||||
tickText: "rgba(131,145,168,0.92)",
|
||||
tickMajor: "rgba(255,255,255,0.13)",
|
||||
tickMinor: "rgba(255,255,255,0.08)",
|
||||
rulerBorder: "rgba(255,255,255,0.16)",
|
||||
rowBackground: "#0B0C0F",
|
||||
rowBorder: "rgba(255,255,255,0.06)",
|
||||
gutterBackground: "#0E0F12",
|
||||
gutterBorder: "rgba(255,255,255,0.10)",
|
||||
textPrimary: "rgba(255,255,255,0.92)",
|
||||
textSecondary: "rgba(255,255,255,0.62)",
|
||||
tickText: "rgba(255,255,255,0.34)",
|
||||
tickMajor: "rgba(255,255,255,0.10)",
|
||||
tickMinor: "rgba(255,255,255,0.06)",
|
||||
clipBackground: "#141922",
|
||||
clipBackgroundActive: "#181e28",
|
||||
clipBorder: "rgba(255,255,255,0.10)",
|
||||
@@ -67,7 +67,7 @@ export const defaultTimelineTheme: TimelineTheme = {
|
||||
handleColor: "rgba(255,255,255,0.2)",
|
||||
panelResizeSeam: "rgba(255,255,255,0.12)",
|
||||
panelResizeActive: "rgba(255,255,255,0.24)",
|
||||
clipRadius: "6px",
|
||||
clipRadius: "8px",
|
||||
};
|
||||
|
||||
export function getTimelineTrackStyle(_tag: string): TimelineTrackStyle {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { updateTimelineActiveClipClasses } from "./useTimelineActiveClips";
|
||||
|
||||
function appendClip(container: HTMLElement, id: string, start: string, end: string): HTMLElement {
|
||||
const clip = document.createElement("div");
|
||||
clip.dataset.clip = "true";
|
||||
clip.dataset.elId = id;
|
||||
clip.dataset.clipStart = start;
|
||||
clip.dataset.clipEnd = end;
|
||||
container.append(clip);
|
||||
return clip;
|
||||
}
|
||||
|
||||
describe("updateTimelineActiveClipClasses", () => {
|
||||
it("toggles data-active only for clips containing the current time", () => {
|
||||
const container = document.createElement("div");
|
||||
const intro = appendClip(container, "intro", "0", "2");
|
||||
const hero = appendClip(container, "hero", "2", "5");
|
||||
const outro = appendClip(container, "outro", "5", "8");
|
||||
const previous = new Set<string>();
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 2.25);
|
||||
|
||||
expect(intro.hasAttribute("data-active")).toBe(false);
|
||||
expect(hero.hasAttribute("data-active")).toBe(true);
|
||||
expect(outro.hasAttribute("data-active")).toBe(false);
|
||||
expect(previous).toEqual(new Set(["hero"]));
|
||||
});
|
||||
|
||||
it("never marks hidden clips active inside their time window", () => {
|
||||
const container = document.createElement("div");
|
||||
const hidden = appendClip(container, "hidden", "0", "5");
|
||||
const visible = appendClip(container, "visible", "0", "5");
|
||||
hidden.dataset.clipHidden = "true";
|
||||
const previous = new Set<string>();
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 2);
|
||||
|
||||
expect(hidden.hasAttribute("data-active")).toBe(false);
|
||||
expect(visible.hasAttribute("data-active")).toBe(true);
|
||||
expect(previous).toEqual(new Set(["visible"]));
|
||||
});
|
||||
|
||||
it("diffs against the previous active set", () => {
|
||||
const container = document.createElement("div");
|
||||
const intro = appendClip(container, "intro", "0", "2");
|
||||
const hero = appendClip(container, "hero", "2", "5");
|
||||
const previous = new Set(["intro"]);
|
||||
intro.toggleAttribute("data-active", true);
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 2);
|
||||
|
||||
expect(intro.hasAttribute("data-active")).toBe(true);
|
||||
expect(hero.hasAttribute("data-active")).toBe(true);
|
||||
expect(previous).toEqual(new Set(["intro", "hero"]));
|
||||
});
|
||||
|
||||
it("keeps a clip active through its inclusive end boundary", () => {
|
||||
const container = document.createElement("div");
|
||||
const intro = appendClip(container, "intro", "0", "2");
|
||||
const previous = new Set<string>();
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 0);
|
||||
|
||||
expect(intro.hasAttribute("data-active")).toBe(true);
|
||||
expect(previous).toEqual(new Set(["intro"]));
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 2);
|
||||
|
||||
expect(intro.hasAttribute("data-active")).toBe(true);
|
||||
expect(previous).toEqual(new Set(["intro"]));
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 2.001);
|
||||
|
||||
expect(intro.hasAttribute("data-active")).toBe(false);
|
||||
expect(previous).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("ignores clips with invalid timing data", () => {
|
||||
const container = document.createElement("div");
|
||||
const missingId = appendClip(container, "", "0", "2");
|
||||
const missingTiming = appendClip(container, "bad", "", "2");
|
||||
const previous = new Set<string>();
|
||||
|
||||
updateTimelineActiveClipClasses(container, previous, 1);
|
||||
|
||||
expect(missingId.hasAttribute("data-active")).toBe(false);
|
||||
expect(missingTiming.hasAttribute("data-active")).toBe(false);
|
||||
expect(previous).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from "react";
|
||||
import { liveTime } from "../store/playerStore";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
|
||||
interface ActiveClipRecord {
|
||||
id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
hidden: boolean;
|
||||
element: HTMLElement;
|
||||
}
|
||||
|
||||
interface UseTimelineActiveClipsInput {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
currentTime: number;
|
||||
clipStateVersion: string;
|
||||
}
|
||||
|
||||
function readFiniteNumber(value: string | undefined): number | null {
|
||||
if (value === undefined || value.trim() === "") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function readClipRecord(element: Element): ActiveClipRecord | null {
|
||||
if (!(element instanceof HTMLElement)) return null;
|
||||
const id = element.dataset.elId;
|
||||
const start = readFiniteNumber(element.dataset.clipStart);
|
||||
const end = readFiniteNumber(element.dataset.clipEnd);
|
||||
const hidden = element.dataset.clipHidden === "true";
|
||||
if (!id || start === null || end === null) return null;
|
||||
return { id, start, end, hidden, element };
|
||||
}
|
||||
|
||||
function collectTimelineClipRecords(container: HTMLElement): ActiveClipRecord[] {
|
||||
const records: ActiveClipRecord[] = [];
|
||||
for (const element of container.querySelectorAll('[data-clip="true"]')) {
|
||||
const record = readClipRecord(element);
|
||||
if (record) records.push(record);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function indexClipRecordsById(records: ActiveClipRecord[]): Map<string, ActiveClipRecord> {
|
||||
const recordsById = new Map<string, ActiveClipRecord>();
|
||||
for (const record of records) recordsById.set(record.id, record);
|
||||
return recordsById;
|
||||
}
|
||||
|
||||
function getActiveClipIds(records: ActiveClipRecord[], time: number): Set<string> {
|
||||
const next = new Set<string>();
|
||||
if (!Number.isFinite(time)) return next;
|
||||
for (const record of records) {
|
||||
if (record.hidden) continue;
|
||||
if (time >= record.start && time <= record.end) next.add(record.id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function setsMatch(left: Set<string>, right: Set<string>): boolean {
|
||||
if (left.size !== right.size) return false;
|
||||
for (const value of left) {
|
||||
if (!right.has(value)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyActiveClipDiff(records: ActiveClipRecord[], previous: Set<string>, time: number) {
|
||||
const next = getActiveClipIds(records, time);
|
||||
const changed = !setsMatch(previous, next);
|
||||
for (const record of records) {
|
||||
const wasActive = previous.has(record.id);
|
||||
const isActive = next.has(record.id);
|
||||
if (wasActive === isActive) continue;
|
||||
record.element.toggleAttribute("data-active", isActive);
|
||||
}
|
||||
previous.clear();
|
||||
for (const id of next) previous.add(id);
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function updateTimelineActiveClipClasses(
|
||||
container: HTMLElement,
|
||||
previous: Set<string>,
|
||||
time: number,
|
||||
) {
|
||||
applyActiveClipDiff(collectTimelineClipRecords(container), previous, time);
|
||||
}
|
||||
|
||||
export function useTimelineActiveClips({
|
||||
scrollRef,
|
||||
currentTime,
|
||||
clipStateVersion,
|
||||
}: UseTimelineActiveClipsInput) {
|
||||
const recordsRef = useRef<ActiveClipRecord[]>([]);
|
||||
const recordsByIdRef = useRef(new Map<string, ActiveClipRecord>());
|
||||
const previousActiveIdsRef = useRef(new Set<string>());
|
||||
|
||||
const refreshRecords = useCallback(
|
||||
(time: number) => {
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) {
|
||||
recordsRef.current = [];
|
||||
recordsByIdRef.current.clear();
|
||||
previousActiveIdsRef.current.clear();
|
||||
return;
|
||||
}
|
||||
recordsRef.current = collectTimelineClipRecords(scroll);
|
||||
recordsByIdRef.current = indexClipRecordsById(recordsRef.current);
|
||||
applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time);
|
||||
},
|
||||
[scrollRef],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
refreshRecords(currentTime);
|
||||
}, [currentTime, clipStateVersion, refreshRecords]);
|
||||
|
||||
useMountEffect(() => {
|
||||
const unsub = liveTime.subscribe((time) => {
|
||||
applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time);
|
||||
});
|
||||
return unsub;
|
||||
});
|
||||
}
|
||||
@@ -94,6 +94,12 @@ export function useTimelinePlayhead({
|
||||
syncPlayheadPosition(currentTime);
|
||||
}, [currentTime, pps, syncPlayheadPosition]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll || zoomMode !== "fit") return;
|
||||
scroll.scrollLeft = 0;
|
||||
}, [zoomMode, pps, scrollRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildExpandedElements } from "./useExpandedTimelineElements";
|
||||
import {
|
||||
buildExpandedElements,
|
||||
resolveTimelineExpansionRawId,
|
||||
} from "./useExpandedTimelineElements";
|
||||
import { buildTimelineElementKey } from "../lib/timelineElementHelpers";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { ClipManifestClip } from "../lib/playbackTypes";
|
||||
@@ -19,8 +22,14 @@ const clip = (over: Partial<ClipManifestClip>): ClipManifestClip => ({
|
||||
...over,
|
||||
});
|
||||
|
||||
const el = (over: Partial<TimelineElement>): TimelineElement =>
|
||||
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as TimelineElement;
|
||||
const el = (over: Partial<TimelineElement>): TimelineElement => ({
|
||||
id: "x",
|
||||
start: 0,
|
||||
duration: 1,
|
||||
track: 0,
|
||||
tag: "div",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("buildExpandedElements", () => {
|
||||
it("rebases a 1-level child onto its sub-comp host (start + sourceFile)", () => {
|
||||
@@ -42,6 +51,7 @@ describe("buildExpandedElements", () => {
|
||||
expect(child.sourceFile).toBe("stats.html");
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("rebases a 2-level child onto its NESTED host, not the top-level scene", () => {
|
||||
// top host A@10 (a.html) embeds host B@12 (b.html); child C lives in b.html.
|
||||
// Edits must rebase onto B (12 / b.html), not A (10 / a.html).
|
||||
@@ -65,6 +75,7 @@ describe("buildExpandedElements", () => {
|
||||
expect(child.sourceFile).toBe("b.html"); // B's file, not a.html
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("rebases a 3-level child onto its deepest host, not intermediate or top", () => {
|
||||
// A@10 (a.html) → B@12 (b.html) → C@13 (c.html); leaf D lives in c.html.
|
||||
// Edits must rebase onto C (13 / c.html), not B (12 / b.html) or A (10 / a.html).
|
||||
@@ -166,3 +177,99 @@ describe("buildExpandedElements", () => {
|
||||
expect(out.some((e) => e.domId === "scene-host")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTimelineExpansionRawId", () => {
|
||||
it("returns null when paused inside a childless top-level clip", () => {
|
||||
const manifest = [clip({ id: "title", start: 0, duration: 4 })];
|
||||
|
||||
expect(
|
||||
resolveTimelineExpansionRawId({
|
||||
selectedElementId: null,
|
||||
isPlaying: false,
|
||||
currentTime: 2,
|
||||
manifest,
|
||||
parentMap: new Map(),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("auto-expands an active composition with children when paused and nothing is selected", () => {
|
||||
const manifest = [
|
||||
clip({ id: "scene", start: 1, duration: 5 }),
|
||||
clip({ id: "headline", start: 1.5, duration: 2 }),
|
||||
];
|
||||
const parentMap = new Map([["headline", "scene"]]);
|
||||
|
||||
expect(
|
||||
resolveTimelineExpansionRawId({
|
||||
selectedElementId: null,
|
||||
isPlaying: false,
|
||||
currentTime: 2,
|
||||
manifest,
|
||||
parentMap,
|
||||
}),
|
||||
).toBe("scene");
|
||||
});
|
||||
|
||||
it("auto-expands the innermost active nested composition when paused", () => {
|
||||
const manifest = [
|
||||
clip({ id: "outer", start: 0, duration: 10 }),
|
||||
clip({ id: "inner", start: 2, duration: 5 }),
|
||||
clip({ id: "leaf", start: 3, duration: 1 }),
|
||||
];
|
||||
const parentMap = new Map([
|
||||
["inner", "outer"],
|
||||
["leaf", "inner"],
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveTimelineExpansionRawId({
|
||||
selectedElementId: null,
|
||||
isPlaying: false,
|
||||
currentTime: 3.5,
|
||||
manifest,
|
||||
parentMap,
|
||||
}),
|
||||
).toBe("inner");
|
||||
});
|
||||
|
||||
it("does not auto-expand an active composition while playing", () => {
|
||||
const manifest = [
|
||||
clip({ id: "scene", start: 0, duration: 5 }),
|
||||
clip({ id: "headline", start: 1, duration: 2 }),
|
||||
];
|
||||
const parentMap = new Map([["headline", "scene"]]);
|
||||
|
||||
expect(
|
||||
resolveTimelineExpansionRawId({
|
||||
selectedElementId: null,
|
||||
isPlaying: true,
|
||||
currentTime: 2,
|
||||
manifest,
|
||||
parentMap,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps selected elements ahead of paused active composition auto-expansion", () => {
|
||||
const manifest = [
|
||||
clip({ id: "scene", start: 0, duration: 6 }),
|
||||
clip({ id: "headline", start: 1, duration: 2 }),
|
||||
clip({ id: "caption", start: 4, duration: 1 }),
|
||||
];
|
||||
const parentMap = new Map([
|
||||
["headline", "scene"],
|
||||
["caption", "scene"],
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveTimelineExpansionRawId({
|
||||
selectedElementId: "caption",
|
||||
isPlaying: false,
|
||||
currentTime: 1.5,
|
||||
manifest,
|
||||
parentMap,
|
||||
}),
|
||||
).toBe("caption");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,9 @@ function findTopLevelAncestor(id: string, parentMap: Map<string, string>): strin
|
||||
while (parentMap.has(current)) {
|
||||
if (visited.has(current)) return current;
|
||||
visited.add(current);
|
||||
current = parentMap.get(current)!;
|
||||
const parent = parentMap.get(current);
|
||||
if (!parent) return current;
|
||||
current = parent;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
@@ -36,6 +38,67 @@ function resolveRawId(
|
||||
return null;
|
||||
}
|
||||
|
||||
interface TimelineExpansionRawIdInput {
|
||||
selectedElementId: string | null;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
manifest: ClipManifestClip[];
|
||||
parentMap: Map<string, string>;
|
||||
}
|
||||
|
||||
function clipContainsTime(clip: ClipManifestClip, time: number): boolean {
|
||||
return Number.isFinite(time) && time >= clip.start && time < clip.start + clip.duration;
|
||||
}
|
||||
|
||||
function getActiveParentDepth(id: string, parentMap: Map<string, string>, activeIds: Set<string>) {
|
||||
let depth = 0;
|
||||
let parent = parentMap.get(id);
|
||||
const visited = new Set<string>();
|
||||
visited.add(id);
|
||||
while (parent) {
|
||||
if (visited.has(parent)) return depth;
|
||||
visited.add(parent);
|
||||
if (activeIds.has(parent)) depth += 1;
|
||||
parent = parentMap.get(parent);
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
function findActiveExpandableCompositionId(
|
||||
currentTime: number,
|
||||
manifest: ClipManifestClip[],
|
||||
parentMap: Map<string, string>,
|
||||
): string | null {
|
||||
const parentIds = new Set(parentMap.values());
|
||||
const activeIds = new Set<string>();
|
||||
for (const clip of manifest) {
|
||||
if (!clip.id || !parentIds.has(clip.id) || !clipContainsTime(clip, currentTime)) continue;
|
||||
activeIds.add(clip.id);
|
||||
}
|
||||
let bestId: string | null = null;
|
||||
let bestDepth = -1;
|
||||
for (const id of activeIds) {
|
||||
const depth = getActiveParentDepth(id, parentMap, activeIds);
|
||||
if (depth <= bestDepth) continue;
|
||||
bestId = id;
|
||||
bestDepth = depth;
|
||||
}
|
||||
return bestId;
|
||||
}
|
||||
|
||||
export function resolveTimelineExpansionRawId({
|
||||
selectedElementId,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
manifest,
|
||||
parentMap,
|
||||
}: TimelineExpansionRawIdInput): string | null {
|
||||
const selectedRawId = resolveRawId(selectedElementId, manifest, parentMap);
|
||||
if (selectedRawId) return selectedRawId;
|
||||
if (isPlaying) return null;
|
||||
return findActiveExpandableCompositionId(currentTime, manifest, parentMap);
|
||||
}
|
||||
|
||||
function filterToTopLevel(
|
||||
elements: TimelineElement[],
|
||||
parentMap: Map<string, string>,
|
||||
@@ -105,7 +168,7 @@ function buildChildElements(
|
||||
domId,
|
||||
selector,
|
||||
sourceFile: editBasis.sourceFile,
|
||||
timingSource: "authored" as const,
|
||||
timingSource: "authored",
|
||||
});
|
||||
}
|
||||
return result;
|
||||
@@ -122,19 +185,21 @@ function domSiblingClips(
|
||||
): ClipManifestClip[] {
|
||||
return domClipChildren
|
||||
.filter((c) => c.parentId === siblingParentId)
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
start: host.start,
|
||||
duration: host.duration,
|
||||
track: host.track,
|
||||
kind: "element" as const,
|
||||
tagName: null,
|
||||
compositionId: null,
|
||||
parentCompositionId: host.id ?? null,
|
||||
compositionSrc: host.compositionSrc ?? null,
|
||||
assetUrl: null,
|
||||
}));
|
||||
.map(
|
||||
(c): ClipManifestClip => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
start: host.start,
|
||||
duration: host.duration,
|
||||
track: host.track,
|
||||
kind: "element",
|
||||
tagName: null,
|
||||
compositionId: null,
|
||||
parentCompositionId: host.id ?? null,
|
||||
compositionSrc: host.compositionSrc ?? null,
|
||||
assetUrl: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Exported for tests.
|
||||
@@ -191,16 +256,38 @@ export function useExpandedTimelineElements(): TimelineElement[] {
|
||||
const clipParentMap = usePlayerStore((s) => s.clipParentMap);
|
||||
const domClipChildren = usePlayerStore((s) => s.domClipChildren);
|
||||
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
|
||||
// Resolve which raw clip drives expansion. This reads currentTime (for paused
|
||||
// auto-expand) so it re-runs each scrub tick, but it's a cheap manifest scan and
|
||||
// its RESULT only changes when the playhead crosses a composition boundary. Keying
|
||||
// the expensive build below on these ids (not raw currentTime) avoids re-allocating
|
||||
// expandedElements — and cascading TimelineClip re-renders — on every tick.
|
||||
const { rawId, selectedRawId } = useMemo(() => {
|
||||
if (!clipManifest || clipManifest.length === 0 || clipParentMap.size === 0) {
|
||||
return { rawId: null as string | null, selectedRawId: null as string | null };
|
||||
}
|
||||
return {
|
||||
rawId: resolveTimelineExpansionRawId({
|
||||
selectedElementId,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
manifest: clipManifest,
|
||||
parentMap: clipParentMap,
|
||||
}),
|
||||
selectedRawId: resolveRawId(selectedElementId, clipManifest, clipParentMap),
|
||||
};
|
||||
}, [clipManifest, clipParentMap, selectedElementId, isPlaying, currentTime]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!clipManifest || clipManifest.length === 0 || clipParentMap.size === 0) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
const rawId = resolveRawId(selectedElementId, clipManifest, clipParentMap);
|
||||
if (!rawId) return filterToTopLevel(elements, clipParentMap);
|
||||
|
||||
const immediateParent = clipParentMap.get(rawId)!;
|
||||
const immediateParent = selectedRawId ? clipParentMap.get(rawId) : rawId;
|
||||
if (!immediateParent) return filterToTopLevel(elements, clipParentMap);
|
||||
const topLevel = findTopLevelAncestor(rawId, clipParentMap) ?? immediateParent;
|
||||
return buildExpandedElements(
|
||||
elements,
|
||||
@@ -210,5 +297,5 @@ export function useExpandedTimelineElements(): TimelineElement[] {
|
||||
immediateParent,
|
||||
domClipChildren,
|
||||
);
|
||||
}, [elements, clipManifest, clipParentMap, domClipChildren, selectedElementId]);
|
||||
}, [elements, clipManifest, clipParentMap, domClipChildren, rawId, selectedRawId]);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ describe("formatTime", () => {
|
||||
expect(formatTime(0)).toBe("0:00");
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("formats seconds less than a minute", () => {
|
||||
expect(formatTime(5)).toBe("0:05");
|
||||
expect(formatTime(30)).toBe("0:30");
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseTimelineFromDOM, createImplicitTimelineLayersFromDOM } from "./timelineDOM";
|
||||
import {
|
||||
createTimelineElementFromManifestClip,
|
||||
parseTimelineFromDOM,
|
||||
createImplicitTimelineLayersFromDOM,
|
||||
} from "./timelineDOM";
|
||||
|
||||
function makeDoc(html: string): Document {
|
||||
const d = document.implementation.createHTMLDocument();
|
||||
@@ -55,6 +59,49 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
|
||||
|
||||
expect(elements.map((el) => el.tag)).toEqual(["img"]);
|
||||
});
|
||||
|
||||
it("marks parsed timeline elements hidden when data-hidden is present", () => {
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root">
|
||||
<div id="hero" class="clip" data-start="0" data-duration="5" data-hidden></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const elements = parseTimelineFromDOM(doc, 10);
|
||||
const hero = elements.find((el) => el.domId === "hero");
|
||||
|
||||
expect(hero?.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("marks manifest timeline elements hidden when the host has data-hidden", () => {
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root">
|
||||
<div id="hero" class="clip" data-start="0" data-duration="5" data-hidden></div>
|
||||
</div>
|
||||
`);
|
||||
const hostEl = doc.getElementById("hero");
|
||||
|
||||
const element = createTimelineElementFromManifestClip({
|
||||
clip: {
|
||||
id: "hero",
|
||||
label: "Hero",
|
||||
kind: "element",
|
||||
tagName: "div",
|
||||
start: 0,
|
||||
duration: 5,
|
||||
track: 0,
|
||||
compositionId: null,
|
||||
parentCompositionId: null,
|
||||
compositionSrc: null,
|
||||
assetUrl: null,
|
||||
},
|
||||
fallbackIndex: 0,
|
||||
doc,
|
||||
hostEl,
|
||||
});
|
||||
|
||||
expect(element.hidden).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
|
||||
|
||||
@@ -65,6 +65,7 @@ function resolveClipTag(clip: ClipManifestClip): string {
|
||||
return clip.tagName || clip.kind || "div";
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function createTimelineElementFromManifestClip(params: {
|
||||
clip: ClipManifestClip;
|
||||
fallbackIndex: number;
|
||||
@@ -120,6 +121,7 @@ export function createTimelineElementFromManifestClip(params: {
|
||||
|
||||
if (hostEl) {
|
||||
applyMediaMetadataFromElement(entry, hostEl);
|
||||
if (hostEl.hasAttribute("data-hidden")) entry.hidden = true;
|
||||
const timelineRole = hostEl.getAttribute("data-timeline-role");
|
||||
if (timelineRole) entry.timelineRole = timelineRole;
|
||||
}
|
||||
@@ -234,6 +236,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
||||
const els: TimelineElement[] = [];
|
||||
let trackCounter = 0;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
nodes.forEach((node) => {
|
||||
if (node === rootComp) return;
|
||||
if (isTimelineIgnoredElement(node)) return;
|
||||
@@ -256,6 +259,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
||||
|
||||
const trackStr = el.getAttribute("data-track-index");
|
||||
const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
|
||||
// fallow-ignore-next-line code-duplication
|
||||
const compId = el.getAttribute("data-composition-id");
|
||||
const selector = getTimelineElementSelector(el);
|
||||
const sourceFile = getTimelineElementSourceFile(el);
|
||||
@@ -308,6 +312,9 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
||||
if (el.hasAttribute("data-timeline-locked")) {
|
||||
entry.timelineLocked = true;
|
||||
}
|
||||
if (el.hasAttribute("data-hidden")) {
|
||||
entry.hidden = true;
|
||||
}
|
||||
|
||||
const timelineRole = el.getAttribute("data-timeline-role");
|
||||
if (timelineRole) entry.timelineRole = timelineRole;
|
||||
|
||||
@@ -344,6 +344,7 @@ export function buildMissingCompositionElements(
|
||||
|
||||
const trackStr = el.getAttribute("data-track-index");
|
||||
const track = trackStr != null ? parseInt(trackStr, 10) : 0;
|
||||
// fallow-ignore-next-line code-duplication
|
||||
const compSrc =
|
||||
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
|
||||
const selector = getTimelineElementSelector(el);
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface TimelineElement {
|
||||
timingSource?: "authored" | "implicit";
|
||||
/** Set by data-timeline-locked on the host element — disables move and trim in Studio. */
|
||||
timelineLocked?: boolean;
|
||||
/** Set by data-hidden on the host element — hides the clip in preview and render. */
|
||||
hidden?: boolean;
|
||||
/** Value of data-timeline-role attribute — used to identify music vs. voiceover. */
|
||||
timelineRole?: string;
|
||||
/**
|
||||
@@ -141,7 +143,9 @@ interface PlayerState {
|
||||
setSelectedElementId: (id: string | null) => void;
|
||||
updateElement: (
|
||||
elementId: string,
|
||||
updates: Partial<Pick<TimelineElement, "start" | "duration" | "track" | "playbackStart">>,
|
||||
updates: Partial<
|
||||
Pick<TimelineElement, "start" | "duration" | "track" | "playbackStart" | "hidden">
|
||||
>,
|
||||
) => void;
|
||||
setZoomMode: (mode: ZoomMode) => void;
|
||||
setManualZoomPercent: (percent: number) => void;
|
||||
|
||||
@@ -117,6 +117,114 @@ body {
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.timeline-clip {
|
||||
background-color: rgba(255, 255, 255, 0.055);
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.timeline-clip::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: rgba(60, 230, 172, 0.2);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-clip[data-active] {
|
||||
border-color: rgba(60, 230, 172, 0.55);
|
||||
}
|
||||
|
||||
.timeline-clip[data-active]::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.timeline-clip.is-hovered {
|
||||
background-color: rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
|
||||
.timeline-clip[data-active].is-hovered {
|
||||
border-color: rgba(60, 230, 172, 0.75);
|
||||
}
|
||||
|
||||
.timeline-clip.is-selected {
|
||||
box-shadow: 0 0 0 1.5px rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.timeline-clip[data-active].is-selected {
|
||||
box-shadow: 0 0 0 1.5px rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.timeline-clip.is-dragging {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.timeline-clip[data-active].is-selected.is-dragging {
|
||||
box-shadow:
|
||||
0 0 0 1.5px rgba(255, 255, 255, 0.85),
|
||||
0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.timeline-clip__label {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 12px;
|
||||
right: 10px;
|
||||
z-index: 6;
|
||||
overflow: hidden;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeline-clip[data-active] .timeline-clip__label {
|
||||
color: rgba(232, 255, 247, 0.95);
|
||||
}
|
||||
|
||||
.timeline-clip__timecode {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
left: 12px;
|
||||
overflow: hidden;
|
||||
color: rgba(255, 255, 255, 0.34);
|
||||
font-family: "SF Mono", "Fira Code", monospace;
|
||||
font-size: 9px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.timeline-clip {
|
||||
transition:
|
||||
background-color 200ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
border-color 200ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 200ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
color 200ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.timeline-clip::before {
|
||||
transition: opacity 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.timeline-clip__label {
|
||||
transition: color 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.timeline-clip__handle-bar {
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
/* CodeMirror overrides */
|
||||
.cm-editor {
|
||||
height: 100%;
|
||||
|
||||
@@ -26,6 +26,15 @@ describe("applyPatchByTarget", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a boolean data attribute by selector", () => {
|
||||
const html = `<div class="headline clip" data-start="0" data-hidden></div>`;
|
||||
const op: PatchOperation = { type: "attribute", property: "hidden", value: null };
|
||||
|
||||
expect(applyPatchByTarget(html, { selector: ".headline" }, op)).toBe(
|
||||
`<div class="headline clip" data-start="0"></div>`,
|
||||
);
|
||||
});
|
||||
|
||||
it("updates inline z-index by selector when the clip has no DOM id", () => {
|
||||
const html = `<div class="headline clip" style="position: absolute; opacity: 1" data-start="0"></div>`;
|
||||
const op: PatchOperation = { type: "inline-style", property: "z-index", value: "3" };
|
||||
@@ -366,6 +375,7 @@ describe("motion attribute round-trip via sourcePatcher", () => {
|
||||
duration: 0.6,
|
||||
ease: "power2.out",
|
||||
from: { autoAlpha: 0, y: 32 },
|
||||
// fallow-ignore-next-line code-duplication
|
||||
to: { autoAlpha: 1, y: 0 },
|
||||
};
|
||||
|
||||
@@ -420,6 +430,7 @@ describe("motion attribute round-trip via sourcePatcher", () => {
|
||||
duration: 1,
|
||||
ease: "none",
|
||||
from: { opacity: 0 },
|
||||
// fallow-ignore-next-line code-duplication
|
||||
to: { opacity: 1 },
|
||||
};
|
||||
|
||||
|
||||
@@ -323,8 +323,9 @@ function patchAttributeByTarget(
|
||||
|
||||
if (value === null) {
|
||||
// Remove the attribute if present
|
||||
if (!attrPattern.test(tag)) return html;
|
||||
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}=(["'])[^"']*\\1`);
|
||||
const boolAttrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
|
||||
if (!boolAttrPattern.test(tag)) return html;
|
||||
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
|
||||
const newTag = tag.replace(removePattern, "");
|
||||
return replaceTagAtMatch(html, match, newTag);
|
||||
}
|
||||
@@ -357,8 +358,9 @@ function patchAttribute(
|
||||
const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
|
||||
|
||||
if (value === null) {
|
||||
if (!attrPattern.test(tag)) return html;
|
||||
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}=(["'])[^"']*\\1`);
|
||||
const boolAttrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
|
||||
if (!boolAttrPattern.test(tag)) return html;
|
||||
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
|
||||
const newTag = tag.replace(removePattern, "");
|
||||
return html.replace(tag, newTag);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user