@tristan2828/ui-foundation 4.61.1 → 4.62.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.
@@ -0,0 +1,216 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // A table's grips in the rich-text editor, Notion's way: while the pointer
3
+ // is over a cell, or the caret is in one, a grip sits on the top edge of
4
+ // its column and another on the left edge of its row. Each opens that
5
+ // column's or that row's menu: insert, move and delete, the column's
6
+ // alignment and sort, the header column, and colours (rich-text-table.ts).
7
+ // A right-click in a cell, or Shift+F10 (or the menu key) with the caret
8
+ // in one, opens the cell's menu: its colour, then its row's and its
9
+ // column's items. The grips are mouse shortcuts, out of the tab order;
10
+ // the keyboard has the cell's menu.
11
+ //
12
+ // Rendered into the table node view's grip layer (rich-text-node-views.ts)
13
+ // through a portal. A menu acts on the cell it was opened for, then gives
14
+ // focus back to the text with the caret in that cell.
15
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
16
+ import { TextSelection } from '@milkdown/kit/prose/state';
17
+ import { selectedRect, TableMap } from '@milkdown/kit/prose/tables';
18
+ import { AlignCenterIcon, AlignLeftIcon, AlignRightIcon, ArrowDownAZIcon, ArrowDownIcon, ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon, ArrowUpZAIcon, BetweenHorizontalEndIcon, BetweenHorizontalStartIcon, BetweenVerticalEndIcon, BetweenVerticalStartIcon, GripHorizontalIcon, GripVerticalIcon, PanelLeftIcon, Trash2Icon, } from 'lucide-react';
19
+ import { cn } from 'cn';
20
+ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '../ui/dropdown-menu.js';
21
+ import { HIGHLIGHT_COLORS } from '../../lib/markdown-highlight.js';
22
+ import { tableAt } from './rich-text-formats.js';
23
+ import { cellColorAt, columnAlignment, headerColumnOn, tableActionEnabled, tableColorEnabled, } from './rich-text-table.js';
24
+ /** The event the editor sends a table's frame to open the caret's cell menu (Shift+F10). */
25
+ export const TABLE_MENU_EVENT = 'rich-text-table-menu';
26
+ const SWATCH = {
27
+ amber: 'bg-highlight-amber',
28
+ yellow: 'bg-highlight-yellow',
29
+ green: 'bg-highlight-green',
30
+ red: 'bg-highlight-red',
31
+ };
32
+ const ROW_ITEMS = [
33
+ { action: 'rowAbove', label: 'Insert row above', icon: BetweenHorizontalStartIcon },
34
+ { action: 'rowBelow', label: 'Insert row below', icon: BetweenHorizontalEndIcon },
35
+ { action: 'moveRowUp', label: 'Move row up', icon: ArrowUpIcon },
36
+ { action: 'moveRowDown', label: 'Move row down', icon: ArrowDownIcon },
37
+ { action: 'deleteRow', label: 'Delete row', icon: Trash2Icon },
38
+ ];
39
+ const COLUMN_ITEMS = [
40
+ { action: 'columnLeft', label: 'Insert column left', icon: BetweenVerticalStartIcon },
41
+ { action: 'columnRight', label: 'Insert column right', icon: BetweenVerticalEndIcon },
42
+ { action: 'moveColumnLeft', label: 'Move column left', icon: ArrowLeftIcon },
43
+ { action: 'moveColumnRight', label: 'Move column right', icon: ArrowRightIcon },
44
+ { action: 'deleteColumn', label: 'Delete column', icon: Trash2Icon },
45
+ ];
46
+ const SORT_ITEMS = [
47
+ { action: 'sortAscending', label: 'Sort ascending', icon: ArrowDownAZIcon },
48
+ { action: 'sortDescending', label: 'Sort descending', icon: ArrowUpZAIcon },
49
+ ];
50
+ const ALIGNMENTS = [
51
+ { value: 'left', action: 'alignLeft', label: 'Left', icon: AlignLeftIcon },
52
+ { value: 'center', action: 'alignCenter', label: 'Center', icon: AlignCenterIcon },
53
+ { value: 'right', action: 'alignRight', label: 'Right', icon: AlignRightIcon },
54
+ ];
55
+ // The cell an element is in, if it's one of this table's.
56
+ function cellOf(table, target) {
57
+ const cell = target instanceof Element ? target.closest('td, th') : null;
58
+ if (!(cell instanceof HTMLTableCellElement) || cell.closest('table') !== table)
59
+ return null;
60
+ return { row: cell.parentElement.rowIndex, column: cell.cellIndex };
61
+ }
62
+ // The state with the caret put in a cell, to ask what applies there.
63
+ function stateAt(state, tablePos, cell) {
64
+ const table = state.doc.nodeAt(tablePos);
65
+ if (!table || table.type.name !== 'table')
66
+ return null;
67
+ const map = TableMap.get(table);
68
+ if (cell.row >= map.height || cell.column >= map.width)
69
+ return null;
70
+ const at = tablePos + 1 + map.map[cell.row * map.width + cell.column] + 1;
71
+ return state.apply(state.tr.setSelection(TextSelection.near(state.doc.resolve(at))));
72
+ }
73
+ export function TableGrips({ view, state, getPos, frame, box, table, readOnly, focused, onAction, onColor, text }) {
74
+ const [hover, setHover] = useState(null);
75
+ const [open, setOpen] = useState(null);
76
+ const [target, setTarget] = useState(null);
77
+ // Bumped when the layout moves without a new state (scroll, resize).
78
+ const [, setLayout] = useState(0);
79
+ const columnGrip = useRef(null);
80
+ const rowGrip = useRef(null);
81
+ const tablePos = getPos();
82
+ // The caret's cell, when it's in this table.
83
+ let caret = null;
84
+ if (focused && state && tablePos !== undefined && tableAt(state)?.pos === tablePos) {
85
+ const { top, left } = selectedRect(state);
86
+ caret = { row: top, column: left };
87
+ }
88
+ // The caret's cell for the keyboard's menu, read by a listener set up once.
89
+ const caretRef = useRef(caret);
90
+ useLayoutEffect(() => {
91
+ caretRef.current = caret;
92
+ });
93
+ const active = open ? target : (hover ?? caret);
94
+ // Pointer over a cell: its grips. Leaving the table, back to the caret's.
95
+ useEffect(() => {
96
+ const onMove = (event) => {
97
+ const cell = cellOf(table, event.target);
98
+ if (cell)
99
+ setHover((now) => (now && now.row === cell.row && now.column === cell.column ? now : cell));
100
+ };
101
+ const onLeave = () => setHover(null);
102
+ const onLayout = () => setLayout((n) => n + 1);
103
+ const onMenu = () => {
104
+ const now = caretRef.current;
105
+ if (!now)
106
+ return;
107
+ setTarget(now);
108
+ setOpen('cell');
109
+ };
110
+ const onContext = (event) => {
111
+ const cell = cellOf(table, event.target);
112
+ if (!cell || !view.editable)
113
+ return;
114
+ event.preventDefault();
115
+ setTarget(cell);
116
+ setOpen('cell');
117
+ };
118
+ frame.addEventListener('pointermove', onMove);
119
+ frame.addEventListener('pointerleave', onLeave);
120
+ frame.addEventListener(TABLE_MENU_EVENT, onMenu);
121
+ frame.addEventListener('contextmenu', onContext);
122
+ box.addEventListener('scroll', onLayout);
123
+ window.addEventListener('resize', onLayout);
124
+ const sizes = new ResizeObserver(onLayout);
125
+ sizes.observe(table);
126
+ return () => {
127
+ frame.removeEventListener('pointermove', onMove);
128
+ frame.removeEventListener('pointerleave', onLeave);
129
+ frame.removeEventListener(TABLE_MENU_EVENT, onMenu);
130
+ frame.removeEventListener('contextmenu', onContext);
131
+ box.removeEventListener('scroll', onLayout);
132
+ window.removeEventListener('resize', onLayout);
133
+ sizes.disconnect();
134
+ };
135
+ }, [frame, box, table, view, caretRef]);
136
+ // Where the grips go: the column's on the middle of its top edge (the
137
+ // header cell's), the row's on the middle of its left edge. A column
138
+ // scrolled out of the box has none. Placed on the elements themselves,
139
+ // after each render (the layout is the browser's, not React's).
140
+ useLayoutEffect(() => {
141
+ const columnButton = columnGrip.current;
142
+ const rowButton = rowGrip.current;
143
+ if (!columnButton || !rowButton)
144
+ return;
145
+ const row = active ? table.rows[active.row] : undefined;
146
+ const header = active ? table.rows[0]?.cells[active.column] : undefined;
147
+ if (!active || !row || !header) {
148
+ columnButton.hidden = true;
149
+ rowButton.hidden = true;
150
+ return;
151
+ }
152
+ const origin = frame.getBoundingClientRect();
153
+ const scroll = box.getBoundingClientRect();
154
+ const head = header.getBoundingClientRect();
155
+ const line = row.getBoundingClientRect();
156
+ const middle = head.left + head.width / 2;
157
+ columnButton.hidden = middle < scroll.left || middle > scroll.right;
158
+ columnButton.style.left = `${middle - origin.left}px`;
159
+ columnButton.style.top = `${head.top - origin.top}px`;
160
+ rowButton.hidden = false;
161
+ rowButton.style.left = `${scroll.left - origin.left}px`;
162
+ rowButton.style.top = `${line.top - origin.top + line.height / 2}px`;
163
+ });
164
+ if (readOnly || tablePos === undefined || !state)
165
+ return null;
166
+ const at = (cell) => (cell ? stateAt(state, tablePos, cell) : null);
167
+ const menuCell = open ? target : active;
168
+ const there = at(menuCell);
169
+ const enabled = (action) => (there ? tableActionEnabled(there, action) : false);
170
+ const columnName = (column) => table.rows[0]?.cells[column]?.textContent?.trim() || `column ${column + 1}`;
171
+ const openMenu = (menu) => (next) => {
172
+ if (next) {
173
+ setTarget(active);
174
+ setOpen(menu);
175
+ }
176
+ else
177
+ setOpen(null);
178
+ };
179
+ // An item runs once its menu has closed: an action that rebuilds the
180
+ // table (a move, a sort) remakes its node view, grips and menu with it,
181
+ // and focus then goes to the text, not to a menu that's gone.
182
+ const later = (act) => {
183
+ setOpen(null);
184
+ setTimeout(act);
185
+ };
186
+ const run = (action) => {
187
+ const cell = menuCell;
188
+ if (cell)
189
+ later(() => onAction(cell, action));
190
+ };
191
+ const color = (scope, value) => {
192
+ const cell = menuCell;
193
+ if (cell)
194
+ later(() => onColor(cell, scope, value));
195
+ };
196
+ const items = (list) => list.map(({ action, label, icon: Icon }) => (_jsxs(DropdownMenuItem, { disabled: !enabled(action), onClick: () => run(action), children: [_jsx(Icon, { "aria-hidden": "true" }), label] }, action)));
197
+ const colorGroup = (scope, label) => {
198
+ if (!there || !tableColorEnabled(there, scope))
199
+ return null;
200
+ const current = scope === 'cell' ? cellColorAt(there) : null;
201
+ return (_jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: label }), _jsxs(DropdownMenuRadioGroup
202
+ // A cell says its colour; a row's or a column's cells may differ.
203
+ , {
204
+ // A cell says its colour; a row's or a column's cells may differ.
205
+ value: scope === 'cell' ? (current ?? '') : null, onValueChange: (value) => color(scope, HIGHLIGHT_COLORS.includes(value) ? value : null), children: [HIGHLIGHT_COLORS.map((one) => (_jsxs(DropdownMenuRadioItem, { value: one, closeOnClick: true, children: [_jsx("span", { "aria-hidden": "true", className: cn('size-4 shrink-0 rounded-sm border border-foreground/30', SWATCH[one]) }), one[0].toUpperCase() + one.slice(1)] }, one))), _jsxs(DropdownMenuRadioItem, { value: "", closeOnClick: true, children: [_jsx("span", { "aria-hidden": "true", className: "size-4 shrink-0 rounded-sm border border-foreground/30" }), "No colour"] })] })] }));
206
+ };
207
+ const columnItems = (withColor, label) => (_jsxs(_Fragment, { children: [_jsxs(DropdownMenuGroup, { children: [label && _jsx(DropdownMenuLabel, { children: label }), items(COLUMN_ITEMS)] }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Align column" }), _jsx(DropdownMenuRadioGroup, { value: there ? columnAlignment(there) : 'left', onValueChange: (value) => {
208
+ const one = ALIGNMENTS.find((alignment) => alignment.value === value);
209
+ if (one)
210
+ run(one.action);
211
+ }, children: ALIGNMENTS.map(({ value, label, icon: Icon }) => (_jsxs(DropdownMenuRadioItem, { value: value, closeOnClick: true, children: [_jsx(Icon, { "aria-hidden": "true" }), label] }, value))) })] }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuGroup, { children: items(SORT_ITEMS) }), menuCell?.column === 0 && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuCheckboxItem, { checked: there ? headerColumnOn(there) : false, disabled: !enabled('headerColumn'), onCheckedChange: () => run('headerColumn'), closeOnClick: true, children: [_jsx(PanelLeftIcon, { "aria-hidden": "true" }), "Header column"] })] })), withColor && there && tableColorEnabled(there, 'column') && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), colorGroup('column', 'Colour column')] })), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onClick: () => run('deleteTable'), children: [_jsx(Trash2Icon, { "aria-hidden": "true" }), "Delete table"] })] }));
212
+ const rowColor = there && tableColorEnabled(there, 'row');
213
+ const finalFocus = () => text() ?? true;
214
+ const grip = 'pointer-events-auto absolute flex items-center justify-center rounded-sm border border-border bg-muted text-muted-foreground shadow-xs outline-none hover:bg-primary hover:text-primary-foreground data-popup-open:bg-primary data-popup-open:text-primary-foreground';
215
+ return (_jsxs(_Fragment, { children: [_jsxs(DropdownMenu, { open: open === 'column' || open === 'cell', onOpenChange: openMenu('column'), children: [_jsx(DropdownMenuTrigger, { tabIndex: -1, "aria-label": menuCell ? `Column: ${columnName(menuCell.column)}` : 'Column', ref: columnGrip, "data-slot": "rich-text-table-column-grip", className: cn(grip, 'h-3.5 w-6 -translate-x-1/2 -translate-y-1/2'), children: _jsx(GripHorizontalIcon, { "aria-hidden": "true", className: "size-3" }) }), _jsx(DropdownMenuContent, { className: "w-56", "data-rich-text-menu": "", finalFocus: finalFocus, children: open === 'cell' ? (_jsxs(_Fragment, { children: [menuCell && menuCell.row > 0 && (_jsxs(_Fragment, { children: [colorGroup('cell', 'Cell colour'), _jsx(DropdownMenuSeparator, {})] })), _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Row" }), items(ROW_ITEMS)] }), _jsx(DropdownMenuSeparator, {}), columnItems(false, menuCell ? `Column: ${columnName(menuCell.column)}` : 'Column')] })) : (columnItems(true)) })] }), _jsxs(DropdownMenu, { open: open === 'row', onOpenChange: openMenu('row'), children: [_jsx(DropdownMenuTrigger, { tabIndex: -1, "aria-label": menuCell ? (menuCell.row === 0 ? 'Header row' : `Row ${menuCell.row}`) : 'Row', ref: rowGrip, "data-slot": "rich-text-table-row-grip", className: cn(grip, 'h-6 w-3.5 -translate-x-1/2 -translate-y-1/2'), children: _jsx(GripVerticalIcon, { "aria-hidden": "true", className: "size-3" }) }), _jsxs(DropdownMenuContent, { className: "w-56", "data-rich-text-menu": "", finalFocus: finalFocus, children: [_jsx(DropdownMenuGroup, { children: items(ROW_ITEMS) }), rowColor && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), colorGroup('row', 'Colour row')] }))] })] })] }));
216
+ }
@@ -1,6 +1,7 @@
1
- import { type EditorState, type Transaction } from '@milkdown/kit/prose/state';
1
+ import { Plugin, type EditorState, type Transaction } from '@milkdown/kit/prose/state';
2
+ import { type HighlightColor } from '../../lib/markdown-highlight.js';
2
3
  type Dispatch = (tr: Transaction) => void;
3
- export type TableAction = 'rowAbove' | 'rowBelow' | 'columnLeft' | 'columnRight' | 'alignLeft' | 'alignCenter' | 'alignRight' | 'deleteRow' | 'deleteColumn' | 'deleteTable';
4
+ export type TableAction = 'rowAbove' | 'rowBelow' | 'columnLeft' | 'columnRight' | 'alignLeft' | 'alignCenter' | 'alignRight' | 'deleteRow' | 'deleteColumn' | 'deleteTable' | 'moveRowUp' | 'moveRowDown' | 'moveColumnLeft' | 'moveColumnRight' | 'sortAscending' | 'sortDescending' | 'headerColumn';
4
5
  export type ColumnAlignment = 'left' | 'center' | 'right';
5
6
  /** The caret's column's alignment, as the header cell has it (GFM's default is left). */
6
7
  export declare function columnAlignment(state: EditorState): ColumnAlignment;
@@ -8,6 +9,22 @@ export declare function columnAlignment(state: EditorState): ColumnAlignment;
8
9
  export declare function tableActionEnabled(state: EditorState, action: TableAction): boolean;
9
10
  /** Runs an action where the caret is. False when it can't apply there. */
10
11
  export declare function runTableAction(state: EditorState, dispatch: Dispatch, action: TableAction): boolean;
12
+ /** Whether the caret's table has a header column (its first cells bold). */
13
+ export declare function headerColumnOn(state: EditorState): boolean;
14
+ /** What a colour is put on: the caret's cell, its whole row, or its column's body cells. */
15
+ export type TableColorScope = 'cell' | 'row' | 'column';
16
+ /** Whether a colour can go on that scope where the caret is (never the header row's cells). */
17
+ export declare function tableColorEnabled(state: EditorState, scope: TableColorScope): boolean;
18
+ /** The cell colour where the caret is, or null (none, or the header row). */
19
+ export declare function cellColorAt(state: EditorState): HighlightColor | null;
20
+ /**
21
+ * Colours cells: each one's whole text put in that highlight (`null`
22
+ * takes the highlight off). An empty cell stays as it is: a highlight
23
+ * needs words.
24
+ */
25
+ export declare function colorTableCells(state: EditorState, dispatch: Dispatch, scope: TableColorScope, color: HighlightColor | null): boolean;
26
+ /** The plugin that draws cell colours and a header column (tableDecorations). */
27
+ export declare function tableLook(): Plugin;
11
28
  /**
12
29
  * The editor's table keys, before Milkdown's own (which go to the next
13
30
  * cell on Tab, and out of the table on any Enter). True when it handled
@@ -1,4 +1,32 @@
1
- import { TextSelection } from '@milkdown/kit/prose/state';
1
+ // What the rich-text editor can do to a table: rows and columns added,
2
+ // deleted and moved, a column's alignment, the rows sorted by a column,
3
+ // cells coloured, a header column, the whole table deleted, and its keys.
4
+ // Plain ProseMirror commands over prosemirror-tables and Milkdown's gfm
5
+ // schema, so each is one undo step.
6
+ //
7
+ // Sorting rewrites the rows' order: what the editor shows is what it
8
+ // saves, so a sort is an edit like any other (Ctrl+Z puts them back).
9
+ // Colours and the header column are stored as Markdown already writes
10
+ // them (src/lib/markdown-table.ts): a coloured cell is its whole text in
11
+ // one highlight, a header column is its first cells in bold. The table's
12
+ // decorations (tableLook) draw them.
13
+ //
14
+ // What GFM can't store, the editor doesn't offer: a table always keeps its
15
+ // header row and at least one row under it, and at least one column, so
16
+ // those deletes are off where they'd break that (the header row can't be
17
+ // deleted or have a row put above it). Alignment is per column, as the
18
+ // Markdown writes it (`:---:`).
19
+ //
20
+ // Keys (handleTableKey), on top of Milkdown's Tab/Shift+Tab between cells:
21
+ // Tab in the last cell adds a row; Enter goes to the cell below, and out
22
+ // of the table from its last row; Shift+Enter does nothing (a line break
23
+ // can't be stored inside a cell).
24
+ import { closeHistory } from '@milkdown/kit/prose/history';
25
+ import { Fragment } from '@milkdown/kit/prose/model';
26
+ import { Plugin, PluginKey, TextSelection } from '@milkdown/kit/prose/state';
27
+ import { Decoration, DecorationSet } from '@milkdown/kit/prose/view';
28
+ import { isHighlightColor } from '../../lib/markdown-highlight.js';
29
+ import { cellColor, compareCells, isHeaderColumn } from '../../lib/markdown-table.js';
2
30
  import { addColumnAfter, addColumnBefore, deleteColumn, deleteRow, deleteTable, isInTable, selectedRect, TableMap, } from '@milkdown/kit/prose/tables';
3
31
  const ALIGN = {
4
32
  alignLeft: 'left',
@@ -26,6 +54,19 @@ export function tableActionEnabled(state, action) {
26
54
  return top > 0 && bottom - top < map.height - 1;
27
55
  case 'deleteColumn':
28
56
  return right - left < map.width;
57
+ case 'moveRowUp':
58
+ return top > 1;
59
+ case 'moveRowDown':
60
+ return top > 0 && bottom < map.height;
61
+ case 'moveColumnLeft':
62
+ return left > 0;
63
+ case 'moveColumnRight':
64
+ return right < map.width;
65
+ case 'sortAscending':
66
+ case 'sortDescending':
67
+ return map.height > 2;
68
+ case 'headerColumn':
69
+ return map.height > 1;
29
70
  default:
30
71
  return true;
31
72
  }
@@ -52,10 +93,200 @@ export function runTableAction(state, dispatch, action) {
52
93
  return deleteColumn(state, dispatch);
53
94
  case 'deleteTable':
54
95
  return deleteTable(state, dispatch);
96
+ case 'moveRowUp':
97
+ case 'moveRowDown':
98
+ return moveRow(state, dispatch, action === 'moveRowUp' ? -1 : 1);
99
+ case 'moveColumnLeft':
100
+ case 'moveColumnRight':
101
+ return moveColumn(state, dispatch, action === 'moveColumnLeft' ? -1 : 1);
102
+ case 'sortAscending':
103
+ case 'sortDescending':
104
+ return sortRows(state, dispatch, action === 'sortAscending' ? 1 : -1);
105
+ case 'headerColumn':
106
+ return toggleHeaderColumn(state, dispatch);
55
107
  default:
56
108
  return false;
57
109
  }
58
110
  }
111
+ // The table rebuilt from its rows, in place of the old one, and the caret
112
+ // into the cell at `row`, `column` of the new one. Its own undo step,
113
+ // never merged with the edit before it (two sorts in a row undo one at a time).
114
+ function replaceRows(state, dispatch, rows, row, column) {
115
+ const { table, tableStart } = selectedRect(state);
116
+ const next = table.copy(Fragment.from(rows));
117
+ const tr = state.tr.replaceWith(tableStart - 1, tableStart - 1 + table.nodeSize, next);
118
+ const map = TableMap.get(next);
119
+ tr.setSelection(TextSelection.near(tr.doc.resolve(tableStart + map.map[row * map.width + column] + 1)));
120
+ dispatch(closeHistory(tr.scrollIntoView()));
121
+ return true;
122
+ }
123
+ const rowsOf = (table) => {
124
+ const rows = [];
125
+ table.forEach((row) => rows.push(row));
126
+ return rows;
127
+ };
128
+ // The caret's row swapped with the one above or below (never the header
129
+ // row: GFM's first row is always the header).
130
+ function moveRow(state, dispatch, by) {
131
+ const { table, top, left } = selectedRect(state);
132
+ const rows = rowsOf(table);
133
+ const to = top + by;
134
+ [rows[top], rows[to]] = [rows[to], rows[top]];
135
+ return replaceRows(state, dispatch, rows, to, left);
136
+ }
137
+ // The caret's column swapped with the one left or right of it, in every
138
+ // row; each cell keeps its alignment, so the column's moves with it.
139
+ function moveColumn(state, dispatch, by) {
140
+ const { table, top, left } = selectedRect(state);
141
+ const to = left + by;
142
+ const rows = rowsOf(table).map((row) => {
143
+ const cells = [];
144
+ row.forEach((cell) => cells.push(cell));
145
+ [cells[left], cells[to]] = [cells[to], cells[left]];
146
+ return row.copy(Fragment.from(cells));
147
+ });
148
+ return replaceRows(state, dispatch, rows, top, to);
149
+ }
150
+ // The body rows in the order of the caret's column (numbers by value,
151
+ // then words, empty cells last), the header row kept first. Rows that
152
+ // tie keep their order. The caret goes to the column's header.
153
+ function sortRows(state, dispatch, direction) {
154
+ const { table, left } = selectedRect(state);
155
+ const [header, ...body] = rowsOf(table);
156
+ const text = (row) => row.child(left).textContent;
157
+ const sorted = body
158
+ .map((row, index) => ({ row, index }))
159
+ .sort((a, b) => compareCells(text(a.row), text(b.row)) * direction || a.index - b.index)
160
+ .map(({ row }) => row);
161
+ return replaceRows(state, dispatch, [header, ...sorted], 0, left);
162
+ }
163
+ // A cell's text as runs, with their highlight and bold (markdown-table.ts).
164
+ function runsOf(cell) {
165
+ const runs = [];
166
+ cell.descendants((node) => {
167
+ if (!node.isText)
168
+ return true;
169
+ const highlight = node.marks.find((mark) => mark.type.name === 'highlight');
170
+ const color = highlight ? (isHighlightColor(highlight.attrs.color) ? highlight.attrs.color : 'amber') : null;
171
+ runs.push({ text: node.text ?? '', color, bold: node.marks.some((mark) => mark.type.name === 'strong') });
172
+ return false;
173
+ });
174
+ return runs;
175
+ }
176
+ // Each body row's first cell, and where it starts in the document.
177
+ function firstCells(table, tableStart) {
178
+ const cells = [];
179
+ table.forEach((row, offset, index) => {
180
+ if (index > 0 && row.firstChild)
181
+ cells.push({ cell: row.firstChild, pos: tableStart + offset + 1 });
182
+ });
183
+ return cells;
184
+ }
185
+ /** Whether the caret's table has a header column (its first cells bold). */
186
+ export function headerColumnOn(state) {
187
+ if (!isInTable(state))
188
+ return false;
189
+ const { table, tableStart } = selectedRect(state);
190
+ return isHeaderColumn(firstCells(table, tableStart).map(({ cell }) => runsOf(cell)));
191
+ }
192
+ // The header column on (each body row's first cell bolded) or off (unbolded).
193
+ function toggleHeaderColumn(state, dispatch) {
194
+ const { table, tableStart } = selectedRect(state);
195
+ const strong = state.schema.marks.strong;
196
+ const on = headerColumnOn(state);
197
+ const tr = state.tr;
198
+ for (const { cell, pos } of firstCells(table, tableStart)) {
199
+ const from = pos + 1;
200
+ const to = pos + cell.nodeSize - 1;
201
+ if (on)
202
+ tr.removeMark(from, to, strong);
203
+ else if (cell.textContent.trim())
204
+ tr.addMark(from, to, strong.create());
205
+ }
206
+ if (tr.docChanged)
207
+ dispatch(closeHistory(tr));
208
+ return true;
209
+ }
210
+ /** Whether a colour can go on that scope where the caret is (never the header row's cells). */
211
+ export function tableColorEnabled(state, scope) {
212
+ if (!isInTable(state))
213
+ return false;
214
+ const { map, top } = selectedRect(state);
215
+ return scope === 'column' ? map.height > 1 : top > 0;
216
+ }
217
+ /** The cell colour where the caret is, or null (none, or the header row). */
218
+ export function cellColorAt(state) {
219
+ if (!isInTable(state))
220
+ return null;
221
+ const { table, map, left, top } = selectedRect(state);
222
+ const cell = table.nodeAt(map.map[top * map.width + left]);
223
+ return cell ? cellColor(runsOf(cell)) : null;
224
+ }
225
+ /**
226
+ * Colours cells: each one's whole text put in that highlight (`null`
227
+ * takes the highlight off). An empty cell stays as it is: a highlight
228
+ * needs words.
229
+ */
230
+ export function colorTableCells(state, dispatch, scope, color) {
231
+ if (!tableColorEnabled(state, scope))
232
+ return false;
233
+ const { map, table, tableStart, top, bottom, left, right } = selectedRect(state);
234
+ const highlight = state.schema.marks.highlight;
235
+ const rows = scope === 'column' ? [1, map.height] : [top, bottom];
236
+ const columns = scope === 'row' ? [0, map.width] : [left, right];
237
+ const tr = state.tr;
238
+ for (let row = rows[0]; row < rows[1]; row++) {
239
+ for (let column = columns[0]; column < columns[1]; column++) {
240
+ const offset = map.map[row * map.width + column];
241
+ const cell = table.nodeAt(offset);
242
+ const from = tableStart + offset + 1;
243
+ const to = tableStart + offset + cell.nodeSize - 1;
244
+ tr.removeMark(from, to, highlight);
245
+ if (color && cell.textContent.trim())
246
+ tr.addMark(from, to, highlight.create({ color }));
247
+ }
248
+ }
249
+ if (tr.docChanged)
250
+ dispatch(closeHistory(tr));
251
+ return true;
252
+ }
253
+ // The decorations that draw what the Markdown keeps: a coloured cell's
254
+ // wash (`data-color`) and a header column's first cells (`data-row-header`),
255
+ // styled in styles/index.css ("Tables in written text").
256
+ function tableDecorations(doc) {
257
+ const decorations = [];
258
+ doc.descendants((node, pos) => {
259
+ if (node.type.name !== 'table')
260
+ return node.isBlock && !node.isTextblock;
261
+ const tableStart = pos + 1;
262
+ node.forEach((row, rowOffset) => {
263
+ row.forEach((cell, cellOffset) => {
264
+ const color = cellColor(runsOf(cell));
265
+ const at = tableStart + rowOffset + 1 + cellOffset;
266
+ if (color)
267
+ decorations.push(Decoration.node(at, at + cell.nodeSize, { 'data-color': color }));
268
+ });
269
+ });
270
+ const firsts = firstCells(node, tableStart);
271
+ if (isHeaderColumn(firsts.map(({ cell }) => runsOf(cell))))
272
+ for (const { cell, pos: at } of firsts)
273
+ decorations.push(Decoration.node(at, at + cell.nodeSize, { 'data-row-header': '' }));
274
+ return false;
275
+ });
276
+ return DecorationSet.create(doc, decorations);
277
+ }
278
+ const tableLookKey = new PluginKey('tableLook');
279
+ /** The plugin that draws cell colours and a header column (tableDecorations). */
280
+ export function tableLook() {
281
+ return new Plugin({
282
+ key: tableLookKey,
283
+ state: {
284
+ init: (_config, state) => tableDecorations(state.doc),
285
+ apply: (tr, previous) => (tr.docChanged ? tableDecorations(tr.doc) : previous),
286
+ },
287
+ props: { decorations: (state) => tableLookKey.getState(state) },
288
+ });
289
+ }
59
290
  // A row of empty cells, each aligned as its column is, above or below the
60
291
  // caret's rows (or at the end: `'end'`), and the caret into it in the
61
292
  // same column (the first, at the end).
@@ -2,7 +2,6 @@ import { type Ref } from 'react';
2
2
  import type { EditorState } from '@milkdown/kit/prose/state';
3
3
  import { type FormatItem } from './rich-text-format-items.js';
4
4
  import { type FormatId } from './rich-text-formats.js';
5
- import type { TableAction } from './rich-text-table.js';
6
5
  declare const SETS: {
7
6
  all: FormatItem[][];
8
7
  tools: FormatItem[][];
@@ -16,18 +15,16 @@ export type FormatButtonsProps = {
16
15
  readOnly: boolean;
17
16
  /** A button pressed: run the format on the editor and give it focus back. */
18
17
  onFormat: (id: FormatId) => void;
19
- /** A Table menu item picked (only the fixed toolbar has the menu). */
20
- onTable?: (action: TableAction) => void;
21
18
  /** The Image button pressed: pick images to upload. No button without it. */
22
19
  onImage?: () => void;
23
20
  /** The Full screen button pressed. No button without it. */
24
21
  onFullScreen?: () => void;
25
22
  /** Whether the editor is full screen now (the button's state). */
26
23
  fullScreen?: boolean;
27
- /** The editable element, for the Table menu to give focus back to. */
24
+ /** The editable element, for the Highlight menu to give focus back to. */
28
25
  text?: () => HTMLElement | null;
29
26
  };
30
- export declare function FormatButtons({ formats, state, readOnly, onFormat, onTable, onImage, onFullScreen, fullScreen, text }: FormatButtonsProps): import("react").JSX.Element;
27
+ export declare function FormatButtons({ formats, state, readOnly, onFormat, onImage, onFullScreen, fullScreen, text }: FormatButtonsProps): import("react").JSX.Element;
31
28
  export interface RichTextToolbarProps {
32
29
  /** The editor's state now, to show which formats are on. Null until it's made. */
33
30
  state: EditorState | null;
@@ -36,18 +33,16 @@ export interface RichTextToolbarProps {
36
33
  controls?: string;
37
34
  /** A button pressed: run the format on the editor and give it focus back. */
38
35
  onFormat: (id: FormatId) => void;
39
- /** A Table menu item picked: run it on the editor and give it focus back. */
40
- onTable: (action: TableAction) => void;
41
36
  /** The Image button pressed (only when the editor uploads images). */
42
37
  onImage?: () => void;
43
38
  /** The Full screen button pressed. No button without it. */
44
39
  onFullScreen?: () => void;
45
40
  /** Whether the editor is full screen now. */
46
41
  fullScreen?: boolean;
47
- /** The editable element, for the Table menu to give focus back to. */
42
+ /** The editable element, for the Highlight menu to give focus back to. */
48
43
  text: () => HTMLElement | null;
49
44
  /** The toolbar's element, for Alt+F10 to reach it from the text. */
50
45
  ref?: Ref<HTMLDivElement>;
51
46
  }
52
- export declare function RichTextToolbar({ state, readOnly, controls, onFormat, onTable, onImage, onFullScreen, fullScreen, text, ref }: RichTextToolbarProps): import("react").JSX.Element;
47
+ export declare function RichTextToolbar({ state, readOnly, controls, onFormat, onImage, onFullScreen, fullScreen, text, ref }: RichTextToolbarProps): import("react").JSX.Element;
53
48
  export {};
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  // The rich-text editor's fixed toolbar: one row of pressed/not-pressed
3
3
  // buttons above the text, each a format and its keyboard shortcut, a
4
- // Table menu (rich-text-table-menu.tsx), and, when the editor uploads
5
- // images, an Image button (a plain button: it opens the file picker).
4
+ // Table button that inserts one (a table's own grips edit it:
5
+ // rich-text-table-grips.tsx), and, when the editor uploads images, an
6
+ // Image button (a plain button: it opens the file picker).
6
7
  //
7
8
  // - One tab stop (ARIA's toolbar pattern): Tab reaches the last button
8
9
  // used, the arrow keys, Home and End move along the row.
@@ -24,7 +25,6 @@ import { FORMAT_GROUPS, FORMAT_ITEMS } from './rich-text-format-items.js';
24
25
  import { formatOn } from './rich-text-formats.js';
25
26
  import { ariaKeys, isApple, shownKeys } from './rich-text-shortcuts.js';
26
27
  import { RichTextHighlightMenu } from './rich-text-highlight-menu.js';
27
- import { RichTextTableMenu } from './rich-text-table-menu.js';
28
28
  // Which buttons a toolbar has: every format (the fixed one), or the ones
29
29
  // that apply to selected words (the floating one).
30
30
  const SELECTION = ['bold', 'italic', 'strike', 'code', 'highlight', 'link'];
@@ -51,7 +51,7 @@ function useCompact() {
51
51
  // A toolbar's row of buttons: one tab stop, the last one used (ARIA's
52
52
  // toolbar pattern); the arrow keys, Home and End move along the row. The
53
53
  // toolbar's element (`role="toolbar"`, its name) is the caller's.
54
- export function FormatButtons({ formats, state, readOnly, onFormat, onTable, onImage, onFullScreen, fullScreen = false, text = () => null }) {
54
+ export function FormatButtons({ formats, state, readOnly, onFormat, onImage, onFullScreen, fullScreen = false, text = () => null }) {
55
55
  const apple = isApple();
56
56
  const compact = useCompact();
57
57
  const groups = SETS[formats].map((group) => group.filter((item) => compact || !item.touch)).filter((group) => group.length > 0);
@@ -83,10 +83,6 @@ export function FormatButtons({ formats, state, readOnly, onFormat, onTable, onI
83
83
  };
84
84
  return (_jsxs(TooltipProvider, { delay: 400, children: [groups.map((group, groupIndex) => (_jsxs(Fragment, { children: [groupIndex > 0 && _jsx(Separator, { orientation: "vertical", className: "mx-1 h-5 self-center" }), group.map((item) => {
85
85
  const index = items.indexOf(item);
86
- if (item.id === 'table')
87
- return (_jsx(RichTextTableMenu, { ref: (element) => {
88
- buttons.current[index] = element;
89
- }, state: state, readOnly: readOnly, tabIndex: index === current ? 0 : -1, onFocus: () => setCurrent(index), onKeyDown: onKeyDown, onInsert: () => onFormat('table'), onAction: (action) => onTable?.(action), text: text }, item.id));
90
86
  if (item.id === 'highlight')
91
87
  return (_jsx(RichTextHighlightMenu, { ref: (element) => {
92
88
  buttons.current[index] = element;
@@ -102,7 +98,9 @@ export function FormatButtons({ formats, state, readOnly, onFormat, onTable, onI
102
98
  if (item.action)
103
99
  return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx(Button, { ref: (element) => {
104
100
  buttons.current[index] = element;
105
- }, variant: "ghost", size: "icon-sm", "aria-label": item.label, disabled: readOnly, tabIndex: index === current ? 0 : -1, onMouseDown: (event) => event.preventDefault(), onFocus: () => setCurrent(index), onKeyDown: onKeyDown, onClick: () => onFormat(item.id) }), children: _jsx(Icon, {}) }), _jsx(TooltipContent, { children: tip })] }, item.id));
101
+ }, variant: "ghost", size: "icon-sm", "aria-label": item.label,
102
+ // A table goes in only outside one.
103
+ disabled: readOnly || (item.id === 'table' && state !== null && formatOn(state, 'table')), tabIndex: index === current ? 0 : -1, onMouseDown: (event) => event.preventDefault(), onFocus: () => setCurrent(index), onKeyDown: onKeyDown, onClick: () => onFormat(item.id) }), children: _jsx(Icon, {}) }), _jsx(TooltipContent, { children: tip })] }, item.id));
106
104
  return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx(Toggle, { ref: (element) => {
107
105
  buttons.current[index] = element;
108
106
  }, size: "icon-sm", "aria-label": item.label, "aria-keyshortcuts": item.keys ? ariaKeys(item.keys, apple) : undefined, pressed: state ? formatOn(state, item.id) : false, disabled: readOnly, tabIndex: index === current ? 0 : -1, className: "aria-pressed:bg-muted aria-pressed:text-foreground",
@@ -114,6 +112,6 @@ export function FormatButtons({ formats, state, readOnly, onFormat, onTable, onI
114
112
  buttons.current[fullScreenIndex] = element;
115
113
  }, size: "icon-sm", "aria-label": "Full screen", pressed: fullScreen, tabIndex: fullScreenIndex === current ? 0 : -1, className: "aria-pressed:bg-muted aria-pressed:text-foreground", onMouseDown: (event) => event.preventDefault(), onFocus: () => setCurrent(fullScreenIndex), onKeyDown: onKeyDown, onPressedChange: onFullScreen }), children: fullScreen ? _jsx(Minimize2Icon, {}) : _jsx(Maximize2Icon, {}) }), _jsx(TooltipContent, { children: fullScreen ? 'Leave full screen · Esc' : 'Full screen' })] })] }))] }));
116
114
  }
117
- export function RichTextToolbar({ state, readOnly, controls, onFormat, onTable, onImage, onFullScreen, fullScreen, text, ref }) {
118
- return (_jsx("div", { ref: ref, role: "toolbar", "aria-label": "Formatting", "aria-controls": controls, "data-slot": "rich-text-toolbar", className: "flex flex-wrap items-center gap-0.5 border-b border-input px-1 py-1", children: _jsx(FormatButtons, { formats: "all", state: state, readOnly: readOnly, onFormat: onFormat, onTable: onTable, onImage: onImage, onFullScreen: onFullScreen, fullScreen: fullScreen, text: text }) }));
115
+ export function RichTextToolbar({ state, readOnly, controls, onFormat, onImage, onFullScreen, fullScreen, text, ref }) {
116
+ return (_jsx("div", { ref: ref, role: "toolbar", "aria-label": "Formatting", "aria-controls": controls, "data-slot": "rich-text-toolbar", className: "flex flex-wrap items-center gap-0.5 border-b border-input px-1 py-1", children: _jsx(FormatButtons, { formats: "all", state: state, readOnly: readOnly, onFormat: onFormat, onImage: onImage, onFullScreen: onFullScreen, fullScreen: fullScreen, text: text }) }));
119
117
  }
@@ -8,8 +8,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  // the tab order.
9
9
  // - A table's bars add a column at its right and a row at its end, shown
10
10
  // while the pointer is over the table or the caret is in it. Mouse
11
- // shortcuts as well: Tab in the last cell adds a row, and the toolbar's
12
- // Table menu does both.
11
+ // shortcuts as well: Tab in the last cell adds a row, and a cell's menu
12
+ // (its grips, or Shift+F10: rich-text-table-grips.tsx) does both.
13
13
  //
14
14
  // Each gives focus back to the text when it's done.
15
15
  import { PlusIcon } from 'lucide-react';
package/dist/index.d.ts CHANGED
@@ -25,7 +25,7 @@ export { FoundationProviders } from './components/app/foundation-providers.js';
25
25
  export { Markdown, type MarkdownProps } from './components/app/markdown.js';
26
26
  export { countTasks, toggleTaskAt, type TaskCount } from './lib/markdown-tasks.js';
27
27
  export { NotesOutline, type NotesOutlineProps } from './components/app/notes-outline.js';
28
- export type { HeadingFrom, HeadingLevel } from './components/app/prose-look.js';
28
+ export type { HeadingFrom, HeadingLevel, TableLook } from './components/app/prose-look.js';
29
29
  export { MockModeBanner } from './components/app/mock-mode-banner.js';
30
30
  export { MultiChoice } from './components/app/multi-choice.js';
31
31
  export { MultiPick, type MultiPickOption, type MultiPickProps } from './components/app/multi-pick.js';