diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2026-02-23 03:24:02 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-23 03:24:02 +0100 |
| commit | 93d1e3383ce47e45157d70f9ba6251239dae6bc2 (patch) | |
| tree | 1e33a2d7533b65f0e7c8927f03905a1a99937bd1 /apps/web/src/stores/stickers-store.ts | |
| parent | fca99d6126c31fbb18ed9f1034cee6f940b040e8 (diff) | |
| download | opencut-93d1e3383ce47e45157d70f9ba6251239dae6bc2.tar.gz | |
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
Diffstat (limited to 'apps/web/src/stores/stickers-store.ts')
| -rw-r--r-- | apps/web/src/stores/stickers-store.ts | 321 |
1 files changed, 182 insertions, 139 deletions
diff --git a/apps/web/src/stores/stickers-store.ts b/apps/web/src/stores/stickers-store.ts index edc78220..3f713b3d 100644 --- a/apps/web/src/stores/stickers-store.ts +++ b/apps/web/src/stores/stickers-store.ts @@ -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, + searchStickers: ({ query }: { query: string }) => Promise<void>; + addStickerToTimeline: ({ + stickerId, + name, }: { - collection: string | null; + stickerId: string; + name?: string; }) => 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; + 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, - }), - setSelectedCollection: ({ collection }) => { - set({ - selectedCollection: collection, - viewMode: collection ? "collection" : "browse", - currentCollection: null, - }); + searchResults: null, + recentStickers: [], - if (collection) { - get().loadCollection({ prefix: collection }); - } - }, + isSearching: false, + addingSticker: null, - setViewMode: ({ mode }) => set({ viewMode: mode }), + setSearchQuery: ({ query }) => set({ searchQuery: query }), - 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 }); - } - }, + setSelectedCategory: ({ category }) => + set({ + selectedCategory: category in STICKER_CATEGORIES ? category : "all", + viewMode: "browse", + }), - 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; + } - 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"; - const { selectedCategory } = get(); + 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 }); + } + }, - 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: ({ + stickerId, + name, + }: { + stickerId: string; + name?: string; + }) => { + set({ addingSticker: stickerId }); + try { + const editor = EditorCore.getInstance(); + const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.timeline.getTracks(); - 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; - const stickerTrack = tracks.find((t) => t.type === "sticker"); - let trackId: string; + if (stickerTrack) { + trackId = stickerTrack.id; + } else { + trackId = editor.timeline.addTrack({ type: "sticker" }); + } - 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, + }); - const element = buildStickerElement({ iconName, startTime: currentTime }); - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element, - }); + get().addToRecentStickers({ stickerId }); + } finally { + set({ addingSticker: null }); + } + }, - get().addToRecentStickers({ iconName }); - } finally { - set({ addingSticker: null }); - } - }, + addToRecentStickers: ({ stickerId }: { stickerId: string }) => { + const sanitizedStickerIds = sanitizeRecentStickers({ + recentStickers: [stickerId], + }); + if (sanitizedStickerIds.length === 0) { + return; + } - addToRecentStickers: ({ iconName }: { iconName: string }) => { - set((state) => { - const recent = [ - iconName, - ...state.recentStickers.filter((s) => s !== iconName), - ]; - return { - recentStickers: recent.slice(0, MAX_RECENT_STICKERS), - }; - }); - }, + set((state) => { + const recent = [ + sanitizedStickerIds[0], + ...state.recentStickers.filter((s) => s !== sanitizedStickerIds[0]), + ]; + return { + recentStickers: recent.slice(0, MAX_RECENT_STICKERS), + }; + }); + }, - clearRecentStickers: () => set({ recentStickers: [] }), -})); + 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, + }), + }, + ), +); |
