@tristan2828/ui-foundation 3.16.1 → 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.
@@ -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); 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
+ 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), unless
26
- // `toolbar={false}`.
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(() => {
@@ -249,7 +267,12 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
249
267
  const changes = $prose(() => new Plugin({
250
268
  view: () => ({
251
269
  update: (view, previous) => {
270
+ if (cancelled)
271
+ return;
252
272
  setEditorState(view.state);
273
+ // Esc hides the floating toolbar for one selection only.
274
+ if (view.state.selection.empty)
275
+ setDismissed(null);
253
276
  if (!view.state.doc.eq(previous.doc))
254
277
  emit();
255
278
  },
@@ -308,7 +331,49 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
308
331
  : null;
309
332
  return doc ? ctx.get(serializerCtx)(doc) : slice.content.textBetween(0, slice.content.size, '\n\n');
310
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
+ },
311
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
+ }
312
377
  const mod = event.metaKey || event.ctrlKey;
313
378
  if (mod && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'k') {
314
379
  event.preventDefault();
@@ -360,12 +425,16 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
360
425
  }
361
426
  editorRef.current = editor;
362
427
  baseline = editor.action(getMarkdown());
363
- setEditorState(editor.action((ctx) => ctx.get(editorViewCtx).state));
428
+ const view = editor.action((ctx) => ctx.get(editorViewCtx));
429
+ setEditorView(view);
430
+ setEditorState(view.state);
364
431
  if (autoFocus)
365
432
  editor.action((ctx) => ctx.get(editorViewCtx).focus());
366
433
  });
367
434
  return () => {
368
435
  cancelled = true;
436
+ setEditorState(null);
437
+ setEditorView(null);
369
438
  const editor = editorRef.current;
370
439
  editorRef.current = null;
371
440
  if (editor)
@@ -399,20 +468,48 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
399
468
  view.focus();
400
469
  });
401
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();
402
489
  const closeLink = () => {
403
490
  setLinkBox(null);
404
491
  editorRef.current?.action((ctx) => ctx.get(editorViewCtx).focus());
405
492
  };
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',
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,
494
+ // Only once the editor is made: until then the editable element,
495
+ // and its id, aren't in the page (axe: aria-valid-attr-value).
496
+ controls: editorState ? editableId : undefined, onFormat: runFormat })), _jsx("div", { ref: rootRef, className: cn('min-h-24 px-2.5 py-2',
407
497
  // Read-only text is muted; the toolbar's buttons say so by being disabled.
408
498
  'has-[[aria-readonly=true]]:text-muted-foreground',
409
499
  // The same type roles <Markdown> uses, so text reads the same
410
500
  // 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',
501
+ '[&_.ProseMirror]:flex [&_.ProseMirror]:min-h-20 [&_.ProseMirror]:flex-col [&_.ProseMirror]:gap-3',
502
+ // ProseMirror's own required style (prosemirror-view's CSS): spaces
503
+ // as typed, so one at the end of a line isn't kept as &nbsp;.
504
+ '[&_.ProseMirror]:whitespace-pre-wrap [&_.ProseMirror]:break-words', '[&_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
505
  // Raw HTML from the text: shown as its characters, never as markup.
413
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',
414
507
  // 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
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
416
513
  ? `${(length - maxLength).toLocaleString()} characters over the limit of ${maxLength.toLocaleString()}`
417
514
  : `${length.toLocaleString()} of ${maxLength.toLocaleString()} characters` })), _jsx(Popover, { open: linkBox !== null, onOpenChange: (open) => !open && closeLink(), children: linkBox && (_jsx(LinkForm, { box: linkBox, onApply: (href) => {
418
515
  const editor = editorRef.current;
@@ -19,9 +19,10 @@ export type RichTextEditorProps = {
19
19
  */
20
20
  maxLength?: number;
21
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.
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
- export function RichTextToolbar({ state, readOnly, controls, onFormat }) {
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 + ITEMS.length) % ITEMS.length;
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(ITEMS.length - 1),
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: _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))) }) }));
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
  }
@@ -17,7 +17,11 @@
17
17
  // original's own source; only a changed or new block is written by the
18
18
  // editor (in the document's own bullet and rule marks, `markdownStyle`).
19
19
  // - Blocks the editor can't show (a link reference's definition) are kept
