@tristan2828/ui-foundation 4.26.0 → 4.28.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.
@@ -153,7 +153,8 @@ is a filled-in example while the Widgets demo is still here).
153
153
  controls named by position ("Item 2 text"), a per-item `FieldError`
154
154
  (a server 422 at `["body", "checklist", 2, "text"]` arrives as
155
155
  `fieldErrors["checklist.2.text"]` and binds to that row), and a summary
156
- in the table, never the items — and a `computed` field copies
156
+ in the table, never the items (an item with a `reference` field
157
+ copies Pairings, below) — and a `computed` field copies
157
158
  Progress (`checklistState`): `readOnly` in `openapi.yaml` and absent
158
159
  from `<Entity>Create`/`<Entity>Update`, one SQL expression in the
159
160
  router that the filter and sort use (correlated `EXISTS`, or a scalar
@@ -186,6 +187,95 @@ is a filled-in example while the Widgets demo is still here).
186
187
  Select is (Storybook `app/DateField` EditInPlace). The backend column
187
188
  is a SQL `date` (`datetime.date` in the model).
188
189
 
190
+ **An image on a record** (a photo of it) is the stored file's id on the
191
+ wire (`string`, nullable), and on the form the foundation's
192
+ `ImageField` in a `Controller` (`z.string().nullable()`): `value` and
193
+ `onChange` are the id, `thumbUrl` the record's saved photo,
194
+ `onBusyChange` holds the submit while a file uploads (Storybook
195
+ `app/ImageField`). Its `upload` is the app's own, one function in the
196
+ gateway that sends the file to the app's endpoint and resolves to
197
+ `{ id, thumbUrl, url }` (the `ImageUploadResult`), rejecting with the
198
+ `AppError`; the same function goes to a notes field's
199
+ `RichTextEditor`, so pictures in its text use the same storage:
200
+
201
+ ```ts
202
+ // src/api/gateway/files.ts
203
+ export async function uploadImage(file: File): Promise<ImageUploadResult> {
204
+ const body = new FormData()
205
+ body.append('file', file)
206
+ const response = await safeFetch('/files', { method: 'POST', body })
207
+ if (response.status !== 201) throw toAppError(response.status, response.body, response.headers)
208
+ const { id } = response.body as components['schemas']['File']
209
+ return { id, thumbUrl: `/api/files/${id}/content?size=thumb`, url: `/api/files/${id}/content` }
210
+ }
211
+ ```
212
+
213
+ The types it takes default to JPEG, PNG and WebP; never add SVG or
214
+ HTML, which run script when served from the app's own domain. The
215
+ endpoint, the storage and serving the files are the app's (the
216
+ template has none); `<Markdown>` shows an image only from a web
217
+ address or a path on the site, so `url` is one of those.
218
+
219
+ **A `reference` inside a sub-record item** (a list whose items each
220
+ point at another entity's record) copies Pairings, Widget's
221
+ `pairings`: Checklist's list, with a reference in each item.
222
+ - *Wire:* the item carries the referenced id only, named as a
223
+ top-level reference is (`{ categoryId, note }`, `seriesId` for a
224
+ Series), never a name or a nested record. Every name, on the form
225
+ and on the view, comes from the other entity's lookup by id
226
+ (`GET /<other>?ids=…`), as for a `multi reference`; add `ids` to
227
+ its list endpoint if it lacks one. An optional text field beside it
228
+ is a required `string` that is `""` when empty (`note`), so an item
229
+ never holds a null. `openapi.yaml` describes the item as its own
230
+ schema (`WidgetPairing`), with `maxItems` on the list.
231
+ - *Backend:* a child table like the checklist's (`widget_pairings`:
232
+ `position`, and a foreign key to the referenced table, commented),
233
+ replaced whole on every write. Before saving, the router checks every
234
+ item's id in one query (`_check_item_categories_exist`): one that
235
+ isn't there, or, for a per-user entity, isn't this user's, is a 422 at
236
+ the item's own path, `["body", "pairings", 2, "categoryId"]`, never a
237
+ foreign-key 500. Its tests: order kept with repeats, replace and
238
+ clear, the note trimmed, an unknown id refused at its item on create
239
+ and on update with nothing saved, `null` and too many refused.
240
+ - *The mocks* refuse an unknown id at the same path, and trim the note.
241
+ - *The form's row* is one component shared with editing in place
242
+ (`WidgetPairingItemFields` in `widget-fields.tsx`): the referenced
243
+ record's combobox (`WidgetCategoryCombobox`, the form's Category
244
+ combobox with its own search per row, a lookup by id for the picked
245
+ record's name, and the picked name not counted as a search, so
246
+ opening the list offers every record) and the other fields, each
247
+ named by position ("Pairing 2 category"). On the form it sits in a
248
+ `Controller` over `<list>.<i>.<field>Id`, the rest `register`ed, and
249
+ a server 422 at that path (`fieldErrors["pairings.2.categoryId"]`)
250
+ binds to that row's picker. A new row starts with no pick, which the
251
+ schema refuses on that row ("Pick a category or remove the
252
+ pairing"), so a blank item is never sent.
253
+ - *The view* shows each item as the referenced record's name, from the
254
+ view's one lookup by id (add every item's id to the ids it already
255
+ looks up), then its other fields. The name links to the record's
256
+ view when that entity has one (`<Link to={`/<other>/${id}`}>`);
257
+ WidgetCategory has none, so Pairings shows it as plain text.
258
+ - *Editing in place* is `editInPlace({ kind: 'list', ... })` like
259
+ Checklist's (`pairings` in `widgetEdits`): `renderItem` is the
260
+ form's row, `renderShown` the item as the view shows it, `newItem`
261
+ starts with no pick. In the row, Enter in the open list picks
262
+ without saving; Enter in the row's text box saves the item, as does
263
+ leaving the row (a click on an option is still inside it), and the
264
+ item being added opens again after an Enter, as Checklist's does.
265
+ - *Specs* (copy the `pairings` tests): on the form, saved picks load
266
+ by name, a pick found by searching saves, a row with no pick is
267
+ caught on that row, and a server 422 at the item's reference binds
268
+ to that row's picker; on the view, the names and the sparse record's
269
+ not-set label; in place (`the pairings edited in place`), an item
270
+ added from the keyboard (search, Enter picks, Enter saves) with the
271
+ PATCH sending ids, an item's reference changed by a click, move and
272
+ remove, no pick never sent, and a refused id staying open with the
273
+ reason; and the in-place a11y block for a row open and refused.
274
+ - *Not built:* a "New …" option in the item's picker that creates the
275
+ referenced record inline. Create it on its own screen (or the
276
+ table's `InlineCreate`) first. AI chats (MCP) print an item's
277
+ reference as its id, not its name.
278
+
189
279
  **A `yes/no` marked `toggle`** copies In Stock's cell
190
280
  (`in-stock-toggle.tsx`, cell pattern 15): a `Switch` in its own
191
281
  component that PATCHes the one field through `useSaveWidgetField`, the
@@ -389,7 +479,8 @@ is a filled-in example while the Widgets demo is still here).
389
479
  shared with the form (`WidgetChecklistItemFields`), the item as the
390
480
  view shows it (its quick action, if any) as `renderShown`, the list's
391
481
  schema, and `save: (change) => edit.mutateAsync((current) => ({
392
- <field>: change(current.<field>) }))`. **With no `Edit in place`
482
+ <field>: change(current.<field>) }))` (an item with a reference:
483
+ copy `pairings` in `widgetEdits`, above). **With no `Edit in place`
393
484
  line, nothing is editable in place** and none of this is copied.
394
485
  Never a computed field.
395
486
 
@@ -947,7 +947,11 @@ const price = editInPlace({
947
947
  `toolbar={false}` leaves out the toolbars and the slash menu where a
948
948
  value is short and the shortcuts are enough; `toolbar="floating"` keeps
949
949
  the floating one and the slash menu without the bar above (an `inline`
950
- field's look).
950
+ field's look). Images in the text (`![alt](url)`) show; give it the
951
+ app's `upload` (the same one `ImageField` takes) and an image pasted,
952
+ dropped or picked with the toolbar's Image button is uploaded and goes
953
+ in as `![](url)`, a placeholder showing meanwhile (never saved), a
954
+ refusal or failure in a toast. No `upload`, no file handling at all.
951
955
  - **The save is the single-field save** (`useRecordUpdate` with
952
956
  `optimistic: false, toastOnError: false`, the entity's
953
957
  `useEdit<Entity>Field`), `mutateAsync` of one field's PATCH.
@@ -989,6 +993,13 @@ Checklist is the reference (`checklist` in `widgetEdits`). It's built on
989
993
  about the list (too many items) under the row being added.
990
994
  - `content` shows above the items (the done-count) and `emptyLabel` in
991
995
  place of them when there are none.
996
+ - **An item with a reference** (Widget's Pairings, `pairings` in
997
+ `widgetEdits`) reads as the referenced record's name, from the view's
998
+ lookup by id, and its row is the form's: a combobox per row, with its
999
+ own search. Enter in the open list picks without saving, a click on an
1000
+ option is still inside the row, and Enter in the row's text box or
1001
+ leaving the row saves the item, ids only on the wire. A new item starts
1002
+ with no pick, which the list's schema refuses on that row.
992
1003
 
993
1004
  ```tsx
