diff --git a/packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts b/packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts index 1306e7919a1..5e9eb97b612 100644 --- a/packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts +++ b/packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts @@ -17,253 +17,6 @@ export type TableInfo = { rowButtonElement?: HTMLElement; }; -export const createColumnInsertButton = (editor: Editor, tableInfo: TableInfo): HTMLElement => { - const button = document.createElement("button"); - button.type = "button"; - button.className = "table-column-insert-button"; - button.title = "Insert columns"; - button.ariaLabel = "Insert columns"; - - const icon = document.createElement("span"); - icon.innerHTML = addSvg; - button.appendChild(icon); - - let mouseDownX = 0; - let isDragging = false; - let dragStarted = false; - let lastActionX = 0; - const DRAG_THRESHOLD = 5; // pixels to start drag - const ACTION_THRESHOLD = 150; // pixels total distance to trigger action - - const onMouseDown = (e: MouseEvent) => { - if (e.button !== 0) return; // Only left mouse button - - e.preventDefault(); - e.stopPropagation(); - - mouseDownX = e.clientX; - lastActionX = e.clientX; - isDragging = false; - dragStarted = false; - - document.addEventListener("mousemove", onMouseMove); - document.addEventListener("mouseup", onMouseUp); - }; - - const onMouseMove = (e: MouseEvent) => { - const deltaX = e.clientX - mouseDownX; - const distance = Math.abs(deltaX); - - // Start dragging if moved more than threshold - if (!isDragging && distance > DRAG_THRESHOLD) { - isDragging = true; - dragStarted = true; - - // Visual feedback - button.classList.add("dragging"); - document.body.style.userSelect = "none"; - } - - if (isDragging) { - const totalDistance = Math.abs(e.clientX - lastActionX); - - // Only trigger action when total distance reaches threshold - if (totalDistance >= ACTION_THRESHOLD) { - // Determine direction based on current movement relative to last action point - const directionFromLastAction = e.clientX - lastActionX; - - // Right direction - add columns - if (directionFromLastAction > 0) { - insertColumnAfterLast(editor, tableInfo); - lastActionX = e.clientX; // Reset action point - } - // Left direction - delete empty columns - else if (directionFromLastAction < 0) { - const deleted = removeLastColumn(editor, tableInfo); - if (deleted) { - lastActionX = e.clientX; // Reset action point - } - } - } - } - }; - - const onMouseUp = () => { - document.removeEventListener("mousemove", onMouseMove); - document.removeEventListener("mouseup", onMouseUp); - - if (isDragging) { - // Clean up drag state - button.classList.remove("dragging"); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - } else if (!dragStarted) { - // Handle as click if no dragging occurred - insertColumnAfterLast(editor, tableInfo); - } - - isDragging = false; - dragStarted = false; - }; - - button.addEventListener("mousedown", onMouseDown); - - // Prevent context menu and text selection - button.addEventListener("contextmenu", (e) => e.preventDefault()); - button.addEventListener("selectstart", (e) => e.preventDefault()); - - return button; -}; - -export const createRowInsertButton = (editor: Editor, tableInfo: TableInfo): HTMLElement => { - const button = document.createElement("button"); - button.type = "button"; - button.className = "table-row-insert-button"; - button.title = "Insert rows"; - button.ariaLabel = "Insert rows"; - - const icon = document.createElement("span"); - icon.innerHTML = addSvg; - button.appendChild(icon); - - let mouseDownY = 0; - let isDragging = false; - let dragStarted = false; - let lastActionY = 0; - const DRAG_THRESHOLD = 5; // pixels to start drag - const ACTION_THRESHOLD = 40; // pixels total distance to trigger action - - const onMouseDown = (e: MouseEvent) => { - if (e.button !== 0) return; // Only left mouse button - - e.preventDefault(); - e.stopPropagation(); - - mouseDownY = e.clientY; - lastActionY = e.clientY; - isDragging = false; - dragStarted = false; - - document.addEventListener("mousemove", onMouseMove); - document.addEventListener("mouseup", onMouseUp); - }; - - const onMouseMove = (e: MouseEvent) => { - const deltaY = e.clientY - mouseDownY; - const distance = Math.abs(deltaY); - - // Start dragging if moved more than threshold - if (!isDragging && distance > DRAG_THRESHOLD) { - isDragging = true; - dragStarted = true; - - // Visual feedback - button.classList.add("dragging"); - document.body.style.userSelect = "none"; - } - - if (isDragging) { - const totalDistance = Math.abs(e.clientY - lastActionY); - - // Only trigger action when total distance reaches threshold - if (totalDistance >= ACTION_THRESHOLD) { - // Determine direction based on current movement relative to last action point - const directionFromLastAction = e.clientY - lastActionY; - - // Down direction - add rows - if (directionFromLastAction > 0) { - insertRowAfterLast(editor, tableInfo); - lastActionY = e.clientY; // Reset action point - } - // Up direction - delete empty rows - else if (directionFromLastAction < 0) { - const deleted = removeLastRow(editor, tableInfo); - if (deleted) { - lastActionY = e.clientY; // Reset action point - } - } - } - } - }; - - const onMouseUp = () => { - document.removeEventListener("mousemove", onMouseMove); - document.removeEventListener("mouseup", onMouseUp); - - if (isDragging) { - // Clean up drag state - button.classList.remove("dragging"); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - } else if (!dragStarted) { - // Handle as click if no dragging occurred - insertRowAfterLast(editor, tableInfo); - } - - isDragging = false; - dragStarted = false; - }; - - button.addEventListener("mousedown", onMouseDown); - - // Prevent context menu and text selection - button.addEventListener("contextmenu", (e) => e.preventDefault()); - button.addEventListener("selectstart", (e) => e.preventDefault()); - - return button; -}; - -export const findAllTables = (editor: Editor): TableInfo[] => { - const tables: TableInfo[] = []; - const tableElements = editor.view.dom.querySelectorAll("table"); - - tableElements.forEach((tableElement) => { - // Find the table's ProseMirror position - let tablePos = -1; - let tableNode: ProseMirrorNode | null = null; - - // Walk through the document to find matching table nodes - editor.state.doc.descendants((node, pos) => { - if (node.type.spec.tableRole === "table") { - const domAtPos = editor.view.domAtPos(pos + 1); - let domTable = domAtPos.node; - - // Navigate to find the table element - while (domTable && domTable.parentNode && domTable.nodeType !== Node.ELEMENT_NODE) { - domTable = domTable.parentNode; - } - - while (domTable && domTable.parentNode && (domTable as HTMLElement).tagName !== "TABLE") { - domTable = domTable.parentNode; - } - - if (domTable === tableElement) { - tablePos = pos; - tableNode = node; - return false; // Stop iteration - } - } - }); - - if (tablePos !== -1 && tableNode) { - tables.push({ - tableElement, - tableNode, - tablePos, - }); - } - }); - - return tables; -}; - -const getCurrentTableInfo = (editor: Editor, tableInfo: TableInfo): TableInfo => { - // Refresh table info to get latest state - const tables = findAllTables(editor); - const updated = tables.find((t) => t.tableElement === tableInfo.tableElement); - return updated || tableInfo; -}; - // Column functions const insertColumnAfterLast = (editor: Editor, tableInfo: TableInfo) => { const currentTableInfo = getCurrentTableInfo(editor, tableInfo); @@ -291,14 +44,12 @@ const removeLastColumn = (editor: Editor, tableInfo: TableInfo): boolean => { const { tableNode, tablePos } = currentTableInfo; const tableMapData = TableMap.get(tableNode); - // Don't delete if only one column left if (tableMapData.width <= 1) { return false; } const lastColumnIndex = tableMapData.width - 1; - // Check if last column is empty if (!isColumnEmpty(currentTableInfo, lastColumnIndex)) { return false; } @@ -319,45 +70,6 @@ const removeLastColumn = (editor: Editor, tableInfo: TableInfo): boolean => { return true; }; -// Helper function to check if a single cell is empty -const isCellEmpty = (cell: ProseMirrorNode | null | undefined): boolean => { - if (!cell || cell.content.size === 0) { - return true; - } - - // Check if cell has any non-empty content - let hasContent = false; - cell.content.forEach((node) => { - if (node.type.name === "paragraph") { - if (node.content.size > 0) { - hasContent = true; - } - } else if (node.content.size > 0 || node.isText) { - hasContent = true; - } - }); - - return !hasContent; -}; - -const isColumnEmpty = (tableInfo: TableInfo, columnIndex: number): boolean => { - const { tableNode } = tableInfo; - const tableMapData = TableMap.get(tableNode); - - // Check each cell in the column - for (let row = 0; row < tableMapData.height; row++) { - const cellIndex = row * tableMapData.width + columnIndex; - const cellPos = tableMapData.map[cellIndex]; - const cell = tableNode.nodeAt(cellPos); - - if (!isCellEmpty(cell)) { - return false; - } - } - return true; -}; - -// Row functions const insertRowAfterLast = (editor: Editor, tableInfo: TableInfo) => { const currentTableInfo = getCurrentTableInfo(editor, tableInfo); const { tableNode, tablePos } = currentTableInfo; @@ -384,14 +96,12 @@ const removeLastRow = (editor: Editor, tableInfo: TableInfo): boolean => { const { tableNode, tablePos } = currentTableInfo; const tableMapData = TableMap.get(tableNode); - // Don't delete if only one row left if (tableMapData.height <= 1) { return false; } const lastRowIndex = tableMapData.height - 1; - // Check if last row is empty if (!isRowEmpty(currentTableInfo, lastRowIndex)) { return false; } @@ -412,11 +122,46 @@ const removeLastRow = (editor: Editor, tableInfo: TableInfo): boolean => { return true; }; +// Helper functions +const isCellEmpty = (cell: ProseMirrorNode | null | undefined): boolean => { + if (!cell || cell.content.size === 0) { + return true; + } + + let hasContent = false; + cell.content.forEach((node) => { + if (node.type.name === "paragraph") { + if (node.content.size > 0) { + hasContent = true; + } + } else if (node.content.size > 0 || node.isText) { + hasContent = true; + } + }); + + return !hasContent; +}; + +const isColumnEmpty = (tableInfo: TableInfo, columnIndex: number): boolean => { + const { tableNode } = tableInfo; + const tableMapData = TableMap.get(tableNode); + + for (let row = 0; row < tableMapData.height; row++) { + const cellIndex = row * tableMapData.width + columnIndex; + const cellPos = tableMapData.map[cellIndex]; + const cell = tableNode.nodeAt(cellPos); + + if (!isCellEmpty(cell)) { + return false; + } + } + return true; +}; + const isRowEmpty = (tableInfo: TableInfo, rowIndex: number): boolean => { const { tableNode } = tableInfo; const tableMapData = TableMap.get(tableNode); - // Check each cell in the row for (let col = 0; col < tableMapData.width; col++) { const cellIndex = rowIndex * tableMapData.width + col; const cellPos = tableMapData.map[cellIndex]; @@ -428,3 +173,243 @@ const isRowEmpty = (tableInfo: TableInfo, rowIndex: number): boolean => { } return true; }; + +type InsertDirection = "column" | "row"; + +interface InsertButtonConfig { + direction: InsertDirection; + className: string; + title: string; + ariaLabel: string; + dragThreshold: number; + actionThreshold: number; + coordinate: "clientX" | "clientY"; +} + +interface DragState { + mouseDownPosition: number; + isDragging: boolean; + dragStarted: boolean; + itemsAdded: number; + originalItemCount: number; +} + +interface InsertHandlers { + insertItem: (editor: Editor, tableInfo: TableInfo) => void; + removeItem: (editor: Editor, tableInfo: TableInfo) => boolean; + getItemCount: (tableNode: ProseMirrorNode) => number; + isEmpty: (tableInfo: TableInfo, itemIndex: number) => boolean; +} + +// Column handlers +const columnHandlers: InsertHandlers = { + insertItem: insertColumnAfterLast, + removeItem: removeLastColumn, + getItemCount: (tableNode) => TableMap.get(tableNode).width, + isEmpty: isColumnEmpty, +}; + +// Row handlers +const rowHandlers: InsertHandlers = { + insertItem: insertRowAfterLast, + removeItem: removeLastRow, + getItemCount: (tableNode) => TableMap.get(tableNode).height, + isEmpty: isRowEmpty, +}; + +// Configuration for different button types +const BUTTON_CONFIGS: Record = { + column: { + direction: "column", + className: "table-column-insert-button", + title: "Insert columns", + ariaLabel: "Insert columns", + dragThreshold: 5, + actionThreshold: 150, + coordinate: "clientX", + }, + row: { + direction: "row", + className: "table-row-insert-button", + title: "Insert rows", + ariaLabel: "Insert rows", + dragThreshold: 5, + actionThreshold: 40, + coordinate: "clientY", + }, +}; + +const createInsertButton = ( + editor: Editor, + tableInfo: TableInfo, + config: InsertButtonConfig, + handlers: InsertHandlers +): HTMLElement => { + const button = document.createElement("button"); + button.type = "button"; + button.className = config.className; + button.title = config.title; + button.ariaLabel = config.ariaLabel; + + const icon = document.createElement("span"); + icon.innerHTML = addSvg; + button.appendChild(icon); + + const dragState: DragState = { + mouseDownPosition: 0, + isDragging: false, + dragStarted: false, + itemsAdded: 0, + originalItemCount: 0, + }; + + const onMouseDown = (e: MouseEvent) => { + if (e.button !== 0) return; + + e.preventDefault(); + e.stopPropagation(); + + dragState.mouseDownPosition = e[config.coordinate]; + dragState.isDragging = false; + dragState.dragStarted = false; + + // Initialize with existing item count + const currentTableInfo = getCurrentTableInfo(editor, tableInfo); + dragState.originalItemCount = handlers.getItemCount(currentTableInfo.tableNode); + dragState.itemsAdded = dragState.originalItemCount; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }; + + const onMouseMove = (e: MouseEvent) => { + const delta = e[config.coordinate] - dragState.mouseDownPosition; + const distance = Math.abs(delta); + + if (!dragState.isDragging && distance > config.dragThreshold) { + dragState.isDragging = true; + dragState.dragStarted = true; + button.classList.add("dragging"); + document.body.style.userSelect = "none"; + } + + if (dragState.isDragging) { + const targetItems = calculateTargetItems(delta, dragState.originalItemCount, config.actionThreshold); + + // Add items if needed + while (dragState.itemsAdded < targetItems) { + handlers.insertItem(editor, tableInfo); + dragState.itemsAdded++; + } + + // Remove items if needed + while (dragState.itemsAdded > targetItems) { + const deleted = handlers.removeItem(editor, tableInfo); + if (deleted) { + dragState.itemsAdded--; + } else { + break; + } + } + } + }; + + const onMouseUp = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + + if (dragState.isDragging) { + button.classList.remove("dragging"); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + } else if (!dragState.dragStarted) { + handlers.insertItem(editor, tableInfo); + dragState.itemsAdded++; + } + + dragState.isDragging = false; + dragState.dragStarted = false; + }; + + button.addEventListener("mousedown", onMouseDown); + button.addEventListener("contextmenu", (e) => e.preventDefault()); + button.addEventListener("selectstart", (e) => e.preventDefault()); + + return button; +}; + +const calculateTargetItems = (delta: number, originalCount: number, actionThreshold: number): number => { + let targetItems = originalCount; + + if (delta > 0) { + // Moving in positive direction - add items + let itemsToAdd = 0; + while (itemsToAdd * actionThreshold + actionThreshold / 2 <= delta) { + itemsToAdd++; + } + targetItems = originalCount + itemsToAdd; + } else if (delta < 0) { + // Moving in negative direction - remove items + const distance = Math.abs(delta); + let itemsToRemove = 0; + while (itemsToRemove * actionThreshold + actionThreshold / 2 <= distance) { + itemsToRemove++; + } + targetItems = Math.max(1, originalCount - itemsToRemove); + } + + return targetItems; +}; + +export const createColumnInsertButton = (editor: Editor, tableInfo: TableInfo): HTMLElement => + createInsertButton(editor, tableInfo, BUTTON_CONFIGS.column, columnHandlers); + +export const createRowInsertButton = (editor: Editor, tableInfo: TableInfo): HTMLElement => + createInsertButton(editor, tableInfo, BUTTON_CONFIGS.row, rowHandlers); + +export const findAllTables = (editor: Editor): TableInfo[] => { + const tables: TableInfo[] = []; + const tableElements = editor.view.dom.querySelectorAll("table"); + + tableElements.forEach((tableElement) => { + let tablePos = -1; + let tableNode: ProseMirrorNode | null = null; + + editor.state.doc.descendants((node, pos) => { + if (node.type.spec.tableRole === "table") { + const domAtPos = editor.view.domAtPos(pos + 1); + let domTable = domAtPos.node; + + while (domTable && domTable.parentNode && domTable.nodeType !== Node.ELEMENT_NODE) { + domTable = domTable.parentNode; + } + + while (domTable && domTable.parentNode && (domTable as HTMLElement).tagName !== "TABLE") { + domTable = domTable.parentNode; + } + + if (domTable === tableElement) { + tablePos = pos; + tableNode = node; + return false; + } + } + }); + + if (tablePos !== -1 && tableNode) { + tables.push({ + tableElement, + tableNode, + tablePos, + }); + } + }); + + return tables; +}; + +const getCurrentTableInfo = (editor: Editor, tableInfo: TableInfo): TableInfo => { + const tables = findAllTables(editor); + const updated = tables.find((t) => t.tableElement === tableInfo.tableElement); + return updated || tableInfo; +};