feat: masks, properties refactor, shaders, storage migrations, and more

This commit is contained in:
Maze Winther
2026-03-29 15:48:22 +02:00
parent 39ea298a9c
commit 8db3bead13
690 changed files with 35618 additions and 7337 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
import { create } from "zustand";
import { DEFAULT_CANVAS_PRESETS } from "@/constants/project-constants";
import type { TCanvasSize } from "@/types/project";
import type { TCanvasSize } from "@/lib/project/types";
interface EditorState {
isInitializing: boolean;
+140 -18
View File
@@ -6,10 +6,10 @@ import type { TActionWithOptionalArgs } from "@/lib/actions";
import { getDefaultShortcuts } from "@/lib/actions";
import { isTypableDOMElement } from "@/utils/browser";
import { isAppleDevice } from "@/utils/platform";
import type { KeybindingConfig, ShortcutKey } from "@/types/keybinding";
import type { KeybindingConfig, ShortcutKey } from "@/lib/actions/keybinding";
import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations";
export const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
export interface KeybindingConflict {
key: ShortcutKey;
@@ -20,7 +20,9 @@ export interface KeybindingConflict {
interface KeybindingsState {
keybindings: KeybindingConfig;
isCustomized: boolean;
keybindingsEnabled: boolean;
overlayDepth: number;
openOverlayIds: string[];
isLoadingProject: boolean;
isRecording: boolean;
updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void;
@@ -28,8 +30,9 @@ interface KeybindingsState {
resetToDefaults: () => void;
importKeybindings: (config: KeybindingConfig) => void;
exportKeybindings: () => KeybindingConfig;
enableKeybindings: () => void;
disableKeybindings: () => void;
openOverlay: (overlayId: string, source: string) => void;
closeOverlay: (overlayId: string, source: string) => void;
setLoadingProject: (loading: boolean) => void;
setIsRecording: (isRecording: boolean) => void;
validateKeybinding: (
key: ShortcutKey,
@@ -48,9 +51,112 @@ export const useKeybindingsStore = create<KeybindingsState>()(
(set, get) => ({
keybindings: { ...defaultKeybindings },
isCustomized: false,
keybindingsEnabled: true,
overlayDepth: 0,
openOverlayIds: [],
isLoadingProject: false,
isRecording: false,
openOverlay: (overlayId, source) =>
set((s) => {
const openOverlayIds = s.openOverlayIds.includes(overlayId)
? s.openOverlayIds
: [...s.openOverlayIds, overlayId];
const nextOverlayDepth = openOverlayIds.length;
// #region agent log
fetch(
"http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Debug-Session-Id": "3997d9",
},
body: JSON.stringify({
sessionId: "3997d9",
runId: "initial",
hypothesisId: "H2",
location: "keybindings-store.ts:openOverlay",
message: "Overlay depth incremented",
data: {
source,
overlayId,
overlayDepth: s.overlayDepth,
nextOverlayDepth,
openOverlayIds,
},
timestamp: Date.now(),
}),
},
).catch(() => {});
// #endregion
return {
openOverlayIds,
overlayDepth: nextOverlayDepth,
};
}),
closeOverlay: (overlayId, source) =>
set((s) => {
const openOverlayIds = s.openOverlayIds.filter(
(id) => id !== overlayId,
);
const nextOverlayDepth = openOverlayIds.length;
// #region agent log
fetch(
"http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Debug-Session-Id": "3997d9",
},
body: JSON.stringify({
sessionId: "3997d9",
runId: "initial",
hypothesisId: "H2",
location: "keybindings-store.ts:closeOverlay",
message: "Overlay depth decremented",
data: {
source,
overlayId,
overlayDepth: s.overlayDepth,
nextOverlayDepth,
openOverlayIds,
},
timestamp: Date.now(),
}),
},
).catch(() => {});
// #endregion
return {
openOverlayIds,
overlayDepth: nextOverlayDepth,
};
}),
setLoadingProject: (loading) => {
// #region agent log
fetch(
"http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Debug-Session-Id": "3997d9",
},
body: JSON.stringify({
sessionId: "3997d9",
runId: "initial",
hypothesisId: "H2",
location: "keybindings-store.ts:setLoadingProject",
message: "Loading gate updated",
data: { loading },
timestamp: Date.now(),
}),
},
).catch(() => {});
// #endregion
set({ isLoadingProject: loading });
},
updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => {
set((state) => {
const newKeybindings = { ...state.keybindings };
@@ -82,17 +188,9 @@ export const useKeybindingsStore = create<KeybindingsState>()(
});
},
enableKeybindings: () => {
set({ keybindingsEnabled: true });
},
disableKeybindings: () => {
set({ keybindingsEnabled: false });
},
importKeybindings: (config: KeybindingConfig) => {
for (const [key] of Object.entries(config)) {
if (typeof key !== "string" || key.length === 0) {
importKeybindings: (config: KeybindingConfig) => {
for (const [key] of Object.entries(config)) {
if (typeof key !== "string" || key.length === 0) {
throw new Error(`Invalid key format: ${key}`);
}
}
@@ -124,6 +222,27 @@ export const useKeybindingsStore = create<KeybindingsState>()(
return null;
},
setIsRecording: (isRecording: boolean) => {
// #region agent log
fetch(
"http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Debug-Session-Id": "3997d9",
},
body: JSON.stringify({
sessionId: "3997d9",
runId: "initial",
hypothesisId: "H2",
location: "keybindings-store.ts:setIsRecording",
message: "Recording gate updated",
data: { isRecording },
timestamp: Date.now(),
}),
},
).catch(() => {});
// #endregion
set({ isRecording });
},
@@ -169,7 +288,10 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
return `${modifierKey}+${key}` as ShortcutKey;
}
if (isDOMElement(target) && isTypableDOMElement({ element: target as HTMLElement }))
if (
isDOMElement(target) &&
isTypableDOMElement({ element: target as HTMLElement })
)
return null;
return `${key}` as ShortcutKey;
@@ -1,6 +1,7 @@
import { v2ToV3 } from "./v2-to-v3";
import { v3ToV4 } from "./v3-to-v4";
import { v4ToV5 } from "./v4-to-v5";
import { v5ToV6 } from "./v5-to-v6";
type MigrationFn = ({ state }: { state: unknown }) => unknown;
@@ -8,9 +9,10 @@ const migrations: Record<number, MigrationFn> = {
2: v2ToV3,
3: v3ToV4,
4: v4ToV5,
5: v5ToV6,
};
export const CURRENT_VERSION = 5;
export const CURRENT_VERSION = 6;
export function runMigrations({
state,
@@ -1,4 +1,4 @@
import type { KeybindingConfig, ShortcutKey } from "@/types/keybinding";
import type { KeybindingConfig, ShortcutKey } from "@/lib/actions/keybinding";
import type { TActionWithOptionalArgs } from "@/lib/actions";
interface V2State {
@@ -1,6 +1,6 @@
import type { TActionWithOptionalArgs } from "@/lib/actions";
import type { ShortcutKey } from "@/types/keybinding";
import type { KeybindingConfig } from "@/types/keybinding";
import type { ShortcutKey } from "@/lib/actions/keybinding";
import type { KeybindingConfig } from "@/lib/actions/keybinding";
interface V3State {
keybindings: KeybindingConfig;
@@ -1,4 +1,4 @@
import type { KeybindingConfig } from "@/types/keybinding";
import type { KeybindingConfig } from "@/lib/actions/keybinding";
interface V4State {
keybindings: KeybindingConfig;
@@ -0,0 +1,17 @@
import type { KeybindingConfig } from "@/lib/actions/keybinding";
interface V5State {
keybindings: KeybindingConfig;
isCustomized: boolean;
}
export function v5ToV6({ state }: { state: unknown }): unknown {
const v5 = state as V5State;
const keybindings = { ...v5.keybindings };
if (keybindings.escape === "deselect-all") {
keybindings.escape = "cancel-interaction";
}
return { ...v5, keybindings };
}
+45 -27
View File
@@ -1,20 +1,28 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { TPlatformLayout } from "@/types/editor";
interface LayoutGuideSettings {
platform: TPlatformLayout | null;
}
import { isGuideId, type GuideId } from "@/lib/guides";
import { DEFAULT_GRID_CONFIG } from "@/constants/guide-constants";
import type { GridConfig } from "@/lib/guides/types";
interface PreviewOverlaysState {
bookmarks: boolean;
}
interface PersistedPreviewState {
activeGuide?: string | null;
layoutGuide?: {
platform?: string | null;
};
overlays?: PreviewOverlaysState;
gridConfig?: GridConfig;
}
interface PreviewState {
layoutGuide: LayoutGuideSettings;
activeGuide: GuideId | null;
overlays: PreviewOverlaysState;
setLayoutGuide: (settings: Partial<LayoutGuideSettings>) => void;
toggleLayoutGuide: (platform: TPlatformLayout) => void;
gridConfig: GridConfig;
toggleGuide: (guideId: GuideId) => void;
setGridConfig: (config: Partial<GridConfig>) => void;
setOverlayVisibility: ({
overlay,
isVisible,
@@ -33,24 +41,33 @@ const DEFAULT_PREVIEW_OVERLAYS: PreviewOverlaysState = {
bookmarks: true,
};
function getPersistedActiveGuide(
state: PersistedPreviewState | undefined,
): GuideId | null {
const persistedGuide =
state?.activeGuide ?? state?.layoutGuide?.platform ?? null;
if (typeof persistedGuide !== "string") {
return null;
}
return isGuideId(persistedGuide) ? persistedGuide : null;
}
export const usePreviewStore = create<PreviewState>()(
persist(
(set) => ({
layoutGuide: { platform: null },
activeGuide: null,
overlays: DEFAULT_PREVIEW_OVERLAYS,
setLayoutGuide: (settings) => {
gridConfig: DEFAULT_GRID_CONFIG,
toggleGuide: (guideId) => {
set((state) => ({
layoutGuide: {
...state.layoutGuide,
...settings,
},
activeGuide: state.activeGuide === guideId ? null : guideId,
}));
},
toggleLayoutGuide: (platform) => {
setGridConfig: (config) => {
set((state) => ({
layoutGuide: {
platform: state.layoutGuide.platform === platform ? null : platform,
},
gridConfig: { ...state.gridConfig, ...config },
}));
},
setOverlayVisibility: ({ overlay, isVisible }) => {
@@ -72,22 +89,23 @@ export const usePreviewStore = create<PreviewState>()(
}),
{
name: "preview-settings",
version: 2,
version: 4,
migrate: (persistedState) => {
const state = persistedState as
| {
layoutGuide?: LayoutGuideSettings;
overlays?: PreviewOverlaysState;
}
| undefined;
const state = persistedState as PersistedPreviewState | undefined;
return {
layoutGuide: state?.layoutGuide ?? { platform: null },
activeGuide: getPersistedActiveGuide(state),
overlays: state?.overlays ?? DEFAULT_PREVIEW_OVERLAYS,
gridConfig: {
rows: state?.gridConfig?.rows ?? DEFAULT_GRID_CONFIG.rows,
cols: state?.gridConfig?.cols ?? DEFAULT_GRID_CONFIG.cols,
},
};
},
partialize: (state) => ({
layoutGuide: state.layoutGuide,
activeGuide: state.activeGuide,
overlays: state.overlays,
gridConfig: state.gridConfig,
}),
},
),
+1 -1
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import type { SoundEffect, SavedSound } from "@/types/sounds";
import type { SoundEffect, SavedSound } from "@/lib/sounds/types";
import { storageService } from "@/services/storage/service";
import { toast } from "sonner";
import { EditorCore } from "@/core";
+99 -80
View File
@@ -1,16 +1,20 @@
import { create } from "zustand";
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 {
browseAll,
browseCategory,
searchAll,
searchStickers as searchStickersFromProviders,
} from "@/lib/stickers";
import type { StickerBrowseResult, StickerSearchResult } from "@/lib/stickers";
import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
import type { StickerCategory } from "@/types/stickers";
import type { StickerCategory } from "@/lib/stickers/types";
import { registerDefaultStickerProviders } from "@/lib/stickers/providers";
import { hasProvider } from "@/lib/stickers/registry";
import { stickersRegistry } from "@/lib/stickers/registry";
import { parseStickerId } from "@/lib/stickers/sticker-id";
const MAX_RECENT_STICKERS = 50;
let browseRequestVersion = 0;
function isValidStickerId(value: unknown): value is string {
if (typeof value !== "string") {
@@ -19,7 +23,7 @@ function isValidStickerId(value: unknown): value is string {
try {
const parsed = parseStickerId({ stickerId: value });
return hasProvider({ providerId: parsed.providerId });
return stickersRegistry.has(parsed.providerId);
} catch {
return false;
}
@@ -60,20 +64,15 @@ interface StickersStore {
selectedCategory: StickerCategory;
viewMode: ViewMode;
searchResults: StickerSearchResult | null;
browseContent: StickerBrowseResult | null;
recentStickers: string[];
isSearching: boolean;
addingSticker: string | null;
isBrowsing: boolean;
setSearchQuery: ({ query }: { query: string }) => void;
setSelectedCategory: ({ category }: { category: StickerCategory }) => void;
searchStickers: ({ query }: { query: string }) => Promise<void>;
addStickerToTimeline: ({
stickerId,
name,
}: {
stickerId: string;
name?: string;
}) => void;
browseStickers: () => Promise<void>;
addToRecentStickers: ({ stickerId }: { stickerId: string }) => void;
clearRecentStickers: () => void;
}
@@ -86,22 +85,34 @@ export const useStickersStore = create<StickersStore>()(
viewMode: "browse",
searchResults: null,
browseContent: null,
recentStickers: [],
isSearching: false,
addingSticker: null,
isBrowsing: false,
setSearchQuery: ({ query }) => set({ searchQuery: query }),
setSelectedCategory: ({ category }) =>
set({
selectedCategory: category in STICKER_CATEGORIES ? category : "all",
viewMode: "browse",
}),
setSelectedCategory: ({ category }) => {
set({
selectedCategory: category in STICKER_CATEGORIES ? category : "all",
browseContent: null,
});
const query = get().searchQuery.trim();
if (query) {
void get().searchStickers({ query });
return;
}
void get().browseStickers();
},
searchStickers: async ({ query }: { query: string }) => {
if (!query.trim()) {
const trimmedQuery = query.trim();
if (!trimmedQuery) {
set({ searchResults: null, viewMode: "browse" });
await get().browseStickers();
return;
}
@@ -111,12 +122,17 @@ export const useStickersStore = create<StickersStore>()(
set({ isSearching: true, viewMode: "search" });
try {
const results = await searchStickersFromProviders({
query,
category: selectedCategory,
limit: 100,
});
set({ searchResults: results });
if (selectedCategory === "all") {
const browseContent = await searchAll({ query: trimmedQuery });
set({ browseContent, searchResults: null });
} else {
const results = await searchStickersFromProviders({
query: trimmedQuery,
category: selectedCategory,
limit: 100,
});
set({ searchResults: results });
}
} catch (error) {
console.error("Search failed:", error);
set({ searchResults: null });
@@ -125,67 +141,70 @@ export const useStickersStore = create<StickersStore>()(
}
},
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();
browseStickers: async () => {
const version = ++browseRequestVersion;
const category = get().selectedCategory;
const selectedCategory =
category in STICKER_CATEGORIES ? category : "all";
const stickerTrack = tracks.find((t) => t.type === "sticker");
let trackId: string;
set({ isBrowsing: true, viewMode: "browse" });
try {
const browseContent =
selectedCategory === "all"
? await browseAll({
recentStickers: get().recentStickers,
})
: await browseCategory({
category: selectedCategory,
});
if (stickerTrack) {
trackId = stickerTrack.id;
} else {
trackId = editor.timeline.addTrack({ type: "sticker" });
}
if (version !== browseRequestVersion) return;
set({ browseContent });
} catch (error) {
if (version !== browseRequestVersion) return;
console.error("Browse failed:", error);
set({ browseContent: null });
} finally {
if (version === browseRequestVersion) {
set({ isBrowsing: false });
}
}
},
const element = buildStickerElement({
stickerId,
name,
startTime: currentTime,
});
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element,
});
addToRecentStickers: ({ stickerId }: { stickerId: string }) => {
const sanitizedStickerIds = sanitizeRecentStickers({
recentStickers: [stickerId],
});
if (sanitizedStickerIds.length === 0) {
return;
}
get().addToRecentStickers({ stickerId });
} finally {
set({ addingSticker: null });
set((state) => {
const recent = [
sanitizedStickerIds[0],
...state.recentStickers.filter((s) => s !== sanitizedStickerIds[0]),
];
return {
recentStickers: recent.slice(0, MAX_RECENT_STICKERS),
};
});
if (get().viewMode === "browse" && get().selectedCategory === "all") {
void get().browseStickers();
}
},
clearRecentStickers: () => {
set({ recentStickers: [] });
if (get().viewMode === "browse" && get().selectedCategory === "all") {
void get().browseStickers();
}
},
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: [] }),
}),
{
name: "stickers-settings",
version: 1,
migrate: (persistedState) => {
if (
typeof persistedState === "object" &&
+1 -1
View File
@@ -5,7 +5,7 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { ClipboardItem } from "@/types/timeline";
import type { ClipboardItem } from "@/lib/timeline";
interface TimelineStore {
snappingEnabled: boolean;