@tristan2828/ui-foundation 3.17.0 → 3.18.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/cell-patterns.md +5 -2
- package/dist/components/app/milkdown-editor.js +59 -1
- package/dist/components/app/rich-text-editor.d.ts +4 -2
- package/dist/components/app/rich-text-format-items.d.ts +12 -0
- package/dist/components/app/rich-text-format-items.js +27 -0
- package/dist/components/app/rich-text-slash-menu.d.ts +18 -0
- package/dist/components/app/rich-text-slash-menu.js +52 -0
- package/dist/components/app/rich-text-slash.d.ts +30 -0
- package/dist/components/app/rich-text-slash.js +111 -0
- package/dist/components/app/rich-text-toolbar.d.ts +1 -10
- package/dist/components/app/rich-text-toolbar.js +3 -25
- package/package.json +1 -1
|
@@ -746,8 +746,11 @@ const price = editInPlace({
|
|
|
746
746
|
words (bold, italic, strikethrough, code, link); Alt+F10 reaches the
|
|
747
747
|
floating one, else the fixed one. Both are part of the field, so moving
|
|
748
748
|
to them doesn't save, and Esc hides the floating one before it gives up
|
|
749
|
-
the edit.
|
|
750
|
-
|
|
749
|
+
the edit. "/" typed at a line's start lists the blocks the line can
|
|
750
|
+
become (headings, lists, quote, code block); typing filters, Enter or
|
|
751
|
+
Tab picks, and Esc closes the list before it gives up the edit.
|
|
752
|
+
`toolbar={false}` leaves out the toolbars and the slash menu where a
|
|
753
|
+
value is short and the shortcuts are enough.
|
|
751
754
|
- **The save is the single-field save** (`useRecordUpdate` with
|
|
752
755
|
`optimistic: false, toastOnError: false`, the entity's
|
|
753
756
|
`useEdit<Entity>Field`), `mutateAsync` of one field's PATCH.
|
|
@@ -26,6 +26,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
26
26
|
// floating one over selected words (rich-text-floating-toolbar.tsx),
|
|
27
27
|
// unless `toolbar={false}`. Alt+F10 reaches the floating one when it
|
|
28
28
|
// shows, else the fixed one.
|
|
29
|
+
// - A slash menu (rich-text-slash.ts, rich-text-slash-menu.tsx): "/" at a
|
|
30
|
+
// line's start lists the blocks it can become, also unless
|
|
31
|
+
// `toolbar={false}`.
|
|
29
32
|
import { useEffect, useId, useRef, useState } from 'react';
|
|
30
33
|
import { defaultValueCtx, Editor, editorViewCtx, editorViewOptionsCtx, parserCtx, remarkStringifyOptionsCtx, rootCtx, schemaCtx, serializerCtx, } from '@milkdown/kit/core';
|
|
31
34
|
import { history } from '@milkdown/kit/plugin/history';
|
|
@@ -44,6 +47,8 @@ import { linkHref } from '../../lib/link-href.js';
|
|
|
44
47
|
import { MarkdownMergeError, markdownStyle, mergeMarkdown } from '../../lib/markdown-merge.js';
|
|
45
48
|
import { RichTextFloatingToolbar } from './rich-text-floating-toolbar.js';
|
|
46
49
|
import { toggleFormat } from './rich-text-formats.js';
|
|
50
|
+
import { applySlash, closeSlash, slashItems, slashOptionId, slashPlugin, slashQuery, } from './rich-text-slash.js';
|
|
51
|
+
import { RichTextSlashMenu } from './rich-text-slash-menu.js';
|
|
47
52
|
import { RichTextToolbar } from './rich-text-toolbar.js';
|
|
48
53
|
// The commonmark preset, minus the plugin that writes `<br />`. A $remark
|
|
49
54
|
// plugin is a pair (its options and the plugin), spread into `plugins`.
|
|
@@ -205,6 +210,11 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
205
210
|
const [field, setField] = useState(null);
|
|
206
211
|
// Whether the floating toolbar shows, for the editor's keys (set up once).
|
|
207
212
|
const floatingOpenRef = useRef(false);
|
|
213
|
+
// The slash menu's highlighted item, for the query it was moved on (a
|
|
214
|
+
// new query starts again from the first).
|
|
215
|
+
const [slashActive, setSlashActive] = useState({ key: '', index: 0 });
|
|
216
|
+
// The open slash menu, for the editor's keys (set up once).
|
|
217
|
+
const slashMenuRef = useRef(null);
|
|
208
218
|
// Opens the link box: made with the editor, used by the toolbar's Link.
|
|
209
219
|
const openLinkRef = useRef(() => { });
|
|
210
220
|
// The latest props, for the editor's callbacks, which are set up once.
|
|
@@ -238,6 +248,21 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
238
248
|
attributes['aria-placeholder'] = placeholder;
|
|
239
249
|
if (toolbar)
|
|
240
250
|
attributes['aria-keyshortcuts'] = 'Alt+F10';
|
|
251
|
+
// The slash menu: open while a "/" typed at a line's start is followed
|
|
252
|
+
// by something some block answers to, and the field has focus.
|
|
253
|
+
const slash = toolbar && !readOnly && focused && editorState ? slashQuery(editorState) : null;
|
|
254
|
+
const slashMatches = slash ? slashItems(slash.query) : [];
|
|
255
|
+
const slashOpen = slash !== null && slashMatches.length > 0;
|
|
256
|
+
const slashMenuKey = slash ? `${slash.from}:${slash.query}` : '';
|
|
257
|
+
const slashIndex = slashActive.key === slashMenuKey ? Math.min(slashActive.index, slashMatches.length - 1) : 0;
|
|
258
|
+
const slashListId = `${ids}-blocks`;
|
|
259
|
+
// The text is the combobox's input: it names the list and the
|
|
260
|
+
// highlighted item, and focus stays in it.
|
|
261
|
+
if (slashOpen) {
|
|
262
|
+
attributes['aria-autocomplete'] = 'list';
|
|
263
|
+
attributes['aria-controls'] = slashListId;
|
|
264
|
+
attributes['aria-activedescendant'] = slashOptionId(slashListId, slashIndex);
|
|
265
|
+
}
|
|
241
266
|
const attributesKey = JSON.stringify(attributes);
|
|
242
267
|
// The editor is made once, from the Markdown it opened with.
|
|
243
268
|
useEffect(() => {
|
|
@@ -297,6 +322,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
297
322
|
};
|
|
298
323
|
openLinkRef.current = openLink;
|
|
299
324
|
// Paste, copy, the editor's own keys and the placeholder.
|
|
325
|
+
const slashing = $prose(() => slashPlugin());
|
|
300
326
|
const input = $prose((ctx) => new Plugin({
|
|
301
327
|
props: {
|
|
302
328
|
// Plain text is read as Markdown, so `## Notes` or `**bold**`
|
|
@@ -407,6 +433,34 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
407
433
|
...options,
|
|
408
434
|
editable: () => !latest.current.readOnly,
|
|
409
435
|
attributes,
|
|
436
|
+
// The slash menu's keys, as view props: before Milkdown's own
|
|
437
|
+
// keymap, whose Enter would split the line.
|
|
438
|
+
handleKeyDown: (view, event) => {
|
|
439
|
+
const menu = slashMenuRef.current;
|
|
440
|
+
if (!menu)
|
|
441
|
+
return false;
|
|
442
|
+
const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
|
|
443
|
+
const plain = !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey;
|
|
444
|
+
if (step && plain) {
|
|
445
|
+
event.preventDefault();
|
|
446
|
+
const count = menu.items.length;
|
|
447
|
+
setSlashActive({ key: menu.key, index: (menu.index + step + count) % count });
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
if ((event.key === 'Enter' || event.key === 'Tab') && plain) {
|
|
451
|
+
event.preventDefault();
|
|
452
|
+
applySlash(view, menu.span, menu.items[menu.index].id);
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
// Esc closes the menu only: the field's own Esc (editing in
|
|
456
|
+
// place gives up the edit) waits for the next one.
|
|
457
|
+
if (event.key === 'Escape') {
|
|
458
|
+
event.preventDefault();
|
|
459
|
+
view.dispatch(closeSlash(view.state));
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
return false;
|
|
463
|
+
},
|
|
410
464
|
nodeViews: {
|
|
411
465
|
heading: headingView,
|
|
412
466
|
list_item: (node, view, getPos) => node.attrs.checked == null ? undefined : taskItemView(node, view, getPos),
|
|
@@ -417,6 +471,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
417
471
|
.use(gfm)
|
|
418
472
|
.use(history)
|
|
419
473
|
.use(changes)
|
|
474
|
+
.use(slashing)
|
|
420
475
|
.use(input);
|
|
421
476
|
void make.create().then((editor) => {
|
|
422
477
|
if (cancelled) {
|
|
@@ -486,6 +541,9 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
486
541
|
floatingOpenRef.current = floatingOpen;
|
|
487
542
|
});
|
|
488
543
|
const returnToText = () => editorView?.focus();
|
|
544
|
+
useEffect(() => {
|
|
545
|
+
slashMenuRef.current = slashOpen && slash ? { key: slashMenuKey, span: slash, items: slashMatches, index: slashIndex } : null;
|
|
546
|
+
});
|
|
489
547
|
const closeLink = () => {
|
|
490
548
|
setLinkBox(null);
|
|
491
549
|
editorRef.current?.action((ctx) => ctx.get(editorViewCtx).focus());
|
|
@@ -505,7 +563,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
505
563
|
// Raw HTML from the text: shown as its characters, never as markup.
|
|
506
564
|
'[&_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',
|
|
507
565
|
// The placeholder, in an empty document only (a decoration).
|
|
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) => {
|
|
566
|
+
'[&_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)]') }), slashOpen && slash && editorView && (_jsx(RichTextSlashMenu, { id: slashListId, view: editorView, at: slash.to, field: field, items: slashMatches, active: slashIndex, onHighlight: (index) => setSlashActive({ key: slashMenuKey, index }), onPick: (format) => applySlash(editorView, slash, format) })), 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
567
|
const next = event.relatedTarget;
|
|
510
568
|
if (!editorView.dom.contains(next) && !floatingRef.current?.contains(next))
|
|
511
569
|
setFocused(false);
|
|
@@ -21,8 +21,10 @@ export type RichTextEditorProps = {
|
|
|
21
21
|
/**
|
|
22
22
|
* The formatting toolbars: one fixed above the text (bold, headings,
|
|
23
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.
|
|
25
|
-
*
|
|
24
|
+
* selected words (bold to link). Alt+F10 reaches them from the text.
|
|
25
|
+
* With them, "/" at a line's start lists the blocks the line can become
|
|
26
|
+
* (the slash menu). On by default; `false` leaves the text alone, its
|
|
27
|
+
* shortcuts still working.
|
|
26
28
|
*/
|
|
27
29
|
toolbar?: boolean;
|
|
28
30
|
className?: string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type LucideIcon } from 'lucide-react';
|
|
2
|
+
import type { FormatId } from './rich-text-formats.js';
|
|
3
|
+
export type FormatItem = {
|
|
4
|
+
id: FormatId;
|
|
5
|
+
label: string;
|
|
6
|
+
icon: LucideIcon;
|
|
7
|
+
/** Milkdown's own shortcut, as `aria-keyshortcuts` writes it with Mod for Ctrl/Cmd. */
|
|
8
|
+
keys?: string;
|
|
9
|
+
/** More for the tooltip than the shortcut. */
|
|
10
|
+
hint?: string;
|
|
11
|
+
};
|
|
12
|
+
export declare const FORMAT_GROUPS: FormatItem[][];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The rich-text editor's formats as people see them: each one's name, icon
|
|
2
|
+
// and shortcut, in the groups the fixed toolbar shows. Shared by the
|
|
3
|
+
// toolbars (rich-text-toolbar.tsx) and the slash menu
|
|
4
|
+
// (rich-text-slash-menu.tsx).
|
|
5
|
+
import { BoldIcon, CodeIcon, Heading1Icon, Heading2Icon, ItalicIcon, LinkIcon, ListChecksIcon, ListIcon, ListOrderedIcon, SquareCodeIcon, StrikethroughIcon, TextQuoteIcon, } from 'lucide-react';
|
|
6
|
+
export const FORMAT_GROUPS = [
|
|
7
|
+
[
|
|
8
|
+
{ id: 'bold', label: 'Bold', icon: BoldIcon, keys: 'Mod+B' },
|
|
9
|
+
{ id: 'italic', label: 'Italic', icon: ItalicIcon, keys: 'Mod+I' },
|
|
10
|
+
{ id: 'strike', label: 'Strikethrough', icon: StrikethroughIcon, keys: 'Mod+Alt+X' },
|
|
11
|
+
{ id: 'code', label: 'Code', icon: CodeIcon, keys: 'Mod+E' },
|
|
12
|
+
],
|
|
13
|
+
[
|
|
14
|
+
{ id: 'heading', label: 'Heading', icon: Heading1Icon, keys: 'Mod+Alt+1' },
|
|
15
|
+
{ id: 'subheading', label: 'Subheading', icon: Heading2Icon, keys: 'Mod+Alt+2' },
|
|
16
|
+
],
|
|
17
|
+
[
|
|
18
|
+
{ id: 'bullets', label: 'Bulleted list', icon: ListIcon, keys: 'Mod+Alt+8' },
|
|
19
|
+
{ id: 'numbers', label: 'Numbered list', icon: ListOrderedIcon, keys: 'Mod+Alt+7' },
|
|
20
|
+
{ id: 'tasks', label: 'Task list', icon: ListChecksIcon, hint: 'tick an item: Mod+Shift+Enter' },
|
|
21
|
+
],
|
|
22
|
+
[
|
|
23
|
+
{ id: 'quote', label: 'Quote', icon: TextQuoteIcon, keys: 'Mod+Shift+B' },
|
|
24
|
+
{ id: 'codeBlock', label: 'Code block', icon: SquareCodeIcon, keys: 'Mod+Alt+C' },
|
|
25
|
+
],
|
|
26
|
+
[{ id: 'link', label: 'Link', icon: LinkIcon, keys: 'Mod+K' }],
|
|
27
|
+
];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { EditorView } from '@milkdown/kit/prose/view';
|
|
2
|
+
import type { FormatItem } from './rich-text-format-items.js';
|
|
3
|
+
import type { FormatId } from './rich-text-formats.js';
|
|
4
|
+
export interface RichTextSlashMenuProps {
|
|
5
|
+
/** The list's id, for the text's `aria-controls`. */
|
|
6
|
+
id: string;
|
|
7
|
+
view: EditorView;
|
|
8
|
+
/** The caret: where it's placed. */
|
|
9
|
+
at: number;
|
|
10
|
+
/** The field's element it's placed against (positioned: `relative`). */
|
|
11
|
+
field: HTMLElement | null;
|
|
12
|
+
items: FormatItem[];
|
|
13
|
+
/** The highlighted item: Enter picks it. */
|
|
14
|
+
active: number;
|
|
15
|
+
onHighlight: (index: number) => void;
|
|
16
|
+
onPick: (id: FormatId) => void;
|
|
17
|
+
}
|
|
18
|
+
export declare function RichTextSlashMenu({ id, view, at, field, items, active, onHighlight, onPick }: RichTextSlashMenuProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The rich-text editor's slash menu: the blocks a paragraph can become
|
|
3
|
+
// (Heading to Code block), listed under the caret after a "/" typed at a
|
|
4
|
+
// line's start (rich-text-slash.ts). ARIA's combobox pattern with the
|
|
5
|
+
// text as the input: focus never leaves the text. The text names this
|
|
6
|
+
// list (`aria-controls`) and the highlighted item
|
|
7
|
+
// (`aria-activedescendant`); the arrow keys move the highlight, Enter or
|
|
8
|
+
// Tab picks, Esc closes it and leaves what was typed (milkdown-editor.tsx).
|
|
9
|
+
//
|
|
10
|
+
// Placed against the field's element like the floating toolbar, not in a
|
|
11
|
+
// portal (rich-text-floating-toolbar.tsx says why): under the caret, or
|
|
12
|
+
// over it when the window has no room below.
|
|
13
|
+
import { useLayoutEffect, useState } from 'react';
|
|
14
|
+
import { cn } from 'cn';
|
|
15
|
+
import { slashOptionId } from './rich-text-slash.js';
|
|
16
|
+
// Its gap from the caret's line.
|
|
17
|
+
const GAP = 4;
|
|
18
|
+
function place(view, at, field, menu) {
|
|
19
|
+
const caret = view.coordsAtPos(at);
|
|
20
|
+
const box = field.getBoundingClientRect();
|
|
21
|
+
const { offsetWidth: width, offsetHeight: height } = menu;
|
|
22
|
+
const below = caret.bottom + GAP + height <= window.innerHeight || caret.top - GAP - height < 0;
|
|
23
|
+
const top = below ? caret.bottom + GAP : caret.top - GAP - height;
|
|
24
|
+
const left = Math.max(0, Math.min(caret.left - box.left, box.width - width));
|
|
25
|
+
return { top: top - box.top, left, side: below ? 'bottom' : 'top' };
|
|
26
|
+
}
|
|
27
|
+
export function RichTextSlashMenu({ id, view, at, field, items, active, onHighlight, onPick }) {
|
|
28
|
+
const [menu, setMenu] = useState(null);
|
|
29
|
+
const [placement, setPlacement] = useState(null);
|
|
30
|
+
// Placed as the caret moves and the list narrows, and again when the
|
|
31
|
+
// window's size changes.
|
|
32
|
+
useLayoutEffect(() => {
|
|
33
|
+
if (!menu || !field)
|
|
34
|
+
return;
|
|
35
|
+
const update = () => setPlacement(place(view, at, field, menu));
|
|
36
|
+
update();
|
|
37
|
+
window.addEventListener('resize', update);
|
|
38
|
+
return () => window.removeEventListener('resize', update);
|
|
39
|
+
}, [view, at, field, menu, items.length]);
|
|
40
|
+
// The highlighted item stays in sight.
|
|
41
|
+
useLayoutEffect(() => {
|
|
42
|
+
menu?.querySelector(`#${CSS.escape(slashOptionId(id, active))}`)?.scrollIntoView({ block: 'nearest' });
|
|
43
|
+
}, [menu, id, active]);
|
|
44
|
+
return (_jsx("div", { ref: setMenu, id: id, role: "listbox", "aria-label": "Blocks", "data-slot": "rich-text-slash-menu", "data-side": placement?.side, style: placement ? { top: placement.top, left: placement.left } : undefined, className: cn('absolute z-50 flex max-h-72 w-56 flex-col gap-0.5 overflow-y-auto rounded-lg bg-popover p-1 text-sm 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',
|
|
45
|
+
// Measured before it's seen, so it never shows in the wrong place.
|
|
46
|
+
!placement && 'invisible'), children: items.map((item, index) => {
|
|
47
|
+
const Icon = item.icon;
|
|
48
|
+
return (_jsxs("div", { id: slashOptionId(id, index), role: "option", "aria-selected": index === active, className: "flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 select-none aria-selected:bg-muted",
|
|
49
|
+
// A press never takes focus from the text.
|
|
50
|
+
onMouseDown: (event) => event.preventDefault(), onMouseMove: () => index !== active && onHighlight(index), onClick: () => onPick(item.id), children: [_jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0" }), item.label] }, item.id));
|
|
51
|
+
}) }));
|
|
52
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Plugin, type EditorState, type Transaction } from '@milkdown/kit/prose/state';
|
|
2
|
+
import type { EditorView } from '@milkdown/kit/prose/view';
|
|
3
|
+
import { type FormatItem } from './rich-text-format-items.js';
|
|
4
|
+
import { type FormatId } from './rich-text-formats.js';
|
|
5
|
+
/** Where the "/" that opened the menu is, while it's open. */
|
|
6
|
+
type SlashState = {
|
|
7
|
+
from: number;
|
|
8
|
+
} | null;
|
|
9
|
+
/** The open menu's span in the text ("/" to the caret) and what's typed after the "/". */
|
|
10
|
+
export type SlashQuery = {
|
|
11
|
+
from: number;
|
|
12
|
+
to: number;
|
|
13
|
+
query: string;
|
|
14
|
+
};
|
|
15
|
+
/** The open menu's span and query, or null when it's closed. */
|
|
16
|
+
export declare function slashQuery(state: EditorState): SlashQuery | null;
|
|
17
|
+
/** The transaction that closes the menu, leaving what was typed as text (Esc). */
|
|
18
|
+
export declare function closeSlash(state: EditorState): Transaction;
|
|
19
|
+
/** The id of the menu's item at `index`, for the text's `aria-activedescendant`. */
|
|
20
|
+
export declare function slashOptionId(listId: string, index: number): string;
|
|
21
|
+
/** The plugin: opens on a typed "/", follows the text, closes once the span stops qualifying. */
|
|
22
|
+
export declare function slashPlugin(): Plugin<SlashState>;
|
|
23
|
+
/** The blocks matching what's typed after the "/": in the name, or the start of a keyword. */
|
|
24
|
+
export declare function slashItems(query: string): FormatItem[];
|
|
25
|
+
/**
|
|
26
|
+
* Picks an item: the "/…" goes and the paragraph becomes that block, in one
|
|
27
|
+
* transaction (one undo step, which brings the "/…" back).
|
|
28
|
+
*/
|
|
29
|
+
export declare function applySlash(view: EditorView, span: SlashQuery, id: FormatId): void;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// The slash menu's logic: "/" typed at the start of a top-level paragraph
|
|
2
|
+
// opens it, what's typed after the "/" filters it, and picking an item
|
|
3
|
+
// turns the paragraph into that block, the "/…" gone. The menu itself:
|
|
4
|
+
// rich-text-slash-menu.tsx; its keys and ARIA: milkdown-editor.tsx.
|
|
5
|
+
//
|
|
6
|
+
// Only a "/" typed there opens it: one already in the text (a path,
|
|
7
|
+
// `/usr/bin`) never does, and neither does a "/" mid-line or inside a
|
|
8
|
+
// list, a quote or a code block.
|
|
9
|
+
import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
|
|
10
|
+
import { FORMAT_GROUPS } from './rich-text-format-items.js';
|
|
11
|
+
import { toggleFormat } from './rich-text-formats.js';
|
|
12
|
+
const slashKey = new PluginKey('rich-text-slash');
|
|
13
|
+
// Longer than any item's name: past it, the person is writing, not picking.
|
|
14
|
+
const MAX_QUERY = 24;
|
|
15
|
+
// A top-level paragraph's start: where a "/" opens the menu.
|
|
16
|
+
function atParagraphStart(state, pos) {
|
|
17
|
+
const $pos = state.doc.resolve(pos);
|
|
18
|
+
return $pos.depth === 1 && $pos.parent.type.name === 'paragraph' && $pos.parentOffset === 0;
|
|
19
|
+
}
|
|
20
|
+
// The menu's span, if the "/" at `from` still opens it: the caret after it
|
|
21
|
+
// in the same paragraph, nothing selected, and what follows the "/" plain
|
|
22
|
+
// text that doesn't start with a space.
|
|
23
|
+
function queryAt(state, from) {
|
|
24
|
+
const { selection, doc } = state;
|
|
25
|
+
if (!selection.empty || from >= doc.content.size || !atParagraphStart(state, from))
|
|
26
|
+
return null;
|
|
27
|
+
const to = selection.from;
|
|
28
|
+
if (to <= from || !doc.resolve(from).sameParent(selection.$from))
|
|
29
|
+
return null;
|
|
30
|
+
const text = doc.textBetween(from, to, '\n', '\n');
|
|
31
|
+
if (!text.startsWith('/'))
|
|
32
|
+
return null;
|
|
33
|
+
const query = text.slice(1);
|
|
34
|
+
if (query.length > MAX_QUERY || /^\s|\n/.test(query))
|
|
35
|
+
return null;
|
|
36
|
+
return { from, to, query };
|
|
37
|
+
}
|
|
38
|
+
/** The open menu's span and query, or null when it's closed. */
|
|
39
|
+
export function slashQuery(state) {
|
|
40
|
+
const open = slashKey.getState(state);
|
|
41
|
+
return open ? queryAt(state, open.from) : null;
|
|
42
|
+
}
|
|
43
|
+
/** The transaction that closes the menu, leaving what was typed as text (Esc). */
|
|
44
|
+
export function closeSlash(state) {
|
|
45
|
+
return state.tr.setMeta(slashKey, 'close');
|
|
46
|
+
}
|
|
47
|
+
/** The id of the menu's item at `index`, for the text's `aria-activedescendant`. */
|
|
48
|
+
export function slashOptionId(listId, index) {
|
|
49
|
+
return `${listId}-${index}`;
|
|
50
|
+
}
|
|
51
|
+
/** The plugin: opens on a typed "/", follows the text, closes once the span stops qualifying. */
|
|
52
|
+
export function slashPlugin() {
|
|
53
|
+
return new Plugin({
|
|
54
|
+
key: slashKey,
|
|
55
|
+
state: {
|
|
56
|
+
init: () => null,
|
|
57
|
+
apply: (tr, value, _previous, next) => {
|
|
58
|
+
const meta = tr.getMeta(slashKey);
|
|
59
|
+
if (meta === 'close')
|
|
60
|
+
return null;
|
|
61
|
+
const from = meta ? meta.from : value ? tr.mapping.map(value.from) : null;
|
|
62
|
+
// Closed for good once it stops qualifying: the caret coming back
|
|
63
|
+
// later doesn't reopen it.
|
|
64
|
+
return from !== null && queryAt(next, from) ? { from } : null;
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
props: {
|
|
68
|
+
handleTextInput: (view, from, to, text) => {
|
|
69
|
+
if (text !== '/' || from !== to || !atParagraphStart(view.state, from))
|
|
70
|
+
return false;
|
|
71
|
+
view.dispatch(view.state.tr.insertText('/', from, to).setMeta(slashKey, { from }));
|
|
72
|
+
return true;
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// What each block answers to besides its name.
|
|
78
|
+
const KEYWORDS = {
|
|
79
|
+
heading: ['h1', 'title'],
|
|
80
|
+
subheading: ['h2'],
|
|
81
|
+
bullets: ['ul', 'unordered', 'bullet'],
|
|
82
|
+
numbers: ['ol', 'ordered', 'number'],
|
|
83
|
+
tasks: ['todo', 'checkbox', 'checklist'],
|
|
84
|
+
quote: ['blockquote'],
|
|
85
|
+
codeBlock: ['pre'],
|
|
86
|
+
};
|
|
87
|
+
// The menu's items: the blocks, in the toolbar's order.
|
|
88
|
+
const BLOCKS = FORMAT_GROUPS.flat().filter((item) => KEYWORDS[item.id]);
|
|
89
|
+
/** The blocks matching what's typed after the "/": in the name, or the start of a keyword. */
|
|
90
|
+
export function slashItems(query) {
|
|
91
|
+
const typed = query.trim().toLowerCase();
|
|
92
|
+
if (!typed)
|
|
93
|
+
return BLOCKS;
|
|
94
|
+
return BLOCKS.filter((item) => item.label.toLowerCase().includes(typed) || KEYWORDS[item.id].some((word) => word.startsWith(typed)));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Picks an item: the "/…" goes and the paragraph becomes that block, in one
|
|
98
|
+
* transaction (one undo step, which brings the "/…" back).
|
|
99
|
+
*/
|
|
100
|
+
export function applySlash(view, span, id) {
|
|
101
|
+
const tr = view.state.tr.delete(span.from, span.to).setMeta(slashKey, 'close');
|
|
102
|
+
let formatted = null;
|
|
103
|
+
toggleFormat(view.state.apply(tr), (next) => (formatted = next), id);
|
|
104
|
+
const done = formatted;
|
|
105
|
+
if (done) {
|
|
106
|
+
for (const step of done.steps)
|
|
107
|
+
tr.step(step);
|
|
108
|
+
tr.setSelection(done.selection.getBookmark().resolve(tr.doc));
|
|
109
|
+
}
|
|
110
|
+
view.dispatch(tr.scrollIntoView());
|
|
111
|
+
}
|
|
@@ -1,16 +1,7 @@
|
|
|
1
|
-
import { type LucideIcon } from 'lucide-react';
|
|
2
1
|
import { type Ref } from 'react';
|
|
3
2
|
import type { EditorState } from '@milkdown/kit/prose/state';
|
|
3
|
+
import { type FormatItem } from './rich-text-format-items.js';
|
|
4
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
5
|
declare const SETS: {
|
|
15
6
|
all: FormatItem[][];
|
|
16
7
|
selection: FormatItem[][];
|
|
@@ -12,34 +12,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
12
12
|
// - Read-only (while a save is in flight), every button is disabled.
|
|
13
13
|
// - Alt+F10 in the text reaches it (the floating toolbar first, when it's
|
|
14
14
|
// showing: rich-text-floating-toolbar.tsx).
|
|
15
|
-
import { BoldIcon, CodeIcon, Heading1Icon, Heading2Icon, ItalicIcon, LinkIcon, ListChecksIcon, ListIcon, ListOrderedIcon, SquareCodeIcon, StrikethroughIcon, TextQuoteIcon, } from 'lucide-react';
|
|
16
15
|
import { Fragment, useRef, useState } from 'react';
|
|
17
16
|
import { Separator } from '../ui/separator.js';
|
|
18
17
|
import { Toggle } from '../ui/toggle.js';
|
|
19
18
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip.js';
|
|
19
|
+
import { FORMAT_GROUPS } from './rich-text-format-items.js';
|
|
20
20
|
import { formatOn } from './rich-text-formats.js';
|
|
21
|
-
const GROUPS = [
|
|
22
|
-
[
|
|
23
|
-
{ id: 'bold', label: 'Bold', icon: BoldIcon, keys: 'Mod+B' },
|
|
24
|
-
{ id: 'italic', label: 'Italic', icon: ItalicIcon, keys: 'Mod+I' },
|
|
25
|
-
{ id: 'strike', label: 'Strikethrough', icon: StrikethroughIcon, keys: 'Mod+Alt+X' },
|
|
26
|
-
{ id: 'code', label: 'Code', icon: CodeIcon, keys: 'Mod+E' },
|
|
27
|
-
],
|
|
28
|
-
[
|
|
29
|
-
{ id: 'heading', label: 'Heading', icon: Heading1Icon, keys: 'Mod+Alt+1' },
|
|
30
|
-
{ id: 'subheading', label: 'Subheading', icon: Heading2Icon, keys: 'Mod+Alt+2' },
|
|
31
|
-
],
|
|
32
|
-
[
|
|
33
|
-
{ id: 'bullets', label: 'Bulleted list', icon: ListIcon, keys: 'Mod+Alt+8' },
|
|
34
|
-
{ id: 'numbers', label: 'Numbered list', icon: ListOrderedIcon, keys: 'Mod+Alt+7' },
|
|
35
|
-
{ id: 'tasks', label: 'Task list', icon: ListChecksIcon, hint: 'tick an item: Mod+Shift+Enter' },
|
|
36
|
-
],
|
|
37
|
-
[
|
|
38
|
-
{ id: 'quote', label: 'Quote', icon: TextQuoteIcon, keys: 'Mod+Shift+B' },
|
|
39
|
-
{ id: 'codeBlock', label: 'Code block', icon: SquareCodeIcon, keys: 'Mod+Alt+C' },
|
|
40
|
-
],
|
|
41
|
-
[{ id: 'link', label: 'Link', icon: LinkIcon, keys: 'Mod+K' }],
|
|
42
|
-
];
|
|
43
21
|
function isApple() {
|
|
44
22
|
return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
45
23
|
}
|
|
@@ -60,8 +38,8 @@ function shownKeys(keys, apple) {
|
|
|
60
38
|
// Which buttons a toolbar has: every format (the fixed one), or the ones
|
|
61
39
|
// that apply to selected words (the floating one).
|
|
62
40
|
const SETS = {
|
|
63
|
-
all:
|
|
64
|
-
selection: [
|
|
41
|
+
all: FORMAT_GROUPS,
|
|
42
|
+
selection: [FORMAT_GROUPS[0], FORMAT_GROUPS[4]],
|
|
65
43
|
};
|
|
66
44
|
// A toolbar's row of buttons: one tab stop, the last one used (ARIA's
|
|
67
45
|
// toolbar pattern); the arrow keys, Home and End move along the row. The
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.18.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": {
|