@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.
- package/conventions/docs/cell-patterns.md +18 -10
- package/conventions/docs/design-language.md +21 -12
- package/dist/components/app/data-table.js +4 -2
- package/dist/components/app/markdown.js +17 -1
- package/dist/components/app/milkdown-editor.js +90 -7
- package/dist/components/app/rich-text-format-items.d.ts +9 -0
- package/dist/components/app/rich-text-format-items.js +14 -1
- package/dist/components/app/rich-text-formats.d.ts +12 -1
- package/dist/components/app/rich-text-formats.js +64 -1
- package/dist/components/app/rich-text-highlight-menu.d.ts +18 -0
- package/dist/components/app/rich-text-highlight-menu.js +43 -0
- package/dist/components/app/rich-text-slash.js +12 -4
- package/dist/components/app/rich-text-toolbar.d.ts +10 -2
- package/dist/components/app/rich-text-toolbar.js +38 -11
- package/dist/lib/markdown-highlight.d.ts +26 -0
- package/dist/lib/markdown-highlight.js +137 -0
- package/package.json +1 -1
- package/styles/index.css +4 -0
- package/styles/theme.css +12 -0
|
@@ -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–
|
|
30
|
-
- [A list of sub-items](#a-list-of-sub-items) · lines
|
|
31
|
-
- [In a table's rows](#in-a-tables-rows) · lines
|
|
32
|
-
- [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines
|
|
33
|
-
- [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines
|
|
34
|
-
- [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines
|
|
35
|
-
- [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines
|
|
36
|
-
- [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines
|
|
37
|
-
- [23. A Group menu, and a record read beside the list](#23-a-group-menu-and-a-record-read-beside-the-list) · lines
|
|
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–
|
|
13
|
-
- [Written text (notes)](#written-text-notes) · lines 277–
|
|
14
|
-
- [Density](#density) · lines
|
|
15
|
-
- [Widget cards (hub pages)](#widget-cards-hub-pages) · lines
|
|
16
|
-
- [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines
|
|
17
|
-
- [Notices](#notices) · lines
|
|
18
|
-
- [A value over time](#a-value-over-time) · lines
|
|
19
|
-
- [A list as a board](#a-list-as-a-board) · lines
|
|
20
|
-
- [A list as cards](#a-list-as-cards) · lines
|
|
21
|
-
- [Key numbers on a record](#key-numbers-on-a-record) · lines
|
|
22
|
-
- [Picking at random](#picking-at-random) · lines
|
|
23
|
-
- [Filling in what's missing](#filling-in-whats-missing) · lines
|
|
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 (!
|
|
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 .
|
|
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',
|
|
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) && !
|
|
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
|
-
|
|
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
|
|
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, ...
|
|
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,
|
|
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
|
|
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
|
|
57
|
+
// The Image and Full screen buttons, last in the row, after the formats.
|
|
42
58
|
const imageIndex = onImage ? items.length : -1;
|
|
43
|
-
const
|
|
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.
|
|
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));
|