@metaobjectsdev/react 0.18.0 → 0.19.0-rc.1
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.
- package/dist/crop-to-blob.d.ts +8 -0
- package/dist/crop-to-blob.d.ts.map +1 -0
- package/dist/crop-to-blob.js +26 -0
- package/dist/crop-to-blob.js.map +1 -0
- package/dist/image-adapter-provider.d.ts +11 -0
- package/dist/image-adapter-provider.d.ts.map +1 -0
- package/dist/image-adapter-provider.js +17 -0
- package/dist/image-adapter-provider.js.map +1 -0
- package/dist/image-upload.d.ts +22 -0
- package/dist/image-upload.d.ts.map +1 -0
- package/dist/image-upload.js +64 -0
- package/dist/image-upload.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/form.css +58 -0
- package/package.json +11 -4
- package/src/crop-to-blob.ts +26 -0
- package/src/image-adapter-provider.tsx +26 -0
- package/src/image-upload.tsx +128 -0
- package/src/index.ts +7 -0
|
@@ -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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.19.0-rc.1",
|
|
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.
|
|
50
|
+
"@metaobjectsdev/runtime-web": "0.19.0-rc.1"
|
|
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";
|