@tristan2828/ui-foundation 3.15.0 → 3.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/conventions/docs/add-an-entity.md +5 -1
- package/conventions/docs/cell-patterns.md +4 -1
- package/conventions/docs/entity-plan-template.md +2 -1
- package/dist/components/app/milkdown-editor.d.ts +1 -1
- package/dist/components/app/milkdown-editor.js +40 -12
- package/dist/components/app/rich-text-editor.d.ts +6 -0
- package/dist/components/app/rich-text-editor.js +1 -1
- package/dist/components/app/rich-text-formats.d.ts +12 -0
- package/dist/components/app/rich-text-formats.js +125 -0
- package/dist/components/app/rich-text-toolbar.d.ts +12 -0
- package/dist/components/app/rich-text-toolbar.js +98 -0
- package/package.json +1 -1
|
@@ -104,7 +104,11 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
104
104
|
its "List" column, toolbar filters from its "Filter" column, form fields
|
|
105
105
|
and labels from its field table, and field types by pattern (a
|
|
106
106
|
`reference` field copies Category's searchable combobox, a
|
|
107
|
-
`single choice` copies Status's select
|
|
107
|
+
`single choice` copies Status's select — each option shows its label,
|
|
108
|
+
never its wire value, from one `Record<value, label>`
|
|
109
|
+
(`WIDGET_STATUS_LABELS`) read by the options, the trigger (through
|
|
110
|
+
`<SelectValue>`'s children, which otherwise shows the raw value), the
|
|
111
|
+
toolbar filter and every badge — a `date-time` copies
|
|
108
112
|
Available From's date picker, a `yes/no` copies In Stock — a `Switch`
|
|
109
113
|
(`@tristan2828/ui-foundation/ui/switch`) in a horizontal `Field` with
|
|
110
114
|
its label beside it on the form, cell pattern 12 (the word Yes/No) in
|
|
@@ -741,7 +741,10 @@ const price = editInPlace({
|
|
|
741
741
|
Its keys: Ctrl/Cmd+K adds or edits a link (web and email addresses
|
|
742
742
|
only), Ctrl/Cmd+Shift+Enter ticks the task item the caret is in, and
|
|
743
743
|
Markdown pasted as plain text arrives formatted. Ctrl/Cmd+Enter stays
|
|
744
|
-
the save.
|
|
744
|
+
the save. A formatting toolbar sits above the text (bold to link, each
|
|
745
|
+
button naming its shortcut); it's part of the field, so moving to it
|
|
746
|
+
doesn't save. `toolbar={false}` leaves it out where a value is short
|
|
747
|
+
and the shortcuts are enough.
|
|
745
748
|
- **The save is the single-field save** (`useRecordUpdate` with
|
|
746
749
|
`optimistic: false, toastOnError: false`, the entity's
|
|
747
750
|
`useEdit<Entity>Field`), `mutateAsync` of one field's PATCH.
|
|
@@ -30,7 +30,8 @@ One or two sentences: what one record is, and who uses the screens.
|
|
|
30
30
|
`date-time`, `email`, `yes/no` (a boolean that is always yes or no:
|
|
31
31
|
say its default and the filter's three labels, e.g. Any stock /
|
|
32
32
|
In stock / Out of stock), `single choice` (list the options, in display
|
|
33
|
-
order
|
|
33
|
+
order, with a label for any whose wire value isn't fit to show, as for
|
|
34
|
+
a multi choice; see the note below if it is *optional*), `multi choice` (list
|
|
34
35
|
the options, with a label for any whose wire value isn't fit to show,
|
|
35
36
|
e.g. `quick_win` (Quick win); any number can be picked —
|
|
36
37
|
chips on the form, badges in the table, an any-of filter), `reference`
|
|
@@ -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, 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, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
// The rich-text editor behind RichTextEditor, loaded only when one opens
|
|
3
3
|
// (rich-text-editor.tsx imports this lazily): Milkdown, a ProseMirror
|
|
4
4
|
// editor whose Markdown goes in and out through remark, the same parser
|
|
@@ -22,6 +22,8 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
22
22
|
// in. Ctrl/Cmd+Enter stays the field's save.
|
|
23
23
|
// - A placeholder in an empty document, and a count of the Markdown's
|
|
24
24
|
// length as it nears `maxLength`.
|
|
25
|
+
// - A fixed toolbar above the text (rich-text-toolbar.tsx), unless
|
|
26
|
+
// `toolbar={false}`.
|
|
25
27
|
import { useEffect, useId, useRef, useState } from 'react';
|
|
26
28
|
import { defaultValueCtx, Editor, editorViewCtx, editorViewOptionsCtx, parserCtx, remarkStringifyOptionsCtx, rootCtx, schemaCtx, serializerCtx, } from '@milkdown/kit/core';
|
|
27
29
|
import { history } from '@milkdown/kit/plugin/history';
|
|
@@ -38,6 +40,8 @@ import { Input } from '../ui/input.js';
|
|
|
38
40
|
import { Popover, PopoverContent } from '../ui/popover.js';
|
|
39
41
|
import { linkHref } from '../../lib/link-href.js';
|
|
40
42
|
import { MarkdownMergeError, markdownStyle, mergeMarkdown } from '../../lib/markdown-merge.js';
|
|
43
|
+
import { toggleFormat } from './rich-text-formats.js';
|
|
44
|
+
import { RichTextToolbar } from './rich-text-toolbar.js';
|
|
41
45
|
// The commonmark preset, minus the plugin that writes `<br />`. A $remark
|
|
42
46
|
// plugin is a pair (its options and the plugin), spread into `plugins`.
|
|
43
47
|
const LEFT_OUT = remarkPreserveEmptyLinePlugin;
|
|
@@ -178,11 +182,15 @@ function applyLink(view, target, href) {
|
|
|
178
182
|
}
|
|
179
183
|
view.dispatch(tr.scrollIntoView());
|
|
180
184
|
}
|
|
181
|
-
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
|
|
185
|
+
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, toolbar = true, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
|
|
182
186
|
const rootRef = useRef(null);
|
|
183
187
|
const editorRef = useRef(null);
|
|
184
188
|
const [linkBox, setLinkBox] = useState(null);
|
|
185
189
|
const [length, setLength] = useState(defaultValue.length);
|
|
190
|
+
// The editor's state after each change, for the toolbar's pressed buttons.
|
|
191
|
+
const [editorState, setEditorState] = useState(null);
|
|
192
|
+
// Opens the link box: made with the editor, used by the toolbar's Link.
|
|
193
|
+
const openLinkRef = useRef(() => { });
|
|
186
194
|
// The latest props, for the editor's callbacks, which are set up once.
|
|
187
195
|
const latest = useRef({ onChange, onProblem, readOnly, placeholder });
|
|
188
196
|
useEffect(() => {
|
|
@@ -190,6 +198,9 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
190
198
|
});
|
|
191
199
|
const ids = useId();
|
|
192
200
|
const counterId = `${ids}-count`;
|
|
201
|
+
// The editable element's id: the app's, or one of its own, so the
|
|
202
|
+
// toolbar can name what it controls.
|
|
203
|
+
const editableId = id ?? `${ids}-text`;
|
|
193
204
|
// The count shows from 80% of the limit: below that it's noise.
|
|
194
205
|
const showCount = maxLength !== undefined && length >= maxLength * 0.8;
|
|
195
206
|
const describedBy = [ariaDescribedBy, showCount ? counterId : undefined].filter(Boolean).join(' ');
|
|
@@ -198,8 +209,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
198
209
|
'aria-readonly': String(readOnly),
|
|
199
210
|
class: 'outline-none',
|
|
200
211
|
};
|
|
201
|
-
|
|
202
|
-
attributes.id = id;
|
|
212
|
+
attributes.id = editableId;
|
|
203
213
|
if (ariaLabel)
|
|
204
214
|
attributes['aria-label'] = ariaLabel;
|
|
205
215
|
if (ariaLabelledBy)
|
|
@@ -239,6 +249,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
239
249
|
const changes = $prose(() => new Plugin({
|
|
240
250
|
view: () => ({
|
|
241
251
|
update: (view, previous) => {
|
|
252
|
+
setEditorState(view.state);
|
|
242
253
|
if (!view.state.doc.eq(previous.doc))
|
|
243
254
|
emit();
|
|
244
255
|
},
|
|
@@ -261,6 +272,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
261
272
|
},
|
|
262
273
|
});
|
|
263
274
|
};
|
|
275
|
+
openLinkRef.current = openLink;
|
|
264
276
|
// Paste, copy, the editor's own keys and the placeholder.
|
|
265
277
|
const input = $prose((ctx) => new Plugin({
|
|
266
278
|
props: {
|
|
@@ -348,6 +360,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
348
360
|
}
|
|
349
361
|
editorRef.current = editor;
|
|
350
362
|
baseline = editor.action(getMarkdown());
|
|
363
|
+
setEditorState(editor.action((ctx) => ctx.get(editorViewCtx).state));
|
|
351
364
|
if (autoFocus)
|
|
352
365
|
editor.action((ctx) => ctx.get(editorViewCtx).focus());
|
|
353
366
|
});
|
|
@@ -373,18 +386,33 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
373
386
|
view.setProps({ editable: () => !readOnly, attributes: JSON.parse(attributesKey) });
|
|
374
387
|
});
|
|
375
388
|
}, [readOnly, attributesKey]);
|
|
389
|
+
// A toolbar button: its format on the selection, then focus back in the
|
|
390
|
+
// text (Link opens the link box instead, which gives it back on close).
|
|
391
|
+
const runFormat = (format) => {
|
|
392
|
+
editorRef.current?.action((ctx) => {
|
|
393
|
+
const view = ctx.get(editorViewCtx);
|
|
394
|
+
if (format === 'link') {
|
|
395
|
+
openLinkRef.current(view);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
toggleFormat(view.state, view.dispatch, format);
|
|
399
|
+
view.focus();
|
|
400
|
+
});
|
|
401
|
+
};
|
|
376
402
|
const closeLink = () => {
|
|
377
403
|
setLinkBox(null);
|
|
378
404
|
editorRef.current?.action((ctx) => ctx.get(editorViewCtx).focus());
|
|
379
405
|
};
|
|
380
|
-
return (_jsxs(_Fragment, { children: [
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
406
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { "data-slot": "rich-text-editor", className: cn('flex w-full min-w-0 flex-col rounded-lg border border-input bg-transparent type-body text-foreground break-words transition-[border-color,box-shadow]', 'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', 'has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/20', className), children: [toolbar && (_jsx(RichTextToolbar, { state: editorState, readOnly: readOnly, controls: editableId, onFormat: runFormat })), _jsx("div", { ref: rootRef, className: cn('min-h-24 px-2.5 py-2',
|
|
407
|
+
// Read-only text is muted; the toolbar's buttons say so by being disabled.
|
|
408
|
+
'has-[[aria-readonly=true]]:text-muted-foreground',
|
|
409
|
+
// The same type roles <Markdown> uses, so text reads the same
|
|
410
|
+
// being edited as being read.
|
|
411
|
+
'[&_.ProseMirror]:flex [&_.ProseMirror]:min-h-20 [&_.ProseMirror]:flex-col [&_.ProseMirror]:gap-3', '[&_h3]:type-section-title [&_h4]:type-section-title [&_h5]:type-label [&_h6]:type-label', '[&_ul]:flex [&_ul]:list-disc [&_ul]:flex-col [&_ul]:gap-1 [&_ul]:pl-6', '[&_ol]:flex [&_ol]:list-decimal [&_ol]:flex-col [&_ol]:gap-1 [&_ol]:pl-6', '[&_li[data-item-type=task]]:-ml-6 [&_li[data-item-type=task]]:flex [&_li[data-item-type=task]]:list-none [&_li[data-item-type=task]]:items-start [&_li[data-item-type=task]]:gap-2', '[&_li[data-item-type=task]>input]:mt-1 [&_li[data-item-type=task]>input]:size-4 [&_li[data-item-type=task]>input]:accent-primary', '[&_a]:text-primary [&_a]:underline [&_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]:border-border', '[&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:type-label',
|
|
412
|
+
// Raw HTML from the text: shown as its characters, never as markup.
|
|
413
|
+
'[&_span[data-type=html]]:rounded [&_span[data-type=html]]:bg-muted [&_span[data-type=html]]:px-1 [&_span[data-type=html]]:font-mono [&_span[data-type=html]]:text-foreground',
|
|
414
|
+
// The placeholder, in an empty document only (a decoration).
|
|
415
|
+
'[&_p[data-placeholder]]:before:pointer-events-none [&_p[data-placeholder]]:before:float-left [&_p[data-placeholder]]:before:h-0 [&_p[data-placeholder]]:before:text-muted-foreground [&_p[data-placeholder]]:before:content-[attr(data-placeholder)]') })] }), 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
|
|
388
416
|
? `${(length - maxLength).toLocaleString()} characters over the limit of ${maxLength.toLocaleString()}`
|
|
389
417
|
: `${length.toLocaleString()} of ${maxLength.toLocaleString()} characters` })), _jsx(Popover, { open: linkBox !== null, onOpenChange: (open) => !open && closeLink(), children: linkBox && (_jsx(LinkForm, { box: linkBox, onApply: (href) => {
|
|
390
418
|
const editor = editorRef.current;
|
|
@@ -18,6 +18,12 @@ export type RichTextEditorProps = {
|
|
|
18
18
|
* Typing isn't stopped: the form's schema still refuses a save over it.
|
|
19
19
|
*/
|
|
20
20
|
maxLength?: number;
|
|
21
|
+
/**
|
|
22
|
+
* The formatting toolbar above the text (bold, headings, lists, link, …),
|
|
23
|
+
* each button with its shortcut. On by default; `false` leaves the text
|
|
24
|
+
* alone, its shortcuts still working.
|
|
25
|
+
*/
|
|
26
|
+
toolbar?: boolean;
|
|
21
27
|
className?: string;
|
|
22
28
|
/** The field's label. A rich-text area must have one: this, or `aria-labelledby`. */
|
|
23
29
|
'aria-label'?: string;
|
|
@@ -21,5 +21,5 @@ import { cn } from 'cn';
|
|
|
21
21
|
import { Skeleton } from '../ui/skeleton.js';
|
|
22
22
|
const MilkdownEditor = lazy(() => import('./milkdown-editor.js'));
|
|
23
23
|
export function RichTextEditor(props) {
|
|
24
|
-
return (_jsx(Suspense, { fallback: _jsxs("div", { "data-slot": "rich-text-editor", "data-state": "loading", className: cn('flex
|
|
24
|
+
return (_jsx(Suspense, { fallback: _jsxs("div", { "data-slot": "rich-text-editor", "data-state": "loading", className: cn('flex w-full flex-col rounded-lg border border-input', props.className), children: [props.toolbar !== false && _jsx("div", { className: "h-9 border-b border-input" }), _jsxs("div", { className: "flex min-h-24 flex-col gap-2 px-2.5 py-2", children: [_jsx(Skeleton, { className: "h-4 w-3/4" }), _jsx(Skeleton, { className: "h-4 w-1/2" })] })] }), children: _jsx(MilkdownEditor, { ...props }) }));
|
|
25
25
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { EditorState, Transaction } from '@milkdown/kit/prose/state';
|
|
2
|
+
export type FormatId = 'bold' | 'italic' | 'strike' | 'code' | 'heading' | 'subheading' | 'bullets' | 'numbers' | 'tasks' | 'quote' | 'codeBlock' | 'link';
|
|
3
|
+
type Dispatch = (tr: Transaction) => void;
|
|
4
|
+
/** Whether a format is on where the caret is. */
|
|
5
|
+
export declare function formatOn(state: EditorState, id: FormatId): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Turns a format on, or off when it's already on, where the caret is.
|
|
8
|
+
* Returns false when it can't apply there. `link` isn't run here: the
|
|
9
|
+
* toolbar opens the link box for it.
|
|
10
|
+
*/
|
|
11
|
+
export declare function toggleFormat(state: EditorState, dispatch: Dispatch, id: FormatId): boolean;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// What the rich-text editor's toolbar can do: each format, whether it's on
|
|
2
|
+
// where the caret (or the selection) is, and how to turn it on or off.
|
|
3
|
+
// Plain ProseMirror commands over Milkdown's schema (commonmark + gfm), so
|
|
4
|
+
// a toolbar press is one undo step, the same as its keyboard shortcut.
|
|
5
|
+
import { lift, setBlockType, toggleMark, wrapIn } from '@milkdown/kit/prose/commands';
|
|
6
|
+
import { liftListItem, wrapInList } from '@milkdown/kit/prose/schema-list';
|
|
7
|
+
const MARKS = {
|
|
8
|
+
bold: 'strong',
|
|
9
|
+
italic: 'emphasis',
|
|
10
|
+
strike: 'strike_through',
|
|
11
|
+
code: 'inlineCode',
|
|
12
|
+
link: 'link',
|
|
13
|
+
};
|
|
14
|
+
function markOn(state, type) {
|
|
15
|
+
const { from, to, empty, $from } = state.selection;
|
|
16
|
+
if (empty)
|
|
17
|
+
return Boolean(type.isInSet(state.storedMarks ?? $from.marks()));
|
|
18
|
+
return state.doc.rangeHasMark(from, to, type);
|
|
19
|
+
}
|
|
20
|
+
// The innermost ancestor of the caret matching `test`, and its position.
|
|
21
|
+
function ancestor(state, test) {
|
|
22
|
+
const { $from } = state.selection;
|
|
23
|
+
for (let depth = $from.depth; depth > 0; depth--) {
|
|
24
|
+
const node = $from.node(depth);
|
|
25
|
+
if (test(node))
|
|
26
|
+
return { node, pos: $from.before(depth) };
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const isList = (node) => node.type.name === 'bullet_list' || node.type.name === 'ordered_list';
|
|
31
|
+
/** Whether a format is on where the caret is. */
|
|
32
|
+
export function formatOn(state, id) {
|
|
33
|
+
const { schema } = state;
|
|
34
|
+
const mark = MARKS[id];
|
|
35
|
+
if (mark)
|
|
36
|
+
return markOn(state, schema.marks[mark]);
|
|
37
|
+
const parent = state.selection.$from.parent;
|
|
38
|
+
switch (id) {
|
|
39
|
+
case 'heading':
|
|
40
|
+
case 'subheading':
|
|
41
|
+
return parent.type.name === 'heading' && parent.attrs.level === (id === 'heading' ? 1 : 2);
|
|
42
|
+
case 'codeBlock':
|
|
43
|
+
return parent.type.name === 'code_block';
|
|
44
|
+
case 'quote':
|
|
45
|
+
return ancestor(state, (node) => node.type.name === 'blockquote') !== null;
|
|
46
|
+
default: {
|
|
47
|
+
const list = ancestor(state, isList);
|
|
48
|
+
const item = ancestor(state, (node) => node.type.name === 'list_item');
|
|
49
|
+
if (!list || !item)
|
|
50
|
+
return false;
|
|
51
|
+
const task = item.node.attrs.checked != null;
|
|
52
|
+
if (id === 'tasks')
|
|
53
|
+
return task;
|
|
54
|
+
if (id === 'numbers')
|
|
55
|
+
return list.node.type.name === 'ordered_list';
|
|
56
|
+
return list.node.type.name === 'bullet_list' && !task;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// The list the caret is in, turned into `type`, every item a task item or
|
|
61
|
+
// none (`checked`: false for a task, null for not).
|
|
62
|
+
function convertList(tr, type, checked) {
|
|
63
|
+
const $from = tr.selection.$from;
|
|
64
|
+
for (let depth = $from.depth; depth > 0; depth--) {
|
|
65
|
+
const node = $from.node(depth);
|
|
66
|
+
if (!isList(node))
|
|
67
|
+
continue;
|
|
68
|
+
const pos = $from.before(depth);
|
|
69
|
+
tr.setNodeMarkup(pos, type);
|
|
70
|
+
node.forEach((item, offset) => {
|
|
71
|
+
const itemPos = pos + 1 + offset;
|
|
72
|
+
const keep = checked === false && item.attrs.checked != null ? item.attrs.checked : checked;
|
|
73
|
+
tr.setNodeMarkup(itemPos, undefined, {
|
|
74
|
+
...item.attrs,
|
|
75
|
+
checked: keep,
|
|
76
|
+
listType: type.name === 'ordered_list' ? 'ordered' : 'bullet',
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
return tr;
|
|
80
|
+
}
|
|
81
|
+
return tr;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Turns a format on, or off when it's already on, where the caret is.
|
|
85
|
+
* Returns false when it can't apply there. `link` isn't run here: the
|
|
86
|
+
* toolbar opens the link box for it.
|
|
87
|
+
*/
|
|
88
|
+
export function toggleFormat(state, dispatch, id) {
|
|
89
|
+
const { schema } = state;
|
|
90
|
+
const mark = MARKS[id];
|
|
91
|
+
if (mark && id !== 'link')
|
|
92
|
+
return toggleMark(schema.marks[mark])(state, dispatch);
|
|
93
|
+
const on = formatOn(state, id);
|
|
94
|
+
const paragraph = schema.nodes.paragraph;
|
|
95
|
+
switch (id) {
|
|
96
|
+
case 'heading':
|
|
97
|
+
case 'subheading':
|
|
98
|
+
return on
|
|
99
|
+
? setBlockType(paragraph)(state, dispatch)
|
|
100
|
+
: setBlockType(schema.nodes.heading, { level: id === 'heading' ? 1 : 2 })(state, dispatch);
|
|
101
|
+
case 'codeBlock':
|
|
102
|
+
return on ? setBlockType(paragraph)(state, dispatch) : setBlockType(schema.nodes.code_block)(state, dispatch);
|
|
103
|
+
case 'quote':
|
|
104
|
+
return on ? lift(state, dispatch) : wrapIn(schema.nodes.blockquote)(state, dispatch);
|
|
105
|
+
case 'bullets':
|
|
106
|
+
case 'numbers':
|
|
107
|
+
case 'tasks': {
|
|
108
|
+
if (on)
|
|
109
|
+
return liftListItem(schema.nodes.list_item)(state, dispatch);
|
|
110
|
+
const type = id === 'numbers' ? schema.nodes.ordered_list : schema.nodes.bullet_list;
|
|
111
|
+
const checked = id === 'tasks' ? false : null;
|
|
112
|
+
if (ancestor(state, isList)) {
|
|
113
|
+
dispatch(convertList(state.tr, type, checked));
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
let wrapped = null;
|
|
117
|
+
if (!wrapInList(type)(state, (tr) => (wrapped = tr)))
|
|
118
|
+
return false;
|
|
119
|
+
dispatch(convertList(wrapped, type, checked));
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
default:
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { EditorState } from '@milkdown/kit/prose/state';
|
|
2
|
+
import { type FormatId } from './rich-text-formats.js';
|
|
3
|
+
export interface RichTextToolbarProps {
|
|
4
|
+
/** The editor's state now, to show which formats are on. Null until it's made. */
|
|
5
|
+
state: EditorState | null;
|
|
6
|
+
readOnly: boolean;
|
|
7
|
+
/** The editable element's id, for `aria-controls`. */
|
|
8
|
+
controls?: string;
|
|
9
|
+
/** A button pressed: run the format on the editor and give it focus back. */
|
|
10
|
+
onFormat: (id: FormatId) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function RichTextToolbar({ state, readOnly, controls, onFormat }: RichTextToolbarProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
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
|
+
//
|
|
5
|
+
// - One tab stop (ARIA's toolbar pattern): Tab reaches the last button
|
|
6
|
+
// used, the arrow keys, Home and End move along the row.
|
|
7
|
+
// - A press acts on the editor's selection and gives focus back to the
|
|
8
|
+
// text; a mouse press never takes focus from it, so the selection stays.
|
|
9
|
+
// - Each button says whether its format is on where the caret is
|
|
10
|
+
// (`aria-pressed`), and names its shortcut (`aria-keyshortcuts`, and the
|
|
11
|
+
// tooltip).
|
|
12
|
+
// - Read-only (while a save is in flight), every button is disabled.
|
|
13
|
+
import { BoldIcon, CodeIcon, Heading1Icon, Heading2Icon, ItalicIcon, LinkIcon, ListChecksIcon, ListIcon, ListOrderedIcon, SquareCodeIcon, StrikethroughIcon, TextQuoteIcon, } from 'lucide-react';
|
|
14
|
+
import { Fragment, useRef, useState } from 'react';
|
|
15
|
+
import { Separator } from '../ui/separator.js';
|
|
16
|
+
import { Toggle } from '../ui/toggle.js';
|
|
17
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip.js';
|
|
18
|
+
import { formatOn } from './rich-text-formats.js';
|
|
19
|
+
const GROUPS = [
|
|
20
|
+
[
|
|
21
|
+
{ id: 'bold', label: 'Bold', icon: BoldIcon, keys: 'Mod+B' },
|
|
22
|
+
{ id: 'italic', label: 'Italic', icon: ItalicIcon, keys: 'Mod+I' },
|
|
23
|
+
{ id: 'strike', label: 'Strikethrough', icon: StrikethroughIcon, keys: 'Mod+Alt+X' },
|
|
24
|
+
{ id: 'code', label: 'Code', icon: CodeIcon, keys: 'Mod+E' },
|
|
25
|
+
],
|
|
26
|
+
[
|
|
27
|
+
{ id: 'heading', label: 'Heading', icon: Heading1Icon, keys: 'Mod+Alt+1' },
|
|
28
|
+
{ id: 'subheading', label: 'Subheading', icon: Heading2Icon, keys: 'Mod+Alt+2' },
|
|
29
|
+
],
|
|
30
|
+
[
|
|
31
|
+
{ id: 'bullets', label: 'Bulleted list', icon: ListIcon, keys: 'Mod+Alt+8' },
|
|
32
|
+
{ id: 'numbers', label: 'Numbered list', icon: ListOrderedIcon, keys: 'Mod+Alt+7' },
|
|
33
|
+
{ id: 'tasks', label: 'Task list', icon: ListChecksIcon, hint: 'tick an item: Mod+Shift+Enter' },
|
|
34
|
+
],
|
|
35
|
+
[
|
|
36
|
+
{ id: 'quote', label: 'Quote', icon: TextQuoteIcon, keys: 'Mod+Shift+B' },
|
|
37
|
+
{ id: 'codeBlock', label: 'Code block', icon: SquareCodeIcon, keys: 'Mod+Alt+C' },
|
|
38
|
+
],
|
|
39
|
+
[{ id: 'link', label: 'Link', icon: LinkIcon, keys: 'Mod+K' }],
|
|
40
|
+
];
|
|
41
|
+
const ITEMS = GROUPS.flat();
|
|
42
|
+
function isApple() {
|
|
43
|
+
return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
44
|
+
}
|
|
45
|
+
// `aria-keyshortcuts` names real keys: Meta on Apple, Control elsewhere.
|
|
46
|
+
function ariaKeys(keys, apple) {
|
|
47
|
+
return keys.replace('Mod', apple ? 'Meta' : 'Control');
|
|
48
|
+
}
|
|
49
|
+
// What a person reads: ⌘⌥X on Apple, Ctrl+Alt+X elsewhere.
|
|
50
|
+
function shownKeys(keys, apple) {
|
|
51
|
+
if (!apple)
|
|
52
|
+
return keys.replace('Mod', 'Ctrl');
|
|
53
|
+
const symbols = { Mod: '⌘', Alt: '⌥', Shift: '⇧', Enter: '↵' };
|
|
54
|
+
return keys
|
|
55
|
+
.split('+')
|
|
56
|
+
.map((part) => symbols[part] ?? part)
|
|
57
|
+
.join('');
|
|
58
|
+
}
|
|
59
|
+
export function RichTextToolbar({ state, readOnly, controls, onFormat }) {
|
|
60
|
+
const apple = isApple();
|
|
61
|
+
const buttons = useRef([]);
|
|
62
|
+
// The one button Tab reaches: the last one used.
|
|
63
|
+
const [current, setCurrent] = useState(0);
|
|
64
|
+
const move = (index) => {
|
|
65
|
+
const next = (index + ITEMS.length) % ITEMS.length;
|
|
66
|
+
setCurrent(next);
|
|
67
|
+
buttons.current[next]?.focus();
|
|
68
|
+
};
|
|
69
|
+
const onKeyDown = (event) => {
|
|
70
|
+
const keys = {
|
|
71
|
+
ArrowRight: () => move(current + 1),
|
|
72
|
+
ArrowLeft: () => move(current - 1),
|
|
73
|
+
Home: () => move(0),
|
|
74
|
+
End: () => move(ITEMS.length - 1),
|
|
75
|
+
};
|
|
76
|
+
const action = keys[event.key];
|
|
77
|
+
if (!action || event.metaKey || event.ctrlKey || event.altKey)
|
|
78
|
+
return;
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
action();
|
|
81
|
+
};
|
|
82
|
+
return (_jsx(TooltipProvider, { delay: 400, children: _jsx("div", { 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", onKeyDown: onKeyDown, children: GROUPS.map((group, groupIndex) => (_jsxs(Fragment, { children: [groupIndex > 0 && _jsx(Separator, { orientation: "vertical", className: "mx-1 h-5 self-center" }), group.map((item) => {
|
|
83
|
+
const index = ITEMS.indexOf(item);
|
|
84
|
+
const Icon = item.icon;
|
|
85
|
+
const tip = [
|
|
86
|
+
item.label,
|
|
87
|
+
item.keys && `(${shownKeys(item.keys, apple)})`,
|
|
88
|
+
item.hint && `· ${item.hint.replace(/Mod\+Shift\+Enter/, shownKeys('Mod+Shift+Enter', apple))}`,
|
|
89
|
+
]
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
.join(' ');
|
|
92
|
+
return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx(Toggle, { ref: (element) => {
|
|
93
|
+
buttons.current[index] = element;
|
|
94
|
+
}, 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",
|
|
95
|
+
// A mouse press keeps focus, and the selection, in the text.
|
|
96
|
+
onMouseDown: (event) => event.preventDefault(), onFocus: () => setCurrent(index), onPressedChange: () => onFormat(item.id) }), children: _jsx(Icon, {}) }), _jsx(TooltipContent, { children: tip })] }, item.id));
|
|
97
|
+
})] }, groupIndex))) }) }));
|
|
98
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.16.1",
|
|
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": {
|