994
1005
  const checklist = editInPlace<ChecklistItem>({
@@ -96,9 +96,28 @@ per list; delete the section if there are none.
96
96
  | done | Done | yes/no | yes | default no |
97
97
 
98
98
  - Item fields use the same types as the main table (`text`, `url`,
99
- `yes/no`… — not another sub-record or a reference). Items have no screen
100
- of their own: they're added, edited, reordered and removed on the
101
- record's form, and the order is kept.
99
+ `yes/no`…), including a `reference` (below), but not another
100
+ sub-record, a `multi reference` or a `multi choice`. Items have no
101
+ screen of their own: they're added, edited, reordered and removed on
102
+ the record's form, and the order is kept.
103
+ - **A `reference` item field** points each item at a record of another
104
+ entity: name it, as for a top-level reference (`seriesId` → Series).
105
+ Each row picks it with the same searchable combobox; the item carries
106
+ the referenced id only, and every name comes from the other entity's
107
+ lookup by id (its list endpoint must accept `ids`). Say whether it's
108
+ required, and whether the same record may appear in more than one
109
+ item. On the view each item reads as the record's name, linked to its
110
+ view when it has one. An optional text item field beside it is `""`
111
+ when empty. Widget's `pairings` is the worked example
112
+ (`docs/entities/widget.md`):
113
+
114
+ | Field | Label | Type | Required | Options / rules |
115
+ |---|---|---|---|---|
116
+ | categoryId | Category | reference → WidgetCategory | yes | picked from a searchable list; repeats allowed |
117
+ | note | Note | text | no | up to 100 chars, trimmed; empty means none |
118
+
119
+ Not supported yet: creating the referenced record from the item's
120
+ picker (a "New …" option); create it on its own screen first.
102
121
 
103
122
  ## List screen
104
123
 
@@ -0,0 +1,37 @@
1
+ import { type ImageUpload } from '../../lib/image-file.js';
2
+ export type ImageFieldProps = {
3
+ /** The main button's id (Add or Replace), for the field's `<FieldLabel htmlFor>`. */
4
+ id?: string;
5
+ /** The stored image's id, as the record keeps it, or null for none. */
6
+ value: string | null;
7
+ /**
8
+ * The current image's thumbnail, for a value the field didn't upload
9
+ * itself (the record's saved photo). After an upload the field shows
10
+ * the upload's own `thumbUrl`.
11
+ */
12
+ thumbUrl?: string | null;
13
+ /** Called with the new image's id once it's stored, or null when removed. */
14
+ onChange: (id: string | null) => void;
15
+ /** Stores one file in the app's storage and resolves to its id and thumbnail. */
16
+ upload: ImageUpload;
17
+ /** The image types taken. Defaults to JPEG, PNG and WebP (`DEFAULT_IMAGE_TYPES`). */
18
+ accept?: readonly string[];
19
+ /** The largest file taken, in bytes: a bigger one is refused before it's sent. */
20
+ maxSize?: number;
21
+ /** What the image is, in the buttons' words: "Add photo", "Replace photo". Defaults to `'photo'`. */
22
+ noun?: string;
23
+ /** The thumbnail's alt text. Defaults to "Current photo" (with `noun`). */
24
+ alt?: string;
25
+ /** The value was refused (the form's schema, the server): `aria-invalid` on the main button. */
26
+ invalid?: boolean;
27
+ /** Saving: the image shows, nothing can be changed. */
28
+ readOnly?: boolean;
29
+ /** Told when an upload starts and ends, so a form can hold its submit meanwhile. */
30
+ onBusyChange?: (busy: boolean) => void;
31
+ className?: string;
32
+ /** The field's name, on its group of controls: this, or `aria-labelledby` (the label's id). */
33
+ 'aria-label'?: string;
34
+ 'aria-labelledby'?: string;
35
+ 'aria-describedby'?: string;
36
+ };
37
+ export declare function ImageField({ id, value, thumbUrl, onChange, upload, accept, maxSize, noun, alt, invalid, readOnly, onBusyChange, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, }: ImageFieldProps): import("react").JSX.Element;
@@ -0,0 +1,103 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // One image on a record (a photo of it): a thumbnail with Replace and
3
+ // Remove, or Add photo when there's none. A form control like DateField:
4
+ // a react-hook-form `Controller` on the form, its value what the record
5
+ // stores, the stored file's id (or null).
6
+ //
7
+ // The upload is the app's (`upload`, its own storage: the package knows no
8
+ // endpoint). A file picked uploads at once; while it does, the buttons
9
+ // wait and say so, and the value changes only once it's stored. A file
10
+ // the field won't take (its type, its size) is refused before it's sent,
11
+ // and why shows under the field, as does a failed upload's reason.
12
+ //
13
+ // Remove only empties the field; deleting the stored file, if ever, is the
14
+ // app's.
15
+ import { useEffect, useId, useRef, useState } from 'react';
16
+ import { ImageIcon, ImagePlusIcon, RefreshCwIcon, XIcon } from 'lucide-react';
17
+ import { cn } from 'cn';
18
+ import { Button } from '../ui/button.js';
19
+ import { Spinner } from '../ui/spinner.js';
20
+ import { acceptAttribute, DEFAULT_IMAGE_TYPES, imageRefusal, uploadFailure } from '../../lib/image-file.js';
21
+ export function ImageField({ id, value, thumbUrl, onChange, upload, accept = DEFAULT_IMAGE_TYPES, maxSize, noun = 'photo', alt, invalid = false, readOnly = false, onBusyChange, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, }) {
22
+ const ids = useId();
23
+ const errorId = `${ids}-error`;
24
+ const input = useRef(null);
25
+ const main = useRef(null);
26
+ const [busy, setBusy] = useState(false);
27
+ // Why the last file picked isn't the field's image.
28
+ const [problem, setProblem] = useState(null);
29
+ // What a screen reader hears of an upload: started, done.
30
+ const [status, setStatus] = useState('');
31
+ // The image this field uploaded, so it shows before the record has it.
32
+ const [uploaded, setUploaded] = useState(null);
33
+ // A thumbnail that didn't load shows as the image glyph instead.
34
+ const [broken, setBroken] = useState(null);
35
+ const mounted = useRef(true);
36
+ useEffect(() => {
37
+ mounted.current = true;
38
+ return () => {
39
+ mounted.current = false;
40
+ };
41
+ }, []);
42
+ const latestBusy = useRef(onBusyChange);
43
+ useEffect(() => {
44
+ latestBusy.current = onBusyChange;
45
+ });
46
+ const changeBusy = (next) => {
47
+ setBusy(next);
48
+ latestBusy.current?.(next);
49
+ };
50
+ const Noun = noun.charAt(0).toUpperCase() + noun.slice(1);
51
+ const src = value === null ? null : uploaded?.id === value ? uploaded.thumbUrl : (thumbUrl ?? null);
52
+ const shown = src !== null && src !== broken ? src : null;
53
+ const pick = async (file) => {
54
+ if (!file)
55
+ return;
56
+ const refusal = imageRefusal(file, { accept, maxSize });
57
+ setProblem(refusal);
58
+ if (refusal)
59
+ return;
60
+ changeBusy(true);
61
+ setStatus(`Uploading ${noun}…`);
62
+ try {
63
+ const result = await upload(file);
64
+ if (!mounted.current)
65
+ return;
66
+ setUploaded({ id: result.id, thumbUrl: result.thumbUrl });
67
+ setBroken(null);
68
+ setStatus(`${Noun} added.`);
69
+ onChange(result.id);
70
+ }
71
+ catch (error) {
72
+ if (!mounted.current)
73
+ return;
74
+ setStatus('');
75
+ setProblem(uploadFailure(error, noun));
76
+ }
77
+ finally {
78
+ if (mounted.current)
79
+ changeBusy(false);
80
+ }
81
+ };
82
+ const remove = () => {
83
+ setProblem(null);
84
+ setStatus(`${Noun} removed.`);
85
+ onChange(null);
86
+ // Remove goes with the image: focus stays in the field.
87
+ main.current?.focus();
88
+ };
89
+ const open = () => {
90
+ if (!busy && !readOnly)
91
+ input.current?.click();
92
+ };
93
+ const describedBy = [ariaDescribedBy, problem ? errorId : undefined].filter(Boolean).join(' ') || undefined;
94
+ const mainLabel = busy ? `Uploading ${noun}…` : value !== null ? `Replace ${noun}` : `Add ${noun}`;
95
+ return (_jsxs("div", { "data-slot": "image-field", role: "group", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-busy": busy || undefined, className: cn('flex flex-col gap-2', className), children: [_jsxs("div", { className: "flex items-center gap-3", children: [value !== null ? (_jsxs("div", { className: "relative size-24 shrink-0 overflow-hidden rounded-lg border border-border bg-muted", children: [shown ? (_jsx("img", { src: shown, alt: alt ?? `Current ${noun}`, className: "size-full object-cover", onError: () => setBroken(shown) })) : (_jsx("div", { role: "img", "aria-label": alt ?? `Current ${noun}`, className: "flex size-full items-center justify-center text-muted-foreground", children: _jsx(ImageIcon, { "aria-hidden": "true", className: "size-6" }) })), busy && (_jsx("div", { className: "absolute inset-0 flex items-center justify-center bg-background/70", children: _jsx(Spinner, { "aria-hidden": "true", className: "size-5 text-foreground" }) }))] })) : (_jsx("div", { "aria-hidden": "true", onClick: open, className: cn('flex size-24 shrink-0 items-center justify-center rounded-lg border-2 border-dashed border-input text-muted-foreground', !busy && !readOnly && 'cursor-pointer hover:bg-muted'), children: busy ? _jsx(Spinner, { className: "size-5" }) : _jsx(ImagePlusIcon, { className: "size-6" }) })), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsxs(Button, { ref: main, id: id, type: "button", variant: "outline", size: "sm", "aria-label": mainLabel, "aria-describedby": describedBy, "aria-invalid": invalid || undefined, disabled: busy || readOnly,
96
+ // Keeps focus through an upload, so it's where it was after.
97
+ focusableWhenDisabled: true, onClick: open, children: [busy ? (_jsx(Spinner, { "aria-hidden": "true" })) : value !== null ? (_jsx(RefreshCwIcon, { "aria-hidden": "true" })) : (_jsx(ImagePlusIcon, { "aria-hidden": "true" })), busy ? 'Uploading…' : value !== null ? 'Replace' : `Add ${noun}`] }), value !== null && (_jsxs(Button, { type: "button", variant: "ghost", size: "sm", "aria-label": `Remove ${noun}`, disabled: busy || readOnly, onClick: remove, children: [_jsx(XIcon, { "aria-hidden": "true" }), "Remove"] }))] })] }), problem && (_jsx("p", { id: errorId, role: "alert", "data-slot": "image-field-error", className: "type-caption text-destructive-text", children: problem })), _jsx("span", { role: "status", className: "sr-only", children: status }), _jsx("input", { ref: input, type: "file", accept: acceptAttribute(accept), hidden: true, tabIndex: -1, onChange: (event) => {
98
+ const file = event.target.files?.[0];
99
+ // Cleared, so picking the same file again still counts.
100
+ event.target.value = '';
101
+ void pick(file);
102
+ } })] }));
103
+ }
@@ -61,7 +61,7 @@ const COMPONENTS = {
61
61
  code: (props) => _jsx("code", { ...omitNode(props), className: "rounded bg-muted px-1 py-0.5 font-mono text-foreground" }),
62
62
  img: (props) => {
63
63
  const { alt, ...rest } = omitNode(props);
64
- return _jsx("img", { ...rest, alt: alt ?? '', loading: "lazy", className: "max-w-full rounded-md" });
64
+ return _jsx("img", { ...rest, alt: alt ?? '', loading: "lazy", className: "h-auto max-w-full rounded-md" });
65
65
  },
66
66
  // GFM tables use the package's own table, zebra stripes and density
67
67
  // tokens included, so a table in the notes reads like every other table.
@@ -1,2 +1,2 @@
1
1
  import type { RichTextEditorProps } from './rich-text-editor.js';
2
- export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, inline, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
2
+ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, inline, upload, imageAccept, imageMaxSize, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
@@ -40,6 +40,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
40
40
  // `toolbar={false}`.
41
41
  // - `inline`: the field drawn as the text it edits (no border, no box),
42
42
  // for notes that are always editable where they're read.
43
+ // - Images in the text show (`![alt](url)`), at most the field's width.
44
+ // With `upload`, an image pasted, dropped or picked with the toolbar's
45
+ // Image button is uploaded through it and goes in as `![](url)`
46
+ // (rich-text-image-upload.ts); a refusal or a failed upload is a toast.
43
47
  import { useEffect, useId, useRef, useState } from 'react';
44
48
  import { createPortal } from 'react-dom';
45
49
  import { defaultValueCtx, Editor, editorViewCtx, editorViewOptionsCtx, parserCtx, remarkStringifyOptionsCtx, rootCtx, schemaCtx, serializerCtx, } from '@milkdown/kit/core';
@@ -52,10 +56,12 @@ import { isInTable, TableMap } from '@milkdown/kit/prose/tables';
52
56
  import { Decoration, DecorationSet } from '@milkdown/kit/prose/view';
53
57
  import { $nodeSchema, $prose, $remark, getMarkdown } from '@milkdown/kit/utils';
54
58
  import { cn } from 'cn';
59
+ import { toast } from 'sonner';
55
60
  import { Button } from '../ui/button.js';
56
61
  import { Field, FieldError, FieldLabel } from '../ui/field.js';
57
62
  import { Input } from '../ui/input.js';
58
63
  import { Popover, PopoverContent } from '../ui/popover.js';
64
+ import { acceptAttribute } from '../../lib/image-file.js';
59
65
  import { linkHref, linkRefusal } from '../../lib/link-href.js';
60
66
  import { passEscapeThrough } from './escape-passthrough.js';
61
67
  import { calloutHandler, remarkCalloutNodes } from '../../lib/markdown-callout.js';
@@ -64,6 +70,7 @@ import { headingClass } from './prose-look.js';
64
70
  import { RichTextFloatingToolbar } from './rich-text-floating-toolbar.js';
65
71
  import { tableAt, toggleFormat } from './rich-text-formats.js';
66
72
  import { calloutKind, calloutView, tableView } from './rich-text-node-views.js';
73
+ import { droppedFiles, imageUploadPlugin, pastedImages, uploadImages, uploadPlaceholders } from './rich-text-image-upload.js';
67
74
  import { handleShortcut } from './rich-text-shortcuts.js';
68
75
  import { addRowAtEnd, handleTableKey, runTableAction } from './rich-text-table.js';
69
76
  import { applySlash, closeSlash, slashItems, slashOptionId, slashPlugin, slashQuery, } from './rich-text-slash.js';
@@ -109,6 +116,12 @@ const calloutSchema = $nodeSchema('callout', () => ({
109
116
  },
110
117
  },
111
118
  }));
