mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
codebase overhaul (#697)
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import type { ElementType } from "react";
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
ArrowRightDoubleIcon,
|
||||
ClosedCaptionIcon,
|
||||
Folder03Icon,
|
||||
Happy01Icon,
|
||||
HeadphonesIcon,
|
||||
MagicWand05Icon,
|
||||
TextIcon,
|
||||
Settings01Icon,
|
||||
SlidersHorizontalIcon,
|
||||
ColorsIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
|
||||
|
||||
export const TAB_KEYS = [
|
||||
"media",
|
||||
"sounds",
|
||||
"text",
|
||||
"stickers",
|
||||
"effects",
|
||||
"transitions",
|
||||
"captions",
|
||||
"filters",
|
||||
"adjustment",
|
||||
"settings",
|
||||
] as const;
|
||||
|
||||
export type Tab = (typeof TAB_KEYS)[number];
|
||||
|
||||
const createHugeiconsIcon =
|
||||
({ icon }: { icon: IconSvgElement }) =>
|
||||
({ className }: { className?: string }) => (
|
||||
<HugeiconsIcon icon={icon} className={className} />
|
||||
);
|
||||
|
||||
export const tabs = {
|
||||
media: {
|
||||
icon: createHugeiconsIcon({ icon: Folder03Icon }),
|
||||
label: "Media",
|
||||
},
|
||||
sounds: {
|
||||
icon: createHugeiconsIcon({ icon: HeadphonesIcon }),
|
||||
label: "Sounds",
|
||||
},
|
||||
text: {
|
||||
icon: createHugeiconsIcon({ icon: TextIcon }),
|
||||
label: "Text",
|
||||
},
|
||||
stickers: {
|
||||
icon: createHugeiconsIcon({ icon: Happy01Icon }),
|
||||
label: "Stickers",
|
||||
},
|
||||
effects: {
|
||||
icon: createHugeiconsIcon({ icon: MagicWand05Icon }),
|
||||
label: "Effects",
|
||||
},
|
||||
transitions: {
|
||||
icon: createHugeiconsIcon({ icon: ArrowRightDoubleIcon }),
|
||||
label: "Transitions",
|
||||
},
|
||||
captions: {
|
||||
icon: createHugeiconsIcon({ icon: ClosedCaptionIcon }),
|
||||
label: "Captions",
|
||||
},
|
||||
filters: {
|
||||
icon: createHugeiconsIcon({ icon: ColorsIcon }),
|
||||
label: "Filters",
|
||||
},
|
||||
adjustment: {
|
||||
icon: createHugeiconsIcon({ icon: SlidersHorizontalIcon }),
|
||||
label: "Adjustment",
|
||||
},
|
||||
settings: {
|
||||
icon: createHugeiconsIcon({ icon: Settings01Icon }),
|
||||
label: "Settings",
|
||||
},
|
||||
} satisfies Record<
|
||||
Tab,
|
||||
{ icon: ElementType<{ className?: string }>; label: string }
|
||||
>;
|
||||
|
||||
type MediaViewMode = "grid" | "list";
|
||||
|
||||
interface AssetsPanelStore {
|
||||
activeTab: Tab;
|
||||
setActiveTab: (tab: Tab) => void;
|
||||
highlightMediaId: string | null;
|
||||
requestRevealMedia: (mediaId: string) => void;
|
||||
clearHighlight: () => void;
|
||||
|
||||
/* Media */
|
||||
mediaViewMode: MediaViewMode;
|
||||
setMediaViewMode: (mode: MediaViewMode) => void;
|
||||
}
|
||||
|
||||
export const useAssetsPanelStore = create<AssetsPanelStore>((set) => ({
|
||||
activeTab: "media",
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
highlightMediaId: null,
|
||||
requestRevealMedia: (mediaId) =>
|
||||
set({ activeTab: "media", highlightMediaId: mediaId }),
|
||||
clearHighlight: () => set({ highlightMediaId: null }),
|
||||
mediaViewMode: "grid",
|
||||
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
|
||||
}));
|
||||
@@ -1,91 +1,70 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { CanvasPreset } from "@/types/editor";
|
||||
|
||||
export type PlatformLayout = "tiktok";
|
||||
|
||||
export const PLATFORM_LAYOUTS: Record<PlatformLayout, string> = {
|
||||
tiktok: "TikTok",
|
||||
};
|
||||
import type { TPlatformLayout } from "@/types/editor";
|
||||
import { DEFAULT_CANVAS_PRESETS } from "@/constants/project-constants";
|
||||
import type { TCanvasSize } from "@/types/project";
|
||||
|
||||
interface LayoutGuideSettings {
|
||||
platform: PlatformLayout | null;
|
||||
platform: TPlatformLayout | null;
|
||||
}
|
||||
|
||||
interface EditorState {
|
||||
// Loading states
|
||||
isInitializing: boolean;
|
||||
isPanelsReady: boolean;
|
||||
|
||||
// Editor UI settings
|
||||
canvasPresets: CanvasPreset[];
|
||||
layoutGuide: LayoutGuideSettings;
|
||||
|
||||
// Actions
|
||||
setInitializing: (loading: boolean) => void;
|
||||
setPanelsReady: (ready: boolean) => void;
|
||||
initializeApp: () => Promise<void>;
|
||||
setLayoutGuide: (settings: Partial<LayoutGuideSettings>) => void;
|
||||
toggleLayoutGuide: (platform: PlatformLayout) => void;
|
||||
isInitializing: boolean;
|
||||
isPanelsReady: boolean;
|
||||
canvasPresets: TCanvasSize[];
|
||||
layoutGuide: LayoutGuideSettings;
|
||||
setInitializing: (loading: boolean) => void;
|
||||
setPanelsReady: (ready: boolean) => void;
|
||||
initializeApp: () => Promise<void>;
|
||||
setLayoutGuide: (settings: Partial<LayoutGuideSettings>) => void;
|
||||
toggleLayoutGuide: (platform: TPlatformLayout) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [
|
||||
{ name: "16:9", width: 1920, height: 1080 },
|
||||
{ name: "9:16", width: 1080, height: 1920 },
|
||||
{ name: "1:1", width: 1080, height: 1080 },
|
||||
{ name: "4:3", width: 1440, height: 1080 },
|
||||
];
|
||||
|
||||
export const useEditorStore = create<EditorState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Initial states
|
||||
isInitializing: true,
|
||||
isPanelsReady: false,
|
||||
canvasPresets: DEFAULT_CANVAS_PRESETS,
|
||||
layoutGuide: {
|
||||
platform: null,
|
||||
},
|
||||
persist(
|
||||
(set) => ({
|
||||
isInitializing: true,
|
||||
isPanelsReady: false,
|
||||
canvasPresets: DEFAULT_CANVAS_PRESETS,
|
||||
layoutGuide: {
|
||||
platform: null,
|
||||
},
|
||||
setInitializing: (loading) => {
|
||||
set({ isInitializing: loading });
|
||||
},
|
||||
|
||||
// Actions
|
||||
setInitializing: (loading) => {
|
||||
set({ isInitializing: loading });
|
||||
},
|
||||
setPanelsReady: (ready) => {
|
||||
set({ isPanelsReady: ready });
|
||||
},
|
||||
|
||||
setPanelsReady: (ready) => {
|
||||
set({ isPanelsReady: ready });
|
||||
},
|
||||
initializeApp: async () => {
|
||||
set({ isInitializing: true, isPanelsReady: false });
|
||||
|
||||
initializeApp: async () => {
|
||||
console.log("Initializing video editor...");
|
||||
set({ isInitializing: true, isPanelsReady: false });
|
||||
set({ isPanelsReady: true, isInitializing: false });
|
||||
},
|
||||
|
||||
set({ isPanelsReady: true, isInitializing: false });
|
||||
console.log("Video editor ready");
|
||||
},
|
||||
setLayoutGuide: (settings) => {
|
||||
set((state) => ({
|
||||
layoutGuide: {
|
||||
...state.layoutGuide,
|
||||
...settings,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
setLayoutGuide: (settings) => {
|
||||
set((state) => ({
|
||||
layoutGuide: {
|
||||
...state.layoutGuide,
|
||||
...settings,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
toggleLayoutGuide: (platform) => {
|
||||
set((state) => ({
|
||||
layoutGuide: {
|
||||
platform: state.layoutGuide.platform === platform ? null : platform,
|
||||
},
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "editor-settings",
|
||||
partialize: (state) => ({
|
||||
layoutGuide: state.layoutGuide,
|
||||
}),
|
||||
}
|
||||
)
|
||||
toggleLayoutGuide: (platform) => {
|
||||
set((state) => ({
|
||||
layoutGuide: {
|
||||
platform: state.layoutGuide.platform === platform ? null : platform,
|
||||
},
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "editor-settings",
|
||||
partialize: (state) => ({
|
||||
layoutGuide: state.layoutGuide,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,264 +2,255 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { ActionWithOptionalArgs } from "@/constants/actions";
|
||||
import { isAppleDevice, isDOMElement, isTypableElement } from "@/lib/utils";
|
||||
import { KeybindingConfig, ShortcutKey } from "@/types/keybinding";
|
||||
import type { TActionWithOptionalArgs } from "@/lib/actions";
|
||||
import { getDefaultShortcuts } from "@/lib/actions";
|
||||
import { isTypableDOMElement } from "@/utils/browser";
|
||||
import { isAppleDevice } from "@/utils/platform";
|
||||
import type { KeybindingConfig, ShortcutKey } from "@/types/keybinding";
|
||||
import {
|
||||
runMigrations,
|
||||
CURRENT_VERSION,
|
||||
} from "./keybindings/migrations";
|
||||
|
||||
// Default keybindings configuration
|
||||
export const defaultKeybindings: KeybindingConfig = {
|
||||
space: "toggle-play",
|
||||
j: "seek-backward",
|
||||
k: "toggle-play",
|
||||
l: "seek-forward",
|
||||
left: "frame-step-backward",
|
||||
right: "frame-step-forward",
|
||||
"shift+left": "jump-backward",
|
||||
"shift+right": "jump-forward",
|
||||
home: "goto-start",
|
||||
enter: "goto-start",
|
||||
end: "goto-end",
|
||||
s: "split-element",
|
||||
n: "toggle-snapping",
|
||||
"ctrl+a": "select-all",
|
||||
"ctrl+d": "duplicate-selected",
|
||||
"ctrl+c": "copy-selected",
|
||||
"ctrl+v": "paste-selected",
|
||||
"ctrl+z": "undo",
|
||||
"ctrl+shift+z": "redo",
|
||||
"ctrl+y": "redo",
|
||||
delete: "delete-selected",
|
||||
backspace: "delete-selected",
|
||||
};
|
||||
export const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
|
||||
|
||||
export interface KeybindingConflict {
|
||||
key: ShortcutKey;
|
||||
existingAction: ActionWithOptionalArgs;
|
||||
newAction: ActionWithOptionalArgs;
|
||||
key: ShortcutKey;
|
||||
existingAction: TActionWithOptionalArgs;
|
||||
newAction: TActionWithOptionalArgs;
|
||||
}
|
||||
|
||||
interface KeybindingsState {
|
||||
keybindings: KeybindingConfig;
|
||||
isCustomized: boolean;
|
||||
keybindingsEnabled: boolean;
|
||||
isRecording: boolean;
|
||||
keybindings: KeybindingConfig;
|
||||
isCustomized: boolean;
|
||||
keybindingsEnabled: boolean;
|
||||
isRecording: boolean;
|
||||
|
||||
// Actions
|
||||
updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => void;
|
||||
removeKeybinding: (key: ShortcutKey) => void;
|
||||
resetToDefaults: () => void;
|
||||
importKeybindings: (config: KeybindingConfig) => void;
|
||||
exportKeybindings: () => KeybindingConfig;
|
||||
enableKeybindings: () => void;
|
||||
disableKeybindings: () => void;
|
||||
setIsRecording: (isRecording: boolean) => void;
|
||||
updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void;
|
||||
removeKeybinding: (key: ShortcutKey) => void;
|
||||
resetToDefaults: () => void;
|
||||
importKeybindings: (config: KeybindingConfig) => void;
|
||||
exportKeybindings: () => KeybindingConfig;
|
||||
enableKeybindings: () => void;
|
||||
disableKeybindings: () => void;
|
||||
setIsRecording: (isRecording: boolean) => void;
|
||||
validateKeybinding: (
|
||||
key: ShortcutKey,
|
||||
action: TActionWithOptionalArgs,
|
||||
) => KeybindingConflict | null;
|
||||
getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[];
|
||||
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
|
||||
}
|
||||
|
||||
// Validation
|
||||
validateKeybinding: (
|
||||
key: ShortcutKey,
|
||||
action: ActionWithOptionalArgs
|
||||
) => KeybindingConflict | null;
|
||||
getKeybindingsForAction: (action: ActionWithOptionalArgs) => ShortcutKey[];
|
||||
|
||||
// Utility
|
||||
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
|
||||
function isDOMElement(el: EventTarget | null): el is HTMLElement {
|
||||
return !!el && (el instanceof Element || el instanceof HTMLElement);
|
||||
}
|
||||
|
||||
export const useKeybindingsStore = create<KeybindingsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
keybindings: { ...defaultKeybindings },
|
||||
isCustomized: false,
|
||||
keybindingsEnabled: true,
|
||||
isRecording: false,
|
||||
persist(
|
||||
(set, get) => ({
|
||||
keybindings: { ...defaultKeybindings },
|
||||
isCustomized: false,
|
||||
keybindingsEnabled: true,
|
||||
isRecording: false,
|
||||
|
||||
updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => {
|
||||
set((state) => {
|
||||
const newKeybindings = { ...state.keybindings };
|
||||
newKeybindings[key] = action;
|
||||
updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => {
|
||||
set((state) => {
|
||||
const newKeybindings = { ...state.keybindings };
|
||||
newKeybindings[key] = action;
|
||||
|
||||
return {
|
||||
keybindings: newKeybindings,
|
||||
isCustomized: true,
|
||||
};
|
||||
});
|
||||
},
|
||||
return {
|
||||
keybindings: newKeybindings,
|
||||
isCustomized: true,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeKeybinding: (key: ShortcutKey) => {
|
||||
set((state) => {
|
||||
const newKeybindings = { ...state.keybindings };
|
||||
delete newKeybindings[key];
|
||||
removeKeybinding: (key: ShortcutKey) => {
|
||||
set((state) => {
|
||||
const newKeybindings = { ...state.keybindings };
|
||||
delete newKeybindings[key];
|
||||
|
||||
return {
|
||||
keybindings: newKeybindings,
|
||||
isCustomized: true,
|
||||
};
|
||||
});
|
||||
},
|
||||
return {
|
||||
keybindings: newKeybindings,
|
||||
isCustomized: true,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
resetToDefaults: () => {
|
||||
set({
|
||||
keybindings: { ...defaultKeybindings },
|
||||
isCustomized: false,
|
||||
});
|
||||
},
|
||||
resetToDefaults: () => {
|
||||
set({
|
||||
keybindings: { ...defaultKeybindings },
|
||||
isCustomized: false,
|
||||
});
|
||||
},
|
||||
|
||||
enableKeybindings: () => {
|
||||
set({ keybindingsEnabled: true });
|
||||
},
|
||||
enableKeybindings: () => {
|
||||
set({ keybindingsEnabled: true });
|
||||
},
|
||||
|
||||
disableKeybindings: () => {
|
||||
set({ keybindingsEnabled: false });
|
||||
},
|
||||
disableKeybindings: () => {
|
||||
set({ keybindingsEnabled: false });
|
||||
},
|
||||
|
||||
importKeybindings: (config: KeybindingConfig) => {
|
||||
// Validate all keys and actions
|
||||
for (const [key, action] of Object.entries(config)) {
|
||||
// Validate the key format
|
||||
if (typeof key !== "string" || key.length === 0) {
|
||||
throw new Error(`Invalid key format: ${key}`);
|
||||
}
|
||||
}
|
||||
set({
|
||||
keybindings: { ...config },
|
||||
isCustomized: true,
|
||||
});
|
||||
},
|
||||
importKeybindings: (config: KeybindingConfig) => {
|
||||
// Validate all keys and actions
|
||||
for (const [key] of Object.entries(config)) {
|
||||
// Validate the key format
|
||||
if (typeof key !== "string" || key.length === 0) {
|
||||
throw new Error(`Invalid key format: ${key}`);
|
||||
}
|
||||
}
|
||||
set({
|
||||
keybindings: { ...config },
|
||||
isCustomized: true,
|
||||
});
|
||||
},
|
||||
|
||||
exportKeybindings: () => {
|
||||
return get().keybindings;
|
||||
},
|
||||
exportKeybindings: () => {
|
||||
return get().keybindings;
|
||||
},
|
||||
|
||||
validateKeybinding: (
|
||||
key: ShortcutKey,
|
||||
action: ActionWithOptionalArgs
|
||||
) => {
|
||||
const { keybindings } = get();
|
||||
const existingAction = keybindings[key];
|
||||
validateKeybinding: (
|
||||
key: ShortcutKey,
|
||||
action: TActionWithOptionalArgs,
|
||||
) => {
|
||||
const { keybindings } = get();
|
||||
const existingAction = keybindings[key];
|
||||
|
||||
if (existingAction && existingAction !== action) {
|
||||
return {
|
||||
key,
|
||||
existingAction,
|
||||
newAction: action,
|
||||
};
|
||||
}
|
||||
if (existingAction && existingAction !== action) {
|
||||
return {
|
||||
key,
|
||||
existingAction,
|
||||
newAction: action,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
setIsRecording: (isRecording: boolean) => {
|
||||
set({ isRecording });
|
||||
},
|
||||
return null;
|
||||
},
|
||||
setIsRecording: (isRecording: boolean) => {
|
||||
set({ isRecording });
|
||||
},
|
||||
|
||||
getKeybindingsForAction: (action: ActionWithOptionalArgs) => {
|
||||
const { keybindings } = get();
|
||||
return Object.keys(keybindings).filter(
|
||||
(key) => keybindings[key as ShortcutKey] === action
|
||||
) as ShortcutKey[];
|
||||
},
|
||||
getKeybindingsForAction: (action: TActionWithOptionalArgs) => {
|
||||
const { keybindings } = get();
|
||||
return Object.keys(keybindings).filter(
|
||||
(key) => keybindings[key as ShortcutKey] === action,
|
||||
) as ShortcutKey[];
|
||||
},
|
||||
|
||||
getKeybindingString: (ev: KeyboardEvent) => {
|
||||
return generateKeybindingString(ev) as ShortcutKey | null;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "opencut-keybindings",
|
||||
version: 2,
|
||||
}
|
||||
)
|
||||
getKeybindingString: (ev: KeyboardEvent) => {
|
||||
return generateKeybindingString(ev) as ShortcutKey | null;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "opencut-keybindings",
|
||||
version: CURRENT_VERSION,
|
||||
partialize: (state) => ({
|
||||
keybindings: state.keybindings,
|
||||
isCustomized: state.isCustomized,
|
||||
}),
|
||||
migrate: (persisted, version) =>
|
||||
runMigrations({ state: persisted, fromVersion: version }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Utility functions
|
||||
function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
|
||||
const target = ev.target;
|
||||
const target = ev.target;
|
||||
|
||||
// We may or may not have a modifier key
|
||||
const modifierKey = getActiveModifier(ev);
|
||||
// We may or may not have a modifier key
|
||||
const modifierKey = getActiveModifier(ev);
|
||||
|
||||
// We will always have a non-modifier key
|
||||
const key = getPressedKey(ev);
|
||||
if (!key) return null;
|
||||
// We will always have a non-modifier key
|
||||
const key = getPressedKey(ev);
|
||||
if (!key) return null;
|
||||
|
||||
// All key combos backed by modifiers are valid shortcuts (whether currently typing or not)
|
||||
if (modifierKey) {
|
||||
// If the modifier is shift and the target is an input, we ignore
|
||||
if (
|
||||
modifierKey === "shift" &&
|
||||
isDOMElement(target) &&
|
||||
isTypableElement(target)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// All key combos backed by modifiers are valid shortcuts (whether currently typing or not)
|
||||
if (modifierKey) {
|
||||
// If the modifier is shift and the target is an input, we ignore
|
||||
if (
|
||||
modifierKey === "shift" &&
|
||||
isDOMElement(target) &&
|
||||
isTypableDOMElement({ element: target as HTMLElement })
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${modifierKey}+${key}` as ShortcutKey;
|
||||
}
|
||||
return `${modifierKey}+${key}` as ShortcutKey;
|
||||
}
|
||||
|
||||
// no modifier key here then we do not do anything while on input
|
||||
if (isDOMElement(target) && isTypableElement(target)) return null;
|
||||
// no modifier key here then we do not do anything while on input
|
||||
if (
|
||||
isDOMElement(target) &&
|
||||
isTypableDOMElement({ element: target as HTMLElement })
|
||||
)
|
||||
return null;
|
||||
|
||||
// single key while not input
|
||||
return `${key}` as ShortcutKey;
|
||||
// single key while not input
|
||||
return `${key}` as ShortcutKey;
|
||||
}
|
||||
|
||||
function getPressedKey(ev: KeyboardEvent): string | null {
|
||||
// Sometimes the property code is not available on the KeyboardEvent object
|
||||
const key = (ev.key ?? "").toLowerCase();
|
||||
const code = ev.code ?? "";
|
||||
// Sometimes the property code is not available on the KeyboardEvent object
|
||||
const key = (ev.key ?? "").toLowerCase();
|
||||
const code = ev.code ?? "";
|
||||
|
||||
if (code === "Space" || key === " " || key === "spacebar" || key === "space")
|
||||
return "space";
|
||||
if (code === "Space" || key === " " || key === "spacebar" || key === "space")
|
||||
return "space";
|
||||
|
||||
// Check arrow keys
|
||||
if (key.startsWith("arrow")) {
|
||||
return key.slice(5);
|
||||
}
|
||||
// Check arrow keys
|
||||
if (key.startsWith("arrow")) {
|
||||
return key.slice(5);
|
||||
}
|
||||
|
||||
// Check for special keys
|
||||
if (key === "tab") return "tab";
|
||||
if (key === "home") return "home";
|
||||
if (key === "end") return "end";
|
||||
if (key === "delete") return "delete";
|
||||
if (key === "backspace") return "backspace";
|
||||
// Check for special keys
|
||||
if (key === "tab") return "tab";
|
||||
if (key === "home") return "home";
|
||||
if (key === "end") return "end";
|
||||
if (key === "delete") return "delete";
|
||||
if (key === "backspace") return "backspace";
|
||||
|
||||
// Check letter keys
|
||||
if (code.startsWith("Key")) {
|
||||
const letter = code.slice(3).toLowerCase();
|
||||
if (letter.length === 1 && letter >= "a" && letter <= "z") {
|
||||
return letter;
|
||||
}
|
||||
}
|
||||
// Check letter keys
|
||||
if (code.startsWith("Key")) {
|
||||
const letter = code.slice(3).toLowerCase();
|
||||
if (letter.length === 1 && letter >= "a" && letter <= "z") {
|
||||
return letter;
|
||||
}
|
||||
}
|
||||
|
||||
// Check number keys using physical position for AZERTY support
|
||||
if (code.startsWith("Digit")) {
|
||||
const digit = code.slice(5);
|
||||
if (digit.length === 1 && digit >= "0" && digit <= "9") {
|
||||
return digit;
|
||||
}
|
||||
}
|
||||
// Check number keys using physical position for AZERTY support
|
||||
if (code.startsWith("Digit")) {
|
||||
const digit = code.slice(5);
|
||||
if (digit.length === 1 && digit >= "0" && digit <= "9") {
|
||||
return digit;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for other layouts
|
||||
const isDigit = key.length === 1 && key >= "0" && key <= "9";
|
||||
if (isDigit) return key;
|
||||
// Fallback for other layouts
|
||||
const isDigit = key.length === 1 && key >= "0" && key <= "9";
|
||||
if (isDigit) return key;
|
||||
|
||||
// Check if slash, period or enter
|
||||
if (key === "/" || key === "." || key === "enter") return key;
|
||||
// Check if slash, period or enter
|
||||
if (key === "/" || key === "." || key === "enter") return key;
|
||||
|
||||
// If no other cases match, this is not a valid key
|
||||
return null;
|
||||
// If no other cases match, this is not a valid key
|
||||
return null;
|
||||
}
|
||||
|
||||
function getActiveModifier(ev: KeyboardEvent): string | null {
|
||||
const modifierKeys = {
|
||||
ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey,
|
||||
alt: ev.altKey,
|
||||
shift: ev.shiftKey,
|
||||
};
|
||||
const modifierKeys = {
|
||||
ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey,
|
||||
alt: ev.altKey,
|
||||
shift: ev.shiftKey,
|
||||
};
|
||||
|
||||
// active modifier: ctrl | alt | ctrl+alt | ctrl+shift | ctrl+alt+shift | alt+shift
|
||||
// modiferKeys object's keys are sorted to match the above order
|
||||
const activeModifier = Object.keys(modifierKeys)
|
||||
.filter((key) => modifierKeys[key as keyof typeof modifierKeys])
|
||||
.join("+");
|
||||
// active modifier: ctrl | alt | ctrl+alt | ctrl+shift | ctrl+alt+shift | alt+shift
|
||||
// modiferKeys object's keys are sorted to match the above order
|
||||
const activeModifier = Object.keys(modifierKeys)
|
||||
.filter((key) => modifierKeys[key as keyof typeof modifierKeys])
|
||||
.join("+");
|
||||
|
||||
return activeModifier === "" ? null : activeModifier;
|
||||
return activeModifier === "" ? null : activeModifier;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { v2ToV3 } from './v2-to-v3';
|
||||
|
||||
type MigrationFn = ({ state }: { state: unknown }) => unknown;
|
||||
|
||||
/**
|
||||
* key = version we're migrating from
|
||||
* value = migration function
|
||||
*/
|
||||
const migrations: Record<number, MigrationFn> = {
|
||||
2: v2ToV3,
|
||||
};
|
||||
|
||||
export const CURRENT_VERSION = 3;
|
||||
|
||||
export function runMigrations({
|
||||
state,
|
||||
fromVersion,
|
||||
}: {
|
||||
state: unknown;
|
||||
fromVersion: number;
|
||||
}): unknown {
|
||||
let current = state;
|
||||
for (let version = fromVersion; version < CURRENT_VERSION; version++) {
|
||||
const migrate = migrations[version];
|
||||
if (migrate) current = migrate({ state: current });
|
||||
}
|
||||
return current;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { KeybindingConfig, ShortcutKey } from "@/types/keybinding";
|
||||
import type { TActionWithOptionalArgs } from "@/lib/actions";
|
||||
|
||||
interface V2State {
|
||||
keybindings: KeybindingConfig;
|
||||
isCustomized: boolean;
|
||||
}
|
||||
|
||||
export function v2ToV3({ state }: { state: unknown }): unknown {
|
||||
const v2 = state as V2State;
|
||||
|
||||
const renames: Record<string, string> = {
|
||||
"split-selected": "split",
|
||||
"split-selected-left": "split-left",
|
||||
"split-selected-right": "split-right",
|
||||
};
|
||||
|
||||
const migrated = { ...v2.keybindings };
|
||||
for (const [key, action] of Object.entries(migrated)) {
|
||||
if (action && renames[action]) {
|
||||
migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...v2, keybindings: migrated };
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { useTimelineStore } from "./timeline-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { MediaType, MediaFile } from "@/types/media";
|
||||
import { videoCache } from "@/lib/video-cache";
|
||||
|
||||
interface MediaStore {
|
||||
mediaFiles: MediaFile[];
|
||||
isLoading: boolean;
|
||||
|
||||
// Actions
|
||||
addMediaFile: (
|
||||
projectId: string,
|
||||
file: Omit<MediaFile, "id">
|
||||
) => Promise<void>;
|
||||
removeMediaFile: (projectId: string, id: string) => Promise<void>;
|
||||
loadProjectMedia: (projectId: string) => Promise<void>;
|
||||
clearProjectMedia: (projectId: string) => Promise<void>;
|
||||
clearAllMedia: () => void;
|
||||
}
|
||||
|
||||
// Helper function to determine file type
|
||||
export const getFileType = (file: File): MediaType | null => {
|
||||
const { type } = file;
|
||||
|
||||
if (type.startsWith("image/")) {
|
||||
return "image";
|
||||
}
|
||||
if (type.startsWith("video/")) {
|
||||
return "video";
|
||||
}
|
||||
if (type.startsWith("audio/")) {
|
||||
return "audio";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper function to get image dimensions
|
||||
export const getImageDimensions = (
|
||||
file: File
|
||||
): Promise<{ width: number; height: number }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new window.Image();
|
||||
|
||||
img.addEventListener("load", () => {
|
||||
const width = img.naturalWidth;
|
||||
const height = img.naturalHeight;
|
||||
resolve({ width, height });
|
||||
img.remove();
|
||||
});
|
||||
|
||||
img.addEventListener("error", () => {
|
||||
reject(new Error("Could not load image"));
|
||||
img.remove();
|
||||
});
|
||||
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to generate video thumbnail and get dimensions
|
||||
export const generateVideoThumbnail = (
|
||||
file: File
|
||||
): Promise<{ thumbnailUrl: string; width: number; height: number }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement("video") as HTMLVideoElement;
|
||||
const canvas = document.createElement("canvas") as HTMLCanvasElement;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error("Could not get canvas context"));
|
||||
return;
|
||||
}
|
||||
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
// Seek to 1 second or 10% of duration, whichever is smaller
|
||||
video.currentTime = Math.min(1, video.duration * 0.1);
|
||||
});
|
||||
|
||||
video.addEventListener("seeked", () => {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8);
|
||||
const width = video.videoWidth;
|
||||
const height = video.videoHeight;
|
||||
|
||||
resolve({ thumbnailUrl, width, height });
|
||||
|
||||
// Cleanup
|
||||
video.remove();
|
||||
canvas.remove();
|
||||
});
|
||||
|
||||
video.addEventListener("error", () => {
|
||||
reject(new Error("Could not load video"));
|
||||
video.remove();
|
||||
canvas.remove();
|
||||
});
|
||||
|
||||
video.src = URL.createObjectURL(file);
|
||||
video.load();
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to get media duration
|
||||
export const getMediaDuration = (file: File): Promise<number> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const element = document.createElement(
|
||||
file.type.startsWith("video/") ? "video" : "audio"
|
||||
) as HTMLVideoElement;
|
||||
|
||||
element.addEventListener("loadedmetadata", () => {
|
||||
resolve(element.duration);
|
||||
element.remove();
|
||||
});
|
||||
|
||||
element.addEventListener("error", () => {
|
||||
reject(new Error("Could not load media"));
|
||||
element.remove();
|
||||
});
|
||||
|
||||
element.src = URL.createObjectURL(file);
|
||||
element.load();
|
||||
});
|
||||
};
|
||||
|
||||
export const getMediaAspectRatio = (item: MediaFile): number => {
|
||||
if (item.width && item.height) {
|
||||
return item.width / item.height;
|
||||
}
|
||||
return 16 / 9; // Default aspect ratio
|
||||
};
|
||||
|
||||
export const useMediaStore = create<MediaStore>((set, get) => ({
|
||||
mediaFiles: [],
|
||||
isLoading: false,
|
||||
|
||||
addMediaFile: async (projectId, file) => {
|
||||
const newItem: MediaFile = {
|
||||
...file,
|
||||
id: generateUUID(),
|
||||
};
|
||||
|
||||
// Add to local state immediately for UI responsiveness
|
||||
set((state) => ({
|
||||
mediaFiles: [...state.mediaFiles, newItem],
|
||||
}));
|
||||
|
||||
// Save to persistent storage in background
|
||||
try {
|
||||
await storageService.saveMediaFile({ projectId, mediaItem: newItem });
|
||||
} catch (error) {
|
||||
console.error("Failed to save media item:", error);
|
||||
// Remove from local state if save failed
|
||||
set((state) => ({
|
||||
mediaFiles: state.mediaFiles.filter((media) => media.id !== newItem.id),
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
removeMediaFile: async (projectId: string, id: string) => {
|
||||
const state = get();
|
||||
const item = state.mediaFiles.find((media) => media.id === id);
|
||||
|
||||
videoCache.clearVideo(id);
|
||||
|
||||
// Cleanup object URLs to prevent memory leaks
|
||||
if (item?.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// 1) Remove from local state immediately
|
||||
set((state) => ({
|
||||
mediaFiles: state.mediaFiles.filter((media) => media.id !== id),
|
||||
}));
|
||||
|
||||
// 2) Cascade into the timeline: remove any elements using this media ID
|
||||
const timeline = useTimelineStore.getState();
|
||||
const { tracks, deleteSelected, setSelectedElements } = timeline;
|
||||
|
||||
// Find all elements that reference this media
|
||||
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
|
||||
for (const track of tracks) {
|
||||
for (const el of track.elements) {
|
||||
if (el.type === "media" && el.mediaId === id) {
|
||||
elementsToRemove.push({ trackId: track.id, elementId: el.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there are elements to remove, use unified delete function
|
||||
if (elementsToRemove.length > 0) {
|
||||
setSelectedElements(elementsToRemove);
|
||||
deleteSelected();
|
||||
}
|
||||
|
||||
// 3) Remove from persistent storage
|
||||
try {
|
||||
await storageService.deleteMediaFile({ projectId, id });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete media item:", error);
|
||||
}
|
||||
},
|
||||
|
||||
loadProjectMedia: async (projectId) => {
|
||||
set({ isLoading: true });
|
||||
|
||||
try {
|
||||
const mediaItems = await storageService.loadAllMediaFiles({ projectId });
|
||||
|
||||
// Regenerate thumbnails for video items
|
||||
const updatedMediaItems = await Promise.all(
|
||||
mediaItems.map(async (item) => {
|
||||
if (item.type === "video" && item.file) {
|
||||
try {
|
||||
const { thumbnailUrl, width, height } =
|
||||
await generateVideoThumbnail(item.file);
|
||||
return {
|
||||
...item,
|
||||
thumbnailUrl,
|
||||
width: width || item.width,
|
||||
height: height || item.height,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to regenerate thumbnail for video ${item.id}:`,
|
||||
error
|
||||
);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
|
||||
set({ mediaFiles: updatedMediaItems });
|
||||
} catch (error) {
|
||||
console.error("Failed to load media items:", error);
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
clearProjectMedia: async (projectId) => {
|
||||
const state = get();
|
||||
|
||||
// Cleanup all object URLs
|
||||
state.mediaFiles.forEach((item) => {
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
}
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear local state
|
||||
set({ mediaFiles: [] });
|
||||
|
||||
// Clear persistent storage
|
||||
try {
|
||||
const mediaIds = state.mediaFiles.map((item) => item.id);
|
||||
await Promise.all(
|
||||
mediaIds.map((id) => storageService.deleteMediaFile({ projectId, id }))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to clear media items from storage:", error);
|
||||
}
|
||||
},
|
||||
|
||||
clearAllMedia: () => {
|
||||
const state = get();
|
||||
|
||||
videoCache.clearAll();
|
||||
|
||||
// Cleanup all object URLs
|
||||
state.mediaFiles.forEach((item) => {
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
}
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear local state
|
||||
set({ mediaFiles: [] });
|
||||
},
|
||||
}));
|
||||
@@ -1,225 +1,93 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { PANEL_CONFIG } from "@/constants/editor-constants";
|
||||
|
||||
export type PanelPreset =
|
||||
| "default"
|
||||
| "media"
|
||||
| "inspector"
|
||||
| "vertical-preview";
|
||||
|
||||
interface PanelSizes {
|
||||
toolsPanel: number;
|
||||
previewPanel: number;
|
||||
propertiesPanel: number;
|
||||
mainContent: number;
|
||||
timeline: number;
|
||||
export interface PanelSizes {
|
||||
tools: number;
|
||||
preview: number;
|
||||
properties: number;
|
||||
mainContent: number;
|
||||
timeline: number;
|
||||
}
|
||||
|
||||
export const PRESET_CONFIGS: Record<PanelPreset, PanelSizes> = {
|
||||
default: {
|
||||
toolsPanel: 25,
|
||||
previewPanel: 50,
|
||||
propertiesPanel: 25,
|
||||
mainContent: 70,
|
||||
timeline: 30,
|
||||
},
|
||||
media: {
|
||||
toolsPanel: 30,
|
||||
previewPanel: 45,
|
||||
propertiesPanel: 25,
|
||||
mainContent: 100,
|
||||
timeline: 25,
|
||||
},
|
||||
inspector: {
|
||||
toolsPanel: 30,
|
||||
previewPanel: 70,
|
||||
propertiesPanel: 30,
|
||||
mainContent: 75,
|
||||
timeline: 25,
|
||||
},
|
||||
"vertical-preview": {
|
||||
toolsPanel: 30,
|
||||
previewPanel: 40,
|
||||
propertiesPanel: 30,
|
||||
mainContent: 75,
|
||||
timeline: 25,
|
||||
},
|
||||
};
|
||||
export type PanelId = keyof PanelSizes;
|
||||
|
||||
interface PanelState extends PanelSizes {
|
||||
activePreset: PanelPreset;
|
||||
presetCustomSizes: Record<PanelPreset, Partial<PanelSizes>>;
|
||||
resetCounter: number;
|
||||
|
||||
mediaViewMode: "grid" | "list";
|
||||
|
||||
setToolsPanel: (size: number) => void;
|
||||
setPreviewPanel: (size: number) => void;
|
||||
setPropertiesPanel: (size: number) => void;
|
||||
setMainContent: (size: number) => void;
|
||||
setTimeline: (size: number) => void;
|
||||
setMediaViewMode: (mode: "grid" | "list") => void;
|
||||
|
||||
setActivePreset: (preset: PanelPreset) => void;
|
||||
resetPreset: (preset: PanelPreset) => void;
|
||||
getCurrentPresetSizes: () => PanelSizes;
|
||||
interface PanelState {
|
||||
panels: PanelSizes;
|
||||
setPanel: (panel: PanelId, size: number) => void;
|
||||
setPanels: (sizes: Partial<PanelSizes>) => void;
|
||||
resetPanels: () => void;
|
||||
}
|
||||
|
||||
export const usePanelStore = create<PanelState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...PRESET_CONFIGS.default,
|
||||
activePreset: "default" as PanelPreset,
|
||||
presetCustomSizes: {
|
||||
default: {},
|
||||
media: {},
|
||||
inspector: {},
|
||||
"vertical-preview": {},
|
||||
},
|
||||
resetCounter: 0,
|
||||
persist(
|
||||
(set) => ({
|
||||
...PANEL_CONFIG,
|
||||
setPanel: (panel, size) =>
|
||||
set((state) => ({
|
||||
panels: {
|
||||
...state.panels,
|
||||
[panel]: size,
|
||||
},
|
||||
})),
|
||||
setPanels: (sizes) =>
|
||||
set((state) => ({
|
||||
panels: {
|
||||
...state.panels,
|
||||
...sizes,
|
||||
},
|
||||
})),
|
||||
resetPanels: () => set({ ...PANEL_CONFIG }),
|
||||
}),
|
||||
{
|
||||
name: "panel-sizes",
|
||||
version: 2,
|
||||
migrate: (persistedState) => {
|
||||
const state = persistedState as
|
||||
| {
|
||||
panels?: Partial<PanelSizes> | null;
|
||||
toolsPanel?: number;
|
||||
previewPanel?: number;
|
||||
propertiesPanel?: number;
|
||||
mainContent?: number;
|
||||
timeline?: number;
|
||||
tools?: number;
|
||||
preview?: number;
|
||||
properties?: number;
|
||||
}
|
||||
| undefined
|
||||
| null;
|
||||
|
||||
mediaViewMode: "grid" as const,
|
||||
if (!state) return { panels: { ...PANEL_CONFIG.panels } };
|
||||
|
||||
setToolsPanel: (size) => {
|
||||
const { activePreset, presetCustomSizes } = get();
|
||||
set({
|
||||
toolsPanel: size,
|
||||
presetCustomSizes: {
|
||||
...presetCustomSizes,
|
||||
[activePreset]: {
|
||||
...presetCustomSizes[activePreset],
|
||||
toolsPanel: size,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
setPreviewPanel: (size) => {
|
||||
const { activePreset, presetCustomSizes } = get();
|
||||
set({
|
||||
previewPanel: size,
|
||||
presetCustomSizes: {
|
||||
...presetCustomSizes,
|
||||
[activePreset]: {
|
||||
...presetCustomSizes[activePreset],
|
||||
previewPanel: size,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
setPropertiesPanel: (size) => {
|
||||
const { activePreset, presetCustomSizes } = get();
|
||||
set({
|
||||
propertiesPanel: size,
|
||||
presetCustomSizes: {
|
||||
...presetCustomSizes,
|
||||
[activePreset]: {
|
||||
...presetCustomSizes[activePreset],
|
||||
propertiesPanel: size,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
setMainContent: (size) => {
|
||||
const { activePreset, presetCustomSizes } = get();
|
||||
set({
|
||||
mainContent: size,
|
||||
presetCustomSizes: {
|
||||
...presetCustomSizes,
|
||||
[activePreset]: {
|
||||
...presetCustomSizes[activePreset],
|
||||
mainContent: size,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
setTimeline: (size) => {
|
||||
const { activePreset, presetCustomSizes } = get();
|
||||
set({
|
||||
timeline: size,
|
||||
presetCustomSizes: {
|
||||
...presetCustomSizes,
|
||||
[activePreset]: {
|
||||
...presetCustomSizes[activePreset],
|
||||
timeline: size,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
|
||||
if (state.panels && typeof state.panels === "object") {
|
||||
return {
|
||||
panels: {
|
||||
...PANEL_CONFIG.panels,
|
||||
...state.panels,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
setActivePreset: (preset) => {
|
||||
const {
|
||||
activePreset: currentPreset,
|
||||
presetCustomSizes,
|
||||
toolsPanel,
|
||||
previewPanel,
|
||||
propertiesPanel,
|
||||
mainContent,
|
||||
timeline,
|
||||
} = get();
|
||||
|
||||
const updatedPresetCustomSizes = {
|
||||
...presetCustomSizes,
|
||||
[currentPreset]: {
|
||||
toolsPanel,
|
||||
previewPanel,
|
||||
propertiesPanel,
|
||||
mainContent,
|
||||
timeline,
|
||||
},
|
||||
};
|
||||
|
||||
const defaultSizes = PRESET_CONFIGS[preset];
|
||||
const customSizes = updatedPresetCustomSizes[preset] || {};
|
||||
const finalSizes = { ...defaultSizes, ...customSizes } as PanelSizes;
|
||||
|
||||
set({
|
||||
activePreset: preset,
|
||||
presetCustomSizes: updatedPresetCustomSizes,
|
||||
...finalSizes,
|
||||
});
|
||||
},
|
||||
|
||||
resetPreset: (preset) => {
|
||||
const { presetCustomSizes, activePreset, resetCounter } = get();
|
||||
const defaultSizes = PRESET_CONFIGS[preset];
|
||||
|
||||
const newPresetCustomSizes = {
|
||||
...presetCustomSizes,
|
||||
[preset]: {},
|
||||
};
|
||||
|
||||
const updates: Partial<PanelState> = {
|
||||
presetCustomSizes: newPresetCustomSizes,
|
||||
resetCounter: resetCounter + 1,
|
||||
};
|
||||
|
||||
if (preset === activePreset) {
|
||||
Object.assign(updates, defaultSizes);
|
||||
}
|
||||
|
||||
set(updates);
|
||||
},
|
||||
|
||||
getCurrentPresetSizes: () => {
|
||||
const {
|
||||
toolsPanel,
|
||||
previewPanel,
|
||||
propertiesPanel,
|
||||
mainContent,
|
||||
timeline,
|
||||
} = get();
|
||||
return {
|
||||
toolsPanel,
|
||||
previewPanel,
|
||||
propertiesPanel,
|
||||
mainContent,
|
||||
timeline,
|
||||
};
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "panel-sizes",
|
||||
}
|
||||
)
|
||||
return {
|
||||
panels: {
|
||||
tools: state.tools ?? state.toolsPanel ?? PANEL_CONFIG.panels.tools,
|
||||
preview:
|
||||
state.preview ??
|
||||
state.previewPanel ??
|
||||
PANEL_CONFIG.panels.preview,
|
||||
properties:
|
||||
state.properties ??
|
||||
state.propertiesPanel ??
|
||||
PANEL_CONFIG.panels.properties,
|
||||
mainContent: state.mainContent ?? PANEL_CONFIG.panels.mainContent,
|
||||
timeline: state.timeline ?? PANEL_CONFIG.panels.timeline,
|
||||
},
|
||||
};
|
||||
},
|
||||
partialize: (state) => ({
|
||||
panels: state.panels,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import type { PlaybackState, PlaybackControls } from "@/types/playback";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { DEFAULT_FPS, useProjectStore } from "./project-store";
|
||||
|
||||
interface PlaybackStore extends PlaybackState, PlaybackControls {
|
||||
setDuration: (duration: number) => void;
|
||||
setCurrentTime: (time: number) => void;
|
||||
}
|
||||
|
||||
let playbackTimer: number | null = null;
|
||||
|
||||
const startTimer = (store: () => PlaybackStore) => {
|
||||
if (playbackTimer) cancelAnimationFrame(playbackTimer);
|
||||
|
||||
// Use requestAnimationFrame for smoother updates
|
||||
const updateTime = () => {
|
||||
const state = store();
|
||||
if (state.isPlaying && state.currentTime < state.duration) {
|
||||
const now = performance.now();
|
||||
const delta = (now - lastUpdate) / 1000; // Convert to seconds
|
||||
lastUpdate = now;
|
||||
|
||||
const newTime = state.currentTime + delta * state.speed;
|
||||
|
||||
// Get actual content duration from timeline store
|
||||
const actualContentDuration = useTimelineStore
|
||||
.getState()
|
||||
.getTotalDuration();
|
||||
|
||||
// Stop at actual content end, not timeline duration (which has 10s minimum)
|
||||
// It was either this or reducing default min timeline to 1 second
|
||||
const effectiveDuration =
|
||||
actualContentDuration > 0 ? actualContentDuration : state.duration;
|
||||
|
||||
if (newTime >= effectiveDuration) {
|
||||
// When content completes, pause just before the end so we can see the last frame
|
||||
const projectFps = useProjectStore.getState().activeProject?.fps;
|
||||
if (!projectFps)
|
||||
console.error("Project FPS is not set, assuming " + DEFAULT_FPS + "fps");
|
||||
|
||||
const frameOffset = 1 / (projectFps ?? DEFAULT_FPS); // Stop 1 frame before end based on project FPS
|
||||
const stopTime = Math.max(0, effectiveDuration - frameOffset);
|
||||
|
||||
state.pause();
|
||||
state.setCurrentTime(stopTime);
|
||||
// Notify video elements to sync with end position
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playback-seek", {
|
||||
detail: { time: stopTime },
|
||||
})
|
||||
);
|
||||
} else {
|
||||
state.setCurrentTime(newTime);
|
||||
// Notify video elements to sync
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playback-update", { detail: { time: newTime } })
|
||||
);
|
||||
}
|
||||
}
|
||||
playbackTimer = requestAnimationFrame(updateTime);
|
||||
};
|
||||
|
||||
let lastUpdate = performance.now();
|
||||
playbackTimer = requestAnimationFrame(updateTime);
|
||||
};
|
||||
|
||||
const stopTimer = () => {
|
||||
if (playbackTimer) {
|
||||
cancelAnimationFrame(playbackTimer);
|
||||
playbackTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: 1,
|
||||
muted: false,
|
||||
previousVolume: 1,
|
||||
speed: 1.0,
|
||||
|
||||
play: () => {
|
||||
const state = get();
|
||||
|
||||
const actualContentDuration = useTimelineStore
|
||||
.getState()
|
||||
.getTotalDuration();
|
||||
const effectiveDuration =
|
||||
actualContentDuration > 0 ? actualContentDuration : state.duration;
|
||||
|
||||
if (effectiveDuration > 0) {
|
||||
const fps = useProjectStore.getState().activeProject?.fps ?? DEFAULT_FPS;
|
||||
const frameOffset = 1 / fps;
|
||||
const endThreshold = Math.max(0, effectiveDuration - frameOffset);
|
||||
|
||||
if (state.currentTime >= endThreshold) {
|
||||
get().seek(0);
|
||||
}
|
||||
}
|
||||
|
||||
set({ isPlaying: true });
|
||||
startTimer(get);
|
||||
},
|
||||
|
||||
pause: () => {
|
||||
set({ isPlaying: false });
|
||||
stopTimer();
|
||||
},
|
||||
|
||||
toggle: () => {
|
||||
const { isPlaying } = get();
|
||||
if (isPlaying) {
|
||||
get().pause();
|
||||
} else {
|
||||
get().play();
|
||||
}
|
||||
},
|
||||
|
||||
seek: (time: number) => {
|
||||
const { duration } = get();
|
||||
const clampedTime = Math.max(0, Math.min(duration, time));
|
||||
set({ currentTime: clampedTime });
|
||||
|
||||
const event = new CustomEvent("playback-seek", {
|
||||
detail: { time: clampedTime },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
|
||||
setVolume: (volume: number) =>
|
||||
set((state) => ({
|
||||
volume: Math.max(0, Math.min(1, volume)),
|
||||
muted: volume === 0,
|
||||
previousVolume: volume > 0 ? volume : state.previousVolume,
|
||||
})),
|
||||
|
||||
setSpeed: (speed: number) => {
|
||||
const newSpeed = Math.max(0.1, Math.min(2.0, speed));
|
||||
set({ speed: newSpeed });
|
||||
|
||||
const event = new CustomEvent("playback-speed", {
|
||||
detail: { speed: newSpeed },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
|
||||
setDuration: (duration: number) => set({ duration }),
|
||||
setCurrentTime: (time: number) => set({ currentTime: time }),
|
||||
|
||||
mute: () => {
|
||||
const { volume, previousVolume } = get();
|
||||
set({
|
||||
muted: true,
|
||||
previousVolume: volume > 0 ? volume : previousVolume,
|
||||
volume: 0,
|
||||
});
|
||||
},
|
||||
|
||||
unmute: () => {
|
||||
const { previousVolume } = get();
|
||||
set({ muted: false, volume: previousVolume ?? 1 });
|
||||
},
|
||||
|
||||
toggleMute: () => {
|
||||
const { muted } = get();
|
||||
if (muted) {
|
||||
get().unmute();
|
||||
} else {
|
||||
get().mute();
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -1,571 +0,0 @@
|
||||
import { TProject, BlurIntensity, Scene } from "@/types/project";
|
||||
import { create } from "zustand";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { toast } from "sonner";
|
||||
import { useMediaStore } from "./media-store";
|
||||
import { useTimelineStore } from "./timeline-store";
|
||||
import { useSceneStore } from "./scene-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { CanvasSize, CanvasMode } from "@/types/editor";
|
||||
|
||||
export const DEFAULT_CANVAS_SIZE: CanvasSize = { width: 1920, height: 1080 };
|
||||
export const DEFAULT_FPS = 30;
|
||||
|
||||
export function createMainScene(): Scene {
|
||||
return {
|
||||
id: generateUUID(),
|
||||
name: "Main Scene",
|
||||
isMain: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
const createDefaultProject = (name: string): TProject => {
|
||||
const mainScene = createMainScene();
|
||||
|
||||
return {
|
||||
id: generateUUID(),
|
||||
name,
|
||||
thumbnail: "",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
scenes: [mainScene],
|
||||
currentSceneId: mainScene.id,
|
||||
backgroundColor: "#000000",
|
||||
backgroundType: "color",
|
||||
blurIntensity: 8,
|
||||
bookmarks: [],
|
||||
fps: DEFAULT_FPS,
|
||||
canvasSize: DEFAULT_CANVAS_SIZE,
|
||||
canvasMode: "preset",
|
||||
};
|
||||
};
|
||||
|
||||
interface ProjectStore {
|
||||
activeProject: TProject | null;
|
||||
savedProjects: TProject[];
|
||||
isLoading: boolean;
|
||||
isInitialized: boolean;
|
||||
invalidProjectIds?: Set<string>;
|
||||
|
||||
// Actions
|
||||
createNewProject: (name: string) => Promise<string>;
|
||||
loadProject: (id: string) => Promise<void>;
|
||||
saveCurrentProject: () => Promise<void>;
|
||||
loadAllProjects: () => Promise<void>;
|
||||
deleteProject: (id: string) => Promise<void>;
|
||||
closeProject: () => void;
|
||||
renameProject: (projectId: string, name: string) => Promise<void>;
|
||||
duplicateProject: (projectId: string) => Promise<string>;
|
||||
updateProjectBackground: (backgroundColor: string) => Promise<void>;
|
||||
updateBackgroundType: (
|
||||
type: "color" | "blur",
|
||||
options?: { backgroundColor?: string; blurIntensity?: BlurIntensity }
|
||||
) => Promise<void>;
|
||||
updateProjectFps: (fps: number) => Promise<void>;
|
||||
updateCanvasSize: (size: CanvasSize, mode: CanvasMode) => Promise<void>;
|
||||
|
||||
// Bookmark methods
|
||||
toggleBookmark: (time: number) => Promise<void>;
|
||||
isBookmarked: (time: number) => boolean;
|
||||
removeBookmark: (time: number) => Promise<void>;
|
||||
|
||||
getFilteredAndSortedProjects: (
|
||||
searchQuery: string,
|
||||
sortOption: string
|
||||
) => TProject[];
|
||||
|
||||
// Global invalid project ID tracking
|
||||
isInvalidProjectId: (id: string) => boolean;
|
||||
markProjectIdAsInvalid: (id: string) => void;
|
||||
clearInvalidProjectIds: () => void;
|
||||
}
|
||||
|
||||
export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
activeProject: null,
|
||||
savedProjects: [],
|
||||
isLoading: true,
|
||||
isInitialized: false,
|
||||
invalidProjectIds: new Set<string>(),
|
||||
|
||||
// Implementation of bookmark methods
|
||||
toggleBookmark: async (time: number) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
// Round time to the nearest frame
|
||||
const fps = activeProject.fps || DEFAULT_FPS;
|
||||
const frameTime = Math.round(time * fps) / fps;
|
||||
|
||||
const bookmarks = activeProject.bookmarks || [];
|
||||
let updatedBookmarks: number[];
|
||||
|
||||
// Check if already bookmarked
|
||||
const bookmarkIndex = bookmarks.findIndex(
|
||||
(bookmark) => Math.abs(bookmark - frameTime) < 0.001
|
||||
);
|
||||
|
||||
if (bookmarkIndex !== -1) {
|
||||
// Remove bookmark
|
||||
updatedBookmarks = bookmarks.filter((_, i) => i !== bookmarkIndex);
|
||||
} else {
|
||||
// Add bookmark
|
||||
updatedBookmarks = [...bookmarks, frameTime].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
bookmarks: updatedBookmarks,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Failed to update project bookmarks:", error);
|
||||
toast.error("Failed to update bookmarks", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
isBookmarked: (time: number) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject || !activeProject.bookmarks) return false;
|
||||
|
||||
// Round time to the nearest frame
|
||||
const fps = activeProject.fps || DEFAULT_FPS;
|
||||
const frameTime = Math.round(time * fps) / fps;
|
||||
|
||||
return activeProject.bookmarks.some(
|
||||
(bookmark) => Math.abs(bookmark - frameTime) < 0.001
|
||||
);
|
||||
},
|
||||
|
||||
removeBookmark: async (time: number) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject || !activeProject.bookmarks) return;
|
||||
|
||||
// Round time to the nearest frame
|
||||
const fps = activeProject.fps || DEFAULT_FPS;
|
||||
const frameTime = Math.round(time * fps) / fps;
|
||||
|
||||
const updatedBookmarks = activeProject.bookmarks.filter(
|
||||
(bookmark) => Math.abs(bookmark - frameTime) >= 0.001
|
||||
);
|
||||
|
||||
if (updatedBookmarks.length === activeProject.bookmarks.length) {
|
||||
// No bookmark found to remove
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
bookmarks: updatedBookmarks,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Failed to update project bookmarks:", error);
|
||||
toast.error("Failed to remove bookmark", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
createNewProject: async (name: string) => {
|
||||
const newProject = createDefaultProject(name);
|
||||
|
||||
set({ activeProject: newProject });
|
||||
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const sceneStore = useSceneStore.getState();
|
||||
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
|
||||
sceneStore.initializeScenes({
|
||||
scenes: newProject.scenes,
|
||||
currentSceneId: newProject.currentSceneId,
|
||||
});
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: newProject });
|
||||
// Reload all projects to update the list
|
||||
await get().loadAllProjects();
|
||||
return newProject.id;
|
||||
} catch (error) {
|
||||
toast.error("Failed to save new project");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
loadProject: async (id: string) => {
|
||||
if (!get().isInitialized) {
|
||||
set({ isLoading: true });
|
||||
}
|
||||
|
||||
// Prevent flicker when switching projects - clear all stores
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const sceneStore = useSceneStore.getState();
|
||||
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
sceneStore.clearScenes();
|
||||
|
||||
try {
|
||||
const project = await storageService.loadProject({ id });
|
||||
if (project) {
|
||||
set({ activeProject: project });
|
||||
|
||||
let currentScene = null;
|
||||
if (project.scenes && project.scenes.length > 0) {
|
||||
sceneStore.initializeScenes({
|
||||
scenes: project.scenes,
|
||||
currentSceneId: project.currentSceneId,
|
||||
});
|
||||
// Get current scene directly from project data (don't rely on store state)
|
||||
currentScene =
|
||||
project.scenes.find((s) => s.id === project.currentSceneId) ||
|
||||
project.scenes.find((s) => s.isMain) ||
|
||||
project.scenes[0];
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
mediaStore.loadProjectMedia(id),
|
||||
timelineStore.loadProjectTimeline({
|
||||
projectId: id,
|
||||
sceneId: currentScene?.id,
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
throw new Error(`Project with id ${id} not found`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load project:", error);
|
||||
throw error; // Re-throw so the editor page can handle it
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
saveCurrentProject: async () => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
try {
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const sceneStore = useSceneStore.getState();
|
||||
const currentScene = sceneStore.currentScene;
|
||||
|
||||
await Promise.all([
|
||||
storageService.saveProject({ project: activeProject }),
|
||||
timelineStore.saveProjectTimeline({
|
||||
projectId: activeProject.id,
|
||||
sceneId: currentScene?.id,
|
||||
}),
|
||||
]);
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Failed to save project:", error);
|
||||
}
|
||||
},
|
||||
|
||||
loadAllProjects: async () => {
|
||||
if (!get().isInitialized) {
|
||||
set({ isLoading: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const projects = await storageService.loadAllProjects();
|
||||
set({ savedProjects: projects });
|
||||
} catch (error) {
|
||||
console.error("Failed to load projects:", error);
|
||||
} finally {
|
||||
set({ isLoading: false, isInitialized: true });
|
||||
}
|
||||
},
|
||||
|
||||
deleteProject: async (id: string) => {
|
||||
try {
|
||||
await Promise.all([
|
||||
storageService.deleteProjectMedia({ projectId: id }),
|
||||
storageService.deleteProjectTimeline({ projectId: id }),
|
||||
storageService.deleteProject({ id }),
|
||||
]);
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
|
||||
// If deleted active project, close it and clear data
|
||||
const { activeProject } = get();
|
||||
if (activeProject?.id === id) {
|
||||
set({ activeProject: null });
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const sceneStore = useSceneStore.getState();
|
||||
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
sceneStore.clearScenes();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete project:", error);
|
||||
}
|
||||
},
|
||||
|
||||
closeProject: () => {
|
||||
set({ activeProject: null });
|
||||
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const sceneStore = useSceneStore.getState();
|
||||
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
sceneStore.clearScenes();
|
||||
},
|
||||
|
||||
renameProject: async (id: string, name: string) => {
|
||||
const { savedProjects } = get();
|
||||
|
||||
// Find the project to rename
|
||||
const projectToRename = savedProjects.find((p) => p.id === id);
|
||||
if (!projectToRename) {
|
||||
toast.error("Project not found", {
|
||||
description: "Please try again",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedProject = {
|
||||
...projectToRename,
|
||||
name,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
|
||||
await get().loadAllProjects();
|
||||
|
||||
// Update activeProject if same project
|
||||
const { activeProject } = get();
|
||||
if (activeProject?.id === id) {
|
||||
set({ activeProject: updatedProject });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to rename project:", error);
|
||||
toast.error("Failed to rename project", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
duplicateProject: async (projectId: string) => {
|
||||
try {
|
||||
const project = await storageService.loadProject({ id: projectId });
|
||||
if (!project) {
|
||||
toast.error("Project not found", {
|
||||
description: "Please try again",
|
||||
});
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const { savedProjects } = get();
|
||||
|
||||
// Extract the base name (remove any existing numbering)
|
||||
const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/);
|
||||
const baseName = numberMatch ? numberMatch[2] : project.name;
|
||||
const existingNumbers: number[] = [];
|
||||
|
||||
// Check for pattern "(number) baseName" in existing projects
|
||||
savedProjects.forEach((p) => {
|
||||
const match = p.name.match(/^\((\d+)\)\s+(.+)$/);
|
||||
if (match && match[2] === baseName) {
|
||||
existingNumbers.push(parseInt(match[1], 10));
|
||||
}
|
||||
});
|
||||
|
||||
const nextNumber =
|
||||
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
|
||||
|
||||
const newProject: TProject = {
|
||||
...project,
|
||||
id: generateUUID(),
|
||||
name: `(${nextNumber}) ${baseName}`,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await storageService.saveProject({ project: newProject });
|
||||
await get().loadAllProjects();
|
||||
return newProject.id;
|
||||
} catch (error) {
|
||||
console.error("Failed to duplicate project:", error);
|
||||
toast.error("Failed to duplicate project", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateProjectBackground: async (backgroundColor: string) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
backgroundColor,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update project background:", error);
|
||||
toast.error("Failed to update background", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateBackgroundType: async (
|
||||
type: "color" | "blur",
|
||||
options?: { backgroundColor?: string; blurIntensity?: BlurIntensity }
|
||||
) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
backgroundType: type,
|
||||
...(options?.backgroundColor && {
|
||||
backgroundColor: options.backgroundColor,
|
||||
}),
|
||||
...(options?.blurIntensity !== undefined && {
|
||||
blurIntensity: options.blurIntensity,
|
||||
}),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update background type:", error);
|
||||
toast.error("Failed to update background", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateProjectFps: async (fps: number) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
fps,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update project FPS:", error);
|
||||
toast.error("Failed to update project FPS", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateCanvasSize: async (size: CanvasSize, mode: CanvasMode) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
canvasSize: size,
|
||||
canvasMode: mode,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update canvas size:", error);
|
||||
toast.error("Failed to update canvas size", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => {
|
||||
const { savedProjects } = get();
|
||||
|
||||
const filteredProjects = savedProjects.filter((project) =>
|
||||
project.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const sortedProjects = [...filteredProjects].sort((a, b) => {
|
||||
const [key, order] = sortOption.split("-");
|
||||
|
||||
if (key !== "createdAt" && key !== "name") {
|
||||
console.warn(`Invalid sort key: ${key}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const aValue = a[key];
|
||||
const bValue = b[key];
|
||||
|
||||
if (aValue === undefined || bValue === undefined) return 0;
|
||||
|
||||
if (order === "asc") {
|
||||
if (aValue < bValue) return -1;
|
||||
if (aValue > bValue) return 1;
|
||||
return 0;
|
||||
}
|
||||
if (aValue > bValue) return -1;
|
||||
if (aValue < bValue) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return sortedProjects;
|
||||
},
|
||||
|
||||
// Global invalid project ID tracking
|
||||
isInvalidProjectId: (id: string) => {
|
||||
const invalidIds = get().invalidProjectIds || new Set();
|
||||
return invalidIds.has(id);
|
||||
},
|
||||
|
||||
markProjectIdAsInvalid: (id: string) => {
|
||||
set((state) => ({
|
||||
invalidProjectIds: new Set([
|
||||
...(state.invalidProjectIds || new Set()),
|
||||
id,
|
||||
]),
|
||||
}));
|
||||
},
|
||||
|
||||
clearInvalidProjectIds: () => {
|
||||
set({ invalidProjectIds: new Set() });
|
||||
},
|
||||
}));
|
||||
@@ -1,318 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { Scene } from "@/types/project";
|
||||
import { useProjectStore } from "./project-store";
|
||||
import { useTimelineStore } from "./timeline-store";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
export function getMainScene({ scenes }: { scenes: Scene[] }): Scene | null {
|
||||
return scenes.find((scene) => scene.isMain) || null;
|
||||
}
|
||||
|
||||
function ensureMainScene(scenes: Scene[]): Scene[] {
|
||||
const hasMain = scenes.some((scene) => scene.isMain);
|
||||
if (!hasMain) {
|
||||
const mainScene: Scene = {
|
||||
id: generateUUID(),
|
||||
name: "Main scene",
|
||||
isMain: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
return [mainScene, ...scenes];
|
||||
}
|
||||
return scenes;
|
||||
}
|
||||
|
||||
interface SceneStore {
|
||||
// Current scene state
|
||||
currentScene: Scene | null;
|
||||
scenes: Scene[];
|
||||
|
||||
// Scene management
|
||||
createScene: ({
|
||||
name,
|
||||
isMain,
|
||||
}: {
|
||||
name: string;
|
||||
isMain: boolean;
|
||||
}) => Promise<string>;
|
||||
deleteScene: ({ sceneId }: { sceneId: string }) => Promise<void>;
|
||||
renameScene: ({
|
||||
sceneId,
|
||||
name,
|
||||
}: {
|
||||
sceneId: string;
|
||||
name: string;
|
||||
}) => Promise<void>;
|
||||
switchToScene: ({ sceneId }: { sceneId: string }) => Promise<void>;
|
||||
|
||||
// Scene utilities
|
||||
getMainScene: () => Scene | null;
|
||||
getCurrentScene: () => Scene | null;
|
||||
|
||||
// Project integration
|
||||
loadProjectScenes: ({ projectId }: { projectId: string }) => Promise<void>;
|
||||
initializeScenes: ({
|
||||
scenes,
|
||||
currentSceneId,
|
||||
}: {
|
||||
scenes: Scene[];
|
||||
currentSceneId?: string;
|
||||
}) => void;
|
||||
clearScenes: () => void;
|
||||
}
|
||||
|
||||
export const useSceneStore = create<SceneStore>((set, get) => ({
|
||||
currentScene: null,
|
||||
scenes: [],
|
||||
|
||||
createScene: async ({ name, isMain = false }) => {
|
||||
const { scenes } = get();
|
||||
|
||||
const newScene = {
|
||||
id: generateUUID(),
|
||||
name,
|
||||
isMain,
|
||||
isBackground: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const updatedScenes = [...scenes, newScene];
|
||||
|
||||
const projectStore = useProjectStore.getState();
|
||||
const { activeProject } = projectStore;
|
||||
|
||||
if (!activeProject) {
|
||||
throw new Error("No active project");
|
||||
}
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: updatedScenes,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
useProjectStore.setState({ activeProject: updatedProject });
|
||||
set({ scenes: updatedScenes });
|
||||
return newScene.id;
|
||||
} catch (error) {
|
||||
console.error("Failed to create scene:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteScene: async ({ sceneId }: { sceneId: string }) => {
|
||||
const { scenes, currentScene } = get();
|
||||
const sceneToDelete = scenes.find((s) => s.id === sceneId);
|
||||
|
||||
if (!sceneToDelete) {
|
||||
throw new Error("Scene not found");
|
||||
}
|
||||
|
||||
if (sceneToDelete.isMain) {
|
||||
throw new Error("Cannot delete main scene");
|
||||
}
|
||||
|
||||
const updatedScenes = scenes.filter((s) => s.id !== sceneId);
|
||||
|
||||
// Determine new current scene if we're deleting the current one
|
||||
let newCurrentScene = currentScene;
|
||||
if (currentScene?.id === sceneId) {
|
||||
newCurrentScene = getMainScene({ scenes: updatedScenes });
|
||||
}
|
||||
|
||||
// Update project
|
||||
const projectStore = useProjectStore.getState();
|
||||
const { activeProject } = projectStore;
|
||||
|
||||
if (!activeProject) {
|
||||
throw new Error("No active project");
|
||||
}
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: updatedScenes,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
useProjectStore.setState({ activeProject: updatedProject });
|
||||
set({
|
||||
scenes: updatedScenes,
|
||||
currentScene: newCurrentScene,
|
||||
});
|
||||
|
||||
// If we switched scenes, load the new scene's timeline
|
||||
if (newCurrentScene && newCurrentScene.id !== currentScene?.id) {
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
await timelineStore.loadProjectTimeline({
|
||||
projectId: activeProject.id,
|
||||
sceneId: newCurrentScene.id,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete scene:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
renameScene: async ({ sceneId, name }: { sceneId: string; name: string }) => {
|
||||
const { scenes } = get();
|
||||
const updatedScenes = scenes.map((scene) =>
|
||||
scene.id === sceneId ? { ...scene, name, updatedAt: new Date() } : scene
|
||||
);
|
||||
|
||||
// Update project
|
||||
const projectStore = useProjectStore.getState();
|
||||
const { activeProject } = projectStore;
|
||||
|
||||
if (!activeProject) {
|
||||
throw new Error("No active project");
|
||||
}
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: updatedScenes,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
useProjectStore.setState({ activeProject: updatedProject });
|
||||
set({
|
||||
scenes: updatedScenes,
|
||||
currentScene: updatedScenes.find((s) => s.id === sceneId) || null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to rename scene:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
switchToScene: async ({ sceneId }: { sceneId: string }) => {
|
||||
const { scenes } = get();
|
||||
const targetScene = scenes.find((s) => s.id === sceneId);
|
||||
|
||||
if (!targetScene) {
|
||||
throw new Error("Scene not found");
|
||||
}
|
||||
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const projectStore = useProjectStore.getState();
|
||||
const { activeProject } = projectStore;
|
||||
const { currentScene } = get();
|
||||
|
||||
if (activeProject && currentScene) {
|
||||
await timelineStore.saveProjectTimeline({
|
||||
projectId: activeProject.id,
|
||||
sceneId: currentScene.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (activeProject) {
|
||||
await timelineStore.loadProjectTimeline({
|
||||
projectId: activeProject.id,
|
||||
sceneId,
|
||||
});
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
currentSceneId: sceneId,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
useProjectStore.setState({ activeProject: updatedProject });
|
||||
}
|
||||
|
||||
set({ currentScene: targetScene });
|
||||
},
|
||||
|
||||
getMainScene: () => {
|
||||
const { scenes } = get();
|
||||
return scenes.find((scene) => scene.isMain) || null;
|
||||
},
|
||||
|
||||
getCurrentScene: () => {
|
||||
return get().currentScene;
|
||||
},
|
||||
|
||||
loadProjectScenes: async ({ projectId }: { projectId: string }) => {
|
||||
try {
|
||||
const project = await storageService.loadProject({ id: projectId });
|
||||
if (project?.scenes) {
|
||||
const ensuredScenes = project.scenes.map((scene) => ({
|
||||
...scene,
|
||||
isMain: scene.isMain || false,
|
||||
}));
|
||||
const currentScene =
|
||||
ensuredScenes.find((s) => s.id === project.currentSceneId) ||
|
||||
ensuredScenes[0];
|
||||
|
||||
set({
|
||||
scenes: ensuredScenes,
|
||||
currentScene,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load project scenes:", error);
|
||||
set({ scenes: [], currentScene: null });
|
||||
}
|
||||
},
|
||||
|
||||
initializeScenes: ({
|
||||
scenes,
|
||||
currentSceneId,
|
||||
}: {
|
||||
scenes: Scene[];
|
||||
currentSceneId?: string;
|
||||
}) => {
|
||||
const ensuredScenes = ensureMainScene(scenes);
|
||||
const currentScene = currentSceneId
|
||||
? ensuredScenes.find((s) => s.id === currentSceneId)
|
||||
: null;
|
||||
|
||||
const fallbackScene = getMainScene({ scenes: ensuredScenes });
|
||||
|
||||
set({
|
||||
scenes: ensuredScenes,
|
||||
currentScene: currentScene || fallbackScene,
|
||||
});
|
||||
|
||||
if (ensuredScenes.length > scenes.length) {
|
||||
const projectStore = useProjectStore.getState();
|
||||
const { activeProject } = projectStore;
|
||||
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: ensuredScenes,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
storageService
|
||||
.saveProject({ project: updatedProject })
|
||||
.then(() => {
|
||||
useProjectStore.setState({ activeProject: updatedProject });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"Failed to save project with background scene:",
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearScenes: () => {
|
||||
set({
|
||||
scenes: [],
|
||||
currentScene: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
+227
-248
@@ -1,282 +1,261 @@
|
||||
import { create } from "zustand";
|
||||
import type { SoundEffect, SavedSound } from "@/types/sounds";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { storageService } from "@/services/storage/storage-service";
|
||||
import { toast } from "sonner";
|
||||
import { useMediaStore } from "./media-store";
|
||||
import { useTimelineStore } from "./timeline-store";
|
||||
import { useProjectStore } from "./project-store";
|
||||
import { usePlaybackStore } from "./playback-store";
|
||||
import { EditorCore } from "@/core";
|
||||
import { buildLibraryAudioElement } from "@/lib/timeline/element-utils";
|
||||
|
||||
interface SoundsStore {
|
||||
topSoundEffects: SoundEffect[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
hasLoaded: boolean;
|
||||
topSoundEffects: SoundEffect[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
hasLoaded: boolean;
|
||||
showCommercialOnly: boolean;
|
||||
toggleCommercialFilter: () => void;
|
||||
searchQuery: string;
|
||||
searchResults: SoundEffect[];
|
||||
isSearching: boolean;
|
||||
searchError: string | null;
|
||||
lastSearchQuery: string;
|
||||
scrollPosition: number;
|
||||
currentPage: number;
|
||||
hasNextPage: boolean;
|
||||
totalCount: number;
|
||||
isLoadingMore: boolean;
|
||||
savedSounds: SavedSound[];
|
||||
isSavedSoundsLoaded: boolean;
|
||||
isLoadingSavedSounds: boolean;
|
||||
savedSoundsError: string | null;
|
||||
|
||||
// Filter state
|
||||
showCommercialOnly: boolean;
|
||||
toggleCommercialFilter: () => void;
|
||||
|
||||
// Search state
|
||||
searchQuery: string;
|
||||
searchResults: SoundEffect[];
|
||||
isSearching: boolean;
|
||||
searchError: string | null;
|
||||
lastSearchQuery: string;
|
||||
scrollPosition: number;
|
||||
|
||||
// Pagination state
|
||||
currentPage: number;
|
||||
hasNextPage: boolean;
|
||||
totalCount: number;
|
||||
isLoadingMore: boolean;
|
||||
|
||||
// Saved sounds state
|
||||
savedSounds: SavedSound[];
|
||||
isSavedSoundsLoaded: boolean;
|
||||
isLoadingSavedSounds: boolean;
|
||||
savedSoundsError: string | null;
|
||||
|
||||
// Timeline integration
|
||||
addSoundToTimeline: (sound: SoundEffect) => Promise<boolean>;
|
||||
|
||||
setTopSoundEffects: (sounds: SoundEffect[]) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setHasLoaded: (loaded: boolean) => void;
|
||||
|
||||
// Search actions
|
||||
setSearchQuery: (query: string) => void;
|
||||
setSearchResults: (results: SoundEffect[]) => void;
|
||||
setSearching: (searching: boolean) => void;
|
||||
setSearchError: (error: string | null) => void;
|
||||
setLastSearchQuery: (query: string) => void;
|
||||
setScrollPosition: (position: number) => void;
|
||||
|
||||
// Pagination actions
|
||||
setCurrentPage: (page: number) => void;
|
||||
setHasNextPage: (hasNext: boolean) => void;
|
||||
setTotalCount: (count: number) => void;
|
||||
setLoadingMore: (loading: boolean) => void;
|
||||
appendSearchResults: (results: SoundEffect[]) => void;
|
||||
appendTopSounds: (results: SoundEffect[]) => void;
|
||||
resetPagination: () => void;
|
||||
|
||||
// Saved sounds actions
|
||||
loadSavedSounds: () => Promise<void>;
|
||||
saveSoundEffect: (soundEffect: SoundEffect) => Promise<void>;
|
||||
removeSavedSound: (soundId: number) => Promise<void>;
|
||||
isSoundSaved: (soundId: number) => boolean;
|
||||
toggleSavedSound: (soundEffect: SoundEffect) => Promise<void>;
|
||||
clearSavedSounds: () => Promise<void>;
|
||||
addSoundToTimeline: ({ sound }: { sound: SoundEffect }) => Promise<boolean>;
|
||||
setTopSoundEffects: ({ sounds }: { sounds: SoundEffect[] }) => void;
|
||||
setLoading: ({ loading }: { loading: boolean }) => void;
|
||||
setError: ({ error }: { error: string | null }) => void;
|
||||
setHasLoaded: ({ loaded }: { loaded: boolean }) => void;
|
||||
setSearchQuery: ({ query }: { query: string }) => void;
|
||||
setSearchResults: ({ results }: { results: SoundEffect[] }) => void;
|
||||
setSearching: ({ searching }: { searching: boolean }) => void;
|
||||
setSearchError: ({ error }: { error: string | null }) => void;
|
||||
setLastSearchQuery: ({ query }: { query: string }) => void;
|
||||
setScrollPosition: ({ position }: { position: number }) => void;
|
||||
setCurrentPage: ({ page }: { page: number }) => void;
|
||||
setHasNextPage: ({ hasNext }: { hasNext: boolean }) => void;
|
||||
setTotalCount: ({ count }: { count: number }) => void;
|
||||
setLoadingMore: ({ loading }: { loading: boolean }) => void;
|
||||
appendSearchResults: ({ results }: { results: SoundEffect[] }) => void;
|
||||
appendTopSounds: ({ results }: { results: SoundEffect[] }) => void;
|
||||
resetPagination: () => void;
|
||||
loadSavedSounds: () => Promise<void>;
|
||||
saveSoundEffect: ({
|
||||
soundEffect,
|
||||
}: {
|
||||
soundEffect: SoundEffect;
|
||||
}) => Promise<void>;
|
||||
removeSavedSound: ({ soundId }: { soundId: number }) => Promise<void>;
|
||||
isSoundSaved: ({ soundId }: { soundId: number }) => boolean;
|
||||
toggleSavedSound: ({
|
||||
soundEffect,
|
||||
}: {
|
||||
soundEffect: SoundEffect;
|
||||
}) => Promise<void>;
|
||||
clearSavedSounds: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSoundsStore = create<SoundsStore>((set, get) => ({
|
||||
topSoundEffects: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
hasLoaded: false,
|
||||
showCommercialOnly: true,
|
||||
topSoundEffects: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
hasLoaded: false,
|
||||
showCommercialOnly: true,
|
||||
|
||||
toggleCommercialFilter: () => {
|
||||
set((state) => ({ showCommercialOnly: !state.showCommercialOnly }));
|
||||
},
|
||||
toggleCommercialFilter: () => {
|
||||
set((state) => ({ showCommercialOnly: !state.showCommercialOnly }));
|
||||
},
|
||||
|
||||
// Search state
|
||||
searchQuery: "",
|
||||
searchResults: [],
|
||||
isSearching: false,
|
||||
searchError: null,
|
||||
lastSearchQuery: "",
|
||||
scrollPosition: 0,
|
||||
searchQuery: "",
|
||||
searchResults: [],
|
||||
isSearching: false,
|
||||
searchError: null,
|
||||
lastSearchQuery: "",
|
||||
scrollPosition: 0,
|
||||
currentPage: 1,
|
||||
hasNextPage: false,
|
||||
totalCount: 0,
|
||||
isLoadingMore: false,
|
||||
savedSounds: [],
|
||||
isSavedSoundsLoaded: false,
|
||||
isLoadingSavedSounds: false,
|
||||
savedSoundsError: null,
|
||||
|
||||
// Pagination state
|
||||
currentPage: 1,
|
||||
hasNextPage: false,
|
||||
totalCount: 0,
|
||||
isLoadingMore: false,
|
||||
setTopSoundEffects: ({ sounds }) => set({ topSoundEffects: sounds }),
|
||||
setLoading: ({ loading }) => set({ isLoading: loading }),
|
||||
setError: ({ error }) => set({ error }),
|
||||
setHasLoaded: ({ loaded }) => set({ hasLoaded: loaded }),
|
||||
setSearchQuery: ({ query }) => set({ searchQuery: query }),
|
||||
setSearchResults: ({ results }) =>
|
||||
set({ searchResults: results, currentPage: 1 }),
|
||||
setSearching: ({ searching }) => set({ isSearching: searching }),
|
||||
setSearchError: ({ error }) => set({ searchError: error }),
|
||||
setLastSearchQuery: ({ query }) => set({ lastSearchQuery: query }),
|
||||
setScrollPosition: ({ position }) => set({ scrollPosition: position }),
|
||||
setCurrentPage: ({ page }) => set({ currentPage: page }),
|
||||
setHasNextPage: ({ hasNext }) => set({ hasNextPage: hasNext }),
|
||||
setTotalCount: ({ count }) => set({ totalCount: count }),
|
||||
setLoadingMore: ({ loading }) => set({ isLoadingMore: loading }),
|
||||
|
||||
// Saved sounds state
|
||||
savedSounds: [],
|
||||
isSavedSoundsLoaded: false,
|
||||
isLoadingSavedSounds: false,
|
||||
savedSoundsError: null,
|
||||
appendSearchResults: ({ results }) =>
|
||||
set((state) => ({
|
||||
searchResults: [...state.searchResults, ...results],
|
||||
})),
|
||||
|
||||
setTopSoundEffects: (sounds) => set({ topSoundEffects: sounds }),
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
setError: (error) => set({ error }),
|
||||
setHasLoaded: (loaded) => set({ hasLoaded: loaded }),
|
||||
appendTopSounds: ({ results }) =>
|
||||
set((state) => ({
|
||||
topSoundEffects: [...state.topSoundEffects, ...results],
|
||||
})),
|
||||
|
||||
// Search actions
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setSearchResults: (results) =>
|
||||
set({ searchResults: results, currentPage: 1 }),
|
||||
setSearching: (searching) => set({ isSearching: searching }),
|
||||
setSearchError: (error) => set({ searchError: error }),
|
||||
setLastSearchQuery: (query) => set({ lastSearchQuery: query }),
|
||||
setScrollPosition: (position) => set({ scrollPosition: position }),
|
||||
resetPagination: () =>
|
||||
set({
|
||||
currentPage: 1,
|
||||
hasNextPage: false,
|
||||
totalCount: 0,
|
||||
isLoadingMore: false,
|
||||
}),
|
||||
|
||||
// Pagination actions
|
||||
setCurrentPage: (page) => set({ currentPage: page }),
|
||||
setHasNextPage: (hasNext) => set({ hasNextPage: hasNext }),
|
||||
setTotalCount: (count) => set({ totalCount: count }),
|
||||
setLoadingMore: (loading) => set({ isLoadingMore: loading }),
|
||||
appendSearchResults: (results) =>
|
||||
set((state) => ({
|
||||
searchResults: [...state.searchResults, ...results],
|
||||
})),
|
||||
appendTopSounds: (results) =>
|
||||
set((state) => ({
|
||||
topSoundEffects: [...state.topSoundEffects, ...results],
|
||||
})),
|
||||
resetPagination: () =>
|
||||
set({
|
||||
currentPage: 1,
|
||||
hasNextPage: false,
|
||||
totalCount: 0,
|
||||
isLoadingMore: false,
|
||||
}),
|
||||
loadSavedSounds: async () => {
|
||||
if (get().isSavedSoundsLoaded) return;
|
||||
|
||||
// Saved sounds actions
|
||||
loadSavedSounds: async () => {
|
||||
if (get().isSavedSoundsLoaded) return;
|
||||
try {
|
||||
set({ isLoadingSavedSounds: true, savedSoundsError: null });
|
||||
const savedSoundsData = await storageService.loadSavedSounds();
|
||||
set({
|
||||
savedSounds: savedSoundsData.sounds,
|
||||
isSavedSoundsLoaded: true,
|
||||
isLoadingSavedSounds: false,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to load saved sounds";
|
||||
set({
|
||||
savedSoundsError: errorMessage,
|
||||
isLoadingSavedSounds: false,
|
||||
});
|
||||
console.error("Failed to load saved sounds:", error);
|
||||
}
|
||||
},
|
||||
|
||||
try {
|
||||
set({ isLoadingSavedSounds: true, savedSoundsError: null });
|
||||
const savedSoundsData = await storageService.loadSavedSounds();
|
||||
set({
|
||||
savedSounds: savedSoundsData.sounds,
|
||||
isSavedSoundsLoaded: true,
|
||||
isLoadingSavedSounds: false,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to load saved sounds";
|
||||
set({
|
||||
savedSoundsError: errorMessage,
|
||||
isLoadingSavedSounds: false,
|
||||
});
|
||||
console.error("Failed to load saved sounds:", error);
|
||||
}
|
||||
},
|
||||
saveSoundEffect: async ({ soundEffect }) => {
|
||||
try {
|
||||
await storageService.saveSoundEffect({ soundEffect });
|
||||
|
||||
saveSoundEffect: async (soundEffect: SoundEffect) => {
|
||||
try {
|
||||
await storageService.saveSoundEffect({ soundEffect });
|
||||
const savedSoundsData = await storageService.loadSavedSounds();
|
||||
set({ savedSounds: savedSoundsData.sounds });
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to save sound";
|
||||
set({ savedSoundsError: errorMessage });
|
||||
toast.error("Failed to save sound");
|
||||
console.error("Failed to save sound:", error);
|
||||
}
|
||||
},
|
||||
|
||||
// Refresh saved sounds
|
||||
const savedSoundsData = await storageService.loadSavedSounds();
|
||||
set({ savedSounds: savedSoundsData.sounds });
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to save sound";
|
||||
set({ savedSoundsError: errorMessage });
|
||||
toast.error("Failed to save sound");
|
||||
console.error("Failed to save sound:", error);
|
||||
}
|
||||
},
|
||||
removeSavedSound: async ({ soundId }) => {
|
||||
try {
|
||||
await storageService.removeSavedSound({ soundId });
|
||||
|
||||
removeSavedSound: async (soundId: number) => {
|
||||
try {
|
||||
await storageService.removeSavedSound({ soundId });
|
||||
set((state) => ({
|
||||
savedSounds: state.savedSounds.filter((sound) => sound.id !== soundId),
|
||||
}));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to remove sound";
|
||||
set({ savedSoundsError: errorMessage });
|
||||
toast.error("Failed to remove sound");
|
||||
console.error("Failed to remove sound:", error);
|
||||
}
|
||||
},
|
||||
|
||||
// Update local state immediately
|
||||
set((state) => ({
|
||||
savedSounds: state.savedSounds.filter((sound) => sound.id !== soundId),
|
||||
}));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to remove sound";
|
||||
set({ savedSoundsError: errorMessage });
|
||||
toast.error("Failed to remove sound");
|
||||
console.error("Failed to remove sound:", error);
|
||||
}
|
||||
},
|
||||
isSoundSaved: ({ soundId }) => {
|
||||
const { savedSounds } = get();
|
||||
return savedSounds.some((sound) => sound.id === soundId);
|
||||
},
|
||||
|
||||
isSoundSaved: (soundId: number) => {
|
||||
const { savedSounds } = get();
|
||||
return savedSounds.some((sound) => sound.id === soundId);
|
||||
},
|
||||
toggleSavedSound: async ({ soundEffect }) => {
|
||||
const { isSoundSaved, saveSoundEffect, removeSavedSound } = get();
|
||||
|
||||
toggleSavedSound: async (soundEffect: SoundEffect) => {
|
||||
const { isSoundSaved, saveSoundEffect, removeSavedSound } = get();
|
||||
if (isSoundSaved({ soundId: soundEffect.id })) {
|
||||
await removeSavedSound({ soundId: soundEffect.id });
|
||||
} else {
|
||||
await saveSoundEffect({ soundEffect });
|
||||
}
|
||||
},
|
||||
|
||||
if (isSoundSaved(soundEffect.id)) {
|
||||
await removeSavedSound(soundEffect.id);
|
||||
} else {
|
||||
await saveSoundEffect(soundEffect);
|
||||
}
|
||||
},
|
||||
clearSavedSounds: async () => {
|
||||
try {
|
||||
await storageService.clearSavedSounds();
|
||||
set({
|
||||
savedSounds: [],
|
||||
savedSoundsError: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to clear saved sounds";
|
||||
set({ savedSoundsError: errorMessage });
|
||||
toast.error("Failed to clear saved sounds");
|
||||
console.error("Failed to clear saved sounds:", error);
|
||||
}
|
||||
},
|
||||
|
||||
clearSavedSounds: async () => {
|
||||
try {
|
||||
await storageService.clearSavedSounds();
|
||||
set({
|
||||
savedSounds: [],
|
||||
savedSoundsError: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to clear saved sounds";
|
||||
set({ savedSoundsError: errorMessage });
|
||||
toast.error("Failed to clear saved sounds");
|
||||
console.error("Failed to clear saved sounds:", error);
|
||||
}
|
||||
},
|
||||
addSoundToTimeline: async ({ sound }) => {
|
||||
const audioUrl = sound.previewUrl;
|
||||
if (!audioUrl) {
|
||||
toast.error("Sound file not available");
|
||||
return false;
|
||||
}
|
||||
|
||||
addSoundToTimeline: async (sound) => {
|
||||
const activeProject = useProjectStore.getState().activeProject;
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const editor = EditorCore.getInstance();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
|
||||
const audioUrl = sound.previewUrl;
|
||||
if (!audioUrl) {
|
||||
toast.error("Sound file not available");
|
||||
return false;
|
||||
}
|
||||
const response = await fetch(audioUrl);
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to download audio: ${response.statusText}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(audioUrl);
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to download audio: ${response.statusText}`);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const audioContext = new AudioContext();
|
||||
const buffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], `${sound.name}.mp3`, {
|
||||
type: "audio/mpeg",
|
||||
});
|
||||
const audioTrack = tracks.find((t) => t.type === "audio");
|
||||
let trackId: string;
|
||||
|
||||
await useMediaStore.getState().addMediaFile(activeProject.id, {
|
||||
name: sound.name,
|
||||
type: "audio",
|
||||
file,
|
||||
duration: sound.duration,
|
||||
url: URL.createObjectURL(file),
|
||||
});
|
||||
if (audioTrack) {
|
||||
trackId = audioTrack.id;
|
||||
} else {
|
||||
trackId = editor.timeline.addTrack({ type: "audio" });
|
||||
}
|
||||
|
||||
const mediaItem = useMediaStore
|
||||
.getState()
|
||||
.mediaFiles.find((item) => item.file === file);
|
||||
if (!mediaItem) throw new Error("Failed to create media item");
|
||||
const element = buildLibraryAudioElement({
|
||||
sourceUrl: audioUrl,
|
||||
name: sound.name,
|
||||
duration: sound.duration,
|
||||
startTime: currentTime,
|
||||
buffer,
|
||||
});
|
||||
|
||||
const success = useTimelineStore
|
||||
.getState()
|
||||
.addElementAtTime(mediaItem, usePlaybackStore.getState().currentTime);
|
||||
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
throw new Error("Failed to add to timeline - check for overlaps");
|
||||
} catch (error) {
|
||||
console.error("Failed to add sound to timeline:", error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to add sound to timeline",
|
||||
{ id: `sound-${sound.id}` }
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to add sound to timeline:", error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to add sound to timeline",
|
||||
{ id: `sound-${sound.id}` },
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,231 +1,176 @@
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
getCollections,
|
||||
getCollection,
|
||||
searchIcons,
|
||||
downloadSvgAsText,
|
||||
svgToFile,
|
||||
type IconSet,
|
||||
type CollectionInfo,
|
||||
type IconSearchResult,
|
||||
getCollections,
|
||||
getCollection,
|
||||
searchIcons,
|
||||
type IconSet,
|
||||
type CollectionInfo,
|
||||
type IconSearchResult,
|
||||
} from "@/lib/iconify-api";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import type { MediaFile } from "@/types/media";
|
||||
import { EditorCore } from "@/core";
|
||||
import { buildStickerElement } from "@/lib/timeline/element-utils";
|
||||
import { STICKER_CATEGORY_CONFIG } from "@/constants/stickers-constants";
|
||||
import type { StickerCategory } from "@/types/stickers";
|
||||
|
||||
export type StickerCategory = "all" | "general" | "brands" | "emoji";
|
||||
type ViewMode = "search" | "browse" | "collection";
|
||||
|
||||
interface StickersStore {
|
||||
searchQuery: string;
|
||||
selectedCategory: StickerCategory;
|
||||
selectedCollection: string | null;
|
||||
viewMode: "search" | "browse" | "collection";
|
||||
searchQuery: string;
|
||||
selectedCategory: StickerCategory;
|
||||
selectedCollection: string | null;
|
||||
viewMode: ViewMode;
|
||||
collections: Record<string, IconSet>;
|
||||
currentCollection: CollectionInfo | null;
|
||||
searchResults: IconSearchResult | null;
|
||||
recentStickers: string[];
|
||||
isLoadingCollections: boolean;
|
||||
isLoadingCollection: boolean;
|
||||
isSearching: boolean;
|
||||
addingSticker: string | null;
|
||||
|
||||
collections: Record<string, IconSet>;
|
||||
currentCollection: CollectionInfo | null;
|
||||
searchResults: IconSearchResult | null;
|
||||
recentStickers: string[];
|
||||
isLoadingCollections: boolean;
|
||||
isLoadingCollection: boolean;
|
||||
isSearching: boolean;
|
||||
isDownloading: boolean;
|
||||
addingSticker: string | null;
|
||||
|
||||
setSearchQuery: (query: string) => void;
|
||||
setSelectedCategory: (category: StickerCategory) => void;
|
||||
setSelectedCollection: (collection: string | null) => void;
|
||||
setViewMode: (mode: "search" | "browse" | "collection") => void;
|
||||
|
||||
loadCollections: () => Promise<void>;
|
||||
loadCollection: (prefix: string) => Promise<void>;
|
||||
searchStickers: (query: string) => Promise<void>;
|
||||
downloadSticker: (iconName: string) => Promise<File | null>;
|
||||
addStickerToTimeline: (iconName: string) => Promise<void>;
|
||||
|
||||
addToRecentStickers: (iconName: string) => void;
|
||||
clearRecentStickers: () => void;
|
||||
setSearchQuery: ({ query }: { query: string }) => void;
|
||||
setSelectedCategory: ({ category }: { category: StickerCategory }) => void;
|
||||
setSelectedCollection: ({
|
||||
collection,
|
||||
}: {
|
||||
collection: string | null;
|
||||
}) => void;
|
||||
setViewMode: ({ mode }: { mode: ViewMode }) => void;
|
||||
loadCollections: () => Promise<void>;
|
||||
loadCollection: ({ prefix }: { prefix: string }) => Promise<void>;
|
||||
searchStickers: ({ query }: { query: string }) => Promise<void>;
|
||||
addStickerToTimeline: ({ iconName }: { iconName: string }) => void;
|
||||
addToRecentStickers: ({ iconName }: { iconName: string }) => void;
|
||||
clearRecentStickers: () => void;
|
||||
}
|
||||
|
||||
const MAX_RECENT_STICKERS = 50;
|
||||
|
||||
export const useStickersStore = create<StickersStore>((set, get) => ({
|
||||
searchQuery: "",
|
||||
selectedCategory: "all",
|
||||
selectedCollection: null,
|
||||
viewMode: "browse",
|
||||
searchQuery: "",
|
||||
selectedCategory: "all",
|
||||
selectedCollection: null,
|
||||
viewMode: "browse",
|
||||
|
||||
collections: {},
|
||||
currentCollection: null,
|
||||
searchResults: null,
|
||||
recentStickers: [],
|
||||
collections: {},
|
||||
currentCollection: null,
|
||||
searchResults: null,
|
||||
recentStickers: [],
|
||||
|
||||
isLoadingCollections: false,
|
||||
isLoadingCollection: false,
|
||||
isSearching: false,
|
||||
isDownloading: false,
|
||||
addingSticker: null,
|
||||
isLoadingCollections: false,
|
||||
isLoadingCollection: false,
|
||||
isSearching: false,
|
||||
addingSticker: null,
|
||||
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setSearchQuery: ({ query }) => set({ searchQuery: query }),
|
||||
|
||||
setSelectedCategory: (category) =>
|
||||
set({
|
||||
selectedCategory: category,
|
||||
viewMode: "browse",
|
||||
selectedCollection: null,
|
||||
currentCollection: null,
|
||||
}),
|
||||
setSelectedCategory: ({ category }) =>
|
||||
set({
|
||||
selectedCategory: category,
|
||||
viewMode: "browse",
|
||||
selectedCollection: null,
|
||||
currentCollection: null,
|
||||
}),
|
||||
|
||||
setSelectedCollection: (collection) => {
|
||||
set({
|
||||
selectedCollection: collection,
|
||||
viewMode: collection ? "collection" : "browse",
|
||||
currentCollection: null,
|
||||
});
|
||||
setSelectedCollection: ({ collection }) => {
|
||||
set({
|
||||
selectedCollection: collection,
|
||||
viewMode: collection ? "collection" : "browse",
|
||||
currentCollection: null,
|
||||
});
|
||||
|
||||
if (collection) {
|
||||
get().loadCollection(collection);
|
||||
}
|
||||
},
|
||||
if (collection) {
|
||||
get().loadCollection({ prefix: collection });
|
||||
}
|
||||
},
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
setViewMode: ({ mode }) => set({ viewMode: mode }),
|
||||
|
||||
loadCollections: async () => {
|
||||
set({ isLoadingCollections: true });
|
||||
try {
|
||||
const collections = await getCollections();
|
||||
set({ collections });
|
||||
} catch (error) {
|
||||
console.error("Failed to load collections:", error);
|
||||
} finally {
|
||||
set({ isLoadingCollections: false });
|
||||
}
|
||||
},
|
||||
loadCollections: async () => {
|
||||
set({ isLoadingCollections: true });
|
||||
try {
|
||||
const collections = await getCollections();
|
||||
set({ collections });
|
||||
} catch (error) {
|
||||
console.error("Failed to load collections:", error);
|
||||
} finally {
|
||||
set({ isLoadingCollections: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadCollection: async (prefix: string) => {
|
||||
set({ isLoadingCollection: true });
|
||||
try {
|
||||
const collection = await getCollection(prefix);
|
||||
set({ currentCollection: collection });
|
||||
} catch (error) {
|
||||
console.error(`Failed to load collection ${prefix}:`, error);
|
||||
set({ currentCollection: null });
|
||||
} finally {
|
||||
set({ isLoadingCollection: false });
|
||||
}
|
||||
},
|
||||
loadCollection: async ({ prefix }: { prefix: string }) => {
|
||||
set({ isLoadingCollection: true });
|
||||
try {
|
||||
const collection = await getCollection(prefix);
|
||||
set({ currentCollection: collection });
|
||||
} catch (error) {
|
||||
console.error(`Failed to load collection ${prefix}:`, error);
|
||||
set({ currentCollection: null });
|
||||
} finally {
|
||||
set({ isLoadingCollection: false });
|
||||
}
|
||||
},
|
||||
|
||||
searchStickers: async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
set({ searchResults: null, viewMode: "browse" });
|
||||
return;
|
||||
}
|
||||
searchStickers: async ({ query }: { query: string }) => {
|
||||
if (!query.trim()) {
|
||||
set({ searchResults: null, viewMode: "browse" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { selectedCategory } = get();
|
||||
const { selectedCategory } = get();
|
||||
|
||||
set({ isSearching: true, viewMode: "search" });
|
||||
try {
|
||||
let category: string | undefined;
|
||||
set({ isSearching: true, viewMode: "search" });
|
||||
try {
|
||||
const category = STICKER_CATEGORY_CONFIG[selectedCategory];
|
||||
const results = await searchIcons(query, 100, undefined, category);
|
||||
set({ searchResults: results });
|
||||
} catch (error) {
|
||||
console.error("Search failed:", error);
|
||||
set({ searchResults: null });
|
||||
} finally {
|
||||
set({ isSearching: false });
|
||||
}
|
||||
},
|
||||
|
||||
if (selectedCategory !== "all") {
|
||||
if (selectedCategory === "general") {
|
||||
category = "General";
|
||||
} else if (selectedCategory === "brands") {
|
||||
category = "Brands / Social";
|
||||
} else if (selectedCategory === "emoji") {
|
||||
category = "Emoji";
|
||||
}
|
||||
}
|
||||
addStickerToTimeline: ({ iconName }: { iconName: string }) => {
|
||||
set({ addingSticker: iconName });
|
||||
try {
|
||||
const editor = EditorCore.getInstance();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
|
||||
const results = await searchIcons(query, 100, undefined, category);
|
||||
set({ searchResults: results });
|
||||
} catch (error) {
|
||||
console.error("Search failed:", error);
|
||||
set({ searchResults: null });
|
||||
} finally {
|
||||
set({ isSearching: false });
|
||||
}
|
||||
},
|
||||
const stickerTrack = tracks.find((t) => t.type === "sticker");
|
||||
let trackId: string;
|
||||
|
||||
downloadSticker: async (iconName: string) => {
|
||||
set({ isDownloading: true });
|
||||
try {
|
||||
const svgText = await downloadSvgAsText(iconName, {
|
||||
width: 200,
|
||||
height: 200,
|
||||
});
|
||||
if (stickerTrack) {
|
||||
trackId = stickerTrack.id;
|
||||
} else {
|
||||
trackId = editor.timeline.addTrack({ type: "sticker" });
|
||||
}
|
||||
|
||||
const fileName = `${iconName.replace(":", "-")}.svg`;
|
||||
const file = svgToFile(svgText, fileName);
|
||||
const element = buildStickerElement({ iconName, startTime: currentTime });
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element,
|
||||
});
|
||||
|
||||
get().addToRecentStickers(iconName);
|
||||
get().addToRecentStickers({ iconName });
|
||||
} finally {
|
||||
set({ addingSticker: null });
|
||||
}
|
||||
},
|
||||
|
||||
return file;
|
||||
} catch (error) {
|
||||
console.error(`Failed to download sticker ${iconName}:`, error);
|
||||
return null;
|
||||
} finally {
|
||||
set({ isDownloading: false });
|
||||
}
|
||||
},
|
||||
addToRecentStickers: ({ iconName }: { iconName: string }) => {
|
||||
set((state) => {
|
||||
const recent = [
|
||||
iconName,
|
||||
...state.recentStickers.filter((s) => s !== iconName),
|
||||
];
|
||||
return {
|
||||
recentStickers: recent.slice(0, MAX_RECENT_STICKERS),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
addStickerToTimeline: async (iconName: string) => {
|
||||
set({ addingSticker: iconName });
|
||||
try {
|
||||
const { activeProject } = useProjectStore.getState();
|
||||
if (!activeProject) {
|
||||
throw new Error("No active project");
|
||||
}
|
||||
|
||||
const file = await get().downloadSticker(iconName);
|
||||
if (!file) {
|
||||
throw new Error("Failed to download sticker");
|
||||
}
|
||||
|
||||
const mediaItem: Omit<MediaFile, "id"> = {
|
||||
name: iconName.replace(":", "-"),
|
||||
type: "image",
|
||||
file,
|
||||
url: URL.createObjectURL(file),
|
||||
width: 200,
|
||||
height: 200,
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
|
||||
ephemeral: false,
|
||||
};
|
||||
|
||||
const { addMediaFile } = useMediaStore.getState();
|
||||
await addMediaFile(activeProject.id, mediaItem);
|
||||
|
||||
const added = useMediaStore
|
||||
.getState()
|
||||
.mediaFiles.find(
|
||||
(m) => m.url === mediaItem.url && m.name === mediaItem.name
|
||||
);
|
||||
if (!added) {
|
||||
throw new Error("Sticker not in media store");
|
||||
}
|
||||
|
||||
const { currentTime } = usePlaybackStore.getState();
|
||||
const { addElementAtTime } = useTimelineStore.getState();
|
||||
addElementAtTime(added, currentTime);
|
||||
} finally {
|
||||
set({ addingSticker: null });
|
||||
}
|
||||
},
|
||||
|
||||
addToRecentStickers: (iconName: string) => {
|
||||
set((state) => {
|
||||
const recent = [
|
||||
iconName,
|
||||
...state.recentStickers.filter((s) => s !== iconName),
|
||||
];
|
||||
return {
|
||||
recentStickers: recent.slice(0, MAX_RECENT_STICKERS),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearRecentStickers: () => set({ recentStickers: [] }),
|
||||
clearRecentStickers: () => set({ recentStickers: [] }),
|
||||
}));
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export type TextPropertiesTab = "transform" | "style";
|
||||
export type TextPropertiesTab = "text" | "transform";
|
||||
|
||||
export interface TextPropertiesTabMeta {
|
||||
value: TextPropertiesTab;
|
||||
label: string;
|
||||
value: TextPropertiesTab;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const TEXT_PROPERTIES_TABS: ReadonlyArray<TextPropertiesTabMeta> = [
|
||||
{ value: "transform", label: "Transform" },
|
||||
{ value: "style", label: "Style" },
|
||||
{ value: "text", label: "Text" },
|
||||
{ value: "transform", label: "Transform" },
|
||||
] as const;
|
||||
|
||||
export function isTextPropertiesTab(value: string): value is TextPropertiesTab {
|
||||
return TEXT_PROPERTIES_TABS.some((t) => t.value === value);
|
||||
return TEXT_PROPERTIES_TABS.some((t) => t.value === value);
|
||||
}
|
||||
|
||||
interface TextPropertiesState {
|
||||
activeTab: TextPropertiesTab;
|
||||
setActiveTab: (tab: TextPropertiesTab) => void;
|
||||
activeTab: TextPropertiesTab;
|
||||
setActiveTab: (tab: TextPropertiesTab) => void;
|
||||
}
|
||||
|
||||
export const useTextPropertiesStore = create<TextPropertiesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
activeTab: "transform",
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
}),
|
||||
{ name: "text-properties" }
|
||||
)
|
||||
persist(
|
||||
(set) => ({
|
||||
activeTab: "text",
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
}),
|
||||
{ name: "text-properties" },
|
||||
),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user