Add HeroUI loading skeletons and refine timeline presentation

This commit is contained in:
webadderall
2026-09-19 18:31:18 +10:00
parent 3e53a8d838
commit af5cd85fb7
14 changed files with 287 additions and 97 deletions
@@ -0,0 +1,59 @@
import { Card, Skeleton } from "@heroui/react";
/** Keep the editor's proportions visible while project media is being opened. */
export function EditorLoadingSkeleton() {
return (
<div
className="flex h-screen flex-col overflow-hidden bg-editor-bg"
role="status"
aria-label="Loading editor"
aria-busy="true"
>
<span className="sr-only">Loading editor…</span>
<div
aria-hidden="true"
className="flex h-14 shrink-0 items-center justify-between px-5"
>
<Skeleton className="h-7 w-24 rounded-lg" />
<Skeleton className="h-4 w-36" />
<Skeleton className="h-8 w-24 rounded-full" />
</div>
<div aria-hidden="true" className="flex min-h-0 flex-1 pt-3">
<div className="flex w-16 shrink-0 flex-col items-center gap-3 py-2.5">
{Array.from({ length: 6 }, (_, i) => (
<Skeleton key={i} className="size-8 rounded-lg" />
))}
</div>
<Card className="mb-3 min-h-0 w-[320px] shrink-0 gap-7 overflow-hidden p-5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-full rounded-full" />
<div className="grid grid-cols-3 gap-2">
{Array.from({ length: 6 }, (_, i) => (
<Skeleton key={i} className="aspect-square rounded-lg" />
))}
</div>
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="space-y-4">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-2 w-full rounded-full" />
</div>
))}
</Card>
<div className="flex min-w-0 flex-1 flex-col gap-4 px-6 py-3">
<Skeleton className="min-h-0 w-full flex-1 rounded-xl" />
<Skeleton className="mx-auto h-8 w-48 shrink-0 rounded-full" />
</div>
</div>
<div
aria-hidden="true"
className="flex shrink-0 flex-col gap-3 px-4 pb-4 pt-2"
style={{ height: "22%", minHeight: 180, maxHeight: 280 }}
>
<Skeleton className="h-7 w-64 rounded-lg" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-[54px] w-4/5 rounded-lg" />
<Skeleton className="h-6 w-1/3 rounded-lg" />
</div>
</div>
);
}
@@ -1,3 +1,4 @@
import { Skeleton } from "@/components/ui/skeleton";
import { Slider } from "@/components/ui/slider";
import {
CaretDown,
@@ -65,6 +66,7 @@ type Props = {
handleSaveAutoCaptionEdit: (target: CaptionEditTarget, text: string) => void;
handleSelectAnnotation: (id: string | null) => void;
setDuration: Dispatch<SetStateAction<number>>;
isPreviewReady: boolean;
setIsPreviewReady: Dispatch<SetStateAction<boolean>>;
setCurrentTime: Dispatch<SetStateAction<number>>;
setIsPlaying: Dispatch<SetStateAction<boolean>>;
@@ -107,6 +109,7 @@ export function EditorPreviewPanel(props: Props) {
handleSaveAutoCaptionEdit,
handleSelectAnnotation,
setDuration,
isPreviewReady,
setIsPreviewReady,
setCurrentTime,
setIsPlaying,
@@ -179,6 +182,12 @@ export function EditorPreviewPanel(props: Props) {
boxSizing: "border-box",
}}
>
{videoPath && !isPreviewReady && (
<Skeleton
aria-label="Loading preview"
className="pointer-events-none absolute inset-0 z-20 h-full w-full rounded-xl"
/>
)}
<EditorVideoPreview
videoPath={videoPath}
previewVersion={previewVersion}
@@ -17,6 +17,7 @@ import type { useProjectState } from "../state/useProjectState";
import type { useTimelineState } from "../state/useTimelineState";
import { CropEditorDialog } from "./CropEditorDialog";
import { EditorDialogs } from "./EditorDialogs";
import { EditorLoadingSkeleton } from "./EditorLoadingSkeleton";
import { EditorHeader } from "./EditorHeader";
import { EditorPreviewPanel } from "./EditorPreviewPanel";
import { EditorSidebar } from "./EditorSidebar";
@@ -120,11 +121,11 @@ export function EditorShell(props: Props) {
);
if (project.loading)
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-foreground">Loading video...</div>
<>
<EditorLoadingSkeleton />
{editorDialogs}
<Toaster className="pointer-events-auto" />
</div>
</>
);
if (project.error)
return (
@@ -217,6 +218,7 @@ export function EditorShell(props: Props) {
handleSaveAutoCaptionEdit={autoCaption.handleSaveAutoCaptionEdit}
handleSelectAnnotation={handleSelectAnnotation}
setDuration={ui.setDuration}
isPreviewReady={ui.isPreviewReady}
setIsPreviewReady={ui.setIsPreviewReady}
setCurrentTime={ui.setCurrentTime}
setIsPlaying={ui.setIsPlaying}
@@ -60,15 +60,15 @@ export function EditorSidebar({ t, activeSection, setActiveSection, settingsPane
[t],
);
return (
<div className="flex min-h-0 shrink-0 gap-2 pb-3 pr-2">
<div className="flex min-h-0 shrink-0 pb-3 pr-2">
<nav
aria-label={t("settings.sections.title", "Editor tools")}
className="flex w-14 flex-col items-center gap-3 py-2.5"
className="flex w-16 shrink-0 flex-col items-center gap-3 py-2.5"
>
<ToggleButtonGroup
orientation="vertical"
isDetached
className="gap-2"
className="w-full items-center gap-2"
selectionMode="single"
disallowEmptySelection
selectedKeys={[activeSection]}
+55 -50
View File
@@ -1,5 +1,4 @@
import {
FilmSlate as Film,
Gauge,
ChatCircle as MessageSquare,
MusicNotes as Music,
@@ -125,7 +124,7 @@ export default function Item({
const clipSpeedLabel = isClip ? formatClipSpeedLabel(speedValue ?? 1) : null;
const glassClass = isZoom
? glassStyles.glassPurple
? glassStyles.glassBlue
: isTrim
? glassStyles.glassRed
: isClip
@@ -158,6 +157,11 @@ export default function Item({
{...attributes}
data-timeline-item="true"
data-variant={variant}
aria-label={
isClip
? `Clip${clipSpeedLabel ? ` ${clipSpeedLabel}` : ""} · ${timeLabel}`
: undefined
}
onPointerDownCapture={handleSelect}
className="group h-full"
>
@@ -195,12 +199,20 @@ export default function Item({
/>
)}
<div
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
className={cn(
glassStyles.zoomEndCap,
glassStyles.left,
isClip && glassStyles.clipHandle,
)}
style={{ cursor: "col-resize", pointerEvents: "auto" }}
title="Resize left"
/>
<div
className={cn(glassStyles.zoomEndCap, glassStyles.right)}
className={cn(
glassStyles.zoomEndCap,
glassStyles.right,
isClip && glassStyles.clipHandle,
)}
style={{ cursor: "col-resize", pointerEvents: "auto" }}
title="Resize right"
/>
@@ -220,54 +232,47 @@ export default function Item({
<SpeakerX className="w-3 h-3 text-red-300/90 shrink-0" />
</div>
)}
{/* Content */}
<div
title={`${isZoom ? `${ZOOM_LABELS[zoomDepth]} ${zoomMode === "manual" ? "Manual" : "Auto"}` : typeof children === "string" ? children : "Clip"} · ${timeLabel}`}
className={cn(
"relative z-10 flex max-w-full items-center justify-center gap-1 px-1 text-[11px] font-medium text-black/70 dark:text-white/90 select-none overflow-hidden",
isClip && "rounded bg-black/65 px-2 py-1 text-white dark:text-white",
)}
>
{isZoom ? (
<>
<ZoomIn className="zoom-icon size-3 shrink-0" />
<span className="zoom-value whitespace-nowrap">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
<span className="zoom-mode ml-1 font-normal">
{zoomMode === "manual" ? "Manual" : "Auto"}
{/* Normal-speed clips show only the filmstrip and resize handles. */}
{(!isClip || clipSpeedLabel) && (
<div
title={`${isZoom ? `${ZOOM_LABELS[zoomDepth]} ${zoomMode === "manual" ? "Manual" : "Auto"}` : typeof children === "string" ? children : "Clip"} · ${timeLabel}`}
className={cn(
"relative z-10 flex max-w-full items-center justify-center gap-1 px-1 text-[11px] font-medium text-black/70 dark:text-white/90 select-none overflow-hidden",
isClip &&
"rounded bg-black/65 px-2 py-1 text-white dark:text-white",
isZoom && "text-white dark:text-white",
)}
>
{isClip ? (
clipSpeedLabel
) : isZoom ? (
<>
<ZoomIn className="zoom-icon size-3 shrink-0" />
<span className="zoom-value whitespace-nowrap">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
<span className="zoom-mode ml-1 font-normal">
{zoomMode === "manual" ? "Manual" : "Auto"}
</span>
</span>
</span>
</>
) : (
<>
{isClip ? (
<Film className="size-3 shrink-0" />
) : isTrim ? (
<Scissors className="size-3 shrink-0" />
) : isSpeed ? (
<Gauge className="size-3 shrink-0" />
) : isAudio ? (
<Music className="size-3 shrink-0" />
) : (
<MessageSquare className="size-3 shrink-0" />
)}
<span className="truncate">
{isClip
? "Clip"
: isTrim
? "Trim"
: isSpeed
? `${speedValue}×`
: children}
</span>
{clipSpeedLabel && (
<span className="shrink-0 text-[10px] tabular-nums">
{clipSpeedLabel}
</>
) : (
<>
{isTrim ? (
<Scissors className="size-3 shrink-0" />
) : isSpeed ? (
<Gauge className="size-3 shrink-0" />
) : isAudio ? (
<Music className="size-3 shrink-0" />
) : (
<MessageSquare className="size-3 shrink-0" />
)}
<span className="truncate">
{isTrim ? "Trim" : isSpeed ? `${speedValue}×` : children}
</span>
)}
</>
)}
</div>
</>
)}
</div>
)}
</div>
</div>
</div>
@@ -86,25 +86,20 @@
z-index: 10;
}
.glassPurple {
.glassBlue {
position: relative;
border-radius: 8px;
background: #4a2a69;
border: 1px solid #5c3385;
transition:
background 0.15s ease,
border-color 0.15s ease;
background: var(--accent);
border: 1px solid transparent;
transition: background 0.15s ease;
}
.glassPurple:hover {
background: #593180;
border-color: #733da7;
.glassBlue:hover {
background: color-mix(in srgb, var(--accent) 88%, white);
}
.glassPurple.selected {
background: #643690;
border-color: #a855f7;
box-shadow: inset 0 0 0 1.5px #a855f7;
.glassBlue.selected {
border-color: color-mix(in srgb, var(--accent) 60%, white);
z-index: 10;
}
@@ -252,20 +247,6 @@
box-shadow: inset 0 0 0 1.5px #f59e0b;
}
:global(:root:not(.dark)) .glassPurple {
background: linear-gradient(180deg, #f3e8ff 0%, #d8b4fe 100%);
border-color: #c084fc;
}
:global(:root:not(.dark)) .glassPurple:hover {
background: linear-gradient(180deg, #e9d5ff 0%, #c084fc 100%);
border-color: #a855f7;
}
:global(:root:not(.dark)) .glassPurple.selected {
background: linear-gradient(180deg, #d8b4fe 0%, #a855f7 100%);
border-color: #a855f7;
box-shadow: inset 0 0 0 1.5px #a855f7;
}
:global(:root:not(.dark)) .glassCyan {
background: linear-gradient(180deg, #dbeafe 0%, #93c5fd 100%);
border-color: #60a5fa;
@@ -323,7 +304,7 @@
.glassRed:hover .zoomEndCap,
.glassYellow:hover .zoomEndCap,
.glassAmber:hover .zoomEndCap,
.glassPurple:hover .zoomEndCap,
.glassBlue:hover .zoomEndCap,
.glassCyan:hover .zoomEndCap,
.glassDarkGreen:hover .zoomEndCap,
.glassCaption:hover .zoomEndCap {
@@ -345,3 +326,11 @@
.zoomEndCap::after {
content: none;
}
/* Clip handles remain legible over both light and dark source frames. */
.zoomEndCap.clipHandle,
:global(:root:not(.dark)) .zoomEndCap.clipHandle {
opacity: 1;
background: white;
box-shadow: 0 0 2px rgb(0 0 0 / 45%);
}
+6 -2
View File
@@ -1,8 +1,10 @@
import type { RowDefinition } from "dnd-timeline";
import { useRow } from "dnd-timeline";
import { TIMELINE_CLIP_ROW_HEIGHT_PX } from "./timelineLayout";
interface RowProps extends RowDefinition {
compact?: boolean;
filmstrip?: boolean;
children: React.ReactNode;
label?: string;
hint?: string;
@@ -18,6 +20,7 @@ interface RowProps extends RowDefinition {
export default function Row({
id,
compact = false,
filmstrip = false,
children,
label,
hint,
@@ -37,8 +40,9 @@ export default function Row({
style={{
...rowWrapperStyle,
marginBottom: 2,
flexGrow: compact ? 0 : 1,
flexBasis: compact ? 32 : undefined,
flexGrow: compact || filmstrip ? 0 : 1,
flexShrink: 0,
flexBasis: filmstrip ? TIMELINE_CLIP_ROW_HEIGHT_PX : compact ? 32 : undefined,
}}
>
{label && (
@@ -1,3 +1,4 @@
import { Skeleton } from "@/components/ui/skeleton";
import { useTimelineContext, type Span } from "dnd-timeline";
import { useEffect, useRef, useState } from "react";
import { filmstripSampleTimes } from "../../core/filmstrip";
@@ -16,6 +17,7 @@ export function ClipFilmstrip({
const ref = useRef<HTMLDivElement>(null);
const [count, setCount] = useState(0);
const [frames, setFrames] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const node = ref.current;
if (!node) return;
@@ -41,6 +43,7 @@ export function ClipFilmstrip({
{ start, end },
count,
);
setLoading(times.length > 0);
if (times.length) {
void extractFilmstrip(path, times, controller.signal)
.then((images) => {
@@ -48,6 +51,9 @@ export function ClipFilmstrip({
})
.catch(() => {
/* Keep the clip's color when a source cannot be decoded. */
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
}
return () => controller.abort();
@@ -59,6 +65,7 @@ export function ClipFilmstrip({
className="pointer-events-none absolute inset-0 flex overflow-hidden"
aria-hidden="true"
>
{loading && <Skeleton className="h-full w-full rounded-none" />}
{frames.map((frame, index) => (
<img
key={index}
@@ -538,7 +538,7 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
return (
<>
<Row id={CLIP_ROW_ID} isEmpty={clipItems.length === 0} hint={HINT_CLIP}>
<Row filmstrip id={CLIP_ROW_ID} isEmpty={clipItems.length === 0} hint={HINT_CLIP}>
{clipItems.map((item) => (
<Item
id={item.id}
@@ -616,7 +616,7 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
>
<div
className={cn(
glassStyles.glassPurple,
glassStyles.glassBlue,
"w-full h-full overflow-hidden flex items-center justify-center cursor-default relative opacity-80",
)}
>
@@ -3,6 +3,7 @@ import {
getTimelineContentMinHeightPx,
getTimelineRowsMinHeightPx,
getTimelineViewportStretchFactor,
TIMELINE_CLIP_ROW_HEIGHT_PX,
TIMELINE_AXIS_HEIGHT_PX,
TIMELINE_ROW_MIN_HEIGHT_PX,
TIMELINE_VISIBLE_ROW_COUNT,
@@ -10,9 +11,14 @@ import {
describe("timelineLayout", () => {
it("reserves vertical space for every rendered timeline row", () => {
expect(getTimelineRowsMinHeightPx(5)).toBe(5 * TIMELINE_ROW_MIN_HEIGHT_PX);
expect(getTimelineRowsMinHeightPx(5)).toBe(
TIMELINE_CLIP_ROW_HEIGHT_PX + 4 * TIMELINE_ROW_MIN_HEIGHT_PX + 10,
);
expect(getTimelineContentMinHeightPx(5)).toBe(
TIMELINE_AXIS_HEIGHT_PX + 5 * TIMELINE_ROW_MIN_HEIGHT_PX,
TIMELINE_AXIS_HEIGHT_PX +
TIMELINE_CLIP_ROW_HEIGHT_PX +
4 * TIMELINE_ROW_MIN_HEIGHT_PX +
10,
);
});
@@ -25,9 +31,11 @@ describe("timelineLayout", () => {
});
it("floors fractional row counts", () => {
expect(getTimelineRowsMinHeightPx(2.9)).toBe(2 * TIMELINE_ROW_MIN_HEIGHT_PX);
expect(getTimelineRowsMinHeightPx(2.9)).toBe(
TIMELINE_CLIP_ROW_HEIGHT_PX + TIMELINE_ROW_MIN_HEIGHT_PX + 4,
);
expect(getTimelineContentMinHeightPx(2.9)).toBe(
TIMELINE_AXIS_HEIGHT_PX + 2 * TIMELINE_ROW_MIN_HEIGHT_PX,
TIMELINE_AXIS_HEIGHT_PX + TIMELINE_CLIP_ROW_HEIGHT_PX + TIMELINE_ROW_MIN_HEIGHT_PX + 4,
);
});
@@ -1,5 +1,6 @@
export const TIMELINE_AXIS_HEIGHT_PX = 32;
export const TIMELINE_ROW_MIN_HEIGHT_PX = 28;
export const TIMELINE_ROW_MIN_HEIGHT_PX = 32;
export const TIMELINE_CLIP_ROW_HEIGHT_PX = TIMELINE_ROW_MIN_HEIGHT_PX * 2;
export const TIMELINE_VISIBLE_ROW_COUNT = 3;
function normalizeRowCount(rowCount: number) {
@@ -11,7 +12,11 @@ function normalizeRowCount(rowCount: number) {
}
export function getTimelineRowsMinHeightPx(rowCount: number) {
return normalizeRowCount(rowCount) * TIMELINE_ROW_MIN_HEIGHT_PX;
const count = normalizeRowCount(rowCount);
// The first lane is a double-height filmstrip; reserve its extra space.
return count
? TIMELINE_CLIP_ROW_HEIGHT_PX + (count - 1) * TIMELINE_ROW_MIN_HEIGHT_PX + count * 2
: 0;
}
export function getTimelineContentMinHeightPx(rowCount: number) {
+4 -2
View File
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
test("selecting an untouched clip does not introduce a leading gap", async ({ page }) => {
test.setTimeout(60000);
test.setTimeout(120000);
await installDesktopBridge(page);
await page.addInitScript(() => {
window.electronAPI.onMenuSaveProject = (callback) => {
@@ -46,7 +46,9 @@ test("selecting an untouched clip does not introduce a leading gap", async ({ pa
window.dispatchEvent(new Event("test-save-project"));
});
await expect
.poll(() => page.evaluate(() => sessionStorage.getItem("test-saved-clips")))
.poll(() => page.evaluate(() => sessionStorage.getItem("test-saved-clips")), {
timeout: 20000,
})
.not.toBeNull();
const clips = await page.evaluate(() =>
JSON.parse(sessionStorage.getItem("test-saved-clips")!),
+1 -1
View File
@@ -1,7 +1,7 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
test("editor loads video, switches tools and edits export options", async ({ page }) => {
test.setTimeout(60000);
test.setTimeout(120000);
const errors: string[] = [];
page.on("pageerror", (e) => {
errors.push(e.message);
+100
View File
@@ -0,0 +1,100 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
test("editor uses skeletons until media opens", async ({ page }) => {
test.setTimeout(60000);
let release!: () => void;
let releaseMedia!: () => void;
const mediaPending = new Promise<void>((resolve) => {
releaseMedia = resolve;
});
await page.route("**/tests/ui/fixtures/preview.mp4", async (route) => {
await mediaPending;
await route.continue();
});
const pending = new Promise<void>((resolve) => {
release = resolve;
});
await page.exposeFunction("waitForTestMedia", () => pending);
await installDesktopBridge(page);
await page.addInitScript(() => {
window.electronAPI.getCurrentVideoPath = async () => {
await (
window as unknown as { waitForTestMedia: () => Promise<void> }
).waitForTestMedia();
return { success: true, path: `${location.origin}/tests/ui/fixtures/preview.mp4` };
};
});
await page.goto("/?windowType=editor");
const loading = page.getByRole("status", { name: "Loading editor" });
await expect(loading).toBeVisible({ timeout: 20000 });
await expect(loading.locator(".skeleton").first()).toBeVisible();
await page.screenshot({ path: "test-results/editor-skeleton.png", animations: "disabled" });
release();
await expect(loading).toHaveCount(0);
await expect(page.getByLabel("Loading preview", { exact: true })).toBeVisible();
releaseMedia();
await expect(page.getByLabel("Loading preview", { exact: true })).toHaveCount(0, {
timeout: 20000,
});
await expect(page.locator('[data-variant="clip"]')).toBeVisible();
});
test("filmstrips have white handles, conditional speed badges and centered navigation", async ({
page,
}) => {
test.setTimeout(90000);
await installDesktopBridge(page, "filmstrip.mp4");
await page.goto("/?windowType=editor");
const clip = page.locator('[data-timeline-item][data-variant="clip"]');
await expect(clip.locator("img").first()).toBeVisible({ timeout: 20000 });
await expect(clip).toHaveText("");
await page.mouse.move(1000, 300);
for (const side of ["left", "right"]) {
await expect(clip.getByTitle(`Resize ${side}`)).toHaveCSS("opacity", "1");
await expect(clip.getByTitle(`Resize ${side}`)).toHaveCSS(
"background-color",
"rgb(255, 255, 255)",
);
}
await page.getByRole("button", { name: "Add Zoom (Z)", exact: true }).click();
const zoom = page.locator('[data-variant="zoom"] .timeline-block');
await expect(zoom).toBeVisible();
const clipHeight = (await clip.locator(".timeline-block").boundingBox())!.height;
const zoomHeight = (await zoom.boundingBox())!.height;
expect(Math.abs(clipHeight - zoomHeight * 2)).toBeLessThan(1);
const colors = await zoom.evaluate((node) => {
const probe = document.createElement("span");
probe.style.background = "var(--accent)";
document.body.append(probe);
const accent = getComputedStyle(probe).backgroundColor;
probe.remove();
return [getComputedStyle(node).backgroundColor, accent];
});
expect(colors[0]).toBe(colors[1]);
for (const width of [1440, 1250, 800]) {
await page.setViewportSize({ width, height: 800 });
await expect(clip.locator("img").first()).toBeVisible({ timeout: 20000 });
await page.evaluate(
(dark) => document.documentElement.classList.toggle("dark", dark),
width === 1250,
);
const panel = (await page.locator("aside").boundingBox())!;
const nav = page.getByRole("navigation", { name: "Editor tools" });
for (const control of await nav.locator('button, [role="radio"]').all()) {
const box = (await control.boundingBox())!;
expect(Math.abs(box.x + box.width / 2 - panel.x / 2)).toBeLessThan(1);
}
await page.screenshot({
path: `test-results/clip-rail-${width}.png`,
animations: "disabled",
});
}
await clip.click();
const speed = page.getByRole("slider", { name: "Speed", exact: true });
await speed.focus();
await page.keyboard.press("ArrowRight");
await expect(clip).toHaveText("1.25x");
await page.keyboard.press("ArrowLeft");
await expect(clip).toHaveText("");
});