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
@@ -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 {