@vespercms/admin 0.0.0-stage → 0.1.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +119 -2
  2. package/dist/BodyEditor-_mkh0BSQ.js +3047 -0
  3. package/dist/a11y/FieldNotes.d.ts +23 -0
  4. package/dist/a11y/NotesDrawer.d.ts +23 -0
  5. package/dist/a11y/pdf.d.ts +3 -0
  6. package/dist/a11y/run.d.ts +3 -0
  7. package/dist/a11y/useNotes.d.ts +28 -0
  8. package/dist/app/App.d.ts +2 -0
  9. package/dist/app/Screen.d.ts +88 -0
  10. package/dist/app/UserMenu.d.ts +7 -0
  11. package/dist/app/api.d.ts +75 -0
  12. package/dist/app/appearance.d.ts +10 -0
  13. package/dist/app/browser-api.d.ts +17 -0
  14. package/dist/app/collection-icon.d.ts +12 -0
  15. package/dist/app/context.d.ts +23 -0
  16. package/dist/app/entries-changed.d.ts +11 -0
  17. package/dist/app/file-url.d.ts +13 -0
  18. package/dist/app/format.d.ts +4 -0
  19. package/dist/app/idb.d.ts +7 -0
  20. package/dist/app/load.d.ts +16 -0
  21. package/dist/app/media.d.ts +5 -0
  22. package/dist/app/navigation.d.ts +4 -0
  23. package/dist/app/pane-context.d.ts +19 -0
  24. package/dist/app/panes.d.ts +14 -0
  25. package/dist/app/router.d.ts +62 -0
  26. package/dist/body/BodyEditor.d.ts +42 -0
  27. package/dist/body/TableView.d.ts +2 -0
  28. package/dist/body/catalog.d.ts +47 -0
  29. package/dist/body/checks.d.ts +15 -0
  30. package/dist/body/chips.d.ts +31 -0
  31. package/dist/body/commands.d.ts +31 -0
  32. package/dist/body/document.d.ts +42 -0
  33. package/dist/body/insert.d.ts +69 -0
  34. package/dist/body/nodes.d.ts +38 -0
  35. package/dist/body/reveal.d.ts +13 -0
  36. package/dist/body/scroll.d.ts +16 -0
  37. package/dist/body/settings.d.ts +19 -0
  38. package/dist/body/shortcuts.d.ts +13 -0
  39. package/dist/body/table.d.ts +31 -0
  40. package/dist/body/ui.d.ts +30 -0
  41. package/dist/body/views.d.ts +9 -0
  42. package/dist/checks-C-0xDu-I.js +57 -0
  43. package/dist/cloud/CloudGate.d.ts +14 -0
  44. package/dist/cloud/access.d.ts +14 -0
  45. package/dist/cloud/changes.d.ts +26 -0
  46. package/dist/cloud/connection.d.ts +100 -0
  47. package/dist/cloud/editor.d.ts +44 -0
  48. package/dist/cloud/session.d.ts +62 -0
  49. package/dist/cloud/storage.d.ts +45 -0
  50. package/dist/editor/Field.d.ts +24 -0
  51. package/dist/editor/PagePicker.d.ts +20 -0
  52. package/dist/editor/arrange.d.ts +13 -0
  53. package/dist/editor/context.d.ts +23 -0
  54. package/dist/editor/copy.d.ts +9 -0
  55. package/dist/editor/reveal.d.ts +7 -0
  56. package/dist/editor/target.d.ts +21 -0
  57. package/dist/editor/values.d.ts +18 -0
  58. package/dist/geo-map-CT3OGD7O.js +110 -0
  59. package/dist/github/GitHubGate.d.ts +15 -0
  60. package/dist/github/api.d.ts +20 -0
  61. package/dist/github/cache.d.ts +2 -0
  62. package/dist/github/session.d.ts +35 -0
  63. package/dist/index.d.ts +3 -0
  64. package/dist/markdown-4fVeej3c.js +211 -0
  65. package/dist/media/MediaPicker.d.ts +28 -0
  66. package/dist/media/file-icon.d.ts +3 -0
  67. package/dist/media/formats.d.ts +23 -0
  68. package/dist/media/hygiene.d.ts +37 -0
  69. package/dist/media/upload.d.ts +31 -0
  70. package/dist/mount.d.ts +3 -0
  71. package/dist/pdf-m5s_uXJ3.js +24 -0
  72. package/dist/run-y4XRkg17.js +7 -0
  73. package/dist/schedule/ScheduleDialog.d.ts +28 -0
  74. package/dist/schedule/ScheduleSummary.d.ts +12 -0
  75. package/dist/schedule/ScheduledView.d.ts +8 -0
  76. package/dist/schedule/cloud.d.ts +21 -0
  77. package/dist/schedule/now.d.ts +5 -0
  78. package/dist/schedule/times.d.ts +35 -0
  79. package/dist/screens/EntryEditor.d.ts +13 -0
  80. package/dist/screens/EntryList.d.ts +16 -0
  81. package/dist/screens/Home.d.ts +6 -0
  82. package/dist/screens/ListPane.d.ts +6 -0
  83. package/dist/screens/MediaBrowser.d.ts +7 -0
  84. package/dist/screens/NotFound.d.ts +3 -0
  85. package/dist/screens/PageTree.d.ts +10 -0
  86. package/dist/screens/Redirects.d.ts +1 -0
  87. package/dist/screens/Unavailable.d.ts +8 -0
  88. package/dist/screens/status.d.ts +36 -0
  89. package/dist/shortcuts-CarKeDqp.js +4666 -0
  90. package/dist/studio.css +3 -0
  91. package/dist/studio.js +6449 -0
  92. package/dist/types.d.ts +76 -0
  93. package/dist/widgets/basic.d.ts +25 -0
  94. package/dist/widgets/blank-date.d.ts +8 -0
  95. package/dist/widgets/crops.d.ts +43 -0
  96. package/dist/widgets/focal-point.d.ts +15 -0
  97. package/dist/widgets/geo-map.d.ts +10 -0
  98. package/dist/widgets/geo.d.ts +16 -0
  99. package/dist/widgets/group.d.ts +41 -0
  100. package/dist/widgets/icon.d.ts +9 -0
  101. package/dist/widgets/image.d.ts +16 -0
  102. package/dist/widgets/markdown-lazy.d.ts +2 -0
  103. package/dist/widgets/markdown.d.ts +2 -0
  104. package/dist/widgets/reference.d.ts +12 -0
  105. package/dist/widgets/registry.d.ts +32 -0
  106. package/dist/widgets/special.d.ts +20 -0
  107. package/dist/widgets/union.d.ts +36 -0
  108. package/package.json +63 -4
  109. package/src/a11y/FieldNotes.tsx +56 -0
  110. package/src/a11y/NotesDrawer.tsx +81 -0
  111. package/src/a11y/pdf.ts +45 -0
  112. package/src/a11y/run.ts +12 -0
  113. package/src/a11y/useNotes.ts +139 -0
  114. package/src/app/App.tsx +156 -0
  115. package/src/app/Screen.tsx +416 -0
  116. package/src/app/UserMenu.tsx +95 -0
  117. package/src/app/api.ts +176 -0
  118. package/src/app/appearance.ts +54 -0
  119. package/src/app/browser-api.ts +173 -0
  120. package/src/app/collection-icon.ts +15 -0
  121. package/src/app/context.tsx +32 -0
  122. package/src/app/entries-changed.ts +48 -0
  123. package/src/app/file-url.tsx +44 -0
  124. package/src/app/format.ts +39 -0
  125. package/src/app/idb.ts +82 -0
  126. package/src/app/load.ts +40 -0
  127. package/src/app/media.ts +20 -0
  128. package/src/app/navigation.ts +4 -0
  129. package/src/app/pane-context.ts +25 -0
  130. package/src/app/panes.tsx +513 -0
  131. package/src/app/router.ts +158 -0
  132. package/src/body/BodyEditor.tsx +1139 -0
  133. package/src/body/TableView.tsx +700 -0
  134. package/src/body/catalog.ts +198 -0
  135. package/src/body/checks.ts +83 -0
  136. package/src/body/chips.ts +150 -0
  137. package/src/body/commands.ts +191 -0
  138. package/src/body/document.ts +388 -0
  139. package/src/body/insert.tsx +301 -0
  140. package/src/body/nodes.ts +287 -0
  141. package/src/body/reveal.ts +93 -0
  142. package/src/body/scroll.ts +53 -0
  143. package/src/body/settings.tsx +510 -0
  144. package/src/body/shortcuts.ts +31 -0
  145. package/src/body/table.ts +192 -0
  146. package/src/body/ui.ts +77 -0
  147. package/src/body/views.tsx +268 -0
  148. package/src/build.ts +6 -0
  149. package/src/cloud/CloudGate.tsx +245 -0
  150. package/src/cloud/access.ts +52 -0
  151. package/src/cloud/changes.tsx +474 -0
  152. package/src/cloud/connection.ts +281 -0
  153. package/src/cloud/editor.tsx +250 -0
  154. package/src/cloud/session.ts +289 -0
  155. package/src/cloud/storage.ts +212 -0
  156. package/src/editor/Field.tsx +182 -0
  157. package/src/editor/PagePicker.tsx +102 -0
  158. package/src/editor/arrange.ts +98 -0
  159. package/src/editor/context.tsx +31 -0
  160. package/src/editor/copy.ts +30 -0
  161. package/src/editor/reveal.ts +38 -0
  162. package/src/editor/target.ts +104 -0
  163. package/src/editor/values.ts +71 -0
  164. package/src/github/GitHubGate.tsx +298 -0
  165. package/src/github/api.ts +81 -0
  166. package/src/github/cache.ts +64 -0
  167. package/src/github/session.ts +146 -0
  168. package/src/index.ts +10 -0
  169. package/src/media/MediaPicker.tsx +302 -0
  170. package/src/media/file-icon.ts +17 -0
  171. package/src/media/formats.ts +262 -0
  172. package/src/media/hygiene.ts +220 -0
  173. package/src/media/upload.ts +47 -0
  174. package/src/mount.tsx +20 -0
  175. package/src/schedule/ScheduleDialog.tsx +303 -0
  176. package/src/schedule/ScheduleSummary.tsx +59 -0
  177. package/src/schedule/ScheduledView.tsx +294 -0
  178. package/src/schedule/cloud.ts +66 -0
  179. package/src/schedule/now.ts +18 -0
  180. package/src/schedule/times.ts +122 -0
  181. package/src/screens/EntryEditor.tsx +1817 -0
  182. package/src/screens/EntryList.tsx +272 -0
  183. package/src/screens/Home.tsx +131 -0
  184. package/src/screens/ListPane.tsx +477 -0
  185. package/src/screens/MediaBrowser.tsx +253 -0
  186. package/src/screens/NotFound.tsx +19 -0
  187. package/src/screens/PageTree.tsx +738 -0
  188. package/src/screens/Redirects.tsx +522 -0
  189. package/src/screens/Unavailable.tsx +27 -0
  190. package/src/screens/status.tsx +104 -0
  191. package/src/studio.css +168 -0
  192. package/src/types.ts +82 -0
  193. package/src/widgets/basic.tsx +315 -0
  194. package/src/widgets/blank-date.ts +32 -0
  195. package/src/widgets/crops.tsx +399 -0
  196. package/src/widgets/focal-point.tsx +240 -0
  197. package/src/widgets/geo-map.tsx +160 -0
  198. package/src/widgets/geo.tsx +170 -0
  199. package/src/widgets/group.tsx +827 -0
  200. package/src/widgets/icon.tsx +47 -0
  201. package/src/widgets/image.tsx +407 -0
  202. package/src/widgets/markdown-lazy.tsx +22 -0
  203. package/src/widgets/markdown.tsx +267 -0
  204. package/src/widgets/reference.tsx +196 -0
  205. package/src/widgets/registry.ts +104 -0
  206. package/src/widgets/special.tsx +290 -0
  207. package/src/widgets/union.tsx +233 -0
