mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
Add HeroUI loading skeletons and refine timeline presentation
This commit is contained in:
@@ -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]}
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
Reference in New Issue
Block a user