diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 63daca28e9..d7165e2c72 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -481,6 +481,22 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { app.get("/icons/*", serveStudioStaticFile); app.get("/favicon.svg", serveStudioStaticFile); + // ── Runtime env injection ─────────────────────────────────────────────── + // When the studio is served as a pre-built SPA, Vite `VITE_STUDIO_*` env + // vars were baked at build time. Collect any such vars from the current + // process.env and inject them as `window.__HF_STUDIO_ENV__` so the client + // can pick them up at runtime, overriding the baked defaults. + function buildRuntimeEnvScript(): string { + const overrides: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith("VITE_STUDIO_") && value !== undefined) { + overrides[key] = value; + } + } + if (Object.keys(overrides).length === 0) return ""; + return ``; + } + // SPA fallback app.get("*", (c) => { const indexPath = resolve(studioDir, "index.html"); @@ -540,7 +556,12 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { 500, ); } - return c.html(readFileSync(indexPath, "utf-8")); + let html = readFileSync(indexPath, "utf-8"); + const envScript = buildRuntimeEnvScript(); + if (envScript) { + html = html.replace("", `${envScript}`); + } + return c.html(html); }); return { app, watcher }; diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index b65c6f706b..2e9f68a4a8 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -12,6 +12,7 @@ import { usePreviewPersistence } from "./hooks/usePreviewPersistence"; import { useTimelineEditing } from "./hooks/useTimelineEditing"; import { addBlockToProject } from "./utils/blockInstaller"; import type { BlockParam } from "@hyperframes/core/registry"; +import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab"; import { useDomEditSession } from "./hooks/useDomEditSession"; import { useAppHotkeys } from "./hooks/useAppHotkeys"; import { useClipboard } from "./hooks/useClipboard"; @@ -80,6 +81,7 @@ export function StudioApp() { params: BlockParam[]; compositionPath: string; } | null>(null); + const [blockPreview, setBlockPreview] = useState(null); const previewIframeRef = useRef(null); const activeCompPathRef = useRef(activeCompPath); @@ -190,6 +192,8 @@ export function StudioApp() { projectId, blockName, activeCompPath, + previewIframe: previewIframeRef.current, + currentTime: usePlayerStore.getState().currentTime, timelineElements, readProjectFile: fileManager.readProjectFile, writeProjectFile: fileManager.writeProjectFile, @@ -233,6 +237,40 @@ export function StudioApp() { blockName, activeCompPath, placement, + previewIframe: previewIframeRef.current, + currentTime: usePlayerStore.getState().currentTime, + timelineElements, + readProjectFile: fileManager.readProjectFile, + writeProjectFile: fileManager.writeProjectFile, + recordEdit: editHistory.recordEdit, + refreshFileTree: fileManager.refreshFileTree, + reloadPreview, + showToast, + }); + }, + [ + projectId, + activeCompPath, + timelineElements, + fileManager.readProjectFile, + fileManager.writeProjectFile, + fileManager.refreshFileTree, + editHistory.recordEdit, + reloadPreview, + showToast, + ], + ); + + const handlePreviewBlockDrop = useCallback( + (blockName: string, position: { left: number; top: number }) => { + if (!projectId) return; + void addBlockToProject({ + projectId, + blockName, + activeCompPath, + visualPosition: position, + previewIframe: previewIframeRef.current, + currentTime: usePlayerStore.getState().currentTime, timelineElements, readProjectFile: fileManager.readProjectFile, writeProjectFile: fileManager.writeProjectFile, @@ -532,6 +570,7 @@ export function StudioApp() { leftSidebarRef={leftSidebarRef} onSelectComposition={handleSelectComposition} onAddBlock={handleAddBlock} + onPreviewBlock={setBlockPreview} onLint={handleLint} linting={linting} /> @@ -541,6 +580,7 @@ export function StudioApp() { handleTimelineElementDelete={timelineEditing.handleTimelineElementDelete} handleTimelineAssetDrop={timelineEditing.handleTimelineAssetDrop} handleTimelineBlockDrop={handleTimelineBlockDrop} + handlePreviewBlockDrop={handlePreviewBlockDrop} handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop} handleTimelineElementMove={timelineEditing.handleTimelineElementMove} handleTimelineElementResize={timelineEditing.handleTimelineElementResize} @@ -548,6 +588,7 @@ export function StudioApp() { setCompIdToSrc={setCompIdToSrc} setCompositionLoading={setCompositionLoading} shouldShowSelectedDomBounds={shouldShowSelectedDomBounds} + blockPreview={blockPreview} /> {!panelLayout.rightCollapsed && ( diff --git a/packages/studio/src/components/StudioLeftSidebar.tsx b/packages/studio/src/components/StudioLeftSidebar.tsx index d71f2e1060..19bb7e9b93 100644 --- a/packages/studio/src/components/StudioLeftSidebar.tsx +++ b/packages/studio/src/components/StudioLeftSidebar.tsx @@ -7,11 +7,13 @@ import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; import { useStudioContext } from "../contexts/StudioContext"; import { useFileManagerContext } from "../contexts/FileManagerContext"; import { getPersistedRenderSettings } from "./renders/renderSettings"; +import type { BlockPreviewInfo } from "./sidebar/BlocksTab"; export interface StudioLeftSidebarProps { leftSidebarRef: RefObject; onSelectComposition: (comp: string) => void; onAddBlock: (blockName: string) => void; + onPreviewBlock?: (preview: BlockPreviewInfo | null) => void; onLint: () => void; linting: boolean; } @@ -21,6 +23,7 @@ export function StudioLeftSidebar({ leftSidebarRef, onSelectComposition, onAddBlock, + onPreviewBlock, onLint, linting, }: StudioLeftSidebarProps) { @@ -128,6 +131,7 @@ export function StudioLeftSidebar({ linting={linting} onToggleCollapse={toggleLeftSidebar} onAddBlock={onAddBlock} + onPreviewBlock={onPreviewBlock} />
, ) => Promise | void; + handlePreviewBlockDrop?: ( + blockName: string, + position: { left: number; top: number }, + ) => Promise | void; handleTimelineFileDrop: ( files: File[], placement?: Pick, @@ -45,6 +50,7 @@ export interface StudioPreviewAreaProps { setCompIdToSrc: (map: Map) => void; setCompositionLoading: (loading: boolean) => void; shouldShowSelectedDomBounds: boolean; + blockPreview?: BlockPreviewInfo | null; } export function StudioPreviewArea({ @@ -53,6 +59,7 @@ export function StudioPreviewArea({ handleTimelineElementDelete, handleTimelineAssetDrop, handleTimelineBlockDrop, + handlePreviewBlockDrop, handleTimelineFileDrop, handleTimelineElementMove, handleTimelineElementResize, @@ -60,6 +67,7 @@ export function StudioPreviewArea({ setCompIdToSrc, setCompositionLoading, shouldShowSelectedDomBounds, + blockPreview, }: StudioPreviewAreaProps) { const { projectId, @@ -104,6 +112,7 @@ export function StudioPreviewArea({ onDeleteElement={handleTimelineElementDelete} onAssetDrop={handleTimelineAssetDrop} onBlockDrop={handleTimelineBlockDrop} + onPreviewBlockDrop={handlePreviewBlockDrop} onFileDrop={handleTimelineFileDrop} onMoveElement={handleTimelineElementMove} onResizeElement={handleTimelineElementResize} @@ -123,7 +132,26 @@ export function StudioPreviewArea({ }} onIframeRef={handlePreviewIframeRef} previewOverlay={ - captionEditMode ? ( + blockPreview ? ( +
+ {blockPreview.videoUrl ? ( +
+ ) : captionEditMode ? ( ) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
onSetStyle("z-index", next)} diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index bd3b59c5b4..c373247304 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -30,7 +30,16 @@ export function resolveStudioBooleanEnvFlag( // and downstream `env[name]` reads would crash. Fall back to `{}` so // every flag resolves to its declared default outside Vite. Direct // property access keeps Vite's compile-time transform happy. -const env = (import.meta.env ?? {}) as StudioFeatureFlagEnv; +// +// When the studio is served as a pre-built SPA by the embedded Hono server, +// `import.meta.env` values were baked at build time. The server injects +// `window.__HF_STUDIO_ENV__` with any `VITE_STUDIO_*` env vars from the +// user's shell, so runtime overrides take precedence over baked defaults. +const runtimeEnv = + typeof window !== "undefined" + ? ((window as Window & { __HF_STUDIO_ENV__?: StudioFeatureFlagEnv }).__HF_STUDIO_ENV__ ?? {}) + : {}; +const env = { ...(import.meta.env ?? {}), ...runtimeEnv } as StudioFeatureFlagEnv; export const STUDIO_PREVIEW_MANUAL_EDITING_ENABLED = resolveStudioBooleanEnvFlag( env, diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index efee6163f1..068603f12a 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -13,6 +13,7 @@ import type { TimelineElement } from "../../player"; import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing"; import { NLEPreview } from "./NLEPreview"; import { CompositionBreadcrumb } from "./CompositionBreadcrumb"; +import { usePreviewBlockDrop } from "./usePreviewBlockDrop"; import { useCompositionStack } from "./useCompositionStack"; import { TIMELINE_TOGGLE_SHORTCUT_LABEL, @@ -54,6 +55,10 @@ interface NLELayoutProps { blockName: string, placement: Pick, ) => Promise | void; + onPreviewBlockDrop?: ( + blockName: string, + position: { left: number; top: number }, + ) => Promise | void; /** Persist timeline move actions back into source HTML */ onMoveElement?: ( element: TimelineElement, @@ -107,6 +112,7 @@ export const NLELayout = memo(function NLELayout({ onDeleteElement, onAssetDrop, onBlockDrop, + onPreviewBlockDrop, onMoveElement, onResizeElement, onBlockedEditAttempt, @@ -131,6 +137,22 @@ export const NLELayout = memo(function NLELayout({ usePlayerStore.getState().reset(); } + const stageRefForDrop = useRef(null); + const handleStageRef = useCallback((ref: React.RefObject) => { + stageRefForDrop.current = ref.current; + }, []); + + const { + isDragOver: previewDragOver, + handleDragOver: handlePreviewDragOver, + handleDragLeave: handlePreviewDragLeave, + handleDrop: handlePreviewDrop, + } = usePreviewBlockDrop({ + portrait, + stageRef: stageRefForDrop as React.RefObject, + onBlockDrop: onPreviewBlockDrop, + }); + // Lightweight reload: change iframe src instead of destroying the Player. // refreshPlayer() saves the seek position and appends a cache-busting _t // param — the Player instance stays alive so the adapter is available for @@ -344,7 +366,13 @@ export const NLELayout = memo(function NLELayout({ > {/* Preview + player controls */}
-
+
+ {previewDragOver && ( +
+ )} {!isFullscreen && previewOverlay}
diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index 7e829d3982..7f2d7e543c 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -12,7 +12,6 @@ import { type PreviewZoomState, } from "./previewZoom"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; - interface NLEPreviewProps { projectId: string; iframeRef: Ref; @@ -21,6 +20,7 @@ interface NLEPreviewProps { portrait?: boolean; directUrl?: string; suppressLoadingOverlay?: boolean; + onStageRef?: (ref: React.RefObject) => void; } export function getPreviewPlayerKey({ @@ -90,10 +90,14 @@ export const NLEPreview = memo(function NLEPreview({ portrait, directUrl, suppressLoadingOverlay, + onStageRef, }: NLEPreviewProps) { const activeKey = getPreviewPlayerKey({ projectId, directUrl }); const viewportRef = useRef(null); const stageRef = useRef(null); + useEffect(() => { + onStageRef?.(stageRef); + }, [onStageRef]); const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, portrait)); const zoomRef = useRef(loadInitialZoom()); diff --git a/packages/studio/src/components/nle/usePreviewBlockDrop.ts b/packages/studio/src/components/nle/usePreviewBlockDrop.ts new file mode 100644 index 0000000000..f1cb714bb5 --- /dev/null +++ b/packages/studio/src/components/nle/usePreviewBlockDrop.ts @@ -0,0 +1,109 @@ +import { useCallback, useState, type RefObject } from "react"; +import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop"; + +interface UsePreviewBlockDropOptions { + portrait?: boolean; + stageRef: RefObject; + onBlockDrop?: (blockName: string, position: { left: number; top: number }) => void; +} + +interface BlockDropPayload { + name: string; + dimensions?: { width: number; height: number }; +} + +function parseBlockPayload(raw: string): BlockDropPayload | null { + try { + const parsed = JSON.parse(raw) as { + name?: string; + dimensions?: { width: number; height: number }; + }; + return parsed.name ? (parsed as BlockDropPayload) : null; + } catch { + return null; + } +} + +function resolveCompositionPosition( + clientX: number, + clientY: number, + stageRect: DOMRect, + portrait: boolean | undefined, +): { left: number; top: number } | null { + if (stageRect.width === 0 || stageRect.height === 0) return null; + + const normalizedX = (clientX - stageRect.left) / stageRect.width; + const normalizedY = (clientY - stageRect.top) / stageRect.height; + + const compWidth = portrait ? 1080 : 1920; + const compHeight = portrait ? 1920 : 1080; + + return { + left: Math.max(0, Math.min(normalizedX * compWidth, compWidth)), + top: Math.max(0, Math.min(normalizedY * compHeight, compHeight)), + }; +} + +function centerBlockAtPosition( + pos: { left: number; top: number }, + block: BlockDropPayload, +): { left: number; top: number } { + const blockW = block.dimensions?.width ?? 0; + const blockH = block.dimensions?.height ?? 0; + return { + left: Math.max(0, pos.left - blockW / 2), + top: Math.max(0, pos.top - blockH / 2), + }; +} + +export function usePreviewBlockDrop({ + portrait, + stageRef, + onBlockDrop, +}: UsePreviewBlockDropOptions) { + const [isDragOver, setIsDragOver] = useState(false); + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + if (!onBlockDrop) return; + if (!e.dataTransfer.types.includes(TIMELINE_BLOCK_MIME)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + setIsDragOver(true); + }, + [onBlockDrop], + ); + + const handleDragLeave = useCallback(() => { + setIsDragOver(false); + }, []); + + // fallow-ignore-next-line complexity + const handleDrop = useCallback( + (e: React.DragEvent) => { + setIsDragOver(false); + if (!onBlockDrop) return; + + const payload = e.dataTransfer.getData(TIMELINE_BLOCK_MIME); + if (!payload) return; + e.preventDefault(); + + const block = parseBlockPayload(payload); + const stage = stageRef.current; + if (!block || !stage) return; + + const pos = resolveCompositionPosition( + e.clientX, + e.clientY, + stage.getBoundingClientRect(), + portrait, + ); + if (!pos) return; + + onBlockDrop(block.name, centerBlockAtPosition(pos, block)); + }, + [onBlockDrop, stageRef, portrait], + ); + + return { isDragOver, handleDragOver, handleDragLeave, handleDrop }; +} diff --git a/packages/studio/src/components/sidebar/BlocksTab.tsx b/packages/studio/src/components/sidebar/BlocksTab.tsx index 02b3ab2cb6..51e4c7622b 100644 --- a/packages/studio/src/components/sidebar/BlocksTab.tsx +++ b/packages/studio/src/components/sidebar/BlocksTab.tsx @@ -1,5 +1,4 @@ import { memo, useState, useCallback, useRef, useEffect } from "react"; -import { createPortal } from "react-dom"; import { useBlockCatalog } from "../../hooks/useBlockCatalog"; import { BLOCK_CATEGORIES, @@ -8,12 +7,19 @@ import { } from "../../utils/blockCategories"; import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop"; +export interface BlockPreviewInfo { + videoUrl?: string; + posterUrl?: string; + title: string; +} + interface BlocksTabProps { onAddBlock: (blockName: string) => void; + onPreviewBlock?: (preview: BlockPreviewInfo | null) => void; } // fallow-ignore-next-line complexity -export const BlocksTab = memo(function BlocksTab({ onAddBlock }: BlocksTabProps) { +export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }: BlocksTabProps) { const { loading, error, search, setSearch, category, setCategory, filteredBlocks } = useBlockCatalog(); @@ -114,6 +120,7 @@ export const BlocksTab = memo(function BlocksTab({ onAddBlock }: BlocksTabProps) videoUrl={block.preview?.video} dimensions={dims} onAdd={() => onAddBlock(block.name)} + onPreview={onPreviewBlock} /> ); })} @@ -163,6 +170,7 @@ function BlockCard({ videoUrl, dimensions, onAdd, + onPreview, }: { name: string; title: string; @@ -173,52 +181,35 @@ function BlockCard({ videoUrl?: string; dimensions?: { width: number; height: number }; onAdd: () => void; + onPreview?: (preview: BlockPreviewInfo | null) => void; }) { const [hovered, setHovered] = useState(false); const [adding, setAdding] = useState(false); const hoverTimer = useRef | null>(null); - const leaveTimer = useRef | null>(null); - const videoRef = useRef(null); const colors = getCategoryColors(category); const needsWebGL = tags?.includes("html-in-canvas") || tags?.includes("webgl"); - const cancelLeave = useCallback(() => { - if (leaveTimer.current) { - clearTimeout(leaveTimer.current); - leaveTimer.current = null; - } - }, []); - const handleEnter = useCallback(() => { - cancelLeave(); - hoverTimer.current = setTimeout(() => setHovered(true), 500); - }, [cancelLeave]); - - const dismiss = useCallback(() => { - if (hoverTimer.current) { - clearTimeout(hoverTimer.current); - hoverTimer.current = null; - } - cancelLeave(); - setHovered(false); - }, [cancelLeave]); + hoverTimer.current = setTimeout(() => { + setHovered(true); + onPreview?.({ videoUrl, posterUrl, title }); + }, 300); + }, [onPreview, videoUrl, posterUrl, title]); const handleLeave = useCallback(() => { if (hoverTimer.current) { clearTimeout(hoverTimer.current); hoverTimer.current = null; } - leaveTimer.current = setTimeout(() => setHovered(false), 150); - }, []); + setHovered(false); + onPreview?.(null); + }, [onPreview]); useEffect(() => { - if (!hovered) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") dismiss(); + return () => { + if (hoverTimer.current) clearTimeout(hoverTimer.current); }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [hovered, dismiss]); + }, []); const handleAdd = useCallback( (e: React.MouseEvent) => { @@ -251,7 +242,6 @@ function BlockCard({
{hovered && videoUrl ? (
- - {/* Fullscreen hover preview */} - {hovered && - (videoUrl || posterUrl) && - createPortal( -
-
- -
e.stopPropagation()} - > -
- {videoUrl ? ( -
-
-
{title}
-
- - - {BLOCK_CATEGORIES.find((c) => c.id === category)?.label} - - {duration != null && ( - {duration}s - )} -
-
-
-
, - document.body, - )}
); } diff --git a/packages/studio/src/components/sidebar/LeftSidebar.tsx b/packages/studio/src/components/sidebar/LeftSidebar.tsx index b3173785e9..0bbb73a8ff 100644 --- a/packages/studio/src/components/sidebar/LeftSidebar.tsx +++ b/packages/studio/src/components/sidebar/LeftSidebar.tsx @@ -10,7 +10,7 @@ import { import { CompositionsTab } from "./CompositionsTab"; import { AssetsTab } from "./AssetsTab"; import { trackStudioEvent } from "../../utils/studioTelemetry"; -import { BlocksTab } from "./BlocksTab"; +import { BlocksTab, type BlockPreviewInfo } from "./BlocksTab"; import { FileTree } from "../editor/FileTree"; import { STUDIO_BLOCKS_PANEL_ENABLED } from "../editor/manualEditingAvailability"; @@ -55,6 +55,7 @@ interface LeftSidebarProps { linting?: boolean; onToggleCollapse?: () => void; onAddBlock?: (blockName: string) => void; + onPreviewBlock?: (preview: BlockPreviewInfo | null) => void; takeoverContent?: ReactNode; } @@ -84,6 +85,7 @@ export const LeftSidebar = memo( linting, onToggleCollapse, onAddBlock, + onPreviewBlock, takeoverContent, }, ref, @@ -165,7 +167,7 @@ export const LeftSidebar = memo( : "text-neutral-500 hover:text-neutral-200" }`} > - Blocks + Catalog )}
@@ -245,7 +247,7 @@ export const LeftSidebar = memo( )} {STUDIO_BLOCKS_PANEL_ENABLED && tab === "blocks" && onAddBlock && ( - + )} {/* Lint button pinned at the bottom */} diff --git a/packages/studio/src/utils/blockInstaller.ts b/packages/studio/src/utils/blockInstaller.ts index fb62ce46b0..c2ca2bbab9 100644 --- a/packages/studio/src/utils/blockInstaller.ts +++ b/packages/studio/src/utils/blockInstaller.ts @@ -9,11 +9,29 @@ import { formatTimelineAttributeNumber } from "../player/components/timelineEdit import { saveProjectFilesWithHistory } from "./studioFileHistory"; import type { EditHistoryKind } from "./editHistory"; +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; track: number }; + visualPosition?: { left: number; top: number }; + previewIframe?: HTMLIFrameElement | null; + currentTime?: number; timelineElements: TimelineElement[]; readProjectFile: (path: string) => Promise; writeProjectFile: (path: string, content: string) => Promise; @@ -44,6 +62,7 @@ export async function addBlockToProject( blockName, activeCompPath, placement, + visualPosition, timelineElements, readProjectFile, writeProjectFile, @@ -102,20 +121,18 @@ export async function addBlockToProject( const isBlock = block.type === "hyperframes:block"; const hostDims = resolveTimelineAssetInitialGeometry(originalContent); + const currentTime = opts.currentTime ?? 0; const start = placement ? Number(formatTimelineAttributeNumber(placement.start)) - : isBlock - ? relevantElements.reduce( - (max, te) => Math.max(max, (te.start ?? 0) + (te.duration ?? 0)), - 0, - ) - : 0; - const duration = isBlock - ? (block as { duration: number }).duration - : relevantElements.reduce( - (max, te) => Math.max(max, (te.start ?? 0) + (te.duration ?? 0)), - 10, - ); + : Number(formatTimelineAttributeNumber(currentTime)); + const blockDuration = + "duration" in block ? (block as { duration: number }).duration : undefined; + const duration = + blockDuration ?? + relevantElements.reduce( + (max, te) => Math.max(max, (te.start ?? 0) + (te.duration ?? 0)), + 10, + ); const track = placement?.track ?? (isBlock @@ -124,26 +141,42 @@ export async function addBlockToProject( ? Math.max(...relevantElements.map((te) => te.track)) + 1 : 1); - const zIndex = Math.max(1, relevantElements.length + 1); - - const width = isBlock - ? (block as { dimensions: { width: number } }).dimensions.width - : hostDims.width; - const height = isBlock - ? (block as { dimensions: { height: number } }).dimensions.height - : hostDims.height; - - const subCompHtml = - `
` + - `
`; - - const patchedContent = insertTimelineAssetIntoSource(originalContent, subCompHtml); + const zIndex = getMaxZIndexFromIframe(opts.previewIframe ?? null) + 1; + + const width = hostDims.width; + const height = hostDims.height; + + const left = visualPosition ? Math.round(visualPosition.left) : 0; + const top = visualPosition ? Math.round(visualPosition.top) : 0; + + const subCompHtml = [ + `
`, + ].join("\n"); + + let patchedContent = insertTimelineAssetIntoSource(originalContent, subCompHtml); + + const newEnd = start + duration; + const rootDurMatch = patchedContent.match( + /(<[^>]*data-composition-id="[^"]*"[^>]*data-duration=")([^"]*)(")/, + ); + if (rootDurMatch) { + const rootDur = parseFloat(rootDurMatch[2]!); + if (newEnd > rootDur) { + patchedContent = patchedContent.replace( + rootDurMatch[0], + `${rootDurMatch[1]}${formatTimelineAttributeNumber(newEnd)}${rootDurMatch[3]}`, + ); + } + } await saveProjectFilesWithHistory({ projectId, diff --git a/packages/studio/src/utils/timelineAssetDrop.ts b/packages/studio/src/utils/timelineAssetDrop.ts index 4857d85f1c..454078151d 100644 --- a/packages/studio/src/utils/timelineAssetDrop.ts +++ b/packages/studio/src/utils/timelineAssetDrop.ts @@ -126,5 +126,12 @@ export function insertTimelineAssetIntoSource(source: string, assetHtml: string) throw new Error("No composition root found in target source"); } const insertAt = match.index + match[0].length; - return `${source.slice(0, insertAt)}${assetHtml}${source.slice(insertAt)}`; + const lineStart = source.lastIndexOf("\n", match.index); + const leadingWhitespace = source.slice(lineStart + 1, match.index).match(/^(\s*)/)?.[1] ?? ""; + const childIndent = leadingWhitespace + " "; + const indented = assetHtml + .split("\n") + .map((line, i) => (i === 0 ? line : childIndent + line)) + .join("\n"); + return `${source.slice(0, insertAt)}\n${childIndent}${indented}${source.slice(insertAt)}`; }