From 1dc7e5080fe431debaec2d8e325f74951b29d66e Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Tue, 5 May 2026 22:48:53 +0530 Subject: [PATCH 01/21] fix: resumable last session from localStorage --- .../src/ImageKitEditor.tsx | 45 ++++++- .../components/editor/ResumeSessionModal.tsx | 108 ++++++++++++++++ .../src/components/editor/index.tsx | 1 + .../src/components/editor/layout.tsx | 11 +- .../hooks/usePersistedEditorSession.test.tsx | 62 ++++++++++ .../src/hooks/usePersistedEditorSession.ts | 43 +++++++ .../src/persistence/editorSessionStorage.ts | 117 ++++++++++++++++++ packages/imagekit-editor-dev/src/store.ts | 38 ++++++ 8 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 packages/imagekit-editor-dev/src/components/editor/ResumeSessionModal.tsx create mode 100644 packages/imagekit-editor-dev/src/hooks/usePersistedEditorSession.test.tsx create mode 100644 packages/imagekit-editor-dev/src/hooks/usePersistedEditorSession.ts create mode 100644 packages/imagekit-editor-dev/src/persistence/editorSessionStorage.ts diff --git a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx index 1572c67..4daefda 100644 --- a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx +++ b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx @@ -6,12 +6,23 @@ import React, { useCallback, useImperativeHandle, useMemo, + useState, } from "react" -import { EditorLayout, EditorWrapper } from "./components/editor" +import { + EditorLayout, + EditorWrapper, + ResumeSessionModal, +} from "./components/editor" import type { HeaderProps } from "./components/header" import type { GetTemplatePermissions } from "./context/TemplatePermissionsContext" import { TemplatePermissionsContextProvider } from "./context/TemplatePermissionsContext" import { TemplateStorageContextProvider } from "./context/TemplateStorageContext" +import { + clearEditorSessionFromLocalStorage, + EDITOR_SESSION_STORAGE_KEY, + type PersistedEditorSession, + readEditorSessionFromLocalStorage, +} from "./persistence/editorSessionStorage" import { isTemplateAccessDeniedError, type TemplateStorageProvider, @@ -125,6 +136,17 @@ function ImageKitEditorImpl( [templateStorage], ) + const [resumeSession, setResumeSession] = + useState(null) + + React.useEffect(() => { + const resumableSession = readEditorSessionFromLocalStorage( + EDITOR_SESSION_STORAGE_KEY, + ) + if (!resumableSession) return + setResumeSession(resumableSession) + }, []) + const saveTemplateImperative = useCallback(async () => { // Avoid importing hooks here; implement via store+provider with version gating. if (!resolvedProvider) return @@ -241,7 +263,28 @@ function ImageKitEditorImpl( onAddImage={props.onAddImage} onClose={handleOnClose} exportOptions={props.exportOptions} + pauseLocalSessionPersistence={Boolean(resumeSession)} /> + {resumeSession ? ( + { + useEditorStore + .getState() + .restoreSession(resumeSession.state) + setResumeSession(null) + }} + onStartNew={() => { + clearEditorSessionFromLocalStorage( + EDITOR_SESSION_STORAGE_KEY, + ) + useEditorStore.getState().resetToNewTemplate() + setResumeSession(null) + }} + onCloseEditor={() => { + handleOnClose() + }} + /> + ) : null} diff --git a/packages/imagekit-editor-dev/src/components/editor/ResumeSessionModal.tsx b/packages/imagekit-editor-dev/src/components/editor/ResumeSessionModal.tsx new file mode 100644 index 0000000..5b43c47 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/editor/ResumeSessionModal.tsx @@ -0,0 +1,108 @@ +import { Box, Flex, Icon, IconButton, Text } from "@chakra-ui/react" +import { PiX } from "@react-icons/all-files/pi/PiX" + +export type ResumeSessionModalProps = { + onRestore: () => void + onStartNew: () => void + onCloseEditor: () => void +} + +export function ResumeSessionModal({ + onRestore, + onStartNew, + onCloseEditor, +}: ResumeSessionModalProps) { + return ( + + + {/* Header */} + + + Resume previous session? + + } + aria-label="Close resume session" + onClick={onCloseEditor} + /> + + + {/* Content */} + + + You have unsaved changes from a previous session. Would you like to + restore your work and resume that session, or start a new one? + + + If you start a new session, any previous unsaved changes will be + discarded forever. This action is irreversible. + + + + {/* Footer */} + + + Start a new session + + + Restore session + + + + + ) +} diff --git a/packages/imagekit-editor-dev/src/components/editor/index.tsx b/packages/imagekit-editor-dev/src/components/editor/index.tsx index 2100604..06cc8c7 100644 --- a/packages/imagekit-editor-dev/src/components/editor/index.tsx +++ b/packages/imagekit-editor-dev/src/components/editor/index.tsx @@ -1,2 +1,3 @@ export * from "./layout" +export * from "./ResumeSessionModal" export * from "./wrapper" diff --git a/packages/imagekit-editor-dev/src/components/editor/layout.tsx b/packages/imagekit-editor-dev/src/components/editor/layout.tsx index 9ad0ee5..5f3dd90 100644 --- a/packages/imagekit-editor-dev/src/components/editor/layout.tsx +++ b/packages/imagekit-editor-dev/src/components/editor/layout.tsx @@ -1,6 +1,7 @@ import { Box, Flex } from "@chakra-ui/react" import { useEffect, useState } from "react" import { useAutoSaveTemplate } from "../../hooks/useAutoSaveTemplate" +import { usePersistedEditorSession } from "../../hooks/usePersistedEditorSession" import { useSaveTemplate } from "../../hooks/useSaveTemplate" import { Header, type HeaderProps } from "../header" import { Sidebar } from "../sidebar" @@ -13,9 +14,16 @@ interface Props { onAddImage?: () => void onClose: () => void exportOptions?: HeaderProps["exportOptions"] + /** When true, do not read/write the local session snapshot (e.g. resume modal open). */ + pauseLocalSessionPersistence?: boolean } -export function EditorLayout({ onAddImage, onClose, exportOptions }: Props) { +export function EditorLayout({ + onAddImage, + onClose, + exportOptions, + pauseLocalSessionPersistence = false, +}: Props) { const [viewMode, setViewMode] = useState<"list" | "grid">("list") const [gridImageSize, setGridImageSize] = useState(300) const [isTemplatesOpen, setIsTemplatesOpen] = useState(false) @@ -37,6 +45,7 @@ export function EditorLayout({ onAddImage, onClose, exportOptions }: Props) { useAutoSaveTemplate() useSaveTemplate() + usePersistedEditorSession(pauseLocalSessionPersistence) const closeTemplatesLibrary = () => setIsTemplatesOpen(false) diff --git a/packages/imagekit-editor-dev/src/hooks/usePersistedEditorSession.test.tsx b/packages/imagekit-editor-dev/src/hooks/usePersistedEditorSession.test.tsx new file mode 100644 index 0000000..7c19ee0 --- /dev/null +++ b/packages/imagekit-editor-dev/src/hooks/usePersistedEditorSession.test.tsx @@ -0,0 +1,62 @@ +import { act, render } from "@testing-library/react" +import React from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { EDITOR_SESSION_STORAGE_KEY } from "../persistence/editorSessionStorage" +import { useEditorStore } from "../store" +import { usePersistedEditorSession } from "./usePersistedEditorSession" + +function Harness(props: { paused: boolean }) { + usePersistedEditorSession(props.paused) + return null +} + +describe("usePersistedEditorSession", () => { + beforeEach(() => { + useEditorStore.getState().destroy() + vi.useFakeTimers() + window.localStorage.removeItem(EDITOR_SESSION_STORAGE_KEY) + }) + + it("does not write to localStorage while paused (including initial debounced write)", () => { + const setItemSpy = vi.spyOn(Storage.prototype, "setItem") + + render() + + // Allow any pending timers to run; paused should prevent scheduling entirely. + act(() => { + vi.runAllTimers() + }) + expect(setItemSpy).not.toHaveBeenCalled() + + // Even if committable state changes, persistence must remain paused. + act(() => { + useEditorStore.getState().bumpLocalChangeVersion() + vi.runAllTimers() + }) + expect(setItemSpy).not.toHaveBeenCalled() + }) + + it("resumes writing once unpaused (initial write + subsequent version bumps)", () => { + const setItemSpy = vi.spyOn(Storage.prototype, "setItem") + const { rerender } = render() + + act(() => { + vi.runAllTimers() + }) + expect(setItemSpy).not.toHaveBeenCalled() + + // Unpause: hook effect should set up subscription and schedule an initial write. + rerender() + act(() => { + vi.runAllTimers() + }) + expect(setItemSpy).toHaveBeenCalled() + + const callsAfterInitial = setItemSpy.mock.calls.length + act(() => { + useEditorStore.getState().bumpLocalChangeVersion() + vi.runAllTimers() + }) + expect(setItemSpy.mock.calls.length).toBeGreaterThan(callsAfterInitial) + }) +}) diff --git a/packages/imagekit-editor-dev/src/hooks/usePersistedEditorSession.ts b/packages/imagekit-editor-dev/src/hooks/usePersistedEditorSession.ts new file mode 100644 index 0000000..fd65562 --- /dev/null +++ b/packages/imagekit-editor-dev/src/hooks/usePersistedEditorSession.ts @@ -0,0 +1,43 @@ +import { useEffect } from "react" +import { + buildPersistedEditorSession, + EDITOR_SESSION_STORAGE_KEY, + writeEditorSessionToLocalStorage, +} from "../persistence/editorSessionStorage" +import { useEditorStore } from "../store" + +export const PERSIST_DEBOUNCE_MS = 150 + +export function usePersistedEditorSession(paused: boolean) { + useEffect(() => { + if (paused) return + + let timer: ReturnType | null = null + const persist = () => { + const state = useEditorStore.getState() + const session = buildPersistedEditorSession(state) + writeEditorSessionToLocalStorage({ + key: EDITOR_SESSION_STORAGE_KEY, + session, + }) + } + + const unsub = useEditorStore.subscribe( + (s) => s.localChangeVersion, + () => { + if (timer) clearTimeout(timer) + timer = setTimeout(persist, PERSIST_DEBOUNCE_MS) + }, + ) + + // Persist at least once after mount so a session exists even before edits. + // (Still cheap, and helps with abrupt refresh right after open.) + if (timer) clearTimeout(timer) + timer = setTimeout(persist, PERSIST_DEBOUNCE_MS) + + return () => { + unsub() + if (timer) clearTimeout(timer) + } + }, [paused]) +} diff --git a/packages/imagekit-editor-dev/src/persistence/editorSessionStorage.ts b/packages/imagekit-editor-dev/src/persistence/editorSessionStorage.ts new file mode 100644 index 0000000..967743a --- /dev/null +++ b/packages/imagekit-editor-dev/src/persistence/editorSessionStorage.ts @@ -0,0 +1,117 @@ +import type { EditorState, Transformation } from "../store" + +export const EDITOR_SESSION_STORAGE_VERSION = 1 as const + +export type PersistedEditorSession = { + v: typeof EDITOR_SESSION_STORAGE_VERSION + savedAt: number + state: PersistedEditorSessionState +} + +export type PersistedEditorSessionState = Pick< + EditorState, + | "transformations" + | "visibleTransformations" + | "templateName" + | "templateId" + | "templateIsPrivate" + | "syncStatus" + | "isPristine" + | "localChangeVersion" + | "lastSyncedVersion" + | "lastSavedAt" +> & { + _internalState?: never + signingAbortControllers?: never + signingImages?: never + signedUrlCache?: never + storageError?: never + templateStorageWriteBlocked?: never + transformationConfigFormDirty?: never +} + +export const EDITOR_SESSION_STORAGE_KEY = "ik-editor:lastSession" + +export function buildPersistedEditorSession( + state: EditorState, +): PersistedEditorSession { + // Explicitly pick serializable + resumable fields. + const persistedState: PersistedEditorSessionState = { + transformations: state.transformations as Transformation[], + visibleTransformations: state.visibleTransformations, + templateName: state.templateName, + templateId: state.templateId, + templateIsPrivate: state.templateIsPrivate, + syncStatus: state.syncStatus, + isPristine: state.isPristine, + localChangeVersion: state.localChangeVersion, + lastSyncedVersion: state.lastSyncedVersion, + lastSavedAt: state.lastSavedAt, + } + + return { + v: EDITOR_SESSION_STORAGE_VERSION, + savedAt: Date.now(), + state: persistedState, + } +} + +export function writeEditorSessionToLocalStorage(args: { + key: string + session: PersistedEditorSession +}): void { + if (typeof window === "undefined") return + try { + window.localStorage.setItem(args.key, JSON.stringify(args.session)) + } catch { + // Ignore quota/serialization errors; persistence is best-effort. + } +} + +export function clearEditorSessionFromLocalStorage(key: string): void { + if (typeof window === "undefined") return + try { + window.localStorage.removeItem(key) + } catch { + // ignore + } +} + +function isValidSessionState(x: unknown): x is PersistedEditorSessionState { + const v = x as Record | null + return ( + !!v && + typeof v === "object" && + Array.isArray(v.transformations) && + typeof v.visibleTransformations === "object" && + typeof v.templateName === "string" && + (typeof v.templateId === "string" || v.templateId === null) && + (typeof v.templateIsPrivate === "boolean" || + v.templateIsPrivate === null) && + typeof v.isPristine === "boolean" && + typeof v.localChangeVersion === "number" && + typeof v.lastSyncedVersion === "number" + ) +} + +export function readEditorSessionFromLocalStorage( + key: string, +): PersistedEditorSession | null { + if (typeof window === "undefined") return null + try { + const raw = window.localStorage.getItem(key) + if (!raw) return null + const parsed = JSON.parse(raw) + if ( + !parsed || + parsed.v !== EDITOR_SESSION_STORAGE_VERSION || + typeof parsed.savedAt !== "number" || + !isValidSessionState(parsed.state) + ) { + return null + } + return parsed as PersistedEditorSession + } catch { + return null + } +} diff --git a/packages/imagekit-editor-dev/src/store.ts b/packages/imagekit-editor-dev/src/store.ts index 3d4e2fb..fc1600b 100644 --- a/packages/imagekit-editor-dev/src/store.ts +++ b/packages/imagekit-editor-dev/src/store.ts @@ -180,6 +180,21 @@ export type EditorActions< setLastSavedAt: (ts: number | null) => void setTransformationConfigFormDirty: (dirty: boolean) => void resetToNewTemplate: () => void + restoreSession: ( + state: Pick< + EditorState, + | "transformations" + | "visibleTransformations" + | "templateName" + | "templateId" + | "templateIsPrivate" + | "syncStatus" + | "isPristine" + | "localChangeVersion" + | "lastSyncedVersion" + | "lastSavedAt" + >, + ) => void /** * Blocks any further writes to template storage while keeping the current * template state intact (so the user can keep viewing/editing locally). @@ -637,6 +652,29 @@ const useEditorStore = create()( }) }, + restoreSession: (persisted) => { + set(() => ({ + transformations: persisted.transformations, + visibleTransformations: persisted.visibleTransformations, + templateName: persisted.templateName, + templateId: persisted.templateId, + templateIsPrivate: persisted.templateIsPrivate, + syncStatus: persisted.syncStatus, + isPristine: persisted.isPristine, + localChangeVersion: persisted.localChangeVersion, + lastSyncedVersion: persisted.lastSyncedVersion, + lastSavedAt: persisted.lastSavedAt, + storageError: undefined, + templateStorageWriteBlocked: false, + transformationConfigFormDirty: false, + _internalState: { + sidebarState: "none", + selectedTransformationKey: null, + transformationToEdit: null, + }, + })) + }, + blockTemplateStorageWrites: (message) => { set({ syncStatus: "error", From f513690d4d9f39287591ecacea5f5f0efddbd021 Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Tue, 5 May 2026 23:07:17 +0530 Subject: [PATCH 02/21] fix: noise on blank state; now new templates will never be created by auto-saves --- .../components/header/TemplateStatus.test.tsx | 40 +++++++++++++++++++ .../src/hooks/useTemplateSync.ts | 8 ++++ 2 files changed, 48 insertions(+) diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.test.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.test.tsx index 469475e..630f69f 100644 --- a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.test.tsx +++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.test.tsx @@ -3,6 +3,7 @@ import { act, fireEvent, render, screen } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import { TemplateStorageContextProvider } from "../../context/TemplateStorageContext" import { + DEBOUNCE_MS, INTERVAL_SAVE_MS, useAutoSaveTemplate, } from "../../hooks/useAutoSaveTemplate" @@ -149,6 +150,7 @@ describe("TemplateStatus", () => { // Start in a fully synced "saved" state. useEditorStore.setState({ + templateId: "t1", isPristine: false, syncStatus: "saved", localChangeVersion: 1, @@ -196,6 +198,44 @@ describe("TemplateStatus", () => { expect(saveTemplate).toHaveBeenCalledTimes(1) }) + it("does not auto-create a new template on blank slate (templateId=null)", async () => { + // biome-ignore lint/suspicious/noExplicitAny: test stub provider signature + const saveTemplate = vi.fn(async (r: any) => ({ + id: "t-created", + clientNumber: "c1", + isPrivate: true, + name: r.name ?? "n", + transformations: r.transformations ?? [], + isPinned: false, + createdBy: { userId: "u1", name: "U", email: "u@example.com" }, + updatedBy: { userId: "u1", name: "U", email: "u@example.com" }, + createdAt: Date.now(), + updatedAt: Date.now(), + })) + + // Default store state starts as a blank slate: templateId=null. + renderWithProvider({ saveTemplate }) + + // Trigger the metadata auto-save path by changing the template name. + act(() => { + useEditorStore.getState().setTemplateName("My New Template") + }) + + // Debounced metadata save should NOT run when templateId is null. + await act(async () => { + vi.advanceTimersByTime(DEBOUNCE_MS + 1) + await Promise.resolve() + }) + expect(saveTemplate).toHaveBeenCalledTimes(0) + + // Interval auto-save should also NOT run (even though we are now "dirty"). + await act(async () => { + vi.advanceTimersByTime(INTERVAL_SAVE_MS + 1) + await Promise.resolve() + }) + expect(saveTemplate).toHaveBeenCalledTimes(0) + }) + it("editing an existing transformation flips status to unsaved immediately (before Apply/Save)", () => { useEditorStore.setState({ isPristine: false, diff --git a/packages/imagekit-editor-dev/src/hooks/useTemplateSync.ts b/packages/imagekit-editor-dev/src/hooks/useTemplateSync.ts index 3c53922..6fe24a7 100644 --- a/packages/imagekit-editor-dev/src/hooks/useTemplateSync.ts +++ b/packages/imagekit-editor-dev/src/hooks/useTemplateSync.ts @@ -29,6 +29,14 @@ export function useTemplateSync() { const state = useEditorStore.getState() if (state.templateStorageWriteBlocked) return null + // Auto-save must never create a brand new template (noise on blank slate). + // Creating a new template is reserved for explicit user actions (manual/imperative/etc). + if ( + state.templateId === null && + (args.reason === "auto_metadata" || args.reason === "auto_interval") + ) { + return null + } const saveStartedAtVersion = state.localChangeVersion savingRef.current = true From a8a6128dec6c2672acabe8d1b992a3e5f9e4a88d Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Tue, 5 May 2026 23:25:29 +0530 Subject: [PATCH 03/21] fix: skeleton loaders in templates dropdown and library modal + fixed height of template dropdown to eliminate layout shift when there are less number of items in the list --- .../components/header/TemplatesDropdown.tsx | 235 +++++++++++------- .../templates/TemplatesLibraryView.tsx | 87 ++++++- 2 files changed, 226 insertions(+), 96 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx index 0468636..f16eb0e 100644 --- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx +++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx @@ -13,6 +13,9 @@ import { PopoverBody, PopoverContent, PopoverTrigger, + Skeleton, + SkeletonCircle, + SkeletonText, Spinner, Text, Tooltip, @@ -206,6 +209,7 @@ export function TemplatesDropdown({ const { saveNow } = useTemplateSync() const { isOpen, onOpen, onClose } = useDisclosure() const [templates, setTemplates] = useState([]) + const [loading, setLoading] = useState(false) const [search, setSearch] = useState("") const [pinningId, setPinningId] = useState(null) const [hoveredTemplateId, setHoveredTemplateId] = useState( @@ -238,8 +242,13 @@ export function TemplatesDropdown({ const fetchTemplates = useCallback(async () => { if (!provider) return - const list = await provider.listTemplates() - setTemplates(list) + setLoading(true) + try { + const list = await provider.listTemplates() + setTemplates(list) + } finally { + setLoading(false) + } }, [provider]) useEffect(() => { @@ -281,6 +290,8 @@ export function TemplatesDropdown({ }).slice(0, MAX_VISIBLE) }, [templates, templateId, templateName, shouldShowCurrent, search]) + const skeletonRows = useMemo(() => Array.from({ length: 5 }), []) + useEffect(() => { if (!isOpen) return // If the hovered template is no longer in the filtered list (e.g. search changed), @@ -526,99 +537,137 @@ export function TemplatesDropdown({ - - {shouldShowCurrent && ( - - {/* Visibility Icon (fallback to private when unknown) */} - - - {/* Name + badge */} - - - + {loading ? ( + + + {skeletonRows.map((_, i) => ( + - - {truncateTemplateName(templateName)} - - - - Current - - - - - {/* Transform count on the right */} - - {currentTransformCount} transformation - {currentTransformCount !== 1 ? "s" : ""} - - - )} - - {filtered.length === 0 && !shouldShowCurrent ? ( - - - {search ? "No templates found" : "No saved templates yet"} - - - ) : filtered.length === 0 && shouldShowCurrent ? ( - - - {search - ? "No other templates found" - : "No other saved templates"} - - + + + + + + + ))} + + ) : ( - filtered.map((record) => ( - setHoveredTemplateId(record.id)} - onMouseLeave={() => - setHoveredTemplateId((current) => - current === record.id ? null : current, - ) - } - /> - )) + <> + {shouldShowCurrent && ( + + {/* Visibility Icon (fallback to private when unknown) */} + + + {/* Name + badge */} + + + + + {truncateTemplateName(templateName)} + + + + Current + + + + + {/* Transform count on the right */} + + {currentTransformCount} transformation + {currentTransformCount !== 1 ? "s" : ""} + + + )} + + {filtered.length === 0 && !shouldShowCurrent ? ( + + + {search + ? "No templates found" + : "No saved templates yet"} + + + ) : filtered.length === 0 && shouldShowCurrent ? ( + + + {search + ? "No other templates found" + : "No other saved templates"} + + + ) : ( + filtered.map((record) => ( + setHoveredTemplateId(record.id)} + onMouseLeave={() => + setHoveredTemplateId((current) => + current === record.id ? null : current, + ) + } + /> + )) + )} + )} diff --git a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx index 5256aa3..5f052bc 100644 --- a/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx +++ b/packages/imagekit-editor-dev/src/components/templates/TemplatesLibraryView.tsx @@ -13,6 +13,9 @@ import { PopoverBody, PopoverContent, PopoverTrigger, + Skeleton, + SkeletonCircle, + SkeletonText, Spinner, Text, Tooltip, @@ -65,6 +68,86 @@ const PopoverContentAny = chakraAny(PopoverContent) const PopoverBodyAny = chakraAny(PopoverBody) const DividerAny = chakraAny(Divider) +function TemplatesLibrarySkeleton() { + const rows = Array.from({ length: 8 }) + return ( + + {/* Header skeleton */} + + + + + + + + + + + + + + + + + + + {/* Rows skeleton */} + + {rows.map((_, i) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ))} + + + ) +} + function formatRelativeTime(ts: number): string { const now = Date.now() // If the timestamp is within 10 seconds of now, show "Just now" @@ -529,9 +612,7 @@ export function TemplatesLibraryView({ onClose }: Props) { data-testid="templates-library-scroll" > {loading ? ( - - - + ) : ( <> {/* Table header */} From 84ffdc634b0152c15a6df6c38f835f579c7366ff Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Tue, 5 May 2026 23:48:54 +0530 Subject: [PATCH 04/21] fix: fixed gradient color picker crashing when clearing from color or to color --- .../src/components/common/GradientPicker.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/common/GradientPicker.tsx b/packages/imagekit-editor-dev/src/components/common/GradientPicker.tsx index aa4be82..d723578 100644 --- a/packages/imagekit-editor-dev/src/components/common/GradientPicker.tsx +++ b/packages/imagekit-editor-dev/src/components/common/GradientPicker.tsx @@ -28,6 +28,11 @@ export type GradientPickerState = { type DirectionMode = "direction" | "degrees" +function isCompleteHexColor(value: string): boolean { + // Accept #RRGGBB and #RRGGBBAA. (Inputs may be temporarily incomplete while typing.) + return /^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(value) +} + function rgbaToHex(rgba: string): string { const parts = rgba.match(/[\d.]+/g)?.map(Number) ?? [] @@ -65,18 +70,26 @@ const GradientPickerField = ({ errors?: FieldErrors> }) => { function getLinearGradientString(value: GradientPickerState): string { + // NOTE: The gradient parser used by the picker is strict and crashes on + // invalid/incomplete color tokens (e.g. empty string when clearing inputs). + // Keep the preview gradient always valid by falling back to defaults. + const fromColor = isCompleteHexColor(value.from) ? value.from : "#FFFFFFFF" + const toColor = isCompleteHexColor(value.to) ? value.to : "#00000000" + let direction = "" const dirInt = Number(value.direction as string) if (!Number.isNaN(dirInt)) { direction = `${dirInt}deg` } else { - direction = `to ${String(value.direction).split("_").join(" ")}` + const dirString = String(value.direction || "bottom") + direction = `to ${dirString.split("_").join(" ")}` } const stopPoint = typeof value.stopPoint === "number" ? value.stopPoint : Number(value.stopPoint) - return `linear-gradient(${direction}, ${value.from} 0%, ${value.to} ${stopPoint}%)` + const safeStopPoint = Number.isFinite(stopPoint) ? stopPoint : 100 + return `linear-gradient(${direction}, ${fromColor} 0%, ${toColor} ${safeStopPoint}%)` } const [localValue, setLocalValue] = useState( From f77060dc9740d11b566310580ab9e0f316512335 Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Wed, 6 May 2026 00:03:40 +0530 Subject: [PATCH 05/21] fix: layout shift in transformation name component due to overflowing text onHover --- .../sidebar/sortable-transformation-item.tsx | 455 +++++++++--------- 1 file changed, 232 insertions(+), 223 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx b/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx index 25e739c..3f8f549 100644 --- a/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx +++ b/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx @@ -152,243 +152,252 @@ export const SortableTransformationItem = ({ )} - {isRenaming ? ( - - - { - if (e.key === "Enter") { - const newName = renameInputRef.current?.value.trim() - if (newName && newName.length > 0) { - updateTransformation(transformation.id, { - ...transformation, - name: newName, - }) + + {isRenaming ? ( + + + { + if (e.key === "Enter") { + const newName = renameInputRef.current?.value.trim() + if (newName && newName.length > 0) { + updateTransformation(transformation.id, { + ...transformation, + name: newName, + }) + } + setIsRenaming(false) + } else if (e.key === "Escape") { + setIsRenaming(false) } - setIsRenaming(false) - } else if (e.key === "Escape") { - setIsRenaming(false) - } - }} - variant="flushed" - /> - - } - variant="ghost" - color={baseIconColor} - onClick={() => { - const newName = renameInputRef.current?.value.trim() - if (newName && newName.length > 0) { - updateTransformation(transformation.id, { - ...transformation, - name: newName, - }) - } - setIsRenaming(false) - }} - /> - } - variant="ghost" - color={baseIconColor} - onClick={() => { - setIsRenaming(false) }} + variant="flushed" /> + + } + variant="ghost" + color={baseIconColor} + onClick={() => { + const newName = renameInputRef.current?.value.trim() + if (newName && newName.length > 0) { + updateTransformation(transformation.id, { + ...transformation, + name: newName, + }) + } + setIsRenaming(false) + }} + /> + } + variant="ghost" + color={baseIconColor} + onClick={() => { + setIsRenaming(false) + }} + /> + - - - Press{" "} - - {navigator.platform.toLowerCase().includes("mac") - ? "Return" - : "Enter"} - {" "} - to save, Esc to cancel - - - ) : ( - - {transformation.name} - - )} - - {isHover && !isRenaming && ( - - + Press{" "} + + {navigator.platform.toLowerCase().includes("mac") + ? "Return" + : "Enter"} + {" "} + to save, Esc to cancel + + + ) : ( + + + {transformation.name} + + + )} + + + {/* Reserve space for right-side actions to avoid layout shift */} + + + { + e.stopPropagation() + toggleTransformationVisibility(transformation.id) + }} > - { - e.stopPropagation() - toggleTransformationVisibility(transformation.id) - }} + + + + + + e.stopPropagation()} + p={0} + bg="transparent" + _hover={{ bg: "transparent" }} > - + - - - e.stopPropagation()} - p={0} - bg="transparent" - _hover={{ bg: "transparent" }} - > - - - - - } - onClick={(e) => { - e.stopPropagation() - _setSidebarState("type") - _setTransformationToEdit(transformation.id, "above") - }} - > - Add transformation before - - } - onClick={(e) => { - e.stopPropagation() - _setSidebarState("type") - _setTransformationToEdit(transformation.id, "below") - }} - > - Add transformation after - - } - onClick={(e) => { - e.stopPropagation() - const currentIndex = transformations.findIndex( - (t) => t.id === transformation.id, - ) - const transformationId = addTransformation( - { - ...transformation, - name: transformation.name - ? `${transformation.name} (Copy)` - : transformation.name, - }, - currentIndex + 1, - ) - _setSidebarState("config") - _setTransformationToEdit(transformationId, "inplace") - }} - > - Duplicate - - } - onClick={(e) => { - e.stopPropagation() - _setSidebarState("config") - _setSelectedTransformationKey(transformation.key) - _setTransformationToEdit(transformation.id, "inplace") - }} - > - Edit transformation - - } - onClick={(e) => { - e.stopPropagation() - setIsRenaming(true) - _setSidebarState("config") - _setSelectedTransformationKey(transformation.key) - _setTransformationToEdit(transformation.id, "inplace") - }} - > - Rename - - } - onClick={(e) => { - e.stopPropagation() - const currentIndex = transformations.findIndex( - (t) => t.id === transformation.id, - ) - if (currentIndex > 0) { - const targetId = transformations[currentIndex - 1].id - moveTransformation(transformation.id, targetId) - } - }} - isDisabled={ - transformations.findIndex( - (t) => t.id === transformation.id, - ) <= 0 + + } + onClick={(e) => { + e.stopPropagation() + _setSidebarState("type") + _setTransformationToEdit(transformation.id, "above") + }} + > + Add transformation before + + } + onClick={(e) => { + e.stopPropagation() + _setSidebarState("type") + _setTransformationToEdit(transformation.id, "below") + }} + > + Add transformation after + + } + onClick={(e) => { + e.stopPropagation() + const currentIndex = transformations.findIndex( + (t) => t.id === transformation.id, + ) + const transformationId = addTransformation( + { + ...transformation, + name: transformation.name + ? `${transformation.name} (Copy)` + : transformation.name, + }, + currentIndex + 1, + ) + _setSidebarState("config") + _setTransformationToEdit(transformationId, "inplace") + }} + > + Duplicate + + } + onClick={(e) => { + e.stopPropagation() + _setSidebarState("config") + _setSelectedTransformationKey(transformation.key) + _setTransformationToEdit(transformation.id, "inplace") + }} + > + Edit transformation + + } + onClick={(e) => { + e.stopPropagation() + setIsRenaming(true) + _setSidebarState("config") + _setSelectedTransformationKey(transformation.key) + _setTransformationToEdit(transformation.id, "inplace") + }} + > + Rename + + } + onClick={(e) => { + e.stopPropagation() + const currentIndex = transformations.findIndex( + (t) => t.id === transformation.id, + ) + if (currentIndex > 0) { + const targetId = transformations[currentIndex - 1].id + moveTransformation(transformation.id, targetId) } - > - Move up - - } - onClick={(e) => { - e.stopPropagation() - const currentIndex = transformations.findIndex( - (t) => t.id === transformation.id, - ) - if (currentIndex < transformations.length - 1) { - const targetId = transformations[currentIndex + 1].id - moveTransformation(transformation.id, targetId) - } - }} - isDisabled={ - transformations.findIndex( - (t) => t.id === transformation.id, - ) >= - transformations.length - 1 + }} + isDisabled={ + transformations.findIndex( + (t) => t.id === transformation.id, + ) <= 0 + } + > + Move up + + } + onClick={(e) => { + e.stopPropagation() + const currentIndex = transformations.findIndex( + (t) => t.id === transformation.id, + ) + if (currentIndex < transformations.length - 1) { + const targetId = transformations[currentIndex + 1].id + moveTransformation(transformation.id, targetId) } - > - Move down - - } - color="red.500" - onClick={(e) => { - e.stopPropagation() - removeTransformation(transformation.id) - if ( - _internalState.selectedTransformationKey === - transformation.key - ) { - _setSidebarState("none") - _setSelectedTransformationKey(null) - _setTransformationToEdit(null) - } - }} - > - Delete - - - - - )} + }} + isDisabled={ + transformations.findIndex( + (t) => t.id === transformation.id, + ) >= + transformations.length - 1 + } + > + Move down + + } + color="red.500" + onClick={(e) => { + e.stopPropagation() + removeTransformation(transformation.id) + if ( + _internalState.selectedTransformationKey === + transformation.key + ) { + _setSidebarState("none") + _setSelectedTransformationKey(null) + _setTransformationToEdit(null) + } + }} + > + Delete + + + + )} From 6637c3a5a00f3f8b61991f1d27001c8f1e551d69 Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Wed, 6 May 2026 00:11:31 +0530 Subject: [PATCH 06/21] fix: height of new button in template dropdown --- .../src/components/header/TemplatesDropdown.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx index f16eb0e..11064bf 100644 --- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx +++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx @@ -529,6 +529,8 @@ export function TemplatesDropdown({ variant="ghost" leftIcon={} px="4" + h="10" + minH="10" flexShrink={0} fontWeight="normal" onClick={handleNewTemplate} From 274dbe84e68d01badc1bb81448e612267b25fc47 Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Wed, 6 May 2026 13:51:34 +0530 Subject: [PATCH 07/21] chore: update example project to make sure it runs with the latest package changes --- examples/react-example/package.json | 8 +- examples/react-example/src/index.tsx | 116 +++++++++++++++++++++++++-- yarn.lock | 8 +- 3 files changed, 124 insertions(+), 8 deletions(-) diff --git a/examples/react-example/package.json b/examples/react-example/package.json index 6930172..e498398 100644 --- a/examples/react-example/package.json +++ b/examples/react-example/package.json @@ -3,12 +3,18 @@ "version": "0.1.0", "private": true, "dependencies": { + "@chakra-ui/icons": "1.1.1", + "@chakra-ui/react": "~1.8.9", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", "@imagekit/editor": "workspace:*", "@types/node": "^20.11.24", "@types/react": "^17.0.2", "@types/react-dom": "^17.0.2", + "framer-motion": "6.5.1", "react": "^17.0.2", - "react-dom": "^17.0.2" + "react-dom": "^17.0.2", + "react-select": "^5.2.1" }, "devDependencies": { "@vitejs/plugin-react": "^4.5.2", diff --git a/examples/react-example/src/index.tsx b/examples/react-example/src/index.tsx index c84c050..5d36144 100644 --- a/examples/react-example/src/index.tsx +++ b/examples/react-example/src/index.tsx @@ -1,15 +1,117 @@ -import { Icon } from "@chakra-ui/react" import { - createLocalStorageProvider, ImageKitEditor, type ImageKitEditorProps, type ImageKitEditorRef, + type TemplateStorageProvider, TRANSFORMATION_STATE_VERSION, + type Transformation, } from "@imagekit/editor" -import { PiDownload } from "@react-icons/all-files/pi/PiDownload" import React, { useCallback, useEffect } from "react" import ReactDOM from "react-dom" +const TEMPLATE_STORAGE_KEY = "ik-editor:templates:v1" + +type StoredTemplateRecord = { + id: string + clientNumber: string + isPrivate: boolean + isPinned: boolean + name: string + transformations: Omit[] + createdBy: { userId: string; name: string; email: string } + updatedBy: { userId: string; name: string; email: string } + createdAt: number + updatedAt: number + lastUsedAt?: number +} + +function readAllTemplates(): StoredTemplateRecord[] { + const raw = localStorage.getItem(TEMPLATE_STORAGE_KEY) + if (!raw) return [] + try { + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? (parsed as StoredTemplateRecord[]) : [] + } catch { + return [] + } +} + +function writeAllTemplates(records: StoredTemplateRecord[]) { + localStorage.setItem(TEMPLATE_STORAGE_KEY, JSON.stringify(records)) +} + +function createLocalTemplateStorage(): TemplateStorageProvider { + const session = { + userId: "demo-user", + name: "Demo User", + email: "demo@example.com", + clientNumber: "demo-client", + } + + return { + async listTemplates() { + return readAllTemplates().sort((a, b) => b.updatedAt - a.updatedAt) + }, + async getTemplate(id: string) { + return readAllTemplates().find((t) => t.id === id) ?? null + }, + async saveTemplate(input) { + const now = Date.now() + const all = readAllTemplates() + const existing = input.id + ? (all.find((t) => t.id === input.id) ?? null) + : null + + const id = existing?.id ?? crypto.randomUUID?.() ?? String(now) + const record: StoredTemplateRecord = { + id, + clientNumber: input.clientNumber ?? existing?.clientNumber ?? "demo", + isPrivate: input.isPrivate ?? existing?.isPrivate ?? false, + isPinned: input.isPinned ?? existing?.isPinned ?? false, + name: input.name, + transformations: input.transformations, + createdBy: input.createdBy ?? + existing?.createdBy ?? { + userId: session.userId, + name: session.name, + email: session.email, + }, + updatedBy: input.updatedBy ?? { + userId: session.userId, + name: session.name, + email: session.email, + }, + createdAt: input.createdAt ?? existing?.createdAt ?? now, + updatedAt: input.updatedAt ?? now, + lastUsedAt: existing?.lastUsedAt, + } + + const next = [record, ...all.filter((t) => t.id !== id)] + writeAllTemplates(next) + return record + }, + async deleteTemplate(id: string) { + writeAllTemplates(readAllTemplates().filter((t) => t.id !== id)) + }, + async setTemplatePinned(id: string, isPinned: boolean) { + const all = readAllTemplates() + const existing = all.find((t) => t.id === id) + if (!existing) { + throw new Error("Template not found") + } + const updated = { ...existing, isPinned, updatedAt: Date.now() } + writeAllTemplates([updated, ...all.filter((t) => t.id !== id)]) + return updated + }, + getProviderName() { + return "localStorage" + }, + getCurrentUserSession() { + return session + }, + } +} + function App() { const [open, setOpen] = React.useState(true) const [editorProps, setEditorProps] = @@ -115,12 +217,14 @@ function App() { // })), ], onAddImage: handleAddImage, - onClose: () => setOpen(false), + onClose: ({ destroy }) => { + destroy() + setOpen(false) + }, exportOptions: [ { type: "button", label: "Export", - icon: , isVisible: true, onClick: (images, currentImage) => { console.log("Export images:", images, currentImage) @@ -149,7 +253,7 @@ function App() { console.log("Signed URL", request.url) return Promise.resolve(request.url) }, - templateStorage: createLocalStorageProvider(), + templateStorage: createLocalTemplateStorage(), }) }, [handleAddImage]) diff --git a/yarn.lock b/yarn.lock index 5cbe0e6..794c9d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -986,7 +986,7 @@ __metadata: languageName: node linkType: hard -"@chakra-ui/react@npm:1.8.9": +"@chakra-ui/react@npm:1.8.9, @chakra-ui/react@npm:~1.8.9": version: 1.8.9 resolution: "@chakra-ui/react@npm:1.8.9" dependencies: @@ -6266,13 +6266,19 @@ __metadata: version: 0.0.0-use.local resolution: "react-example@workspace:examples/react-example" dependencies: + "@chakra-ui/icons": "npm:1.1.1" + "@chakra-ui/react": "npm:~1.8.9" + "@emotion/react": "npm:^11.14.0" + "@emotion/styled": "npm:^11.14.1" "@imagekit/editor": "workspace:*" "@types/node": "npm:^20.11.24" "@types/react": "npm:^17.0.2" "@types/react-dom": "npm:^17.0.2" "@vitejs/plugin-react": "npm:^4.5.2" + framer-motion: "npm:6.5.1" react: "npm:^17.0.2" react-dom: "npm:^17.0.2" + react-select: "npm:^5.2.1" typescript: "npm:4.9.3" vite: "npm:^6.3.5" languageName: unknown From d90d93ac7b845178ee703600b10e4b195414520a Mon Sep 17 00:00:00 2001 From: Abhinav Dhiman Date: Wed, 6 May 2026 14:16:02 +0530 Subject: [PATCH 08/21] chore: add yalc for local package linking and development workflow --- .gitignore | 4 +- DEVELOPMENT.md | 67 +++++++ package.json | 3 +- packages/imagekit-editor-dev/vite.config.ts | 21 ++- yarn.lock | 198 +++++++++++++++++++- 5 files changed, 288 insertions(+), 5 deletions(-) create mode 100644 DEVELOPMENT.md diff --git a/.gitignore b/.gitignore index d208200..5f8f31b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,6 @@ packages/imagekit-editor/*.tgz builds packages/imagekit-editor/README.md .cursor -coverage \ No newline at end of file +coverage +.yalc +yalc.lock diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..e1d9cb9 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,67 @@ +# Development + +## Prerequisites + +- Node.js v20 (use `nvm use`) +- Yarn 4 (via Corepack) +- [yalc](https://github.com/wclr/yalc) (included as a devDependency) + +## Getting Started + +```bash +nvm use +yarn install +yarn dev +``` + +`yarn dev` runs vite in watch mode and **automatically publishes `@imagekit/editor` to the local yalc store** on every rebuild. + +## Linking to External Projects + +Use yalc to test `@imagekit/editor` in any project outside this monorepo: + +### 1. Start dev mode (this repo) + +```bash +yarn dev +``` + +This watches for source changes, rebuilds, and runs `yalc publish --push` automatically after each build. + +### 2. Install yalc globally (required for consuming projects) + +```bash +npm i -g yalc +``` + +### 3. Link in your consuming project + +```bash +# In your external project directory +yalc link @imagekit/editor +``` + +This creates a symlink to the yalc store. Every time the editor rebuilds, your project receives the update automatically via `--push`. + +### 4. Remove the link when done + +```bash +# In your external project directory +yalc remove @imagekit/editor +``` + +## Build + +```bash +yarn build +``` + +Produces the production bundle in `packages/imagekit-editor/dist/`. + +## Package + +```bash +yarn package +``` + +Creates a `.tgz` tarball in `builds/` for manual distribution or testing. diff --git a/package.json b/package.json index dd6d0d6..2457d7f 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "lint-staged": "^16.1.2", "shx": "^0.4.0", "turbo": "^2.0.1", - "vitest": "^2.1.9" + "vitest": "^2.1.9", + "yalc": "^1.0.0-pre.53" }, "packageManager": "yarn@4.9.2", "lint-staged": { diff --git a/packages/imagekit-editor-dev/vite.config.ts b/packages/imagekit-editor-dev/vite.config.ts index b42d49f..85621b4 100644 --- a/packages/imagekit-editor-dev/vite.config.ts +++ b/packages/imagekit-editor-dev/vite.config.ts @@ -1,8 +1,26 @@ +import { execSync } from "node:child_process" import * as path from "node:path" import react from "@vitejs/plugin-react" -import { defineConfig } from "vite" +import { defineConfig, type Plugin } from "vite" import dts from "vite-plugin-dts" +function yalcPublish(): Plugin { + const editorPkgDir = path.resolve(__dirname, "../imagekit-editor") + return { + name: "vite-plugin-yalc-publish", + closeBundle() { + try { + execSync("yalc publish --push --changed", { + cwd: editorPkgDir, + stdio: "inherit", + }) + } catch (e) { + console.error("[yalc] publish failed:", e) + } + }, + } +} + // https://vitejs.dev/config/ export default defineConfig({ plugins: [ @@ -15,6 +33,7 @@ export default defineConfig({ exclude: ["node_modules", "lib"], outDir: "../imagekit-editor/dist/types", }), + yalcPublish(), ], test: { globals: true, diff --git a/yarn.lock b/yarn.lock index 794c9d7..f329572 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3377,6 +3377,16 @@ __metadata: languageName: node linkType: hard +"brace-expansion@npm:^1.1.7": + version: 1.1.14 + resolution: "brace-expansion@npm:1.1.14" + dependencies: + balanced-match: "npm:^1.0.0" + concat-map: "npm:0.0.1" + checksum: 10c0/b6fdac832bc4e36a753658c9ed052c2e1a2be221763b002df25d1efbf7d21724334e726a6cd5eadc72a4b19ec3efb632d629cc003bc9c62f7af7a7915ffa4385 + languageName: node + linkType: hard + "brace-expansion@npm:^2.0.1": version: 2.0.1 resolution: "brace-expansion@npm:2.0.1" @@ -3561,6 +3571,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^7.0.2": + version: 7.0.4 + resolution: "cliui@npm:7.0.4" + dependencies: + string-width: "npm:^4.2.0" + strip-ansi: "npm:^6.0.0" + wrap-ansi: "npm:^7.0.0" + checksum: 10c0/6035f5daf7383470cef82b3d3db00bec70afb3423538c50394386ffbbab135e26c3689c41791f911fa71b62d13d3863c712fdd70f0fbdffd938a1e6fd09aac00 + languageName: node + linkType: hard + "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -3637,6 +3658,13 @@ __metadata: languageName: node linkType: hard +"concat-map@npm:0.0.1": + version: 0.0.1 + resolution: "concat-map@npm:0.0.1" + checksum: 10c0/c996b1cfdf95b6c90fee4dae37e332c8b6eb7d106430c17d538034c0ad9a1630cb194d2ab37293b1bdd4d779494beee7786d586a50bd9376fd6f7bcc2bd4c98f + languageName: node + linkType: hard + "confbox@npm:^0.1.8": version: 0.1.8 resolution: "confbox@npm:0.1.8" @@ -3887,6 +3915,13 @@ __metadata: languageName: node linkType: hard +"detect-indent@npm:^6.0.0": + version: 6.1.0 + resolution: "detect-indent@npm:6.1.0" + checksum: 10c0/dd83cdeda9af219cf77f5e9a0dc31d828c045337386cfb55ce04fad94ba872ee7957336834154f7647b89b899c3c7acc977c57a79b7c776b506240993f97acc7 + languageName: node + linkType: hard + "detect-node-es@npm:^1.1.0": version: 1.1.0 resolution: "detect-node-es@npm:1.1.0" @@ -4469,6 +4504,17 @@ __metadata: languageName: node linkType: hard +"fs-extra@npm:^8.0.1": + version: 8.1.0 + resolution: "fs-extra@npm:8.1.0" + dependencies: + graceful-fs: "npm:^4.2.0" + jsonfile: "npm:^4.0.0" + universalify: "npm:^0.1.0" + checksum: 10c0/259f7b814d9e50d686899550c4f9ded85c46c643f7fe19be69504888e007fcbc08f306fae8ec495b8b998635e997c9e3e175ff2eeed230524ef1c1684cc96423 + languageName: node + linkType: hard + "fs-extra@npm:~7.0.1": version: 7.0.1 resolution: "fs-extra@npm:7.0.1" @@ -4489,6 +4535,13 @@ __metadata: languageName: node linkType: hard +"fs.realpath@npm:^1.0.0": + version: 1.0.0 + resolution: "fs.realpath@npm:1.0.0" + checksum: 10c0/444cf1291d997165dfd4c0d58b69f0e4782bfd9149fd72faa4fe299e68e0e93d6db941660b37dd29153bf7186672ececa3b50b7e7249477b03fdf850f287c948 + languageName: node + linkType: hard + "fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" @@ -4638,6 +4691,20 @@ __metadata: languageName: node linkType: hard +"glob@npm:^7.1.4, glob@npm:^7.1.6": + version: 7.2.3 + resolution: "glob@npm:7.2.3" + dependencies: + fs.realpath: "npm:^1.0.0" + inflight: "npm:^1.0.4" + inherits: "npm:2" + minimatch: "npm:^3.1.1" + once: "npm:^1.3.0" + path-is-absolute: "npm:^1.0.0" + checksum: 10c0/65676153e2b0c9095100fe7f25a778bf45608eeb32c6048cf307f579649bcc30353277b3b898a3792602c65764e5baa4f643714dfbdfd64ea271d210c7a425fe + languageName: node + linkType: hard + "globals@npm:^11.1.0": version: 11.12.0 resolution: "globals@npm:11.12.0" @@ -4652,7 +4719,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.6": +"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 @@ -4815,6 +4882,22 @@ __metadata: languageName: node linkType: hard +"ignore-walk@npm:^3.0.3": + version: 3.0.4 + resolution: "ignore-walk@npm:3.0.4" + dependencies: + minimatch: "npm:^3.0.4" + checksum: 10c0/690372b433887796fa3badd25babab7daf60a1882259dcc130ec78eea79745c2416322e10d1a96b367071204471c532647d20b11cd7ab70bd9b49879e461f956 + languageName: node + linkType: hard + +"ignore@npm:^5.0.4": + version: 5.3.2 + resolution: "ignore@npm:5.3.2" + checksum: 10c0/f9f652c957983634ded1e7f02da3b559a0d4cc210fca3792cb67f1b153623c9c42efdc1c4121af171e295444459fc4a9201101fb041b1104a3c000bccb188337 + languageName: node + linkType: hard + "imagekit-editor-dev@workspace:packages/imagekit-editor-dev": version: 0.0.0-use.local resolution: "imagekit-editor-dev@workspace:packages/imagekit-editor-dev" @@ -4880,6 +4963,7 @@ __metadata: shx: "npm:^0.4.0" turbo: "npm:^2.0.1" vitest: "npm:^2.1.9" + yalc: "npm:^1.0.0-pre.53" languageName: unknown linkType: soft @@ -4921,6 +5005,30 @@ __metadata: languageName: node linkType: hard +"inflight@npm:^1.0.4": + version: 1.0.6 + resolution: "inflight@npm:1.0.6" + dependencies: + once: "npm:^1.3.0" + wrappy: "npm:1" + checksum: 10c0/7faca22584600a9dc5b9fca2cd5feb7135ac8c935449837b315676b4c90aa4f391ec4f42240178244b5a34e8bede1948627fda392ca3191522fc46b34e985ab2 + languageName: node + linkType: hard + +"inherits@npm:2": + version: 2.0.4 + resolution: "inherits@npm:2.0.4" + checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 + languageName: node + linkType: hard + +"ini@npm:^2.0.0": + version: 2.0.0 + resolution: "ini@npm:2.0.0" + checksum: 10c0/2e0c8f386369139029da87819438b20a1ff3fe58372d93fb1a86e9d9344125ace3a806b8ec4eb160a46e64cbc422fe68251869441676af49b7fc441af2389c25 + languageName: node + linkType: hard + "internal-slot@npm:^1.1.0": version: 1.1.0 resolution: "internal-slot@npm:1.1.0" @@ -5646,6 +5754,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^3.0.4, minimatch@npm:^3.1.1": + version: 3.1.5 + resolution: "minimatch@npm:3.1.5" + dependencies: + brace-expansion: "npm:^1.1.7" + checksum: 10c0/2ecbdc0d33f07bddb0315a8b5afbcb761307a8778b48f0b312418ccbced99f104a2d17d8aca7573433c70e8ccd1c56823a441897a45e384ea76ef401a26ace70 + languageName: node + linkType: hard + "minimatch@npm:^9.0.4": version: 9.0.5 resolution: "minimatch@npm:9.0.5" @@ -5841,6 +5958,36 @@ __metadata: languageName: node linkType: hard +"npm-bundled@npm:^1.1.1": + version: 1.1.2 + resolution: "npm-bundled@npm:1.1.2" + dependencies: + npm-normalize-package-bin: "npm:^1.0.1" + checksum: 10c0/3f2337789afc8cb608a0dd71cefe459531053d48a5497db14b07b985c4cab15afcae88600db9f92eae072c89b982eeeec8e4463e1d77bc03a7e90f5dacf29769 + languageName: node + linkType: hard + +"npm-normalize-package-bin@npm:^1.0.1": + version: 1.0.1 + resolution: "npm-normalize-package-bin@npm:1.0.1" + checksum: 10c0/b0c8c05fe419a122e0ff970ccbe7874ae24b4b4b08941a24d18097fe6e1f4b93e3f6abfb5512f9c5488827a5592f2fb3ce2431c41d338802aed24b9a0c160551 + languageName: node + linkType: hard + +"npm-packlist@npm:^2.1.5": + version: 2.2.2 + resolution: "npm-packlist@npm:2.2.2" + dependencies: + glob: "npm:^7.1.6" + ignore-walk: "npm:^3.0.3" + npm-bundled: "npm:^1.1.1" + npm-normalize-package-bin: "npm:^1.0.1" + bin: + npm-packlist: bin/index.js + checksum: 10c0/cf0b1350bfa2e4bdef5e283365fb54811bd095f4b6c8e5f1352a12a155f9aafbd22776b5a79fea7c5e952fab2e72c40f54cea2e139d7d705cfc6f6f955f1aa48 + languageName: node + linkType: hard + "npm-run-path@npm:^2.0.0": version: 2.0.2 resolution: "npm-run-path@npm:2.0.2" @@ -5895,7 +6042,7 @@ __metadata: languageName: node linkType: hard -"once@npm:^1.3.1, once@npm:^1.4.0": +"once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -5991,6 +6138,13 @@ __metadata: languageName: node linkType: hard +"path-is-absolute@npm:^1.0.0": + version: 1.0.1 + resolution: "path-is-absolute@npm:1.0.1" + checksum: 10c0/127da03c82172a2a50099cddbf02510c1791fc2cc5f7713ddb613a56838db1e8168b121a920079d052e0936c23005562059756d653b7c544c53185efe53be078 + languageName: node + linkType: hard + "path-key@npm:^2.0.0, path-key@npm:^2.0.1": version: 2.0.1 resolution: "path-key@npm:2.0.1" @@ -8216,6 +8370,24 @@ __metadata: languageName: node linkType: hard +"yalc@npm:^1.0.0-pre.53": + version: 1.0.0-pre.53 + resolution: "yalc@npm:1.0.0-pre.53" + dependencies: + chalk: "npm:^4.1.0" + detect-indent: "npm:^6.0.0" + fs-extra: "npm:^8.0.1" + glob: "npm:^7.1.4" + ignore: "npm:^5.0.4" + ini: "npm:^2.0.0" + npm-packlist: "npm:^2.1.5" + yargs: "npm:^16.1.1" + bin: + yalc: src/yalc.js + checksum: 10c0/630f65b00740da6d568d46748a40e2bf2c872cf9babe7c319642a5b6db2dcd0a5d4a34e249d20099709e3ba09bb7e9b34ff78af5cd54c690668e094e156551c9 + languageName: node + linkType: hard + "yallist@npm:^3.0.2": version: 3.1.1 resolution: "yallist@npm:3.1.1" @@ -8253,6 +8425,13 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^20.2.2": + version: 20.2.9 + resolution: "yargs-parser@npm:20.2.9" + checksum: 10c0/0685a8e58bbfb57fab6aefe03c6da904a59769bd803a722bb098bd5b0f29d274a1357762c7258fb487512811b8063fb5d2824a3415a0a4540598335b3b086c72 + languageName: node + linkType: hard + "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" @@ -8260,6 +8439,21 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^16.1.1": + version: 16.2.0 + resolution: "yargs@npm:16.2.0" + dependencies: + cliui: "npm:^7.0.2" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + require-directory: "npm:^2.1.1" + string-width: "npm:^4.2.0" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^20.2.2" + checksum: 10c0/b1dbfefa679848442454b60053a6c95d62f2d2e21dd28def92b647587f415969173c6e99a0f3bab4f1b67ee8283bf735ebe3544013f09491186ba9e8a9a2b651 + languageName: node + linkType: hard + "yargs@npm:^17.5.1": version: 17.7.2 resolution: "yargs@npm:17.7.2" From 2f9de9911af1c2fd9cb1d0b98fe70f44e67bd200 Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Tue, 12 May 2026 11:06:52 +0530 Subject: [PATCH 09/21] fix: resume modal behavior conditions to respect the current change number along with the localStorage session data --- .../src/ImageKitEditor.test.tsx | 156 ++++++++++++++++++ .../src/ImageKitEditor.tsx | 7 +- 2 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 packages/imagekit-editor-dev/src/ImageKitEditor.test.tsx diff --git a/packages/imagekit-editor-dev/src/ImageKitEditor.test.tsx b/packages/imagekit-editor-dev/src/ImageKitEditor.test.tsx new file mode 100644 index 0000000..0895d45 --- /dev/null +++ b/packages/imagekit-editor-dev/src/ImageKitEditor.test.tsx @@ -0,0 +1,156 @@ +import "@testing-library/jest-dom/vitest" +import { render, screen, waitFor } from "@testing-library/react" +import React from "react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { ImageKitEditor } from "./ImageKitEditor" +import { + EDITOR_SESSION_STORAGE_KEY, + EDITOR_SESSION_STORAGE_VERSION, +} from "./persistence/editorSessionStorage" +import type { TemplateStorageProvider } from "./storage" +import { useEditorStore } from "./store" + +const RESUME_HEADING = "Resume previous session?" + +function stubTemplateStorage(): TemplateStorageProvider { + return { + getProviderName: () => "test", + getCurrentUserSession: () => ({}), + listTemplates: async () => [], + getTemplate: async () => null, + saveTemplate: async (record) => ({ + id: record.id ?? "t-new", + clientNumber: "c1", + isPrivate: record.isPrivate ?? false, + name: record.name, + transformations: record.transformations ?? [], + isPinned: false, + createdBy: { userId: "u1", name: "U", email: "u@example.com" }, + updatedBy: { userId: "u1", name: "U", email: "u@example.com" }, + createdAt: Date.now(), + updatedAt: Date.now(), + }), + setTemplatePinned: async () => { + throw new Error("not used") + }, + } +} + +function writeLastSessionToLocalStorage(args: { + localChangeVersion: number + lastSyncedVersion: number + isPristine: boolean +}) { + const session = { + v: EDITOR_SESSION_STORAGE_VERSION, + savedAt: Date.now(), + state: { + transformations: [], + visibleTransformations: {}, + templateName: "Untitled Template", + templateId: null, + templateIsPrivate: null, + syncStatus: "saved" as const, + isPristine: args.isPristine, + localChangeVersion: args.localChangeVersion, + lastSyncedVersion: args.lastSyncedVersion, + lastSavedAt: Date.now(), + }, + } + window.localStorage.setItem( + EDITOR_SESSION_STORAGE_KEY, + JSON.stringify(session), + ) +} + +describe("ImageKitEditor resume session modal", () => { + beforeEach(() => { + useEditorStore.getState().destroy() + window.localStorage.removeItem(EDITOR_SESSION_STORAGE_KEY) + }) + + afterEach(() => { + useEditorStore.getState().destroy() + window.localStorage.removeItem(EDITOR_SESSION_STORAGE_KEY) + vi.restoreAllMocks() + }) + + it("does not show resume modal when localStorage is empty", async () => { + render( + {}} + templateStorage={stubTemplateStorage()} + />, + ) + + await waitFor(() => { + expect(screen.queryByText(RESUME_HEADING)).not.toBeInTheDocument() + }) + }) + + it("with template storage: does not show resume modal when versions are in sync", async () => { + writeLastSessionToLocalStorage({ + localChangeVersion: 3, + lastSyncedVersion: 3, + isPristine: false, + }) + + render( + {}} + templateStorage={stubTemplateStorage()} + />, + ) + + await waitFor(() => { + expect(screen.queryByText(RESUME_HEADING)).not.toBeInTheDocument() + }) + }) + + it("with template storage: shows resume modal when local changes are ahead of last sync", async () => { + writeLastSessionToLocalStorage({ + localChangeVersion: 4, + lastSyncedVersion: 2, + isPristine: true, + }) + + render( + {}} + templateStorage={stubTemplateStorage()} + />, + ) + + await waitFor(() => { + expect(screen.getByText(RESUME_HEADING)).toBeInTheDocument() + }) + }) + + it("without template storage: does not show resume modal when session is pristine", async () => { + writeLastSessionToLocalStorage({ + localChangeVersion: 0, + lastSyncedVersion: 0, + isPristine: true, + }) + + render( {}} />) + + await waitFor(() => { + expect(screen.queryByText(RESUME_HEADING)).not.toBeInTheDocument() + }) + }) + + it("without template storage: shows resume modal when session is not pristine", async () => { + writeLastSessionToLocalStorage({ + localChangeVersion: 1, + lastSyncedVersion: 1, + isPristine: false, + }) + + render( {}} />) + + await waitFor(() => { + expect(screen.getByText(RESUME_HEADING)).toBeInTheDocument() + }) + }) +}) diff --git a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx index 4daefda..a830784 100644 --- a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx +++ b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx @@ -144,8 +144,13 @@ function ImageKitEditorImpl( EDITOR_SESSION_STORAGE_KEY, ) if (!resumableSession) return + const persisted = resumableSession.state + const hasUnsavedChanges = resolvedProvider + ? persisted.localChangeVersion !== persisted.lastSyncedVersion + : !persisted.isPristine + if (!hasUnsavedChanges) return setResumeSession(resumableSession) - }, []) + }, [resolvedProvider]) const saveTemplateImperative = useCallback(async () => { // Avoid importing hooks here; implement via store+provider with version gating. From 29263749892742165943587c31d69ff3da75db6a Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Tue, 12 May 2026 11:12:19 +0530 Subject: [PATCH 10/21] ci: disabled yalc publish in test environments --- packages/imagekit-editor-dev/vite.config.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/imagekit-editor-dev/vite.config.ts b/packages/imagekit-editor-dev/vite.config.ts index 85621b4..73d8fcf 100644 --- a/packages/imagekit-editor-dev/vite.config.ts +++ b/packages/imagekit-editor-dev/vite.config.ts @@ -4,11 +4,20 @@ import react from "@vitejs/plugin-react" import { defineConfig, type Plugin } from "vite" import dts from "vite-plugin-dts" +function isYalcPublishDisabled(): boolean { + return ( + process.env.VITEST === "true" || + process.env.CI === "true" || + process.env.DISABLE_YALC === "1" + ) +} + function yalcPublish(): Plugin { const editorPkgDir = path.resolve(__dirname, "../imagekit-editor") return { name: "vite-plugin-yalc-publish", closeBundle() { + if (isYalcPublishDisabled()) return try { execSync("yalc publish --push --changed", { cwd: editorPkgDir, From 2200f2e0ab7a5380de4d446955cc485596c9586f Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Tue, 12 May 2026 13:07:39 +0530 Subject: [PATCH 11/21] chore: improved test coverage --- .../TemplatePermissionsContext.test.tsx | 138 +++++ .../src/hooks/useAutoSaveTemplate.test.tsx | 195 +++++++ .../src/hooks/useSaveTemplate.test.tsx | 151 +++++ .../src/hooks/useTemplateSync.test.tsx | 362 ++++++++++++ .../src/hooks/useVisibility.test.tsx | 145 +++++ .../storage/serializeTransformations.test.ts | 45 ++ .../src/storage/templateAccessError.test.ts | 61 ++ .../imagekit-editor-dev/src/store.test.ts | 548 ++++++++++++++++++ packages/imagekit-editor-dev/vite.config.ts | 21 +- 9 files changed, 1661 insertions(+), 5 deletions(-) create mode 100644 packages/imagekit-editor-dev/src/context/TemplatePermissionsContext.test.tsx create mode 100644 packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.test.tsx create mode 100644 packages/imagekit-editor-dev/src/hooks/useSaveTemplate.test.tsx create mode 100644 packages/imagekit-editor-dev/src/hooks/useTemplateSync.test.tsx create mode 100644 packages/imagekit-editor-dev/src/hooks/useVisibility.test.tsx create mode 100644 packages/imagekit-editor-dev/src/storage/serializeTransformations.test.ts create mode 100644 packages/imagekit-editor-dev/src/storage/templateAccessError.test.ts create mode 100644 packages/imagekit-editor-dev/src/store.test.ts diff --git a/packages/imagekit-editor-dev/src/context/TemplatePermissionsContext.test.tsx b/packages/imagekit-editor-dev/src/context/TemplatePermissionsContext.test.tsx new file mode 100644 index 0000000..442e798 --- /dev/null +++ b/packages/imagekit-editor-dev/src/context/TemplatePermissionsContext.test.tsx @@ -0,0 +1,138 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" +import type { TemplateRecord } from "../storage" +import { + resolveTemplatePermissionBuckets, + resolveTemplatePermissions, + TemplatePermissionsContextProvider, + useTemplatePermissionBuckets, + useTemplatePermissions, +} from "./TemplatePermissionsContext" + +function makeTemplate(overrides: Partial = {}): TemplateRecord { + const base: TemplateRecord = { + id: "t1", + clientNumber: "c1", + isPrivate: false, + name: "My template", + transformations: [], + isPinned: false, + createdBy: { userId: "u1", name: "A", email: "a@x.com" }, + updatedBy: { userId: "u1", name: "A", email: "a@x.com" }, + createdAt: 1, + updatedAt: 2, + } + return { ...base, ...overrides } +} + +describe("resolveTemplatePermissionBuckets", () => { + it("returns allow-all when template is null", () => { + const b = resolveTemplatePermissionBuckets({ + template: null, + getTemplatePermissions: () => ({ + create: false, + view: false, + manage: false, + changeVisibility: false, + delete: false, + pin: false, + }), + }) + expect(b.create).toBe(true) + expect(b.view).toBe(true) + expect(b.manage).toBe(true) + }) + + it("returns allow-all when getTemplatePermissions is null", () => { + const b = resolveTemplatePermissionBuckets({ + template: makeTemplate(), + getTemplatePermissions: null, + }) + expect(b.delete).toBe(true) + expect(b.pin).toBe(true) + }) + + it("delegates to host callback when both template and getter exist", () => { + const b = resolveTemplatePermissionBuckets({ + template: makeTemplate(), + getTemplatePermissions: () => ({ + create: false, + view: true, + manage: false, + changeVisibility: true, + delete: false, + pin: true, + }), + }) + expect(b.create).toBe(false) + expect(b.view).toBe(true) + expect(b.manage).toBe(false) + expect(b.changeVisibility).toBe(true) + expect(b.delete).toBe(false) + expect(b.pin).toBe(true) + }) +}) + +describe("resolveTemplatePermissions", () => { + it("maps manage bucket to rename and save", () => { + const p = resolveTemplatePermissions({ + template: makeTemplate(), + getTemplatePermissions: () => ({ + create: true, + view: true, + manage: false, + changeVisibility: true, + delete: false, + pin: false, + }), + }) + expect(p.rename).toBe(false) + expect(p.save).toBe(false) + expect(p.changeVisibility).toBe(true) + expect(p.create).toBe(true) + expect(p.delete).toBe(false) + expect(p.pin).toBe(false) + }) +}) + +function PermissionsConsumer() { + const perms = useTemplatePermissions(makeTemplate()) + const buckets = useTemplatePermissionBuckets(makeTemplate()) + return ( +
+ {String(perms.save)} + {String(buckets.manage)} +
+ ) +} + +describe("TemplatePermissionsContextProvider + hooks", () => { + it("useTemplatePermissions allows all when no getter is supplied", () => { + render( + + + , + ) + expect(screen.getByTestId("save").textContent).toBe("true") + expect(screen.getByTestId("manage").textContent).toBe("true") + }) + + it("useTemplatePermissionBuckets reflects host getter", () => { + render( + ({ + create: true, + view: true, + manage: false, + changeVisibility: false, + delete: false, + pin: false, + })} + > + + , + ) + expect(screen.getByTestId("manage").textContent).toBe("false") + expect(screen.getByTestId("save").textContent).toBe("false") + }) +}) diff --git a/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.test.tsx b/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.test.tsx new file mode 100644 index 0000000..75cf5cf --- /dev/null +++ b/packages/imagekit-editor-dev/src/hooks/useAutoSaveTemplate.test.tsx @@ -0,0 +1,195 @@ +import { act, render } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { TemplateStorageContextProvider } from "../context/TemplateStorageContext" +import { useEditorStore } from "../store" +import { + DEBOUNCE_MS, + INTERVAL_SAVE_MS, + useAutoSaveTemplate, +} from "./useAutoSaveTemplate" + +function stubProvider(saveTemplate: ReturnType) { + return { + getProviderName: () => "test", + getCurrentUserSession: () => ({}), + listTemplates: async () => [], + getTemplate: async () => null, + saveTemplate, + setTemplatePinned: async () => { + throw new Error("not used") + }, + } +} + +function MountAutoSave() { + useAutoSaveTemplate() + return null +} + +const saved = { + id: "t1", + clientNumber: "c1", + isPrivate: false, + name: "T", + transformations: [], + isPinned: false, + createdBy: { userId: "u1", name: "U", email: "u@x.com" }, + updatedBy: { userId: "u1", name: "U", email: "u@x.com" }, + createdAt: 1, + updatedAt: 2, +} + +describe("useAutoSaveTemplate", () => { + beforeEach(() => { + useEditorStore.getState().destroy() + vi.restoreAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("does not auto_interval save when template storage writes are blocked", async () => { + const saveTemplate = vi.fn().mockResolvedValue(saved) + + useEditorStore.setState({ + templateId: "t1", + templateStorageWriteBlocked: true, + isPristine: false, + localChangeVersion: 3, + lastSyncedVersion: 1, + } as Parameters[0]) + + render( + + + , + ) + + vi.advanceTimersByTime(INTERVAL_SAVE_MS) + await vi.runOnlyPendingTimersAsync() + + expect(saveTemplate).not.toHaveBeenCalled() + }) + + it("debounces auto_metadata save when template name changes", async () => { + const saveTemplate = vi + .fn() + .mockImplementation(async (input: { name: string }) => ({ + ...saved, + name: input.name, + })) + + useEditorStore.setState({ + templateId: "t1", + templateName: "A", + isPristine: false, + localChangeVersion: 1, + lastSyncedVersion: 1, + } as Parameters[0]) + + render( + + + , + ) + + await act(async () => { + useEditorStore.setState({ templateName: "B" } as Parameters< + typeof useEditorStore.setState + >[0]) + }) + vi.advanceTimersByTime(DEBOUNCE_MS - 1) + expect(saveTemplate).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + await vi.runOnlyPendingTimersAsync() + + expect(saveTemplate).toHaveBeenCalled() + expect(saveTemplate.mock.calls[0][0].name).toBe("B") + }) + + it("fires auto_interval save when versions differ after interval", async () => { + const saveTemplate = vi.fn().mockResolvedValue(saved) + + useEditorStore.setState({ + templateId: "t1", + templateName: "T", + isPristine: false, + localChangeVersion: 3, + lastSyncedVersion: 1, + } as Parameters[0]) + + render( + + + , + ) + + vi.advanceTimersByTime(INTERVAL_SAVE_MS) + await vi.runOnlyPendingTimersAsync() + + expect(saveTemplate).toHaveBeenCalled() + expect(saveTemplate.mock.calls[0][0]).toEqual( + expect.objectContaining({ + id: "t1", + name: "T", + }), + ) + }) + + it("does not auto_interval save when store is pristine i.e. no templateId or templateName", async () => { + const saveTemplate = vi.fn().mockResolvedValue(saved) + + useEditorStore.setState({ + templateId: "t1", + isPristine: true, + localChangeVersion: 2, + lastSyncedVersion: 1, + } as Parameters[0]) + + render( + + + , + ) + + vi.advanceTimersByTime(INTERVAL_SAVE_MS) + await vi.runOnlyPendingTimersAsync() + + expect(saveTemplate).not.toHaveBeenCalled() + }) + + it("does not auto_interval save when already synced", async () => { + const saveTemplate = vi.fn().mockResolvedValue(saved) + + useEditorStore.setState({ + templateId: "t1", + isPristine: false, + localChangeVersion: 2, + lastSyncedVersion: 2, + } as Parameters[0]) + + render( + + + , + ) + + vi.advanceTimersByTime(INTERVAL_SAVE_MS) + await vi.runOnlyPendingTimersAsync() + + expect(saveTemplate).not.toHaveBeenCalled() + }) +}) diff --git a/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.test.tsx b/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.test.tsx new file mode 100644 index 0000000..8dd4d6f --- /dev/null +++ b/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.test.tsx @@ -0,0 +1,151 @@ +import { act, render, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { TemplateStorageContextProvider } from "../context/TemplateStorageContext" +import { useEditorStore } from "../store" +import { useSaveTemplate } from "./useSaveTemplate" + +function stubProvider(saveTemplate: ReturnType) { + return { + getProviderName: () => "test", + getCurrentUserSession: () => ({}), + listTemplates: async () => [], + getTemplate: async () => null, + saveTemplate, + setTemplatePinned: async () => { + throw new Error("not used") + }, + } +} + +function MountSaveShortcut() { + useSaveTemplate() + return null +} + +describe("useSaveTemplate", () => { + beforeEach(() => { + useEditorStore.getState().destroy() + vi.restoreAllMocks() + }) + + it("does not register shortcut when provider is null", () => { + const addSpy = vi.spyOn(window, "addEventListener") + render( + + + , + ) + expect(addSpy.mock.calls.filter((c) => c[0] === "keydown")).toHaveLength(0) + addSpy.mockRestore() + }) + + it("triggers save on Ctrl+S", async () => { + const saveTemplate = vi.fn().mockResolvedValue({ + id: "t1", + clientNumber: "c1", + isPrivate: false, + name: "T", + transformations: [], + isPinned: false, + createdBy: { userId: "u1", name: "U", email: "u@x.com" }, + updatedBy: { userId: "u1", name: "U", email: "u@x.com" }, + createdAt: 1, + updatedAt: 2, + }) + useEditorStore.setState({ + templateId: "t1", + templateName: "T", + } as Parameters[0]) + + render( + + + , + ) + + await act(async () => { + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "s", + ctrlKey: true, + bubbles: true, + cancelable: true, + }), + ) + }) + + await waitFor(() => { + expect(saveTemplate).toHaveBeenCalled() + }) + }) + + it("triggers save on Meta+S", async () => { + const saveTemplate = vi.fn().mockResolvedValue({ + id: "t1", + clientNumber: "c1", + isPrivate: false, + name: "T", + transformations: [], + isPinned: false, + createdBy: { userId: "u1", name: "U", email: "u@x.com" }, + updatedBy: { userId: "u1", name: "U", email: "u@x.com" }, + createdAt: 1, + updatedAt: 2, + }) + useEditorStore.setState({ + templateId: "t1", + } as Parameters[0]) + + render( + + + , + ) + + await act(async () => { + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "s", + metaKey: true, + bubbles: true, + }), + ) + }) + + await waitFor(() => { + expect(saveTemplate).toHaveBeenCalled() + }) + }) + + it("removes listener on unmount", () => { + const removeSpy = vi.spyOn(window, "removeEventListener") + const saveTemplate = vi.fn().mockResolvedValue({ + id: "t1", + clientNumber: "c1", + isPrivate: false, + name: "T", + transformations: [], + isPinned: false, + createdBy: { userId: "u1", name: "U", email: "u@x.com" }, + updatedBy: { userId: "u1", name: "U", email: "u@x.com" }, + createdAt: 1, + updatedAt: 2, + }) + + const { unmount } = render( + + + , + ) + + unmount() + expect(removeSpy.mock.calls.some((c) => c[0] === "keydown")).toBe(true) + removeSpy.mockRestore() + }) +}) diff --git a/packages/imagekit-editor-dev/src/hooks/useTemplateSync.test.tsx b/packages/imagekit-editor-dev/src/hooks/useTemplateSync.test.tsx new file mode 100644 index 0000000..e4511b2 --- /dev/null +++ b/packages/imagekit-editor-dev/src/hooks/useTemplateSync.test.tsx @@ -0,0 +1,362 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { TemplateStorageContextProvider } from "../context/TemplateStorageContext" +import type { SaveTemplateInput, TemplateRecord } from "../storage" +import { TemplateAccessDeniedError } from "../storage/templateAccessError" +import { useEditorStore } from "../store" +import { type SaveReason, useTemplateSync } from "./useTemplateSync" + +function savedRecord(overrides: Partial = {}): TemplateRecord { + return { + id: "saved-id", + clientNumber: "c1", + isPrivate: false, + name: "Saved", + transformations: [], + isPinned: false, + createdBy: { userId: "u1", name: "U", email: "u@x.com" }, + updatedBy: { userId: "u1", name: "U", email: "u@x.com" }, + createdAt: 1, + updatedAt: 2, + ...overrides, + } +} + +function stubProvider(saveTemplate: ReturnType) { + return { + getProviderName: () => "test", + getCurrentUserSession: () => ({}), + listTemplates: async () => [], + getTemplate: async () => null, + saveTemplate, + setTemplatePinned: async () => { + throw new Error("not used") + }, + } +} + +function SaveTrigger(props: { + reason?: SaveReason + overrides?: Partial> +}) { + const { saveNow, hasProvider } = useTemplateSync() + return ( +
+ {String(hasProvider)} + +
+ ) +} + +describe("useTemplateSync", () => { + beforeEach(() => { + useEditorStore.getState().destroy() + vi.restoreAllMocks() + }) + + it("exposes hasProvider false when no storage context", () => { + render() + expect(screen.getByTestId("has").textContent).toBe("false") + }) + + it("does not call provider saveTemplate when there is no provider", () => { + const saveTemplate = vi.fn() + render() + fireEvent.click(screen.getByRole("button", { name: "save" })) + expect(saveTemplate).not.toHaveBeenCalled() + }) + + it("returns null when template storage writes are blocked", () => { + const saveTemplate = vi.fn().mockResolvedValue(savedRecord()) + useEditorStore.setState({ + templateStorageWriteBlocked: true, + templateId: "t1", + } as Parameters[0]) + + render( + + + , + ) + + fireEvent.click(screen.getByRole("button", { name: "save" })) + expect(saveTemplate).not.toHaveBeenCalled() + }) + + it("skips auto save when templateId is null for auto_metadata", () => { + const saveTemplate = vi.fn().mockResolvedValue(savedRecord()) + useEditorStore.setState({ + templateId: null, + templateName: "X", + } as Parameters[0]) + + render( + + + , + ) + + fireEvent.click(screen.getByRole("button", { name: "save" })) + expect(saveTemplate).not.toHaveBeenCalled() + }) + + it("skips auto_interval when templateId is null", () => { + const saveTemplate = vi.fn().mockResolvedValue(savedRecord()) + + render( + + + , + ) + + fireEvent.click(screen.getByRole("button", { name: "save" })) + expect(saveTemplate).not.toHaveBeenCalled() + }) + + it("calls saveTemplate and marks saved when no edits occur during save", async () => { + const saveTemplate = vi + .fn() + .mockResolvedValue(savedRecord({ id: "new-id", name: "N" })) + useEditorStore.setState({ + templateId: "old-id", + templateName: "Old", + localChangeVersion: 2, + lastSyncedVersion: 1, + } as Parameters[0]) + + render( + + + , + ) + + fireEvent.click(screen.getByRole("button", { name: "save" })) + await waitFor(() => { + expect(useEditorStore.getState().syncStatus).toBe("saved") + }) + expect(saveTemplate).toHaveBeenCalledTimes(1) + const st = useEditorStore.getState() + expect(st.templateId).toBe("new-id") + expect(st.templateName).toBe("N") + }) + + it("sets unsaved when localChangeVersion changes during save", async () => { + let release!: (r: TemplateRecord) => void + const gate = new Promise((res) => { + release = res + }) + const saveTemplate = vi.fn().mockReturnValue(gate) + + useEditorStore.setState({ + templateId: "t1", + templateName: "T", + localChangeVersion: 5, + lastSyncedVersion: 4, + } as Parameters[0]) + + render( + + + , + ) + + fireEvent.click(screen.getByRole("button", { name: "save" })) + expect(saveTemplate).toHaveBeenCalled() + + await act(async () => { + await Promise.resolve() + useEditorStore.getState().bumpLocalChangeVersion() + release(savedRecord({ id: "t1", name: "T" })) + }) + + await waitFor(() => { + expect(useEditorStore.getState().syncStatus).toBe("unsaved") + }) + }) + + it("passes overrides.isPrivate to saveTemplate", async () => { + const saveTemplate = vi.fn().mockResolvedValue(savedRecord()) + useEditorStore.setState({ + templateId: "t1", + templateName: "T", + templateIsPrivate: false, + } as Parameters[0]) + + render( + + + , + ) + + fireEvent.click(screen.getByRole("button", { name: "save" })) + await waitFor(() => { + expect(saveTemplate).toHaveBeenCalled() + }) + expect(saveTemplate.mock.calls[0][0]).toMatchObject({ isPrivate: true }) + }) + + it("passes templateIsPrivate from store when overrides omit isPrivate", async () => { + const saveTemplate = vi.fn().mockResolvedValue(savedRecord()) + useEditorStore.setState({ + templateId: "t1", + templateName: "T", + templateIsPrivate: true, + } as Parameters[0]) + + render( + + + , + ) + + fireEvent.click(screen.getByRole("button", { name: "save" })) + await waitFor(() => { + expect(saveTemplate).toHaveBeenCalled() + }) + expect(saveTemplate.mock.calls[0][0]).toMatchObject({ isPrivate: true }) + }) + + it("blocks writes on TemplateAccessDeniedError", async () => { + const saveTemplate = vi + .fn() + .mockRejectedValue(new TemplateAccessDeniedError()) + useEditorStore.setState({ + templateId: "t1", + } as Parameters[0]) + + render( + + + , + ) + + fireEvent.click(screen.getByRole("button", { name: "save" })) + await waitFor(() => { + expect(useEditorStore.getState().templateStorageWriteBlocked).toBe(true) + }) + expect(useEditorStore.getState().syncStatus).toBe("error") + }) + + it("sets error sync status on generic failure", async () => { + const saveTemplate = vi.fn().mockRejectedValue(new Error("network")) + useEditorStore.setState({ + templateId: "t1", + } as Parameters[0]) + + render( + + + , + ) + + fireEvent.click(screen.getByRole("button", { name: "save" })) + await waitFor(() => { + expect(useEditorStore.getState().syncStatus).toBe("error") + }) + expect(useEditorStore.getState().storageError).toBe("network") + }) + + it("blocks writes with default message when access error is not an Error instance", async () => { + const saveTemplate = vi.fn().mockRejectedValue({ status: 403 }) + useEditorStore.setState({ + templateId: "t1", + } as Parameters[0]) + + render( + + + , + ) + + fireEvent.click(screen.getByRole("button", { name: "save" })) + await waitFor(() => { + expect(useEditorStore.getState().templateStorageWriteBlocked).toBe(true) + }) + expect(useEditorStore.getState().storageError).toBe( + "You no longer have access to this template.", + ) + }) + + it("sets generic error message when failure is not an Error instance", async () => { + const saveTemplate = vi.fn().mockRejectedValue("boom") + useEditorStore.setState({ + templateId: "t1", + } as Parameters[0]) + + render( + + + , + ) + + fireEvent.click(screen.getByRole("button", { name: "save" })) + await waitFor(() => { + expect(useEditorStore.getState().syncStatus).toBe("error") + }) + expect(useEditorStore.getState().storageError).toBe( + "Failed to save template", + ) + }) + + it("ignores overlapping saveNow calls while a save is in flight", async () => { + let release!: (r: TemplateRecord) => void + const gate = new Promise((res) => { + release = res + }) + const saveTemplate = vi.fn().mockReturnValue(gate) + + useEditorStore.setState({ + templateId: "t1", + } as Parameters[0]) + + render( + + + , + ) + + fireEvent.click(screen.getByRole("button", { name: "save" })) + fireEvent.click(screen.getByRole("button", { name: "save" })) + expect(saveTemplate).toHaveBeenCalledTimes(1) + + await act(async () => { + release(savedRecord({ id: "t1" })) + }) + }) +}) diff --git a/packages/imagekit-editor-dev/src/hooks/useVisibility.test.tsx b/packages/imagekit-editor-dev/src/hooks/useVisibility.test.tsx new file mode 100644 index 0000000..245704c --- /dev/null +++ b/packages/imagekit-editor-dev/src/hooks/useVisibility.test.tsx @@ -0,0 +1,145 @@ +import { act, render, screen } from "@testing-library/react" +import { useEffect } from "react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { useVisibility } from "./useVisibility" + +afterEach(() => { + vi.restoreAllMocks() +}) + +function VisibilityHarness(props: { + enabled: boolean + rootMargin?: string + root?: Element | null + onVisible?: (v: boolean) => void +}) { + const { ref, visible } = useVisibility( + props.enabled, + props.rootMargin ?? "300px", + props.root, + ) + useEffect(() => { + props.onVisible?.(visible) + }, [props, visible]) + return ( +
+
+ {String(visible)} +
+ ) +} + +describe("useVisibility", () => { + beforeEach(() => { + vi.stubGlobal( + "IntersectionObserver", + class { + readonly root: Element | null = null + readonly rootMargin = "" + readonly thresholds: readonly number[] = [] + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + takeRecords = () => [] + constructor( + public cb: IntersectionObserverCallback, + _init?: IntersectionObserverInit, + ) {} + }, + ) + }) + + it("when disabled, visible is true and observer is not used", () => { + const ctorSpy = vi.spyOn(globalThis, "IntersectionObserver") + + render() + expect(screen.getByTestId("vis").textContent).toBe("true") + expect(ctorSpy).not.toHaveBeenCalled() + }) + + it("when IntersectionObserver is missing, sets visible true", () => { + const Original = globalThis.IntersectionObserver + // @ts-expect-error test env without IO + delete globalThis.IntersectionObserver + try { + render() + expect(screen.getByTestId("vis").textContent).toBe("true") + } finally { + globalThis.IntersectionObserver = Original + } + }) + + it("sets visible when intersection entry is intersecting", () => { + let callback: IntersectionObserverCallback | null = null + class IO { + observe = vi.fn() + disconnect = vi.fn() + constructor(cb: IntersectionObserverCallback) { + callback = cb + } + } + vi.stubGlobal("IntersectionObserver", IO) + + render() + act(() => { + callback?.( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) + }) + expect(screen.getByTestId("vis").textContent).toBe("true") + }) + + it("getBoundingClientRect fallback marks visible when element is in viewport", () => { + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ + top: 10, + bottom: 100, + left: 10, + right: 100, + width: 90, + height: 90, + x: 10, + y: 10, + toJSON: () => ({}), + }) + render() + expect(screen.getByTestId("vis").textContent).toBe("true") + }) + + it("passes root and rootMargin to IntersectionObserver", () => { + const calls: unknown[][] = [] + class IO { + constructor(_cb: unknown, init?: IntersectionObserverInit) { + calls.push([init?.root, init?.rootMargin]) + } + observe = vi.fn() + disconnect = vi.fn() + } + vi.stubGlobal("IntersectionObserver", IO) + + const root = document.createElement("div") + render() + expect(calls[0]).toEqual([root, "10px"]) + }) + + it("ignores observer callback after unmount", () => { + let callback: IntersectionObserverCallback | null = null + class IO { + observe = vi.fn() + disconnect = vi.fn() + constructor(cb: IntersectionObserverCallback) { + callback = cb + } + } + vi.stubGlobal("IntersectionObserver", IO) + + const { unmount } = render() + unmount() + act(() => { + callback?.( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) + }) + }) +}) diff --git a/packages/imagekit-editor-dev/src/storage/serializeTransformations.test.ts b/packages/imagekit-editor-dev/src/storage/serializeTransformations.test.ts new file mode 100644 index 0000000..d597456 --- /dev/null +++ b/packages/imagekit-editor-dev/src/storage/serializeTransformations.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest" +import { TRANSFORMATION_STATE_VERSION } from "../store" +import { normalizeTransformationStepsForPersistence } from "./serializeTransformations" +import type { SaveTemplateInput } from "./types" + +function minimalStep( + overrides: Partial = {}, +): SaveTemplateInput["transformations"][number] { + return { + key: "w", + name: "Width", + type: "transformation", + value: { w: 100 } as SaveTemplateInput["transformations"][number]["value"], + ...overrides, + } +} + +describe("normalizeTransformationStepsForPersistence", () => { + it("returns empty array for empty input", () => { + expect(normalizeTransformationStepsForPersistence([])).toEqual([]) + }) + + it("fills missing version with TRANSFORMATION_STATE_VERSION", () => { + const out = normalizeTransformationStepsForPersistence([ + minimalStep({ version: undefined }), + ]) + expect(out[0].version).toBe(TRANSFORMATION_STATE_VERSION) + }) + + it("preserves an explicit version on a step", () => { + const out = normalizeTransformationStepsForPersistence([ + minimalStep({ version: "v1" }), + ]) + expect(out[0].version).toBe("v1") + }) + + it("maps multiple steps independently", () => { + const out = normalizeTransformationStepsForPersistence([ + minimalStep({ key: "a", version: undefined }), + minimalStep({ key: "b", version: "v1" }), + ]) + expect(out[0].version).toBe(TRANSFORMATION_STATE_VERSION) + expect(out[1].version).toBe("v1") + }) +}) diff --git a/packages/imagekit-editor-dev/src/storage/templateAccessError.test.ts b/packages/imagekit-editor-dev/src/storage/templateAccessError.test.ts new file mode 100644 index 0000000..486e2a2 --- /dev/null +++ b/packages/imagekit-editor-dev/src/storage/templateAccessError.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest" +import { + applyTemplateStorageAccessFailure, + isTemplateAccessDeniedError, + TemplateAccessDeniedError, +} from "./templateAccessError" + +describe("isTemplateAccessDeniedError", () => { + it("is true for TemplateAccessDeniedError", () => { + expect(isTemplateAccessDeniedError(new TemplateAccessDeniedError())).toBe( + true, + ) + }) + + it("is true for object with status 401 or 403", () => { + expect(isTemplateAccessDeniedError({ status: 401 })).toBe(true) + expect(isTemplateAccessDeniedError({ status: 403 })).toBe(true) + }) + + it("is false for other status or non-objects", () => { + expect(isTemplateAccessDeniedError({ status: 404 })).toBe(false) + expect(isTemplateAccessDeniedError(new Error("x"))).toBe(false) + expect(isTemplateAccessDeniedError(null)).toBe(false) + }) +}) + +describe("applyTemplateStorageAccessFailure", () => { + it("returns false and does not call actions when not an access error", () => { + const deny = vi.fn() + expect( + applyTemplateStorageAccessFailure(new Error("nope"), { + denyTemplateStorageAccessAndReset: deny, + }), + ).toBe(false) + expect(deny).not.toHaveBeenCalled() + }) + + it("calls deny with message for TemplateAccessDeniedError", () => { + const deny = vi.fn() + const err = new TemplateAccessDeniedError("custom", 403) + expect( + applyTemplateStorageAccessFailure(err, { + denyTemplateStorageAccessAndReset: deny, + }), + ).toBe(true) + expect(deny).toHaveBeenCalledWith("custom") + }) + + it("calls deny with default message for status-shaped error", () => { + const deny = vi.fn() + expect( + applyTemplateStorageAccessFailure( + { status: 403 }, + { denyTemplateStorageAccessAndReset: deny }, + ), + ).toBe(true) + expect(deny).toHaveBeenCalledWith( + "You no longer have access to this template.", + ) + }) +}) diff --git a/packages/imagekit-editor-dev/src/store.test.ts b/packages/imagekit-editor-dev/src/store.test.ts new file mode 100644 index 0000000..afc7706 --- /dev/null +++ b/packages/imagekit-editor-dev/src/store.test.ts @@ -0,0 +1,548 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { + TRANSFORMATION_STATE_VERSION, + type Transformation, + useEditorStore, +} from "./store" + +const SAMPLE_URL = "https://ik.imagekit.io/demo/tr:f-auto/sample.jpg" + +function borderTransform(): Omit { + return { + key: "adjust-border", + name: "Border", + type: "transformation", + value: { borderWidth: 2, borderColor: "#000000" }, + version: TRANSFORMATION_STATE_VERSION, + } +} + +function resizeTransform(): Omit { + return { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + width: 100, + height: 100, + mode: "cm-pad_extract", + }, + version: TRANSFORMATION_STATE_VERSION, + } +} + +beforeEach(() => { + useEditorStore.getState().destroy() + vi.restoreAllMocks() +}) + +afterEach(() => { + useEditorStore.getState().destroy() +}) + +describe("useEditorStore", () => { + describe("destroy", () => { + it("resets to default template + empty images", () => { + useEditorStore.getState().initialize({ imageList: [SAMPLE_URL] }) + useEditorStore.getState().setTemplateName("X") + useEditorStore.getState().destroy() + + const s = useEditorStore.getState() + expect(s.templateName).toBe("Untitled Template") + expect(s.originalImageList).toHaveLength(0) + expect(s.transformations).toHaveLength(0) + expect(s.localChangeVersion).toBe(0) + expect(s.syncStatus).toBe("unsaved") + }) + }) + + describe("initialize", () => { + it("no-op when nothing passed", () => { + useEditorStore.getState().initialize() + expect(useEditorStore.getState().originalImageList).toHaveLength(0) + }) + + it("loads images and sets current to first", () => { + useEditorStore.getState().initialize({ + imageList: [SAMPLE_URL, "https://example.com/other.jpg"], + }) + const s = useEditorStore.getState() + expect(s.imageList.length).toBeGreaterThan(0) + expect(s.currentImage).toBeTruthy() + expect(s.originalImageList).toHaveLength(2) + }) + + it("stores signer and focusObjects", () => { + const signer = vi.fn() + useEditorStore.getState().initialize({ + imageList: [SAMPLE_URL], + signer, + focusObjects: ["foo"] as never, + }) + expect(useEditorStore.getState().signer).toBe(signer) + expect(useEditorStore.getState().focusObjects).toEqual(["foo"]) + }) + + it("templateId sets pristine false and sync saved with versions reset", () => { + useEditorStore.getState().initialize({ templateId: "tid-1" }) + const s = useEditorStore.getState() + expect(s.templateId).toBe("tid-1") + expect(s.isPristine).toBe(false) + expect(s.syncStatus).toBe("saved") + expect(s.localChangeVersion).toBe(0) + expect(s.lastSyncedVersion).toBe(0) + }) + + it("templateName alone triggers same synced bootstrap", () => { + useEditorStore.getState().initialize({ templateName: "Hello" }) + const s = useEditorStore.getState() + expect(s.templateName).toBe("Hello") + expect(s.syncStatus).toBe("saved") + expect(s.isPristine).toBe(false) + }) + }) + + describe("images", () => { + it("setCurrentImage", () => { + useEditorStore.getState().initialize({ imageList: [SAMPLE_URL] }) + useEditorStore.getState().setCurrentImage(undefined) + expect(useEditorStore.getState().currentImage).toBeUndefined() + }) + + it("setImageDimensions updates matching file only", () => { + useEditorStore.getState().initialize({ imageList: [SAMPLE_URL] }) + useEditorStore + .getState() + .setImageDimensions("https://unknown.test/x.jpg", { + width: 1, + height: 1, + }) + expect( + useEditorStore.getState().originalImageList[0].imageDimensions, + ).toBeNull() + + useEditorStore.getState().setImageDimensions(SAMPLE_URL, { + width: 400, + height: 300, + }) + expect( + useEditorStore.getState().originalImageList[0].imageDimensions, + ).toEqual({ + width: 400, + height: 300, + }) + }) + + it("addImage appends new url and switches current", () => { + useEditorStore.getState().initialize({ imageList: [SAMPLE_URL] }) + useEditorStore.getState().addImage("https://example.com/second.jpg") + expect(useEditorStore.getState().originalImageList).toHaveLength(2) + expect(useEditorStore.getState().currentImage).toContain("second.jpg") + }) + + it("addImage existing url only switches current", () => { + useEditorStore.getState().initialize({ + imageList: [SAMPLE_URL, "https://example.com/b.jpg"], + }) + const before = useEditorStore.getState().originalImageList.length + useEditorStore.getState().addImage(SAMPLE_URL) + expect(useEditorStore.getState().originalImageList).toHaveLength(before) + expect(useEditorStore.getState().currentImage).toBe(SAMPLE_URL) + }) + + it("addImages skips duplicates", () => { + useEditorStore.getState().initialize({ imageList: [SAMPLE_URL] }) + useEditorStore + .getState() + .addImages([SAMPLE_URL, "https://example.com/new.jpg"]) + expect(useEditorStore.getState().originalImageList).toHaveLength(2) + }) + + it("removeImage switches current when removing active", () => { + useEditorStore.getState().initialize({ + imageList: [ + SAMPLE_URL, + "https://example.com/a.jpg", + "https://example.com/b.jpg", + ], + }) + useEditorStore.getState().setCurrentImage("https://example.com/a.jpg") + useEditorStore.getState().removeImage("https://example.com/a.jpg") + expect(useEditorStore.getState().currentImage).toBe( + "https://example.com/b.jpg", + ) + }) + + it("removeImage picks prior image when removing last item", () => { + useEditorStore.getState().initialize({ + imageList: [SAMPLE_URL, "https://example.com/a.jpg"], + }) + useEditorStore.getState().setCurrentImage("https://example.com/a.jpg") + useEditorStore.getState().removeImage("https://example.com/a.jpg") + expect(useEditorStore.getState().currentImage).toBe(SAMPLE_URL) + }) + + it("removeImage clears current when list becomes empty", () => { + useEditorStore.getState().initialize({ imageList: [SAMPLE_URL] }) + useEditorStore.getState().removeImage(SAMPLE_URL) + expect(useEditorStore.getState().currentImage).toBeUndefined() + }) + + it("removeImage clears signing state for that url", () => { + useEditorStore.getState().initialize({ imageList: [SAMPLE_URL] }) + const ac = new AbortController() + useEditorStore.setState({ + signingImages: { [SAMPLE_URL]: true }, + signingAbortControllers: { [SAMPLE_URL]: ac }, + signedUrlCache: { [`${SAMPLE_URL}::[]`]: "cached" }, + }) + const spy = vi.spyOn(ac, "abort") + useEditorStore.getState().removeImage(SAMPLE_URL) + expect(spy).toHaveBeenCalled() + expect( + useEditorStore.getState().signingImages[SAMPLE_URL], + ).toBeUndefined() + }) + }) + + describe("transformations", () => { + it("loadTemplate assigns ids, versions, visibility from enabled", () => { + useEditorStore + .getState() + .loadTemplate([{ ...borderTransform(), enabled: false }]) + const s = useEditorStore.getState() + expect(s.transformations).toHaveLength(1) + expect(s.transformations[0].version).toBe(TRANSFORMATION_STATE_VERSION) + expect(s.visibleTransformations[s.transformations[0].id]).toBe(false) + expect(s.syncStatus).toBe("saved") + expect(s.localChangeVersion).toBe(s.lastSyncedVersion) + }) + + it("moveTransformation reorders and bumps version", () => { + useEditorStore + .getState() + .loadTemplate([borderTransform(), resizeTransform()]) + const [a, b] = useEditorStore.getState().transformations + const v0 = useEditorStore.getState().localChangeVersion + useEditorStore.getState().moveTransformation(b.id, a.id) + const order = useEditorStore.getState().transformations.map((t) => t.id) + expect(order[0]).toBe(b.id) + expect(useEditorStore.getState().localChangeVersion).toBeGreaterThan(v0) + }) + + it("moveTransformation no-op when ids invalid", () => { + useEditorStore.getState().loadTemplate([borderTransform()]) + const v0 = useEditorStore.getState().localChangeVersion + useEditorStore.getState().moveTransformation("nope", "nah") + expect(useEditorStore.getState().localChangeVersion).toBe(v0) + }) + + it("toggleTransformationVisibility updates visible map and transformation.enabled", () => { + useEditorStore.getState().loadTemplate([borderTransform()]) + const id = useEditorStore.getState().transformations[0].id + expect(useEditorStore.getState().visibleTransformations[id]).not.toBe( + false, + ) + useEditorStore.getState().toggleTransformationVisibility(id) + expect(useEditorStore.getState().visibleTransformations[id]).toBe(false) + expect(useEditorStore.getState().transformations[0].enabled).toBe(false) + }) + + it("addTransformation appends", () => { + useEditorStore.getState().loadTemplate([]) + const id = useEditorStore.getState().addTransformation(borderTransform()) + expect( + useEditorStore.getState().transformations.map((t) => t.id), + ).toContain(id) + expect(useEditorStore.getState().visibleTransformations[id]).toBe(true) + }) + + it("addTransformation inserts at position", () => { + useEditorStore + .getState() + .loadTemplate([resizeTransform(), borderTransform()]) + const id = useEditorStore + .getState() + .addTransformation(borderTransform(), 0) + expect(useEditorStore.getState().transformations[0].id).toBe(id) + }) + + it("removeTransformation", () => { + useEditorStore.getState().loadTemplate([borderTransform()]) + const id = useEditorStore.getState().transformations[0].id + useEditorStore.getState().removeTransformation(id) + expect(useEditorStore.getState().transformations).toHaveLength(0) + }) + + it("updateTransformation preserves id", () => { + useEditorStore.getState().loadTemplate([borderTransform()]) + const id = useEditorStore.getState().transformations[0].id + const updated: Transformation = { + ...useEditorStore.getState().transformations[0], + name: "Renamed", + } + useEditorStore.getState().updateTransformation(id, updated) + expect(useEditorStore.getState().transformations[0].name).toBe("Renamed") + expect(useEditorStore.getState().transformations[0].id).toBe(id) + }) + }) + + describe("template metadata & sync helpers", () => { + it("setTemplateName bumps version when name changes", () => { + const v0 = useEditorStore.getState().localChangeVersion + useEditorStore.getState().setTemplateName("A") + expect(useEditorStore.getState().localChangeVersion).toBeGreaterThan(v0) + const v1 = useEditorStore.getState().localChangeVersion + useEditorStore.getState().setTemplateName("A") + expect(useEditorStore.getState().localChangeVersion).toBe(v1) + }) + + it("setTemplateIsPrivate bumps only when value changes", () => { + useEditorStore.getState().setTemplateIsPrivate(true) + const v = useEditorStore.getState().localChangeVersion + useEditorStore.getState().setTemplateIsPrivate(true) + expect(useEditorStore.getState().localChangeVersion).toBe(v) + useEditorStore.getState().setTemplateIsPrivate(false) + expect(useEditorStore.getState().localChangeVersion).toBeGreaterThan(v) + }) + + it("hydrateTemplateMetadata", () => { + useEditorStore.getState().hydrateTemplateMetadata({ + templateId: "z", + templateName: "Z", + templateIsPrivate: false, + }) + const s = useEditorStore.getState() + expect(s.templateId).toBe("z") + expect(s.templateName).toBe("Z") + expect(s.templateIsPrivate).toBe(false) + }) + + it("setSyncStatus with optional error", () => { + useEditorStore.getState().setSyncStatus("error", "e") + expect(useEditorStore.getState().storageError).toBe("e") + }) + + it("markSynced with and without explicit version", () => { + useEditorStore.setState({ + localChangeVersion: 7, + lastSyncedVersion: 1, + }) + useEditorStore.getState().markSynced(5) + expect(useEditorStore.getState().lastSyncedVersion).toBe(5) + useEditorStore.getState().markSynced() + expect(useEditorStore.getState().lastSyncedVersion).toBe(7) + }) + + it("bumpLocalChangeVersion", () => { + const v = useEditorStore.getState().localChangeVersion + useEditorStore.getState().bumpLocalChangeVersion() + expect(useEditorStore.getState().localChangeVersion).toBe(v + 1) + }) + + it("setLastSavedAt and setTransformationConfigFormDirty and setIsPristine", () => { + useEditorStore.getState().setLastSavedAt(12345) + expect(useEditorStore.getState().lastSavedAt).toBe(12345) + useEditorStore.getState().setTransformationConfigFormDirty(true) + expect(useEditorStore.getState().transformationConfigFormDirty).toBe(true) + useEditorStore.getState().setIsPristine(false) + expect(useEditorStore.getState().isPristine).toBe(false) + }) + + it("setShowOriginal", () => { + useEditorStore.getState().setShowOriginal(true) + expect(useEditorStore.getState().showOriginal).toBe(true) + }) + + it("setTemplateId", () => { + useEditorStore.getState().setTemplateId("abc") + expect(useEditorStore.getState().templateId).toBe("abc") + }) + }) + + describe("session reset & recovery", () => { + it("resetToNewTemplate", () => { + useEditorStore.setState({ + transformations: [{ id: "x", ...borderTransform() } as Transformation], + templateName: "Old", + templateId: "id", + localChangeVersion: 9, + }) + useEditorStore.getState().resetToNewTemplate() + const s = useEditorStore.getState() + expect(s.transformations).toHaveLength(0) + expect(s.templateId).toBeNull() + expect(s.localChangeVersion).toBe(0) + expect(s.syncStatus).toBe("unsaved") + }) + + it("restoreSession", () => { + useEditorStore.getState().restoreSession({ + transformations: [{ id: "x", ...borderTransform() } as Transformation], + visibleTransformations: { x: true }, + templateName: "R", + templateId: "tid", + templateIsPrivate: true, + syncStatus: "saved", + isPristine: false, + localChangeVersion: 3, + lastSyncedVersion: 3, + lastSavedAt: 99, + }) + const s = useEditorStore.getState() + expect(s.templateName).toBe("R") + expect(s.templateStorageWriteBlocked).toBe(false) + expect(s.transformationConfigFormDirty).toBe(false) + expect(s.lastSavedAt).toBe(99) + }) + + it("blockTemplateStorageWrites uses default message when omitted", () => { + useEditorStore.getState().blockTemplateStorageWrites() + expect(useEditorStore.getState().storageError).toBe( + "You no longer have access to this template.", + ) + expect(useEditorStore.getState().templateStorageWriteBlocked).toBe(true) + }) + + it("denyTemplateStorageAccessAndReset", () => { + useEditorStore.getState().loadTemplate([borderTransform()]) + useEditorStore.getState().denyTemplateStorageAccessAndReset("gone") + const s = useEditorStore.getState() + expect(s.transformations).toHaveLength(0) + expect(s.storageError).toBe("gone") + expect(s.templateStorageWriteBlocked).toBe(true) + }) + }) + + describe("_internal UI helpers", () => { + it("_setSidebarState", () => { + useEditorStore.getState()._setSidebarState("config") + expect(useEditorStore.getState()._internalState.sidebarState).toBe( + "config", + ) + }) + + it("_setSelectedTransformationKey", () => { + useEditorStore.getState()._setSelectedTransformationKey("k") + expect( + useEditorStore.getState()._internalState.selectedTransformationKey, + ).toBe("k") + }) + + it("_setTransformationToEdit clears when empty id", () => { + useEditorStore.getState()._setTransformationToEdit("x", "inplace") + useEditorStore.getState()._setTransformationToEdit("") + expect( + useEditorStore.getState()._internalState.transformationToEdit, + ).toBeNull() + }) + + it("_setTransformationToEdit inplace above below", () => { + useEditorStore.getState()._setTransformationToEdit("t1", "inplace") + expect( + useEditorStore.getState()._internalState.transformationToEdit, + ).toEqual({ + transformationId: "t1", + position: "inplace", + }) + useEditorStore.getState()._setTransformationToEdit("t2", "above") + expect( + useEditorStore.getState()._internalState.transformationToEdit, + ).toEqual({ + position: "above", + targetId: "t2", + }) + useEditorStore.getState()._setTransformationToEdit("t3", "below") + expect( + useEditorStore.getState()._internalState.transformationToEdit, + ).toEqual({ + position: "below", + targetId: "t3", + }) + }) + }) + + describe("recomputeImages (subscriptions)", () => { + it("computes imageList after template load", async () => { + useEditorStore.getState().initialize({ imageList: [SAMPLE_URL] }) + useEditorStore.getState().loadTemplate([borderTransform()]) + await vi.waitFor(() => { + expect(useEditorStore.getState().imageList[0]).not.toBe(SAMPLE_URL) + }) + expect(useEditorStore.getState().currentTransformKey).not.toBe("") + }) + + it("showOriginal passes raw url without transforms", async () => { + useEditorStore.getState().initialize({ imageList: [SAMPLE_URL] }) + useEditorStore.getState().loadTemplate([borderTransform()]) + await vi.waitFor(() => + expect(useEditorStore.getState().currentTransformKey).not.toBe(""), + ) + useEditorStore.getState().setShowOriginal(true) + await vi.waitFor(() => { + expect(useEditorStore.getState().currentTransformKey).toBe("original") + }) + expect(useEditorStore.getState().imageList[0]).toBe(SAMPLE_URL) + }) + + it("signed URL path invokes signer and caches result", async () => { + const signer = vi.fn().mockResolvedValue("https://signed.example/img") + useEditorStore.getState().initialize({ + imageList: [ + { + url: SAMPLE_URL, + metadata: { requireSignedUrl: true }, + }, + ], + signer, + }) + useEditorStore.getState().loadTemplate([borderTransform()]) + await vi.waitFor(() => expect(signer).toHaveBeenCalled()) + await vi.waitFor(() => { + expect(useEditorStore.getState().imageList[0]).toBe( + "https://signed.example/img", + ) + }) + const cacheKeys = Object.keys(useEditorStore.getState().signedUrlCache) + expect(cacheKeys.length).toBeGreaterThan(0) + }) + + it("aborts pending signers when transform stack identity changes", async () => { + const signer = vi + .fn() + .mockImplementation(() => new Promise(() => {})) + useEditorStore.getState().initialize({ + imageList: [{ url: SAMPLE_URL, metadata: { requireSignedUrl: true } }], + signer, + }) + useEditorStore.getState().loadTemplate([borderTransform()]) + await vi.waitFor(() => expect(signer).toHaveBeenCalled()) + const urls = Object.keys( + useEditorStore.getState().signingAbortControllers, + ) + expect(urls.length).toBeGreaterThan(0) + const controller = + useEditorStore.getState().signingAbortControllers[urls[0]] + const spy = vi.spyOn(controller, "abort") + useEditorStore.getState().loadTemplate([resizeTransform()]) + await vi.waitFor(() => expect(spy).toHaveBeenCalled()) + }) + + it("signer rejection logs non-abort errors", async () => { + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const signer = vi.fn().mockRejectedValue(new Error("fail")) + useEditorStore.getState().initialize({ + imageList: [ + { + url: SAMPLE_URL, + metadata: { requireSignedUrl: true }, + }, + ], + signer, + }) + useEditorStore.getState().loadTemplate([borderTransform()]) + await vi.waitFor(() => expect(errSpy).toHaveBeenCalled()) + errSpy.mockRestore() + }) + }) +}) diff --git a/packages/imagekit-editor-dev/vite.config.ts b/packages/imagekit-editor-dev/vite.config.ts index 73d8fcf..d873a22 100644 --- a/packages/imagekit-editor-dev/vite.config.ts +++ b/packages/imagekit-editor-dev/vite.config.ts @@ -52,14 +52,25 @@ export default defineConfig({ coverage: { provider: "v8", reporter: ["text", "json", "html"], - include: ["src/schema/**/*.{ts,tsx}"], - exclude: ["src/**/*.{test,spec}.{ts,tsx}", "node_modules/**"], + include: [ + "src/store.ts", + "src/schema/**/*.{ts,tsx}", + "src/hooks/**/*.{ts,tsx}", + "src/context/**/*.{ts,tsx}", + "src/storage/**/*.{ts,tsx}", + "src/sync/**/*.{ts,tsx}", + ], + exclude: [ + "src/**/*.{test,spec}.{ts,tsx}", + "node_modules/**", + /** Interfaces only; no runtime code to cover */ + "src/storage/types.ts", + ], thresholds: { - // Only enforced on src/schema files - focusing on validation logic - lines: 90, // Realistic threshold given UI visibility code + lines: 90, branches: 90, statements: 90, - perFile: false, // Global threshold across all schema files + perFile: false, }, }, }, From d83d5463d493584c226286317e2d240175ea6f23 Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Tue, 12 May 2026 13:55:24 +0530 Subject: [PATCH 12/21] refactor: store split into slices and test cases to ensure nothing breaks before, during and after this refactor --- .../imagekit-editor-dev/src/store.test.ts | 13 + packages/imagekit-editor-dev/src/store.ts | 1095 ----------------- .../src/store/createEditorStore.test.ts | 94 ++ .../src/store/createEditorStore.ts | 166 +++ .../imagekit-editor-dev/src/store/index.ts | 3 + .../src/store/initialState.test.ts | 41 + .../src/store/initialState.ts | 45 + .../src/store/pure/calculateImageList.test.ts | 131 ++ .../src/store/pure/calculateImageList.ts | 215 ++++ .../src/store/pure/normalizeImage.test.ts | 36 + .../src/store/pure/normalizeImage.ts | 23 + .../src/store/slices/imagesSlice.test.ts | 111 ++ .../src/store/slices/imagesSlice.ts | 106 ++ .../src/store/slices/lifecycleSlice.test.ts | 74 ++ .../src/store/slices/lifecycleSlice.ts | 47 + .../src/store/slices/sidebarSlice.test.ts | 56 + .../src/store/slices/sidebarSlice.ts | 73 ++ .../src/store/slices/syncSlice.test.ts | 43 + .../src/store/slices/syncSlice.ts | 46 + .../src/store/slices/templateSlice.test.ts | 100 ++ .../src/store/slices/templateSlice.ts | 134 ++ .../store/slices/transformationsSlice.test.ts | 98 ++ .../src/store/slices/transformationsSlice.ts | 163 +++ .../src/store/test/helpers.ts | 28 + .../imagekit-editor-dev/src/store/types.ts | 205 +++ packages/imagekit-editor-dev/vite.config.ts | 4 +- 26 files changed, 2053 insertions(+), 1097 deletions(-) delete mode 100644 packages/imagekit-editor-dev/src/store.ts create mode 100644 packages/imagekit-editor-dev/src/store/createEditorStore.test.ts create mode 100644 packages/imagekit-editor-dev/src/store/createEditorStore.ts create mode 100644 packages/imagekit-editor-dev/src/store/index.ts create mode 100644 packages/imagekit-editor-dev/src/store/initialState.test.ts create mode 100644 packages/imagekit-editor-dev/src/store/initialState.ts create mode 100644 packages/imagekit-editor-dev/src/store/pure/calculateImageList.test.ts create mode 100644 packages/imagekit-editor-dev/src/store/pure/calculateImageList.ts create mode 100644 packages/imagekit-editor-dev/src/store/pure/normalizeImage.test.ts create mode 100644 packages/imagekit-editor-dev/src/store/pure/normalizeImage.ts create mode 100644 packages/imagekit-editor-dev/src/store/slices/imagesSlice.test.ts create mode 100644 packages/imagekit-editor-dev/src/store/slices/imagesSlice.ts create mode 100644 packages/imagekit-editor-dev/src/store/slices/lifecycleSlice.test.ts create mode 100644 packages/imagekit-editor-dev/src/store/slices/lifecycleSlice.ts create mode 100644 packages/imagekit-editor-dev/src/store/slices/sidebarSlice.test.ts create mode 100644 packages/imagekit-editor-dev/src/store/slices/sidebarSlice.ts create mode 100644 packages/imagekit-editor-dev/src/store/slices/syncSlice.test.ts create mode 100644 packages/imagekit-editor-dev/src/store/slices/syncSlice.ts create mode 100644 packages/imagekit-editor-dev/src/store/slices/templateSlice.test.ts create mode 100644 packages/imagekit-editor-dev/src/store/slices/templateSlice.ts create mode 100644 packages/imagekit-editor-dev/src/store/slices/transformationsSlice.test.ts create mode 100644 packages/imagekit-editor-dev/src/store/slices/transformationsSlice.ts create mode 100644 packages/imagekit-editor-dev/src/store/test/helpers.ts create mode 100644 packages/imagekit-editor-dev/src/store/types.ts diff --git a/packages/imagekit-editor-dev/src/store.test.ts b/packages/imagekit-editor-dev/src/store.test.ts index afc7706..42b5105 100644 --- a/packages/imagekit-editor-dev/src/store.test.ts +++ b/packages/imagekit-editor-dev/src/store.test.ts @@ -1,3 +1,16 @@ +/** + * This file was created before refactoring the store into slices. + * Even though we have a suite of tests for the slices, we will keep + * this file around for a while to ensure we don't break anything. + * + * This approach of refactoring was done to ensure that the store is treated + * as a black box to the rest of the application and tests here assert and lock + * the behavior of the store. + * + * If these tests pass before and after refactoring, it tells us that the behavior + * of the store has not changed. + */ + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { TRANSFORMATION_STATE_VERSION, diff --git a/packages/imagekit-editor-dev/src/store.ts b/packages/imagekit-editor-dev/src/store.ts deleted file mode 100644 index fc1600b..0000000 --- a/packages/imagekit-editor-dev/src/store.ts +++ /dev/null @@ -1,1095 +0,0 @@ -import type { UniqueIdentifier } from "@dnd-kit/core" -import { - buildSrc, - buildTransformationString, - type Transformation as IKTransformation, -} from "@imagekit/javascript" -import { create } from "zustand" -import { subscribeWithSelector } from "zustand/middleware" -import { - type DEFAULT_FOCUS_OBJECTS, - getDefaultTransformationFromMode, - type TransformationField, - transformationFormatters, - transformationSchema, -} from "./schema" -import { bumpLocalChangeVersion as bumpVersion } from "./sync/templateSyncVersioning" -import { extractImagePath } from "./utils" - -export const TRANSFORMATION_STATE_VERSION = "v1" as const - -export interface Transformation { - id: string - key: string - name: string - type: "transformation" - value: IKTransformation - version?: typeof TRANSFORMATION_STATE_VERSION - /** Persisted visibility flag. Absent or true = visible; false = hidden. */ - enabled?: boolean -} - -export type RequiredMetadata = { requireSignedUrl: boolean } - -export interface FileElement< - Metadata extends RequiredMetadata = RequiredMetadata, -> { - url: string - metadata: Metadata - imageDimensions: { width: number; height: number } | null -} - -export type InputFileElement< - Metadata extends RequiredMetadata = RequiredMetadata, -> = Omit, "imageDimensions"> - -export interface SignerRequest< - Metadata extends RequiredMetadata = RequiredMetadata, -> { - url: string - transformation: string - metadata: Metadata -} - -export type Signer = ( - item: SignerRequest, - controller?: AbortController, -) => Promise - -interface InternalState { - sidebarState: "none" | "type" | "config" - selectedTransformationKey: string | null - transformationToEdit: - | { - transformationId: string - position: "inplace" - } - | { - position: "above" | "below" - targetId: string - } - | null -} - -export type FocusObjects = - | (typeof DEFAULT_FOCUS_OBJECTS)[number] - | (string & {}) - -export type SyncStatus = "unsaved" | "saving" | "saved" | "error" - -export interface EditorState< - Metadata extends RequiredMetadata = RequiredMetadata, -> { - currentImage: string | undefined - originalImageList: FileElement[] - imageList: string[] - transformations: Transformation[] - visibleTransformations: Record - showOriginal: boolean - signer?: Signer - signingImages: Record - signingAbortControllers: Record - signedUrlCache: Record - currentTransformKey: string - focusObjects?: ReadonlyArray - _internalState: InternalState - templateName: string - templateId: string | null - /** - * Template visibility scope. For dashboard integration this maps to: - * - true => onlyMe (private) - * - false => everyone (shared) - * - null => unknown/unloaded - */ - templateIsPrivate: boolean | null - syncStatus: SyncStatus - storageError?: string - isPristine: boolean - /** - * After a 401/403 template write failure, saves are blocked so a follow-up - * save cannot POST a duplicate after the store clears `templateId`. - */ - templateStorageWriteBlocked: boolean - - /** Versioned sync model to keep UI stable under save/edit races. */ - localChangeVersion: number - lastSyncedVersion: number - /** - * Timestamp (ms) of the last successful save to remote storage. - * Used to debounce/reset periodic auto-save scheduling. - */ - lastSavedAt: number | null - /** - * True while the transformation config sidebar form has unapplied edits (RHF isDirty). - * Used by header status and close confirmation alongside versioned unsynced state. - */ - transformationConfigFormDirty: boolean -} - -export type EditorActions< - Metadata extends RequiredMetadata = RequiredMetadata, -> = { - initialize: (initialData?: { - imageList?: Array> - signer?: Signer - focusObjects?: ReadonlyArray - templateName?: string - templateId?: string - }) => void - destroy: () => void - setCurrentImage: (imageSrc: string | undefined) => void - setImageDimensions: ( - imageSrc: string, - dimensions: { width: number; height: number } | null, - ) => void - addImage: (imageSrc: string | InputFileElement) => void - addImages: (imageSrcs: Array>) => void - removeImage: (imageSrc: string) => void - loadTemplate: (template: Omit[]) => void - moveTransformation: ( - activeId: UniqueIdentifier, - overId: UniqueIdentifier, - ) => void - toggleTransformationVisibility: (id: string) => void - addTransformation: ( - transformation: Omit, - position?: number, - ) => string - removeTransformation: (id: string) => void - updateTransformation: ( - id: string, - updatedTransformation: Omit, - ) => void - setShowOriginal: (showOriginal: boolean) => void - setTemplateName: (name: string) => void - setTemplateId: (id: string | null) => void - setTemplateIsPrivate: (isPrivate: boolean | null) => void - /** - * Sets template metadata from storage responses without bumping local version. - * Use this when hydrating from server/list responses (save success, load from library). - */ - hydrateTemplateMetadata: (meta: { - templateId: string | null - templateName: string - templateIsPrivate: boolean | null - }) => void - setSyncStatus: (status: SyncStatus, error?: string) => void - setIsPristine: (pristine: boolean) => void - bumpLocalChangeVersion: () => void - markSynced: (version?: number) => void - setLastSavedAt: (ts: number | null) => void - setTransformationConfigFormDirty: (dirty: boolean) => void - resetToNewTemplate: () => void - restoreSession: ( - state: Pick< - EditorState, - | "transformations" - | "visibleTransformations" - | "templateName" - | "templateId" - | "templateIsPrivate" - | "syncStatus" - | "isPristine" - | "localChangeVersion" - | "lastSyncedVersion" - | "lastSavedAt" - >, - ) => void - /** - * Blocks any further writes to template storage while keeping the current - * template state intact (so the user can keep viewing/editing locally). - * Intended for 401/403 write failures. - */ - blockTemplateStorageWrites: (message?: string) => void - /** - * Clears the loaded template and surfaces an error when access is revoked - * for viewing/loading the template. - */ - denyTemplateStorageAccessAndReset: (message?: string) => void - - _setSidebarState: (state: "none" | "type" | "config") => void - _setSelectedTransformationKey: (key: string | null) => void - _setTransformationToEdit: ( - transformationId: string | null, - position?: "inplace" | "above" | "below", - ) => void -} - -const initialTransformations: Transformation[] = [] - -const initialVisibleTransformations: Record = {} - -function initTransformationStates(transformations: Transformation[]) { - transformations.forEach((transformation) => { - initialVisibleTransformations[transformation.name] = true - }) -} - -initTransformationStates(initialTransformations) - -function normalizeImage( - image: string | InputFileElement, -): FileElement { - if (typeof image === "string") { - return { - url: image, - metadata: { requireSignedUrl: false } as Metadata, - imageDimensions: null, - } - } - return { - url: image.url, - metadata: image.metadata - ? { - ...image.metadata, - requireSignedUrl: image.metadata.requireSignedUrl ?? false, - } - : ({ requireSignedUrl: false } as Metadata), - imageDimensions: null, - } -} - -const DEFAULT_STATE: EditorState = { - currentImage: undefined, - originalImageList: [], - imageList: [], - transformations: initialTransformations, - visibleTransformations: initialVisibleTransformations, - showOriginal: false, - signer: undefined, - signingImages: {}, - signingAbortControllers: {}, - signedUrlCache: {}, - currentTransformKey: "", - focusObjects: undefined, - _internalState: { - sidebarState: "none", - selectedTransformationKey: null, - transformationToEdit: null, - }, - templateName: "Untitled Template", - templateId: null, - templateIsPrivate: null, - syncStatus: "unsaved", - storageError: undefined, - isPristine: true, - templateStorageWriteBlocked: false, - localChangeVersion: 0, - lastSyncedVersion: 0, - lastSavedAt: null, - transformationConfigFormDirty: false, -} - -const useEditorStore = create()( - subscribeWithSelector((set, get) => ({ - ...DEFAULT_STATE, - - initialize: (initialData) => { - const updates: Partial = {} - if (initialData?.imageList && initialData.imageList.length > 0) { - const imgs = initialData.imageList.map(normalizeImage) - updates.originalImageList = imgs - updates.imageList = imgs.map((i) => i.url) - updates.currentImage = imgs[0].url - } - if (initialData?.signer) { - updates.signer = initialData.signer - } - if (initialData?.focusObjects) { - updates.focusObjects = initialData.focusObjects - } - if (initialData?.templateName) { - updates.templateName = initialData.templateName - updates.isPristine = false - } - if (initialData?.templateId) { - updates.templateId = initialData.templateId - updates.isPristine = false - } - // If host provides a template id/name, assume we're starting from a synced template. - if (initialData?.templateId || initialData?.templateName) { - updates.syncStatus = "saved" - updates.localChangeVersion = 0 - updates.lastSyncedVersion = 0 - } - if (Object.keys(updates).length > 0) { - set(updates as EditorState) - } - }, - - destroy: () => { - set(DEFAULT_STATE) - }, - - // Actions - setCurrentImage: (imageSrc) => { - set({ currentImage: imageSrc }) - }, - - setImageDimensions: (imageSrc, imageDimensions) => { - set((state) => { - const index = state.originalImageList.findIndex( - (img) => img.url === imageSrc, - ) - if (index === -1) return state - const updatedImageList = [...state.originalImageList] - updatedImageList[index].imageDimensions = imageDimensions - return { originalImageList: updatedImageList } - }) - }, - - addImage: (imageSrc) => { - const img = normalizeImage(imageSrc) - if (!get().originalImageList.some((i) => i.url === img.url)) { - set((state) => ({ - originalImageList: [...state.originalImageList, img], - currentImage: img.url, - })) - } else { - set({ currentImage: img.url }) - } - }, - - addImages: (imageSrcs) => { - const existing = get().originalImageList - const uniqueImages = imageSrcs - .map(normalizeImage) - .filter((img) => !existing.some((i) => i.url === img.url)) - set((state) => ({ - originalImageList: [...state.originalImageList, ...uniqueImages], - })) - }, - - removeImage: (imageSrc) => { - set((state) => { - const index = state.originalImageList.findIndex( - (img) => img.url === imageSrc, - ) - // Remove the image from the list - const updatedImageList = state.originalImageList.filter( - (img) => img.url !== imageSrc, - ) - - let newCurrentImage = state.currentImage - if (state.currentImage === imageSrc) { - if (updatedImageList.length > 0) { - if (index >= updatedImageList.length) { - newCurrentImage = - updatedImageList[updatedImageList.length - 1].url - } else { - newCurrentImage = updatedImageList[index].url - } - } else { - newCurrentImage = undefined - } - } - - const updatedSigningImages = { ...state.signingImages } - delete updatedSigningImages[imageSrc] - - const updatedSigningAbortControllers = { - ...state.signingAbortControllers, - } - const controller = updatedSigningAbortControllers[imageSrc] - if (controller) { - controller.abort() - delete updatedSigningAbortControllers[imageSrc] - } - - const updatedSignedUrlCache = { ...state.signedUrlCache } - Object.keys(updatedSignedUrlCache).forEach((key) => { - if (key.startsWith(`${imageSrc}::`)) { - delete updatedSignedUrlCache[key] - } - }) - - return { - originalImageList: updatedImageList, - currentImage: newCurrentImage, - signingImages: updatedSigningImages, - signingAbortControllers: updatedSigningAbortControllers, - signedUrlCache: updatedSignedUrlCache, - } - }) - }, - - loadTemplate: (template) => { - const transformationsWithIds = template.map((transformation, index) => ({ - ...transformation, - id: `transformation-${Date.now()}-${index}`, - version: TRANSFORMATION_STATE_VERSION, - })) - - const visibleTransformations: Record = {} - transformationsWithIds.forEach((t) => { - // enabled absent or true → visible; false → hidden - visibleTransformations[t.id] = t.enabled !== false - }) - - set((state) => { - const nextVersion = bumpVersion(state.localChangeVersion) - return { - transformations: transformationsWithIds, - visibleTransformations: { - ...state.visibleTransformations, - ...visibleTransformations, - }, - _internalState: { - sidebarState: "none", - selectedTransformationKey: null, - transformationToEdit: null, - }, - isPristine: false, - // Loading an existing template implies we're in sync with storage. - syncStatus: "saved", - localChangeVersion: nextVersion, - lastSyncedVersion: nextVersion, - templateStorageWriteBlocked: false, - transformationConfigFormDirty: false, - } - }) - }, - - moveTransformation: (activeId, overId) => { - set((state) => { - const activeIdStr = String(activeId) - const overIdStr = String(overId) - const oldIndex = state.transformations.findIndex( - (item) => item.id === activeIdStr, - ) - const newIndex = state.transformations.findIndex( - (item) => item.id === overIdStr, - ) - - if (oldIndex !== -1 && newIndex !== -1) { - const updatedTransformations = [...state.transformations] - const [removed] = updatedTransformations.splice(oldIndex, 1) - updatedTransformations.splice(newIndex, 0, removed) - - return { - transformations: updatedTransformations, - isPristine: false, - localChangeVersion: bumpVersion(state.localChangeVersion), - } - } - return { transformations: state.transformations } - }) - }, - - toggleTransformationVisibility: (id) => { - set((state) => { - const newVisible = !state.visibleTransformations[id] - return { - visibleTransformations: { - ...state.visibleTransformations, - [id]: newVisible, - }, - // Sync enabled into the transformations array so the auto-save - // subscription (which watches `transformations`) fires, and so the - // visibility state is persisted alongside the transformation data. - transformations: state.transformations.map((t) => - t.id === id ? { ...t, enabled: newVisible } : t, - ), - isPristine: false, - localChangeVersion: bumpVersion(state.localChangeVersion), - } - }) - }, - - addTransformation: (transformation, position) => { - const id = `transformation-${Date.now()}` - - if (typeof position === "number") { - set((state) => { - const transformations = [...state.transformations] - transformations.splice(position, 0, { ...transformation, id }) - return { - transformations, - visibleTransformations: { - ...state.visibleTransformations, - [id]: true, - }, - isPristine: false, - localChangeVersion: bumpVersion(state.localChangeVersion), - } - }) - - return id - } - - set((state) => { - return { - transformations: [ - ...state.transformations, - { ...transformation, id }, - ], - visibleTransformations: { - ...state.visibleTransformations, - [id]: true, - }, - isPristine: false, - localChangeVersion: bumpVersion(state.localChangeVersion), - } - }) - - return id - }, - - removeTransformation: (id) => { - set((state) => ({ - transformations: state.transformations.filter( - (transformation) => transformation.id !== id, - ), - isPristine: false, - localChangeVersion: bumpVersion(state.localChangeVersion), - })) - }, - - updateTransformation: ( - id: string, - updatedTransformation: Transformation, - ) => { - set((state) => ({ - transformations: state.transformations.map((t) => - t.id === id ? { ...updatedTransformation, id } : t, - ), - isPristine: false, - localChangeVersion: bumpVersion(state.localChangeVersion), - })) - }, - - setShowOriginal: (showOriginal) => { - set(() => ({ - showOriginal, - })) - }, - - setTemplateName: (name) => { - set((state) => ({ - templateName: name, - isPristine: state.templateName === name ? state.isPristine : false, - localChangeVersion: - state.templateName === name - ? state.localChangeVersion - : bumpVersion(state.localChangeVersion), - })) - }, - - setTemplateId: (id) => { - set({ templateId: id }) - }, - - setTemplateIsPrivate: (isPrivate) => { - set((state) => ({ - templateIsPrivate: isPrivate, - localChangeVersion: - state.templateIsPrivate === isPrivate - ? state.localChangeVersion - : bumpVersion(state.localChangeVersion), - })) - }, - - hydrateTemplateMetadata: ({ - templateId, - templateName, - templateIsPrivate, - }) => { - set(() => ({ - templateId, - templateName, - templateIsPrivate, - })) - }, - - setSyncStatus: (status, error?) => { - set({ syncStatus: status, storageError: error }) - }, - - bumpLocalChangeVersion: () => { - set((state) => ({ - localChangeVersion: bumpVersion(state.localChangeVersion), - })) - }, - - markSynced: (version) => { - set((state) => ({ - lastSyncedVersion: version ?? state.localChangeVersion, - })) - }, - - setLastSavedAt: (ts) => { - set({ lastSavedAt: ts }) - }, - - setTransformationConfigFormDirty: (dirty) => { - set({ transformationConfigFormDirty: dirty }) - }, - - setIsPristine: (pristine: boolean) => { - set({ isPristine: pristine }) - }, - - resetToNewTemplate: () => { - set({ - transformations: [], - visibleTransformations: {}, - templateName: "Untitled Template", - templateId: null, - templateIsPrivate: null, - syncStatus: "unsaved", - storageError: undefined, - isPristine: true, - templateStorageWriteBlocked: false, - localChangeVersion: 0, - lastSyncedVersion: 0, - lastSavedAt: null, - transformationConfigFormDirty: false, - _internalState: { - sidebarState: "none", - selectedTransformationKey: null, - transformationToEdit: null, - }, - }) - }, - - restoreSession: (persisted) => { - set(() => ({ - transformations: persisted.transformations, - visibleTransformations: persisted.visibleTransformations, - templateName: persisted.templateName, - templateId: persisted.templateId, - templateIsPrivate: persisted.templateIsPrivate, - syncStatus: persisted.syncStatus, - isPristine: persisted.isPristine, - localChangeVersion: persisted.localChangeVersion, - lastSyncedVersion: persisted.lastSyncedVersion, - lastSavedAt: persisted.lastSavedAt, - storageError: undefined, - templateStorageWriteBlocked: false, - transformationConfigFormDirty: false, - _internalState: { - sidebarState: "none", - selectedTransformationKey: null, - transformationToEdit: null, - }, - })) - }, - - blockTemplateStorageWrites: (message) => { - set({ - syncStatus: "error", - storageError: message ?? "You no longer have access to this template.", - templateStorageWriteBlocked: true, - }) - }, - - denyTemplateStorageAccessAndReset: (message) => { - set({ - transformations: [], - visibleTransformations: {}, - templateName: "Untitled Template", - templateId: null, - templateIsPrivate: null, - syncStatus: "error", - storageError: message ?? "You no longer have access to this template.", - isPristine: true, - templateStorageWriteBlocked: true, - localChangeVersion: 0, - lastSyncedVersion: 0, - lastSavedAt: null, - transformationConfigFormDirty: false, - _internalState: { - sidebarState: "none", - selectedTransformationKey: null, - transformationToEdit: null, - }, - }) - }, - - _setSidebarState: (sidebarState) => { - set((state) => ({ - _internalState: { ...state._internalState, sidebarState }, - })) - }, - - _setSelectedTransformationKey: (key) => { - set((state) => ({ - _internalState: { - ...state._internalState, - selectedTransformationKey: key, - }, - })) - }, - - _setTransformationToEdit: ( - transformationOrTargetId: string, - position = "inplace", - ) => { - if (!transformationOrTargetId) { - set((state) => ({ - _internalState: { - ...state._internalState, - transformationToEdit: null, - }, - })) - } else if (position === "inplace") { - set((state) => ({ - _internalState: { - ...state._internalState, - transformationToEdit: { - transformationId: transformationOrTargetId, - position, - }, - }, - })) - } else if (position === "above") { - set((state) => ({ - _internalState: { - ...state._internalState, - transformationToEdit: { - position, - targetId: transformationOrTargetId, - }, - }, - })) - } else if (position === "below") { - set((state) => ({ - _internalState: { - ...state._internalState, - transformationToEdit: { - position, - targetId: transformationOrTargetId, - }, - }, - })) - } - }, - })), -) - -const replaceImagePathPlaceholders = ( - transformations: IKTransformation[], - imagePath: string, -): IKTransformation[] => { - return transformations.map((transformation) => { - const clonedTransformation = { ...transformation } - - if ( - typeof clonedTransformation.raw === "string" && - clonedTransformation.raw.includes("__IMAGE_PATH__") - ) { - clonedTransformation.raw = clonedTransformation.raw.replace( - /__IMAGE_PATH__/g, - imagePath, - ) - } - - return clonedTransformation - }) -} - -const calculateImageList = ( - imageList: FileElement[], - transformations: Transformation[], - visibleTransformations: Record, - showOriginal: boolean, - signer: Signer | undefined, - activeImageIndex: number, - signedUrlCache: Record, -) => { - const IKTransformations = transformations - .filter((transformation) => visibleTransformations[transformation.id]) - .map((transformation) => { - const t = transformationSchema - .find((schema) => schema.key === transformation.key.split("-")[0]) - ?.items.find((item) => item.key === transformation.key) - - const groupedTransforms: Record< - string, - { - fields: Array<{ - name: string - value: unknown - field: TransformationField - }> - transformationKey: string - } - > = {} - - if (t?.transformations) { - t.transformations.forEach((transform) => { - if ( - transform.transformationGroup && - transform.isVisible?.( - transformation.value as Record, - ) !== false - ) { - const value = (transformation.value as Record)[ - transform.name - ] - if (value !== undefined && value !== "") { - if (!groupedTransforms[transform.transformationGroup]) { - groupedTransforms[transform.transformationGroup] = { - fields: [], - transformationKey: - transform.transformationKey || transform.name, - } - } - groupedTransforms[transform.transformationGroup].fields.push({ - name: transform.name, - value, - field: transform, - }) - } - } - }) - } - - const transforms: Record = Object.fromEntries( - Object.entries(transformation.value) - .map(([key, value]) => { - const transform = t?.transformations.find( - (field) => field.name === key, - ) - - if (transform?.transformationGroup) { - return [] - } - - if ( - transform?.isTransformation && - (transform.isVisible?.( - transformation.value as Record, - ) ?? - true) && - value !== "" - ) { - return [transform.transformationKey ?? key, value] - } - return [] - }) - .filter((entry) => entry.length > 0), - ) - - for (const groupName in groupedTransforms) { - const group = groupedTransforms[groupName] - const formatter = transformationFormatters[groupName] - - if (formatter) { - const groupValues = {} as Record - group.fields.forEach((f) => { - groupValues[f.name] = f.value - }) - - formatter(groupValues, transforms) - } - } - - // Special handling for resize_and_crop transformation - let defaultTransformation = t?.defaultTransformation || {} - if (transformation.key === "resize_and_crop-resize_and_crop") { - const value = transformation.value as Record - // Only add crop/cropMode when both width and height and mode are set - if (value.width && value.height && value.mode) { - defaultTransformation = getDefaultTransformationFromMode( - value.mode as string, - ) - } else { - defaultTransformation = {} - } - } - - return { - ...defaultTransformation, - ...transforms, - } - }) - - const transformKey = showOriginal - ? "original" - : JSON.stringify(IKTransformations) - - const imgs: string[] = [] - const toSign: Array<{ - index: number - request: SignerRequest - cacheKey: string - }> = [] - - imageList.forEach((img, index) => { - // Replace any __IMAGE_PATH__ placeholders with actual image path for this specific image - const imagePath = extractImagePath(img.url) - const transformationsForImage = showOriginal - ? [] - : replaceImagePathPlaceholders(IKTransformations, imagePath) - - const req = { - url: img.url, - transformation: transformationsForImage, - metadata: img.metadata, - } - - if (req.transformation.length === 0) { - imgs[index] = req.url - return - } - - if (req.metadata.requireSignedUrl && signer) { - const imageTransformKey = JSON.stringify(req.transformation) - const cacheKey = `${req.url}::${imageTransformKey}` - const cached = signedUrlCache[cacheKey] - if (cached) { - imgs[index] = cached - } else { - imgs[index] = req.url - toSign.push({ - index, - request: { - ...req, - transformation: buildTransformationString(req.transformation), - }, - cacheKey, - }) - } - return - } - - imgs[index] = buildSrc({ - src: req.url, - urlEndpoint: "does-not-matter", - transformation: req.transformation, - }) - }) - - return { imgs, activeImageIndex, toSign, transformKey } -} - -function recomputeImages() { - const state = useEditorStore.getState() - - let currentIndex = 0 - if (state.currentImage) { - const originalIndex = state.originalImageList.findIndex( - (img) => img.url === state.currentImage, - ) - - if (originalIndex >= 0) { - currentIndex = originalIndex - } else { - const imageListIndex = state.imageList.findIndex( - (img) => img === state.currentImage, - ) - currentIndex = Math.max(imageListIndex, 0) - } - } - - const { imgs, activeImageIndex, toSign, transformKey } = calculateImageList( - state.originalImageList, - state.transformations, - state.visibleTransformations, - state.showOriginal, - state.signer, - currentIndex, - state.signedUrlCache, - ) - - const transformationsChanged = transformKey !== state.currentTransformKey - if (transformationsChanged) { - Object.values(state.signingAbortControllers).forEach((c) => c.abort()) - useEditorStore.setState({ signingImages: {}, signingAbortControllers: {} }) - } - - useEditorStore.setState({ - imageList: imgs, - currentImage: imgs[activeImageIndex], - currentTransformKey: transformKey, - }) - - const signer = state.signer - if (signer && toSign.length > 0) { - toSign.forEach(({ index, request, cacheKey }) => { - const existing = - useEditorStore.getState().signingAbortControllers[request.url] - if (existing) existing.abort() - const controller = new AbortController() - useEditorStore.setState((s) => ({ - signingImages: { ...s.signingImages, [request.url]: true }, - signingAbortControllers: { - ...s.signingAbortControllers, - [request.url]: controller, - }, - })) - signer(request, controller) - .then((signedUrl) => { - useEditorStore.setState((s) => { - const updatedImgs = [...s.imageList] - updatedImgs[index] = signedUrl - const wasCurrent = s.currentImage === s.imageList[index] - return { - imageList: updatedImgs, - currentImage: wasCurrent ? signedUrl : s.currentImage, - signedUrlCache: { - ...s.signedUrlCache, - [cacheKey]: signedUrl, - }, - } - }) - }) - .catch((err) => { - if ((err as DOMException)?.name !== "AbortError") { - // eslint-disable-next-line no-console - console.error(err) - } - }) - .finally(() => { - useEditorStore.setState((s) => { - const updatedSigningImages = { ...s.signingImages } - delete updatedSigningImages[request.url] - const updatedControllers = { ...s.signingAbortControllers } - delete updatedControllers[request.url] - return { - signingImages: updatedSigningImages, - signingAbortControllers: updatedControllers, - } - }) - }) - }) - } -} - -useEditorStore.subscribe( - (state) => state.showOriginal, - () => { - recomputeImages() - }, -) - -useEditorStore.subscribe( - (state) => state.transformations, - () => { - recomputeImages() - }, -) - -useEditorStore.subscribe( - (state) => state.visibleTransformations, - () => { - recomputeImages() - }, -) - -useEditorStore.subscribe( - (state) => state.originalImageList, - () => { - recomputeImages() - }, -) - -useEditorStore.subscribe( - (state) => state.signer, - () => { - recomputeImages() - }, -) - -export { useEditorStore } diff --git a/packages/imagekit-editor-dev/src/store/createEditorStore.test.ts b/packages/imagekit-editor-dev/src/store/createEditorStore.test.ts new file mode 100644 index 0000000..fbaf228 --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/createEditorStore.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { useEditorStore } from "." +import { borderTransform, resizeTransform, SAMPLE_URL } from "./test/helpers" + +beforeEach(() => { + useEditorStore.getState().destroy() + vi.restoreAllMocks() +}) + +afterEach(() => { + useEditorStore.getState().destroy() +}) + +describe("createEditorStore (image pipeline subscriptions)", () => { + it("computes imageList after template load", async () => { + useEditorStore.getState().initialize({ imageList: [SAMPLE_URL] }) + useEditorStore.getState().loadTemplate([borderTransform()]) + await vi.waitFor(() => { + expect(useEditorStore.getState().imageList[0]).not.toBe(SAMPLE_URL) + }) + expect(useEditorStore.getState().currentTransformKey).not.toBe("") + }) + + it("showOriginal passes raw url without transforms", async () => { + useEditorStore.getState().initialize({ imageList: [SAMPLE_URL] }) + useEditorStore.getState().loadTemplate([borderTransform()]) + await vi.waitFor(() => + expect(useEditorStore.getState().currentTransformKey).not.toBe(""), + ) + useEditorStore.getState().setShowOriginal(true) + await vi.waitFor(() => { + expect(useEditorStore.getState().currentTransformKey).toBe("original") + }) + expect(useEditorStore.getState().imageList[0]).toBe(SAMPLE_URL) + }) + + it("signed URL path invokes signer and caches result", async () => { + const signer = vi.fn().mockResolvedValue("https://signed.example/img") + useEditorStore.getState().initialize({ + imageList: [ + { + url: SAMPLE_URL, + metadata: { requireSignedUrl: true }, + }, + ], + signer, + }) + useEditorStore.getState().loadTemplate([borderTransform()]) + await vi.waitFor(() => expect(signer).toHaveBeenCalled()) + await vi.waitFor(() => { + expect(useEditorStore.getState().imageList[0]).toBe( + "https://signed.example/img", + ) + }) + const cacheKeys = Object.keys(useEditorStore.getState().signedUrlCache) + expect(cacheKeys.length).toBeGreaterThan(0) + }) + + it("aborts pending signers when transform stack identity changes", async () => { + const signer = vi + .fn() + .mockImplementation(() => new Promise(() => {})) + useEditorStore.getState().initialize({ + imageList: [{ url: SAMPLE_URL, metadata: { requireSignedUrl: true } }], + signer, + }) + useEditorStore.getState().loadTemplate([borderTransform()]) + await vi.waitFor(() => expect(signer).toHaveBeenCalled()) + const urls = Object.keys(useEditorStore.getState().signingAbortControllers) + expect(urls.length).toBeGreaterThan(0) + const controller = + useEditorStore.getState().signingAbortControllers[urls[0]] + const spy = vi.spyOn(controller, "abort") + useEditorStore.getState().loadTemplate([resizeTransform()]) + await vi.waitFor(() => expect(spy).toHaveBeenCalled()) + }) + + it("signer rejection logs non-abort errors", async () => { + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const signer = vi.fn().mockRejectedValue(new Error("fail")) + useEditorStore.getState().initialize({ + imageList: [ + { + url: SAMPLE_URL, + metadata: { requireSignedUrl: true }, + }, + ], + signer, + }) + useEditorStore.getState().loadTemplate([borderTransform()]) + await vi.waitFor(() => expect(errSpy).toHaveBeenCalled()) + errSpy.mockRestore() + }) +}) diff --git a/packages/imagekit-editor-dev/src/store/createEditorStore.ts b/packages/imagekit-editor-dev/src/store/createEditorStore.ts new file mode 100644 index 0000000..f0f7321 --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/createEditorStore.ts @@ -0,0 +1,166 @@ +import type { StoreApi, UseBoundStore } from "zustand" +import { create } from "zustand" +import { subscribeWithSelector } from "zustand/middleware" +import { DEFAULT_STATE } from "./initialState" +import { calculateImageList } from "./pure/calculateImageList" +import { createImagesSlice } from "./slices/imagesSlice" +import { createLifecycleSlice } from "./slices/lifecycleSlice" +import { createSidebarSlice } from "./slices/sidebarSlice" +import { createSyncSlice } from "./slices/syncSlice" +import { createTemplateSlice } from "./slices/templateSlice" +import { createTransformationsSlice } from "./slices/transformationsSlice" +import type { EditorStore } from "./types" + +export function createEditorStore(): UseBoundStore> { + const useEditorStore = create()( + subscribeWithSelector((set, get, store) => ({ + ...DEFAULT_STATE, + ...createLifecycleSlice(set, get, store), + ...createImagesSlice(set, get, store), + ...createTransformationsSlice(set, get, store), + ...createTemplateSlice(set, get, store), + ...createSyncSlice(set, get, store), + ...createSidebarSlice(set, get, store), + })), + ) + + /** + * Recomputes the image list based on the current state of the store. + * This is used to ensure that the image list is always up to date based + * on the current state of the store. + */ + function recomputeImages() { + const state = useEditorStore.getState() + + let currentIndex = 0 + if (state.currentImage) { + const originalIndex = state.originalImageList.findIndex( + (img) => img.url === state.currentImage, + ) + + if (originalIndex >= 0) { + currentIndex = originalIndex + } else { + const imageListIndex = state.imageList.findIndex( + (img) => img === state.currentImage, + ) + currentIndex = Math.max(imageListIndex, 0) + } + } + + const { imgs, activeImageIndex, toSign, transformKey } = calculateImageList( + state.originalImageList, + state.transformations, + state.visibleTransformations, + state.showOriginal, + state.signer, + currentIndex, + state.signedUrlCache, + ) + + const transformationsChanged = transformKey !== state.currentTransformKey + if (transformationsChanged) { + Object.values(state.signingAbortControllers).forEach((c) => c.abort()) + useEditorStore.setState({ + signingImages: {}, + signingAbortControllers: {}, + }) + } + + useEditorStore.setState({ + imageList: imgs, + currentImage: imgs[activeImageIndex], + currentTransformKey: transformKey, + }) + + const signer = state.signer + if (signer && toSign.length > 0) { + toSign.forEach(({ index, request, cacheKey }) => { + const existing = + useEditorStore.getState().signingAbortControllers[request.url] + if (existing) existing.abort() + const controller = new AbortController() + useEditorStore.setState((s) => ({ + signingImages: { ...s.signingImages, [request.url]: true }, + signingAbortControllers: { + ...s.signingAbortControllers, + [request.url]: controller, + }, + })) + signer(request, controller) + .then((signedUrl) => { + useEditorStore.setState((s) => { + const updatedImgs = [...s.imageList] + updatedImgs[index] = signedUrl + const wasCurrent = s.currentImage === s.imageList[index] + return { + imageList: updatedImgs, + currentImage: wasCurrent ? signedUrl : s.currentImage, + signedUrlCache: { + ...s.signedUrlCache, + [cacheKey]: signedUrl, + }, + } + }) + }) + .catch((err) => { + if ((err as DOMException)?.name !== "AbortError") { + // eslint-disable-next-line no-console + console.error(err) + } + }) + .finally(() => { + useEditorStore.setState((s) => { + const updatedSigningImages = { ...s.signingImages } + delete updatedSigningImages[request.url] + const updatedControllers = { ...s.signingAbortControllers } + delete updatedControllers[request.url] + return { + signingImages: updatedSigningImages, + signingAbortControllers: updatedControllers, + } + }) + }) + }) + } + } + + useEditorStore.subscribe( + (state) => state.showOriginal, + () => { + recomputeImages() + }, + ) + + useEditorStore.subscribe( + (state) => state.transformations, + () => { + recomputeImages() + }, + ) + + useEditorStore.subscribe( + (state) => state.visibleTransformations, + () => { + recomputeImages() + }, + ) + + useEditorStore.subscribe( + (state) => state.originalImageList, + () => { + recomputeImages() + }, + ) + + useEditorStore.subscribe( + (state) => state.signer, + () => { + recomputeImages() + }, + ) + + return useEditorStore +} + +export const useEditorStore = createEditorStore() diff --git a/packages/imagekit-editor-dev/src/store/index.ts b/packages/imagekit-editor-dev/src/store/index.ts new file mode 100644 index 0000000..493fd03 --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/index.ts @@ -0,0 +1,3 @@ +export { createEditorStore, useEditorStore } from "./createEditorStore" +export { DEFAULT_STATE } from "./initialState" +export * from "./types" diff --git a/packages/imagekit-editor-dev/src/store/initialState.test.ts b/packages/imagekit-editor-dev/src/store/initialState.test.ts new file mode 100644 index 0000000..ac83f03 --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/initialState.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest" +import { DEFAULT_STATE as DEFAULT_STATE_FROM_STORE } from "." +import { DEFAULT_STATE as DEFAULT_STATE_FROM_MODULE } from "./initialState" + +describe("DEFAULT_STATE", () => { + it("is the same reference when imported from ./store or ./store/initialState", () => { + expect(DEFAULT_STATE_FROM_STORE).toBe(DEFAULT_STATE_FROM_MODULE) + }) + + it("exports the blank-editor baseline from the store barrel", () => { + const DEFAULT_STATE = DEFAULT_STATE_FROM_STORE + expect(DEFAULT_STATE.currentImage).toBeUndefined() + expect(DEFAULT_STATE.originalImageList).toEqual([]) + expect(DEFAULT_STATE.imageList).toEqual([]) + expect(DEFAULT_STATE.transformations).toEqual([]) + expect(DEFAULT_STATE.visibleTransformations).toEqual({}) + expect(DEFAULT_STATE.showOriginal).toBe(false) + expect(DEFAULT_STATE.signer).toBeUndefined() + expect(DEFAULT_STATE.signingImages).toEqual({}) + expect(DEFAULT_STATE.signingAbortControllers).toEqual({}) + expect(DEFAULT_STATE.signedUrlCache).toEqual({}) + expect(DEFAULT_STATE.currentTransformKey).toBe("") + expect(DEFAULT_STATE.focusObjects).toBeUndefined() + expect(DEFAULT_STATE._internalState).toEqual({ + sidebarState: "none", + selectedTransformationKey: null, + transformationToEdit: null, + }) + expect(DEFAULT_STATE.templateName).toBe("Untitled Template") + expect(DEFAULT_STATE.templateId).toBeNull() + expect(DEFAULT_STATE.templateIsPrivate).toBeNull() + expect(DEFAULT_STATE.syncStatus).toBe("unsaved") + expect(DEFAULT_STATE.storageError).toBeUndefined() + expect(DEFAULT_STATE.isPristine).toBe(true) + expect(DEFAULT_STATE.templateStorageWriteBlocked).toBe(false) + expect(DEFAULT_STATE.localChangeVersion).toBe(0) + expect(DEFAULT_STATE.lastSyncedVersion).toBe(0) + expect(DEFAULT_STATE.lastSavedAt).toBeNull() + expect(DEFAULT_STATE.transformationConfigFormDirty).toBe(false) + }) +}) diff --git a/packages/imagekit-editor-dev/src/store/initialState.ts b/packages/imagekit-editor-dev/src/store/initialState.ts new file mode 100644 index 0000000..4b508e0 --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/initialState.ts @@ -0,0 +1,45 @@ +import type { EditorState, Transformation } from "./types" + +const initialTransformations: Transformation[] = [] + +const initialVisibleTransformations: Record = {} + +function initTransformationStates(transformations: Transformation[]) { + transformations.forEach((transformation) => { + initialVisibleTransformations[transformation.name] = true + }) +} + +initTransformationStates(initialTransformations) + +/** Default editor store snapshot used on boot and `destroy()`. */ +export const DEFAULT_STATE: EditorState = { + currentImage: undefined, + originalImageList: [], + imageList: [], + transformations: initialTransformations, + visibleTransformations: initialVisibleTransformations, + showOriginal: false, + signer: undefined, + signingImages: {}, + signingAbortControllers: {}, + signedUrlCache: {}, + currentTransformKey: "", + focusObjects: undefined, + _internalState: { + sidebarState: "none", + selectedTransformationKey: null, + transformationToEdit: null, + }, + templateName: "Untitled Template", + templateId: null, + templateIsPrivate: null, + syncStatus: "unsaved", + storageError: undefined, + isPristine: true, + templateStorageWriteBlocked: false, + localChangeVersion: 0, + lastSyncedVersion: 0, + lastSavedAt: null, + transformationConfigFormDirty: false, +} diff --git a/packages/imagekit-editor-dev/src/store/pure/calculateImageList.test.ts b/packages/imagekit-editor-dev/src/store/pure/calculateImageList.test.ts new file mode 100644 index 0000000..6e93aab --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/pure/calculateImageList.test.ts @@ -0,0 +1,131 @@ +import type { Transformation as IKTransformation } from "@imagekit/javascript" +import { describe, expect, it, vi } from "vitest" +import { + type FileElement, + TRANSFORMATION_STATE_VERSION, + type Transformation, +} from "../types" +import { + calculateImageList, + replaceImagePathPlaceholders, +} from "./calculateImageList" + +const SAMPLE_URL = "https://ik.imagekit.io/demo/tr:f-auto/sample.jpg" + +function borderTransformation(id: string): Transformation { + return { + id, + key: "adjust-border", + name: "Border", + type: "transformation", + value: { borderWidth: 2, borderColor: "#000000" }, + version: TRANSFORMATION_STATE_VERSION, + } +} + +describe("replaceImagePathPlaceholders", () => { + it("returns clones with __IMAGE_PATH__ replaced", () => { + const input: IKTransformation[] = [ + { raw: "tr:w-100,l-image,i-__IMAGE_PATH__,l-end" } as IKTransformation, + ] + const out = replaceImagePathPlaceholders(input, "my-path") + expect(out[0].raw).toBe("tr:w-100,l-image,i-my-path,l-end") + }) + + it("leaves transformations without placeholder unchanged", () => { + const input: IKTransformation[] = [{ w: 50 } as IKTransformation] + expect(replaceImagePathPlaceholders(input, "p")).toEqual(input) + }) +}) + +describe("calculateImageList", () => { + it("uses original transform key when showOriginal is true", () => { + const img: FileElement = { + url: SAMPLE_URL, + metadata: { requireSignedUrl: false }, + imageDimensions: null, + } + const { transformKey, imgs } = calculateImageList( + [img], + [borderTransformation("a")], + { a: true }, + true, + undefined, + 0, + {}, + ) + expect(transformKey).toBe("original") + expect(imgs[0]).toBe(SAMPLE_URL) + }) + + it("builds transformed URLs when not original", () => { + const img: FileElement = { + url: SAMPLE_URL, + metadata: { requireSignedUrl: false }, + imageDimensions: null, + } + const { transformKey, imgs } = calculateImageList( + [img], + [borderTransformation("b")], + { b: true }, + false, + undefined, + 0, + {}, + ) + expect(transformKey).not.toBe("original") + expect(imgs[0]).not.toBe(SAMPLE_URL) + expect(imgs[0]).toContain("ik.imagekit.io") + }) + + it("returns raw url when transformation chain is empty after filtering", () => { + const img: FileElement = { + url: SAMPLE_URL, + metadata: { requireSignedUrl: false }, + imageDimensions: null, + } + const { imgs } = calculateImageList( + [img], + [borderTransformation("hidden")], + { hidden: false }, + false, + undefined, + 0, + {}, + ) + expect(imgs[0]).toBe(SAMPLE_URL) + }) + + it("uses signedUrlCache when cache key matches", () => { + const img: FileElement = { + url: SAMPLE_URL, + metadata: { requireSignedUrl: true }, + imageDimensions: null, + } + const t = borderTransformation("c") + const signer = vi.fn() + const first = calculateImageList( + [img], + [t], + { c: true }, + false, + signer, + 0, + {}, + ) + expect(first.toSign).toHaveLength(1) + const cacheKey = first.toSign[0].cacheKey + + const second = calculateImageList( + [img], + [t], + { c: true }, + false, + signer, + 0, + { [cacheKey]: "https://signed-cache.example/img" }, + ) + expect(second.toSign).toHaveLength(0) + expect(second.imgs[0]).toBe("https://signed-cache.example/img") + }) +}) diff --git a/packages/imagekit-editor-dev/src/store/pure/calculateImageList.ts b/packages/imagekit-editor-dev/src/store/pure/calculateImageList.ts new file mode 100644 index 0000000..0607b23 --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/pure/calculateImageList.ts @@ -0,0 +1,215 @@ +import { + buildSrc, + buildTransformationString, + type Transformation as IKTransformation, +} from "@imagekit/javascript" +import { + getDefaultTransformationFromMode, + type TransformationField, + transformationFormatters, + transformationSchema, +} from "../../schema" +import { extractImagePath } from "../../utils" +import type { + FileElement, + Signer, + SignerRequest, + Transformation, +} from "../types" + +export function replaceImagePathPlaceholders( + transformations: IKTransformation[], + imagePath: string, +): IKTransformation[] { + return transformations.map((transformation) => { + const clonedTransformation = { ...transformation } + + if ( + typeof clonedTransformation.raw === "string" && + clonedTransformation.raw.includes("__IMAGE_PATH__") + ) { + clonedTransformation.raw = clonedTransformation.raw.replace( + /__IMAGE_PATH__/g, + imagePath, + ) + } + + return clonedTransformation + }) +} + +export function calculateImageList( + imageList: FileElement[], + transformations: Transformation[], + visibleTransformations: Record, + showOriginal: boolean, + signer: Signer | undefined, + activeImageIndex: number, + signedUrlCache: Record, +) { + const IKTransformations = transformations + .filter((transformation) => visibleTransformations[transformation.id]) + .map((transformation) => { + const t = transformationSchema + .find((schema) => schema.key === transformation.key.split("-")[0]) + ?.items.find((item) => item.key === transformation.key) + + const groupedTransforms: Record< + string, + { + fields: Array<{ + name: string + value: unknown + field: TransformationField + }> + transformationKey: string + } + > = {} + + if (t?.transformations) { + t.transformations.forEach((transform) => { + if ( + transform.transformationGroup && + transform.isVisible?.( + transformation.value as Record, + ) !== false + ) { + const value = (transformation.value as Record)[ + transform.name + ] + if (value !== undefined && value !== "") { + if (!groupedTransforms[transform.transformationGroup]) { + groupedTransforms[transform.transformationGroup] = { + fields: [], + transformationKey: + transform.transformationKey || transform.name, + } + } + groupedTransforms[transform.transformationGroup].fields.push({ + name: transform.name, + value, + field: transform, + }) + } + } + }) + } + + const transforms: Record = Object.fromEntries( + Object.entries(transformation.value) + .map(([key, value]) => { + const transform = t?.transformations.find( + (field) => field.name === key, + ) + + if (transform?.transformationGroup) { + return [] + } + + if ( + transform?.isTransformation && + (transform.isVisible?.( + transformation.value as Record, + ) ?? + true) && + value !== "" + ) { + return [transform.transformationKey ?? key, value] + } + return [] + }) + .filter((entry) => entry.length > 0), + ) + + for (const groupName in groupedTransforms) { + const group = groupedTransforms[groupName] + const formatter = transformationFormatters[groupName] + + if (formatter) { + const groupValues = {} as Record + group.fields.forEach((f) => { + groupValues[f.name] = f.value + }) + + formatter(groupValues, transforms) + } + } + + // Special handling for resize_and_crop transformation + let defaultTransformation = t?.defaultTransformation || {} + if (transformation.key === "resize_and_crop-resize_and_crop") { + const value = transformation.value as Record + // Only add crop/cropMode when both width and height and mode are set + if (value.width && value.height && value.mode) { + defaultTransformation = getDefaultTransformationFromMode( + value.mode as string, + ) + } else { + defaultTransformation = {} + } + } + + return { + ...defaultTransformation, + ...transforms, + } + }) + + const transformKey = showOriginal + ? "original" + : JSON.stringify(IKTransformations) + + const imgs: string[] = [] + const toSign: Array<{ + index: number + request: SignerRequest + cacheKey: string + }> = [] + + imageList.forEach((img, index) => { + // Replace any __IMAGE_PATH__ placeholders with actual image path for this specific image + const imagePath = extractImagePath(img.url) + const transformationsForImage = showOriginal + ? [] + : replaceImagePathPlaceholders(IKTransformations, imagePath) + + const req = { + url: img.url, + transformation: transformationsForImage, + metadata: img.metadata, + } + + if (req.transformation.length === 0) { + imgs[index] = req.url + return + } + + if (req.metadata.requireSignedUrl && signer) { + const imageTransformKey = JSON.stringify(req.transformation) + const cacheKey = `${req.url}::${imageTransformKey}` + const cached = signedUrlCache[cacheKey] + if (cached) { + imgs[index] = cached + } else { + imgs[index] = req.url + toSign.push({ + index, + request: { + ...req, + transformation: buildTransformationString(req.transformation), + }, + cacheKey, + }) + } + return + } + + imgs[index] = buildSrc({ + src: req.url, + urlEndpoint: "does-not-matter", + transformation: req.transformation, + }) + }) + + return { imgs, activeImageIndex, toSign, transformKey } +} diff --git a/packages/imagekit-editor-dev/src/store/pure/normalizeImage.test.ts b/packages/imagekit-editor-dev/src/store/pure/normalizeImage.test.ts new file mode 100644 index 0000000..16fe957 --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/pure/normalizeImage.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest" +import { normalizeImage } from "./normalizeImage" + +describe("normalizeImage", () => { + it("maps string url to FileElement with unsigned metadata", () => { + const el = normalizeImage("https://cdn.example.com/a.jpg") + expect(el.url).toBe("https://cdn.example.com/a.jpg") + expect(el.imageDimensions).toBeNull() + expect(el.metadata.requireSignedUrl).toBe(false) + }) + + it("preserves and normalizes metadata on InputFileElement", () => { + const el = normalizeImage({ + url: "https://x.com/i.png", + metadata: { requireSignedUrl: true }, + }) + expect(el.metadata.requireSignedUrl).toBe(true) + }) + + it("defaults requireSignedUrl when metadata object is missing optional normalization path", () => { + const el = normalizeImage({ + url: "https://x.com/i.png", + metadata: { requireSignedUrl: false }, + }) + expect(el.metadata.requireSignedUrl).toBe(false) + }) + + it("defaults metadata when missing on object input (runtime)", () => { + const el = normalizeImage({ + url: "https://x.com/i.png", + // @ts-expect-error intentional loose payload + metadata: undefined, + }) + expect(el.metadata.requireSignedUrl).toBe(false) + }) +}) diff --git a/packages/imagekit-editor-dev/src/store/pure/normalizeImage.ts b/packages/imagekit-editor-dev/src/store/pure/normalizeImage.ts new file mode 100644 index 0000000..af6cd94 --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/pure/normalizeImage.ts @@ -0,0 +1,23 @@ +import type { FileElement, InputFileElement, RequiredMetadata } from "../types" + +export function normalizeImage< + Metadata extends RequiredMetadata = RequiredMetadata, +>(image: string | InputFileElement): FileElement { + if (typeof image === "string") { + return { + url: image, + metadata: { requireSignedUrl: false } as Metadata, + imageDimensions: null, + } + } + return { + url: image.url, + metadata: image.metadata + ? { + ...image.metadata, + requireSignedUrl: image.metadata.requireSignedUrl ?? false, + } + : ({ requireSignedUrl: false } as Metadata), + imageDimensions: null, + } +} diff --git a/packages/imagekit-editor-dev/src/store/slices/imagesSlice.test.ts b/packages/imagekit-editor-dev/src/store/slices/imagesSlice.test.ts new file mode 100644 index 0000000..17af172 --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/slices/imagesSlice.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { useEditorStore } from ".." +import { SAMPLE_URL } from "../test/helpers" + +beforeEach(() => { + useEditorStore.getState().destroy() + vi.restoreAllMocks() +}) + +afterEach(() => { + useEditorStore.getState().destroy() +}) + +describe("imagesSlice", () => { + it("setCurrentImage", () => { + useEditorStore.getState().initialize({ imageList: [SAMPLE_URL] }) + useEditorStore.getState().setCurrentImage(undefined) + expect(useEditorStore.getState().currentImage).toBeUndefined() + }) + + it("setImageDimensions updates matching file only", () => { + useEditorStore.getState().initialize({ imageList: [SAMPLE_URL] }) + useEditorStore.getState().setImageDimensions("https://unknown.test/x.jpg", { + width: 1, + height: 1, + }) + expect( + useEditorStore.getState().originalImageList[0].imageDimensions, + ).toBeNull() + + useEditorStore.getState().setImageDimensions(SAMPLE_URL, { + width: 400, + height: 300, + }) + expect( + useEditorStore.getState().originalImageList[0].imageDimensions, + ).toEqual({ + width: 400, + height: 300, + }) + }) + + it("addImage appends new url and switches current", () => { + useEditorStore.getState().initialize({ imageList: [SAMPLE_URL] }) + useEditorStore.getState().addImage("https://example.com/second.jpg") + expect(useEditorStore.getState().originalImageList).toHaveLength(2) + expect(useEditorStore.getState().currentImage).toContain("second.jpg") + }) + + it("addImage existing url only switches current", () => { + useEditorStore.getState().initialize({ + imageList: [SAMPLE_URL, "https://example.com/b.jpg"], + }) + const before = useEditorStore.getState().originalImageList.length + useEditorStore.getState().addImage(SAMPLE_URL) + expect(useEditorStore.getState().originalImageList).toHaveLength(before) + expect(useEditorStore.getState().currentImage).toBe(SAMPLE_URL) + }) + + it("addImages skips duplicates", () => { + useEditorStore.getState().initialize({ imageList: [SAMPLE_URL] }) + useEditorStore + .getState() + .addImages([SAMPLE_URL, "https://example.com/new.jpg"]) + expect(useEditorStore.getState().originalImageList).toHaveLength(2) + }) + + it("removeImage switches current when removing active", () => { + useEditorStore.getState().initialize({ + imageList: [ + SAMPLE_URL, + "https://example.com/a.jpg", + "https://example.com/b.jpg", + ], + }) + useEditorStore.getState().setCurrentImage("https://example.com/a.jpg") + useEditorStore.getState().removeImage("https://example.com/a.jpg") + expect(useEditorStore.getState().currentImage).toBe( + "https://example.com/b.jpg", + ) + }) + + it("removeImage picks prior image when removing last item", () => { + useEditorStore.getState().initialize({ + imageList: [SAMPLE_URL, "https://example.com/a.jpg"], + }) + useEditorStore.getState().setCurrentImage("https://example.com/a.jpg") + useEditorStore.getState().removeImage("https://example.com/a.jpg") + expect(useEditorStore.getState().currentImage).toBe(SAMPLE_URL) + }) + + it("removeImage clears current when list becomes empty", () => { + useEditorStore.getState().initialize({ imageList: [SAMPLE_URL] }) + useEditorStore.getState().removeImage(SAMPLE_URL) + expect(useEditorStore.getState().currentImage).toBeUndefined() + }) + + it("removeImage clears signing state for that url", () => { + useEditorStore.getState().initialize({ imageList: [SAMPLE_URL] }) + const ac = new AbortController() + useEditorStore.setState({ + signingImages: { [SAMPLE_URL]: true }, + signingAbortControllers: { [SAMPLE_URL]: ac }, + signedUrlCache: { [`${SAMPLE_URL}::[]`]: "cached" }, + }) + const spy = vi.spyOn(ac, "abort") + useEditorStore.getState().removeImage(SAMPLE_URL) + expect(spy).toHaveBeenCalled() + expect(useEditorStore.getState().signingImages[SAMPLE_URL]).toBeUndefined() + }) +}) diff --git a/packages/imagekit-editor-dev/src/store/slices/imagesSlice.ts b/packages/imagekit-editor-dev/src/store/slices/imagesSlice.ts new file mode 100644 index 0000000..a56f540 --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/slices/imagesSlice.ts @@ -0,0 +1,106 @@ +import type { StateCreator } from "zustand" +import { normalizeImage } from "../pure/normalizeImage" +import type { EditorStore } from "../types" + +export const createImagesSlice: StateCreator< + EditorStore, + [["zustand/subscribeWithSelector", never]], + [], + Pick< + EditorStore, + | "setCurrentImage" + | "setImageDimensions" + | "addImage" + | "addImages" + | "removeImage" + > +> = (set, get) => ({ + setCurrentImage: (imageSrc) => { + set({ currentImage: imageSrc }) + }, + + setImageDimensions: (imageSrc, imageDimensions) => { + set((state) => { + const index = state.originalImageList.findIndex( + (img) => img.url === imageSrc, + ) + if (index === -1) return state + const updatedImageList = [...state.originalImageList] + updatedImageList[index].imageDimensions = imageDimensions + return { originalImageList: updatedImageList } + }) + }, + + addImage: (imageSrc) => { + const img = normalizeImage(imageSrc) + if (!get().originalImageList.some((i) => i.url === img.url)) { + set((state) => ({ + originalImageList: [...state.originalImageList, img], + currentImage: img.url, + })) + } else { + set({ currentImage: img.url }) + } + }, + + addImages: (imageSrcs) => { + const existing = get().originalImageList + const uniqueImages = imageSrcs + .map(normalizeImage) + .filter((img) => !existing.some((i) => i.url === img.url)) + set((state) => ({ + originalImageList: [...state.originalImageList, ...uniqueImages], + })) + }, + + removeImage: (imageSrc) => { + set((state) => { + const index = state.originalImageList.findIndex( + (img) => img.url === imageSrc, + ) + const updatedImageList = state.originalImageList.filter( + (img) => img.url !== imageSrc, + ) + + let newCurrentImage = state.currentImage + if (state.currentImage === imageSrc) { + if (updatedImageList.length > 0) { + if (index >= updatedImageList.length) { + newCurrentImage = updatedImageList[updatedImageList.length - 1].url + } else { + newCurrentImage = updatedImageList[index].url + } + } else { + newCurrentImage = undefined + } + } + + const updatedSigningImages = { ...state.signingImages } + delete updatedSigningImages[imageSrc] + + const updatedSigningAbortControllers = { + ...state.signingAbortControllers, + } + const controller = updatedSigningAbortControllers[imageSrc] + if (controller) { + controller.abort() + delete updatedSigningAbortControllers[imageSrc] + } + + const updatedSignedUrlCache = { ...state.signedUrlCache } + Object.keys(updatedSignedUrlCache).forEach((key) => { + if (key.startsWith(`${imageSrc}::`)) { + delete updatedSignedUrlCache[key] + } + }) + + return { + originalImageList: updatedImageList, + currentImage: newCurrentImage, + signingImages: updatedSigningImages, + signingAbortControllers: updatedSigningAbortControllers, + signedUrlCache: updatedSignedUrlCache, + } + }) + }, +}) diff --git a/packages/imagekit-editor-dev/src/store/slices/lifecycleSlice.test.ts b/packages/imagekit-editor-dev/src/store/slices/lifecycleSlice.test.ts new file mode 100644 index 0000000..f7da45d --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/slices/lifecycleSlice.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { useEditorStore } from ".." +import { SAMPLE_URL } from "../test/helpers" + +beforeEach(() => { + useEditorStore.getState().destroy() +}) + +afterEach(() => { + useEditorStore.getState().destroy() +}) + +describe("lifecycleSlice", () => { + describe("destroy", () => { + it("resets to default template + empty images", () => { + useEditorStore.getState().initialize({ imageList: [SAMPLE_URL] }) + useEditorStore.getState().setTemplateName("X") + useEditorStore.getState().destroy() + + const s = useEditorStore.getState() + expect(s.templateName).toBe("Untitled Template") + expect(s.originalImageList).toHaveLength(0) + expect(s.transformations).toHaveLength(0) + expect(s.localChangeVersion).toBe(0) + expect(s.syncStatus).toBe("unsaved") + }) + }) + + describe("initialize", () => { + it("no-op when nothing passed", () => { + useEditorStore.getState().initialize() + expect(useEditorStore.getState().originalImageList).toHaveLength(0) + }) + + it("loads images and sets current to first", () => { + useEditorStore.getState().initialize({ + imageList: [SAMPLE_URL, "https://example.com/other.jpg"], + }) + const s = useEditorStore.getState() + expect(s.imageList.length).toBeGreaterThan(0) + expect(s.currentImage).toBeTruthy() + expect(s.originalImageList).toHaveLength(2) + }) + + it("stores signer and focusObjects", () => { + const signer = vi.fn() + useEditorStore.getState().initialize({ + imageList: [SAMPLE_URL], + signer, + focusObjects: ["foo"] as never, + }) + expect(useEditorStore.getState().signer).toBe(signer) + expect(useEditorStore.getState().focusObjects).toEqual(["foo"]) + }) + + it("templateId sets pristine false and sync saved with versions reset", () => { + useEditorStore.getState().initialize({ templateId: "tid-1" }) + const s = useEditorStore.getState() + expect(s.templateId).toBe("tid-1") + expect(s.isPristine).toBe(false) + expect(s.syncStatus).toBe("saved") + expect(s.localChangeVersion).toBe(0) + expect(s.lastSyncedVersion).toBe(0) + }) + + it("templateName alone triggers same synced bootstrap", () => { + useEditorStore.getState().initialize({ templateName: "Hello" }) + const s = useEditorStore.getState() + expect(s.templateName).toBe("Hello") + expect(s.syncStatus).toBe("saved") + expect(s.isPristine).toBe(false) + }) + }) +}) diff --git a/packages/imagekit-editor-dev/src/store/slices/lifecycleSlice.ts b/packages/imagekit-editor-dev/src/store/slices/lifecycleSlice.ts new file mode 100644 index 0000000..a475290 --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/slices/lifecycleSlice.ts @@ -0,0 +1,47 @@ +import type { StateCreator } from "zustand" +import { DEFAULT_STATE } from "../initialState" +import { normalizeImage } from "../pure/normalizeImage" +import type { EditorState, EditorStore } from "../types" + +export const createLifecycleSlice: StateCreator< + EditorStore, + [["zustand/subscribeWithSelector", never]], + [], + Pick +> = (set) => ({ + initialize: (initialData) => { + const updates: Partial = {} + if (initialData?.imageList && initialData.imageList.length > 0) { + const imgs = initialData.imageList.map(normalizeImage) + updates.originalImageList = imgs + updates.imageList = imgs.map((i) => i.url) + updates.currentImage = imgs[0].url + } + if (initialData?.signer) { + updates.signer = initialData.signer + } + if (initialData?.focusObjects) { + updates.focusObjects = initialData.focusObjects + } + if (initialData?.templateName) { + updates.templateName = initialData.templateName + updates.isPristine = false + } + if (initialData?.templateId) { + updates.templateId = initialData.templateId + updates.isPristine = false + } + if (initialData?.templateId || initialData?.templateName) { + updates.syncStatus = "saved" + updates.localChangeVersion = 0 + updates.lastSyncedVersion = 0 + } + if (Object.keys(updates).length > 0) { + set(updates as EditorState) + } + }, + + destroy: () => { + set(DEFAULT_STATE) + }, +}) diff --git a/packages/imagekit-editor-dev/src/store/slices/sidebarSlice.test.ts b/packages/imagekit-editor-dev/src/store/slices/sidebarSlice.test.ts new file mode 100644 index 0000000..f655a2e --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/slices/sidebarSlice.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { useEditorStore } from ".." + +beforeEach(() => { + useEditorStore.getState().destroy() +}) + +afterEach(() => { + useEditorStore.getState().destroy() +}) + +describe("sidebarSlice", () => { + it("_setSidebarState", () => { + useEditorStore.getState()._setSidebarState("config") + expect(useEditorStore.getState()._internalState.sidebarState).toBe("config") + }) + + it("_setSelectedTransformationKey", () => { + useEditorStore.getState()._setSelectedTransformationKey("k") + expect( + useEditorStore.getState()._internalState.selectedTransformationKey, + ).toBe("k") + }) + + it("_setTransformationToEdit clears when empty id", () => { + useEditorStore.getState()._setTransformationToEdit("x", "inplace") + useEditorStore.getState()._setTransformationToEdit("") + expect( + useEditorStore.getState()._internalState.transformationToEdit, + ).toBeNull() + }) + + it("_setTransformationToEdit inplace above below", () => { + useEditorStore.getState()._setTransformationToEdit("t1", "inplace") + expect( + useEditorStore.getState()._internalState.transformationToEdit, + ).toEqual({ + transformationId: "t1", + position: "inplace", + }) + useEditorStore.getState()._setTransformationToEdit("t2", "above") + expect( + useEditorStore.getState()._internalState.transformationToEdit, + ).toEqual({ + position: "above", + targetId: "t2", + }) + useEditorStore.getState()._setTransformationToEdit("t3", "below") + expect( + useEditorStore.getState()._internalState.transformationToEdit, + ).toEqual({ + position: "below", + targetId: "t3", + }) + }) +}) diff --git a/packages/imagekit-editor-dev/src/store/slices/sidebarSlice.ts b/packages/imagekit-editor-dev/src/store/slices/sidebarSlice.ts new file mode 100644 index 0000000..1d8287a --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/slices/sidebarSlice.ts @@ -0,0 +1,73 @@ +import type { StateCreator } from "zustand" +import type { EditorStore } from "../types" + +export const createSidebarSlice: StateCreator< + EditorStore, + [["zustand/subscribeWithSelector", never]], + [], + Pick< + EditorStore, + | "_setSidebarState" + | "_setSelectedTransformationKey" + | "_setTransformationToEdit" + > +> = (set) => ({ + _setSidebarState: (sidebarState) => { + set((state) => ({ + _internalState: { ...state._internalState, sidebarState }, + })) + }, + + _setSelectedTransformationKey: (key) => { + set((state) => ({ + _internalState: { + ...state._internalState, + selectedTransformationKey: key, + }, + })) + }, + + _setTransformationToEdit: ( + transformationOrTargetId: string, + position = "inplace", + ) => { + if (!transformationOrTargetId) { + set((state) => ({ + _internalState: { + ...state._internalState, + transformationToEdit: null, + }, + })) + } else if (position === "inplace") { + set((state) => ({ + _internalState: { + ...state._internalState, + transformationToEdit: { + transformationId: transformationOrTargetId, + position, + }, + }, + })) + } else if (position === "above") { + set((state) => ({ + _internalState: { + ...state._internalState, + transformationToEdit: { + position, + targetId: transformationOrTargetId, + }, + }, + })) + } else if (position === "below") { + set((state) => ({ + _internalState: { + ...state._internalState, + transformationToEdit: { + position, + targetId: transformationOrTargetId, + }, + }, + })) + } + }, +}) diff --git a/packages/imagekit-editor-dev/src/store/slices/syncSlice.test.ts b/packages/imagekit-editor-dev/src/store/slices/syncSlice.test.ts new file mode 100644 index 0000000..6edfa76 --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/slices/syncSlice.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { useEditorStore } from ".." + +beforeEach(() => { + useEditorStore.getState().destroy() +}) + +afterEach(() => { + useEditorStore.getState().destroy() +}) + +describe("syncSlice", () => { + it("setSyncStatus with optional error", () => { + useEditorStore.getState().setSyncStatus("error", "e") + expect(useEditorStore.getState().storageError).toBe("e") + }) + + it("markSynced with and without explicit version", () => { + useEditorStore.setState({ + localChangeVersion: 7, + lastSyncedVersion: 1, + }) + useEditorStore.getState().markSynced(5) + expect(useEditorStore.getState().lastSyncedVersion).toBe(5) + useEditorStore.getState().markSynced() + expect(useEditorStore.getState().lastSyncedVersion).toBe(7) + }) + + it("bumpLocalChangeVersion", () => { + const v = useEditorStore.getState().localChangeVersion + useEditorStore.getState().bumpLocalChangeVersion() + expect(useEditorStore.getState().localChangeVersion).toBe(v + 1) + }) + + it("setLastSavedAt and setTransformationConfigFormDirty and setIsPristine", () => { + useEditorStore.getState().setLastSavedAt(12345) + expect(useEditorStore.getState().lastSavedAt).toBe(12345) + useEditorStore.getState().setTransformationConfigFormDirty(true) + expect(useEditorStore.getState().transformationConfigFormDirty).toBe(true) + useEditorStore.getState().setIsPristine(false) + expect(useEditorStore.getState().isPristine).toBe(false) + }) +}) diff --git a/packages/imagekit-editor-dev/src/store/slices/syncSlice.ts b/packages/imagekit-editor-dev/src/store/slices/syncSlice.ts new file mode 100644 index 0000000..30362b7 --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/slices/syncSlice.ts @@ -0,0 +1,46 @@ +import type { StateCreator } from "zustand" +import { bumpLocalChangeVersion as bumpVersion } from "../../sync/templateSyncVersioning" +import type { EditorStore } from "../types" + +export const createSyncSlice: StateCreator< + EditorStore, + [["zustand/subscribeWithSelector", never]], + [], + Pick< + EditorStore, + | "setSyncStatus" + | "bumpLocalChangeVersion" + | "markSynced" + | "setLastSavedAt" + | "setTransformationConfigFormDirty" + | "setIsPristine" + > +> = (set) => ({ + setSyncStatus: (status, error?) => { + set({ syncStatus: status, storageError: error }) + }, + + bumpLocalChangeVersion: () => { + set((state) => ({ + localChangeVersion: bumpVersion(state.localChangeVersion), + })) + }, + + markSynced: (version) => { + set((state) => ({ + lastSyncedVersion: version ?? state.localChangeVersion, + })) + }, + + setLastSavedAt: (ts) => { + set({ lastSavedAt: ts }) + }, + + setTransformationConfigFormDirty: (dirty) => { + set({ transformationConfigFormDirty: dirty }) + }, + + setIsPristine: (pristine: boolean) => { + set({ isPristine: pristine }) + }, +}) diff --git a/packages/imagekit-editor-dev/src/store/slices/templateSlice.test.ts b/packages/imagekit-editor-dev/src/store/slices/templateSlice.test.ts new file mode 100644 index 0000000..0d4e8b5 --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/slices/templateSlice.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { type Transformation, useEditorStore } from ".." +import { borderTransform } from "../test/helpers" + +beforeEach(() => { + useEditorStore.getState().destroy() +}) + +afterEach(() => { + useEditorStore.getState().destroy() +}) + +describe("templateSlice", () => { + it("setTemplateName bumps version when name changes", () => { + const v0 = useEditorStore.getState().localChangeVersion + useEditorStore.getState().setTemplateName("A") + expect(useEditorStore.getState().localChangeVersion).toBeGreaterThan(v0) + const v1 = useEditorStore.getState().localChangeVersion + useEditorStore.getState().setTemplateName("A") + expect(useEditorStore.getState().localChangeVersion).toBe(v1) + }) + + it("setTemplateIsPrivate bumps only when value changes", () => { + useEditorStore.getState().setTemplateIsPrivate(true) + const v = useEditorStore.getState().localChangeVersion + useEditorStore.getState().setTemplateIsPrivate(true) + expect(useEditorStore.getState().localChangeVersion).toBe(v) + useEditorStore.getState().setTemplateIsPrivate(false) + expect(useEditorStore.getState().localChangeVersion).toBeGreaterThan(v) + }) + + it("hydrateTemplateMetadata", () => { + useEditorStore.getState().hydrateTemplateMetadata({ + templateId: "z", + templateName: "Z", + templateIsPrivate: false, + }) + const s = useEditorStore.getState() + expect(s.templateId).toBe("z") + expect(s.templateName).toBe("Z") + expect(s.templateIsPrivate).toBe(false) + }) + + it("setTemplateId", () => { + useEditorStore.getState().setTemplateId("abc") + expect(useEditorStore.getState().templateId).toBe("abc") + }) + + it("resetToNewTemplate", () => { + useEditorStore.setState({ + transformations: [{ id: "x", ...borderTransform() } as Transformation], + templateName: "Old", + templateId: "id", + localChangeVersion: 9, + }) + useEditorStore.getState().resetToNewTemplate() + const s = useEditorStore.getState() + expect(s.transformations).toHaveLength(0) + expect(s.templateId).toBeNull() + expect(s.localChangeVersion).toBe(0) + expect(s.syncStatus).toBe("unsaved") + }) + + it("restoreSession", () => { + useEditorStore.getState().restoreSession({ + transformations: [{ id: "x", ...borderTransform() } as Transformation], + visibleTransformations: { x: true }, + templateName: "R", + templateId: "tid", + templateIsPrivate: true, + syncStatus: "saved", + isPristine: false, + localChangeVersion: 3, + lastSyncedVersion: 3, + lastSavedAt: 99, + }) + const s = useEditorStore.getState() + expect(s.templateName).toBe("R") + expect(s.templateStorageWriteBlocked).toBe(false) + expect(s.transformationConfigFormDirty).toBe(false) + expect(s.lastSavedAt).toBe(99) + }) + + it("blockTemplateStorageWrites uses default message when omitted", () => { + useEditorStore.getState().blockTemplateStorageWrites() + expect(useEditorStore.getState().storageError).toBe( + "You no longer have access to this template.", + ) + expect(useEditorStore.getState().templateStorageWriteBlocked).toBe(true) + }) + + it("denyTemplateStorageAccessAndReset resets the store to the default state", () => { + useEditorStore.getState().loadTemplate([borderTransform()]) + useEditorStore.getState().denyTemplateStorageAccessAndReset("gone") + const s = useEditorStore.getState() + expect(s.transformations).toHaveLength(0) + expect(s.storageError).toBe("gone") + expect(s.templateStorageWriteBlocked).toBe(true) + }) +}) diff --git a/packages/imagekit-editor-dev/src/store/slices/templateSlice.ts b/packages/imagekit-editor-dev/src/store/slices/templateSlice.ts new file mode 100644 index 0000000..d8103e7 --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/slices/templateSlice.ts @@ -0,0 +1,134 @@ +import type { StateCreator } from "zustand" +import { bumpLocalChangeVersion as bumpVersion } from "../../sync/templateSyncVersioning" +import type { EditorStore } from "../types" + +export const createTemplateSlice: StateCreator< + EditorStore, + [["zustand/subscribeWithSelector", never]], + [], + Pick< + EditorStore, + | "setTemplateName" + | "setTemplateId" + | "setTemplateIsPrivate" + | "hydrateTemplateMetadata" + | "resetToNewTemplate" + | "restoreSession" + | "blockTemplateStorageWrites" + | "denyTemplateStorageAccessAndReset" + > +> = (set) => ({ + setTemplateName: (name) => { + set((state) => ({ + templateName: name, + isPristine: state.templateName === name ? state.isPristine : false, + localChangeVersion: + state.templateName === name + ? state.localChangeVersion + : bumpVersion(state.localChangeVersion), + })) + }, + + setTemplateId: (id) => { + set({ templateId: id }) + }, + + setTemplateIsPrivate: (isPrivate) => { + set((state) => ({ + templateIsPrivate: isPrivate, + localChangeVersion: + state.templateIsPrivate === isPrivate + ? state.localChangeVersion + : bumpVersion(state.localChangeVersion), + })) + }, + + hydrateTemplateMetadata: ({ + templateId, + templateName, + templateIsPrivate, + }) => { + set(() => ({ + templateId, + templateName, + templateIsPrivate, + })) + }, + + resetToNewTemplate: () => { + set({ + transformations: [], + visibleTransformations: {}, + templateName: "Untitled Template", + templateId: null, + templateIsPrivate: null, + syncStatus: "unsaved", + storageError: undefined, + isPristine: true, + templateStorageWriteBlocked: false, + localChangeVersion: 0, + lastSyncedVersion: 0, + lastSavedAt: null, + transformationConfigFormDirty: false, + _internalState: { + sidebarState: "none", + selectedTransformationKey: null, + transformationToEdit: null, + }, + }) + }, + + restoreSession: (persisted) => { + set(() => ({ + transformations: persisted.transformations, + visibleTransformations: persisted.visibleTransformations, + templateName: persisted.templateName, + templateId: persisted.templateId, + templateIsPrivate: persisted.templateIsPrivate, + syncStatus: persisted.syncStatus, + isPristine: persisted.isPristine, + localChangeVersion: persisted.localChangeVersion, + lastSyncedVersion: persisted.lastSyncedVersion, + lastSavedAt: persisted.lastSavedAt, + storageError: undefined, + templateStorageWriteBlocked: false, + transformationConfigFormDirty: false, + _internalState: { + sidebarState: "none", + selectedTransformationKey: null, + transformationToEdit: null, + }, + })) + }, + + blockTemplateStorageWrites: (message) => { + set({ + syncStatus: "error", + storageError: message ?? "You no longer have access to this template.", + templateStorageWriteBlocked: true, + }) + }, + + denyTemplateStorageAccessAndReset: (message) => { + set({ + transformations: [], + visibleTransformations: {}, + templateName: "Untitled Template", + templateId: null, + templateIsPrivate: null, + syncStatus: "error", + storageError: message ?? "You no longer have access to this template.", + isPristine: true, + templateStorageWriteBlocked: true, + localChangeVersion: 0, + lastSyncedVersion: 0, + lastSavedAt: null, + transformationConfigFormDirty: false, + _internalState: { + sidebarState: "none", + selectedTransformationKey: null, + transformationToEdit: null, + }, + }) + }, +}) diff --git a/packages/imagekit-editor-dev/src/store/slices/transformationsSlice.test.ts b/packages/imagekit-editor-dev/src/store/slices/transformationsSlice.test.ts new file mode 100644 index 0000000..9e9fb9d --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/slices/transformationsSlice.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { + TRANSFORMATION_STATE_VERSION, + type Transformation, + useEditorStore, +} from ".." +import { borderTransform, resizeTransform } from "../test/helpers" + +beforeEach(() => { + useEditorStore.getState().destroy() +}) + +afterEach(() => { + useEditorStore.getState().destroy() +}) + +describe("transformationsSlice", () => { + it("loadTemplate assigns ids, versions, visibility from enabled", () => { + useEditorStore + .getState() + .loadTemplate([{ ...borderTransform(), enabled: false }]) + const s = useEditorStore.getState() + expect(s.transformations).toHaveLength(1) + expect(s.transformations[0].version).toBe(TRANSFORMATION_STATE_VERSION) + expect(s.visibleTransformations[s.transformations[0].id]).toBe(false) + expect(s.syncStatus).toBe("saved") + expect(s.localChangeVersion).toBe(s.lastSyncedVersion) + }) + + it("moveTransformation reorders and bumps version", () => { + useEditorStore + .getState() + .loadTemplate([borderTransform(), resizeTransform()]) + const [a, b] = useEditorStore.getState().transformations + const v0 = useEditorStore.getState().localChangeVersion + useEditorStore.getState().moveTransformation(b.id, a.id) + const order = useEditorStore.getState().transformations.map((t) => t.id) + expect(order[0]).toBe(b.id) + expect(useEditorStore.getState().localChangeVersion).toBeGreaterThan(v0) + }) + + it("moveTransformation no-op when ids invalid", () => { + useEditorStore.getState().loadTemplate([borderTransform()]) + const v0 = useEditorStore.getState().localChangeVersion + useEditorStore.getState().moveTransformation("nope", "nah") + expect(useEditorStore.getState().localChangeVersion).toBe(v0) + }) + + it("toggleTransformationVisibility updates visible map and transformation.enabled", () => { + useEditorStore.getState().loadTemplate([borderTransform()]) + const id = useEditorStore.getState().transformations[0].id + expect(useEditorStore.getState().visibleTransformations[id]).not.toBe(false) + useEditorStore.getState().toggleTransformationVisibility(id) + expect(useEditorStore.getState().visibleTransformations[id]).toBe(false) + expect(useEditorStore.getState().transformations[0].enabled).toBe(false) + }) + + it("addTransformation appends", () => { + useEditorStore.getState().loadTemplate([]) + const id = useEditorStore.getState().addTransformation(borderTransform()) + expect( + useEditorStore.getState().transformations.map((t) => t.id), + ).toContain(id) + expect(useEditorStore.getState().visibleTransformations[id]).toBe(true) + }) + + it("addTransformation inserts at position", () => { + useEditorStore + .getState() + .loadTemplate([resizeTransform(), borderTransform()]) + const id = useEditorStore.getState().addTransformation(borderTransform(), 0) + expect(useEditorStore.getState().transformations[0].id).toBe(id) + }) + + it("removeTransformation", () => { + useEditorStore.getState().loadTemplate([borderTransform()]) + const id = useEditorStore.getState().transformations[0].id + useEditorStore.getState().removeTransformation(id) + expect(useEditorStore.getState().transformations).toHaveLength(0) + }) + + it("updateTransformation preserves id", () => { + useEditorStore.getState().loadTemplate([borderTransform()]) + const id = useEditorStore.getState().transformations[0].id + const updated: Transformation = { + ...useEditorStore.getState().transformations[0], + name: "Renamed", + } + useEditorStore.getState().updateTransformation(id, updated) + expect(useEditorStore.getState().transformations[0].name).toBe("Renamed") + expect(useEditorStore.getState().transformations[0].id).toBe(id) + }) + + it("setShowOriginal", () => { + useEditorStore.getState().setShowOriginal(true) + expect(useEditorStore.getState().showOriginal).toBe(true) + }) +}) diff --git a/packages/imagekit-editor-dev/src/store/slices/transformationsSlice.ts b/packages/imagekit-editor-dev/src/store/slices/transformationsSlice.ts new file mode 100644 index 0000000..0f116c8 --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/slices/transformationsSlice.ts @@ -0,0 +1,163 @@ +import type { StateCreator } from "zustand" +import { bumpLocalChangeVersion as bumpVersion } from "../../sync/templateSyncVersioning" +import { + type EditorStore, + TRANSFORMATION_STATE_VERSION, + type Transformation, +} from "../types" + +export const createTransformationsSlice: StateCreator< + EditorStore, + [["zustand/subscribeWithSelector", never]], + [], + Pick< + EditorStore, + | "loadTemplate" + | "moveTransformation" + | "toggleTransformationVisibility" + | "addTransformation" + | "removeTransformation" + | "updateTransformation" + | "setShowOriginal" + > +> = (set) => ({ + loadTemplate: (template) => { + const transformationsWithIds = template.map((transformation, index) => ({ + ...transformation, + id: `transformation-${Date.now()}-${index}`, + version: TRANSFORMATION_STATE_VERSION, + })) + + const visibleTransformations: Record = {} + transformationsWithIds.forEach((t) => { + visibleTransformations[t.id] = t.enabled !== false + }) + + set((state) => { + const nextVersion = bumpVersion(state.localChangeVersion) + return { + transformations: transformationsWithIds, + visibleTransformations: { + ...state.visibleTransformations, + ...visibleTransformations, + }, + _internalState: { + sidebarState: "none", + selectedTransformationKey: null, + transformationToEdit: null, + }, + isPristine: false, + syncStatus: "saved", + localChangeVersion: nextVersion, + lastSyncedVersion: nextVersion, + templateStorageWriteBlocked: false, + transformationConfigFormDirty: false, + } + }) + }, + + moveTransformation: (activeId, overId) => { + set((state) => { + const activeIdStr = String(activeId) + const overIdStr = String(overId) + const oldIndex = state.transformations.findIndex( + (item) => item.id === activeIdStr, + ) + const newIndex = state.transformations.findIndex( + (item) => item.id === overIdStr, + ) + + if (oldIndex !== -1 && newIndex !== -1) { + const updatedTransformations = [...state.transformations] + const [removed] = updatedTransformations.splice(oldIndex, 1) + updatedTransformations.splice(newIndex, 0, removed) + + return { + transformations: updatedTransformations, + isPristine: false, + localChangeVersion: bumpVersion(state.localChangeVersion), + } + } + return { transformations: state.transformations } + }) + }, + + toggleTransformationVisibility: (id) => { + set((state) => { + const newVisible = !state.visibleTransformations[id] + return { + visibleTransformations: { + ...state.visibleTransformations, + [id]: newVisible, + }, + transformations: state.transformations.map((t) => + t.id === id ? { ...t, enabled: newVisible } : t, + ), + isPristine: false, + localChangeVersion: bumpVersion(state.localChangeVersion), + } + }) + }, + + addTransformation: (transformation, position) => { + const id = `transformation-${Date.now()}` + + if (typeof position === "number") { + set((state) => { + const transformations = [...state.transformations] + transformations.splice(position, 0, { ...transformation, id }) + return { + transformations, + visibleTransformations: { + ...state.visibleTransformations, + [id]: true, + }, + isPristine: false, + localChangeVersion: bumpVersion(state.localChangeVersion), + } + }) + + return id + } + + set((state) => { + return { + transformations: [...state.transformations, { ...transformation, id }], + visibleTransformations: { + ...state.visibleTransformations, + [id]: true, + }, + isPristine: false, + localChangeVersion: bumpVersion(state.localChangeVersion), + } + }) + + return id + }, + + removeTransformation: (id) => { + set((state) => ({ + transformations: state.transformations.filter( + (transformation) => transformation.id !== id, + ), + isPristine: false, + localChangeVersion: bumpVersion(state.localChangeVersion), + })) + }, + + updateTransformation: (id: string, updatedTransformation: Transformation) => { + set((state) => ({ + transformations: state.transformations.map((t) => + t.id === id ? { ...updatedTransformation, id } : t, + ), + isPristine: false, + localChangeVersion: bumpVersion(state.localChangeVersion), + })) + }, + + setShowOriginal: (showOriginal) => { + set(() => ({ + showOriginal, + })) + }, +}) diff --git a/packages/imagekit-editor-dev/src/store/test/helpers.ts b/packages/imagekit-editor-dev/src/store/test/helpers.ts new file mode 100644 index 0000000..cae903f --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/test/helpers.ts @@ -0,0 +1,28 @@ +import type { Transformation } from "../types" +import { TRANSFORMATION_STATE_VERSION } from "../types" + +export const SAMPLE_URL = "https://ik.imagekit.io/demo/tr:f-auto/sample.jpg" + +export function borderTransform(): Omit { + return { + key: "adjust-border", + name: "Border", + type: "transformation", + value: { borderWidth: 2, borderColor: "#000000" }, + version: TRANSFORMATION_STATE_VERSION, + } +} + +export function resizeTransform(): Omit { + return { + key: "resize_and_crop-resize_and_crop", + name: "Resize", + type: "transformation", + value: { + width: 100, + height: 100, + mode: "cm-pad_extract", + }, + version: TRANSFORMATION_STATE_VERSION, + } +} diff --git a/packages/imagekit-editor-dev/src/store/types.ts b/packages/imagekit-editor-dev/src/store/types.ts new file mode 100644 index 0000000..55b011a --- /dev/null +++ b/packages/imagekit-editor-dev/src/store/types.ts @@ -0,0 +1,205 @@ +import type { UniqueIdentifier } from "@dnd-kit/core" +import type { Transformation as IKTransformation } from "@imagekit/javascript" +import type { DEFAULT_FOCUS_OBJECTS } from "../schema" + +export const TRANSFORMATION_STATE_VERSION = "v1" as const + +export interface Transformation { + id: string + key: string + name: string + type: "transformation" + value: IKTransformation + version?: typeof TRANSFORMATION_STATE_VERSION + /** Persisted visibility flag. Absent or true = visible; false = hidden. */ + enabled?: boolean +} + +export type RequiredMetadata = { requireSignedUrl: boolean } + +export interface FileElement< + Metadata extends RequiredMetadata = RequiredMetadata, +> { + url: string + metadata: Metadata + imageDimensions: { width: number; height: number } | null +} + +export type InputFileElement< + Metadata extends RequiredMetadata = RequiredMetadata, +> = Omit, "imageDimensions"> + +export interface SignerRequest< + Metadata extends RequiredMetadata = RequiredMetadata, +> { + url: string + transformation: string + metadata: Metadata +} + +export type Signer = ( + item: SignerRequest, + controller?: AbortController, +) => Promise + +interface InternalState { + sidebarState: "none" | "type" | "config" + selectedTransformationKey: string | null + transformationToEdit: + | { + transformationId: string + position: "inplace" + } + | { + position: "above" | "below" + targetId: string + } + | null +} + +export type FocusObjects = + | (typeof DEFAULT_FOCUS_OBJECTS)[number] + | (string & {}) + +export type SyncStatus = "unsaved" | "saving" | "saved" | "error" + +export interface EditorState< + Metadata extends RequiredMetadata = RequiredMetadata, +> { + currentImage: string | undefined + originalImageList: FileElement[] + imageList: string[] + transformations: Transformation[] + visibleTransformations: Record + showOriginal: boolean + signer?: Signer + signingImages: Record + signingAbortControllers: Record + signedUrlCache: Record + currentTransformKey: string + focusObjects?: ReadonlyArray + _internalState: InternalState + templateName: string + templateId: string | null + /** + * Template visibility scope. For dashboard integration this maps to: + * - true => onlyMe (private) + * - false => everyone (shared) + * - null => unknown/unloaded + */ + templateIsPrivate: boolean | null + syncStatus: SyncStatus + storageError?: string + isPristine: boolean + /** + * After a 401/403 template write failure, saves are blocked so a follow-up + * save cannot POST a duplicate after the store clears `templateId`. + */ + templateStorageWriteBlocked: boolean + + /** Versioned sync model to keep UI stable under save/edit races. */ + localChangeVersion: number + lastSyncedVersion: number + /** + * Timestamp (ms) of the last successful save to remote storage. + * Used to debounce/reset periodic auto-save scheduling. + */ + lastSavedAt: number | null + /** + * True while the transformation config sidebar form has unapplied edits (RHF isDirty). + * Used by header status and close confirmation alongside versioned unsynced state. + */ + transformationConfigFormDirty: boolean +} + +export type EditorStore = + EditorState & EditorActions + +export type EditorActions< + Metadata extends RequiredMetadata = RequiredMetadata, +> = { + initialize: (initialData?: { + imageList?: Array> + signer?: Signer + focusObjects?: ReadonlyArray + templateName?: string + templateId?: string + }) => void + destroy: () => void + setCurrentImage: (imageSrc: string | undefined) => void + setImageDimensions: ( + imageSrc: string, + dimensions: { width: number; height: number } | null, + ) => void + addImage: (imageSrc: string | InputFileElement) => void + addImages: (imageSrcs: Array>) => void + removeImage: (imageSrc: string) => void + loadTemplate: (template: Omit[]) => void + moveTransformation: ( + activeId: UniqueIdentifier, + overId: UniqueIdentifier, + ) => void + toggleTransformationVisibility: (id: string) => void + addTransformation: ( + transformation: Omit, + position?: number, + ) => string + removeTransformation: (id: string) => void + updateTransformation: ( + id: string, + updatedTransformation: Omit, + ) => void + setShowOriginal: (showOriginal: boolean) => void + setTemplateName: (name: string) => void + setTemplateId: (id: string | null) => void + setTemplateIsPrivate: (isPrivate: boolean | null) => void + /** + * Sets template metadata from storage responses without bumping local version. + * Use this when hydrating from server/list responses (save success, load from library). + */ + hydrateTemplateMetadata: (meta: { + templateId: string | null + templateName: string + templateIsPrivate: boolean | null + }) => void + setSyncStatus: (status: SyncStatus, error?: string) => void + setIsPristine: (pristine: boolean) => void + bumpLocalChangeVersion: () => void + markSynced: (version?: number) => void + setLastSavedAt: (ts: number | null) => void + setTransformationConfigFormDirty: (dirty: boolean) => void + resetToNewTemplate: () => void + restoreSession: ( + state: Pick< + EditorState, + | "transformations" + | "visibleTransformations" + | "templateName" + | "templateId" + | "templateIsPrivate" + | "syncStatus" + | "isPristine" + | "localChangeVersion" + | "lastSyncedVersion" + | "lastSavedAt" + >, + ) => void + /** + * Blocks any further writes to template storage while keeping the current + * template state intact (so the user can keep viewing/editing locally). + * Intended for 401/403 write failures. + */ + blockTemplateStorageWrites: (message?: string) => void + /** + * Clears the loaded template and surfaces an error when access is revoked + * for viewing/loading the template. + */ + denyTemplateStorageAccessAndReset: (message?: string) => void + + _setSidebarState: (state: "none" | "type" | "config") => void + _setSelectedTransformationKey: (key: string | null) => void + _setTransformationToEdit: ( + transformationId: string | null, + position?: "inplace" | "above" | "below", + ) => void +} diff --git a/packages/imagekit-editor-dev/vite.config.ts b/packages/imagekit-editor-dev/vite.config.ts index d873a22..6b6c52e 100644 --- a/packages/imagekit-editor-dev/vite.config.ts +++ b/packages/imagekit-editor-dev/vite.config.ts @@ -53,7 +53,7 @@ export default defineConfig({ provider: "v8", reporter: ["text", "json", "html"], include: [ - "src/store.ts", + "src/store/**/*.ts", "src/schema/**/*.{ts,tsx}", "src/hooks/**/*.{ts,tsx}", "src/context/**/*.{ts,tsx}", @@ -63,8 +63,8 @@ export default defineConfig({ exclude: [ "src/**/*.{test,spec}.{ts,tsx}", "node_modules/**", - /** Interfaces only; no runtime code to cover */ "src/storage/types.ts", + "src/store/types.ts", ], thresholds: { lines: 90, From 1d91839e692bca1ef173da1b3bf2b173b56b81a1 Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Tue, 12 May 2026 14:32:27 +0530 Subject: [PATCH 13/21] fix: issues with race conditions in drafts and provider syncs --- .../src/components/editor/layout.tsx | 4 +- .../sessionDraftAndProviderSync.test.tsx | 414 ++++++++++++++++++ ... => useEditorSessionLocalStorage.test.tsx} | 6 +- .../src/hooks/useEditorSessionLocalStorage.ts | 93 ++++ .../src/hooks/usePersistedEditorSession.ts | 43 -- .../src/hooks/useTemplateSync.ts | 2 + 6 files changed, 514 insertions(+), 48 deletions(-) create mode 100644 packages/imagekit-editor-dev/src/hooks/sessionDraftAndProviderSync.test.tsx rename packages/imagekit-editor-dev/src/hooks/{usePersistedEditorSession.test.tsx => useEditorSessionLocalStorage.test.tsx} (91%) create mode 100644 packages/imagekit-editor-dev/src/hooks/useEditorSessionLocalStorage.ts delete mode 100644 packages/imagekit-editor-dev/src/hooks/usePersistedEditorSession.ts diff --git a/packages/imagekit-editor-dev/src/components/editor/layout.tsx b/packages/imagekit-editor-dev/src/components/editor/layout.tsx index 5f3dd90..b5ca465 100644 --- a/packages/imagekit-editor-dev/src/components/editor/layout.tsx +++ b/packages/imagekit-editor-dev/src/components/editor/layout.tsx @@ -1,7 +1,7 @@ import { Box, Flex } from "@chakra-ui/react" import { useEffect, useState } from "react" import { useAutoSaveTemplate } from "../../hooks/useAutoSaveTemplate" -import { usePersistedEditorSession } from "../../hooks/usePersistedEditorSession" +import { useEditorSessionLocalStorage } from "../../hooks/useEditorSessionLocalStorage" import { useSaveTemplate } from "../../hooks/useSaveTemplate" import { Header, type HeaderProps } from "../header" import { Sidebar } from "../sidebar" @@ -45,7 +45,7 @@ export function EditorLayout({ useAutoSaveTemplate() useSaveTemplate() - usePersistedEditorSession(pauseLocalSessionPersistence) + useEditorSessionLocalStorage(pauseLocalSessionPersistence) const closeTemplatesLibrary = () => setIsTemplatesOpen(false) diff --git a/packages/imagekit-editor-dev/src/hooks/sessionDraftAndProviderSync.test.tsx b/packages/imagekit-editor-dev/src/hooks/sessionDraftAndProviderSync.test.tsx new file mode 100644 index 0000000..862e3b4 --- /dev/null +++ b/packages/imagekit-editor-dev/src/hooks/sessionDraftAndProviderSync.test.tsx @@ -0,0 +1,414 @@ +import "@testing-library/jest-dom/vitest" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import React from "react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { TemplateStorageContextProvider } from "../context/TemplateStorageContext" +import { + EDITOR_SESSION_STORAGE_KEY, + readEditorSessionFromLocalStorage, +} from "../persistence/editorSessionStorage" +import type { TemplateRecord } from "../storage" +import { useEditorStore } from "../store" +import { + PERSIST_DEBOUNCE_MS, + persistEditorSessionNow, + useEditorSessionLocalStorage, +} from "./useEditorSessionLocalStorage" +import { useTemplateSync } from "./useTemplateSync" + +/** + * Version model (same names as in product docs): + * - **Memory** — live Zustand store (`localChangeVersion`, `lastSyncedVersion`). + * - **Draft** — JSON snapshot in localStorage under `EDITOR_SESSION_STORAGE_KEY`. + * - **Provider** — remote template storage; a successful save aligns `lastSyncedVersion` + * with the local revision that was uploaded. + * + * Regression we guard against (draft must track sync metadata, not only edits): + * | Phase | Memory (local / synced) | Provider | Draft (stored lastSynced) | + * |-------|-------------------------|----------|---------------------------| + * | Before save | 1 / 0 | behind | 0 if never flushed after sync | + * | After save | 1 / 1 | caught up | **must be 1** or reopen restores “unsaved” incorrectly | + * + * If the draft still says `lastSyncedVersion === 0` while memory says `1`, closing and + * resuming can drop or confuse user work relative to what the provider actually stored. + */ + +function savedRecord(overrides: Partial = {}): TemplateRecord { + return { + id: "saved-id", + clientNumber: "c1", + isPrivate: false, + name: "Saved", + transformations: [], + isPinned: false, + createdBy: { userId: "u1", name: "U", email: "u@x.com" }, + updatedBy: { userId: "u1", name: "U", email: "u@x.com" }, + createdAt: 1, + updatedAt: 2, + ...overrides, + } +} + +function stubProvider(saveTemplate: ReturnType) { + return { + getProviderName: () => "test", + getCurrentUserSession: () => ({}), + listTemplates: async () => [], + getTemplate: async () => null, + saveTemplate, + setTemplatePinned: async () => { + throw new Error("not used") + }, + } +} + +function readDraftSyncVersions() { + const session = readEditorSessionFromLocalStorage(EDITOR_SESSION_STORAGE_KEY) + if (!session) return null + return { + localChangeVersion: session.state.localChangeVersion, + lastSyncedVersion: session.state.lastSyncedVersion, + syncStatus: session.state.syncStatus, + } +} + +function readMemorySyncVersions() { + const s = useEditorStore.getState() + return { + localChangeVersion: s.localChangeVersion, + lastSyncedVersion: s.lastSyncedVersion, + syncStatus: s.syncStatus, + } +} + +function DraftAndSaveHarness(props: { paused?: boolean }) { + useEditorSessionLocalStorage(props.paused ?? false) + const { saveNow } = useTemplateSync() + return ( + + ) +} + +describe("localStorage session drafts vs provider sync", () => { + beforeEach(() => { + useEditorStore.getState().destroy() + window.localStorage.removeItem(EDITOR_SESSION_STORAGE_KEY) + vi.restoreAllMocks() + }) + + afterEach(() => { + useEditorStore.getState().destroy() + window.localStorage.removeItem(EDITOR_SESSION_STORAGE_KEY) + }) + + it( + "after local edits are ahead of the last provider save, a successful save updates the draft so " + + "lastSyncedVersion matches — the draft must not keep an older lastSyncedVersion while memory already caught up with the provider", + async () => { + const saveTemplate = vi + .fn() + .mockResolvedValue( + savedRecord({ id: "t-remote", name: "From provider" }), + ) + + useEditorStore.setState({ + templateId: "t-remote", + templateName: "Local title", + templateIsPrivate: false, + localChangeVersion: 1, + lastSyncedVersion: 0, + } as Parameters[0]) + + render( + + + , + ) + + fireEvent.click(screen.getByTestId("save-to-provider")) + + await waitFor(() => { + expect(useEditorStore.getState().syncStatus).toBe("saved") + }) + + const memory = readMemorySyncVersions() + expect(memory.localChangeVersion).toBe(1) + expect(memory.lastSyncedVersion).toBe(1) + + const draft = readDraftSyncVersions() + expect(draft).not.toBeNull() + expect(draft!.localChangeVersion).toBe(memory.localChangeVersion) + expect(draft!.lastSyncedVersion).toBe(memory.lastSyncedVersion) + }, + ) + + it( + "the in-memory draft snapshot always matches the store after persistEditorSessionNow " + + "(so there is no split-brain where only memory knows the provider caught up)", + async () => { + const saveTemplate = vi.fn().mockResolvedValue(savedRecord({ id: "t1" })) + + useEditorStore.setState({ + templateId: "t1", + templateName: "T", + localChangeVersion: 2, + lastSyncedVersion: 1, + } as Parameters[0]) + + render( + + + , + ) + + fireEvent.click(screen.getByTestId("save-to-provider")) + await waitFor(() => { + expect(useEditorStore.getState().lastSyncedVersion).toBe(2) + }) + + expect(readDraftSyncVersions()).toEqual(readMemorySyncVersions()) + }, + ) + + it( + "if the user edits again while a provider save is still in flight, the save does not mark synced — " + + "both memory and the draft still agree on localChangeVersion vs lastSyncedVersion after the request finishes", + async () => { + let finishSave!: (r: TemplateRecord) => void + const gate = new Promise((res) => { + finishSave = res + }) + const saveTemplate = vi.fn().mockReturnValue(gate) + + useEditorStore.setState({ + templateId: "t1", + templateName: "T", + localChangeVersion: 5, + lastSyncedVersion: 4, + } as Parameters[0]) + + render( + + + , + ) + + fireEvent.click(screen.getByTestId("save-to-provider")) + await act(async () => { + await Promise.resolve() + }) + + await act(async () => { + useEditorStore.getState().bumpLocalChangeVersion() + finishSave(savedRecord({ id: "t1", name: "T" })) + }) + + await waitFor(() => { + expect(useEditorStore.getState().syncStatus).toBe("unsaved") + }) + + const memory = readMemorySyncVersions() + expect(memory.localChangeVersion).toBe(6) + expect(memory.lastSyncedVersion).toBe(4) + + const draft = readDraftSyncVersions() + expect(draft).not.toBeNull() + expect(draft!.localChangeVersion).toBe(memory.localChangeVersion) + expect(draft!.lastSyncedVersion).toBe(memory.lastSyncedVersion) + }, + ) + + it( + "restoring from the draft after a successful save reloads the same version counters — " + + "simulating close and reopen without losing the fact that provider and draft agree", + async () => { + const saveTemplate = vi + .fn() + .mockResolvedValue(savedRecord({ id: "t-persist" })) + + useEditorStore.setState({ + templateId: "t-persist", + templateName: "N", + localChangeVersion: 1, + lastSyncedVersion: 0, + } as Parameters[0]) + + render( + + + , + ) + + fireEvent.click(screen.getByTestId("save-to-provider")) + await waitFor(() => { + expect(useEditorStore.getState().syncStatus).toBe("saved") + }) + + const session = readEditorSessionFromLocalStorage( + EDITOR_SESSION_STORAGE_KEY, + ) + expect(session).not.toBeNull() + + useEditorStore.getState().destroy() + expect(useEditorStore.getState().localChangeVersion).toBe(0) + + useEditorStore.getState().restoreSession(session!.state) + + expect(useEditorStore.getState().localChangeVersion).toBe(1) + expect(useEditorStore.getState().lastSyncedVersion).toBe(1) + }, + ) + + it("when provider save fails, the draft still reflects the error sync status so resume flow does not assume success", async () => { + const saveTemplate = vi.fn().mockRejectedValue(new Error("network")) + + useEditorStore.setState({ + templateId: "t1", + templateName: "T", + localChangeVersion: 3, + lastSyncedVersion: 2, + } as Parameters[0]) + + render( + + + , + ) + + fireEvent.click(screen.getByTestId("save-to-provider")) + await waitFor(() => { + expect(useEditorStore.getState().syncStatus).toBe("error") + }) + + const draft = readDraftSyncVersions() + expect(draft).not.toBeNull() + expect(draft!.syncStatus).toBe("error") + expect(draft!.localChangeVersion).toBe(3) + expect(draft!.lastSyncedVersion).toBe(2) + }) +}) + +describe("localStorage session drafts — debounce vs immediate persist", () => { + beforeEach(() => { + useEditorStore.getState().destroy() + window.localStorage.removeItem(EDITOR_SESSION_STORAGE_KEY) + vi.useFakeTimers() + }) + + afterEach(() => { + useEditorStore.getState().destroy() + window.localStorage.removeItem(EDITOR_SESSION_STORAGE_KEY) + vi.useRealTimers() + vi.restoreAllMocks() + }) + + function HookOnlyHarness(props: { paused?: boolean }) { + useEditorSessionLocalStorage(props.paused ?? false) + return null + } + + it( + "without waiting for the debounced draft write, calling persistEditorSessionNow still writes the latest " + + "lastSyncedVersion — avoiding a race where the draft would briefly stay on an older sync marker", + () => { + render() + + act(() => { + vi.runAllTimers() + }) + + useEditorStore.setState({ + templateName: "X", + templateId: "t1", + localChangeVersion: 3, + lastSyncedVersion: 2, + } as Parameters[0]) + + act(() => { + useEditorStore.getState().markSynced(3) + }) + + persistEditorSessionNow() + + const draft = readDraftSyncVersions() + expect(draft!.lastSyncedVersion).toBe(3) + expect(draft!.localChangeVersion).toBe(3) + }, + ) + + it( + "when persistence is paused (e.g. resume modal), persistEditorSessionNow does not overwrite localStorage — " + + "so an in-flight provider result cannot clobber the snapshot the user is deciding about", + () => { + render() + + act(() => { + vi.runAllTimers() + }) + + useEditorStore.setState({ + templateId: "t1", + templateName: "T", + localChangeVersion: 9, + lastSyncedVersion: 9, + } as Parameters[0]) + + persistEditorSessionNow() + + expect( + readEditorSessionFromLocalStorage(EDITOR_SESSION_STORAGE_KEY), + ).toBeNull() + }, + ) + + it( + "the hook schedules a debounced write when lastSyncedVersion changes without bumping localChangeVersion — " + + "so subscription-driven drafts stay aligned after markSynced", + () => { + render() + + act(() => { + vi.runAllTimers() + }) + + act(() => { + useEditorStore.setState({ + templateId: "t1", + templateName: "T", + localChangeVersion: 4, + lastSyncedVersion: 3, + } as Parameters[0]) + }) + + act(() => { + useEditorStore.getState().markSynced(4) + }) + + act(() => { + vi.advanceTimersByTime(PERSIST_DEBOUNCE_MS) + }) + + const draft = readDraftSyncVersions() + expect(draft!.localChangeVersion).toBe(4) + expect(draft!.lastSyncedVersion).toBe(4) + }, + ) +}) diff --git a/packages/imagekit-editor-dev/src/hooks/usePersistedEditorSession.test.tsx b/packages/imagekit-editor-dev/src/hooks/useEditorSessionLocalStorage.test.tsx similarity index 91% rename from packages/imagekit-editor-dev/src/hooks/usePersistedEditorSession.test.tsx rename to packages/imagekit-editor-dev/src/hooks/useEditorSessionLocalStorage.test.tsx index 7c19ee0..cd4bd6c 100644 --- a/packages/imagekit-editor-dev/src/hooks/usePersistedEditorSession.test.tsx +++ b/packages/imagekit-editor-dev/src/hooks/useEditorSessionLocalStorage.test.tsx @@ -3,14 +3,14 @@ import React from "react" import { beforeEach, describe, expect, it, vi } from "vitest" import { EDITOR_SESSION_STORAGE_KEY } from "../persistence/editorSessionStorage" import { useEditorStore } from "../store" -import { usePersistedEditorSession } from "./usePersistedEditorSession" +import { useEditorSessionLocalStorage } from "./useEditorSessionLocalStorage" function Harness(props: { paused: boolean }) { - usePersistedEditorSession(props.paused) + useEditorSessionLocalStorage(props.paused) return null } -describe("usePersistedEditorSession", () => { +describe("useEditorSessionLocalStorage", () => { beforeEach(() => { useEditorStore.getState().destroy() vi.useFakeTimers() diff --git a/packages/imagekit-editor-dev/src/hooks/useEditorSessionLocalStorage.ts b/packages/imagekit-editor-dev/src/hooks/useEditorSessionLocalStorage.ts new file mode 100644 index 0000000..7c2417b --- /dev/null +++ b/packages/imagekit-editor-dev/src/hooks/useEditorSessionLocalStorage.ts @@ -0,0 +1,93 @@ +import { useEffect } from "react" +import { shallow } from "zustand/shallow" +import { + buildPersistedEditorSession, + EDITOR_SESSION_STORAGE_KEY, + writeEditorSessionToLocalStorage, +} from "../persistence/editorSessionStorage" +import type { EditorStore } from "../store" +import { useEditorStore } from "../store" + +export const PERSIST_DEBOUNCE_MS = 150 + +/** When true, debounced writes and `persistEditorSessionNow` are skipped (e.g. resume modal open). */ +let sessionPersistencePaused = false + +function writeSessionSnapshot(): void { + const state = useEditorStore.getState() + const session = buildPersistedEditorSession(state) + writeEditorSessionToLocalStorage({ + key: EDITOR_SESSION_STORAGE_KEY, + session, + }) +} + +/** + * Writes the current editor store snapshot to localStorage immediately. + * Call after template sync completes so the draft matches `lastSyncedVersion` / metadata. + */ +export function persistEditorSessionNow(): void { + if (sessionPersistencePaused) return + writeSessionSnapshot() +} + +/** Fields that can change during template save/sync without bumping `localChangeVersion`. */ +function selectPostSyncPersistSlice(state: EditorStore) { + return { + lastSyncedVersion: state.lastSyncedVersion, + lastSavedAt: state.lastSavedAt, + syncStatus: state.syncStatus, + templateId: state.templateId, + templateName: state.templateName, + templateIsPrivate: state.templateIsPrivate, + isPristine: state.isPristine, + } +} + +export function useEditorSessionLocalStorage(paused: boolean) { + useEffect(() => { + sessionPersistencePaused = paused + }, [paused]) + + useEffect(() => { + return () => { + sessionPersistencePaused = false + } + }, []) + + useEffect(() => { + if (paused) return + + let timer: ReturnType | null = null + const schedulePersist = () => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + writeSessionSnapshot() + timer = null + }, PERSIST_DEBOUNCE_MS) + } + + const unsubVersion = useEditorStore.subscribe( + (s) => s.localChangeVersion, + schedulePersist, + ) + + const unsubSync = useEditorStore.subscribe( + selectPostSyncPersistSlice, + schedulePersist, + { equalityFn: shallow }, + ) + + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + writeSessionSnapshot() + timer = null + }, PERSIST_DEBOUNCE_MS) + + return () => { + unsubVersion() + unsubSync() + if (timer) clearTimeout(timer) + } + }, [paused]) +} diff --git a/packages/imagekit-editor-dev/src/hooks/usePersistedEditorSession.ts b/packages/imagekit-editor-dev/src/hooks/usePersistedEditorSession.ts deleted file mode 100644 index fd65562..0000000 --- a/packages/imagekit-editor-dev/src/hooks/usePersistedEditorSession.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useEffect } from "react" -import { - buildPersistedEditorSession, - EDITOR_SESSION_STORAGE_KEY, - writeEditorSessionToLocalStorage, -} from "../persistence/editorSessionStorage" -import { useEditorStore } from "../store" - -export const PERSIST_DEBOUNCE_MS = 150 - -export function usePersistedEditorSession(paused: boolean) { - useEffect(() => { - if (paused) return - - let timer: ReturnType | null = null - const persist = () => { - const state = useEditorStore.getState() - const session = buildPersistedEditorSession(state) - writeEditorSessionToLocalStorage({ - key: EDITOR_SESSION_STORAGE_KEY, - session, - }) - } - - const unsub = useEditorStore.subscribe( - (s) => s.localChangeVersion, - () => { - if (timer) clearTimeout(timer) - timer = setTimeout(persist, PERSIST_DEBOUNCE_MS) - }, - ) - - // Persist at least once after mount so a session exists even before edits. - // (Still cheap, and helps with abrupt refresh right after open.) - if (timer) clearTimeout(timer) - timer = setTimeout(persist, PERSIST_DEBOUNCE_MS) - - return () => { - unsub() - if (timer) clearTimeout(timer) - } - }, [paused]) -} diff --git a/packages/imagekit-editor-dev/src/hooks/useTemplateSync.ts b/packages/imagekit-editor-dev/src/hooks/useTemplateSync.ts index 6fe24a7..2ad016e 100644 --- a/packages/imagekit-editor-dev/src/hooks/useTemplateSync.ts +++ b/packages/imagekit-editor-dev/src/hooks/useTemplateSync.ts @@ -4,6 +4,7 @@ import type { SaveTemplateInput, TemplateRecord } from "../storage" import { isTemplateAccessDeniedError } from "../storage/templateAccessError" import { useEditorStore } from "../store" import { shouldMarkSyncedAfterSave } from "../sync/templateSyncVersioning" +import { persistEditorSessionNow } from "./useEditorSessionLocalStorage" export type SaveReason = | "manual" @@ -99,6 +100,7 @@ export function useTemplateSync() { return null } finally { savingRef.current = false + persistEditorSessionNow() } }, [provider], From 02e7e2191af9ca73b9f5133863183740b8addbb0 Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Tue, 12 May 2026 14:33:14 +0530 Subject: [PATCH 14/21] fix: renaming a transformation now occupies entire space and does not chunk --- .../components/sidebar/sortable-transformation-item.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx b/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx index 3f8f549..043ebeb 100644 --- a/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx +++ b/packages/imagekit-editor-dev/src/components/sidebar/sortable-transformation-item.tsx @@ -154,10 +154,12 @@ export const SortableTransformationItem = ({ {isRenaming ? ( - - + + - {/* Reserve space for right-side actions to avoid layout shift */} + {/* Reserve space for right-side actions to avoid layout shift; hide while renaming so the input spans the full row */} Date: Tue, 12 May 2026 14:56:03 +0530 Subject: [PATCH 15/21] fix: disabled save button in the template status dropdown when there are unapplied edits; also showing a toast when manual save is attempted during this state --- .../components/header/TemplateStatus.test.tsx | 22 +++++++ .../src/components/header/TemplateStatus.tsx | 19 ++++-- .../src/hooks/useSaveTemplate.test.tsx | 64 +++++++++++++++++-- .../src/hooks/useSaveTemplate.ts | 17 ++++- 4 files changed, 111 insertions(+), 11 deletions(-) diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.test.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.test.tsx index 630f69f..251a8c4 100644 --- a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.test.tsx +++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.test.tsx @@ -7,6 +7,7 @@ import { INTERVAL_SAVE_MS, useAutoSaveTemplate, } from "../../hooks/useAutoSaveTemplate" +import { APPLY_CHANGES_BEFORE_SAVE_MESSAGE } from "../../hooks/useSaveTemplate" import { useEditorStore } from "../../store" import { TransformationConfigSidebar } from "../sidebar/transformation-config-sidebar" import { TemplateStatus } from "./TemplateStatus" @@ -118,6 +119,27 @@ describe("TemplateStatus", () => { expect(screen.getByLabelText("template-status-unsaved")).toBeTruthy() }) + it("disables Save in the status popover while transformation config has unapplied edits", () => { + useEditorStore.setState({ + isPristine: true, + syncStatus: "saved", + localChangeVersion: 1, + lastSyncedVersion: 1, + transformationConfigFormDirty: true, + lastSavedAt: Date.now(), + } as unknown as Parameters[0]) + + renderWithProvider() + act(() => { + vi.advanceTimersByTime(3500) + }) + fireEvent.click(screen.getByLabelText("template-status-unsaved")) + expect(screen.getByText(APPLY_CHANGES_BEFORE_SAVE_MESSAGE)).toBeTruthy() + expect( + screen.getByRole("button", { name: /^save$/i }).hasAttribute("disabled"), + ).toBe(true) + }) + it("does not show the saved text while unsynced changes exist", () => { useEditorStore.setState({ isPristine: false, diff --git a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx index f17b117..4c9f06d 100644 --- a/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx +++ b/packages/imagekit-editor-dev/src/components/header/TemplateStatus.tsx @@ -16,7 +16,8 @@ import { MdSync } from "@react-icons/all-files/md/MdSync" import { MdSyncProblem } from "@react-icons/all-files/md/MdSyncProblem" import { useEffect, useRef, useState } from "react" import { useTemplateStorage } from "../../context/TemplateStorageContext" -import { useSaveTemplate } from "../../hooks/useSaveTemplate" +import { APPLY_CHANGES_BEFORE_SAVE_MESSAGE } from "../../hooks/useSaveTemplate" +import { useTemplateSync } from "../../hooks/useTemplateSync" import { useEditorStore } from "../../store" import { chakraAny } from "../../utils" @@ -37,13 +38,16 @@ export function TemplateStatus() { const templateStorageWriteBlocked = useEditorStore( (s) => s.templateStorageWriteBlocked, ) + const transformationConfigFormDirty = useEditorStore( + (s) => s.transformationConfigFormDirty, + ) const hasPendingLocalWork = useEditorStore( (s) => s.localChangeVersion !== s.lastSyncedVersion || s.transformationConfigFormDirty, ) const provider = useTemplateStorage() - const { save } = useSaveTemplate() + const { saveNow } = useTemplateSync() const [notificationVisible, setNotificationVisible] = useState(false) const [lastSyncResult, setLastSyncResult] = useState< @@ -242,14 +246,21 @@ export function TemplateStatus() { {popupBody} + {transformationConfigFormDirty && ( + + {APPLY_CHANGES_BEFORE_SAVE_MESSAGE} + + )} {isUnsavedState && ( void save()} + onClick={() => void saveNow({ reason: "manual" })} isLoading={syncStatus === "saving"} - isDisabled={templateStorageWriteBlocked} + isDisabled={ + templateStorageWriteBlocked || transformationConfigFormDirty + } > Save diff --git a/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.test.tsx b/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.test.tsx index 8dd4d6f..13e348d 100644 --- a/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.test.tsx +++ b/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.test.tsx @@ -1,8 +1,13 @@ -import { act, render, waitFor } from "@testing-library/react" +import { ChakraProvider } from "@chakra-ui/react" +import { act, render, screen, waitFor } from "@testing-library/react" +import type { ReactElement } from "react" import { beforeEach, describe, expect, it, vi } from "vitest" import { TemplateStorageContextProvider } from "../context/TemplateStorageContext" import { useEditorStore } from "../store" -import { useSaveTemplate } from "./useSaveTemplate" +import { + APPLY_CHANGES_BEFORE_SAVE_MESSAGE, + useSaveTemplate, +} from "./useSaveTemplate" function stubProvider(saveTemplate: ReturnType) { return { @@ -22,6 +27,10 @@ function MountSaveShortcut() { return null } +function renderWithChakra(ui: ReactElement) { + return render({ui}) +} + describe("useSaveTemplate", () => { beforeEach(() => { useEditorStore.getState().destroy() @@ -30,7 +39,7 @@ describe("useSaveTemplate", () => { it("does not register shortcut when provider is null", () => { const addSpy = vi.spyOn(window, "addEventListener") - render( + renderWithChakra( , @@ -57,7 +66,7 @@ describe("useSaveTemplate", () => { templateName: "T", } as Parameters[0]) - render( + renderWithChakra( @@ -81,6 +90,49 @@ describe("useSaveTemplate", () => { }) }) + it("does not save when transformation config has unapplied edits; shows toast", async () => { + const saveTemplate = vi.fn().mockResolvedValue({ + id: "t1", + clientNumber: "c1", + isPrivate: false, + name: "T", + transformations: [], + isPinned: false, + createdBy: { userId: "u1", name: "U", email: "u@x.com" }, + updatedBy: { userId: "u1", name: "U", email: "u@x.com" }, + createdAt: 1, + updatedAt: 2, + }) + useEditorStore.setState({ + templateId: "t1", + transformationConfigFormDirty: true, + } as Parameters[0]) + + renderWithChakra( + + + , + ) + + await act(async () => { + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "s", + metaKey: true, + bubbles: true, + cancelable: true, + }), + ) + }) + + await waitFor(() => { + expect(screen.getByText(APPLY_CHANGES_BEFORE_SAVE_MESSAGE)).toBeTruthy() + }) + expect(saveTemplate).not.toHaveBeenCalled() + }) + it("triggers save on Meta+S", async () => { const saveTemplate = vi.fn().mockResolvedValue({ id: "t1", @@ -98,7 +150,7 @@ describe("useSaveTemplate", () => { templateId: "t1", } as Parameters[0]) - render( + renderWithChakra( @@ -136,7 +188,7 @@ describe("useSaveTemplate", () => { updatedAt: 2, }) - const { unmount } = render( + const { unmount } = renderWithChakra( diff --git a/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.ts b/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.ts index 8c31f9b..77ff19f 100644 --- a/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.ts +++ b/packages/imagekit-editor-dev/src/hooks/useSaveTemplate.ts @@ -1,10 +1,16 @@ +import { useToast } from "@chakra-ui/react" import { useCallback, useEffect } from "react" import { useTemplateStorage } from "../context/TemplateStorageContext" +import { useEditorStore } from "../store" import { useTemplateSync } from "./useTemplateSync" +export const APPLY_CHANGES_BEFORE_SAVE_MESSAGE = + "You need to apply changes before you can save them." + export function useSaveTemplate() { const provider = useTemplateStorage() const { saveNow } = useTemplateSync() + const toast = useToast() const save = useCallback(() => saveNow({ reason: "manual" }), [saveNow]) useEffect(() => { @@ -13,13 +19,22 @@ export function useSaveTemplate() { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault() + if (useEditorStore.getState().transformationConfigFormDirty) { + toast({ + title: APPLY_CHANGES_BEFORE_SAVE_MESSAGE, + status: "warning", + duration: 4000, + isClosable: true, + }) + return + } void save() } } window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) - }, [provider, save]) + }, [provider, save, toast]) return { save } } From 266aee5c5088c18c5c7054452aac85ef8508a356 Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Tue, 12 May 2026 15:31:56 +0530 Subject: [PATCH 16/21] fix: initial visibility prop mapping to id instead of name - unused in the codebase currently --- packages/imagekit-editor-dev/src/store/initialState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/imagekit-editor-dev/src/store/initialState.ts b/packages/imagekit-editor-dev/src/store/initialState.ts index 4b508e0..94815b1 100644 --- a/packages/imagekit-editor-dev/src/store/initialState.ts +++ b/packages/imagekit-editor-dev/src/store/initialState.ts @@ -6,7 +6,7 @@ const initialVisibleTransformations: Record = {} function initTransformationStates(transformations: Transformation[]) { transformations.forEach((transformation) => { - initialVisibleTransformations[transformation.name] = true + initialVisibleTransformations[transformation.id] = true }) } From a781158600906b8b200447995c34940025e5119b Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Tue, 12 May 2026 16:45:56 +0530 Subject: [PATCH 17/21] feat: updated example project to match the consuming project's dom structure and theme settings --- examples/react-example/package.json | 7 ++- examples/react-example/src/index.tsx | 14 ++++- examples/react-example/src/theme/hostTheme.ts | 57 +++++++++++++++++++ yarn.lock | 15 ++--- 4 files changed, 81 insertions(+), 12 deletions(-) create mode 100644 examples/react-example/src/theme/hostTheme.ts diff --git a/examples/react-example/package.json b/examples/react-example/package.json index e498398..3e7de1b 100644 --- a/examples/react-example/package.json +++ b/examples/react-example/package.json @@ -3,10 +3,11 @@ "version": "0.1.0", "private": true, "dependencies": { + "@chakra-ui/hooks": "^1.7.1", "@chakra-ui/icons": "1.1.1", - "@chakra-ui/react": "~1.8.9", - "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.1", + "@chakra-ui/react": "^1.6.7", + "@emotion/react": "^11", + "@emotion/styled": "^11", "@imagekit/editor": "workspace:*", "@types/node": "^20.11.24", "@types/react": "^17.0.2", diff --git a/examples/react-example/src/index.tsx b/examples/react-example/src/index.tsx index 5d36144..0c6803a 100644 --- a/examples/react-example/src/index.tsx +++ b/examples/react-example/src/index.tsx @@ -1,3 +1,4 @@ +import { Box, ChakraProvider, Portal } from "@chakra-ui/react" import { ImageKitEditor, type ImageKitEditorProps, @@ -8,6 +9,7 @@ import { } from "@imagekit/editor" import React, { useCallback, useEffect } from "react" import ReactDOM from "react-dom" +import { hostTheme } from "./theme/hostTheme" const TEMPLATE_STORAGE_KEY = "ik-editor:templates:v1" @@ -422,7 +424,13 @@ function App() {
- {open && editorProps && } + {open && editorProps && ( + + + + + + )} ) } @@ -430,7 +438,9 @@ function App() { const root = document.getElementById("root") ReactDOM.render( - + + + , root, ) diff --git a/examples/react-example/src/theme/hostTheme.ts b/examples/react-example/src/theme/hostTheme.ts new file mode 100644 index 0000000..da1c3fe --- /dev/null +++ b/examples/react-example/src/theme/hostTheme.ts @@ -0,0 +1,57 @@ +import { extendTheme } from "@chakra-ui/react" + +/** + * Mirrors consuming project's theme's z-index.ts + * and the component overrides that reference those tokens (tooltip, modal, popover). + */ +const zIndices = { + hide: -1, + auto: "auto" as const, + base: 0, + docked: 10, + dropdown: 1000, + sticky: 1100, + banner: 1200, + overlay: 1300, + modal: 2100, + popover: 2000, + skipLink: 1600, + toast: 1700, + tooltip: 2200, +} + +export const hostTheme = extendTheme({ + zIndices, + styles: { + global: { + html: { overflow: "hidden" }, + }, + }, + components: { + Tooltip: { + baseStyle: { + zIndex: "tooltip", + }, + }, + Popover: { + baseStyle: { + popper: { + zIndex: "popover", + }, + }, + }, + Modal: { + baseStyle: { + overlay: { + zIndex: "modal", + }, + dialogContainer: { + zIndex: "modal", + }, + dialog: { + zIndex: "modal", + }, + }, + }, + }, +}) diff --git a/yarn.lock b/yarn.lock index f329572..a0504a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -683,7 +683,7 @@ __metadata: languageName: node linkType: hard -"@chakra-ui/hooks@npm:1.9.1": +"@chakra-ui/hooks@npm:1.9.1, @chakra-ui/hooks@npm:^1.7.1": version: 1.9.1 resolution: "@chakra-ui/hooks@npm:1.9.1" dependencies: @@ -986,7 +986,7 @@ __metadata: languageName: node linkType: hard -"@chakra-ui/react@npm:1.8.9, @chakra-ui/react@npm:~1.8.9": +"@chakra-ui/react@npm:1.8.9, @chakra-ui/react@npm:^1.6.7": version: 1.8.9 resolution: "@chakra-ui/react@npm:1.8.9" dependencies: @@ -1505,7 +1505,7 @@ __metadata: languageName: node linkType: hard -"@emotion/react@npm:^11.14.0, @emotion/react@npm:^11.8.1": +"@emotion/react@npm:^11, @emotion/react@npm:^11.14.0, @emotion/react@npm:^11.8.1": version: 11.14.0 resolution: "@emotion/react@npm:11.14.0" dependencies: @@ -1546,7 +1546,7 @@ __metadata: languageName: node linkType: hard -"@emotion/styled@npm:^11.14.1": +"@emotion/styled@npm:^11, @emotion/styled@npm:^11.14.1": version: 11.14.1 resolution: "@emotion/styled@npm:11.14.1" dependencies: @@ -6420,10 +6420,11 @@ __metadata: version: 0.0.0-use.local resolution: "react-example@workspace:examples/react-example" dependencies: + "@chakra-ui/hooks": "npm:^1.7.1" "@chakra-ui/icons": "npm:1.1.1" - "@chakra-ui/react": "npm:~1.8.9" - "@emotion/react": "npm:^11.14.0" - "@emotion/styled": "npm:^11.14.1" + "@chakra-ui/react": "npm:^1.6.7" + "@emotion/react": "npm:^11" + "@emotion/styled": "npm:^11" "@imagekit/editor": "workspace:*" "@types/node": "npm:^20.11.24" "@types/react": "npm:^17.0.2" From 18ede78390061b9b0b4dbb97b68c7ea4f2e2ade5 Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Tue, 12 May 2026 17:00:16 +0530 Subject: [PATCH 18/21] fix: added validations for gradient from and to colors --- .../src/schema/background.ts | 6 ++-- .../imagekit-editor-dev/src/schema/index.ts | 9 +++--- .../src/schema/transformation.ts | 29 +++++++++++++++++++ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/packages/imagekit-editor-dev/src/schema/background.ts b/packages/imagekit-editor-dev/src/schema/background.ts index 5bd72dc..abc4203 100644 --- a/packages/imagekit-editor-dev/src/schema/background.ts +++ b/packages/imagekit-editor-dev/src/schema/background.ts @@ -1,6 +1,6 @@ import { z } from "zod/v3" import type { TransformationField } from "." -import { colorValidator } from "./transformation" +import { colorValidator, gradientPickerColorValidator } from "./transformation" export const SUPPORTED_BACKGROUND_TYPES: Record< string, @@ -305,8 +305,8 @@ export const background = { backgroundGradientPaletteSize: z.string().optional(), backgroundGradient: z .object({ - from: z.string().optional(), - to: z.string().optional(), + from: gradientPickerColorValidator.optional(), + to: gradientPickerColorValidator.optional(), direction: z .union([ z.coerce diff --git a/packages/imagekit-editor-dev/src/schema/index.ts b/packages/imagekit-editor-dev/src/schema/index.ts index 1455b1e..bc1d227 100644 --- a/packages/imagekit-editor-dev/src/schema/index.ts +++ b/packages/imagekit-editor-dev/src/schema/index.ts @@ -25,6 +25,7 @@ import { import { colorValidator, commonNumberAndExpressionValidator, + gradientPickerColorValidator, heightValidator, layerXValidator, layerYValidator, @@ -418,8 +419,8 @@ const baseTransformationSchema: TransformationSchema[] = [ .object({ gradient: z .object({ - from: z.string().optional(), - to: z.string().optional(), + from: gradientPickerColorValidator.optional(), + to: gradientPickerColorValidator.optional(), direction: z .union([ z.coerce @@ -2176,8 +2177,8 @@ const baseTransformationSchema: TransformationSchema[] = [ .optional(), gradient: z .object({ - from: z.string().optional(), - to: z.string().optional(), + from: gradientPickerColorValidator.optional(), + to: gradientPickerColorValidator.optional(), direction: z .union([ z.coerce diff --git a/packages/imagekit-editor-dev/src/schema/transformation.ts b/packages/imagekit-editor-dev/src/schema/transformation.ts index 6b05c4e..6c42f47 100644 --- a/packages/imagekit-editor-dev/src/schema/transformation.ts +++ b/packages/imagekit-editor-dev/src/schema/transformation.ts @@ -55,6 +55,35 @@ export const colorValidator = z message: "Enter a valid hex colour code.", }) +/** Gradient picker colours: in-progress # + hex, complete values, or legacy hex without #. */ +export const gradientPickerColorValidator = z + .string() + .superRefine((val, ctx) => { + if (val === "") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter a valid hex colour code.", + }) + return + } + if (/^#[0-9A-Fa-f]{0,8}$/.test(val)) { + const hex = val.slice(1) + if (hex.length === 0) return + if ([1, 2, 4, 5, 7].includes(hex.length)) return + if (colorValidator.safeParse(val).success) return + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter a valid hex colour code.", + }) + return + } + if (colorValidator.safeParse(val).success) return + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter a valid hex colour code.", + }) + }) + const aspectRatioValueValidator = z .string() .regex(/^\d+(\.\d{1,2})?-\d+(\.\d{1,2})?$/) From 0d80a8f7a489ce8b96d445b061bbff0934cc5cd6 Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Tue, 12 May 2026 17:17:51 +0530 Subject: [PATCH 19/21] fix: increase number of items displayed in templates dropdown --- .../src/components/header/TemplatesDropdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx index 11064bf..de3e958 100644 --- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx +++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx @@ -60,7 +60,7 @@ const AvatarAny = chakraAny(Avatar) const SpinnerAny = chakraAny(Spinner) const TooltipAny = chakraAny(Tooltip) -const MAX_VISIBLE = 5 +const MAX_VISIBLE = 10 // --------------------------------------------------------------------------- // DropdownTemplateRow — extracted so hooks (useTemplatePermissions) can be used From 1ffa82a53e3ea93e886efabf364ec9c05796bc72 Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Tue, 12 May 2026 17:18:50 +0530 Subject: [PATCH 20/21] chore: bump version for testing --- packages/imagekit-editor-dev/package.json | 2 +- packages/imagekit-editor/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/imagekit-editor-dev/package.json b/packages/imagekit-editor-dev/package.json index 0d9c7b5..1e7e952 100644 --- a/packages/imagekit-editor-dev/package.json +++ b/packages/imagekit-editor-dev/package.json @@ -1,6 +1,6 @@ { "name": "imagekit-editor-dev", - "version": "3.0.0", + "version": "3.0.1-stage.1", "description": "AI Image Editor powered by ImageKit", "scripts": { "prepack": "yarn build", diff --git a/packages/imagekit-editor/package.json b/packages/imagekit-editor/package.json index 4211de8..b7e2e5a 100644 --- a/packages/imagekit-editor/package.json +++ b/packages/imagekit-editor/package.json @@ -1,6 +1,6 @@ { "name": "@imagekit/editor", - "version": "3.0.0", + "version": "3.0.1-stage.1", "description": "Image Editor powered by ImageKit", "main": "dist/index.cjs.js", "module": "dist/index.es.js", From e678cbb7aa4305f320b9b6b20d0a4eb3aeab0b82 Mon Sep 17 00:00:00 2001 From: Harshit Budhraja Date: Wed, 13 May 2026 14:00:47 +0530 Subject: [PATCH 21/21] chore: bump version to 3.0.1 for release --- packages/imagekit-editor-dev/package.json | 2 +- packages/imagekit-editor/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/imagekit-editor-dev/package.json b/packages/imagekit-editor-dev/package.json index 1e7e952..92173bc 100644 --- a/packages/imagekit-editor-dev/package.json +++ b/packages/imagekit-editor-dev/package.json @@ -1,6 +1,6 @@ { "name": "imagekit-editor-dev", - "version": "3.0.1-stage.1", + "version": "3.0.1", "description": "AI Image Editor powered by ImageKit", "scripts": { "prepack": "yarn build", diff --git a/packages/imagekit-editor/package.json b/packages/imagekit-editor/package.json index b7e2e5a..3837431 100644 --- a/packages/imagekit-editor/package.json +++ b/packages/imagekit-editor/package.json @@ -1,6 +1,6 @@ { "name": "@imagekit/editor", - "version": "3.0.1-stage.1", + "version": "3.0.1", "description": "Image Editor powered by ImageKit", "main": "dist/index.cjs.js", "module": "dist/index.es.js",