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] 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 c5dc4cc35..b8e397197 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 d71f2e106..19bb7e9b9 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 02b3ab2cb..51e4c7622 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 b3173785e..0bbb73a8f 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 802c0d589..cc8452241 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