@@ -0,0 +1,700 @@
1
+ import { Extension, Node, type Editor, type JSONContent } from '@tiptap/core';
2
+ import Image from '@tiptap/extension-image';
3
+ import { Markdown, type MarkdownManager } from '@tiptap/markdown';
4
+ import { EditorContent, NodeViewWrapper, useEditor, type ReactNodeViewProps } from '@tiptap/react';
5
+ import { closeHistory } from '@tiptap/pm/history';
6
+ import StarterKit from '@tiptap/starter-kit';
7
+ import type { TableAlign, TableValue } from '@vespercms/core';
8
+ import { IconButton, Menu, cx, type MenuItem } from '@vespercms/ui';
9
+ import { Fragment, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react';
10
+ import { LinkDialog } from './BodyEditor.tsx';
11
+ import { fallbackLabel } from './catalog.ts';
12
+ import { chipInfo } from './chips.ts';
13
+ import { selectBlock } from './commands.ts';
14
+ import { VesperInline } from './nodes.ts';
15
+ import { isMac, shortcut } from './shortcuts.ts';
16
+ import {
17
+ alignColumn,
18
+ cellText,
19
+ deleteColumn,
20
+ deleteRow,
21
+ insertColumn,
22
+ insertRow,
23
+ moveColumn,
24
+ moveRow,
25
+ parseCell,
26
+ setCell,
27
+ } from './table.ts';
28
+ import { useBodyUi, type BodyUi } from './ui.ts';
29
+
30
+ /*
31
+ * A table in the writing view (DECISIONS.md, "Tables are edited as a grid,
32
+ * and a save splices cells"): the WAI-ARIA data grid pattern. The arrow keys
33
+ * move between cells, Enter or F2 edits one in place, and row and column
34
+ * actions sit behind "Table actions" and Shift+F10. Every change is one
35
+ * transaction on the node's value, checked first by core's writer, so
36
+ * Ctrl+Z undoes it with the rest of the body and nothing that wouldn't save
37
+ * is ever made.
38
+ *
39
+ * Like the other blocks' buttons, the grid is out of the Tab order until
40
+ * someone is in it: from the text, the arrow keys select the table and
41
+ * Enter moves into its cells.
42
+ */
43
+
44
+ type Cell = { row: number; col: number };
45
+ type Then = 'stay' | 'next' | 'previous' | 'leave';
46
+
47
+ /** A selected table shows a focus outline, but only while the text has focus. */
48
+ const SELECTED = cx(
49
+ 'in-[.ProseMirror-focused]:outline-2 in-[.ProseMirror-focused]:outline-solid in-[.ProseMirror-focused]:outline-offset-2 in-[.ProseMirror-focused]:outline-focus',
50
+ 'in-[.ProseMirror-focused]:ring-6 in-[.ProseMirror-focused]:ring-(color:--glow-focus)',
51
+ );
52
+
53
+ const ALIGNS: { id: string; label: string; align: TableAlign }[] = [
54
+ { id: 'left', label: 'Left', align: 'left' },
55
+ { id: 'center', label: 'Centre', align: 'center' },
56
+ { id: 'right', label: 'Right', align: 'right' },
57
+ { id: 'none', label: 'As written', align: null },
58
+ ];
59
+
60
+ const ALIGN_CLASS: Record<string, string> = {
61
+ left: 'text-left',
62
+ center: 'text-center',
63
+ right: 'text-right',
64
+ };
65
+
66
+ /** A chip's name in a cell: its tag's label, or its source. */
67
+ function chipLabel(ui: Pick<BodyUi, 'catalog'>, source: string): string {
68
+ const info = chipInfo(source);
69
+ const kind = info.name ? ui.catalog.find(info.syntax, info.name) : undefined;
70
+ const label =
71
+ kind?.label ??
72
+ (info.name && (info.kind === 'tag' || info.kind === 'component')
73
+ ? fallbackLabel(info.syntax, info.name)
74
+ : null);
75
+ if (label === null) return source.length > 40 ? `${source.slice(0, 37)}…` : source;
76
+ // Its text too, when that's plain words, as the chip in the text shows it.
77
+ const inner = info.inner ?? '';
78
+ return inner && !/[{<*_`[\]\\]/.test(inner) ? `${label} · ${inner}` : label;
79
+ }
80
+
81
+ /** A cell's text without its formatting: for column names. */
82
+ function plainText(ui: Pick<BodyUi, 'catalog'>, inline: readonly JSONContent[]): string {
83
+ return inline
84
+ .map((node) => {
85
+ if (node.type === 'text') return node.text ?? '';
86
+ if (node.type === 'image') return String(node.attrs?.alt ?? '');
87
+ if (node.type === 'vesperInline') return chipLabel(ui, String(node.attrs?.source ?? ''));
88
+ return '';
89
+ })
90
+ .join('')
91
+ .trim();
92
+ }
93
+
94
+ /** A cell's content as the page shows it: formatting, links as text, images by their alt text, chips by name. */
95
+ function CellContent({ inline }: { inline: readonly JSONContent[] }) {
96
+ const ui = useBodyUi();
97
+ return inline.map((node, i) => {
98
+ if (node.type === 'image') {
99
+ const alt = String(node.attrs?.alt ?? '');
100
+ return (
101
+ <span key={i} className="text-content-muted">
102
+ {alt ? `Image: ${alt}` : 'Image'}
103
+ </span>
104
+ );
105
+ }
106
+ if (node.type === 'vesperInline') {
107
+ return (
108
+ <span
109
+ key={i}
110
+ className="mx-0.5 rounded-sm border border-border-strong bg-surface-sunken px-1 py-0.5 text-small"
111
+ >
112
+ {chipLabel(ui, String(node.attrs?.source ?? ''))}
113
+ </span>
114
+ );
115
+ }
116
+ let out: ReactNode = node.text ?? '';
117
+ for (const mark of node.marks ?? []) {
118
+ if (mark.type === 'bold') out = <strong className="font-semibold">{out}</strong>;
119
+ else if (mark.type === 'italic') out = <em>{out}</em>;
120
+ else if (mark.type === 'strike') out = <s>{out}</s>;
121
+ else if (mark.type === 'code') out = <code className="font-mono text-small">{out}</code>;
122
+ else if (mark.type === 'link') out = <span className="text-accent underline">{out}</span>;
123
+ }
124
+ return <Fragment key={i}>{out}</Fragment>;
125
+ });
126
+ }
127
+
128
+ /** What the cell's extensions, made once, need from the latest render. */
129
+ class CellBridge {
130
+ done = false;
131
+ changed = false;
132
+ linkOpen = false;
133
+ onFinish: (inline: JSONContent[] | null, then: Then) => void = () => undefined;
134
+ openLink: () => void = () => undefined;
135
+ update(from: Pick<CellBridge, 'onFinish' | 'linkOpen' | 'openLink'>): void {
136
+ this.onFinish = from.onFinish;
137
+ this.linkOpen = from.linkOpen;
138
+ this.openLink = from.openLink;
139
+ }
140
+ /** Stops editing, once, with the content if it changed. */
141
+ finish(editor: Editor, then: Then): boolean {
142
+ if (this.done) return true;
143
+ this.done = true;
144
+ this.onFinish(this.changed ? (editor.getJSON().content[0]?.content ?? []) : null, then);
145
+ return true;
146
+ }
147
+ }
148
+
149
+ /** The cell's editor holds one line. */
150
+ const CellDocument = Node.create({ name: 'doc', topNode: true, content: 'paragraph' });
151
+
152
+ /**
153
+ * One cell, edited in place: a one-line Markdown editor with bold, italic,
154
+ * strikethrough, code, links, images and chips. Enter or Escape stops
155
+ * editing and keeps the change; Tab and Shift+Tab go on to the next or
156
+ * previous cell; leaving it keeps the change too.
157
+ */
158
+ function CellEditor({
159
+ inline,
160
+ label,
161
+ describedBy,
162
+ chipName,
163
+ onFinish,
164
+ }: {
165
+ inline: readonly JSONContent[];
166
+ label: string;
167
+ describedBy: string;
168
+ chipName: (source: string) => string;
169
+ /** The cell's content when it was changed, else null; and where to go next. */
170
+ onFinish: (inline: JSONContent[] | null, then: Then) => void;
171
+ }) {
172
+ const [linkOpen, setLinkOpen] = useState(false);
173
+ const [bridge] = useState(() => new CellBridge());
174
+ useEffect(() => {
175
+ bridge.update({
176
+ onFinish,
177
+ linkOpen,
178
+ openLink: () => {
179
+ setLinkOpen(true);
180
+ },
181
+ });
182
+ });
183
+ const [extensions] = useState(() => {
184
+ return [
185
+ CellDocument,
186
+ StarterKit.configure({
187
+ document: false,
188
+ heading: false,
189
+ blockquote: false,
190
+ codeBlock: false,
191
+ horizontalRule: false,
192
+ bulletList: false,
193
+ orderedList: false,
194
+ listItem: false,
195
+ listKeymap: false,
196
+ hardBreak: false,
197
+ underline: false,
198
+ trailingNode: false,
199
+ link: { openOnClick: false, autolink: true, defaultProtocol: 'https' },
200
+ }),
201
+ Image.configure({ inline: true }),
202
+ // A chip shows its name, as in the grid.
203
+ VesperInline.extend({
204
+ addNodeView() {
205
+ return ({ node }) => {
206
+ const dom = document.createElement('span');
207
+ dom.className =
208
+ 'mx-0.5 rounded-sm border border-border-strong bg-surface-sunken px-1 py-0.5 text-small';
209
+ dom.textContent = chipName(String(node.attrs.source));
210
+ return { dom };
211
+ };
212
+ },
213
+ }),
214
+ Markdown,
215
+ Extension.create({
216
+ name: 'vesperCellKeys',
217
+ addKeyboardShortcuts() {
218
+ const editor = this.editor;
219
+ return {
220
+ Enter: () => bridge.finish(editor, 'stay'),
221
+ 'Shift-Enter': () => true,
222
+ Escape: () => bridge.finish(editor, 'stay'),
223
+ Tab: () => bridge.finish(editor, 'next'),
224
+ 'Shift-Tab': () => bridge.finish(editor, 'previous'),
225
+ 'Mod-k': () => {
226
+ bridge.openLink();
227
+ return true;
228
+ },
229
+ };
230
+ },
231
+ }),
232
+ Extension.create({
233
+ name: 'vesperCellBlur',
234
+ onUpdate() {
235
+ bridge.changed = true;
236
+ },
237
+ onBlur() {
238
+ // Leaving is judged a moment later: StrictMode takes the editor
239
+ // down and puts it back as it first shows, and the link dialog takes
240
+ // focus for a while. Neither is leaving the cell.
241
+ const editor = this.editor;
242
+ setTimeout(() => {
243
+ if (editor.isDestroyed || editor.view.hasFocus() || bridge.linkOpen) return;
244
+ bridge.finish(editor, 'leave');
245
+ }, 0);
246
+ },
247
+ }),
248
+ ];
249
+ });
250
+ const editor = useEditor({
251
+ immediatelyRender: true,
252
+ shouldRerenderOnTransaction: false,
253
+ extensions,
254
+ content: { type: 'doc', content: [{ type: 'paragraph', content: [...inline] }] },
255
+ editorProps: {
256
+ attributes: {
257
+ role: 'textbox',
258
+ 'aria-multiline': 'false',
259
+ 'aria-label': label,
260
+ 'aria-describedby': describedBy,
261
+ class: 'vesper-body min-h-6 w-full min-w-24 text-body text-content outline-none',
262
+ },
263
+ },
264
+ });
265
+ useEffect(() => {
266
+ // With the caret at the end, as F2 does in a spreadsheet. At once, not in
267
+ // the next frame as Tiptap's focus command does, so keys typed straight
268
+ // after Enter land in the cell.
269
+ const toEnd = () => {
270
+ editor.commands.setTextSelection(editor.state.doc.content.size - 1);
271
+ editor.view.focus();
272
+ };
273
+ toEnd();
274
+ // And again in the next frame if the text took focus back meanwhile: the
275
+ // Insert menu focuses the text in one, as Tiptap does, before a new table's
276
+ // first cell opens.
277
+ const frame = requestAnimationFrame(() => {
278
+ if (!editor.isDestroyed && !bridge.done && !editor.view.hasFocus()) toEnd();
279
+ });
280
+ return () => {
281
+ cancelAnimationFrame(frame);
282
+ };
283
+ }, [editor, bridge]);
284
+ return (
285
+ <>
286
+ <EditorContent editor={editor} />
287
+ <LinkDialog
288
+ editor={editor}
289
+ isOpen={linkOpen}
290
+ onOpenChange={(open) => {
291
+ setLinkOpen(open);
292
+ }}
293
+ />
294
+ </>
295
+ );
296
+ }
297
+
298
+ export function TableView({ node, getPos, selected, editor }: ReactNodeViewProps) {
299
+ const ui = useBodyUi();
300
+ const value = node.attrs.value as TableValue;
301
+ const path = (node.attrs.vesperBlock as string | null) ?? null;
302
+ const markdown = editor.markdown as MarkdownManager;
303
+ const rowCount = value.rows.length;
304
+ const colCount = value.columns.length;
305
+ const labelId = useId();
306
+ const problemId = useId();
307
+ const helpId = useId();
308
+ const [active, setActive] = useState<Cell>({ row: 0, col: 0 });
309
+ const [editing, setEditing] = useState<Cell | null>(null);
310
+ // Where the actions menu was opened from: a cell (Shift+F10) gets focus back.
311
+ const [menu, setMenu] = useState<'cell' | 'button' | null>(null);
312
+ const [inside, setInside] = useState(false);
313
+ const [problem, setProblem] = useState<string | null>(null);
314
+ const cells = useRef(new Map<string, HTMLElement>());
315
+ const at: Cell = {
316
+ row: Math.min(active.row, rowCount - 1),
317
+ col: Math.min(active.col, colCount - 1),
318
+ };
319
+ // The latest cell and editing state, for focus moved after a render.
320
+ const latest = useRef({ at, editing });
321
+ useEffect(() => {
322
+ latest.current = { at, editing };
323
+ });
324
+
325
+ const parsed = useMemo(
326
+ () => value.rows.map((row) => row.cells.map((cell) => parseCell(markdown, cell) ?? [])),
327
+ [value, markdown],
328
+ );
329
+ const headers = (parsed[0] ?? []).map(
330
+ (inline, i) => plainText(ui, inline) || `column ${String(i + 1)}`,
331
+ );
332
+ const columnName = (col: number) => headers[col] ?? `column ${String(col + 1)}`;
333
+
334
+ /**
335
+ * Focuses the current cell once the change has rendered. A timeout, as the
336
+ * list's rows do: after a menu, React Aria gives focus back to its button
337
+ * in an animation frame, unless something else has taken it by then.
338
+ */
339
+ const focusLater = () => {
340
+ setTimeout(() => {
341
+ const { at: cell, editing: open } = latest.current;
342
+ if (!open) cells.current.get(`${String(cell.row)}:${String(cell.col)}`)?.focus();
343
+ }, 0);
344
+ };
345
+
346
+ /** Moves to a cell, kept inside the table: the one shown, or the one a change just made. */
347
+ const goTo = (row: number, col: number, table: TableValue = value) => {
348
+ const next = {
349
+ row: Math.max(0, Math.min(table.rows.length - 1, row)),
350
+ col: Math.max(0, Math.min(table.columns.length - 1, col)),
351
+ };
352
+ latest.current = { ...latest.current, at: next };
353
+ setActive(next);
354
+ focusLater();
355
+ };
356
+
357
+ /** Starts or stops editing a cell; the cell being edited is the current one. */
358
+ const edit = (cell: Cell | null) => {
359
+ latest.current = { at: cell ?? latest.current.at, editing: cell };
360
+ if (cell) setActive(cell);
361
+ setEditing(cell);
362
+ };
363
+
364
+ // A table just inserted starts with its first cell being edited: once,
365
+ // when its view first shows, on word from the insertion outside React.
366
+ useEffect(() => {
367
+ // eslint-disable-next-line react-hooks/set-state-in-effect
368
+ if (ui.takeNewTable()) edit({ row: 0, col: 0 });
369
+ // eslint-disable-next-line react-hooks/exhaustive-deps
370
+ }, []);
371
+
372
+ /** Makes a change, if core's writer would save it, as one transaction. */
373
+ const apply = (next: TableValue, message?: string): boolean => {
374
+ const check = ui.checkTable(path, next);
375
+ if (!check.ok) {
376
+ setProblem(check.reason);
377
+ ui.announce(check.reason);
378
+ return false;
379
+ }
380
+ const pos = getPos();
381
+ const current = typeof pos === 'number' ? editor.state.doc.nodeAt(pos) : null;
382
+ if (typeof pos !== 'number' || current?.type.name !== 'vesperTable') return false;
383
+ setProblem(null);
384
+ // Its own step in the history, so Ctrl+Z undoes it alone.
385
+ editor.view.dispatch(
386
+ closeHistory(
387
+ editor.state.tr.setNodeMarkup(pos, undefined, { ...current.attrs, value: next }),
388
+ ),
389
+ );
390
+ if (message) ui.announce(message);
391
+ return true;
392
+ };
393
+
394
+ const leave = () => {
395
+ const pos = getPos();
396
+ if (typeof pos === 'number') selectBlock(editor, pos);
397
+ };
398
+
399
+ const rowName = (row: number) => (row === 0 ? 'the header row' : `row ${String(row + 1)}`);
400
+
401
+ /** What an action changes, what it says, and the cell focus goes to after it. */
402
+ const changeFor = (id: string, { row, col }: Cell): [TableValue, string, Cell] | null => {
403
+ const name = columnName(col);
404
+ const number = String(row + 1);
405
+ if (id === 'row-above' && row > 0) {
406
+ return [insertRow(value, row), `Row added above ${rowName(row)}.`, { row, col }];
407
+ }
408
+ if (id === 'row-below') {
409
+ return [insertRow(value, row + 1), `Row added below ${rowName(row)}.`, { row: row + 1, col }];
410
+ }
411
+ if (id === 'column-left') {
412
+ return [insertColumn(value, col), `Column added left of ${name}.`, { row, col }];
413
+ }
414
+ if (id === 'column-right') {
415
+ return [
416
+ insertColumn(value, col + 1),
417
+ `Column added right of ${name}.`,
418
+ { row, col: col + 1 },
419
+ ];
420
+ }
421
+ if (id === 'row-up' && row > 1) {
422
+ return [moveRow(value, row, row - 1), `Row ${number} moved up.`, { row: row - 1, col }];
423
+ }
424
+ if (id === 'row-down' && row > 0 && row < rowCount - 1) {
425
+ return [moveRow(value, row, row + 1), `Row ${number} moved down.`, { row: row + 1, col }];
426
+ }
427
+ if (id === 'column-move-left' && col > 0) {
428
+ return [moveColumn(value, col, col - 1), `Column ${name} moved left.`, { row, col: col - 1 }];
429
+ }
430
+ if (id === 'column-move-right' && col < colCount - 1) {
431
+ return [
432
+ moveColumn(value, col, col + 1),
433
+ `Column ${name} moved right.`,
434
+ { row, col: col + 1 },
435
+ ];
436
+ }
437
+ if (id === 'row-delete' && row > 0)
438
+ return [deleteRow(value, row), `Row ${number} deleted.`, { row, col }];
439
+ if (id === 'column-delete' && colCount > 1) {
440
+ return [deleteColumn(value, col), `Column ${name} deleted.`, { row, col }];
441
+ }
442
+ return null;
443
+ };
444
+
445
+ const act = (id: string) => {
446
+ const change = changeFor(id, at);
447
+ if (change && apply(change[0], change[1])) goTo(change[2].row, change[2].col, change[0]);
448
+ else goTo(at.row, at.col);
449
+ };
450
+
451
+ const align = (id: string) => {
452
+ const choice = ALIGNS.find((a) => a.id === id);
453
+ const { col } = at;
454
+ if (!choice || value.align[col] === choice.align) return;
455
+ const how =
456
+ choice.align === null ? 'as written' : choice.id === 'center' ? 'centre' : choice.id;
457
+ apply(alignColumn(value, col, choice.align), `Column ${columnName(col)} aligned ${how}.`);
458
+ };
459
+
460
+ const finish = (cell: Cell) => (inline: JSONContent[] | null, then: Then) => {
461
+ edit(null);
462
+ let next = value;
463
+ let message: string | undefined;
464
+ if (inline) {
465
+ const text = cellText(markdown, inline);
466
+ const again = parseCell(markdown, text);
467
+ if (!again || cellText(markdown, again) !== text) {
468
+ const reason = 'That can’t be written in a table cell, so the cell wasn’t changed.';
469
+ setProblem(reason);
470
+ ui.announce(reason);
471
+ goTo(cell.row, cell.col);
472
+ return;
473
+ }
474
+ if (text !== value.rows[cell.row]?.cells[cell.col])
475
+ next = setCell(next, cell.row, cell.col, text);
476
+ }
477
+ const last = cell.row === rowCount - 1 && cell.col === colCount - 1;
478
+ let target: Cell | null = null;
479
+ if (then === 'next' && last) {
480
+ // As word processors do: Tab in the last cell adds a row.
481
+ next = insertRow(next, rowCount);
482
+ message = 'Row added.';
483
+ target = { row: rowCount, col: 0 };
484
+ } else if (then === 'next') {
485
+ target =
486
+ cell.col < colCount - 1
487
+ ? { row: cell.row, col: cell.col + 1 }
488
+ : { row: cell.row + 1, col: 0 };
489
+ } else if (then === 'previous') {
490
+ target =
491
+ cell.col > 0
492
+ ? { row: cell.row, col: cell.col - 1 }
493
+ : cell.row > 0
494
+ ? { row: cell.row - 1, col: colCount - 1 }
495
+ : null;
496
+ }
497
+ if (next !== value && !apply(next, message)) {
498
+ goTo(cell.row, cell.col);
499
+ return;
500
+ }
501
+ if (then === 'leave') return;
502
+ if (target) edit(target);
503
+ else {
504
+ goTo(cell.row, cell.col);
505
+ }
506
+ };
507
+
508
+ const onKeyDown = (event: React.KeyboardEvent) => {
509
+ // The cell's editor has keys of its own.
510
+ if (editing) return;
511
+ const { row, col } = at;
512
+ const mod = isMac ? event.metaKey : event.ctrlKey;
513
+ const key = event.key;
514
+ const editable = !ui.disabled;
515
+ if (event.altKey && editable && key === 'ArrowUp') act('row-up');
516
+ else if (event.altKey && editable && key === 'ArrowDown') act('row-down');
517
+ else if (event.altKey && editable && key === 'ArrowLeft') act('column-move-left');
518
+ else if (event.altKey && editable && key === 'ArrowRight') act('column-move-right');
519
+ else if (key === 'ArrowUp') goTo(row - 1, col);
520
+ else if (key === 'ArrowDown') goTo(row + 1, col);
521
+ else if (key === 'ArrowLeft') goTo(row, col - 1);
522
+ else if (key === 'ArrowRight') goTo(row, col + 1);
523
+ else if (key === 'Home') goTo(event.ctrlKey || event.metaKey ? 0 : row, 0);
524
+ else if (key === 'End') {
525
+ goTo(event.ctrlKey || event.metaKey ? rowCount - 1 : row, colCount - 1);
526
+ } else if ((key === 'Enter' || key === 'F2') && editable) edit({ row, col });
527
+ else if (key === 'Escape') leave();
528
+ else if (((key === 'F10' && event.shiftKey) || key === 'ContextMenu') && editable) {
529
+ setMenu('cell');
530
+ } else if (mod && key.toLowerCase() === 'z') {
531
+ if (event.shiftKey) editor.commands.redo();
532
+ else editor.commands.undo();
533
+ } else if (mod && key.toLowerCase() === 'y') editor.commands.redo();
534
+ else return;
535
+ event.preventDefault();
536
+ event.stopPropagation();
537
+ };
538
+
539
+ const items: MenuItem[] = [
540
+ { id: 'row-above', label: 'Insert row above', isDisabled: at.row === 0 },
541
+ { id: 'row-below', label: 'Insert row below' },
542
+ { id: 'column-left', label: 'Insert column left' },
543
+ { id: 'column-right', label: 'Insert column right' },
544
+ { id: 'row-up', label: 'Move row up', isDisabled: at.row <= 1 },
545
+ { id: 'row-down', label: 'Move row down', isDisabled: at.row === 0 || at.row === rowCount - 1 },
546
+ { id: 'column-move-left', label: 'Move column left', isDisabled: at.col === 0 },
547
+ { id: 'column-move-right', label: 'Move column right', isDisabled: at.col === colCount - 1 },
548
+ { id: 'row-delete', label: 'Delete row', isDanger: true, isDisabled: at.row === 0 },
549
+ { id: 'column-delete', label: 'Delete column', isDanger: true, isDisabled: colCount <= 1 },
550
+ ];
551
+ const current = ALIGNS.find((a) => a.align === (value.align[at.col] ?? null)) ?? ALIGNS[3];
552
+
553
+ const columnWord = colCount === 1 ? 'column' : 'columns';
554
+ return (
555
+ <NodeViewWrapper
556
+ as="div"
557
+ role="group"
558
+ aria-labelledby={labelId}
559
+ aria-describedby={problem ? problemId : undefined}
560
+ onFocus={() => {
561
+ setInside(true);
562
+ }}
563
+ onBlur={(event: React.FocusEvent) => {
564
+ if (!event.currentTarget.contains(event.relatedTarget)) {
565
+ setInside(false);
566
+ }
567
+ }}
568
+ className={cx('my-3', selected && SELECTED)}
569
+ >
570
+ <div data-vesper-table-ui="" className="mb-1 flex items-center justify-between gap-2">
571
+ <p id={labelId} className="text-label text-content-muted">
572
+ Table · {colCount} {columnWord}
573
+ </p>
574
+ {!ui.disabled && (
575
+ <Menu
576
+ label="Table actions"
577
+ trigger={
578
+ <IconButton
579
+ icon="more"
580
+ label="Table actions"
581
+ // A stop only while someone is in the table: Shift+Tab from a cell reaches it.
582
+ excludeFromTabOrder={!inside}
583
+ className="size-8"
584
+ />
585
+ }
586
+ items={items}
587
+ onAction={act}
588
+ choice={{
589
+ title: 'Align column',
590
+ options: ALIGNS.map((a) => ({ id: a.id, label: a.label })),
591
+ selected: current?.id ?? 'none',
592
+ onChange: align,
593
+ }}
594
+ isOpen={menu !== null}
595
+ onOpenChange={(open) => {
596
+ const from = menu;
597
+ setMenu(open ? (from ?? 'button') : null);
598
+ // Opened from a cell, the menu gives focus back to the cell.
599
+ if (!open && from === 'cell') focusLater();
600
+ }}
601
+ />
602
+ )}
603
+ </div>
604
+ <div
605
+ data-vesper-table-ui=""
606
+ className="overflow-x-auto rounded-md border border-border bg-surface-raised"
607
+ >
608
+ <table
609
+ // The APG's data grid is a table with this role.
610
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
611
+ role="grid"
612
+ aria-label={`Table: ${headers.join(', ')}`}
613
+ aria-rowcount={rowCount}
614
+ aria-colcount={colCount}
615
+ aria-describedby={helpId}
616
+ onKeyDown={onKeyDown}
617
+ className="w-full border-collapse text-body"
618
+ >
619
+ {/* The header row, then the rest. */}
620
+ {[value.rows.slice(0, 1), value.rows.slice(1)].map((group, g) => {
621
+ const rows = group.map((row, i) => {
622
+ const r = g === 0 ? i : i + 1;
623
+ return (
624
+ <tr key={r} aria-rowindex={r + 1}>
625
+ {row.cells.map((_, c) => {
626
+ const isActive = at.row === r && at.col === c;
627
+ const isEditing = editing?.row === r && editing.col === c;
628
+ const Tag = r === 0 ? 'th' : 'td';
629
+ return (
630
+ <Tag
631
+ key={c}
632
+ ref={(element: HTMLElement | null) => {
633
+ const k = `${String(r)}:${String(c)}`;
634
+ if (element) cells.current.set(k, element);
635
+ else cells.current.delete(k);
636
+ }}
637
+ role={r === 0 ? 'columnheader' : 'gridcell'}
638
+ aria-colindex={c + 1}
639
+ tabIndex={isActive && inside && !isEditing ? 0 : -1}
640
+ data-vesper-active={isActive ? '' : undefined}
641
+ onFocus={(event) => {
642
+ if (isEditing) return;
643
+ setActive({ row: r, col: c });
644
+ // The text's caret stays in the page's selection while a cell has
645
+ // focus, and text inserted without keys (dictation, an input
646
+ // method) would land there, unseen. With none, it lands nowhere.
647
+ const selection = window.getSelection();
648
+ if (
649
+ selection?.anchorNode &&
650
+ !event.currentTarget.contains(selection.anchorNode)
651
+ ) {
652
+ selection.removeAllRanges();
653
+ }
654
+ }}
655
+ onDoubleClick={() => {
656
+ if (!ui.disabled) edit({ row: r, col: c });
657
+ }}
658
+ className={cx(
659
+ 'border border-border px-3 py-2 align-top outline-none',
660
+ r === 0 ? 'bg-surface-sunken text-label text-content' : 'text-content',
661
+ ALIGN_CLASS[value.align[c] ?? ''] ?? 'text-left',
662
+ 'focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-focus',
663
+ // The cell being edited keeps the ring while its editor has focus.
664
+ isEditing && 'outline-2 outline-solid -outline-offset-2 outline-focus',
665
+ )}
666
+ >
667
+ {isEditing ? (
668
+ <CellEditor
669
+ inline={parsed[r]?.[c] ?? []}
670
+ label={`${columnName(c)}, ${rowName(r)}`}
671
+ describedBy={helpId}
672
+ chipName={(source) => chipLabel(ui, source)}
673
+ onFinish={finish({ row: r, col: c })}
674
+ />
675
+ ) : (
676
+ <CellContent inline={parsed[r]?.[c] ?? []} />
677
+ )}
678
+ </Tag>
679
+ );
680
+ })}
681
+ </tr>
682
+ );
683
+ });
684
+ return g === 0 ? <thead key="head">{rows}</thead> : <tbody key="body">{rows}</tbody>;
685
+ })}
686
+ </table>
687
+ </div>
688
+ <p id={helpId} className="sr-only">
689
+ {editing
690
+ ? `Enter or Escape keeps the cell, Tab goes to the next. ${shortcut('Mod', 'B').text} bold, ${shortcut('Mod', 'I').text} italic, ${shortcut('Mod', 'E').text} code, ${shortcut('Mod', 'K').text} link.`
691
+ : 'Enter edits the cell. Shift+F10 for row and column actions. Escape returns to the text.'}
692
+ </p>
693
+ {problem && (
694
+ <p id={problemId} className="mt-2 text-small text-danger">
695
+ {problem}
696
+ </p>
697
+ )}
698
+ </NodeViewWrapper>
699
+ );
700
+ }