@tristan2828/ui-foundation 4.27.0 → 4.29.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.
Files changed (37) hide show
  1. package/conventions/AGENTS.md +3 -1
  2. package/conventions/docs/add-an-entity.md +29 -0
  3. package/conventions/docs/cell-patterns.md +5 -1
  4. package/conventions/docs/design-language.md +44 -0
  5. package/conventions/docs/timed-screens.md +114 -8
  6. package/dist/components/app/app-shell.d.ts +10 -1
  7. package/dist/components/app/app-shell.js +2 -2
  8. package/dist/components/app/foundation-providers.js +5 -1
  9. package/dist/components/app/icon-component.d.ts +12 -0
  10. package/dist/components/app/icon-component.js +1 -0
  11. package/dist/components/app/image-field.d.ts +37 -0
  12. package/dist/components/app/image-field.js +103 -0
  13. package/dist/components/app/markdown.js +1 -1
  14. package/dist/components/app/milkdown-editor.d.ts +1 -1
  15. package/dist/components/app/milkdown-editor.js +84 -5
  16. package/dist/components/app/rich-text-editor.d.ts +16 -0
  17. package/dist/components/app/rich-text-editor.js +5 -0
  18. package/dist/components/app/rich-text-image-upload.d.ts +42 -0
  19. package/dist/components/app/rich-text-image-upload.js +174 -0
  20. package/dist/components/app/rich-text-toolbar.d.ts +6 -2
  21. package/dist/components/app/rich-text-toolbar.js +36 -28
  22. package/dist/components/app/widget-card.d.ts +7 -3
  23. package/dist/error-reporting/error-reporting-provider.js +7 -1
  24. package/dist/error-reporting/reporter.d.ts +20 -1
  25. package/dist/error-reporting/reporter.js +60 -6
  26. package/dist/hooks/use-session-clock.d.ts +19 -4
  27. package/dist/hooks/use-session-clock.js +35 -6
  28. package/dist/index.d.ts +4 -1
  29. package/dist/index.js +3 -1
  30. package/dist/lib/audio-cues.d.ts +28 -6
  31. package/dist/lib/audio-cues.js +57 -8
  32. package/dist/lib/image-file.d.ts +37 -0
  33. package/dist/lib/image-file.js +82 -0
  34. package/dist/lib/markdown-merge.js +1 -1
  35. package/dist/lib/stale-chunk-reload.d.ts +14 -0
  36. package/dist/lib/stale-chunk-reload.js +64 -0
  37. package/package.json +1 -1
@@ -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
  }
@@ -1,11 +1,15 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import { type To } from 'react-router';
3
- import { type LucideIcon } from 'lucide-react';
4
3
  import type { AppError } from '../../api/contracts.js';
4
+ import type { IconComponent } from './icon-component.js';
5
5
  export interface WidgetCardProps {
6
6
  title: string;
7
- /** A Lucide icon shown before the title. Decorative: the title names the card. */
8
- icon?: LucideIcon;
7
+ /**
8
+ * An icon shown before the title: a Lucide icon, or an app's own mark (an
9
+ * outside service's logo on a card about that service). Decorative: the
10
+ * title names the card.
11
+ */
12
+ icon?: IconComponent;
9
13
  /** The title's heading level: 2 under the page's h1 (the default), deeper under a section. */
10
14
  headingLevel?: 2 | 3 | 4 | 5 | 6;
11
15
  /**
@@ -6,12 +6,18 @@ import { useQueryClient } from '@tanstack/react-query';
6
6
  import { useEffect, useState } from 'react';
7
7
  import { reportClientError } from '../api/gateway/client-errors.js';
8
8
  import { useAuth } from '../auth/use-auth.js';
9
+ import { browserStaleChunkReload } from '../lib/stale-chunk-reload.js';
9
10
  import { ErrorReportingContext, useRoutePattern } from './error-reporting-context.js';
10
11
  import { createErrorReporter } from './reporter.js';
11
12
  export function ErrorReportingProvider({ options, children }) {
12
13
  const queryClient = useQueryClient();
13
14
  const { status } = useAuth();
14
- const [reporter] = useState(() => createErrorReporter({ options, send: (report) => void reportClientError(report) }));
15
+ const [reporter] = useState(() => createErrorReporter({
16
+ options,
17
+ send: (report) => void reportClientError(report),
18
+ // A failure that sends the page to a new build is the old build going, not a bug.
19
+ muted: browserStaleChunkReload().isReloading,
20
+ }));
15
21
  useEffect(() => reporter.setSignedIn(status === 'authenticated'), [reporter, status]);
16
22
  useEffect(() => reporter.install(queryClient, window), [reporter, queryClient]);
17
23
  return _jsx(ErrorReportingContext.Provider, { value: reporter, children: children });