Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1dc7e50
fix: resumable last session from localStorage
hrhv May 5, 2026
f513690
fix: noise on blank state; now new templates will never be created by…
hrhv May 5, 2026
a8a6128
fix: skeleton loaders in templates dropdown and library modal + fixed…
hrhv May 5, 2026
84ffdc6
fix: fixed gradient color picker crashing when clearing from color or…
hrhv May 5, 2026
f77060d
fix: layout shift in transformation name component due to overflowing…
hrhv May 5, 2026
6637c3a
fix: height of new button in template dropdown
hrhv May 5, 2026
274dbe8
chore: update example project to make sure it runs with the latest pa…
hrhv May 6, 2026
d90d93a
chore: add yalc for local package linking and development workflow
ahnv May 6, 2026
2f9de99
fix: resume modal behavior conditions to respect the current change n…
hrhv May 12, 2026
2926374
ci: disabled yalc publish in test environments
hrhv May 12, 2026
2200f2e
chore: improved test coverage
hrhv May 12, 2026
d83d546
refactor: store split into slices and test cases to ensure nothing br…
hrhv May 12, 2026
c8e7687
Merge remote-tracking branch 'origin/main' into ux-improvements
hrhv May 12, 2026
1d91839
fix: issues with race conditions in drafts and provider syncs
hrhv May 12, 2026
02e7e21
fix: renaming a transformation now occupies entire space and does not…
hrhv May 12, 2026
704d9ae
fix: disabled save button in the template status dropdown when there …
hrhv May 12, 2026
266aee5
fix: initial visibility prop mapping to id instead of name - unused i…
hrhv May 12, 2026
a781158
feat: updated example project to match the consuming project's dom st…
hrhv May 12, 2026
18ede78
fix: added validations for gradient from and to colors
hrhv May 12, 2026
0d80a8f
fix: increase number of items displayed in templates dropdown
hrhv May 12, 2026
1ffa82a
chore: bump version for testing
hrhv May 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions examples/react-example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@chakra-ui/hooks": "^1.7.1",
"@chakra-ui/icons": "1.1.1",
"@chakra-ui/react": "~1.8.9",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@chakra-ui/react": "^1.6.7",
"@emotion/react": "^11",
"@emotion/styled": "^11",
"@imagekit/editor": "workspace:*",
"@types/node": "^20.11.24",
"@types/react": "^17.0.2",
Expand Down
14 changes: 12 additions & 2 deletions examples/react-example/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Box, ChakraProvider, Portal } from "@chakra-ui/react"
import {
ImageKitEditor,
type ImageKitEditorProps,
Expand All @@ -8,6 +9,7 @@ import {
} from "@imagekit/editor"
import React, { useCallback, useEffect } from "react"
import ReactDOM from "react-dom"
import { hostTheme } from "./theme/hostTheme"

const TEMPLATE_STORAGE_KEY = "ik-editor:templates:v1"

Expand Down Expand Up @@ -422,15 +424,23 @@ function App() {
</div>
</div>

{open && editorProps && <ImageKitEditor {...editorProps} ref={ref} />}
{open && editorProps && (
<Portal>
<Box zIndex="modal" position="relative">
<ImageKitEditor {...editorProps} ref={ref} />
</Box>
</Portal>
)}
</>
)
}

const root = document.getElementById("root")
ReactDOM.render(
<React.StrictMode>
<App />
<ChakraProvider theme={hostTheme}>
<App />
</ChakraProvider>
</React.StrictMode>,
root,
)
57 changes: 57 additions & 0 deletions examples/react-example/src/theme/hostTheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { extendTheme } from "@chakra-ui/react"

/**
* Mirrors consuming project's theme's z-index.ts
* and the component overrides that reference those tokens (tooltip, modal, popover).
*/
const zIndices = {
hide: -1,
auto: "auto" as const,
base: 0,
docked: 10,
dropdown: 1000,
sticky: 1100,
banner: 1200,
overlay: 1300,
modal: 2100,
popover: 2000,
skipLink: 1600,
toast: 1700,
tooltip: 2200,
}