20
- // where they were.
20
+ // where they were. A block the editor shows its own way (a table it
21
+ // padded, marks it nested differently) and that `meaning` still misses is
22
+ // written as the editor shows it, as long as nothing in it is lost
23
+ // (`content`): no text, address or image. A block the editor drops part
24
+ // of (it doesn't show images) is kept as written, and the guard refuses.
21
25
  // - Guard: the result must mean exactly what the editor shows. If it
22
26
  // wouldn't (two blocks fusing, say), the merge throws instead of saving
23
27
  // something else; the draft stays on screen.
@@ -30,34 +34,103 @@ function parse(markdown) {
30
34
  }
31
35
  // Fields that are about how a node was written, not what it means.
32
36
  const STYLE_ONLY = new Set(['position', 'data', 'spread', 'label', 'referenceType']);
37
+ // Nodes whose children are inline text: compared as runs of marked text.
38
+ const INLINE_PARENTS = new Set(['paragraph', 'heading', 'tableCell']);
39
+ // A reference link (or image) as the link it stands for, when its
40
+ // definition is in the document.
41
+ function resolved(node, definitions) {
42
+ if (node.type !== 'linkReference' && node.type !== 'imageReference')
43
+ return node;
44
+ const definition = definitions.get(String(node.identifier));
45
+ if (!definition)
46
+ return node;
47
+ return {
48
+ type: node.type === 'linkReference' ? 'link' : 'image',
49
+ url: definition.url,
50
+ title: definition.title ?? null,
51
+ alt: node.alt,
52
+ children: node.children,
53
+ };
54
+ }
55
+ // The mark an inline node puts on its text, or undefined for text itself.
56
+ function markOf(node) {
57
+ if (node.type === 'strong' || node.type === 'emphasis' || node.type === 'delete')
58
+ return node.type;
59
+ if (node.type === 'link')
60
+ return `link ${JSON.stringify([node.url, node.title ?? null])}`;
61
+ return undefined;
62
+ }
63
+ // Inline content as what a reader sees: each piece of text (or code, an
64
+ // image, a break) with the set of marks on it, whichever way they were
65
+ // nested. `**[a](u)**` and `[**a**](u)` are the same bold link; a link with
66
+ // no text shows nothing.
67
+ function runs(nodes, definitions, marks = [], out = []) {
68
+ for (const child of nodes) {
69
+ const node = resolved(child, definitions);
70
+ const mark = markOf(node);
71
+ if (mark) {
72
+ runs(node.children ?? [], definitions, [...marks, mark], out);
73
+ continue;
74
+ }
75
+ const set = [...new Set(marks)].sort();
76
+ const last = out.at(-1);
77
+ if (node.type === 'text' && last?.type === 'text' && JSON.stringify(last.marks) === JSON.stringify(set)) {
78
+ last.value = String(last.value) + String(node.value);
79
+ }
80
+ else {
81
+ out.push({ ...meaning(node, definitions), marks: set });
82
+ }
83
+ }
84
+ return out;
85
+ }
33
86
  // A block's meaning as a comparable string: positions and source style
