@realiizlabs/admin 0.8.2 → 0.9.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.
@@ -4,16 +4,35 @@ import { C as ContentTypeEntry } from '../types-DyryuIX1.cjs';
4
4
  import 'zod';
5
5
 
6
6
  /**
7
- * Control strings frontmatter values. Pure.
7
+ * Picked images beside the form state. An image FIELD's value stays a path
8
+ * string (what the frontmatter wants); the shrunk bytes for a newly chosen
9
+ * picture live here until submit, keyed by field name, with an object URL for
10
+ * the preview and the previous path so Remove can restore it.
8
11
  *
9
- * Controls hold strings (or booleans / Files). The schema wants typed values.
10
- * `toControl` seeds a control from an existing frontmatter value including
11
- * a Date from a previously-parsed file — and `toValue` assembles what goes to
12
- * safeParse. Anything the schema then coerces (dates) is the schema's business.
12
+ * The host decides the path and does the shrinking (`onPickImage`) because it
13
+ * knows the slug and the size rules; this hook only keeps the result.
13
14
  */
14
-
15
- /** Stand-in path for an image chosen but not yet uploaded — replaced by ADMIN-06. */
16
- declare const PENDING_UPLOAD_PREFIX = "pending-upload/";
15
+ interface PickedImage {
16
+ path: string;
17
+ blob: Blob;
18
+ previewUrl: string;
19
+ width?: number;
20
+ height?: number;
21
+ /** The field's value before this pick, restored by Remove. */
22
+ previous: string;
23
+ }
24
+ type PickResult = {
25
+ path: string;
26
+ blob: Blob;
27
+ previewUrl?: string;
28
+ width?: number;
29
+ height?: number;
30
+ };
31
+ type PickImage = (field: string, file: File) => Promise<PickResult>;
32
+ type SubmittedImages = Record<string, {
33
+ path: string;
34
+ blob: Blob;
35
+ }>;
17
36
 
18
37
  type LabelRenderer = (label: string, help: string) => ReactNode;
19
38
  /**
@@ -70,13 +89,25 @@ interface MarkdownEditorProps {
70
89
  * selection as `<Term definition="">word</Term>`. Off unless the site has such a component.
71
90
  */
72
91
  termComponent?: string;
92
+ /**
93
+ * Upload a picture for the body: shrink, name and hand back its final path and a
94
+ * preview URL. When set, the toolbar Image button opens a file picker (and drop or
95
+ * paste of an image onto the text does the same) and inserts ![description](path).
96
+ */
97
+ onUploadImage?: (file: File) => Promise<{
98
+ path: string;
99
+ previewUrl: string;
100
+ }>;
101
+ /** Paths of not-yet-committed pictures → object URLs, applied to <img src> in the preview. */
102
+ previewSrc?: Record<string, string>;
73
103
  }
74
- declare function MarkdownEditor({ id, name, value, onChange, placeholder, renderPreview, previewClassName, minHeight, termComponent }: MarkdownEditorProps): react.JSX.Element;
104
+ declare function MarkdownEditor({ id, name, value, onChange, placeholder, renderPreview, previewClassName, minHeight, termComponent, onUploadImage, previewSrc }: MarkdownEditorProps): react.JSX.Element;
75
105
 
76
106
  interface ContentFormSubmission {
77
107
  frontmatter: Record<string, unknown>;
78
108
  body: string;
79
- files: Record<string, File>;
109
+ /** Newly picked pictures by field name — the frontmatter already holds each one's final path. */
110
+ images: SubmittedImages;
80
111
  }
