@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.
- package/conventions/docs/add-an-entity.md +3 -3
- package/conventions/docs/cell-patterns.md +13 -4
- package/conventions/docs/design-language.md +22 -0
- package/dist/components/app/markdown.js +38 -20
- package/dist/components/app/milkdown-editor.js +189 -31
- package/dist/components/app/prose-look.d.ts +32 -0
- package/dist/components/app/prose-look.js +48 -0
- package/dist/components/app/rich-text-format-items.d.ts +13 -1
- package/dist/components/app/rich-text-format-items.js +43 -10
- package/dist/components/app/rich-text-formats.d.ts +15 -2
- package/dist/components/app/rich-text-formats.js +99 -10
- package/dist/components/app/rich-text-node-views.d.ts +31 -0
- package/dist/components/app/rich-text-node-views.js +79 -0
- package/dist/components/app/rich-text-shortcuts.d.ts +12 -0
- package/dist/components/app/rich-text-shortcuts.js +52 -0
- package/dist/components/app/rich-text-slash-menu.js +4 -1
- package/dist/components/app/rich-text-slash.js +26 -10
- package/dist/components/app/rich-text-table-menu.d.ts +20 -0
- package/dist/components/app/rich-text-table-menu.js +53 -0
- package/dist/components/app/rich-text-table.d.ts +19 -0
- package/dist/components/app/rich-text-table.js +162 -0
- package/dist/components/app/rich-text-toolbar.d.ts +11 -2
- package/dist/components/app/rich-text-toolbar.js +14 -23
- package/dist/components/app/rich-text-widgets.d.ts +19 -0
- package/dist/components/app/rich-text-widgets.js +34 -0
- package/dist/components/ui/badge.d.ts +1 -1
- package/dist/components/ui/dropdown-menu.d.ts +29 -0
- package/dist/components/ui/dropdown-menu.js +51 -0
- package/dist/lib/markdown-callout.d.ts +38 -0
- package/dist/lib/markdown-callout.js +103 -0
- package/package.json +1 -1
- package/styles/index.css +17 -0
- package/styles/theme.css +24 -0
|
@@ -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)
|
|
4
|
-
// (rich-text-slash-menu.tsx).
|
|
5
|
-
|
|
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+
|
|
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: '
|
|
15
|
-
{ id: '
|
|
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: '
|
|
19
|
-
{ id: '
|
|
20
|
-
{ id: '
|
|
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: '
|
|
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
|
|
2
|
-
|
|
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
|
|
4
|
-
// a toolbar press is one undo step, the
|
|
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 '
|
|
40
|
-
|
|
41
|
-
|
|
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 '
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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,
|
|
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,
|
|
3
|
-
// turns the paragraph into that block, the "/…" gone.
|
|
4
|
-
// rich-text-slash-menu.tsx; its keys and ARIA:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The fixed toolbar's Table menu: a table inserted where the caret is, or,
|
|
3
|
+
// in a table, its rows, columns and alignment (rich-text-table.ts). A menu
|
|
4
|
+
// button in the toolbar's row (one of its tab stops' places, moved to with
|
|
5
|
+
// the arrow keys like the rest); the menu itself is Base UI's (ARIA's menu
|
|
6
|
+
// pattern). Picking an item gives focus back to the text, the caret where
|
|
7
|
+
// it was (the editor keeps its selection while the menu has focus); Esc
|
|
8
|
+
// gives it back to the button, as a menu button's does.
|
|
9
|
+
import { useRef } from 'react';
|
|
10
|
+
import { AlignCenterIcon, AlignLeftIcon, AlignRightIcon, BetweenHorizontalEndIcon, BetweenHorizontalStartIcon, BetweenVerticalEndIcon, BetweenVerticalStartIcon, ChevronDownIcon, TableIcon, Trash2Icon, } from 'lucide-react';
|
|
11
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '../ui/dropdown-menu.js';
|
|
12
|
+
import { toggleVariants } from '../ui/toggle.js';
|
|
13
|
+
import { tableAt } from './rich-text-formats.js';
|
|
14
|
+
import { columnAlignment, tableActionEnabled } from './rich-text-table.js';
|
|
15
|
+
const ROWS = [
|
|
16
|
+
{ action: 'rowAbove', label: 'Insert row above', icon: BetweenHorizontalStartIcon },
|
|
17
|
+
{ action: 'rowBelow', label: 'Insert row below', icon: BetweenHorizontalEndIcon },
|
|
18
|
+
{ action: 'columnLeft', label: 'Insert column left', icon: BetweenVerticalStartIcon },
|
|
19
|
+
{ action: 'columnRight', label: 'Insert column right', icon: BetweenVerticalEndIcon },
|
|
20
|
+
];
|
|
21
|
+
const ALIGNMENTS = [
|
|
22
|
+
{ value: 'left', action: 'alignLeft', label: 'Left', icon: AlignLeftIcon },
|
|
23
|
+
{ value: 'center', action: 'alignCenter', label: 'Center', icon: AlignCenterIcon },
|
|
24
|
+
{ value: 'right', action: 'alignRight', label: 'Right', icon: AlignRightIcon },
|
|
25
|
+
];
|
|
26
|
+
const DELETES = [
|
|
27
|
+
{ action: 'deleteRow', label: 'Delete row', icon: Trash2Icon },
|
|
28
|
+
{ action: 'deleteColumn', label: 'Delete column', icon: Trash2Icon },
|
|
29
|
+
{ action: 'deleteTable', label: 'Delete table', icon: Trash2Icon },
|
|
30
|
+
];
|
|
31
|
+
export function RichTextTableMenu({ state, readOnly, tabIndex, onFocus, onKeyDown, onInsert, onAction, text, ref, }) {
|
|
32
|
+
const inTable = state ? tableAt(state) !== null : false;
|
|
33
|
+
const enabled = (action) => (state ? tableActionEnabled(state, action) : false);
|
|
34
|
+
// Whether the menu is closing because an item was picked.
|
|
35
|
+
const picked = useRef(false);
|
|
36
|
+
const run = (action) => {
|
|
37
|
+
picked.current = true;
|
|
38
|
+
action();
|
|
39
|
+
};
|
|
40
|
+
const finalFocus = () => {
|
|
41
|
+
const toText = picked.current;
|
|
42
|
+
picked.current = false;
|
|
43
|
+
return toText ? (text() ?? true) : true;
|
|
44
|
+
};
|
|
45
|
+
const item = ({ action, label, icon: Icon }) => (_jsxs(DropdownMenuItem, { disabled: !enabled(action), onClick: () => run(() => onAction(action)), children: [_jsx(Icon, { "aria-hidden": "true" }), label] }, action));
|
|
46
|
+
return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { ref: ref, "aria-label": "Table", disabled: readOnly, tabIndex: tabIndex, "data-active": inTable || undefined,
|
|
47
|
+
// Shaped like the toolbar's toggles: on while the caret is in a table.
|
|
48
|
+
className: toggleVariants({ size: 'sm', className: 'gap-0.5 px-1.5 data-active:bg-muted data-active:text-foreground' }), onFocus: onFocus, onKeyDown: onKeyDown, children: [_jsx(TableIcon, { "aria-hidden": "true" }), _jsx(ChevronDownIcon, { "aria-hidden": "true", className: "size-3" })] }), _jsxs(DropdownMenuContent, { className: "w-56", finalFocus: finalFocus, children: [_jsxs(DropdownMenuItem, { disabled: inTable, onClick: () => run(onInsert), children: [_jsx(TableIcon, { "aria-hidden": "true" }), "Insert table"] }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuGroup, { children: ROWS.map(item) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Align column" }), _jsx(DropdownMenuRadioGroup, { value: state && inTable ? columnAlignment(state) : null, onValueChange: (value) => {
|
|
49
|
+
const alignment = ALIGNMENTS.find((one) => one.value === value);
|
|
50
|
+
if (alignment)
|
|
51
|
+
run(() => onAction(alignment.action));
|
|
52
|
+
}, children: ALIGNMENTS.map(({ value, label, icon: Icon }) => (_jsxs(DropdownMenuRadioItem, { value: value, disabled: !inTable, closeOnClick: true, children: [_jsx(Icon, { "aria-hidden": "true" }), label] }, value))) })] }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuGroup, { children: DELETES.map(item) })] })] }));
|
|
53
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type EditorState, type Transaction } from '@milkdown/kit/prose/state';
|
|
2
|
+
type Dispatch = (tr: Transaction) => void;
|
|
3
|
+
export type TableAction = 'rowAbove' | 'rowBelow' | 'columnLeft' | 'columnRight' | 'alignLeft' | 'alignCenter' | 'alignRight' | 'deleteRow' | 'deleteColumn' | 'deleteTable';
|
|
4
|
+
export type ColumnAlignment = 'left' | 'center' | 'right';
|
|
5
|
+
/** The caret's column's alignment, as the header cell has it (GFM's default is left). */
|
|
6
|
+
export declare function columnAlignment(state: EditorState): ColumnAlignment;
|
|
7
|
+
/** Whether an action can apply where the caret is. */
|
|
8
|
+
export declare function tableActionEnabled(state: EditorState, action: TableAction): boolean;
|
|
9
|
+
/** Runs an action where the caret is. False when it can't apply there. */
|
|
10
|
+
export declare function runTableAction(state: EditorState, dispatch: Dispatch, action: TableAction): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* The editor's table keys, before Milkdown's own (which go to the next
|
|
13
|
+
* cell on Tab, and out of the table on any Enter). True when it handled
|
|
14
|
+
* the key.
|
|
15
|
+
*/
|
|
16
|
+
export declare function handleTableKey(state: EditorState, dispatch: Dispatch, event: KeyboardEvent): boolean;
|
|
17
|
+
/** A row added at the table's end, the caret into its first cell (the "Add a row" bar). */
|
|
18
|
+
export declare function addRowAtEnd(state: EditorState, dispatch: Dispatch): boolean;
|
|
19
|
+
export {};
|