Skip to content
File

Blob: src/client/components/editor/controllers/table-menu-actions.tsx

typescript435 lines
1import type { ReactNode } from "react";
2import type { Editor } from "@tiptap/react";
3import type { Node as PMNode } from "@tiptap/pm/model";
4import { moveTableColumn, moveTableRow } from "@tiptap/pm/tables";
5import { TableMap } from "@tiptap/pm/tables";
6import {
7 ArrowDown,
8 ArrowLeft,
9 ArrowLeftRight,
10 ArrowRight,
11 ArrowUp,
12 ChevronDown,
13 ChevronLeft,
14 ChevronRight,
15 ChevronUp,
16 Columns3,
17 EqualApproximately,
18 Heading,
19 RotateCcw,
20 Rows3,
21 Trash2,
22} from "lucide-react";
23import { findTableElement } from "../extensions/table/dom";
24import { columnCellSelection, rowCellSelection, setCaretInCell } from "../extensions/table/selection";
25import { activeCellInfo, resolveOpenMenuState } from "../extensions/table/state";
26import type { OpenMenuState } from "../extensions/table/state";
27 
28export interface TableMenuAction {
29 key: string;
30 icon: ReactNode;
31 label: string;
32 onSelect: () => void;
33 disabled?: boolean;
34 danger?: boolean;
35}
36 
37export type TableMenuSection = TableMenuAction[];
38 
39interface BaseResolvedTarget {
40 table: PMNode;
41 tablePos: number;
42 tableKey: string;
43 rowCount: number;
44 colCount: number;
45}
46 
47interface ResolvedRowTarget extends BaseResolvedTarget {
48 kind: "row";
49 index: number;
50 anchorCellPos: number;
51 row: number;
52 col: number;
53}
54 
55interface ResolvedColumnTarget extends BaseResolvedTarget {
56 kind: "col";
57 index: number;
58 anchorCellPos: number;
59 row: number;
60 col: number;
61}
62 
63interface ResolvedCornerTarget extends BaseResolvedTarget {
64 kind: "corner";
65 index: null;
66}
67 
68type TableWidthCommandChain = ReturnType<Editor["chain"]> & {
69 resetTableColumnWidths: (tablePos: number) => TableWidthCommandChain;
70 distributeTableColumnsEvenly: (tablePos: number) => TableWidthCommandChain;
71 fitTableColumnsToContent: (tablePos: number) => TableWidthCommandChain;
72};
73 
74function withTableWidthCommands(editor: Editor): TableWidthCommandChain {
75 return editor.chain().focus(null, { scrollIntoView: false }) as TableWidthCommandChain;
76}
77 
78export function buildRowMenuSections({
79 editor,
80 openMenu,
81 onDone,
82}: {
83 editor: Editor;
84 openMenu: OpenMenuState;
85 onDone: (focusTrigger?: boolean) => void;
86}): TableMenuSection[] {
87 const target = resolveRowTarget(editor, openMenu);
88 if (!target) return [];
89 
90 const run = (fn: (resolved: ResolvedRowTarget) => boolean) => {
91 const current = resolveRowTarget(editor, openMenu);
92 if (current) fn(current);
93 onDone(false);
94 };
95 
96 return [
97 [
98 {
99 key: "row-select",
100 icon: <Rows3 size={14} />,
101 label: "Select row",
102 onSelect: () =>
103 run((resolved) => {
104 return selectRowTarget(editor, resolved);
105 }),
106 },
107 ],
108 [
109 {
110 key: "row-insert-above",
111 icon: <ChevronUp size={14} />,
112 label: "Insert row above",
113 onSelect: () =>
114 run((resolved) => {
115 focusRowTarget(editor, resolved);
116 return editor.chain().focus(null, { scrollIntoView: false }).addRowBefore().run();
117 }),
118 },
119 {
120 key: "row-insert-below",
121 icon: <ChevronDown size={14} />,
122 label: "Insert row below",
123 onSelect: () =>
124 run((resolved) => {
125 focusRowTarget(editor, resolved);
126 return editor.chain().focus(null, { scrollIntoView: false }).addRowAfter().run();
127 }),
128 },
129 ],
130 [
131 {
132 key: "row-move-up",
133 icon: <ArrowUp size={14} />,
134 label: "Move row up",
135 disabled: target.index === 0,
136 onSelect: () =>
137 run((resolved) => {
138 if (resolved.index <= 0) return false;
139 moveTableRow({ from: resolved.index, to: resolved.index - 1, pos: resolved.tablePos + 1, select: true })(
140 editor.state,
141 editor.view.dispatch.bind(editor.view),
142 );
143 return true;
144 }),
145 },
146 {
147 key: "row-move-down",
148 icon: <ArrowDown size={14} />,
149 label: "Move row down",
150 disabled: target.index >= target.rowCount - 1,
151 onSelect: () =>
152 run((resolved) => {
153 if (resolved.index >= resolved.rowCount - 1) return false;
154 moveTableRow({ from: resolved.index, to: resolved.index + 1, pos: resolved.tablePos + 1, select: true })(
155 editor.state,
156 editor.view.dispatch.bind(editor.view),
157 );
158 return true;
159 }),
160 },
161 ],
162 [
163 {
164 key: "row-delete",
165 icon: <Trash2 size={14} />,
166 label: "Delete row",
167 danger: true,
168 disabled: target.rowCount <= 1,
169 onSelect: () =>
170 run((resolved) => {
171 focusRowTarget(editor, resolved);
172 return editor.chain().focus(null, { scrollIntoView: false }).deleteRow().run();
173 }),
174 },
175 ],
176 ];
177}
178 
179export function buildColumnMenuSections({
180 editor,
181 openMenu,
182 onDone,
183}: {
184 editor: Editor;
185 openMenu: OpenMenuState;
186 onDone: (focusTrigger?: boolean) => void;
187}): TableMenuSection[] {
188 const target = resolveColumnTarget(editor, openMenu);
189 if (!target) return [];
190 
191 const run = (fn: (resolved: ResolvedColumnTarget) => boolean) => {
192 const current = resolveColumnTarget(editor, openMenu);
193 if (current) fn(current);
194 onDone(false);
195 };
196 
197 return [
198 [
199 {
200 key: "col-select",
201 icon: <Columns3 size={14} />,
202 label: "Select column",
203 onSelect: () =>
204 run((resolved) => {
205 return selectColumnTarget(editor, resolved);
206 }),
207 },
208 ],
209 [
210 {
211 key: "col-insert-left",
212 icon: <ChevronLeft size={14} />,
213 label: "Insert column left",
214 onSelect: () =>
215 run((resolved) => {
216 if (!focusColumnTarget(editor, resolved)) return false;
217 return editor.chain().focus(null, { scrollIntoView: false }).addColumnBefore().run();
218 }),
219 },
220 {
221 key: "col-insert-right",
222 icon: <ChevronRight size={14} />,
223 label: "Insert column right",
224 onSelect: () =>
225 run((resolved) => {
226 if (!focusColumnTarget(editor, resolved)) return false;
227 return editor.chain().focus(null, { scrollIntoView: false }).addColumnAfter().run();
228 }),
229 },
230 ],
231 [
232 {
233 key: "col-move-left",
234 icon: <ArrowLeft size={14} />,
235 label: "Move column left",
236 disabled: target.index === 0,
237 onSelect: () =>
238 run((resolved) => {
239 if (resolved.index <= 0) return false;
240 moveTableColumn({ from: resolved.index, to: resolved.index - 1, pos: resolved.tablePos + 1, select: true })(
241 editor.state,
242 editor.view.dispatch.bind(editor.view),
243 );
244 return true;
245 }),
246 },
247 {
248 key: "col-move-right",
249 icon: <ArrowRight size={14} />,
250 label: "Move column right",
251 disabled: target.index >= target.colCount - 1,
252 onSelect: () =>
253 run((resolved) => {
254 if (resolved.index >= resolved.colCount - 1) return false;
255 moveTableColumn({ from: resolved.index, to: resolved.index + 1, pos: resolved.tablePos + 1, select: true })(
256 editor.state,
257 editor.view.dispatch.bind(editor.view),
258 );
259 return true;
260 }),
261 },
262 ],
263 [
264 {
265 key: "col-delete",
266 icon: <Trash2 size={14} />,
267 label: "Delete column",
268 danger: true,
269 disabled: target.colCount <= 1,
270 onSelect: () =>
271 run((resolved) => {
272 const active = activeCellInfo(editor.state);
273 const activeRow = active?.tablePos === resolved.tablePos ? active.row : resolved.row;
274 if (!focusColumnTarget(editor, resolved)) return false;
275 
276 const didDelete = editor.chain().focus(null, { scrollIntoView: false }).deleteColumn().run();
277 if (!didDelete) return false;
278 
279 const nextTable = editor.state.doc.nodeAt(resolved.tablePos);
280 if (!nextTable || nextTable.type.spec.tableRole !== "table") return true;
281 
282 const nextMap = TableMap.get(nextTable);
283 const targetRow = Math.min(activeRow, nextMap.height - 1);
284 const targetCol = Math.min(resolved.col, nextMap.width - 1);
285 setCaretInCell(editor.view, nextTable, resolved.tablePos, targetRow, targetCol);
286 
287 if (targetCol === nextMap.width - 1) {
288 requestAnimationFrame(() => {
289 const wrapper = findTableElement(editor.view, resolved.tablePos)?.closest<HTMLElement>(".tableWrapper");
290 if (!wrapper) return;
291 wrapper.scrollLeft = wrapper.scrollWidth;
292 });
293 }
294 
295 return true;
296 }),
297 },
298 ],
299 ];
300}
301 
302export function buildTableMenuSections({
303 editor,
304 openMenu,
305 canResetWidths,
306 onDone,
307}: {
308 editor: Editor;
309 openMenu: OpenMenuState;
310 canResetWidths: boolean;
311 onDone: (focusTrigger?: boolean) => void;
312}): TableMenuSection[] {
313 const target = resolveCornerTarget(editor, openMenu);
314 if (!target) return [];
315 
316 const run = (fn: (resolved: ResolvedCornerTarget) => boolean) => {
317 const current = resolveCornerTarget(editor, openMenu);
318 if (current) fn(current);
319 onDone(false);
320 };
321 
322 return [
323 [
324 {
325 key: "table-toggle-header-row",
326 icon: <Heading size={14} />,
327 label: "Toggle header row",
328 onSelect: () =>
329 run((resolved) => {
330 focusTableTarget(editor, resolved);
331 return editor.chain().focus(null, { scrollIntoView: false }).toggleHeaderRow().run();
332 }),
333 },
334 {
335 key: "table-toggle-header-column",
336 icon: <Heading size={14} />,
337 label: "Toggle header column",
338 onSelect: () =>
339 run((resolved) => {
340 focusTableTarget(editor, resolved);
341 return editor.chain().focus(null, { scrollIntoView: false }).toggleHeaderColumn().run();
342 }),
343 },
344 ],
345 [
346 {
347 key: "table-reset-widths",
348 icon: <RotateCcw size={14} />,
349 label: "Reset column widths",
350 disabled: !canResetWidths,
351 onSelect: () =>
352 run((resolved) => withTableWidthCommands(editor).resetTableColumnWidths(resolved.tablePos).run()),
353 },
354 {
355 key: "table-distribute-widths",
356 icon: <EqualApproximately size={14} />,
357 label: "Distribute columns evenly",
358 onSelect: () =>
359 run((resolved) => withTableWidthCommands(editor).distributeTableColumnsEvenly(resolved.tablePos).run()),
360 },
361 {
362 key: "table-fit-widths",
363 icon: <ArrowLeftRight size={14} />,
364 label: "Fit all columns to content",
365 onSelect: () =>
366 run((resolved) => withTableWidthCommands(editor).fitTableColumnsToContent(resolved.tablePos).run()),
367 },
368 ],
369 [
370 {
371 key: "table-delete",
372 icon: <Trash2 size={14} />,
373 label: "Delete table",
374 danger: true,
375 onSelect: () =>
376 run((resolved) => {
377 focusTableTarget(editor, resolved);
378 return editor.chain().focus(null, { scrollIntoView: false }).deleteTable().run();
379 }),
380 },
381 ],
382 ];
383}
384 
385function resolveRowTarget(editor: Editor, openMenu: OpenMenuState): ResolvedRowTarget | null {
386 const resolved = resolveOpenMenuState(editor.state.doc, openMenu);
387 return resolved?.kind === "row" ? (resolved as ResolvedRowTarget) : null;
388}
389 
390function resolveColumnTarget(editor: Editor, openMenu: OpenMenuState): ResolvedColumnTarget | null {
391 const resolved = resolveOpenMenuState(editor.state.doc, openMenu);
392 return resolved?.kind === "col" ? (resolved as ResolvedColumnTarget) : null;
393}
394 
395function resolveCornerTarget(editor: Editor, openMenu: OpenMenuState): ResolvedCornerTarget | null {
396 const resolved = resolveOpenMenuState(editor.state.doc, openMenu);
397 return resolved?.kind === "corner" ? (resolved as ResolvedCornerTarget) : null;
398}
399 
400function focusRowTarget(editor: Editor, target: ResolvedRowTarget) {
401 const selection = rowCellSelection(editor.state.doc, target.tablePos, target.table, target.row);
402 if (selection) {
403 editor.view.dispatch(editor.state.tr.setSelection(selection));
404 return;
405 }
406 setCaretInCell(editor.view, target.table, target.tablePos, target.row, 0);
407}
408 
409function focusColumnTarget(editor: Editor, target: ResolvedColumnTarget): boolean {
410 const selection = columnCellSelection(editor.state.doc, target.tablePos, target.table, target.col);
411 if (!selection) return false;
412 editor.view.dispatch(editor.state.tr.setSelection(selection));
413 return true;
414}
415 
416function selectRowTarget(editor: Editor, target: ResolvedRowTarget): boolean {
417 const selection = rowCellSelection(editor.state.doc, target.tablePos, target.table, target.row);
418 if (!selection) return false;
419 editor.view.dispatch(editor.state.tr.setSelection(selection));
420 editor.view.focus();
421 return true;
422}
423 
424function selectColumnTarget(editor: Editor, target: ResolvedColumnTarget): boolean {
425 const selection = columnCellSelection(editor.state.doc, target.tablePos, target.table, target.col);
426 if (!selection) return false;
427 editor.view.dispatch(editor.state.tr.setSelection(selection));
428 editor.view.focus();
429 return true;
430}
431 
432function focusTableTarget(editor: Editor, target: ResolvedCornerTarget) {
433 setCaretInCell(editor.view, target.table, target.tablePos, 0, 0);
434}