@tristan2828/ui-foundation 3.16.2 → 3.17.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/docs/cell-patterns.md +6 -3
- package/dist/components/app/milkdown-editor.js +94 -6
- package/dist/components/app/rich-text-editor.d.ts +4 -3
- package/dist/components/app/rich-text-floating-toolbar.d.ts +25 -0
- package/dist/components/app/rich-text-floating-toolbar.js +92 -0
- package/dist/components/app/rich-text-toolbar.d.ts +29 -1
- package/dist/components/app/rich-text-toolbar.js +35 -20
- package/package.json +1 -1
|
@@ -742,9 +742,12 @@ const price = editInPlace({
|
|
|
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
744
|
the save. A formatting toolbar sits above the text (bold to link, each
|
|
745
|
-
button naming its shortcut)
|
|
746
|
-
|
|
747
|
-
|
|
745
|
+
button naming its shortcut), and a smaller one floats over selected
|
|
746
|
+
words (bold, italic, strikethrough, code, link); Alt+F10 reaches the
|
|
747
|
+
floating one, else the fixed one. Both are part of the field, so moving
|
|
748
|
+
to them doesn't save, and Esc hides the floating one before it gives up
|
|
749
|
+
the edit. `toolbar={false}` leaves both out where a value is short and
|
|
750
|
+
the shortcuts are enough.
|
|
748
751
|
- **The save is the single-field save** (`useRecordUpdate` with
|
|
749
752
|
`optimistic: false, toastOnError: false`, the entity's
|
|
750
753
|
`useEdit<Entity>Field`), `mutateAsync` of one field's PATCH.
|
|
@@ -22,15 +22,17 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } 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),
|
|
26
|
-
//
|
|
25
|
+
// - A fixed toolbar above the text (rich-text-toolbar.tsx), and a
|
|
26
|
+
// floating one over selected words (rich-text-floating-toolbar.tsx),
|
|
27
|
+
// unless `toolbar={false}`. Alt+F10 reaches the floating one when it
|
|
28
|
+
// shows, else the fixed one.
|
|
27
29
|
import { useEffect, useId, useRef, useState } from 'react';
|
|
28
30
|
import { defaultValueCtx, Editor, editorViewCtx, editorViewOptionsCtx, parserCtx, remarkStringifyOptionsCtx, rootCtx, schemaCtx, serializerCtx, } from '@milkdown/kit/core';
|
|
29
31
|
import { history } from '@milkdown/kit/plugin/history';
|
|
30
32
|
import { commands, inputRules, keymap, markInputRules, plugins, remarkPreserveEmptyLinePlugin, schema, } from '@milkdown/kit/preset/commonmark';
|
|
31
33
|
import { gfm } from '@milkdown/kit/preset/gfm';
|
|
32
34
|
import { Fragment, Slice } from '@milkdown/kit/prose/model';
|
|
33
|
-
import { Plugin } from '@milkdown/kit/prose/state';
|
|
35
|
+
import { NodeSelection, Plugin } from '@milkdown/kit/prose/state';
|
|
34
36
|
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view';
|
|
35
37
|
import { $prose, getMarkdown } from '@milkdown/kit/utils';
|
|
36
38
|
import { cn } from 'cn';
|
|
@@ -40,6 +42,7 @@ import { Input } from '../ui/input.js';
|
|
|
40
42
|
import { Popover, PopoverContent } from '../ui/popover.js';
|
|
41
43
|
import { linkHref } from '../../lib/link-href.js';
|
|
42
44
|
import { MarkdownMergeError, markdownStyle, mergeMarkdown } from '../../lib/markdown-merge.js';
|
|
45
|
+
import { RichTextFloatingToolbar } from './rich-text-floating-toolbar.js';
|
|
43
46
|
import { toggleFormat } from './rich-text-formats.js';
|
|
44
47
|
import { RichTextToolbar } from './rich-text-toolbar.js';
|
|
45
48
|
// The commonmark preset, minus the plugin that writes `<br />`. A $remark
|
|
@@ -189,6 +192,19 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
189
192
|
const [length, setLength] = useState(defaultValue.length);
|
|
190
193
|
// The editor's state after each change, for the toolbar's pressed buttons.
|
|
191
194
|
const [editorState, setEditorState] = useState(null);
|
|
195
|
+
const [editorView, setEditorView] = useState(null);
|
|
196
|
+
// For the floating toolbar: focus is in the text (or on the floating
|
|
197
|
+
// toolbar), the mouse is down choosing words, and the selection Esc hid
|
|
198
|
+
// it for.
|
|
199
|
+
const [focused, setFocused] = useState(false);
|
|
200
|
+
const [selecting, setSelecting] = useState(false);
|
|
201
|
+
const [dismissed, setDismissed] = useState(null);
|
|
202
|
+
const toolbarRef = useRef(null);
|
|
203
|
+
const floatingRef = useRef(null);
|
|
204
|
+
// The field's element, which the floating toolbar is placed against.
|
|
205
|
+
const [field, setField] = useState(null);
|
|
206
|
+
// Whether the floating toolbar shows, for the editor's keys (set up once).
|
|
207
|
+
const floatingOpenRef = useRef(false);
|
|
192
208
|
// Opens the link box: made with the editor, used by the toolbar's Link.
|
|
193
209
|
const openLinkRef = useRef(() => { });
|
|
194
210
|
// The latest props, for the editor's callbacks, which are set up once.
|
|
@@ -220,6 +236,8 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
220
236
|
attributes['aria-invalid'] = 'true';
|
|
221
237
|
if (placeholder)
|
|
222
238
|
attributes['aria-placeholder'] = placeholder;
|
|
239
|
+
if (toolbar)
|
|
240
|
+
attributes['aria-keyshortcuts'] = 'Alt+F10';
|
|
223
241
|
const attributesKey = JSON.stringify(attributes);
|
|
224
242
|
// The editor is made once, from the Markdown it opened with.
|
|
225
243
|
useEffect(() => {
|
|
@@ -252,6 +270,9 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
252
270
|
if (cancelled)
|
|
253
271
|
return;
|
|
254
272
|
setEditorState(view.state);
|
|
273
|
+
// Esc hides the floating toolbar for one selection only.
|
|
274
|
+
if (view.state.selection.empty)
|
|
275
|
+
setDismissed(null);
|
|
255
276
|
if (!view.state.doc.eq(previous.doc))
|
|
256
277
|
emit();
|
|
257
278
|
},
|
|
@@ -310,7 +331,49 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
310
331
|
: null;
|
|
311
332
|
return doc ? ctx.get(serializerCtx)(doc) : slice.content.textBetween(0, slice.content.size, '\n\n');
|
|
312
333
|
},
|
|
334
|
+
handleDOMEvents: {
|
|
335
|
+
focus: () => {
|
|
336
|
+
setFocused(true);
|
|
337
|
+
return false;
|
|
338
|
+
},
|
|
339
|
+
// Moving to the floating toolbar keeps it showing.
|
|
340
|
+
blur: (_view, event) => {
|
|
341
|
+
if (!floatingRef.current?.contains(event.relatedTarget))
|
|
342
|
+
setFocused(false);
|
|
343
|
+
return false;
|
|
344
|
+
},
|
|
345
|
+
// The floating toolbar waits for the mouse to be up: a drag
|
|
346
|
+
// is still choosing the words.
|
|
347
|
+
mousedown: (_view, event) => {
|
|
348
|
+
if (event.button !== 0)
|
|
349
|
+
return false;
|
|
350
|
+
setDismissed(null);
|
|
351
|
+
setSelecting(true);
|
|
352
|
+
window.addEventListener('mouseup', () => setSelecting(false), { once: true });
|
|
353
|
+
return false;
|
|
354
|
+
},
|
|
355
|
+
},
|
|
313
356
|
handleKeyDown: (view, event) => {
|
|
357
|
+
setSelecting(false);
|
|
358
|
+
// Alt+F10: to the floating toolbar when it shows, else the
|
|
359
|
+
// fixed one (the convention editors share).
|
|
360
|
+
if (event.altKey && event.key === 'F10') {
|
|
361
|
+
const bar = floatingOpenRef.current ? floatingRef.current : toolbarRef.current;
|
|
362
|
+
const button = bar?.querySelector('button[tabindex="0"]');
|
|
363
|
+
if (!button)
|
|
364
|
+
return false;
|
|
365
|
+
event.preventDefault();
|
|
366
|
+
button.focus();
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
// Esc hides the floating toolbar first; only the next one
|
|
370
|
+
// reaches the field (editing in place gives up on it).
|
|
371
|
+
if (event.key === 'Escape' && floatingOpenRef.current) {
|
|
372
|
+
event.preventDefault();
|
|
373
|
+
const { from, to } = view.state.selection;
|
|
374
|
+
setDismissed({ from, to });
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
314
377
|
const mod = event.metaKey || event.ctrlKey;
|
|
315
378
|
if (mod && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'k') {
|
|
316
379
|
event.preventDefault();
|
|
@@ -362,13 +425,16 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
362
425
|
}
|
|
363
426
|
editorRef.current = editor;
|
|
364
427
|
baseline = editor.action(getMarkdown());
|
|
365
|
-
|
|
428
|
+
const view = editor.action((ctx) => ctx.get(editorViewCtx));
|
|
429
|
+
setEditorView(view);
|
|
430
|
+
setEditorState(view.state);
|
|
366
431
|
if (autoFocus)
|
|
367
432
|
editor.action((ctx) => ctx.get(editorViewCtx).focus());
|
|
368
433
|
});
|
|
369
434
|
return () => {
|
|
370
435
|
cancelled = true;
|
|
371
436
|
setEditorState(null);
|
|
437
|
+
setEditorView(null);
|
|
372
438
|
const editor = editorRef.current;
|
|
373
439
|
editorRef.current = null;
|
|
374
440
|
if (editor)
|
|
@@ -402,11 +468,29 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
402
468
|
view.focus();
|
|
403
469
|
});
|
|
404
470
|
};
|
|
471
|
+
// The floating toolbar: words selected, focus in the field, the mouse up,
|
|
472
|
+
// and nothing else open over the text.
|
|
473
|
+
const selection = editorState?.selection;
|
|
474
|
+
const floatingOpen = Boolean(toolbar &&
|
|
475
|
+
!readOnly &&
|
|
476
|
+
editorView &&
|
|
477
|
+
focused &&
|
|
478
|
+
!selecting &&
|
|
479
|
+
linkBox === null &&
|
|
480
|
+
selection &&
|
|
481
|
+
!selection.empty &&
|
|
482
|
+
!(selection instanceof NodeSelection) &&
|
|
483
|
+
!selection.$from.parent.type.spec.code &&
|
|
484
|
+
!(dismissed && dismissed.from === selection.from && dismissed.to === selection.to));
|
|
485
|
+
useEffect(() => {
|
|
486
|
+
floatingOpenRef.current = floatingOpen;
|
|
487
|
+
});
|
|
488
|
+
const returnToText = () => editorView?.focus();
|
|
405
489
|
const closeLink = () => {
|
|
406
490
|
setLinkBox(null);
|
|
407
491
|
editorRef.current?.action((ctx) => ctx.get(editorViewCtx).focus());
|
|
408
492
|
};
|
|
409
|
-
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,
|
|
493
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: setField, "data-slot": "rich-text-editor", className: cn('relative 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, { ref: toolbarRef, state: editorState, readOnly: readOnly,
|
|
410
494
|
// Only once the editor is made: until then the editable element,
|
|
411
495
|
// and its id, aren't in the page (axe: aria-valid-attr-value).
|
|
412
496
|
controls: editorState ? editableId : undefined, onFormat: runFormat })), _jsx("div", { ref: rootRef, className: cn('min-h-24 px-2.5 py-2',
|
|
@@ -421,7 +505,11 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
421
505
|
// Raw HTML from the text: shown as its characters, never as markup.
|
|
422
506
|
'[&_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',
|
|
423
507
|
// The placeholder, in an empty document only (a decoration).
|
|
424
|
-
'[&_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)]') })
|
|
508
|
+
'[&_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)]') }), floatingOpen && editorView && editorState && (_jsx(RichTextFloatingToolbar, { ref: floatingRef, view: editorView, state: editorState, field: field, controls: editableId, onFormat: runFormat, onDismiss: () => setDismissed({ from: editorState.selection.from, to: editorState.selection.to }), onReturn: returnToText, onBlur: (event) => {
|
|
509
|
+
const next = event.relatedTarget;
|
|
510
|
+
if (!editorView.dom.contains(next) && !floatingRef.current?.contains(next))
|
|
511
|
+
setFocused(false);
|
|
512
|
+
} }))] }), 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
|
|
425
513
|
? `${(length - maxLength).toLocaleString()} characters over the limit of ${maxLength.toLocaleString()}`
|
|
426
514
|
: `${length.toLocaleString()} of ${maxLength.toLocaleString()} characters` })), _jsx(Popover, { open: linkBox !== null, onOpenChange: (open) => !open && closeLink(), children: linkBox && (_jsx(LinkForm, { box: linkBox, onApply: (href) => {
|
|
427
515
|
const editor = editorRef.current;
|
|
@@ -19,9 +19,10 @@ export type RichTextEditorProps = {
|
|
|
19
19
|
*/
|
|
20
20
|
maxLength?: number;
|
|
21
21
|
/**
|
|
22
|
-
* The formatting
|
|
23
|
-
* each button with its shortcut
|
|
24
|
-
*
|
|
22
|
+
* The formatting toolbars: one fixed above the text (bold, headings,
|
|
23
|
+
* lists, link, …), each button with its shortcut, and one floating over
|
|
24
|
+
* selected words (bold to link). Alt+F10 reaches them from the text. On
|
|
25
|
+
* by default; `false` leaves the text alone, its shortcuts still working.
|
|
25
26
|
*/
|
|
26
27
|
toolbar?: boolean;
|
|
27
28
|
className?: string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type FocusEvent, type Ref } from 'react';
|
|
2
|
+
import type { EditorState } from '@milkdown/kit/prose/state';
|
|
3
|
+
import type { EditorView } from '@milkdown/kit/prose/view';
|
|
4
|
+
import type { FormatId } from './rich-text-formats.js';
|
|
5
|
+
export interface RichTextFloatingToolbarProps {
|
|
6
|
+
/** The editor, to measure the selection. */
|
|
7
|
+
view: EditorView;
|
|
8
|
+
/** The editor's state now: the selection, and which formats are on. */
|
|
9
|
+
state: EditorState;
|
|
10
|
+
/** The field's element it's placed against (positioned: `relative`). */
|
|
11
|
+
field: HTMLElement | null;
|
|
12
|
+
/** The editable element's id, for `aria-controls`. */
|
|
13
|
+
controls: string;
|
|
14
|
+
/** A button pressed: run the format on the editor and give it focus back. */
|
|
15
|
+
onFormat: (id: FormatId) => void;
|
|
16
|
+
/** Esc: hide it until the selection changes. */
|
|
17
|
+
onDismiss: () => void;
|
|
18
|
+
/** Esc or Tab: focus back to the text. */
|
|
19
|
+
onReturn: () => void;
|
|
20
|
+
/** Focus leaving one of its buttons, for the editor to tell whether it left the field. */
|
|
21
|
+
onBlur: (event: FocusEvent) => void;
|
|
22
|
+
/** Its element, for Alt+F10 to reach it and for the editor's own blur. */
|
|
23
|
+
ref?: Ref<HTMLDivElement>;
|
|
24
|
+
}
|
|
25
|
+
export declare function RichTextFloatingToolbar({ view, state, field, controls, onFormat, onDismiss, onReturn, onBlur, ref, }: RichTextFloatingToolbarProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// The rich-text editor's floating toolbar: the formats for selected words
|
|
3
|
+
// (Bold, Italic, Strikethrough, Code · Link), over the selection. The
|
|
4
|
+
// fixed toolbar (rich-text-toolbar.tsx) stays; this one saves the trip up
|
|
5
|
+
// to it. The editor decides when it shows (milkdown-editor.tsx):
|
|
6
|
+
//
|
|
7
|
+
// - While words are selected in the text and the field has focus, once
|
|
8
|
+
// the mouse is up (not while a drag is still choosing them). Not in a
|
|
9
|
+
// code block (no formats apply there), read-only, or with the link box
|
|
10
|
+
// open.
|
|
11
|
+
// - Above the selection, or below it when there's no room for it inside
|
|
12
|
+
// the text (the first line), so it never covers the fixed toolbar.
|
|
13
|
+
// - A mouse press never takes focus from the text, as on the fixed one.
|
|
14
|
+
// - Keyboard: Alt+F10 in the text reaches it; the arrow keys, Home and
|
|
15
|
+
// End move along it (one tab stop, as on the fixed one); Esc or Tab goes
|
|
16
|
+
// back to the text, the selection kept. Esc (here or in the text) hides
|
|
17
|
+
// it until the selection changes.
|
|
18
|
+
//
|
|
19
|
+
// It sits inside the field's own element, placed against it, not in a
|
|
20
|
+
// portal: it scrolls with the text, is part of the field for editing in
|
|
21
|
+
// place and inside a dialog, and needs none of a popover's focus guards
|
|
22
|
+
// (Base UI's Popover adds tabbable `aria-hidden` guards while focus is
|
|
23
|
+
// outside it, which for this toolbar is always, and axe fails them).
|
|
24
|
+
import { useLayoutEffect, useState } from 'react';
|
|
25
|
+
import { cn } from 'cn';
|
|
26
|
+
import { FormatButtons } from './rich-text-toolbar.js';
|
|
27
|
+
// Its gap from the words.
|
|
28
|
+
const GAP = 8;
|
|
29
|
+
// The selection's box on screen: the words themselves on one line, the
|
|
30
|
+
// text's width over several.
|
|
31
|
+
function selectionRect(view, from, to) {
|
|
32
|
+
const size = view.state.doc.content.size;
|
|
33
|
+
const start = view.coordsAtPos(Math.min(from, size), 1);
|
|
34
|
+
const end = view.coordsAtPos(Math.min(to, size), -1);
|
|
35
|
+
const top = Math.min(start.top, end.top);
|
|
36
|
+
const bottom = Math.max(start.bottom, end.bottom);
|
|
37
|
+
if (end.top < start.bottom)
|
|
38
|
+
return new DOMRect(Math.min(start.left, end.left), top, Math.abs(end.left - start.left), bottom - top);
|
|
39
|
+
const text = view.dom.getBoundingClientRect();
|
|
40
|
+
return new DOMRect(text.left, top, text.width, bottom - top);
|
|
41
|
+
}
|
|
42
|
+
// Where it goes, against `field` (its positioned parent): centred over
|
|
43
|
+
// the words, below them when there's no room above inside the text, and
|
|
44
|
+
// never past the field's sides.
|
|
45
|
+
function place(view, from, to, field, bar) {
|
|
46
|
+
const words = selectionRect(view, from, to);
|
|
47
|
+
const box = field.getBoundingClientRect();
|
|
48
|
+
const text = view.dom.getBoundingClientRect();
|
|
49
|
+
const { offsetWidth: width, offsetHeight: height } = bar;
|
|
50
|
+
const above = words.top - height - GAP >= text.top;
|
|
51
|
+
const top = above ? words.top - height - GAP : words.bottom + GAP;
|
|
52
|
+
const centre = words.left + words.width / 2 - width / 2;
|
|
53
|
+
const left = Math.max(0, Math.min(centre - box.left, box.width - width));
|
|
54
|
+
return { top: top - box.top, left, side: above ? 'top' : 'bottom' };
|
|
55
|
+
}
|
|
56
|
+
export function RichTextFloatingToolbar({ view, state, field, controls, onFormat, onDismiss, onReturn, onBlur, ref, }) {
|
|
57
|
+
const [bar, setBar] = useState(null);
|
|
58
|
+
const [placement, setPlacement] = useState(null);
|
|
59
|
+
const { from, to } = state.selection;
|
|
60
|
+
// Placed after each change (a mark makes the words wider), and again
|
|
61
|
+
// when the window's width reflows the text.
|
|
62
|
+
useLayoutEffect(() => {
|
|
63
|
+
if (!bar || !field)
|
|
64
|
+
return;
|
|
65
|
+
const update = () => setPlacement(place(view, from, to, field, bar));
|
|
66
|
+
update();
|
|
67
|
+
window.addEventListener('resize', update);
|
|
68
|
+
return () => window.removeEventListener('resize', update);
|
|
69
|
+
}, [view, state, from, to, field, bar]);
|
|
70
|
+
const onKeyDown = (event) => {
|
|
71
|
+
if (event.key === 'Escape') {
|
|
72
|
+
// Its own: not the field's (editing in place would give up the edit).
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
event.stopPropagation();
|
|
75
|
+
onDismiss();
|
|
76
|
+
onReturn();
|
|
77
|
+
}
|
|
78
|
+
else if (event.key === 'Tab') {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
onReturn();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
return (_jsx("div", { ref: (element) => {
|
|
84
|
+
setBar(element);
|
|
85
|
+
if (typeof ref === 'function')
|
|
86
|
+
ref(element);
|
|
87
|
+
else if (ref)
|
|
88
|
+
ref.current = element;
|
|
89
|
+
}, role: "toolbar", "aria-label": "Format selection", "aria-controls": controls, "data-slot": "rich-text-floating-toolbar", "data-side": placement?.side, style: placement ? { top: placement.top, left: placement.left } : undefined, className: cn('absolute z-50 flex items-center gap-0.5 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10', 'animate-in fade-in-0 zoom-in-95 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2',
|
|
90
|
+
// Measured before it's seen, so it never shows in the wrong place.
|
|
91
|
+
!placement && 'invisible'), onKeyDown: onKeyDown, onBlur: onBlur, children: _jsx(FormatButtons, { formats: "selection", state: state, readOnly: false, onFormat: onFormat }) }));
|
|
92
|
+
}
|
|
@@ -1,5 +1,30 @@
|
|
|
1
|
+
import { type LucideIcon } from 'lucide-react';
|
|
2
|
+
import { type Ref } from 'react';
|
|
1
3
|
import type { EditorState } from '@milkdown/kit/prose/state';
|
|
2
4
|
import { type FormatId } from './rich-text-formats.js';
|
|
5
|
+
type FormatItem = {
|
|
6
|
+
id: FormatId;
|
|
7
|
+
label: string;
|
|
8
|
+
icon: LucideIcon;
|
|
9
|
+
/** Milkdown's own shortcut, as `aria-keyshortcuts` writes it with Mod for Ctrl/Cmd. */
|
|
10
|
+
keys?: string;
|
|
11
|
+
/** More for the tooltip than the shortcut. */
|
|
12
|
+
hint?: string;
|
|
13
|
+
};
|
|
14
|
+
declare const SETS: {
|
|
15
|
+
all: FormatItem[][];
|
|
16
|
+
selection: FormatItem[][];
|
|
17
|
+
};
|
|
18
|
+
export type FormatButtonsProps = {
|
|
19
|
+
/** Every format, or the ones for selected words. */
|
|
20
|
+
formats: keyof typeof SETS;
|
|
21
|
+
/** The editor's state now, to show which formats are on. Null until it's made. */
|
|
22
|
+
state: EditorState | null;
|
|
23
|
+
readOnly: boolean;
|
|
24
|
+
/** A button pressed: run the format on the editor and give it focus back. */
|
|
25
|
+
onFormat: (id: FormatId) => void;
|
|
26
|
+
};
|
|
27
|
+
export declare function FormatButtons({ formats, state, readOnly, onFormat }: FormatButtonsProps): import("react").JSX.Element;
|
|
3
28
|
export interface RichTextToolbarProps {
|
|
4
29
|
/** The editor's state now, to show which formats are on. Null until it's made. */
|
|
5
30
|
state: EditorState | null;
|
|
@@ -8,5 +33,8 @@ export interface RichTextToolbarProps {
|
|
|
8
33
|
controls?: string;
|
|
9
34
|
/** A button pressed: run the format on the editor and give it focus back. */
|
|
10
35
|
onFormat: (id: FormatId) => void;
|
|
36
|
+
/** The toolbar's element, for Alt+F10 to reach it from the text. */
|
|
37
|
+
ref?: Ref<HTMLDivElement>;
|
|
11
38
|
}
|
|
12
|
-
export declare function RichTextToolbar({ state, readOnly, controls, onFormat }: RichTextToolbarProps): import("react").JSX.Element;
|
|
39
|
+
export declare function RichTextToolbar({ state, readOnly, controls, onFormat, ref }: RichTextToolbarProps): import("react").JSX.Element;
|
|
40
|
+
export {};
|
|
@@ -10,6 +10,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
10
10
|
// (`aria-pressed`), and names its shortcut (`aria-keyshortcuts`, and the
|
|
11
11
|
// tooltip).
|
|
12
12
|
// - Read-only (while a save is in flight), every button is disabled.
|
|
13
|
+
// - Alt+F10 in the text reaches it (the floating toolbar first, when it's
|
|
14
|
+
// showing: rich-text-floating-toolbar.tsx).
|
|
13
15
|
import { BoldIcon, CodeIcon, Heading1Icon, Heading2Icon, ItalicIcon, LinkIcon, ListChecksIcon, ListIcon, ListOrderedIcon, SquareCodeIcon, StrikethroughIcon, TextQuoteIcon, } from 'lucide-react';
|
|
14
16
|
import { Fragment, useRef, useState } from 'react';
|
|
15
17
|
import { Separator } from '../ui/separator.js';
|
|
@@ -38,7 +40,6 @@ const GROUPS = [
|
|
|
38
40
|
],
|
|
39
41
|
[{ id: 'link', label: 'Link', icon: LinkIcon, keys: 'Mod+K' }],
|
|
40
42
|
];
|
|
41
|
-
const ITEMS = GROUPS.flat();
|
|
42
43
|
function isApple() {
|
|
43
44
|
return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
44
45
|
}
|
|
@@ -56,13 +57,24 @@ function shownKeys(keys, apple) {
|
|
|
56
57
|
.map((part) => symbols[part] ?? part)
|
|
57
58
|
.join('');
|
|
58
59
|
}
|
|
59
|
-
|
|
60
|
+
// Which buttons a toolbar has: every format (the fixed one), or the ones
|
|
61
|
+
// that apply to selected words (the floating one).
|
|
62
|
+
const SETS = {
|
|
63
|
+
all: GROUPS,
|
|
64
|
+
selection: [GROUPS[0], GROUPS[4]],
|
|
65
|
+
};
|
|
66
|
+
// A toolbar's row of buttons: one tab stop, the last one used (ARIA's
|
|
67
|
+
// toolbar pattern); the arrow keys, Home and End move along the row. The
|
|
68
|
+
// toolbar's element (`role="toolbar"`, its name) is the caller's.
|
|
69
|
+
export function FormatButtons({ formats, state, readOnly, onFormat }) {
|
|
60
70
|
const apple = isApple();
|
|
71
|
+
const groups = SETS[formats];
|
|
72
|
+
const items = groups.flat();
|
|
61
73
|
const buttons = useRef([]);
|
|
62
74
|
// The one button Tab reaches: the last one used.
|
|
63
75
|
const [current, setCurrent] = useState(0);
|
|
64
76
|
const move = (index) => {
|
|
65
|
-
const next = (index +
|
|
77
|
+
const next = (index + items.length) % items.length;
|
|
66
78
|
setCurrent(next);
|
|
67
79
|
buttons.current[next]?.focus();
|
|
68
80
|
};
|
|
@@ -71,7 +83,7 @@ export function RichTextToolbar({ state, readOnly, controls, onFormat }) {
|
|
|
71
83
|
ArrowRight: () => move(current + 1),
|
|
72
84
|
ArrowLeft: () => move(current - 1),
|
|
73
85
|
Home: () => move(0),
|
|
74
|
-
End: () => move(
|
|
86
|
+
End: () => move(items.length - 1),
|
|
75
87
|
};
|
|
76
88
|
const action = keys[event.key];
|
|
77
89
|
if (!action || event.metaKey || event.ctrlKey || event.altKey)
|
|
@@ -79,20 +91,23 @@ export function RichTextToolbar({ state, readOnly, controls, onFormat }) {
|
|
|
79
91
|
event.preventDefault();
|
|
80
92
|
action();
|
|
81
93
|
};
|
|
82
|
-
return (_jsx(TooltipProvider, { delay: 400, children:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
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) => {
|
|
95
|
+
const index = items.indexOf(item);
|
|
96
|
+
const Icon = item.icon;
|
|
97
|
+
const tip = [
|
|
98
|
+
item.label,
|
|
99
|
+
item.keys && `(${shownKeys(item.keys, apple)})`,
|
|
100
|
+
item.hint && `· ${item.hint.replace(/Mod\+Shift\+Enter/, shownKeys('Mod+Shift+Enter', apple))}`,
|
|
101
|
+
]
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.join(' ');
|
|
104
|
+
return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx(Toggle, { ref: (element) => {
|
|
105
|
+
buttons.current[index] = element;
|
|
106
|
+
}, 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",
|
|
107
|
+
// A mouse press keeps focus, and the selection, in the text.
|
|
108
|
+
onMouseDown: (event) => event.preventDefault(), onFocus: () => setCurrent(index), onKeyDown: onKeyDown, onPressedChange: () => onFormat(item.id) }), children: _jsx(Icon, {}) }), _jsx(TooltipContent, { children: tip })] }, item.id));
|
|
109
|
+
})] }, groupIndex))) }));
|
|
110
|
+
}
|
|
111
|
+
export function RichTextToolbar({ state, readOnly, controls, onFormat, ref }) {
|
|
112
|
+
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 }) }));
|
|
98
113
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.17.0",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|