Compare commits

...

2 Commits

Author SHA1 Message Date
Aaryan Khandelwal
3379e21b97 refactor: drag logic 2025-07-12 17:06:38 +05:30
Aaryan Khandelwal
18ba1c0f22 chore: smooth insert handles 2025-07-12 16:57:18 +05:30

View File

@@ -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<InsertDirection, InsertButtonConfig> = {
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;
};