119
+ function emptyImageTitles(node) {
120
+ if (node.type === 'image' && node.title == null)
121
+ node.title = '';
122
+ node.children?.forEach(emptyImageTitles);
123
+ }
124
+ const imageTitles = $remark('imageTitles', () => () => (tree) => emptyImageTitles(tree));
112
125
  // Raw HTML pasted in as Milkdown's html chip becomes the plain text it
113
126
  // shows, so a paste can never add markup to the Markdown.
114
127
  function htmlToText(fragment) {
@@ -240,7 +253,7 @@ function applyLink(view, target, href) {
240
253
  }
241
254
  view.dispatch(tr.scrollIntoView());
242
255
  }
243
- export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, toolbar = true, inline = false, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
256
+ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, toolbar = true, inline = false, upload, imageAccept, imageMaxSize, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
244
257
  const rootRef = useRef(null);
245
258
  const editorRef = useRef(null);
246
259
  const [linkBox, setLinkBox] = useState(null);
@@ -271,10 +284,12 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
271
284
  // rendered into through a portal.
272
285
  const [widgets, setWidgets] = useState([]);
273
286
  // The latest props, for the editor's callbacks, which are set up once.
274
- const latest = useRef({ onChange, onProblem, readOnly, placeholder });
287
+ const latest = useRef({ onChange, onProblem, readOnly, placeholder, upload, imageAccept, imageMaxSize });
275
288
  useEffect(() => {
276
- latest.current = { onChange, onProblem, readOnly, placeholder };
289
+ latest.current = { onChange, onProblem, readOnly, placeholder, upload, imageAccept, imageMaxSize };
277
290
  });
