@tristan2828/ui-foundation 3.20.1 → 3.22.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 (41) hide show
  1. package/conventions/docs/add-an-entity.md +9 -4
  2. package/conventions/docs/cell-patterns.md +13 -4
  3. package/conventions/docs/design-language.md +22 -0
  4. package/dist/components/app/data-table.js +21 -13
  5. package/dist/components/app/markdown.js +38 -20
  6. package/dist/components/app/milkdown-editor.js +189 -31
  7. package/dist/components/app/prose-look.d.ts +32 -0
  8. package/dist/components/app/prose-look.js +48 -0
  9. package/dist/components/app/rich-text-format-items.d.ts +13 -1
  10. package/dist/components/app/rich-text-format-items.js +43 -10
  11. package/dist/components/app/rich-text-formats.d.ts +15 -2
  12. package/dist/components/app/rich-text-formats.js +99 -10
  13. package/dist/components/app/rich-text-node-views.d.ts +31 -0
  14. package/dist/components/app/rich-text-node-views.js +79 -0
  15. package/dist/components/app/rich-text-shortcuts.d.ts +12 -0
  16. package/dist/components/app/rich-text-shortcuts.js +52 -0
  17. package/dist/components/app/rich-text-slash-menu.js +4 -1
  18. package/dist/components/app/rich-text-slash.js +26 -10
  19. package/dist/components/app/rich-text-table-menu.d.ts +20 -0
  20. package/dist/components/app/rich-text-table-menu.js +53 -0
  21. package/dist/components/app/rich-text-table.d.ts +19 -0
  22. package/dist/components/app/rich-text-table.js +162 -0
  23. package/dist/components/app/rich-text-toolbar.d.ts +11 -2
  24. package/dist/components/app/rich-text-toolbar.js +14 -23
  25. package/dist/components/app/rich-text-widgets.d.ts +19 -0
  26. package/dist/components/app/rich-text-widgets.js +34 -0
  27. package/dist/components/app/secondary-filters.d.ts +18 -0
  28. package/dist/components/app/secondary-filters.js +32 -0
  29. package/dist/components/ui/badge.d.ts +1 -1
  30. package/dist/components/ui/combobox.js +7 -1
  31. package/dist/components/ui/dropdown-menu.d.ts +29 -0
  32. package/dist/components/ui/dropdown-menu.js +51 -0
  33. package/dist/hooks/use-table-url-state.d.ts +1 -1
  34. package/dist/hooks/use-table-url-state.js +6 -5
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.js +1 -0
  37. package/dist/lib/markdown-callout.d.ts +38 -0
  38. package/dist/lib/markdown-callout.js +103 -0
  39. package/package.json +1 -1
  40. package/styles/index.css +17 -0
  41. package/styles/theme.css +30 -0
@@ -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>;
@@ -1,27 +1,60 @@
1
1
  // The rich-text editor's formats as people see them: each one's name, icon
2
2
  // and shortcut, in the groups the fixed toolbar shows. Shared by the
