mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: major editor overhaul (assets, properties, timeline, fonts) (#709)
* feat: major editor overhaul (assets, properties, timeline, fonts) Refactor editor core systems to standardize UI architecture and improve performance. Assets & Properties: - Replace monolithic property items with composable `Section` architecture. - Add specialized sections for Transform, Blending, and Text. - Implement `NumberField` with scrubbing and math evaluation. - Add new ColorPicker with EyeDropper and multiple format support. - Standardize asset panels using new `PanelView` layout. Fonts & Stickers: - Implement custom font atlas/sprite system for high-performance previews. - Add virtualized FontPicker with search and favorites. - Refactor stickers to use a provider-based architecture (icons, emoji, flags, shapes). - Standardize sticker IDs to `provider:value` format. Timeline & Interaction: - Convert bookmarks to rich objects with notes, colors, and duration. - Refactor drag-and-drop to use Command pattern (enabling proper undo/redo). - Add Shift modifier to disable snapping during moves/resizes. - Add new overlays for layout guides and text editing. Renderer: - Add support for multi-line text, custom line-height, and letter-spacing. - Implement global composite operation (blend modes). - Update sticker node to resolve dynamic provider IDs. Infrastructure: - Add storage migrations (v3->v6) for text weights, sticker IDs, and bookmarks. - Update global styles and core UI components (Button, Input, Popover). * add ts-nocheck directive to settings-legacy.tsx to suppress TypeScript errors * fix: correct global composite operation assignment in TextNode to ensure proper blend mode handling * deleted shadcn components with errors * formatting * fix linter issues * migrate from next middleware to proxy * add missing component back * add breadcrumb back * chore: add @radix-ui/react-primitive deps * chore: more deps * chore: add missing env vars to bun-ci * next env
This commit is contained in:
@@ -1,70 +1,24 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { TPlatformLayout } from "@/types/editor";
|
||||
import { DEFAULT_CANVAS_PRESETS } from "@/constants/project-constants";
|
||||
import type { TCanvasSize } from "@/types/project";
|
||||
|
||||
interface LayoutGuideSettings {
|
||||
platform: TPlatformLayout | null;
|
||||
}
|
||||
|
||||
interface EditorState {
|
||||
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;
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
isInitializing: true,
|
||||
isPanelsReady: false,
|
||||
canvasPresets: DEFAULT_CANVAS_PRESETS,
|
||||
layoutGuide: {
|
||||
platform: null,
|
||||
},
|
||||
setInitializing: (loading) => {
|
||||
set({ isInitializing: loading });
|
||||
},
|
||||
|
||||
setPanelsReady: (ready) => {
|
||||
set({ isPanelsReady: ready });
|
||||
},
|
||||
|
||||
initializeApp: async () => {
|
||||
set({ isInitializing: true, isPanelsReady: false });
|
||||
|
||||
set({ isPanelsReady: true, isInitializing: false });
|
||||
},
|
||||
|
||||
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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
export const useEditorStore = create<EditorState>()((set) => ({
|
||||
isInitializing: true,
|
||||
isPanelsReady: false,
|
||||
canvasPresets: DEFAULT_CANVAS_PRESETS,
|
||||
setInitializing: (loading) => set({ isInitializing: loading }),
|
||||
setPanelsReady: (ready) => set({ isPanelsReady: ready }),
|
||||
initializeApp: async () => {
|
||||
set({ isInitializing: true, isPanelsReady: false });
|
||||
set({ isPanelsReady: true, isInitializing: false });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -7,10 +7,7 @@ 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";
|
||||
import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations";
|
||||
|
||||
export const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { v2ToV3 } from './v2-to-v3';
|
||||
import { v2ToV3 } from "./v2-to-v3";
|
||||
import { v3ToV4 } from "./v3-to-v4";
|
||||
|
||||
type MigrationFn = ({ state }: { state: unknown }) => unknown;
|
||||
|
||||
@@ -8,9 +9,10 @@ type MigrationFn = ({ state }: { state: unknown }) => unknown;
|
||||
*/
|
||||
const migrations: Record<number, MigrationFn> = {
|
||||
2: v2ToV3,
|
||||
3: v3ToV4,
|
||||
};
|
||||
|
||||
export const CURRENT_VERSION = 3;
|
||||
export const CURRENT_VERSION = 4;
|
||||
|
||||
export function runMigrations({
|
||||
state,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { TActionWithOptionalArgs } from "@/lib/actions";
|
||||
import type { ShortcutKey } from "@/types/keybinding";
|
||||
import type { KeybindingConfig } from "@/types/keybinding";
|
||||
|
||||
interface V3State {
|
||||
keybindings: KeybindingConfig;
|
||||
isCustomized: boolean;
|
||||
}
|
||||
|
||||
export function v3ToV4({ state }: { state: unknown }): unknown {
|
||||
const v3 = state as V3State;
|
||||
|
||||
const renames: Record<string, string> = {
|
||||
"paste-selected": "paste-copied",
|
||||
};
|
||||
|
||||
const migrated = { ...v3.keybindings };
|
||||
for (const [key, action] of Object.entries(migrated)) {
|
||||
if (action && renames[action]) {
|
||||
migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...v3, keybindings: migrated };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { TPlatformLayout } from "@/types/editor";
|
||||
|
||||
interface LayoutGuideSettings {
|
||||
platform: TPlatformLayout | null;
|
||||
}
|
||||
|
||||
interface PreviewOverlaysState {
|
||||
bookmarks: boolean;
|
||||
}
|
||||
|
||||
interface PreviewState {
|
||||
layoutGuide: LayoutGuideSettings;
|
||||
overlays: PreviewOverlaysState;
|
||||
setLayoutGuide: (settings: Partial<LayoutGuideSettings>) => void;
|
||||
toggleLayoutGuide: (platform: TPlatformLayout) => void;
|
||||
setOverlayVisibility: ({
|
||||
overlay,
|
||||
isVisible,
|
||||
}: {
|
||||
overlay: keyof PreviewOverlaysState;
|
||||
isVisible: boolean;
|
||||
}) => void;
|
||||
toggleOverlayVisibility: ({
|
||||
overlay,
|
||||
}: {
|
||||
overlay: keyof PreviewOverlaysState;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_PREVIEW_OVERLAYS: PreviewOverlaysState = {
|
||||
bookmarks: true,
|
||||
};
|
||||
|
||||
export const usePreviewStore = create<PreviewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
layoutGuide: { platform: null },
|
||||
overlays: DEFAULT_PREVIEW_OVERLAYS,
|
||||
setLayoutGuide: (settings) => {
|
||||
set((state) => ({
|
||||
layoutGuide: {
|
||||
...state.layoutGuide,
|
||||
...settings,
|
||||
},
|
||||
}));
|
||||
},
|
||||
toggleLayoutGuide: (platform) => {
|
||||
set((state) => ({
|
||||
layoutGuide: {
|
||||
platform: state.layoutGuide.platform === platform ? null : platform,
|
||||
},
|
||||
}));
|
||||
},
|
||||
setOverlayVisibility: ({ overlay, isVisible }) => {
|
||||
set((state) => ({
|
||||
overlays: {
|
||||
...state.overlays,
|
||||
[overlay]: isVisible,
|
||||
},
|
||||
}));
|
||||
},
|
||||
toggleOverlayVisibility: ({ overlay }) => {
|
||||
set((state) => ({
|
||||
overlays: {
|
||||
...state.overlays,
|
||||
[overlay]: !state.overlays[overlay],
|
||||
},
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "preview-settings",
|
||||
version: 2,
|
||||
migrate: (persistedState) => {
|
||||
const state = persistedState as
|
||||
| {
|
||||
layoutGuide?: LayoutGuideSettings;
|
||||
overlays?: PreviewOverlaysState;
|
||||
}
|
||||
| undefined;
|
||||
return {
|
||||
layoutGuide: state?.layoutGuide ?? { platform: null },
|
||||
overlays: state?.overlays ?? DEFAULT_PREVIEW_OVERLAYS,
|
||||
};
|
||||
},
|
||||
partialize: (state) => ({
|
||||
layoutGuide: state.layoutGuide,
|
||||
overlays: state.overlays,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
+261
-261
@@ -1,261 +1,261 @@
|
||||
import { create } from "zustand";
|
||||
import type { SoundEffect, SavedSound } from "@/types/sounds";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import { toast } from "sonner";
|
||||
import { EditorCore } from "@/core";
|
||||
import { buildLibraryAudioElement } from "@/lib/timeline/element-utils";
|
||||
|
||||
interface SoundsStore {
|
||||
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;
|
||||
|
||||
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,
|
||||
|
||||
toggleCommercialFilter: () => {
|
||||
set((state) => ({ showCommercialOnly: !state.showCommercialOnly }));
|
||||
},
|
||||
|
||||
searchQuery: "",
|
||||
searchResults: [],
|
||||
isSearching: false,
|
||||
searchError: null,
|
||||
lastSearchQuery: "",
|
||||
scrollPosition: 0,
|
||||
currentPage: 1,
|
||||
hasNextPage: false,
|
||||
totalCount: 0,
|
||||
isLoadingMore: false,
|
||||
savedSounds: [],
|
||||
isSavedSoundsLoaded: false,
|
||||
isLoadingSavedSounds: false,
|
||||
savedSoundsError: null,
|
||||
|
||||
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 }),
|
||||
|
||||
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;
|
||||
|
||||
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 });
|
||||
|
||||
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 });
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
toggleSavedSound: async ({ soundEffect }) => {
|
||||
const { isSoundSaved, saveSoundEffect, removeSavedSound } = get();
|
||||
|
||||
if (isSoundSaved({ soundId: soundEffect.id })) {
|
||||
await removeSavedSound({ soundId: 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);
|
||||
}
|
||||
},
|
||||
|
||||
addSoundToTimeline: async ({ sound }) => {
|
||||
const audioUrl = sound.previewUrl;
|
||||
if (!audioUrl) {
|
||||
toast.error("Sound file not available");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const editor = EditorCore.getInstance();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
|
||||
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 audioTrack = tracks.find((t) => t.type === "audio");
|
||||
let trackId: string;
|
||||
|
||||
if (audioTrack) {
|
||||
trackId = audioTrack.id;
|
||||
} else {
|
||||
trackId = editor.timeline.addTrack({ type: "audio" });
|
||||
}
|
||||
|
||||
const element = buildLibraryAudioElement({
|
||||
sourceUrl: audioUrl,
|
||||
name: sound.name,
|
||||
duration: sound.duration,
|
||||
startTime: currentTime,
|
||||
buffer,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
}));
|
||||
import { create } from "zustand";
|
||||
import type { SoundEffect, SavedSound } from "@/types/sounds";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import { toast } from "sonner";
|
||||
import { EditorCore } from "@/core";
|
||||
import { buildLibraryAudioElement } from "@/lib/timeline/element-utils";
|
||||
|
||||
interface SoundsStore {
|
||||
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;
|
||||
|
||||
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,
|
||||
|
||||
toggleCommercialFilter: () => {
|
||||
set((state) => ({ showCommercialOnly: !state.showCommercialOnly }));
|
||||
},
|
||||
|
||||
searchQuery: "",
|
||||
searchResults: [],
|
||||
isSearching: false,
|
||||
searchError: null,
|
||||
lastSearchQuery: "",
|
||||
scrollPosition: 0,
|
||||
currentPage: 1,
|
||||
hasNextPage: false,
|
||||
totalCount: 0,
|
||||
isLoadingMore: false,
|
||||
savedSounds: [],
|
||||
isSavedSoundsLoaded: false,
|
||||
isLoadingSavedSounds: false,
|
||||
savedSoundsError: null,
|
||||
|
||||
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 }),
|
||||
|
||||
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;
|
||||
|
||||
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 });
|
||||
|
||||
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 });
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
toggleSavedSound: async ({ soundEffect }) => {
|
||||
const { isSoundSaved, saveSoundEffect, removeSavedSound } = get();
|
||||
|
||||
if (isSoundSaved({ soundId: soundEffect.id })) {
|
||||
await removeSavedSound({ soundId: 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);
|
||||
}
|
||||
},
|
||||
|
||||
addSoundToTimeline: async ({ sound }) => {
|
||||
const audioUrl = sound.previewUrl;
|
||||
if (!audioUrl) {
|
||||
toast.error("Sound file not available");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const editor = EditorCore.getInstance();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
|
||||
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 audioTrack = tracks.find((t) => t.type === "audio");
|
||||
let trackId: string;
|
||||
|
||||
if (audioTrack) {
|
||||
trackId = audioTrack.id;
|
||||
} else {
|
||||
trackId = editor.timeline.addTrack({ type: "audio" });
|
||||
}
|
||||
|
||||
const element = buildLibraryAudioElement({
|
||||
sourceUrl: audioUrl,
|
||||
name: sound.name,
|
||||
duration: sound.duration,
|
||||
startTime: currentTime,
|
||||
buffer,
|
||||
});
|
||||
|
||||
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,176 +1,219 @@
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
getCollections,
|
||||
getCollection,
|
||||
searchIcons,
|
||||
type IconSet,
|
||||
type CollectionInfo,
|
||||
type IconSearchResult,
|
||||
} from "@/lib/iconify-api";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { EditorCore } from "@/core";
|
||||
import { searchStickers as searchStickersFromProviders } from "@/lib/stickers";
|
||||
import type { StickerSearchResult } from "@/lib/stickers";
|
||||
import { buildStickerElement } from "@/lib/timeline/element-utils";
|
||||
import { STICKER_CATEGORY_CONFIG } from "@/constants/stickers-constants";
|
||||
import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
|
||||
import type { StickerCategory } from "@/types/stickers";
|
||||
import { registerDefaultStickerProviders } from "@/lib/stickers/providers";
|
||||
import { hasProvider } from "@/lib/stickers/registry";
|
||||
import { parseStickerId } from "@/lib/stickers/sticker-id";
|
||||
|
||||
type ViewMode = "search" | "browse" | "collection";
|
||||
const MAX_RECENT_STICKERS = 50;
|
||||
|
||||
function isValidStickerId(value: unknown): value is string {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseStickerId({ stickerId: value });
|
||||
return hasProvider({ providerId: parsed.providerId });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeRecentStickers({
|
||||
recentStickers,
|
||||
}: {
|
||||
recentStickers: unknown;
|
||||
}): string[] {
|
||||
registerDefaultStickerProviders({});
|
||||
|
||||
if (!Array.isArray(recentStickers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sanitized: string[] = [];
|
||||
for (const stickerId of recentStickers) {
|
||||
if (!isValidStickerId(stickerId)) {
|
||||
continue;
|
||||
}
|
||||
if (sanitized.includes(stickerId)) {
|
||||
continue;
|
||||
}
|
||||
sanitized.push(stickerId);
|
||||
if (sanitized.length >= MAX_RECENT_STICKERS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
type ViewMode = "search" | "browse";
|
||||
|
||||
interface StickersStore {
|
||||
searchQuery: string;
|
||||
selectedCategory: StickerCategory;
|
||||
selectedCollection: string | null;
|
||||
viewMode: ViewMode;
|
||||
collections: Record<string, IconSet>;
|
||||
currentCollection: CollectionInfo | null;
|
||||
searchResults: IconSearchResult | null;
|
||||
searchResults: StickerSearchResult | null;
|
||||
recentStickers: string[];
|
||||
isLoadingCollections: boolean;
|
||||
isLoadingCollection: boolean;
|
||||
isSearching: boolean;
|
||||
addingSticker: string | null;
|
||||
|
||||
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;
|
||||
addStickerToTimeline: ({
|
||||
stickerId,
|
||||
name,
|
||||
}: {
|
||||
stickerId: string;
|
||||
name?: string;
|
||||
}) => void;
|
||||
addToRecentStickers: ({ stickerId }: { stickerId: string }) => void;
|
||||
clearRecentStickers: () => void;
|
||||
}
|
||||
|
||||
const MAX_RECENT_STICKERS = 50;
|
||||
|
||||
export const useStickersStore = create<StickersStore>((set, get) => ({
|
||||
searchQuery: "",
|
||||
selectedCategory: "all",
|
||||
selectedCollection: null,
|
||||
viewMode: "browse",
|
||||
|
||||
collections: {},
|
||||
currentCollection: null,
|
||||
searchResults: null,
|
||||
recentStickers: [],
|
||||
|
||||
isLoadingCollections: false,
|
||||
isLoadingCollection: false,
|
||||
isSearching: false,
|
||||
addingSticker: null,
|
||||
|
||||
setSearchQuery: ({ query }) => set({ searchQuery: query }),
|
||||
|
||||
setSelectedCategory: ({ category }) =>
|
||||
set({
|
||||
selectedCategory: category,
|
||||
export const useStickersStore = create<StickersStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
searchQuery: "",
|
||||
selectedCategory: "all",
|
||||
viewMode: "browse",
|
||||
selectedCollection: null,
|
||||
currentCollection: null,
|
||||
|
||||
searchResults: null,
|
||||
recentStickers: [],
|
||||
|
||||
isSearching: false,
|
||||
addingSticker: null,
|
||||
|
||||
setSearchQuery: ({ query }) => set({ searchQuery: query }),
|
||||
|
||||
setSelectedCategory: ({ category }) =>
|
||||
set({
|
||||
selectedCategory: category in STICKER_CATEGORIES ? category : "all",
|
||||
viewMode: "browse",
|
||||
}),
|
||||
|
||||
searchStickers: async ({ query }: { query: string }) => {
|
||||
if (!query.trim()) {
|
||||
set({ searchResults: null, viewMode: "browse" });
|
||||
return;
|
||||
}
|
||||
|
||||
const category = get().selectedCategory;
|
||||
const selectedCategory =
|
||||
category in STICKER_CATEGORIES ? category : "all";
|
||||
|
||||
set({ isSearching: true, viewMode: "search" });
|
||||
try {
|
||||
const results = await searchStickersFromProviders({
|
||||
query,
|
||||
category: selectedCategory,
|
||||
limit: 100,
|
||||
});
|
||||
set({ searchResults: results });
|
||||
} catch (error) {
|
||||
console.error("Search failed:", error);
|
||||
set({ searchResults: null });
|
||||
} finally {
|
||||
set({ isSearching: false });
|
||||
}
|
||||
},
|
||||
|
||||
addStickerToTimeline: ({
|
||||
stickerId,
|
||||
name,
|
||||
}: {
|
||||
stickerId: string;
|
||||
name?: string;
|
||||
}) => {
|
||||
set({ addingSticker: stickerId });
|
||||
try {
|
||||
const editor = EditorCore.getInstance();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
|
||||
const stickerTrack = tracks.find((t) => t.type === "sticker");
|
||||
let trackId: string;
|
||||
|
||||
if (stickerTrack) {
|
||||
trackId = stickerTrack.id;
|
||||
} else {
|
||||
trackId = editor.timeline.addTrack({ type: "sticker" });
|
||||
}
|
||||
|
||||
const element = buildStickerElement({
|
||||
stickerId,
|
||||
name,
|
||||
startTime: currentTime,
|
||||
});
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element,
|
||||
});
|
||||
|
||||
get().addToRecentStickers({ stickerId });
|
||||
} finally {
|
||||
set({ addingSticker: null });
|
||||
}
|
||||
},
|
||||
|
||||
addToRecentStickers: ({ stickerId }: { stickerId: string }) => {
|
||||
const sanitizedStickerIds = sanitizeRecentStickers({
|
||||
recentStickers: [stickerId],
|
||||
});
|
||||
if (sanitizedStickerIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const recent = [
|
||||
sanitizedStickerIds[0],
|
||||
...state.recentStickers.filter((s) => s !== sanitizedStickerIds[0]),
|
||||
];
|
||||
return {
|
||||
recentStickers: recent.slice(0, MAX_RECENT_STICKERS),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearRecentStickers: () => set({ recentStickers: [] }),
|
||||
}),
|
||||
|
||||
setSelectedCollection: ({ collection }) => {
|
||||
set({
|
||||
selectedCollection: collection,
|
||||
viewMode: collection ? "collection" : "browse",
|
||||
currentCollection: null,
|
||||
});
|
||||
|
||||
if (collection) {
|
||||
get().loadCollection({ prefix: collection });
|
||||
}
|
||||
},
|
||||
|
||||
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 });
|
||||
}
|
||||
},
|
||||
|
||||
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 }: { query: string }) => {
|
||||
if (!query.trim()) {
|
||||
set({ searchResults: null, viewMode: "browse" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { selectedCategory } = get();
|
||||
|
||||
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 });
|
||||
}
|
||||
},
|
||||
|
||||
addStickerToTimeline: ({ iconName }: { iconName: string }) => {
|
||||
set({ addingSticker: iconName });
|
||||
try {
|
||||
const editor = EditorCore.getInstance();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
|
||||
const stickerTrack = tracks.find((t) => t.type === "sticker");
|
||||
let trackId: string;
|
||||
|
||||
if (stickerTrack) {
|
||||
trackId = stickerTrack.id;
|
||||
} else {
|
||||
trackId = editor.timeline.addTrack({ type: "sticker" });
|
||||
}
|
||||
|
||||
const element = buildStickerElement({ iconName, startTime: currentTime });
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element,
|
||||
});
|
||||
|
||||
get().addToRecentStickers({ iconName });
|
||||
} finally {
|
||||
set({ addingSticker: null });
|
||||
}
|
||||
},
|
||||
|
||||
addToRecentStickers: ({ iconName }: { iconName: string }) => {
|
||||
set((state) => {
|
||||
const recent = [
|
||||
iconName,
|
||||
...state.recentStickers.filter((s) => s !== iconName),
|
||||
];
|
||||
return {
|
||||
recentStickers: recent.slice(0, MAX_RECENT_STICKERS),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearRecentStickers: () => set({ recentStickers: [] }),
|
||||
}));
|
||||
{
|
||||
name: "stickers-settings",
|
||||
migrate: (persistedState) => {
|
||||
if (
|
||||
typeof persistedState === "object" &&
|
||||
persistedState !== null &&
|
||||
"selectedCategory" in persistedState
|
||||
) {
|
||||
const typedState = persistedState as {
|
||||
selectedCategory?: string;
|
||||
recentStickers?: string[];
|
||||
};
|
||||
const category = typedState.selectedCategory ?? "all";
|
||||
return {
|
||||
...typedState,
|
||||
selectedCategory:
|
||||
category in STICKER_CATEGORIES
|
||||
? (category as StickerCategory)
|
||||
: "all",
|
||||
recentStickers: sanitizeRecentStickers({
|
||||
recentStickers: typedState.recentStickers ?? [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
return persistedState;
|
||||
},
|
||||
partialize: (state) => ({
|
||||
selectedCategory: state.selectedCategory,
|
||||
recentStickers: state.recentStickers,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user