refactor(studio): split oversized files to pass 600-line check (#1313)

This commit is contained in:
Miguel Ángel
2026-06-09 19:53:14 -04:00
committed by GitHub
parent d13ae13670
commit 81416ab3c9
17 changed files with 1380 additions and 1055 deletions
@@ -0,0 +1,145 @@
import type { TimelineElement } from "../player";
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import type { EditHistoryKind } from "../utils/editHistory";
// ── Types ──
interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}
export function buildPatchTarget(element: {
domId?: string;
hfId?: string;
selector?: string;
selectorIndex?: number;
}) {
if (element.domId) {
return {
id: element.domId,
hfId: element.hfId,
selector: element.selector,
selectorIndex: element.selectorIndex,
};
}
if (element.hfId) {
return { hfId: element.hfId, selector: element.selector, selectorIndex: element.selectorIndex };
}
if (element.selector) {
return { selector: element.selector, selectorIndex: element.selectorIndex };
}
return null;
}
export type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
// The runtime re-reads data-start/data-duration from the DOM on each sync tick
// (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are
// picked up automatically on the next frame without a rebind call.
export function patchIframeDomTiming(
iframe: HTMLIFrameElement | null,
element: TimelineElement,
attrs: Array<[string, string]>,
): void {
try {
const doc = iframe?.contentDocument;
if (!doc) return;
const el = element.domId
? doc.getElementById(element.domId)
: element.selector
? (doc.querySelectorAll(element.selector)[element.selectorIndex ?? 0] ?? null)
: null;
if (!el) return;
for (const [name, value] of attrs) el.setAttribute(name, value);
} catch {
// Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
}
}
export function resolveResizePlaybackStart(
original: string,
target: PatchTarget,
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "playbackStart">,
): { attrName: string; value: number } | null {
if (updates.playbackStart != null) {
const attrName =
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
return { attrName, value: updates.playbackStart };
}
const trimDelta = updates.start - element.start;
if (trimDelta === 0) return null;
const raw =
readAttributeByTarget(original, target, "playback-start") ??
readAttributeByTarget(original, target, "media-start");
const current = raw != null ? parseFloat(raw) : undefined;
if (current == null || !Number.isFinite(current)) return null;
const attrName =
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
return {
attrName,
value: Math.max(0, current + trimDelta * Math.max(element.playbackRate ?? 1, 0.1)),
};
}
export interface PersistTimelineEditInput {
projectId: string;
element: TimelineElement;
activeCompPath: string | null;
label: string;
buildPatches: (original: string, target: PatchTarget) => string;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
}
export async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<void> {
const targetPath = input.element.sourceFile || input.activeCompPath || "index.html";
const originalContent = await readFileContent(input.projectId, targetPath);
const patchTarget = buildPatchTarget(input.element);
if (!patchTarget) {
throw new Error(`Timeline element ${input.element.id} is missing a patchable target`);
}
const patchedContent = input.buildPatches(originalContent, patchTarget);
if (patchedContent === originalContent) {
throw new Error(`Unable to patch timeline element ${input.element.id} in ${targetPath}`);
}
input.pendingTimelineEditPathRef.current.add(targetPath);
input.domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: input.projectId,
label: input.label,
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: input.writeProjectFile,
recordEdit: input.recordEdit,
});
input.domEditSaveTimestampRef.current = Date.now();
}
export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
const response = await fetch(
`/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
);
if (!response.ok) {
throw new Error(`Failed to read ${targetPath}`);
}
const data = (await response.json()) as { content?: string };
if (typeof data.content !== "string") {
throw new Error(`Missing file contents for ${targetPath}`);
}
return data.content;
}
// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
export { applyPatchByTarget, formatTimelineAttributeNumber };