@tristan2828/ui-foundation 4.61.0 → 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.
@@ -26,15 +26,15 @@
26
26
  - [A variant: a pressed icon inside another value's cell](#a-variant-a-pressed-icon-inside-another-values-cell) · lines 565–648
27
27
  - [16. Title linking to the record's view](#16-title-linking-to-the-records-view) · lines 650–685
28
28
  - [17. Quick action on the view](#17-quick-action-on-the-view) · lines 687–760
29
- - [18. Editable value](#18-editable-value) · lines 762–1221
30
- - [A list of sub-items](#a-list-of-sub-items) · lines 1037–1122
31
- - [In a table's rows](#in-a-tables-rows) · lines 1124–1209
32
- - [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines 1211–1221
33
- - [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines 1223–1289
34
- - [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines 1291–1355
35
- - [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines 1357–1425
36
- - [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines 1427–1455
37
- - [23. A Group menu, and a record read beside the list](#23-a-group-menu-and-a-record-read-beside-the-list) · lines 1457–1533
29
+ - [18. Editable value](#18-editable-value) · lines 762–1226
30
+ - [A list of sub-items](#a-list-of-sub-items) · lines 1042–1127
31
+ - [In a table's rows](#in-a-tables-rows) · lines 1129–1214
32
+ - [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines 1216–1226
33
+ - [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines 1228–1294
34
+ - [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines 1296–1360
35
+ - [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines 1362–1430
36
+ - [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines 1432–1460
37
+ - [23. A Group menu, and a record read beside the list](#23-a-group-menu-and-a-record-read-beside-the-list) · lines 1462–1538
38
38
 
39
39
  <!-- /index -->
40
40
 
@@ -1002,8 +1002,13 @@ const price = editInPlace({
1002
1002
  is GitHub's `> [!NOTE]` (note, tip, important, warning, caution), its
1003
1003
  icon a menu of kinds. In a table, Tab in the last cell adds a row,
1004
1004
  Enter goes down a column (and out from the last row), bars under and
1005
- beside it add a row or a column, and the toolbar's Table menu inserts
1006
- rows and columns, aligns a column and deletes. Selected words can be
1005
+ beside it add a row or a column, and a grip on the hovered column and
1006
+ row opens its menu: insert, move and delete, align, sort (rewriting the
1007
+ rows), a header column (its first cells bold) and cell colours (a
1008
+ cell's whole text in a highlight); a right-click or Shift+F10 in a cell
1009
+ opens the cell's. Columns fit their words. `tableLook="boxed"` (on the
1010
+ editor and on `<Markdown>`) gives a roomier boxed table with zebra rows
1011
+ instead of the compact grid. Selected words can be
1007
1012
  highlighted in four colours (amber, yellow, green, red) from the
1008
1013
  toolbars' Highlight menu or with "/highlight", and un-highlighted;
1009
1014
  they're saved as `==words==` (`=={green}words==` for the others), plain
@@ -1,5 +1,5 @@
1
1
  import { type ComponentProps } from 'react';
2
- import { type HeadingFrom, type HeadingLevel } from './prose-look.js';
2
+ import { type HeadingFrom, type HeadingLevel, type TableLook } from './prose-look.js';
3
3
  export type MarkdownProps = {
4
4
  /** The Markdown source. Raw HTML in it is shown as text, never rendered. */
5
5
  children: string;
@@ -44,6 +44,11 @@ export type MarkdownProps = {
44
44
  * are pictures and the text stays read-only.
45
45
  */
46
46
  onTasksChange?: (markdown: string) => void | Promise<unknown>;
47
+ /**
48
+ * How tables look: `'compact'` (the default) or `'boxed'`, as on
49
+ * RichTextEditor. Either way columns fit their words.
50
+ */
51
+ tableLook?: TableLook;
47
52
  className?: string;
48
53
  } & Omit<ComponentProps<'div'>, 'children'>;
49
- export declare function Markdown({ children, headingLevel, headingFrom, foldKey: foldKeyProp, foldable, onTasksChange: onTasksChangeProp, className, ...rest }: MarkdownProps): import("react").JSX.Element;
54
+ export declare function Markdown({ children, headingLevel, headingFrom, foldKey: foldKeyProp, foldable, onTasksChange: onTasksChangeProp, tableLook, className, ...rest }: MarkdownProps): import("react").JSX.Element;
@@ -23,6 +23,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
23
23
  // sit: `#` and `##` on a tinted band, `###` a small uppercase label, and
24
24
  // the text under them lined up with the words in the band (prose-look.ts).
25
25
  //
26
+ // A table's columns fit their words (a cell wraps at 18rem; a right-
27
+ // aligned column, numbers, never wraps), in one of two looks
28
+ // (`tableLook`, as on RichTextEditor: styles/index.css "Tables in written
29
+ // text"). A cell whose whole text is one highlight is painted in it, and
30
+ // a first column of bold cells is row headers (src/lib/markdown-table.ts).
26
31
  // A table wider than the text scrolls sideways, and while it does, its
27
32
  // box is a tab stop, named for its columns, that the arrow keys scroll
28
33
  // (prose-table-scroll.ts). Its cells wrap first, so that's only a table
@@ -60,11 +65,11 @@ import remarkGfm from 'remark-gfm';
60
65
  import { cn } from 'cn';
61
66
  import { ChevronDownIcon, ExternalLinkIcon, SquareCheckIcon, SquareIcon } from 'lucide-react';
62
67
  import { Checkbox } from '../ui/checkbox.js';
63
- import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table.js';
64
68
  import { useFolds } from '../../hooks/fold-store.js';
65
69
  import { CALLOUT_KINDS, remarkCallouts } from '../../lib/markdown-callout.js';
66
70
  import { rehypeFold, toggleFold } from '../../lib/markdown-fold.js';
67
71
  import { isHighlightColor, remarkHighlights } from '../../lib/markdown-highlight.js';
72
+ import { rehypeTables } from '../../lib/markdown-table.js';
68
73
  import { toggleTaskAt } from '../../lib/markdown-tasks.js';
69
74
  import { textTopHeading } from '../../lib/markdown-top-heading.js';
70
75
  import { CALLOUT_BOX, CALLOUT_LOOK, DEFAULT_HEADING_LEVEL, FOLD_BUTTON, FOLD_HEADING, headingBand, headingClass, headingTag, PROSE, PROSE_INSET, } from './prose-look.js';
@@ -87,10 +92,9 @@ function omitNode(props) {
87
92
  delete rest.node;
88
93
  return rest;
89
94
  }
90
- // A GFM table in the package's own table, in its box that scrolls
91
- // sideways: a tab stop and a named region only while the table overflows
92
- // it. Watched again after each render, so a header row with new words
93
- // renames it.
95
+ // A GFM table in its box that scrolls sideways: a tab stop and a named
96
+ // region only while the table overflows it. Watched again after each
97
+ // render, so a header row with new words renames it.
94
98
  function NotesTable({ hidden, ...props }) {
95
99
  const boxRef = useRef(null);
96
100
  useEffect(() => {
@@ -101,9 +105,7 @@ function NotesTable({ hidden, ...props }) {
101
105
  const region = watchTableScroll(box, table, headerTexts(table));
102
106
  return () => region.destroy();
103
107
  });
104
- return (_jsx(Table, { ...props, containerRef: boxRef,
105
- // Folded away, the box goes with the table.
106
- containerClassName: cn('rounded-lg border border-border', TABLE_SCROLL_FOCUS, hidden && 'hidden') }));
108
+ return (_jsx("div", { ref: boxRef, "data-slot": "table-container", className: cn('prose-table', TABLE_SCROLL_FOCUS, hidden && 'hidden'), children: _jsx("table", { ...props }) }));
107
109
  }
108
110
  // Ticks a task item by where it starts in the text: given only with
109
111
  // `onTasksChange`. Each task item hands its box its own start and words.
@@ -211,16 +213,9 @@ const COMPONENTS = {
211
213
  const { alt, ...rest } = omitNode(props);
212
214
  return _jsx("img", { ...rest, alt: alt ?? '', loading: "lazy", className: "h-auto max-w-full rounded-md" });
213
215
  },
214
- // GFM tables use the package's own table, zebra stripes and density
215
- // tokens included, so a table in the notes reads like every other table.
216
+ // GFM tables: drawn by styles/index.css "Tables in written text"
217
+ // (rehypeTables marks coloured cells and row headers).
216
218
  table: (props) => _jsx(NotesTable, { ...omitNode(props) }),
217
- thead: (props) => _jsx(TableHeader, { ...omitNode(props), className: "bg-muted" }),
218
- tbody: (props) => _jsx(TableBody, { ...omitNode(props) }),
219
- tr: (props) => _jsx(TableRow, { ...omitNode(props) }),
220
- // Notes' cells wrap (a cell can hold a sentence) and have a line
221
- // between columns, so a wide table still reads column by column.
222
- th: (props) => _jsx(TableHead, { ...omitNode(props), className: "border-r border-border whitespace-normal last:border-r-0" }),
223
- td: (props) => _jsx(TableCell, { ...omitNode(props), className: "border-r border-border whitespace-normal last:border-r-0" }),
224
219
  };
225
220
  // The components for each `headingLevel` and level that takes it (`#`,
226
221
  // or the text's highest with `headingFrom="text"`), made once each.
@@ -236,7 +231,7 @@ function componentsFor(top, textTop) {
236
231
  }
237
232
  return components;
238
233
  }
239
- export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', foldKey: foldKeyProp, foldable = true, onTasksChange: onTasksChangeProp, className, ...rest }) {
234
+ export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', foldKey: foldKeyProp, foldable = true, onTasksChange: onTasksChangeProp, tableLook = 'compact', className, ...rest }) {
240
235
  // On paper (EntityView's Print notes): every heading plain and open,
241
236
  // every task box a picture.
242
237
  const printing = usePrinting();
@@ -245,7 +240,7 @@ export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headi
245
240
  const textTop = useMemo(() => (headingFrom === 'text' ? textTopHeading(children) : 1), [headingFrom, children]);
246
241
  const components = useMemo(() => componentsFor(headingLevel, textTop), [headingLevel, textTop]);
247
242
  const [folded, setFolded] = useFolds(foldKey);
248
- const rehypePlugins = useMemo(() => (foldKey === undefined ? [] : [[rehypeFold, { folded }]]), [foldKey, folded]);
243
+ const rehypePlugins = useMemo(() => (foldKey === undefined ? [rehypeTables] : [rehypeTables, [rehypeFold, { folded }]]), [foldKey, folded]);
249
244
  const toggle = useCallback((id) => setFolded(toggleFold(folded, id)), [folded, setFolded]);
250
245
  // Ticks not yet back as `children`: shown, and built on by the next
251
246
  // tick, so two quick ticks on a slow save both land. Kept while
@@ -261,5 +256,5 @@ export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headi
261
256
  if (saving && typeof saving.then === 'function')
262
257
  saving.then(undefined, () => setPending((current) => (current?.sent.includes(next) ? null : current)));
263
258
  }, [onTasksChange, children, shown, live]);
264
- return (_jsx("div", { "data-slot": "markdown", "data-fold-scope": foldKey, ...rest, className: cn('flex min-w-0 flex-col gap-3 type-body text-foreground break-words', PROSE_INSET, className), children: _jsx(FoldContext.Provider, { value: foldKey === undefined ? null : toggle, children: _jsx(TaskToggleContext.Provider, { value: onTasksChange ? tickTask : null, children: _jsx(ReactMarkdown, { remarkPlugins: REMARK_PLUGINS, rehypePlugins: rehypePlugins, components: components, children: onTasksChange ? shown : children }) }) }) }));
259
+ return (_jsx("div", { "data-slot": "markdown", "data-table-look": tableLook, "data-fold-scope": foldKey, ...rest, className: cn('flex min-w-0 flex-col gap-3 type-body text-foreground break-words', PROSE_INSET, className), children: _jsx(FoldContext.Provider, { value: foldKey === undefined ? null : toggle, children: _jsx(TaskToggleContext.Provider, { value: onTasksChange ? tickTask : null, children: _jsx(ReactMarkdown, { remarkPlugins: REMARK_PLUGINS, rehypePlugins: rehypePlugins, components: components, children: onTasksChange ? shown : children }) }) }) }));
265
260
  }
@@ -1,2 +1,2 @@
1
1
  import type { RichTextEditorProps } from './rich-text-editor.js';
2
- export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, inline, upload, imageAccept, imageMaxSize, headingLevel, headingFrom, foldKey, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
2
+ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, inline, upload, imageAccept, imageMaxSize, headingLevel, headingFrom, foldKey, tableLook, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
@@ -20,11 +20,16 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
20
20
  // task-list item renders a real checkbox that toggles it.
21
21
  // - Callouts (`> [!NOTE]`, src/lib/markdown-callout.ts): a node of their
22
22
  // own, drawn as <Markdown> draws them, the icon a menu of kinds.
23
- // - Tables in a box that scrolls sideways (a tab stop the arrow keys
24
- // scroll while it does: rich-text-node-views.ts), with Notion's "add a row" and
25
- // "add a column" bars, Tab in the last cell adding a row and Enter going
26
- // down a column (rich-text-table.ts); the toolbar's Table menu does the
27
- // rest.
23
+ // - Tables whose columns fit their words, in a box that scrolls sideways
24
+ // (a tab stop the arrow keys scroll while it does: rich-text-node-views.ts),
25
+ // in one of two looks (`tableLook`: styles/index.css "Tables in written
26
+ // text"). Notion's "add a row" and "add a column" bars, and its grips: one
27
+ // on the column and one on the row the pointer or the caret is in, each
28
+ // a menu to insert, move, delete, align, sort and colour
29
+ // (rich-text-table-grips.tsx); right-click or Shift+F10 in a cell opens
30
+ // the cell's. Tab in the last cell adds a row and Enter goes down a
31
+ // column (rich-text-table.ts). Colours and a header column are kept as
32
+ // Markdown writes them (src/lib/markdown-table.ts).
28
33
  // - Edited blocks are written in the document's own bullet and rule marks.
29
34
  // - Markdown pasted as plain text arrives formatted (still no HTML), and a
30
35
  // copy out of the editor is Markdown.
@@ -86,7 +91,8 @@ import { calloutKind, calloutView, tableView } from './rich-text-node-views.js';
86
91
  import { droppedFiles, imageUploadPlugin, pastedImages, uploadImages, uploadPlaceholders } from './rich-text-image-upload.js';
87
92
  import { handleShortcut } from './rich-text-shortcuts.js';
88
93
  import { splitTaskItem, taskInputRule } from './rich-text-tasks.js';
89
- import { addRowAtEnd, handleTableKey, runTableAction } from './rich-text-table.js';
94
+ import { addRowAtEnd, colorTableCells, handleTableKey, runTableAction, tableLook as tableLookPlugin } from './rich-text-table.js';
95
+ import { TABLE_MENU_EVENT, TableGrips } from './rich-text-table-grips.js';
90
96
  import { applySlash, closeSlash, slashItems, slashOptionId, slashPlugin, slashQuery, } from './rich-text-slash.js';
91
97
  import { RichTextSlashMenu } from './rich-text-slash-menu.js';
92
98
  import { RichTextToolbar } from './rich-text-toolbar.js';
@@ -161,6 +167,9 @@ const highlightSchema = $markSchema('highlight', () => ({
161
167
  },
162
168
  },
163
169
  }));
170
+ // A table's cell colours and header column, drawn from the Markdown that
171
+ // keeps them (rich-text-table.ts tableLook).
172
+ const tableDecorations = $prose(() => tableLookPlugin());
164
173
  function emptyImageTitles(node) {
165
174
  if (node.type === 'image' && node.title == null)
166
175
  node.title = '';
@@ -357,7 +366,7 @@ function applyLink(view, target, href) {
357
366
  }
358
367
  view.dispatch(tr.scrollIntoView());
359
368
  }
360
- export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, toolbar = true, inline = false, upload, imageAccept, imageMaxSize, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', foldKey, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
369
+ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, toolbar = true, inline = false, upload, imageAccept, imageMaxSize, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', foldKey, tableLook = 'compact', className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
361
370
  const rootRef = useRef(null);
362
371
  const editorRef = useRef(null);
363
372
  const [linkBox, setLinkBox] = useState(null);
@@ -753,6 +762,16 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
753
762
  return true;
754
763
  if (handleTableKey(view.state, view.dispatch, event))
755
764
  return true;
765
+ // Shift+F10 or the menu key in a table: the cell's menu, from its grips.
766
+ if (((event.key === 'F10' && event.shiftKey) || event.key === 'ContextMenu') && isInTable(view.state)) {
767
+ const at = view.domAtPos(view.state.selection.from).node;
768
+ const frame = (at instanceof Element ? at : at.parentElement)?.closest('[data-slot=rich-text-table]');
769
+ if (frame) {
770
+ event.preventDefault();
771
+ frame.dispatchEvent(new CustomEvent(TABLE_MENU_EVENT));
772
+ return true;
773
+ }
774
+ }
756
775
  // Enter in a task item: the next one, unticked (rich-text-tasks.ts).
757
776
  if (event.key === 'Enter' && plain && !event.isComposing && splitTaskItem(view.state, view.dispatch))
758
777
  return true;
@@ -778,6 +797,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
778
797
  .use(calloutSchema)
779
798
  .use(highlightRemark)
780
799
  .use(highlightSchema)
800
+ .use(tableDecorations)
781
801
  .use(imageTitles)
782
802
  .use(history)
783
803
  .use(changes)
@@ -814,8 +834,9 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
814
834
  void editor.destroy();
815
835
  root.replaceChildren();
816
836
  };
817
- // Made once per mount: later changes to defaultValue are ignored, as
818
- // with any uncontrolled input. Attributes and read-only follow below.
837
+ // Made once per mount. A new defaultValue the editor didn't report
838
+ // remounts it (RichTextEditor's key), so it isn't read here.
839
+ // Attributes and read-only follow below.
819
840
  // eslint-disable-next-line react-hooks/exhaustive-deps
820
841
  }, []);
821
842
  // Read-only and the ARIA attributes follow their props.
@@ -860,10 +881,33 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
860
881
  // The editable element: where a menu over the text gives focus back
861
882
  // (ProseMirror puts the caret back where it was as it takes it).
862
883
  const textElement = () => editorView?.dom ?? null;
863
- // A Table menu item, on the caret's table (the menu gives focus back).
864
- const runTable = (action) => {
865
- if (editorView)
866
- runTableAction(editorView.state, editorView.dispatch, action);
884
+ // A grip's menu item, on the cell it was opened for: the caret into
885
+ // that cell, the action, and focus back in the text.
886
+ const caretInto = (getPos, cell) => {
887
+ if (!editorView)
888
+ return false;
889
+ const pos = getPos();
890
+ const table = pos === undefined ? null : editorView.state.doc.nodeAt(pos);
891
+ if (pos === undefined || table?.type.name !== 'table')
892
+ return false;
893
+ const map = TableMap.get(table);
894
+ if (cell.row >= map.height || cell.column >= map.width)
895
+ return false;
896
+ const at = pos + 1 + map.map[cell.row * map.width + cell.column] + 1;
897
+ editorView.dispatch(editorView.state.tr.setSelection(TextSelection.near(editorView.state.doc.resolve(at))));
898
+ return true;
899
+ };
900
+ const runTableAt = (getPos, cell, action) => {
901
+ if (!editorView || !caretInto(getPos, cell))
902
+ return;
903
+ runTableAction(editorView.state, editorView.dispatch, action);
904
+ editorView.focus();
905
+ };
906
+ const colorTableAt = (getPos, cell, scope, color) => {
907
+ if (!editorView || !caretInto(getPos, cell))
908
+ return;
909
+ colorTableCells(editorView.state, editorView.dispatch, scope, color);
910
+ editorView.focus();
867
911
  };
868
912
  // A callout's menu: its kind changed, or the callout taken away (its
869
913
  // blocks kept). The menu gives focus back to the text.
@@ -945,14 +989,14 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
945
989
  setLinkBox(null);
946
990
  editorRef.current?.action((ctx) => ctx.get(editorViewCtx).focus());
947
991
  };
948
- return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: setField, "data-slot": "rich-text-editor", "data-full-screen": fullScreen || undefined, onKeyDown: leaveFullScreen, className: cn(fullScreen ? 'fixed inset-0 z-50 h-dvh rounded-none! border-0! bg-background! ring-0!' : 'relative', 'flex w-full min-w-0 flex-col bg-transparent type-body text-foreground break-words',
992
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: setField, "data-slot": "rich-text-editor", "data-table-look": tableLook, "data-full-screen": fullScreen || undefined, onKeyDown: leaveFullScreen, className: cn(fullScreen ? 'fixed inset-0 z-50 h-dvh rounded-none! border-0! bg-background! ring-0!' : 'relative', 'flex w-full min-w-0 flex-col bg-transparent type-body text-foreground break-words',
949
993
  // Inline, it's the text itself: the caret shows where it's
950
994
  // edited, and a refusal's reason under it says what's wrong.
951
995
  !inline &&
952
996
  'rounded-lg border border-input transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', !inline && 'has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/20', className), children: [fixedToolbar && (_jsx(RichTextToolbar, { ref: toolbarRef, state: editorState, readOnly: readOnly,
953
997
  // Only once the editor is made: until then the editable element,
954
998
  // and its id, aren't in the page (axe: aria-valid-attr-value).
955
- controls: editorState ? editableId : undefined, onFormat: runFormat, onTable: runTable, onImage: upload ? pickImages : undefined, onFullScreen: toggleFullScreen, fullScreen: fullScreen, text: textElement })), upload && (_jsx("input", { ref: imageInputRef, type: "file", accept: acceptAttribute(imageAccept), multiple: true, hidden: true, tabIndex: -1, onChange: (event) => {
999
+ controls: editorState ? editableId : undefined, onFormat: runFormat, onImage: upload ? pickImages : undefined, onFullScreen: toggleFullScreen, fullScreen: fullScreen, text: textElement })), upload && (_jsx("input", { ref: imageInputRef, type: "file", accept: acceptAttribute(imageAccept), multiple: true, hidden: true, tabIndex: -1, onChange: (event) => {
956
1000
  const files = Array.from(event.target.files ?? []);
957
1001
  // Cleared, so picking the same file again still counts.
958
1002
  event.target.value = '';
@@ -980,10 +1024,6 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
980
1024
  // (ProseMirror's own separator images, around an image alone on
981
1025
  // its line, are left alone.)
982
1026
  '[&_img[src]]:inline-block [&_img[src]]:h-auto [&_img[src]]:max-w-full [&_img[src]]:rounded-md [&_img[src]]:align-bottom', '[&_img.ProseMirror-selectednode]:outline-2 [&_img.ProseMirror-selectednode]:outline-offset-2 [&_img.ProseMirror-selectednode]:outline-ring',
983
- // A table: a header row on the muted fill, a line between every
984
- // cell, cells that wrap and never shrink to nothing; selected
985
- // cells (a drag across them) tinted.
986
- '[&_table]:w-full [&_table]:border-collapse', '[&_:is(td,th)]:min-w-28 [&_:is(td,th)]:border-r [&_:is(td,th)]:border-b [&_:is(td,th)]:border-border [&_:is(td,th)]:px-(--table-cell-px) [&_:is(td,th)]:py-(--table-cell-py) [&_:is(td,th)]:align-top', '[&_:is(td,th):last-child]:border-r-0 [&_tr:last-child>td]:border-b-0', '[&_th]:bg-muted [&_th]:text-left [&_th]:type-label', '[&_.selectedCell]:bg-info/15',
987
1027
  // Raw HTML from the text: shown as its characters, never as markup.
988
1028
  '[&_span[data-type=html]]:rounded [&_span[data-type=html]]:bg-muted [&_span[data-type=html]]:px-1 [&_span[data-type=html]]:font-mono [&_span[data-type=html]]:text-foreground',
989
1029
  // The placeholder, in an empty document only (a decoration).
@@ -994,6 +1034,8 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
994
1034
  return [
995
1035
  createPortal(_jsx(TableBar, { side: "row", active: active, readOnly: readOnly, onAdd: () => addToTable(widget.getPos, 'row') }), widget.slot, `${widget.key}-row`),
996
1036
  createPortal(_jsx(TableBar, { side: "column", active: active, readOnly: readOnly, onAdd: () => addToTable(widget.getPos, 'column') }), widget.column, `${widget.key}-column`),
1037
+ editorView &&
1038
+ createPortal(_jsx(TableGrips, { view: editorView, state: editorState, getPos: widget.getPos, frame: widget.frame, box: widget.box, table: widget.table, readOnly: readOnly, focused: focused, onAction: (cell, action) => runTableAt(widget.getPos, cell, action), onColor: (cell, scope, color) => colorTableAt(widget.getPos, cell, scope, color), text: textElement }), widget.grips, `${widget.key}-grips`),
997
1039
  ];
998
1040
  }), slashOpen && slash && editorView && (_jsx(RichTextSlashMenu, { id: slashListId, view: editorView, at: slash.to, field: field, items: slashMatches, active: slashIndex, onHighlight: (index) => setSlashActive({ key: slashMenuKey, index }), onPick: (format) => applySlash(editorView, slash, format) })), toolsOpen && (_jsx(RichTextToolsBar, { ref: toolsRef, state: editorState, readOnly: readOnly, fullScreen: fullScreen, controls: editableId, onFormat: runFormat, onFullScreen: toggleFullScreen, onReturn: returnToText, onBlur: (event) => {
999
1041
  const next = event.relatedTarget;
@@ -84,3 +84,10 @@ export type CalloutLook = {
84
84
  export declare const CALLOUT_LOOK: Record<CalloutKind, CalloutLook>;
85
85
  /** The box every callout shares: the icon in a column, the text beside it. */
86
86
  export declare const CALLOUT_BOX = "flex gap-2.5 rounded-lg border px-3 py-2.5";
87
+ /**
88
+ * How a table in written text looks (styles/index.css "Tables in written
89
+ * text"): `compact`, a full grid with tight cells and the header row on
90
+ * the heading band, or `boxed`, a rounded box, roomier cells, a muted
91
+ * header row and zebra rows.
92
+ */
93
+ export type TableLook = 'compact' | 'boxed';
@@ -1,9 +1,13 @@
1
1
  import type { ImageUpload } from '../../lib/image-file.js';
2
- import type { HeadingFrom, HeadingLevel } from './prose-look.js';
2
+ import type { HeadingFrom, HeadingLevel, TableLook } from './prose-look.js';
3
3
  export type RichTextEditorProps = {
4
4
  /** The editable element's id, for a label's `htmlFor` or a test. */
5
5
  id?: string;
6
- /** The Markdown it opens with. Read once, when it mounts (uncontrolled). */
6
+ /**
7
+ * The Markdown it opens with. A later value the editor didn't report
8
+ * (a record loaded after it mounted, a reset) replaces the text; the
9
+ * value it reported, passed back, changes nothing.
10
+ */
7
11
  defaultValue: string;
8
12
  /** The Markdown to save, after every edit: untouched blocks as originally written. */
9
13
  onChange: (markdown: string) => void;
@@ -86,6 +90,13 @@ export type RichTextEditorProps = {
86
90
  * Default `true`.
87
91
  */
88
92
  foldable?: boolean;
93
+ /**
94
+ * How tables look: `'compact'` (the default), a full grid of hairlines
95
+ * with tight cells and the header row on the heading band, or
96
+ * `'boxed'`, a rounded box with roomier cells, a muted header row and
97
+ * zebra rows. The same on <Markdown>. Either way columns fit their words.
98
+ */
99
+ tableLook?: TableLook;
89
100
  className?: string;
90
101
  /** The field's label. A rich-text area must have one: this, or `aria-labelledby`. */
91
102
  'aria-label'?: string;
@@ -23,16 +23,50 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
23
23
  // Image button is uploaded through the app's own storage and goes in as
24
24
  // `![](url)`; while it uploads, a placeholder shows where it will go.
25
25
  //
26
+ // A table's columns fit their words. Grips on its column and row (or a
27
+ // right-click, or Shift+F10, in a cell) open menus to insert, move,
28
+ // delete, align, sort and colour; a header column and cell colours are
29
+ // kept as Markdown already writes them (src/lib/markdown-table.ts).
30
+ // `tableLook` picks compact (the default) or boxed, as on <Markdown>.
31
+ //
26
32
  // With `foldKey`, the text folds by its headings (NotesOutline lists them
27
33
  // beside it), the Markdown untouched.
28
34
  //
35
+ // `defaultValue` is read when it mounts, then followed: a new value the
36
+ // editor didn't report itself (a form's loaded record arriving after the
37
+ // editor, a reset) replaces the text, as if it opened with it. Its own
38
+ // edits, coming back as the value, change nothing, so the caret stays
39
+ // where it is. A caller that never feeds `onChange` back keeps its text.
40
+ //
29
41
  // The editor (Milkdown) is loaded on first use, so a page that only reads
30
42
  // Markdown never downloads it. Until it arrives, a placeholder the size of
31
43
  // the field shows.
32
- import { lazy, Suspense } from 'react';
44
+ import { lazy, Suspense, useState } from 'react';
33
45
  import { cn } from 'cn';
34
46
  import { Skeleton } from '../ui/skeleton.js';
35
47
  const MilkdownEditor = lazy(() => import('./milkdown-editor.js'));
48
+ // When the editor opens again: each time `defaultValue` changes to
49
+ // Markdown it didn't report. Adjusted while rendering (React's "storing
50
+ // information from previous renders"), so the new text never shows a
51
+ // frame late.
52
+ function useOpenings(defaultValue) {
53
+ const [seen, setSeen] = useState(defaultValue);
54
+ const [reported, setReported] = useState(defaultValue);
55
+ const [opening, setOpening] = useState(0);
56
+ if (defaultValue !== seen) {
57
+ setSeen(defaultValue);
58
+ if (defaultValue !== reported) {
59
+ setReported(defaultValue);
60
+ setOpening(opening + 1);
61
+ }
62
+ }
63
+ return { opening, report: setReported };
64
+ }
36
65
  export function RichTextEditor(props) {
37
- return (_jsx(Suspense, { fallback: _jsxs("div", { "data-slot": "rich-text-editor", "data-state": "loading", className: cn('flex w-full flex-col', !props.inline && 'rounded-lg border border-input', props.className), children: [(props.toolbar ?? true) === true && _jsx("div", { className: "h-9 border-b border-input" }), _jsxs("div", { className: cn('flex flex-col gap-2', props.inline ? 'py-0.5' : 'min-h-24 px-2.5 py-2'), children: [_jsx(Skeleton, { className: "h-4 w-3/4" }), _jsx(Skeleton, { className: "h-4 w-1/2" })] })] }), children: _jsx(MilkdownEditor, { ...props, foldKey: props.foldable === false ? undefined : props.foldKey }) }));
66
+ const { opening, report } = useOpenings(props.defaultValue);
67
+ const { onChange } = props;
68
+ return (_jsx(Suspense, { fallback: _jsxs("div", { "data-slot": "rich-text-editor", "data-state": "loading", className: cn('flex w-full flex-col', !props.inline && 'rounded-lg border border-input', props.className), children: [(props.toolbar ?? true) === true && _jsx("div", { className: "h-9 border-b border-input" }), _jsxs("div", { className: cn('flex flex-col gap-2', props.inline ? 'py-0.5' : 'min-h-24 px-2.5 py-2'), children: [_jsx(Skeleton, { className: "h-4 w-3/4" }), _jsx(Skeleton, { className: "h-4 w-1/2" })] })] }), children: _jsx(MilkdownEditor, { ...props, onChange: (markdown) => {
69
+ report(markdown);
70
+ onChange(markdown);
71
+ }, foldKey: props.foldable === false ? undefined : props.foldKey }, opening) }));
38
72
  }
@@ -37,7 +37,7 @@ export const FORMAT_GROUPS = [
37
37
  { id: 'codeBlock', label: 'Code block', icon: SquareCodeIcon, keys: 'Block+8' },
38
38
  { id: 'divider', label: 'Divider', icon: MinusIcon, hint: 'or type ---' },
39
39
  ],
40
- [{ id: 'table', label: 'Table', icon: TableIcon }],
40
+ [{ id: 'table', label: 'Table', icon: TableIcon, hint: 'its grips edit it', action: true }],
41
41
  [{ id: 'link', label: 'Link', icon: LinkIcon, keys: 'Mod+K' }],
42
42
  ];
43
43
  /** Every format by its id. */
@@ -13,6 +13,11 @@ export type Widget = {
13
13
  key: string;
14
14
  slot: HTMLElement;
15
15
  column: HTMLElement;
16
+ /** The layer the grips are placed in (rich-text-table-grips.tsx), over the whole frame. */
17
+ grips: HTMLElement;
18
+ frame: HTMLElement;
19
+ box: HTMLElement;
20
+ table: HTMLTableElement;
16
21
  getPos: () => number | undefined;
17
22
  };
18
23
  /** Where node views tell the editor their slots came, changed and went. */
@@ -24,9 +29,10 @@ export declare function calloutKind(value: unknown): CalloutKind;
24
29
  /** A callout: its kind's box, its icon (a widget: the kind's menu) beside its blocks. */
25
30
  export declare function calloutView(node: ProseNode, getPos: () => number | undefined, registry: WidgetRegistry): NodeView;
26
31
  /**
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. While it scrolls, the box is a tab stop and
32
+ * A table: in a box as wide as its columns' words that scrolls sideways
33
+ * when it's wider than the text, with an "add a column" bar along its
34
+ * right, an "add a row" bar under it and a layer over it for its grips
35
+ * (widgets), Notion's way. While it scrolls, the box is a tab stop and
30
36
  * a region named for its columns (prose-table-scroll.ts), which the arrow
31
37
  * keys, Home and End scroll; a click in it still puts the caret in the
32
38
  * cell clicked.
@@ -86,9 +86,10 @@ function scrollKey(box, event) {
86
86
  // starts, or this long for one that never clicks (a touch that pans).
87
87
  const PRESS_MS = 1000;
88
88
  /**
89
- * A table: in a box that scrolls sideways when it's wider than the text,
90
- * with an "add a column" bar along its right and an "add a row" bar under
91
- * it (widgets), Notion's way. While it scrolls, the box is a tab stop and
89
+ * A table: in a box as wide as its columns' words that scrolls sideways
90
+ * when it's wider than the text, with an "add a column" bar along its
91
+ * right, an "add a row" bar under it and a layer over it for its grips
92
+ * (widgets), Notion's way. While it scrolls, the box is a tab stop and
92
93
  * a region named for its columns (prose-table-scroll.ts), which the arrow
93
94
  * keys, Home and End scroll; a click in it still puts the caret in the
94
95
  * cell clicked.
@@ -96,17 +97,20 @@ const PRESS_MS = 1000;
96
97
  export function tableView(node, view, getPos, registry) {
97
98
  const dom = document.createElement('div');
98
99
  dom.dataset.slot = 'rich-text-table';
99
- dom.className = 'group/table grid grid-cols-[minmax(0,1fr)_auto] gap-1';
100
+ // As wide as the table (its columns fit their words), never wider than the text.
101
+ dom.className = 'group/table relative grid w-fit max-w-full grid-cols-[minmax(0,auto)_auto] gap-1';
100
102
  const scroll = document.createElement('div');
101
- scroll.className = cn('overflow-x-auto rounded-lg border border-border', TABLE_SCROLL_FOCUS);
103
+ // Its look: styles/index.css "Tables in written text".
104
+ scroll.className = cn('prose-table', TABLE_SCROLL_FOCUS);
102
105
  const table = document.createElement('table');
103
106
  const contentDOM = document.createElement('tbody');
104
107
  table.append(contentDOM);
105
108
  scroll.append(table);
106
109
  const column = slot('flex w-5');
107
110
  const row = slot('col-start-1 flex h-5');
108
- dom.append(scroll, column, row);
109
- registry.set({ type: 'table', key: nextKey(), slot: row, column, getPos });
111
+ const grips = slot('pointer-events-none absolute inset-0');
112
+ dom.append(scroll, column, row, grips);
113
+ registry.set({ type: 'table', key: nextKey(), slot: row, column, grips, frame: dom, box: scroll, table, getPos });
110
114
  const region = watchTableScroll(scroll, table, columnsOf(node));
111
115
  // A press in the box is the text's: a click in a cell puts the caret
112
116
  // there. The browser focuses the nearest focusable ancestor of what was
@@ -167,7 +171,7 @@ export function tableView(node, view, getPos, registry) {
167
171
  region.setColumns(columnsOf(next));
168
172
  return true;
169
173
  },
170
- stopEvent: (event) => row.contains(event.target) || column.contains(event.target),
174
+ stopEvent: (event) => row.contains(event.target) || column.contains(event.target) || grips.contains(event.target),
171
175
  ignoreMutation: outside(contentDOM),
172
176
  destroy: () => {
173
177
  released();
@@ -0,0 +1,31 @@
1
+ import { type EditorState } from '@milkdown/kit/prose/state';
2
+ import type { EditorView } from '@milkdown/kit/prose/view';
3
+ import { type HighlightColor } from '../../lib/markdown-highlight.js';
4
+ import { type TableAction, type TableColorScope } from './rich-text-table.js';
5
+ /** A cell by its row (0 is the header row) and column. */
6
+ export type TableCell = {
7
+ row: number;
8
+ column: number;
9
+ };
10
+ /** The event the editor sends a table's frame to open the caret's cell menu (Shift+F10). */
11
+ export declare const TABLE_MENU_EVENT = "rich-text-table-menu";
12
+ export interface TableGripsProps {
13
+ view: EditorView;
14
+ /** The editor's state, so the grips follow each change. */
15
+ state: EditorState | null;
16
+ getPos: () => number | undefined;
17
+ /** The node view's frame (the grips' layer is positioned in it), its scroll box and its table. */
18
+ frame: HTMLElement;
19
+ box: HTMLElement;
20
+ table: HTMLTableElement;
21
+ readOnly: boolean;
22
+ /** Whether focus is in the field: the caret's cell has grips only then. */
23
+ focused: boolean;
24
+ /** Runs a table action on a cell (the caret put there first), focus back in the text. */
25
+ onAction: (cell: TableCell, action: TableAction) => void;
26
+ /** Colours a cell, its row or its column (null: no colour). */
27
+ onColor: (cell: TableCell, scope: TableColorScope, color: HighlightColor | null) => void;
28
+ /** The editable element, where focus goes when a menu closes. */
29
+ text: () => HTMLElement | null;
30
+ }
31
+ export declare function TableGrips({ view, state, getPos, frame, box, table, readOnly, focused, onAction, onColor, text }: TableGripsProps): import("react").JSX.Element | null;