3
- // toolbars (rich-text-toolbar.tsx) and the slash menu
4
- // (rich-text-slash-menu.tsx).
5
- import { BoldIcon, CodeIcon, Heading1Icon, Heading2Icon, ItalicIcon, LinkIcon, ListChecksIcon, ListIcon, ListOrderedIcon, SquareCodeIcon, StrikethroughIcon, TextQuoteIcon, } from 'lucide-react';
3
+ // toolbars (rich-text-toolbar.tsx), the slash menu
4
+ // (rich-text-slash-menu.tsx) and the keys (rich-text-shortcuts.ts).
5
+ //
6
+ // Shortcuts are Notion's where Notion has one: Ctrl+Shift+1 to 6 and 8 for
7
+ // blocks (Cmd+Option on a Mac, where Cmd+Shift+3 to 5 take screenshots),
8
+ // written `Block+…` here; Mod+Shift+S strikes through. Milkdown's own
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';
11
+ import { CALLOUT_KINDS } from '../../lib/markdown-callout.js';
12
+ import { CALLOUT_LOOK } from './prose-look.js';
6
13
  export const FORMAT_GROUPS = [
7
14
  [
8
15
  { id: 'bold', label: 'Bold', icon: BoldIcon, keys: 'Mod+B' },
9
16
  { id: 'italic', label: 'Italic', icon: ItalicIcon, keys: 'Mod+I' },
10
- { id: 'strike', label: 'Strikethrough', icon: StrikethroughIcon, keys: 'Mod+Alt+X' },
17
+ { id: 'strike', label: 'Strikethrough', icon: StrikethroughIcon, keys: 'Mod+Shift+S' },
11
18
  { id: 'code', label: 'Code', icon: CodeIcon, keys: 'Mod+E' },
12
19
  ],
13
20
  [
14
- { id: 'heading', label: 'Heading', icon: Heading1Icon, keys: 'Mod+Alt+1' },
15
- { id: 'subheading', label: 'Subheading', icon: Heading2Icon, keys: 'Mod+Alt+2' },
21
+ { id: 'heading1', label: 'Heading 1', icon: Heading1Icon, keys: 'Block+1' },
22
+ { id: 'heading2', label: 'Heading 2', icon: Heading2Icon, keys: 'Block+2' },
23
+ { id: 'heading3', label: 'Heading 3', icon: Heading3Icon, keys: 'Block+3' },
16
24
  ],
17
25
  [
18
- { id: 'bullets', label: 'Bulleted list', icon: ListIcon, keys: 'Mod+Alt+8' },
19
- { id: 'numbers', label: 'Numbered list', icon: ListOrderedIcon, keys: 'Mod+Alt+7' },
20
- { id: 'tasks', label: 'Task list', icon: ListChecksIcon, hint: 'tick an item: Mod+Shift+Enter' },
26
+ { id: 'tasks', label: 'Task list', icon: ListChecksIcon, keys: 'Block+4', hint: 'tick an item: Mod+Shift+Enter' },
27
+ { id: 'bullets', label: 'Bulleted list', icon: ListIcon, keys: 'Block+5' },
28
+ { id: 'numbers', label: 'Numbered list', icon: ListOrderedIcon, keys: 'Block+6' },
21
29
  ],
22
30
  [
23
31
  { id: 'quote', label: 'Quote', icon: TextQuoteIcon, keys: 'Mod+Shift+B' },
24
- { id: 'codeBlock', label: 'Code block', icon: SquareCodeIcon, keys: 'Mod+Alt+C' },
32
+ { id: 'callout', label: 'Callout', icon: CALLOUT_LOOK.note.icon },
33
+ { id: 'codeBlock', label: 'Code block', icon: SquareCodeIcon, keys: 'Block+8' },
34
+ { id: 'divider', label: 'Divider', icon: MinusIcon, hint: 'or type ---' },
25
35
  ],
36
+ [{ id: 'table', label: 'Table', icon: TableIcon }],
26
37
  [{ id: 'link', label: 'Link', icon: LinkIcon, keys: 'Mod+K' }],
27
38
  ];
39
+ /** Every format by its id. */
40
+ export const FORMAT_ITEMS = new Map(FORMAT_GROUPS.flat().map((item) => [item.id, item]));
41
+ /**
42
+ * A callout of each kind but the default, for the slash menu: "Warning
43
+ * callout". Not on the toolbar (its Callout makes a note).
44
+ */
45
+ export const CALLOUT_VARIANTS = CALLOUT_KINDS.filter((kind) => kind !== 'note').map((kind) => ({
46
+ id: `callout-${kind}`,
47
+ label: `${CALLOUT_LOOK[kind].label} callout`,
48
+ icon: CALLOUT_LOOK[kind].icon,
49
+ }));
50
+ /** Notion's block keys: the digit after Ctrl+Shift (Cmd+Option on a Mac), and its format. */
51
+ export const BLOCK_KEYS = {
52
+ '0': 'text',
53
+ '1': 'heading1',
54
+ '2': 'heading2',
55
+ '3': 'heading3',
56
+ '4': 'tasks',
57
+ '5': 'bullets',
58
+ '6': 'numbers',
59
+ '8': 'codeBlock',
60
+ };
@@ -1,6 +1,17 @@
1
- import type { EditorState, Transaction } from '@milkdown/kit/prose/state';
2
- export type FormatId = 'bold' | 'italic' | 'strike' | 'code' | 'heading' | 'subheading' | 'bullets' | 'numbers' | 'tasks' | 'quote' | 'codeBlock' | 'link';
1
+ import { type Node as ProseNode, type Schema } from '@milkdown/kit/prose/model';
2
+ import { type EditorState, type Transaction } from '@milkdown/kit/prose/state';
3
+ import type { CalloutKind } from '../../lib/markdown-callout.js';
4
+ export type FormatId = 'bold' | 'italic' | 'strike' | 'code' | 'text' | 'heading1' | 'heading2' | 'heading3' | 'bullets' | 'numbers' | 'tasks' | 'quote' | CalloutFormat | 'codeBlock' | 'divider' | 'table' | 'link';
5
+ /** A callout of each kind: `callout` is a note, the default. */
6
+ export type CalloutFormat = 'callout' | `callout-${Exclude<CalloutKind, 'note'>}`;
7
+ /** The kind of callout a format makes, or null when it isn't one. */
8
+ export declare function calloutKindOf(id: FormatId): CalloutKind | null;
3
9
  type Dispatch = (tr: Transaction) => void;
