mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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:
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user