Restore editor preset header menu

This commit is contained in:
webadderall
2026-05-05 17:05:44 +10:00
parent 467f1aa0e6
commit f287dd3132
2 changed files with 440 additions and 14 deletions
+426 -4
View File
@@ -1,4 +1,5 @@
import {
BookmarkSimple,
Check,
CaretDown as ChevronDown,
CaretUp as ChevronUp,
@@ -38,6 +39,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Toaster } from "@/components/ui/sonner";
import { useI18n } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
@@ -106,7 +109,12 @@ import { ExportSettingsMenu } from "./ExportSettingsMenu";
import ExtensionManager from "./ExtensionManager";
import {
loadEditorPreferences,
loadEditorPresets,
saveEditorPreferences,
saveEditorPresets,
serializeEditorPresetSnapshot,
type EditorPreset,
type EditorPresetSnapshot,
} from "./editorPreferences";
import ProjectBrowserDialog, { type ProjectLibraryEntry } from "./ProjectBrowserDialog";
import {
@@ -734,6 +742,10 @@ export default function VideoEditor() {
const [exportedFilePath, setExportedFilePath] = useState<string | undefined>(undefined);
const [hasPendingExportSave, setHasPendingExportSave] = useState(false);
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<EditorProjectData | null>(null);
const [editorPresets, setEditorPresets] = useState<EditorPreset[]>(() => loadEditorPresets());
const [activeEditorPresetId, setActiveEditorPresetId] = useState<string | null>(null);
const [presetPopoverOpen, setPresetPopoverOpen] = useState(false);
const [presetNameDraft, setPresetNameDraft] = useState("");
const [showCropModal, setShowCropModal] = useState(false);
const [previewVersion, setPreviewVersion] = useState(0);
const [isPreviewReady, setIsPreviewReady] = useState(false);
@@ -811,6 +823,297 @@ export default function VideoEditor() {
setHistoryVersion((version) => version + 1);
}, []);
const captureEditorPresetSnapshot = useCallback(
(): EditorPresetSnapshot => ({
wallpaper,
shadowIntensity,
backgroundBlur,
zoomMotionBlur,
zoomTemporalMotionBlur: zoomMotionBlur,
zoomMotionBlurSampleCount: initialEditorPreferences.zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction:
initialEditorPreferences.zoomMotionBlurShutterFraction,
connectZooms,
zoomInDurationMs,
zoomInOverlapMs,
zoomOutDurationMs,
connectedZoomGapMs,
connectedZoomDurationMs,
zoomInEasing,
zoomOutEasing,
connectedZoomEasing,
showCursor,
loopCursor,
cursorStyle,
cursorSize,
cursorSmoothing,
cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier,
cursorSpringMassMultiplier,
cursorMotionBlur,
cursorClickBounce,
cursorClickBounceDuration,
cursorSway,
borderRadius,
padding: { ...padding },
frame,
webcam: { ...webcam },
aspectRatio,
exportEncodingMode,
exportBackendPreference,
exportPipelineModel,
exportQuality,
mp4FrameRate,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
autoCaptionSettings: { ...autoCaptionSettings },
whisperExecutablePath,
whisperModelPath,
}),
[
wallpaper,
shadowIntensity,
backgroundBlur,
zoomMotionBlur,
initialEditorPreferences.zoomMotionBlurSampleCount,
initialEditorPreferences.zoomMotionBlurShutterFraction,
connectZooms,
zoomInDurationMs,
zoomInOverlapMs,
zoomOutDurationMs,
connectedZoomGapMs,
connectedZoomDurationMs,
zoomInEasing,
zoomOutEasing,
connectedZoomEasing,
showCursor,
loopCursor,
cursorStyle,
cursorSize,
cursorSmoothing,
cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier,
cursorSpringMassMultiplier,
cursorMotionBlur,
cursorClickBounce,
cursorClickBounceDuration,
cursorSway,
borderRadius,
padding,
frame,
webcam,
aspectRatio,
exportEncodingMode,
exportBackendPreference,
exportPipelineModel,
exportQuality,
mp4FrameRate,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
autoCaptionSettings,
whisperExecutablePath,
whisperModelPath,
],
);
const currentPresetSnapshot = useMemo(
() => captureEditorPresetSnapshot(),
[captureEditorPresetSnapshot],
);
const currentPresetSignature = useMemo(
() => serializeEditorPresetSnapshot(currentPresetSnapshot),
[currentPresetSnapshot],
);
const currentEditorPreset = useMemo(
() => editorPresets.find((preset) => preset.id === activeEditorPresetId) ?? null,
[activeEditorPresetId, editorPresets],
);
useEffect(() => {
const activePreset = currentEditorPreset;
if (
activePreset &&
serializeEditorPresetSnapshot(activePreset.snapshot) === currentPresetSignature
) {
return;
}
const matchingPreset =
editorPresets.find(
(preset) =>
serializeEditorPresetSnapshot(preset.snapshot) === currentPresetSignature,
) ?? null;
const nextActivePresetId = matchingPreset?.id ?? null;
if (nextActivePresetId !== activeEditorPresetId) {
setActiveEditorPresetId(nextActivePresetId);
}
}, [activeEditorPresetId, currentEditorPreset, currentPresetSignature, editorPresets]);
useEffect(() => {
if (!presetPopoverOpen) {
setPresetNameDraft("");
}
}, [presetPopoverOpen]);
const applyEditorPresetSnapshot = useCallback((snapshot: EditorPresetSnapshot) => {
setWallpaper(snapshot.wallpaper);
setShadowIntensity(snapshot.shadowIntensity);
setBackgroundBlur(snapshot.backgroundBlur);
setZoomMotionBlur(snapshot.zoomMotionBlur);
setConnectZooms(snapshot.connectZooms);
setZoomInDurationMs(snapshot.zoomInDurationMs);
setZoomInOverlapMs(snapshot.zoomInOverlapMs);
setZoomOutDurationMs(snapshot.zoomOutDurationMs);
setConnectedZoomGapMs(snapshot.connectedZoomGapMs);
setConnectedZoomDurationMs(snapshot.connectedZoomDurationMs);
setZoomInEasing(snapshot.zoomInEasing);
setZoomOutEasing(snapshot.zoomOutEasing);
setConnectedZoomEasing(snapshot.connectedZoomEasing);
setShowCursor(snapshot.showCursor);
setLoopCursor(snapshot.loopCursor);
setCursorStyle(snapshot.cursorStyle);
setCursorSize(snapshot.cursorSize);
setCursorSmoothing(snapshot.cursorSmoothing);
setCursorSpringStiffnessMultiplier(snapshot.cursorSpringStiffnessMultiplier);
setCursorSpringDampingMultiplier(snapshot.cursorSpringDampingMultiplier);
setCursorSpringMassMultiplier(snapshot.cursorSpringMassMultiplier);
setCursorMotionBlur(snapshot.cursorMotionBlur);
setCursorClickBounce(snapshot.cursorClickBounce);
setCursorClickBounceDuration(snapshot.cursorClickBounceDuration);
setCursorSway(snapshot.cursorSway);
setBorderRadius(snapshot.borderRadius);
setPadding({ ...snapshot.padding });
setFrame(snapshot.frame);
setWebcam({ ...snapshot.webcam });
setAspectRatio(snapshot.aspectRatio);
setExportEncodingMode(snapshot.exportEncodingMode);
setExportBackendPreference(snapshot.exportBackendPreference);
setExportPipelineModel(snapshot.exportPipelineModel);
setExportQuality(snapshot.exportQuality);
setMp4FrameRate(snapshot.mp4FrameRate);
setExportFormat(snapshot.exportFormat);
setGifFrameRate(snapshot.gifFrameRate);
setGifLoop(snapshot.gifLoop);
setGifSizePreset(snapshot.gifSizePreset);
setAutoCaptionSettings({ ...snapshot.autoCaptionSettings });
setWhisperExecutablePath(snapshot.whisperExecutablePath);
setWhisperModelPath(snapshot.whisperModelPath);
}, []);
const handleApplyEditorPreset = useCallback(
(presetId: string) => {
const preset = editorPresets.find((item) => item.id === presetId);
if (!preset) {
return;
}
setActiveEditorPresetId(preset.id);
applyEditorPresetSnapshot(preset.snapshot);
toast.success(
t("editor.presets.toasts.applied", 'Applied preset "{{name}}"', {
name: preset.name,
}),
);
},
[applyEditorPresetSnapshot, editorPresets, t],
);
const handleSaveEditorPreset = useCallback(
(name: string) => {
const normalizedName = name.trim().replace(/\s+/g, " ");
if (normalizedName.length === 0) {
toast.error(t("editor.presets.errors.nameRequired", "Enter a preset name."));
return false;
}
const hasDuplicateName = editorPresets.some(
(preset) => preset.name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase(),
);
if (hasDuplicateName) {
toast.error(
t(
"editor.presets.errors.duplicateName",
"A preset with that name already exists.",
),
);
return false;
}
const snapshot = captureEditorPresetSnapshot();
const timestamp = new Date().toISOString();
const nextPreset: EditorPreset = {
id: crypto.randomUUID(),
name: normalizedName,
createdAt: timestamp,
updatedAt: timestamp,
snapshot,
};
const nextPresets: EditorPreset[] = [nextPreset, ...editorPresets];
if (!saveEditorPresets(nextPresets)) {
toast.error(
t(
"editor.presets.errors.saveFailed",
"Could not save that preset. Check your browser storage settings and try again.",
),
);
return false;
}
setEditorPresets(nextPresets);
setActiveEditorPresetId(nextPreset.id);
toast.success(
t("editor.presets.toasts.saved", 'Saved preset "{{name}}"', {
name: normalizedName,
}),
);
return true;
},
[captureEditorPresetSnapshot, editorPresets, t],
);
const handleDeleteEditorPreset = useCallback(
(presetId: string) => {
const preset = editorPresets.find((item) => item.id === presetId);
if (!preset) {
return;
}
const nextPresets = editorPresets.filter((item) => item.id !== presetId);
if (!saveEditorPresets(nextPresets)) {
toast.error(
t(
"editor.presets.errors.deleteFailed",
"Could not delete that preset. Check your browser storage settings and try again.",
),
);
return;
}
setEditorPresets(nextPresets);
if (preset.id === activeEditorPresetId) {
setActiveEditorPresetId(null);
}
toast.success(
t("editor.presets.toasts.deleted", 'Deleted preset "{{name}}"', {
name: preset.name,
}),
);
},
[activeEditorPresetId, editorPresets, t],
);
const handleSavePresetSubmit = useCallback(() => {
const didSave = handleSaveEditorPreset(presetNameDraft);
if (didSave) {
setPresetNameDraft("");
}
}, [handleSaveEditorPreset, presetNameDraft]);
const clearPendingExportSave = useCallback(() => {
const pending = pendingExportSaveRef.current;
pendingExportSaveRef.current = null;
@@ -934,7 +1237,7 @@ export default function VideoEditor() {
previewWidth,
previewHeight,
cursorTelemetry,
effectiveShowCursor,
showCursor: effectiveShowCursor,
cursorStyle,
cursorSize,
cursorSmoothing,
@@ -4032,7 +4335,7 @@ export default function VideoEditor() {
autoCaptionSettings,
zoomRegions: effectiveZoomRegions,
cursorTelemetry: effectiveCursorTelemetry,
effectiveShowCursor,
showCursor: effectiveShowCursor,
cursorStyle,
cursorSize,
cursorSmoothing,
@@ -4207,7 +4510,7 @@ export default function VideoEditor() {
autoCaptionSettings,
zoomRegions: effectiveZoomRegions,
cursorTelemetry: effectiveCursorTelemetry,
effectiveShowCursor,
showCursor: effectiveShowCursor,
cursorStyle,
cursorSize,
cursorSmoothing,
@@ -4954,9 +5257,128 @@ export default function VideoEditor() {
)}
</div>
<div
className="flex items-center gap-2 justify-self-end pr-3"
className="flex items-center justify-self-end pr-3"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<Popover open={presetPopoverOpen} onOpenChange={setPresetPopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
title={t("editor.presets.open", "Open presets")}
aria-label={t("editor.presets.open", "Open presets")}
className="inline-flex items-center gap-1.5 bg-transparent p-0 text-sm font-medium tracking-tight text-foreground outline-none transition-opacity hover:opacity-80"
>
<span className="flex items-center gap-1.5">
<BookmarkSimple weight="fill" className="h-4 w-4" />
<span>
{currentEditorPreset?.name ?? t("editor.presets.label", "Presets")}
</span>
</span>
<ChevronDown className="h-3.5 w-3.5 text-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={10}
className="w-[300px] rounded-2xl border border-foreground/10 bg-editor-surface-alt p-3 shadow-xl"
>
<div className="space-y-3">
<form
onSubmit={(event) => {
event.preventDefault();
handleSavePresetSubmit();
}}
className="space-y-2"
>
<p className="text-[11px] 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 rounded-xl border-foreground/10 bg-background/70 text-sm"
placeholder={t(
"editor.presets.namePlaceholder",
"Preset name",
)}
aria-label={t(
"editor.presets.namePlaceholder",
"Preset name",
)}
/>
<Button
type="submit"
size="sm"
className="h-9 rounded-xl bg-[#2563EB] px-3 text-white hover:bg-[#1d4ed8]"
>
{t("common.actions.save", "Save")}
</Button>
</div>
</form>
<div className="space-y-2">
<p className="text-[11px] 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">
{t("editor.presets.empty", "No presets yet.")}
</div>
) : (
editorPresets.map((preset) => {
const isActive = preset.id === currentEditorPreset?.id;
return (
<div
key={preset.id}
className={cn(
"flex items-center gap-2 rounded-xl border px-2 py-2 text-sm 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",
)}
>
<button
type="button"
onClick={() => handleApplyEditorPreset(preset.id)}
className="flex min-w-0 flex-1 items-center justify-between text-left"
>
<span className="truncate pr-3">{preset.name}</span>
{isActive ? (
<Check className="h-3.5 w-3.5 shrink-0 text-[#2563EB]" />
) : null}
</button>
<button
type="button"
onClick={() => handleDeleteEditorPreset(preset.id)}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-foreground/8 hover:text-foreground"
aria-label={t(
"editor.presets.deleteAriaLabel",
"Delete preset {{name}}",
{ name: preset.name },
)}
title={t(
"editor.presets.deleteAriaLabel",
"Delete preset {{name}}",
{ name: preset.name },
)}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
})
)}
</div>
</div>
</div>
</PopoverContent>
</Popover>
<div
aria-hidden="true"
className="mx-2 h-4 w-px shrink-0 bg-foreground/10 opacity-0"
/>
<DropdownMenu
open={showExportDropdown}
onOpenChange={setShowExportDropdown}
@@ -240,6 +240,17 @@ function normalizeEditorPreset(candidate: unknown): EditorPreset | null {
};
}
function normalizeEditorPresets(candidates: unknown): EditorPreset[] {
if (!Array.isArray(candidates)) {
return [];
}
return candidates
.map((item) => normalizeEditorPreset(item))
.filter((preset): preset is EditorPreset => preset !== null)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
export function serializeEditorPresetSnapshot(snapshot: EditorPresetSnapshot): string {
return JSON.stringify(normalizeEditorPresetSnapshot(snapshot));
}
@@ -429,15 +440,7 @@ export function loadEditorPresets(): EditorPreset[] {
return [];
}
const parsed = JSON.parse(stored);
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.map((item) => normalizeEditorPreset(item))
.filter((preset): preset is EditorPreset => preset !== null)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
return normalizeEditorPresets(JSON.parse(stored));
} catch {
return [];
}
@@ -449,7 +452,8 @@ export function saveEditorPresets(presets: EditorPreset[]): boolean {
}
try {
globalThis.localStorage.setItem(EDITOR_PRESETS_STORAGE_KEY, JSON.stringify(presets));
const normalized = normalizeEditorPresets(presets);
globalThis.localStorage.setItem(EDITOR_PRESETS_STORAGE_KEY, JSON.stringify(normalized));
return true;
} catch {
// Ignore storage failures so editor controls still work.