10
+ /** The table the caret is in, and its position, or null. */
11
+ export declare function tableAt(state: EditorState): {
12
+ node: ProseNode;
13
+ pos: number;
14
+ } | null;
4
15
  /** Whether a format is on where the caret is. */
5
16
  export declare function formatOn(state: EditorState, id: FormatId): boolean;
6
17
  /**
@@ -9,4 +20,6 @@ export declare function formatOn(state: EditorState, id: FormatId): boolean;
9
20
  * toolbar opens the link box for it.
10
21
  */
11
22
  export declare function toggleFormat(state: EditorState, dispatch: Dispatch, id: FormatId): boolean;
23
+ /** A new table: a header row and two rows, three columns, every cell empty and unaligned. */
24
+ export declare function createTable(schema: Schema, rows?: number, columns?: number): ProseNode;
12
25
  export {};
@@ -1,9 +1,19 @@
1
1
  // What the rich-text editor's toolbar can do: each format, whether it's on
2
2
  // where the caret (or the selection) is, and how to turn it on or off.
3
- // Plain ProseMirror commands over Milkdown's schema (commonmark + gfm), so
4
- // a toolbar press is one undo step, the same as its keyboard shortcut.
3
+ // Plain ProseMirror commands over Milkdown's schema (commonmark + gfm, and
4
+ // the editor's callout node), so a toolbar press is one undo step, the
5
+ // same as its keyboard shortcut.
5
6
  import { lift, setBlockType, toggleMark, wrapIn } from '@milkdown/kit/prose/commands';
7
+ import { Fragment } from '@milkdown/kit/prose/model';
6
8
  import { liftListItem, wrapInList } from '@milkdown/kit/prose/schema-list';
9
+ import { TextSelection } from '@milkdown/kit/prose/state';
10
+ /** The kind of callout a format makes, or null when it isn't one. */
11
+ export function calloutKindOf(id) {
12
+ if (id === 'callout')
13
+ return 'note';
14
+ return id.startsWith('callout-') ? id.slice('callout-'.length) : null;
15
+ }
16
+ const HEADING_LEVEL = { heading1: 1, heading2: 2, heading3: 3 };
7
17
  const MARKS = {
8
18
  bold: 'strong',
9
19
  italic: 'emphasis',
@@ -27,6 +37,10 @@ function ancestor(state, test) {
27
37
  }
28
38
  return null;
29
39
  }
40
+ /** The table the caret is in, and its position, or null. */
41
+ export function tableAt(state) {
42
+ return ancestor(state, (node) => node.type.name === 'table');
43
+ }
30
44
  const isList = (node) => node.type.name === 'bullet_list' || node.type.name === 'ordered_list';
31
45
  /** Whether a format is on where the caret is. */
