@tristan2828/ui-foundation 3.20.0 → 3.21.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.
Files changed (33) hide show
  1. package/conventions/docs/add-an-entity.md +3 -3
  2. package/conventions/docs/cell-patterns.md +13 -4
  3. package/conventions/docs/design-language.md +22 -0
  4. package/dist/components/app/markdown.js +38 -20
  5. package/dist/components/app/milkdown-editor.js +189 -31
  6. package/dist/components/app/prose-look.d.ts +32 -0
  7. package/dist/components/app/prose-look.js +48 -0
  8. package/dist/components/app/rich-text-format-items.d.ts +13 -1
  9. package/dist/components/app/rich-text-format-items.js +43 -10
  10. package/dist/components/app/rich-text-formats.d.ts +15 -2
  11. package/dist/components/app/rich-text-formats.js +99 -10
  12. package/dist/components/app/rich-text-node-views.d.ts +31 -0
  13. package/dist/components/app/rich-text-node-views.js +79 -0
  14. package/dist/components/app/rich-text-shortcuts.d.ts +12 -0
  15. package/dist/components/app/rich-text-shortcuts.js +52 -0
  16. package/dist/components/app/rich-text-slash-menu.js +4 -1
  17. package/dist/components/app/rich-text-slash.js +26 -10
  18. package/dist/components/app/rich-text-table-menu.d.ts +20 -0
  19. package/dist/components/app/rich-text-table-menu.js +53 -0
  20. package/dist/components/app/rich-text-table.d.ts +19 -0
  21. package/dist/components/app/rich-text-table.js +162 -0
  22. package/dist/components/app/rich-text-toolbar.d.ts +11 -2
  23. package/dist/components/app/rich-text-toolbar.js +14 -23
  24. package/dist/components/app/rich-text-widgets.d.ts +19 -0
  25. package/dist/components/app/rich-text-widgets.js +34 -0
  26. package/dist/components/ui/badge.d.ts +1 -1
  27. package/dist/components/ui/dropdown-menu.d.ts +29 -0
  28. package/dist/components/ui/dropdown-menu.js +51 -0
  29. package/dist/lib/markdown-callout.d.ts +38 -0
  30. package/dist/lib/markdown-callout.js +103 -0
  31. package/package.json +1 -1
  32. package/styles/index.css +17 -0
  33. package/styles/theme.css +24 -0
@@ -275,9 +275,9 @@ is a filled-in example while the Widgets demo is still here).
275
275
  `<a target="_blank" rel="noreferrer">` with an `ExternalLinkIcon`
276
276
  and a visually hidden "(opens in a new tab)".
277
277
  - **Long text the plan marks Markdown** renders through the
278
- foundation's `<Markdown>`: headings, lists, tables and links, links
279
- opening in a new tab; raw HTML shows as the characters typed, never
280
- as markup. The app needs no Markdown dependency of its own. Plain
278
+ foundation's `<Markdown>`: headings, lists, tables, callouts
279
+ (`> [!NOTE]`) and links, links opening in a new tab; raw HTML shows
280
+ as the characters typed, never as markup. The app needs no Markdown dependency of its own. Plain
281
281
  long text renders as text (`whitespace-pre-line` keeps its line
282
282
  breaks).
283
283
  - **Quick actions: only the fields the plan's `Quick actions` line
