Refine editor panels and add source-frame timeline filmstrips

This commit is contained in:
webadderall
2026-09-19 17:55:27 +10:00
parent fc89e1d42e
commit 7dba6e3071
33 changed files with 1291 additions and 722 deletions
+1 -1
View File
@@ -319,7 +319,7 @@ export const SourceSelector = React.memo(function SourceSelector({
const { onMouseEnter } = useHudInteraction();
return (
<Popover open={open} onOpenChange={onOpenChange} modal={false}>
<Popover open={open} onOpenChange={onOpenChange} modal={true}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className="launch-theme w-80 p-0 source-selector-popover"
@@ -90,7 +90,7 @@ export function HudPopover({
}) {
const { onMouseEnter } = useHudInteraction();
return (
<Popover open={open} onOpenChange={onOpenChange} modal={false}>
<Popover open={open} onOpenChange={onOpenChange} modal={true}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className={`launch-theme ${styles.menuCard} ${styles.electronNoDrag}`}
+1 -1
View File
@@ -21,7 +21,7 @@ export function DialogContent({
}) {
const { t } = useI18n();
return (
<Modal.Backdrop>
<Modal.Backdrop isDismissable>
<Modal.Container size="lg" placement="center">
<Modal.Dialog {...props} className={cn("w-full gap-4", className)}>
<Modal.CloseTrigger aria-label={t("common.actions.close", "Close")} />
+39 -7
View File
@@ -1,18 +1,28 @@
import { Popover as HeroPopover } from "@heroui/react";
import { createContext, useContext, type ComponentProps, type ReactNode } from "react";
import { createContext, useContext, useState, type ComponentProps, type ReactNode } from "react";
import { cn } from "@/lib/utils";
const ModalContext = createContext(true);
const CloseContext = createContext<(() => void) | undefined>(undefined);
export function Popover({
open,
defaultOpen,
onOpenChange,
modal = true,
children,
...props
}: Omit<ComponentProps<typeof HeroPopover>, "isOpen"> & { open?: boolean; modal?: boolean }) {
const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false);
const changeOpen = (value: boolean) => {
setInternalOpen(value);
onOpenChange?.(value);
};
return (
<ModalContext.Provider value={modal}>
<HeroPopover {...props} isOpen={open}>
{children}
</HeroPopover>
<CloseContext.Provider value={() => changeOpen(false)}>
<HeroPopover {...props} isOpen={open ?? internalOpen} onOpenChange={changeOpen}>
{children}
</HeroPopover>
</CloseContext.Provider>
</ModalContext.Provider>
);
}
@@ -46,6 +56,7 @@ export function PopoverContent({
...props
}: ContentProps) {
const modal = useContext(ModalContext);
const close = useContext(CloseContext);
const placement = (align === "center" ? side : `${side} ${align}`) as ComponentProps<
typeof HeroPopover.Content
>["placement"];
@@ -61,10 +72,31 @@ export function PopoverContent({
UNSTABLE_portalContainer={
usePortal ? undefined : (document.getElementById("root") ?? undefined)
}
className={cn("max-w-[calc(100vw-24px)]", className)}
className="max-w-[calc(100vw-24px)]"
>
<HeroPopover.Dialog aria-label={props["aria-label"] ?? "Options"}>
{children}
<HeroPopover.Dialog
aria-label={props["aria-label"] ?? "Options"}
className={cn("max-w-full max-h-[min(80vh,640px)] overflow-y-auto p-4", className)}
>
<div
className="contents"
onKeyDownCapture={(event) => {
// Choice groups can consume Escape; dismiss before their keyboard handler.
// A nested portalled menu handles its own Escape first.
if (
event.key === "Escape" &&
!event.defaultPrevented &&
!props.isKeyboardDismissDisabled &&
event.currentTarget.contains(event.target as Node)
) {
event.preventDefault();
event.stopPropagation();
close?.();
}
}}
>
{children}
</div>
</HeroPopover.Dialog>
</HeroPopover.Content>
);
@@ -28,7 +28,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ChoiceGroup, ChoiceItem } from "@/components/ui/choice-group";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
import { cn } from "@/lib/utils";
@@ -145,21 +145,34 @@ export function AnnotationSettingsPanel({
<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
value={annotation.type}
onValueChange={(value) => onTypeChange(value as AnnotationType)}
className="mb-6"
>
<TabsList className="w-full grid grid-cols-4">
<TabsTrigger value="text" className="gap-1 text-xs">
<div className="space-y-4">
<ChoiceGroup
aria-label="Annotation type"
value={annotation.type}
onValueChange={(value) => onTypeChange(value as AnnotationType)}
className="grid grid-cols-4 gap-2"
>
<ChoiceItem
value="text"
aria-label={t("annotations.text")}
className="min-w-0 gap-1 px-1 text-xs"
>
<Type className="w-4 h-4" />
{t("annotations.text")}
</TabsTrigger>
<TabsTrigger value="image" className="gap-1 text-xs">
</ChoiceItem>
<ChoiceItem
value="image"
aria-label={t("annotations.image")}
className="min-w-0 gap-1 px-1 text-xs"
>
<ImageIcon className="w-4 h-4" />
{t("annotations.image")}
</TabsTrigger>
<TabsTrigger value="figure" className="gap-1 text-xs">
</ChoiceItem>
<ChoiceItem
value="figure"
aria-label={t("annotations.arrow")}
className="min-w-0 gap-1 px-1 text-xs"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
@@ -174,449 +187,496 @@ export function AnnotationSettingsPanel({
/>
</svg>
{t("annotations.arrow")}
</TabsTrigger>
<TabsTrigger value="blur" className="gap-1 text-xs">
</ChoiceItem>
<ChoiceItem
value="blur"
aria-label={t("annotations.blur")}
className="min-w-0 gap-1 px-1 text-xs"
>
<SquareDashed className="w-4 h-4" />
{t("annotations.blur")}
</TabsTrigger>
</TabsList>
</ChoiceItem>
</ChoiceGroup>
{/* Text Content */}
<TabsContent value="text" className="mt-0 space-y-4 p-0">
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.textContent")}
</label>
<TextArea
value={annotation.textContent || annotation.content}
onChange={(e) => onContentChange(e.target.value)}
placeholder={t("annotations.textPlaceholder")}
rows={5}
className="w-full px-3 py-2 text-sm resize-none"
/>
</div>
{/* Styling Controls */}
{annotation.type === "text" && (
<div className="space-y-4">
{/* Font Family & Size */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.fontStyle")}
</label>
<Select
value={annotation.style.fontFamily}
onValueChange={(value) =>
onStyleChange({ fontFamily: value })
}
>
<SelectTrigger className="w-full h-9 text-xs">
<SelectValue
placeholder={t("annotations.selectStyle")}
/>
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{fontFamilies.map((font) => (
<SelectItem
key={font.value}
value={font.value}
style={{ fontFamily: font.value }}
>
{font.label}
</SelectItem>
))}
{customFonts.length > 0 && (
<>
<div className="px-2 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Custom Fonts
</div>
{customFonts.map((font) => (
<SelectItem
key={font.id}
value={font.fontFamily}
style={{
fontFamily: font.fontFamily,
}}
>
{font.name}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.size")}
</label>
<Select
value={annotation.style.fontSize.toString()}
onValueChange={(value) =>
onStyleChange({ fontSize: parseInt(value) })
}
>
<SelectTrigger className="w-full h-9 text-xs">
<SelectValue placeholder={t("annotations.size")} />
</SelectTrigger>
<SelectContent className="max-h-[200px]">
{FONT_SIZES.map((size) => (
<SelectItem key={size} value={size.toString()}>
{size}px
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Add Custom Font Button */}
<div>
<AddCustomFontDialog
onFontAdded={(font) => {
setCustomFonts(getCustomFonts());
onStyleChange({ fontFamily: font.fontFamily });
}}
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.textContent")}
</label>
<TextArea
value={annotation.textContent || annotation.content}
onChange={(e) => onContentChange(e.target.value)}
placeholder={t("annotations.textPlaceholder")}
rows={3}
className="w-full px-3 py-2 text-sm resize-none"
/>
</div>
{/* Formatting Toggles */}
<div className="flex items-center justify-between gap-2">
<ToggleGroup
type="multiple"
value={[
...(annotation.style.fontWeight === "bold"
? ["bold"]
: []),
...(annotation.style.fontStyle === "italic"
? ["italic"]
: []),
...(annotation.style.textDecoration === "underline"
? ["underline"]
: []),
]}
size="sm"
className="justify-start"
>
<ToggleGroupItem
value="bold"
aria-label={t("annotations.toggleBold")}
onClick={() =>
onStyleChange({
fontWeight:
annotation.style.fontWeight === "bold"
? "normal"
: "bold",
})
}
className="h-8 w-8"
>
<Bold className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="italic"
aria-label={t("annotations.toggleItalic")}
onClick={() =>
onStyleChange({
fontStyle:
annotation.style.fontStyle === "italic"
? "normal"
: "italic",
})
}
className="h-8 w-8"
>
<Italic className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="underline"
aria-label={t("annotations.toggleUnderline")}
onClick={() =>
onStyleChange({
textDecoration:
annotation.style.textDecoration ===
"underline"
? "none"
: "underline",
})
}
className="h-8 w-8"
>
<Underline className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
{/* Styling Controls */}
<div className="space-y-4">
{/* Font Family & Size */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.fontStyle")}
</label>
<Select
value={annotation.style.fontFamily}
onValueChange={(value) =>
onStyleChange({ fontFamily: value })
}
>
<SelectTrigger className="w-full h-9 text-xs">
<SelectValue
placeholder={t("annotations.selectStyle")}
/>
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{!fontFamilies.some(
(font) =>
font.value ===
annotation.style.fontFamily,
) &&
!customFonts.some(
(font) =>
font.fontFamily ===
annotation.style.fontFamily,
) && (
<SelectItem
value={annotation.style.fontFamily}
>
{annotation.style.fontFamily
.split(",")[0]
.replace(/"/g, "")}
</SelectItem>
)}
{fontFamilies.map((font) => (
<SelectItem
key={font.value}
value={font.value}
style={{ fontFamily: font.value }}
>
{font.label}
</SelectItem>
))}
{customFonts.length > 0 && (
<>
<div className="px-2 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Custom Fonts
</div>
{customFonts.map((font) => (
<SelectItem
key={font.id}
value={font.fontFamily}
style={{
fontFamily: font.fontFamily,
}}
>
{font.name}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.size")}
</label>
<Select
value={annotation.style.fontSize.toString()}
onValueChange={(value) =>
onStyleChange({ fontSize: parseInt(value) })
}
>
<SelectTrigger className="w-full h-9 text-xs">
<SelectValue
placeholder={t("annotations.size")}
/>
</SelectTrigger>
<SelectContent className="max-h-[200px]">
{FONT_SIZES.map((size) => (
<SelectItem
key={size}
value={size.toString()}
>
{size}px
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<ToggleGroup
type="single"
value={annotation.style.textAlign}
size="sm"
className="justify-start"
>
<ToggleGroupItem
value="left"
aria-label={t("annotations.alignLeft")}
onClick={() => onStyleChange({ textAlign: "left" })}
className="h-8 w-8"
>
<AlignLeft className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="center"
aria-label={t("annotations.alignCenter")}
onClick={() => onStyleChange({ textAlign: "center" })}
className="h-8 w-8"
>
<AlignCenter className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="right"
aria-label={t("annotations.alignRight")}
onClick={() => onStyleChange({ textAlign: "right" })}
className="h-8 w-8"
>
<AlignRight className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Colors */}
<div className="grid grid-cols-2 gap-4">
{/* Add Custom Font Button */}
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.textColor")}
</label>
<ColorControl
value={annotation.style.color}
label={t("annotations.textColor")}
onChange={(color) => onStyleChange({ color })}
colors={colorPalette}
compact
<AddCustomFontDialog
onFontAdded={(font) => {
setCustomFonts(getCustomFonts());
onStyleChange({ fontFamily: font.fontFamily });
}}
/>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.background")}
</label>
<ColorControl
value={annotation.style.backgroundColor}
label={t("annotations.background")}
onChange={(color) =>
onStyleChange({ backgroundColor: color })
}
colors={colorPalette}
compact
onClear={() =>
onStyleChange({ backgroundColor: "transparent" })
}
/>
{/* Formatting Toggles */}
<div className="flex flex-wrap items-center justify-between gap-2">
<ToggleGroup
type="multiple"
value={[
...(annotation.style.fontWeight === "bold"
? ["bold"]
: []),
...(annotation.style.fontStyle === "italic"
? ["italic"]
: []),
...(annotation.style.textDecoration === "underline"
? ["underline"]
: []),
]}
size="sm"
className="justify-start"
>
<ToggleGroupItem
value="bold"
aria-label={t("annotations.toggleBold")}
onClick={() =>
onStyleChange({
fontWeight:
annotation.style.fontWeight === "bold"
? "normal"
: "bold",
})
}
className="h-8 w-8 min-w-8 p-0"
>
<Bold className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="italic"
aria-label={t("annotations.toggleItalic")}
onClick={() =>
onStyleChange({
fontStyle:
annotation.style.fontStyle === "italic"
? "normal"
: "italic",
})
}
className="h-8 w-8 min-w-8 p-0"
>
<Italic className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="underline"
aria-label={t("annotations.toggleUnderline")}
onClick={() =>
onStyleChange({
textDecoration:
annotation.style.textDecoration ===
"underline"
? "none"
: "underline",
})
}
className="h-8 w-8 min-w-8 p-0"
>
<Underline className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup
type="single"
value={annotation.style.textAlign}
size="sm"
className="justify-start"
>
<ToggleGroupItem
value="left"
aria-label={t("annotations.alignLeft")}
onClick={() => onStyleChange({ textAlign: "left" })}
className="h-8 w-8 min-w-8 p-0"
>
<AlignLeft className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="center"
aria-label={t("annotations.alignCenter")}
onClick={() =>
onStyleChange({ textAlign: "center" })
}
className="h-8 w-8 min-w-8 p-0"
>
<AlignCenter className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="right"
aria-label={t("annotations.alignRight")}
onClick={() =>
onStyleChange({ textAlign: "right" })
}
className="h-8 w-8 min-w-8 p-0"
>
<AlignRight className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Colors */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.textColor")}
</label>
<ColorControl
value={annotation.style.color}
label={t("annotations.textColor")}
onChange={(color) => onStyleChange({ color })}
colors={colorPalette}
compact
/>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.background")}
</label>
<ColorControl
value={annotation.style.backgroundColor}
label={t("annotations.background")}
onChange={(color) =>
onStyleChange({ backgroundColor: color })
}
colors={colorPalette}
compact
onClear={() =>
onStyleChange({
backgroundColor: "transparent",
})
}
/>
</div>
</div>
</div>
</div>
</TabsContent>
)}
{/* Image Upload */}
<TabsContent value="image" className="mt-0 space-y-4 p-0">
<input
type="file"
ref={fileInputRef}
onChange={handleImageUpload}
accept=".jpg,.jpeg,.png,.gif,.webp,image/*"
className="hidden"
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="outline"
className="w-full gap-2 py-8"
>
<Upload className="w-5 h-5" />
{t("annotations.uploadImage")}
</Button>
{annotation.type === "image" && (
<div className="space-y-4">
<input
type="file"
ref={fileInputRef}
onChange={handleImageUpload}
accept=".jpg,.jpeg,.png,.gif,.webp,image/*"
className="hidden"
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="outline"
className="w-full gap-2 py-8"
>
<Upload className="w-5 h-5" />
{t("annotations.uploadImage")}
</Button>
{annotation.content && annotation.content.startsWith("data:image") && (
<div className="rounded-lg border border-foreground/10 overflow-hidden bg-foreground/5 p-2">
<img
src={annotation.content}
alt="Uploaded annotation"
className="w-full h-auto rounded-md"
/>
</div>
)}
{annotation.content &&
annotation.content.startsWith("data:image") && (
<div className="rounded-lg border border-foreground/10 overflow-hidden bg-foreground/5 p-2">
<img
src={annotation.content}
alt="Uploaded annotation"
className="w-full h-auto rounded-md"
/>
</div>
)}
<p className="text-xs text-muted-foreground/70 text-center leading-relaxed">
{t("annotations.supportedFormats")}
</p>
</TabsContent>
<p className="text-xs text-muted-foreground/70 text-center leading-relaxed">
{t("annotations.supportedFormats")}
</p>
</div>
)}
<TabsContent value="figure" className="mt-0 space-y-4 p-0">
<div>
<label className="text-sm font-medium text-foreground mb-3 block">
{t("annotations.arrowDirection")}
</label>
<div className="grid grid-cols-4 gap-2">
{(
[
"up",
"down",
"left",
"right",
"up-right",
"up-left",
"down-right",
"down-left",
] as ArrowDirection[]
).map((direction) => {
const ArrowComponent = getArrowComponent(direction);
return (
<ToggleButton
isSelected={
annotation.figureData?.arrowDirection ===
direction
}
key={direction}
onClick={() => {
const newFigureData: FigureData = {
...annotation.figureData!,
arrowDirection: direction,
};
onFigureDataChange?.(newFigureData);
}}
aria-label={t(
"annotations.arrowDirectionOption",
"Arrow direction: {{direction}}",
{ direction: direction.replace(/-/g, " ") },
)}
className={cn(
"h-16 flex items-center justify-center p-2",
)}
>
<ArrowComponent
color={
{annotation.type === "figure" && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-foreground mb-3 block">
{t("annotations.arrowDirection")}
</label>
<div className="grid grid-cols-4 gap-2">
{(
[
"up",
"down",
"left",
"right",
"up-right",
"up-left",
"down-right",
"down-left",
] as ArrowDirection[]
).map((direction) => {
const ArrowComponent = getArrowComponent(direction);
return (
<ToggleButton
isSelected={
annotation.figureData?.arrowDirection ===
direction
? "#ffffff"
: "#94a3b8"
}
strokeWidth={3}
/>
</ToggleButton>
);
})}
</div>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.strokeWidth", undefined, {
width: annotation.figureData?.strokeWidth || 4,
})}
</label>
<Slider
aria-label={t("annotations.strokeWidth", undefined, {
width: annotation.figureData?.strokeWidth || 4,
})}
value={[annotation.figureData?.strokeWidth || 4]}
onValueChange={([value]) => {
const newFigureData: FigureData = {
...annotation.figureData!,
strokeWidth: value,
};
onFigureDataChange?.(newFigureData);
}}
min={1}
max={6}
step={1}
className="my-3 w-full"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.arrowColor")}
</label>
<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 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-sm font-medium text-foreground">
{t("annotations.blurStrength", undefined, {
strength: annotation.blurIntensity ?? 20,
})}
</span>
</div>
<Slider
aria-label={t("annotations.blurStrength", undefined, {
strength: annotation.blurIntensity ?? 20,
key={direction}
onClick={() => {
const newFigureData: FigureData = {
...annotation.figureData!,
arrowDirection: direction,
};
onFigureDataChange?.(newFigureData);
}}
aria-label={t(
"annotations.arrowDirectionOption",
"Arrow direction: {{direction}}",
{ direction: direction.replace(/-/g, " ") },
)}
className={cn(
"h-16 flex items-center justify-center p-2",
)}
>
<ArrowComponent
color={
annotation.figureData
?.arrowDirection === direction
? "#ffffff"
: "#94a3b8"
}
strokeWidth={3}
/>
</ToggleButton>
);
})}
value={[annotation.blurIntensity ?? 20]}
onValueChange={([value]) => onBlurIntensityChange?.(value)}
</div>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.strokeWidth", undefined, {
width: annotation.figureData?.strokeWidth || 4,
})}
</label>
<Slider
aria-label={t("annotations.strokeWidth", undefined, {
width: annotation.figureData?.strokeWidth || 4,
})}
value={[annotation.figureData?.strokeWidth || 4]}
onValueChange={([value]) => {
const newFigureData: FigureData = {
...annotation.figureData!,
strokeWidth: value,
};
onFigureDataChange?.(newFigureData);
}}
min={1}
max={100}
max={6}
step={1}
className="w-full"
className="my-3 w-full"
/>
</div>
<div className="w-full space-y-5 mt-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">
{t(
"annotations.solidColor",
"Solid Color (Censorship)",
)}
</span>
</div>
<div className="flex flex-col gap-3">
<Button
variant="secondary"
aria-pressed={
!annotation.blurColor ||
annotation.blurColor === "transparent"
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.arrowColor")}
</label>
<ColorControl
value={annotation.figureData?.color || "#2563EB"}
label={t("annotations.arrowColor")}
onChange={(color) =>
onFigureDataChange?.({
...annotation.figureData!,
color,
})
}
colors={colorPalette}
compact
/>
</div>
</div>
)}
{annotation.type === "blur" && (
<div className="space-y-4">
<div className="flex flex-col items-center">
<div className="w-full space-y-5">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">
{t("annotations.blurStrength", undefined, {
strength: annotation.blurIntensity ?? 20,
})}
</span>
</div>
<Slider
aria-label={t("annotations.blurStrength", undefined, {
strength: annotation.blurIntensity ?? 20,
})}
value={[annotation.blurIntensity ?? 20]}
onValueChange={([value]) =>
onBlurIntensityChange?.(value)
}
onClick={() => onBlurColorChange?.("")}
>
{t("annotations.none", "None")}
</Button>
<ColorPalette
color={annotation.blurColor || "transparent"}
colors={colorPalette}
onChange={({ hex }) => onBlurColorChange?.(hex)}
/>
<ColorControl
label={t("annotations.customColor", "Custom color")}
value={annotation.blurColor || "#000000"}
onChange={(color) => onBlurColorChange?.(color)}
min={1}
max={100}
step={1}
className="w-full"
/>
</div>
<div className="w-full space-y-5 mt-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">
{t(
"annotations.solidColor",
"Solid Color (Censorship)",
)}
</span>
</div>
<div className="flex flex-col gap-3">
<Button
variant="secondary"
aria-pressed={
!annotation.blurColor ||
annotation.blurColor === "transparent"
}
onClick={() => onBlurColorChange?.("")}
>
{t("annotations.none", "None")}
</Button>
<ColorPalette
color={annotation.blurColor || "transparent"}
colors={colorPalette}
onChange={({ hex }) => onBlurColorChange?.(hex)}
/>
<ColorControl
label={t("annotations.customColor", "Custom color")}
value={annotation.blurColor || "#000000"}
onChange={(color) => onBlurColorChange?.(color)}
/>
</div>
</div>
</div>
</div>
</TabsContent>
</Tabs>
)}
</div>
<div className="mt-6 p-3 bg-foreground/5 rounded-lg border border-foreground/5">
<div className="flex items-center gap-2 mb-2 text-muted-foreground">
<details className="mt-4 text-muted-foreground">
<summary className="flex cursor-pointer items-center gap-2 py-2">
<Info className="w-3.5 h-3.5" />
<span className="text-xs font-medium">
{t("annotations.shortcutsAndTips")}
</span>
</div>
<ul className="text-[10px] text-muted-foreground space-y-1.5 list-disc pl-3 leading-relaxed">
</summary>
<ul className="text-xs text-muted-foreground space-y-1.5 list-disc pl-3 leading-relaxed">
<li>{t("annotations.tipSelectAnnotation")}</li>
<li>{t("annotations.tipCycleForward")}</li>
<li>{t("annotations.tipCycleBackward")}</li>
</ul>
</div>
</details>
</div>
</div>
<div className="shrink-0 px-5 py-4">
@@ -1,4 +1,4 @@
import { Card, Chip, Popover } from "@heroui/react";
import { Card, Popover } from "@heroui/react";
import { useEffect, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { toFileUrl } from "./projectPersistence";
@@ -55,9 +55,9 @@ export default function ProjectBrowserDialog({
const content = (
<Card
ref={panelRef}
className="w-[300px] max-w-[calc(100vw-24px)] rounded-none bg-transparent p-1 shadow-none"
className="w-[340px] max-w-[calc(100vw-24px)] rounded-none bg-transparent p-4 shadow-none"
>
<Card.Header className="flex-row items-center justify-between">
<Card.Header className="flex-row items-center justify-between gap-3">
<Card.Title>Projects</Card.Title>
{onImportFile && (
<Button size="sm" variant="secondary" onClick={onImportFile}>
@@ -65,19 +65,23 @@ export default function ProjectBrowserDialog({
</Button>
)}
</Card.Header>
<Card.Content className="max-h-80 overflow-auto">
<Card.Content className="max-h-80 overflow-auto min-w-0">
{visibleEntries.length ? (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
{visibleEntries.map((entry) => (
<Button
key={entry.path}
variant="ghost"
onClick={() => onOpenProject(entry.path)}
onClick={() => {
onOpenProject(entry.path);
onOpenChange(false);
}}
aria-label={entry.name}
aria-current={entry.isCurrent ? "true" : undefined}
className="h-auto min-w-0 flex-col items-stretch gap-2 p-2"
title={entry.name}
className="h-auto w-full min-w-0 justify-start gap-3 rounded-lg p-2"
>
<div className="relative aspect-video overflow-hidden rounded-lg bg-default">
<div className="relative aspect-video w-18 shrink-0 overflow-hidden rounded-md bg-default">
{entry.thumbnailPath ? (
<img
src={toFileUrl(entry.thumbnailPath)}
@@ -86,21 +90,19 @@ export default function ProjectBrowserDialog({
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full items-center justify-center text-xs text-muted">
No preview yet
<span className="flex h-full items-center justify-center text-[10px] text-muted">
No preview
</span>
)}
{entry.isCurrent && (
<Chip
size="sm"
color="accent"
className="absolute right-1 top-1"
>
Current
</Chip>
)}
</div>
<span className="truncate text-left text-xs">{entry.name}</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 text-left">
<span className="truncate text-[13px]">{entry.name}</span>
<span className="text-[11px] text-muted">
{entry.isCurrent
? "Current project"
: new Date(entry.updatedAt).toLocaleDateString()}
</span>
</span>
</Button>
))}
</div>
@@ -116,8 +118,7 @@ export default function ProjectBrowserDialog({
<Popover isOpen={open} onOpenChange={onOpenChange}>
<Popover.Content
triggerRef={anchorRef}
placement={preferredDirection === "up" ? "top end" : "bottom end"}
isNonModal
placement={preferredDirection === "up" ? "top end" : "bottom start"}
>
<Popover.Dialog aria-label="Projects" className="p-0">
{content}
+145 -120
View File
@@ -1133,6 +1133,7 @@ export function SettingsPanel({
);
const removeBackgroundEnabled = aspectRatio === "native" && isZeroPadding(padding);
const [backgroundDirection, setBackgroundDirection] = useState(1);
const [backgroundTab, setBackgroundTab] = useState<BackgroundTab>(() =>
getBackgroundTabForWallpaper(selected),
);
@@ -1341,7 +1342,7 @@ export function SettingsPanel({
const wallpaperTileClass = (isSelected: boolean) =>
cn(
"group relative aspect-[4/3] h-auto w-full min-w-0 overflow-hidden rounded-md p-1",
isSelected && "ring-2 ring-accent",
isSelected && "opacity-70",
);
const crop = cropRegion ?? {
@@ -1620,7 +1621,13 @@ export function SettingsPanel({
type="single"
value={backgroundTab}
onValueChange={(value) => {
if (value) setBackgroundTab(value as typeof backgroundTab);
if (value) {
const order = ["image", "video", "color", "gradient"];
setBackgroundDirection(
order.indexOf(value) > order.indexOf(backgroundTab) ? 1 : -1,
);
setBackgroundTab(value as typeof backgroundTab);
}
}}
aria-label={tSettings("background.title", "Background type")}
className="grid w-full grid-cols-4 gap-2"
@@ -1643,13 +1650,25 @@ export function SettingsPanel({
))}
</ChoiceGroup>
<div className="pt-4">
<AnimatePresence mode="wait" initial={false}>
<div className="pt-4 overflow-hidden">
<AnimatePresence mode="wait" initial={false} custom={backgroundDirection}>
<motion.div
key={backgroundTab}
initial={{ opacity: 0, y: 10, filter: "blur(8px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -8, filter: "blur(6px)" }}
custom={backgroundDirection}
variants={{
enter: (direction: number) => ({
x: `${direction * 100}%`,
opacity: 0,
}),
center: { x: 0, opacity: 1 },
exit: (direction: number) => ({
x: `${direction * -100}%`,
opacity: 0,
}),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.2, ease: "easeOut" }}
>
{backgroundTab === "image" || backgroundTab === "video" ? (
@@ -2027,16 +2046,18 @@ export function SettingsPanel({
</div>
<div className="py-2 space-y-3">
<div>
<Button
type="button"
variant="outline"
onClick={onPickWhisperModel}
className="h-9 w-full px-4 text-sm"
>
{tSettings("captions.selectModel", "Select Model")}
</Button>
</div>
{advanced && (
<div>
<Button
type="button"
variant="outline"
onClick={onPickWhisperModel}
className="h-9 w-full px-4 text-sm"
>
{tSettings("captions.useCustomModel", "Use custom")}
</Button>
</div>
)}
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-foreground">
{tSettings("captions.language", "Language")}
@@ -2057,8 +2078,32 @@ export function SettingsPanel({
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-foreground">
{tSettings("captions.animation", "Animation")}
</div>
<Select
value={autoCaptionSettings.animationStyle}
onValueChange={(value) =>
updateAutoCaptionSettings({
animationStyle: value as AutoCaptionAnimation,
})
}
>
<SelectTrigger className="h-9 w-[180px] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CAPTION_ANIMATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="grid w-full grid-cols-2 gap-2">
<div className="grid w-full grid-cols-1 gap-2">
{whisperModelDownloadStatus === "downloading" ? (
<Button type="button" disabled className="h-9 w-full px-4 text-sm">
{tSettings("captions.downloading", "Downloading...")}{" "}
@@ -2133,57 +2178,24 @@ export function SettingsPanel({
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3 py-2">
<div className="text-xs text-muted-foreground">
{tSettings("captions.animation", "Animation")}
</div>
<Select
value={autoCaptionSettings.animationStyle}
onValueChange={(value) =>
updateAutoCaptionSettings({
animationStyle: value as AutoCaptionAnimation,
})
}
>
<SelectTrigger className="h-9 w-[160px] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CAPTION_ANIMATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between gap-3 py-2">
{advanced && (
{advanced && (
<div className="flex items-center justify-between gap-3 py-2">
<div className="text-xs text-muted-foreground">
{tSettings("captions.timelineQuickAdd", "Hover to add on timeline")}
</div>
)}
<Switch
checked={autoCaptionSettings.timelineQuickAdd}
onCheckedChange={(timelineQuickAdd) =>
updateAutoCaptionSettings({ timelineQuickAdd })
}
aria-label={tSettings(
"captions.timelineQuickAdd",
"Hover to add on timeline",
)}
/>
</div>
<label className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">
{tSettings("captions.textColor", "Text color")}
</span>
<ColorControl
label={tSettings("captions.textColor", "Text color")}
value={autoCaptionSettings.textColor}
onChange={(color) => updateAutoCaptionSettings({ textColor: color })}
/>
</label>
<Switch
checked={autoCaptionSettings.timelineQuickAdd}
onCheckedChange={(timelineQuickAdd) =>
updateAutoCaptionSettings({ timelineQuickAdd })
}
aria-label={tSettings(
"captions.timelineQuickAdd",
"Hover to add on timeline",
)}
/>
</div>
)}
<div className="mb-1 text-sm font-medium text-foreground">
{tSettings("captions.fontSettings", "Font Settings")}
</div>
@@ -2198,6 +2210,17 @@ export function SettingsPanel({
formatValue={(value) => `${Math.round(value)}px`}
parseInput={(text) => parseFloat(text.replace(/px$/, ""))}
/>
<div className="flex items-center justify-between py-2">
<span className="text-sm font-medium text-foreground">
{tSettings("captions.textColor", "Text color")}
</span>
<ColorControl
compact
label={tSettings("captions.textColor", "Text color")}
value={autoCaptionSettings.textColor}
onChange={(color) => updateAutoCaptionSettings({ textColor: color })}
/>
</div>
{advanced && (
<SliderControl
label={tSettings("captions.rowCount", "Rows")}
@@ -2604,18 +2627,7 @@ export function SettingsPanel({
<section className="flex flex-col gap-4">
{selectedZoomId && (
<>
<div className="flex items-center justify-between gap-3">
<SectionLabel>{tSettings("sections.zoom", "Zoom")}</SectionLabel>
{selectedZoomDepth && (
<span className="rounded-full bg-[#2563EB]/10 px-2 py-0.5 text-xs font-medium uppercase tracking-wider text-[#2563EB]">
{
ZOOM_DEPTH_OPTIONS.find(
(o) => o.depth === selectedZoomDepth,
)?.label
}
</span>
)}
</div>
<SectionLabel>{tSettings("zoom.mode", "Mode")}</SectionLabel>
<div className="mb-1">
<ChoiceGroup
aria-label="Zoom mode"
@@ -2642,6 +2654,7 @@ export function SettingsPanel({
)}
</p>
</div>
<SectionLabel>{tSettings("zoom.amount", "Amount")}</SectionLabel>
<ChoiceGroup
aria-label="Zoom level"
value={String(selectedZoomDepth)}
@@ -2656,7 +2669,6 @@ export function SettingsPanel({
</ChoiceItem>
))}
</ChoiceGroup>
<div className="h-px bg-foreground/[0.06] my-1" />
</>
)}
<div className="flex items-center justify-between gap-3">
@@ -2859,24 +2871,26 @@ export function SettingsPanel({
{t("common.actions.reset", "Reset")}
</Button>
</div>
<div className="flex items-center justify-between gap-3">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{tSettings("effects.showCursor")}</span>
<Switch
aria-label={tSettings("effects.showCursor")}
checked={showCursor}
onCheckedChange={onShowCursorChange}
/>
</label>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{tSettings("effects.loopCursor")}</span>
<Switch
aria-label={tSettings("effects.loopCursor")}
checked={loopCursor}
onCheckedChange={onLoopCursorChange}
/>
</label>
</div>
{advanced && (
<div className="flex flex-col gap-3">
<label className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>{tSettings("effects.showCursor")}</span>
<Switch
aria-label={tSettings("effects.showCursor")}
checked={showCursor}
onCheckedChange={onShowCursorChange}
/>
</label>
<label className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>{tSettings("effects.loopCursor")}</span>
<Switch
aria-label={tSettings("effects.loopCursor")}
checked={loopCursor}
onCheckedChange={onLoopCursorChange}
/>
</label>
</div>
)}
</div>
<div className="flex flex-col gap-3">
<div className="space-y-1.5">
@@ -3099,26 +3113,37 @@ export function SettingsPanel({
onCheckedChange={(enabled) => updateWebcam({ enabled })}
/>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">
{tSettings("effects.webcamReactToZoom")}
</span>
<Switch
aria-label={tSettings("effects.webcamReactToZoom")}
checked={webcam?.reactToZoom ?? DEFAULT_WEBCAM_REACT_TO_ZOOM}
onCheckedChange={(reactToZoom) => updateWebcam({ reactToZoom })}
/>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">
{tSettings("effects.webcamMirror", "Mirror webcam")}
</span>
<Switch
aria-label={tSettings("effects.webcamMirror", "Mirror webcam")}
checked={webcam?.mirror ?? true}
onCheckedChange={(mirror) => updateWebcam({ mirror })}
/>
</div>
{advanced && (
<>
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">
{tSettings("effects.webcamReactToZoom")}
</span>
<Switch
aria-label={tSettings("effects.webcamReactToZoom")}
checked={
webcam?.reactToZoom ?? DEFAULT_WEBCAM_REACT_TO_ZOOM
}
onCheckedChange={(reactToZoom) =>
updateWebcam({ reactToZoom })
}
/>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">
{tSettings("effects.webcamMirror", "Mirror webcam")}
</span>
<Switch
aria-label={tSettings(
"effects.webcamMirror",
"Mirror webcam",
)}
checked={webcam?.mirror ?? true}
onCheckedChange={(mirror) => updateWebcam({ mirror })}
/>
</div>
</>
)}
<SliderControl
label={tSettings("effects.webcamWidth", "Webcam Width")}
value={webcamWidth}
@@ -3318,12 +3343,12 @@ export function SettingsPanel({
tSettings("effects.webcamFootageDescription")}
</div>
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
<div className="grid grid-cols-1 gap-2">
<Button
type="button"
variant="outline"
onClick={onUploadWebcam}
className="h-9 min-w-0 gap-2 px-3"
className="h-9 w-full min-w-0 justify-start gap-2 px-3"
>
<Upload className="h-3 w-3" />
<span className="min-w-0 truncate">
@@ -3337,7 +3362,7 @@ export function SettingsPanel({
type="button"
variant="outline"
onClick={onClearWebcam}
className="h-9 min-w-0 gap-2 px-3"
className="h-9 w-full min-w-0 justify-start gap-2 px-3"
>
<Trash2 className="h-3 w-3" />
<span className="min-w-0 truncate">
@@ -1,9 +1,8 @@
import { Button, ToggleButton } from "@heroui/react";
import { Plus, X } from "@phosphor-icons/react";
import { Check, Plus, X } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import { getRenderableVideoUrl } from "@/lib/assetPath";
import { isVideoWallpaperSource } from "@/lib/wallpapers";
import { cn } from "@/lib/utils";
interface WallpaperTile {
key: string;
@@ -48,10 +47,7 @@ export function WallpaperGrid({
isSelected={selected}
aria-label={item.label}
onChange={() => onSelect(item.value)}
className={cn(
"relative aspect-[4/3] h-auto w-full min-w-0 overflow-hidden rounded-md p-0",
selected && "ring-2 ring-accent ring-offset-2 ring-offset-surface",
)}
className="relative aspect-[4/3] h-auto w-full min-w-0 overflow-hidden rounded-md p-0"
>
{isVideoWallpaperSource(item.previewUrl) ? (
<WallpaperVideoPreview src={item.previewUrl} />
@@ -63,6 +59,11 @@ export function WallpaperGrid({
className="absolute inset-0 h-full w-full select-none object-cover"
/>
)}
{selected && (
<span className="absolute bottom-1 left-1 flex size-4 items-center justify-center rounded-full bg-black/65 text-white">
<Check className="size-3" />
</span>
)}
</ToggleButton>
{item.removable && (
<Button
@@ -99,7 +99,7 @@ export function EditorExportMenu(props: Props) {
if (open) handleOpenExportDropdown();
else setShowExportDropdown(false);
}}
modal={false}
modal={true}
>
<PopoverTrigger asChild>
<Button
@@ -43,8 +43,14 @@ export function EditorPresetMenu({ t, presets }: Props) {
<CaretDown className="h-3.5 w-3.5 text-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={10} className="w-[300px] p-3">
<div className="space-y-3">
<PopoverContent
align="end"
sideOffset={10}
aria-label="Presets"
className="w-[320px] p-4"
>
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("editor.presets.label", "Presets")}</h3>
<form
onSubmit={(event) => {
event.preventDefault();
@@ -52,29 +58,29 @@ export function EditorPresetMenu({ t, presets }: Props) {
}}
className="space-y-2"
>
<p className="text-[11px] font-medium text-foreground">
<p className="text-xs font-medium text-foreground">
{t("editor.presets.saveCurrentAs", "Save current preset as")}
</p>
<div className="flex items-center gap-2">
<Input
value={presetNameDraft}
onChange={(event) => setPresetNameDraft(event.target.value)}
className="h-9 text-sm"
className="h-9 min-w-0 flex-1 text-[13px]"
placeholder={t("editor.presets.namePlaceholder", "Preset name")}
aria-label={t("editor.presets.namePlaceholder", "Preset name")}
/>
<Button type="submit" size="sm" className="h-9 px-3">
<Button type="submit" size="sm" className="h-9 shrink-0 px-3">
{t("common.actions.save", "Save")}
</Button>
</div>
</form>
<div className="space-y-2">
<p className="text-[11px] font-medium text-foreground">
<p className="text-xs font-medium text-foreground">
{t("editor.presets.savedList", "Saved presets")}
</p>
<div className="max-h-56 space-y-1 overflow-y-auto pr-1 custom-scrollbar">
{editorPresets.length === 0 ? (
<div className="rounded-xl border border-dashed border-foreground/10 px-3 py-4 text-center text-[11px] text-muted-foreground">
<div className="rounded-lg bg-default px-3 py-4 text-center text-[11px] text-muted-foreground">
{t("editor.presets.empty", "No presets yet.")}
</div>
) : (
@@ -84,17 +90,17 @@ export function EditorPresetMenu({ t, presets }: Props) {
<div
key={preset.id}
className={cn(
"flex items-center gap-2 rounded-xl border px-2 py-2 text-sm transition-colors",
"flex min-w-0 items-center gap-1 rounded-lg p-1 text-[13px] transition-colors",
isActive
? "border-[#2563EB]/20 bg-[#2563EB]/10 text-foreground"
: "border-foreground/8 bg-foreground/[0.03] text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
? "bg-default text-foreground"
: "text-muted hover:bg-default hover:text-foreground",
)}
>
<Button
variant="ghost"
type="button"
onClick={() => handleApplyEditorPreset(preset.id)}
className="flex min-w-0 flex-1 items-center justify-between text-left"
className="flex h-8 min-w-0 flex-1 items-center justify-between px-2 text-left text-[13px]"
>
<span className="truncate pr-3">{preset.name}</span>
{isActive ? (
@@ -105,7 +111,8 @@ export function EditorPresetMenu({ t, presets }: Props) {
variant="ghost"
type="button"
onClick={() => handleDeleteEditorPreset(preset.id)}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center"
size="icon"
className="inline-flex h-7 w-7 min-w-7 shrink-0 items-center justify-center p-0"
aria-label={t(
"editor.presets.deleteAriaLabel",
"Delete preset {{name}}",
@@ -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-14 shrink-0 items-center justify-center gap-3">
<div className="flex h-10 shrink-0 items-center justify-center gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -161,7 +161,7 @@ export function EditorPreviewPanel(props: Props) {
</Button>
</div>
<div
className="flex min-h-0 w-full flex-1 items-stretch px-6 py-5"
className="flex min-h-0 w-full flex-1 items-stretch px-4 py-3"
style={{ flex: "1 1 auto", margin: 0 }}
>
<div
@@ -59,7 +59,7 @@ export function EditorTimelinePanel(props: Props) {
return (
<div
className="flex flex-shrink-0 flex-col bg-transparent px-4 pb-4 pt-2"
style={{ height: "22%", minHeight: 180, maxHeight: 280 }}
style={{ height: "18%", minHeight: 148, maxHeight: 220 }}
>
<TimelineEditor
ref={timelineRef}
+60 -81
View File
@@ -3,22 +3,25 @@ import {
Gauge,
ChatCircle as MessageSquare,
MusicNotes as Music,
MouseLeftClickIcon as PhMouseLeftClick,
Scissors,
SpeakerX,
MagnifyingGlassPlus as ZoomIn,
} from "@phosphor-icons/react";
import { ClipFilmstrip } from "./components/filmstrip/ClipFilmstrip";
import type { Span } from "dnd-timeline";
import { useItem } from "dnd-timeline";
import { useMemo } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { formatClipSpeedLabel } from "../clipSpeedChange";
import { formatPlayheadTime } from "./core/time";
import AudioWaveform from "./components/waveform/AudioWaveform";
import type { AudioPeaksData } from "./core/timelineTypes";
import glassStyles from "./ItemGlass.module.css";
interface ItemProps {
videoPath?: string | null;
sourceSpan?: Span;
id: string;
span: Span;
rowId: string;
@@ -50,18 +53,10 @@ const ZOOM_LABELS: Record<number, string> = {
6: "5×",
};
function formatMs(ms: number): string {
const totalSeconds = ms / 1000;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes > 0) {
return `${minutes}:${seconds.toFixed(1).padStart(4, "0")}`;
}
return `${seconds.toFixed(1)}s`;
}
export default function Item({
id,
videoPath,
sourceSpan,
span,
rowId,
disabled = false,
@@ -89,7 +84,7 @@ export default function Item({
});
const timeLabel = useMemo(
() => `${formatMs(span.start)} – ${formatMs(span.end)}`,
() => `${formatPlayheadTime(span.start)} – ${formatPlayheadTime(span.end)}`,
[span.start, span.end],
);
@@ -106,6 +101,7 @@ export default function Item({
{...listeners}
{...attributes}
data-timeline-item="true"
data-variant={variant}
onMouseDownCapture={(event) => event.stopPropagation()}
onClickCapture={(event) => event.stopPropagation()}
>
@@ -161,6 +157,7 @@ export default function Item({
{...listeners}
{...attributes}
data-timeline-item="true"
data-variant={variant}
onPointerDownCapture={handleSelect}
className="group h-full"
>
@@ -177,18 +174,26 @@ export default function Item({
<div
className={cn(
glassClass,
"w-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
"timeline-block w-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
isSelected && glassStyles.selected,
)}
style={{
height: "85%",
minHeight: 22,
minWidth: MIN_ITEM_PX,
containerType: "inline-size",
}}
onClick={(event) => {
event.stopPropagation();
}}
>
{isClip && videoPath && (
<ClipFilmstrip
path={videoPath}
span={span}
sourceSpan={sourceSpan ?? span}
/>
)}
<div
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
style={{ cursor: "col-resize", pointerEvents: "auto" }}
@@ -216,77 +221,51 @@ export default function Item({
</div>
)}
{/* Content */}
<div className="relative z-10 flex flex-col items-center justify-center text-black/70 dark:text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none overflow-hidden">
<div className="flex items-center gap-1.5">
{isZoom ? (
<>
<ZoomIn className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
</>
) : isTrim ? (
<>
<Scissors className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
Trim
</span>
</>
) : isClip ? (
<>
<Film className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
Clip
</span>
{clipSpeedLabel && (
<span className="rounded-[4px] bg-black/10 px-1 text-[9px] font-bold tabular-nums text-black/65 dark:bg-white/15 dark:text-white/80">
{clipSpeedLabel}
</span>
)}
</>
) : isSpeed ? (
<>
<Gauge className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{speedValue !== undefined ? `${speedValue}×` : "Speed"}
</span>
</>
) : isAudio ? (
<>
<Music className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight truncate max-w-full">
{children}
</span>
</>
) : (
<>
<MessageSquare className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{children}
</span>
</>
)}
</div>
<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 ? (
<div
className={`flex items-center gap-0.5 transition-opacity ${isSelected ? "opacity-70" : "opacity-0 group-hover:opacity-50"}`}
>
<PhMouseLeftClick
className="w-2.5 h-2.5 shrink-0"
weight={zoomMode === "manual" ? "regular" : "fill"}
/>
<span className="text-[9px] font-medium tracking-tight whitespace-nowrap">
{zoomMode === "manual" ? "Manual" : "Auto"}
<>
<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>
</div>
</>
) : (
<span
className={`text-[9px] tabular-nums tracking-tight whitespace-nowrap transition-opacity ${
isSelected ? "opacity-60" : "opacity-0 group-hover:opacity-40"
}`}
>
{timeLabel}
</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}
</span>
)}
</>
)}
</div>
</div>
+8 -1
View File
@@ -2,6 +2,7 @@ import type { RowDefinition } from "dnd-timeline";
import { useRow } from "dnd-timeline";
interface RowProps extends RowDefinition {
compact?: boolean;
children: React.ReactNode;
label?: string;
hint?: string;
@@ -16,6 +17,7 @@ interface RowProps extends RowDefinition {
export default function Row({
id,
compact = false,
children,
label,
hint,
@@ -32,7 +34,12 @@ export default function Row({
return (
<div
className="bg-transparent relative flex-1 min-h-[26px]"
style={{ ...rowWrapperStyle, marginBottom: 2 }}
style={{
...rowWrapperStyle,
marginBottom: 2,
flexGrow: compact ? 0 : 1,
flexBasis: compact ? 32 : undefined,
}}
>
{label && (
<div
@@ -480,6 +480,7 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
timelineRef={timelineContainerRef}
/>
<TimelineCanvas
videoPath={videoPath}
items={timelineItems}
videoDurationMs={totalMs}
currentTimeMs={currentTimeMs}
@@ -13,8 +13,8 @@ export default function TimelineAxis({ videoDurationMs, currentTimeMs }: Timelin
const sideProperty = direction === "rtl" ? "right" : "left";
const { intervalMs } = useMemo(
() => calculateAxisScale(range.end - range.start),
[range.end, range.start],
() => calculateAxisScale(range.end - range.start, valueToPixels(range.end - range.start)),
[range.end, range.start, valueToPixels],
);
const markers = useMemo(() => {
@@ -32,11 +32,8 @@ export default function TimelineAxis({ videoDurationMs, currentTimeMs }: Timelin
markerTimes.add(Math.round(time));
}
if (visibleStart <= maxTime) markerTimes.add(Math.round(visibleStart));
if (videoDurationMs > 0) markerTimes.add(Math.round(videoDurationMs));
const sorted = Array.from(markerTimes)
.filter((time) => time <= maxTime)
.filter((time) => time >= visibleStart && time <= visibleEnd)
.sort((a, b) => a - b);
const minorTicks: number[] = [];
@@ -54,7 +51,7 @@ export default function TimelineAxis({ videoDurationMs, currentTimeMs }: Timelin
return (
<div
className="h-8 bg-editor-bg border-b border-foreground/10 relative overflow-hidden select-none"
className="timeline-axis h-8 bg-editor-bg relative overflow-hidden select-none"
style={{
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth}px`,
}}
@@ -80,7 +77,16 @@ export default function TimelineAxis({ videoDurationMs, currentTimeMs }: Timelin
flexDirection: "row",
alignItems: "flex-end",
[sideProperty]: `${offset}px`,
transform: direction === "rtl" ? "translateX(50%)" : "translateX(-50%)",
transform:
offset < 30
? "none"
: offset > valueToPixels(range.end - range.start) - 30
? direction === "rtl"
? "translateX(100%)"
: "translateX(-100%)"
: direction === "rtl"
? "translateX(50%)"
: "translateX(-50%)",
};
return (
@@ -91,7 +97,7 @@ export default function TimelineAxis({ videoDurationMs, currentTimeMs }: Timelin
className={cn(
"text-[10px] font-medium tabular-nums tracking-tight",
Math.abs(marker.time - currentTimeMs) < 1
? "text-[#2563EB]"
? "text-red-500"
: "text-foreground/40",
)}
>
@@ -0,0 +1,74 @@
import { useTimelineContext, type Span } from "dnd-timeline";
import { useEffect, useRef, useState } from "react";
import { filmstripSampleTimes } from "../../core/filmstrip";
import { extractFilmstrip } from "./frameCache";
export function ClipFilmstrip({
path,
span,
sourceSpan,
}: {
path: string;
span: Span;
sourceSpan: Span;
}) {
const { range } = useTimelineContext();
const ref = useRef<HTMLDivElement>(null);
const [count, setCount] = useState(0);
const [frames, setFrames] = useState<string[]>([]);
useEffect(() => {
const node = ref.current;
if (!node) return;
const observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
setCount(
width > 0
? Math.min(32, Math.max(1, Math.ceil(width / Math.max(60, (height * 16) / 9))))
: 0,
);
});
observer.observe(node);
return () => observer.disconnect();
}, []);
const start = Math.max(span.start, range.start);
const end = Math.min(span.end, range.end);
useEffect(() => {
const controller = new AbortController();
setFrames([]);
const times = filmstripSampleTimes(
{ start: span.start, end: span.end },
{ start: sourceSpan.start, end: sourceSpan.end },
{ start, end },
count,
);
if (times.length) {
void extractFilmstrip(path, times, controller.signal)
.then((images) => {
if (!controller.signal.aborted) setFrames(images);
})
.catch(() => {
/* Keep the clip's color when a source cannot be decoded. */
});
}
return () => controller.abort();
}, [path, span.start, span.end, sourceSpan.start, sourceSpan.end, start, end, count]);
return (
<div
ref={ref}
data-testid="clip-filmstrip"
className="pointer-events-none absolute inset-0 flex overflow-hidden"
aria-hidden="true"
>
{frames.map((frame, index) => (
<img
key={index}
src={frame}
alt=""
draggable={false}
className="h-full min-w-0 flex-1 object-cover"
style={{ width: `${100 / frames.length}%` }}
/>
))}
</div>
);
}
@@ -0,0 +1,101 @@
import { getRenderableVideoUrl } from "@/lib/assetPath";
// One background decoder at a time; never seek the editor's playback element.
let queue: Promise<unknown> = Promise.resolve();
const frames = new Map<string, string>();
const MAX_CACHED_FRAMES = 256;
function waitForVideo(video: HTMLVideoElement, event: string, signal: AbortSignal) {
return new Promise<void>((resolve, reject) => {
const cleanup = () => {
clearTimeout(timeout);
video.removeEventListener(event, ready);
video.removeEventListener("error", failed);
signal.removeEventListener("abort", failed);
};
const ready = () => {
cleanup();
resolve();
};
const failed = () => {
cleanup();
reject(new Error("Frame extraction cancelled or unavailable"));
};
const timeout = setTimeout(failed, 8000);
video.addEventListener(event, ready, { once: true });
video.addEventListener("error", failed, { once: true });
signal.addEventListener("abort", failed, { once: true });
if (signal.aborted) failed();
});
}
export function extractFilmstrip(
path: string,
times: number[],
signal: AbortSignal,
): Promise<string[]> {
const job = queue.then(async () => {
if (signal.aborted) return [];
const keys = times.map((time) => `${path}:${Math.round(time)}`);
if (keys.every((key) => frames.has(key))) return keys.map((key) => frames.get(key)!);
const url = await getRenderableVideoUrl(path);
if (signal.aborted) return [];
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.preload = "auto";
video.crossOrigin = "anonymous";
const canvas = document.createElement("canvas");
canvas.width = 160;
canvas.height = 90;
const context = canvas.getContext("2d");
if (!context) return [];
try {
const loaded = waitForVideo(video, "loadeddata", signal);
video.src = url;
await loaded;
const result: string[] = [];
for (let index = 0; index < times.length; index++) {
if (signal.aborted) return [];
const cached = frames.get(keys[index]);
if (cached) {
result.push(cached);
continue;
}
const time = Math.max(0, Math.min(times[index] / 1000, video.duration - 0.001));
if (Math.abs(video.currentTime - time) > 0.0001) {
const sought = waitForVideo(video, "seeked", signal);
video.currentTime = time;
await sought;
}
// Some non-seekable sources silently reset to zero; don't show misleading copies.
if (Math.abs(video.currentTime - time) > 0.12)
throw new Error("Source does not support accurate seeking");
const scale = Math.max(
canvas.width / video.videoWidth,
canvas.height / video.videoHeight,
);
const width = video.videoWidth * scale;
const height = video.videoHeight * scale;
context.drawImage(
video,
(canvas.width - width) / 2,
(canvas.height - height) / 2,
width,
height,
);
const frame = canvas.toDataURL("image/jpeg", 0.7);
frames.set(keys[index], frame);
if (frames.size > MAX_CACHED_FRAMES) frames.delete(frames.keys().next().value!);
result.push(frame);
}
return result;
} finally {
video.pause();
video.removeAttribute("src");
video.load();
}
});
queue = job.catch(() => undefined);
return job;
}
@@ -30,7 +30,10 @@ export default function PlaybackCursor({
const handleMouseMove = (e: MouseEvent) => {
if (!timelineRef.current || !onSeek) return;
const rect = timelineRef.current.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
const clickX =
direction === "rtl"
? rect.right - sidebarWidth - e.clientX
: e.clientX - rect.left - sidebarWidth;
const relativeMs = pixelsToValue(clickX);
let absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
@@ -70,6 +73,7 @@ export default function PlaybackCursor({
videoDurationMs,
pixelsToValue,
keyframes,
direction,
]);
if (videoDurationMs <= 0 || currentTimeMs < 0) return null;
@@ -80,6 +84,7 @@ export default function PlaybackCursor({
return (
<div
data-testid="timeline-playhead"
className="absolute top-0 bottom-0 z-50 group/cursor"
style={{
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth - 1}px`,
@@ -87,7 +92,7 @@ export default function PlaybackCursor({
}}
>
<div
className="absolute top-0 bottom-0 w-[2px] bg-accent cursor-ew-resize pointer-events-auto"
className="absolute top-0 bottom-0 w-px bg-white shadow-[0_0_0_1px_#ef4444] cursor-ew-resize pointer-events-auto"
style={{ [sideProperty]: `${offset}px` }}
onMouseDown={(e) => {
e.stopPropagation();
@@ -98,7 +103,7 @@ export default function PlaybackCursor({
className="absolute -top-1 left-1/2 -translate-x-1/2 hover:scale-125 transition-transform"
style={{ width: "16px", height: "16px" }}
>
<div className="w-3 h-3 mx-auto mt-[2px] bg-accent rotate-45 rounded-sm" />
<div className="w-3 h-3 mx-auto mt-[2px] bg-red-500 rotate-45 rounded-sm" />
</div>
<div
className={cn(
@@ -42,7 +42,6 @@ import {
TIMELINE_AXIS_HEIGHT_PX,
} from "../../timelineLayout";
import TimelineAxis from "../axis/TimelineAxis";
import ClipMarkerOverlay from "../overlays/ClipMarkerOverlay";
import PlaybackCursor from "../playhead/PlaybackCursor";
const HINT_CLIP = "Press C to split clip";
@@ -50,6 +49,7 @@ const HINT_ANNOTATION = "Press A to add annotation";
const HINT_AUDIO = "Click music icon to add audio";
interface TimelineCanvasProps {
videoPath?: string | null;
items: TimelineRenderItem[];
videoDurationMs: number;
currentTimeMs: number;
@@ -359,6 +359,7 @@ function useTimelineHover({
}
interface TimelineCanvasRowsProps {
videoPath?: string | null;
items: TimelineRenderItem[];
videoDurationMs: number;
selectAllBlocksActive: boolean;
@@ -438,8 +439,8 @@ function AudioItemWithWaveform({
}
const TimelineCanvasRows = memo(function TimelineCanvasRows({
videoPath,
items,
videoDurationMs,
selectAllBlocksActive,
selectedZoomId,
selectedClipId,
@@ -538,7 +539,6 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
return (
<>
<Row id={CLIP_ROW_ID} isEmpty={clipItems.length === 0} hint={HINT_CLIP}>
<ClipMarkerOverlay videoDurationMs={videoDurationMs} />
{clipItems.map((item) => (
<Item
id={item.id}
@@ -548,6 +548,8 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
isSelected={item.id === selectedClipId}
onSelectId={onSelectClip}
variant="clip"
videoPath={videoPath}
sourceSpan={item.sourceSpan ?? item.span}
speedValue={item.speedValue}
>
{item.label}
@@ -588,6 +590,7 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
<Row
id={ZOOM_ROW_ID}
compact
isEmpty={zoomItems.length === 0}
onMouseEnter={onZoomRowMouseEnter}
onMouseMove={onZoomRowMouseMove}
@@ -747,6 +750,7 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
});
export default function TimelineCanvas({
videoPath,
items,
videoDurationMs,
currentTimeMs,
@@ -1032,6 +1036,7 @@ export default function TimelineCanvas({
style={{ minHeight: timelineRowsMinHeightPx }}
>
<TimelineCanvasRows
videoPath={videoPath}
items={items}
videoDurationMs={videoDurationMs}
selectAllBlocksActive={selectAllBlocksActive}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { filmstripSampleTimes } from "./filmstrip";
describe("filmstrip source sampling", () => {
it("samples a trimmed and sped-up clip in its original source", () => {
expect(
filmstripSampleTimes(
{ start: 2000, end: 4000 },
{ start: 10000, end: 14000 },
{ start: 0, end: 6000 },
2,
),
).toEqual([11000, 13000]);
});
it("only decodes the visible portion when zoomed or scrolled", () => {
expect(
filmstripSampleTimes(
{ start: 2000, end: 4000 },
{ start: 10000, end: 14000 },
{ start: 3000, end: 4000 },
2,
),
).toEqual([12500, 13500]);
});
it("does not sample offscreen or zero-duration clips", () => {
expect(
filmstripSampleTimes(
{ start: 0, end: 1000 },
{ start: 0, end: 1000 },
{ start: 2000, end: 3000 },
10,
),
).toEqual([]);
expect(
filmstripSampleTimes(
{ start: 0, end: 0 },
{ start: 0, end: 1000 },
{ start: 0, end: 1000 },
10,
),
).toEqual([]);
});
});
@@ -0,0 +1,15 @@
import type { Span } from "dnd-timeline";
/** Sample the visible edit in source coordinates, including source trims and speed. */
export function filmstripSampleTimes(span: Span, source: Span, visible: Span, count: number) {
const start = Math.max(span.start, visible.start);
const end = Math.min(span.end, visible.end);
if (end <= start || span.end <= span.start || count < 1) return [];
return Array.from({ length: count }, (_, index) => {
const timelineMs = start + ((index + 0.5) / count) * (end - start);
return (
source.start +
((timelineMs - span.start) / (span.end - span.start)) * (source.end - source.start)
);
});
}
@@ -56,3 +56,16 @@ describe("timeline core/time", () => {
expect(huge.intervalMs).toBeGreaterThanOrEqual(typical.intervalMs);
});
});
describe("readable timeline ruler", () => {
it("carries fractional rounding into the next minute", () => {
expect(formatTimeLabel(59999, 100)).toBe("1:00.00");
expect(formatPlayheadTime(59999)).toBe("1:00.0");
expect(formatTimeLabel(3599999, 100)).toBe("1:00:00.00");
});
it("reduces tick density in narrow windows", () => {
expect(calculateAxisScale(10000, 400).intervalMs).toBeGreaterThan(
calculateAxisScale(10000, 1600).intervalMs,
);
});
});
@@ -27,17 +27,21 @@ const SCALE_CANDIDATES = [
{ intervalSeconds: 3600, gridSeconds: 300 },
];
export function calculateAxisScale(visibleRangeMs: number): {
export function calculateAxisScale(
visibleRangeMs: number,
widthPx?: number,
): {
intervalMs: number;
gridMs: number;
} {
const visibleSeconds = visibleRangeMs / 1000;
const markerCount = widthPx ? Math.max(2, Math.floor(widthPx / 80)) : TARGET_MARKER_COUNT;
const candidate =
SCALE_CANDIDATES.find((scaleCandidate) => {
if (visibleSeconds <= 0) {
return true;
}
return visibleSeconds / scaleCandidate.intervalSeconds <= TARGET_MARKER_COUNT;
return visibleSeconds / scaleCandidate.intervalSeconds <= markerCount;
}) ?? SCALE_CANDIDATES[SCALE_CANDIDATES.length - 1];
return {
@@ -85,32 +89,20 @@ export function normalizeWheelDeltaToPixels(delta: number, deltaMode: number) {
}
export function formatTimeLabel(milliseconds: number, intervalMs: number) {
const totalSeconds = milliseconds / 1000;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const fractionalDigits = intervalMs < 250 ? 2 : intervalMs < 1000 ? 1 : 0;
if (hours > 0) {
const minutesString = minutes.toString().padStart(2, "0");
const secondsString = Math.floor(seconds).toString().padStart(2, "0");
return `${hours}:${minutesString}:${secondsString}`;
}
if (fractionalDigits > 0) {
const secondsWithFraction = seconds.toFixed(fractionalDigits);
const [wholeSeconds, fraction] = secondsWithFraction.split(".");
return `${minutes}:${wholeSeconds.padStart(2, "0")}.${fraction}`;
}
return `${minutes}:${Math.floor(seconds).toString().padStart(2, "0")}`;
const digits = intervalMs < 250 ? 2 : intervalMs < 1000 ? 1 : 0;
const factor = 10 ** digits;
const safeMs = Number.isFinite(milliseconds) ? Math.max(0, milliseconds) : 0;
const total =
(digits ? Math.round((safeMs / 1000) * factor) : Math.floor(safeMs / 1000)) / factor;
const hours = Math.floor(total / 3600);
const minutes = Math.floor(total / 60) % 60;
const seconds = (total % 60).toFixed(digits).padStart(digits ? 3 + digits : 2, "0");
return hours
? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}`
: `${minutes}:${seconds}`;
}
export function formatPlayheadTime(ms: number): string {
const s = ms / 1000;
const min = Math.floor(s / 60);
const sec = s % 60;
if (min > 0) return `${min}:${sec.toFixed(1).padStart(4, "0")}`;
return `${sec.toFixed(1)}s`;
const rounded = Math.round(Math.max(0, ms) / 100) * 100;
return rounded < 60000 ? `${(rounded / 1000).toFixed(1)}s` : formatTimeLabel(rounded, 500);
}
@@ -31,11 +31,11 @@ describe("timelineLayout", () => {
);
});
it("stretches content height to keep a two-row viewport", () => {
expect(TIMELINE_VISIBLE_ROW_COUNT).toBe(2);
it("stretches content height to keep three compact rows visible", () => {
expect(TIMELINE_VISIBLE_ROW_COUNT).toBe(3);
expect(getTimelineViewportStretchFactor(2)).toBe(1);
expect(getTimelineViewportStretchFactor(4)).toBe(2);
expect(getTimelineViewportStretchFactor(5)).toBe(2.5);
expect(getTimelineViewportStretchFactor(3)).toBe(1);
expect(getTimelineViewportStretchFactor(6)).toBe(2);
expect(getTimelineViewportStretchFactor(0)).toBe(1);
});
});
@@ -1,6 +1,6 @@
export const TIMELINE_AXIS_HEIGHT_PX = 32;
export const TIMELINE_ROW_MIN_HEIGHT_PX = 28;
export const TIMELINE_VISIBLE_ROW_COUNT = 2;
export const TIMELINE_VISIBLE_ROW_COUNT = 3;
function normalizeRowCount(rowCount: number) {
if (!Number.isFinite(rowCount)) {
+1 -1
View File
@@ -191,7 +191,7 @@ export const DEFAULT_ZOOM_OUT_EASING: ZoomTransitionEasing = "recordly";
export const DEFAULT_CONNECTED_ZOOM_EASING: ZoomTransitionEasing = "glide";
export const DEFAULT_WEBCAM_SIZE = 40;
export const DEFAULT_WEBCAM_REACT_TO_ZOOM = true;
export const DEFAULT_WEBCAM_ROUNDNESS = 100;
export const DEFAULT_WEBCAM_ROUNDNESS = 69;
export const DEFAULT_WEBCAM_SHADOW = 0.3;
export const DEFAULT_WEBCAM_MARGIN = 24;
export const DEFAULT_WEBCAM_POSITION_PRESET: WebcamPositionPreset = "bottom-right";
+29
View File
@@ -126,3 +126,32 @@
display: none;
}
}
/* highlight rings look ugly — keep them transparent; keyboard focus gets an inset underline. */
:root,
.dark {
--focus: transparent;
}
[data-focus-visible="true"] {
box-shadow: inset 0 -2px 0 color-mix(in oklab, var(--foreground) 45%, transparent);
}
@container (max-width: 110px) {
.zoom-mode {
display: none;
}
}
@container (max-width: 48px) {
.zoom-icon {
display: none;
}
}
@container (max-width: 28px) {
.zoom-value {
display: none;
}
.zoom-icon {
display: block;
}
}