@metaobjectsdev/react 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ import type { Area } from "react-easy-crop";
2
+ /**
3
+ * Draws the selected crop rectangle of `src` onto a canvas bounded to
4
+ * `maxEdge` on its longest side, then re-encodes to JPEG. The canvas
5
+ * redraw drops all EXIF/GPS metadata as a side effect.
6
+ */
7
+ export declare function cropToBlob(src: string, area: Area, maxEdge: number): Promise<Blob>;
8
+ //# sourceMappingURL=crop-to-blob.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crop-to-blob.d.ts","sourceRoot":"","sources":["../src/crop-to-blob.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAC;AAG5C;;;;GAIG;AACH,wBAAsB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBxF"}
@@ -0,0 +1,26 @@
1
+ import { canvasToJpegBlob } from "@metaobjectsdev/runtime-web";
2
+ /**
3
+ * Draws the selected crop rectangle of `src` onto a canvas bounded to
4
+ * `maxEdge` on its longest side, then re-encodes to JPEG. The canvas
5
+ * redraw drops all EXIF/GPS metadata as a side effect.
6
+ */
7
+ export async function cropToBlob(src, area, maxEdge) {
8
+ const img = await new Promise((resolve, reject) => {
9
+ const el = new Image();
10
+ el.onload = () => resolve(el);
11
+ el.onerror = reject;
12
+ el.src = src;
13
+ });
14
+ const scale = Math.min(1, maxEdge / Math.max(area.width, area.height));
15
+ const w = Math.round(area.width * scale);
16
+ const h = Math.round(area.height * scale);
17
+ const canvas = document.createElement("canvas");
18
+ canvas.width = w;
19
+ canvas.height = h;
20
+ const ctx = canvas.getContext("2d");
21
+ if (!ctx)
22
+ throw new Error("canvas 2d context unavailable");
23
+ ctx.drawImage(img, area.x, area.y, area.width, area.height, 0, 0, w, h);
24
+ return canvasToJpegBlob(canvas, 0.9);
25
+ }
26
+ //# sourceMappingURL=crop-to-blob.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crop-to-blob.js","sourceRoot":"","sources":["../src/crop-to-blob.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AAE/D;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,GAAW,EAAE,IAAU,EAAE,OAAe;IACvE,MAAM,GAAG,GAAG,MAAM,IAAI,OAAO,CAAmB,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QAClE,MAAM,EAAE,GAAG,IAAI,KAAK,EAAE,CAAC;QACvB,EAAE,CAAC,MAAM,GAAG,GAAG,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;QAC9B,EAAE,CAAC,OAAO,GAAG,MAAM,CAAC;QACpB,EAAE,CAAC,GAAG,GAAG,GAAG,CAAC;IACf,CAAC,CAAC,CAAC;IACH,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;IACvE,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC,CAAC;IACzC,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC,CAAC;IAC1C,MAAM,MAAM,GAAG,QAAQ,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;IAChD,MAAM,CAAC,KAAK,GAAG,CAAC,CAAC;IACjB,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC;IAClB,MAAM,GAAG,GAAG,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;IACpC,IAAI,CAAC,GAAG;QAAE,MAAM,IAAI,KAAK,CAAC,+BAA+B,CAAC,CAAC;IAC3D,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;IACxE,OAAO,gBAAgB,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;AACvC,CAAC"}
@@ -0,0 +1,11 @@
1
+ import { type ReactNode } from "react";
2
+ import type { ImageUploadAdapter } from "@metaobjectsdev/runtime-web";
3
+ export interface ImageUploadAdapterProviderProps {
4
+ value: ImageUploadAdapter;
5
+ children: ReactNode;
6
+ }
7
+ /** Wrap your app (or the relevant subtree) to supply an upload/serve adapter to <ImageUpload>. */
8
+ export declare function ImageUploadAdapterProvider({ value, children }: ImageUploadAdapterProviderProps): import("react").JSX.Element;
9
+ /** Reads the image upload adapter from context. Throws if not provided. */
10
+ export declare function useImageUploadAdapter(): ImageUploadAdapter;
11
+ //# sourceMappingURL=image-adapter-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image-adapter-provider.d.ts","sourceRoot":"","sources":["../src/image-adapter-provider.tsx"],"names":[],"mappings":"AAAA,OAAO,EAA6B,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAClE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,6BAA6B,CAAC;AAItE,MAAM,WAAW,+BAA+B;IAC9C,KAAK,EAAE,kBAAkB,CAAC;IAC1B,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED,kGAAkG;AAClG,wBAAgB,0BAA0B,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,+BAA+B,+BAE9F;AAED,2EAA2E;AAC3E,wBAAgB,qBAAqB,IAAI,kBAAkB,CAS1D"}
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext } from "react";
3
+ const ImageUploadAdapterContext = createContext(null);
4
+ /** Wrap your app (or the relevant subtree) to supply an upload/serve adapter to <ImageUpload>. */
5
+ export function ImageUploadAdapterProvider({ value, children }) {
6
+ return _jsx(ImageUploadAdapterContext.Provider, { value: value, children: children });
7
+ }
8
+ /** Reads the image upload adapter from context. Throws if not provided. */
9
+ export function useImageUploadAdapter() {
10
+ const adapter = useContext(ImageUploadAdapterContext);
11
+ if (!adapter) {
12
+ throw new Error("useImageUploadAdapter() called outside <ImageUploadAdapterProvider>. " +
13
+ "Wrap your app (or the relevant subtree) with ImageUploadAdapterProvider value={...}.");
14
+ }
15
+ return adapter;
16
+ }
17
+ //# sourceMappingURL=image-adapter-provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image-adapter-provider.js","sourceRoot":"","sources":["../src/image-adapter-provider.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,aAAa,EAAE,UAAU,EAAkB,MAAM,OAAO,CAAC;AAGlE,MAAM,yBAAyB,GAAG,aAAa,CAA4B,IAAI,CAAC,CAAC;AAOjF,kGAAkG;AAClG,MAAM,UAAU,0BAA0B,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAmC;IAC7F,OAAO,KAAC,yBAAyB,CAAC,QAAQ,IAAC,KAAK,EAAE,KAAK,YAAG,QAAQ,GAAsC,CAAC;AAC3G,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,qBAAqB;IACnC,MAAM,OAAO,GAAG,UAAU,CAAC,yBAAyB,CAAC,CAAC;IACtD,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CACb,uEAAuE;YACrE,sFAAsF,CACzF,CAAC;IACJ,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC"}
@@ -0,0 +1,22 @@
1
+ import type { ReactElement } from "react";
2
+ import type { ImageMeta } from "@metaobjectsdev/runtime-web";
3
+ export interface ImageUploadProps {
4
+ /** Current stored image key, or null/undefined when no image is set. */
5
+ value?: string | null;
6
+ /** Called with the new stored image key, or null when the image is removed. */
7
+ onChange: (key: string | null) => void;
8
+ /** Resolved view.image attrs (aspectRatio / maxEdge / store / accept / maxBytes). */
9
+ meta: ImageMeta;
10
+ }
11
+ /**
12
+ * Metadata-driven image upload + crop control. Renders a preview plus
13
+ * Upload/Replace/Remove actions when idle, and a lazy-loaded crop UI
14
+ * (react-easy-crop) while a picked file is being cropped. On save, crops the
15
+ * selection to a bounded JPEG and hands the blob to the adapter supplied via
16
+ * <ImageUploadAdapterProvider> — this component has no knowledge of the
17
+ * storage backend, upload endpoint, or auth.
18
+ *
19
+ * Bound by the generated form's `view.image` field; usable standalone too.
20
+ */
21
+ export declare function ImageUpload({ value, onChange, meta }: ImageUploadProps): ReactElement;
22
+ //# sourceMappingURL=image-upload.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image-upload.d.ts","sourceRoot":"","sources":["../src/image-upload.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAe,YAAY,EAAE,MAAM,OAAO,CAAC;AAEvD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAQ7D,MAAM,WAAW,gBAAgB;IAC/B,wEAAwE;IACxE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,+EAA+E;IAC/E,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IACvC,qFAAqF;IACrF,IAAI,EAAE,SAAS,CAAC;CACjB;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,gBAAgB,GAAG,YAAY,CAiGrF"}
@@ -0,0 +1,64 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { lazy, Suspense, useCallback, useRef, useState } from "react";
3
+ import { useImageUploadAdapter } from "./image-adapter-provider.js";
4
+ import { cropToBlob } from "./crop-to-blob.js";
5
+ // react-easy-crop is an optional peer dependency — loaded lazily so consumers
6
+ // who don't use <ImageUpload> never pay for it.
7
+ const Cropper = lazy(() => import("react-easy-crop"));
8
+ /**
9
+ * Metadata-driven image upload + crop control. Renders a preview plus
10
+ * Upload/Replace/Remove actions when idle, and a lazy-loaded crop UI
11
+ * (react-easy-crop) while a picked file is being cropped. On save, crops the
12
+ * selection to a bounded JPEG and hands the blob to the adapter supplied via
13
+ * <ImageUploadAdapterProvider> — this component has no knowledge of the
14
+ * storage backend, upload endpoint, or auth.
15
+ *
16
+ * Bound by the generated form's `view.image` field; usable standalone too.
17
+ */
18
+ export function ImageUpload({ value, onChange, meta }) {
19
+ const adapter = useImageUploadAdapter();
20
+ const [editing, setEditing] = useState(null); // object URL being cropped
21
+ const [crop, setCrop] = useState({ x: 0, y: 0 });
22
+ const [zoom, setZoom] = useState(1);
23
+ const areaRef = useRef(null);
24
+ const [busy, setBusy] = useState(false);
25
+ const [error, setError] = useState(null);
26
+ const accept = (meta.accept ?? ["image/jpeg", "image/png", "image/webp"]).join(",");
27
+ const onPick = (e) => {
28
+ const file = e.target.files?.[0];
29
+ if (!file)
30
+ return;
31
+ if (meta.maxBytes && file.size > meta.maxBytes)
32
+ return; // client guard; server re-enforces
33
+ setError(null);
34
+ setEditing(URL.createObjectURL(file));
35
+ };
36
+ const onSave = useCallback(async () => {
37
+ if (!editing || !areaRef.current)
38
+ return;
39
+ setBusy(true);
40
+ setError(null);
41
+ try {
42
+ const blob = await cropToBlob(editing, areaRef.current, meta.maxEdge ?? 2000);
43
+ // exactOptionalPropertyTypes: only forward `store` when the caller supplied one.
44
+ const { key } = await adapter.upload(blob, { ...(meta.store !== undefined && { store: meta.store }) });
45
+ onChange(key);
46
+ URL.revokeObjectURL(editing);
47
+ setEditing(null);
48
+ }
49
+ catch {
50
+ // Crop/upload failed (adapter rejected the image, network drop, etc).
51
+ // Leave `editing` in place so the user's crop selection isn't lost —
52
+ // they can retry Save crop without re-picking the file.
53
+ setError("Something went wrong saving that image. Try again.");
54
+ }
55
+ finally {
56
+ setBusy(false);
57
+ }
58
+ }, [editing, meta.maxEdge, meta.store, adapter, onChange]);
59
+ return (_jsxs("div", { className: "metaobjects-image-upload", children: [value && !editing && (_jsx("img", { src: adapter.imageUrl(value), alt: "", className: "metaobjects-image-preview" })), !editing && (_jsxs("div", { className: "metaobjects-image-actions", children: [_jsxs("label", { className: "metaobjects-form-submit", children: [value ? "Replace" : "Upload", _jsx("input", { type: "file", accept: accept, onChange: onPick, hidden: true })] }), value && (_jsx("button", { type: "button", className: "metaobjects-form-submit", onClick: () => onChange(null), children: "Remove" }))] })), editing && (_jsxs("div", { className: "metaobjects-image-cropper", children: [_jsx(Suspense, { fallback: _jsx("p", { children: "Loading cropper\u2026" }), children: _jsx("div", { style: { position: "relative", height: 320 }, children: _jsx(Cropper, { image: editing, crop: crop, zoom: zoom, aspect: meta.aspectRatio, onCropChange: setCrop, onZoomChange: setZoom, onCropComplete: (_croppedArea, croppedAreaPixels) => (areaRef.current = croppedAreaPixels) }) }) }), _jsxs("div", { className: "metaobjects-image-actions", children: [_jsx("button", { type: "button", className: "metaobjects-form-submit", disabled: busy, onClick: onSave, children: busy ? "Saving…" : "Save crop" }), _jsx("button", { type: "button", className: "metaobjects-form-submit", disabled: busy, onClick: () => {
60
+ URL.revokeObjectURL(editing);
61
+ setEditing(null);
62
+ }, children: "Cancel" })] }), error && (_jsx("span", { className: "metaobjects-field-error", role: "alert", children: error }))] }))] }));
63
+ }
64
+ //# sourceMappingURL=image-upload.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image-upload.js","sourceRoot":"","sources":["../src/image-upload.tsx"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -1,3 +1,6 @@
1
1
  export { useEntityForm, type EntityFieldMeta, type EntityMeta, type BoundInputProps, type InputAccessor, type UseEntityFormOptions, type UseEntityFormReturn, } from "./use-entity-form.js";