32
46
  export function formatOn(state, id) {
@@ -35,10 +49,19 @@ export function formatOn(state, id) {
35
49
  if (mark)
36
50
  return markOn(state, schema.marks[mark]);
37
51
  const parent = state.selection.$from.parent;
52
+ const level = HEADING_LEVEL[id];
53
+ if (level)
54
+ return parent.type.name === 'heading' && parent.attrs.level === level;
55
+ const kind = calloutKindOf(id);
56
+ if (kind)
57
+ return ancestor(state, (node) => node.type.name === 'callout')?.node.attrs.kind === kind;
38
58
  switch (id) {
39
- case 'heading':
40
- case 'subheading':
41
- return parent.type.name === 'heading' && parent.attrs.level === (id === 'heading' ? 1 : 2);
59
+ case 'text':
60
+ return parent.type.name === 'paragraph' && !ancestor(state, (node) => isList(node) || node.type.name === 'blockquote');
61
+ case 'divider':
62
+ return false;
63
+ case 'table':
64
+ return tableAt(state) !== null;
42
65
  case 'codeBlock':
43
66
  return parent.type.name === 'code_block';
44
67
  case 'quote':
@@ -92,12 +115,19 @@ export function toggleFormat(state, dispatch, id) {
92
115
  return toggleMark(schema.marks[mark])(state, dispatch);
93
116
  const on = formatOn(state, id);
94
117
  const paragraph = schema.nodes.paragraph;
118
+ const level = HEADING_LEVEL[id];
119
+ if (level)
120
+ return on ? setBlockType(paragraph)(state, dispatch) : setBlockType(schema.nodes.heading, { level })(state, dispatch);
121
+ const kind = calloutKindOf(id);
122
+ if (kind)
123
+ return toggleCallout(state, dispatch, kind);
95
124
  switch (id) {
96
- case 'heading':
97
- case 'subheading':
98
- return on
99
- ? setBlockType(paragraph)(state, dispatch)
100
- : setBlockType(schema.nodes.heading, { level: id === 'heading' ? 1 : 2 })(state, dispatch);
125
+ case 'text':
126
+ return toText(state, dispatch);
127
+ case 'divider':
128
+ return insertBlock(state, dispatch, schema.nodes.hr.create(), false);
129
+ case 'table':
130
+ return on ? false : insertBlock(state, dispatch, createTable(schema), true);
101
131
  case 'codeBlock':
102
132
  return on ? setBlockType(paragraph)(state, dispatch) : setBlockType(schema.nodes.code_block)(state, dispatch);
103
133
  case 'quote':
@@ -123,3 +153,62 @@ export function toggleFormat(state, dispatch, id) {
123
153
  return false;
124
154
  }
125
155
  }
156
+ // The line the caret is in as plain text (Notion's "Text"): out of a
157
+ // heading or code block, a list, a quote. False when it already is.
158
+ function toText(state, dispatch) {
159
+ const { schema } = state;
160
+ const parent = state.selection.$from.parent;
161
+ if (parent.type !== schema.nodes.paragraph)
162
+ return setBlockType(schema.nodes.paragraph)(state, dispatch);
163
+ if (ancestor(state, isList))
164
+ return liftListItem(schema.nodes.list_item)(state, dispatch);
165
+ if (ancestor(state, (node) => node.type.name === 'blockquote' || node.type.name === 'callout'))
166
+ return lift(state, dispatch);
167
+ return false;
168
+ }
169
+ // A callout of `kind` where the caret is: around its block, or the
170
+ // callout it's in turned into that kind, or (already that kind) the
171
+ // callout taken away, its blocks kept.
172
+ function toggleCallout(state, dispatch, kind) {
173
+ const callout = ancestor(state, (node) => node.type.name === 'callout');
174
+ if (!callout)
175
+ return wrapIn(state.schema.nodes.callout, { kind })(state, dispatch);
176
+ const { node, pos } = callout;
177
+ if (node.attrs.kind !== kind) {
178
+ dispatch(state.tr.setNodeMarkup(pos, undefined, { ...node.attrs, kind }));
179
+ return true;
180
+ }
181
+ const tr = state.tr.replaceWith(pos, pos + node.nodeSize, node.content);
182
+ dispatch(tr.setSelection(TextSelection.create(tr.doc, Math.max(0, state.selection.from - 1))).scrollIntoView());
183
+ return true;
184
+ }
185
+ /** A new table: a header row and two rows, three columns, every cell empty and unaligned. */
186
+ export function createTable(schema, rows = 3, columns = 3) {
187
+ const row = (type, cell) => schema.nodes[type].create(null, Array.from({ length: columns }, () => schema.nodes[cell].createAndFill({ alignment: null })));
188
+ return schema.nodes.table.create(null, [
189
+ row('table_header_row', 'table_header'),
190
+ ...Array.from({ length: rows - 1 }, () => row('table_row', 'table_cell')),
191
+ ]);
192
+ }
193
+ // A block (a rule, a table) where the caret is: in place of an empty
194
+ // line, else after the caret's block. A line to go on writing follows it
195
+ // when nothing does. The caret goes into the block (`into`), or past it.
196
+ function insertBlock(state, dispatch, block, into) {
197
+ const { $from } = state.selection;
198
+ if (tableAt(state) || !$from.parent.isTextblock || $from.parent.type.spec.code)
199
+ return false;
200
+ const depth = $from.depth;
201
+ const container = $from.node(depth - 1);
202
+ const index = $from.index(depth - 1);
203
+ const empty = $from.parent.content.size === 0;
204
+ const followed = index + 1 < container.childCount;
205
+ const content = Fragment.from(into && followed ? [block] : [block, state.schema.nodes.paragraph.create()]);
206
+ const at = empty ? index : index + 1;
207
+ if (!container.canReplace(at, empty ? index + 1 : at, content))
208
+ return false;
209
+ const from = empty ? $from.before(depth) : $from.after(depth);
210
+ const tr = state.tr.replaceWith(from, empty ? $from.after(depth) : from, content);
211
+ tr.setSelection(TextSelection.near(tr.doc.resolve(into ? from + 1 : from + block.nodeSize), 1));
212
+ dispatch(tr.scrollIntoView());
213
+ return true;
214
+ }
@@ -0,0 +1,31 @@
1
+ import type { Node as ProseNode } from '@milkdown/kit/prose/model';
2
+ import type { NodeView } from '@milkdown/kit/prose/view';
3
+ import { type CalloutKind } from '../../lib/markdown-callout.js';
4
+ /** A slot a node view has, for the editor to render its widget into. */
5
+ export type Widget = {
6
+ type: 'callout';
7
+ key: string;
8
+ slot: HTMLElement;
9
+ kind: CalloutKind;
10
+ getPos: () => number | undefined;
11
+ } | {
12
+ type: 'table';
13
+ key: string;
14
+ slot: HTMLElement;
15
+ column: HTMLElement;
16
+ getPos: () => number | undefined;
17
+ };
18
+ /** Where node views tell the editor their slots came, changed and went. */
19
+ export type WidgetRegistry = {
20
+ set: (widget: Widget) => void;
21
+ remove: (slot: HTMLElement) => void;
22
+ };
23
+ export declare function calloutKind(value: unknown): CalloutKind;
24
+ /** A callout: its kind's box, its icon (a widget: the kind's menu) beside its blocks. */
25
+ export declare function calloutView(node: ProseNode, getPos: () => number | undefined, registry: WidgetRegistry): NodeView;
26
+ /**
27
+ * A table: in a box that scrolls sideways when it's wider than the text,
28
+ * with an "add a column" bar along its right and an "add a row" bar under
29
+ * it (widgets), Notion's way.
30
+ */
31
+ export declare function tableView(getPos: () => number | undefined, registry: WidgetRegistry): NodeView;
@@ -0,0 +1,79 @@
1
+ import { cn } from 'cn';
2
+ import { CALLOUT_KINDS } from '../../lib/markdown-callout.js';
3
+ import { CALLOUT_BOX, CALLOUT_LOOK } from './prose-look.js';
4
+ // Each slot's key for its portal, for as long as the node view lives.
5
+ let made = 0;
6
+ const nextKey = () => `widget-${++made}`;
7
+ export function calloutKind(value) {
8
+ return CALLOUT_KINDS.includes(value) ? value : 'note';
9
+ }
10
+ function slot(className, tag = 'div') {
11
+ const element = document.createElement(tag);
12
+ element.contentEditable = 'false';
13
+ element.className = className;
14
+ return element;
15
+ }
16
+ // Mutations ProseMirror shouldn't read as edits: anything outside the
17
+ // node's editable content (its slots, and the classes set here).
18
+ function outside(contentDOM) {
19
+ return (mutation) => mutation.type !== 'selection' && !contentDOM.contains(mutation.target);
20
+ }
21
+ /** A callout: its kind's box, its icon (a widget: the kind's menu) beside its blocks. */
22
+ export function calloutView(node, getPos, registry) {
23
+ const dom = document.createElement('div');
24
+ const icon = slot('flex h-5 shrink-0 items-center', 'span');
25
+ const contentDOM = document.createElement('div');
26
+ contentDOM.className = 'flex min-w-0 flex-1 flex-col gap-2';
27
+ dom.append(icon, contentDOM);
28
+ let current = node;
29
+ const key = nextKey();
30
+ const render = () => {
31
+ const kind = calloutKind(current.attrs.kind);
32
+ dom.dataset.callout = kind;
33
+ dom.className = cn(CALLOUT_BOX, CALLOUT_LOOK[kind].box);
34
+ registry.set({ type: 'callout', key, slot: icon, kind, getPos });
35
+ };
36
+ render();
37
+ return {
38
+ dom,
39
+ contentDOM,
40
+ update: (next) => {
41
+ if (next.type !== current.type)
42
+ return false;
43
+ current = next;
44
+ render();
45
+ return true;
46
+ },
47
+ stopEvent: (event) => icon.contains(event.target),
48
+ ignoreMutation: outside(contentDOM),
49
+ destroy: () => registry.remove(icon),
50
+ };
51
+ }
52
+ /**
53
+ * A table: in a box that scrolls sideways when it's wider than the text,
54
+ * with an "add a column" bar along its right and an "add a row" bar under
55
+ * it (widgets), Notion's way.
56
+ */
57
+ export function tableView(getPos, registry) {
58
+ const dom = document.createElement('div');
59
+ dom.dataset.slot = 'rich-text-table';
60
+ dom.className = 'group/table grid grid-cols-[minmax(0,1fr)_auto] gap-1';
61
+ const scroll = document.createElement('div');
62
+ scroll.className = 'overflow-x-auto rounded-lg border border-border';
63
+ const table = document.createElement('table');
64
+ const contentDOM = document.createElement('tbody');
65
+ table.append(contentDOM);
66
+ scroll.append(table);
67
+ const column = slot('flex w-5');
68
+ const row = slot('col-start-1 flex h-5');
69
+ dom.append(scroll, column, row);
70
+ registry.set({ type: 'table', key: nextKey(), slot: row, column, getPos });
71
+ return {
72
+ dom,
73
+ contentDOM,
74
+ update: (next) => next.type.name === 'table',
75
+ stopEvent: (event) => row.contains(event.target) || column.contains(event.target),
76
+ ignoreMutation: outside(contentDOM),
77
+ destroy: () => registry.remove(row),
78
+ };
79
+ }
@@ -0,0 +1,12 @@
1
+ import type { EditorState, Transaction } from '@milkdown/kit/prose/state';
2
+ export declare function isApple(): boolean;
3
+ /** For `aria-keyshortcuts`, which names real keys: Meta on Apple, Control elsewhere. */
4
+ export declare function ariaKeys(keys: string, apple: boolean): string;
5
+ /** What a person reads: ⌘⌥X on Apple, Ctrl+Alt+X elsewhere. */
6
+ export declare function shownKeys(keys: string, apple: boolean): string;
7
+ /**
8
+ * Runs the shortcut a key press is, if it's one of these. True when it
9
+ * was (whether or not the format could apply where the caret is: the key
10
+ * is taken either way, so the browser's own never runs).
11
+ */
12
+ export declare function handleShortcut(state: EditorState, dispatch: (tr: Transaction) => void, event: KeyboardEvent): boolean;
@@ -0,0 +1,52 @@
1
+ import { BLOCK_KEYS } from './rich-text-format-items.js';
2
+ import { toggleFormat } from './rich-text-formats.js';
3
+ export function isApple() {
4
+ return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
5
+ }
6
+ // Notion's block keys: Cmd+Option on Apple, Ctrl+Shift elsewhere.
7
+ function withBlock(keys, apple) {
8
+ return keys.replace('Block', apple ? 'Mod+Alt' : 'Ctrl+Shift');
9
+ }
10
+ /** For `aria-keyshortcuts`, which names real keys: Meta on Apple, Control elsewhere. */
11
+ export function ariaKeys(keys, apple) {
12
+ return withBlock(keys, apple)
13
+ .replace('Mod', apple ? 'Meta' : 'Control')
14
+ .replace('Ctrl', 'Control');
15
+ }
16
+ /** What a person reads: ⌘⌥X on Apple, Ctrl+Alt+X elsewhere. */
17
+ export function shownKeys(keys, apple) {
18
+ const real = withBlock(keys, apple);
19
+ if (!apple)
20
+ return real.replace('Mod', 'Ctrl');
21
+ const symbols = { Mod: '⌘', Alt: '⌥', Shift: '⇧', Enter: '↵' };
22
+ return real
23
+ .split('+')
24
+ .map((part) => symbols[part] ?? part)
25
+ .join('');
26
+ }
27
+ // The format a key press asks for, or null when it's none of these.
28
+ function shortcutFormat(event, apple) {
29
+ const digit = /^(?:Digit|Numpad)(\d)$/.exec(event.code)?.[1];
30
+ const ctrlShift = event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey;
31
+ // Ctrl+Alt on Windows is AltGr, which types characters ({ and [ on a
32
+ // German keyboard), so Cmd+Option counts on Apple only.
33
+ const cmdOption = apple && event.metaKey && event.altKey && !event.shiftKey && !event.ctrlKey;
34
+ if (digit && (ctrlShift || cmdOption))
35
+ return BLOCK_KEYS[digit] ?? null;
36
+ const mod = apple ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
37
+ if (mod && event.shiftKey && !event.altKey && event.code === 'KeyS')
38
+ return 'strike';
39
+ return null;
40
+ }
41
+ /**
42
+ * Runs the shortcut a key press is, if it's one of these. True when it
43
+ * was (whether or not the format could apply where the caret is: the key
44
+ * is taken either way, so the browser's own never runs).
45
+ */
46
+ export function handleShortcut(state, dispatch, event) {
47
+ const format = shortcutFormat(event, isApple());
48
+ if (!format)
49
+ return false;
50
+ toggleFormat(state, dispatch, format);
51
+ return true;
52
+ }
@@ -41,7 +41,10 @@ export function RichTextSlashMenu({ id, view, at, field, items, active, onHighli
41
41
  useLayoutEffect(() => {
42
42
  menu?.querySelector(`#${CSS.escape(slashOptionId(id, active))}`)?.scrollIntoView({ block: 'nearest' });
43
43
  }, [menu, id, active]);
44
- return (_jsx("div", { ref: setMenu, id: id, role: "listbox", "aria-label": "Blocks", "data-slot": "rich-text-slash-menu", "data-side": placement?.side, style: placement ? { top: placement.top, left: placement.left } : undefined, className: cn('absolute z-50 flex max-h-72 w-56 flex-col gap-0.5 overflow-y-auto rounded-lg bg-popover p-1 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10', 'animate-in fade-in-0 zoom-in-95 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2',
44
+ return (_jsx("div", { ref: setMenu, id: id, role: "listbox", "aria-label": "Blocks", "data-slot": "rich-text-slash-menu", "data-side": placement?.side,
45
+ // Tall enough for every block, but a filtered list can still scroll:
46
+ // focusable for axe (scrollable-region-focusable), never by a press.
47
+ tabIndex: -1, onMouseDown: (event) => event.preventDefault(), style: placement ? { top: placement.top, left: placement.left } : undefined, className: cn('absolute z-50 flex max-h-96 w-56 flex-col gap-0.5 overflow-y-auto rounded-lg bg-popover p-1 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10', 'animate-in fade-in-0 zoom-in-95 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2',
45
48
  // Measured before it's seen, so it never shows in the wrong place.
46
49
  !placement && 'invisible'), children: items.map((item, index) => {
47
50
  const Icon = item.icon;
@@ -1,21 +1,24 @@
1
1
  // The slash menu's logic: "/" typed at the start of a top-level paragraph
2
- // opens it, what's typed after the "/" filters it, and picking an item
3
- // turns the paragraph into that block, the "/…" gone. The menu itself:
4
- // rich-text-slash-menu.tsx; its keys and ARIA: milkdown-editor.tsx.
2
+ // (or one in a callout) opens it, what's typed after the "/" filters it,
3
+ // and picking an item turns the paragraph into that block, the "/…" gone.
4
+ // The menu itself: rich-text-slash-menu.tsx; its keys and ARIA:
5
+ // milkdown-editor.tsx.
5
6
  //
6
7
  // Only a "/" typed there opens it: one already in the text (a path,
7
8
  // `/usr/bin`) never does, and neither does a "/" mid-line or inside a
8
9
  // list, a quote or a code block.
9
10
  import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
10
- import { FORMAT_GROUPS } from './rich-text-format-items.js';
11
+ import { CALLOUT_VARIANTS, FORMAT_GROUPS } from './rich-text-format-items.js';
11
12
  import { toggleFormat } from './rich-text-formats.js';
12
13
  const slashKey = new PluginKey('rich-text-slash');
13
14
  // Longer than any item's name: past it, the person is writing, not picking.
14
15
  const MAX_QUERY = 24;
15
- // A top-level paragraph's start: where a "/" opens the menu.
16
+ // A top-level paragraph's start, or one's in a callout (where picking a
17
+ // kind of callout changes the callout's): where a "/" opens the menu.
16
18
  function atParagraphStart(state, pos) {
17
19
  const $pos = state.doc.resolve(pos);
18
- return $pos.depth === 1 && $pos.parent.type.name === 'paragraph' && $pos.parentOffset === 0;
20
+ const placed = $pos.depth === 1 || ($pos.depth === 2 && $pos.node(1).type.name === 'callout');
21
+ return placed && $pos.parent.type.name === 'paragraph' && $pos.parentOffset === 0;
19
22
  }
20
23
  // The menu's span, if the "/" at `from` still opens it: the caret after it
21
24
  // in the same paragraph, nothing selected, and what follows the "/" plain
@@ -76,22 +79,35 @@ export function slashPlugin() {
76
79
  }
77
80
  // What each block answers to besides its name.
78
81
  const KEYWORDS = {
79
- heading: ['h1', 'title'],
80
- subheading: ['h2'],
82
+ heading1: ['h1', 'title', 'heading'],
83
+ heading2: ['h2', 'subheading', 'heading'],
84
+ heading3: ['h3', 'heading'],
81
85
  bullets: ['ul', 'unordered', 'bullet'],
82
86
  numbers: ['ol', 'ordered', 'number'],
83
87
  tasks: ['todo', 'checkbox', 'checklist'],
84
88
  quote: ['blockquote'],
89
+ callout: ['note', 'info', 'alert', 'box'],
90
+ 'callout-tip': ['callout', 'hint', 'idea', 'alert'],
91
+ 'callout-important': ['callout', 'alert'],
92
+ 'callout-warning': ['callout', 'alert', 'warn'],
93
+ 'callout-caution': ['callout', 'alert', 'danger'],
85
94
  codeBlock: ['pre'],
95
+ divider: ['hr', 'rule', 'line', 'separator', '---'],
96
+ table: ['grid', 'columns'],
86
97
  };
87
- // The menu's items: the blocks, in the toolbar's order.
98
+ // The menu's items: the blocks, in the toolbar's order. The kinds of
99
+ // callout past the default only once something's typed ("/warn"), so
100
+ // the list "/" opens with stays short.
88
101
  const BLOCKS = FORMAT_GROUPS.flat().filter((item) => KEYWORDS[item.id]);
102
+ const TYPED_ONLY = CALLOUT_VARIANTS;
89
103
  /** The blocks matching what's typed after the "/": in the name, or the start of a keyword. */
90
104
  export function slashItems(query) {
91
105
  const typed = query.trim().toLowerCase();
92
106
  if (!typed)
93
107
  return BLOCKS;
94
- return BLOCKS.filter((item) => item.label.toLowerCase().includes(typed) || KEYWORDS[item.id].some((word) => word.startsWith(typed)));
108
+ const blocks = [...BLOCKS];
109
+ blocks.splice(BLOCKS.findIndex((item) => item.id === 'callout') + 1, 0, ...TYPED_ONLY);
110
+ return blocks.filter((item) => item.label.toLowerCase().includes(typed) || KEYWORDS[item.id].some((word) => word.startsWith(typed)));
95
111
  }
96
112
  /**
97
113
  * Picks an item: the "/…" goes and the paragraph becomes that block, in one
@@ -0,0 +1,20 @@
1
+ import { type KeyboardEvent, type Ref } from 'react';
2
+ import type { EditorState } from '@milkdown/kit/prose/state';
3
+ import { type TableAction } from './rich-text-table.js';
4
+ export interface RichTextTableMenuProps {
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
+ /** Insert table picked. */
13
+ onInsert: () => void;
14
+ /** A row, column or alignment item picked. */
15
+ onAction: (action: TableAction) => void;
16
+ /** The editable element, where focus goes once an item is picked. */
17
+ text: () => HTMLElement | null;
18
+ ref?: Ref<HTMLButtonElement>;
19
+ }
20
+ export declare function RichTextTableMenu({ state, readOnly, tabIndex, onFocus, onKeyDown, onInsert, onAction, text, ref, }: RichTextTableMenuProps): import("react").JSX.Element;