diff --git a/.gitignore b/.gitignore index d208200..5f8f31b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,6 @@ packages/imagekit-editor/*.tgz builds packages/imagekit-editor/README.md .cursor -coverage \ No newline at end of file +coverage +.yalc +yalc.lock diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..e1d9cb9 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,67 @@ +# Development + +## Prerequisites + +- Node.js v20 (use `nvm use`) +- Yarn 4 (via Corepack) +- [yalc](https://github.com/wclr/yalc) (included as a devDependency) + +## Getting Started + +```bash +nvm use +yarn install +yarn dev +``` + +`yarn dev` runs vite in watch mode and **automatically publishes `@imagekit/editor` to the local yalc store** on every rebuild. + +## Linking to External Projects + +Use yalc to test `@imagekit/editor` in any project outside this monorepo: + +### 1. Start dev mode (this repo) + +```bash +yarn dev +``` + +This watches for source changes, rebuilds, and runs `yalc publish --push` automatically after each build. + +### 2. Install yalc globally (required for consuming projects) + +```bash +npm i -g yalc +``` + +### 3. Link in your consuming project + +```bash +# In your external project directory +yalc link @imagekit/editor +``` + +This creates a symlink to the yalc store. Every time the editor rebuilds, your project receives the update automatically via `--push`. + +### 4. Remove the link when done + +```bash +# In your external project directory +yalc remove @imagekit/editor +``` + +## Build + +```bash +yarn build +``` + +Produces the production bundle in `packages/imagekit-editor/dist/`. + +## Package + +```bash +yarn package +``` + +Creates a `.tgz` tarball in `builds/` for manual distribution or testing. diff --git a/package.json b/package.json index dd6d0d6..2457d7f 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "lint-staged": "^16.1.2", "shx": "^0.4.0", "turbo": "^2.0.1", - "vitest": "^2.1.9" + "vitest": "^2.1.9", + "yalc": "^1.0.0-pre.53" }, "packageManager": "yarn@4.9.2", "lint-staged": { diff --git a/packages/imagekit-editor-dev/package.json b/packages/imagekit-editor-dev/package.json index 0d9c7b5..6783aa4 100644 --- a/packages/imagekit-editor-dev/package.json +++ b/packages/imagekit-editor-dev/package.json @@ -50,7 +50,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@hookform/resolvers": "^5.1.1", - "@imagekit/javascript": "^5.1.0", + "@imagekit/javascript": "^5.3.0", "@react-icons/all-files": "https://github.com/react-icons/react-icons/releases/download/v5.4.0/react-icons-all-files-5.4.0.tgz", "@tanstack/react-virtual": "^3.13.12", "framer-motion": "6.5.1", diff --git a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx index 1572c67..c0a7a91 100644 --- a/packages/imagekit-editor-dev/src/ImageKitEditor.tsx +++ b/packages/imagekit-editor-dev/src/ImageKitEditor.tsx @@ -13,12 +13,17 @@ import type { GetTemplatePermissions } from "./context/TemplatePermissionsContex import { TemplatePermissionsContextProvider } from "./context/TemplatePermissionsContext" import { TemplateStorageContextProvider } from "./context/TemplateStorageContext" import { + applyTemplateStorageAccessFailure, isTemplateAccessDeniedError, type TemplateStorageProvider, } from "./storage" import { + applyTemplateRecord, + type CanvasConfig, + type EditorMode, type FocusObjects, type InputFileElement, + type OnPickImage, type RequiredMetadata, type Signer, type Transformation, @@ -82,6 +87,16 @@ interface EditorProps { initialImages?: Array> signer?: Signer onAddImage?: () => void + /** + * Optional async image picker. When provided, image-path fields (currently + * the image layer's `imageUrl`) render a small folder icon next to the + * input; clicking it invokes this callback. Resolve to a URL/path string to + * fill the field, or to `null`/`undefined` to leave the field unchanged. + * + * The host owns the picker UI and any backend calls; the editor never opens + * a media library itself. + */ + onPickImage?: OnPickImage exportOptions?: HeaderProps["exportOptions"] focusObjects?: ReadonlyArray onClose: (args: { dirty: boolean; destroy: () => void }) => void @@ -96,6 +111,26 @@ interface EditorProps { * If omitted, the editor defaults to allowing all actions. */ getTemplatePermissions?: GetTemplatePermissions + /** + * Open the editor with this template pre-loaded. The editor calls + * `templateStorage.getTemplate(initialTemplateId)` on mount and applies + * the result. Requires `templateStorage` to be configured. + * + * Failures (template not found, access denied, network error) are surfaced + * via the standard sync-status error UI; the editor still opens empty. + */ + initialTemplateId?: string + /** + * Editor authoring mode. Defaults to `"editing"` (regular media editing). + * Pass `"canvas"` to author a layer-only template against a sized blank + * canvas; `canvas` prop must also be provided in that case. + * + * If `initialTemplateId` is supplied and the loaded template has its own + * `mode`, that wins (the template carries its authoring context). + */ + mode?: EditorMode + /** Canvas dimensions and optional background. Required when `mode === "canvas"`. */ + canvas?: CanvasConfig } function ImageKitEditorImpl( @@ -106,9 +141,13 @@ function ImageKitEditorImpl( theme, initialImages, signer, + onPickImage, focusObjects, templateStorage, getTemplatePermissions, + initialTemplateId, + mode, + canvas, } = props const { addImage, @@ -143,6 +182,8 @@ function ImageKitEditorImpl( ...(state.templateIsPrivate !== null ? { isPrivate: state.templateIsPrivate } : {}), + mode: state.mode, + ...(state.mode === "canvas" ? { canvas: state.canvas } : {}), }) const after = useEditorStore.getState() after.hydrateTemplateMetadata({ @@ -203,9 +244,60 @@ function ImageKitEditorImpl( initialize({ imageList: initialImages, signer, + onPickImage, focusObjects, + mode, + canvas, }) - }, [initialImages, signer, focusObjects, initialize]) + }, [initialImages, signer, onPickImage, focusObjects, initialize, mode, canvas]) + + // Load template by id from the configured storage provider when + // `initialTemplateId` is supplied. This runs after `initialize` so it can + // overwrite any reset metadata. Keyed on (provider, id) so switching either + // re-fetches. + React.useEffect(() => { + if (!initialTemplateId) return + if (!resolvedProvider) { + console.warn( + "ImageKitEditor: `initialTemplateId` was provided but no `templateStorage` is configured.", + ) + return + } + + let cancelled = false + const store = useEditorStore.getState() + + resolvedProvider + .getTemplate(initialTemplateId) + .then((record) => { + if (cancelled) return + if (!record) { + useEditorStore + .getState() + .setSyncStatus("error", "Template not found.") + return + } + applyTemplateRecord(record) + }) + .catch((err) => { + if (cancelled) return + const handled = applyTemplateStorageAccessFailure(err, { + denyTemplateStorageAccessAndReset: + store.denyTemplateStorageAccessAndReset, + }) + if (handled) return + useEditorStore + .getState() + .setSyncStatus( + "error", + err instanceof Error ? err.message : "Failed to load template", + ) + }) + + return () => { + cancelled = true + } + }, [resolvedProvider, initialTemplateId]) useImperativeHandle( ref, diff --git a/packages/imagekit-editor-dev/src/backward-compatibility.test.ts b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts index 7807c90..22d8114 100644 --- a/packages/imagekit-editor-dev/src/backward-compatibility.test.ts +++ b/packages/imagekit-editor-dev/src/backward-compatibility.test.ts @@ -963,6 +963,703 @@ describe("Backward Compatibility - V1 Templates", () => { }) }) + describe("Schema Validators - Layer Anchor & Centre Position (lap/lxc/lyc)", () => { + // ----- Backwards-compatibility: legacy templates without the new fields + it("legacy text layer (no anchor/centre fields) still validates", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { + text: "Hi", + positionX: "100", + positionY: "50", + fontSize: 24, + radius: 0, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("legacy image layer (no anchor/centre fields) still validates", () => { + const template: Omit = { + key: "layers-image", + name: "Image", + type: "transformation", + value: { + imageUrl: "logo.png", + positionX: "100", + positionY: "50", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + // ----- Centre-reference (lxc / lyc) accepts numbers + expressions + it("text layer accepts positionXCenter / positionYCenter as numbers", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { + text: "Hi", + positionXCenter: "50", + positionYCenter: "N100", + fontSize: 24, + radius: 0, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("image layer accepts positionXCenter / positionYCenter as expressions", () => { + const template: Omit = { + key: "layers-image", + name: "Image", + type: "transformation", + value: { + imageUrl: "logo.png", + positionXCenter: "bw_div_2", + positionYCenter: "bh_mul_0.4", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + // ----- Mutual exclusion: x + xCenter (and y + yCenter) on the same axis + it("rejects positionX + positionXCenter set together (text layer)", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { + text: "Hi", + positionX: "10", + positionXCenter: "20", + fontSize: 24, + radius: 0, + }, + version: "v1", + } + const result = validateTransformation(template) + expect(result.valid).toBe(false) + expect(result.errors.join("\n")).toMatch(/Position X.*center.*not both/i) + }) + + it("rejects positionY + positionYCenter set together (image layer)", () => { + const template: Omit = { + key: "layers-image", + name: "Image", + type: "transformation", + value: { + imageUrl: "logo.png", + positionY: "10", + positionYCenter: "20", + }, + version: "v1", + } + const result = validateTransformation(template) + expect(result.valid).toBe(false) + expect(result.errors.join("\n")).toMatch(/Position Y.*center.*not both/i) + }) + + // ----- Anchor (lap) + it("text layer accepts a valid layerAnchor when paired with a position", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { + text: "Hi", + positionX: "N25", + positionY: "25", + layerAnchor: "top_right", + fontSize: 24, + radius: 0, + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("rejects layerAnchor set without any position offset (text layer)", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { + text: "Hi", + layerAnchor: "center", + fontSize: 24, + radius: 0, + }, + version: "v1", + } + const result = validateTransformation(template) + expect(result.valid).toBe(false) + expect(result.errors.join("\n")).toMatch(/Anchor Point requires/i) + }) + + it("rejects layerAnchor set without any position offset (image layer)", () => { + const template: Omit = { + key: "layers-image", + name: "Image", + type: "transformation", + value: { + imageUrl: "logo.png", + layerAnchor: "bottom", + }, + } + const result = validateTransformation(template) + expect(result.valid).toBe(false) + expect(result.errors.join("\n")).toMatch(/Anchor Point requires/i) + }) + + it("rejects an unknown anchor value", () => { + const template: Omit = { + key: "layers-image", + name: "Image", + type: "transformation", + value: { + imageUrl: "logo.png", + positionX: "10", + layerAnchor: "middle", + }, + } + expect(validateTransformation(template).valid).toBe(false) + }) + + // ----- Formatter: emits xCenter/yCenter/anchorPoint, normalises `-` to `N` + it("textLayer formatter emits xCenter/yCenter/anchorPoint with negative-prefix normalisation", () => { + const transforms: Record = {} + transformationFormatters.textLayer( + { + text: "Hi", + positionXCenter: "-50", + positionYCenter: "100", + layerAnchor: "bottom_right", + }, + transforms, + ) + expect(transforms.overlay).toMatchObject({ + type: "text", + text: "Hi", + position: { + xCenter: "N50", + yCenter: "100", + anchorPoint: "bottom_right", + }, + }) + }) + + it("imageLayer formatter emits xCenter/yCenter/anchorPoint", () => { + const transforms: Record = {} + transformationFormatters.imageLayer( + { + imageUrl: "logo.png", + positionXCenter: "bw_div_2", + positionYCenter: "-30", + layerAnchor: "center", + }, + transforms, + ) + expect(transforms.overlay).toMatchObject({ + type: "image", + input: "logo.png", + position: { + xCenter: "bw_div_2", + yCenter: "N30", + anchorPoint: "center", + }, + }) + }) + + // ----- Formatter: legacy x/y still flow through unchanged + it("textLayer formatter without new fields produces no xCenter/yCenter/anchorPoint", () => { + const transforms: Record = {} + transformationFormatters.textLayer( + { text: "Hi", positionX: "10", positionY: "20" }, + transforms, + ) + const overlay = transforms.overlay as { + position: Record + } + expect(overlay.position).toEqual({ x: "10", y: "20" }) + expect(overlay.position.xCenter).toBeUndefined() + expect(overlay.position.yCenter).toBeUndefined() + expect(overlay.position.anchorPoint).toBeUndefined() + }) + }) + + describe("Schema Validators - Layer Raw Passthrough (rawTransformation)", () => { + it("text layer accepts rawTransformation", () => { + const template: Omit = { + key: "layers-text", + name: "Text", + type: "transformation", + value: { + text: "Hi", + fontSize: 24, + radius: 0, + rawTransformation: "lm-multiply", + }, + version: "v1", + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("image layer accepts rawTransformation", () => { + const template: Omit = { + key: "layers-image", + name: "Image", + type: "transformation", + value: { + imageUrl: "logo.png", + rawTransformation: "e-shadow-st-40_bl-15", + }, + } + expect(validateTransformation(template).valid).toBe(true) + }) + + it("textLayer formatter writes rawTransformation into overlay.transformation[0].raw", () => { + const transforms: Record = {} + transformationFormatters.textLayer( + { text: "Hi", rawTransformation: "lm-multiply" }, + transforms, + ) + const overlay = transforms.overlay as { + transformation: Array<{ raw?: string }> + } + expect(overlay.transformation[0]?.raw).toBe("lm-multiply") + }) + + it("imageLayer formatter writes rawTransformation into overlay.transformation[0].raw", () => { + const transforms: Record = {} + transformationFormatters.imageLayer( + { imageUrl: "logo.png", rawTransformation: "e-shadow" }, + transforms, + ) + const overlay = transforms.overlay as { + transformation: Array<{ raw?: string }> + } + expect(overlay.transformation[0]?.raw).toBe("e-shadow") + }) + + it("formatter trims surrounding whitespace and ignores blank rawTransformation", () => { + const a: Record = {} + transformationFormatters.imageLayer( + { imageUrl: "logo.png", rawTransformation: " lm-multiply " }, + a, + ) + expect( + (a.overlay as { transformation: Array<{ raw?: string }> }) + .transformation[0]?.raw, + ).toBe("lm-multiply") + + const b: Record = {} + transformationFormatters.textLayer( + { text: "Hi", rawTransformation: " " }, + b, + ) + const overlayB = b.overlay as { + transformation?: Array<{ raw?: string }> + } + // No styling fields set + blank raw means no inner transformation + // array is created at all. + expect(overlayB.transformation).toBeUndefined() + }) + }) + + describe("Nested Layers (children)", () => { + it("legacy template without children produces a byte-identical URL", async () => { + const { buildSrc } = await import("@imagekit/javascript") + const { convertTransformationToIK } = await import( + "./transformationConverter" + ) + const layer: Transformation = { + id: "t1", + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { imageUrl: "logo.png", width: "100" }, + } + const url = buildSrc({ + urlEndpoint: "https://ik.imagekit.io/demo", + src: "/base.jpg", + transformation: [convertTransformationToIK(layer)], + }) + expect(url).toBe( + "https://ik.imagekit.io/demo/base.jpg?tr=l-image,i-logo.png,w-100,l-end", + ) + }) + + it("image layer with a nested image child appends a nested overlay step", async () => { + const { buildSrc } = await import("@imagekit/javascript") + const { convertTransformationToIK } = await import( + "./transformationConverter" + ) + const parent: Transformation = { + id: "p", + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { imageUrl: "outer.png" }, + children: [ + { + id: "c", + key: "layers-image", + name: "Inner Logo", + type: "transformation", + value: { imageUrl: "inner.png" }, + }, + ], + } + const url = buildSrc({ + urlEndpoint: "https://ik.imagekit.io/demo", + src: "/base.jpg", + transformation: [convertTransformationToIK(parent)], + }) + expect(url).toBe( + "https://ik.imagekit.io/demo/base.jpg?tr=l-image,i-outer.png,l-image,i-inner.png,l-end,l-end", + ) + }) + + it("canvas (ik_canvas) layer with text + image children", async () => { + const { buildSrc } = await import("@imagekit/javascript") + const { convertTransformationToIK } = await import( + "./transformationConverter" + ) + const parent: Transformation = { + id: "p", + key: "layers-image", + name: "Canvas", + type: "transformation", + value: { imageUrl: "ik_canvas", width: "500", height: "120" }, + children: [ + { + id: "c1", + key: "layers-text", + name: "Caption", + type: "transformation", + value: { text: "Hello", radius: 0 }, + }, + { + id: "c2", + key: "layers-image", + name: "Logo", + type: "transformation", + value: { imageUrl: "logo.png" }, + }, + ], + } + const url = buildSrc({ + urlEndpoint: "https://ik.imagekit.io/demo", + src: "/base.jpg", + transformation: [convertTransformationToIK(parent)], + }) + expect(url).toBe( + "https://ik.imagekit.io/demo/base.jpg?tr=l-image,i-ik_canvas,w-500,h-120:l-text,i-Hello,r-0,l-end:l-image,i-logo.png,l-end,l-end", + ) + }) + + it("hidden child (enabled === false) is skipped from the URL", async () => { + const { buildSrc } = await import("@imagekit/javascript") + const { convertTransformationToIK } = await import( + "./transformationConverter" + ) + const parent: Transformation = { + id: "p", + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { imageUrl: "outer.png" }, + children: [ + { + id: "c1", + key: "layers-image", + name: "Visible", + type: "transformation", + value: { imageUrl: "shown.png" }, + }, + { + id: "c2", + key: "layers-image", + name: "Hidden", + type: "transformation", + value: { imageUrl: "hidden.png" }, + enabled: false, + }, + ], + } + const url = buildSrc({ + urlEndpoint: "https://ik.imagekit.io/demo", + src: "/base.jpg", + transformation: [convertTransformationToIK(parent)], + }) + expect(url).toBe( + "https://ik.imagekit.io/demo/base.jpg?tr=l-image,i-outer.png,l-image,i-shown.png,l-end,l-end", + ) + }) + + it("3-level nesting (root + child + grandchild) emits all three layers", async () => { + const { buildSrc } = await import("@imagekit/javascript") + const { convertTransformationToIK } = await import( + "./transformationConverter" + ) + const root: Transformation = { + id: "r", + key: "layers-image", + name: "Root", + type: "transformation", + value: { imageUrl: "outer.png" }, + children: [ + { + id: "c", + key: "layers-image", + name: "Child", + type: "transformation", + value: { imageUrl: "middle.png" }, + children: [ + { + id: "g", + key: "layers-text", + name: "Grandchild", + type: "transformation", + value: { text: "deep", radius: 0 }, + }, + ], + }, + ], + } + const url = buildSrc({ + urlEndpoint: "https://ik.imagekit.io/demo", + src: "/base.jpg", + transformation: [convertTransformationToIK(root)], + }) + expect(url).toBe( + "https://ik.imagekit.io/demo/base.jpg?tr=l-image,i-outer.png,l-image,i-middle.png,l-text,i-deep,r-0,l-end,l-end,l-end", + ) + }) + + it("findTransformationDeep locates a nested child by id", async () => { + const { findTransformationDeep } = await import("./store") + const tree: Transformation[] = [ + { + id: "root", + key: "layers-image", + name: "Root", + type: "transformation", + value: { imageUrl: "x.png" }, + children: [ + { + id: "deep", + key: "layers-text", + name: "Deep", + type: "transformation", + value: { text: "hi", radius: 0 }, + }, + ], + }, + ] + expect(findTransformationDeep(tree, "deep")?.name).toBe("Deep") + expect(findTransformationDeep(tree, "missing")).toBeUndefined() + }) + + it("non-layer child (ai-removedotbg) is appended as a chained step inside the parent layer", async () => { + const { buildSrc } = await import("@imagekit/javascript") + const { convertTransformationToIK } = await import( + "./transformationConverter" + ) + // Parent has multiple own-params (image url + width + trim) so the SDK + // serializes the child with an explicit `:` chain separator. With a + // single-param parent the SDK collapses to a `,` joiner — equivalent + // ImageKit syntax, but less obvious that the child is a chained step. + const parent: Transformation = { + id: "p", + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { + imageUrl: "photo.jpg", + width: "13", + trimEnabled: true, + trimThreshold: 10, + }, + children: [ + { + id: "c", + key: "ai-removedotbg", + name: "Remove Background", + type: "transformation", + value: { removedotbg: true }, + }, + ], + } + const url = buildSrc({ + urlEndpoint: "https://ik.imagekit.io/demo", + src: "/base.jpg", + transformation: [convertTransformationToIK(parent)], + }) + expect(url).toBe( + "https://ik.imagekit.io/demo/base.jpg?tr=l-image,i-photo.jpg,w-13,t-10:e-removedotbg,l-end", + ) + }) + + it("mixes non-layer and nested-layer children in declaration order", async () => { + const { buildSrc } = await import("@imagekit/javascript") + const { convertTransformationToIK } = await import( + "./transformationConverter" + ) + const parent: Transformation = { + id: "p", + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { imageUrl: "photo.jpg" }, + children: [ + { + id: "c1", + key: "adjust-blur", + name: "Blur", + type: "transformation", + value: { blur: 5 }, + }, + { + id: "c2", + key: "layers-text", + name: "Caption", + type: "transformation", + value: { text: "Sale", radius: 0 }, + }, + ], + } + const url = buildSrc({ + urlEndpoint: "https://ik.imagekit.io/demo", + src: "/base.jpg", + transformation: [convertTransformationToIK(parent)], + }) + expect(url).toBe( + "https://ik.imagekit.io/demo/base.jpg?tr=l-image,i-photo.jpg,bl-5:l-text,i-Sale,r-0,l-end,l-end", + ) + }) + + it("hidden non-layer child is skipped from the URL", async () => { + const { buildSrc } = await import("@imagekit/javascript") + const { convertTransformationToIK } = await import( + "./transformationConverter" + ) + const parent: Transformation = { + id: "p", + key: "layers-image", + name: "Image Layer", + type: "transformation", + value: { imageUrl: "photo.jpg" }, + children: [ + { + id: "c1", + key: "adjust-blur", + name: "Blur", + type: "transformation", + value: { blur: 5 }, + enabled: false, + }, + ], + } + const url = buildSrc({ + urlEndpoint: "https://ik.imagekit.io/demo", + src: "/base.jpg", + transformation: [convertTransformationToIK(parent)], + }) + expect(url).toBe( + "https://ik.imagekit.io/demo/base.jpg?tr=l-image,i-photo.jpg,l-end", + ) + }) + + it("isAllowedChildKey enforces per-parent allow lists", async () => { + const { isAllowedChildKey } = await import("./store") + + // Image layer: liberal allow list including AI + adjust + nested layers. + expect(isAllowedChildKey("layers-image", "ai-removedotbg")).toBe(true) + expect(isAllowedChildKey("layers-image", "adjust-blur")).toBe(true) + expect(isAllowedChildKey("layers-image", "layers-text")).toBe(true) + // Delivery transforms are output-only; never valid inside a layer block. + expect(isAllowedChildKey("layers-image", "delivery-format")).toBe(false) + + // Canvas layer: tighter list (no blur/AI), but layers still allowed. + expect(isAllowedChildKey("layers-canvas", "adjust-radius")).toBe(true) + expect(isAllowedChildKey("layers-canvas", "adjust-blur")).toBe(false) + expect(isAllowedChildKey("layers-canvas", "ai-removedotbg")).toBe(false) + expect(isAllowedChildKey("layers-canvas", "layers-image")).toBe(true) + + // Text layers are leaves: nothing is allowed, including other layers. + expect(isAllowedChildKey("layers-text", "adjust-blur")).toBe(false) + expect(isAllowedChildKey("layers-text", "adjust-shadow")).toBe(false) + expect(isAllowedChildKey("layers-text", "layers-image")).toBe(true) + // ^ Note: the layer-keys short-circuit returns true here. The picker + // additionally gates on canHostLayerChildren, which excludes text. + }) + + it("canHostLayerChildren only lets image/canvas host children", async () => { + const { canHostLayerChildren } = await import("./store") + expect(canHostLayerChildren("layers-image")).toBe(true) + expect(canHostLayerChildren("layers-canvas")).toBe(true) + expect(canHostLayerChildren("layers-text")).toBe(false) + expect(canHostLayerChildren("adjust-blur")).toBe(false) + }) + + it("getLayerDepth counts only layer ancestors, not non-layer ones", async () => { + const { getLayerDepth } = await import("./store") + const tree: Transformation[] = [ + { + id: "root", + key: "layers-image", + name: "Root", + type: "transformation", + value: { imageUrl: "a.png" }, + children: [ + { + // Non-layer child of root layer — itself at depth 0 (it has + // zero *layer* ancestors above its parent slot). + id: "blur", + key: "adjust-blur", + name: "Blur", + type: "transformation", + value: { blur: 4 }, + }, + { + // Nested layer — depth 1. + id: "child", + key: "layers-image", + name: "Child", + type: "transformation", + value: { imageUrl: "b.png" }, + children: [ + { + id: "grand", + key: "layers-text", + name: "Grand", + type: "transformation", + value: { text: "hi", radius: 0 }, + }, + ], + }, + ], + }, + ] + expect(getLayerDepth(tree, "root")).toBe(0) + // Non-layer children inherit the parent's depth (they don't open a + // new l-...,l-end scope). + expect(getLayerDepth(tree, "blur")).toBe(1) + expect(getLayerDepth(tree, "child")).toBe(1) + expect(getLayerDepth(tree, "grand")).toBe(2) + expect(getLayerDepth(tree, "missing")).toBeUndefined() + }) + }) + describe("Resize & Crop Complex Validations", () => { it("should require mode when both width and height are specified", () => { const template: Omit = { diff --git a/packages/imagekit-editor-dev/src/components/common/CheckboxCardField.tsx b/packages/imagekit-editor-dev/src/components/common/CheckboxCardField.tsx index 6f56a95..389dff9 100644 --- a/packages/imagekit-editor-dev/src/components/common/CheckboxCardField.tsx +++ b/packages/imagekit-editor-dev/src/components/common/CheckboxCardField.tsx @@ -110,7 +110,7 @@ export const CheckboxCardField: React.FC = ({ p="2" transition="all 0.12s ease-in-out" borderColor={isChecked ? selectedBorder : "gray.200"} - bg={isChecked ? selectedBg : "transparent"} + bg={isChecked ? selectedBg : "white"} _hover={{ bg: disabled ? undefined : isChecked ? selectedBg : hoverBg, }} diff --git a/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx b/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx index 07da10a..0f09f05 100644 --- a/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx +++ b/packages/imagekit-editor-dev/src/components/common/ColorPickerField.tsx @@ -73,9 +73,15 @@ const ColorPickerField = ({ return `#${rgb}${standardAlphaHex}` } - // Get the preview color that shows what downstream will actually render + // Get the preview color that shows what downstream will actually render. + // When the value is empty we render `transparent` so the swatch shows the + // checkered pattern instead of an inherited background. const getPreviewColor = (color: string): string => { - if (!color || !color?.startsWith("#")) { + if (!color) { + return "transparent" + } + + if (!color?.startsWith("#")) { return color } @@ -87,6 +93,15 @@ const ColorPickerField = ({ return color } + // The underlying color picker library throws "Expected color definition" + // when handed an empty string. Fall back to fully transparent white so the + // picker stays usable when the field has been cleared, and matches the + // `#FFFFFF` placeholder shown in the input. + const getPickerValue = (color: string): string => { + const standard = convertDownstreamToStandard(color) + return standard && standard.startsWith("#") ? standard : "#FFFFFF00" + } + const handleColorChange = (color: string) => { const parts = color.match(/[\d.]+/g)?.map(Number) ?? [] @@ -184,7 +199,7 @@ const ColorPickerField = ({ void value?: GradientPickerState | null errors?: FieldErrors> + nestedVariables?: Record + onCreateNestedVariable?: (path: string[], variable: { name: string; label: string; description?: string }) => void + onUpdateNestedVariable?: (path: string[], updates: { label?: string; description?: string }) => void + onUnbindNestedVariable?: (path: string[]) => void + onChangeNestedVariableDefault?: (path: string[], value: unknown) => void }) => { + const editorMode = useEditorStore((s) => s.mode) + const isCanvasMode = editorMode === "canvas" + const allTransformations = useEditorStore((s) => s.transformations) + const allTakenVariableNames = useMemo( + () => listVariables(allTransformations).map((v) => v.name), + [allTransformations], + ) + + // Check if from/to are variablized + const fromVariable = nestedVariables?.from as VariableRef | undefined + const toVariable = nestedVariables?.to as VariableRef | undefined + const isFromVariablized = fromVariable && isVariableRef(fromVariable) + const isToVariablized = toVariable && isVariableRef(toVariable) + + // Validation for variable default values + const isFromDefaultInvalid = isFromVariablized && (!fromVariable?.defaultValue || (typeof fromVariable.defaultValue === "string" && fromVariable.defaultValue.trim() === "")) + const isToDefaultInvalid = isToVariablized && (!toVariable?.defaultValue || (typeof toVariable.defaultValue === "string" && toVariable.defaultValue.trim() === "")) + + // Stable callbacks for nested variable default value changes to prevent infinite loops + const handleFromDefaultChange = useCallback( + (_: string, newValue: string) => { + onChangeNestedVariableDefault?.(["from"], newValue) + }, + [onChangeNestedVariableDefault], + ) + + const handleToDefaultChange = useCallback( + (_: string, newValue: string) => { + onChangeNestedVariableDefault?.(["to"], newValue) + }, + [onChangeNestedVariableDefault], + ) + function getLinearGradientString(value: GradientPickerState): string { let direction = "" const dirInt = Number(value.direction as string) @@ -76,7 +126,12 @@ const GradientPickerField = ({ typeof value.stopPoint === "number" ? value.stopPoint : Number(value.stopPoint) - return `linear-gradient(${direction}, ${value.from} 0%, ${value.to} ${stopPoint}%)` + // Guard against empty from/to so the CSS / underlying gradient parser + // doesn't throw "Expected color definition". An empty value can occur + // briefly while the user is editing a color input. + const from = value.from || "#00000000" + const to = value.to || "#00000000" + return `linear-gradient(${direction}, ${from} 0%, ${to} ${stopPoint}%)` } const [localValue, setLocalValue] = useState( @@ -176,6 +231,31 @@ const GradientPickerField = ({ setLocalValue(newValue) } + // Stable callbacks for color changes to prevent infinite loops + const handleFromColorChange = useCallback( + (_: string, newValue: string) => { + setLocalValue((prev) => { + const updated = { ...prev, from: newValue } + const gradientString = getLinearGradientString(updated) + setGradient(gradientString) + return updated + }) + }, + [], + ) + + const handleToColorChange = useCallback( + (_: string, newValue: string) => { + setLocalValue((prev) => { + const updated = { ...prev, to: newValue } + const gradientString = getLinearGradientString(updated) + setGradient(gradientString) + return updated + }) + }, + [], + ) + useEffect(() => { setValue(fieldName, debouncedValue) }, [debouncedValue, fieldName, setValue]) @@ -222,53 +302,135 @@ const GradientPickerField = ({ - - From Color - - { - const newValue = e.target.value - if (newValue.match(/^#[0-9A-Fa-f]{0,8}$/)) { - applyGradientInputChanges({ ...localValue, from: newValue }) - } else if (newValue === "") { - applyGradientInputChanges({ ...localValue, from: "" }) - } - }} - borderColor="gray.200" - placeholder="#FFFFFF" - fontFamily="mono" - borderRadius="4px" - /> - - {errors?.[fieldName]?.from?.message} - + + + From Color + + {isCanvasMode && !isFromVariablized && onCreateNestedVariable && ( + { + onCreateNestedVariable(["from"], variable) + }} + /> + )} + + {isFromVariablized ? ( + + { + onUpdateNestedVariable?.(["from"], updates) + }} + onUnbind={() => { + onUnbindNestedVariable?.(["from"]) + }} + /> + + + + Default value + + + {isFromDefaultInvalid && ( + + Default value is required + + )} + + + + ) : ( + + )} + {!isFromVariablized && ( + + {errors?.[fieldName]?.from?.message} + + )} - - To Color - - { - const newValue = e.target.value - if (newValue.match(/^#[0-9A-Fa-f]{0,8}$/)) { - applyGradientInputChanges({ ...localValue, to: newValue }) - } else if (newValue === "") { - applyGradientInputChanges({ ...localValue, to: "" }) - } - }} - borderColor="gray.200" - placeholder="#FFFFFF" - fontFamily="mono" - borderRadius="4px" - /> - - {errors?.[fieldName]?.to?.message} - + + + To Color + + {isCanvasMode && !isToVariablized && onCreateNestedVariable && ( + { + onCreateNestedVariable(["to"], variable) + }} + /> + )} + + {isToVariablized ? ( + + { + onUpdateNestedVariable?.(["to"], updates) + }} + onUnbind={() => { + onUnbindNestedVariable?.(["to"]) + }} + /> + + + + Default value + + + {isToDefaultInvalid && ( + + Default value is required + + )} + + + + ) : ( + + )} + {!isToVariablized && ( + + {errors?.[fieldName]?.to?.message} + + )} diff --git a/packages/imagekit-editor-dev/src/components/common/RadioCardField.tsx b/packages/imagekit-editor-dev/src/components/common/RadioCardField.tsx index 3cdb7c8..79f7a88 100644 --- a/packages/imagekit-editor-dev/src/components/common/RadioCardField.tsx +++ b/packages/imagekit-editor-dev/src/components/common/RadioCardField.tsx @@ -46,11 +46,10 @@ export const RadioCardField: React.FC = ({ role="radiogroup" align="stretch" spacing="2" - wrap="wrap" - // simple responsive columns + wrap="nowrap" sx={{ "& > [data-radio-card]": { - flexBasis: `calc(${100 / columns}% - 8px)`, + flex: "1 1 0", minWidth: 0, }, }} @@ -72,7 +71,7 @@ export const RadioCardField: React.FC = ({ p="2" transition="all 0.12s ease-in-out" borderColor={isSelected ? selectedBorder : "gray.200"} - bg={isSelected ? selectedBg : "transparent"} + bg={isSelected ? selectedBg : "white"} _hover={{ bg: isSelected ? selectedBg : hoverBg }} _focusVisible={{ boxShadow: "0 0 0 2px var(--chakra-colors-blue-400)", diff --git a/packages/imagekit-editor-dev/src/components/editor/ActionBar.tsx b/packages/imagekit-editor-dev/src/components/editor/ActionBar.tsx index 27eb7cb..cdf5cb0 100644 --- a/packages/imagekit-editor-dev/src/components/editor/ActionBar.tsx +++ b/packages/imagekit-editor-dev/src/components/editor/ActionBar.tsx @@ -18,7 +18,13 @@ import { PiGridFour } from "@react-icons/all-files/pi/PiGridFour" import { PiImageSquare } from "@react-icons/all-files/pi/PiImageSquare" import { PiListBullets } from "@react-icons/all-files/pi/PiListBullets" import { type FC, useMemo } from "react" -import { useEditorStore } from "../../store" +import { findTransformationDeep, useEditorStore } from "../../store" +import { listVariables } from "../../variables/listVariables" +import { CanvasSettingsPopover } from "./CanvasSettingsPopover" +import { + VariablesListPopover, + type VariableListEntry, +} from "./VariablesListPopover" interface ActionBarProps { viewMode: "list" | "grid" @@ -39,7 +45,36 @@ export const ActionBar: FC = ({ originalImageList, showOriginal, setShowOriginal, + mode, + canvas, + transformations, } = useEditorStore() + const isCanvas = mode === "canvas" + // Variables are a canvas-mode-only feature; the count badge is the only + // affordance in the action bar (per-field hover affordances live in the + // sidebar). Skip the work entirely outside canvas mode. + const variables = useMemo( + () => (isCanvas ? listVariables(transformations) : []), + [isCanvas, transformations], + ) + // Resolve each variable's owning step name once so the popover doesn't + // re-walk the (potentially nested) transformation tree on every render. + // Explicit return type keeps the Chakra style-prop unions inside + // ActionBar's JSX from blowing past TypeScript's inference budget. + const variableEntries = useMemo( + () => + variables.map((v) => ({ + name: v.name, + label: v.label, + defaultValue: v.defaultValue, + description: v.description, + fieldLabel: v.field.label, + stepName: + findTransformationDeep(transformations, v.transformationId)?.name ?? + "Unknown step", + })), + [variables, transformations], + ) const imageDimensions = useMemo(() => { const idx = imageList.findIndex((img) => img === currentImage) @@ -61,18 +96,20 @@ export const ActionBar: FC = ({ alignItems="center" > - + {!isCanvas && ( + + )} - {viewMode === "list" && imageDimensions && ( + {viewMode === "list" && imageDimensions && !isCanvas && ( <> = ({ )} + {isCanvas && canvas && ( + <> + + + + )} + + {isCanvas && ( + <> + + + + )} + = ({ size="md" variant="ghost" aria-label="Toggle view" + isDisabled={isCanvas} icon={ Math.max(0, Math.min(255, Math.round(v))) + const alpha = a > 1 ? a / 100 : a + const alpha8 = clamp8(alpha * 255) + return [r, g, b] + .map(clamp8) + .concat(alpha8) + .map((v) => v.toString(16).padStart(2, "0").toUpperCase()) + .join("") +} + +/** 8-digit hex (no `#`) → `#RRGGBBAA` for the picker input. */ +function hex8ToPickerHex(hex8: string): string { + const trimmed = hex8.replace(/^#/, "") + if (trimmed.length === 6) return `#${trimmed.toUpperCase()}FF` + if (trimmed.length === 8) return `#${trimmed.toUpperCase()}` + return "#FFFFFFFF" +} + +/** Pad a 6-digit hex up to 8-digit with full alpha for storage normalization. */ +function normalizeStoredBg(input: string): string { + const v = input.replace(/^#/, "").toUpperCase() + if (/^[0-9A-F]{6}$/.test(v)) return `${v}FF` + if (/^[0-9A-F]{8}$/.test(v)) return v + return DEFAULT_BG +} + +interface Props { + canvas: CanvasConfig +} + +export const CanvasSettingsPopover: FC = ({ canvas }) => { + const setCanvas = useEditorStore((s) => s.setCanvas) + const { isOpen, onOpen, onClose } = useDisclosure() + + const [width, setWidth] = useState(canvas.width) + const [height, setHeight] = useState(canvas.height) + const [bgEnabled, setBgEnabled] = useState( + canvas.background !== undefined, + ) + const [bg, setBg] = useState( + normalizeStoredBg(canvas.background ?? DEFAULT_BG), + ) + + // Re-sync local state when the popover opens or external canvas changes + // (e.g. switching templates) so we always edit the live values. + useEffect(() => { + setWidth(canvas.width) + setHeight(canvas.height) + setBgEnabled(canvas.background !== undefined) + setBg(normalizeStoredBg(canvas.background ?? DEFAULT_BG)) + }, [canvas.width, canvas.height, canvas.background]) + + const apply = () => { + setCanvas({ + // Preserve host-supplied sourceUrl — the popover only edits dimensions + // and the optional background; the source asset is not user-editable. + sourceUrl: canvas.sourceUrl, + width: Math.max(MIN_DIM, Math.min(MAX_DIM, Math.round(width))), + height: Math.max(MIN_DIM, Math.min(MAX_DIM, Math.round(height))), + ...(bgEnabled ? { background: normalizeStoredBg(bg) } : {}), + }) + onClose() + } + + return ( + + + + + + + + + + Canvas settings + + + + + + Width + + { + if (!Number.isNaN(v)) setWidth(v) + }} + > + + + + + + + + + + Height + + { + if (!Number.isNaN(v)) setHeight(v) + }} + > + + + + + + + + + + + + + Background + + + + {bgEnabled ? "On" : "Off"} + + setBgEnabled(e.target.checked)} + /> + + + {bgEnabled ? ( + <> + + # + { + const v = e.target.value.replace(/^#/, "").toUpperCase() + if (/^[0-9A-F]{0,8}$/.test(v)) setBg(v) + }} + /> + + + { + const next = rgbaStringToHex8(c) + if (next) setBg(next) + }} + disableDarkMode + hideGradientType + hideGradientAngle + hideGradientControls + hideGradientStop + hideColorTypeBtns + hideInputType + hideInputs + hideAdvancedSliders + hideColorGuide + hidePresets + hideEyeDrop + /> + + + ) : ( + + No background — ImageKit's default fill will be used. + + )} + + + + + + + + + + + ) +} diff --git a/packages/imagekit-editor-dev/src/components/editor/GridView.tsx b/packages/imagekit-editor-dev/src/components/editor/GridView.tsx index 7671b8e..61e27af 100644 --- a/packages/imagekit-editor-dev/src/components/editor/GridView.tsx +++ b/packages/imagekit-editor-dev/src/components/editor/GridView.tsx @@ -28,7 +28,9 @@ export const GridView: FC = ({ imageSize, onAddImage }) => { signingImages, removeImage, setImageDimensions, + mode, } = useEditorStore() + const isCanvas = mode === "canvas" return ( = ({ imageSize, onAddImage }) => { > - - - - - Add New Image - + {!isCanvas && ( + + + + + Add New Image + + - + )} {imageList.map((imageSrc, index) => { const originalUrl = originalImageList[index]?.url diff --git a/packages/imagekit-editor-dev/src/components/editor/ListView.tsx b/packages/imagekit-editor-dev/src/components/editor/ListView.tsx index 57657ff..c39ce5c 100644 --- a/packages/imagekit-editor-dev/src/components/editor/ListView.tsx +++ b/packages/imagekit-editor-dev/src/components/editor/ListView.tsx @@ -1,4 +1,5 @@ -import { Center, Flex, Spinner } from "@chakra-ui/react" +import { Center, Flex, Icon, Spinner, Text } from "@chakra-ui/react" +import { PiPlus } from "@react-icons/all-files/pi/PiPlus" import type { FC } from "react" import { useEditorStore } from "../../store" import RetryableImage from "../RetryableImage" @@ -8,6 +9,19 @@ interface ListViewProps { onAddImage?: () => void } +/** + * Inline 8x8 PNG of a light gray + white 4x4 checker, base64-encoded. + * Used as a backdrop in canvas mode when the template has no background so + * the user can still see the canvas boundary against transparency. + */ +const CHECKER_BG_STYLE = { + backgroundImage: + "linear-gradient(45deg, #d9d9d9 25%, transparent 25%), linear-gradient(-45deg, #d9d9d9 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d9d9d9 75%), linear-gradient(-45deg, transparent 75%, #d9d9d9 75%)", + backgroundSize: "16px 16px", + backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0", + backgroundColor: "#ffffff", +} as const + export const ListView: FC = ({ onAddImage }) => { const { currentImage, @@ -16,9 +30,17 @@ export const ListView: FC = ({ onAddImage }) => { originalImageList, signingImages, setImageDimensions, + mode, + canvas, _internalState, } = useEditorStore() + const isCanvas = mode === "canvas" + const isEmpty = imageList.length === 0 + // Show a checker backdrop in canvas mode when the user has no background + // configured, so the canvas boundary remains visible against transparency. + const showCheckerBackdrop = isCanvas && !canvas?.background + return ( <> = ({ onAddImage }) => { height="full" width="full" > - - - - } - isLoading={(() => { - const idx = imageList.findIndex((img) => img === currentImage) - if (idx === -1) return false - const originalUrl = originalImageList[idx]?.url - return originalUrl ? signingImages[originalUrl] : false - })()} - onLoad={(event) => { - console.log(event) - if (!currentImage) return - const idx = imageList.findIndex((img) => img === currentImage) - if (idx === -1) return - // biome-ignore lint/style/noNonNullAssertion: - setImageDimensions(originalImageList[idx]!.url, { - width: event.currentTarget.naturalWidth, - height: event.currentTarget.naturalHeight, - }) - }} - /> + {isEmpty ? ( + + + + No image to preview + + + {onAddImage + ? "Add an image to preview your template." + : "Provide an image to preview this template."} + + + ) : ( + + + + + } + isLoading={(() => { + const idx = imageList.findIndex((img) => img === currentImage) + if (idx === -1) return false + const originalUrl = originalImageList[idx]?.url + return originalUrl ? signingImages[originalUrl] : false + })()} + onLoad={(event) => { + console.log(event) + if (!currentImage) return + const idx = imageList.findIndex((img) => img === currentImage) + if (idx === -1) return + // biome-ignore lint/style/noNonNullAssertion: + setImageDimensions(originalImageList[idx]!.url, { + width: event.currentTarget.naturalWidth, + height: event.currentTarget.naturalHeight, + }) + }} + /> + + )} - { - setCurrentImage(imageSrc) - }} - /> + {!isCanvas && ( + { + setCurrentImage(imageSrc) + }} + /> + )} ) } diff --git a/packages/imagekit-editor-dev/src/components/editor/VariablesListPopover.tsx b/packages/imagekit-editor-dev/src/components/editor/VariablesListPopover.tsx new file mode 100644 index 0000000..a6d3041 --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/editor/VariablesListPopover.tsx @@ -0,0 +1,172 @@ +import { + Badge, + Box, + Divider, + Flex, + HStack, + Icon, + Popover, + PopoverArrow, + PopoverBody, + PopoverContent, + PopoverHeader, + PopoverTrigger, + Text, + VStack, +} from "@chakra-ui/react" +import { PiBracketsCurly } from "@react-icons/all-files/pi/PiBracketsCurly" +import type { FC } from "react" + +/** + * The popover's row shape is intentionally narrow (no schema field types) + * so importing it into `ActionBar.tsx` doesn't drag the large + * `TransformationField` union into that file's JSX inference and trip + * "union type too complex to represent". + */ +export interface VariableListEntry { + name: string + label: string + defaultValue?: unknown + description?: string + stepName: string + fieldLabel: string +} + +interface VariablesListPopoverProps { + entries: VariableListEntry[] +} + +/** + * Read-only popover that lists every template variable defined in the + * current canvas. Lives in its own file so the (already large) Chakra + * style-prop unions in `ActionBar.tsx` don't blow past TypeScript's + * inference budget when this UI is added. + */ +export const VariablesListPopover: FC = ({ + entries, +}) => { + const count = entries.length + return ( + + + + + + Variables + + 0 ? "purple" : "gray"} + borderRadius="full" + px="2" + > + {count} + + + + + + + Template variables{" "} + + ({count}) + + + + {count === 0 ? ( + + + No variables yet. Hover any field label in the sidebar and + click{" "} + + {"{}"} + {" "} + to make it a variable. + + + ) : ( + }> + {entries.map((v) => { + // Render the marker's default in a way that's safe for any + // value the user may have stored — strings, numbers, and + // JSON-serialisable composites all become readable text. + const hasDefault = + v.defaultValue !== undefined && + v.defaultValue !== null && + v.defaultValue !== "" + const defaultPreview = hasDefault + ? typeof v.defaultValue === "string" + ? v.defaultValue + : JSON.stringify(v.defaultValue) + : null + return ( + + + + {v.label} + + + ${v.name} + + + + {v.stepName} · {v.fieldLabel} + + {defaultPreview !== null && ( + + + Default:{" "} + + + {defaultPreview} + + + )} + {v.description && ( + + {v.description} + + )} + + ) + })} + + )} + + + + ) +} diff --git a/packages/imagekit-editor-dev/src/components/editor/layout.tsx b/packages/imagekit-editor-dev/src/components/editor/layout.tsx index 9ad0ee5..053619b 100644 --- a/packages/imagekit-editor-dev/src/components/editor/layout.tsx +++ b/packages/imagekit-editor-dev/src/components/editor/layout.tsx @@ -2,6 +2,7 @@ import { Box, Flex } from "@chakra-ui/react" import { useEffect, useState } from "react" import { useAutoSaveTemplate } from "../../hooks/useAutoSaveTemplate" import { useSaveTemplate } from "../../hooks/useSaveTemplate" +import { useEditorStore } from "../../store" import { Header, type HeaderProps } from "../header" import { Sidebar } from "../sidebar" import { TemplatesLibraryView } from "../templates/TemplatesLibraryView" @@ -19,6 +20,10 @@ export function EditorLayout({ onAddImage, onClose, exportOptions }: Props) { const [viewMode, setViewMode] = useState<"list" | "grid">("list") const [gridImageSize, setGridImageSize] = useState(300) const [isTemplatesOpen, setIsTemplatesOpen] = useState(false) + const isCanvas = useEditorStore((s) => s.mode === "canvas") + // Canvas mode has only the synthetic source pixel; force list view so we + // never render the (empty) grid. + const effectiveViewMode = isCanvas ? "list" : viewMode // Close templates modal on Escape while it's open useEffect(() => { @@ -56,13 +61,13 @@ export function EditorLayout({ onAddImage, onClose, exportOptions }: Props) { position="relative" > - {viewMode === "list" && } - {viewMode === "grid" && ( + {effectiveViewMode === "list" && } + {effectiveViewMode === "grid" && ( )} diff --git a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx index 0468636..e284993 100644 --- a/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx +++ b/packages/imagekit-editor-dev/src/components/header/TemplatesDropdown.tsx @@ -33,7 +33,7 @@ import { useTemplateStorage } from "../../context/TemplateStorageContext" import { useTemplateSync } from "../../hooks/useTemplateSync" import type { TemplateRecord } from "../../storage" import { isTemplateAccessDeniedError } from "../../storage/templateAccessError" -import { useEditorStore } from "../../store" +import { applyTemplateRecord, useEditorStore } from "../../store" import { chakraAny, formatTemplateNameForUI, @@ -220,10 +220,7 @@ export function TemplatesDropdown({ null, ) - const { loadTemplate, resetToNewTemplate } = useEditorStore() - const hydrateTemplateMetadata = useEditorStore( - (s) => s.hydrateTemplateMetadata, - ) + const { resetToNewTemplate } = useEditorStore() const templateId = useEditorStore((s) => s.templateId) const templateName = useEditorStore((s) => s.templateName) const transformations = useEditorStore((s) => s.transformations) @@ -334,12 +331,7 @@ export function TemplatesDropdown({ } const doLoadTemplate = (record: TemplateRecord) => { - loadTemplate(record.transformations) - hydrateTemplateMetadata({ - templateId: record.id, - templateName: record.name, - templateIsPrivate: record.isPrivate, - }) + applyTemplateRecord(record) onClose() setPendingTemplate(null) } diff --git a/packages/imagekit-editor-dev/src/components/sidebar/MakeVariableButton.tsx b/packages/imagekit-editor-dev/src/components/sidebar/MakeVariableButton.tsx new file mode 100644 index 0000000..f0cc20c --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/sidebar/MakeVariableButton.tsx @@ -0,0 +1,354 @@ +import { + Badge, + Button, + Flex, + FormControl, + FormHelperText, + FormLabel, + HStack, + IconButton, + Input, + Popover, + PopoverBody, + PopoverContent, + PopoverTrigger, + Text, + useDisclosure, + VStack, +} from "@chakra-ui/react" +import { PiBracketsCurly } from "@react-icons/all-files/pi/PiBracketsCurly" +import { PiPencilSimple } from "@react-icons/all-files/pi/PiPencilSimple" +import { PiX } from "@react-icons/all-files/pi/PiX" +import { type FC, useCallback, useState } from "react" +import { generateVariableName } from "../../variables" + +/** + * Soft cap on the user-facing label. The slug derived from this label is + * already capped to 32 chars in `slugifyLabel`; capping the input itself + * keeps the UX predictable and prevents pathological pastes from blowing + * out the chip / popover layout. + */ +const MAX_LABEL_LENGTH = 64 + +/** + * Field types whose value is a single scalar and therefore safe to expose as + * a template variable. Composite fields (gradient picker, padding/perspective/ + * radius inputs) describe sub-trees rather than a single override value, so + * v1 of the variables feature deliberately does not surface a make-variable + * affordance for them. They can be added later, one at a time, with explicit + * sub-key selection. + */ +const VARIABLIZABLE_FIELD_TYPES = new Set([ + "input", + "textarea", + "switch", + "slider", + "color-picker", + "select", + "select-creatable", + "radio-card", + "checkbox-card", + "anchor", + "zoom", +]) + +/** Whether a field can be made into a variable based on its `fieldType`. */ +export function isVariablizableFieldType(fieldType: string | undefined) { + return !!fieldType && VARIABLIZABLE_FIELD_TYPES.has(fieldType) +} + +/** + * Whether a field can be made into a variable. Checks: + * 1. Field type (must be in VARIABLIZABLE_FIELD_TYPES) + * 2. Field is not flagged as `nonVariablizable` in the schema (typically + * control fields whose value gates the visibility of other fields). + */ +export function isVariablizableField(field: { + fieldType?: string + nonVariablizable?: boolean +}) { + return isVariablizableFieldType(field.fieldType) && !field.nonVariablizable +} + +interface MakeVariableButtonProps { + /** The field this button might variabilize (used for the suggested label). */ + fieldLabel: string + /** Names already taken inside this template (for collision-proof generation). */ + takenNames: Iterable + /** Called with the freshly generated variable when the user confirms. */ + onCreate: (variable: { name: string; label: string; description?: string }) => void +} + +/** + * Hover-revealed `{}` icon shown next to a field label inside the + * transformation config sidebar. Clicking opens a small popover with a single + * Label input and a Save CTA. On Save, a collision-proof variable name is + * generated from the label and the field's current literal value is captured + * as the new variable's default. + * + * The button itself does NOT mutate the editor store. The parent (sidebar) + * receives the new variable record via `onCreate` and is responsible for + * threading it through the form's bind state and the eventual Apply commit, + * so the variable is persisted exactly when (and only when) the user clicks + * Apply — the same commit point as every other field change. + */ +export const MakeVariableButton: FC = ({ + fieldLabel, + takenNames, + onCreate, +}) => { + const { isOpen, onOpen, onClose } = useDisclosure() + const [label, setLabel] = useState(fieldLabel) + const [description, setDescription] = useState("") + + const handleOpen = useCallback(() => { + setLabel(fieldLabel) + setDescription("") + onOpen() + }, [fieldLabel, onOpen]) + + const handleSave = () => { + const trimmed = label.trim() || fieldLabel + const name = generateVariableName(trimmed, takenNames) + const trimmedDescription = description.trim() + onCreate({ + name, + label: trimmed, + ...(trimmedDescription && { description: trimmedDescription }), + }) + onClose() + } + + return ( + + + } + aria-label="Make this field a variable" + size="xs" + variant="ghost" + color="purple.600" + // Show only on the parent FormLabel hover; the parent gives this + // group an opacity controlled by `_groupHover`. + opacity={isOpen ? 1 : 0} + _groupHover={{ opacity: 1 }} + transition="opacity 0.1s" + /> + + + + + + + Label + + setLabel(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleSave() + } + }} + maxLength={MAX_LABEL_LENGTH} + autoFocus + /> + + + + Description (optional) + + setDescription(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleSave() + } + }} + placeholder="What does this variable control?" + maxLength={MAX_LABEL_LENGTH} + /> + + Put a helpful description for your team members and AI assist + + + + + + + + + + + ) +} + +interface BoundVariableChipProps { + /** The marker stored in the field — drives chip text + edit popover state. */ + variable: { $var: string; label: string; description?: string } + /** Called when the user edits the label or description via the chip's edit popover. */ + onRename: (updates: { label?: string; description?: string }) => void + /** Called when the user clicks the unbind X. */ + onUnbind: () => void +} + +/** + * Read-only stand-in for a field's normal input when the field is bound to a + * template variable. Shows `${label}` plus the resolved default that the + * editor preview is using, and exposes inline edit/unbind actions. + * + * The actual input control is hidden while a field is bound: editing the + * literal value would silently overwrite the marker, which is exactly the + * footgun the variables feature exists to prevent. Hosts override the value + * at runtime via the `overrides` map, not by typing into the editor. + */ +export const BoundVariableChip: FC = ({ + variable, + onRename, + onUnbind, +}) => { + const { isOpen, onOpen, onClose } = useDisclosure() + const [label, setLabel] = useState(variable.label) + const [description, setDescription] = useState(variable.description || "") + + const handleSave = () => { + const trimmedLabel = label.trim() + const trimmedDescription = description.trim() + const updates: { label?: string; description?: string } = {} + + if (trimmedLabel && trimmedLabel !== variable.label) { + updates.label = trimmedLabel + } + if (trimmedDescription !== (variable.description || "")) { + updates.description = trimmedDescription + } + + if (Object.keys(updates).length > 0) { + onRename(updates) + } + onClose() + } + + return ( + + + ${variable.$var} + + + {variable.label} + + { + setLabel(variable.label) + setDescription(variable.description || "") + onOpen() + }} + onClose={onClose} + placement="bottom-end" + > + + } + aria-label="Edit variable" + size="xs" + variant="ghost" + /> + + + + + + + Label + + setLabel(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleSave() + } + }} + maxLength={MAX_LABEL_LENGTH} + autoFocus + /> + + + + Description (optional) + + setDescription(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleSave() + } + }} + placeholder="What does this variable control?" + maxLength={MAX_LABEL_LENGTH} + /> + + Put a helpful description for your team members and AI assist + + + + + + + + + + + } + aria-label="Unbind variable" + size="xs" + variant="ghost" + onClick={onUnbind} + /> + + ) +} + + diff --git a/packages/imagekit-editor-dev/src/components/sidebar/TransformationFieldRenderer.tsx b/packages/imagekit-editor-dev/src/components/sidebar/TransformationFieldRenderer.tsx new file mode 100644 index 0000000..aecd3ad --- /dev/null +++ b/packages/imagekit-editor-dev/src/components/sidebar/TransformationFieldRenderer.tsx @@ -0,0 +1,734 @@ +import { + Box, + Button, + Flex, + IconButton, + Input, + InputGroup, + InputRightElement, + Slider, + SliderFilledTrack, + SliderThumb, + SliderTrack, + Switch, + Textarea, + Tooltip, +} from "@chakra-ui/react" +import { PiFolderOpen } from "@react-icons/all-files/pi/PiFolderOpen" +import { PiX } from "@react-icons/all-files/pi/PiX" +import startCase from "lodash/startCase" +import { type FC, type ReactNode, useCallback } from "react" +import type { ColorPickerProps } from "react-best-gradient-color-picker" +import type { FieldErrors } from "react-hook-form" +import Select, { components as selectComponents } from "react-select" +import CreateableSelect from "react-select/creatable" +import type { TransformationField } from "../../schema" +import { isStepAligned } from "../../utils" +import AnchorField from "../common/AnchorField" +import CheckboxCardField from "../common/CheckboxCardField" +import ColorPickerField from "../common/ColorPickerField" +import RadiusInputField, { + type RadiusErrors, + type RadiusState, +} from "../common/CornerRadiusInput" +import DistortPerspectiveInput, { + type PerspectiveErrors, + type PerspectiveObject, +} from "../common/DistortPerspectiveInput" +import GradientPicker, { + type GradientPickerState, +} from "../common/GradientPicker" +import PaddingInputField, { + type PaddingErrors, + type PaddingState, +} from "../common/PaddingInput" +import RadioCardField from "../common/RadioCardField" +import ZoomInput from "../common/ZoomInput" + +/** + * Option shape for `select` / `select-creatable` field types. Mirrors the + * `fieldProps.options` entries declared in the transformation schema. + */ +export interface FieldOption { + value: string + label: string + icon?: ReactNode +} + +/** + * Module-level custom IndicatorsContainer that renders a folder-icon picker + * button before react-select's stock indicators. Hoisted out of the renderer + * so its identity is stable across every render — react-select reconciles + * `components.*` by reference and would remount the indicators subtree on + * every keystroke if we redefined this inline. + * + * The picker callback is read from `props.selectProps.onPickFile`, which is + * react-select's documented passthrough for arbitrary host data. + */ +const FilePickerIndicatorsContainer: typeof selectComponents.IndicatorsContainer = + (props) => { + const { onPickFile } = props.selectProps as unknown as { + onPickFile?: () => void + } + return ( + + + + + {props.children} + + ) + } + +/** + * Compact, ghost-styled ClearIndicator that visually matches the + * `ColorPickerField` clear button (small `PiX`, no vertical separator, + * blue on hover). Keeps a single design language for "clearable" fields + * across the sidebar instead of the stock react-select gray X + `|`. + */ +const CompactClearIndicator: typeof selectComponents.ClearIndicator = ( + props, +) => { + const { innerProps } = props + return ( + + + + ) +} + +/** Hides react-select's default `|` divider before the indicators. */ +const NullIndicatorSeparator = (() => + null) as unknown as typeof selectComponents.IndicatorSeparator + +const COMPACT_SELECT_COMPONENTS = { + ClearIndicator: CompactClearIndicator, + IndicatorSeparator: NullIndicatorSeparator, +} + +const FILE_PICKER_COMPONENTS = { + ...COMPACT_SELECT_COMPONENTS, + IndicatorsContainer: FilePickerIndicatorsContainer, +} + +export interface TransformationFieldRendererProps { + /** The schema field metadata (label, fieldType, fieldProps, …). */ + field: TransformationField + /** Current value (typed by the field's Zod schema). */ + value: unknown + /** Called whenever the user mutates the value. */ + onChange: (value: unknown) => void + /** Optional blur callback (used by inputs that need RHF blur tracking). */ + onBlur?: () => void + /** + * The full RHF errors object, used only by composite fields + * (`gradient-picker`, `padding-input`, `distort-perspective-input`, + * `radius-input`) which look up sub-keys themselves. Plain inputs ignore it. + */ + errors?: FieldErrors + /** Force-disable the underlying control (e.g. aspectRatio when w+h set). */ + disabled?: boolean + /** + * Override the `select` options. The sidebar uses this to inject the live + * `focusObjects` list from the store when rendering the `focusObject` field; + * everywhere else this is undefined and the schema-declared options apply. + */ + selectOptionsOverride?: FieldOption[] + /** + * Called after a composite-field commit to ask RHF to re-run validation. + * Used by `padding-input`, `distort-perspective-input`, `radius-input`. + * No-op when omitted. + */ + onTrigger?: () => void + /** + * Override the `id` attribute placed on the underlying input element. + * Defaults to `field.name`. Hosts rendering multiple rows of the same + * template (e.g. a creative-automation override grid) must pass a unique + * id per row — otherwise every row shares the same `id` and clicking any + * label scrolls the browser to the first row's input. + * + * `VariableField` derives this automatically from its `idPrefix` prop; + * the sidebar never needs to set it (each field renders exactly once). + */ + inputId?: string + /** + * Async picker invoked when the user clicks the folder icon on an image + * path field (currently `imageUrl` of the image layer). When omitted, no + * icon is rendered and the field behaves as a plain text input — callers + * can still type a path manually. See `OnPickImage` in the store for the + * resolution contract. + */ + onPickImage?: () => Promise + /** + * Map of nested variable bindings for composite fields (e.g., gradient-picker). + * Key is the nested property path (e.g., "from", "to"), value is the VariableRef. + * When a nested property is variablized, the composite field should render + * variable UI (chip + default value input) instead of the normal control. + */ + nestedVariables?: Record + /** + * Called when the user wants to bind a nested property to a variable. + * Path is relative to the field (e.g., ["from"] for gradient from color). + */ + onCreateNestedVariable?: (path: string[], variable: { name: string; label: string; description?: string }) => void + /** + * Called when the user wants to rename/update a nested variable. + */ + onUpdateNestedVariable?: (path: string[], updates: { label?: string; description?: string }) => void + /** + * Called when the user wants to unbind a nested variable. + */ + onUnbindNestedVariable?: (path: string[]) => void + /** + * Called when the user changes the default value of a nested variable. + */ + onChangeNestedVariableDefault?: (path: string[], value: unknown) => void +} + +/** + * Render the input control for a single `TransformationField` as a controlled + * component (`value` / `onChange`). Extracted from the transformation config + * sidebar so the same per-field render logic can drive both the editor and the + * host-side `VariableField` used in spreadsheet-style override grids. + * + * Behavior is intentionally identical to the sidebar's previous inline switch: + * any visual or validation discrepancy here would surface as a diff between + * authoring (editor) and runtime (host overrides), which is exactly the failure + * mode the variables feature exists to prevent. + * + * Wraps no `FormControl`, label, error message, or help text — the caller + * decides how to surround the input. Composite fields that already render + * their own error UI (gradient/padding/perspective/radius) receive the full + * `errors` object verbatim. + */ +export const TransformationFieldRenderer: FC< + TransformationFieldRendererProps +> = ({ + field, + value, + onChange, + onBlur, + errors, + disabled, + selectOptionsOverride, + onTrigger, + inputId, + onPickImage, + nestedVariables, + onCreateNestedVariable, + onUpdateNestedVariable, + onUnbindNestedVariable, + onChangeNestedVariableDefault, +}) => { + const resolvedId = inputId ?? field.name + // ColorPickerField / GradientPicker call `setValue(name, value)` inside + // useEffect with `setValue` in their dep array. If we hand them a fresh + // closure on every render, that effect fires every render and the field + // loops forever (Maximum update depth exceeded). Memoize the adapter so + // identity is stable across renders for the same `onChange`. + const setValueAdapter = useCallback( + (_name: string, v: unknown) => onChange(v), + [onChange], + ) + switch (field.fieldType) { + case "select": { + const options = + selectOptionsOverride ?? + field.fieldProps?.options?.map((option) => ({ + value: option.value, + label: option.label, + })) + const isCreatable = field.fieldProps?.isCreatable === true + const isClearable: boolean = field.fieldProps?.isClearable ?? false + + const selectedValue = isCreatable + ? options?.find((o) => o.value === value) || + (value + ? { value: value as string, label: startCase(value as string) } + : null) + : options?.find((o) => o.value === value) + + const selectStyles = { + control: (base: Record) => ({ + ...base, + fontSize: "12px", + minHeight: "32px", + borderColor: "#E2E8F0", + backgroundColor: "white", + }), + menu: (base: Record) => ({ + ...base, + zIndex: 10, + }), + option: (base: Record) => ({ + ...base, + fontSize: "12px", + }), + } + + return isCreatable ? ( + `Use "${inputValue}"`} + isClearable={isClearable} + placeholder="Select" + menuPlacement="auto" + options={options} + value={selectedValue} + onChange={(o) => { + const single = o as { value?: string } | null + onChange(single?.value) + }} + onBlur={onBlur} + components={COMPACT_SELECT_COMPONENTS} + styles={selectStyles} + /> + ) : ( + onChange(e.target.value)} + onBlur={onBlur} + disabled={disabled} + pr={showImagePicker ? "2.5rem" : undefined} + /> + ) + if (!showImagePicker) return inputEl + return ( + + {inputEl} + + } + size="sm" + variant="ghost" + onClick={async () => { + try { + const picked = await onPickImage?.() + if (picked) onChange(picked) + } catch { + // Host picker errors are theirs to surface; the editor + // intentionally swallows them so a rejected promise can + // never break the sidebar form. + } + }} + tabIndex={-1} + /> + + + ) + } + + case "textarea": + return ( +