mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user