export const hostTheme = extendTheme({
zIndices,
styles: {
global: {
html: { overflow: "hidden" },
},
},
components: {
Tooltip: {
baseStyle: {
zIndex: "tooltip",
},
},
Popover: {
baseStyle: {
popper: {
zIndex: "popover",
},
},
},
Modal: {
baseStyle: {
overlay: {
zIndex: "modal",
},
dialogContainer: {
zIndex: "modal",
},
dialog: {
zIndex: "modal",
},
},
},
},
})
2 changes: 1 addition & 1 deletion packages/imagekit-editor-dev/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "imagekit-editor-dev",
"version": "3.0.0",
"version": "3.0.1-stage.1",
"description": "AI Image Editor powered by ImageKit",
"scripts": {
"prepack": "yarn build",
Expand Down
156 changes: 156 additions & 0 deletions packages/imagekit-editor-dev/src/ImageKitEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import "@testing-library/jest-dom/vitest"
import { render, screen, waitFor } from "@testing-library/react"
import React from "react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { ImageKitEditor } from "./ImageKitEditor"
import {
EDITOR_SESSION_STORAGE_KEY,
EDITOR_SESSION_STORAGE_VERSION,
} from "./persistence/editorSessionStorage"
import type { TemplateStorageProvider } from "./storage"
import { useEditorStore } from "./store"

const RESUME_HEADING = "Resume previous session?"

function stubTemplateStorage(): TemplateStorageProvider {
return {
getProviderName: () => "test",
getCurrentUserSession: () => ({}),
listTemplates: async () => [],
getTemplate: async () => null,
saveTemplate: async (record) => ({
id: record.id ?? "t-new",
clientNumber: "c1",
isPrivate: record.isPrivate ?? false,
name: record.name,
transformations: record.transformations ?? [],
isPinned: false,
createdBy: { userId: "u1", name: "U", email: "u@example.com" },
updatedBy: { userId: "u1", name: "U", email: "u@example.com" },
createdAt: Date.now(),
updatedAt: Date.now(),
}),
setTemplatePinned: async () => {
throw new Error("not used")
},
}
}

function writeLastSessionToLocalStorage(args: {
localChangeVersion: number
lastSyncedVersion: number
isPristine: boolean
}) {
const session = {
v: EDITOR_SESSION_STORAGE_VERSION,
savedAt: Date.now(),
state: {
transformations: [],
visibleTransformations: {},
templateName: "Untitled Template",
templateId: null,
templateIsPrivate: null,
syncStatus: "saved" as const,
isPristine: args.isPristine,
localChangeVersion: args.localChangeVersion,
lastSyncedVersion: args.lastSyncedVersion,
lastSavedAt: Date.now(),
},
}
window.localStorage.setItem(
EDITOR_SESSION_STORAGE_KEY,
JSON.stringify(session),
)
}

describe("ImageKitEditor resume session modal", () => {
beforeEach(() => {
useEditorStore.getState().destroy()
window.localStorage.removeItem(EDITOR_SESSION_STORAGE_KEY)
})

afterEach(() => {
useEditorStore.getState().destroy()
window.localStorage.removeItem(EDITOR_SESSION_STORAGE_KEY)
vi.restoreAllMocks()
})

it("does not show resume modal when localStorage is empty", async () => {
render(
<ImageKitEditor
onClose={() => {}}
templateStorage={stubTemplateStorage()}
/>,
)

await waitFor(() => {
expect(screen.queryByText(RESUME_HEADING)).not.toBeInTheDocument()
})
})

it("with template storage: does not show resume modal when versions are in sync", async () => {
writeLastSessionToLocalStorage({
localChangeVersion: 3,
lastSyncedVersion: 3,
isPristine: false,
})

render(
<ImageKitEditor
onClose={() => {}}
templateStorage={stubTemplateStorage()}
/>,
)

await waitFor(() => {
expect(screen.queryByText(RESUME_HEADING)).not.toBeInTheDocument()
})
})

it("with template storage: shows resume modal when local changes are ahead of last sync", async () => {
writeLastSessionToLocalStorage({
localChangeVersion: 4,
lastSyncedVersion: 2,
isPristine: true,
})

render(
<ImageKitEditor
onClose={() => {}}
templateStorage={stubTemplateStorage()}
/>,
)

await waitFor(() => {
expect(screen.getByText(RESUME_HEADING)).toBeInTheDocument()
})
})

it("without template storage: does not show resume modal when session is pristine", async () => {
writeLastSessionToLocalStorage({
localChangeVersion: 0,
lastSyncedVersion: 0,
isPristine: true,
})

render(<ImageKitEditor onClose={() => {}} />)

await waitFor(() => {
expect(screen.queryByText(RESUME_HEADING)).not.toBeInTheDocument()
})
})

it("without template storage: shows resume modal when session is not pristine", async () => {
writeLastSessionToLocalStorage({
localChangeVersion: 1,
lastSyncedVersion: 1,
isPristine: false,
})

render(<ImageKitEditor onClose={() => {}} />)

await waitFor(() => {
expect(screen.getByText(RESUME_HEADING)).toBeInTheDocument()
})
})
})
50 changes: 49 additions & 1 deletion packages/imagekit-editor-dev/src/ImageKitEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,23 @@ import React, {
useCallback,
useImperativeHandle,
useMemo,
useState,
} from "react"
import { EditorLayout, EditorWrapper } from "./components/editor"
import {
EditorLayout,
EditorWrapper,
ResumeSessionModal,
} from "./components/editor"
import type { HeaderProps } from "./components/header"
import type { GetTemplatePermissions } from "./context/TemplatePermissionsContext"
import { TemplatePermissionsContextProvider } from "./context/TemplatePermissionsContext"
import { TemplateStorageContextProvider } from "./context/TemplateStorageContext"
import {
clearEditorSessionFromLocalStorage,
EDITOR_SESSION_STORAGE_KEY,
type PersistedEditorSession,
readEditorSessionFromLocalStorage,
} from "./persistence/editorSessionStorage"
import {
isTemplateAccessDeniedError,
type TemplateStorageProvider,
Expand Down Expand Up @@ -125,6 +136,22 @@ function ImageKitEditorImpl<M extends RequiredMetadata>(
[templateStorage],
)

const [resumeSession, setResumeSession] =
useState<PersistedEditorSession | null>(null)

React.useEffect(() => {
const resumableSession = readEditorSessionFromLocalStorage(
EDITOR_SESSION_STORAGE_KEY,
)
if (!resumableSession) return
const persisted = resumableSession.state
const hasUnsavedChanges = resolvedProvider
? persisted.localChangeVersion !== persisted.lastSyncedVersion
: !persisted.isPristine
if (!hasUnsavedChanges) return
setResumeSession(resumableSession)
}, [resolvedProvider])

const saveTemplateImperative = useCallback(async () => {
// Avoid importing hooks here; implement via store+provider with version gating.
if (!resolvedProvider) return
Expand Down Expand Up @@ -241,7 +268,28 @@ function ImageKitEditorImpl<M extends RequiredMetadata>(
onAddImage={props.onAddImage}
onClose={handleOnClose}
exportOptions={props.exportOptions}
pauseLocalSessionPersistence={Boolean(resumeSession)}
/>
{resumeSession ? (
<ResumeSessionModal
onRestore={() => {
useEditorStore
.getState()
.restoreSession(resumeSession.state)
setResumeSession(null)
}}
onStartNew={() => {
clearEditorSessionFromLocalStorage(
EDITOR_SESSION_STORAGE_KEY,
)
useEditorStore.getState().resetToNewTemplate()
setResumeSession(null)
}}
onCloseEditor={() => {
handleOnClose()
}}
/>
) : null}
</EditorWrapper>
</TemplateStorageContextProvider>
</TemplatePermissionsContextProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export type GradientPickerState = {

type DirectionMode = "direction" | "degrees"

function isCompleteHexColor(value: string): boolean {
// Accept #RRGGBB and #RRGGBBAA. (Inputs may be temporarily incomplete while typing.)
return /^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(value)
}

function rgbaToHex(rgba: string): string {
const parts = rgba.match(/[\d.]+/g)?.map(Number) ?? []

Expand Down Expand Up @@ -65,18 +70,26 @@ const GradientPickerField = ({
errors?: FieldErrors<Record<string, unknown>>
}) => {
function getLinearGradientString(value: GradientPickerState): string {
// NOTE: The gradient parser used by the picker is strict and crashes on
// invalid/incomplete color tokens (e.g. empty string when clearing inputs).
// Keep the preview gradient always valid by falling back to defaults.
const fromColor = isCompleteHexColor(value.from) ? value.from : "#FFFFFFFF"
const toColor = isCompleteHexColor(value.to) ? value.to : "#00000000"

let direction = ""
const dirInt = Number(value.direction as string)
if (!Number.isNaN(dirInt)) {
direction = `${dirInt}deg`
} else {
direction = `to ${String(value.direction).split("_").join(" ")}`
const dirString = String(value.direction || "bottom")
direction = `to ${dirString.split("_").join(" ")}`
}
const stopPoint =
typeof value.stopPoint === "number"
? value.stopPoint
: Number(value.stopPoint)
return `linear-gradient(${direction}, ${value.from} 0%, ${value.to} ${stopPoint}%)`
const safeStopPoint = Number.isFinite(stopPoint) ? stopPoint : 100
return `linear-gradient(${direction}, ${fromColor} 0%, ${toColor} ${safeStopPoint}%)`
}

const [localValue, setLocalValue] = useState<GradientPickerState>(
Expand Down
Loading
Loading