291
+ // The toolbar's Image button opens this file picker.
292
+ const imageInputRef = useRef(null);
278
293
  // The fixed bar above the text, and the menus over it (the floating
279
294
  // toolbar and the slash menu), which `'floating'` keeps on their own.
280
295
  const fixedToolbar = toolbar === true;
@@ -407,6 +422,42 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
407
422
  openLinkRef.current = openLink;
408
423
  // Paste, copy, the editor's own keys and the placeholder.
409
424
  const slashing = $prose(() => slashPlugin());
425
+ // Images pasted or dropped, uploaded through the app's `upload`; with
426
+ // none given, a paste or drop is left to the handlers after this one,
427
+ // as if this weren't here.
428
+ const imageUploads = $prose(() => imageUploadPlugin());
429
+ const startUploads = (view, files, at) => {
430
+ const { upload: send, imageAccept: accept, imageMaxSize: maxSize } = latest.current;
431
+ if (!send)
432
+ return false;
433
+ uploadImages(view, files, at, { upload: send, accept, maxSize, onError: (message) => toast.error(message) });
434
+ return true;
435
+ };
436
+ const imageInput = $prose(() => new Plugin({
437
+ props: {
438
+ handlePaste: (view, event) => {
439
+ if (!latest.current.upload || !view.editable)
440
+ return false;
441
+ const files = pastedImages(event.clipboardData);
442
+ if (files.length === 0)
443
+ return false;
444
+ event.preventDefault();
445
+ return startUploads(view, files, null);
446
+ },
447
+ // Files from outside (a text or an image moved within the
448
+ // editor carries none).
449
+ handleDrop: (view, event, _slice, moved) => {
450
+ if (moved || !latest.current.upload || !view.editable)
451
+ return false;
452
+ const files = droppedFiles(event.dataTransfer);
453
+ if (files.length === 0)
454
+ return false;
455
+ event.preventDefault();
456
+ const at = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos ?? view.state.selection.from;
457
+ return startUploads(view, files, at);
458
+ },
459
+ },
460
+ }));
410
461
  const input = $prose((ctx) => new Plugin({
411
462
  props: {
412
463
  // Plain text is read as Markdown, so `## Notes` or `**bold**`
@@ -576,9 +627,12 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
576
627
  .use(gfm)
577
628
  .use(calloutRemark)
578
629
  .use(calloutSchema)
630
+ .use(imageTitles)
579
631
  .use(history)
580
632
  .use(changes)
581
633
  .use(slashing)
634
+ .use(imageUploads)
635
+ .use(imageInput)
582
636
  .use(input);
583
637
  void make.create().then((editor) => {
584
638
  if (cancelled) {
@@ -617,6 +671,22 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
617
671
  view.setProps({ editable: () => !readOnly, attributes: JSON.parse(attributesKey) });
618
672
  });
619
673
  }, [readOnly, attributesKey]);
674
+ // The toolbar's Image button: the file picker, then each file picked
675
+ // uploaded in place of the selection, and focus back in the text.
676
+ const pickImages = () => imageInputRef.current?.click();
677
+ const imagesPicked = (files) => {
678
+ const send = latest.current.upload;
679
+ if (!editorView || !send || files.length === 0)
680
+ return;
681
+ uploadImages(editorView, files, null, {
682
+ upload: send,
683
+ accept: imageAccept,
684
+ maxSize: imageMaxSize,
685
+ onError: (message) => toast.error(message),
686
+ });
687
+ editorView.focus();
688
+ };
689
+ const uploading = editorState ? uploadPlaceholders(editorState).length : 0;
620
690
  // A toolbar button: its format on the selection, then focus back in the
621
691
  // text (Link opens the link box instead, which gives it back on close).
622
692
  const runFormat = (format) => {
@@ -709,7 +779,12 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
709
779
  'rounded-lg border border-input transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', !inline && 'has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/20', className), children: [fixedToolbar && (_jsx(RichTextToolbar, { ref: toolbarRef, state: editorState, readOnly: readOnly,
710
780
  // Only once the editor is made: until then the editable element,
711
781
  // and its id, aren't in the page (axe: aria-valid-attr-value).
712
- controls: editorState ? editableId : undefined, onFormat: runFormat, onTable: runTable, text: textElement })), _jsx("div", { ref: rootRef, className: cn(!inline && 'min-h-24 px-2.5 py-2',
782
+ controls: editorState ? editableId : undefined, onFormat: runFormat, onTable: runTable, onImage: upload ? pickImages : undefined, text: textElement })), upload && (_jsx("input", { ref: imageInputRef, type: "file", accept: acceptAttribute(imageAccept), multiple: true, hidden: true, tabIndex: -1, onChange: (event) => {
783
+ const files = Array.from(event.target.files ?? []);
784
+ // Cleared, so picking the same file again still counts.
785
+ event.target.value = '';
786
+ imagesPicked(files);
787
+ } })), _jsx("div", { ref: rootRef, className: cn(!inline && 'min-h-24 px-2.5 py-2',
713
788
  // Read-only text is muted; the toolbar's buttons say so by being disabled.
714
789
  'has-[[aria-readonly=true]]:text-muted-foreground',
715
790
  // The same type roles <Markdown> uses, so text reads the same
@@ -724,6 +799,10 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
724
799
  // Headings, callouts and tables are drawn by their node views;
725
800
  // these are the elements Milkdown draws itself.
726
801
  '[&_a]:text-link [&_a]:underline [&_a]:decoration-link/40 [&_a]:underline-offset-4', '[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground', '[&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:font-mono', '[&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-muted [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:font-mono', '[&_hr]:my-2 [&_hr]:h-0.5 [&_hr]:rounded-full [&_hr]:border-0 [&_hr]:bg-rule', '[&_hr.ProseMirror-selectednode]:outline-2 [&_hr.ProseMirror-selectednode]:outline-offset-4 [&_hr.ProseMirror-selectednode]:outline-ring',
802
+ // An image, as <Markdown> shows it: never wider than the field.
803
+ // (ProseMirror's own separator images, around an image alone on
804
+ // its line, are left alone.)
805
+ '[&_img[src]]:inline-block [&_img[src]]:h-auto [&_img[src]]:max-w-full [&_img[src]]:rounded-md [&_img[src]]:align-bottom', '[&_img.ProseMirror-selectednode]:outline-2 [&_img.ProseMirror-selectednode]:outline-offset-2 [&_img.ProseMirror-selectednode]:outline-ring',
727
806
  // A table: a header row on the muted fill, a line between every
728
807
  // cell, cells that wrap and never shrink to nothing; selected
729
808
  // cells (a drag across them) tinted.
@@ -743,7 +822,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
743
822
  const next = event.relatedTarget;
744
823
  if (!editorView.dom.contains(next) && !floatingRef.current?.contains(next))
745
824
  setFocused(false);
746
- } }))] }), showCount && (_jsx("p", { id: counterId, "data-slot": "rich-text-editor-count", className: cn('text-right type-caption tabular-nums', length > maxLength ? 'text-destructive-text' : 'text-muted-foreground'), children: length > maxLength
825
+ } }))] }), upload && (_jsx("p", { role: "status", className: "sr-only", children: uploading > 0 ? `Uploading ${uploading === 1 ? 'an image' : `${uploading} images`}…` : '' })), showCount && (_jsx("p", { id: counterId, "data-slot": "rich-text-editor-count", className: cn('text-right type-caption tabular-nums', length > maxLength ? 'text-destructive-text' : 'text-muted-foreground'), children: length > maxLength
747
826
  ? `${(length - maxLength).toLocaleString()} characters over the limit of ${maxLength.toLocaleString()}`
748
827
  : `${length.toLocaleString()} of ${maxLength.toLocaleString()} characters` })), _jsx(Popover, { open: linkBox !== null, onOpenChange: (open) => !open && closeLink(), children: linkBox && (_jsx(LinkForm, { box: linkBox, onApply: (href) => {
749
828
  const editor = editorRef.current;
@@ -1,3 +1,4 @@
1
+ import type { ImageUpload } from '../../lib/image-file.js';
1
2
  export type RichTextEditorProps = {
2
3
  /** The editable element's id, for a label's `htmlFor` or a test. */
3
4
  id?: string;
@@ -36,6 +37,21 @@ export type RichTextEditorProps = {
36
37
  * `placeholder`, which is all an empty one shows.
37
38
  */
38
39
  inline?: boolean;
40
+ /**
41
+ * Images uploaded into the text: pasted, dropped, or picked with an
42
+ * Image button on the fixed toolbar. Each file goes through this (the
43
+ * app's own storage: the package knows no endpoint) and goes in as
44
+ * `![](url)`, from the result's `url`, else its `thumbUrl`. A
45
+ * placeholder shows where it will go while it uploads (it isn't saved);
46
+ * a refused file or a failed upload takes it away and says why in a
47
+ * toast. Without it, the editor handles no files: a paste or drop does
48
+ * what it always did.
49
+ */
50
+ upload?: ImageUpload;
51
+ /** The image types `upload` takes. Defaults to JPEG, PNG and WebP (`DEFAULT_IMAGE_TYPES`). */
52
+ imageAccept?: readonly string[];
53
+ /** The largest image `upload` takes, in bytes: a bigger one is refused before it's sent, with its size and the limit. */
54
+ imageMaxSize?: number;
39
55
  className?: string;
40
56
  /** The field's label. A rich-text area must have one: this, or `aria-labelledby`. */
41
57
  'aria-label'?: string;
@@ -13,6 +13,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
13
13
  // Raw HTML in the text shows as its characters, as <Markdown> shows it,
14
14
  // and nothing pasted or typed can add markup.
15
15
  //
16
+ // Images in the text (`![alt](url)`) show, at most the field's width.
17
+ // With `upload`, an image pasted, dropped or picked with the toolbar's
18
+ // Image button is uploaded through the app's own storage and goes in as
19
+ // `![](url)`; while it uploads, a placeholder shows where it will go.
20
+ //
16
21
  // The editor (Milkdown) is loaded on first use, so a page that only reads
17
22
  // Markdown never downloads it. Until it arrives, a placeholder the size of
18
23
  // the field shows.
@@ -0,0 +1,42 @@
1
+ import { Plugin, type EditorState, type Transaction } from '@milkdown/kit/prose/state';
2
+ import { DecorationSet } from '@milkdown/kit/prose/view';
3
+ import { type ImageFileRules, type ImageUpload } from '../../lib/image-file.js';
4
+ /** The plugin that holds and draws the placeholders. */
5
+ export declare function imageUploadPlugin(): Plugin<DecorationSet>;
6
+ /** The uploads in flight: each placeholder's file name and where it is now, in document order. */
7
+ export declare function uploadPlaceholders(state: EditorState): {
8
+ id: number;
9
+ name: string;
10
+ pos: number;
11
+ }[];
12
+ export type UploadTarget = {
13
+ readonly state: EditorState;
14
+ dispatch: (tr: Transaction) => void;
15
+ readonly isDestroyed?: boolean;
16
+ };
17
+ export type ImageUploadOptions = ImageFileRules & {
18
+ upload: ImageUpload;
19
+ /** A file refused before upload (its type, its size), or an upload that failed: why. */
20
+ onError: (message: string) => void;
21
+ };
22
+ /**
23
+ * Uploads `files` and inserts each as an image: at `at` (a drop's
24
+ * position), or with `at` null in place of the selection (a paste, the
25
+ * toolbar's button). A file refused by type or size never starts; each
26
+ * refusal's reason goes to `onError`. Returns whether any upload started.
27
+ */
28
+ export declare function uploadImages(target: UploadTarget, files: readonly File[], at: number | null, options: ImageUploadOptions): boolean;
29
+ type TransferLike = {
30
+ files?: ArrayLike<File> | null;
31
+ getData?: (type: string) => string;
32
+ } | null;
33
+ /**
34
+ * The files a paste uploads: its files, unless it also carries HTML with
35
+ * words in it. An office app copies a picture of the selection along with
36
+ * its text, and that paste is the text. A screenshot, or an image copied
37
+ * from a page (its HTML just the `<img>`), uploads.
38
+ */
39
+ export declare function pastedImages(data: TransferLike): File[];
40
+ /** The files a drop uploads: every file dropped from outside the editor. */
41
+ export declare function droppedFiles(data: TransferLike): File[];
42
+ export {};
@@ -0,0 +1,174 @@
1
+ // Images uploaded into the rich-text editor: pasted, dropped, or picked
2
+ // from the toolbar's Image button, each sent through the app's `upload`
3
+ // and inserted as `![](url)` where it was put. Only with `upload` given
4
+ // (RichTextEditor's prop): without it, the editor handles none of this.
5
+ //
6
+ // While a file uploads, a placeholder ("Uploading photo.jpg…") shows where
7
+ // it will go. The placeholder is a decoration, not part of the document:
8
+ // nothing is saved for it, and an autosave mid-upload saves the text as
9
+ // it is. It moves with the text around it, and what's typed at the caret
10
+ // meanwhile goes after it, as if the image were already there; when the
11
+ // upload resolves, the image goes in where it is now and the placeholder
12
+ // goes. If the upload fails, the placeholder goes and `onError` gets the
13
+ // reason; if the text around it was deleted meanwhile, the image is
14
+ // dropped.
15
+ //
16
+ // Several files upload at once and go in in the order they came, each
17
+ // waiting for the one before it to land or fail.
18
+ //
19
+ // React-free and DOM-free (but the placeholder's element, made only when
20
+ // the view draws it), so its rules are unit tested in node
21
+ // (tests/rich-text-image-upload.test.ts).
22
+ import { Fragment, Slice } from '@milkdown/kit/prose/model';
23
+ import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
24
+ import { dropPoint } from '@milkdown/kit/prose/transform';
25
+ import { Decoration, DecorationSet } from '@milkdown/kit/prose/view';
26
+ import { imageRefusal, uploadFailure } from '../../lib/image-file.js';
27
+ const uploadKey = new PluginKey('rich-text-image-upload');
28
+ // The placeholder's element: a muted chip with a spinner, never editable.
29
+ function placeholderElement(name) {
30
+ const chip = document.createElement('span');
31
+ chip.contentEditable = 'false';
32
+ chip.dataset.slot = 'rich-text-image-upload';
33
+ chip.className =
34
+ 'mx-0.5 inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-0.5 align-middle type-caption text-foreground';
35
+ const spinner = document.createElement('span');
36
+ spinner.setAttribute('aria-hidden', 'true');
37
+ spinner.className = 'inline-block size-3 animate-spin rounded-full border-2 border-current border-r-transparent';
38
+ chip.append(spinner, `Uploading ${name}…`);
39
+ return chip;
40
+ }
41
+ // A placeholder's decoration. `side: -1`: text typed at its spot goes
42
+ // after it, where the image will be.
43
+ function placeholder({ id, name, pos }) {
44
+ return Decoration.widget(pos, () => placeholderElement(name), { id, name, key: `upload-${id}`, side: -1 });
45
+ }
46
+ /** The plugin that holds and draws the placeholders. */
47
+ export function imageUploadPlugin() {
48
+ return new Plugin({
49
+ key: uploadKey,
50
+ state: {
51
+ init: () => DecorationSet.empty,
52
+ apply: (tr, set) => {
53
+ const mapped = set.map(tr.mapping, tr.doc);
54
+ const action = tr.getMeta(uploadKey);
55
+ if (!action)
56
+ return mapped;
57
+ if ('add' in action)
58
+ return mapped.add(tr.doc, action.add.map(placeholder));
59
+ const id = 'remove' in action ? action.remove : action.land;
60
+ const rest = mapped.remove(mapped.find(undefined, undefined, (spec) => spec.id === id));
61
+ if ('remove' in action)
62
+ return rest;
63
+ const at = tr.mapping.map(action.at, -1);
64
+ const waiting = rest.find(at, at).filter((decoration) => decoration.from === at);
65
+ // Made before `remove`, which empties the array it's given.
66
+ const moved = waiting.map((decoration) => placeholder({ ...decoration.spec, pos: action.after }));
67
+ return rest.remove(waiting).add(tr.doc, moved);
68
+ },
69
+ },
70
+ props: {
71
+ decorations: (state) => uploadKey.getState(state),
72
+ },
73
+ });
74
+ }
75
+ /** The uploads in flight: each placeholder's file name and where it is now, in document order. */
76
+ export function uploadPlaceholders(state) {
77
+ const set = uploadKey.getState(state);
78
+ if (!set)
79
+ return [];
80
+ return set
81
+ .find()
82
+ .map((decoration) => ({ id: decoration.spec.id, name: decoration.spec.name, pos: decoration.from }))
83
+ .sort((a, b) => a.pos - b.pos);
84
+ }
85
+ function placeholderAt(state, id) {
86
+ return uploadPlaceholders(state).find((placeholder) => placeholder.id === id)?.pos ?? null;
87
+ }
88
+ function imageNode(state, src) {
89
+ return state.schema.nodes.image.create({ src, alt: '', title: '' });
90
+ }
91
+ // Where an image put at `pos` can go: `pos` itself in text, else the
92
+ // nearest place that takes inline content (between two blocks, a new
93
+ // paragraph there).
94
+ function insertPoint(doc, pos, image) {
95
+ return dropPoint(doc, pos, new Slice(Fragment.from(image), 0, 0)) ?? pos;
96
+ }
97
+ let nextId = 0;
98
+ /**
99
+ * Uploads `files` and inserts each as an image: at `at` (a drop's
100
+ * position), or with `at` null in place of the selection (a paste, the
101
+ * toolbar's button). A file refused by type or size never starts; each
102
+ * refusal's reason goes to `onError`. Returns whether any upload started.
103
+ */
104
+ export function uploadImages(target, files, at, options) {
105
+ const accepted = [];
106
+ for (const file of files) {
107
+ const refusal = imageRefusal(file, options);
108
+ if (refusal)
109
+ options.onError(refusal);
110
+ else
111
+ accepted.push(file);
112
+ }
113
+ if (accepted.length === 0)
114
+ return false;
115
+ const tr = target.state.tr;
116
+ if (at === null && !tr.selection.empty)
117
+ tr.deleteSelection();
118
+ const pos = insertPoint(tr.doc, at ?? tr.selection.from, imageNode(target.state, ''));
119
+ const uploads = accepted.map((file) => ({ id: ++nextId, file }));
120
+ target.dispatch(tr.setMeta(uploadKey, { add: uploads.map(({ id, file }) => ({ id, name: file.name || 'image', pos })) }));
121
+ const settle = (id, outcome) => {
122
+ if (target.isDestroyed)
123
+ return;
124
+ const placeholder = placeholderAt(target.state, id);
125
+ if (placeholder === null)
126
+ return;
127
+ const next = target.state.tr;
128
+ if ('src' in outcome) {
129
+ const image = imageNode(target.state, outcome.src);
130
+ const where = insertPoint(next.doc, placeholder, image);
131
+ next.replaceWith(where, where, image);
132
+ next.setMeta(uploadKey, { land: id, at: placeholder, after: next.mapping.map(where, -1) + image.nodeSize });
133
+ }
134
+ else {
135
+ next.setMeta(uploadKey, { remove: id });
136
+ options.onError(uploadFailure(outcome.error));
137
+ }
138
+ target.dispatch(next);
139
+ };
140
+ // Every upload starts now; each lands after the one before it.
141
+ let previous = Promise.resolve();
142
+ for (const { id, file } of uploads) {
143
+ const outcome = new Promise((resolve) => resolve(options.upload(file).then((result) => ({ src: result.url ?? result.thumbUrl })))).catch((error) => ({ error }));
144
+ previous = previous.then(() => outcome).then((result) => settle(id, result));
145
+ }
146
+ return true;
147
+ }
148
+ // Whether pasted HTML has words in it, not just an image. Style and
149
+ // script blocks and comments aren't words.
150
+ function htmlHasText(html) {
151
+ const text = html
152
+ .replace(/<(style|script)\b[\s\S]*?<\/\1>/gi, '')
153
+ .replace(/<!--[\s\S]*?-->/g, '')
154
+ .replace(/<[^>]*>/g, '')
155
+ .replace(/&nbsp;|&#160;/g, ' ');
156
+ return text.trim().length > 0;
157
+ }
158
+ /**
159
+ * The files a paste uploads: its files, unless it also carries HTML with
160
+ * words in it. An office app copies a picture of the selection along with
161
+ * its text, and that paste is the text. A screenshot, or an image copied
162
+ * from a page (its HTML just the `<img>`), uploads.
163
+ */
164
+ export function pastedImages(data) {
165
+ const files = Array.from(data?.files ?? []);
166
+ if (files.length === 0)
167
+ return [];
168
+ const html = data?.getData?.('text/html') ?? '';
169
+ return html && htmlHasText(html) ? [] : files;
170
+ }
171
+ /** The files a drop uploads: every file dropped from outside the editor. */
172
+ export function droppedFiles(data) {
173
+ return Array.from(data?.files ?? []);
174
+ }
@@ -17,10 +17,12 @@ export type FormatButtonsProps = {
17
17
  onFormat: (id: FormatId) => void;
18
18
  /** A Table menu item picked (only the fixed toolbar has the menu). */
19
19
  onTable?: (action: TableAction) => void;
20
+ /** The Image button pressed: pick images to upload. No button without it. */
21
+ onImage?: () => void;
20
22
  /** The editable element, for the Table menu to give focus back to. */
21
23
  text?: () => HTMLElement | null;
22
24
  };
23
- export declare function FormatButtons({ formats, state, readOnly, onFormat, onTable, text }: FormatButtonsProps): import("react").JSX.Element;
25
+ export declare function FormatButtons({ formats, state, readOnly, onFormat, onTable, onImage, text }: FormatButtonsProps): import("react").JSX.Element;
24
26
  export interface RichTextToolbarProps {
25
27
  /** The editor's state now, to show which formats are on. Null until it's made. */
26
28
  state: EditorState | null;
@@ -31,10 +33,12 @@ export interface RichTextToolbarProps {
31
33
  onFormat: (id: FormatId) => void;
32
34
  /** A Table menu item picked: run it on the editor and give it focus back. */
33
35
  onTable: (action: TableAction) => void;
36
+ /** The Image button pressed (only when the editor uploads images). */
37
+ onImage?: () => void;
34
38
  /** The editable element, for the Table menu to give focus back to. */
35
39
  text: () => HTMLElement | null;
36
40
  /** The toolbar's element, for Alt+F10 to reach it from the text. */
37
41
  ref?: Ref<HTMLDivElement>;
38
42
  }
39
- export declare function RichTextToolbar({ state, readOnly, controls, onFormat, onTable, text, ref }: RichTextToolbarProps): import("react").JSX.Element;
43
+ export declare function RichTextToolbar({ state, readOnly, controls, onFormat, onTable, onImage, text, ref }: RichTextToolbarProps): import("react").JSX.Element;
40
44
  export {};
@@ -1,7 +1,8 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  // The rich-text editor's fixed toolbar: one row of pressed/not-pressed
3
- // buttons above the text, each a format and its keyboard shortcut, and a
4
- // Table menu (rich-text-table-menu.tsx).
3
+ // buttons above the text, each a format and its keyboard shortcut, a
4
+ // Table menu (rich-text-table-menu.tsx), and, when the editor uploads
5
+ // images, an Image button (a plain button: it opens the file picker).
5
6
  //
6
7
  // - One tab stop (ARIA's toolbar pattern): Tab reaches the last button
7
8
  // used, the arrow keys, Home and End move along the row.
@@ -14,6 +15,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
15
  // - Alt+F10 in the text reaches it (the floating toolbar first, when it's
15
16
  // showing: rich-text-floating-toolbar.tsx).
16
17
  import { Fragment, useRef, useState } from 'react';
18
+ import { ImageIcon } from 'lucide-react';
19
+ import { Button } from '../ui/button.js';
17
20
  import { Separator } from '../ui/separator.js';
18
21
  import { Toggle } from '../ui/toggle.js';
19
22
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip.js';
@@ -31,15 +34,18 @@ const SETS = {
31
34
  // A toolbar's row of buttons: one tab stop, the last one used (ARIA's
32
35
  // toolbar pattern); the arrow keys, Home and End move along the row. The
33
36
  // toolbar's element (`role="toolbar"`, its name) is the caller's.
34
- export function FormatButtons({ formats, state, readOnly, onFormat, onTable, text = () => null }) {
37
+ export function FormatButtons({ formats, state, readOnly, onFormat, onTable, onImage, text = () => null }) {
35
38
  const apple = isApple();
36
39
  const groups = SETS[formats];
37
40
  const items = groups.flat();
41
+ // The Image button, last in the row, after the formats.
42
+ const imageIndex = onImage ? items.length : -1;
43
+ const count = items.length + (onImage ? 1 : 0);
38
44
  const buttons = useRef([]);
39
45
  // The one button Tab reaches: the last one used.
40
46
  const [current, setCurrent] = useState(0);
41
47
  const move = (index) => {
42
- const next = (index + items.length) % items.length;
48
+ const next = (index + count) % count;
43
49
  setCurrent(next);
44
50
  buttons.current[next]?.focus();
45
51
  };
@@ -48,7 +54,7 @@ export function FormatButtons({ formats, state, readOnly, onFormat, onTable, tex
48
54
  ArrowRight: () => move(current + 1),
49
55
  ArrowLeft: () => move(current - 1),
50
56
  Home: () => move(0),
51
- End: () => move(items.length - 1),
57
+ End: () => move(count - 1),
52
58
  };
53
59
  const action = keys[event.key];
54
60
  if (!action || event.metaKey || event.ctrlKey || event.altKey)
@@ -56,27 +62,29 @@ export function FormatButtons({ formats, state, readOnly, onFormat, onTable, tex
56
62
  event.preventDefault();
57
63
  action();
58
64
  };
59
- return (_jsx(TooltipProvider, { delay: 400, children: groups.map((group, groupIndex) => (_jsxs(Fragment, { children: [groupIndex > 0 && _jsx(Separator, { orientation: "vertical", className: "mx-1 h-5 self-center" }), group.map((item) => {
60
- const index = items.indexOf(item);
61
- if (item.id === 'table')
62
- return (_jsx(RichTextTableMenu, { ref: (element) => {
63
- buttons.current[index] = element;
64
- }, state: state, readOnly: readOnly, tabIndex: index === current ? 0 : -1, onFocus: () => setCurrent(index), onKeyDown: onKeyDown, onInsert: () => onFormat('table'), onAction: (action) => onTable?.(action), text: text }, item.id));
65
- const Icon = item.icon;
66
- const tip = [
67
- item.label,
68
- item.keys && `(${shownKeys(item.keys, apple)})`,
69
- item.hint && `· ${item.hint.replace(/Mod\+Shift\+Enter/, shownKeys('Mod+Shift+Enter', apple))}`,
70
- ]
71
- .filter(Boolean)
72
- .join(' ');
73
- return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx(Toggle, { ref: (element) => {
74
- buttons.current[index] = element;
75
- }, size: "icon-sm", "aria-label": item.label, "aria-keyshortcuts": item.keys ? ariaKeys(item.keys, apple) : undefined, pressed: state ? formatOn(state, item.id) : false, disabled: readOnly, tabIndex: index === current ? 0 : -1, className: "aria-pressed:bg-muted aria-pressed:text-foreground",
76
- // A mouse press keeps focus, and the selection, in the text.
77
- onMouseDown: (event) => event.preventDefault(), onFocus: () => setCurrent(index), onKeyDown: onKeyDown, onPressedChange: () => onFormat(item.id) }), children: _jsx(Icon, {}) }), _jsx(TooltipContent, { children: tip })] }, item.id));
78
- })] }, groupIndex))) }));
65
+ return (_jsxs(TooltipProvider, { delay: 400, children: [groups.map((group, groupIndex) => (_jsxs(Fragment, { children: [groupIndex > 0 && _jsx(Separator, { orientation: "vertical", className: "mx-1 h-5 self-center" }), group.map((item) => {
66
+ const index = items.indexOf(item);
67
+ if (item.id === 'table')
68
+ return (_jsx(RichTextTableMenu, { ref: (element) => {
69
+ buttons.current[index] = element;
70
+ }, state: state, readOnly: readOnly, tabIndex: index === current ? 0 : -1, onFocus: () => setCurrent(index), onKeyDown: onKeyDown, onInsert: () => onFormat('table'), onAction: (action) => onTable?.(action), text: text }, item.id));
71
+ const Icon = item.icon;
72
+ const tip = [
73
+ item.label,
74
+ item.keys && `(${shownKeys(item.keys, apple)})`,
75
+ item.hint && `· ${item.hint.replace(/Mod\+Shift\+Enter/, shownKeys('Mod+Shift+Enter', apple))}`,
76
+ ]
77
+ .filter(Boolean)
78
+ .join(' ');
79
+ return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx(Toggle, { ref: (element) => {
80
+ buttons.current[index] = element;
81
+ }, size: "icon-sm", "aria-label": item.label, "aria-keyshortcuts": item.keys ? ariaKeys(item.keys, apple) : undefined, pressed: state ? formatOn(state, item.id) : false, disabled: readOnly, tabIndex: index === current ? 0 : -1, className: "aria-pressed:bg-muted aria-pressed:text-foreground",
82
+ // A mouse press keeps focus, and the selection, in the text.
83
+ onMouseDown: (event) => event.preventDefault(), onFocus: () => setCurrent(index), onKeyDown: onKeyDown, onPressedChange: () => onFormat(item.id) }), children: _jsx(Icon, {}) }), _jsx(TooltipContent, { children: tip })] }, item.id));
84
+ })] }, groupIndex))), onImage && (_jsxs(_Fragment, { children: [_jsx(Separator, { orientation: "vertical", className: "mx-1 h-5 self-center" }), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx(Button, { ref: (element) => {
85
+ buttons.current[imageIndex] = element;
86
+ }, variant: "ghost", size: "icon-sm", "aria-label": "Image", disabled: readOnly, tabIndex: imageIndex === current ? 0 : -1, onMouseDown: (event) => event.preventDefault(), onFocus: () => setCurrent(imageIndex), onKeyDown: onKeyDown, onClick: onImage }), children: _jsx(ImageIcon, {}) }), _jsx(TooltipContent, { children: "Image \u00B7 or paste or drop one" })] })] }))] }));
79
87
  }