2
2
  export { CurrencyInput, type CurrencyInputProps } from "./currency-input.js";
3
+ export { ImageUpload, type ImageUploadProps } from "./image-upload.js";
4
+ export { ImageUploadAdapterProvider, useImageUploadAdapter, type ImageUploadAdapterProviderProps, } from "./image-adapter-provider.js";
5
+ export { cropToBlob } from "./crop-to-blob.js";
3
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,aAAa,EACb,KAAK,eAAe,EACpB,KAAK,UAAU,EACf,KAAK,eAAe,EACpB,KAAK,aAAa,EAClB,KAAK,oBAAoB,EACzB,KAAK,mBAAmB,GACzB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,aAAa,EACb,KAAK,eAAe,EACpB,KAAK,UAAU,EACf,KAAK,eAAe,EACpB,KAAK,aAAa,EAClB,KAAK,oBAAoB,EACzB,KAAK,mBAAmB,GACzB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC7E,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACvE,OAAO,EACL,0BAA0B,EAC1B,qBAAqB,EACrB,KAAK,+BAA+B,GACrC,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC"}
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  // Public API surface for @metaobjectsdev/react.
2
2
  export { useEntityForm, } from "./use-entity-form.js";
3
3
  export { CurrencyInput } from "./currency-input.js";
4
+ export { ImageUpload } from "./image-upload.js";
5
+ export { ImageUploadAdapterProvider, useImageUploadAdapter, } from "./image-adapter-provider.js";
6
+ export { cropToBlob } from "./crop-to-blob.js";
4
7
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,gDAAgD;AAChD,OAAO,EACL,aAAa,GAOd,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,aAAa,EAA2B,MAAM,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,gDAAgD;AAChD,OAAO,EACL,aAAa,GAOd,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,aAAa,EAA2B,MAAM,qBAAqB,CAAC;AAC7E,OAAO,EAAE,WAAW,EAAyB,MAAM,mBAAmB,CAAC;AACvE,OAAO,EACL,0BAA0B,EAC1B,qBAAqB,GAEtB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC"}
package/form.css ADDED
@@ -0,0 +1,58 @@
1
+ /* Default styling for the metadata-driven form controls emitted by
2
+ @metaobjectsdev/codegen-ts-react's formFile generator, plus the
3
+ <ImageUpload> component. Single-class specificity so a host app's own
4
+ reset/preflight styles don't win. Colors resolve through CSS custom
5
+ properties with a fallback hex, so this file is themable and works with
6
+ zero configuration out of the box. */
7
+ .metaobjects-form { display: grid; gap: 1.25rem; }
8
+
9
+ .metaobjects-field { display: grid; gap: 0.5rem; }
10
+
11
+ .metaobjects-field-label {
12
+ font-size: 0.72rem;
13
+ text-transform: uppercase;
14
+ letter-spacing: 0.2em;
15
+ color: var(--mo-fg, #eae3d3);
16
+ opacity: 0.62;
17
+ }
18
+
19
+ .metaobjects-field-input {
20
+ width: 100%;
21
+ padding: 0.75rem 1rem;
22
+ background: color-mix(in srgb, var(--mo-field-bg, #142a33) 40%, transparent);
23
+ border: 1px solid color-mix(in srgb, var(--mo-accent, #c9a467) 28%, transparent);
24
+ color: var(--mo-fg, #eae3d3);
25
+ font: inherit;
26
+ outline: none;
27
+ }
28
+ .metaobjects-field-input:focus-visible { border-color: var(--mo-accent, #c9a467); }
29
+ textarea.metaobjects-field-input { resize: vertical; min-height: 6rem; }
30
+
31
+ .metaobjects-field-checkbox { width: 1rem; height: 1rem; accent-color: var(--mo-accent, #c9a467); }
32
+ .metaobjects-field-radios { display: grid; gap: 0.5rem; border: 0; padding: 0; }
33
+ .metaobjects-field-radio { display: flex; gap: 0.5rem; align-items: center; color: var(--mo-fg, #eae3d3); }
34
+
35
+ .metaobjects-field-error { font-size: 0.8rem; color: var(--mo-accent-soft, #e3cd9f); }
36
+
37
+ .metaobjects-image-upload { display: grid; gap: 0.75rem; }
38
+ .metaobjects-image-preview { max-width: 100%; height: auto; }
39
+ .metaobjects-image-actions { display: grid; grid-auto-flow: column; justify-content: start; gap: 0.75rem; }
40
+ /* react-easy-crop requires a positioned, sized ancestor or its internal
41
+ absolutely-positioned layers collapse to 0x0. This is a functional
42
+ minimum, not decoration — override freely (e.g. a taller crop area). */
43
+ .metaobjects-image-cropper { position: relative; width: 100%; height: 300px; }
44
+
45
+ .metaobjects-form-actions { margin-top: 0.5rem; }
46
+ .metaobjects-form-submit {
47
+ padding: 0.6rem 1.5rem;
48
+ background: var(--mo-accent, #c9a467);
49
+ border: 1px solid var(--mo-accent, #c9a467);
50
+ color: var(--mo-accent-fg, #0f1d24);
51
+ font-size: 0.72rem;
52
+ text-transform: uppercase;
53
+ letter-spacing: 0.22em;
54
+ cursor: pointer;
55
+ transition: background-color 0.2s;
56
+ }
57
+ .metaobjects-form-submit:hover { background: var(--mo-accent-soft, #e3cd9f); }
58
+ .metaobjects-form-submit:disabled { opacity: 0.6; cursor: not-allowed; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metaobjectsdev/react",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "React runtime for metaobjects: useEntityForm hook and CurrencyInput component.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -10,11 +10,13 @@
10
10
  "bun": "./src/index.ts",
11
11
  "types": "./dist/index.d.ts",
12
12
  "default": "./dist/index.js"
13
- }
13
+ },
14
+ "./form.css": "./form.css"
14
15
  },
15
16
  "files": [
16
17
  "dist",
17
18
  "src",
19
+ "form.css",
18
20
  "README.md",
19
21
  "LICENSE"
20
22
  ],
@@ -45,11 +47,12 @@
45
47
  "access": "public"
46
48
  },
47
49
  "dependencies": {
48
- "@metaobjectsdev/runtime-web": "0.18.0"
50
+ "@metaobjectsdev/runtime-web": "0.19.0"
49
51
  },
50
52
  "peerDependencies": {
51
53
  "@hookform/resolvers": ">=3.0.0",
52
54
  "react": ">=18.0.0",
55
+ "react-easy-crop": ">=5.0.0",
53
56
  "react-hook-form": ">=7.0.0",
54
57
  "zod": ">=3.23.0"
55
58
  },
@@ -60,6 +63,9 @@
60
63
  "react": {
61
64
  "optional": true
62
65
  },
66
+ "react-easy-crop": {
67
+ "optional": true
68
+ },
63
69
  "react-hook-form": {
64
70
  "optional": true
65
71
  },
@@ -69,13 +75,14 @@
69
75
  },
70
76
  "devDependencies": {
71
77
  "@hookform/resolvers": "^3.10.0",
72
- "@testing-library/react": "^16.0.0",
73
78
  "@testing-library/dom": "^10.0.0",
79
+ "@testing-library/react": "^16.0.0",
74
80
  "@types/react": "^19.0.0",
75
81
  "bun-types": "latest",
76
82
  "jsdom": "^29.1.1",
77
83
  "react": "^19.0.0",
78
84
  "react-dom": "^19.0.0",
85
+ "react-easy-crop": "^5.0.0",
79
86
  "react-hook-form": "^7.54.0",
80
87
  "typescript": "^5.6.0",
81
88
  "zod": "^4.4.3"
@@ -0,0 +1,26 @@
1
+ import type { Area } from "react-easy-crop";
2
+ import { canvasToJpegBlob } from "@metaobjectsdev/runtime-web";
3
+
4
+ /**
5
+ * Draws the selected crop rectangle of `src` onto a canvas bounded to
6
+ * `maxEdge` on its longest side, then re-encodes to JPEG. The canvas
7
+ * redraw drops all EXIF/GPS metadata as a side effect.
8
+ */
9
+ export async function cropToBlob(src: string, area: Area, maxEdge: number): Promise<Blob> {
10
+ const img = await new Promise<HTMLImageElement>((resolve, reject) => {
11
+ const el = new Image();
12
+ el.onload = () => resolve(el);
13
+ el.onerror = reject;
14
+ el.src = src;
15
+ });
16
+ const scale = Math.min(1, maxEdge / Math.max(area.width, area.height));
17
+ const w = Math.round(area.width * scale);
18
+ const h = Math.round(area.height * scale);
19
+ const canvas = document.createElement("canvas");
20
+ canvas.width = w;
21
+ canvas.height = h;
22
+ const ctx = canvas.getContext("2d");
23
+ if (!ctx) throw new Error("canvas 2d context unavailable");
24
+ ctx.drawImage(img, area.x, area.y, area.width, area.height, 0, 0, w, h);
25
+ return canvasToJpegBlob(canvas, 0.9);
26
+ }
@@ -0,0 +1,26 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import type { ImageUploadAdapter } from "@metaobjectsdev/runtime-web";
3
+
4
+ const ImageUploadAdapterContext = createContext<ImageUploadAdapter | null>(null);
5
+
6
+ export interface ImageUploadAdapterProviderProps {
7
+ value: ImageUploadAdapter;
8
+ children: ReactNode;
9
+ }
10
+
11
+ /** Wrap your app (or the relevant subtree) to supply an upload/serve adapter to <ImageUpload>. */
12
+ export function ImageUploadAdapterProvider({ value, children }: ImageUploadAdapterProviderProps) {
13
+ return <ImageUploadAdapterContext.Provider value={value}>{children}</ImageUploadAdapterContext.Provider>;
14
+ }
15
+
16
+ /** Reads the image upload adapter from context. Throws if not provided. */
17
+ export function useImageUploadAdapter(): ImageUploadAdapter {
18
+ const adapter = useContext(ImageUploadAdapterContext);
19
+ if (!adapter) {
20
+ throw new Error(
21
+ "useImageUploadAdapter() called outside <ImageUploadAdapterProvider>. " +
22
+ "Wrap your app (or the relevant subtree) with ImageUploadAdapterProvider value={...}.",
23
+ );
24
+ }
25
+ return adapter;
26
+ }
@@ -0,0 +1,128 @@
1
+ import { lazy, Suspense, useCallback, useRef, useState } from "react";
2
+ import type { ChangeEvent, ReactElement } from "react";
3
+ import type { Area } from "react-easy-crop";
4
+ import type { ImageMeta } from "@metaobjectsdev/runtime-web";
5
+ import { useImageUploadAdapter } from "./image-adapter-provider.js";
6
+ import { cropToBlob } from "./crop-to-blob.js";
7
+
8
+ // react-easy-crop is an optional peer dependency — loaded lazily so consumers
9
+ // who don't use <ImageUpload> never pay for it.
10
+ const Cropper = lazy(() => import("react-easy-crop"));
11
+
12
+ export interface ImageUploadProps {
13
+ /** Current stored image key, or null/undefined when no image is set. */
14
+ value?: string | null;
15
+ /** Called with the new stored image key, or null when the image is removed. */
16
+ onChange: (key: string | null) => void;
17
+ /** Resolved view.image attrs (aspectRatio / maxEdge / store / accept / maxBytes). */
18
+ meta: ImageMeta;
19
+ }
20
+
21
+ /**
22
+ * Metadata-driven image upload + crop control. Renders a preview plus
23
+ * Upload/Replace/Remove actions when idle, and a lazy-loaded crop UI
24
+ * (react-easy-crop) while a picked file is being cropped. On save, crops the
25
+ * selection to a bounded JPEG and hands the blob to the adapter supplied via
26
+ * <ImageUploadAdapterProvider> — this component has no knowledge of the
27
+ * storage backend, upload endpoint, or auth.
28
+ *
29
+ * Bound by the generated form's `view.image` field; usable standalone too.
30
+ */
31
+ export function ImageUpload({ value, onChange, meta }: ImageUploadProps): ReactElement {
32
+ const adapter = useImageUploadAdapter();
33
+ const [editing, setEditing] = useState<string | null>(null); // object URL being cropped
34
+ const [crop, setCrop] = useState({ x: 0, y: 0 });
35
+ const [zoom, setZoom] = useState(1);
36
+ const areaRef = useRef<Area | null>(null);
37
+ const [busy, setBusy] = useState(false);
38
+ const [error, setError] = useState<string | null>(null);
39
+ const accept = (meta.accept ?? ["image/jpeg", "image/png", "image/webp"]).join(",");
40
+
41
+ const onPick = (e: ChangeEvent<HTMLInputElement>) => {
42
+ const file = e.target.files?.[0];
43
+ if (!file) return;
44
+ if (meta.maxBytes && file.size > meta.maxBytes) return; // client guard; server re-enforces
45
+ setError(null);
46
+ setEditing(URL.createObjectURL(file));
47
+ };
48
+
49
+ const onSave = useCallback(async () => {
50
+ if (!editing || !areaRef.current) return;
51
+ setBusy(true);
52
+ setError(null);
53
+ try {
54
+ const blob = await cropToBlob(editing, areaRef.current, meta.maxEdge ?? 2000);
55
+ // exactOptionalPropertyTypes: only forward `store` when the caller supplied one.
56
+ const { key } = await adapter.upload(blob, { ...(meta.store !== undefined && { store: meta.store }) });
57
+ onChange(key);
58
+ URL.revokeObjectURL(editing);
59
+ setEditing(null);
60
+ } catch {
61
+ // Crop/upload failed (adapter rejected the image, network drop, etc).
62
+ // Leave `editing` in place so the user's crop selection isn't lost —
63
+ // they can retry Save crop without re-picking the file.
64
+ setError("Something went wrong saving that image. Try again.");
65
+ } finally {
66
+ setBusy(false);
67
+ }
68
+ }, [editing, meta.maxEdge, meta.store, adapter, onChange]);
69
+
70
+ return (
71
+ <div className="metaobjects-image-upload">
72
+ {value && !editing && (
73
+ <img src={adapter.imageUrl(value)} alt="" className="metaobjects-image-preview" />
74
+ )}
75
+ {!editing && (
76
+ <div className="metaobjects-image-actions">
77
+ <label className="metaobjects-form-submit">
78
+ {value ? "Replace" : "Upload"}
79
+ <input type="file" accept={accept} onChange={onPick} hidden />
80
+ </label>
81
+ {value && (
82
+ <button type="button" className="metaobjects-form-submit" onClick={() => onChange(null)}>
83
+ Remove
84
+ </button>
85
+ )}
86
+ </div>
87
+ )}
88
+ {editing && (
89
+ <div className="metaobjects-image-cropper">
90
+ <Suspense fallback={<p>Loading cropper…</p>}>
91
+ <div style={{ position: "relative", height: 320 }}>
92
+ <Cropper
93
+ image={editing}
94
+ crop={crop}
95
+ zoom={zoom}
96
+ aspect={meta.aspectRatio}
97
+ onCropChange={setCrop}
98
+ onZoomChange={setZoom}
99
+ onCropComplete={(_croppedArea, croppedAreaPixels) => (areaRef.current = croppedAreaPixels)}
100
+ />
101
+ </div>
102
+ </Suspense>
103
+ <div className="metaobjects-image-actions">
104
+ <button type="button" className="metaobjects-form-submit" disabled={busy} onClick={onSave}>
105
+ {busy ? "Saving…" : "Save crop"}
106
+ </button>
107
+ <button
108
+ type="button"
109
+ className="metaobjects-form-submit"
110
+ disabled={busy}
111
+ onClick={() => {
112
+ URL.revokeObjectURL(editing);
113
+ setEditing(null);
114
+ }}
115
+ >
116
+ Cancel
117
+ </button>
118
+ </div>
119
+ {error && (
120
+ <span className="metaobjects-field-error" role="alert">
121
+ {error}
122
+ </span>
123
+ )}
124
+ </div>
125
+ )}
126
+ </div>
127
+ );
128
+ }
package/src/index.ts CHANGED
@@ -9,3 +9,10 @@ export {
9
9
  type UseEntityFormReturn,
10
10
  } from "./use-entity-form.js";
11
11
  export { CurrencyInput, type CurrencyInputProps } from "./currency-input.js";
12
+ export { ImageUpload, type ImageUploadProps } from "./image-upload.js";
13
+ export {
14
+ ImageUploadAdapterProvider,
15
+ useImageUploadAdapter,
16
+ type ImageUploadAdapterProviderProps,
17
+ } from "./image-adapter-provider.js";
18
+ export { cropToBlob } from "./crop-to-blob.js";