fix(studio): fix PropertyPanel format and file size regression (#1229)

The fit-to-children merge re-inlined TimingSection that was already
extracted to propertyPanelTimingSection.ts, pushing the file to 687
lines (over the 600 limit) and introducing format issues.

- Removed duplicate TimingSection, import from extracted module
- Extracted computeFitToChildrenSize to propertyPanelHelpers
- Formatted PropertyPanel.tsx (608 lines, down from 687)
This commit is contained in:
Miguel Ángel
2026-06-05 18:40:35 -04:00
committed by GitHub
parent 4d96886655
commit 2c73fa9c8f
2 changed files with 103 additions and 148 deletions
@@ -1,8 +1,6 @@
import { memo } from "react";
import { Clock, Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
import { type DomEditSelection } from "./domEditing";
import { Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
import type { ImportedFontAsset } from "./fontAssets";
import {
EMPTY_STYLES,
formatPxMetricValue,
@@ -17,6 +15,8 @@ import { GsapAnimationSection } from "./GsapAnimationSection";
import { KeyframeNavigation } from "./KeyframeNavigation";
import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
import { usePlayerStore } from "../../player";
import { TimingSection } from "./propertyPanelTimingSection";
import { computeFitToChildrenSize, type PropertyPanelProps } from "./propertyPanelHelpers";
// Re-export helpers that external consumers import from this module
export {
@@ -30,124 +30,6 @@ export {
setCssFilterFunctionPx,
} from "./propertyPanelHelpers";
interface PropertyPanelProps {
projectId: string;
projectDir: string | null;
assets: string[];
element: DomEditSelection | null;
multiSelectCount?: number;
copiedAgentPrompt: boolean;
onClearSelection: () => void;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
onSetText: (value: string, fieldKey?: string) => void;
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
onRemoveTextField: (fieldKey: string) => void;
onAskAgent: () => void;
onImportAssets?: (files: FileList) => Promise<string[]>;
fontAssets?: ImportedFontAsset[];
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
gsapAnimations?: import("@hyperframes/core/gsap-parser").GsapAnimation[];
gsapMultipleTimelines?: boolean;
gsapUnsupportedTimelinePattern?: boolean;
onUpdateGsapProperty?: (animId: string, prop: string, value: number | string) => void;
onUpdateGsapMeta?: (
animId: string,
updates: { duration?: number; ease?: string; position?: number },
) => void;
onDeleteGsapAnimation?: (animId: string) => void;
onAddGsapProperty?: (animId: string, prop: string) => void;
onRemoveGsapProperty?: (animId: string, prop: string) => void;
onUpdateGsapFromProperty?: (animId: string, prop: string, value: number | string) => void;
onAddGsapFromProperty?: (animId: string, prop: string) => void;
onRemoveGsapFromProperty?: (animId: string, prop: string) => void;
onAddGsapAnimation?: (method: "to" | "from" | "set" | "fromTo") => void;
onAddKeyframe?: (
animationId: string,
percentage: number,
property: string,
value: number | string,
) => void;
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
onConvertToKeyframes?: (animationId: string) => void;
onCommitAnimatedProperty?: (
selection: DomEditSelection,
property: string,
value: number | string,
) => Promise<void>;
onSeekToTime?: (time: number) => void;
}
/* ------------------------------------------------------------------ */
/* TimingSection */
/* ------------------------------------------------------------------ */
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);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}
function TimingSection({
element,
onSetAttribute,
}: {
element: DomEditSelection;
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
}) {
const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
const duration =
Number.parseFloat(
element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0",
) || 0;
const end = start + duration;
const commitStart = (nextValue: string) => {
const parsed = parseTimingValue(nextValue);
if (parsed == null) return;
void onSetAttribute("start", parsed.toFixed(2));
};
const commitDuration = (nextValue: string) => {
const parsed = parseTimingValue(nextValue);
if (parsed == null || parsed <= 0) return;
void onSetAttribute("duration", parsed.toFixed(2));
};
const commitEnd = (nextValue: string) => {
const parsed = parseTimingValue(nextValue);
if (parsed == null || parsed <= start) return;
void onSetAttribute("duration", (parsed - start).toFixed(2));
};
return (
<Section title="Timing" icon={<Clock size={15} />}>
<div className={RESPONSIVE_GRID}>
<MetricField label="Start" value={formatTimingValue(start)} onCommit={commitStart} />
<MetricField label="End" value={formatTimingValue(end)} onCommit={commitEnd} />
</div>
<div className="mt-3">
<MetricField
label="Duration"
value={formatTimingValue(duration)}
onCommit={commitDuration}
/>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* PropertyPanel */
/* ------------------------------------------------------------------ */
@@ -508,35 +390,18 @@ export const PropertyPanel = memo(function PropertyPanel({
className="flex-shrink-0 rounded p-1 text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
title="Fit to children"
onClick={() => {
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;
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;
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);
if (width > 0 && height > 0) onSetManualSize(element, { width, height });
const size = computeFitToChildrenSize(element);
if (size) onSetManualSize(element, size);
}}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2">
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
>
<rect x="2" y="2" width="10" height="10" strokeDasharray="2 1.5" rx="1" />
<path d="M2 4.5h1m-1 5h1m8-5h1m-1 5h1M4.5 2v1m5-1v1M4.5 11v1m5-1v1" />
</svg>
@@ -1,5 +1,61 @@
import { parseCssColor, type ParsedColor } from "./colorValue";
import { COMMON_LOCAL_FONT_FAMILIES } from "./fontCatalog";
import type { DomEditSelection } from "./domEditing";
import type { ImportedFontAsset } from "./fontAssets";
export interface PropertyPanelProps {
projectId: string;
projectDir: string | null;
assets: string[];
element: DomEditSelection | null;
multiSelectCount?: number;
copiedAgentPrompt: boolean;
onClearSelection: () => void;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
onSetText: (value: string, fieldKey?: string) => void;
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
onRemoveTextField: (fieldKey: string) => void;
onAskAgent: () => void;
onImportAssets?: (files: FileList) => Promise<string[]>;
fontAssets?: ImportedFontAsset[];
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
gsapAnimations?: import("@hyperframes/core/gsap-parser").GsapAnimation[];
gsapMultipleTimelines?: boolean;
gsapUnsupportedTimelinePattern?: boolean;
onUpdateGsapProperty?: (animId: string, prop: string, value: number | string) => void;
onUpdateGsapMeta?: (
animId: string,
updates: { duration?: number; ease?: string; position?: number },
) => void;
onDeleteGsapAnimation?: (animId: string) => void;
onAddGsapProperty?: (animId: string, prop: string) => void;
onRemoveGsapProperty?: (animId: string, prop: string) => void;
onUpdateGsapFromProperty?: (animId: string, prop: string, value: number | string) => void;
onAddGsapFromProperty?: (animId: string, prop: string) => void;
onRemoveGsapFromProperty?: (animId: string, prop: string) => void;
onAddGsapAnimation?: (method: "to" | "from" | "set" | "fromTo") => void;
onAddKeyframe?: (
animationId: string,
percentage: number,
property: string,
value: number | string,
) => void;
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
onConvertToKeyframes?: (animationId: string) => void;
onCommitAnimatedProperty?: (
selection: DomEditSelection,
property: string,
value: number | string,
) => Promise<void>;
onSeekToTime?: (time: number) => void;
}
/* ------------------------------------------------------------------ */
/* Font types & constants (shared by font and section modules) */
@@ -399,3 +455,37 @@ export function extractBackgroundImageUrl(value: string | undefined): string {
if (endParen < index) return "";
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;
}