80
- export function RichTextToolbar({ state, readOnly, controls, onFormat, onTable, text, ref }) {
81
- return (_jsx("div", { ref: ref, role: "toolbar", "aria-label": "Formatting", "aria-controls": controls, "data-slot": "rich-text-toolbar", className: "flex flex-wrap items-center gap-0.5 border-b border-input px-1 py-1", children: _jsx(FormatButtons, { formats: "all", state: state, readOnly: readOnly, onFormat: onFormat, onTable: onTable, text: text }) }));
88
+ export function RichTextToolbar({ state, readOnly, controls, onFormat, onTable, onImage, text, ref }) {
89
+ return (_jsx("div", { ref: ref, role: "toolbar", "aria-label": "Formatting", "aria-controls": controls, "data-slot": "rich-text-toolbar", className: "flex flex-wrap items-center gap-0.5 border-b border-input px-1 py-1", children: _jsx(FormatButtons, { formats: "all", state: state, readOnly: readOnly, onFormat: onFormat, onTable: onTable, onImage: onImage, text: text }) }));
82
90
  }
package/dist/index.d.ts CHANGED
@@ -31,6 +31,8 @@ export { ProgressRing, type ProgressRingProps } from './components/app/progress-
31
31
  export { StepBar, type StepBarProps } from './components/app/step-bar.js';
32
32
  export { RatingStar, type RatingStarProps, type RatingStarValue } from './components/app/rating-star.js';
33
33
  export { DateField, type DateFieldProps } from './components/app/date-field.js';
34
+ export { ImageField, type ImageFieldProps } from './components/app/image-field.js';
35
+ export { DEFAULT_IMAGE_TYPES, type ImageUpload, type ImageUploadResult } from './lib/image-file.js';
34
36
  export { formatPlainDate, type PlainDate } from './lib/plain-date.js';
35
37
  export { ChoiceCell, LinkCell, NameCell, type ChoiceCellProps, type LinkCellProps, type NameCellProps, } from './components/app/row-edit-cells.js';
36
38
  export { WidgetCard, WidgetList, WidgetListItem, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
package/dist/index.js CHANGED
@@ -36,6 +36,8 @@ export { ProgressRing } from './components/app/progress-ring.js';
36
36
  export { StepBar } from './components/app/step-bar.js';
37
37
  export { RatingStar } from './components/app/rating-star.js';
38
38
  export { DateField } from './components/app/date-field.js';
39
+ export { ImageField } from './components/app/image-field.js';
40
+ export { DEFAULT_IMAGE_TYPES } from './lib/image-file.js';
39
41
  export { formatPlainDate } from './lib/plain-date.js';
40
42
  export { ChoiceCell, LinkCell, NameCell, } from './components/app/row-edit-cells.js';
41
43
  export { WidgetCard, WidgetList, WidgetListItem, } from './components/app/widget-card.js';
@@ -0,0 +1,37 @@
1
+ /** What an app's upload resolves to: the stored file's id and its addresses. */
2
+ export type ImageUploadResult = {
3
+ /** The stored file's id, what the record keeps (ImageField's value). */
4
+ id: string;
5
+ /** A small version for a thumbnail (ImageField shows it). */
6
+ thumbUrl: string;
7
+ /**
8
+ * The full-size image, for text (`![](url)` in the rich-text editor).
9
+ * Without it the editor inserts `thumbUrl`.
10
+ */
11
+ url?: string;
12
+ };
13
+ /**
14
+ * The app's upload: sends one file to its own storage and resolves to
15
+ * where it is, or rejects (an `AppError`, an `Error`: its `message` is
16
+ * shown).
17
+ */
18
+ export type ImageUpload = (file: File) => Promise<ImageUploadResult>;
19
+ /** The image types accepted unless a field says otherwise. */
20
+ export declare const DEFAULT_IMAGE_TYPES: readonly string[];
21
+ export type ImageFileRules = {
22
+ /** MIME types, `image/jpeg`; `image/*` for any image. Defaults to `DEFAULT_IMAGE_TYPES`. */
23
+ accept?: readonly string[];
24
+ /** The largest file accepted, in bytes. No limit when unset. */
25
+ maxSize?: number;
26
+ };
27
+ /** A size as a person reads it: "820 KB", "4.2 MB". */
28
+ export declare function formatBytes(bytes: number): string;
29
+ /** The `accept` attribute for a file input: `image/jpeg,image/png,image/webp`. */
30
+ export declare function acceptAttribute(accept?: readonly string[]): string;
31
+ /**
32
+ * Why a file can't be uploaded, as a sentence to show under the field, or
33
+ * null when it can: a type not accepted, or over the size limit.
34
+ */
35
+ export declare function imageRefusal(file: Pick<File, 'type' | 'size'>, rules?: ImageFileRules): string | null;
36
+ /** What a failed upload says: the rejection's own `message` (an `AppError`'s), else a generic sentence. */
37
+ export declare function uploadFailure(error: unknown, noun?: string): string;
@@ -0,0 +1,82 @@
1
+ // Images a person picks, pastes or drops, before they're uploaded: which
2
+ // files are accepted, and why one isn't, in words. Shared by ImageField
3
+ // and the rich-text editor's upload. The upload itself is the app's (an
4
+ // `ImageUpload`): the package knows no endpoint and no storage.
5
+ /** The image types accepted unless a field says otherwise. */
6
+ export const DEFAULT_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
7
+ // How a type is named to a person: "JPEG", not "image/jpeg".
8
+ const TYPE_NAMES = {
9
+ 'image/jpeg': 'JPEG',
10
+ 'image/png': 'PNG',
11
+ 'image/webp': 'WebP',
12
+ 'image/gif': 'GIF',
13
+ 'image/avif': 'AVIF',
14
+ 'image/heic': 'HEIC',
15
+ 'image/heif': 'HEIF',
16
+ 'image/svg+xml': 'SVG',
17
+ 'image/bmp': 'BMP',
18
+ 'image/tiff': 'TIFF',
19
+ 'application/pdf': 'PDF',
20
+ };
21
+ function typeName(type) {
22
+ if (TYPE_NAMES[type])
23
+ return TYPE_NAMES[type];
24
+ if (type === 'image/*')
25
+ return 'image';
26
+ const subtype = type.split('/')[1] ?? type;
27
+ return subtype.replace(/^x-/, '').toUpperCase();
28
+ }
29
+ // "JPEG, PNG or WebP".
30
+ function listOr(names) {
31
+ return names.length <= 1 ? (names[0] ?? '') : `${names.slice(0, -1).join(', ')} or ${names.at(-1)}`;
32
+ }
33
+ /** A size as a person reads it: "820 KB", "4.2 MB". */
34
+ export function formatBytes(bytes) {
35
+ if (bytes < 1024)
36
+ return `${bytes} bytes`;
37
+ if (bytes < 1024 * 1024)
38
+ return `${Math.round(bytes / 1024)} KB`;
39
+ const mb = bytes / (1024 * 1024);
40
+ return `${mb < 10 ? Math.round(mb * 10) / 10 : Math.round(mb)} MB`;
41
+ }
42
+ function accepts(accept, type) {
43
+ return accept.some((allowed) => allowed === type || (allowed.endsWith('/*') && type.startsWith(allowed.slice(0, -1))));
44
+ }
45
+ /** The `accept` attribute for a file input: `image/jpeg,image/png,image/webp`. */
46
+ export function acceptAttribute(accept = DEFAULT_IMAGE_TYPES) {
47
+ return accept.join(',');
48
+ }
49
+ /**
50
+ * Why a file can't be uploaded, as a sentence to show under the field, or
51
+ * null when it can: a type not accepted, or over the size limit.
52
+ */
53
+ export function imageRefusal(file, rules = {}) {
54
+ const accept = rules.accept ?? DEFAULT_IMAGE_TYPES;
55
+ if (!accepts(accept, file.type)) {
56
+ const wanted = listOr(accept.map(typeName));
57
+ const choose = accept.length === 1 && accept[0] === 'image/*' ? 'Choose an image.' : `Choose a ${wanted} image.`;
58
+ // A type with a name people know is named; any other file that isn't
59
+ // an image is just that.
60
+ const named = TYPE_NAMES[file.type] ?? (file.type.startsWith('image/') ? typeName(file.type) : null);
61
+ return named ? `That file is ${article(named)} ${named}. ${choose}` : `That file isn't an image. ${choose}`;
62
+ }
63
+ if (rules.maxSize !== undefined && file.size > rules.maxSize) {
64
+ return `That image is ${formatBytes(file.size)}. The limit is ${formatBytes(rules.maxSize)}.`;
65
+ }
66
+ return null;
67
+ }
68
+ // "a GIF", "an SVG", "an HEIC": by how the name is said, letter by letter
69
+ // when it's an acronym.
70
+ function article(name) {
71
+ const spelled = /^[A-Z][A-Z0-9]+$/.test(name);
72
+ return (spelled ? /^[AEFHILMNORSX]/ : /^[aeiou]/i).test(name) ? 'an' : 'a';
73
+ }
74
+ /** What a failed upload says: the rejection's own `message` (an `AppError`'s), else a generic sentence. */
75
+ export function uploadFailure(error, noun = 'image') {
76
+ if (error && typeof error === 'object' && 'message' in error) {
77
+ const message = error.message;
78
+ if (typeof message === 'string' && message.trim())
79
+ return message;
80
+ }
81
+ return `The ${noun} couldn't be uploaded. Try again.`;
82
+ }
@@ -21,7 +21,7 @@
21
21
  // padded, marks it nested differently) and that `meaning` still misses is
22
22
  // written as the editor shows it, as long as nothing in it is lost
23
23
  // (`content`): no text, address or image. A block the editor drops part
24
- // of (it doesn't show images) is kept as written, and the guard refuses.
24
+ // of (until 4.28, every image) is kept as written, and the guard refuses.
25
25
  // - Guard: the result must mean exactly what the editor shows. If it
26
26
  // wouldn't (two blocks fusing, say), the merge throws instead of saving
27
27
  // something else; the draft stays on screen.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.26.0",
3
+ "version": "4.28.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {