@tristan2828/ui-foundation 4.47.2 → 4.49.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.
@@ -26,15 +26,15 @@
26
26
  - [A variant: a pressed icon inside another value's cell](#a-variant-a-pressed-icon-inside-another-values-cell) · lines 565–648
27
27
  - [16. Title linking to the record's view](#16-title-linking-to-the-records-view) · lines 650–685
28
28
  - [17. Quick action on the view](#17-quick-action-on-the-view) · lines 687–760
29
- - [18. Editable value](#18-editable-value) · lines 762–1208
30
- - [A list of sub-items](#a-list-of-sub-items) · lines 1024–1109
31
- - [In a table's rows](#in-a-tables-rows) · lines 1111–1196
32
- - [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines 1198–1208
33
- - [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines 1210–1276
34
- - [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines 1278–1342
35
- - [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines 1344–1412
36
- - [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines 1414–1442
37
- - [23. A Group menu, and a record read beside the list](#23-a-group-menu-and-a-record-read-beside-the-list) · lines 1444–1518
29
+ - [18. Editable value](#18-editable-value) · lines 762–1216
30
+ - [A list of sub-items](#a-list-of-sub-items) · lines 1032–1117
31
+ - [In a table's rows](#in-a-tables-rows) · lines 1119–1204
32
+ - [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines 1206–1216
33
+ - [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines 1218–1284
34
+ - [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines 1286–1350
35
+ - [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines 1352–1420
36
+ - [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines 1422–1450
37
+ - [23. A Group menu, and a record read beside the list](#23-a-group-menu-and-a-record-read-beside-the-list) · lines 1452–1526
38
38
 
39
39
  <!-- /index -->
40
40
 
@@ -1003,7 +1003,15 @@ const price = editInPlace({
1003
1003
  icon a menu of kinds. In a table, Tab in the last cell adds a row,
1004
1004
  Enter goes down a column (and out from the last row), bars under and
1005
1005
  beside it add a row or a column, and the toolbar's Table menu inserts
1006
- rows and columns, aligns a column and deletes.
1006
+ rows and columns, aligns a column and deletes. Selected words can be
1007
+ highlighted in four colours (amber, yellow, green, red) from the
1008
+ toolbars' Highlight menu or with "/highlight", and un-highlighted;
1009
+ they're saved as `==words==` (`=={green}words==` for the others), plain
1010
+ Markdown, and `<Markdown>` shows them. The fixed toolbar's Full screen
1011
+ button fills the window with the field (Esc leaves; nothing is lost or
1012
+ saved by it), and on a touch screen or under 768px it carries Indent
1013
+ and Outdent for list items, where there is no Tab. A new checklist item
1014
+ shows a "To-do" placeholder and the caret until you type.
1007
1015
  `toolbar={false}` leaves out the toolbars and the slash menu where a
1008
1016
  value is short and the shortcuts are enough; `toolbar="floating"` keeps
1009
1017
  the floating one and the slash menu without the bar above (an `inline`
@@ -9,18 +9,18 @@
9
9
  - [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines 113–126
10
10
  - [Logos and brand marks](#logos-and-brand-marks) · lines 128–172
11
11
  - [Categorical colour](#categorical-colour) · lines 174–244
12
- - [Typography](#typography) · lines 246–328
13
- - [Written text (notes)](#written-text-notes) · lines 277–328
14
- - [Density](#density) · lines 330–387
15
- - [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 389–503
16
- - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 505–574
17
- - [Notices](#notices) · lines 576–604
18
- - [A value over time](#a-value-over-time) · lines 606–639
19
- - [A list as a board](#a-list-as-a-board) · lines 641–692
20
- - [A list as cards](#a-list-as-cards) · lines 694–740
21
- - [Key numbers on a record](#key-numbers-on-a-record) · lines 742–764
22
- - [Picking at random](#picking-at-random) · lines 766–797
23
- - [Filling in what's missing](#filling-in-whats-missing) · lines 799–849
12
+ - [Typography](#typography) · lines 246–337
13
+ - [Written text (notes)](#written-text-notes) · lines 277–337
14
+ - [Density](#density) · lines 339–396
15
+ - [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 398–512
16
+ - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 514–583
17
+ - [Notices](#notices) · lines 585–613
18
+ - [A value over time](#a-value-over-time) · lines 615–648
19
+ - [A list as a board](#a-list-as-a-board) · lines 650–701
20
+ - [A list as cards](#a-list-as-cards) · lines 703–749
21
+ - [Key numbers on a record](#key-numbers-on-a-record) · lines 751–773
22
+ - [Picking at random](#picking-at-random) · lines 775–806
23
+ - [Filling in what's missing](#filling-in-whats-missing) · lines 808–858
24
24
 
25
25
  <!-- /index -->
26
26
 
@@ -323,6 +323,15 @@ shade: note `info`, tip `success`, important category slot 2, warning
323
323
  `warning`, caution `destructive`. The package draws all of it; an app
324
324
  only renders `<Markdown>` or the editor.
325
325
 
326
+ Highlighted words (`==words==` in the Markdown) have four colour tokens:
327
+ `--highlight-amber` (the default), `--highlight-yellow`,
328
+ `--highlight-green` and `--highlight-red` (`bg-highlight-amber`, …). Each
329
+ is a wash behind words that keep `--foreground`, so the pair is the
330
+ contrast that counts: light washes in the light theme and deep ones in the
331
+ dark, each pair checked by axe in both (the Default story's four marks).
332
+ Use them as a background behind written text only, never as a tone: a
333
+ status is `success`, `warning`, `destructive` or `info`.
334
+
326
335
  `--link` is for written text only. A link in a table cell is
327
336
  `text-foreground` and underlined, never blue: a column of coloured text
328
337
  pulls the eye from every other column (cell patterns 16 and 20).
@@ -179,9 +179,11 @@ const GROUP_TOGGLE_CLASS = '-mx-1 inline-flex items-baseline gap-2 rounded-sm px
179
179
  // ring inside it (a row has no outline of its own room); the picked one
180
180
  // takes the muted fill on every cell, over the zebra stripe and the pinned
181
181
  // cells' opaque backgrounds as highlightRow's wash does, with a bar in
182
- // --primary at its start, so it reads without colour too.
182
+ // --primary at its start, so it reads without colour too. Muted text on that
183
+ // fill measures 4.34:1 in light, so inside the row it takes the same darker
184
+ // grey as a highlighted row's (theme.css).
183
185
  const SELECTABLE_ROW_CLASS = 'cursor-pointer outline-none focus-visible:[&>td]:shadow-[inset_0_2px_0_0_var(--ring),inset_0_-2px_0_0_var(--ring)]';
184
- const SELECTED_ROW_CLASS = '[&>td]:bg-muted [&>td:first-child]:shadow-[inset_3px_0_0_0_var(--primary)] ' +
186
+ const SELECTED_ROW_CLASS = '[--muted-foreground:var(--table-highlight-muted-foreground)] [&>td]:bg-muted [&>td:first-child]:shadow-[inset_3px_0_0_0_var(--primary)] ' +
185
187
  'focus-visible:[&>td:first-child]:shadow-[inset_3px_0_0_0_var(--primary),inset_0_2px_0_0_var(--ring),inset_0_-2px_0_0_var(--ring)]';
186
188
  // The table and the panel side by side once one is picked on a wide
187
189
  // screen. The grid is there from the start, so opening the panel never
@@ -36,6 +36,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
36
36
  //
37
37
  // A quote that starts `[!NOTE]` (GitHub's alert syntax) is a callout: a
38
38
  // tinted box with its kind's icon (src/lib/markdown-callout.ts).
39
+ //
40
+ // `==words==` is highlighted words (amber, or `=={green}words==`: yellow,
41
+ // green, red): a <mark> on the colour's wash (src/lib/markdown-highlight.ts).
39
42
  import { useEffect, useMemo, useRef } from 'react';
40
43
  import ReactMarkdown, {} from 'react-markdown';
41
44
  import remarkGfm from 'remark-gfm';
@@ -43,10 +46,17 @@ import { cn } from 'cn';
43
46
  import { ExternalLinkIcon, SquareCheckIcon, SquareIcon } from 'lucide-react';
44
47
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table.js';
45
48
  import { CALLOUT_KINDS, remarkCallouts } from '../../lib/markdown-callout.js';
49
+ import { isHighlightColor, remarkHighlights } from '../../lib/markdown-highlight.js';
46
50
  import { textTopHeading } from '../../lib/markdown-top-heading.js';
47
51
  import { CALLOUT_BOX, CALLOUT_LOOK, DEFAULT_HEADING_LEVEL, headingBand, headingClass, headingTag, PROSE, PROSE_INSET, } from './prose-look.js';
48
52
  import { headerTexts, TABLE_SCROLL_FOCUS, watchTableScroll } from './prose-table-scroll.js';
49
- const REMARK_PLUGINS = [remarkGfm, remarkCallouts];
53
+ const REMARK_PLUGINS = [remarkGfm, remarkCallouts, remarkHighlights];
54
+ const HIGHLIGHT_WASH = {
55
+ amber: 'bg-highlight-amber',
56
+ yellow: 'bg-highlight-yellow',
57
+ green: 'bg-highlight-green',
58
+ red: 'bg-highlight-red',
59
+ };
50
60
  function isCalloutKind(value) {
51
61
  return CALLOUT_KINDS.includes(value);
52
62
  }
@@ -103,6 +113,12 @@ const COMPONENTS = {
103
113
  input: ({ checked }) => checked ? (_jsx(SquareCheckIcon, { role: "img", "aria-label": "Done", className: "mr-2 inline-block size-4 align-[-0.1875rem] text-foreground" })) : (_jsx(SquareIcon, { role: "img", "aria-label": "Not done", className: "mr-2 inline-block size-4 align-[-0.1875rem] text-muted-foreground" })),
104
114
  blockquote: (props) => (_jsx("blockquote", { ...omitNode(props), className: "flex flex-col gap-3 border-l-2 border-border pl-4 text-muted-foreground" })),
105
115
  hr: () => _jsx("hr", { className: PROSE.rule }),
116
+ // Highlighted words (remarkHighlights marks them): the colour's wash.
117
+ mark: (props) => {
118
+ const { children, ...rest } = omitNode(props);
119
+ const color = rest['data-color'];
120
+ return (_jsx("mark", { ...rest, className: cn('rounded-sm text-foreground', isHighlightColor(color) ? HIGHLIGHT_WASH[color] : 'bg-highlight-amber'), children: children }));
121
+ },
106
122
  // A callout (remarkCallouts marks it): its kind's wash and icon, named
107
123
  // for screen readers by the kind ("Note: …").
108
124
  div: (props) => {
@@ -58,7 +58,7 @@ import { Fragment, Slice } from '@milkdown/kit/prose/model';
58
58
  import { NodeSelection, Plugin, TextSelection } from '@milkdown/kit/prose/state';
59
59
  import { isInTable, TableMap } from '@milkdown/kit/prose/tables';
60
60
  import { Decoration, DecorationSet } from '@milkdown/kit/prose/view';
61
- import { $nodeSchema, $prose, $remark, getMarkdown } from '@milkdown/kit/utils';
61
+ import { $markSchema, $nodeSchema, $prose, $remark, getMarkdown } from '@milkdown/kit/utils';
62
62
  import { cn } from 'cn';
63
63
  import { toast } from 'sonner';
64
64
  import { Button } from '../ui/button.js';
@@ -69,6 +69,7 @@ import { acceptAttribute } from '../../lib/image-file.js';
69
69
  import { linkHref, linkRefusal } from '../../lib/link-href.js';
70
70
  import { passEscapeThrough } from './escape-passthrough.js';
71
71
  import { calloutHandler, remarkCalloutNodes } from '../../lib/markdown-callout.js';
72
+ import { DEFAULT_HIGHLIGHT, highlightHandler, isHighlightColor, remarkHighlightNodes } from '../../lib/markdown-highlight.js';
72
73
  import { textTopHeading } from '../../lib/markdown-top-heading.js';
73
74
  import { MarkdownMergeError, markdownStyle, mergeMarkdown } from '../../lib/markdown-merge.js';
74
75
  import { DEFAULT_HEADING_LEVEL, headingBand, headingClass, headingTag } from './prose-look.js';
@@ -121,6 +122,36 @@ const calloutSchema = $nodeSchema('callout', () => ({
121
122
  },
122
123
  },
123
124
  }));
125
+ // Highlighted words (`==words==`, `=={green}words==`: src/lib/markdown-highlight.ts):
126
+ // a mark of their own, its colour an attribute.
127
+ const highlightRemark = $remark('remarkHighlightNodes', () => remarkHighlightNodes);
128
+ const highlightSchema = $markSchema('highlight', () => ({
129
+ attrs: { color: { default: DEFAULT_HIGHLIGHT } },
130
+ parseDOM: [
131
+ {
132
+ tag: 'mark[data-color]',
133
+ getAttrs: (dom) => {
134
+ const color = dom.dataset.color;
135
+ return { color: isHighlightColor(color) ? color : DEFAULT_HIGHLIGHT };
136
+ },
137
+ },
138
+ ],
139
+ toDOM: (mark) => ['mark', { 'data-color': String(mark.attrs.color) }, 0],
140
+ parseMarkdown: {
141
+ match: (node) => node.type === 'highlight',
142
+ runner: (state, node, type) => {
143
+ state.openMark(type, { color: isHighlightColor(node.color) ? node.color : DEFAULT_HIGHLIGHT });
144
+ state.next(node.children);
145
+ state.closeMark(type);
146
+ },
147
+ },
148
+ toMarkdown: {
149
+ match: (mark) => mark.type.name === 'highlight',
150
+ runner: (state, mark) => {
151
+ state.withMark(mark, 'highlight', undefined, { color: mark.attrs.color });
152
+ },
153
+ },
154
+ }));
124
155
  function emptyImageTitles(node) {
125
156
  if (node.type === 'image' && node.title == null)
126
157
  node.title = '';
@@ -167,6 +198,8 @@ function taskItemView(node, view, getPos) {
167
198
  box.tabIndex = -1;
168
199
  box.setAttribute('aria-label', 'Done');
169
200
  const contentDOM = document.createElement('div');
201
+ // Takes the row's width, so an empty item's caret and placeholder show.
202
+ contentDOM.className = 'min-w-0 flex-1';
170
203
  dom.append(box, contentDOM);
171
204
  dom.dataset.itemType = 'task';
172
205
  let current = node;
@@ -297,6 +330,23 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
297
330
  useEffect(() => {
298
331
  latest.current = { onChange, onProblem, readOnly, placeholder, upload, imageAccept, imageMaxSize };
299
332
  });
333
+ // Whether focus is on the floating toolbar, or in a menu opened from it
334
+ // (the Highlight menu's popup is in a portal).
335
+ const inFloating = (node) => Boolean(floatingRef.current?.contains(node) || (node instanceof Element && node.closest('[data-rich-text-menu]')));
336
+ // Full screen: the field fills the window, in place (the editor stays
337
+ // mounted, so nothing is lost).
338
+ const [fullScreen, setFullScreen] = useState(false);
339
+ const fullScreenRef = useRef(false);
340
+ useEffect(() => {
341
+ fullScreenRef.current = fullScreen;
342
+ if (!fullScreen)
343
+ return;
344
+ const overflow = document.body.style.overflow;
345
+ document.body.style.overflow = 'hidden';
346
+ return () => {
347
+ document.body.style.overflow = overflow;
348
+ };
349
+ }, [fullScreen]);
300
350
  // The toolbar's Image button opens this file picker.
301
351
  const imageInputRef = useRef(null);
302
352
  // The fixed bar above the text, and the menus over it (the floating
@@ -510,7 +560,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
510
560
  },
511
561
  // Moving to the floating toolbar keeps it showing.
512
562
  blur: (_view, event) => {
513
- if (!floatingRef.current?.contains(event.relatedTarget))
563
+ if (!inFloating(event.relatedTarget))
514
564
  setFocused(false);
515
565
  return false;
516
566
  },
@@ -546,6 +596,12 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
546
596
  setDismissed({ from, to });
547
597
  return true;
548
598
  }
599
+ // Esc leaves full screen, and goes no further.
600
+ if (event.key === 'Escape' && fullScreenRef.current) {
601
+ event.preventDefault();
602
+ setFullScreen(false);
603
+ return true;
604
+ }
549
605
  // An Esc nothing over the text took: the field's to act on,
550
606
  // though ProseMirror will mark it handled.
551
607
  if (event.key === 'Escape')
@@ -566,6 +622,13 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
566
622
  return false;
567
623
  },
568
624
  decorations: (state) => {
625
+ // An empty task item the caret is in says where typing goes.
626
+ const { $from, empty } = state.selection;
627
+ if (empty && $from.parent.type.name === 'paragraph' && $from.parent.content.size === 0 && $from.depth >= 2) {
628
+ const item = $from.node($from.depth - 1);
629
+ if (item.type.name === 'list_item' && item.attrs.checked != null)
630
+ return DecorationSet.create(state.doc, [Decoration.node($from.before(), $from.after(), { 'data-placeholder': 'To-do' })]);
631
+ }
569
632
  const text = latest.current.placeholder;
570
633
  const only = state.doc.childCount === 1 ? state.doc.firstChild : null;
571
634
  if (!text || !only || only.type.name !== 'paragraph' || only.content.size > 0)
@@ -581,7 +644,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
581
644
  ctx.update(remarkStringifyOptionsCtx, (options) => ({
582
645
  ...options,
583
646
  ...markdownStyle(original),
584
- handlers: { ...options.handlers, callout: calloutHandler },
647
+ handlers: { ...options.handlers, callout: calloutHandler, highlight: highlightHandler },
585
648
  }));
586
649
  ctx.update(editorViewOptionsCtx, (options) => ({
587
650
  ...options,
@@ -639,6 +702,8 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
639
702
  .use(gfm)
640
703
  .use(calloutRemark)
641
704
  .use(calloutSchema)
705
+ .use(highlightRemark)
706
+ .use(highlightSchema)
642
707
  .use(imageTitles)
643
708
  .use(history)
644
709
  .use(changes)
@@ -777,6 +842,20 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
777
842
  floatingOpenRef.current = floatingOpen;
778
843
  });
779
844
  const returnToText = () => editorView?.focus();
845
+ const toggleFullScreen = () => {
846
+ setFullScreen((on) => !on);
847
+ editorView?.focus();
848
+ };
849
+ // Esc on the toolbar leaves full screen too (a menu's own Esc, in its
850
+ // portal, isn't ours).
851
+ const leaveFullScreen = (event) => {
852
+ if (!fullScreen || event.key !== 'Escape' || event.defaultPrevented || !event.currentTarget.contains(event.target))
853
+ return;
854
+ event.preventDefault();
855
+ event.stopPropagation();
856
+ setFullScreen(false);
857
+ editorView?.focus();
858
+ };
780
859
  useEffect(() => {
781
860
  slashMenuRef.current = slashOpen && slash ? { key: slashMenuKey, span: slash, items: slashMatches, index: slashIndex } : null;
782
861
  });
@@ -784,19 +863,21 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
784
863
  setLinkBox(null);
785
864
  editorRef.current?.action((ctx) => ctx.get(editorViewCtx).focus());
786
865
  };
787
- return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: setField, "data-slot": "rich-text-editor", className: cn('relative flex w-full min-w-0 flex-col bg-transparent type-body text-foreground break-words',
866
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: setField, "data-slot": "rich-text-editor", "data-full-screen": fullScreen || undefined, onKeyDown: leaveFullScreen, className: cn(fullScreen ? 'fixed inset-0 z-50 h-dvh rounded-none! border-0! bg-background! ring-0!' : 'relative', 'flex w-full min-w-0 flex-col bg-transparent type-body text-foreground break-words',
788
867
  // Inline, it's the text itself: the caret shows where it's
789
868
  // edited, and a refusal's reason under it says what's wrong.
790
869
  !inline &&
791
870
  'rounded-lg border border-input transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', !inline && 'has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/20', className), children: [fixedToolbar && (_jsx(RichTextToolbar, { ref: toolbarRef, state: editorState, readOnly: readOnly,
792
871
  // Only once the editor is made: until then the editable element,
793
872
  // and its id, aren't in the page (axe: aria-valid-attr-value).
794
- controls: editorState ? editableId : undefined, onFormat: runFormat, onTable: runTable, onImage: upload ? pickImages : undefined, text: textElement })), upload && (_jsx("input", { ref: imageInputRef, type: "file", accept: acceptAttribute(imageAccept), multiple: true, hidden: true, tabIndex: -1, onChange: (event) => {
873
+ controls: editorState ? editableId : undefined, onFormat: runFormat, onTable: runTable, onImage: upload ? pickImages : undefined, onFullScreen: toggleFullScreen, fullScreen: fullScreen, text: textElement })), upload && (_jsx("input", { ref: imageInputRef, type: "file", accept: acceptAttribute(imageAccept), multiple: true, hidden: true, tabIndex: -1, onChange: (event) => {
795
874
  const files = Array.from(event.target.files ?? []);
796
875
  // Cleared, so picking the same file again still counts.
797
876
  event.target.value = '';
798
877
  imagesPicked(files);
799
878
  } })), _jsx("div", { ref: rootRef, className: cn(!inline && 'min-h-24 px-2.5 py-2',
879
+ // Full screen: the text scrolls under the toolbar, in a column to read.
880
+ fullScreen && 'min-h-0 flex-1 overflow-y-auto px-4 py-3 md:px-8 [&_.ProseMirror]:mx-auto [&_.ProseMirror]:w-full [&_.ProseMirror]:max-w-3xl',
800
881
  // Read-only text is muted; the toolbar's buttons say so by being disabled.
801
882
  'has-[[aria-readonly=true]]:text-muted-foreground',
802
883
  // The same type roles <Markdown> uses, so text reads the same
@@ -807,7 +888,9 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
807
888
  '[&_.ProseMirror:has(>[data-band])>:not([data-band])]:mx-3', !inline && '[&_.ProseMirror]:min-h-20',
808
889
  // ProseMirror's own required style (prosemirror-view's CSS): spaces
809
890
  // as typed, so one at the end of a line isn't kept as &nbsp;.
810
- '[&_.ProseMirror]:whitespace-pre-wrap [&_.ProseMirror]:break-words', '[&_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',
891
+ '[&_.ProseMirror]:whitespace-pre-wrap [&_.ProseMirror]:break-words', '[&_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',
892
+ // Highlighted words: the colour's wash, the text's own colour on it.
893
+ '[&_mark]:rounded-sm [&_mark]:text-foreground [&_mark[data-color=amber]]:bg-highlight-amber [&_mark[data-color=yellow]]:bg-highlight-yellow [&_mark[data-color=green]]:bg-highlight-green [&_mark[data-color=red]]:bg-highlight-red', '[&_li[data-item-type=task]>input]:mt-1 [&_li[data-item-type=task]>input]:size-4 [&_li[data-item-type=task]>input]:accent-primary',
811
894
  // Headings, callouts and tables are drawn by their node views;
812
895
  // these are the elements Milkdown draws itself.
813
896
  '[&_a]:text-link [&_a]:underline [&_a]:decoration-link/40 [&_a]:underline-offset-4', '[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground', '[&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:font-mono', '[&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-muted [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:font-mono', '[&_hr]:my-2 [&_hr]:h-0.5 [&_hr]:rounded-full [&_hr]:border-0 [&_hr]:bg-rule', '[&_hr.ProseMirror-selectednode]:outline-2 [&_hr.ProseMirror-selectednode]:outline-offset-4 [&_hr.ProseMirror-selectednode]:outline-ring',
@@ -832,7 +915,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
832
915
  ];
833
916
  }), 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) => {
834
917
  const next = event.relatedTarget;
835
- if (!editorView.dom.contains(next) && !floatingRef.current?.contains(next))
918
+ if (!editorView.dom.contains(next) && !inFloating(next))
836
919
  setFocused(false);
837
920
  } }))] }), upload && (_jsx("p", { role: "status", className: "sr-only", children: uploading > 0 ? `Uploading ${uploading === 1 ? 'an image' : `${uploading} images`}…` : '' })), showCount && (_jsx("p", { id: counterId, "data-slot": "rich-text-editor-count", className: cn('text-right type-caption tabular-nums', length > maxLength ? 'text-destructive-text' : 'text-muted-foreground'), children: length > maxLength
838
921
  ? `${(length - maxLength).toLocaleString()} characters over the limit of ${maxLength.toLocaleString()}`
@@ -11,6 +11,10 @@ export type FormatItem = {
11
11
  keys?: string;
12
12
  /** More for the tooltip than the shortcut. */
13
13
  hint?: string;
14
+ /** Does something rather than turns on and off: a plain button, never pressed. */
15
+ action?: boolean;
16
+ /** Only in a toolbar on a touch screen or a narrow one, where the keyboard has no Tab. */
17
+ touch?: boolean;
14
18
  };
15
19
  export declare const FORMAT_GROUPS: FormatItem[][];
16
20
  /** Every format by its id. */
@@ -20,5 +24,10 @@ export declare const FORMAT_ITEMS: Map<FormatId, FormatItem>;
20
24
  * callout". Not on the toolbar (its Callout makes a note).
21
25
  */
22
26
  export declare const CALLOUT_VARIANTS: FormatItem[];
27
+ /**
28
+ * Highlight in each colour, for the slash menu: "Green highlight". Not on
29
+ * the toolbar (its Highlight is a menu of them).
30
+ */
31
+ export declare const HIGHLIGHT_VARIANTS: FormatItem[];
23
32
  /** Notion's block keys: the digit after Ctrl+Shift (Cmd+Option on a Mac), and its format. */
24
33
  export declare const BLOCK_KEYS: Record<string, FormatId>;
@@ -7,15 +7,17 @@
7
7
  // blocks (Cmd+Option on a Mac, where Cmd+Shift+3 to 5 take screenshots),
8
8
  // written `Block+…` here; Mod+Shift+S strikes through. Milkdown's own
9
9
  // (Ctrl+Alt+1, Mod+Alt+X, Mod+Shift+B, …) still work.
10
- import { BoldIcon, CodeIcon, Heading1Icon, Heading2Icon, Heading3Icon, ItalicIcon, LinkIcon, ListChecksIcon, ListIcon, ListOrderedIcon, MinusIcon, SquareCodeIcon, StrikethroughIcon, TableIcon, TextQuoteIcon, } from 'lucide-react';
10
+ import { BoldIcon, CodeIcon, Heading1Icon, Heading2Icon, Heading3Icon, HighlighterIcon, IndentDecreaseIcon, IndentIncreaseIcon, ItalicIcon, LinkIcon, ListChecksIcon, ListIcon, ListOrderedIcon, MinusIcon, SquareCodeIcon, StrikethroughIcon, TableIcon, TextQuoteIcon, } from 'lucide-react';
11
11
  import { CALLOUT_KINDS } from '../../lib/markdown-callout.js';
12
12
  import { CALLOUT_LOOK } from './prose-look.js';
13
+ import { HIGHLIGHT_COLORS } from '../../lib/markdown-highlight.js';
13
14
  export const FORMAT_GROUPS = [
14
15
  [
15
16
  { id: 'bold', label: 'Bold', icon: BoldIcon, keys: 'Mod+B' },
16
17
  { id: 'italic', label: 'Italic', icon: ItalicIcon, keys: 'Mod+I' },
17
18
  { id: 'strike', label: 'Strikethrough', icon: StrikethroughIcon, keys: 'Mod+Shift+S' },
18
19
  { id: 'code', label: 'Code', icon: CodeIcon, keys: 'Mod+E' },
20
+ { id: 'highlight', label: 'Highlight', icon: HighlighterIcon },
19
21
  ],
20
22
  [
21
23
  { id: 'heading1', label: 'Heading 1', icon: Heading1Icon, keys: 'Block+1' },
@@ -26,6 +28,8 @@ export const FORMAT_GROUPS = [
26
28
  { id: 'tasks', label: 'Task list', icon: ListChecksIcon, keys: 'Block+4', hint: 'tick an item: Mod+Shift+Enter' },
27
29
  { id: 'bullets', label: 'Bulleted list', icon: ListIcon, keys: 'Block+5' },
28
30
  { id: 'numbers', label: 'Numbered list', icon: ListOrderedIcon, keys: 'Block+6' },
31
+ { id: 'outdent', label: 'Outdent', icon: IndentDecreaseIcon, hint: 'a list item, one level out', action: true, touch: true },
32
+ { id: 'indent', label: 'Indent', icon: IndentIncreaseIcon, hint: 'a list item, one level in', action: true, touch: true },
29
33
  ],
30
34
  [
31
35
  { id: 'quote', label: 'Quote', icon: TextQuoteIcon, keys: 'Mod+Shift+B' },
@@ -47,6 +51,15 @@ export const CALLOUT_VARIANTS = CALLOUT_KINDS.filter((kind) => kind !== 'note').
47
51
  label: `${CALLOUT_LOOK[kind].label} callout`,
48
52
  icon: CALLOUT_LOOK[kind].icon,
49
53
  }));
54
+ /**
55
+ * Highlight in each colour, for the slash menu: "Green highlight". Not on
56
+ * the toolbar (its Highlight is a menu of them).
57
+ */
58
+ export const HIGHLIGHT_VARIANTS = HIGHLIGHT_COLORS.map((color) => ({
59
+ id: `highlight-${color}`,
60
+ label: `${color[0].toUpperCase()}${color.slice(1)} highlight`,
61
+ icon: HighlighterIcon,
62
+ }));
50
63
  /** Notion's block keys: the digit after Ctrl+Shift (Cmd+Option on a Mac), and its format. */
51
64
  export const BLOCK_KEYS = {
52
65
  '0': 'text',
@@ -1,12 +1,23 @@
1
1
  import { type Node as ProseNode, type Schema } from '@milkdown/kit/prose/model';
2
2
  import { type EditorState, type Transaction } from '@milkdown/kit/prose/state';
3
3
  import type { CalloutKind } from '../../lib/markdown-callout.js';
4
- export type FormatId = 'bold' | 'italic' | 'strike' | 'code' | 'text' | 'heading1' | 'heading2' | 'heading3' | 'bullets' | 'numbers' | 'tasks' | 'quote' | CalloutFormat | 'codeBlock' | 'divider' | 'table' | 'link';
4
+ import { type HighlightColor } from '../../lib/markdown-highlight.js';
5
+ export type FormatId = 'bold' | 'italic' | 'strike' | 'code' | 'highlight' | HighlightFormat | 'highlight-off' | 'indent' | 'outdent' | 'text' | 'heading1' | 'heading2' | 'heading3' | 'bullets' | 'numbers' | 'tasks' | 'quote' | CalloutFormat | 'codeBlock' | 'divider' | 'table' | 'link';
6
+ /** Words highlighted in each colour. `highlight` is the toolbar's menu of them. */
7
+ export type HighlightFormat = `highlight-${HighlightColor}`;
8
+ /** The colour a highlight format puts on, or null when it isn't one. */
9
+ export declare function highlightColorOf(id: FormatId): HighlightColor | null;
5
10
  /** A callout of each kind: `callout` is a note, the default. */
6
11
  export type CalloutFormat = 'callout' | `callout-${Exclude<CalloutKind, 'note'>}`;
7
12
  /** The kind of callout a format makes, or null when it isn't one. */
8
13
  export declare function calloutKindOf(id: FormatId): CalloutKind | null;
9
14
  type Dispatch = (tr: Transaction) => void;
15
+ /**
16
+ * The colour of the highlight on the selection (the caret's own, or what
17
+ * is typed next, with nothing selected), or null when there is none or the
18
+ * words are highlighted in more than one.
19
+ */
20
+ export declare function highlightColor(state: EditorState): HighlightColor | null;
10
21
  /** The table the caret is in, and its position, or null. */
11
22
  export declare function tableAt(state: EditorState): {
12
23
  node: ProseNode;
@@ -5,8 +5,14 @@
5
5
  // same as its keyboard shortcut.
6
6
  import { lift, setBlockType, toggleMark, wrapIn } from '@milkdown/kit/prose/commands';
7
7
  import { Fragment } from '@milkdown/kit/prose/model';
8
- import { liftListItem, wrapInList } from '@milkdown/kit/prose/schema-list';
8
+ import { liftListItem, sinkListItem, wrapInList } from '@milkdown/kit/prose/schema-list';
9
9
  import { TextSelection } from '@milkdown/kit/prose/state';
10
+ import { HIGHLIGHT_COLORS } from '../../lib/markdown-highlight.js';
11
+ /** The colour a highlight format puts on, or null when it isn't one. */
12
+ export function highlightColorOf(id) {
13
+ const color = id.startsWith('highlight-') ? id.slice('highlight-'.length) : null;
14
+ return HIGHLIGHT_COLORS.find((one) => one === color) ?? null;
15
+ }
10
16
  /** The kind of callout a format makes, or null when it isn't one. */
11
17
  export function calloutKindOf(id) {
12
18
  if (id === 'callout')
@@ -21,6 +27,46 @@ const MARKS = {
21
27
  code: 'inlineCode',
22
28
  link: 'link',
23
29
  };
30
+ /**
31
+ * The colour of the highlight on the selection (the caret's own, or what
32
+ * is typed next, with nothing selected), or null when there is none or the
33
+ * words are highlighted in more than one.
34
+ */
35
+ export function highlightColor(state) {
36
+ const type = state.schema.marks.highlight;
37
+ if (!type)
38
+ return null;
39
+ const { from, to, empty, $from } = state.selection;
40
+ const colors = new Set();
41
+ if (empty)
42
+ colors.add(type.isInSet(state.storedMarks ?? $from.marks())?.attrs.color ?? null);
43
+ else
44
+ state.doc.nodesBetween(from, to, (node) => {
45
+ if (node.isInline)
46
+ colors.add(type.isInSet(node.marks)?.attrs.color ?? null);
47
+ });
48
+ if (colors.size !== 1)
49
+ return null;
50
+ const [only] = [...colors];
51
+ return HIGHLIGHT_COLORS.find((color) => color === only) ?? null;
52
+ }
53
+ // Highlights the selection in `color` (what was highlighted before, in any
54
+ // colour, replaced), or takes the highlight off with null. With nothing
55
+ // selected it is what is typed next.
56
+ function setHighlight(state, dispatch, color) {
57
+ const type = state.schema.marks.highlight;
58
+ if (!type)
59
+ return false;
60
+ const { from, to, empty } = state.selection;
61
+ if (empty) {
62
+ const tr = state.tr.removeStoredMark(type);
63
+ dispatch(color ? tr.addStoredMark(type.create({ color })) : tr);
64
+ return true;
65
+ }
66
+ const tr = state.tr.removeMark(from, to, type);
67
+ dispatch(color ? tr.addMark(from, to, type.create({ color })) : tr);
68
+ return true;
69
+ }
24
70
  function markOn(state, type) {
25
71
  const { from, to, empty, $from } = state.selection;
26
72
  if (empty)
@@ -52,6 +98,11 @@ export function formatOn(state, id) {
52
98
  const level = HEADING_LEVEL[id];
53
99
  if (level)
54
100
  return parent.type.name === 'heading' && parent.attrs.level === level;
101
+ if (id === 'highlight')
102
+ return highlightColor(state) !== null;
103
+ const color = highlightColorOf(id);
104
+ if (color)
105
+ return highlightColor(state) === color;
55
106
  const kind = calloutKindOf(id);
56
107
  if (kind)
57
108
  return ancestor(state, (node) => node.type.name === 'callout')?.node.attrs.kind === kind;
@@ -59,6 +110,9 @@ export function formatOn(state, id) {
59
110
  case 'text':
60
111
  return parent.type.name === 'paragraph' && !ancestor(state, (node) => isList(node) || node.type.name === 'blockquote');
61
112
  case 'divider':
113
+ case 'indent':
114
+ case 'outdent':
115
+ case 'highlight-off':
62
116
  return false;
63
117
  case 'table':
64
118
  return tableAt(state) !== null;
@@ -113,6 +167,15 @@ export function toggleFormat(state, dispatch, id) {
113
167
  const mark = MARKS[id];
114
168
  if (mark && id !== 'link')
115
169
  return toggleMark(schema.marks[mark])(state, dispatch);
170
+ const color = highlightColorOf(id);
171
+ if (color)
172
+ return setHighlight(state, dispatch, formatOn(state, id) ? null : color);
173
+ if (id === 'highlight-off')
174
+ return setHighlight(state, dispatch, null);
175
+ if (id === 'indent')
176
+ return sinkListItem(schema.nodes.list_item)(state, dispatch);
177
+ if (id === 'outdent')
178
+ return liftListItem(schema.nodes.list_item)(state, dispatch);
116
179
  const on = formatOn(state, id);
117
180
  const paragraph = schema.nodes.paragraph;
118
181
  const level = HEADING_LEVEL[id];
@@ -0,0 +1,18 @@
1
+ import { type KeyboardEvent, type Ref } from 'react';
2
+ import type { EditorState } from '@milkdown/kit/prose/state';
3
+ import { type FormatId } from './rich-text-formats.js';
4
+ export interface RichTextHighlightMenuProps {
5
+ state: EditorState | null;
6
+ readOnly: boolean;
7
+ /** The toolbar's roving tab stop: 0 for the one Tab reaches. */
8
+ tabIndex: number;
9
+ onFocus: () => void;
10
+ /** The toolbar's arrow keys. */
11
+ onKeyDown: (event: KeyboardEvent) => void;
12
+ /** A colour, or Remove highlight, picked. */
13
+ onFormat: (id: FormatId) => void;
14
+ /** The editable element, where focus goes once an item is picked. */
15
+ text: () => HTMLElement | null;
16
+ ref?: Ref<HTMLButtonElement>;
17
+ }
18
+ export declare function RichTextHighlightMenu({ state, readOnly, tabIndex, onFocus, onKeyDown, onFormat, text, ref }: RichTextHighlightMenuProps): import("react").JSX.Element;
@@ -0,0 +1,43 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The toolbars' Highlight menu: the four colours (amber, yellow, green,
3
+ // red) to put on the selected words, or on what is typed next, and "Remove
4
+ // highlight". A menu button in the toolbar's row, moved to with the arrow
5
+ // keys like the rest; the menu is Base UI's (ARIA's menu pattern), the
6
+ // colour on the words its checked item. Picking gives focus back to the
7
+ // text, the selection kept. The popup is marked `data-rich-text-menu` so
8
+ // the floating toolbar, which this menu also sits in, knows focus in it is
9
+ // still the field's (milkdown-editor.tsx).
10
+ import { useRef } from 'react';
11
+ import { ChevronDownIcon, HighlighterIcon, RemoveFormattingIcon } from 'lucide-react';
12
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '../ui/dropdown-menu.js';
13
+ import { toggleVariants } from '../ui/toggle.js';
14
+ import { HIGHLIGHT_COLORS } from '../../lib/markdown-highlight.js';
15
+ import { highlightColor } from './rich-text-formats.js';
16
+ const SWATCH = {
17
+ amber: 'bg-highlight-amber',
18
+ yellow: 'bg-highlight-yellow',
19
+ green: 'bg-highlight-green',
20
+ red: 'bg-highlight-red',
21
+ };
22
+ export function RichTextHighlightMenu({ state, readOnly, tabIndex, onFocus, onKeyDown, onFormat, text, ref }) {
23
+ const current = state ? highlightColor(state) : null;
24
+ // Whether the menu is closing because an item was picked.
25
+ const picked = useRef(false);
26
+ const run = (id) => {
27
+ picked.current = true;
28
+ onFormat(id);
29
+ };
30
+ const finalFocus = () => {
31
+ const toText = picked.current;
32
+ picked.current = false;
33
+ return toText ? (text() ?? true) : true;
34
+ };
35
+ return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { ref: ref, "aria-label": "Highlight", disabled: readOnly, tabIndex: tabIndex, "data-active": current !== null || undefined,
36
+ // Shaped like the toolbar's toggles: on while the words are highlighted.
37
+ className: toggleVariants({ size: 'sm', className: 'gap-0.5 px-1.5 data-active:bg-muted data-active:text-foreground' }),
38
+ // A mouse press keeps focus, and the selection, in the text.
39
+ onMouseDown: (event) => event.preventDefault(), onFocus: onFocus, onKeyDown: onKeyDown, children: [_jsx(HighlighterIcon, { "aria-hidden": "true" }), _jsx(ChevronDownIcon, { "aria-hidden": "true", className: "size-3" })] }), _jsxs(DropdownMenuContent, { className: "w-44", "data-rich-text-menu": "", finalFocus: finalFocus, children: [_jsx(DropdownMenuRadioGroup, { value: current, onValueChange: (value) => {
40
+ if (HIGHLIGHT_COLORS.includes(value))
41
+ run(`highlight-${value}`);
42
+ }, children: HIGHLIGHT_COLORS.map((color) => (_jsxs(DropdownMenuRadioItem, { value: color, closeOnClick: true, children: [_jsx("span", { "aria-hidden": "true", className: `size-4 shrink-0 rounded-sm border border-foreground/30 ${SWATCH[color]}` }), color[0].toUpperCase() + color.slice(1)] }, color))) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { disabled: current === null, onClick: () => run('highlight-off'), children: [_jsx(RemoveFormattingIcon, { "aria-hidden": "true" }), "Remove highlight"] })] })] }));
43
+ }
@@ -8,7 +8,7 @@
8
8
  // `/usr/bin`) never does, and neither does a "/" mid-line or inside a
9
9
  // list, a quote or a code block.
10
10
  import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
11
- import { CALLOUT_VARIANTS, FORMAT_GROUPS } from './rich-text-format-items.js';
11
+ import { CALLOUT_VARIANTS, FORMAT_GROUPS, HIGHLIGHT_VARIANTS } from './rich-text-format-items.js';
12
12
  import { toggleFormat } from './rich-text-formats.js';
13
13
  const slashKey = new PluginKey('rich-text-slash');
14
14
  // Longer than any item's name: past it, the person is writing, not picking.
@@ -94,19 +94,24 @@ const KEYWORDS = {
94
94
  codeBlock: ['pre'],
95
95
  divider: ['hr', 'rule', 'line', 'separator', '---'],
96
96
  table: ['grid', 'columns'],
97
+ 'highlight-amber': ['highlight', 'mark', 'marker', 'colour', 'color'],
98
+ 'highlight-yellow': ['highlight', 'mark', 'marker', 'colour', 'color'],
99
+ 'highlight-green': ['highlight', 'mark', 'marker', 'colour', 'color'],
100
+ 'highlight-red': ['highlight', 'mark', 'marker', 'colour', 'color'],
97
101
  };
98
102
  // The menu's items: the blocks, in the toolbar's order. The kinds of
99
- // callout past the default only once something's typed ("/warn"), so
103
+ // callout past the default, and the highlights, only once something's
104
+ // typed ("/warn"), so
100
105
  // the list "/" opens with stays short.
101
106
  const BLOCKS = FORMAT_GROUPS.flat().filter((item) => KEYWORDS[item.id]);
102
- const TYPED_ONLY = CALLOUT_VARIANTS;
103
107
  /** The blocks matching what's typed after the "/": in the name, or the start of a keyword. */
104
108
  export function slashItems(query) {
105
109
  const typed = query.trim().toLowerCase();
106
110
  if (!typed)
107
111
  return BLOCKS;
108
112
  const blocks = [...BLOCKS];
109
- blocks.splice(BLOCKS.findIndex((item) => item.id === 'callout') + 1, 0, ...TYPED_ONLY);
113
+ blocks.splice(BLOCKS.findIndex((item) => item.id === 'callout') + 1, 0, ...CALLOUT_VARIANTS);
114
+ blocks.push(...HIGHLIGHT_VARIANTS);
110
115
  return blocks.filter((item) => item.label.toLowerCase().includes(typed) || KEYWORDS[item.id].some((word) => word.startsWith(typed)));
111
116
  }
112
117
  /**
@@ -122,6 +127,9 @@ export function applySlash(view, span, id) {
122
127
  for (const step of done.steps)
123
128
  tr.step(step);
124
129
  tr.setSelection(done.selection.getBookmark().resolve(tr.doc));
130
+ // A highlight picked here is for what is typed next.
131
+ if (done.storedMarks)
132
+ tr.setStoredMarks(done.storedMarks);
125
133
  }
126
134
  view.dispatch(tr.scrollIntoView());
127
135
  }
@@ -19,10 +19,14 @@ export type FormatButtonsProps = {
19
19
  onTable?: (action: TableAction) => void;
20
20
  /** The Image button pressed: pick images to upload. No button without it. */
21
21
  onImage?: () => void;
22
+ /** The Full screen button pressed. No button without it. */
23
+ onFullScreen?: () => void;
24
+ /** Whether the editor is full screen now (the button's state). */
25
+ fullScreen?: boolean;
22
26
  /** The editable element, for the Table menu to give focus back to. */
23
27
  text?: () => HTMLElement | null;
24
28
  };
25
- export declare function FormatButtons({ formats, state, readOnly, onFormat, onTable, onImage, text }: FormatButtonsProps): import("react").JSX.Element;
29
+ export declare function FormatButtons({ formats, state, readOnly, onFormat, onTable, onImage, onFullScreen, fullScreen, text }: FormatButtonsProps): import("react").JSX.Element;
26
30
  export interface RichTextToolbarProps {
27
31
  /** The editor's state now, to show which formats are on. Null until it's made. */
28
32
  state: EditorState | null;
@@ -35,10 +39,14 @@ export interface RichTextToolbarProps {
35
39
  onTable: (action: TableAction) => void;
36
40
  /** The Image button pressed (only when the editor uploads images). */
37
41
  onImage?: () => void;
42
+ /** The Full screen button pressed. No button without it. */
43
+ onFullScreen?: () => void;
44
+ /** Whether the editor is full screen now. */
45
+ fullScreen?: boolean;
38
46
  /** The editable element, for the Table menu to give focus back to. */
39
47
  text: () => HTMLElement | null;
40
48
  /** The toolbar's element, for Alt+F10 to reach it from the text. */
41
49
  ref?: Ref<HTMLDivElement>;
42
50
  }
43
- export declare function RichTextToolbar({ state, readOnly, controls, onFormat, onTable, onImage, text, ref }: RichTextToolbarProps): import("react").JSX.Element;
51
+ export declare function RichTextToolbar({ state, readOnly, controls, onFormat, onTable, onImage, onFullScreen, fullScreen, text, ref }: RichTextToolbarProps): import("react").JSX.Element;
44
52
  export {};
@@ -14,8 +14,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
14
14
  // - Read-only (while a save is in flight), every button is disabled.
15
15
  // - Alt+F10 in the text reaches it (the floating toolbar first, when it's
16
16
  // showing: rich-text-floating-toolbar.tsx).
17
- import { Fragment, useRef, useState } from 'react';
18
- import { ImageIcon } from 'lucide-react';
17
+ import { Fragment, useEffect, useRef, useState } from 'react';
18
+ import { ImageIcon, Maximize2Icon, Minimize2Icon } from 'lucide-react';
19
19
  import { Button } from '../ui/button.js';
20
20
  import { Separator } from '../ui/separator.js';
21
21
  import { Toggle } from '../ui/toggle.js';
@@ -23,24 +23,41 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/
23
23
  import { FORMAT_GROUPS, FORMAT_ITEMS } from './rich-text-format-items.js';
24
24
  import { formatOn } from './rich-text-formats.js';
25
25
  import { ariaKeys, isApple, shownKeys } from './rich-text-shortcuts.js';
26
+ import { RichTextHighlightMenu } from './rich-text-highlight-menu.js';
26
27
  import { RichTextTableMenu } from './rich-text-table-menu.js';
27
28
  // Which buttons a toolbar has: every format (the fixed one), or the ones
28
29
  // that apply to selected words (the floating one).
29
- const SELECTION = ['bold', 'italic', 'strike', 'code', 'link'];
30
+ const SELECTION = ['bold', 'italic', 'strike', 'code', 'highlight', 'link'];
30
31
  const SETS = {
31
32
  all: FORMAT_GROUPS,
32
- selection: [SELECTION.slice(0, 4), SELECTION.slice(4)].map((ids) => ids.map((id) => FORMAT_ITEMS.get(id))),
33
+ selection: [SELECTION.slice(0, 5), SELECTION.slice(5)].map((ids) => ids.map((id) => FORMAT_ITEMS.get(id))),
33
34
  };
35
+ // A touch screen or a narrow one: where the keyboard has no Tab, so the
36
+ // toolbar carries Indent and Outdent for list items.
37
+ const COMPACT = '(pointer: coarse), (max-width: 767px)';
38
+ function useCompact() {
39
+ const [compact, setCompact] = useState(() => typeof window !== 'undefined' && window.matchMedia(COMPACT).matches);
40
+ useEffect(() => {
41
+ const query = window.matchMedia(COMPACT);
42
+ const update = () => setCompact(query.matches);
43
+ update();
44
+ query.addEventListener('change', update);
45
+ return () => query.removeEventListener('change', update);
46
+ }, []);
47
+ return compact;
48
+ }
34
49
  // A toolbar's row of buttons: one tab stop, the last one used (ARIA's
35
50
  // toolbar pattern); the arrow keys, Home and End move along the row. The
36
51
  // toolbar's element (`role="toolbar"`, its name) is the caller's.
37
- export function FormatButtons({ formats, state, readOnly, onFormat, onTable, onImage, text = () => null }) {
52
+ export function FormatButtons({ formats, state, readOnly, onFormat, onTable, onImage, onFullScreen, fullScreen = false, text = () => null }) {
38
53
  const apple = isApple();
39
- const groups = SETS[formats];
54
+ const compact = useCompact();
55
+ const groups = SETS[formats].map((group) => group.filter((item) => compact || !item.touch)).filter((group) => group.length > 0);
40
56
  const items = groups.flat();
41
- // The Image button, last in the row, after the formats.
57
+ // The Image and Full screen buttons, last in the row, after the formats.
42
58
  const imageIndex = onImage ? items.length : -1;
43
- const count = items.length + (onImage ? 1 : 0);
59
+ const fullScreenIndex = onFullScreen ? items.length + (onImage ? 1 : 0) : -1;
60
+ const count = items.length + (onImage ? 1 : 0) + (onFullScreen ? 1 : 0);
44
61
  const buttons = useRef([]);
45
62
  // The one button Tab reaches: the last one used.
46
63
  const [current, setCurrent] = useState(0);
@@ -68,6 +85,10 @@ export function FormatButtons({ formats, state, readOnly, onFormat, onTable, onI
68
85
  return (_jsx(RichTextTableMenu, { ref: (element) => {
69
86
  buttons.current[index] = element;
70
87
  }, state: state, readOnly: readOnly, tabIndex: index === current ? 0 : -1, onFocus: () => setCurrent(index), onKeyDown: onKeyDown, onInsert: () => onFormat('table'), onAction: (action) => onTable?.(action), text: text }, item.id));
88
+ if (item.id === 'highlight')
89
+ return (_jsx(RichTextHighlightMenu, { ref: (element) => {
90
+ buttons.current[index] = element;
91
+ }, state: state, readOnly: readOnly, tabIndex: index === current ? 0 : -1, onFocus: () => setCurrent(index), onKeyDown: onKeyDown, onFormat: onFormat, text: text }, item.id));
71
92
  const Icon = item.icon;
72
93
  const tip = [
73
94
  item.label,
@@ -76,6 +97,10 @@ export function FormatButtons({ formats, state, readOnly, onFormat, onTable, onI
76
97
  ]
77
98
  .filter(Boolean)
78
99
  .join(' ');
100
+ if (item.action)
101
+ return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx(Button, { ref: (element) => {
102
+ buttons.current[index] = element;
103
+ }, variant: "ghost", size: "icon-sm", "aria-label": item.label, disabled: readOnly, tabIndex: index === current ? 0 : -1, onMouseDown: (event) => event.preventDefault(), onFocus: () => setCurrent(index), onKeyDown: onKeyDown, onClick: () => onFormat(item.id) }), children: _jsx(Icon, {}) }), _jsx(TooltipContent, { children: tip })] }, item.id));
79
104
  return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx(Toggle, { ref: (element) => {
80
105
  buttons.current[index] = element;
81
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",
@@ -83,8 +108,10 @@ export function FormatButtons({ formats, state, readOnly, onFormat, onTable, onI
83
108
  onMouseDown: (event) => event.preventDefault(), onFocus: () => setCurrent(index), onKeyDown: onKeyDown, onPressedChange: () => onFormat(item.id) }), children: _jsx(Icon, {}) }), _jsx(TooltipContent, { children: tip })] }, item.id));
84
109
  })] }, groupIndex))), onImage && (_jsxs(_Fragment, { children: [_jsx(Separator, { orientation: "vertical", className: "mx-1 h-5 self-center" }), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx(Button, { ref: (element) => {
85
110
  buttons.current[imageIndex] = element;
86
- }, variant: "ghost", size: "icon-sm", "aria-label": "Image", disabled: readOnly, tabIndex: imageIndex === current ? 0 : -1, onMouseDown: (event) => event.preventDefault(), onFocus: () => setCurrent(imageIndex), onKeyDown: onKeyDown, onClick: onImage }), children: _jsx(ImageIcon, {}) }), _jsx(TooltipContent, { children: "Image \u00B7 or paste or drop one" })] })] }))] }));
111
+ }, variant: "ghost", size: "icon-sm", "aria-label": "Image", disabled: readOnly, tabIndex: imageIndex === current ? 0 : -1, onMouseDown: (event) => event.preventDefault(), onFocus: () => setCurrent(imageIndex), onKeyDown: onKeyDown, onClick: onImage }), children: _jsx(ImageIcon, {}) }), _jsx(TooltipContent, { children: "Image \u00B7 or paste or drop one" })] })] })), onFullScreen && (_jsxs(_Fragment, { children: [_jsx(Separator, { orientation: "vertical", className: "mx-1 h-5 self-center" }), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx(Toggle, { ref: (element) => {
112
+ buttons.current[fullScreenIndex] = element;
113
+ }, size: "icon-sm", "aria-label": "Full screen", pressed: fullScreen, tabIndex: fullScreenIndex === current ? 0 : -1, className: "aria-pressed:bg-muted aria-pressed:text-foreground", onMouseDown: (event) => event.preventDefault(), onFocus: () => setCurrent(fullScreenIndex), onKeyDown: onKeyDown, onPressedChange: onFullScreen }), children: fullScreen ? _jsx(Minimize2Icon, {}) : _jsx(Maximize2Icon, {}) }), _jsx(TooltipContent, { children: fullScreen ? 'Leave full screen · Esc' : 'Full screen' })] })] }))] }));
87
114
  }
88
- export function RichTextToolbar({ state, readOnly, controls, onFormat, onTable, onImage, text, ref }) {
89
- return (_jsx("div", { ref: ref, role: "toolbar", "aria-label": "Formatting", "aria-controls": controls, "data-slot": "rich-text-toolbar", className: "flex flex-wrap items-center gap-0.5 border-b border-input px-1 py-1", children: _jsx(FormatButtons, { formats: "all", state: state, readOnly: readOnly, onFormat: onFormat, onTable: onTable, onImage: onImage, text: text }) }));
115
+ export function RichTextToolbar({ state, readOnly, controls, onFormat, onTable, onImage, onFullScreen, fullScreen, text, ref }) {
116
+ return (_jsx("div", { ref: ref, role: "toolbar", "aria-label": "Formatting", "aria-controls": controls, "data-slot": "rich-text-toolbar", className: "flex flex-wrap items-center gap-0.5 border-b border-input px-1 py-1", children: _jsx(FormatButtons, { formats: "all", state: state, readOnly: readOnly, onFormat: onFormat, onTable: onTable, onImage: onImage, onFullScreen: onFullScreen, fullScreen: fullScreen, text: text }) }));
90
117
  }
@@ -0,0 +1,26 @@
1
+ export declare const HIGHLIGHT_COLORS: readonly ["amber", "yellow", "green", "red"];
2
+ export type HighlightColor = (typeof HIGHLIGHT_COLORS)[number];
3
+ export declare const DEFAULT_HIGHLIGHT: HighlightColor;
4
+ export declare function isHighlightColor(value: unknown): value is HighlightColor;
5
+ type MdNode = {
6
+ type: string;
7
+ children?: MdNode[];
8
+ value?: string;
9
+ data?: Record<string, unknown>;
10
+ [key: string]: unknown;
11
+ };
12
+ /** For react-markdown: highlighted words become `<mark data-color="amber">`. */
13
+ export declare function remarkHighlights(): (tree: unknown) => void;
14
+ /** For the rich-text editor: highlighted words become `highlight` nodes (`color`), which its mark schema reads and `highlightHandler` writes. */
15
+ export declare function remarkHighlightNodes(): (tree: unknown) => void;
16
+ type ToMarkdownState = {
17
+ enter: (name: string) => () => void;
18
+ createTracker: (info: unknown) => {
19
+ move: (value: string) => string;
20
+ current: () => unknown;
21
+ };
22
+ containerPhrasing: (node: MdNode, info: unknown) => string;
23
+ };
24
+ /** Writes a `highlight` node as `==words==`, `=={green}words==` for any colour but the default. */
25
+ export declare function highlightHandler(node: MdNode, _parent: unknown, state: ToMarkdownState, info: unknown): string;
26
+ export {};
@@ -0,0 +1,137 @@
1
+ // Highlighted words in Markdown: `==words==`, the syntax Obsidian and
2
+ // others share, with the colour in braces after the opening marks for any
3
+ // but the default amber.
4
+ //
5
+ // ==amber, the default== =={yellow}yellow== =={green}green== =={red}red==
6
+ //
7
+ // It's plain text where nothing reads it (the marks show), and no HTML:
8
+ // <Markdown> never renders raw HTML, and the editor never writes any.
9
+ // remark has no highlight, so these plugins read the marks from the text
10
+ // nodes of a parsed document: `remarkHighlights` for <Markdown> (a `mark`
11
+ // element with `data-color`) and `remarkHighlightNodes` for the rich-text
12
+ // editor (a `highlight` node it has a mark schema for, written back with
13
+ // `highlightHandler`). The merge (markdown-merge.ts) needs neither: to it
14
+ // the marks are text.
15
+ //
16
+ // Marks pair within one parent (a paragraph, a heading, a link's text),
17
+ // so words can wrap bold or a link but never run across a block. An opening
18
+ // `==` is followed by a non-space, a closing one preceded by one; a lone
19
+ // `==` stays text.
20
+ export const HIGHLIGHT_COLORS = ['amber', 'yellow', 'green', 'red'];
21
+ export const DEFAULT_HIGHLIGHT = 'amber';
22
+ export function isHighlightColor(value) {
23
+ return HIGHLIGHT_COLORS.includes(value);
24
+ }
25
+ // An opening mark (with its colour, if any) or a closing one.
26
+ const MARK = new RegExp(`==(?:[{](${HIGHLIGHT_COLORS.join('|')})[}])?`, 'g');
27
+ // A text node cut at its marks.
28
+ function cut(value) {
29
+ const pieces = [];
30
+ let last = 0;
31
+ for (const found of value.matchAll(MARK)) {
32
+ const at = found.index;
33
+ if (at > last)
34
+ pieces.push({ text: value.slice(last, at) });
35
+ pieces.push({ mark: true, color: found[1] ?? null, start: at, end: at + found[0].length, source: found[0] });
36
+ last = at + found[0].length;
37
+ }
38
+ if (last < value.length)
39
+ pieces.push({ text: value.slice(last) });
40
+ return pieces;
41
+ }
42
+ /** The children with their `==` pairs wrapped in `wrap(color, children)`; the same array when there's none. */
43
+ function wrapMarks(children, wrap) {
44
+ if (!children.some((child) => child.type === 'text' && typeof child.value === 'string' && child.value.includes('==')))
45
+ return children;
46
+ // Flat tokens: nodes, and marks with the text around them.
47
+ const tokens = [];
48
+ children.forEach((child, childIndex) => {
49
+ if (child.type !== 'text' || typeof child.value !== 'string' || !child.value.includes('==')) {
50
+ tokens.push({ node: child });
51
+ return;
52
+ }
53
+ const pieces = cut(child.value);
54
+ pieces.forEach((piece, index) => {
55
+ if ('text' in piece) {
56
+ tokens.push({ node: { type: 'text', value: piece.text } });
57
+ }
58
+ else {
59
+ const prev = pieces[index - 1];
60
+ const next = pieces[index + 1];
61
+ tokens.push({
62
+ mark: piece,
63
+ before: prev ? ('text' in prev ? prev.text.slice(-1) : 'x') : childIndex === 0 ? ' ' : 'x',
64
+ after: next ? ('text' in next ? next.text.slice(0, 1) : 'x') : childIndex === children.length - 1 ? ' ' : 'x',
65
+ });
66
+ }
67
+ });
68
+ });
69
+ const out = [];
70
+ let open = null;
71
+ const text = (value) => ({ type: 'text', value });
72
+ const isMark = (token) => 'mark' in token;
73
+ for (const token of tokens) {
74
+ if (!isMark(token)) {
75
+ out.push(token.node);
76
+ continue;
77
+ }
78
+ const space = (character) => /\s/.test(character);
79
+ if (open && token.mark.color === null && !space(token.before) && out.length > open.at + 1) {
80
+ const inner = out.splice(open.at + 1);
81
+ out[open.at] = wrap(open.color, inner);
82
+ open = null;
83
+ }
84
+ else if (!open && !space(token.after)) {
85
+ open = { color: token.mark.color ?? DEFAULT_HIGHLIGHT, at: out.length, source: token.mark.source };
86
+ out.push(text(token.mark.source));
87
+ }
88
+ else {
89
+ out.push(text(token.mark.source));
90
+ }
91
+ }
92
+ return merge(out);
93
+ }
94
+ // Neighbouring text nodes joined, so unpaired marks leave the text as it was.
95
+ function merge(nodes) {
96
+ const out = [];
97
+ for (const node of nodes) {
98
+ const last = out[out.length - 1];
99
+ if (last && last.type === 'text' && node.type === 'text')
100
+ out[out.length - 1] = { type: 'text', value: String(last.value) + String(node.value) };
101
+ else
102
+ out.push(node);
103
+ }
104
+ return out;
105
+ }
106
+ function visit(node, wrap) {
107
+ if (!node.children)
108
+ return;
109
+ for (const child of node.children)
110
+ visit(child, wrap);
111
+ if (node.type === 'code' || node.type === 'inlineCode')
112
+ return;
113
+ node.children = wrapMarks(node.children, wrap);
114
+ }
115
+ /** For react-markdown: highlighted words become `<mark data-color="amber">`. */
116
+ export function remarkHighlights() {
117
+ return (tree) => visit(tree, (color, children) => ({
118
+ type: 'highlight',
119
+ children,
120
+ data: { hName: 'mark', hProperties: { dataColor: color } },
121
+ }));
122
+ }
123
+ /** For the rich-text editor: highlighted words become `highlight` nodes (`color`), which its mark schema reads and `highlightHandler` writes. */
124
+ export function remarkHighlightNodes() {
125
+ return (tree) => visit(tree, (color, children) => ({ type: 'highlight', color, children }));
126
+ }
127
+ /** Writes a `highlight` node as `==words==`, `=={green}words==` for any colour but the default. */
128
+ export function highlightHandler(node, _parent, state, info) {
129
+ const exit = state.enter('highlight');
130
+ const tracker = state.createTracker(info);
131
+ const color = isHighlightColor(node.color) ? node.color : DEFAULT_HIGHLIGHT;
132
+ let value = tracker.move(color === DEFAULT_HIGHLIGHT ? '==' : `=={${color}}`);
133
+ value += tracker.move(state.containerPhrasing(node, { before: value, after: '=', ...tracker.current() }));
134
+ value += tracker.move('==');
135
+ exit();
136
+ return value;
137
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.47.2",
3
+ "version": "4.49.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": {
package/styles/index.css CHANGED
@@ -54,6 +54,10 @@
54
54
  --color-info-foreground: var(--info-foreground);
55
55
  --color-info-text: var(--info-text);
56
56
  --color-link: var(--link);
57
+ --color-highlight-amber: var(--highlight-amber);
58
+ --color-highlight-yellow: var(--highlight-yellow);
59
+ --color-highlight-green: var(--highlight-green);
60
+ --color-highlight-red: var(--highlight-red);
57
61
  --color-rule: var(--rule);
58
62
  --color-heading-band: var(--heading-band);
59
63
  --color-heading-bar: var(--heading-bar);
package/styles/theme.css CHANGED
@@ -209,6 +209,13 @@
209
209
  /* A link in written text: coloured as well as underlined, so it reads
210
210
  as a link at a glance (on white: 5.59). */
211
211
  --link: var(--blue-700);
212
+ /* Highlighted words in written text (the notes editor's highlight, four
213
+ colours): a wash behind words that keep --foreground. Light washes,
214
+ text on each measures 11 or more. */
215
+ --highlight-amber: oklch(0.9 0.1 75);
216
+ --highlight-yellow: oklch(0.94 0.13 100);
217
+ --highlight-green: oklch(0.9 0.1 150);
218
+ --highlight-red: oklch(0.89 0.07 25);
212
219
  /* A thematic break (`---`) in written text: a 2px line meant to be
213
220
  seen, stronger than --border's hairline (on white: 2.59). */
214
221
  --rule: var(--gray-400);
@@ -358,6 +365,11 @@
358
365
  --warning-text: var(--amber-400);
359
366
  --info-text: var(--blue-400);
360
367
  --link: var(--blue-400); /* on near-black: 8.90 */
368
+ /* Highlighted words: deep washes, --foreground on each measures 7 or more. */
369
+ --highlight-amber: oklch(0.42 0.09 75);
370
+ --highlight-yellow: oklch(0.44 0.09 100);
371
+ --highlight-green: oklch(0.4 0.08 150);
372
+ --highlight-red: oklch(0.4 0.1 25);
361
373
  --rule: var(--gray-600); /* on near-black: 2.54 */
362
374
  /* Stronger is lighter on the dark page: halfway from --muted to gray-700. */
363
375
  --heading-band: color-mix(in oklab, var(--gray-900), var(--gray-700));