mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-10-04 19:47:41 +00:00
Refine editor panels and add source-frame timeline filmstrips
This commit is contained in:
@@ -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}`}
|
||||
|
||||
@@ -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")} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user