>
+}) {
+ 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/useTemplateSync.ts b/packages/imagekit-editor-dev/src/hooks/useTemplateSync.ts
index 3c53922..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"
@@ -29,6 +30,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
@@ -91,6 +100,7 @@ export function useTemplateSync() {
return null
} finally {
savingRef.current = false
+ persistEditorSessionNow()
}
},
[provider],
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 (
+
+ )
+}
+
+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/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/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})?$/)
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..42b5105
--- /dev/null
+++ b/packages/imagekit-editor-dev/src/store.test.ts
@@ -0,0 +1,561 @@
+/**
+ * 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,
+ 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/src/store.ts b/packages/imagekit-editor-dev/src/store.ts
deleted file mode 100644
index 3d4e2fb..0000000
--- a/packages/imagekit-editor-dev/src/store.ts
+++ /dev/null
@@ -1,1057 +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
- /**
- * 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,
- },
- })
- },
-
- 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..94815b1
--- /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.id] = 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 85621b4..6b6c52e 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,
@@ -43,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/**",
+ "src/storage/types.ts",
+ "src/store/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,
},
},
},
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",
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"