@@ -740,15 +740,24 @@ const price = editInPlace({
740
740
  (a count shows from 80% of it, in the destructive text tone once over).
741
741
  Its keys: Ctrl/Cmd+K adds or edits a link (web and email addresses
742
742
  only), Ctrl/Cmd+Shift+Enter ticks the task item the caret is in, and
743
- Markdown pasted as plain text arrives formatted. Ctrl/Cmd+Enter stays
744
- the save. A formatting toolbar sits above the text (bold to link, each
743
+ Markdown pasted as plain text arrives formatted. Notion's block keys
744
+ work: Ctrl+Shift+1 to 3 for headings, +4 tasks, +5 bullets, +6 numbers,
745
+ +8 a code block, +0 back to text (Cmd+Option on a Mac), and
746
+ Ctrl/Cmd+Shift+S strikes through. Ctrl/Cmd+Enter stays the save, in a
747
+ table too. A formatting toolbar sits above the text (bold to link, each
745
748
  button naming its shortcut), and a smaller one floats over selected
746
749
  words (bold, italic, strikethrough, code, link); Alt+F10 reaches the
747
750
  floating one, else the fixed one. Both are part of the field, so moving
748
751
  to them doesn't save, and Esc hides the floating one before it gives up
749
752
  the edit. "/" typed at a line's start lists the blocks the line can
750
- become (headings, lists, quote, code block); typing filters, Enter or
751
- Tab picks, and Esc closes the list before it gives up the edit.
753
+ become (three headings, lists, quote, callout, code block, divider,
754
+ table); typing filters ("/warn" finds a warning callout), Enter or Tab
755
+ picks, and Esc closes the list before it gives up the edit. A callout
756
+ is GitHub's `> [!NOTE]` (note, tip, important, warning, caution), its
757
+ icon a menu of kinds. In a table, Tab in the last cell adds a row,
758
+ Enter goes down a column (and out from the last row), bars under and
759
+ beside it add a row or a column, and the toolbar's Table menu inserts
760
+ rows and columns, aligns a column and deletes.
752
761
  `toolbar={false}` leaves out the toolbars and the slash menu where a
753
762
  value is short and the shortcuts are enough.
754
763
  - **The save is the single-field save** (`useRecordUpdate` with
@@ -172,6 +172,28 @@ height and weight together, from tokens in the package's `styles/theme.css`:
172
172
  - A shadcn primitive keeps its own sizes (`CardTitle`, `Button`). Don't put
173
173
  a role on one: its built-in size classes would then compete with the role.
174
174
 
175
+ ### Written text (notes)
176
+
177
+ Long text written as Markdown (`<Markdown>` and `RichTextEditor`) has its
178
+ own three heading roles, so `#`, `##` and `###` in a note never look
179
+ alike. They're for headings inside the text only, never a screen's own
180
+ outline (that stays `type-page-title` and `type-section-title`):
181
+
182
+ | Class | For | Size / weight |
183
+ |---|---|---|
184
+ | `type-heading-1` | `#` in a note, with a rule under it | 26px / 700 |
185
+ | `type-heading-2` | `##` | 20px / 600 |
186
+ | `type-heading-3` | `###` | 17px / 600 |
187
+
188
+ The same text has two colour tokens of its own: `--link` (`text-link`), a
189
+ link's colour, blue as well as underlined (5.59:1 on white, 8.90:1 on the
190
+ dark page), and `--rule` (`bg-rule`), the 2px line a `---` divider draws,
191
+ stronger than `--border`'s hairline. A callout (`> [!NOTE]`) is a 10% wash
192
+ of its kind's tone behind ordinary text, with the icon in the tone's text
193
+ shade: note `info`, tip `success`, important category slot 2, warning
194
+ `warning`, caution `destructive`. The package draws all of it; an app
195
+ only renders `<Markdown>` or the editor.
196
+
175
197
  ## Density
176
198
 
177
199
  Tables have three densities, set with `data-density` on **any ancestor**
@@ -4,7 +4,12 @@ import remarkGfm from 'remark-gfm';
4
4
  import { cn } from 'cn';
5
5
  import { ExternalLinkIcon, SquareCheckIcon, SquareIcon } from 'lucide-react';
6
6
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table.js';
7
- const REMARK_PLUGINS = [remarkGfm];
7
+ import { CALLOUT_KINDS, remarkCallouts } from '../../lib/markdown-callout.js';
8
+ import { CALLOUT_BOX, CALLOUT_LOOK, headingClass, PROSE } from './prose-look.js';
9
+ const REMARK_PLUGINS = [remarkGfm, remarkCallouts];
10
+ function isCalloutKind(value) {
11
+ return CALLOUT_KINDS.includes(value);
12
+ }
8
13
  // Every element react-markdown hands over carries `node` (the syntax tree
9
14
  // node). It isn't a DOM attribute, so it's dropped before spreading.
10
15
  function omitNode(props) {
@@ -13,33 +18,44 @@ function omitNode(props) {
13
18
  return rest;
14
19
  }
15
20
  const COMPONENTS = {
16
- h1: (props) => _jsx("h3", { ...omitNode(props), className: "type-section-title mt-2 text-foreground" }),
17
- h2: (props) => _jsx("h4", { ...omitNode(props), className: "type-section-title mt-2 text-foreground" }),
18
- h3: (props) => _jsx("h5", { ...omitNode(props), className: "type-label mt-1 text-foreground" }),
19
- h4: (props) => _jsx("h6", { ...omitNode(props), className: "type-label mt-1 text-foreground" }),
20
- h5: (props) => _jsx("h6", { ...omitNode(props), className: "type-label mt-1 text-foreground" }),
21
- h6: (props) => _jsx("h6", { ...omitNode(props), className: "type-label mt-1 text-foreground" }),
21
+ h1: (props) => _jsx("h3", { ...omitNode(props), className: cn(headingClass(1), 'text-foreground') }),
22
+ h2: (props) => _jsx("h4", { ...omitNode(props), className: cn(headingClass(2), 'text-foreground') }),
23
+ h3: (props) => _jsx("h5", { ...omitNode(props), className: cn(headingClass(3), 'text-foreground') }),
24
+ h4: (props) => _jsx("h6", { ...omitNode(props), className: cn(headingClass(4), 'text-foreground') }),
25
+ h5: (props) => _jsx("h6", { ...omitNode(props), className: cn(headingClass(5), 'text-foreground') }),
26
+ h6: (props) => _jsx("h6", { ...omitNode(props), className: cn(headingClass(6), 'text-foreground') }),
22
27
  a: (props) => {
23
28
  const { href, children, ...rest } = omitNode(props);
24
29
  if (!href)
25
30
  return _jsx("span", { children: children });
26
- return (_jsxs("a", { ...rest, href: href, target: "_blank", rel: "noreferrer", className: "text-primary underline underline-offset-4 hover:text-primary/80", children: [children, _jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "ml-0.5 inline size-3 align-baseline" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] }));
27
- },
28
- ul: (props) => {
29
- const { className, ...rest } = omitNode(props);
30
- const isTaskList = className?.includes('contains-task-list');
31
- return _jsx("ul", { ...rest, className: cn('flex flex-col gap-1', isTaskList ? 'list-none' : 'list-disc pl-6') });
31
+ return (_jsxs("a", { ...rest, href: href, target: "_blank", rel: "noreferrer", className: PROSE.link, children: [children, _jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "ml-0.5 inline size-3 align-baseline" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] }));
32
32
  },
33
+ // A list can mix plain and task items (`- a` then `- [ ] b` is one
34
+ // list): each task item drops its own bullet, the rest keep theirs. Its
35
+ // box is inline, so a loose list's item (its text in a <p>) lays out
36
+ // the same as a tight one's.
37
+ ul: (props) => _jsx("ul", { ...omitNode(props), className: "flex list-disc flex-col gap-1 pl-6" }),
33
38
  ol: (props) => _jsx("ol", { ...omitNode(props), className: "flex list-decimal flex-col gap-1 pl-6" }),
34
39
  li: (props) => {
35
40
  const { className, ...rest } = omitNode(props);
36
- return _jsx("li", { ...rest, className: cn(className?.includes('task-list-item') && 'flex items-start gap-2') });
41
+ return _jsx("li", { ...rest, className: cn(className?.includes('task-list-item') && '-ml-6 list-none') });
37
42
  },
38
43
  // A GitHub task-list box (`- [x] done`). Read-only, so a glyph with its
39
44
  // state in words, never a checkbox someone will try to tick.
40
- input: ({ checked }) => checked ? (_jsx(SquareCheckIcon, { role: "img", "aria-label": "Done", className: "mt-0.5 size-4 shrink-0 text-foreground" })) : (_jsx(SquareIcon, { role: "img", "aria-label": "Not done", className: "mt-0.5 size-4 shrink-0 text-muted-foreground" })),
45
+ 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" })),
41
46
  blockquote: (props) => (_jsx("blockquote", { ...omitNode(props), className: "flex flex-col gap-3 border-l-2 border-border pl-4 text-muted-foreground" })),
42
- hr: () => _jsx("hr", { className: "border-border" }),
47
+ hr: () => _jsx("hr", { className: PROSE.rule }),
48
+ // A callout (remarkCallouts marks it): its kind's wash and icon, named
49
+ // for screen readers by the kind ("Note: …").
50
+ div: (props) => {
51
+ const { children, ...rest } = omitNode(props);
52
+ const kind = rest['data-callout'];
53
+ if (!isCalloutKind(kind))
54
+ return _jsx("div", { ...rest, children: children });
55
+ const look = CALLOUT_LOOK[kind];
56
+ const Icon = look.icon;
57
+ return (_jsxs("div", { role: "note", "data-callout": kind, className: cn(CALLOUT_BOX, look.box), children: [_jsx(Icon, { "aria-hidden": "true", className: cn('mt-0.5 size-4 shrink-0', look.tint) }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsxs("span", { className: "sr-only", children: [look.label, ": "] }), children] })] }));
58
+ },
43
59
  pre: (props) => (_jsx("pre", { ...omitNode(props), className: "overflow-x-auto rounded-lg bg-muted p-3 font-mono text-foreground [&_code]:bg-transparent [&_code]:p-0" })),
44
60
  code: (props) => _jsx("code", { ...omitNode(props), className: "rounded bg-muted px-1 py-0.5 font-mono text-foreground" }),
45
61
  img: (props) => {
@@ -48,12 +64,14 @@ const COMPONENTS = {
48
64
  },
49
65
  // GFM tables use the package's own table, zebra stripes and density
50
66
  // tokens included, so a table in the notes reads like every other table.
51
- table: (props) => _jsx(Table, { ...omitNode(props) }),
52
- thead: (props) => _jsx(TableHeader, { ...omitNode(props) }),
67
+ table: (props) => _jsx(Table, { ...omitNode(props), containerClassName: "rounded-lg border border-border" }),
68
+ thead: (props) => _jsx(TableHeader, { ...omitNode(props), className: "bg-muted" }),
53
69
  tbody: (props) => _jsx(TableBody, { ...omitNode(props) }),
54
70
  tr: (props) => _jsx(TableRow, { ...omitNode(props) }),
55
- th: (props) => _jsx(TableHead, { ...omitNode(props) }),
56
- td: (props) => _jsx(TableCell, { ...omitNode(props) }),
71
+ // Notes' cells wrap (a cell can hold a sentence) and have a line
72
+ // between columns, so a wide table still reads column by column.
73
+ th: (props) => _jsx(TableHead, { ...omitNode(props), className: "border-r border-border whitespace-normal last:border-r-0" }),
74
+ td: (props) => _jsx(TableCell, { ...omitNode(props), className: "border-r border-border whitespace-normal last:border-r-0" }),
57
75
  };
58
76
  export function Markdown({ children, className, ...rest }) {
59
77
  return (_jsx("div", { "data-slot": "markdown", ...rest, className: cn('flex min-w-0 flex-col gap-3 type-body text-foreground break-words', className), children: _jsx(ReactMarkdown, { remarkPlugins: REMARK_PLUGINS, components: COMPONENTS, children: children }) }));
@@ -13,13 +13,22 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
13
13
  // in the text shows as its characters (an uneditable chip, Milkdown's
14
14
  // html node), and HTML pasted as such a chip becomes plain text.
15
15
  // - Headings render from <h3>, like <Markdown>: the page owns <h1> and
16
- // <h2>. A task-list item renders a real checkbox that toggles it.
16
+ // <h2>; they look like <Markdown>'s (prose-look.ts). A task-list item
17
+ // renders a real checkbox that toggles it.
18
+ // - Callouts (`> [!NOTE]`, src/lib/markdown-callout.ts): a node of their
19
+ // own, drawn as <Markdown> draws them, the icon a menu of kinds.
20
+ // - Tables in a box that scrolls sideways, with Notion's "add a row" and
21
+ // "add a column" bars, Tab in the last cell adding a row and Enter going
22
+ // down a column (rich-text-table.ts); the toolbar's Table menu does the
23
+ // rest.
17
24
  // - Edited blocks are written in the document's own bullet and rule marks.
18
25
  // - Markdown pasted as plain text arrives formatted (still no HTML), and a
19
26
  // copy out of the editor is Markdown.
20
27
  // - Ctrl/Cmd+K adds or edits a link (a box at the caret: web and email
21
28
  // addresses only); Ctrl/Cmd+Shift+Enter ticks the task item the caret is
22
- // in. Ctrl/Cmd+Enter stays the field's save.
29
+ // in. Ctrl/Cmd+Enter stays the field's save, in a table too. Notion's
30
+ // block keys (Ctrl+Shift+1 for a heading, +5 for bullets, …:
31
+ // rich-text-shortcuts.ts).
23
32
  // - A placeholder in an empty document, and a count of the Markdown's
24
33
  // length as it nears `maxLength`.
25
34
  // - A fixed toolbar above the text (rich-text-toolbar.tsx), and a
@@ -30,26 +39,34 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
30
39
  // line's start lists the blocks it can become, also unless
31
40
  // `toolbar={false}`.
32
41
  import { useEffect, useId, useRef, useState } from 'react';
42
+ import { createPortal } from 'react-dom';
33
43
  import { defaultValueCtx, Editor, editorViewCtx, editorViewOptionsCtx, parserCtx, remarkStringifyOptionsCtx, rootCtx, schemaCtx, serializerCtx, } from '@milkdown/kit/core';
34
44
  import { history } from '@milkdown/kit/plugin/history';
35
45
  import { commands, inputRules, keymap, markInputRules, plugins, remarkPreserveEmptyLinePlugin, schema, } from '@milkdown/kit/preset/commonmark';
36
46
  import { gfm } from '@milkdown/kit/preset/gfm';
37
47
  import { Fragment, Slice } from '@milkdown/kit/prose/model';
38
- import { NodeSelection, Plugin } from '@milkdown/kit/prose/state';
48
+ import { NodeSelection, Plugin, TextSelection } from '@milkdown/kit/prose/state';
49
+ import { isInTable, TableMap } from '@milkdown/kit/prose/tables';
39
50
  import { Decoration, DecorationSet } from '@milkdown/kit/prose/view';
40
- import { $prose, getMarkdown } from '@milkdown/kit/utils';
51
+ import { $nodeSchema, $prose, $remark, getMarkdown } from '@milkdown/kit/utils';
41
52
  import { cn } from 'cn';
42
53
  import { Button } from '../ui/button.js';
43
54
  import { Field, FieldError, FieldLabel } from '../ui/field.js';
44
55
  import { Input } from '../ui/input.js';
45
56
  import { Popover, PopoverContent } from '../ui/popover.js';
46
57
  import { linkHref } from '../../lib/link-href.js';
58
+ import { calloutHandler, remarkCalloutNodes } from '../../lib/markdown-callout.js';
47
59
  import { MarkdownMergeError, markdownStyle, mergeMarkdown } from '../../lib/markdown-merge.js';
60
+ import { headingClass } from './prose-look.js';
48
61
  import { RichTextFloatingToolbar } from './rich-text-floating-toolbar.js';
49
- import { toggleFormat } from './rich-text-formats.js';
62
+ import { tableAt, toggleFormat } from './rich-text-formats.js';
63
+ import { calloutKind, calloutView, tableView } from './rich-text-node-views.js';
64
+ import { handleShortcut } from './rich-text-shortcuts.js';
65
+ import { addRowAtEnd, handleTableKey, runTableAction } from './rich-text-table.js';
50
66
  import { applySlash, closeSlash, slashItems, slashOptionId, slashPlugin, slashQuery, } from './rich-text-slash.js';
51
67
  import { RichTextSlashMenu } from './rich-text-slash-menu.js';
52
68
  import { RichTextToolbar } from './rich-text-toolbar.js';
69
+ import { CalloutKindMenu, TableBar } from './rich-text-widgets.js';
53
70
  // The commonmark preset, minus the plugin that writes `<br />`. A $remark
54
71
  // plugin is a pair (its options and the plugin), spread into `plugins`.
55
72
  const LEFT_OUT = remarkPreserveEmptyLinePlugin;
@@ -61,6 +78,34 @@ const COMMONMARK = [
61
78
  ...keymap,
62
79
  ...plugins.filter((plugin) => !LEFT_OUT.includes(plugin)),
63
80
  ];
81
+ // Callouts: a node of their own, read from the quote remarkCalloutNodes
82
+ // marks and written back as one with its marker (calloutHandler).
83
+ const calloutRemark = $remark('remarkCalloutNodes', () => remarkCalloutNodes);
84
+ const calloutSchema = $nodeSchema('callout', () => ({
85
+ content: 'block+',
86
+ group: 'block',
87
+ defining: true,
88
+ attrs: { kind: { default: 'note' }, marker: { default: null } },
89
+ parseDOM: [
90
+ {
91
+ tag: 'div[data-callout]',
92
+ getAttrs: (dom) => ({ kind: calloutKind(dom.dataset.callout) }),
93
+ },
94
+ ],
95
+ toDOM: (node) => ['div', { 'data-callout': String(node.attrs.kind) }, 0],
96
+ parseMarkdown: {
97
+ match: (node) => node.type === 'callout',
98
+ runner: (state, node, type) => {
99
+ state.openNode(type, { kind: calloutKind(node.kind), marker: node.marker ?? null }).next(node.children).closeNode();
100
+ },
101
+ },
102
+ toMarkdown: {
103
+ match: (node) => node.type.name === 'callout',
104
+ runner: (state, node) => {
105
+ state.openNode('callout', undefined, { kind: node.attrs.kind, marker: node.attrs.marker }).next(node.content).closeNode();
106
+ },
107
+ },
108
+ }));
64
109
  // Raw HTML pasted in as Milkdown's html chip becomes the plain text it
65
110
  // shows, so a paste can never add markup to the Markdown.
66
111
  function htmlToText(fragment) {
@@ -78,7 +123,9 @@ function htmlToText(fragment) {
78
123
  return Fragment.fromArray(nodes);
79
124
  }
80
125
  function headingView(node) {
81
- const dom = document.createElement(`h${Math.min(Number(node.attrs.level) + 2, 6)}`);
126
+ const level = Number(node.attrs.level);
127
+ const dom = document.createElement(`h${Math.min(level + 2, 6)}`);
128
+ dom.className = headingClass(level);
82
129
  return {
83
130
  dom,
84
131
  contentDOM: dom,
@@ -217,6 +264,9 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
217
264
  const slashMenuRef = useRef(null);
218
265
  // Opens the link box: made with the editor, used by the toolbar's Link.
219
266
  const openLinkRef = useRef(() => { });
267
+ // The node views' slots (a callout's icon, a table's bars), each
268
+ // rendered into through a portal.
269
+ const [widgets, setWidgets] = useState([]);
220
270
  // The latest props, for the editor's callbacks, which are set up once.
221
271
  const latest = useRef({ onChange, onProblem, readOnly, placeholder });
222
272
  useEffect(() => {
@@ -306,6 +356,33 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
306
356
  transformPasted: (slice) => new Slice(htmlToText(slice.content), slice.openStart, slice.openEnd),
307
357
  },
308
358
  }));
359
+ // The node views' slots, by slot; React hears of a change once per
360
+ // task, however many node views changed in it.
361
+ const slots = new Map();
362
+ let syncQueued = false;
363
+ const sync = () => {
364
+ if (syncQueued)
365
+ return;
366
+ syncQueued = true;
367
+ queueMicrotask(() => {
368
+ syncQueued = false;
369
+ if (!cancelled)
370
+ setWidgets([...slots.values()]);
371
+ });
372
+ };
373
+ const registry = {
374
+ set: (widget) => {
375
+ const before = slots.get(widget.slot);
376
+ if (before && before.type === widget.type && (before.type !== 'callout' || before.kind === widget.kind))
377
+ return;
378
+ slots.set(widget.slot, widget);
379
+ sync();
380
+ },
381
+ remove: (slot) => {
382
+ if (slots.delete(slot))
383
+ sync();
384
+ },
385
+ };
309
386
  // Opens the link box at the start of what the link applies to.
310
387
  const openLink = (view) => {
311
388
  if (!view.editable)
@@ -428,47 +505,66 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
428
505
  .config((ctx) => {
429
506
  ctx.set(rootCtx, root);
430
507
  ctx.set(defaultValueCtx, original);
431
- ctx.update(remarkStringifyOptionsCtx, (options) => ({ ...options, ...markdownStyle(original) }));
508
+ ctx.update(remarkStringifyOptionsCtx, (options) => ({
509
+ ...options,
510
+ ...markdownStyle(original),
511
+ handlers: { ...options.handlers, callout: calloutHandler },
512
+ }));
432
513
  ctx.update(editorViewOptionsCtx, (options) => ({
433
514
  ...options,
434
515
  editable: () => !latest.current.readOnly,
435
516
  attributes,
436
- // The slash menu's keys, as view props: before Milkdown's own
437
- // keymap, whose Enter would split the line.
517
+ // The slash menu's keys, the editor's own shortcuts and its table
518
+ // keys, as view props: before Milkdown's own keymap, whose Enter
519
+ // would split the line (or leave a table).
438
520
  handleKeyDown: (view, event) => {
439
521
  const menu = slashMenuRef.current;
440
- if (!menu)
441
- return false;
442
- const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
443
522
  const plain = !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey;
444
- if (step && plain) {
445
- event.preventDefault();
446
- const count = menu.items.length;
447
- setSlashActive({ key: menu.key, index: (menu.index + step + count) % count });
448
- return true;
523
+ if (menu) {
524
+ const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
525
+ if (step && plain) {
526
+ event.preventDefault();
527
+ const count = menu.items.length;
528
+ setSlashActive({ key: menu.key, index: (menu.index + step + count) % count });
529
+ return true;
530
+ }
531
+ if ((event.key === 'Enter' || event.key === 'Tab') && plain) {
532
+ event.preventDefault();
533
+ applySlash(view, menu.span, menu.items[menu.index].id);
534
+ return true;
535
+ }
536
+ // Esc closes the menu only: the field's own Esc (editing in
537
+ // place gives up the edit) waits for the next one.
538
+ if (event.key === 'Escape') {
539
+ event.preventDefault();
540
+ view.dispatch(closeSlash(view.state));
541
+ return true;
542
+ }
449
543
  }
450
- if ((event.key === 'Enter' || event.key === 'Tab') && plain) {
451
- event.preventDefault();
452
- applySlash(view, menu.span, menu.items[menu.index].id);
544
+ if (!view.editable)
545
+ return false;
546
+ if (handleShortcut(view.state, view.dispatch, event))
453
547
  return true;
454
- }
455
- // Esc closes the menu only: the field's own Esc (editing in
456
- // place gives up the edit) waits for the next one.
457
- if (event.key === 'Escape') {
458
- event.preventDefault();
459
- view.dispatch(closeSlash(view.state));
548
+ if (handleTableKey(view.state, view.dispatch, event))
460
549
  return true;
461
- }
462
- return false;
550
+ // Ctrl/Cmd+Enter in a table is the field's (a save), not
551
+ // Milkdown's "leave the table": taken here, so its keymap never
552
+ // sees it, and left to reach the field.
553
+ const mod = event.metaKey || event.ctrlKey;
554
+ return mod && !event.shiftKey && !event.altKey && event.key === 'Enter' && isInTable(view.state);
463
555
  },
464
556
  nodeViews: {
465
557
  heading: headingView,
558
+ callout: (node, _view, getPos) => calloutView(node, getPos, registry),
559
+ table: (_node, _view, getPos) => tableView(getPos, registry),
466
560
  list_item: (node, view, getPos) => node.attrs.checked == null ? undefined : taskItemView(node, view, getPos),
467
561
  },
468
562
  }));
469
563
  })
470
564
  .use(COMMONMARK)
471
565
  .use(gfm)
566
+ .use(calloutRemark)
567
+ .use(calloutSchema)
472
568
  .use(history)
473
569
  .use(changes)
474
570
  .use(slashing)
@@ -523,6 +619,53 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
523
619
  view.focus();
524
620
  });
525
621
  };
622
+ // The editable element: where a menu over the text gives focus back
623
+ // (ProseMirror puts the caret back where it was as it takes it).
624
+ const textElement = () => editorView?.dom ?? null;
625
+ // A Table menu item, on the caret's table (the menu gives focus back).
626
+ const runTable = (action) => {
627
+ if (editorView)
628
+ runTableAction(editorView.state, editorView.dispatch, action);
629
+ };
630
+ // A callout's menu: its kind changed, or the callout taken away (its
631
+ // blocks kept). The menu gives focus back to the text.
632
+ const changeCallout = (getPos, kind) => {
633
+ if (!editorView)
634
+ return;
635
+ const pos = getPos();
636
+ const node = pos === undefined ? null : editorView.state.doc.nodeAt(pos);
637
+ if (pos === undefined || node?.type.name !== 'callout')
638
+ return;
639
+ const tr = editorView.state.tr;
640
+ if (kind)
641
+ tr.setNodeMarkup(pos, undefined, { ...node.attrs, kind });
642
+ else
643
+ tr.replaceWith(pos, pos + node.nodeSize, node.content).setSelection(TextSelection.near(tr.doc.resolve(pos + 1)));
644
+ editorView.dispatch(tr);
645
+ };
646
+ // A table's bar: the caret into its last cell, then a row at its end
647
+ // (the caret into its first cell) or a column at its right (the caret
648
+ // into its header).
649
+ const addToTable = (getPos, side) => {
650
+ if (!editorView)
651
+ return;
652
+ const pos = getPos();
653
+ const table = pos === undefined ? null : editorView.state.doc.nodeAt(pos);
654
+ if (pos === undefined || table?.type.name !== 'table')
655
+ return;
656
+ const last = TextSelection.near(editorView.state.doc.resolve(pos + table.nodeSize - 1), -1);
657
+ editorView.dispatch(editorView.state.tr.setSelection(last));
658
+ if (side === 'row')
659
+ addRowAtEnd(editorView.state, editorView.dispatch);
660
+ else if (runTableAction(editorView.state, editorView.dispatch, 'columnRight')) {
661
+ const added = editorView.state.doc.nodeAt(pos);
662
+ const map = TableMap.get(added);
663
+ const header = TextSelection.near(editorView.state.doc.resolve(pos + 1 + map.map[map.width - 1] + 1));
664
+ editorView.dispatch(editorView.state.tr.setSelection(header).scrollIntoView());
665
+ }
666
+ editorView.focus();
667
+ };
668
+ const caretTable = editorState ? tableAt(editorState)?.pos : undefined;
526
669
  // The floating toolbar: words selected, focus in the field, the mouse up,
527
670
  // and nothing else open over the text.
528
671
  const selection = editorState?.selection;
@@ -551,7 +694,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
551
694
  return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: setField, "data-slot": "rich-text-editor", className: cn('relative flex w-full min-w-0 flex-col rounded-lg border border-input bg-transparent type-body text-foreground break-words transition-[border-color,box-shadow]', 'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', 'has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/20', className), children: [toolbar && (_jsx(RichTextToolbar, { ref: toolbarRef, state: editorState, readOnly: readOnly,
552
695
  // Only once the editor is made: until then the editable element,
553
696
  // and its id, aren't in the page (axe: aria-valid-attr-value).
554
- controls: editorState ? editableId : undefined, onFormat: runFormat })), _jsx("div", { ref: rootRef, className: cn('min-h-24 px-2.5 py-2',
697
+ controls: editorState ? editableId : undefined, onFormat: runFormat, onTable: runTable, text: textElement })), _jsx("div", { ref: rootRef, className: cn('min-h-24 px-2.5 py-2',
555
698
  // Read-only text is muted; the toolbar's buttons say so by being disabled.
556
699
  'has-[[aria-readonly=true]]:text-muted-foreground',
557
700
  // The same type roles <Markdown> uses, so text reads the same
@@ -559,11 +702,26 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
559
702
  '[&_.ProseMirror]:flex [&_.ProseMirror]:min-h-20 [&_.ProseMirror]:flex-col [&_.ProseMirror]:gap-3',
560
703
  // ProseMirror's own required style (prosemirror-view's CSS): spaces
561
704
  // as typed, so one at the end of a line isn't kept as &nbsp;.
562
- '[&_.ProseMirror]:whitespace-pre-wrap [&_.ProseMirror]:break-words', '[&_h3]:type-section-title [&_h4]:type-section-title [&_h5]:type-label [&_h6]:type-label', '[&_ul]:flex [&_ul]:list-disc [&_ul]:flex-col [&_ul]:gap-1 [&_ul]:pl-6', '[&_ol]:flex [&_ol]:list-decimal [&_ol]:flex-col [&_ol]:gap-1 [&_ol]:pl-6', '[&_li[data-item-type=task]]:-ml-6 [&_li[data-item-type=task]]:flex [&_li[data-item-type=task]]:list-none [&_li[data-item-type=task]]:items-start [&_li[data-item-type=task]]:gap-2', '[&_li[data-item-type=task]>input]:mt-1 [&_li[data-item-type=task]>input]:size-4 [&_li[data-item-type=task]>input]:accent-primary', '[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4', '[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground', '[&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:font-mono', '[&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-muted [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:font-mono', '[&_hr]:border-border', '[&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:type-label',
705
+ '[&_.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',
706
+ // Headings, callouts and tables are drawn by their node views;
707
+ // these are the elements Milkdown draws itself.
708
+ '[&_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',
709
+ // A table: a header row on the muted fill, a line between every
710
+ // cell, cells that wrap and never shrink to nothing; selected
711
+ // cells (a drag across them) tinted.
712
+ '[&_table]:w-full [&_table]:border-collapse', '[&_:is(td,th)]:min-w-28 [&_:is(td,th)]:border-r [&_:is(td,th)]:border-b [&_:is(td,th)]:border-border [&_:is(td,th)]:px-(--table-cell-px) [&_:is(td,th)]:py-(--table-cell-py) [&_:is(td,th)]:align-top', '[&_:is(td,th):last-child]:border-r-0 [&_tr:last-child>td]:border-b-0', '[&_th]:bg-muted [&_th]:text-left [&_th]:type-label', '[&_.selectedCell]:bg-info/15',
563
713
  // Raw HTML from the text: shown as its characters, never as markup.
564
714
  '[&_span[data-type=html]]:rounded [&_span[data-type=html]]:bg-muted [&_span[data-type=html]]:px-1 [&_span[data-type=html]]:font-mono [&_span[data-type=html]]:text-foreground',
565
715
  // The placeholder, in an empty document only (a decoration).
566
- '[&_p[data-placeholder]]:before:pointer-events-none [&_p[data-placeholder]]:before:float-left [&_p[data-placeholder]]:before:h-0 [&_p[data-placeholder]]:before:text-muted-foreground [&_p[data-placeholder]]:before:content-[attr(data-placeholder)]') }), slashOpen && slash && editorView && (_jsx(RichTextSlashMenu, { id: slashListId, view: editorView, at: slash.to, field: field, items: slashMatches, active: slashIndex, onHighlight: (index) => setSlashActive({ key: slashMenuKey, index }), onPick: (format) => applySlash(editorView, slash, format) })), floatingOpen && editorView && editorState && (_jsx(RichTextFloatingToolbar, { ref: floatingRef, view: editorView, state: editorState, field: field, controls: editableId, onFormat: runFormat, onDismiss: () => setDismissed({ from: editorState.selection.from, to: editorState.selection.to }), onReturn: returnToText, onBlur: (event) => {
716
+ '[&_p[data-placeholder]]:before:pointer-events-none [&_p[data-placeholder]]:before:float-left [&_p[data-placeholder]]:before:h-0 [&_p[data-placeholder]]:before:text-muted-foreground [&_p[data-placeholder]]:before:content-[attr(data-placeholder)]') }), widgets.map((widget) => {
717
+ if (widget.type === 'callout')
718
+ return createPortal(_jsx(CalloutKindMenu, { kind: widget.kind, readOnly: readOnly, onPick: (kind) => changeCallout(widget.getPos, kind), onRemove: () => changeCallout(widget.getPos, null), text: textElement }), widget.slot, widget.key);
719
+ const active = caretTable !== undefined && caretTable === widget.getPos();
720
+ return [
721
+ createPortal(_jsx(TableBar, { side: "row", active: active, readOnly: readOnly, onAdd: () => addToTable(widget.getPos, 'row') }), widget.slot, `${widget.key}-row`),
722
+ createPortal(_jsx(TableBar, { side: "column", active: active, readOnly: readOnly, onAdd: () => addToTable(widget.getPos, 'column') }), widget.column, `${widget.key}-column`),
723
+ ];
724
+ }), 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) => {
567
725
  const next = event.relatedTarget;
568
726
  if (!editorView.dom.contains(next) && !floatingRef.current?.contains(next))
569
727
  setFocused(false);
@@ -0,0 +1,32 @@
1
+ import { type LucideIcon } from 'lucide-react';
2
+ import type { CalloutKind } from '../../lib/markdown-callout.js';
3
+ /**
4
+ * Classes for written text's own elements. No colours on the headings:
5
+ * <Markdown> adds the text colour, and the editor's read-only text is
6
+ * muted as a whole.
7
+ */
8
+ export declare const PROSE: {
9
+ /** `#`: the largest, with a rule under it, so it reads as the top of the outline. */
10
+ readonly heading1: "type-heading-1 mt-4 border-b border-border pb-1.5 first:mt-0";
11
+ readonly heading2: "type-heading-2 mt-3 first:mt-0";
12
+ readonly heading3: "type-heading-3 mt-1 first:mt-0";
13
+ /** `####` and deeper: a label, as before. */
14
+ readonly heading4: "type-label mt-1 first:mt-0";
15
+ /** Coloured as well as underlined. */
16
+ readonly link: "text-link underline decoration-link/40 underline-offset-4 transition-colors hover:decoration-link";
17
+ /** `---`: a 2px line in the rule tone, with room around it. */
18
+ readonly rule: "my-2 h-0.5 rounded-full border-0 bg-rule";
19
+ };
20
+ /** A heading's classes by its Markdown level (1 for `#`). */
21
+ export declare function headingClass(level: number): string;
22
+ export type CalloutLook = {
23
+ label: string;
24
+ icon: LucideIcon;
25
+ /** The box's wash and border. */
26
+ box: string;
27
+ /** The icon's tint. */
28
+ tint: string;
29
+ };
30
+ export declare const CALLOUT_LOOK: Record<CalloutKind, CalloutLook>;
31
+ /** The box every callout shares: the icon in a column, the text beside it. */
32
+ export declare const CALLOUT_BOX = "flex gap-2.5 rounded-lg border px-3 py-2.5";
@@ -0,0 +1,48 @@
1
+ // How written text looks, the same in <Markdown> and in the rich-text
2
+ // editor: its headings, links and rules (PROSE), and each kind of callout
3
+ // (src/lib/markdown-callout.ts says what one is): its name, its icon, and
4
+ // its colours. A callout is a wash of the kind's tone behind ordinary text,
5
+ // the icon in the tone's text shade, so the text keeps the page's
6
+ // contrast and the icon clears 3:1 against the wash. Note is the default
7
+ // (what "Callout" inserts).
8
+ import { InfoIcon, LightbulbIcon, MessageSquareWarningIcon, OctagonAlertIcon, TriangleAlertIcon, } from 'lucide-react';
9
+ /**
10
+ * Classes for written text's own elements. No colours on the headings:
11
+ * <Markdown> adds the text colour, and the editor's read-only text is
12
+ * muted as a whole.
13
+ */
14
+ export const PROSE = {
15
+ /** `#`: the largest, with a rule under it, so it reads as the top of the outline. */
16
+ heading1: 'type-heading-1 mt-4 border-b border-border pb-1.5 first:mt-0',
17
+ heading2: 'type-heading-2 mt-3 first:mt-0',
18
+ heading3: 'type-heading-3 mt-1 first:mt-0',
19
+ /** `####` and deeper: a label, as before. */
20
+ heading4: 'type-label mt-1 first:mt-0',
21
+ /** Coloured as well as underlined. */
22
+ link: 'text-link underline decoration-link/40 underline-offset-4 transition-colors hover:decoration-link',
23
+ /** `---`: a 2px line in the rule tone, with room around it. */
24
+ rule: 'my-2 h-0.5 rounded-full border-0 bg-rule',
25
+ };
26
+ /** A heading's classes by its Markdown level (1 for `#`). */
27
+ export function headingClass(level) {
28
+ return level === 1 ? PROSE.heading1 : level === 2 ? PROSE.heading2 : level === 3 ? PROSE.heading3 : PROSE.heading4;
29
+ }
30
+ export const CALLOUT_LOOK = {
31
+ note: { label: 'Note', icon: InfoIcon, box: 'bg-info/10 border-info/25', tint: 'text-info-text' },
32
+ tip: { label: 'Tip', icon: LightbulbIcon, box: 'bg-success/10 border-success/25', tint: 'text-success-text' },
33
+ important: {
34
+ label: 'Important',
35
+ icon: MessageSquareWarningIcon,
36
+ box: 'bg-category-2/10 border-category-2/25',
37
+ tint: 'text-category-2',
38
+ },
39
+ warning: { label: 'Warning', icon: TriangleAlertIcon, box: 'bg-warning/10 border-warning/25', tint: 'text-warning-text' },
40
+ caution: {
41
+ label: 'Caution',
42
+ icon: OctagonAlertIcon,
43
+ box: 'bg-destructive/10 border-destructive/25',
44
+ tint: 'text-destructive-text',
45
+ },
46
+ };
47
+ /** The box every callout shares: the icon in a column, the text beside it. */
48
+ export const CALLOUT_BOX = 'flex gap-2.5 rounded-lg border px-3 py-2.5';
@@ -4,9 +4,21 @@ export type FormatItem = {
4
4
  id: FormatId;
5
5
  label: string;
6
6
  icon: LucideIcon;
7
- /** Milkdown's own shortcut, as `aria-keyshortcuts` writes it with Mod for Ctrl/Cmd. */
7
+ /**
8
+ * Its shortcut, as `aria-keyshortcuts` writes it with Mod for Ctrl/Cmd,
9
+ * and Block for Notion's block keys (Ctrl+Shift, or Cmd+Option on a Mac).
10
+ */
8
11
  keys?: string;
9
12
  /** More for the tooltip than the shortcut. */
10
13
  hint?: string;
11
14
  };
12
15
  export declare const FORMAT_GROUPS: FormatItem[][];
16
+ /** Every format by its id. */
17
+ export declare const FORMAT_ITEMS: Map<FormatId, FormatItem>;
18
+ /**
19
+ * A callout of each kind but the default, for the slash menu: "Warning
20
+ * callout". Not on the toolbar (its Callout makes a note).
21
+ */
22
+ export declare const CALLOUT_VARIANTS: FormatItem[];
23
+ /** Notion's block keys: the digit after Ctrl+Shift (Cmd+Option on a Mac), and its format. */
24
+ export declare const BLOCK_KEYS: Record<string, FormatId>;