Skip to content

Commit 3d641f6

Browse files
authored
Merge pull request #12 from imagekit-developer/ux-improvements
Improvements and Bug Fixes
2 parents 25363e2 + e678cbb commit 3d641f6

59 files changed

Lines changed: 5485 additions & 1412 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

examples/react-example/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
"version": "0.1.0",
44
"private": true,
55
"dependencies": {
6+
"@chakra-ui/hooks": "^1.7.1",
67
"@chakra-ui/icons": "1.1.1",
7-
"@chakra-ui/react": "~1.8.9",
8-
"@emotion/react": "^11.14.0",
9-
"@emotion/styled": "^11.14.1",
8+
"@chakra-ui/react": "^1.6.7",
9+
"@emotion/react": "^11",
10+
"@emotion/styled": "^11",
1011
"@imagekit/editor": "workspace:*",
1112
"@types/node": "^20.11.24",
1213
"@types/react": "^17.0.2",

examples/react-example/src/index.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Box, ChakraProvider, Portal } from "@chakra-ui/react"
12
import {
23
ImageKitEditor,
34
type ImageKitEditorProps,
@@ -8,6 +9,7 @@ import {
89
} from "@imagekit/editor"
910
import React, { useCallback, useEffect } from "react"
1011
import ReactDOM from "react-dom"
12+
import { hostTheme } from "./theme/hostTheme"
1113

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

@@ -422,15 +424,23 @@ function App() {
422424
</div>
423425
</div>
424426

425-
{open && editorProps && <ImageKitEditor {...editorProps} ref={ref} />}
427+
{open && editorProps && (
428+
<Portal>
429+
<Box zIndex="modal" position="relative">
430+
<ImageKitEditor {...editorProps} ref={ref} />
431+
</Box>
432+
</Portal>
433+
)}
426434
</>
427435
)
428436
}
429437

430438
const root = document.getElementById("root")
431439
ReactDOM.render(
432440
<React.StrictMode>
433-
<App />
441+
<ChakraProvider theme={hostTheme}>
442+
<App />
443+
</ChakraProvider>
434444
</React.StrictMode>,
435445
root,
436446
)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { extendTheme } from "@chakra-ui/react"
2+
3+
/**
4+
* Mirrors consuming project's theme's z-index.ts
5+
* and the component overrides that reference those tokens (tooltip, modal, popover).
6+
*/
7+
const zIndices = {
8+
hide: -1,
9+
auto: "auto" as const,
10+
base: 0,
11+
docked: 10,
12+
dropdown: 1000,
13+
sticky: 1100,
14+
banner: 1200,
15+
overlay: 1300,
16+
modal: 2100,
17+
popover: 2000,
18+
skipLink: 1600,
19+
toast: 1700,
20+
tooltip: 2200,
21+
}
22+
23+
export const hostTheme = extendTheme({
24+
zIndices,
25+
styles: {
26+
global: {
27+
html: { overflow: "hidden" },
28+
},
29+
},
30+
components: {
31+
Tooltip: {
32+
baseStyle: {
33+
zIndex: "tooltip",
34+
},
35+
},
36+
Popover: {
37+
baseStyle: {
38+
popper: {
39+
zIndex: "popover",
40+
},
41+
},
42+
},
43+
Modal: {
44+
baseStyle: {
45+
overlay: {
46+
zIndex: "modal",
47+
},
48+
dialogContainer: {
49+
zIndex: "modal",
50+
},
51+
dialog: {
52+
zIndex: "modal",
53+
},
54+
},
55+
},
56+
},
57+
})

