mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 20:07:39 +00:00
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:
co-authored by
Miguel Ángel
parent
6f677292ae
commit
7bff49ecf0
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user