refactor(studio): simplify hooks, split contexts, remove dead code (#1416)

* fix(studio): guard Zustand no-op setters and fix useConsoleErrorCapture memory leak

- Guard setIsPlaying to skip set() when value unchanged (eliminates 60
  notifications/sec during reverse playback)
- Guard caption store selectGroup to bail before set() when group missing
  (prevents empty Zustand notifications)
- Guard clearSelection to skip when already empty
- Fix useConsoleErrorCapture: restore original console.error, remove error
  event listener, and delete __hfErrorCapture flag on cleanup

* fix(studio): delete dead files and unused exports

Remove 7 dead files (audioBeatDetection, keyframeSnapping,
timelineInspector, DopesheetStrip, StaggerControls,
TimelineLayerPanel, TimelineEditorNotice) and their test companions.

Delete unused computeFitToChildrenSize export from propertyPanelHelpers.

Fix re-export indirection: useDomEditCommits and studioMotionOps.test
now import patch builders directly from manualEditsDomPatches instead
of the re-export passthrough in manualEditsDom.

* fix(studio): eliminate effect-chain state mirroring for lint findings, hover, and GSAP fetch

Move lint findingsByElement sync from App.tsx into useLintModal where
the value is produced, removing the mirroring useEffect. Consolidate
4 hover-clearing effects in useDomSelection into 2 (one unconditional
on context change, one conditional combining caption mode, selection
match, and disconnected element checks). Fold the GSAP retry effect
into the fetch effect in useGsapTweenCache, scheduling a single retry
via setTimeout when the initial fetch returns 0 animations.

Eliminates 3 unnecessary render cycles from effect chains.

* fix(studio): memoize renderQueue, toolbar, and canvas rect to prevent re-render cascade

- Wrap renderQueue object in useMemo so StudioContext consumers don't
  re-render on every App render
- Memoize timelineToolbar JSX so NLELayout memo isn't defeated
- Move canvasRect getBoundingClientRect() from render-time IIFE to a
  useLayoutEffect-backed ref, eliminating layout thrashing
- Track and clear setTimeout handles in refreshPreviewDocumentVersion
  to prevent stale timer accumulation on rapid calls and unmount

* refactor(studio): consolidate GSAP shared primitives — defaults, iframe access, keyframe parsing

Extract duplicated PROPERTY_DEFAULTS, IframeGsap interface, iframe
accessors (getIframeGsap, queryIframeElement), percentage keyframe
parsing, and toAbsoluteTime into a single gsapShared.ts module.

Removes ~120 lines of copy-pasted logic across 8 hook files, reducing
drift risk between the duplicate implementations.

* fix(studio): remove dead store fields, dead file, duplicate helper, and unsafe assertions

* refactor(studio): deduplicate selector helpers, rounding utils, percentage computation, and iframe access

* fix(studio): split StudioContext into Shell + Playback to prevent cascade re-renders

* refactor(studio): decompose useGsapScriptCommits into focused mutation hooks

* refactor(studio): decompose useFileManager into focused file operation hooks

Extract useFileTree (tree loading, refresh, derived assets/compositions)
and useEditorSave (debounced save with history tracking) from the 508-LOC
useFileManager. The parent hook composes both and retains file I/O,
click-to-source, upload/import, and CRUD — preserving the same public
interface so no consumers change.

* refactor(studio): decompose useDomEditCommits into focused commit hooks

Extract geometry (path offset, box size, rotation) and element lifecycle
(delete, z-index reorder) into useDomGeometryCommits and
useElementLifecycleOps. Parent keeps persistDomEditOperations as core
and composes all sub-hooks — public interface unchanged.

* refactor(studio): simplify useAppHotkeys with declarative command table

* refactor(studio): simplify useAppHotkeys with declarative command table

Replace 15 individual useRef callback refs with a single cbRef object.
Extract keydown dispatch into pure dispatchModifierKey/dispatchPlainKey
functions. Merge duplicate undo/redo logic into shared applyHistory.
Extract cross-origin listener boilerplate into safeAddListener/safeRemoveListener.

Hook body: 204 LOC (down from 445). Public API unchanged.

* fix(studio): remove unused getDomEditTargetKey import

* refactor(studio): decompose useDomEditSession into focused editing hooks

Extract GSAP-aware geometry intercepts (move/resize/rotation) and
animated property commit into useGsapAwareEditing, and selection
wiring, GSAP cache management, preview sync, and selection handlers
into useDomEditWiring. The parent remains a pure composition shell.

* style(studio): fix formatting in 5 files

* fix(studio): trim App.tsx to 598 lines (under 600 limit)

---------

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
Miguel Ángel
2026-06-13 18:25:11 -04:00
committed by GitHub
co-authored by Miguel Ángel
parent 6f677292ae
commit 7bff49ecf0
77 changed files with 3165 additions and 3572 deletions
@@ -5,9 +5,9 @@ import {
STUDIO_MANUAL_EDITING_DISABLED_TITLE,
} from "./editor/manualEditingAvailability";
import { getHistoryShortcutLabel } from "../utils/studioHelpers";
import { useStudioContext } from "../contexts/StudioContext";
import { useStudioShellContext } from "../contexts/StudioContext";
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
import { useDomEditContext } from "../contexts/DomEditContext";
import { useDomEditActionsContext } from "../contexts/DomEditContext";
import { trackStudioEvent } from "../utils/studioTelemetry";
export interface StudioHeaderProps {
@@ -150,9 +150,9 @@ export function StudioHeader({
inspectorPanelActive,
onExport,
}: StudioHeaderProps) {
const { projectId, editHistory, handleUndo, handleRedo } = useStudioContext();
const { projectId, editHistory, handleUndo, handleRedo } = useStudioShellContext();
const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
const { clearDomSelection } = useDomEditContext();
const { clearDomSelection } = useDomEditActionsContext();
return (
<div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
@@ -4,7 +4,7 @@ import { LeftSidebar, type LeftSidebarHandle } from "./sidebar/LeftSidebar";
import { MediaPreview } from "./MediaPreview";
import { isMediaFile } from "../utils/mediaTypes";
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
import { useStudioContext } from "../contexts/StudioContext";
import { useStudioShellContext } from "../contexts/StudioContext";
import { useFileManagerContext } from "../contexts/FileManagerContext";
import { getPersistedRenderSettings } from "./renders/renderSettings";
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
@@ -39,7 +39,7 @@ export function StudioLeftSidebar({
handlePanelResizeMove,
handlePanelResizeEnd,
} = usePanelLayoutContext();
const { projectId, renderQueue, waitForPendingDomEditSaves } = useStudioContext();
const { projectId, renderQueue, waitForPendingDomEditSaves } = useStudioShellContext();
const {
compositions,
assets,
@@ -1,4 +1,4 @@
import { useState, type ReactNode } from "react";
import { useState, useMemo, type ReactNode } from "react";
import { NLELayout } from "./nle/NLELayout";
import { CaptionOverlay } from "../captions/components/CaptionOverlay";
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
@@ -13,8 +13,9 @@ import {
STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
STUDIO_PREVIEW_SELECTION_ENABLED,
} from "./editor/manualEditingAvailability";
import { useStudioContext } from "../contexts/StudioContext";
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
import { useDomEditContext } from "../contexts/DomEditContext";
import { TimelineEditProvider } from "../contexts/TimelineEditContext";
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
import type { GestureRecordingState } from "./editor/GestureRecordControl";
@@ -91,18 +92,20 @@ export function StudioPreviewArea({
}: StudioPreviewAreaProps) {
const {
projectId,
refreshKey,
activeCompPath,
setActiveCompPath,
captionEditMode,
compositionLoading,
isPlaying,
previewIframeRef,
refreshPreviewDocumentVersion,
handlePreviewIframeRef,
timelineVisible,
toggleTimelineVisibility,
} = useStudioContext();
} = useStudioShellContext();
const {
refreshKey,
captionEditMode,
compositionLoading,
isPlaying,
refreshPreviewDocumentVersion,
} = useStudioPlaybackContext();
const {
domEditHoverSelection,
@@ -137,187 +140,208 @@ export function StudioPreviewArea({
};
});
// fallow-ignore-next-line complexity
const timelineEditCallbacks = useMemo(
() => ({
onMoveElement: handleTimelineElementMove,
onResizeElement: handleTimelineElementResize,
onBlockedEditAttempt: handleBlockedTimelineEdit,
onSplitElement: handleTimelineElementSplit,
onRazorSplit: handleRazorSplit,
onRazorSplitAll: handleRazorSplitAll,
onDeleteAllKeyframes: (elId: string) => {
const rawId = elId.includes("#") ? (elId.split("#").pop() ?? elId) : elId;
handleGsapDeleteAllForElement(`#${rawId}`);
},
onDeleteKeyframe: (_elId: string, pct: number) => {
const cacheKey = domEditSelection?.id ?? "";
const cached = usePlayerStore.getState().keyframeCache.get(cacheKey);
const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
const group = kf?.propertyGroup;
const anim =
(group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ??
selectedGsapAnimations.find((a) => a.keyframes);
if (!anim) return;
handleGsapRemoveKeyframe(anim.id, kf?.tweenPercentage ?? pct);
},
onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => {
for (const anim of selectedGsapAnimations) {
if (anim.keyframes) handleGsapUpdateMeta(anim.id, { ease });
}
},
onMoveKeyframe: (_el: TimelineElement, oldPct: number, newPct: number) => {
const cacheKey = domEditSelection?.id ?? "";
const cached = usePlayerStore.getState().keyframeCache.get(cacheKey);
const cachedKf = cached?.keyframes.find((k) => Math.abs(k.percentage - oldPct) < 0.2);
const group = cachedKf?.propertyGroup;
const anim =
(group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ??
selectedGsapAnimations.find((a) => a.keyframes);
if (!anim?.keyframes) return;
const tweenOldPct = cachedKf?.tweenPercentage ?? oldPct;
const kf = anim.keyframes.keyframes.find((k) => Math.abs(k.percentage - tweenOldPct) < 0.2);
if (!kf) return;
const tweenStart = anim.resolvedStart ?? 0;
const tweenDur = anim.duration ?? 1;
const newAbsTime = _el.start + (newPct / 100) * _el.duration;
const tweenNewPct =
tweenDur > 0
? Math.max(
0,
Math.min(100, Math.round(((newAbsTime - tweenStart) / tweenDur) * 1000) / 10),
)
: 0;
handleGsapRemoveKeyframe(anim.id, tweenOldPct);
for (const [prop, val] of Object.entries(kf.properties)) {
handleGsapAddKeyframe(anim.id, tweenNewPct, prop, val);
}
},
onToggleKeyframeAtPlayhead: (el: TimelineElement) => {
const currentTime = usePlayerStore.getState().currentTime;
const pct =
el.duration > 0
? Math.max(0, Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100)))
: 0;
const anim = selectedGsapAnimations.find((a) => a.keyframes);
if (anim?.keyframes) {
const existing = anim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
if (existing) {
handleGsapRemoveKeyframe(anim.id, existing.percentage);
} else {
handleGsapAddKeyframe(anim.id, pct, "x", 0);
}
} else {
const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes);
if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id);
}
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
handleTimelineElementMove,
handleTimelineElementResize,
handleBlockedTimelineEdit,
handleTimelineElementSplit,
handleRazorSplit,
handleRazorSplitAll,
handleGsapDeleteAllForElement,
domEditSelection?.id,
selectedGsapAnimations,
handleGsapRemoveKeyframe,
handleGsapUpdateMeta,
handleGsapAddKeyframe,
handleGsapConvertToKeyframes,
],
);
return (
<div className="flex-1 flex flex-col relative min-w-0">
<div className="flex-1 min-h-0 relative">
<NLELayout
projectId={projectId}
refreshKey={refreshKey}
activeCompositionPath={activeCompPath}
timelineToolbar={timelineToolbar}
renderClipContent={renderClipContent}
onDeleteElement={handleTimelineElementDelete}
onAssetDrop={handleTimelineAssetDrop}
onBlockDrop={handleTimelineBlockDrop}
onPreviewBlockDrop={handlePreviewBlockDrop}
onFileDrop={handleTimelineFileDrop}
onMoveElement={handleTimelineElementMove}
onResizeElement={handleTimelineElementResize}
onBlockedEditAttempt={handleBlockedTimelineEdit}
onSplitElement={handleTimelineElementSplit}
onRazorSplit={handleRazorSplit}
onRazorSplitAll={handleRazorSplitAll}
onSelectTimelineElement={handleTimelineElementSelect}
onDeleteAllKeyframes={(elId) => {
const rawId = elId.includes("#") ? elId.split("#").pop()! : elId;
handleGsapDeleteAllForElement(`#${rawId}`);
}}
onDeleteKeyframe={(_elId, pct) => {
const cacheKey = domEditSelection?.id ?? "";
const cached = usePlayerStore.getState().keyframeCache.get(cacheKey);
const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
const group = kf?.propertyGroup;
const anim =
(group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ??
selectedGsapAnimations.find((a) => a.keyframes);
if (!anim) return;
handleGsapRemoveKeyframe(anim.id, kf?.tweenPercentage ?? pct);
}}
onChangeKeyframeEase={(_elId, _pct, ease) => {
for (const anim of selectedGsapAnimations) {
if (anim.keyframes) handleGsapUpdateMeta(anim.id, { ease });
}
}}
// fallow-ignore-next-line complexity
onMoveKeyframe={(_el, oldPct, newPct) => {
const cacheKey = domEditSelection?.id ?? "";
const cached = usePlayerStore.getState().keyframeCache.get(cacheKey);
const cachedKf = cached?.keyframes.find((k) => Math.abs(k.percentage - oldPct) < 0.2);
const group = cachedKf?.propertyGroup;
const anim =
(group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ??
selectedGsapAnimations.find((a) => a.keyframes);
if (!anim?.keyframes) return;
const tweenOldPct = cachedKf?.tweenPercentage ?? oldPct;
const kf = anim.keyframes.keyframes.find(
(k) => Math.abs(k.percentage - tweenOldPct) < 0.2,
);
if (!kf) return;
const tweenStart = anim.resolvedStart ?? 0;
const tweenDur = anim.duration ?? 1;
const newAbsTime = _el.start + (newPct / 100) * _el.duration;
const tweenNewPct =
tweenDur > 0
? Math.max(
0,
Math.min(100, Math.round(((newAbsTime - tweenStart) / tweenDur) * 1000) / 10),
)
: 0;
handleGsapRemoveKeyframe(anim.id, tweenOldPct);
for (const [prop, val] of Object.entries(kf.properties)) {
handleGsapAddKeyframe(anim.id, tweenNewPct, prop, val);
}
}}
onToggleKeyframeAtPlayhead={(el) => {
const currentTime = usePlayerStore.getState().currentTime;
const pct =
el.duration > 0
? Math.max(
0,
Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100)),
)
: 0;
const anim = selectedGsapAnimations.find((a) => a.keyframes);
if (anim?.keyframes) {
const existing = anim.keyframes.keyframes.find(
(k) => Math.abs(k.percentage - pct) <= 1,
);
if (existing) {
handleGsapRemoveKeyframe(anim.id, existing.percentage);
} else {
handleGsapAddKeyframe(anim.id, pct, "x", 0);
<TimelineEditProvider value={timelineEditCallbacks}>
<NLELayout
projectId={projectId}
refreshKey={refreshKey}
activeCompositionPath={activeCompPath}
timelineToolbar={timelineToolbar}
renderClipContent={renderClipContent}
onDeleteElement={handleTimelineElementDelete}
onAssetDrop={handleTimelineAssetDrop}
onBlockDrop={handleTimelineBlockDrop}
onPreviewBlockDrop={handlePreviewBlockDrop}
onFileDrop={handleTimelineFileDrop}
onSelectTimelineElement={handleTimelineElementSelect}
onCompIdToSrcChange={setCompIdToSrc}
onCompositionLoadingChange={setCompositionLoading}
onCompositionChange={(compPath) => {
// Sync activeCompPath when user drills down via timeline double-click
// or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
// Guard against no-op updates to prevent circular refresh cascades
// between activeCompPath → compositionStack → onCompositionChange.
if (compPath !== activeCompPath) {
setActiveCompPath(compPath);
refreshPreviewDocumentVersion();
}
} else {
const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes);
if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id);
}
}}
onCompIdToSrcChange={setCompIdToSrc}
onCompositionLoadingChange={setCompositionLoading}
onCompositionChange={(compPath) => {
// Sync activeCompPath when user drills down via timeline double-click
// or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
// Guard against no-op updates to prevent circular refresh cascades
// between activeCompPath → compositionStack → onCompositionChange.
if (compPath !== activeCompPath) {
setActiveCompPath(compPath);
refreshPreviewDocumentVersion();
}
}}
onIframeRef={handlePreviewIframeRef}
previewOverlay={
blockPreview ? (
<div className="absolute inset-0 z-30 bg-black pointer-events-none">
{blockPreview.videoUrl ? (
<video
src={blockPreview.videoUrl}
autoPlay
muted
loop
playsInline
className="w-full h-full object-contain"
/>
) : blockPreview.posterUrl ? (
<img
src={blockPreview.posterUrl}
alt={blockPreview.title}
className="w-full h-full object-contain"
/>
) : null}
</div>
) : captionEditMode ? (
<CaptionOverlay iframeRef={previewIframeRef} />
) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
<>
<DomEditOverlay
iframeRef={previewIframeRef}
activeCompositionPath={activeCompPath}
hoverSelection={
STUDIO_PREVIEW_SELECTION_ENABLED &&
!captionEditMode &&
!compositionLoading &&
!isPlaying
? domEditHoverSelection
: null
}
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED && !isGestureRecording}
onCanvasMouseDown={handlePreviewCanvasMouseDown}
onCanvasPointerMove={handlePreviewCanvasPointerMove}
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
onSelectionChange={applyDomSelection}
onBlockedMove={handleBlockedDomMove}
onManualDragStart={handleDomManualDragStart}
onPathOffsetCommit={handleDomPathOffsetCommit}
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
onBoxSizeCommit={handleDomBoxSizeCommit}
onRotationCommit={handleDomRotationCommit}
gridVisible={snapPrefs.gridVisible}
gridSpacing={snapPrefs.gridSpacing}
recordingState={recordingState}
onToggleRecording={onToggleRecording}
/>
<SnapToolbar onSnapChange={setSnapPrefs} />
{gestureOverlay}
</>
) : null
}
timelineFooter={
captionEditMode ? (
<div className="border-t border-neutral-800/30 flex-shrink-0" style={{ height: 60 }}>
<div className="flex items-center gap-1.5 px-2 py-0.5">
<span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
Captions
</span>
}}
onIframeRef={handlePreviewIframeRef}
previewOverlay={
blockPreview ? (
<div className="absolute inset-0 z-30 bg-black pointer-events-none">
{blockPreview.videoUrl ? (
<video
src={blockPreview.videoUrl}
autoPlay
muted
loop
playsInline
className="w-full h-full object-contain"
/>
) : blockPreview.posterUrl ? (
<img
src={blockPreview.posterUrl}
alt={blockPreview.title}
className="w-full h-full object-contain"
/>
) : null}
</div>
<CaptionTimeline pixelsPerSecond={100} />
</div>
) : undefined
}
timelineVisible={timelineVisible}
onToggleTimeline={toggleTimelineVisibility}
/>
) : captionEditMode ? (
<CaptionOverlay iframeRef={previewIframeRef} />
) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
<>
<DomEditOverlay
iframeRef={previewIframeRef}
activeCompositionPath={activeCompPath}
hoverSelection={
STUDIO_PREVIEW_SELECTION_ENABLED &&
!captionEditMode &&
!compositionLoading &&
!isPlaying
? domEditHoverSelection
: null
}
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
allowCanvasMovement={
STUDIO_PREVIEW_MANUAL_EDITING_ENABLED && !isGestureRecording
}
onCanvasMouseDown={handlePreviewCanvasMouseDown}
onCanvasPointerMove={handlePreviewCanvasPointerMove}
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
onSelectionChange={applyDomSelection}
onBlockedMove={handleBlockedDomMove}
onManualDragStart={handleDomManualDragStart}
onPathOffsetCommit={handleDomPathOffsetCommit}
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
onBoxSizeCommit={handleDomBoxSizeCommit}
onRotationCommit={handleDomRotationCommit}
gridVisible={snapPrefs.gridVisible}
gridSpacing={snapPrefs.gridSpacing}
recordingState={recordingState}
onToggleRecording={onToggleRecording}
/>
<SnapToolbar onSnapChange={setSnapPrefs} />
{gestureOverlay}
</>
) : null
}
timelineFooter={
captionEditMode ? (
<div
className="border-t border-neutral-800/30 flex-shrink-0"
style={{ height: 60 }}
>
<div className="flex items-center gap-1.5 px-2 py-0.5">
<span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
Captions
</span>
</div>
<CaptionTimeline pixelsPerSecond={100} />
</div>
) : undefined
}
timelineVisible={timelineVisible}
onToggleTimeline={toggleTimelineVisibility}
/>
</TimelineEditProvider>
</div>
<StudioFeedbackBar />
</div>
@@ -8,7 +8,7 @@ import type { RenderJob } from "./renders/useRenderQueue";
import type { BlockParam } from "@hyperframes/core/registry";
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "./editor/manualEditingAvailability";
import { useStudioContext } from "../contexts/StudioContext";
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
import { useFileManagerContext } from "../contexts/FileManagerContext";
import { useDomEditContext } from "../contexts/DomEditContext";
@@ -47,14 +47,14 @@ export function StudioRightPanel({
} = usePanelLayoutContext();
const {
captionEditMode,
previewIframeRef,
projectId,
activeCompPath,
compositionDimensions,
waitForPendingDomEditSaves,
renderQueue,
} = useStudioContext();
} = useStudioShellContext();
const { captionEditMode } = useStudioPlaybackContext();
const {
domEditSelection,
@@ -1,141 +0,0 @@
import { memo, useCallback, useRef } from "react";
interface DopesheetKeyframe {
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}
interface DopesheetStripProps {
keyframes: DopesheetKeyframe[];
selectedPercentage: number | null;
currentPercentage: number;
accentColor?: string;
onSelectKeyframe: (percentage: number) => void;
onDragKeyframe?: (fromPct: number, toPct: number) => void;
}
const DIAMOND_SIZE = 8;
const HALF = DIAMOND_SIZE / 2;
const STRIP_HEIGHT = 20;
const PADDING_X = 8;
export const DopesheetStrip = memo(function DopesheetStrip({
keyframes,
selectedPercentage,
currentPercentage,
accentColor = "#3CE6AC",
onSelectKeyframe,
onDragKeyframe,
}: DopesheetStripProps) {
const containerRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{ startX: number; startPct: number } | null>(null);
const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
const handlePointerDown = useCallback(
(e: React.PointerEvent, pct: number) => {
if (e.button !== 0) return;
e.stopPropagation();
const startX = e.clientX;
const handleMove = (me: PointerEvent) => {
if (Math.abs(me.clientX - startX) > 4) {
dragRef.current = { startX, startPct: pct };
}
};
const handleUp = (ue: PointerEvent) => {
document.removeEventListener("pointermove", handleMove);
document.removeEventListener("pointerup", handleUp);
if (dragRef.current && containerRef.current && onDragKeyframe) {
const rect = containerRef.current.getBoundingClientRect();
const usableWidth = rect.width - PADDING_X * 2;
const dx = ue.clientX - dragRef.current.startX;
const dpct = (dx / usableWidth) * 100;
const newPct = Math.max(0, Math.min(100, Math.round((pct + dpct) * 10) / 10));
if (newPct !== pct) onDragKeyframe(pct, newPct);
} else {
onSelectKeyframe(pct);
}
dragRef.current = null;
};
document.addEventListener("pointermove", handleMove);
document.addEventListener("pointerup", handleUp);
},
[onSelectKeyframe, onDragKeyframe],
);
return (
<div
ref={containerRef}
className="relative w-full rounded-md bg-neutral-900/60 border border-neutral-800/50"
style={{ height: STRIP_HEIGHT }}
>
{/* Playhead indicator */}
<div
className="absolute top-0 bottom-0 w-px bg-white/30"
style={{
left: `${PADDING_X + (currentPercentage / 100) * (100 - PADDING_X * 2)}%`,
marginLeft: -0.5,
}}
/>
{/* Diamond markers */}
<svg
className="absolute inset-0 w-full"
style={{ height: STRIP_HEIGHT }}
viewBox={`0 0 100 ${STRIP_HEIGHT}`}
preserveAspectRatio="none"
>
{sorted.map((kf) => {
const x = PADDING_X + (kf.percentage / 100) * (100 - PADDING_X * 2);
const y = STRIP_HEIGHT / 2;
const isSelected =
selectedPercentage !== null && Math.abs(kf.percentage - selectedPercentage) < 0.5;
const isHold = kf.ease === "steps(1)";
const fillColor = isSelected ? accentColor : "#737373";
return (
<g
key={kf.percentage}
onPointerDown={(e) => handlePointerDown(e, kf.percentage)}
style={{ cursor: "pointer" }}
>
{isHold ? (
<rect
x={x - HALF}
y={y - HALF}
width={DIAMOND_SIZE}
height={DIAMOND_SIZE}
fill={fillColor}
/>
) : (
<rect
x={x - HALF}
y={y - HALF}
width={DIAMOND_SIZE}
height={DIAMOND_SIZE}
fill={fillColor}
transform={`rotate(45, ${x}, ${y})`}
/>
)}
</g>
);
})}
</svg>
{/* Time labels */}
{sorted.length > 0 && (
<div
className="absolute bottom-0 left-0 right-0 flex justify-between px-2 text-[8px] text-neutral-600 pointer-events-none"
style={{ lineHeight: "10px" }}
>
<span>{sorted[0].percentage}%</span>
{sorted.length > 1 && <span>{sorted[sorted.length - 1].percentage}%</span>}
</div>
)}
</div>
);
});
@@ -1,5 +1,6 @@
import { memo, useCallback, useRef, useState } from "react";
import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnimationConstants";
import { roundToCenti } from "../../utils/rounding";
const PRESET_GRID_EASES = [
"none",
@@ -75,9 +76,7 @@ const EasePresetGrid = memo(function EasePresetGrid({
);
});
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
const round2 = roundToCenti;
export function EaseCurveSection({
ease,
@@ -5,7 +5,7 @@ import {
resolveDomEditSelection,
type DomEditLayerItem,
} from "./domEditing";
import { useStudioContext } from "../../contexts/StudioContext";
import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
import { useDomEditContext } from "../../contexts/DomEditContext";
import { usePlayerStore } from "../../player";
import {
@@ -54,14 +54,8 @@ interface CollapsedState {
// fallow-ignore-next-line complexity
export const LayersPanel = memo(function LayersPanel() {
const {
previewIframeRef,
activeCompPath,
refreshKey,
compositionLoading,
timelineElements,
showToast,
} = useStudioContext();
const { previewIframeRef, activeCompPath, showToast } = useStudioShellContext();
const { refreshKey, compositionLoading, timelineElements } = useStudioPlaybackContext();
const currentTime = usePlayerStore((s) => s.currentTime);
const {
domEditSelection,
@@ -1,6 +1,6 @@
import { memo, useEffect, useRef, useState } from "react";
import { Eye, Layers, Move, X } from "../../icons/SystemIcons";
import { useStudioContext } from "../../contexts/StudioContext";
import { useStudioShellContext } from "../../contexts/StudioContext";
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
import {
EMPTY_STYLES,
@@ -83,7 +83,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onToggleRecording,
}: PropertyPanelProps) {
const styles = element?.computedStyles ?? EMPTY_STYLES;
const { showToast } = useStudioContext();
const { showToast } = useStudioShellContext();
const [clipboardCopied, setClipboardCopied] = useState(false);
const clipboardTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const storeTime = usePlayerStore((s) => s.currentTime);
@@ -1,61 +0,0 @@
import { memo, useState } from "react";
import { MetricField } from "./propertyPanelPrimitives";
export type StaggerOrder = "dom" | "reverse" | "center" | "edges" | "random";
interface StaggerControlsProps {
elementCount: number;
onApplyStagger: (offsetMs: number, order: StaggerOrder) => void;
}
const ORDER_OPTIONS: StaggerOrder[] = ["dom", "reverse", "center", "edges", "random"];
const ORDER_LABELS: Record<StaggerOrder, string> = {
dom: "DOM order",
reverse: "Reverse",
center: "Center out",
edges: "Edges in",
random: "Random",
};
export const StaggerControls = memo(function StaggerControls({
elementCount,
onApplyStagger,
}: StaggerControlsProps) {
const [offsetMs, setOffsetMs] = useState(80);
const [order, setOrder] = useState<StaggerOrder>("dom");
if (elementCount < 2) return null;
return (
<div className="flex items-center gap-2 rounded-lg border border-neutral-800 bg-neutral-900/50 px-2 py-1.5">
<span className="text-[10px] font-medium text-neutral-500">Stagger</span>
<MetricField
label="Offset"
value={String(offsetMs)}
suffix="ms"
onCommit={(raw) => {
const v = Number.parseInt(raw, 10);
if (Number.isFinite(v) && v >= 0) setOffsetMs(v);
}}
/>
<select
value={order}
onChange={(e) => setOrder(e.target.value as StaggerOrder)}
className="rounded-md border border-neutral-700 bg-neutral-900 px-1.5 py-1 text-[10px] text-neutral-200 outline-none"
>
{ORDER_OPTIONS.map((o) => (
<option key={o} value={o}>
{ORDER_LABELS[o]}
</option>
))}
</select>
<button
type="button"
onClick={() => onApplyStagger(offsetMs, order)}
className="rounded-md bg-panel-accent/10 px-2 py-1 text-[10px] font-semibold text-panel-accent transition-colors hover:bg-panel-accent/20"
>
Apply ({elementCount})
</button>
</div>
);
});
@@ -1,42 +0,0 @@
import { describe, expect, it } from "vitest";
import { Window } from "happy-dom";
import type { DomEditLayerItem } from "./domEditing";
import { getTimelineLayerPanelSummary } from "./TimelineLayerPanel";
function createLayer(overrides: Partial<DomEditLayerItem> = {}): DomEditLayerItem {
const window = new Window();
return {
childCount: 0,
depth: 0,
element: window.document.createElement(overrides.tagName ?? "div"),
key: "layer",
label: "Layer",
sourceFile: "index.html",
tagName: "div",
...overrides,
};
}
describe("TimelineLayerPanel", () => {
it("describes a leaf media clip as a single selectable layer", () => {
expect(
getTimelineLayerPanelSummary([
createLayer({ key: "alpha-video", label: "Alpha Video", tagName: "video" }),
]),
).toBe("Single selectable media layer");
});
it("describes real nested layers with the nested count", () => {
expect(
getTimelineLayerPanelSummary([
createLayer({ key: "root", childCount: 2 }),
createLayer({ key: "title", depth: 1 }),
createLayer({ key: "subtitle", depth: 1 }),
]),
).toBe("2 nested selectable layers");
});
it("keeps empty layer lists explicit", () => {
expect(getTimelineLayerPanelSummary([])).toBe("No selectable layers");
});
});
@@ -1,15 +0,0 @@
import type { DomEditLayerItem } from "./domEditing";
const MEDIA_LAYER_TAGS = new Set(["audio", "canvas", "img", "picture", "svg", "video"]);
export function getTimelineLayerPanelSummary(layers: readonly DomEditLayerItem[]): string {
const childCount = Math.max(0, layers.length - 1);
if (childCount > 0) {
return `${childCount} nested selectable layer${childCount === 1 ? "" : "s"}`;
}
const layer = layers[0];
if (!layer) return "No selectable layers";
return MEDIA_LAYER_TAGS.has(layer.tagName.trim().toLowerCase())
? "Single selectable media layer"
: "Single selectable layer";
}
@@ -1,3 +1,5 @@
import { roundToCenti } from "../../utils/rounding";
export interface ParsedColor {
red: number;
green: number;
@@ -24,7 +26,7 @@ function toHex(value: number): string {
}
function formatAlpha(value: number): string {
return `${Math.round(clampAlpha(value) * 100) / 100}`;
return `${roundToCenti(clampAlpha(value))}`;
}
export function parseCssColor(value: string): ParsedColor | null {
@@ -1,3 +1,5 @@
import { roundToCenti } from "../../utils/rounding";
export type GradientKind = "linear" | "radial" | "conic";
export type RadialSizeKeyword =
@@ -124,9 +126,7 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function round(value: number): number {
return Math.round(value * 100) / 100;
}
const round = roundToCenti;
function parsePercent(value: string | undefined, fallback: number): number {
const parsed = parseCssNumber(value);
@@ -506,18 +506,6 @@ export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle
);
}
/* ── HTML patch builders (re-exported from manualEditsDomPatches) ── */
export {
buildPathOffsetPatches,
buildClearPathOffsetPatches,
buildBoxSizePatches,
buildClearBoxSizePatches,
buildRotationPatches,
buildClearRotationPatches,
buildMotionPatches,
buildClearMotionPatches,
} from "./manualEditsDomPatches";
/* ── Seek reapply (position + motion) ────────────────────────────── */
function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
@@ -3,6 +3,7 @@ import { COMMON_LOCAL_FONT_FAMILIES } from "./fontCatalog";
import type { DomEditSelection } from "./domEditing";
import type { ImportedFontAsset } from "./fontAssets";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { roundToCenti } from "../../utils/rounding";
export interface PropertyPanelProps {
projectId: string;
@@ -239,8 +240,13 @@ export function parseNumericValue(value: string | undefined): number | null {
return Number.isFinite(parsed) ? parsed : null;
}
export function formatTimingValue(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
return `${seconds.toFixed(2)}s`;
}
export function formatNumericValue(value: number): string {
const rounded = Math.round(value * 100) / 100;
const rounded = roundToCenti(value);
return Number.isInteger(rounded)
? `${rounded}`
: rounded.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
@@ -473,40 +479,6 @@ export function extractBackgroundImageUrl(value: string | undefined): string {
return value.slice(index, endParen).trim();
}
// ── Fit to children ──────────────────────────────────────────────────
export function computeFitToChildrenSize(
element: DomEditSelection,
): { width: number; height: number } | null {
const el = element.element;
const win = el.ownerDocument?.defaultView;
const children = Array.from(el.children).filter((c): c is HTMLElement => c.nodeType === 1);
if (children.length === 0) return null;
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const child of children) {
if (win) {
const cs = win.getComputedStyle(child);
if (cs.visibility === "hidden" || cs.display === "none") continue;
}
const r = child.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
minX = Math.min(minX, r.left);
minY = Math.min(minY, r.top);
maxX = Math.max(maxX, r.right);
maxY = Math.max(maxY, r.bottom);
}
if (!isFinite(minX)) return null;
const parentRect = el.getBoundingClientRect();
const scaleX = parentRect.width > 0 ? element.boundingBox.width / parentRect.width : 1;
const scaleY = parentRect.height > 0 ? element.boundingBox.height / parentRect.height : 1;
const width = Math.round((maxX - minX) * scaleX);
const height = Math.round((maxY - minY) * scaleY);
return width > 0 && height > 0 ? { width, height } : null;
}
// ── GSAP runtime value readers (used by PropertyPanel) ────────────────────
export function readGsapRuntimeValuesForPanel(
@@ -541,7 +513,7 @@ export function readGsapRuntimeValuesForPanel(
const result: Record<string, number> = {};
for (const prop of propKeys) {
const v = Number(gsap.getProperty(el, prop));
if (Number.isFinite(v)) result[prop] = Math.round(v * 100) / 100;
if (Number.isFinite(v)) result[prop] = roundToCenti(v);
}
return Object.keys(result).length > 0 ? result : null;
} catch {
@@ -568,8 +540,8 @@ export function readGsapBorderRadiusForPanel(
if (!iframe?.contentDocument || !selector) return null;
try {
const el = iframe.contentDocument.querySelector(selector);
if (!el) return null;
const cs = iframe.contentWindow!.getComputedStyle(el);
if (!el || !iframe.contentWindow) return null;
const cs = iframe.contentWindow.getComputedStyle(el);
const parse = (v: string) => Number.parseFloat(v) || 0;
return {
tl: parse(cs.borderTopLeftRadius),
@@ -3,6 +3,7 @@ import { Check, ClipboardList, Film, Music } from "../../icons/SystemIcons";
import type { DomEditSelection } from "./domEditing";
import {
formatNumericValue,
formatTimingValue,
LABEL,
parseNumericValue,
RESPONSIVE_GRID,
@@ -15,11 +16,6 @@ export function isMediaElement(element: DomEditSelection): boolean {
return MEDIA_TAGS.has(element.tagName);
}
function formatTimingValue(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
return `${seconds.toFixed(2)}s`;
}
export function MediaSection({
projectDir,
element,
@@ -1,13 +1,8 @@
import { Clock } from "../../icons/SystemIcons";
import type { DomEditSelection } from "./domEditing";
import { RESPONSIVE_GRID } from "./propertyPanelHelpers";
import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
import { MetricField, Section } from "./propertyPanelPrimitives";
function formatTimingValue(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
return `${seconds.toFixed(2)}s`;
}
function parseTimingValue(input: string): number | null {
const cleaned = input.replace(/s$/i, "").trim();
const parsed = Number.parseFloat(cleaned);
@@ -11,7 +11,7 @@ import {
STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
} from "./studioMotionTypes";
import { buildMotionPatches, buildClearMotionPatches } from "./manualEditsDom";
import { buildMotionPatches, buildClearMotionPatches } from "./manualEditsDomPatches";
import { applyPatchByTarget, readAttributeByTarget } from "../../utils/sourcePatcher";
function createElement(markup: string): HTMLElement {
@@ -18,6 +18,7 @@ import {
type StudioMotionManifest,
type StudioMotionTarget,
} from "./studioMotionTypes";
import { roundTo3 } from "../../utils/rounding";
// ── Private helpers ──
@@ -34,7 +35,7 @@ function sanitizeEase(value: string): string {
}
function roundEaseNumber(value: number): number {
return Math.round(value * 1000) / 1000;
return roundTo3(value);
}
function clampRange(value: number, min: number, max: number, fallback: number): number {
@@ -10,7 +10,6 @@ import {
import { useMountEffect } from "../../hooks/useMountEffect";
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
import type { TimelineElement } from "../../player";
import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
import { NLEPreview } from "./NLEPreview";
import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
@@ -20,7 +19,7 @@ import {
getTimelineToggleTitle,
} from "../../utils/timelineDiscovery";
interface NLELayoutProps extends TimelineEditCallbacks {
interface NLELayoutProps {
projectId: string;
portrait?: boolean;
/** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */
@@ -104,18 +103,7 @@ export const NLELayout = memo(function NLELayout({
onAssetDrop,
onBlockDrop,
onPreviewBlockDrop,
onMoveElement,
onResizeElement,
onBlockedEditAttempt,
onSplitElement,
onRazorSplit,
onRazorSplitAll,
onSelectTimelineElement,
onDeleteKeyframe,
onDeleteAllKeyframes,
onChangeKeyframeEase,
onMoveKeyframe,
onToggleKeyframeAtPlayhead,
onCompIdToSrcChange,
timelineVisible,
onToggleTimeline,
@@ -444,18 +432,7 @@ export const NLELayout = memo(function NLELayout({
onDeleteElement={onDeleteElement}
onAssetDrop={onAssetDrop}
onBlockDrop={onBlockDrop}
onMoveElement={onMoveElement}
onResizeElement={onResizeElement}
onBlockedEditAttempt={onBlockedEditAttempt}
onSplitElement={onSplitElement}
onRazorSplit={onRazorSplit}
onRazorSplitAll={onRazorSplitAll}
onSelectElement={onSelectTimelineElement}
onDeleteKeyframe={onDeleteKeyframe}
onDeleteAllKeyframes={onDeleteAllKeyframes}
onChangeKeyframeEase={onChangeKeyframeEase}
onMoveKeyframe={onMoveKeyframe}
onToggleKeyframeAtPlayhead={onToggleKeyframeAtPlayhead}
/>
</div>
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
@@ -1,133 +0,0 @@
import { TIMELINE_TOGGLE_SHORTCUT_LABEL } from "../../utils/timelineDiscovery";
import { PlayheadIndicator } from "../../player/components/PlayheadIndicator";
interface TimelineEditorNoticeProps {
onDismiss: () => void;
}
export function TimelineEditorNotice({ onDismiss }: TimelineEditorNoticeProps) {
return (
<aside
aria-live="polite"
className="pointer-events-none relative w-[320px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-white/10 bg-[#0f1115]/88 text-neutral-100 shadow-[0_18px_40px_rgba(0,0,0,0.3),0_4px_14px_rgba(0,0,0,0.18)] backdrop-blur-xl"
>
<style>{`
@keyframes hfTimelineNoticeClipNudge {
0%, 100% { transform: translate3d(0, 0, 0); }
20% { transform: translate3d(0, 0, 0); }
52% { transform: translate3d(12px, 0, 0); }
72% { transform: translate3d(12px, 0, 0); }
100% { transform: translate3d(0, 0, 0); }
}
@keyframes hfTimelineNoticePlayheadSweep {
0% { transform: translateX(0); opacity: 0; }
10% { opacity: 1; }
75% { opacity: 1; }
100% { transform: translateX(218px); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.hf-timeline-notice-clip,
.hf-timeline-notice-playhead {
animation: none !important;
}
}
`}</style>
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss timeline editor notice"
className="pointer-events-auto absolute right-3 top-3 z-10 flex h-7 w-7 items-center justify-center rounded-lg text-neutral-500 transition-colors duration-150 hover:bg-white/[0.06] hover:text-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-studio-accent/50"
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.25"
strokeLinecap="round"
aria-hidden="true"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<div className="flex items-start gap-3 px-4 py-3.5">
<div className="min-w-0 flex-1">
<div
aria-hidden="true"
className="mb-3 overflow-hidden rounded-[14px] bg-[#0d1117] p-2.5"
>
<div className="relative overflow-hidden rounded-[11px] bg-[#0f141c] px-2.5 pb-2 pt-1.5">
<div className="mb-1.5 flex items-center justify-between pl-6 pr-1 text-[8px] font-medium text-[#7f8796]">
<span>0:00</span>
<span>0:05</span>
<span>0:10</span>
</div>
<div className="pointer-events-none absolute inset-x-0 top-[18px] h-px bg-white/[0.04]" />
<div
className="hf-timeline-notice-playhead pointer-events-none absolute left-[31px] top-[18px] h-[70px] w-0"
style={{
animation:
"hfTimelineNoticePlayheadSweep 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
}}
>
<PlayheadIndicator />
</div>
<div className="flex flex-col gap-1.5">
{[0, 1, 2].map((trackIndex) => (
<div
key={trackIndex}
className="relative h-6 overflow-hidden rounded-[10px] bg-white/[0.035]"
>
<div className="absolute inset-y-0 left-[24px] w-px bg-white/[0.035]" />
<div className="absolute inset-y-0 left-[100px] w-px bg-white/[0.035]" />
<div className="absolute inset-y-0 left-[176px] w-px bg-white/[0.035]" />
</div>
))}
</div>
<div className="pointer-events-none absolute inset-x-0 top-[21px] h-[70px]">
<div className="absolute left-[34px] top-[3px] h-[18px] w-[56px] rounded-[9px] bg-white/[0.07]" />
<div
className="hf-timeline-notice-clip absolute left-[82px] top-[27px] h-[18px] w-[110px] rounded-[9px] bg-studio-accent/18 ring-1 ring-inset ring-studio-accent/28"
style={{
animation:
"hfTimelineNoticeClipNudge 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
}}
/>
<div className="absolute left-[52px] top-[51px] h-[18px] w-[72px] rounded-[9px] bg-white/[0.07]" />
</div>
</div>
</div>
<div className="min-w-0 pr-9">
<p className="text-[12px] font-semibold leading-none tracking-tight text-neutral-100">
Timeline editing is on
</p>
<p className="mt-1.5 text-[12px] leading-5 text-neutral-300">
Drag clips to move timing, use{" "}
<span className="font-mono text-[11px] text-studio-accent">Shift</span> + click to
edit a full clip range, and watch for resize handles only on clips Studio can patch
safely. Toggle the timeline with{" "}
<span className="rounded-md border border-white/8 bg-white/[0.04] px-1.5 py-0.5 font-mono text-[11px] text-studio-accent">
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
</span>
.
</p>
</div>
<div className="mt-2 text-[10px] leading-none text-neutral-500">
Dismiss once and it stays hidden.
</div>
</div>
</div>
</aside>
);
}
@@ -8,7 +8,7 @@ import {
} from "../../utils/blockCategories";
import { usePlayerStore } from "../../player";
import { formatTime } from "../../player/lib/time";
import { useStudioContext } from "../../contexts/StudioContext";
import { useStudioShellContext } from "../../contexts/StudioContext";
export interface BlockPreviewInfo {
videoUrl?: string;
posterUrl?: string;
@@ -345,7 +345,7 @@ function BlockCard({
[onAdd, adding],
);
const { activeCompPath, compositionDimensions } = useStudioContext();
const { activeCompPath, compositionDimensions } = useStudioShellContext();
const handleShowPrompt = useCallback(
(e: React.MouseEvent) => {