mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 15:55:35 +00:00
Restore editor preset header menu
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user