Files
edentext/src/lib/editor/tableRowResize.ts
T

246 lines
9.7 KiB
TypeScript

import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import type { Transaction } from '@tiptap/pm/state';
import type { EditorState } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import type { EditorView } from '@tiptap/pm/view';
import { cellAround } from '@tiptap/pm/tables';
import { domCellAround, tableColumnResizeKey } from './tableColumnResize';
// Word-style row-height drag: changes the row above the grid line, min-height
// semantics (stored as `rowHeight` in tableRow.ts). Live preview is a meta-only
// Decoration.node (no history); yields to column resize at a corner.
const HANDLE_WIDTH = 5; // px from a row border that activates the handle
const MIN_ROW_PX = 16; // smallest a row may be dragged to (content still protects it)
interface RowResizeState {
activeRow: number; // doc pos directly before the active row, or -1
dragging: boolean;
previewHeight: number | null; // live drag height in unscaled document px
}
interface RowResizeAction {
setActive?: number;
setDragging?: boolean;
setPreview?: number | null;
}
export const tableRowResizeKey = new PluginKey<RowResizeState>('tableRowResize');
// ─── Geometry helpers ────────────────────────────────────────────────────────
// Position directly before the row whose lower border is near the pointer. We
// probe just ABOVE the pointer so a border between row i and i+1 resolves to row i
// (the row that gets resized). Returns -1 above the table's first row.
function edgeRowPos(view: EditorView, event: MouseEvent): number {
const found = view.posAtCoords({ left: event.clientX, top: event.clientY - HANDLE_WIDTH });
if (!found) return -1;
const $cell = cellAround(view.state.doc.resolve(found.pos));
if (!$cell) return -1;
return $cell.before(); // position before the row node
}
// The <tr> element for the row at `rowPos` (a before-row position).
function rowDOM(view: EditorView, rowPos: number): HTMLElement | null {
let dom = view.domAtPos(rowPos + 1).node as HTMLElement | null;
while (dom && dom.nodeName !== 'TR') dom = dom.parentNode as HTMLElement | null;
return (dom as HTMLElement) ?? null;
}
// ─── Event handlers ────────────────────────────────────────────────────────
function handleMouseMove(view: EditorView, event: MouseEvent): void {
if (!view.editable) return;
const state = tableRowResizeKey.getState(view.state);
if (!state || state.dragging) return;
// Corner precedence: if a column border is active, don't claim a row border.
const colState = tableColumnResizeKey.getState(view.state);
if (colState && colState.activeHandle > -1) {
if (state.activeRow !== -1) {
view.dispatch(view.state.tr.setMeta(tableRowResizeKey, { setActive: -1 } as RowResizeAction));
}
return;
}
const target = domCellAround(event.target as HTMLElement | null);
let row = -1;
if (target) {
const { top, bottom } = target.getBoundingClientRect();
if (bottom - event.clientY <= HANDLE_WIDTH || event.clientY - top <= HANDLE_WIDTH) {
row = edgeRowPos(view, event);
}
}
if (row !== state.activeRow) {
view.dispatch(view.state.tr.setMeta(tableRowResizeKey, { setActive: row } as RowResizeAction));
}
}
function handleMouseDown(view: EditorView, event: MouseEvent): boolean {
if (!view.editable) return false;
const state = tableRowResizeKey.getState(view.state);
if (!state || state.activeRow === -1 || state.dragging) return false;
const activeRow = state.activeRow;
const rowNode = view.state.doc.nodeAt(activeRow);
if (!rowNode || rowNode.type.name !== 'tableRow') return false;
const tr = rowDOM(view, activeRow);
if (!tr) return false;
const startHeight = tr.offsetHeight; // unscaled document px (ignores CSS zoom)
if (!startHeight) return false;
// offsetHeight is unscaled; getBoundingClientRect is scaled by the .paper zoom.
// Their ratio is the zoom factor, so the mouse delta (viewport px) converts back
// to document px without walking the ancestor chain.
const zoom = tr.getBoundingClientRect().height / startHeight || 1;
const startY = event.clientY;
let lastHeight = startHeight;
let pendingHeight = startHeight;
let moved = false;
let rafPending = false;
const win = view.dom.ownerDocument.defaultView ?? window;
view.dispatch(view.state.tr.setMeta(tableRowResizeKey, { setDragging: true } as RowResizeAction));
function flush(): void {
rafPending = false;
const st = tableRowResizeKey.getState(view.state);
if (!st?.dragging) return;
view.dispatch(
view.state.tr
.setMeta(tableRowResizeKey, { setPreview: pendingHeight } as RowResizeAction)
.setMeta('addToHistory', false),
);
}
function move(e: MouseEvent): void {
if (!e.buttons) {
finish();
return;
}
pendingHeight = Math.max(MIN_ROW_PX, Math.round(startHeight + (e.clientY - startY) / zoom));
lastHeight = pendingHeight;
moved = true;
if (!rafPending) {
rafPending = true;
win.requestAnimationFrame(flush);
}
}
function finish(): void {
win.removeEventListener('mouseup', finish);
win.removeEventListener('mousemove', move);
const st = tableRowResizeKey.getState(view.state);
if (!st?.dragging) return;
const node = view.state.doc.nodeAt(activeRow);
if (moved && node && node.type.name === 'tableRow') {
const tx = view.state.tr.setNodeMarkup(activeRow, undefined, { ...node.attrs, rowHeight: lastHeight });
tx.setMeta(tableRowResizeKey, { setDragging: false, setPreview: null } as RowResizeAction);
view.dispatch(tx);
} else {
view.dispatch(
view.state.tr.setMeta(tableRowResizeKey, { setDragging: false, setPreview: null } as RowResizeAction),
);
}
}
win.addEventListener('mouseup', finish);
win.addEventListener('mousemove', move);
event.preventDefault();
return true;
}
// ─── Decorations: handle at the active row's bottom edge + live preview ───────
function rowDecorations(state: EditorState, activeRow: number, previewHeight: number | null): DecorationSet {
const rowNode = state.doc.nodeAt(activeRow);
if (!rowNode || rowNode.type.name !== 'tableRow') return DecorationSet.empty;
const decos: Decoration[] = [];
const dragging = tableRowResizeKey.getState(state)?.dragging;
// Live preview: apply the dragged height through ProseMirror so it isn't reverted.
if (dragging && previewHeight != null) {
decos.push(
Decoration.node(activeRow, activeRow + rowNode.nodeSize, { style: `height: ${previewHeight}px` }),
);
}
// One handle per cell at the row's lower edge → a continuous line across the row.
rowNode.forEach((cell, offset) => {
const cellPos = activeRow + 1 + offset;
const handle = document.createElement('div');
handle.className = 'row-resize-handle';
decos.push(Decoration.widget(cellPos + cell.nodeSize - 1, handle));
});
return DecorationSet.create(state.doc, decos);
}
// ─── Plugin / extension ──────────────────────────────────────────────────────
export const TableRowResize = Extension.create({
name: 'tableRowResize',
addProseMirrorPlugins() {
return [
new Plugin<RowResizeState>({
key: tableRowResizeKey,
state: {
init: (): RowResizeState => ({ activeRow: -1, dragging: false, previewHeight: null }),
apply(tr: Transaction, prev: RowResizeState): RowResizeState {
const action = tr.getMeta(tableRowResizeKey) as RowResizeAction | undefined;
if (action) {
const next: RowResizeState = { ...prev };
if ('setActive' in action) {
next.activeRow = action.setActive ?? -1;
next.dragging = false;
next.previewHeight = null;
}
if ('setDragging' in action) next.dragging = !!action.setDragging;
if ('setPreview' in action) next.previewHeight = action.setPreview ?? null;
return next;
}
if (prev.activeRow > -1 && tr.docChanged) {
const mapped = tr.mapping.map(prev.activeRow, -1);
const node = tr.doc.nodeAt(mapped);
if (!node || node.type.name !== 'tableRow') {
return { activeRow: -1, dragging: false, previewHeight: null };
}
return { ...prev, activeRow: mapped };
}
return prev;
},
},
props: {
attributes: (state): Record<string, string> => {
const s = tableRowResizeKey.getState(state);
return s && s.activeRow > -1 ? { class: 'row-resize-cursor' } : {};
},
handleDOMEvents: {
mousemove: (view, event) => {
handleMouseMove(view, event as MouseEvent);
return false;
},
mouseleave: (view) => {
const s = tableRowResizeKey.getState(view.state);
if (s && s.activeRow > -1 && !s.dragging) {
view.dispatch(view.state.tr.setMeta(tableRowResizeKey, { setActive: -1 } as RowResizeAction));
}
return false;
},
mousedown: (view, event) => handleMouseDown(view, event as MouseEvent),
},
decorations: (state) => {
const s = tableRowResizeKey.getState(state);
if (s && s.activeRow > -1) return rowDecorations(state, s.activeRow, s.previewHeight);
return null;
},
},
}),
];
},
});