import { zeroes } from "@heydovetail/array"; import { fsm2 } from "@heydovetail/ui-components"; import { Fragment, ResolvedPos } from "prosemirror-model"; import { EditorState, Plugin, Transaction } from "prosemirror-state"; import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; import { CssClassName, Data, PixelSize } from "../constants"; import { CellAttrs, EditorSchema } from "../schema"; import { DocNode, DocPos, DocPosOfCell, TableAxis, TableColumnIndex, TableNode, TablePosOfCell, TableRowIndex, TableStart } from "../types"; import * as dom from "../util/dom"; import { el } from "../util/dom"; import { PluginDescriptor } from "../util/PluginDescriptor"; import { CellSelection, normalizeSelection } from "./CellSelection"; import { clipCells, fitSlice, insertCells, pastedCells } from "./copypaste"; import { addColumn, addRow, deleteTable, fixTables, removeColumns, removeRows, toggleTableSelection } from "./operations"; import { TableMap } from "./TableMap"; import { TableView, updateColumns } from "./TableView"; import { axisSelectionForCell, cellAround, cellNearMouse, CellNearMouseStrategy, closestDomCell, closestTableFromSelection, editable, findTable, inSameTable, isInTable, pointsAtCell, selectionCell } from "./util"; const enum TableStateType { IDLE = "idle", COMPOSING_CELL_SELECTION = "composingCellSelection", COMPOSING_AXIS_CELL_SELECTION = "composingAxisCellSelection", DISPLAY_COLUMN_RESIZE_HANDLE = "displayColumnResizeHandle", RESIZING_COLUMN = "resizingColumn", DISPLAY_COLUMN_OR_ROW_INSERT_HANDLE = "displayColumnOrRowInsertHandle" } const enum CellEdge { TOP, RIGHT, BOTTOM, LEFT } /** * Nothing interesting is happening with the table, there's no column resizing * or cell selection composition occurring. */ interface IdleState { readonly type: TableStateType.IDLE; } interface ComposingCellSelectionState { readonly type: TableStateType.COMPOSING_CELL_SELECTION; // This piece of state is used to remember when a mouse-drag // cell-selection is happening, so that it can continue even as // transactions (which might move its anchor cell) come in. readonly anchor: DocPos; } interface ComposingAxisCellSelectionState { readonly type: TableStateType.COMPOSING_AXIS_CELL_SELECTION; readonly anchor: DocPos; readonly axis: TableAxis; } interface DisplayColumnResizeHandleState { readonly type: TableStateType.DISPLAY_COLUMN_RESIZE_HANDLE; readonly columnHandleLeftCellPos: DocPosOfCell; } interface ResizingColumnState { readonly type: TableStateType.RESIZING_COLUMN; readonly columnHandleLeftCellPos: DocPosOfCell; readonly startX: number; readonly startWidth: number; } /** * When the mouse is hovering a button that, when clicked, would insert a * column. */ interface DisplayColumnOrRowInsertHandleState { readonly type: TableStateType.DISPLAY_COLUMN_OR_ROW_INSERT_HANDLE; readonly tableStart: TableStart; readonly axis: TableAxis; readonly edgeIndex: number; } type PluginState = | IdleState | DisplayColumnResizeHandleState | ComposingCellSelectionState | ComposingAxisCellSelectionState | ResizingColumnState | DisplayColumnOrRowInsertHandleState; const idleState: IdleState = { type: TableStateType.IDLE }; const transition = fsm2.createTransition()({ composingAxisCellSelection: { tr: (prev, { tr }: { tr: Transaction }) => { const { deleted, pos } = tr.mapping.mapResult(prev.anchor); return deleted ? idleState : toAxisCellSelectingState(prev, prev.axis, pos as DocPosOfCell); }, toIdle: () => idleState }, composingCellSelection: { tr: (prev, { tr }: { tr: Transaction }) => { const { deleted, pos } = tr.mapping.mapResult(prev.anchor); return deleted ? idleState : toCellSelectingState(prev, pos as DocPosOfCell); }, toIdle: () => idleState }, displayColumnOrRowInsertHandle: { tr: (prev, { tr }: { tr: Transaction }) => { const { deleted, pos } = tr.mapping.mapResult(prev.tableStart); if (!deleted) { const tableStart = pos as TableStart; const table = findTable(tr.doc as DocNode, tableStart); if (table !== null) { const tableMap = TableMap.get(table); const newIndex = prev.axis === TableAxis.ROW ? Math.min(tableMap.height, prev.edgeIndex) : Math.min(tableMap.width, prev.edgeIndex); return toDisplayColumnOrRowInsertHandleState(prev, tableStart, prev.axis, newIndex); } } return idleState; }, toIdle: () => idleState }, displayColumnResizeHandle: { tr: (prev, { tr }: { tr: Transaction }) => { const { deleted, pos } = tr.mapping.mapResult(prev.columnHandleLeftCellPos); return deleted || !pointsAtCell(tr.doc.resolve(pos)) ? idleState : toDisplayColumnResizeHandleState(prev, { columnHandleLeftCellPos: pos as DocPosOfCell }); }, toIdle: () => idleState, withColumnHandleLeftCellPos: (prev, { columnHandleLeftCellPos }: { columnHandleLeftCellPos: DocPosOfCell }) => ({ ...prev, columnHandleLeftCellPos }) }, idle: { tr: (prev, _opts: { tr: Transaction }) => prev, toDisplayColumnResizeHandleState: (_, opts: { columnHandleLeftCellPos: DocPosOfCell }) => ({ type: TableStateType.DISPLAY_COLUMN_RESIZE_HANDLE, columnHandleLeftCellPos: opts.columnHandleLeftCellPos }) }, resizingColumn: { tr: (prev, { tr }: { tr: Transaction }) => { const { deleted, pos } = tr.mapping.mapResult(prev.columnHandleLeftCellPos); return deleted || !pointsAtCell(tr.doc.resolve(pos)) ? idleState : toResizingColumnState(prev, pos as DocPosOfCell, prev.startX, prev.startWidth); }, toIdle: () => idleState } }); function toResizingColumnState( _: DisplayColumnResizeHandleState | ResizingColumnState, columnHandleLeftCellPos: DocPosOfCell, startX: number, startWidth: number ): ResizingColumnState { return { type: TableStateType.RESIZING_COLUMN, columnHandleLeftCellPos, startX, startWidth }; } function toCellSelectingState(_: IdleState | ComposingCellSelectionState, anchor: DocPosOfCell): ComposingCellSelectionState { return { type: TableStateType.COMPOSING_CELL_SELECTION, anchor }; } function toAxisCellSelectingState( _: IdleState | ComposingAxisCellSelectionState, axis: TableAxis, anchor: DocPosOfCell ): ComposingAxisCellSelectionState { return { type: TableStateType.COMPOSING_AXIS_CELL_SELECTION, axis, anchor }; } function toDisplayColumnResizeHandleState( _: IdleState | DisplayColumnResizeHandleState, opts: { columnHandleLeftCellPos: DocPosOfCell } ): DisplayColumnResizeHandleState { return { type: TableStateType.DISPLAY_COLUMN_RESIZE_HANDLE, columnHandleLeftCellPos: opts.columnHandleLeftCellPos }; } function toDisplayColumnOrRowInsertHandleState( _: IdleState | DisplayColumnOrRowInsertHandleState, tableStart: TableStart, axis: TableAxis, edgeIndex: number ): DisplayColumnOrRowInsertHandleState { return { type: TableStateType.DISPLAY_COLUMN_OR_ROW_INSERT_HANDLE, tableStart, axis, edgeIndex }; } const { getPluginState, getPluginStateOrThrow, key, setPluginState } = new PluginDescriptor("TablePlugin"); /** * A [plugin](http://prosemirror.net/docs/ref/#state.Plugin) that, when added to * an editor, enables cell-selection, handles cell-based copy/paste, allows * column resizing, and makes sure tables stay well-formed (each row has the * same width, and cells don't overlap). * * You should probably put this plugin near the end of your array of plugins, * since it handles mouse and arrow key events in tables rather broadly, and * other plugins, like wedge, might want to get a turn first to perform more * specific behavior. */ export class TablePlugin extends Plugin { constructor({ columnEdgeHitZoneWidth = 10, cellMinWidth = PixelSize.TABLE_CELL_MIN_WIDTH } = {}) { let view: EditorView | undefined; super({ key, state: { init() { return idleState; }, apply(tr, cur: PluginState) { const nextPluginState = getPluginState(tr); return nextPluginState !== null ? nextPluginState : tr.docChanged ? transition(cur).tr({ tr }) : cur; } }, view: editorView => { view = editorView; return {}; }, props: { attributes(state) { if (view !== undefined && !editable(view)) { return; } const pluginState = getPluginStateOrThrow(state); return pluginState.type === TableStateType.DISPLAY_COLUMN_RESIZE_HANDLE ? { class: CssClassName.TABLE_RESIZE_CURSOR } : null; }, decorations(state) { if (view !== undefined && !editable(view)) { return; } const decorations: Decoration[] = []; const pluginState = getPluginStateOrThrow(state); // Gutters and pimples const tableDescriptor = closestTableFromSelection(state.selection); if (tableDescriptor != null) { const { node, start } = tableDescriptor; const tableMap = TableMap.get(node); decorations.push(...pimpleAndGutterDecorations(state, start, tableMap), betaLozengeDecorations(start, tableMap)); } decorations.push(...columnAndRowEdgeDecorations(state, pluginState), ...cellSelectionDecorations(state)); return decorations.length > 0 ? DecorationSet.create(state.doc, decorations) : null; }, handleDOMEvents: { mousedown: (view, event) => { if (!editable(view)) { return false; } const mouseEvent = event as MouseEvent; // Handlers have different priorities (i.e. opportunity to handle // the event) as sequenced here: return ( handleColumnResizeMouseDown(view, mouseEvent, cellMinWidth) || handleGutterMouseDown(view, mouseEvent) || handleCellSelectionMouseDown(view, mouseEvent) || handlePimpleMouseDown(view, mouseEvent) ); }, mousemove(view, event) { if (!editable(view)) { return false; } const mouseEvent = event as MouseEvent; return ( handleResizeColumnMouseMove(view, mouseEvent, columnEdgeHitZoneWidth, cellMinWidth) || handleInsertColumnMouseMove(view, mouseEvent) ); }, mouseleave(view) { if (!editable(view)) { return false; } handleMouseLeaveEditor(view); return false; } }, createSelectionBetween(view) { const pluginState = getPluginStateOrThrow(view.state); switch (pluginState.type) { case TableStateType.COMPOSING_AXIS_CELL_SELECTION: case TableStateType.COMPOSING_CELL_SELECTION: { // In these states we ProseMirror to ignore the selection changes // that occur in the DOM when the mouse is dragged, because we're // customising the selection (e.g. dragging cell selection). return view.state.selection; } case TableStateType.DISPLAY_COLUMN_OR_ROW_INSERT_HANDLE: case TableStateType.DISPLAY_COLUMN_RESIZE_HANDLE: case TableStateType.IDLE: case TableStateType.RESIZING_COLUMN: return; default: { const exhausted: never = pluginState; throw exhausted; } } }, handleTripleClick: (view, pos) => { if (!editable(view)) { return false; } const doc = view.state.doc; const $cell = cellAround(doc.resolve(pos)); if ($cell === null) { return false; } view.dispatch(view.state.tr.setSelection(new CellSelection($cell))); return true; }, handlePaste: (view, _, slice) => { if (!isInTable(view.state)) { return false; } let cells = pastedCells(slice); const sel = view.state.selection; if (sel instanceof CellSelection) { if (cells == null) { cells = { width: 1, height: 1, rows: [Fragment.from(fitSlice(view.state.schema.nodes.td, slice))] }; } const table = sel.$anchorCell.node(-1) as TableNode; const tableStart = sel.$anchorCell.start(-1) as TableStart; const rect = TableMap.get(table).rectBetween( (sel.$anchorCell.pos - tableStart) as TablePosOfCell, (sel.$headCell.pos - tableStart) as TablePosOfCell ); cells = clipCells(cells, rect.right - rect.left, rect.bottom - rect.top); insertCells(view.state, view.dispatch, tableStart, rect, cells); return true; } else if (cells != null) { const $cell = selectionCell(view.state); if ($cell === null) { return false; } const tableStart = $cell.start(-1) as TableStart; const table = $cell.node(-1) as TableNode; const cellPos = ($cell.pos - tableStart) as TablePosOfCell; insertCells(view.state, view.dispatch, tableStart, TableMap.get(table).findCell(cellPos), cells); return true; } else { return false; } }, nodeViews: { tbl: node => new TableView(node, cellMinWidth) } }, appendTransaction(_, oldState, state) { return normalizeSelection(state, fixTables(state, oldState)); } }); } } /** * Handle a `mousemove` event by performing column resizing if possible. * * @param view * @param mouseEvent `mousemove` DOM event. * @param columnEdgeHitZoneWidth Width of the "hit area" for a column handle (assuming * centered on a column edge). * @param cellMinWidth Minimum width of a cell (prevents resizing smaller than * this). */ function handleResizeColumnMouseMove( view: EditorView, mouseEvent: MouseEvent, columnEdgeHitZoneWidth: number, cellMinWidth: number ): boolean { // Don't show drag handle unless the selection is in the table. This avoids // visual distractions when moving the mouse pointer around the page. if (closestTableFromSelection(view.state.selection) === null) { return false; } const pluginState = getPluginStateOrThrow(view.state); switch (pluginState.type) { case TableStateType.RESIZING_COLUMN: displayColumnWidth( view, pluginState.columnHandleLeftCellPos, draggedWidth(pluginState.startX, pluginState.startWidth, mouseEvent, cellMinWidth), cellMinWidth ); break; case TableStateType.DISPLAY_COLUMN_RESIZE_HANDLE: { const columnEdgeHandleCellPos = cellPosForColumnEdge(view, mouseEvent, columnEdgeHitZoneWidth); if (columnEdgeHandleCellPos === null) { setPluginState(view, transition(pluginState).toIdle()); return true; } else if (columnEdgeHandleCellPos !== pluginState.columnHandleLeftCellPos) { // The mouse pointer has moved over a different column edge, so we // update the state to refer to the new column. setPluginState( view, transition(pluginState).withColumnHandleLeftCellPos({ columnHandleLeftCellPos: columnEdgeHandleCellPos }) ); return true; } // The mouse pointer is still over the same column edge, so the // state should not transition. break; } case TableStateType.IDLE: { const columnEdgeHandleCellPos = cellPosForColumnEdge(view, mouseEvent, columnEdgeHitZoneWidth); if (columnEdgeHandleCellPos !== null) { setPluginState( view, transition(pluginState).toDisplayColumnResizeHandleState({ columnHandleLeftCellPos: columnEdgeHandleCellPos }) ); return true; } break; } case TableStateType.COMPOSING_CELL_SELECTION: case TableStateType.COMPOSING_AXIS_CELL_SELECTION: case TableStateType.DISPLAY_COLUMN_OR_ROW_INSERT_HANDLE: // No transition is appropriate from these states. break; default: { const exhausted: never = pluginState; exhausted; } } return false; } function parseAxis(value: number): TableAxis | void { const castValue = value as TableAxis; switch (castValue) { case TableAxis.COLUMN: return TableAxis.COLUMN; case TableAxis.ROW: return TableAxis.ROW; default: { const exhausted: never = castValue; exhausted; } } } const enum PimpleType { INSERT_AXIS, DELETE_AXIS, DELETE_TABLE } interface AxisPimple { type: PimpleType.INSERT_AXIS | PimpleType.DELETE_AXIS; axis: TableAxis; index: number; tableStart: TableStart; } interface TablePimple { type: PimpleType.DELETE_TABLE; tableStart: TableStart; } type Pimple = AxisPimple | TablePimple; function pimpleType(element: HTMLElement): PimpleType | void { const { classList } = element; if (classList.contains(CssClassName.TABLE_PIMPLE_INSERT)) { return PimpleType.INSERT_AXIS; } else if (classList.contains(CssClassName.TABLE_PIMPLE_DELETE)) { return classList.contains(CssClassName.TABLE_PIMPLE_ORIGIN) ? PimpleType.DELETE_TABLE : PimpleType.DELETE_AXIS; } } function pimpleFromDom(element: HTMLElement): Pimple | void { const type = pimpleType(element); if (type !== undefined) { const tableStart = Number(element.dataset[Data.TABLE_PIMPLE_TABLE_START]) as TableStart; switch (type) { case PimpleType.DELETE_AXIS: case PimpleType.INSERT_AXIS: { const axis = parseAxis(Number(element.dataset[Data.TABLE_PIMPLE_AXIS])); const index = Number(element.dataset[Data.TABLE_PIMPLE_INDEX]); if (axis !== undefined) { return { type, axis, index, tableStart }; } break; } case PimpleType.DELETE_TABLE: { return { type, tableStart }; } default: { const exhausted: never = type; exhausted; } } } } const enum GutterType { AXIS, ORIGIN } interface AxisGutter { type: GutterType.AXIS; axis: TableAxis; index: number; tableStart: TableStart; } interface OriginGutter { type: GutterType.ORIGIN; } type Gutter = AxisGutter | OriginGutter; function gutterFromDom(element: HTMLElement): Gutter | void { if (element.classList.contains(CssClassName.TABLE_GUTTER_AXIS)) { const axis = parseAxis(Number(element.dataset[Data.TABLE_GUTTER_AXIS])); const index = Number(element.dataset[Data.TABLE_GUTTER_INDEX]); const tableStart = Number(element.dataset[Data.TABLE_GUTTER_TABLE_START]) as TableStart; if (axis !== undefined) { return { type: GutterType.AXIS, axis, index, tableStart }; } } else if (element.classList.contains(CssClassName.TABLE_GUTTER_ORIGIN)) { return { type: GutterType.ORIGIN }; } } function handleInsertColumnMouseMove(view: EditorView, mouseEvent: MouseEvent): boolean { const pluginState = getPluginStateOrThrow(view.state); const element = mouseEvent.target as HTMLElement; switch (pluginState.type) { case TableStateType.IDLE: { const pimple = pimpleFromDom(element); if (pimple !== undefined && pimple.type == PimpleType.INSERT_AXIS) { setPluginState(view, toDisplayColumnOrRowInsertHandleState(pluginState, pimple.tableStart, pimple.axis, pimple.index)); } break; } case TableStateType.DISPLAY_COLUMN_OR_ROW_INSERT_HANDLE: { const pimple = pimpleFromDom(element); if (pimple !== undefined && pimple.type == PimpleType.INSERT_AXIS) { if ( pluginState.tableStart !== pimple.tableStart || pluginState.axis !== pimple.axis || pluginState.edgeIndex !== pimple.index ) { setPluginState( view, toDisplayColumnOrRowInsertHandleState(pluginState, pimple.tableStart, pimple.axis, pimple.index) ); } } else { setPluginState(view, transition(pluginState).toIdle()); } break; } case TableStateType.COMPOSING_CELL_SELECTION: case TableStateType.COMPOSING_AXIS_CELL_SELECTION: case TableStateType.DISPLAY_COLUMN_RESIZE_HANDLE: case TableStateType.RESIZING_COLUMN: break; default: { const exhausted: never = pluginState; throw exhausted; } } return false; } function handleMouseLeaveEditor(view: EditorView) { const pluginState = getPluginStateOrThrow(view.state); switch (pluginState.type) { case TableStateType.DISPLAY_COLUMN_RESIZE_HANDLE: case TableStateType.RESIZING_COLUMN: case TableStateType.DISPLAY_COLUMN_OR_ROW_INSERT_HANDLE: { setPluginState(view, transition(pluginState).toIdle()); break; } case TableStateType.IDLE: // Idle -> Idle is a no-op. case TableStateType.COMPOSING_AXIS_CELL_SELECTION: case TableStateType.COMPOSING_CELL_SELECTION: // It's a nice UX to **stay** in this state, as it allows exaggerated // dragging with the mouse to create a cell selection. Transitioning to // idle here ends up feeling like the interaction "breaks" while dragging. break; default: { const exhausted: never = pluginState; exhausted; } } } function handlePimpleMouseDown(view: EditorView, mouseEvent: MouseEvent): boolean { const eventTarget = mouseEvent.target as HTMLElement; const pimple = pimpleFromDom(eventTarget); if (pimple !== undefined) { const { tableStart } = pimple; const table = findTable(view.state.doc as DocNode, tableStart); if (table !== null) { const tableMap = TableMap.get(table); const tr = view.state.tr; switch (pimple.type) { case PimpleType.INSERT_AXIS: { const { index, axis } = pimple; switch (axis) { case TableAxis.ROW: { addRow(tr, table, tableStart, tableMap, index); break; } case TableAxis.COLUMN: { addColumn(tr, table, tableStart, tableMap, index); break; } default: { const exhausted: never = axis; exhausted; } } break; } case PimpleType.DELETE_AXIS: { const { index, axis } = pimple; switch (axis) { case TableAxis.ROW: { removeRows(tr, tableStart, index as TableRowIndex); break; } case TableAxis.COLUMN: { removeColumns(tr, tableStart, index as TableColumnIndex); break; } default: { const exhausted: never = axis; exhausted; } } break; } case PimpleType.DELETE_TABLE: { deleteTable(tr, tableStart); break; } default: { const exhausted: never = pimple; exhausted; } } mouseEvent.preventDefault(); view.dispatch(tr); return true; } } return false; } /** * Editor `mousedown` handler responsible for determining if a gutter was * clicked, and reacting to it appropriately. * * Returns `true` if the event was handled. * * @param view * @param mouseEvent */ function handleGutterMouseDown(view: EditorView, mouseEvent: MouseEvent): boolean { const gutter = gutterFromDom(mouseEvent.target as HTMLElement); if (gutter !== undefined) { const $anchor = cellNearMouse(view, mouseEvent, CellNearMouseStrategy.DOM_HIERARCHY); if ($anchor !== null) { const tr = view.state.tr; switch (gutter.type) { case GutterType.AXIS: { if (mouseEvent.shiftKey) { } else { tr.setSelection(axisSelectionForCell($anchor, gutter.axis)); } break; } case GutterType.ORIGIN: { const table = $anchor.node(-1) as TableNode; const tableStart = $anchor.start(-1) as TableStart; toggleTableSelection(tr, table, tableStart); break; } default: { const exhausted: never = gutter; throw exhausted; } } view.dispatch(tr); mouseEvent.preventDefault(); return true; } } return false; } function handleCellSelectionMouseDown(view: EditorView, mouseEvent: MouseEvent): boolean { if (mouseEvent.ctrlKey || mouseEvent.metaKey) { return false; } const startDomCell = closestDomCell(view, mouseEvent.target as HTMLElement); if (startDomCell === null) { return false; } let $anchor; if (mouseEvent.shiftKey && view.state.selection instanceof CellSelection) { // When there's already a cell selection, shift clicking on a cell updates // the head cell to the one clicked. setCellSelection(view.state.selection.$anchorCell, mouseEvent); mouseEvent.preventDefault(); } else if ( mouseEvent.shiftKey && ($anchor = cellAround(view.state.selection.$anchor)) !== null && cellNearMouse(view, mouseEvent)!.pos != $anchor.pos ) { // Adding to a non-cell-selection that starts in another cell (causing a // cell selection to be created). setCellSelection($anchor, mouseEvent); mouseEvent.preventDefault(); } // Create and dispatch a cell selection between the given anchor and // the position under the mouse. function setCellSelection($anchor: ResolvedPos, event: MouseEvent) { let $head = cellNearMouse(view, event); const pluginState = getPluginStateOrThrow(view.state); if ($head === null || !inSameTable($anchor, $head)) { if (pluginState.type === TableStateType.IDLE) { $head = $anchor; } else { return false; } } const selection = new CellSelection($anchor, $head); if (pluginState.type === TableStateType.IDLE || !view.state.selection.eq(selection)) { view.dispatch(view.state.tr.setSelection(selection)); if (pluginState.type === TableStateType.IDLE) { setPluginState(view, toCellSelectingState(pluginState, $anchor.pos as DocPosOfCell)); } } return false; } // Stop listening to mouse motion events. function stop() { // HACK: TypeScript gets confused when using the real Document | // DocumentFragment type. Since DocumentFragment is a light-weight version // of DocumentFragment, it's the better choice to assume. const root = view.root as DocumentFragment; root.removeEventListener("mouseup", stop); root.removeEventListener("dragstart", stop); root.removeEventListener("mousemove", handleMouseMove); const pluginState = getPluginStateOrThrow(view.state); if (pluginState.type === TableStateType.COMPOSING_CELL_SELECTION) { setPluginState(view, transition(pluginState).toIdle()); } } function handleMouseMove(event: Event) { const pluginState = getPluginStateOrThrow(view.state); let $anchor; if (pluginState.type === TableStateType.COMPOSING_CELL_SELECTION) { // Continuing an existing cross-cell selection $anchor = view.state.doc.resolve(pluginState.anchor); } else if (pluginState.type === TableStateType.IDLE && closestDomCell(view, event.target as HTMLElement) != startDomCell) { // Moving out of the initial cell -- start a new cell selection // TODO: WTF $anchor = cellNearMouse(view, mouseEvent); if ($anchor === null) { stop(); return; } } if ($anchor !== undefined) { setCellSelection($anchor, event as MouseEvent); } } const root = view.root as DocumentFragment; root.addEventListener("mouseup", stop); root.addEventListener("dragstart", stop); root.addEventListener("mousemove", handleMouseMove); return false; } function handleColumnResizeMouseDown(view: EditorView, event: MouseEvent, cellMinWidth: number): boolean { const state = getPluginStateOrThrow(view.state); if (state.type != TableStateType.DISPLAY_COLUMN_RESIZE_HANDLE) { return false; } const cell = view.state.doc.nodeAt(state.columnHandleLeftCellPos); if (cell == null) { return false; } const width = currentColWidth(view, state.columnHandleLeftCellPos, cell.attrs as CellAttrs); setPluginState(view, toResizingColumnState(state, state.columnHandleLeftCellPos, event.clientX, width)); function finish(event: MouseEvent) { if (typeof window !== "undefined") { window.removeEventListener("mouseup", finish); window.removeEventListener("mousemove", onMousemove); } const pluginState = getPluginStateOrThrow(view.state); if (pluginState.type === TableStateType.RESIZING_COLUMN) { updateColumnWidth( view, pluginState.columnHandleLeftCellPos, draggedWidth(pluginState.startX, pluginState.startWidth, event, cellMinWidth) ); setPluginState(view, transition(pluginState).toIdle()); } } function onMousemove(event: MouseEvent) { if (event.which === 0) { finish(event); } } if (typeof window !== "undefined") { window.addEventListener("mouseup", finish); window.addEventListener("mousemove", onMousemove); } event.preventDefault(); return true; } function currentColWidth(view: EditorView, cellPos: number, { colspan, colwidth }: CellAttrs) { const width = colwidth != null ? colwidth[colwidth.length - 1] : null; if (width !== null) { return width; } // Not fixed, read current width from DOM // HACK let domWidth = (view.domAtPos(cellPos + 1).node as HTMLElement).offsetWidth; let parts = colspan; if (colwidth != null) { for (let i = 0; i < colspan; i++) { if (colwidth[i] > 0) { domWidth -= colwidth[i]; parts--; } } } return domWidth / parts; } /** * If the mouse is hovering a column edge, return the position of the cell to * the left of the edge. */ function cellPosForColumnEdge(view: EditorView, mouseEvent: MouseEvent, columnEdgeHitZoneWidth: number): DocPosOfCell | null { const domCell = closestDomCell(view, mouseEvent.target as dom.Node); if (domCell === null) { return null; } // We know the mouse pointer is over a cell, now we need to determine // if it overlaps the left or right (within the hit zone) to be // considered as hovering the column edge. // // If the pointer is over the right edge, we want the position of the // current cell, if it's over the left edge we want the position of // the previous cell. // // This approach elegantly avoids the left edge of the table from // being dragged. // Only half the column edge hit zone overlaps with an individual cell. const cellEdgeHitZoneWidth = columnEdgeHitZoneWidth / 2; // Use the dimensions of the cell and the position of the mouse pointer to // determine if the pointer is overlapping an edge hit zone. const { left, right } = domCell.getBoundingClientRect(); const columnEdgeSide = mouseEvent.clientX - left <= cellEdgeHitZoneWidth ? CellEdge.LEFT : right - mouseEvent.clientX <= cellEdgeHitZoneWidth ? CellEdge.RIGHT : null; if (columnEdgeSide === null) { // The mouse pointer isn’t close-enough to either side to be considered a hit // with the column edge. return null; } const pos = view.posAtCoords({ left: mouseEvent.clientX, top: mouseEvent.clientY }); if (pos == null) { return null; } const $cell = cellAround(view.state.doc.resolve(pos.pos)); if ($cell === null) { return null; } else if (columnEdgeSide == CellEdge.RIGHT) { return $cell.pos as DocPosOfCell; } else { const table = $cell.node(-1) as TableNode; const map = TableMap.get(table); const tableStart = $cell.start(-1); const cellPos = ($cell.pos - tableStart) as TablePosOfCell; const index = map.map.indexOf(cellPos); return index % map.width == 0 ? null : ((tableStart + map.map[index - 1]) as DocPosOfCell); } } function draggedWidth(startX: number, startWidth: number, event: MouseEvent, cellMinWidth: number) { const offset = event.clientX - startX; return Math.max(cellMinWidth, startWidth + offset); } function updateColumnWidth(view: EditorView, cell: DocPos, width: number) { const $cell = view.state.doc.resolve(cell); const table = $cell.node(-1) as TableNode; const map = TableMap.get(table); const tableStart = $cell.start(-1) as TableStart; const cellPos = ($cell.pos - tableStart) as TablePosOfCell; const col = map.columnIndex(cellPos) + $cell.nodeAfter!.attrs.colspan - 1; const tr = view.state.tr; for (let row = 0; row < map.height; row++) { const mapIndex = row * map.width + col; // Rowspanning cell that has already been handled if (row > 0 && map.map[mapIndex] == map.map[mapIndex - map.width]) { continue; } const pos = map.map[mapIndex]; const attrs = table.nodeAt(pos)!.attrs as CellAttrs; const index = attrs.colspan == 1 ? 0 : col - map.columnIndex(pos); if (attrs.colwidth != null && attrs.colwidth[index] == width) { continue; } const colwidth = attrs.colwidth != null ? attrs.colwidth.slice() : zeroes(attrs.colspan); colwidth[index] = width; tr.setNodeMarkup(tableStart + pos, undefined, { ...attrs, colwidth: colwidth }); } if (tr.docChanged) { view.dispatch(tr); } } function displayColumnWidth(view: EditorView, cell: DocPos, width: number, cellMinWidth: number): void { const $cell = view.state.doc.resolve(cell); const table = $cell.node(-1) as TableNode; const tableStart = $cell.start(-1) as TableStart; const cellPos = ($cell.pos - tableStart) as TablePosOfCell; const cellAfter = $cell.nodeAfter; if (cellAfter != null) { const col = TableMap.get(table).columnIndex(cellPos) + cellAfter.attrs.colspan - 1; let dom: dom.Node | null = view.domAtPos($cell.start(-1)).node; while (dom !== null && dom.nodeName != "TABLE") { dom = dom.parentNode; } if (dom !== null) { updateColumns(table, dom.firstChild! as HTMLElement, dom as HTMLTableElement, cellMinWidth, col, width); } } } /** * Create decorations for highlighting a column edge. */ function columnEdgeHighlightDecorations( $cell: ResolvedPos, edge: CellEdge.LEFT | CellEdge.RIGHT, table = $cell.node(-1) as TableNode, tableStart = $cell.start(-1) as TableStart, tableMap = TableMap.get(table) ): Decoration[] { const decorations = []; const cellPos = ($cell.pos - tableStart) as TablePosOfCell; const columnIndex = tableMap.columnIndex(cellPos); for (let row = 0; row < tableMap.height; row++) { const cellIndex = columnIndex + row * tableMap.width; const localCellPos = tableMap.map[cellIndex]; // Gracefully handle merged cells, by only adding a decoration to them once. // // Add a decoration for positions that have: // // - (edge=RIGHT): the right of the table or a different cell (not merged), and // - (edge=LEFT): the left of the table or a different cell (not merged), and // - above them, the top of the table or a different cell if ( (edge === CellEdge.RIGHT ? columnIndex == tableMap.width || localCellPos != tableMap.map[cellIndex + 1] : columnIndex == 0 || localCellPos != tableMap.map[cellIndex - 1]) && (row == 0 || localCellPos != tableMap.map[cellIndex - tableMap.width - 1]) ) { const widgetPos = tableStart + localCellPos + 1; decorations.push( Decoration.widget( widgetPos, el( "div", edge === CellEdge.RIGHT ? CssClassName.TABLE_COLUMN_EDGE_HIGHLIGHT_RIGHT : CssClassName.TABLE_COLUMN_EDGE_HIGHLIGHT_LEFT ) ) ); } } return decorations; } /** * Create decorations for highlighting a column edge. */ function rowEdgeHighlightDecorations( $cell: ResolvedPos, edge: CellEdge.TOP | CellEdge.BOTTOM, table = $cell.node(-1) as TableNode, tableStart = $cell.start(-1) as TableStart, tableMap = TableMap.get(table) ): Decoration[] { const decorations = []; const cellPos = ($cell.pos - tableStart) as TablePosOfCell; const rowIndex = tableMap.rowIndex(cellPos); for (let column = 0; column < tableMap.width; column++) { const cellIndex = column + rowIndex * tableMap.width; const localCellPos = tableMap.map[cellIndex]; const cell = table.nodeAt(localCellPos)!; // Gracefully handle merged cells, by only adding a decoration to them once. // // Add a decoration for positions that have: // // - (edge=BOTTOM): below them, the bottom of the table or a different cell // (not merged), and // - (edge=TOP): above them, the top of the table or a different cell, and // - to their left, the left of the table or a different cell if ( (edge === CellEdge.BOTTOM ? rowIndex == tableMap.height || localCellPos != tableMap.map[cellIndex + tableMap.width] : rowIndex == 0 || localCellPos != tableMap.map[cellIndex - tableMap.width]) && (column == 0 || localCellPos != tableMap.map[cellIndex - 1]) ) { const widgetPos = tableStart + localCellPos + cell.nodeSize - 1; decorations.push( Decoration.widget( widgetPos, el( "div", edge === CellEdge.BOTTOM ? CssClassName.TABLE_ROW_EDGE_HIGHLIGHT_BOTTOM : CssClassName.TABLE_ROW_EDGE_HIGHLIGHT_TOP ) ) ); } } return decorations; } /** * Build a DOM node for use as an insert pimple widget decoration. */ function domForInsertPimple(tableStart: TableStart, edge: CellEdge, index: number): HTMLDivElement { const dom = el( "div", CssClassName.TABLE_PIMPLE_INSERT, edge === CellEdge.TOP ? CssClassName.TABLE_ROW_PIMPLE_TOP : edge === CellEdge.BOTTOM ? CssClassName.TABLE_ROW_PIMPLE_BOTTOM : edge === CellEdge.LEFT ? CssClassName.TABLE_COLUMN_PIMPLE_LEFT : CssClassName.TABLE_COLUMN_PIMPLE_RIGHT ); const tableAxis = edge === CellEdge.TOP || edge === CellEdge.BOTTOM ? TableAxis.ROW : TableAxis.COLUMN; dom.dataset[Data.TABLE_PIMPLE_TABLE_START] = `${tableStart}`; dom.dataset[Data.TABLE_PIMPLE_AXIS] = `${tableAxis}`; dom.dataset[Data.TABLE_PIMPLE_INDEX] = `${index}`; dom.title = `Insert ${tableAxis === TableAxis.COLUMN ? "column" : "row"}`; return dom; } function domForDeletePimple(tableStart: TableStart, axis: TableAxis, index: number): HTMLDivElement { const dom = el( "div", CssClassName.TABLE_PIMPLE_DELETE, axis === TableAxis.COLUMN ? CssClassName.TABLE_COLUMN_PIMPLE : CssClassName.TABLE_ROW_PIMPLE ); dom.dataset[Data.TABLE_PIMPLE_TABLE_START] = `${tableStart}`; dom.dataset[Data.TABLE_PIMPLE_AXIS] = `${axis}`; dom.dataset[Data.TABLE_PIMPLE_INDEX] = `${index}`; dom.title = `Delete ${axis === TableAxis.COLUMN ? "column" : "row"}`; return dom; } function domForTableDeletePimple(tableStart: TableStart): HTMLDivElement { const dom = el("div", CssClassName.TABLE_PIMPLE_DELETE, CssClassName.TABLE_PIMPLE_ORIGIN); dom.dataset[Data.TABLE_PIMPLE_TABLE_START] = `${tableStart}`; dom.title = "Delete table"; return dom; } function domForAxisGutter(tableStart: TableStart, axis: TableAxis, index: number, isSelected: boolean): HTMLDivElement { const dom = el( "div", CssClassName.TABLE_GUTTER_AXIS, axis === TableAxis.COLUMN ? CssClassName.TABLE_GUTTER_AXIS_COLUMN : CssClassName.TABLE_GUTTER_AXIS_ROW, isSelected ? CssClassName.TABLE_GUTTER_SELECTED : null ); dom.dataset[Data.TABLE_GUTTER_TABLE_START] = `${tableStart}`; dom.dataset[Data.TABLE_GUTTER_AXIS] = `${axis}`; dom.dataset[Data.TABLE_GUTTER_INDEX] = `${index}`; return dom; } /** * Column and row edge decorations for a given state. */ function columnAndRowEdgeDecorations(state: EditorState, pluginState: PluginState): Decoration[] { const decorations = []; // Column resize handle if (typeof document !== "undefined") { switch (pluginState.type) { case TableStateType.DISPLAY_COLUMN_RESIZE_HANDLE: case TableStateType.RESIZING_COLUMN: { const $cell = state.doc.resolve(pluginState.columnHandleLeftCellPos); decorations.push(...columnEdgeHighlightDecorations($cell, CellEdge.RIGHT)); break; } case TableStateType.DISPLAY_COLUMN_OR_ROW_INSERT_HANDLE: { const { tableStart } = pluginState; const table = findTable(state.doc as DocNode, tableStart); if (table !== null) { const tableMap = TableMap.get(table); const { edgeIndex, tableStart } = pluginState; switch (pluginState.axis) { case TableAxis.COLUMN: { const columnIndex = Math.min(edgeIndex, tableMap.width - 1); const $cell = state.doc.resolve(tableStart + tableMap.map[columnIndex]); const cellEdge = edgeIndex > columnIndex ? CellEdge.RIGHT : CellEdge.LEFT; decorations.push(...columnEdgeHighlightDecorations($cell, cellEdge, table, tableStart, tableMap)); break; } case TableAxis.ROW: { const rowIndex = Math.min(edgeIndex, tableMap.height - 1); const $cell = state.doc.resolve(tableStart + tableMap.map[rowIndex * tableMap.width]); const cellEdge = edgeIndex > rowIndex ? CellEdge.BOTTOM : CellEdge.TOP; decorations.push(...rowEdgeHighlightDecorations($cell, cellEdge, table, tableStart, tableMap)); break; } default: { const exhausted: never = pluginState.axis; void exhausted; } } } break; } case TableStateType.IDLE: case TableStateType.COMPOSING_AXIS_CELL_SELECTION: case TableStateType.COMPOSING_CELL_SELECTION: break; default: { const exhausted: never = pluginState; void exhausted; } } } return decorations; } /** * Cell selection decorations for a state. */ function cellSelectionDecorations(state: EditorState): Decoration[] { const decorations: Decoration[] = []; if (state.selection instanceof CellSelection) { state.selection.forEachCell((node, pos) => { decorations.push(Decoration.node(pos, pos + node.nodeSize, { class: CssClassName.TABLE_SELECTED_CELL })); }); } return decorations; } /** * Beta lozenge decorations */ function betaLozengeDecorations(tableStart: TableStart, tableMap: TableMap): Decoration { const cellPos = tableMap.map[tableMap.map.length - 1]; return Decoration.widget(tableStart + cellPos + 1, el("div", CssClassName.TABLE_BETA_LOZENGE)); } function pimpleAndGutterDecorations(state: EditorState, tableStart: TableStart, map: TableMap): Decoration[] { const decorations = []; const cellSelection = state.selection instanceof CellSelection ? state.selection : null; const isAnyAxisSelected = cellSelection !== null && (cellSelection.isColSelection() || cellSelection.isRowSelection()); const selectionEdgeRect = cellSelection !== null ? map.rectBetween( (cellSelection.$anchorCell.pos - tableStart) as TablePosOfCell, (cellSelection.$headCell.pos - tableStart) as TablePosOfCell ) : null; const isTotalSelection = selectionEdgeRect !== null && selectionEdgeRect.left === 0 && selectionEdgeRect.right === map.width && selectionEdgeRect.top === 0 && selectionEdgeRect.bottom === map.height; // Add top gutter and pimples for (let i = 0; i < map.width; i++) { const cellPos = tableStart + map.map[i]; const widgetPos = cellPos + 1; const isColumnSelected = selectionEdgeRect !== null && selectionEdgeRect.top === 0 && selectionEdgeRect.bottom === map.height && i >= selectionEdgeRect.left && i < selectionEdgeRect.right; // Insert pimple if (!isAnyAxisSelected) { decorations.push(Decoration.widget(widgetPos, domForInsertPimple(tableStart, CellEdge.LEFT, i))); } // Delete pimple if (isColumnSelected) { decorations.push(Decoration.widget(widgetPos, domForDeletePimple(tableStart, TableAxis.COLUMN, i))); } // Gutter decorations.push(Decoration.widget(widgetPos, domForAxisGutter(tableStart, TableAxis.COLUMN, i, isColumnSelected))); } // Table right edge pimple if (!isAnyAxisSelected) { const cellPos = tableStart + map.map[map.width - 1]; const widgetPos = cellPos + 1; decorations.push(Decoration.widget(widgetPos, domForInsertPimple(tableStart, CellEdge.RIGHT, map.width))); } // Add left gutter and pimples for (let i = 0; i < map.height; i++) { const cellPos = tableStart + map.map[i * map.width]; const widgetPos = cellPos + 1; const isRowSelected = selectionEdgeRect !== null && selectionEdgeRect.left === 0 && selectionEdgeRect.right === map.width && i >= selectionEdgeRect.top && i < selectionEdgeRect.bottom; // Insert pimple if (!isAnyAxisSelected) { decorations.push(Decoration.widget(widgetPos, domForInsertPimple(tableStart, CellEdge.TOP, i))); } // Delete pimple if (isRowSelected) { decorations.push(Decoration.widget(widgetPos, domForDeletePimple(tableStart, TableAxis.ROW, i))); } // Gutter decorations.push(Decoration.widget(widgetPos, domForAxisGutter(tableStart, TableAxis.ROW, i, isRowSelected))); } // Bottom row pimple if (!isAnyAxisSelected) { const cellPos = tableStart + map.map[(map.height - 1) * map.width]; const widgetPos = cellPos + 1; decorations.push(Decoration.widget(widgetPos, domForInsertPimple(tableStart, CellEdge.BOTTOM, map.height))); } { // Add origin gutter const cellPos = tableStart + map.map[0]; const widgetPos = cellPos + 1; decorations.push( Decoration.widget( widgetPos, el("div", CssClassName.TABLE_GUTTER_ORIGIN, isTotalSelection ? CssClassName.TABLE_GUTTER_SELECTED : null) ) ); if (isTotalSelection) { decorations.push(Decoration.widget(widgetPos, domForTableDeletePimple(tableStart))); } } return decorations; }