From 1d6ea53db9a51c1962339b8ed28433686a63c3cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 21 May 2026 16:36:50 -0400 Subject: [PATCH 1/9] feat(studio): preserve drop position when dropping blocks onto preview Blocks dragged from the Blocks panel and dropped onto the composition preview now land at the position where they were dropped, instead of always being placed at (0, 0). The preview viewport converts screen coordinates to composition space using the stage element's bounding rect, accounting for zoom and pan. A visual drop indicator (dashed border overlay) appears while dragging over the preview. --- packages/studio/src/App.tsx | 31 +++++ .../src/components/StudioPreviewArea.tsx | 6 + .../studio/src/components/nle/NLELayout.tsx | 6 + .../studio/src/components/nle/NLEPreview.tsx | 16 +++ .../src/components/nle/usePreviewBlockDrop.ts | 109 ++++++++++++++++++ packages/studio/src/utils/blockInstaller.ts | 7 +- 6 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 packages/studio/src/components/nle/usePreviewBlockDrop.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index b65c6f706b..c5dc4cc35f 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -255,6 +255,36 @@ export function StudioApp() { ], ); + const handlePreviewBlockDrop = useCallback( + (blockName: string, position: { left: number; top: number }) => { + if (!projectId) return; + void addBlockToProject({ + projectId, + blockName, + activeCompPath, + visualPosition: position, + 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 clearDomSelectionRef = useRef<() => void>(() => {}); const domEditSelectionBridgeRef = useRef(null); const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise>( @@ -541,6 +571,7 @@ export function StudioApp() { handleTimelineElementDelete={timelineEditing.handleTimelineElementDelete} handleTimelineAssetDrop={timelineEditing.handleTimelineAssetDrop} handleTimelineBlockDrop={handleTimelineBlockDrop} + handlePreviewBlockDrop={handlePreviewBlockDrop} handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop} handleTimelineElementMove={timelineEditing.handleTimelineElementMove} handleTimelineElementResize={timelineEditing.handleTimelineElementResize} diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 00201a0fa5..862bb86512 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -29,6 +29,10 @@ export interface StudioPreviewAreaProps { blockName: string, placement: Pick, ) => Promise | void; + handlePreviewBlockDrop?: ( + blockName: string, + position: { left: number; top: number }, + ) => Promise | void; handleTimelineFileDrop: ( files: File[], placement?: Pick, @@ -53,6 +57,7 @@ export function StudioPreviewArea({ handleTimelineElementDelete, handleTimelineAssetDrop, handleTimelineBlockDrop, + handlePreviewBlockDrop, handleTimelineFileDrop, handleTimelineElementMove, handleTimelineElementResize, @@ -104,6 +109,7 @@ export function StudioPreviewArea({ onDeleteElement={handleTimelineElementDelete} onAssetDrop={handleTimelineAssetDrop} onBlockDrop={handleTimelineBlockDrop} + onPreviewBlockDrop={handlePreviewBlockDrop} onFileDrop={handleTimelineFileDrop} onMoveElement={handleTimelineElementMove} onResizeElement={handleTimelineElementResize} diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index efee6163f1..2a2fe70b91 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -54,6 +54,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 +111,7 @@ export const NLELayout = memo(function NLELayout({ onDeleteElement, onAssetDrop, onBlockDrop, + onPreviewBlockDrop, onMoveElement, onResizeElement, onBlockedEditAttempt, @@ -353,6 +358,7 @@ export const NLELayout = memo(function NLELayout({ portrait={portrait} directUrl={directUrl} suppressLoadingOverlay={hasLoadedOnceRef.current} + onBlockDrop={onPreviewBlockDrop} /> {!isFullscreen && previewOverlay} diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index 7e829d3982..ec3ea2aa27 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -12,6 +12,7 @@ import { type PreviewZoomState, } from "./previewZoom"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; +import { usePreviewBlockDrop } from "./usePreviewBlockDrop"; interface NLEPreviewProps { projectId: string; @@ -21,6 +22,7 @@ interface NLEPreviewProps { portrait?: boolean; directUrl?: string; suppressLoadingOverlay?: boolean; + onBlockDrop?: (blockName: string, position: { left: number; top: number }) => void; } export function getPreviewPlayerKey({ @@ -90,6 +92,7 @@ export const NLEPreview = memo(function NLEPreview({ portrait, directUrl, suppressLoadingOverlay, + onBlockDrop, }: NLEPreviewProps) { const activeKey = getPreviewPlayerKey({ projectId, directUrl }); const viewportRef = useRef(null); @@ -364,6 +367,13 @@ export const NLEPreview = memo(function NLEPreview({ }; }, [applyPan]); + const { + isDragOver: previewDragOver, + handleDragOver: handlePreviewDragOver, + handleDragLeave: handlePreviewDragLeave, + handleDrop: handlePreviewDrop, + } = usePreviewBlockDrop({ portrait, stageRef, onBlockDrop }); + const initial = zoomRef.current; return ( @@ -373,6 +383,9 @@ export const NLEPreview = memo(function NLEPreview({ className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40 bg-neutral-700" tabIndex={0} aria-label="Composition preview" + onDragOver={handlePreviewDragOver} + onDragLeave={handlePreviewDragLeave} + onDrop={handlePreviewDrop} >
+ {previewDragOver && ( +
+ )}
; + 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/utils/blockInstaller.ts b/packages/studio/src/utils/blockInstaller.ts index fb62ce46b0..802c0d589d 100644 --- a/packages/studio/src/utils/blockInstaller.ts +++ b/packages/studio/src/utils/blockInstaller.ts @@ -14,6 +14,7 @@ interface AddBlockOptions { blockName: string; activeCompPath: string | null; placement?: { start: number; track: number }; + visualPosition?: { left: number; top: number }; timelineElements: TimelineElement[]; readProjectFile: (path: string) => Promise; writeProjectFile: (path: string, content: string) => Promise; @@ -44,6 +45,7 @@ export async function addBlockToProject( blockName, activeCompPath, placement, + visualPosition, timelineElements, readProjectFile, writeProjectFile, @@ -133,6 +135,9 @@ export async function addBlockToProject( ? (block as { dimensions: { height: number } }).dimensions.height : hostDims.height; + const left = visualPosition ? Math.round(visualPosition.left) : 0; + const top = visualPosition ? Math.round(visualPosition.top) : 0; + const subCompHtml = `
` + + `style="position: absolute; left: ${left}px; top: ${top}px; width: ${width}px; height: ${height}px; z-index: ${zIndex}">` + `
`; const patchedContent = insertTimelineAssetIntoSource(originalContent, subCompHtml); From 9f9b9f4c068c2197834c335b3e0bc77e73cbd3d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 21 May 2026 17:09:28 -0400 Subject: [PATCH 2/9] feat(studio): improve blocks panel UX - Rename "Blocks" tab to "Catalog" - Replace fullscreen hover popup with inline preview in main area - Fix z-index: newly added blocks/components use max existing z-index + 1 instead of element count, ensuring they appear on top --- packages/studio/src/App.tsx | 4 + .../src/components/StudioLeftSidebar.tsx | 4 + .../src/components/StudioPreviewArea.tsx | 30 +++++ .../src/components/sidebar/BlocksTab.tsx | 120 ++++-------------- .../src/components/sidebar/LeftSidebar.tsx | 8 +- packages/studio/src/utils/blockInstaller.ts | 7 +- 6 files changed, 71 insertions(+), 102 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index c5dc4cc35f..b8e397197e 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); @@ -562,6 +564,7 @@ export function StudioApp() { leftSidebarRef={leftSidebarRef} onSelectComposition={handleSelectComposition} onAddBlock={handleAddBlock} + onPreviewBlock={setBlockPreview} onLint={handleLint} linting={linting} /> @@ -579,6 +582,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} />
) => void; setCompositionLoading: (loading: boolean) => void; shouldShowSelectedDomBounds: boolean; + blockPreview?: BlockPreviewInfo | null; } export function StudioPreviewArea({ @@ -65,6 +67,7 @@ export function StudioPreviewArea({ setCompIdToSrc, setCompositionLoading, shouldShowSelectedDomBounds, + blockPreview, }: StudioPreviewAreaProps) { const { projectId, @@ -174,6 +177,33 @@ export function StudioPreviewArea({ timelineVisible={timelineVisible} onToggleTimeline={toggleTimelineVisibility} /> + {blockPreview && ( +
+
+
+ {blockPreview.videoUrl ? ( +
+
+
{blockPreview.title}
+
+
+
+ )}
); } 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 802c0d589d..cc84522411 100644 --- a/packages/studio/src/utils/blockInstaller.ts +++ b/packages/studio/src/utils/blockInstaller.ts @@ -126,7 +126,12 @@ export async function addBlockToProject( ? Math.max(...relevantElements.map((te) => te.track)) + 1 : 1); - const zIndex = Math.max(1, relevantElements.length + 1); + const zIndexMatches = originalContent.matchAll(/z-index:\s*(\d+)/g); + let maxExistingZ = 0; + for (const m of zIndexMatches) { + maxExistingZ = Math.max(maxExistingZ, parseInt(m[1]!, 10)); + } + const zIndex = maxExistingZ + 1; const width = isBlock ? (block as { dimensions: { width: number } }).dimensions.width From f51d324ff5a8f3073f3bd19446edd3eb599699de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 21 May 2026 17:18:55 -0400 Subject: [PATCH 3/9] fix(studio): show block preview directly in the player area Render the catalog hover preview as a full-bleed video inside the preview overlay slot instead of a dimmed card overlay on top. --- .../src/components/StudioPreviewArea.tsx | 48 ++++++++----------- 1 file changed, 20 insertions(+), 28 deletions(-) diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index ff4511de1d..1c14402bc5 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -132,7 +132,26 @@ export function StudioPreviewArea({ }} onIframeRef={handlePreviewIframeRef} previewOverlay={ - captionEditMode ? ( + blockPreview ? ( +
+ {blockPreview.videoUrl ? ( +
+ ) : captionEditMode ? ( ) : STUDIO_INSPECTOR_PANELS_ENABLED ? ( - {blockPreview && ( -
-
-
- {blockPreview.videoUrl ? ( -
-
-
{blockPreview.title}
-
-
-
- )} ); } From 289aa03499e4fa7c1187f89a39991afc0087f045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 21 May 2026 16:38:17 -0400 Subject: [PATCH 4/9] fix(studio): inject runtime env overrides for pre-built SPA mode VITE_STUDIO_* env vars set in the user's shell had no effect when running `hyperframes preview` because the pre-built studio bundle had them baked at Vite build time. The embedded Hono server now collects VITE_STUDIO_* vars from process.env and injects them as a `window.__HF_STUDIO_ENV__` script tag into index.html. The client merges this runtime object on top of the baked `import.meta.env`, so flags like VITE_STUDIO_ENABLE_BLOCKS_PANEL=1 work as expected at runtime. --- packages/cli/src/server/studioServer.ts | 23 ++++++++++++++++++- .../editor/manualEditingAvailability.ts | 11 ++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) 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/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, From 0999ed56e6dc017f84cfb9cd0d5ec91491282d38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 21 May 2026 17:39:24 -0400 Subject: [PATCH 5/9] fix(studio): lift block drop handling above DomEditOverlay The DomEditOverlay sits at z-10 with pointer-events:auto over the preview, intercepting all drag events before they reach NLEPreview's viewport. Move block drop handling from NLEPreview up to the wrapper div in NLELayout that contains both the preview and the overlay, so drag-and-drop from the Catalog panel onto the preview area works regardless of inspector state. --- .../studio/src/components/nle/NLELayout.tsx | 30 +++++++++++++++++-- .../studio/src/components/nle/NLEPreview.tsx | 22 ++++---------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 2a2fe70b91..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, @@ -136,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 @@ -349,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 ec3ea2aa27..7f2d7e543c 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -12,8 +12,6 @@ import { type PreviewZoomState, } from "./previewZoom"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; -import { usePreviewBlockDrop } from "./usePreviewBlockDrop"; - interface NLEPreviewProps { projectId: string; iframeRef: Ref; @@ -22,7 +20,7 @@ interface NLEPreviewProps { portrait?: boolean; directUrl?: string; suppressLoadingOverlay?: boolean; - onBlockDrop?: (blockName: string, position: { left: number; top: number }) => void; + onStageRef?: (ref: React.RefObject) => void; } export function getPreviewPlayerKey({ @@ -92,11 +90,14 @@ export const NLEPreview = memo(function NLEPreview({ portrait, directUrl, suppressLoadingOverlay, - onBlockDrop, + 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()); @@ -367,13 +368,6 @@ export const NLEPreview = memo(function NLEPreview({ }; }, [applyPan]); - const { - isDragOver: previewDragOver, - handleDragOver: handlePreviewDragOver, - handleDragLeave: handlePreviewDragLeave, - handleDrop: handlePreviewDrop, - } = usePreviewBlockDrop({ portrait, stageRef, onBlockDrop }); - const initial = zoomRef.current; return ( @@ -383,9 +377,6 @@ export const NLEPreview = memo(function NLEPreview({ className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40 bg-neutral-700" tabIndex={0} aria-label="Composition preview" - onDragOver={handlePreviewDragOver} - onDragLeave={handlePreviewDragLeave} - onDrop={handlePreviewDrop} >
- {previewDragOver && ( -
- )}
Date: Thu, 21 May 2026 17:57:52 -0400 Subject: [PATCH 6/9] fix(studio): read z-index from live iframe DOM, rename Layer to Z-index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace fragile regex z-index parsing with getComputedStyle on the preview iframe elements — the same source of truth the inspector uses. Rename "Layer" to "Z-index" in the design panel for clarity. --- packages/studio/src/App.tsx | 3 +++ .../src/components/editor/PropertyPanel.tsx | 2 +- packages/studio/src/utils/blockInstaller.ts | 23 ++++++++++++++----- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index b8e397197e..4f191dcb62 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -192,6 +192,7 @@ export function StudioApp() { projectId, blockName, activeCompPath, + previewIframe: previewIframeRef.current, timelineElements, readProjectFile: fileManager.readProjectFile, writeProjectFile: fileManager.writeProjectFile, @@ -235,6 +236,7 @@ export function StudioApp() { blockName, activeCompPath, placement, + previewIframe: previewIframeRef.current, timelineElements, readProjectFile: fileManager.readProjectFile, writeProjectFile: fileManager.writeProjectFile, @@ -265,6 +267,7 @@ export function StudioApp() { blockName, activeCompPath, visualPosition: position, + previewIframe: previewIframeRef.current, timelineElements, readProjectFile: fileManager.readProjectFile, writeProjectFile: fileManager.writeProjectFile, diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 96a2208625..d40ab09743 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -323,7 +323,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onSetStyle("z-index", next)} diff --git a/packages/studio/src/utils/blockInstaller.ts b/packages/studio/src/utils/blockInstaller.ts index cc84522411..816c9faaff 100644 --- a/packages/studio/src/utils/blockInstaller.ts +++ b/packages/studio/src/utils/blockInstaller.ts @@ -9,12 +9,28 @@ 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; timelineElements: TimelineElement[]; readProjectFile: (path: string) => Promise; writeProjectFile: (path: string, content: string) => Promise; @@ -126,12 +142,7 @@ export async function addBlockToProject( ? Math.max(...relevantElements.map((te) => te.track)) + 1 : 1); - const zIndexMatches = originalContent.matchAll(/z-index:\s*(\d+)/g); - let maxExistingZ = 0; - for (const m of zIndexMatches) { - maxExistingZ = Math.max(maxExistingZ, parseInt(m[1]!, 10)); - } - const zIndex = maxExistingZ + 1; + const zIndex = getMaxZIndexFromIframe(opts.previewIframe ?? null) + 1; const width = isBlock ? (block as { dimensions: { width: number } }).dimensions.width From 997e3948e4a36469002f55529ba0e207c65f6ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 21 May 2026 18:04:54 -0400 Subject: [PATCH 7/9] fix(studio): use host composition dimensions for all added blocks Blocks were using their own native dimensions (e.g. 1920x1080) instead of the host composition dimensions (e.g. 1280x720), causing them to overflow the viewport and break the layout. The block's iframe scales its content to fit the container, so using host dimensions is correct. --- packages/studio/src/utils/blockInstaller.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/studio/src/utils/blockInstaller.ts b/packages/studio/src/utils/blockInstaller.ts index 816c9faaff..660285c79c 100644 --- a/packages/studio/src/utils/blockInstaller.ts +++ b/packages/studio/src/utils/blockInstaller.ts @@ -144,12 +144,8 @@ export async function addBlockToProject( const zIndex = getMaxZIndexFromIframe(opts.previewIframe ?? null) + 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 width = hostDims.width; + const height = hostDims.height; const left = visualPosition ? Math.round(visualPosition.left) : 0; const top = visualPosition ? Math.round(visualPosition.top) : 0; From 6f9b655fff2f40c558ce533f6cf53f3ebc82655f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 21 May 2026 18:09:16 -0400 Subject: [PATCH 8/9] fix(studio): format inserted block HTML with proper indentation insertTimelineAssetIntoSource now detects the parent indent level and adds the new element with matching child indentation. Block attributes are written one-per-line for readability. --- packages/studio/src/utils/blockInstaller.ts | 21 +++++++++++-------- .../studio/src/utils/timelineAssetDrop.ts | 9 +++++++- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/studio/src/utils/blockInstaller.ts b/packages/studio/src/utils/blockInstaller.ts index 660285c79c..fbcce82d49 100644 --- a/packages/studio/src/utils/blockInstaller.ts +++ b/packages/studio/src/utils/blockInstaller.ts @@ -150,15 +150,18 @@ export async function addBlockToProject( const left = visualPosition ? Math.round(visualPosition.left) : 0; const top = visualPosition ? Math.round(visualPosition.top) : 0; - const subCompHtml = - `
` + - `
`; + const subCompHtml = [ + `
`, + ].join("\n"); const patchedContent = insertTimelineAssetIntoSource(originalContent, subCompHtml); 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)}`; } From 67701df4e9378b98d551a2dce6a1c80b1a65a2b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 21 May 2026 18:25:39 -0400 Subject: [PATCH 9/9] fix(studio): add blocks at current playhead, extend root duration Blocks and components now start at the current playhead position instead of being appended after all existing content. If the new element extends beyond the root composition's data-duration, the root is automatically extended to fit. --- packages/studio/src/App.tsx | 3 ++ packages/studio/src/utils/blockInstaller.ts | 39 ++++++++++++++------- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 4f191dcb62..2e9f68a4a8 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -193,6 +193,7 @@ export function StudioApp() { blockName, activeCompPath, previewIframe: previewIframeRef.current, + currentTime: usePlayerStore.getState().currentTime, timelineElements, readProjectFile: fileManager.readProjectFile, writeProjectFile: fileManager.writeProjectFile, @@ -237,6 +238,7 @@ export function StudioApp() { activeCompPath, placement, previewIframe: previewIframeRef.current, + currentTime: usePlayerStore.getState().currentTime, timelineElements, readProjectFile: fileManager.readProjectFile, writeProjectFile: fileManager.writeProjectFile, @@ -268,6 +270,7 @@ export function StudioApp() { activeCompPath, visualPosition: position, previewIframe: previewIframeRef.current, + currentTime: usePlayerStore.getState().currentTime, timelineElements, readProjectFile: fileManager.readProjectFile, writeProjectFile: fileManager.writeProjectFile, diff --git a/packages/studio/src/utils/blockInstaller.ts b/packages/studio/src/utils/blockInstaller.ts index fbcce82d49..c2ca2bbab9 100644 --- a/packages/studio/src/utils/blockInstaller.ts +++ b/packages/studio/src/utils/blockInstaller.ts @@ -31,6 +31,7 @@ interface AddBlockOptions { 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; @@ -120,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 @@ -163,7 +162,21 @@ export async function addBlockToProject( `>
`, ].join("\n"); - const patchedContent = insertTimelineAssetIntoSource(originalContent, subCompHtml); + 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,