34
- // dropped, and a reference link resolved to the link it stands for.
87
+ // dropped, a reference link resolved to the link it stands for, inline
88
+ // marks compared as a set, and a table's rows all as wide as its widest
89
+ // (GFM allows short rows; the editor pads them with empty cells).
35
90
  function meaning(node, definitions) {
36
91
  if (Array.isArray(node))
37
92
  return node.map((child) => meaning(child, definitions));
38
93
  if (!node || typeof node !== 'object')
39
94
  return node;
40
- const md = node;
41
- if (md.type === 'linkReference' || md.type === 'imageReference') {
42
- const definition = definitions.get(String(md.identifier));
43
- if (definition) {
44
- return meaning({
45
- type: md.type === 'linkReference' ? 'link' : 'image',
46
- url: definition.url,
47
- title: definition.title ?? null,
48
- alt: md.alt,
49
- children: md.children,
50
- }, definitions);
51
- }
52
- }
95
+ const md = resolved(node, definitions);
53
96
  const out = {};
54
97
  for (const key of Object.keys(md).sort()) {
55
98
  if (STYLE_ONLY.has(key))
56
99
  continue;
57
- out[key] = meaning(md[key], definitions);
100
+ out[key] = key === 'children' && INLINE_PARENTS.has(md.type) ? runs(md.children ?? [], definitions) : meaning(md[key], definitions);
58
101
  }
59
102
  if ((md.type === 'link' || md.type === 'image') && out.title === undefined)
60
103
  out.title = null;
104
+ if (md.type === 'table') {
105
+ const rows = out.children;
106
+ const width = Math.max(0, ...rows.map((row) => row.children.length));
107
+ for (const row of rows) {
108
+ while (row.children.length < width)
109
+ row.children.push({ children: [], type: 'tableCell' });
110
+ }
111
+ const align = out.align ?? [];
112
+ out.align = Array.from({ length: width }, (_value, index) => align[index] ?? null);
113
+ }
114
+ return out;
115
+ }
116
+ // Everything in a block a reader could lose: its text (spacing aside),
117
+ // code, raw HTML, link and image addresses, image alt text, rules and
118
+ // task boxes. Not how any of it is marked or laid out.
119
+ function content(node, definitions) {
120
+ const md = resolved(node, definitions);
121
+ let out = '';
122
+ if (typeof md.value === 'string')
123
+ out += md.value.replace(/\s+/g, '');
124
+ if (md.type === 'link' || md.type === 'image')
125
+ out += `<${String(md.url)}>`;
126
+ if (md.type === 'image' || md.type === 'imageReference')
127
+ out += `![${String(md.alt ?? '')}]`;
128
+ if (md.type === 'thematicBreak')
129
+ out += '<hr>';
130
+ if (md.type === 'listItem' && typeof md.checked === 'boolean')
131
+ out += md.checked ? '[x]' : '[ ]';
132
+ for (const child of md.children ?? [])
133
+ out += content(child, definitions);
61
134
  return out;
62
135
  }
63
136
  function blocks(markdown) {
@@ -76,6 +149,7 @@ function blocks(markdown) {
76
149
  end,
77
150
  source: markdown.slice(start, end),
78
151
  key: JSON.stringify(meaning(child, definitions)),
152
+ content: content(child, definitions),
79
153
  };
80
154
  });
81
155
  }
@@ -104,6 +178,33 @@ function match(from, to) {
104
178
  }
105
179
  return pairs;
106
180
  }
181
+ // The original blocks the editor shows: each matched one, and each run of
182
+ // unmatched ones between two matches that the editor shows its own way,
183
+ // with nothing lost, as the unmatched blocks it has between the same two.
184
+ // Those are written as the editor has them; a definition never is.
185
+ function shownOwnWay(originals, loaded, loadedToOriginal) {
186
+ const shown = new Set(loadedToOriginal.values());
187
+ const anchors = [[-1, -1], ...[...loadedToOriginal].map(([to, from]) => [from, to]), [originals.length, loaded.length]];
188
+ for (let k = 1; k < anchors.length; k++) {
189
+ const [fromStart, toStart] = anchors[k - 1];
190
+ const [fromEnd, toEnd] = anchors[k];
191
+ const unmatched = originals
192
+ .map((_block, index) => index)
193
+ .slice(fromStart + 1, fromEnd)
194
+ .filter((index) => originals[index].type !== 'definition');
195
+ if (unmatched.length === 0)
196
+ continue;
197
+ const before = unmatched.map((index) => originals[index].content).join('');
198
+ const after = loaded
199
+ .slice(toStart + 1, toEnd)
200
+ .map((block) => block.content)
201
+ .join('');
202
+ if (before === after)
203
+ for (const index of unmatched)
204
+ shown.add(index);
205
+ }
206
+ return shown;
207
+ }
107
208
  export class MarkdownMergeError extends Error {
108
209
  constructor() {
109
210
  super("This text can't be saved without changing parts you didn't edit. Undo the last change, or edit it in the form.");
@@ -139,9 +240,9 @@ export function mergeMarkdown(original, baseline, current) {
139
240
  const after = sources.slice(index + 1).find((value) => value !== undefined) ?? originals.length;
140
241
  return { written: block, at: (before + after) / 2 };
141
242
  });
142
- // Blocks the editor never showed (definitions): after whatever came
143
- // before them in the original.
144
- const shown = new Set(loadedToOriginal.values());
243
+ // Blocks the editor never showed (definitions, and any block it dropped
244
+ // part of): after whatever came before them in the original.
245
+ const shown = shownOwnWay(originals, loaded, loadedToOriginal);
145
246
  originals.forEach((_block, index) => {
146
247
  if (shown.has(index))
147
248
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "3.16.1",
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": {