81
112
  interface ContentFormProps {
82
113
  entry: ContentTypeEntry;
@@ -99,6 +130,17 @@ interface ContentFormProps {
99
130
  previewClassName?: string;
100
131
  /** Site tooltip MDX component name (e.g. "Term") to expose as a toolbar button. */
101
132
  termComponent?: string;
133
+ /**
134
+ * Image fields: shrink and name a chosen file (the host knows the slug and the
135
+ * size rules) and hand back { path, blob, previewUrl?, width?, height? }. The
136
+ * field's value becomes `path`; the blob rides along in the submission. Without
137
+ * this, image fields are plain text paths.
138
+ */
139
+ onPickImage?: PickImage;
140
+ /** Editor toolbar Image button: upload a picture for the body and return its path + preview URL. */
141
+ onUploadImage?: MarkdownEditorProps["onUploadImage"];
142
+ /** Paths of not-yet-committed pictures → object URLs, so the Preview tab shows them. */
143
+ previewSrc?: MarkdownEditorProps["previewSrc"];
102
144
  /**
103
145
  * Put Cancel/Save in a bar that the host can pin to the bottom of the page
104
146
  * (`.realiiz-form__bar`; the shell makes it sticky). The bar also reports
@@ -130,7 +172,22 @@ interface ContentFormProps {
130
172
  }
131
173
  /** "Hello, World! 2026" → "hello-world-2026" — the same rule the blog's slug regex enforces. */
132
174
  declare function slugify(s: string): string;
133
- declare function ContentForm({ entry, initialValues, initialBody, onSubmit, onCancel, submitLabel, className, showBody, bodyLabel, renderPreview, previewClassName, termComponent, stickyBar, status, barExtra, footer, renderLabel, derive, guardUnsaved }: ContentFormProps): react.JSX.Element;
175
+ declare function ContentForm({ entry, initialValues, initialBody, onSubmit, onCancel, submitLabel, className, showBody, bodyLabel, renderPreview, previewClassName, termComponent, onPickImage, onUploadImage, previewSrc, stickyBar, status, barExtra, footer, renderLabel, derive, guardUnsaved }: ContentFormProps): react.JSX.Element;
176
+
177
+ declare const IMAGE_HINT = "JPG, PNG or WebP, up to 10MB. We shrink it for the web.";
178
+ interface ImageInputProps {
179
+ id: string;
180
+ name: string;
181
+ /** The field's current value — a path such as /blog/my-post.webp, or "". */
182
+ value: string;
183
+ picked?: PickedImage;
184
+ invalid?: boolean;
185
+ disabled?: boolean;
186
+ onPick: (file: File) => Promise<void>;
187
+ onRemove?: () => void;
188
+ onBlur?: () => void;
189
+ }
190
+ declare function ImageInput(p: ImageInputProps): react.JSX.Element;
134
191
 
135
192
  /**
136
193
  * ChipsInput — tags as chips. Enter or comma adds, × or Backspace-on-empty
@@ -149,4 +206,4 @@ declare function ChipsInput({ id, name, value, onChange, onBlur, placeholder, in
149
206
  max?: number;
150
207
  }): react.JSX.Element;
151
208
 
152
- export { ChipsInput, ContentForm, type ContentFormProps, type ContentFormSubmission, type LabelRenderer, LengthGauge, MarkdownEditor, type MarkdownEditorProps, PENDING_UPLOAD_PREFIX, TOOLS, slugify };
209
+ export { ChipsInput, ContentForm, type ContentFormProps, type ContentFormSubmission, IMAGE_HINT, ImageInput, type ImageInputProps, type LabelRenderer, LengthGauge, MarkdownEditor, type MarkdownEditorProps, type PickImage, type PickResult, type PickedImage, type SubmittedImages, TOOLS, slugify };
@@ -4,16 +4,35 @@ import { C as ContentTypeEntry } from '../types-DyryuIX1.js';
4
4
  import 'zod';
5
5
 
6
6
  /**
7
- * Control strings frontmatter values. Pure.
7
+ * Picked images beside the form state. An image FIELD's value stays a path
8
+ * string (what the frontmatter wants); the shrunk bytes for a newly chosen
9
+ * picture live here until submit, keyed by field name, with an object URL for
10
+ * the preview and the previous path so Remove can restore it.
8
11
  *
9
- * Controls hold strings (or booleans / Files). The schema wants typed values.
10
- * `toControl` seeds a control from an existing frontmatter value including
11
- * a Date from a previously-parsed file — and `toValue` assembles what goes to
12
- * safeParse. Anything the schema then coerces (dates) is the schema's business.
12
+ * The host decides the path and does the shrinking (`onPickImage`) because it
13
+ * knows the slug and the size rules; this hook only keeps the result.
13
14
  */
14
-
15
- /** Stand-in path for an image chosen but not yet uploaded — replaced by ADMIN-06. */
16
- declare const PENDING_UPLOAD_PREFIX = "pending-upload/";
15
+ interface PickedImage {
16
+ path: string;
17
+ blob: Blob;
18
+ previewUrl: string;
19
+ width?: number;
20
+ height?: number;
21
+ /** The field's value before this pick, restored by Remove. */
22
+ previous: string;
23
+ }
24
+ type PickResult = {
25
+ path: string;
26
+ blob: Blob;
27
+ previewUrl?: string;
28
+ width?: number;
29
+ height?: number;
30
+ };
31
+ type PickImage = (field: string, file: File) => Promise<PickResult>;
32
+ type SubmittedImages = Record<string, {
33
+ path: string;
34
+ blob: Blob;
35
+ }>;
17
36
 
18
37
  type LabelRenderer = (label: string, help: string) => ReactNode;
19
38
  /**
@@ -70,13 +89,25 @@ interface MarkdownEditorProps {
70
89
  * selection as `<Term definition="">word</Term>`. Off unless the site has such a component.
71
90
  */
72
91
  termComponent?: string;
92
+ /**
93
+ * Upload a picture for the body: shrink, name and hand back its final path and a
94
+ * preview URL. When set, the toolbar Image button opens a file picker (and drop or
95
+ * paste of an image onto the text does the same) and inserts ![description](path).
96
+ */
97
+ onUploadImage?: (file: File) => Promise<{
98
+ path: string;
99
+ previewUrl: string;
100
+ }>;
101
+ /** Paths of not-yet-committed pictures → object URLs, applied to <img src> in the preview. */
102
+ previewSrc?: Record<string, string>;
73
103
  }
74
- declare function MarkdownEditor({ id, name, value, onChange, placeholder, renderPreview, previewClassName, minHeight, termComponent }: MarkdownEditorProps): react.JSX.Element;
104
+ declare function MarkdownEditor({ id, name, value, onChange, placeholder, renderPreview, previewClassName, minHeight, termComponent, onUploadImage, previewSrc }: MarkdownEditorProps): react.JSX.Element;
75
105
 
76
106
  interface ContentFormSubmission {
77
107
  frontmatter: Record<string, unknown>;
78
108
  body: string;
79
- files: Record<string, File>;
109
+ /** Newly picked pictures by field name — the frontmatter already holds each one's final path. */
110
+ images: SubmittedImages;
80
111
  }
81
112
  interface ContentFormProps {
82
113
  entry: ContentTypeEntry;
@@ -99,6 +130,17 @@ interface ContentFormProps {
99
130
  previewClassName?: string;
100
131
  /** Site tooltip MDX component name (e.g. "Term") to expose as a toolbar button. */
101
132
  termComponent?: string;
133
+ /**
134
+ * Image fields: shrink and name a chosen file (the host knows the slug and the
135
+ * size rules) and hand back { path, blob, previewUrl?, width?, height? }. The
136
+ * field's value becomes `path`; the blob rides along in the submission. Without
137
+ * this, image fields are plain text paths.
138
+ */
139
+ onPickImage?: PickImage;
140
+ /** Editor toolbar Image button: upload a picture for the body and return its path + preview URL. */
141
+ onUploadImage?: MarkdownEditorProps["onUploadImage"];
142
+ /** Paths of not-yet-committed pictures → object URLs, so the Preview tab shows them. */
143
+ previewSrc?: MarkdownEditorProps["previewSrc"];
102
144
  /**
103
145
  * Put Cancel/Save in a bar that the host can pin to the bottom of the page
104
146
  * (`.realiiz-form__bar`; the shell makes it sticky). The bar also reports
@@ -130,7 +172,22 @@ interface ContentFormProps {
130
172
  }
131
173
  /** "Hello, World! 2026" → "hello-world-2026" — the same rule the blog's slug regex enforces. */
132
174
  declare function slugify(s: string): string;
133
- declare function ContentForm({ entry, initialValues, initialBody, onSubmit, onCancel, submitLabel, className, showBody, bodyLabel, renderPreview, previewClassName, termComponent, stickyBar, status, barExtra, footer, renderLabel, derive, guardUnsaved }: ContentFormProps): react.JSX.Element;
175
+ declare function ContentForm({ entry, initialValues, initialBody, onSubmit, onCancel, submitLabel, className, showBody, bodyLabel, renderPreview, previewClassName, termComponent, onPickImage, onUploadImage, previewSrc, stickyBar, status, barExtra, footer, renderLabel, derive, guardUnsaved }: ContentFormProps): react.JSX.Element;
176
+
177
+ declare const IMAGE_HINT = "JPG, PNG or WebP, up to 10MB. We shrink it for the web.";
178
+ interface ImageInputProps {
179
+ id: string;
180
+ name: string;
181
+ /** The field's current value — a path such as /blog/my-post.webp, or "". */
182
+ value: string;
183
+ picked?: PickedImage;
184
+ invalid?: boolean;
185
+ disabled?: boolean;
186
+ onPick: (file: File) => Promise<void>;
187
+ onRemove?: () => void;
188
+ onBlur?: () => void;
189
+ }
190
+ declare function ImageInput(p: ImageInputProps): react.JSX.Element;
134
191
 
135
192
  /**
136
193
  * ChipsInput — tags as chips. Enter or comma adds, × or Backspace-on-empty
@@ -149,4 +206,4 @@ declare function ChipsInput({ id, name, value, onChange, onBlur, placeholder, in
149
206
  max?: number;
150
207
  }): react.JSX.Element;
151
208
 
152
- export { ChipsInput, ContentForm, type ContentFormProps, type ContentFormSubmission, type LabelRenderer, LengthGauge, MarkdownEditor, type MarkdownEditorProps, PENDING_UPLOAD_PREFIX, TOOLS, slugify };
209
+ export { ChipsInput, ContentForm, type ContentFormProps, type ContentFormSubmission, IMAGE_HINT, ImageInput, type ImageInputProps, type LabelRenderer, LengthGauge, MarkdownEditor, type MarkdownEditorProps, type PickImage, type PickResult, type PickedImage, type SubmittedImages, TOOLS, slugify };
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { walkSchema } from '../chunk-J6FI73PF.js';
3
- import { FORM_CSS } from '../chunk-CE2DRQBD.js';
3
+ import { FORM_CSS } from '../chunk-FTD4WE3V.js';
4
4
  import { useState, useRef, useCallback, useEffect, useId, useMemo } from 'react';
5
5
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
6
6
 
@@ -82,16 +82,7 @@ function Control(p) {
82
82
  case "boolean":
83
83
  return /* @__PURE__ */ jsx("input", { ...common, type: "checkbox", checked: p.value === true, onChange: (e) => p.onChange(e.target.checked) });
84
84
  case "image":
85
- return /* @__PURE__ */ jsx(
86
- "input",
87
- {
88
- ...common,
89
- type: "file",
90
- accept: "image/*",
91
- className: cls,
92
- onChange: (e) => p.onChange(e.target.files?.[0] ?? null)
93
- }
94
- );
85
+ return /* @__PURE__ */ jsx("input", { ...common, type: "text", className: cls, value: str, placeholder: p.spec.placeholder ?? "/images/picture.webp", onChange: (e) => p.onChange(e.target.value) });
95
86
  case "date":
96
87
  return /* @__PURE__ */ jsx("input", { ...common, type: "date", className: cls, value: str, onChange: (e) => p.onChange(e.target.value) });
97
88
  case "number":
@@ -137,6 +128,151 @@ function Control(p) {
137
128
  );
138
129
  }
139
130
  }
131
+ function useImages() {
132
+ const [images, setImages] = useState({});
133
+ const latest = useRef(images);
134
+ latest.current = images;
135
+ const urls = useRef(/* @__PURE__ */ new Set());
136
+ useEffect(() => {
137
+ const set = urls.current;
138
+ return () => {
139
+ for (const u of set) URL.revokeObjectURL(u);
140
+ };
141
+ }, []);
142
+ const keep = useCallback((field, r, previous) => {
143
+ const previewUrl = r.previewUrl ?? URL.createObjectURL(r.blob);
144
+ if (!r.previewUrl) urls.current.add(previewUrl);
145
+ const picked = { path: r.path, blob: r.blob, previewUrl, width: r.width, height: r.height, previous };
146
+ setImages((m) => ({ ...m, [field]: picked }));
147
+ return picked;
148
+ }, []);
149
+ const remove = useCallback((field) => {
150
+ const previous = latest.current[field]?.previous;
151
+ setImages((m) => {
152
+ const rest = { ...m };
153
+ delete rest[field];
154
+ return rest;
155
+ });
156
+ return previous;
157
+ }, []);
158
+ const toSubmission = useCallback(() => {
159
+ return Object.fromEntries(Object.entries(images).map(([k, v]) => [k, { path: v.path, blob: v.blob }]));
160
+ }, [images]);
161
+ return { images, keep, remove, toSubmission };
162
+ }
163
+ function formatBytes(n) {
164
+ if (n < 1024 * 1024) return `${Math.max(1, Math.round(n / 1024))} KB`;
165
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
166
+ }
167
+
168
+ // src/forms-ui/md/upload.ts
169
+ var IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif";
170
+ function pickFile(accept = IMAGE_ACCEPT) {
171
+ return new Promise((resolve) => {
172
+ const input = document.createElement("input");
173
+ input.type = "file";
174
+ input.accept = accept;
175
+ input.style.display = "none";
176
+ document.body.appendChild(input);
177
+ const done = () => {
178
+ resolve(input.files?.[0]);
179
+ input.remove();
180
+ };
181
+ input.addEventListener("change", done, { once: true });
182
+ window.addEventListener("focus", () => setTimeout(() => {
183
+ if (document.body.contains(input)) done();
184
+ }, 400), { once: true });
185
+ input.click();
186
+ });
187
+ }
188
+ function imageFromDrop(e) {
189
+ return Array.from(e.dataTransfer?.files ?? []).find((f) => f.type.startsWith("image/"));
190
+ }
191
+ function imageFromPaste(e) {
192
+ const item = Array.from(e.clipboardData?.items ?? []).find((i) => i.kind === "file" && i.type.startsWith("image/"));
193
+ return item?.getAsFile() ?? void 0;
194
+ }
195
+ function messageOf(err) {
196
+ if (err && typeof err === "object" && err.name === "MediaError") return err.message;
197
+ return "That picture couldn\u2019t be prepared. Try a different file.";
198
+ }
199
+ var IMAGE_HINT = "JPG, PNG or WebP, up to 10MB. We shrink it for the web.";
200
+ function ImageInput(p) {
201
+ const [busy, setBusy] = useState(false);
202
+ const [error, setError] = useState(null);
203
+ const [over, setOver] = useState(false);
204
+ const zone = useRef(null);
205
+ const take = async (file) => {
206
+ if (!file || busy || p.disabled) return;
207
+ setBusy(true);
208
+ setError(null);
209
+ try {
210
+ await p.onPick(file);
211
+ } catch (err) {
212
+ setError(messageOf(err));
213
+ } finally {
214
+ setBusy(false);
215
+ }
216
+ };
217
+ const choose = async () => take(await pickFile());
218
+ const onDrop = (e) => {
219
+ e.preventDefault();
220
+ setOver(false);
221
+ void take(imageFromDrop(e));
222
+ };
223
+ const onPaste = (e) => {
224
+ const f = imageFromPaste(e);
225
+ if (f) {
226
+ e.preventDefault();
227
+ void take(f);
228
+ }
229
+ };
230
+ const src = p.picked?.previewUrl ?? (p.value || null);
231
+ const detail = p.picked ? [p.picked.width && p.picked.height ? `${p.picked.width} \xD7 ${p.picked.height}` : null, formatBytes(p.picked.blob.size)].filter(Boolean).join(" \xB7 ") : p.value;
232
+ return /* @__PURE__ */ jsxs(
233
+ "div",
234
+ {
235
+ ref: zone,
236
+ className: ["realiiz-image", over && "realiiz-image--over", p.invalid && "realiiz-image--invalid", busy && "realiiz-image--busy"].filter(Boolean).join(" "),
237
+ "data-field": p.name,
238
+ onDragOver: (e) => {
239
+ e.preventDefault();
240
+ if (!over) setOver(true);
241
+ },
242
+ onDragLeave: () => setOver(false),
243
+ onDrop,
244
+ onPaste,
245
+ onBlur: p.onBlur,
246
+ tabIndex: -1,
247
+ children: [
248
+ /* @__PURE__ */ jsx("input", { id: p.id, name: p.name, type: "text", value: p.value, readOnly: true, className: "realiiz-sr-only", "aria-busy": busy || void 0 }),
249
+ src ? /* @__PURE__ */ jsxs("div", { className: "realiiz-image__has", children: [
250
+ /* @__PURE__ */ jsx("img", { src, alt: "", className: "realiiz-image__thumb", "data-testid": "realiiz-image-thumb" }),
251
+ /* @__PURE__ */ jsxs("div", { className: "realiiz-image__meta", children: [
252
+ /* @__PURE__ */ jsx("div", { className: "realiiz-image__detail", children: busy ? "Shrinking\u2026" : detail }),
253
+ /* @__PURE__ */ jsxs("div", { className: "realiiz-image__actions", children: [
254
+ /* @__PURE__ */ jsx("button", { type: "button", className: "realiiz-form__button", onClick: choose, disabled: busy || p.disabled, children: "Replace" }),
255
+ p.picked && p.onRemove && /* @__PURE__ */ jsx("button", { type: "button", className: "realiiz-image__remove", onClick: p.onRemove, disabled: busy, children: "Remove" })
256
+ ] })
257
+ ] })
258
+ ] }) : /* @__PURE__ */ jsxs("div", { className: "realiiz-image__empty", children: [
259
+ /* @__PURE__ */ jsx(PictureIcon, {}),
260
+ /* @__PURE__ */ jsx("button", { type: "button", className: "realiiz-form__button", onClick: choose, disabled: busy || p.disabled, children: busy ? "Shrinking\u2026" : "Choose a picture" }),
261
+ /* @__PURE__ */ jsx("span", { className: "realiiz-image__or", children: "or drag one here" })
262
+ ] }),
263
+ /* @__PURE__ */ jsx("div", { className: "realiiz-image__hint", children: IMAGE_HINT }),
264
+ error && /* @__PURE__ */ jsx("div", { className: "realiiz-form__error", role: "alert", children: error })
265
+ ]
266
+ }
267
+ );
268
+ }
269
+ function PictureIcon() {
270
+ return /* @__PURE__ */ jsxs("svg", { width: "28", height: "28", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", className: "realiiz-image__icon", children: [
271
+ /* @__PURE__ */ jsx("rect", { x: "3", y: "5", width: "18", height: "14", rx: "2" }),
272
+ /* @__PURE__ */ jsx("circle", { cx: "9", cy: "10", r: "1.6" }),
273
+ /* @__PURE__ */ jsx("path", { d: "M21 16l-5-5-8 8" })
274
+ ] });
275
+ }
140
276
  var defaultLabel = (label, help) => /* @__PURE__ */ jsx("span", { className: "realiiz-form__help", title: help, children: label });
