import type { RegistryItem } from "@hyperframes/core/registry"; import type { TimelineElement } from "../player"; import { insertTimelineAssetIntoSource, resolveTimelineAssetCompositionSize, } from "./timelineAssetDrop"; import { collectHtmlIds } from "./studioHelpers"; import { generateId } from "./generateId"; import { formatTimelineAttributeNumber } from "../player/components/timelineEditing"; import { saveProjectFilesWithHistory } from "./studioFileHistory"; import type { EditHistoryKind } from "./editHistory"; import { extendRootDurationInSource } from "./rootDuration"; function getMaxZIndexFromIframe(iframe: HTMLIFrameElement | null): number { try { const doc = iframe?.contentDocument; if (!doc) return 0; let max = 0; for (const el of doc.body.querySelectorAll("*")) { const z = parseInt(getComputedStyle(el).zIndex, 10); if (Number.isFinite(z) && z > max) max = z; } return max; } catch { return 0; } } interface AddBlockOptions { projectId: string; blockName: string; activeCompPath: string | null; placement?: { start: number; duration?: number; track?: number }; visualPosition?: { left: number; top: number }; previewIframe?: HTMLIFrameElement | null; currentTime?: number; timelineElements: TimelineElement[]; readProjectFile: (path: string) => Promise; writeProjectFile: (path: string, content: string) => Promise; recordEdit: (entry: { label: string; kind: EditHistoryKind; coalesceKey?: string; files: Record; }) => Promise; refreshFileTree: () => Promise; reloadPreview: () => void; showToast: (msg: string) => void; } function buildUniqueCompositionId(baseName: string, existingIds: Iterable): string { const idSet = new Set(existingIds); if (!idSet.has(baseName)) return baseName; let i = 2; while (idSet.has(`${baseName}_${i}`)) i++; return `${baseName}_${i}`; } async function installRegistryItem({ projectId, blockName, showToast, }: Pick): Promise<{ block: RegistryItem; compositionFile: string; } | null> { const response = await fetch(`/api/projects/${projectId}/registry/install`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ blockName }), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: "Install failed" })); showToast((error as { error?: string }).error || "Failed to install block"); return null; } const { written, block } = (await response.json()) as { written: string[]; block: RegistryItem; }; const compositionFile = written.find((file) => file.endsWith(".html")) ?? written[0]; if (!compositionFile) { showToast("Installed but no composition file was written"); return null; } return { block, compositionFile }; } async function makeComponentBackgroundTransparent( block: RegistryItem, compositionFile: string, readProjectFile: AddBlockOptions["readProjectFile"], writeProjectFile: AddBlockOptions["writeProjectFile"], ): Promise { if (block.type !== "hyperframes:component") return; const content = await readProjectFile(compositionFile); const transparentContent = content.replace( /background:\s*(?:#(?:0a0a0a|000000|000|0a0805)|rgba?\([^)]*\))\s*;/g, "background: transparent;", ); if (transparentContent !== content) await writeProjectFile(compositionFile, transparentContent); } function resolveBlockPlacement({ block, placement, timelineElements, currentTime, }: { block: RegistryItem; placement: AddBlockOptions["placement"]; timelineElements: TimelineElement[]; currentTime: number; }) { const isBlock = block.type === "hyperframes:block"; const { start: placementStart = currentTime, duration: placementDuration, track: placementTrack, } = placement ?? {}; const start = Number(formatTimelineAttributeNumber(placementStart)); const blockDuration = "duration" in block ? (block as { duration: number }).duration : undefined; const duration = placementDuration ?? blockDuration ?? timelineElements.reduce( (max, element) => Math.max(max, (element.start ?? 0) + (element.duration ?? 0)), 10, ); const nextTrack = Math.max(0, ...timelineElements.map((element) => element.track)) + 1; const track = placementTrack ?? (isBlock ? 0 : nextTrack); return { duration, isBlock, start, track }; } function buildSubCompositionHtml({ id, compositionFile, start, duration, track, width, height, left, top, zIndex, }: { id: string; compositionFile: string; start: number; duration: number; track: number; width: number; height: number; left: number; top: number; zIndex: number; }): string { return [ ``, ].join("\n"); } export async function addBlockToProject( opts: AddBlockOptions, ): Promise<{ block: RegistryItem; compositionPath: string } | null> { const { projectId, blockName, activeCompPath, placement, visualPosition, timelineElements, readProjectFile, writeProjectFile, recordEdit, refreshFileTree, reloadPreview, showToast, } = opts; try { const installed = await installRegistryItem({ projectId, blockName, showToast }); if (!installed) return null; const { block, compositionFile } = installed; await makeComponentBackgroundTransparent( block, compositionFile, readProjectFile, writeProjectFile, ); const targetPath = activeCompPath || "index.html"; const originalContent = await readProjectFile(targetPath); const relevantElements = timelineElements.filter( (element) => (element.sourceFile || targetPath) === targetPath, ); const { duration, isBlock, start, track } = resolveBlockPlacement({ block, placement, timelineElements: relevantElements, currentTime: opts.currentTime ?? 0, }); const { width, height } = resolveTimelineAssetCompositionSize(originalContent); const subComposition = buildSubCompositionHtml({ id: buildUniqueCompositionId(block.name, collectHtmlIds(originalContent)), compositionFile, start, duration, track, width, height, left: visualPosition ? Math.round(visualPosition.left) : 0, top: visualPosition ? Math.round(visualPosition.top) : 0, zIndex: getMaxZIndexFromIframe(opts.previewIframe ?? null) + 1, }); const patchedContent = extendRootDurationInSource( insertTimelineAssetIntoSource(originalContent, subComposition), start + duration, ); await saveProjectFilesWithHistory({ projectId, label: `Add ${isBlock ? "block" : "component"}: ${block.title}`, kind: "timeline", files: { [targetPath]: patchedContent }, readFile: async () => originalContent, writeFile: writeProjectFile, recordEdit, }); await refreshFileTree(); reloadPreview(); return { block, compositionPath: compositionFile }; } catch (error) { const message = error instanceof Error ? error.message : "Failed to add block"; showToast(message); return null; } }