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:
Maze
2026-02-23 03:24:02 +01:00
committed by GitHub
parent fca99d6126
commit 93d1e3383c
215 changed files with 26980 additions and 8364 deletions
+11 -57
View File
@@ -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 });
},
}));
+1 -4
View File
@@ -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 };
}
+94
View File
@@ -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
View File
@@ -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;
}
},
}));
+197 -154
View File
@@ -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,
}),
},
),
);