141
277
  function LengthGauge({ length: raw, range, suffix }) {
142
278
  const [min, max] = range;
@@ -169,7 +305,19 @@ function Field(p) {
169
305
  const isCheck = p.spec.input === "boolean";
170
306
  const isTags = p.spec.input === "tags";
171
307
  const labelText = p.spec.help ? (p.renderLabel ?? defaultLabel)(p.spec.label, p.spec.help) : p.spec.label;
172
- const control = isTags ? /* @__PURE__ */ jsx(
308
+ const control = p.spec.input === "image" && p.onPickImage ? /* @__PURE__ */ jsx(
309
+ ImageInput,
310
+ {
311
+ id,
312
+ name: p.spec.name,
313
+ value: typeof p.value === "string" ? p.value : "",
314
+ picked: p.picked,
315
+ invalid,
316
+ onPick: p.onPickImage,
317
+ onRemove: p.onRemoveImage,
318
+ onBlur: p.onBlur
319
+ }
320
+ ) : isTags ? /* @__PURE__ */ jsx(
173
321
  ChipsInput,
174
322
  {
175
323
  id,
@@ -196,10 +344,6 @@ function Field(p) {
196
344
  ] }),
197
345
  control
198
346
  ] }),
199
- p.spec.input === "image" && p.current && !(p.value instanceof File) && /* @__PURE__ */ jsxs("div", { className: "realiiz-form__current", children: [
200
- "Current: ",
201
- p.current
202
- ] }),
203
347
  isText && (p.spec.recommended || typeof max === "number") && /* @__PURE__ */ jsxs("div", { className: "realiiz-form__helper", children: [
204
348
  p.spec.recommended ? /* @__PURE__ */ jsx(LengthGauge, { length: len, range: p.spec.recommended, suffix: p.spec.suffix }) : /* @__PURE__ */ jsx("span", {}),
205
349
  typeof max === "number" && /* @__PURE__ */ jsxs("span", { className: "realiiz-form__count", children: [
@@ -534,7 +678,7 @@ var TABLE_OPS = [
534
678
  { op: "colAfter", label: "+ Col", tip: "Insert a column to the right" },
535
679
  { op: "colDel", label: "\u2212 Col", tip: "Delete this column" }
536
680
  ];
537
- function MarkdownEditor({ id, name = "body", value, onChange, placeholder, renderPreview, previewClassName, minHeight = 320, termComponent }) {
681
+ function MarkdownEditor({ id, name = "body", value, onChange, placeholder, renderPreview, previewClassName, minHeight = 320, termComponent, onUploadImage, previewSrc }) {
538
682
  const [mode, setMode] = useState(() => renderPreview && value.trim() ? "preview" : "edit");
539
683
  const [preview, setPreview] = useState(null);
540
684
  const [loading, setLoading] = useState(false);
@@ -542,6 +686,7 @@ function MarkdownEditor({ id, name = "body", value, onChange, placeholder, rende
542
686
  const [on, setOn] = useState(() => /* @__PURE__ */ new Set());
543
687
  const [inTable, setInTable] = useState(false);
544
688
  const [note, setNote] = useState(null);
689
+ const [uploading, setUploading] = useState(false);
545
690
  const ta = useRef(null);
546
691
  const sync = useCallback(() => {
547
692
  const el = ta.current;
@@ -588,6 +733,37 @@ function MarkdownEditor({ id, name = "body", value, onChange, placeholder, rende
588
733
  setNote(null);
589
734
  commit(r);
590
735
  };
736
+ const upload = async (file) => {
737
+ const el = ta.current;
738
+ if (!file || !onUploadImage || !el || uploading) return;
739
+ setUploading(true);
740
+ setNote(null);
741
+ const a = el.selectionStart, b = el.selectionEnd;
742
+ try {
743
+ const { path } = await onUploadImage(file);
744
+ const alt = value.slice(a, b) || "description";
745
+ const text = value.slice(0, a) + `![${alt}](${path})` + value.slice(b);
746
+ commit({ text, a: a + 2, b: a + 2 + alt.length });
747
+ } catch (err) {
748
+ setNote(messageOf(err));
749
+ } finally {
750
+ setUploading(false);
751
+ }
752
+ };
753
+ const onDrop = (e) => {
754
+ const f = imageFromDrop(e);
755
+ if (f && onUploadImage) {
756
+ e.preventDefault();
757
+ void upload(f);
758
+ }
759
+ };
760
+ const onPaste = (e) => {
761
+ const f = imageFromPaste(e);
762
+ if (f && onUploadImage) {
763
+ e.preventDefault();
764
+ void upload(f);
765
+ }
766
+ };
591
767
  const onKey = (e) => {
592
768
  if (e.key === "Escape" && expanded) {
593
769
  e.preventDefault();
@@ -626,7 +802,7 @@ function MarkdownEditor({ id, name = "body", value, onChange, placeholder, rende
626
802
  let live = true;
627
803
  setLoading(true);
628
804
  renderPreview(value).then((r) => {
629
- if (live) setPreview(r);
805
+ if (live) setPreview("html" in r ? { html: swapPreviewSrc(r.html, previewSrc) } : r);
630
806
  }).catch((err) => {
631
807
  if (live) setPreview({ error: err instanceof Error ? err.message : String(err) });
632
808
  }).finally(() => {
@@ -635,11 +811,13 @@ function MarkdownEditor({ id, name = "body", value, onChange, placeholder, rende
635
811
  return () => {
636
812
  live = false;
637
813
  };
638
- }, [mode, value, renderPreview]);
814
+ }, [mode, value, renderPreview, previewSrc]);
639
815
  const termTool = termComponent ? { id: "term", title: `Tooltip \u2014 wraps the selection in <${termComponent}>`, apply: toggleTerm(termComponent) } : null;
640
816
  const btn = (toolId) => {
641
817
  const t = toolId === "term" && termTool ? termTool : toolById(toolId);
642
- return /* @__PURE__ */ jsx("button", { type: "button", "data-tool": t.id, "data-tip": t.title, "aria-label": t.title, "aria-pressed": on.has(t.id) || void 0, className: on.has(t.id) ? "on" : void 0, onMouseDown: (e) => e.preventDefault(), onClick: () => run(t.apply), children: MD_ICONS[t.id] }, t.id);
818
+ const uploads = t.id === "image" && Boolean(onUploadImage);
819
+ const title = uploads ? uploading ? "Shrinking\u2026" : "Picture \u2014 choose a file, or drop/paste one into the text" : t.title;
820
+ return /* @__PURE__ */ jsx("button", { type: "button", "data-tool": t.id, "data-tip": title, "aria-label": title, "aria-pressed": on.has(t.id) || void 0, "aria-busy": uploads && uploading ? true : void 0, className: on.has(t.id) ? "on" : void 0, disabled: uploads && uploading, onMouseDown: (e) => e.preventDefault(), onClick: () => uploads ? pickFile().then(upload) : run(t.apply), children: MD_ICONS[t.id] }, t.id);
643
821
  };
644
822
  return /* @__PURE__ */ jsxs("div", { className: ["realiiz-md", expanded && "realiiz-md--fs"].filter(Boolean).join(" "), children: [
645
823
  renderPreview && /* @__PURE__ */ jsxs("span", { className: "realiiz-md__mode", role: "tablist", "aria-label": "Editor mode", children: [
@@ -696,6 +874,8 @@ function MarkdownEditor({ id, name = "body", value, onChange, placeholder, rende
696
874
  onKeyUp: sync,
697
875
  onClick: sync,
698
876
  onKeyDown: onKey,
877
+ onDrop,
878
+ onPaste,
699
879
  spellCheck: true
700
880
  }
701
881
  ) : /* @__PURE__ */ jsxs("div", { className: "realiiz-md__preview", style: expanded ? void 0 : { minHeight }, "aria-live": "polite", "aria-busy": loading, children: [
@@ -707,9 +887,13 @@ function MarkdownEditor({ id, name = "body", value, onChange, placeholder, rende
707
887
  !loading && preview && "html" in preview && /* @__PURE__ */ jsx("div", { className: ["realiiz-prose", previewClassName].filter(Boolean).join(" "), dangerouslySetInnerHTML: { __html: preview.html } })
708
888
  ] })
709
889
  ] }),
710
- note && /* @__PURE__ */ jsx("div", { className: "realiiz-form__helper realiiz-md__note", role: "status", children: note })
890
+ (note || uploading) && /* @__PURE__ */ jsx("div", { className: "realiiz-form__helper realiiz-md__note", role: "status", children: uploading ? "Shrinking\u2026" : note })
711
891
  ] });
712
892
  }
893
+ function swapPreviewSrc(html, map) {
894
+ if (!map || Object.keys(map).length === 0) return html;
895
+ return html.replace(/(<img\b[^>]*\bsrc=")([^"]+)(")/g, (m, pre, src, post) => map[src] ? `${pre}${map[src]}${post}` : m);
896
+ }
713
897
  function commonPrefix(a, b) {
714
898
  const n = Math.min(a.length, b.length);
715
899
  let i = 0;
@@ -766,7 +950,6 @@ function groupIssues(list) {
766
950
  }
767
951
 
768
952
  // src/forms-ui/values.ts
769
- var PENDING_UPLOAD_PREFIX = "pending-upload/";
770
953
  function toControl(spec, raw) {
771
954
  if (raw === void 0 || raw === null) return spec.input === "boolean" ? false : "";
772
955
  switch (spec.input) {
@@ -785,7 +968,6 @@ function toControl(spec, raw) {
785
968
  }
786
969
  function toFrontmatter(specs, state, hiddenValues) {
787
970
  const frontmatter = { ...hiddenValues };
788
- const files = {};
789
971
  for (const spec of specs) {
790
972
  if (spec.role === "hidden" || spec.role === "body") continue;
791
973
  const v = state[spec.name];
@@ -794,11 +976,6 @@ function toFrontmatter(specs, state, hiddenValues) {
794
976
  else if (spec.required) frontmatter[spec.name] = false;
795
977
  continue;
796
978
  }
797
- if (v instanceof File) {
798
- files[spec.name] = v;
799
- frontmatter[spec.name] = PENDING_UPLOAD_PREFIX + v.name;
800
- continue;
801
- }
802
979
  if (Array.isArray(v)) {
803
980
  if (v.length || spec.required) frontmatter[spec.name] = v;
804
981
  continue;
@@ -821,13 +998,13 @@ function toFrontmatter(specs, state, hiddenValues) {
821
998
  frontmatter[spec.name] = s;
822
999
  }
823
1000
  }
824
- return { frontmatter, files };
1001
+ return frontmatter;
825
1002
  }
826
1003
  var LEAVE_MESSAGE = "You have unsaved changes. Leave this page and lose them?";
827
1004
  function slugify(s) {
828
1005
  return s.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
829
1006
  }
830
- function ContentForm({ entry, initialValues = {}, initialBody = "", onSubmit, onCancel, submitLabel = "Save", className, showBody = true, bodyLabel = "Content", renderPreview, previewClassName, termComponent, stickyBar = false, status, barExtra, footer, renderLabel, derive, guardUnsaved = true }) {
1007
+ function ContentForm({ entry, initialValues = {}, initialBody = "", onSubmit, onCancel, submitLabel = "Save", className, showBody = true, bodyLabel = "Content", renderPreview, previewClassName, termComponent, onPickImage, onUploadImage, previewSrc, stickyBar = false, status, barExtra, footer, renderLabel, derive, guardUnsaved = true }) {
831
1008
  const formId = useId();
832
1009
  const specs = useMemo(() => walkSchema(entry.schema, entry), [entry]);
833
1010
  const visible = useMemo(() => specs.filter((s) => s.role !== "hidden" && s.role !== "body"), [specs]);
@@ -855,6 +1032,16 @@ function ContentForm({ entry, initialValues = {}, initialBody = "", onSubmit, on
855
1032
  update(name, v);
856
1033
  };
857
1034
  const [body, setBody] = useState(initialBody);
1035
+ const pictures = useImages();
1036
+ const pickImage = onPickImage ? (field) => async (file) => {
1037
+ const r = await onPickImage(field, file);
1038
+ pictures.keep(field, r, typeof state[field] === "string" ? state[field] : "");
1039
+ onFieldChange(field, r.path);
1040
+ } : void 0;
1041
+ const removeImage = (field) => () => {
1042
+ const previous = pictures.remove(field);
1043
+ onFieldChange(field, previous ?? "");
1044
+ };
858
1045
  const dirty = body !== initialBody || specs.some((s) => !sameValue(state[s.name], initialState[s.name]));
859
1046
  const [issues, setIssues] = useState([]);
860
1047
  const [attempted, setAttempted] = useState(false);
@@ -904,7 +1091,7 @@ function ContentForm({ entry, initialValues = {}, initialBody = "", onSubmit, on
904
1091
  };
905
1092
  }, [guardUnsaved, dirty, submitting]);
906
1093
  const validate = useCallback(() => {
907
- const { frontmatter } = toFrontmatter(specs, state, hiddenValues);
1094
+ const frontmatter = toFrontmatter(specs, state, hiddenValues);
908
1095
  const result = entry.schema.safeParse(frontmatter);
909
1096
  setIssues(result.success ? [] : mapIssues(result.error.issues, specs));
910
1097
  return result;
@@ -915,10 +1102,9 @@ function ContentForm({ entry, initialValues = {}, initialBody = "", onSubmit, on
915
1102
  setAttempted(true);
916
1103
  const result = validate();
917
1104
  if (!result.success) return;
918
- const { files } = toFrontmatter(specs, state, hiddenValues);
919
1105
  setSubmitting(true);
920
1106
  try {
921
- await onSubmit({ frontmatter: result.data, body, files });
1107
+ await onSubmit({ frontmatter: result.data, body, images: pictures.toSubmission() });
922
1108
  } finally {
923
1109
  setSubmitting(false);
924
1110
  }
@@ -940,9 +1126,11 @@ function ContentForm({ entry, initialValues = {}, initialBody = "", onSubmit, on
940
1126
  spec: s,
941
1127
  value: state[s.name],
942
1128
  issues: byField.get(s.name) ?? [],
943
- current: typeof initialValues[s.name] === "string" ? initialValues[s.name] : void 0,
944
1129
  options: optionsFor(s),
945
1130
  renderLabel,
1131
+ picked: pictures.images[s.name],
1132
+ onPickImage: pickImage?.(s.name),
1133
+ onRemoveImage: removeImage(s.name),
946
1134
  onChange: (v) => onFieldChange(s.name, v),
947
1135
  onBlur: () => {
948
1136
  if (attempted) validate();
@@ -963,7 +1151,9 @@ function ContentForm({ entry, initialValues = {}, initialBody = "", onSubmit, on
963
1151
  placeholder: bodySpec?.placeholder ?? "Write in Markdown. Headings with ##, links as [text](url).",
964
1152
  renderPreview,
965
1153
  previewClassName,
966
- termComponent
1154
+ termComponent,
1155
+ onUploadImage,
1156
+ previewSrc
967
1157
  }
968
1158
  )
969
1159
  ] }),
@@ -981,6 +1171,6 @@ function sameValue(a, b) {
981
1171
  return a === b;
982
1172
  }
983
1173
 
984
- export { ChipsInput, ContentForm, LengthGauge, MarkdownEditor, PENDING_UPLOAD_PREFIX, TOOLS, slugify };
1174
+ export { ChipsInput, ContentForm, IMAGE_HINT, ImageInput, LengthGauge, MarkdownEditor, TOOLS, slugify };
985
1175
  //# sourceMappingURL=index.js.map
986
1176
  //# sourceMappingURL=index.js.map