mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 07:16:02 +00:00
Refine HeroUI editor spacing, cards and responsive alignment
This commit is contained in:
@@ -19,20 +19,24 @@ helper installation. The dependency lockfile belongs to this branch.
|
||||
|
||||
To go back, close the development app and run `npm run dev` from the original
|
||||
`recordly` directory. No reset, stash, or file restoration is needed. You can
|
||||
keep both checkouts while comparing them. If this commit is merged later,
|
||||
`git revert <migration-commit>` reverses the migration without rewriting history.
|
||||
keep both checkouts while comparing them. To undo just the UI refinement pass,
|
||||
use `git revert <refinement-commit>`. To undo the entire migration after merging,
|
||||
revert the branch commits newest-first, including the original `93be193` migration.
|
||||
|
||||
## UI approach
|
||||
|
||||
The controls use HeroUI React 3.2.6 and its default light/dark theme, following
|
||||
[the official component demos](https://heroui.com/en/docs/react/components).
|
||||
This includes buttons, fields, switches, sliders, tabs, toggle groups, radios,
|
||||
This includes buttons, fields, switches, sliders, tabs, tag groups, toggle groups, radios,
|
||||
selects, modals, popovers, menus, tooltips, color pickers, progress indicators,
|
||||
skeletons, and toasts. React 19 and Tailwind 4 satisfy HeroUI v3 requirements.
|
||||
The former Radix, Sonner, and third-party color picker dependencies are removed.
|
||||
|
||||
The editor uses docked surfaces, a fixed-width inspector, aligned toolbars,
|
||||
consistent spacing, and restrained selection colors. Floating layers keep one
|
||||
The editor uses a floating inspector card, an open canvas and timeline, aligned
|
||||
toolbars, consistent spacing, and restrained selection colors. Advanced controls
|
||||
live behind a per-section switch; changing views preserves project values.
|
||||
Background types and other exclusive choices use TagGroup. The header follows
|
||||
native fullscreen state and keeps project titles centered at narrow widths. Floating layers keep one
|
||||
surface instead of nesting cards and shadows. Timeline colors follow the theme
|
||||
and retain the distinction between clip types. The recorder keeps its compact
|
||||
desktop layout.
|
||||
@@ -60,7 +64,8 @@ Browser tests use an explicit mocked Electron bridge and a generated six-second
|
||||
video fixture; they never start a real screen recording. They cover control
|
||||
callbacks and keyboard behavior, modal focus, export settings, presets, cropping,
|
||||
annotation formatting/undo, project menus, recorder popovers, countdown and update
|
||||
windows, theme switching, and a smaller desktop layout. Screenshots and failure
|
||||
windows, theme switching, Advanced state, color editing, and header/playback
|
||||
alignment from 800–1440px with and without macOS window controls. Screenshots and failure
|
||||
traces go to the ignored `test-results/` directory.
|
||||
|
||||
`npm run dev:ui` starts only Vite for browser inspection; the component fixture
|
||||
|
||||
Vendored
+4
@@ -864,6 +864,10 @@ interface Window {
|
||||
onMenuLoadProject: (callback: () => void) => () => void;
|
||||
onMenuSaveProject: (callback: () => void) => () => void;
|
||||
onMenuSaveProjectAs: (callback: () => void) => () => void;
|
||||
getWindowChrome: () => Promise<{ trafficLightsVisible: boolean }>;
|
||||
onWindowChromeChanged: (
|
||||
callback: (chrome: { trafficLightsVisible: boolean }) => void,
|
||||
) => () => void;
|
||||
getPlatform: () => Promise<string>;
|
||||
getLinuxWindowSystem: () => Promise<"wayland" | "x11" | null>;
|
||||
revealInFolder: (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { app, ipcMain } from "electron";
|
||||
import { app, BrowserWindow, ipcMain } from "electron";
|
||||
import { hasAppSetting, readAppSettingsStore, writeAppSettingsStore } from "../../appSettingsStore";
|
||||
import { hideCursor } from "../../cursorHider";
|
||||
import { closeCountdownWindow, createCountdownWindow, getCountdownWindow } from "../../windows";
|
||||
@@ -47,6 +47,17 @@ export function registerSettingsHandlers() {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
ipcMain.handle("get-window-chrome", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
return {
|
||||
trafficLightsVisible:
|
||||
process.platform === "darwin" &&
|
||||
!!win &&
|
||||
!win.isFullScreen() &&
|
||||
!win.isSimpleFullScreen(),
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle("get-platform", () => {
|
||||
return process.platform;
|
||||
});
|
||||
|
||||
@@ -940,6 +940,15 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
ipcRenderer.on("menu-save-project-as", listener);
|
||||
return () => ipcRenderer.removeListener("menu-save-project-as", listener);
|
||||
},
|
||||
getWindowChrome: () => ipcRenderer.invoke("get-window-chrome"),
|
||||
onWindowChromeChanged: (callback: (chrome: { trafficLightsVisible: boolean }) => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
chrome: { trafficLightsVisible: boolean },
|
||||
) => callback(chrome);
|
||||
ipcRenderer.on("window-chrome-changed", listener);
|
||||
return () => ipcRenderer.removeListener("window-chrome-changed", listener);
|
||||
},
|
||||
getPlatform: () => {
|
||||
return ipcRenderer.invoke("get-platform");
|
||||
},
|
||||
|
||||
+12
-1
@@ -919,7 +919,7 @@ export function createEditorWindow(): BrowserWindow {
|
||||
}),
|
||||
...(isMac && {
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
trafficLightPosition: { x: 16, y: 20 },
|
||||
}),
|
||||
autoHideMenuBar: !isMac,
|
||||
transparent: false,
|
||||
@@ -938,6 +938,17 @@ export function createEditorWindow(): BrowserWindow {
|
||||
},
|
||||
});
|
||||
|
||||
const publishWindowChrome = () => {
|
||||
if (!win.isDestroyed())
|
||||
win.webContents.send("window-chrome-changed", {
|
||||
trafficLightsVisible: isMac && !win.isFullScreen() && !win.isSimpleFullScreen(),
|
||||
});
|
||||
};
|
||||
win.on("enter-full-screen", publishWindowChrome);
|
||||
win.on("leave-full-screen", publishWindowChrome);
|
||||
win.on("resize", publishWindowChrome);
|
||||
win.webContents.on("did-finish-load", publishWindowChrome);
|
||||
|
||||
win.once("ready-to-show", () => {
|
||||
console.log(`[PERF:MAIN] Editor Window: ready-to-show in ${Date.now() - perfStart}ms`);
|
||||
win.show();
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Tag, TagGroup } from "@heroui/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
/** Mutually exclusive settings, with HeroUI's keyboard-accessible tag selection. */
|
||||
export function ChoiceGroup({
|
||||
value,
|
||||
onValueChange,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
type?: "single";
|
||||
size?: "sm" | "md" | "lg";
|
||||
fullWidth?: boolean;
|
||||
"aria-label"?: string;
|
||||
}) {
|
||||
return (
|
||||
<TagGroup
|
||||
size="lg"
|
||||
aria-label={props["aria-label"]}
|
||||
selectionMode="single"
|
||||
disallowEmptySelection
|
||||
selectedKeys={value ? [value] : []}
|
||||
onSelectionChange={(keys) => {
|
||||
if (keys !== "all") {
|
||||
const key = Array.from(keys)[0];
|
||||
if (key !== undefined) onValueChange?.(String(key));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TagGroup.List className={className ?? "flex flex-wrap gap-2"}>
|
||||
{children}
|
||||
</TagGroup.List>
|
||||
</TagGroup>
|
||||
);
|
||||
}
|
||||
export function ChoiceItem({
|
||||
value,
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof Tag>, "id"> & {
|
||||
value: string;
|
||||
title?: string;
|
||||
"aria-label"?: string;
|
||||
}) {
|
||||
return (
|
||||
<Tag
|
||||
{...props}
|
||||
id={value}
|
||||
textValue={
|
||||
props.textValue ??
|
||||
title ??
|
||||
(typeof children === "string" ? children : props["aria-label"])
|
||||
}
|
||||
className={`min-h-9 justify-center ${className ?? ""}`}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -32,13 +32,6 @@ export function ColorPalette({ color = "#000000", colors, onChange }: PalettePro
|
||||
</ColorSwatchPicker.Item>
|
||||
))}
|
||||
</ColorSwatchPicker>
|
||||
<ColorField
|
||||
aria-label="Hex color"
|
||||
value={color}
|
||||
onChange={(value) => value && onChange({ hex: value.toString("hex") })}
|
||||
>
|
||||
<Input />
|
||||
</ColorField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,19 +39,34 @@ export function ColorControl({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
colors,
|
||||
compact = false,
|
||||
onClear,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
colors?: readonly string[];
|
||||
compact?: boolean;
|
||||
onClear?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ColorPicker value={value} onChange={(color) => onChange(color.toString("hex"))}>
|
||||
<Button variant="secondary" aria-label={label}>
|
||||
<ColorPicker
|
||||
value={value === "transparent" ? "#00000000" : value}
|
||||
onChange={(color) => onChange(color.toString("hex"))}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label={label}
|
||||
className="h-10 min-w-0 max-w-full gap-2 px-3"
|
||||
>
|
||||
<ColorSwatch size="sm" />
|
||||
{label}
|
||||
<span className="truncate">
|
||||
{compact ? (value === "transparent" ? "None" : value.toUpperCase()) : label}
|
||||
</span>
|
||||
</Button>
|
||||
<ColorPicker.Popover>
|
||||
<Popover.Dialog aria-label={label} className="flex w-64 flex-col gap-3">
|
||||
<Popover.Dialog aria-label={label} className="flex w-64 flex-col gap-4 p-4">
|
||||
<ColorArea colorSpace="hsb" xChannel="saturation" yChannel="brightness">
|
||||
<ColorArea.Thumb />
|
||||
</ColorArea>
|
||||
@@ -67,10 +75,25 @@ export function ColorControl({
|
||||
<ColorSlider.Thumb />
|
||||
</ColorSlider.Track>
|
||||
</ColorSlider>
|
||||
{colors && (
|
||||
<ColorSwatchPicker aria-label="Preset colors">
|
||||
{colors.map((color) => (
|
||||
<ColorSwatchPicker.Item key={color} color={color}>
|
||||
<ColorSwatchPicker.Swatch />
|
||||
<ColorSwatchPicker.Indicator />
|
||||
</ColorSwatchPicker.Item>
|
||||
))}
|
||||
</ColorSwatchPicker>
|
||||
)}
|
||||
<ColorField>
|
||||
<Label>{label}</Label>
|
||||
<Label>Hex color</Label>
|
||||
<Input />
|
||||
</ColorField>
|
||||
{onClear && (
|
||||
<Button variant="ghost" onClick={onClear}>
|
||||
Clear background
|
||||
</Button>
|
||||
)}
|
||||
</Popover.Dialog>
|
||||
</ColorPicker.Popover>
|
||||
</ColorPicker>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
TextB as Bold,
|
||||
CaretDown as ChevronDown,
|
||||
ImageSquare as ImageIcon,
|
||||
Info,
|
||||
TextItalic as Italic,
|
||||
@@ -20,7 +19,7 @@ import { ColorControl, ColorPalette } from "@/components/ui/color-picker";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "@/components/ui/toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -143,7 +142,7 @@ export function AnnotationSettingsPanel({
|
||||
|
||||
return (
|
||||
<Card className="flex min-h-0 flex-1 flex-col gap-0 overflow-hidden rounded-none bg-transparent p-0 shadow-none">
|
||||
<div className="flex-1 min-h-0 p-4 overflow-y-auto custom-scrollbar">
|
||||
<div className="flex-1 min-h-0 px-5 pb-6 pt-1 overflow-y-auto custom-scrollbar">
|
||||
<div className="mb-6">
|
||||
{/* Type Selector */}
|
||||
<Tabs
|
||||
@@ -183,9 +182,9 @@ export function AnnotationSettingsPanel({
|
||||
</TabsList>
|
||||
|
||||
{/* Text Content */}
|
||||
<TabsContent value="text" className="mt-0 space-y-4">
|
||||
<TabsContent value="text" className="mt-0 space-y-4 p-0">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-foreground mb-2 block">
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">
|
||||
{t("annotations.textContent")}
|
||||
</label>
|
||||
<TextArea
|
||||
@@ -202,7 +201,7 @@ export function AnnotationSettingsPanel({
|
||||
{/* Font Family & Size */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-foreground mb-2 block">
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">
|
||||
{t("annotations.fontStyle")}
|
||||
</label>
|
||||
<Select
|
||||
@@ -248,7 +247,7 @@ export function AnnotationSettingsPanel({
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-foreground mb-2 block">
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">
|
||||
{t("annotations.size")}
|
||||
</label>
|
||||
<Select
|
||||
@@ -383,110 +382,40 @@ export function AnnotationSettingsPanel({
|
||||
{/* Colors */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-foreground mb-2 block">
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">
|
||||
{t("annotations.textColor")}
|
||||
</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-9 justify-start gap-2 px-2"
|
||||
>
|
||||
<div
|
||||
className="w-4 h-4 rounded-full border border-foreground/20"
|
||||
style={{
|
||||
backgroundColor: annotation.style.color,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground truncate flex-1 text-left">
|
||||
{annotation.style.color}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[260px] p-3">
|
||||
<ColorPalette
|
||||
color={annotation.style.color}
|
||||
colors={colorPalette}
|
||||
onChange={(color) => {
|
||||
onStyleChange({ color: color.hex });
|
||||
}}
|
||||
style={{
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ColorControl
|
||||
value={annotation.style.color}
|
||||
label={t("annotations.textColor")}
|
||||
onChange={(color) => onStyleChange({ color })}
|
||||
colors={colorPalette}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-foreground mb-2 block">
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">
|
||||
{t("annotations.background")}
|
||||
</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-9 justify-start gap-2 px-2"
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full border border-foreground/20 relative overflow-hidden">
|
||||
<div className="absolute inset-0 checkerboard-bg opacity-50" />
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundColor:
|
||||
annotation.style
|
||||
.backgroundColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate flex-1 text-left">
|
||||
{annotation.style.backgroundColor ===
|
||||
"transparent"
|
||||
? t("annotations.none")
|
||||
: "Color"}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[260px] p-3">
|
||||
<ColorPalette
|
||||
color={
|
||||
annotation.style.backgroundColor ===
|
||||
"transparent"
|
||||
? "#000000"
|
||||
: annotation.style.backgroundColor
|
||||
}
|
||||
colors={colorPalette}
|
||||
onChange={(color) => {
|
||||
onStyleChange({
|
||||
backgroundColor: color.hex,
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full mt-2 text-xs h-7"
|
||||
onClick={() => {
|
||||
onStyleChange({
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("annotations.clearBackground")}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ColorControl
|
||||
value={annotation.style.backgroundColor}
|
||||
label={t("annotations.background")}
|
||||
onChange={(color) =>
|
||||
onStyleChange({ backgroundColor: color })
|
||||
}
|
||||
colors={colorPalette}
|
||||
compact
|
||||
onClear={() =>
|
||||
onStyleChange({ backgroundColor: "transparent" })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Image Upload */}
|
||||
<TabsContent value="image" className="mt-0 space-y-4">
|
||||
<TabsContent value="image" className="mt-0 space-y-4 p-0">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
@@ -518,9 +447,9 @@ export function AnnotationSettingsPanel({
|
||||
</p>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="figure" className="mt-0 space-y-4">
|
||||
<TabsContent value="figure" className="mt-0 space-y-4 p-0">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-foreground mb-3 block">
|
||||
<label className="text-sm font-medium text-foreground mb-3 block">
|
||||
{t("annotations.arrowDirection")}
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
@@ -576,7 +505,7 @@ export function AnnotationSettingsPanel({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-foreground mb-2 block">
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">
|
||||
{t("annotations.strokeWidth", undefined, {
|
||||
width: annotation.figureData?.strokeWidth || 4,
|
||||
})}
|
||||
@@ -596,58 +525,31 @@ export function AnnotationSettingsPanel({
|
||||
min={1}
|
||||
max={6}
|
||||
step={1}
|
||||
className="w-full"
|
||||
className="my-3 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-foreground mb-2 block">
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">
|
||||
{t("annotations.arrowColor")}
|
||||
</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-10 justify-start gap-2"
|
||||
>
|
||||
<div
|
||||
className="w-5 h-5 rounded-full border border-foreground/20"
|
||||
style={{
|
||||
backgroundColor:
|
||||
annotation.figureData?.color || "#2563EB",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground truncate flex-1 text-left">
|
||||
{annotation.figureData?.color || "#2563EB"}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[260px] p-3">
|
||||
<ColorPalette
|
||||
color={annotation.figureData?.color || "#2563EB"}
|
||||
colors={colorPalette}
|
||||
onChange={(color) => {
|
||||
const newFigureData: FigureData = {
|
||||
...annotation.figureData!,
|
||||
color: color.hex,
|
||||
};
|
||||
onFigureDataChange?.(newFigureData);
|
||||
}}
|
||||
style={{
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ColorControl
|
||||
value={annotation.figureData?.color || "#2563EB"}
|
||||
label={t("annotations.arrowColor")}
|
||||
onChange={(color) =>
|
||||
onFigureDataChange?.({ ...annotation.figureData!, color })
|
||||
}
|
||||
colors={colorPalette}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="blur" className="mt-0 space-y-4">
|
||||
<div className="p-4 bg-foreground/5 rounded-xl border border-foreground/10 flex flex-col items-center">
|
||||
<div className="w-full space-y-3">
|
||||
<TabsContent value="blur" className="mt-0 space-y-4 p-0">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("annotations.blurStrength", undefined, {
|
||||
strength: annotation.blurIntensity ?? 20,
|
||||
})}
|
||||
@@ -666,9 +568,9 @@ export function AnnotationSettingsPanel({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-3 mt-4">
|
||||
<div className="w-full space-y-5 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t(
|
||||
"annotations.solidColor",
|
||||
"Solid Color (Censorship)",
|
||||
@@ -717,7 +619,7 @@ export function AnnotationSettingsPanel({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 border-t border-foreground/10 bg-editor-panel p-4 pt-3">
|
||||
<div className="shrink-0 px-5 py-4">
|
||||
<Button
|
||||
onClick={onDelete}
|
||||
variant="destructive-soft"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DownloadSimple as Download, FilmSlate as Film, Image } from "@phosphor-icons/react";
|
||||
import { Card, Label, Description, ToggleButtonGroup, ToggleButton } from "@heroui/react";
|
||||
import { Card, Label, Description, TagGroup, Tag } from "@heroui/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -51,37 +51,45 @@ function Choices<T extends string | number>({
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
options: { value: T; label: ReactNode; description?: string }[];
|
||||
options: { value: T; label: ReactNode; textValue?: string; description?: string }[];
|
||||
onChange?: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>{label}</Label>
|
||||
<ToggleButtonGroup
|
||||
<TagGroup
|
||||
aria-label={label}
|
||||
selectionMode="single"
|
||||
disallowEmptySelection
|
||||
selectedKeys={[String(value)]}
|
||||
onSelectionChange={(keys) => {
|
||||
if (keys === "all") return;
|
||||
const selected = options.find((option) => keys.has(String(option.value)));
|
||||
if (selected) onChange?.(selected.value);
|
||||
}}
|
||||
fullWidth
|
||||
size="sm"
|
||||
size="lg"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<ToggleButton
|
||||
key={option.value}
|
||||
id={String(option.value)}
|
||||
className="h-auto min-h-9 flex-1 flex-col gap-0.5 py-2"
|
||||
>
|
||||
{option.label}
|
||||
{option.description && (
|
||||
<span className="text-[10px] opacity-70">{option.description}</span>
|
||||
)}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
<TagGroup.List className="flex gap-2">
|
||||
{options.map((option) => (
|
||||
<Tag
|
||||
key={option.value}
|
||||
id={String(option.value)}
|
||||
textValue={
|
||||
option.textValue ??
|
||||
(typeof option.label === "string"
|
||||
? option.label
|
||||
: String(option.value))
|
||||
}
|
||||
className="h-auto min-h-10 flex-1 justify-center flex-col gap-0.5 py-2"
|
||||
>
|
||||
{option.label}
|
||||
{option.description && (
|
||||
<span className="text-[10px] opacity-70">{option.description}</span>
|
||||
)}
|
||||
</Tag>
|
||||
))}
|
||||
</TagGroup.List>
|
||||
</TagGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -128,6 +136,7 @@ export function ExportSettingsMenu({
|
||||
options={[
|
||||
{
|
||||
value: "mp4",
|
||||
textValue: tSettings("export.mp4"),
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
<Film />
|
||||
@@ -137,6 +146,7 @@ export function ExportSettingsMenu({
|
||||
},
|
||||
{
|
||||
value: "gif",
|
||||
textValue: tSettings("export.gif"),
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
<Image />
|
||||
@@ -170,7 +180,12 @@ export function ExportSettingsMenu({
|
||||
onChange={onExportEncodingModeChange}
|
||||
options={(["fast", "balanced", "quality"] as const).map((value) => ({
|
||||
value,
|
||||
label: tSettings(`export.encoding.${value}`),
|
||||
label: tSettings(
|
||||
`export.encoding.${value}`,
|
||||
{ fast: "Fast", balanced: "Balanced", quality: "Quality" }[
|
||||
value
|
||||
],
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
<Choices
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,7 @@ export const SliderControl = memo(function SliderControl({
|
||||
maxValue={max}
|
||||
step={step}
|
||||
onChange={(value) => onChange(Number(value))}
|
||||
className="w-full"
|
||||
className="my-1.5 w-full gap-y-2"
|
||||
>
|
||||
<Label>{label}</Label>
|
||||
<Slider.Output>{() => formatValue(value)}</Slider.Output>
|
||||
|
||||
@@ -31,7 +31,7 @@ const CONTACT_EMAIL = "youngchen3442@gmail.com";
|
||||
export const APP_HEADER_ACTION_BUTTON_CLASS =
|
||||
"h-7 px-2 text-xs text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all gap-1.5";
|
||||
export const APP_HEADER_ICON_BUTTON_CLASS =
|
||||
"h-7 w-7 p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all";
|
||||
"h-9 w-9 min-w-9 p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all";
|
||||
|
||||
interface KeyboardShortcutsDialogProps {
|
||||
triggerLabel?: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: setters returned by the editor's domain-state hooks are stable React dispatchers. */
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "@/contexts/I18nContext";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { getAspectRatioValue } from "@/utils/aspectRatioUtils";
|
||||
@@ -92,7 +92,28 @@ export default function VideoEditor() {
|
||||
applySessionPresentation,
|
||||
} = ui;
|
||||
const effectiveShowCursor = sessionShowCursorOverride ?? showCursor;
|
||||
const headerLeftControlsPaddingClass = appPlatform === "darwin" ? "pl-[76px]" : "";
|
||||
const [trafficLightsVisible, setTrafficLightsVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let receivedEvent = false;
|
||||
const unsubscribe = window.electronAPI.onWindowChromeChanged?.((chrome) => {
|
||||
receivedEvent = true;
|
||||
if (active) setTrafficLightsVisible(chrome.trafficLightsVisible);
|
||||
});
|
||||
void window.electronAPI
|
||||
.getWindowChrome?.()
|
||||
.then((chrome) => {
|
||||
if (active && !receivedEvent) setTrafficLightsVisible(chrome.trafficLightsVisible);
|
||||
})
|
||||
.catch(() => {
|
||||
/* Window controls are absent in renderer-only previews. */
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
const headerLeftControlsPaddingClass = trafficLightsVisible ? "pl-[76px]" : "";
|
||||
const { cursorTelemetrySourcePath, autoCaptions, autoCaptionSettings } = timeline;
|
||||
const exportSettings = useExportSettings(
|
||||
initialEditorPreferences,
|
||||
|
||||
@@ -104,7 +104,7 @@ export function EditorExportMenu(props: Props) {
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
className="inline-flex h-8 min-w-[112px] items-center justify-center gap-2 px-4.5"
|
||||
className="inline-flex h-9 min-w-[104px] items-center justify-center gap-2 px-4.5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-sm font-semibold tracking-tight">
|
||||
|
||||
@@ -92,12 +92,12 @@ export function EditorHeader(props: Props) {
|
||||
} = project;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative z-50 flex h-11 flex-shrink-0 items-center justify-between border-b border-separator bg-surface px-4"
|
||||
<header
|
||||
className="editor-header relative z-50 grid h-14 shrink-0 bg-surface grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)_minmax(0,1fr)] items-center gap-3 px-4"
|
||||
style={{ WebkitAppRegion: "drag" } as CSSProperties}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center justify-self-start gap-1.5 ${headerLeftControlsPaddingClass}`}
|
||||
className={`editor-header-start flex items-center justify-self-start gap-1 ${headerLeftControlsPaddingClass}`}
|
||||
style={{ WebkitAppRegion: "no-drag" } as CSSProperties}
|
||||
>
|
||||
<Button
|
||||
@@ -112,15 +112,17 @@ export function EditorHeader(props: Props) {
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
<DiscordLinkButton />
|
||||
<FeedbackDialog />
|
||||
<div className="ml-1 h-5 w-px bg-foreground/10" />
|
||||
<div className="editor-header-community flex items-center gap-1">
|
||||
<DiscordLinkButton />
|
||||
<FeedbackDialog />
|
||||
</div>
|
||||
<div className="w-2 shrink-0" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleUndo}
|
||||
disabled={!canUndo}
|
||||
className="inline-flex h-8 w-8 items-center justify-center p-0 disabled:cursor-not-allowed"
|
||||
className="inline-flex h-9 w-9 min-w-9 items-center justify-center p-0 disabled:cursor-not-allowed"
|
||||
title={t("common.actions.undo", "Undo")}
|
||||
aria-label={t("common.actions.undo", "Undo")}
|
||||
>
|
||||
@@ -131,7 +133,7 @@ export function EditorHeader(props: Props) {
|
||||
variant="ghost"
|
||||
onClick={handleRedo}
|
||||
disabled={!canRedo}
|
||||
className="inline-flex h-8 w-8 items-center justify-center p-0 disabled:cursor-not-allowed"
|
||||
className="inline-flex h-9 w-9 min-w-9 items-center justify-center p-0 disabled:cursor-not-allowed"
|
||||
title={t("common.actions.redo", "Redo")}
|
||||
aria-label={t("common.actions.redo", "Redo")}
|
||||
>
|
||||
@@ -140,16 +142,16 @@ export function EditorHeader(props: Props) {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute left-1/2 flex min-w-0 -translate-x-1/2 items-center justify-center"
|
||||
className="editor-header-title flex min-w-0 items-center justify-center"
|
||||
style={{ WebkitAppRegion: "no-drag" } as CSSProperties}
|
||||
>
|
||||
{isEditingProjectName ? (
|
||||
<form
|
||||
onSubmit={(event) => void handleProjectNameSubmit(event)}
|
||||
className="flex max-w-[min(52vw,460px)] items-baseline gap-1 rounded-[7px] border border-foreground/10 bg-editor-panel/[0.88] px-2.5 py-1 shadow-[0_10px_28px_rgba(0,0,0,0.18)]"
|
||||
className="flex w-full min-w-0 items-center gap-1"
|
||||
>
|
||||
{hasUnsavedChanges ? (
|
||||
<span className="mt-[1px] size-2 shrink-0 rounded-full bg-[#2563EB]" />
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-accent" />
|
||||
) : null}
|
||||
<Input
|
||||
ref={projectNameInputRef}
|
||||
@@ -166,11 +168,10 @@ export function EditorHeader(props: Props) {
|
||||
}
|
||||
}}
|
||||
disabled={isSavingProjectName}
|
||||
className="min-w-[10ch] max-w-[min(40vw,360px)] text-sm disabled:cursor-wait"
|
||||
style={{ width: `${Math.max(projectNameDraft.length, 10)}ch` }}
|
||||
className="min-w-0 w-full text-sm disabled:cursor-wait"
|
||||
aria-label={t("editor.project.renameInput", "Project name")}
|
||||
/>
|
||||
<span className="shrink-0 text-xs font-medium tracking-tight text-muted-foreground/70">
|
||||
<span className="project-file-extension shrink-0 text-xs font-medium tracking-tight text-muted-foreground/70">
|
||||
.recordly
|
||||
</span>
|
||||
</form>
|
||||
@@ -179,17 +180,17 @@ export function EditorHeader(props: Props) {
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => setIsEditingProjectName(true)}
|
||||
className="inline-flex max-w-[min(52vw,460px)] items-baseline gap-1 px-2.5 py-1"
|
||||
className="inline-flex h-9 min-w-0 max-w-full items-center gap-1.5 px-3"
|
||||
title={t("editor.project.renameTitle", "Rename project")}
|
||||
aria-label={t("editor.project.renameTitle", "Rename project")}
|
||||
>
|
||||
{hasUnsavedChanges ? (
|
||||
<span className="mt-[1px] size-2 shrink-0 rounded-full bg-[#2563EB]" />
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-accent" />
|
||||
) : null}
|
||||
<span className="truncate text-sm font-semibold tracking-tight text-foreground/90">
|
||||
{projectDisplayName}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs font-medium tracking-tight text-muted-foreground/70">
|
||||
<span className="project-file-extension shrink-0 text-xs font-medium tracking-tight text-muted-foreground/70">
|
||||
.recordly
|
||||
</span>
|
||||
</Button>
|
||||
@@ -197,14 +198,10 @@ export function EditorHeader(props: Props) {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center justify-self-end"
|
||||
className="editor-header-end flex min-w-0 items-center justify-self-end gap-3"
|
||||
style={{ WebkitAppRegion: "no-drag" } as CSSProperties}
|
||||
>
|
||||
<EditorPresetMenu t={t} presets={presets} />
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mx-2 h-4 w-px shrink-0 bg-foreground/10 opacity-0"
|
||||
/>
|
||||
<EditorExportMenu
|
||||
t={t}
|
||||
exportSettings={exportSettings}
|
||||
@@ -224,6 +221,6 @@ export function EditorHeader(props: Props) {
|
||||
exportMessage={exportMessage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,11 +32,11 @@ export function EditorPresetMenu({ t, presets }: Props) {
|
||||
type="button"
|
||||
title={t("editor.presets.open", "Open presets")}
|
||||
aria-label={t("editor.presets.open", "Open presets")}
|
||||
className="inline-flex items-center gap-1.5 p-0 text-sm"
|
||||
className="inline-flex h-9 min-w-0 max-w-40 items-center gap-2 px-3 text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<BookmarkSimple weight="fill" className="h-4 w-4" />
|
||||
<span>
|
||||
<span className="truncate">
|
||||
{currentEditorPreset?.name ?? t("editor.presets.label", "Presets")}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -117,7 +117,7 @@ export function EditorPreviewPanel(props: Props) {
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="flex h-11 flex-shrink-0 items-center justify-center gap-2 border-b border-separator bg-surface">
|
||||
<div className="flex h-14 shrink-0 items-center justify-center gap-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -164,12 +164,15 @@ export function EditorPreviewPanel(props: Props) {
|
||||
className="flex min-h-0 w-full flex-1 items-stretch px-6 py-5"
|
||||
style={{ flex: "1 1 auto", margin: 0 }}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center px-1">
|
||||
<div
|
||||
className="editor-preview-stage flex min-h-0 min-w-0 flex-1 items-center justify-center"
|
||||
style={{ containerType: "size" }}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
className="editor-preview-frame relative"
|
||||
style={{
|
||||
width: "auto",
|
||||
height: "100%",
|
||||
width: `min(100cqw, calc(100cqh * ${previewAspectRatioValue}))`,
|
||||
height: `min(100cqh, calc(100cqw / ${previewAspectRatioValue}))`,
|
||||
aspectRatio: previewAspectRatioValue,
|
||||
maxWidth: "100%",
|
||||
margin: "0 auto",
|
||||
@@ -213,11 +216,11 @@ export function EditorPreviewPanel(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex h-12 flex-shrink-0 items-center border-t border-separator bg-surface px-4">
|
||||
<div className="z-10 flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<div className="editor-playback relative grid min-h-14 shrink-0 grid-cols-[1fr_auto_1fr] items-center gap-3 px-4">
|
||||
<div className="editor-playback-tools z-10 flex min-w-0 items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1 px-2.5">
|
||||
<Button variant="ghost" size="sm" className="h-9 gap-2 px-3">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">{t("editor.toolbar.addLayer")}</span>
|
||||
<CaretDown className="h-3 w-3" />
|
||||
@@ -258,12 +261,11 @@ export function EditorPreviewPanel(props: Props) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="mx-1 h-4 w-px bg-foreground/10" />
|
||||
<Button
|
||||
onClick={() => timelineRef.current?.addZoom()}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
className="h-9 w-9"
|
||||
title={t("timeline.zoom.addZoom")}
|
||||
>
|
||||
<MagnifyingGlassPlus className="h-4 w-4" />
|
||||
@@ -272,7 +274,7 @@ export function EditorPreviewPanel(props: Props) {
|
||||
onClick={() => timelineRef.current?.suggestZooms()}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
className="h-9 w-9"
|
||||
title={t("timeline.zoom.suggestZooms")}
|
||||
>
|
||||
<MagicWand className="h-4 w-4" />
|
||||
@@ -281,14 +283,14 @@ export function EditorPreviewPanel(props: Props) {
|
||||
onClick={() => timelineRef.current?.splitClip()}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
className="h-9 w-9"
|
||||
title={t("editor.toolbar.splitClip")}
|
||||
>
|
||||
<Scissors className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
|
||||
<div className="editor-playback-center z-10 flex items-center justify-center">
|
||||
<div className="pointer-events-auto flex items-center gap-1.5">
|
||||
<span className="mr-1 text-[10px] font-medium tabular-nums text-muted-foreground">
|
||||
{formatTime(projection.timelinePlayheadTime)}
|
||||
@@ -296,7 +298,7 @@ export function EditorPreviewPanel(props: Props) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
className="h-9 w-9"
|
||||
title={t("editor.playback.skipBack")}
|
||||
onClick={playback.handlePreviewSkipBack}
|
||||
>
|
||||
@@ -305,7 +307,7 @@ export function EditorPreviewPanel(props: Props) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-7 w-7 ${isPlaying ? "bg-foreground/10 text-foreground hover:bg-foreground/20" : "bg-neutral-800 text-white hover:bg-neutral-700 dark:bg-white dark:text-black dark:hover:bg-white/90"} `}
|
||||
className={`h-9 w-9 ${isPlaying ? "bg-foreground/10 text-foreground hover:bg-foreground/20" : "bg-neutral-800 text-white hover:bg-neutral-700 dark:bg-white dark:text-black dark:hover:bg-white/90"} `}
|
||||
onClick={playback.togglePlayPause}
|
||||
title={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
@@ -318,7 +320,7 @@ export function EditorPreviewPanel(props: Props) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
className="h-9 w-9"
|
||||
title={t("editor.playback.skipForward")}
|
||||
onClick={playback.handlePreviewSkipForward}
|
||||
>
|
||||
@@ -330,7 +332,7 @@ export function EditorPreviewPanel(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="z-10 ml-auto flex items-center gap-2">
|
||||
<div className="editor-playback-volume z-10 ml-auto flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -182,7 +182,7 @@ export function EditorShell(props: Props) {
|
||||
/>
|
||||
<EditorAnnouncementBanner />
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<div className="relative z-10 flex min-h-0 flex-1">
|
||||
<div className="relative z-10 flex min-h-0 flex-1 pt-3">
|
||||
<EditorSidebar
|
||||
t={t}
|
||||
activeSection={ui.activeEffectSection}
|
||||
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
FrameCorners,
|
||||
UserCircle,
|
||||
} from "@phosphor-icons/react";
|
||||
import { ToggleButtonGroup, ToggleButton, Tooltip } from "@heroui/react";
|
||||
import { ToggleButtonGroup, ToggleButton, Tooltip, Card, Switch, Label } from "@heroui/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ComponentProps, Dispatch, SetStateAction } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "@/components/ui/toast";
|
||||
import type { useI18n } from "@/contexts/I18nContext";
|
||||
import ExtensionManager from "../ExtensionManager";
|
||||
@@ -25,6 +25,13 @@ type Props = {
|
||||
};
|
||||
|
||||
export function EditorSidebar({ t, activeSection, setActiveSection, settingsPanelProps }: Props) {
|
||||
const [advancedSections, setAdvancedSections] = useState<Record<string, boolean>>({});
|
||||
const advanced = advancedSections[activeSection] ?? false;
|
||||
const hasAdvanced =
|
||||
!settingsPanelProps.selectedAnnotationId &&
|
||||
["scene", "frame", "crop", "cursor", "webcam", "captions", "settings", "zoom"].includes(
|
||||
activeSection,
|
||||
);
|
||||
const sections = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -53,10 +60,10 @@ export function EditorSidebar({ t, activeSection, setActiveSection, settingsPane
|
||||
[t],
|
||||
);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-shrink-0 border-r border-separator bg-surface">
|
||||
<div className="flex min-h-0 shrink-0 gap-2 pb-3 pr-2">
|
||||
<nav
|
||||
aria-label={t("settings.sections.title", "Editor tools")}
|
||||
className="flex w-14 flex-col items-center gap-3 border-r border-separator py-3"
|
||||
className="flex w-14 flex-col items-center gap-3 py-2.5"
|
||||
>
|
||||
<ToggleButtonGroup
|
||||
orientation="vertical"
|
||||
@@ -97,22 +104,46 @@ export function EditorSidebar({ t, activeSection, setActiveSection, settingsPane
|
||||
</Button>
|
||||
</nav>
|
||||
<aside className="flex w-[320px] min-h-0 flex-col">
|
||||
<header className="flex h-11 shrink-0 items-center border-b border-separator px-4">
|
||||
<h2 className="text-sm font-medium">
|
||||
{settingsPanelProps.selectedAnnotationId
|
||||
? t("timeline.annotation.label", "Annotation")
|
||||
: (sections.find((section) => section.id === activeSection)?.label ??
|
||||
t(
|
||||
`settings.sections.${activeSection}`,
|
||||
activeSection.charAt(0).toUpperCase() + activeSection.slice(1),
|
||||
))}
|
||||
</h2>
|
||||
</header>
|
||||
{activeSection === "extensions" ? (
|
||||
<ExtensionManager />
|
||||
) : (
|
||||
<SettingsPanel {...settingsPanelProps} />
|
||||
)}
|
||||
<Card className="min-h-0 flex-1 gap-0 overflow-hidden p-0">
|
||||
<header className="flex min-h-14 shrink-0 items-center justify-between gap-3 px-5 py-3">
|
||||
<Card.Title>
|
||||
{settingsPanelProps.selectedAnnotationId
|
||||
? t("timeline.annotation.label", "Annotation")
|
||||
: (sections.find((section) => section.id === activeSection)
|
||||
?.label ??
|
||||
t(
|
||||
`settings.sections.${activeSection}`,
|
||||
activeSection.charAt(0).toUpperCase() +
|
||||
activeSection.slice(1),
|
||||
))}
|
||||
</Card.Title>
|
||||
{hasAdvanced && (
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={advanced}
|
||||
onChange={(value) =>
|
||||
setAdvancedSections((current) => ({
|
||||
...current,
|
||||
[activeSection]: value,
|
||||
}))
|
||||
}
|
||||
aria-label="Advanced settings"
|
||||
>
|
||||
<Switch.Content>
|
||||
<Label>Advanced</Label>
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
</Switch.Content>
|
||||
</Switch>
|
||||
)}
|
||||
</header>
|
||||
{activeSection === "extensions" ? (
|
||||
<ExtensionManager />
|
||||
) : (
|
||||
<SettingsPanel {...settingsPanelProps} advanced={advanced} />
|
||||
)}
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -58,7 +58,7 @@ export function EditorTimelinePanel(props: Props) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-shrink-0 flex-col border-t border-separator bg-surface px-3 pb-3 pt-1"
|
||||
className="flex flex-shrink-0 flex-col bg-transparent px-4 pb-4 pt-2"
|
||||
style={{ height: "22%", minHeight: 180, maxHeight: 280 }}
|
||||
>
|
||||
<TimelineEditor
|
||||
|
||||
@@ -419,10 +419,10 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col bg-editor-bg overflow-hidden">
|
||||
<div className="flex-1 min-h-0 flex flex-col bg-transparent overflow-hidden">
|
||||
<div
|
||||
ref={timelineContainerRef}
|
||||
className="flex-1 min-h-0 overflow-auto bg-editor-bg relative"
|
||||
className="flex-1 min-h-0 overflow-auto bg-transparent relative"
|
||||
tabIndex={0}
|
||||
onFocus={() => {
|
||||
isTimelineFocusedRef.current = true;
|
||||
|
||||
@@ -88,3 +88,41 @@
|
||||
scrollbar-color: var(--scrollbar-thumb) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive geometry belongs to the editor, leaving HeroUI control styles intact. */
|
||||
.editor-header-start,
|
||||
.editor-header-end {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.editor-playback {
|
||||
container-type: inline-size;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.editor-header {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 0.4fr) minmax(0, 1fr);
|
||||
}
|
||||
.project-file-extension {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@container (max-width: 760px) {
|
||||
.editor-playback-tools {
|
||||
grid-row: 2;
|
||||
grid-column: 1 / -1;
|
||||
justify-content: center;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.editor-playback-center {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
}
|
||||
.editor-playback-volume {
|
||||
grid-row: 1;
|
||||
grid-column: 3;
|
||||
}
|
||||
}
|
||||
@container (max-width: 520px) {
|
||||
.editor-playback-volume .slider {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,14 @@ export async function installDesktopBridge(page: Page) {
|
||||
}),
|
||||
getAccessibilityPermissionStatus: async () => ({ success: true, trusted: true }),
|
||||
getPlatform: async () => "darwin",
|
||||
getWindowChrome: async () => ({ trafficLightsVisible: true }),
|
||||
onWindowChromeChanged: (
|
||||
callback: (chrome: { trafficLightsVisible: boolean }) => void,
|
||||
) => {
|
||||
const listener = (event: Event) => callback((event as CustomEvent).detail);
|
||||
window.addEventListener("test-window-chrome", listener);
|
||||
return () => window.removeEventListener("test-window-chrome", listener);
|
||||
},
|
||||
getAppVersion: async () => "1.4.0",
|
||||
getAnnouncements: async () => ({ success: true, announcements: [] }),
|
||||
loadCurrentProjectFile: async () => ({ success: false }),
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { installDesktopBridge } from "./bridge";
|
||||
|
||||
test("advanced controls preserve values and remember each section's view", async ({ page }) => {
|
||||
await installDesktopBridge(page);
|
||||
await page.goto("/?windowType=editor");
|
||||
const advanced = page.getByRole("switch", { name: "Advanced settings" });
|
||||
await expect(advanced).toBeVisible({ timeout: 20000 });
|
||||
await expect(page.getByRole("switch", { name: "Link padding sides" })).toHaveCount(0);
|
||||
const blur = page.getByRole("slider", { name: "Blur", exact: true });
|
||||
await blur.focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
const value = await blur.inputValue();
|
||||
await page.locator("aside").getByText("Advanced", { exact: true }).click();
|
||||
await expect(page.getByRole("switch", { name: "Link padding sides" })).toBeVisible();
|
||||
await page.getByRole("radio", { name: "Webcam", exact: true }).click();
|
||||
await expect(advanced).not.toBeChecked();
|
||||
await expect(page.getByRole("slider", { name: "Webcam Height" })).toHaveCount(0);
|
||||
await page.locator("aside").getByText("Advanced", { exact: true }).click();
|
||||
await expect(page.getByRole("slider", { name: "Webcam Height" })).toBeAttached();
|
||||
await page.screenshot({
|
||||
path: "test-results/editor-webcam-advanced.png",
|
||||
animations: "disabled",
|
||||
});
|
||||
await page.getByRole("radio", { name: "Scene", exact: true }).click();
|
||||
await expect(advanced).toBeChecked();
|
||||
await expect(blur).toHaveValue(value);
|
||||
await page.locator("aside").getByText("Advanced", { exact: true }).click();
|
||||
await expect(blur).toHaveValue(value);
|
||||
await page.getByRole("row", { name: "Color", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Custom color", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "Hex color", exact: true }).fill("#27AE60");
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.getByRole("textbox", { name: "Hex color", exact: true })).toHaveValue(
|
||||
"#27AE60",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("button", { name: "Custom color", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Hex color", exact: true })).toHaveValue(
|
||||
"#27AE60",
|
||||
);
|
||||
await page.screenshot({ path: "test-results/editor-color-picker.png", animations: "disabled" });
|
||||
});
|
||||
|
||||
test("header stays centered with long names, native chrome and compact windows", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installDesktopBridge(page);
|
||||
await page.goto("/?windowType=editor");
|
||||
await expect(page.getByRole("button", { name: "Rename project" })).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Rename project" }).click();
|
||||
await page
|
||||
.getByRole("textbox", { name: "Project name" })
|
||||
.fill("A very long project title that should stay centered and never cover the toolbar");
|
||||
// Validate the editing state as well as the display state.
|
||||
for (const width of [1440, 1280, 800]) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
for (const trafficLightsVisible of [true, false]) {
|
||||
await page.evaluate(
|
||||
(visible) =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("test-window-chrome", {
|
||||
detail: { trafficLightsVisible: visible },
|
||||
}),
|
||||
),
|
||||
trafficLightsVisible,
|
||||
);
|
||||
await expect(page.locator(".editor-header-start")).toHaveCSS(
|
||||
"padding-left",
|
||||
trafficLightsVisible ? "76px" : "0px",
|
||||
);
|
||||
const stage = await page.locator(".editor-preview-stage").boundingBox();
|
||||
const frame = await page.locator(".editor-preview-frame").boundingBox();
|
||||
expect(
|
||||
Math.abs(stage!.x + stage!.width / 2 - frame!.x - frame!.width / 2),
|
||||
).toBeLessThan(1);
|
||||
expect(
|
||||
Math.abs(stage!.y + stage!.height / 2 - frame!.y - frame!.height / 2),
|
||||
).toBeLessThan(1);
|
||||
const boxes = await Promise.all(
|
||||
[".editor-header-start", ".editor-header-title", ".editor-header-end"].map(
|
||||
(selector) => page.locator(selector).boundingBox(),
|
||||
),
|
||||
);
|
||||
const [left, center, right] = boxes;
|
||||
expect(Math.abs(center!.x + center!.width / 2 - width / 2)).toBeLessThan(1);
|
||||
expect(left!.x + left!.width).toBeLessThanOrEqual(center!.x);
|
||||
expect(center!.x + center!.width).toBeLessThanOrEqual(right!.x);
|
||||
const buttons = await page.locator(".editor-playback button").evaluateAll((nodes) =>
|
||||
nodes
|
||||
.filter((n) => n.getBoundingClientRect().width > 0)
|
||||
.map((n) => {
|
||||
const r = n.getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, right: r.right, bottom: r.bottom };
|
||||
}),
|
||||
);
|
||||
for (let i = 0; i < buttons.length; i++)
|
||||
for (let j = i + 1; j < buttons.length; j++) {
|
||||
const a = buttons[i],
|
||||
b = buttons[j];
|
||||
expect(
|
||||
a.right <= b.x || b.right <= a.x || a.bottom <= b.y || b.bottom <= a.y,
|
||||
).toBe(true);
|
||||
}
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Export", exact: true }),
|
||||
).toBeInViewport();
|
||||
await expect(page.getByRole("button", { name: "Play", exact: true })).toBeInViewport();
|
||||
}
|
||||
await page.screenshot({
|
||||
path: `test-results/editor-layout-${width}.png`,
|
||||
animations: "disabled",
|
||||
});
|
||||
}
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("button", { name: "Rename project" })).toBeVisible();
|
||||
});
|
||||
@@ -1,13 +1,16 @@
|
||||
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);
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => {
|
||||
errors.push(e.message);
|
||||
});
|
||||
await installDesktopBridge(page);
|
||||
await page.goto("/?windowType=editor");
|
||||
await expect(page.getByRole("navigation", { name: "Editor tools" })).toBeVisible();
|
||||
await expect(page.getByRole("navigation", { name: "Editor tools" })).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
await expect(page.getByRole("slider", { name: "Blur", exact: true })).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
@@ -24,7 +27,7 @@ test("editor loads video, switches tools and edits export options", async ({ pag
|
||||
animations: "disabled",
|
||||
});
|
||||
const inspectorWidth = (await page.locator("aside").boundingBox())?.width;
|
||||
await page.getByRole("radio", { name: "Color", exact: true }).click();
|
||||
await page.getByRole("row", { name: "Color", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Custom color", exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Custom color", exact: true }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Custom color", exact: true })).toBeVisible();
|
||||
@@ -34,19 +37,19 @@ test("editor loads video, switches tools and edits export options", async ({ pag
|
||||
await page.getByRole("radio", { name: "Captions", exact: true }).click();
|
||||
await page.getByRole("radio", { name: "Settings", exact: true }).click();
|
||||
await expect(page.getByText("Appearance", { exact: true })).toBeVisible();
|
||||
await page.getByRole("radio", { name: "Dark", exact: true }).click();
|
||||
await page.getByRole("row", { name: "Dark", exact: true }).click();
|
||||
await expect(page.locator("html")).toHaveClass(/dark/);
|
||||
await page.screenshot({
|
||||
path: "test-results/editor-dark.png",
|
||||
fullPage: true,
|
||||
animations: "disabled",
|
||||
});
|
||||
await page.getByRole("radio", { name: "Light", exact: true }).click();
|
||||
await page.getByRole("row", { name: "Light", exact: true }).click();
|
||||
await expect(page.locator("html")).not.toHaveClass(/dark/);
|
||||
await page.getByRole("radio", { name: "Scene", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Export", exact: true }).click();
|
||||
await expect(page.getByRole("radiogroup", { name: "Format", exact: true })).toBeVisible();
|
||||
await page.getByRole("radio", { name: "GIF", exact: true }).click();
|
||||
await expect(page.getByRole("grid", { name: "Format", exact: true })).toBeVisible();
|
||||
await page.getByRole("row", { name: "GIF", exact: true }).click();
|
||||
await expect(page.getByRole("switch", { name: "Loop", exact: false })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: "test-results/editor-export-light.png",
|
||||
|
||||
Reference in New Issue
Block a user