@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.
- package/conventions/AGENTS.md +3 -1
- package/conventions/docs/add-an-entity.md +29 -0
- package/conventions/docs/cell-patterns.md +5 -1
- package/conventions/docs/design-language.md +44 -0
- package/conventions/docs/timed-screens.md +114 -8
- package/dist/components/app/app-shell.d.ts +10 -1
- package/dist/components/app/app-shell.js +2 -2
- package/dist/components/app/foundation-providers.js +5 -1
- package/dist/components/app/icon-component.d.ts +12 -0
- package/dist/components/app/icon-component.js +1 -0
- package/dist/components/app/image-field.d.ts +37 -0
- package/dist/components/app/image-field.js +103 -0
- package/dist/components/app/markdown.js +1 -1
- package/dist/components/app/milkdown-editor.d.ts +1 -1
- package/dist/components/app/milkdown-editor.js +84 -5
- package/dist/components/app/rich-text-editor.d.ts +16 -0
- package/dist/components/app/rich-text-editor.js +5 -0
- package/dist/components/app/rich-text-image-upload.d.ts +42 -0
- package/dist/components/app/rich-text-image-upload.js +174 -0
- package/dist/components/app/rich-text-toolbar.d.ts +6 -2
- package/dist/components/app/rich-text-toolbar.js +36 -28
- package/dist/components/app/widget-card.d.ts +7 -3
- package/dist/error-reporting/error-reporting-provider.js +7 -1
- package/dist/error-reporting/reporter.d.ts +20 -1
- package/dist/error-reporting/reporter.js +60 -6
- package/dist/hooks/use-session-clock.d.ts +19 -4
- package/dist/hooks/use-session-clock.js +35 -6
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/lib/audio-cues.d.ts +28 -6
- package/dist/lib/audio-cues.js +57 -8
- package/dist/lib/image-file.d.ts +37 -0
- package/dist/lib/image-file.js +82 -0
- package/dist/lib/markdown-merge.js +1 -1
- package/dist/lib/stale-chunk-reload.d.ts +14 -0
- package/dist/lib/stale-chunk-reload.js +64 -0
- 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 (``), 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 ``
|
|
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("
|
|
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
|
+
* ``, 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 (``) 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
|
+
// ``; 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 `` 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(/ | /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,
|
|
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 +
|
|
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(
|
|
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 (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
/**
|
|
8
|
-
|
|
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({
|
|
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 });
|