packages/imagekit-editor-dev/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "imagekit-editor-dev",
3-
"version": "3.0.0",
3+
"version": "3.0.1",
44
"description": "AI Image Editor powered by ImageKit",
55
"scripts": {
66
"prepack": "yarn build",
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import "@testing-library/jest-dom/vitest"
2+
import { render, screen, waitFor } from "@testing-library/react"
3+
import React from "react"
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
5+
import { ImageKitEditor } from "./ImageKitEditor"
6+
import {
7+
EDITOR_SESSION_STORAGE_KEY,
8+
EDITOR_SESSION_STORAGE_VERSION,
9+
} from "./persistence/editorSessionStorage"
10+
import type { TemplateStorageProvider } from "./storage"
11+
import { useEditorStore } from "./store"
12+
13+
const RESUME_HEADING = "Resume previous session?"
14+
15+
function stubTemplateStorage(): TemplateStorageProvider {
16+
return {
17+
getProviderName: () => "test",
18+
getCurrentUserSession: () => ({}),
19+
listTemplates: async () => [],
20+
getTemplate: async () => null,
21+
saveTemplate: async (record) => ({
22+
id: record.id ?? "t-new",
23+
clientNumber: "c1",
24+
isPrivate: record.isPrivate ?? false,
25+
name: record.name,
26+
transformations: record.transformations ?? [],
27+
isPinned: false,
28+
createdBy: { userId: "u1", name: "U", email: "u@example.com" },
29+
updatedBy: { userId: "u1", name: "U", email: "u@example.com" },
30+
createdAt: Date.now(),
31+
updatedAt: Date.now(),
32+
}),
33+
setTemplatePinned: async () => {
34+
throw new Error("not used")
35+
},
36+
}
37+
}
38+
39+
function writeLastSessionToLocalStorage(args: {
40+
localChangeVersion: number
41+
lastSyncedVersion: number
42+
isPristine: boolean
43+
}) {
44+
const session = {
45+
v: EDITOR_SESSION_STORAGE_VERSION,
46+
savedAt: Date.now(),
47+
state: {
48+
transformations: [],
49+
visibleTransformations: {},
50+
templateName: "Untitled Template",
51+
templateId: null,
52+
templateIsPrivate: null,
53+
syncStatus: "saved" as const,
54+
isPristine: args.isPristine,
55+
localChangeVersion: args.localChangeVersion,
56+
lastSyncedVersion: args.lastSyncedVersion,
57+
lastSavedAt: Date.now(),
58+
},
59+
}
60+
window.localStorage.setItem(
61+
EDITOR_SESSION_STORAGE_KEY,
62+
JSON.stringify(session),
63+
)
64+
}
65+
66+
describe("ImageKitEditor resume session modal", () => {
67+
beforeEach(() => {
68+
useEditorStore.getState().destroy()
69+
window.localStorage.removeItem(EDITOR_SESSION_STORAGE_KEY)
70+
})
71+
72+
afterEach(() => {
73+
useEditorStore.getState().destroy()
74+
window.localStorage.removeItem(EDITOR_SESSION_STORAGE_KEY)
75+
vi.restoreAllMocks()
76+
})
77+
78+
it("does not show resume modal when localStorage is empty", async () => {
79+
render(
80+
<ImageKitEditor
81+
onClose={() => {}}
82+
templateStorage={stubTemplateStorage()}
83+
/>,
84+
)
85+
86+
await waitFor(() => {
87+
expect(screen.queryByText(RESUME_HEADING)).not.toBeInTheDocument()
88+
})
89+
})
90+
91+
it("with template storage: does not show resume modal when versions are in sync", async () => {
92+
writeLastSessionToLocalStorage({
93+
localChangeVersion: 3,
94+
lastSyncedVersion: 3,
95+
isPristine: false,
96+
})
97+
98+
render(
99+
<ImageKitEditor
100+
onClose={() => {}}
101+
templateStorage={stubTemplateStorage()}
102+
/>,
103+
)
104+
105+
await waitFor(() => {
106+
expect(screen.queryByText(RESUME_HEADING)).not.toBeInTheDocument()
107+
})
108+
})
109+
110+
it("with template storage: shows resume modal when local changes are ahead of last sync", async () => {
111+
writeLastSessionToLocalStorage({
112+
localChangeVersion: 4,
113+
lastSyncedVersion: 2,
114+
isPristine: true,
115+
})
116+
117+
render(
118+
<ImageKitEditor
119+
onClose={() => {}}
120+
templateStorage={stubTemplateStorage()}
121+
/>,
122+
)
123+
124+
await waitFor(() => {
125+
expect(screen.getByText(RESUME_HEADING)).toBeInTheDocument()
126+
})
127+
})
128+
129+
it("without template storage: does not show resume modal when session is pristine", async () => {
130+
writeLastSessionToLocalStorage({
131+
localChangeVersion: 0,
132+
lastSyncedVersion: 0,
133+
isPristine: true,
134+
})
135+
136+
render(<ImageKitEditor onClose={() => {}} />)
137+
138+
await waitFor(() => {
139+
expect(screen.queryByText(RESUME_HEADING)).not.toBeInTheDocument()
140+
})
141+
})
142+
143+
it("without template storage: shows resume modal when session is not pristine", async () => {
144+
writeLastSessionToLocalStorage({
145+
localChangeVersion: 1,
146+
lastSyncedVersion: 1,
147+
isPristine: false,
148+
})
149+
150+
render(<ImageKitEditor onClose={() => {}} />)
151+
152+
await waitFor(() => {
153+
expect(screen.getByText(RESUME_HEADING)).toBeInTheDocument()
154+
})
155+
})
156+
})

packages/imagekit-editor-dev/src/ImageKitEditor.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,23 @@ import React, {
66
useCallback,
77
useImperativeHandle,
88
useMemo,
9+
useState,
910
} from "react"
10-
import { EditorLayout, EditorWrapper } from "./components/editor"
11+
import {
12+
EditorLayout,
13+
EditorWrapper,
14+
ResumeSessionModal,
15+
} from "./components/editor"
1116
import type { HeaderProps } from "./components/header"
1217
import type { GetTemplatePermissions } from "./context/TemplatePermissionsContext"
1318
import { TemplatePermissionsContextProvider } from "./context/TemplatePermissionsContext"
1419
import { TemplateStorageContextProvider } from "./context/TemplateStorageContext"
20+
import {
21+
clearEditorSessionFromLocalStorage,
22+
EDITOR_SESSION_STORAGE_KEY,
23+
type PersistedEditorSession,
24+
readEditorSessionFromLocalStorage,
25+
} from "./persistence/editorSessionStorage"
1526
import {
1627
isTemplateAccessDeniedError,
1728
type TemplateStorageProvider,
@@ -125,6 +136,22 @@ function ImageKitEditorImpl<M extends RequiredMetadata>(
125136
[templateStorage],
126137
)
127138

139+
const [resumeSession, setResumeSession] =
140+
useState<PersistedEditorSession | null>(null)
141+
142+
React.useEffect(() => {
143+
const resumableSession = readEditorSessionFromLocalStorage(
144+
EDITOR_SESSION_STORAGE_KEY,
145+
)
146+
if (!resumableSession) return
147+
const persisted = resumableSession.state
148+
const hasUnsavedChanges = resolvedProvider
149+
? persisted.localChangeVersion !== persisted.lastSyncedVersion
150+
: !persisted.isPristine
151+
if (!hasUnsavedChanges) return
152+
setResumeSession(resumableSession)
153+
}, [resolvedProvider])
154+
128155
const saveTemplateImperative = useCallback(async () => {
129156
// Avoid importing hooks here; implement via store+provider with version gating.
130157
if (!resolvedProvider) return
@@ -241,7 +268,28 @@ function ImageKitEditorImpl<M extends RequiredMetadata>(
241268
onAddImage={props.onAddImage}
242269
onClose={handleOnClose}
243270
exportOptions={props.exportOptions}
271+
pauseLocalSessionPersistence={Boolean(resumeSession)}
244272
/>
273+
{resumeSession ? (
274+
<ResumeSessionModal
275+
onRestore={() => {
276+
useEditorStore
277+
.getState()
278+
.restoreSession(resumeSession.state)
279+
setResumeSession(null)
280+
}}
281+
onStartNew={() => {
282+
clearEditorSessionFromLocalStorage(
283+
EDITOR_SESSION_STORAGE_KEY,
284+
)
285+
useEditorStore.getState().resetToNewTemplate()
286+
setResumeSession(null)
287+
}}
288+
onCloseEditor={() => {
289+
handleOnClose()
290+
}}
291+
/>
292+
) : null}
245293
</EditorWrapper>
246294
</TemplateStorageContextProvider>
247295
</TemplatePermissionsContextProvider>

packages/imagekit-editor-dev/src/components/common/GradientPicker.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export type GradientPickerState = {
2828

2929
type DirectionMode = "direction" | "degrees"
3030

31+
function isCompleteHexColor(value: string): boolean {
32+
// Accept #RRGGBB and #RRGGBBAA. (Inputs may be temporarily incomplete while typing.)
33+
return /^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(value)
34+
}
35+
3136
function rgbaToHex(rgba: string): string {
3237
const parts = rgba.match(/[\d.]+/g)?.map(Number) ?? []
3338

@@ -65,18 +70,26 @@ const GradientPickerField = ({
6570
errors?: FieldErrors<Record<string, unknown>>
6671
}) => {
6772
function getLinearGradientString(value: GradientPickerState): string {
73+
// NOTE: The gradient parser used by the picker is strict and crashes on
74+
// invalid/incomplete color tokens (e.g. empty string when clearing inputs).
75+
// Keep the preview gradient always valid by falling back to defaults.
76+
const fromColor = isCompleteHexColor(value.from) ? value.from : "#FFFFFFFF"
77+
const toColor = isCompleteHexColor(value.to) ? value.to : "#00000000"
78+
6879
let direction = ""
6980
const dirInt = Number(value.direction as string)
7081
if (!Number.isNaN(dirInt)) {
7182
direction = `${dirInt}deg`
7283
} else {
73-
direction = `to ${String(value.direction).split("_").join(" ")}`
84+
const dirString = String(value.direction || "bottom")
85+
direction = `to ${dirString.split("_").join(" ")}`
7486
}
7587
const stopPoint =
7688
typeof value.stopPoint === "number"
7789
? value.stopPoint
7890
: Number(value.stopPoint)
79-
return `linear-gradient(${direction}, ${value.from} 0%, ${value.to} ${stopPoint}%)`
91+
const safeStopPoint = Number.isFinite(stopPoint) ? stopPoint : 100
92+
return `linear-gradient(${direction}, ${fromColor} 0%, ${toColor} ${safeStopPoint}%)`
8093
}
8194

8295
const [localValue, setLocalValue] = useState<GradientPickerState>(

0 commit comments

Comments
 (0)