Operand

thee, sea, us.

gram: docs

> ./packages/vue/src/composables/useTableResize.ts

/**
* useTableResize — Vue port of React PagedEditor.tsx's table-resize logic
* (column / row / right-edge handles).
*
* Ports the mouseDown branch (lines 3110-3232 in React), the mouseMove
* delta tracking (lines 3329-3389), and the mouseUp PM-transaction
* commit (lines 3454-3618). Same constants (1px ≈ 15 twips at 96dpi,
* 300 twips column min, 200 twips row min). Same attribute mutations
* (columnWidths on table node, width/widthType/colwidth on each cell,
* height/heightRule on the row).
*
* Returns:
* - tryStartResize(e, view): returns true if the click started a
* resize (caller should bail early), false otherwise.
* - install(): wires global mousemove/mouseup listeners. Caller
* should invoke the returned cleanup on unmount.
*/
import type { EditorView } from 'prosemirror-view';
import {
readColumnWidths,
readRowHeight,
readColumnWidthAt,
commitColumnResize,
commitRowResize,
commitRightEdgeResize,
TWIPS_PER_PIXEL,
MIN_CELL_WIDTH_TWIPS,
MIN_ROW_HEIGHT_TWIPS,
} from '@eigenpal/docx-editor-core/prosemirror/tableResize';
interface ColumnResizeState {
active: boolean;
startX: number;
handle: HTMLElement | null;
columnIndex: number;
tablePmStart: number;
origWidths: { left: number; right: number };
}
interface RowResizeState {
active: boolean;
startY: number;
handle: HTMLElement | null;
rowIndex: number;
isEdge: boolean;
tablePmStart: number;
origHeight: number;
}
interface RightEdgeResizeState {
active: boolean;
startX: number;
handle: HTMLElement | null;
columnIndex: number;
tablePmStart: number;
origWidth: number;
}
export interface UseTableResizeReturn {
tryStartResize: (e: MouseEvent, view: EditorView) => boolean;
install: () => () => void;
isResizing: () => boolean;
}
export function useTableResize(): UseTableResizeReturn {
const col: ColumnResizeState = {
active: false,
startX: 0,
handle: null,
columnIndex: 0,
tablePmStart: 0,
origWidths: { left: 0, right: 0 },
};
const row: RowResizeState = {
active: false,
startY: 0,
handle: null,
rowIndex: 0,
isEdge: false,
tablePmStart: 0,
origHeight: 0,
};
const edge: RightEdgeResizeState = {
active: false,
startX: 0,
handle: null,
columnIndex: 0,
tablePmStart: 0,
origWidth: 0,
};
let viewRef: EditorView | null = null;
function isResizing(): boolean {
return col.active || row.active || edge.active;
}
function tryStartResize(e: MouseEvent, view: EditorView): boolean {
const target = e.target as HTMLElement;
if (!target?.classList) return false;
if (target.classList.contains('layout-table-resize-handle')) {
e.preventDefault();
e.stopPropagation();
viewRef = view;
col.active = true;
col.startX = e.clientX;
col.handle = target;
target.classList.add('dragging');
col.columnIndex = parseInt(target.dataset.columnIndex ?? '0', 10);
col.tablePmStart = parseInt(target.dataset.tablePmStart ?? '0', 10);
seedColumnWidths(view, col);
return true;
}
if (
target.classList.contains('layout-table-row-resize-handle') ||
target.classList.contains('layout-table-edge-handle-bottom')
) {
e.preventDefault();
e.stopPropagation();
viewRef = view;
row.active = true;
row.startY = e.clientY;
row.handle = target;
row.isEdge = target.dataset.isEdge === 'bottom';
target.classList.add('dragging');
row.rowIndex = parseInt(target.dataset.rowIndex ?? '0', 10);
row.tablePmStart = parseInt(target.dataset.tablePmStart ?? '0', 10);
seedRowHeight(view, row, target);
return true;
}
if (target.classList.contains('layout-table-edge-handle-right')) {
e.preventDefault();
e.stopPropagation();
viewRef = view;
edge.active = true;
edge.startX = e.clientX;
edge.handle = target;
target.classList.add('dragging');
edge.columnIndex = parseInt(target.dataset.columnIndex ?? '0', 10);
edge.tablePmStart = parseInt(target.dataset.tablePmStart ?? '0', 10);
seedRightEdgeWidth(view, edge);
return true;
}
return false;
}
function handleMove(e: MouseEvent) {
if (col.active && col.handle) {
e.preventDefault();
const delta = e.clientX - col.startX;
const origLeft = parseFloat(col.handle.style.left);
col.handle.style.left = `${origLeft + delta}px`;
col.startX = e.clientX;
const deltaTwips = Math.round(delta * TWIPS_PER_PIXEL);
const newLeft = col.origWidths.left + deltaTwips;
const newRight = col.origWidths.right - deltaTwips;
if (newLeft >= MIN_CELL_WIDTH_TWIPS && newRight >= MIN_CELL_WIDTH_TWIPS) {
col.origWidths = { left: newLeft, right: newRight };
}
return;
}
if (row.active && row.handle) {
e.preventDefault();
const delta = e.clientY - row.startY;
const origTop = parseFloat(row.handle.style.top);
row.handle.style.top = `${origTop + delta}px`;
row.startY = e.clientY;
const deltaTwips = Math.round(delta * TWIPS_PER_PIXEL);
const newHeight = row.origHeight + deltaTwips;
if (newHeight >= MIN_ROW_HEIGHT_TWIPS) {
row.origHeight = newHeight;
}
return;
}
if (edge.active && edge.handle) {
e.preventDefault();
const delta = e.clientX - edge.startX;
const origLeft = parseFloat(edge.handle.style.left);
edge.handle.style.left = `${origLeft + delta}px`;
edge.startX = e.clientX;
const deltaTwips = Math.round(delta * TWIPS_PER_PIXEL);
const newWidth = edge.origWidth + deltaTwips;
if (newWidth >= MIN_CELL_WIDTH_TWIPS) {
edge.origWidth = newWidth;
}
}
}
function handleUp(_e: MouseEvent) {
if (col.active) {
col.active = false;
col.handle?.classList.remove('dragging');
if (viewRef)
commitColumnResize(viewRef, {
pmStart: col.tablePmStart,
colIdx: col.columnIndex,
newLeft: col.origWidths.left,
newRight: col.origWidths.right,
});
col.handle = null;
return;
}
if (row.active) {
row.active = false;
row.handle?.classList.remove('dragging');
if (viewRef)
commitRowResize(viewRef, {
pmStart: row.tablePmStart,
rowIdx: row.rowIndex,
newHeight: row.origHeight,
});
row.handle = null;
return;
}
if (edge.active) {
edge.active = false;
edge.handle?.classList.remove('dragging');
if (viewRef)
commitRightEdgeResize(viewRef, {
pmStart: edge.tablePmStart,
colIdx: edge.columnIndex,
newWidth: edge.origWidth,
});
edge.handle = null;
}
}
function install(): () => void {
document.addEventListener('mousemove', handleMove);
document.addEventListener('mouseup', handleUp);
return () => {
document.removeEventListener('mousemove', handleMove);
document.removeEventListener('mouseup', handleUp);
};
}
return { tryStartResize, install, isResizing };
}
// ─── FSM-state ⇆ core adapters ─────────────────────────────────────────────
// The pure PM readers/commits live in core (shared with React). These thin
// wrappers translate between Vue's FSM-state objects and core's explicit
// param shape, and own the DOM row-height estimate when the row has no
// stored height (matching React's FSM).
function seedColumnWidths(view: EditorView, col: ColumnResizeState) {
const w = readColumnWidths(view, col.tablePmStart, col.columnIndex);
if (w) col.origWidths = w;
}
function seedRowHeight(view: EditorView, row: RowResizeState, target: HTMLElement) {
const stored = readRowHeight(view, row.tablePmStart, row.rowIndex);
if (stored != null) {
row.origHeight = stored;
return;
}
// Estimate from rendered height when the row has no explicit height.
const tableEl = target.closest('.layout-table');
const rowEl = tableEl?.querySelector(`[data-row-index="${row.rowIndex}"]`);
const renderedHeight = rowEl ? (rowEl as HTMLElement).getBoundingClientRect().height : 30;
row.origHeight = Math.round(renderedHeight * TWIPS_PER_PIXEL);
}
function seedRightEdgeWidth(view: EditorView, edge: RightEdgeResizeState) {
const w = readColumnWidthAt(view, edge.tablePmStart, edge.columnIndex);
if (w != null) edge.origWidth = w;
}