Skip to content
File

Blob: src/client/components/editor/extensions/table/overlay-view.ts

typescript566 lines
1import { addColumnAfter, addRowAfter, CellSelection, TableMap } from "@tiptap/pm/tables";
2import type { EditorState, PluginView } from "@tiptap/pm/state";
3import type { EditorView } from "@tiptap/pm/view";
4import type { Node as PMNode } from "@tiptap/pm/model";
5import { TABLE_ADD_PILL_SIZE_PX, TABLE_HANDLE_OFFSET_PX } from "./constants";
6import {
7 buildColumnEntries,
8 createLocalRectConverter,
9 findTableForWrapper,
10 findLogicalCellElement,
11 type ColumnEntry,
12 type LocalRect,
13} from "./dom";
14import { completeDrag, createColumnDragState, createRowDragState, type DragState, updateDragState } from "./reorder";
15import { setCaretInCell } from "./selection";
16import {
17 activeCellInfo,
18 createOpenMenuState,
19 createTableHandlesPluginState,
20 tableHandlesKey,
21 tableKeyFromPos,
22 type ActiveCellInfo,
23 type OpenMenuState,
24 type TableHandlesMeta,
25} from "./state";
26 
27interface WrapperState {
28 overlay: HTMLElement;
29 dropIndicator: HTMLElement;
30 onScroll: () => void;
31}
32 
33interface OverlayContext {
34 wrapper: HTMLElement;
35 overlay: HTMLElement;
36 info: { pos: number; node: PMNode };
37 tableKey: string;
38 rows: HTMLTableRowElement[];
39 columnEntries: ColumnEntry[];
40 toLocal: ReturnType<typeof createLocalRectConverter>;
41 wrapperLocal: { left: number; right: number; top: number; bottom: number };
42 tableLocal: { left: number; right: number; top: number; bottom: number };
43 visibleTableLeft: number;
44 visibleTableRight: number;
45 visibleTableTop: number;
46 visibleTableBottom: number;
47 activeInThisTable: ActiveCellInfo | null;
48 menuOnThisTable: OpenMenuState | null;
49 isTyping: boolean;
50 tableWidth: number;
51 tableHeight: number;
52 activeCellLocal: LocalRect | null;
53}
54 
55function clamp(value: number, min: number, max: number): number {
56 if (max <= min) return min;
57 return Math.min(Math.max(value, min), max);
58}
59 
60function intersectsAxis(start: number, end: number, visibleStart: number, visibleEnd: number): boolean {
61 return end > visibleStart && start < visibleEnd;
62}
63 
64export class TableOverlayView implements PluginView {
65 view: EditorView;
66 wrappers = new Map<HTMLElement, WrapperState>();
67 dragState: DragState | null = null;
68 activeCell: ActiveCellInfo | null = null;
69 rafHandle: number | null = null;
70 resizeObserver: ResizeObserver | null = null;
71 dragCleanup: (() => void) | null = null;
72 cleanupFns: Array<() => void> = [];
73 
74 constructor(view: EditorView) {
75 this.view = view;
76 this.resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => this.scheduleSync()) : null;
77 this.activeCell = activeCellInfo(view.state);
78 this.scheduleSync();
79 
80 const onWindowChange = () => this.scheduleSync();
81 window.addEventListener("resize", onWindowChange, { passive: true });
82 window.addEventListener("scroll", onWindowChange, { passive: true, capture: true });
83 this.cleanupFns.push(() => {
84 window.removeEventListener("resize", onWindowChange);
85 window.removeEventListener("scroll", onWindowChange, { capture: true });
86 });
87 }
88 
89 update(view: EditorView, _prevState?: EditorState) {
90 this.view = view;
91 if (this.dragState && _prevState && _prevState.doc !== view.state.doc) {
92 this.endDrag();
93 }
94 
95 const prevActive = this.activeCell;
96 const nextActive = activeCellInfo(view.state);
97 this.activeCell = nextActive;
98 
99 if (prevActive?.tableKey !== nextActive?.tableKey && tableHandlesKey.getState(view.state)?.isTyping) {
100 this.dispatchMeta({ isTyping: false });
101 }
102 
103 this.sync();
104 }
105 
106 destroy() {
107 if (this.rafHandle !== null) cancelAnimationFrame(this.rafHandle);
108 this.rafHandle = null;
109 
110 this.cleanupFns.forEach((fn) => fn());
111 this.cleanupFns = [];
112 
113 this.resizeObserver?.disconnect();
114 this.resizeObserver = null;
115 
116 this.dragCleanup?.();
117 this.dragCleanup = null;
118 this.dragState = null;
119 
120 for (const [wrapper, state] of this.wrappers.entries()) {
121 wrapper.removeEventListener("scroll", state.onScroll);
122 state.overlay.remove();
123 wrapper.classList.remove("has-table-handles");
124 }
125 this.wrappers.clear();
126 }
127 
128 private dispatchMeta(meta: TableHandlesMeta) {
129 if (this.view.isDestroyed) return;
130 this.view.dispatch(this.view.state.tr.setMeta(tableHandlesKey, meta));
131 }
132 
133 private overlayHost(): HTMLElement {
134 return (this.view.dom.offsetParent as HTMLElement | null) ?? this.view.dom.parentElement ?? this.view.dom;
135 }
136 
137 private scheduleSync() {
138 if (this.rafHandle !== null) return;
139 this.rafHandle = requestAnimationFrame(() => {
140 this.rafHandle = null;
141 this.sync();
142 });
143 }
144 
145 private sync() {
146 if (this.view.isDestroyed) return;
147 if (!this.view.editable) {
148 this.teardown();
149 return;
150 }
151 
152 const seen = new Set<HTMLElement>();
153 this.view.dom.querySelectorAll<HTMLElement>(".tableWrapper").forEach((wrapper) => {
154 seen.add(wrapper);
155 this.syncWrapper(wrapper);
156 });
157 
158 for (const [wrapper, state] of this.wrappers.entries()) {
159 if (!seen.has(wrapper)) this.removeWrapperState(wrapper, state);
160 }
161 }
162 
163 private teardown() {
164 for (const [wrapper, state] of this.wrappers.entries()) {
165 this.removeWrapperState(wrapper, state);
166 }
167 this.wrappers.clear();
168 }
169 
170 private removeWrapperState(wrapper: HTMLElement, state: WrapperState) {
171 wrapper.removeEventListener("scroll", state.onScroll);
172 state.overlay.remove();
173 wrapper.classList.remove("has-table-handles");
174 this.resizeObserver?.unobserve(wrapper);
175 this.wrappers.delete(wrapper);
176 }
177 
178 private ensureWrapperState(wrapper: HTMLElement): WrapperState {
179 const existing = this.wrappers.get(wrapper);
180 if (existing) return existing;
181 
182 const overlay = document.createElement("div");
183 overlay.className = "tiptap-table-handles";
184 overlay.contentEditable = "false";
185 
186 const dropIndicator = document.createElement("div");
187 dropIndicator.className = "tiptap-table-drop-indicator";
188 dropIndicator.style.display = "none";
189 overlay.appendChild(dropIndicator);
190 
191 wrapper.classList.add("has-table-handles");
192 this.overlayHost().appendChild(overlay);
193 this.resizeObserver?.observe(wrapper);
194 
195 const onScroll = () => this.scheduleSync();
196 wrapper.addEventListener("scroll", onScroll, { passive: true });
197 
198 const state: WrapperState = { overlay, dropIndicator, onScroll };
199 this.wrappers.set(wrapper, state);
200 return state;
201 }
202 
203 private syncWrapper(wrapper: HTMLElement) {
204 const table = wrapper.querySelector<HTMLTableElement>(":scope > table");
205 if (!table) return;
206 
207 const state = this.ensureWrapperState(wrapper);
208 state.overlay.replaceChildren(state.dropIndicator);
209 state.dropIndicator.style.display = "none";
210 
211 const rows = Array.from(table.querySelectorAll<HTMLTableRowElement>(":scope > tbody > tr"));
212 if (rows.length === 0) return;
213 const firstRow = rows[0];
214 if (!firstRow) return;
215 
216 const info = findTableForWrapper(this.view, wrapper);
217 if (!info) return;
218 
219 const pluginState = tableHandlesKey.getState(this.view.state) ?? createTableHandlesPluginState();
220 const tableKey = tableKeyFromPos(info.pos);
221 const activeInThisTable = this.activeCell?.tableKey === tableKey ? this.activeCell : null;
222 const menuOnThisTable = pluginState.openMenu?.tableKey === tableKey ? pluginState.openMenu : null;
223 if (!activeInThisTable && !menuOnThisTable && !this.dragState) return;
224 if (this.view.state.selection instanceof CellSelection && !menuOnThisTable && !this.dragState) return;
225 
226 const toLocal = createLocalRectConverter(this.overlayHost());
227 const wrapperLocal = toLocal(wrapper.getBoundingClientRect());
228 const tableLocal = toLocal(table.getBoundingClientRect());
229 const activeCellEl = activeInThisTable
230 ? findLogicalCellElement(rows, activeInThisTable.row, activeInThisTable.col)
231 : null;
232 const activeCellLocal = activeCellEl ? toLocal(activeCellEl.getBoundingClientRect()) : null;
233 const map = TableMap.get(info.node);
234 const ctx: OverlayContext = {
235 wrapper,
236 overlay: state.overlay,
237 info,
238 tableKey,
239 rows,
240 columnEntries: buildColumnEntries(firstRow),
241 toLocal,
242 wrapperLocal,
243 tableLocal,
244 visibleTableLeft: Math.max(tableLocal.left, wrapperLocal.left),
245 visibleTableRight: Math.min(tableLocal.right, wrapperLocal.right),
246 visibleTableTop: Math.max(tableLocal.top, wrapperLocal.top),
247 visibleTableBottom: Math.min(tableLocal.bottom, wrapperLocal.bottom),
248 activeInThisTable,
249 menuOnThisTable,
250 isTyping: pluginState.isTyping,
251 tableWidth: map.width,
252 tableHeight: map.height,
253 activeCellLocal,
254 };
255 
256 this.renderRowHandles(ctx);
257 this.renderColumnHandles(ctx);
258 this.renderCornerHandle(ctx);
259 this.renderAddButtons(ctx);
260 }
261 
262 private renderRowHandles(ctx: OverlayContext) {
263 ctx.rows.forEach((row, rowIndex) => {
264 const shouldShow =
265 (ctx.menuOnThisTable?.kind === "row" && ctx.menuOnThisTable.index === rowIndex) ||
266 (!ctx.isTyping && ctx.activeInThisTable?.row === rowIndex);
267 if (!shouldShow) return;
268 
269 const rect = ctx.toLocal(row.getBoundingClientRect());
270 const handle = this.createHandleButton("tiptap-table-row-handle", `Row ${rowIndex + 1} actions`, "Row actions");
271 handle.dataset.tableHandleKind = "row";
272 handle.dataset.tableKey = ctx.tableKey;
273 handle.dataset.index = String(rowIndex);
274 handle.style.top = `${rect.top}px`;
275 handle.style.left = `${ctx.visibleTableLeft - TABLE_HANDLE_OFFSET_PX}px`;
276 handle.style.height = `${rect.height}px`;
277 handle.addEventListener("pointerdown", (event) => this.onRowHandlePointerDown(event, ctx.wrapper, rowIndex));
278 handle.addEventListener("keydown", (event) =>
279 this.onHandleKeyDown(event, "row", rowIndex, ctx.info.pos, ctx.info.node),
280 );
281 ctx.overlay.appendChild(handle);
282 });
283 }
284 
285 private renderColumnHandles(ctx: OverlayContext) {
286 ctx.columnEntries.forEach((entry) => {
287 const shouldShow =
288 (ctx.menuOnThisTable?.kind === "col" && ctx.menuOnThisTable.index === entry.logicalCol) ||
289 (!ctx.isTyping && ctx.activeInThisTable?.col === entry.logicalCol);
290 if (!shouldShow) return;
291 
292 const rect = ctx.toLocal(entry.domCell.getBoundingClientRect());
293 const visibleLeft = Math.max(rect.left, ctx.wrapperLocal.left);
294 const visibleRight = Math.min(rect.right, ctx.wrapperLocal.right);
295 const visibleWidth = Math.max(0, visibleRight - visibleLeft);
296 if (visibleWidth === 0) return;
297 
298 const handle = this.createHandleButton(
299 "tiptap-table-col-handle",
300 `Column ${entry.logicalCol + 1} actions`,
301 "Column actions",
302 );
303 handle.dataset.tableHandleKind = "col";
304 handle.dataset.tableKey = ctx.tableKey;
305 handle.dataset.index = String(entry.logicalCol);
306 handle.dataset.colspan = String(entry.colspan);
307 handle.style.left = `${visibleLeft}px`;
308 handle.style.top = `${ctx.tableLocal.top - TABLE_HANDLE_OFFSET_PX}px`;
309 handle.style.width = `${visibleWidth}px`;
310 handle.addEventListener("pointerdown", (event) =>
311 this.onColHandlePointerDown(event, ctx.wrapper, entry.logicalCol),
312 );
313 handle.addEventListener("keydown", (event) =>
314 this.onHandleKeyDown(event, "col", entry.logicalCol, ctx.info.pos, ctx.info.node),
315 );
316 ctx.overlay.appendChild(handle);
317 });
318 }
319 
320 private renderCornerHandle(ctx: OverlayContext) {
321 const shouldShow = ctx.menuOnThisTable?.kind === "corner" || (!ctx.isTyping && !!ctx.activeInThisTable);
322 if (!shouldShow) return;
323 
324 const corner = this.createHandleButton("tiptap-table-corner-handle", "Table actions", "Table actions");
325 corner.dataset.tableHandleKind = "corner";
326 corner.dataset.tableKey = ctx.tableKey;
327 corner.style.left = `${ctx.visibleTableLeft - TABLE_HANDLE_OFFSET_PX}px`;
328 corner.style.top = `${ctx.tableLocal.top - TABLE_HANDLE_OFFSET_PX}px`;
329 corner.addEventListener("mousedown", (event) => event.preventDefault());
330 corner.addEventListener("click", (event) => {
331 event.preventDefault();
332 event.stopPropagation();
333 this.openMenuFromHandle("corner", null, ctx.info.pos, ctx.info.node);
334 });
335 corner.addEventListener("keydown", (event) =>
336 this.onHandleKeyDown(event, "corner", null, ctx.info.pos, ctx.info.node),
337 );
338 ctx.overlay.appendChild(corner);
339 }
340 
341 private renderAddButtons(ctx: OverlayContext) {
342 const activeCell = ctx.activeInThisTable;
343 const activeCellLocal = ctx.activeCellLocal;
344 if (ctx.isTyping || this.dragState || !activeCell || !activeCellLocal || ctx.menuOnThisTable) return;
345 if (
346 !intersectsAxis(activeCellLocal.left, activeCellLocal.right, ctx.visibleTableLeft, ctx.visibleTableRight) ||
347 !intersectsAxis(activeCellLocal.top, activeCellLocal.bottom, ctx.visibleTableTop, ctx.visibleTableBottom)
348 ) {
349 return;
350 }
351 
352 const showAddCol = activeCell.col === ctx.tableWidth - 1;
353 const showAddRow = activeCell.row === ctx.tableHeight - 1;
354 const pillHalf = TABLE_ADD_PILL_SIZE_PX / 2;
355 
356 if (showAddCol) {
357 const addCol = document.createElement("button");
358 addCol.type = "button";
359 addCol.className = "tiptap-table-add-col";
360 addCol.setAttribute("aria-label", "Add column");
361 addCol.setAttribute("title", "Add column");
362 addCol.textContent = "+";
363 addCol.style.left = `${ctx.visibleTableRight - pillHalf}px`;
364 addCol.style.top = `${clamp(
365 activeCellLocal.top + activeCellLocal.height / 2 - pillHalf,
366 ctx.visibleTableTop,
367 ctx.visibleTableBottom - TABLE_ADD_PILL_SIZE_PX,
368 )}px`;
369 addCol.addEventListener("mousedown", (event) => event.preventDefault());
370 addCol.addEventListener("click", (event) => {
371 event.preventDefault();
372 event.stopPropagation();
373 this.addColumnAtEnd(ctx.wrapper);
374 });
375 ctx.overlay.appendChild(addCol);
376 }
377 
378 if (showAddRow) {
379 const addRow = document.createElement("button");
380 addRow.type = "button";
381 addRow.className = "tiptap-table-add-row";
382 addRow.setAttribute("aria-label", "Add row");
383 addRow.setAttribute("title", "Add row");
384 addRow.textContent = "+";
385 addRow.style.top = `${clamp(activeCellLocal.bottom - pillHalf, ctx.visibleTableTop, ctx.wrapperLocal.bottom)}px`;
386 addRow.style.left = `${clamp(
387 activeCellLocal.left + activeCellLocal.width / 2 - pillHalf,
388 ctx.visibleTableLeft,
389 ctx.visibleTableRight - TABLE_ADD_PILL_SIZE_PX,
390 )}px`;
391 addRow.addEventListener("mousedown", (event) => event.preventDefault());
392 addRow.addEventListener("click", (event) => {
393 event.preventDefault();
394 event.stopPropagation();
395 this.addRowAtEnd(ctx.wrapper);
396 });
397 ctx.overlay.appendChild(addRow);
398 }
399 }
400 
401 private createHandleButton(className: string, ariaLabel: string, title: string) {
402 const button = document.createElement("button");
403 button.type = "button";
404 button.className = className;
405 button.setAttribute("aria-label", ariaLabel);
406 button.setAttribute("title", title);
407 return button;
408 }
409 
410 private openMenuFromHandle(kind: OpenMenuState["kind"], index: number | null, tablePos: number, table: PMNode) {
411 const openMenu = createOpenMenuState(kind, index, tablePos, table);
412 if (!openMenu) return;
413 this.dispatchMeta({ openMenu });
414 }
415 
416 private onHandleKeyDown(
417 event: KeyboardEvent,
418 kind: OpenMenuState["kind"],
419 index: number | null,
420 tablePos: number,
421 table: PMNode,
422 ) {
423 if (event.key !== "Enter" && event.key !== " ") return;
424 event.preventDefault();
425 event.stopPropagation();
426 this.openMenuFromHandle(kind, index, tablePos, table);
427 }
428 
429 private addColumnAtEnd(wrapper: HTMLElement) {
430 const info = findTableForWrapper(this.view, wrapper);
431 if (!info) return;
432 
433 const map = TableMap.get(info.node);
434 setCaretInCell(this.view, info.node, info.pos, map.height - 1, map.width - 1);
435 addColumnAfter(this.view.state, this.view.dispatch.bind(this.view));
436 const nextTable = this.view.state.doc.nodeAt(info.pos);
437 if (!nextTable || nextTable.type.spec.tableRole !== "table") return;
438 
439 const nextMap = TableMap.get(nextTable);
440 setCaretInCell(this.view, nextTable, info.pos, nextMap.height - 1, nextMap.width - 1);
441 requestAnimationFrame(() => {
442 wrapper.scrollLeft = wrapper.scrollWidth;
443 this.scheduleSync();
444 });
445 }
446 
447 private addRowAtEnd(wrapper: HTMLElement) {
448 const info = findTableForWrapper(this.view, wrapper);
449 if (!info) return;
450 
451 const map = TableMap.get(info.node);
452 setCaretInCell(this.view, info.node, info.pos, map.height - 1, map.width - 1);
453 addRowAfter(this.view.state, this.view.dispatch.bind(this.view));
454 const nextTable = this.view.state.doc.nodeAt(info.pos);
455 if (!nextTable || nextTable.type.spec.tableRole !== "table") return;
456 
457 const nextMap = TableMap.get(nextTable);
458 setCaretInCell(this.view, nextTable, info.pos, nextMap.height - 1, nextMap.width - 1);
459 }
460 
461 private onRowHandlePointerDown(event: PointerEvent, wrapper: HTMLElement, rowIndex: number) {
462 if (event.button !== 0) return;
463 event.preventDefault();
464 event.stopPropagation();
465 
466 const dragState = createRowDragState(this.view, wrapper, rowIndex, event.pointerId, event.clientX, event.clientY);
467 if (!dragState) return;
468 
469 this.beginDrag(dragState);
470 (event.currentTarget as Element)?.setPointerCapture?.(event.pointerId);
471 }
472 
473 private onColHandlePointerDown(event: PointerEvent, wrapper: HTMLElement, logicalCol: number) {
474 if (event.button !== 0) return;
475 event.preventDefault();
476 event.stopPropagation();
477 
478 const dragState = createColumnDragState(
479 this.view,
480 wrapper,
481 logicalCol,
482 event.pointerId,
483 event.clientX,
484 event.clientY,
485 );
486 if (!dragState) return;
487 
488 this.beginDrag(dragState);
489 (event.currentTarget as Element)?.setPointerCapture?.(event.pointerId);
490 }
491 
492 private beginDrag(state: DragState) {
493 this.dragState = state;
494 
495 const onMove = (event: PointerEvent) => this.onDragMove(event);
496 const onUp = (event: PointerEvent) => this.onDragUp(event);
497 const onCancel = () => this.endDrag();
498 
499 window.addEventListener("pointermove", onMove);
500 window.addEventListener("pointerup", onUp);
501 window.addEventListener("pointercancel", onCancel);
502 
503 this.dragCleanup = () => {
504 window.removeEventListener("pointermove", onMove);
505 window.removeEventListener("pointerup", onUp);
506 window.removeEventListener("pointercancel", onCancel);
507 };
508 
509 state.wrapper.classList.add("is-dragging-table");
510 }
511 
512 private endDrag() {
513 if (!this.dragState) return;
514 
515 const wrapperState = this.wrappers.get(this.dragState.wrapper);
516 if (wrapperState) wrapperState.dropIndicator.style.display = "none";
517 
518 this.dragState.wrapper.classList.remove("is-dragging-table");
519 this.dragState = null;
520 this.dragCleanup?.();
521 this.dragCleanup = null;
522 this.sync();
523 }
524 
525 private onDragMove(event: PointerEvent) {
526 const state = this.dragState;
527 if (!state || event.pointerId !== state.pointerId) return;
528 
529 const wrapperState = this.wrappers.get(state.wrapper);
530 if (!wrapperState) return;
531 
532 const indicator = updateDragState(
533 this.view,
534 state,
535 event.clientX,
536 event.clientY,
537 createLocalRectConverter(this.overlayHost()),
538 );
539 if (!indicator) {
540 wrapperState.dropIndicator.style.display = "none";
541 return;
542 }
543 
544 wrapperState.dropIndicator.classList.toggle("is-row", indicator.orientation === "row");
545 wrapperState.dropIndicator.classList.toggle("is-column", indicator.orientation === "column");
546 wrapperState.dropIndicator.style.display = "block";
547 wrapperState.dropIndicator.style.top = `${indicator.top}px`;
548 wrapperState.dropIndicator.style.left = `${indicator.left}px`;
549 wrapperState.dropIndicator.style.width = `${indicator.width}px`;
550 wrapperState.dropIndicator.style.height = `${indicator.height}px`;
551 }
552 
553 private onDragUp(event: PointerEvent) {
554 const state = this.dragState;
555 if (!state || event.pointerId !== state.pointerId) return;
556 
557 const openMenu = completeDrag(this.view, state);
558 this.endDrag();
559 if (openMenu) {
560 const table = this.view.state.doc.nodeAt(openMenu.tablePos);
561 if (!table || table.type.spec.tableRole !== "table") return;
562 this.openMenuFromHandle(openMenu.kind, openMenu.index, openMenu.tablePos, table);
563 }
564 }
565}