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
This commit is contained in:
Miguel Ángel
2026-05-21 18:27:29 -04:00
parent 1d6ea53db9
commit 9f9b9f4c06
6 changed files with 71 additions and 102 deletions
+4
View File
@@ -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<BlockPreviewInfo | null>(null);
const previewIframeRef = useRef<HTMLIFrameElement | null>(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 && (
@@ -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<LeftSidebarHandle | null>;
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}
/>
<div
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center"
@@ -12,6 +12,7 @@ import {
} from "./editor/manualEditingAvailability";
import { useStudioContext } from "../contexts/StudioContext";
import { useDomEditContext } from "../contexts/DomEditContext";
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
export interface StudioPreviewAreaProps {
timelineToolbar: ReactNode;
@@ -49,6 +50,7 @@ export interface StudioPreviewAreaProps {
setCompIdToSrc: (map: Map<string, string>) => 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 && (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/70 pointer-events-none">
<div className="relative w-[80%] max-w-[900px] rounded-lg overflow-hidden shadow-2xl border border-neutral-700/40">
<div className="aspect-video bg-neutral-950">
{blockPreview.videoUrl ? (
<video
src={blockPreview.videoUrl}
autoPlay
muted
loop
playsInline
className="w-full h-full object-contain"
/>
) : blockPreview.posterUrl ? (
<img
src={blockPreview.posterUrl}
alt={blockPreview.title}
className="w-full h-full object-contain"
/>
) : null}
</div>
<div className="bg-neutral-900/95 px-3 py-2">
<div className="text-[12px] font-medium text-neutral-200">{blockPreview.title}</div>
</div>
</div>
</div>
)}
</div>
);
}
@@ -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<ReturnType<typeof setTimeout> | null>(null);
const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(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({
<div className="aspect-video w-full overflow-hidden relative">
{hovered && videoUrl ? (
<video
ref={videoRef}
src={videoUrl}
autoPlay
muted
@@ -313,72 +303,6 @@ function BlockCard({
</span>
</div>
</div>
{/* Fullscreen hover preview */}
{hovered &&
(videoUrl || posterUrl) &&
createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center cursor-pointer"
onClick={dismiss}
onPointerEnter={cancelLeave}
onPointerLeave={handleLeave}
>
<div className="bg-black/80 absolute inset-0" />
<button
type="button"
onClick={dismiss}
className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-neutral-800/80 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
<div
className="relative rounded-xl overflow-hidden shadow-2xl border border-neutral-600/30 cursor-default"
style={{ width: "80vw", maxWidth: 1200, maxHeight: "80vh" }}
onClick={(e) => e.stopPropagation()}
>
<div className="aspect-video bg-neutral-950">
{videoUrl ? (
<video
src={videoUrl}
autoPlay
muted
loop
playsInline
className="w-full h-full object-contain"
/>
) : (
<img src={posterUrl} alt={title} className="w-full h-full object-contain" />
)}
</div>
<div className="bg-neutral-900/95 px-4 py-3">
<div className="text-[14px] font-semibold text-neutral-100">{title}</div>
<div className="flex items-center gap-2 mt-1">
<span className={`w-2 h-2 rounded-full ${colors.dot}`} />
<span className={`text-[11px] ${colors.text}`}>
{BLOCK_CATEGORIES.find((c) => c.id === category)?.label}
</span>
{duration != null && (
<span className="text-[11px] text-neutral-500">{duration}s</span>
)}
</div>
</div>
</div>
</div>,
document.body,
)}
</div>
);
}
@@ -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
</button>
)}
</div>
@@ -245,7 +247,7 @@ export const LeftSidebar = memo(
)}
{STUDIO_BLOCKS_PANEL_ENABLED && tab === "blocks" && onAddBlock && (
<BlocksTab onAddBlock={onAddBlock} />
<BlocksTab onAddBlock={onAddBlock} onPreviewBlock={onPreviewBlock} />
)}
{/* Lint button pinned at the bottom */}
+6 -1
View File
@@ -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