From c9a23d5b2127fc99631ef10422e9d602e485e76e Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Thu, 22 Jan 2026 14:06:18 +0530 Subject: [PATCH 1/7] refactor: migrate table drag handles from react to vanilla js --- .../column/drag-handle-vanilla.ts | 454 ++++++++++++++++++ .../plugins/drag-handles/column/plugin.ts | 46 +- .../plugins/drag-handles/dropdown-content.ts | 179 +++++++ .../table/plugins/drag-handles/icons.ts | 49 ++ .../drag-handles/row/drag-handle-vanilla.ts | 453 +++++++++++++++++ .../table/plugins/drag-handles/row/plugin.ts | 46 +- 6 files changed, 1181 insertions(+), 46 deletions(-) create mode 100644 packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle-vanilla.ts create mode 100644 packages/editor/src/core/extensions/table/plugins/drag-handles/dropdown-content.ts create mode 100644 packages/editor/src/core/extensions/table/plugins/drag-handles/icons.ts create mode 100644 packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle-vanilla.ts diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle-vanilla.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle-vanilla.ts new file mode 100644 index 00000000000..7bc9607f161 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle-vanilla.ts @@ -0,0 +1,454 @@ +import { computePosition, flip, shift, autoUpdate } from "@floating-ui/dom"; +import type { Editor } from "@tiptap/core"; +import { TableMap } from "@tiptap/pm/tables"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// icons +import { DRAG_HANDLE_ICONS, createSvgElement } from "../icons"; +// dropdown +import { createDropdownContent, handleDropdownAction } from "../dropdown-content"; +import type { DropdownOption } from "../dropdown-content"; +// extensions +import { + findTable, + getTableHeightPx, + getTableWidthPx, + isCellSelection, + selectColumn, + getSelectedColumns, +} from "@/extensions/table/table/utilities/helpers"; +// local imports +import { moveSelectedColumns, duplicateColumns } from "../actions"; +import { + DROP_MARKER_THICKNESS, + getDropMarker, + getColDragMarker, + hideDragMarker, + hideDropMarker, + updateColDragMarker, + updateColDropMarker, +} from "../marker-utils"; +import { updateCellContentVisibility } from "../utils"; +import { calculateColumnDropIndex, constructColumnDragPreview, getTableColumnNodesInfo } from "./utils"; + +export type ColumnDragHandleConfig = { + editor: Editor; + col: number; +}; + +/** + * Creates a vanilla JS column drag handle element with dropdown functionality + */ +export function createColumnDragHandle(config: ColumnDragHandleConfig): { + element: HTMLElement; + destroy: () => void; +} { + const { editor, col } = config; + + // Create container + const container = document.createElement("div"); + container.className = + "table-col-handle-container absolute z-20 top-0 left-0 flex justify-center items-center w-full -translate-y-1/2"; + + // Create button + const button = document.createElement("button"); + button.type = "button"; + button.className = + "py-1 bg-layer-1 border border-strong-1 rounded-sm outline-none transition-all duration-200 hover:bg-layer-1-hover"; + + // Create icon (Ellipsis lucide icon as SVG) + const icon = createSvgElement(DRAG_HANDLE_ICONS.ellipsis, "size-4 text-primary"); + + button.appendChild(icon); + container.appendChild(button); + + // State for dropdown + let isDropdownOpen = false; + let dropdownElement: HTMLElement | null = null; + let backdropElement: HTMLElement | null = null; + let cleanupFloating: (() => void) | null = null; + + // Track drag event listeners for cleanup + let dragListeners: { + mouseup?: (e: MouseEvent) => void; + mousemove?: (e: MouseEvent) => void; + } = {}; + + // Dropdown toggle function + const toggleDropdown = () => { + if (isDropdownOpen) { + closeDropdown(); + } else { + openDropdown(); + } + }; + + const closeDropdown = () => { + if (!isDropdownOpen) return; + + isDropdownOpen = false; + + // Reset button to closed state + button.className = + "px-1 bg-layer-1 border border-strong-1 rounded-sm outline-none transition-all duration-200 hover:bg-layer-1-hover"; + + // Remove dropdown and backdrop + if (dropdownElement) { + dropdownElement.remove(); + dropdownElement = null; + } + if (backdropElement) { + backdropElement.remove(); + backdropElement = null; + } + + // Cleanup floating UI + if (cleanupFloating) { + cleanupFloating(); + cleanupFloating = null; + } + + // Remove active dropdown extension + setTimeout(() => { + editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE); + }, 0); + }; + + const openDropdown = () => { + if (isDropdownOpen) return; + + isDropdownOpen = true; + + // Update button to open state + button.className = + "px-1 bg-layer-1 border border-strong-1 rounded-sm outline-none transition-all duration-200 opacity-100 bg-accent-primary border-accent-strong"; + + // Add active dropdown extension + editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE); + + // Create backdrop + backdropElement = document.createElement("div"); + backdropElement.style.cssText = "position: fixed; inset: 0; z-index: 99;"; + backdropElement.addEventListener("click", closeDropdown); + document.body.appendChild(backdropElement); + + // Create dropdown + dropdownElement = document.createElement("div"); + dropdownElement.className = + "max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 shadow-raised-200"; + dropdownElement.style.cssText = "position: fixed; z-index: 100;"; + + // Create and append dropdown content + const content = createDropdownContent(getDropdownOptions()); + dropdownElement.appendChild(content); + + document.body.appendChild(dropdownElement); + + // Attach dropdown event listeners + attachDropdownEventListeners(dropdownElement); + + // Setup floating UI positioning + cleanupFloating = autoUpdate(button, dropdownElement, () => { + void computePosition(button, dropdownElement!, { + placement: "bottom-start", + middleware: [ + flip({ + fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"], + }), + shift({ + padding: 8, + }), + ], + }).then(({ x, y }) => { + if (dropdownElement) { + dropdownElement.style.left = `${x}px`; + dropdownElement.style.top = `${y}px`; + } + return; + }); + }); + + // Handle keyboard events + const handleKeyDown = (event: KeyboardEvent) => { + closeDropdown(); + event.preventDefault(); + event.stopPropagation(); + }; + document.addEventListener("keydown", handleKeyDown); + + // Store cleanup for this specific dropdown instance + const originalCleanup = cleanupFloating; + cleanupFloating = () => { + document.removeEventListener("keydown", handleKeyDown); + if (originalCleanup) originalCleanup(); + }; + }; + + const getDropdownOptions = (): DropdownOption[] => [ + { + key: "toggle-header", + label: "Header column", + icon: DRAG_HANDLE_ICONS.toggleRight, + showRightIcon: true, + }, + { + key: "insert-left", + label: "Insert left", + icon: DRAG_HANDLE_ICONS.arrowLeft, + showRightIcon: false, + }, + { + key: "insert-right", + label: "Insert right", + icon: DRAG_HANDLE_ICONS.arrowRight, + showRightIcon: false, + }, + { + key: "duplicate", + label: "Duplicate", + icon: DRAG_HANDLE_ICONS.duplicate, + showRightIcon: false, + }, + { + key: "clear-contents", + label: "Clear contents", + icon: DRAG_HANDLE_ICONS.close, + showRightIcon: false, + }, + { + key: "delete", + label: "Delete", + icon: DRAG_HANDLE_ICONS.trash, + showRightIcon: false, + }, + ]; + + const attachDropdownEventListeners = (dropdown: HTMLElement) => { + const buttons = dropdown.querySelectorAll("button[data-action]"); + const colorPanel = dropdown.querySelector(".color-panel"); + const colorChevron = dropdown.querySelector(".color-chevron"); + + buttons.forEach((btn) => { + const action = btn.getAttribute("data-action"); + if (!action) return; + + btn.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + + // Handle common actions + handleDropdownAction(action, editor, closeDropdown, colorPanel, colorChevron); + + // Handle column-specific actions + switch (action) { + case "toggle-header": + editor.chain().focus().toggleHeaderColumn().run(); + closeDropdown(); + break; + case "set-bg-color": { + const color = btn.getAttribute("data-color"); + if (color) { + editor + .chain() + .focus() + .updateAttributes(CORE_EXTENSIONS.TABLE_CELL, { + background: color, + }) + .run(); + } + closeDropdown(); + break; + } + case "insert-left": + editor.chain().focus().addColumnBefore().run(); + closeDropdown(); + break; + case "insert-right": + editor.chain().focus().addColumnAfter().run(); + closeDropdown(); + break; + case "duplicate": { + const table = findTable(editor.state.selection); + if (table) { + const tableMap = TableMap.get(table.node); + let tr = editor.state.tr; + const selectedColumns = getSelectedColumns(editor.state.selection, tableMap); + tr = duplicateColumns(table, selectedColumns, tr); + editor.view.dispatch(tr); + } + closeDropdown(); + break; + } + case "clear-contents": + editor.chain().focus().clearSelectedCells().run(); + closeDropdown(); + break; + case "delete": + editor.chain().focus().deleteColumn().run(); + closeDropdown(); + break; + } + }); + }); + }; + + // Handle mousedown for dragging + const handleMouseDown = (e: MouseEvent) => { + // Prevent dropdown from opening during drag + if (e.button !== 0) return; // Only left click + + e.stopPropagation(); + e.preventDefault(); + + // Check if this is a click (will be determined by mouseup without much movement) + const startX = e.clientX; + const startY = e.clientY; + let hasMoved = false; + + // Clean up any existing drag listeners + if (dragListeners.mouseup) { + window.removeEventListener("mouseup", dragListeners.mouseup); + } + if (dragListeners.mousemove) { + window.removeEventListener("mousemove", dragListeners.mousemove); + } + dragListeners = {}; + + const table = findTable(editor.state.selection); + if (!table) return; + + editor.view.dispatch(selectColumn(table, col, editor.state.tr)); + + // Drag column logic + const tableWidthPx = getTableWidthPx(table, editor); + const columns = getTableColumnNodesInfo(table, editor); + + let dropIndex = col; + const startLeft = columns[col].left ?? 0; + const startXPos = e.clientX; + const tableElement = editor.view.nodeDOM(table.pos); + + const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null; + const dragMarker = tableElement instanceof HTMLElement ? getColDragMarker(tableElement) : null; + + console.log("Column drag markers found:", { dropMarker: !!dropMarker, dragMarker: !!dragMarker }); + + const handleFinish = (): void => { + // Clean up markers if they exist + if (dropMarker && dragMarker) { + hideDropMarker(dropMarker); + hideDragMarker(dragMarker); + } + + if (isCellSelection(editor.state.selection)) { + updateCellContentVisibility(editor, false); + } + + // Perform drag operation if user moved + if (col !== dropIndex && hasMoved) { + let tr = editor.state.tr; + const selection = editor.state.selection; + if (isCellSelection(selection)) { + const table = findTable(selection); + if (table) { + tr = moveSelectedColumns(editor, table, selection, dropIndex, tr); + } + } + editor.view.dispatch(tr); + } + + window.removeEventListener("mouseup", handleFinish); + window.removeEventListener("mousemove", handleMove); + dragListeners.mouseup = undefined; + dragListeners.mousemove = undefined; + + // If it was just a click (no movement), toggle dropdown + if (!hasMoved) { + toggleDropdown(); + } + }; + + let pseudoColumn: HTMLElement | undefined; + + const handleMove = (moveEvent: MouseEvent): void => { + // Mark that we've moved + const deltaX = Math.abs(moveEvent.clientX - startX); + const deltaY = Math.abs(moveEvent.clientY - startY); + if (deltaX > 3 || deltaY > 3) { + hasMoved = true; + } + + // Calculate drop index + const currentLeft = startLeft + moveEvent.clientX - startXPos; + dropIndex = calculateColumnDropIndex(col, columns, currentLeft); + + // Update visual markers if they exist + if (dropMarker && dragMarker) { + if (!pseudoColumn) { + pseudoColumn = constructColumnDragPreview(editor, editor.state.selection, table); + const tableHeightPx = getTableHeightPx(table, editor); + if (pseudoColumn) { + pseudoColumn.style.height = `${tableHeightPx}px`; + } + } + + const dragMarkerWidthPx = columns[col].width; + const dragMarkerLeftPx = Math.max(0, Math.min(currentLeft, tableWidthPx - dragMarkerWidthPx)); + const dropMarkerLeftPx = + dropIndex <= col ? columns[dropIndex].left : columns[dropIndex].left + columns[dropIndex].width; + + updateColDropMarker({ + element: dropMarker, + left: dropMarkerLeftPx - Math.floor(DROP_MARKER_THICKNESS / 2) - 1, + width: DROP_MARKER_THICKNESS, + }); + updateColDragMarker({ + element: dragMarker, + left: dragMarkerLeftPx, + width: dragMarkerWidthPx, + pseudoColumn, + }); + } + }; + + try { + dragListeners.mouseup = handleFinish; + dragListeners.mousemove = handleMove; + window.addEventListener("mouseup", handleFinish); + window.addEventListener("mousemove", handleMove); + } catch (error) { + console.error("Error in ColumnDragHandle:", error); + handleFinish(); + } + }; + + // Attach mousedown listener + button.addEventListener("mousedown", handleMouseDown); + + // Cleanup function + const destroy = () => { + // Close dropdown if open + if (isDropdownOpen) { + closeDropdown(); + } + + // Remove drag listeners + if (dragListeners.mouseup) { + window.removeEventListener("mouseup", dragListeners.mouseup); + } + if (dragListeners.mousemove) { + window.removeEventListener("mousemove", dragListeners.mousemove); + } + + // Remove mousedown listener + button.removeEventListener("mousedown", handleMouseDown); + + // Remove DOM elements + container.remove(); + }; + + return { + element: container, + destroy, + }; +} diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/plugin.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/plugin.ts index b425a1c782c..9d05936f363 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/plugin.ts +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/plugin.ts @@ -2,7 +2,6 @@ import type { Editor } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { TableMap } from "@tiptap/pm/tables"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { ReactRenderer } from "@tiptap/react"; // extensions import { findTable, @@ -10,16 +9,20 @@ import { haveTableRelatedChanges, } from "@/extensions/table/table/utilities/helpers"; // local imports -import type { ColumnDragHandleProps } from "./drag-handle"; -import { ColumnDragHandle } from "./drag-handle"; +import { createColumnDragHandle } from "./drag-handle-vanilla"; + +type DragHandleInstance = { + element: HTMLElement; + destroy: () => void; +}; type TableColumnDragHandlePluginState = { decorations?: DecorationSet; // track table structure to detect changes tableWidth?: number; tableNodePos?: number; - // track renderers for cleanup - renderers?: ReactRenderer[]; + // track drag handle instances for cleanup + dragHandles?: DragHandleInstance[]; }; const TABLE_COLUMN_DRAG_HANDLE_PLUGIN_KEY = new PluginKey("tableColumnHandlerDecorationPlugin"); @@ -60,43 +63,40 @@ export const TableColumnDragHandlePlugin = (editor: Editor): Plugin { + // Clean up old drag handles before creating new ones + prev.dragHandles?.forEach((handle) => { try { - renderer.destroy(); + handle.destroy(); } catch (error) { - console.error("Error destroying renderer:", error); + console.error("Error destroying drag handle:", error); } }); // recreate all decorations const decorations: Decoration[] = []; - const renderers: ReactRenderer[] = []; + const dragHandles: DragHandleInstance[] = []; for (let col = 0; col < tableMap.width; col++) { const pos = getTableCellWidgetDecorationPos(table, tableMap, col); - const dragHandleComponent = new ReactRenderer(ColumnDragHandle, { - props: { - col, - editor, - } satisfies ColumnDragHandleProps, + const dragHandle = createColumnDragHandle({ editor, + col, }); - renderers.push(dragHandleComponent); - decorations.push(Decoration.widget(pos, () => dragHandleComponent.element)); + dragHandles.push(dragHandle); + decorations.push(Decoration.widget(pos, () => dragHandle.element)); } return { decorations: DecorationSet.create(newState.doc, decorations), tableWidth: tableMap.width, tableNodePos: table.pos, - renderers, + dragHandles, }; }, }, @@ -107,15 +107,15 @@ export const TableColumnDragHandlePlugin = (editor: Editor): Plugin { + state?.dragHandles?.forEach((handle: DragHandleInstance) => { try { - renderer.destroy(); + handle.destroy(); } catch (error) { - console.error("Error destroying renderer:", error); + console.error("Error destroying drag handle:", error); } }); }, diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/dropdown-content.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/dropdown-content.ts new file mode 100644 index 00000000000..46eb53f94b2 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/dropdown-content.ts @@ -0,0 +1,179 @@ +import type { Editor } from "@tiptap/core"; +// constants +import { COLORS_LIST } from "@/constants/common"; +import { CORE_EXTENSIONS } from "@/constants/extension"; +// icons +import { DRAG_HANDLE_ICONS, createSvgElement } from "./icons"; + +export type DropdownOption = { + key: string; + label: string; + icon: string; + showRightIcon: boolean; +}; + +/** + * Creates the color selector section for the dropdown + */ +export function createColorSelector(): HTMLElement { + const container = document.createElement("div"); + container.className = "mb-2"; + + // Create toggle button + const toggleBtn = document.createElement("button"); + toggleBtn.type = "button"; + toggleBtn.className = + "flex items-center justify-between gap-2 w-full rounded-sm px-1 py-1.5 text-11 text-left truncate text-secondary hover:bg-layer-transparent-hover"; + toggleBtn.setAttribute("data-action", "toggle-color-selector"); + + // Left span with icon and text + const leftSpan = document.createElement("span"); + leftSpan.className = "flex items-center gap-2"; + const paletteIcon = createSvgElement(DRAG_HANDLE_ICONS.palette, "shrink-0 size-3"); + leftSpan.appendChild(paletteIcon); + leftSpan.appendChild(document.createTextNode("Color")); + + // Right chevron icon + const chevronIcon = createSvgElement( + DRAG_HANDLE_ICONS.chevronRight, + "shrink-0 size-3 transition-transform duration-200 color-chevron" + ); + + toggleBtn.appendChild(leftSpan); + toggleBtn.appendChild(chevronIcon); + container.appendChild(toggleBtn); + + // Create color panel + const colorPanel = document.createElement("div"); + colorPanel.className = "p-1 space-y-2 mb-1.5 hidden color-panel"; + + const innerDiv = document.createElement("div"); + innerDiv.className = "space-y-1"; + + const title = document.createElement("p"); + title.className = "text-11 text-tertiary font-semibold"; + title.textContent = "Background colors"; + innerDiv.appendChild(title); + + const colorsContainer = document.createElement("div"); + colorsContainer.className = "flex items-center flex-wrap gap-2"; + + // Create color buttons + COLORS_LIST.forEach((color) => { + const colorBtn = document.createElement("button"); + colorBtn.type = "button"; + colorBtn.className = + "flex-shrink-0 size-6 rounded-sm border-[0.5px] border-strong-1 hover:opacity-60 transition-opacity"; + colorBtn.style.backgroundColor = color.backgroundColor; + colorBtn.setAttribute("data-action", "set-bg-color"); + colorBtn.setAttribute("data-color", color.backgroundColor); + colorsContainer.appendChild(colorBtn); + }); + + // Create clear color button + const clearBtn = document.createElement("button"); + clearBtn.type = "button"; + clearBtn.className = + "flex-shrink-0 size-6 grid place-items-center rounded-sm text-tertiary border-[0.5px] border-strong-1 hover:bg-layer-transparent-hover transition-colors"; + clearBtn.setAttribute("data-action", "clear-bg-color"); + const banIcon = createSvgElement(DRAG_HANDLE_ICONS.ban, "size-4"); + clearBtn.appendChild(banIcon); + colorsContainer.appendChild(clearBtn); + + innerDiv.appendChild(colorsContainer); + colorPanel.appendChild(innerDiv); + container.appendChild(colorPanel); + + return container; +} + +/** + * Creates the dropdown content with options and color selector + */ +export function createDropdownContent(options: DropdownOption[]): DocumentFragment { + const fragment = document.createDocumentFragment(); + + // Create option buttons + options.forEach((option, index) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = `flex items-center ${option.showRightIcon ? "justify-between" : ""} gap-2 w-full rounded-sm px-1 py-1.5 text-11 text-left truncate text-secondary hover:bg-layer-transparent-hover`; + btn.setAttribute("data-action", option.key); + + // Create icon element + const iconElement = createSvgElement(option.icon, "shrink-0 size-3"); + + // Create label + const labelDiv = document.createElement("div"); + labelDiv.className = "flex-grow truncate"; + labelDiv.textContent = option.label; + + // Append in correct order + if (option.showRightIcon) { + btn.appendChild(labelDiv); + btn.appendChild(iconElement); + } else { + btn.appendChild(iconElement); + btn.appendChild(labelDiv); + } + + fragment.appendChild(btn); + + // Add divider after first option (header toggle) + if (index === 0) { + const hr = document.createElement("hr"); + hr.className = "my-2 border-subtle"; + fragment.appendChild(hr); + + // Add color selector after divider + const colorSection = createColorSelector(); + fragment.appendChild(colorSection); + } + }); + + return fragment; +} + +/** + * Handles dropdown action events + */ +export function handleDropdownAction( + action: string, + editor: Editor, + onClose: () => void, + colorPanel?: Element | null, + colorChevron?: Element | null +): void { + switch (action) { + case "toggle-color-selector": + if (colorPanel && colorChevron) { + const isHidden = colorPanel.classList.contains("hidden"); + if (isHidden) { + colorPanel.classList.remove("hidden"); + colorChevron.classList.add("rotate-90"); + } else { + colorPanel.classList.add("hidden"); + colorChevron.classList.remove("rotate-90"); + } + } + break; + case "set-bg-color": { + // Color is handled by the button's data-color attribute + // This case is handled in the event listener + break; + } + case "clear-bg-color": + editor + .chain() + .focus() + .updateAttributes(CORE_EXTENSIONS.TABLE_CELL, { + background: null, + }) + .run(); + onClose(); + break; + default: + // Other actions are handled by specific implementations + break; + } +} diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/icons.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/icons.ts new file mode 100644 index 00000000000..37d33f06dc5 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/icons.ts @@ -0,0 +1,49 @@ +/** + * SVG icon paths for table drag handle dropdowns + */ + +export const DRAG_HANDLE_ICONS = { + // Toggle icons + toggleRight: '', + + // Arrow icons + arrowUp: '', + arrowDown: '', + arrowLeft: '', + arrowRight: '', + + // Action icons + duplicate: + '', + close: '', + trash: + '', + + // Color icons + palette: + '', + chevronRight: '', + ban: '', + + // Ellipsis (drag handle button) + ellipsis: '', +} as const; + +/** + * Creates an SVG element (DOM element) with the given icon path + */ +export function createSvgElement(iconPath: string, className = "size-3"): SVGSVGElement { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("class", className); + svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + svg.setAttribute("width", "24"); + svg.setAttribute("height", "24"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + svg.innerHTML = iconPath; + return svg; +} diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle-vanilla.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle-vanilla.ts new file mode 100644 index 00000000000..7d203a5f048 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle-vanilla.ts @@ -0,0 +1,453 @@ +import { computePosition, flip, shift, autoUpdate } from "@floating-ui/dom"; +import type { Editor } from "@tiptap/core"; +import { TableMap } from "@tiptap/pm/tables"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// icons +import { DRAG_HANDLE_ICONS, createSvgElement } from "../icons"; +// dropdown +import { createDropdownContent, handleDropdownAction } from "../dropdown-content"; +import type { DropdownOption } from "../dropdown-content"; +// extensions +import { + findTable, + getTableHeightPx, + getTableWidthPx, + isCellSelection, + selectRow, + getSelectedRows, +} from "@/extensions/table/table/utilities/helpers"; +// local imports +import { moveSelectedRows, duplicateRows } from "../actions"; +import { + DROP_MARKER_THICKNESS, + getDropMarker, + getRowDragMarker, + hideDragMarker, + hideDropMarker, + updateRowDragMarker, + updateRowDropMarker, +} from "../marker-utils"; +import { updateCellContentVisibility } from "../utils"; +import { calculateRowDropIndex, constructRowDragPreview, getTableRowNodesInfo } from "./utils"; + +export type RowDragHandleConfig = { + editor: Editor; + row: number; +}; + +/** + * Creates a vanilla JS row drag handle element with dropdown functionality + */ +export function createRowDragHandle(config: RowDragHandleConfig): { + element: HTMLElement; + destroy: () => void; +} { + const { editor, row } = config; + + // Create container + const container = document.createElement("div"); + container.className = + "table-row-handle-container absolute z-20 top-0 left-0 flex justify-center items-center h-full -translate-x-1/2"; + + // Create button + const button = document.createElement("button"); + button.type = "button"; + button.className = + "py-1 bg-layer-1 border border-strong-1 rounded-sm outline-none transition-all duration-200 hover:bg-layer-1-hover"; + + // Create icon (Ellipsis lucide icon as SVG) + const icon = createSvgElement(DRAG_HANDLE_ICONS.ellipsis, "size-4 text-primary"); + + button.appendChild(icon); + container.appendChild(button); + + // State for dropdown + let isDropdownOpen = false; + let dropdownElement: HTMLElement | null = null; + let backdropElement: HTMLElement | null = null; + let cleanupFloating: (() => void) | null = null; + + // Track drag event listeners for cleanup + let dragListeners: { + mouseup?: (e: MouseEvent) => void; + mousemove?: (e: MouseEvent) => void; + } = {}; + + // Dropdown toggle function + const toggleDropdown = () => { + if (isDropdownOpen) { + closeDropdown(); + } else { + openDropdown(); + } + }; + + const closeDropdown = () => { + if (!isDropdownOpen) return; + + isDropdownOpen = false; + + // Reset button to closed state + button.className = + "py-1 bg-layer-1 border border-strong-1 rounded-sm outline-none transition-all duration-200 hover:bg-layer-1-hover"; + + // Remove dropdown and backdrop + if (dropdownElement) { + dropdownElement.remove(); + dropdownElement = null; + } + if (backdropElement) { + backdropElement.remove(); + backdropElement = null; + } + + // Cleanup floating UI + if (cleanupFloating) { + cleanupFloating(); + cleanupFloating = null; + } + + // Remove active dropdown extension + setTimeout(() => { + editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE); + }, 0); + }; + + const openDropdown = () => { + if (isDropdownOpen) return; + + isDropdownOpen = true; + + // Update button to open state + button.className = + "py-1 bg-layer-1 border border-strong-1 rounded-sm outline-none transition-all duration-200 opacity-100 bg-accent-primary border-accent-strong"; + + // Add active dropdown extension + editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE); + + // Create backdrop + backdropElement = document.createElement("div"); + backdropElement.style.cssText = "position: fixed; inset: 0; z-index: 99;"; + backdropElement.addEventListener("click", closeDropdown); + document.body.appendChild(backdropElement); + + // Create dropdown + dropdownElement = document.createElement("div"); + dropdownElement.className = + "max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 shadow-raised-200"; + dropdownElement.style.cssText = "position: fixed; z-index: 100;"; + + // Create and append dropdown content + const content = createDropdownContent(getDropdownOptions()); + dropdownElement.appendChild(content); + + document.body.appendChild(dropdownElement); + + // Attach dropdown event listeners + attachDropdownEventListeners(dropdownElement); + + // Setup floating UI positioning + cleanupFloating = autoUpdate(button, dropdownElement, () => { + void computePosition(button, dropdownElement!, { + placement: "bottom-start", + middleware: [ + flip({ + fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"], + }), + shift({ + padding: 8, + }), + ], + }).then(({ x, y }) => { + if (dropdownElement) { + dropdownElement.style.left = `${x}px`; + dropdownElement.style.top = `${y}px`; + } + return; + }); + }); + + // Handle keyboard events + const handleKeyDown = (event: KeyboardEvent) => { + closeDropdown(); + event.preventDefault(); + event.stopPropagation(); + }; + document.addEventListener("keydown", handleKeyDown); + + // Store cleanup for this specific dropdown instance + const originalCleanup = cleanupFloating; + cleanupFloating = () => { + document.removeEventListener("keydown", handleKeyDown); + if (originalCleanup) originalCleanup(); + }; + }; + + const getDropdownOptions = (): DropdownOption[] => [ + { + key: "toggle-header", + label: "Header row", + icon: DRAG_HANDLE_ICONS.toggleRight, + showRightIcon: true, + }, + { + key: "insert-above", + label: "Insert above", + icon: DRAG_HANDLE_ICONS.arrowUp, + showRightIcon: false, + }, + { + key: "insert-below", + label: "Insert below", + icon: DRAG_HANDLE_ICONS.arrowDown, + showRightIcon: false, + }, + { + key: "duplicate", + label: "Duplicate", + icon: DRAG_HANDLE_ICONS.duplicate, + showRightIcon: false, + }, + { + key: "clear-contents", + label: "Clear contents", + icon: DRAG_HANDLE_ICONS.close, + showRightIcon: false, + }, + { + key: "delete", + label: "Delete", + icon: DRAG_HANDLE_ICONS.trash, + showRightIcon: false, + }, + ]; + + const attachDropdownEventListeners = (dropdown: HTMLElement) => { + const buttons = dropdown.querySelectorAll("button[data-action]"); + const colorPanel = dropdown.querySelector(".color-panel"); + const colorChevron = dropdown.querySelector(".color-chevron"); + + buttons.forEach((btn) => { + const action = btn.getAttribute("data-action"); + if (!action) return; + + btn.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + + // Handle common actions + handleDropdownAction(action, editor, closeDropdown, colorPanel, colorChevron); + + // Handle row-specific actions + switch (action) { + case "toggle-header": + editor.chain().focus().toggleHeaderRow().run(); + closeDropdown(); + break; + case "set-bg-color": { + const color = btn.getAttribute("data-color"); + if (color) { + editor + .chain() + .focus() + .updateAttributes(CORE_EXTENSIONS.TABLE_CELL, { + background: color, + }) + .run(); + } + closeDropdown(); + break; + } + case "insert-above": + editor.chain().focus().addRowBefore().run(); + closeDropdown(); + break; + case "insert-below": + editor.chain().focus().addRowAfter().run(); + closeDropdown(); + break; + case "duplicate": { + const table = findTable(editor.state.selection); + if (table) { + const tableMap = TableMap.get(table.node); + let tr = editor.state.tr; + const selectedRows = getSelectedRows(editor.state.selection, tableMap); + tr = duplicateRows(table, selectedRows, tr); + editor.view.dispatch(tr); + } + closeDropdown(); + break; + } + case "clear-contents": + editor.chain().focus().clearSelectedCells().run(); + closeDropdown(); + break; + case "delete": + editor.chain().focus().deleteRow().run(); + closeDropdown(); + break; + } + }); + }); + }; + + // Handle mousedown for dragging + const handleMouseDown = (e: MouseEvent) => { + // Prevent dropdown from opening during drag + if (e.button !== 0) return; // Only left click + + e.stopPropagation(); + e.preventDefault(); + + // Check if this is a click (will be determined by mouseup without much movement) + const startX = e.clientX; + const startY = e.clientY; + let hasMoved = false; + + // Clean up any existing drag listeners + if (dragListeners.mouseup) { + window.removeEventListener("mouseup", dragListeners.mouseup); + } + if (dragListeners.mousemove) { + window.removeEventListener("mousemove", dragListeners.mousemove); + } + dragListeners = {}; + + const table = findTable(editor.state.selection); + if (!table) return; + + editor.view.dispatch(selectRow(table, row, editor.state.tr)); + + // Drag row logic + const tableHeightPx = getTableHeightPx(table, editor); + const rows = getTableRowNodesInfo(table, editor); + + let dropIndex = row; + const startTop = rows[row].top ?? 0; + const startYPos = e.clientY; + const tableElement = editor.view.nodeDOM(table.pos); + + const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null; + const dragMarker = tableElement instanceof HTMLElement ? getRowDragMarker(tableElement) : null; + + console.log("Row drag markers found:", { dropMarker: !!dropMarker, dragMarker: !!dragMarker }); + + const handleFinish = (): void => { + // Clean up markers if they exist + if (dropMarker && dragMarker) { + hideDropMarker(dropMarker); + hideDragMarker(dragMarker); + } + + if (isCellSelection(editor.state.selection)) { + updateCellContentVisibility(editor, false); + } + + // Perform drag operation if user moved + if (row !== dropIndex && hasMoved) { + let tr = editor.state.tr; + const selection = editor.state.selection; + if (isCellSelection(selection)) { + const table = findTable(selection); + if (table) { + tr = moveSelectedRows(editor, table, selection, dropIndex, tr); + } + } + editor.view.dispatch(tr); + } + + window.removeEventListener("mouseup", handleFinish); + window.removeEventListener("mousemove", handleMove); + dragListeners.mouseup = undefined; + dragListeners.mousemove = undefined; + + // If it was just a click (no movement), toggle dropdown + if (!hasMoved) { + toggleDropdown(); + } + }; + + let pseudoRow: HTMLElement | undefined; + + const handleMove = (moveEvent: MouseEvent): void => { + // Mark that we've moved + const deltaX = Math.abs(moveEvent.clientX - startX); + const deltaY = Math.abs(moveEvent.clientY - startY); + if (deltaX > 3 || deltaY > 3) { + hasMoved = true; + } + + // Calculate drop index + const cursorTop = startTop + moveEvent.clientY - startYPos; + dropIndex = calculateRowDropIndex(row, rows, cursorTop); + + // Update visual markers if they exist + if (dropMarker && dragMarker) { + if (!pseudoRow) { + pseudoRow = constructRowDragPreview(editor, editor.state.selection, table); + const tableWidthPx = getTableWidthPx(table, editor); + if (pseudoRow) { + pseudoRow.style.width = `${tableWidthPx}px`; + } + } + + const dragMarkerHeightPx = rows[row].height; + const dragMarkerTopPx = Math.max(0, Math.min(cursorTop, tableHeightPx - dragMarkerHeightPx)); + const dropMarkerTopPx = dropIndex <= row ? rows[dropIndex].top : rows[dropIndex].top + rows[dropIndex].height; + + updateRowDropMarker({ + element: dropMarker, + top: dropMarkerTopPx - DROP_MARKER_THICKNESS / 2, + height: DROP_MARKER_THICKNESS, + }); + updateRowDragMarker({ + element: dragMarker, + top: dragMarkerTopPx, + height: dragMarkerHeightPx, + pseudoRow, + }); + } + }; + + try { + dragListeners.mouseup = handleFinish; + dragListeners.mousemove = handleMove; + window.addEventListener("mouseup", handleFinish); + window.addEventListener("mousemove", handleMove); + } catch (error) { + console.error("Error in RowDragHandle:", error); + handleFinish(); + } + }; + + // Attach mousedown listener + button.addEventListener("mousedown", handleMouseDown); + + // Cleanup function + const destroy = () => { + // Close dropdown if open + if (isDropdownOpen) { + closeDropdown(); + } + + // Remove drag listeners + if (dragListeners.mouseup) { + window.removeEventListener("mouseup", dragListeners.mouseup); + } + if (dragListeners.mousemove) { + window.removeEventListener("mousemove", dragListeners.mousemove); + } + + // Remove mousedown listener + button.removeEventListener("mousedown", handleMouseDown); + + // Remove DOM elements + container.remove(); + }; + + return { + element: container, + destroy, + }; +} diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/plugin.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/plugin.ts index 71b7e1c1b16..6fab37c0580 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/plugin.ts +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/plugin.ts @@ -2,7 +2,6 @@ import type { Editor } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { TableMap } from "@tiptap/pm/tables"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { ReactRenderer } from "@tiptap/react"; // extensions import { findTable, @@ -10,16 +9,20 @@ import { haveTableRelatedChanges, } from "@/extensions/table/table/utilities/helpers"; // local imports -import type { RowDragHandleProps } from "./drag-handle"; -import { RowDragHandle } from "./drag-handle"; +import { createRowDragHandle } from "./drag-handle-vanilla"; + +type DragHandleInstance = { + element: HTMLElement; + destroy: () => void; +}; type TableRowDragHandlePluginState = { decorations?: DecorationSet; // track table structure to detect changes tableHeight?: number; tableNodePos?: number; - // track renderers for cleanup - renderers?: ReactRenderer[]; + // track drag handle instances for cleanup + dragHandles?: DragHandleInstance[]; }; const TABLE_ROW_DRAG_HANDLE_PLUGIN_KEY = new PluginKey("tableRowDragHandlePlugin"); @@ -60,43 +63,40 @@ export const TableRowDragHandlePlugin = (editor: Editor): Plugin { + // Clean up old drag handles before creating new ones + prev.dragHandles?.forEach((handle) => { try { - renderer.destroy(); + handle.destroy(); } catch (error) { - console.error("Error destroying renderer:", error); + console.error("Error destroying drag handle:", error); } }); // recreate all decorations const decorations: Decoration[] = []; - const renderers: ReactRenderer[] = []; + const dragHandles: DragHandleInstance[] = []; for (let row = 0; row < tableMap.height; row++) { const pos = getTableCellWidgetDecorationPos(table, tableMap, row * tableMap.width); - const dragHandleComponent = new ReactRenderer(RowDragHandle, { - props: { - editor, - row, - } satisfies RowDragHandleProps, + const dragHandle = createRowDragHandle({ editor, + row, }); - renderers.push(dragHandleComponent); - decorations.push(Decoration.widget(pos, () => dragHandleComponent.element)); + dragHandles.push(dragHandle); + decorations.push(Decoration.widget(pos, () => dragHandle.element)); } return { decorations: DecorationSet.create(newState.doc, decorations), tableHeight: tableMap.height, tableNodePos: table.pos, - renderers, + dragHandles, }; }, }, @@ -107,15 +107,15 @@ export const TableRowDragHandlePlugin = (editor: Editor): Plugin { + state?.dragHandles?.forEach((handle: DragHandleInstance) => { try { - renderer.destroy(); + handle.destroy(); } catch (error) { - console.error("Error destroying renderer:", error); + console.error("Error destroying drag handle:", error); } }); }, From 60d37dde2fe7bd8ede7cc253b08445993fb5d55d Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Thu, 22 Jan 2026 14:22:53 +0530 Subject: [PATCH 2/7] fix: insert handle performance --- .../column/drag-handle-vanilla.ts | 2 -- .../drag-handles/row/drag-handle-vanilla.ts | 2 -- .../table/plugins/insert-handlers/plugin.ts | 16 +++++++-- .../table/plugins/insert-handlers/utils.ts | 34 +++++++------------ 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle-vanilla.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle-vanilla.ts index 7bc9607f161..ae95a6c8ee3 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle-vanilla.ts +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle-vanilla.ts @@ -331,8 +331,6 @@ export function createColumnDragHandle(config: ColumnDragHandleConfig): { const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null; const dragMarker = tableElement instanceof HTMLElement ? getColDragMarker(tableElement) : null; - console.log("Column drag markers found:", { dropMarker: !!dropMarker, dragMarker: !!dragMarker }); - const handleFinish = (): void => { // Clean up markers if they exist if (dropMarker && dragMarker) { diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle-vanilla.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle-vanilla.ts index 7d203a5f048..6836ad7617b 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle-vanilla.ts +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle-vanilla.ts @@ -331,8 +331,6 @@ export function createRowDragHandle(config: RowDragHandleConfig): { const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null; const dragMarker = tableElement instanceof HTMLElement ? getRowDragMarker(tableElement) : null; - console.log("Row drag markers found:", { dropMarker: !!dropMarker, dragMarker: !!dragMarker }); - const handleFinish = (): void => { // Clean up markers if they exist if (dropMarker && dragMarker) { diff --git a/packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts b/packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts index 7188e88d02d..1ae64dc2444 100644 --- a/packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts +++ b/packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts @@ -72,6 +72,18 @@ export const TableInsertPlugin = (editor: Editor): Plugin => { }); }; + let updateScheduled = false; + + const scheduleUpdate = () => { + if (updateScheduled) return; + updateScheduled = true; + + requestAnimationFrame(() => { + updateScheduled = false; + updateAllTables(); + }); + }; + return new Plugin({ key: TABLE_INSERT_PLUGIN_KEY, @@ -80,9 +92,9 @@ export const TableInsertPlugin = (editor: Editor): Plugin => { return { update(view, prevState) { - // Update when document changes + // Debounce updates using RAF to batch multiple changes if (!prevState.doc.eq(view.state.doc)) { - updateAllTables(); + scheduleUpdate(); } }, destroy() { 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 f760538ab22..6f25bc6edd1 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 @@ -219,16 +219,11 @@ export const createRowInsertButton = (editor: Editor, tableInfo: TableInfo): HTM 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") { + // More efficient: iterate through document once instead of DOM + doc for each table + editor.state.doc.descendants((node, pos) => { + if (node.type.spec.tableRole === "table") { + try { const domAtPos = editor.view.domAtPos(pos + 1); let domTable = domAtPos.node; @@ -241,20 +236,17 @@ export const findAllTables = (editor: Editor): TableInfo[] => { domTable = domTable.parentNode; } - if (domTable === tableElement) { - tablePos = pos; - tableNode = node; - return false; // Stop iteration + if (domTable instanceof HTMLElement && domTable.tagName === "TABLE") { + tables.push({ + tableElement: domTable, + tableNode: node, + tablePos: pos, + }); } + } catch (error) { + // Skip tables that fail to resolve + console.error("Error finding table:", error); } - }); - - if (tablePos !== -1 && tableNode) { - tables.push({ - tableElement, - tableNode, - tablePos, - }); } }); From d0fe1ba284b8f8ae24ad4a7ddf84f4afd4589b48 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Thu, 22 Jan 2026 14:24:25 +0530 Subject: [PATCH 3/7] refactor: remove unused files --- ...{drag-handle-vanilla.ts => drag-handle.ts} | 0 .../drag-handles/column/drag-handle.tsx | 258 ------------------ .../plugins/drag-handles/column/plugin.ts | 2 +- ...{drag-handle-vanilla.ts => drag-handle.ts} | 0 .../plugins/drag-handles/row/drag-handle.tsx | 257 ----------------- .../table/plugins/drag-handles/row/plugin.ts | 2 +- 6 files changed, 2 insertions(+), 517 deletions(-) rename packages/editor/src/core/extensions/table/plugins/drag-handles/column/{drag-handle-vanilla.ts => drag-handle.ts} (100%) delete mode 100644 packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx rename packages/editor/src/core/extensions/table/plugins/drag-handles/row/{drag-handle-vanilla.ts => drag-handle.ts} (100%) delete mode 100644 packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle-vanilla.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.ts similarity index 100% rename from packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle-vanilla.ts rename to packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.ts diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx deleted file mode 100644 index ed344a0703f..00000000000 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import { - shift, - flip, - useDismiss, - useFloating, - useInteractions, - autoUpdate, - useClick, - useRole, - FloatingOverlay, - FloatingPortal, -} from "@floating-ui/react"; -import type { Editor } from "@tiptap/core"; -import { Ellipsis } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; -// plane imports -import { cn } from "@plane/utils"; -// constants -import { CORE_EXTENSIONS } from "@/constants/extension"; -// extensions -import { - findTable, - getTableHeightPx, - getTableWidthPx, - isCellSelection, - selectColumn, -} from "@/extensions/table/table/utilities/helpers"; -// local imports -import { moveSelectedColumns } from "../actions"; -import { - DROP_MARKER_THICKNESS, - getColDragMarker, - getDropMarker, - hideDragMarker, - hideDropMarker, - updateColDragMarker, - updateColDropMarker, -} from "../marker-utils"; -import { updateCellContentVisibility } from "../utils"; -import { ColumnOptionsDropdown } from "./dropdown"; -import { calculateColumnDropIndex, constructColumnDragPreview, getTableColumnNodesInfo } from "./utils"; - -export type ColumnDragHandleProps = { - col: number; - editor: Editor; -}; - -export function ColumnDragHandle(props: ColumnDragHandleProps) { - const { col, editor } = props; - // states - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - // Track active event listeners for cleanup - const activeListenersRef = useRef<{ - mouseup?: (e: MouseEvent) => void; - mousemove?: (e: MouseEvent) => void; - }>({}); - - // Cleanup window event listeners on unmount - useEffect(() => { - const listenersRef = activeListenersRef.current; - return () => { - // Remove any lingering window event listeners when component unmounts - if (listenersRef.mouseup) { - window.removeEventListener("mouseup", listenersRef.mouseup); - } - if (listenersRef.mousemove) { - window.removeEventListener("mousemove", listenersRef.mousemove); - } - }; - }, []); - // floating ui - const { refs, floatingStyles, context } = useFloating({ - placement: "bottom-start", - middleware: [ - flip({ - fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"], - }), - shift({ - padding: 8, - }), - ], - open: isDropdownOpen, - onOpenChange: (open) => { - setIsDropdownOpen(open); - if (open) { - editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE); - } else { - setTimeout(() => { - editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE); - }, 0); - } - }, - whileElementsMounted: autoUpdate, - }); - const click = useClick(context); - const dismiss = useDismiss(context); - const role = useRole(context); - const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click, role]); - - useEffect(() => { - if (!isDropdownOpen) return; - const handleKeyDown = (event: KeyboardEvent) => { - context.onOpenChange(false); - event.preventDefault(); - event.stopPropagation(); - }; - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [isDropdownOpen, context]); - - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - - // Prevent multiple simultaneous drag operations - // If there are already listeners attached, remove them first - if (activeListenersRef.current.mouseup) { - window.removeEventListener("mouseup", activeListenersRef.current.mouseup); - } - if (activeListenersRef.current.mousemove) { - window.removeEventListener("mousemove", activeListenersRef.current.mousemove); - } - activeListenersRef.current.mouseup = undefined; - activeListenersRef.current.mousemove = undefined; - - const table = findTable(editor.state.selection); - if (!table) return; - - editor.view.dispatch(selectColumn(table, col, editor.state.tr)); - - // drag column - const tableWidthPx = getTableWidthPx(table, editor); - const columns = getTableColumnNodesInfo(table, editor); - - let dropIndex = col; - const startLeft = columns[col].left ?? 0; - const startX = e.clientX; - const tableElement = editor.view.nodeDOM(table.pos); - - const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null; - const dragMarker = tableElement instanceof HTMLElement ? getColDragMarker(tableElement) : null; - - const handleFinish = () => { - if (!dropMarker || !dragMarker) return; - hideDropMarker(dropMarker); - hideDragMarker(dragMarker); - - if (isCellSelection(editor.state.selection)) { - updateCellContentVisibility(editor, false); - } - - if (col !== dropIndex) { - let tr = editor.state.tr; - const selection = editor.state.selection; - if (isCellSelection(selection)) { - const table = findTable(selection); - if (table) { - tr = moveSelectedColumns(editor, table, selection, dropIndex, tr); - } - } - editor.view.dispatch(tr); - } - window.removeEventListener("mouseup", handleFinish); - window.removeEventListener("mousemove", handleMove); - // Clear the ref - activeListenersRef.current.mouseup = undefined; - activeListenersRef.current.mousemove = undefined; - }; - - let pseudoColumn: HTMLElement | undefined; - - const handleMove = (moveEvent: MouseEvent) => { - if (!dropMarker || !dragMarker) return; - const currentLeft = startLeft + moveEvent.clientX - startX; - dropIndex = calculateColumnDropIndex(col, columns, currentLeft); - - if (!pseudoColumn) { - pseudoColumn = constructColumnDragPreview(editor, editor.state.selection, table); - const tableHeightPx = getTableHeightPx(table, editor); - if (pseudoColumn) { - pseudoColumn.style.height = `${tableHeightPx}px`; - } - } - - const dragMarkerWidthPx = columns[col].width; - const dragMarkerLeftPx = Math.max(0, Math.min(currentLeft, tableWidthPx - dragMarkerWidthPx)); - const dropMarkerLeftPx = - dropIndex <= col ? columns[dropIndex].left : columns[dropIndex].left + columns[dropIndex].width; - - updateColDropMarker({ - element: dropMarker, - left: dropMarkerLeftPx - Math.floor(DROP_MARKER_THICKNESS / 2) - 1, - width: DROP_MARKER_THICKNESS, - }); - updateColDragMarker({ - element: dragMarker, - left: dragMarkerLeftPx, - width: dragMarkerWidthPx, - pseudoColumn, - }); - }; - - try { - // Store references for cleanup - activeListenersRef.current.mouseup = handleFinish; - activeListenersRef.current.mousemove = handleMove; - window.addEventListener("mouseup", handleFinish); - window.addEventListener("mousemove", handleMove); - } catch (error) { - console.error("Error in ColumnDragHandle:", error); - handleFinish(); - } - }, - [col, editor] - ); - - return ( - <> -
- -
- {isDropdownOpen && ( - - {/* Backdrop */} - -
- context.onOpenChange(false)} /> -
-
- )} - - ); -} diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/plugin.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/plugin.ts index 9d05936f363..14a1041406b 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/plugin.ts +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/plugin.ts @@ -9,7 +9,7 @@ import { haveTableRelatedChanges, } from "@/extensions/table/table/utilities/helpers"; // local imports -import { createColumnDragHandle } from "./drag-handle-vanilla"; +import { createColumnDragHandle } from "./drag-handle"; type DragHandleInstance = { element: HTMLElement; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle-vanilla.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.ts similarity index 100% rename from packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle-vanilla.ts rename to packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.ts diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx deleted file mode 100644 index 3425d0cdea2..00000000000 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import { - autoUpdate, - flip, - FloatingOverlay, - FloatingPortal, - shift, - useClick, - useDismiss, - useFloating, - useInteractions, - useRole, -} from "@floating-ui/react"; -import type { Editor } from "@tiptap/core"; -import { Ellipsis } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; -// plane imports -import { cn } from "@plane/utils"; -// constants -import { CORE_EXTENSIONS } from "@/constants/extension"; -// extensions -import { - findTable, - getTableHeightPx, - getTableWidthPx, - isCellSelection, - selectRow, -} from "@/extensions/table/table/utilities/helpers"; -// local imports -import { moveSelectedRows } from "../actions"; -import { - DROP_MARKER_THICKNESS, - getDropMarker, - getRowDragMarker, - hideDragMarker, - hideDropMarker, - updateRowDragMarker, - updateRowDropMarker, -} from "../marker-utils"; -import { updateCellContentVisibility } from "../utils"; -import { RowOptionsDropdown } from "./dropdown"; -import { calculateRowDropIndex, constructRowDragPreview, getTableRowNodesInfo } from "./utils"; - -export type RowDragHandleProps = { - editor: Editor; - row: number; -}; - -export function RowDragHandle(props: RowDragHandleProps) { - const { editor, row } = props; - // states - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - // Track active event listeners for cleanup - const activeListenersRef = useRef<{ - mouseup?: (e: MouseEvent) => void; - mousemove?: (e: MouseEvent) => void; - }>({}); - - // Cleanup window event listeners on unmount - useEffect(() => { - const listenersRef = activeListenersRef.current; - return () => { - // Remove any lingering window event listeners when component unmounts - if (listenersRef.mouseup) { - window.removeEventListener("mouseup", listenersRef.mouseup); - } - if (listenersRef.mousemove) { - window.removeEventListener("mousemove", listenersRef.mousemove); - } - }; - }, []); - // floating ui - const { refs, floatingStyles, context } = useFloating({ - placement: "bottom-start", - middleware: [ - flip({ - fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"], - }), - shift({ - padding: 8, - }), - ], - open: isDropdownOpen, - onOpenChange: (open) => { - setIsDropdownOpen(open); - if (open) { - editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE); - } else { - setTimeout(() => { - editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE); - }, 0); - } - }, - whileElementsMounted: autoUpdate, - }); - const click = useClick(context); - const dismiss = useDismiss(context); - const role = useRole(context); - const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click, role]); - - useEffect(() => { - if (!isDropdownOpen) return; - const handleKeyDown = (event: KeyboardEvent) => { - context.onOpenChange(false); - event.preventDefault(); - event.stopPropagation(); - }; - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [isDropdownOpen, context]); - - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - - // Prevent multiple simultaneous drag operations - // If there are already listeners attached, remove them first - if (activeListenersRef.current.mouseup) { - window.removeEventListener("mouseup", activeListenersRef.current.mouseup); - } - if (activeListenersRef.current.mousemove) { - window.removeEventListener("mousemove", activeListenersRef.current.mousemove); - } - activeListenersRef.current.mouseup = undefined; - activeListenersRef.current.mousemove = undefined; - - const table = findTable(editor.state.selection); - if (!table) return; - - editor.view.dispatch(selectRow(table, row, editor.state.tr)); - - // drag row - const tableHeightPx = getTableHeightPx(table, editor); - const rows = getTableRowNodesInfo(table, editor); - - let dropIndex = row; - const startTop = rows[row].top ?? 0; - const startY = e.clientY; - const tableElement = editor.view.nodeDOM(table.pos); - - const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null; - const dragMarker = tableElement instanceof HTMLElement ? getRowDragMarker(tableElement) : null; - - const handleFinish = (): void => { - if (!dropMarker || !dragMarker) return; - hideDropMarker(dropMarker); - hideDragMarker(dragMarker); - - if (isCellSelection(editor.state.selection)) { - updateCellContentVisibility(editor, false); - } - - if (row !== dropIndex) { - let tr = editor.state.tr; - const selection = editor.state.selection; - if (isCellSelection(selection)) { - const table = findTable(selection); - if (table) { - tr = moveSelectedRows(editor, table, selection, dropIndex, tr); - } - } - editor.view.dispatch(tr); - } - window.removeEventListener("mouseup", handleFinish); - window.removeEventListener("mousemove", handleMove); - // Clear the ref - activeListenersRef.current.mouseup = undefined; - activeListenersRef.current.mousemove = undefined; - }; - - let pseudoRow: HTMLElement | undefined; - - const handleMove = (moveEvent: MouseEvent): void => { - if (!dropMarker || !dragMarker) return; - const cursorTop = startTop + moveEvent.clientY - startY; - dropIndex = calculateRowDropIndex(row, rows, cursorTop); - - if (!pseudoRow) { - pseudoRow = constructRowDragPreview(editor, editor.state.selection, table); - const tableWidthPx = getTableWidthPx(table, editor); - if (pseudoRow) { - pseudoRow.style.width = `${tableWidthPx}px`; - } - } - - const dragMarkerHeightPx = rows[row].height; - const dragMarkerTopPx = Math.max(0, Math.min(cursorTop, tableHeightPx - dragMarkerHeightPx)); - const dropMarkerTopPx = dropIndex <= row ? rows[dropIndex].top : rows[dropIndex].top + rows[dropIndex].height; - - updateRowDropMarker({ - element: dropMarker, - top: dropMarkerTopPx - DROP_MARKER_THICKNESS / 2, - height: DROP_MARKER_THICKNESS, - }); - updateRowDragMarker({ - element: dragMarker, - top: dragMarkerTopPx, - height: dragMarkerHeightPx, - pseudoRow, - }); - }; - - try { - // Store references for cleanup - activeListenersRef.current.mouseup = handleFinish; - activeListenersRef.current.mousemove = handleMove; - window.addEventListener("mouseup", handleFinish); - window.addEventListener("mousemove", handleMove); - } catch (error) { - console.error("Error in RowDragHandle:", error); - handleFinish(); - } - }, - [editor, row] - ); - - return ( - <> -
- -
- {isDropdownOpen && ( - - {/* Backdrop */} - -
- context.onOpenChange(false)} /> -
-
- )} - - ); -} diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/plugin.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/plugin.ts index 6fab37c0580..15e20998bb4 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/plugin.ts +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/plugin.ts @@ -9,7 +9,7 @@ import { haveTableRelatedChanges, } from "@/extensions/table/table/utilities/helpers"; // local imports -import { createRowDragHandle } from "./drag-handle-vanilla"; +import { createRowDragHandle } from "./drag-handle"; type DragHandleInstance = { element: HTMLElement; From c74377fdfbd38e3cc7b657574c07c2ee8660c20b Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Thu, 22 Jan 2026 14:32:11 +0530 Subject: [PATCH 4/7] chore: update comment --- .../src/core/extensions/table/plugins/insert-handlers/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6f25bc6edd1..3b44f30554c 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 @@ -220,7 +220,7 @@ export const createRowInsertButton = (editor: Editor, tableInfo: TableInfo): HTM export const findAllTables = (editor: Editor): TableInfo[] => { const tables: TableInfo[] = []; - // More efficient: iterate through document once instead of DOM + doc for each table + // Iterate through document to look for tables editor.state.doc.descendants((node, pos) => { if (node.type.spec.tableRole === "table") { try { From ad9be51eb488fa01da249e1ebde2d3fbc4630d21 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Thu, 22 Jan 2026 14:42:40 +0530 Subject: [PATCH 5/7] chore: remove backdrop event listeners --- .../table/plugins/drag-handles/column/drag-handle.ts | 11 +++++++++-- .../table/plugins/drag-handles/row/drag-handle.ts | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.ts index ae95a6c8ee3..1c7f8077261 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.ts +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.ts @@ -67,6 +67,7 @@ export function createColumnDragHandle(config: ColumnDragHandleConfig): { let dropdownElement: HTMLElement | null = null; let backdropElement: HTMLElement | null = null; let cleanupFloating: (() => void) | null = null; + let backdropClickHandler: (() => void) | null = null; // Track drag event listeners for cleanup let dragListeners: { @@ -98,11 +99,16 @@ export function createColumnDragHandle(config: ColumnDragHandleConfig): { dropdownElement = null; } if (backdropElement) { + // Remove backdrop listener before removing element + if (backdropClickHandler) { + backdropElement.removeEventListener("click", backdropClickHandler); + backdropClickHandler = null; + } backdropElement.remove(); backdropElement = null; } - // Cleanup floating UI + // Cleanup floating UI (this also removes keydown listener) if (cleanupFloating) { cleanupFloating(); cleanupFloating = null; @@ -129,7 +135,8 @@ export function createColumnDragHandle(config: ColumnDragHandleConfig): { // Create backdrop backdropElement = document.createElement("div"); backdropElement.style.cssText = "position: fixed; inset: 0; z-index: 99;"; - backdropElement.addEventListener("click", closeDropdown); + backdropClickHandler = closeDropdown; + backdropElement.addEventListener("click", backdropClickHandler); document.body.appendChild(backdropElement); // Create dropdown diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.ts index 6836ad7617b..daceb0377ea 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.ts +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.ts @@ -67,6 +67,7 @@ export function createRowDragHandle(config: RowDragHandleConfig): { let dropdownElement: HTMLElement | null = null; let backdropElement: HTMLElement | null = null; let cleanupFloating: (() => void) | null = null; + let backdropClickHandler: (() => void) | null = null; // Track drag event listeners for cleanup let dragListeners: { @@ -98,11 +99,16 @@ export function createRowDragHandle(config: RowDragHandleConfig): { dropdownElement = null; } if (backdropElement) { + // Remove backdrop listener before removing element + if (backdropClickHandler) { + backdropElement.removeEventListener("click", backdropClickHandler); + backdropClickHandler = null; + } backdropElement.remove(); backdropElement = null; } - // Cleanup floating UI + // Cleanup floating UI (this also removes keydown listener) if (cleanupFloating) { cleanupFloating(); cleanupFloating = null; @@ -129,7 +135,8 @@ export function createRowDragHandle(config: RowDragHandleConfig): { // Create backdrop backdropElement = document.createElement("div"); backdropElement.style.cssText = "position: fixed; inset: 0; z-index: 99;"; - backdropElement.addEventListener("click", closeDropdown); + backdropClickHandler = closeDropdown; + backdropElement.addEventListener("click", backdropClickHandler); document.body.appendChild(backdropElement); // Create dropdown From e7c84e86a911697ba7b14589d844ed6a37bc5620 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Thu, 22 Jan 2026 15:49:48 +0530 Subject: [PATCH 6/7] fix: drag handle button UI --- .../plugins/drag-handles/column/drag-handle.ts | 18 +++++++++--------- .../plugins/drag-handles/row/drag-handle.ts | 18 +++++++++--------- packages/editor/src/styles/table.css | 10 +++++++++- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.ts index 1c7f8077261..f3f00d43fe5 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.ts +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.ts @@ -53,8 +53,7 @@ export function createColumnDragHandle(config: ColumnDragHandleConfig): { // Create button const button = document.createElement("button"); button.type = "button"; - button.className = - "py-1 bg-layer-1 border border-strong-1 rounded-sm outline-none transition-all duration-200 hover:bg-layer-1-hover"; + button.className = "default-state"; // Create icon (Ellipsis lucide icon as SVG) const icon = createSvgElement(DRAG_HANDLE_ICONS.ellipsis, "size-4 text-primary"); @@ -89,9 +88,8 @@ export function createColumnDragHandle(config: ColumnDragHandleConfig): { isDropdownOpen = false; - // Reset button to closed state - button.className = - "px-1 bg-layer-1 border border-strong-1 rounded-sm outline-none transition-all duration-200 hover:bg-layer-1-hover"; + // Reset button to default state + button.className = "default-state"; // Remove dropdown and backdrop if (dropdownElement) { @@ -126,15 +124,16 @@ export function createColumnDragHandle(config: ColumnDragHandleConfig): { isDropdownOpen = true; // Update button to open state - button.className = - "px-1 bg-layer-1 border border-strong-1 rounded-sm outline-none transition-all duration-200 opacity-100 bg-accent-primary border-accent-strong"; + button.className = "open-state"; // Add active dropdown extension editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE); // Create backdrop backdropElement = document.createElement("div"); - backdropElement.style.cssText = "position: fixed; inset: 0; z-index: 99;"; + backdropElement.style.position = "fixed"; + backdropElement.style.inset = "0"; + backdropElement.style.zIndex = "99"; backdropClickHandler = closeDropdown; backdropElement.addEventListener("click", backdropClickHandler); document.body.appendChild(backdropElement); @@ -143,7 +142,8 @@ export function createColumnDragHandle(config: ColumnDragHandleConfig): { dropdownElement = document.createElement("div"); dropdownElement.className = "max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 shadow-raised-200"; - dropdownElement.style.cssText = "position: fixed; z-index: 100;"; + dropdownElement.style.position = "fixed"; + dropdownElement.style.zIndex = "100"; // Create and append dropdown content const content = createDropdownContent(getDropdownOptions()); diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.ts index daceb0377ea..754fe27c765 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.ts +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.ts @@ -53,8 +53,7 @@ export function createRowDragHandle(config: RowDragHandleConfig): { // Create button const button = document.createElement("button"); button.type = "button"; - button.className = - "py-1 bg-layer-1 border border-strong-1 rounded-sm outline-none transition-all duration-200 hover:bg-layer-1-hover"; + button.className = "default-state"; // Create icon (Ellipsis lucide icon as SVG) const icon = createSvgElement(DRAG_HANDLE_ICONS.ellipsis, "size-4 text-primary"); @@ -89,9 +88,8 @@ export function createRowDragHandle(config: RowDragHandleConfig): { isDropdownOpen = false; - // Reset button to closed state - button.className = - "py-1 bg-layer-1 border border-strong-1 rounded-sm outline-none transition-all duration-200 hover:bg-layer-1-hover"; + // Reset button to default state + button.className = "default-state"; // Remove dropdown and backdrop if (dropdownElement) { @@ -126,15 +124,16 @@ export function createRowDragHandle(config: RowDragHandleConfig): { isDropdownOpen = true; // Update button to open state - button.className = - "py-1 bg-layer-1 border border-strong-1 rounded-sm outline-none transition-all duration-200 opacity-100 bg-accent-primary border-accent-strong"; + button.className = "open-state"; // Add active dropdown extension editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE); // Create backdrop backdropElement = document.createElement("div"); - backdropElement.style.cssText = "position: fixed; inset: 0; z-index: 99;"; + backdropElement.style.position = "fixed"; + backdropElement.style.inset = "0"; + backdropElement.style.zIndex = "99"; backdropClickHandler = closeDropdown; backdropElement.addEventListener("click", backdropClickHandler); document.body.appendChild(backdropElement); @@ -143,7 +142,8 @@ export function createRowDragHandle(config: RowDragHandleConfig): { dropdownElement = document.createElement("div"); dropdownElement.className = "max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 shadow-raised-200"; - dropdownElement.style.cssText = "position: fixed; z-index: 100;"; + dropdownElement.style.position = "fixed"; + dropdownElement.style.zIndex = "100"; // Create and append dropdown content const content = createDropdownContent(getDropdownOptions()); diff --git a/packages/editor/src/styles/table.css b/packages/editor/src/styles/table.css index 5066204d400..72f8a2593da 100644 --- a/packages/editor/src/styles/table.css +++ b/packages/editor/src/styles/table.css @@ -57,7 +57,15 @@ .table-col-handle-container, .table-row-handle-container { & > button { - opacity: 0; + @apply py-1 opacity-0 rounded-sm outline-none; + + &.default-state { + @apply bg-layer-1 hover:bg-layer-1-hover border border-strong-1; + } + + &.open-state { + @apply opacity-100! bg-accent-primary border-accent-strong; + } } } From 79cb0d38609f1aa521cb30c385dbecbfb1750ee4 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Thu, 22 Jan 2026 17:07:51 +0530 Subject: [PATCH 7/7] fix: col drag handle orientation --- packages/editor/src/styles/table.css | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/styles/table.css b/packages/editor/src/styles/table.css index 72f8a2593da..5b30babdeb2 100644 --- a/packages/editor/src/styles/table.css +++ b/packages/editor/src/styles/table.css @@ -57,7 +57,7 @@ .table-col-handle-container, .table-row-handle-container { & > button { - @apply py-1 opacity-0 rounded-sm outline-none; + @apply opacity-0 rounded-sm outline-none; &.default-state { @apply bg-layer-1 hover:bg-layer-1-hover border border-strong-1; @@ -69,6 +69,22 @@ } } + .table-col-handle-container { + & > button { + @apply px-1; + + svg { + @apply rotate-90; + } + } + } + + .table-row-handle-container { + & > button { + @apply py-1; + } + } + &:hover { .table-col-handle-container, .table-row-handle-container {