@tristan2828/ui-foundation 4.61.1 → 4.63.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/cell-patterns.md +16 -11
- package/conventions/docs/design-language.md +132 -23
- package/dist/components/app/app-shell.js +28 -2
- package/dist/components/app/board.js +1 -1
- package/dist/components/app/data-table.js +1 -1
- package/dist/components/app/foundation-providers.d.ts +14 -1
- package/dist/components/app/foundation-providers.js +4 -3
- package/dist/components/app/markdown.d.ts +7 -2
- package/dist/components/app/markdown.js +15 -20
- package/dist/components/app/milkdown-editor.d.ts +1 -1
- package/dist/components/app/milkdown-editor.js +58 -17
- package/dist/components/app/module-card.js +1 -1
- package/dist/components/app/palette-provider.d.ts +14 -0
- package/dist/components/app/palette-provider.js +35 -0
- package/dist/components/app/prose-look.d.ts +7 -0
- package/dist/components/app/rich-text-editor.d.ts +8 -1
- package/dist/components/app/rich-text-editor.js +6 -0
- package/dist/components/app/rich-text-format-items.js +1 -1
- package/dist/components/app/rich-text-node-views.d.ts +9 -3
- package/dist/components/app/rich-text-node-views.js +12 -8
- package/dist/components/app/rich-text-table-grips.d.ts +31 -0
- package/dist/components/app/rich-text-table-grips.js +216 -0
- package/dist/components/app/rich-text-table.d.ts +19 -2
- package/dist/components/app/rich-text-table.js +232 -1
- package/dist/components/app/rich-text-toolbar.d.ts +4 -9
- package/dist/components/app/rich-text-toolbar.js +9 -11
- package/dist/components/app/rich-text-widgets.js +2 -2
- package/dist/components/app/widget-card.js +2 -2
- package/dist/components/ui/badge.d.ts +1 -1
- package/dist/components/ui/badge.js +5 -2
- package/dist/components/ui/button.js +10 -10
- package/dist/components/ui/card.js +3 -3
- package/dist/components/ui/checkbox.js +1 -1
- package/dist/components/ui/combobox.js +1 -1
- package/dist/components/ui/dialog.js +1 -1
- package/dist/components/ui/input-group.js +1 -1
- package/dist/components/ui/input.js +1 -1
- package/dist/components/ui/progress.d.ts +7 -0
- package/dist/components/ui/progress.js +19 -0
- package/dist/components/ui/select.js +1 -1
- package/dist/components/ui/sheet.js +1 -1
- package/dist/components/ui/slider.d.ts +3 -0
- package/dist/components/ui/slider.js +12 -0
- package/dist/components/ui/textarea.js +1 -1
- package/dist/hooks/use-palette.d.ts +14 -0
- package/dist/hooks/use-palette.js +7 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/lib/markdown-table.d.ts +28 -0
- package/dist/lib/markdown-table.js +106 -0
- package/dist/lib/palettes.d.ts +21 -0
- package/dist/lib/palettes.js +74 -0
- package/package.json +1 -1
- package/styles/index.css +152 -0
- package/styles/theme.css +321 -11
- package/vite/index.d.ts +7 -1
- package/vite/pwa.js +21 -7
- package/dist/components/app/rich-text-table-menu.d.ts +0 -20
- package/dist/components/app/rich-text-table-menu.js +0 -53
|
@@ -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
|
|
24
|
-
// scroll while it does: rich-text-node-views.ts),
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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)
|
|
@@ -861,10 +881,33 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
861
881
|
// The editable element: where a menu over the text gives focus back
|
|
862
882
|
// (ProseMirror puts the caret back where it was as it takes it).
|
|
863
883
|
const textElement = () => editorView?.dom ?? null;
|
|
864
|
-
// A
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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();
|
|
868
911
|
};
|
|
869
912
|
// A callout's menu: its kind changed, or the callout taken away (its
|
|
870
913
|
// blocks kept). The menu gives focus back to the text.
|
|
@@ -946,14 +989,14 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
946
989
|
setLinkBox(null);
|
|
947
990
|
editorRef.current?.action((ctx) => ctx.get(editorViewCtx).focus());
|
|
948
991
|
};
|
|
949
|
-
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',
|
|
950
993
|
// Inline, it's the text itself: the caret shows where it's
|
|
951
994
|
// edited, and a refusal's reason under it says what's wrong.
|
|
952
995
|
!inline &&
|
|
953
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,
|
|
954
997
|
// Only once the editor is made: until then the editable element,
|
|
955
998
|
// and its id, aren't in the page (axe: aria-valid-attr-value).
|
|
956
|
-
controls: editorState ? editableId : undefined, onFormat: runFormat,
|
|
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) => {
|
|
957
1000
|
const files = Array.from(event.target.files ?? []);
|
|
958
1001
|
// Cleared, so picking the same file again still counts.
|
|
959
1002
|
event.target.value = '';
|
|
@@ -981,10 +1024,6 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
981
1024
|
// (ProseMirror's own separator images, around an image alone on
|
|
982
1025
|
// its line, are left alone.)
|
|
983
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',
|
|
984
|
-
// A table: a header row on the muted fill, a line between every
|
|
985
|
-
// cell, cells that wrap and never shrink to nothing; selected
|
|
986
|
-
// cells (a drag across them) tinted.
|
|
987
|
-
'[&_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',
|
|
988
1027
|
// Raw HTML from the text: shown as its characters, never as markup.
|
|
989
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',
|
|
990
1029
|
// The placeholder, in an empty document only (a decoration).
|
|
@@ -995,6 +1034,8 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
995
1034
|
return [
|
|
996
1035
|
createPortal(_jsx(TableBar, { side: "row", active: active, readOnly: readOnly, onAdd: () => addToTable(widget.getPos, 'row') }), widget.slot, `${widget.key}-row`),
|
|
997
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`),
|
|
998
1039
|
];
|
|
999
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) => {
|
|
1000
1041
|
const next = event.relatedTarget;
|
|
@@ -113,7 +113,7 @@ export function ModuleCard({ title, icon: Icon, category, to, href, value, note,
|
|
|
113
113
|
'data-slot': 'module-card-band',
|
|
114
114
|
className: cn(BAND_LINK_CLASS, category && BAND_CLASS[category]),
|
|
115
115
|
};
|
|
116
|
-
const card = (_jsxs(Card, { "data-slot": "module-card", "data-state": state, className: cn('relative h-full min-w-0 gap-0 py-0', mobile === 'row' && 'max-md:hidden', className), children: [href !== undefined ? (_jsxs("a", { href: href, target: "_blank", rel: "noreferrer", ...bandProps, children: [band, _jsx(NewTabMark, {})] })) : (_jsx(Link, { to: to, ...bandProps, children: band })), _jsxs("div", { id: bodyId, "data-slot": "module-card-body", "aria-busy": state === 'loading' || undefined, className: "flex flex-1 flex-col gap-0.5 px-3 py-2 density-compact:py-1.5 max-sm:px-2", children: [state === 'error' ? (_jsxs(_Fragment, { children: [_jsxs("p", { className: "flex items-center gap-1.5 type-body text-foreground", children: [_jsx(AlertCircleIcon, { "aria-hidden": "true", className: "size-4 shrink-0 text-destructive-text" }), _jsxs("span", { className: "min-w-0", children: ["Couldn't load", error?.message && _jsxs("span", { className: "sr-only", children: [": ", error.message] })] })] }), onRetry && (_jsx(Button, { size: "sm", variant: "outline", onClick: onRetry, "aria-describedby": titleId, className: "relative z-10 mt-1 self-start", children: "Retry" }))] })) : state === 'loading' ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "sr-only", children: "Loading" }), _jsx(Skeleton, { "data-state": "loading", className: "my-0.5 h-6 w-12" })] })) : (value !== undefined && (_jsx("span", { "data-slot": "module-card-value", className: "type-numeric text-foreground", children: value }))), note && state !== 'error' && (_jsx("span", { className: "truncate type-body text-muted-foreground", title: note, children: note }))] }), action &&
|
|
116
|
+
const card = (_jsxs(Card, { "data-slot": "module-card", "data-state": state, className: cn('glow-edge relative h-full min-w-0 gap-0 py-0', mobile === 'row' && 'max-md:hidden', className), children: [href !== undefined ? (_jsxs("a", { href: href, target: "_blank", rel: "noreferrer", ...bandProps, children: [band, _jsx(NewTabMark, {})] })) : (_jsx(Link, { to: to, ...bandProps, children: band })), _jsxs("div", { id: bodyId, "data-slot": "module-card-body", "aria-busy": state === 'loading' || undefined, className: "flex flex-1 flex-col gap-0.5 px-3 py-2 density-compact:py-1.5 max-sm:px-2", children: [state === 'error' ? (_jsxs(_Fragment, { children: [_jsxs("p", { className: "flex items-center gap-1.5 type-body text-foreground", children: [_jsx(AlertCircleIcon, { "aria-hidden": "true", className: "size-4 shrink-0 text-destructive-text" }), _jsxs("span", { className: "min-w-0", children: ["Couldn't load", error?.message && _jsxs("span", { className: "sr-only", children: [": ", error.message] })] })] }), onRetry && (_jsx(Button, { size: "sm", variant: "outline", onClick: onRetry, "aria-describedby": titleId, className: "relative z-10 mt-1 self-start", children: "Retry" }))] })) : state === 'loading' ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "sr-only", children: "Loading" }), _jsx(Skeleton, { "data-state": "loading", className: "my-0.5 h-6 w-12" })] })) : (value !== undefined && (_jsx("span", { "data-slot": "module-card-value", className: "type-numeric text-foreground", children: value }))), note && state !== 'error' && (_jsx("span", { className: "truncate type-body text-muted-foreground", title: note, children: note }))] }), action &&
|
|
117
117
|
(action.href !== undefined ? (_jsxs("a", { href: action.href, target: "_blank", rel: "noreferrer", "aria-describedby": titleId, className: ACTION_CLASS, children: [_jsx("span", { className: "truncate", children: action.label }), _jsx(NewTabMark, {})] })) : (_jsx(Link, { to: action.to, "aria-describedby": titleId, className: ACTION_CLASS, children: _jsx("span", { className: "truncate", children: action.label }) })))] }));
|
|
118
118
|
if (mobile === 'tiles')
|
|
119
119
|
return card;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type PaletteName, type PaletteStore } from '../../lib/palettes.js';
|
|
3
|
+
/**
|
|
4
|
+
* Puts the palette on <html>. FoundationProviders renders it; an app
|
|
5
|
+
* doesn't. Without `palette` it does nothing, and the app keeps light and
|
|
6
|
+
* dark.
|
|
7
|
+
*/
|
|
8
|
+
export declare function PaletteProvider({ palette: defaultPalette, picker, store, children, }: {
|
|
9
|
+
palette?: PaletteName;
|
|
10
|
+
picker?: boolean;
|
|
11
|
+
/** For tests; the browser's localStorage by default. */
|
|
12
|
+
store?: PaletteStore;
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
|
|
3
|
+
import { PaletteContext } from '../../hooks/use-palette.js';
|
|
4
|
+
import { browserPaletteStore, subscribeToBrowserPaletteStore } from '../../lib/palettes.js';
|
|
5
|
+
/**
|
|
6
|
+
* Puts the palette on <html>. FoundationProviders renders it; an app
|
|
7
|
+
* doesn't. Without `palette` it does nothing, and the app keeps light and
|
|
8
|
+
* dark.
|
|
9
|
+
*/
|
|
10
|
+
export function PaletteProvider({ palette: defaultPalette, picker = false, store = browserPaletteStore, children, }) {
|
|
11
|
+
const subscribe = useCallback((listener) => (store === browserPaletteStore ? subscribeToBrowserPaletteStore(listener) : store.subscribe(listener)), [store]);
|
|
12
|
+
const saved = useSyncExternalStore(subscribe, store.get, () => null);
|
|
13
|
+
const palette = defaultPalette ? ((picker && saved) || defaultPalette) : null;
|
|
14
|
+
// Before paint, so the first frame is already in the palette.
|
|
15
|
+
useLayoutEffect(() => {
|
|
16
|
+
const root = document.documentElement;
|
|
17
|
+
if (palette)
|
|
18
|
+
root.dataset.palette = palette;
|
|
19
|
+
else
|
|
20
|
+
delete root.dataset.palette;
|
|
21
|
+
return () => {
|
|
22
|
+
delete root.dataset.palette;
|
|
23
|
+
};
|
|
24
|
+
}, [palette]);
|
|
25
|
+
const value = useMemo(() => ({
|
|
26
|
+
palette,
|
|
27
|
+
defaultPalette: defaultPalette ?? null,
|
|
28
|
+
picker: Boolean(defaultPalette && picker),
|
|
29
|
+
setPalette: (next) => {
|
|
30
|
+
if (defaultPalette && picker)
|
|
31
|
+
store.set(next);
|
|
32
|
+
},
|
|
33
|
+
}), [palette, defaultPalette, picker, store]);
|
|
34
|
+
return _jsx(PaletteContext.Provider, { value: value, children: children });
|
|
35
|
+
}
|
|
@@ -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,5 +1,5 @@
|
|
|
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;
|
|
@@ -90,6 +90,13 @@ export type RichTextEditorProps = {
|
|
|
90
90
|
* Default `true`.
|
|
91
91
|
*/
|
|
92
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;
|
|
93
100
|
className?: string;
|
|
94
101
|
/** The field's label. A rich-text area must have one: this, or `aria-labelledby`. */
|
|
95
102
|
'aria-label'?: string;
|
|
@@ -23,6 +23,12 @@ 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
|
// ``; 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
|
//
|
|
@@ -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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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;
|
|
@@ -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
|
+
}
|