summaryrefslogtreecommitdiff
path: root/apps/web/src/stores/stickers-store.ts
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-02-23 03:24:02 +0100
committerGitHub <noreply@github.com>2026-02-23 03:24:02 +0100
commit93d1e3383ce47e45157d70f9ba6251239dae6bc2 (patch)
tree1e33a2d7533b65f0e7c8927f03905a1a99937bd1 /apps/web/src/stores/stickers-store.ts
parentfca99d6126c31fbb18ed9f1034cee6f940b040e8 (diff)
downloadopencut-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.ts321
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,
+ }),
+ },
+ ),
+);