diff --git a/src/components/TextEditor/TextEditor.vue b/src/components/TextEditor/TextEditor.vue index fed824711..5001ac6e8 100644 --- a/src/components/TextEditor/TextEditor.vue +++ b/src/components/TextEditor/TextEditor.vue @@ -21,6 +21,24 @@ /> + @@ -47,12 +65,12 @@ import Typography from '@tiptap/extension-typography' import { TextStyleKit } from '@tiptap/extension-text-style' import { TaskList, TaskItem } from '@tiptap/extension-list' import TextAlign from '@tiptap/extension-text-align' -import { - Table, - TableRow, - TableCell, - TableHeader, -} from '@tiptap/extension-table' +// import { +// Table, +// TableRow, +// TableCell, +// TableHeader, +// } from '@tiptap/extension-table' import { ImageExtension } from './extensions/image' import { VideoExtension } from './extensions/video-extension' @@ -70,6 +88,13 @@ import { ContentPasteExtension } from './extensions/content-paste-extension' import { Heading } from './extensions/heading/heading' import { ImageGroup } from './extensions/image-group/image-group-extension' import { ExtendedCode, ExtendedCodeBlock } from './extensions/code-block' +import TableExtension from './extensions/tables/table-extension' +import TableCellExtension from './extensions/tables/table-cell-extension' +import TableHeaderExtension from './extensions/tables/table-header-extension' +import TableRowExtension from './extensions/tables/table-row-extension' +import TableBorderMenu from './extensions/tables/TableBorderMenu.vue' +import { useTableMenu } from './extensions/tables/use-table-menu' +import { TableCommandsExtension } from './extensions/tables/table-selection-extension' import TextEditorFixedMenu from './components/TextEditorFixedMenu.vue' import TextEditorBubbleMenu from './components/TextEditorBubbleMenu.vue' @@ -175,12 +200,13 @@ onMounted(() => { ? props.starterkitOptions.heading : {}), }), - Table.configure({ - resizable: true, + TableExtension.configure({ + resizable: false, }), - TableRow, - TableHeader, - TableCell, + TableCellExtension, + TableHeaderExtension, + TableRowExtension, + TableCommandsExtension, TaskList, TaskItem.configure({ nested: true, @@ -269,6 +295,25 @@ defineExpose({ editor, rootRef, }) + +const { + showTableBorderMenu, + tableBorderAxis, + tableBorderMenuPos, + tableCellInfo, + canMergeCells, + addRowBefore, + addRowAfter, + deleteRow, + addColumnBefore, + addColumnAfter, + deleteColumn, + mergeCells, + toggleHeader, + setBackgroundColor, + setBorderColor, + setBorderWidth, +} = useTableMenu(editor) diff --git a/src/components/TextEditor/extensions/tables/TableBorderMenuContainer.vue b/src/components/TextEditor/extensions/tables/TableBorderMenuContainer.vue new file mode 100644 index 000000000..dedd6f05f --- /dev/null +++ b/src/components/TextEditor/extensions/tables/TableBorderMenuContainer.vue @@ -0,0 +1,304 @@ + + + diff --git a/src/components/TextEditor/extensions/tables/custom-column-resizing.ts b/src/components/TextEditor/extensions/tables/custom-column-resizing.ts new file mode 100644 index 000000000..83e9d4f3e --- /dev/null +++ b/src/components/TextEditor/extensions/tables/custom-column-resizing.ts @@ -0,0 +1,173 @@ +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { EditorView } from '@tiptap/pm/view' +import { findTable, TableMap } from '@tiptap/pm/tables' +import { Editor } from '@tiptap/core' + +export const customColumnResizingPluginKey = new PluginKey('customColumnResizing') + +export function customColumnResizingPlugin(editor: Editor) { + let isResizing = false + let startX = 0 + let startWidths: number[] = [] + let columnIndex = -1 + let tableStart = 0 + let tableMap: TableMap | null = null + let tableElement: HTMLElement | null = null + + return new Plugin({ + key: customColumnResizingPluginKey, + priority: 5000, + props: { + handleDOMEvents: { + mousedown(view: EditorView, event: MouseEvent) { + const target = event.target as HTMLElement + const handle = target.closest('.column-resize-handle') + + if (!handle) return false + + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + + const tableElement = handle.closest('table') as HTMLElement + if (!tableElement) return false + + let table = null + let pos = 0 + view.state.doc.descendants((node, nodePos) => { + if (node.type.name === 'table' && !table) { + const domNode = view.nodeDOM(nodePos) + if (domNode === tableElement) { + table = { node, start: nodePos } + pos = nodePos + return false + } + } + }) + + if (!table) { + const tableFromSelection = findTable(view.state.selection) + if (tableFromSelection) { + table = tableFromSelection + pos = tableFromSelection.start + } else { + return false + } + } + + tableStart = pos + tableMap = TableMap.get(table.node) + tableElement = view.nodeDOM(tableStart) as HTMLElement + + if (!tableElement || !tableMap) return false + + const handleParent = handle.parentElement + if (!handleParent) return false + + const firstRow = tableElement.querySelector('tr') + if (!firstRow) return false + + const cells = Array.from(firstRow.querySelectorAll('td, th')) as HTMLElement[] + + const cellWithHandle = handle.closest('td, th') as HTMLElement + if (!cellWithHandle) return false + + columnIndex = cells.findIndex(cell => { + const cellRect = cell.getBoundingClientRect() + const handleCellRect = cellWithHandle.getBoundingClientRect() + return Math.abs(cellRect.left - handleCellRect.left) < 5 + }) + + if (columnIndex === -1) { + const handleRect = handle.getBoundingClientRect() + const handleX = handleRect.left + + for (let i = 0; i < cells.length; i++) { + const cellRect = cells[i].getBoundingClientRect() + if (Math.abs(handleX - cellRect.right) < 10) { + columnIndex = i + break + } + } + } + + if (columnIndex === -1) return false + + startWidths = [] + const cellsInColumn = tableMap.cellsInColumn(columnIndex) + cellsInColumn.forEach((cellPos) => { + const pos = tableStart + cellPos + 1 + const domCell = view.nodeDOM(pos) as HTMLElement + if (domCell) { + startWidths.push(domCell.offsetWidth) + } + }) + + if (startWidths.length === 0) return false + + isResizing = true + startX = event.clientX + + document.body.classList.add('resizing-table') + editor.setEditable(false) + + const prosemirrorEl = view.dom as HTMLElement + prosemirrorEl.setAttribute('contenteditable', 'false') + prosemirrorEl.style.userSelect = 'none' + prosemirrorEl.style.cursor = 'col-resize' + + const onMouseMove = (e: MouseEvent) => { + if (!isResizing || !tableMap || !tableElement) return + e.preventDefault() + e.stopPropagation() + + const diff = e.clientX - startX + const baseWidth = startWidths[0] || 50 + const newWidth = Math.max(50, baseWidth + diff) + const cellsInColumn = tableMap.cellsInColumn(columnIndex) + cellsInColumn.forEach((cellPos, idx) => { + const pos = tableStart + cellPos + 1 + const domCell = view.nodeDOM(pos) as HTMLElement + if (domCell) { + domCell.style.width = `${newWidth}px` + domCell.style.minWidth = `${newWidth}px` + } + }) + } + + const onMouseUp = () => { + if (!isResizing) return + + isResizing = false + document.body.classList.remove('resizing-table') + editor.setEditable(true) + const prosemirrorEl = editor.view.dom as HTMLElement + prosemirrorEl.setAttribute('contenteditable', 'true') + prosemirrorEl.style.userSelect = '' + prosemirrorEl.style.cursor = '' + + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + + document.addEventListener('mousemove', onMouseMove, true) + document.addEventListener('mouseup', onMouseUp, true) + + return true + }, + mousemove(view: EditorView, event: MouseEvent) { + if (isResizing) { + const target = event.target as HTMLElement + if (!target.closest('.column-resize-handle')) { + event.preventDefault() + event.stopPropagation() + return true + } + } + return false + }, + }, + }, + }) +} + diff --git a/src/components/TextEditor/extensions/tables/table-border-menu-plugin.ts b/src/components/TextEditor/extensions/tables/table-border-menu-plugin.ts new file mode 100644 index 000000000..b8c6c8a1e --- /dev/null +++ b/src/components/TextEditor/extensions/tables/table-border-menu-plugin.ts @@ -0,0 +1,943 @@ +import { Plugin, PluginKey, EditorState } from '@tiptap/pm/state' +import { TextSelection } from '@tiptap/pm/state' +import { Editor } from '@tiptap/core' +import LucideGripVertical from '~icons/lucide/grip-vertical?raw' +import { CellSelection, findTable, TableMap } from '@tiptap/pm/tables' +import { Decoration, DecorationSet } from '@tiptap/pm/view' + +export const tableBorderMenuPluginKey = new PluginKey('tableBorderMenu') + +export function tableBorderMenuPlugin(editor: Editor) { + let currentRowHandle: HTMLElement | null = null + let currentColHandle: HTMLElement | null = null + let currentCellTrigger: HTMLElement | null = null + let currentTableId: string | null = null + let hideTimeout: NodeJS.Timeout | null = null + let cellTriggerTimeout: NodeJS.Timeout | null = null + let isResizing = false + let rowColHandlerJustClicked = false + let cellTriggerJustClicked = false + + const clearHandles = () => { + if (hideTimeout) clearTimeout(hideTimeout) + hideTimeout = setTimeout(() => { + currentRowHandle?.remove() + currentColHandle?.remove() + currentRowHandle = null + currentColHandle = null + currentTableId = null + }, 100) + } + + const clearCellTrigger = () => { + if (cellTriggerTimeout) clearTimeout(cellTriggerTimeout) + cellTriggerTimeout = setTimeout(() => { + currentCellTrigger?.remove() + currentCellTrigger = null + }, 100) + } + + const cancelCellTriggerClear = () => { + if (cellTriggerTimeout) { + clearTimeout(cellTriggerTimeout) + cellTriggerTimeout = null + } + } + + const cancelClear = () => { + if (hideTimeout) { + clearTimeout(hideTimeout) + hideTimeout = null + } + } + + const hideAllHandles = () => { + if (hideTimeout) { + clearTimeout(hideTimeout) + hideTimeout = null + } + if (cellTriggerTimeout) { + clearTimeout(cellTriggerTimeout) + cellTriggerTimeout = null + } + currentRowHandle?.remove() + currentColHandle?.remove() + currentCellTrigger?.remove() + currentRowHandle = null + currentColHandle = null + currentCellTrigger = null + document.querySelectorAll('.table-cell-trigger-overlay').forEach(el => { + el.remove() + }) + document.querySelectorAll('.table-row-handle-overlay').forEach(el => { + el.remove() + }) + document.querySelectorAll('.table-col-handle-overlay').forEach(el => { + el.remove() + }) + } + + return new Plugin({ + key: tableBorderMenuPluginKey, + state: { + init() { + return DecorationSet.empty + }, + apply(tr, set, oldState, newState) { + return set.map(tr.mapping, tr.doc) + } + }, + props: { + decorations(state) { + return this.getState(state) + }, + handleDOMEvents: { + mousedown(view, event) { + return false + }, + click(view, event) { + return false + }, + mousemove(view, event) { + if (!editor.isEditable) { + clearHandles() + clearCellTrigger() + return false + } + + const target = event.target as HTMLElement + + if (document.body.classList.contains('resizing-table') || target.closest('.column-resize-handle')) { + isResizing = true + hideAllHandles() + document.querySelectorAll('.table-cell-trigger-overlay').forEach(el => { + el.remove() + }) + return false + } + + if (isResizing && !document.body.classList.contains('resizing-table')) { + isResizing = false + } + + if (isResizing) { + currentCellTrigger?.remove() + currentCellTrigger = null + return false + } + + if ( + target.closest('.table-row-handle-overlay') || + target.closest('.table-col-handle-overlay') || + target.closest('.table-cell-trigger-overlay') + ) { + cancelClear() + cancelCellTriggerClear() + return false + } + + if (isResizing) { + return false + } + + const cell = target.closest('td, th') + if (!cell || !cell.closest('.ProseMirror table')) { + clearHandles() + const { selection } = view.state + if (!(selection instanceof CellSelection)) { + clearCellTrigger() + } + return false + } + cancelClear() + + const { selection } = view.state + const isCellSelection = selection instanceof CellSelection + if (isCellSelection) { + currentRowHandle?.remove() + currentColHandle?.remove() + currentRowHandle = null + currentColHandle = null + cancelCellTriggerClear() + } else { + cancelCellTriggerClear() + } + + const row = cell.closest('tr')! + const table = cell.closest('table')! + + const tableId = Array.from( + view.dom.querySelectorAll('.ProseMirror table'), + ) + .indexOf(table as HTMLTableElement) + .toString() + + if (currentTableId && currentTableId !== tableId) { + currentRowHandle?.remove() + currentColHandle?.remove() + currentRowHandle = null + currentColHandle = null + currentTableId = null + } + + currentTableId = tableId + + const rowIndex = Array.from(table.querySelectorAll('tr')).indexOf( + row as HTMLTableRowElement, + ) + const colIndex = Array.from(row.querySelectorAll('td, th')).indexOf( + cell as HTMLTableCellElement, + ) + + if ( + currentRowHandle && + (currentRowHandle.getAttribute('data-row-id') !== + String(rowIndex) || + currentRowHandle.getAttribute('data-table-id') !== tableId) + ) { + currentRowHandle.remove() + currentRowHandle = null + } + if ( + currentColHandle && + (currentColHandle.getAttribute('data-col-id') !== + String(colIndex) || + currentColHandle.getAttribute('data-table-id') !== tableId) + ) { + currentColHandle.remove() + currentColHandle = null + } + + let editorElement = view.dom.parentElement + while (editorElement && getComputedStyle(editorElement).position === 'static') { + editorElement = editorElement.parentElement + } + if (!editorElement) { + editorElement = view.dom.parentElement! + } + + const editorRect = editorElement.getBoundingClientRect() + const tableRect = table.getBoundingClientRect() + + if (!isCellSelection) { + if ( + !currentRowHandle || + currentRowHandle.getAttribute('data-row-id') !== String(rowIndex) || + currentRowHandle.getAttribute('data-table-id') !== tableId + ) { + currentRowHandle?.remove() + + currentRowHandle = document.createElement('div') + currentRowHandle.className = 'table-row-handle-overlay' + + let iconContainer = document.createElement('div') + iconContainer.innerHTML = LucideGripVertical as unknown as string + currentRowHandle.appendChild(iconContainer) + const svg = iconContainer.querySelector('svg') + if (svg) { + svg.style.width = '13px' + svg.style.height = '13px' + } + currentRowHandle.setAttribute('data-row-id', String(rowIndex)) + currentRowHandle.setAttribute('data-table-id', tableId) + + const rowRect = row.getBoundingClientRect() + + currentRowHandle.style.cssText = ` + position: absolute; + left: ${tableRect.left - editorRect.left - 7}px; + top: ${rowRect.top - editorRect.top + rowRect.height / 2 - 10}px; + height: 16px; + width: 12px; + display: flex; + align-items: center; + justify-content: center; + color: var(--ink-gray-7); + cursor: pointer; + z-index: 10; + user-select: none; + background-color: var(--surface-white); + border: 1px solid var(--outline-gray-2); + border-radius: 4px; + box-shadow: 0 1px 2px rgba(0,0,0,0.05); + ` + + currentRowHandle.addEventListener('mouseenter', function () { + this.style.backgroundColor = 'var(--surface-gray-2)' + this.style.borderColor = 'var(--outline-gray-3)' + this.style.color = 'var(--surface-gray-7)' + cancelClear() + }) + + currentRowHandle.addEventListener('mouseleave', function () { + this.style.backgroundColor = 'var(--surface-white)' + this.style.borderColor = 'var(--outline-gray-2)' + this.style.color = 'var(--ink-gray-7)' + clearHandles() + }) + + currentRowHandle.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + + // Set flag to prevent multi-cell menu from showing + rowColHandlerJustClicked = true + setTimeout(() => { + rowColHandlerJustClicked = false + }, 300) + + const cellEl = row.querySelector('td, th') + if (!cellEl) return + + const cellRect = cellEl.getBoundingClientRect() + const rowRect = row.getBoundingClientRect() + const editorRect = editorElement.getBoundingClientRect() + const menuHeight = 30 + const cellPos = view.posAtDOM(cellEl as Node, 0) + editor.commands.focus() + editor.commands.setTextSelection(cellPos) + editor.commands.selectRow(rowIndex) + + const rowHandleLeft = + tableRect.left - editorRect.left - 7 + const rowHandleCenter = rowHandleLeft + 6 + + // Position menu at the center of the row (vertically centered) + const rowCenter = rowRect.top - editorRect.top + rowRect.height / 2 - menuHeight / 2 + + const rowEvent = new CustomEvent('table-border-click', { + bubbles: true, + detail: { + axis: 'row', + position: { + top: rowCenter, + left: rowHandleCenter, + }, + cellInfo: { + element: cellEl, + rowIndex, + colIndex: 0, + isFirstRow: rowIndex === 0, + isIndividualCell: false, + isMultiCellSelection: false, + }, + }, + }) + editorElement.dispatchEvent(rowEvent) + window.dispatchEvent(rowEvent) + }) + + editorElement.appendChild(currentRowHandle) + } + } + if (!isCellSelection) { + if ( + !currentColHandle || + currentColHandle.getAttribute('data-col-id') !== String(colIndex) || + currentColHandle.getAttribute('data-table-id') !== tableId + ) { + currentColHandle?.remove() + + currentColHandle = document.createElement('div') + currentColHandle.className = 'table-col-handle-overlay' + let iconContainer = document.createElement('div') + iconContainer.innerHTML = LucideGripVertical as unknown as string + const svg = iconContainer.querySelector('svg') + if (svg) { + svg.style.width = '13px' + svg.style.height = '13px' + } + + currentColHandle.appendChild(iconContainer) + currentColHandle.setAttribute('data-col-id', String(colIndex)) + currentColHandle.setAttribute('data-table-id', tableId) + + const cellRect = cell.getBoundingClientRect() + + currentColHandle.style.cssText = ` + position: absolute; + left: ${cellRect.left - editorRect.left + cellRect.width / 2 - 10}px; + top: ${tableRect.top - editorRect.top - 7}px; + height: 16px; + width: 12px; + display: flex; + align-items: center; + justify-content: center; + color: var(--ink-gray-7); + cursor: pointer; + z-index: 10; + rotate: 90deg; + user-select: none; + background-color: var(--surface-white); + border: 1px solid var(--outline-gray-2); + border-radius: 4px; + box-shadow: 0 1px 2px rgba(0,0,0,0.05); + ` + + currentColHandle.addEventListener('mouseenter', function () { + this.style.backgroundColor = 'var(--surface-gray-2)' + this.style.borderColor = 'var(--outline-gray-3)' + this.style.color = 'var(--surface-gray-7)' + cancelClear() + }) + + currentColHandle.addEventListener('mouseleave', function () { + this.style.backgroundColor = 'var(--surface-white)' + this.style.borderColor = 'var(--outline-gray-2)' + this.style.color = 'var(--ink-gray-7)' + clearHandles() + }) + + currentColHandle.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + + // Set flag to prevent multi-cell menu from showing + rowColHandlerJustClicked = true + setTimeout(() => { + rowColHandlerJustClicked = false + }, 300) + + const cellRect = cell.getBoundingClientRect() + const editorRect = editorElement.getBoundingClientRect() + const menuHeight = 30 + const gap = 12 + + const cellPos = view.posAtDOM(cell as Node, 0) + editor.commands.focus() + editor.commands.setTextSelection(cellPos) + editor.commands.selectColumn(colIndex) + + // Position menu above the table with some space + const tableTop = tableRect.top - editorRect.top + + const columnEvent = new CustomEvent('table-border-click', { + bubbles: true, + detail: { + axis: 'column', + position: { + top: tableTop - menuHeight - gap, + left: + cellRect.left - + editorRect.left + + cellRect.width / 2, + }, + cellInfo: { + element: cell, + rowIndex, + colIndex, + isFirstRow: rowIndex === 0, + isIndividualCell: false, + isMultiCellSelection: false, + }, + }, + }) + editorElement.dispatchEvent(columnEvent) + window.dispatchEvent(columnEvent) + }) + + editorElement.appendChild(currentColHandle) + } + } + + if (isResizing) { + currentCellTrigger?.remove() + currentCellTrigger = null + return false + } + + const cellId = `${tableId}-${rowIndex}-${colIndex}` + if (!currentCellTrigger || currentCellTrigger.getAttribute('data-cell-id') !== cellId) { + currentCellTrigger?.remove() + + currentCellTrigger = document.createElement('div') + currentCellTrigger.className = 'table-cell-trigger-overlay' + currentCellTrigger.setAttribute('data-cell-id', cellId) + + const cellRect = cell.getBoundingClientRect() + currentCellTrigger.style.cssText = ` + position: absolute; + left: ${cellRect.left - editorRect.left + cellRect.width - 9}px; + top: ${cellRect.top - editorRect.top + cellRect.height / 2 - 9}px; + display: flex; + align-items: center; + justify-content: center; + color: var(--outline-gray-2); + cursor: pointer; + z-index: 10; + user-select: none; + ` + + const svgNS = 'http://www.w3.org/2000/svg' + const svg = document.createElementNS(svgNS, 'svg') + svg.setAttribute('viewBox', '0 0 24 24') + svg.setAttribute('width', '18') + svg.setAttribute('height', '18') + svg.setAttribute('fill', 'currentColor') + const circle = document.createElementNS(svgNS, 'circle') + circle.setAttribute('cx', '12') + circle.setAttribute('cy', '12') + circle.setAttribute('r', '4') + svg.appendChild(circle) + currentCellTrigger.appendChild(svg) + + + currentCellTrigger.addEventListener('mouseenter', () => { + if (currentCellTrigger) { + currentCellTrigger.style.color = 'var(--surface-gray-7)' + cancelCellTriggerClear() + } + }) + currentCellTrigger.addEventListener('mouseleave', () => { + if (currentCellTrigger) { + currentCellTrigger.style.color = 'var(--outline-gray-2)' + clearCellTrigger() + } + }) + currentCellTrigger.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + + // Set flag to prevent menu position from changing + cellTriggerJustClicked = true + setTimeout(() => { + cellTriggerJustClicked = false + }, 300) + + const { selection } = view.state + const isCellSelection = selection instanceof CellSelection + const cellPos = view.posAtDOM(cell as Node, 0) + const $cellPos = view.state.doc.resolve(cellPos) + const table = findTable($cellPos) + + // Store the initial position before selection changes + const triggerRect = currentCellTrigger!.getBoundingClientRect() + const editorScrollTop = editorElement.scrollTop + const editorScrollLeft = editorElement.scrollLeft + const initialPosition = { + top: triggerRect.bottom - editorRect.top + editorScrollTop - 25, + left: triggerRect.left - editorRect.left + editorScrollLeft + } + + if (table) { + editor.commands.focus() + const map = TableMap.get(table.node) + const cellIndex = rowIndex * map.width + colIndex + const cellPosInTable = map.map[cellIndex] + const absoluteCellPos = table.start + cellPosInTable + const cellSelection = CellSelection.create(view.state.doc, absoluteCellPos, absoluteCellPos) + const newTr = view.state.tr.setSelection(cellSelection) + view.dispatch(newTr) + } else { + editor.commands.focus() + editor.commands.setTextSelection(cellPos) + } + + // Use the stored initial position + const cellEvent = new CustomEvent('table-border-click', { + bubbles: true, + detail: { + axis: 'cell', + position: initialPosition, + cellInfo: { element: cell, rowIndex, colIndex, isIndividualCell: !isCellSelection, isMultiCellSelection: isCellSelection }, + }, + }) + editorElement.dispatchEvent(cellEvent) + window.dispatchEvent(cellEvent) + }) + + editorElement.appendChild(currentCellTrigger) + } else if (currentCellTrigger && !isResizing) { + const cellRect = cell.getBoundingClientRect() + currentCellTrigger.style.left = `${cellRect.left - editorRect.left + cellRect.width - 9}px` + currentCellTrigger.style.top = `${cellRect.top - editorRect.top + cellRect.height / 2 - 9}px` + currentCellTrigger.style.display = 'flex' + } else if (currentCellTrigger && isResizing) { + currentCellTrigger.remove() + currentCellTrigger = null + } + + return false + }, + }, + }, + view(view) { + let cellSelectionTimeout: NodeJS.Timeout | null = null + let isMouseDown = false + let lastSelection: any = null + let isResizingHandles = false + + const handleMouseDown = (e: Event) => { + const target = e.target as HTMLElement + if (target && target.closest('.column-resize-handle')) { + isResizingHandles = true + hideAllHandles() + } + isMouseDown = true + if (cellSelectionTimeout) { + clearTimeout(cellSelectionTimeout) + cellSelectionTimeout = null + } + } + + const handleMouseUp = () => { + if (!document.body.classList.contains('resizing-table')) { + isResizing = false + isResizingHandles = false + } + isMouseDown = false + setTimeout(() => { + if (!isResizing && !isResizingHandles) { + checkCellSelection(true) + } + }, 150) + } + + const checkResizing = () => { + if (document.body.classList.contains('resizing-table')) { + if (!isResizing) { + isResizing = true + hideAllHandles() + } + document.querySelectorAll('.table-cell-trigger-overlay').forEach(el => { + el.remove() + }) + if (currentCellTrigger) { + currentCellTrigger.remove() + currentCellTrigger = null + } + } else if (isResizing) { + isResizing = false + } + } + + const showMultiCellMenu = () => { + // Check if row/column menu is currently showing - if so, don't show multi-cell menu + // This prevents menu switch when changing colors from row/column menu + const menuAxis = (window as any).__currentTableMenuAxis + if (menuAxis === 'row' || menuAxis === 'column') { + return + } + + const { selection } = view.state + if (!(selection instanceof CellSelection) || !selection.$anchorCell || !selection.$headCell) { + return + } + + const table = findTable(selection.$anchorCell) + if (!table) return + + const map = TableMap.get(table.node) + const anchorRect = map.findCell(selection.$anchorCell.pos - table.start) + const headRect = map.findCell(selection.$headCell.pos - table.start) + + // Get all cells in the selection + const selectedRows = new Set() + const selectedCols = new Set() + + // Iterate through all cells in the selection rectangle + const minRow = Math.min(anchorRect.top, headRect.top) + const maxRow = Math.max(anchorRect.bottom - 1, headRect.bottom - 1) + const minCol = Math.min(anchorRect.left, headRect.left) + const maxCol = Math.max(anchorRect.right - 1, headRect.right - 1) + + // Collect all unique rows and columns in the selection + for (let row = minRow; row <= maxRow; row++) { + for (let col = minCol; col <= maxCol; col++) { + const cellIndex = row * map.width + col + if (cellIndex >= 0 && cellIndex < map.map.length) { + const cellPos = map.map[cellIndex] + if (cellPos !== undefined) { + selectedRows.add(row) + selectedCols.add(col) + } + } + } + } + + const totalRows = map.height + const totalCols = map.width + + // Get DOM element for anchor cell + const anchorCellDOM = view.nodeDOM(selection.$anchorCell.pos) + const firstCell = anchorCellDOM as HTMLElement + if (!firstCell) return + + const tableDOM = firstCell.closest('table') as HTMLTableElement | null + if (!tableDOM) return + + const row = firstCell.closest('tr') as HTMLTableRowElement | null + if (!row) return + + let editorElement = view.dom.parentElement + while (editorElement && getComputedStyle(editorElement).position === 'static') { + editorElement = editorElement.parentElement + } + if (!editorElement) { + editorElement = view.dom.parentElement! + } + + const editorRect = editorElement.getBoundingClientRect() + const editorScrollTop = editorElement.scrollTop + const editorScrollLeft = editorElement.scrollLeft + const tableRect = tableDOM.getBoundingClientRect() + const menuHeight = 30 + const gap = 12 + + // Check if it's a full row selection (one row, all columns) + if (selectedRows.size === 1 && selectedCols.size === totalCols) { + // Full row selection - show row menu + const rowIndex = Array.from(selectedRows)[0] + const rowRect = row.getBoundingClientRect() + const rowHandleLeft = tableRect.left - editorRect.left - 7 + const rowHandleCenter = rowHandleLeft + 6 + const rowCenter = rowRect.top - editorRect.top + rowRect.height / 2 - menuHeight / 2 + + const rowEvent = new CustomEvent('table-border-click', { + bubbles: true, + detail: { + axis: 'row', + position: { + top: rowCenter, + left: rowHandleCenter, + }, + cellInfo: { + element: firstCell, + rowIndex, + colIndex: 0, + isFirstRow: rowIndex === 0, + isIndividualCell: false, + isMultiCellSelection: false, + }, + }, + }) + editorElement.dispatchEvent(rowEvent) + window.dispatchEvent(rowEvent) + return + } + + // Check if it's a full column selection (one column, all rows) + if (selectedCols.size === 1 && selectedRows.size === totalRows) { + // Full column selection - show column menu + const colIndex = Array.from(selectedCols)[0] + const cellRect = firstCell.getBoundingClientRect() + const tableTop = tableRect.top - editorRect.top + + const columnEvent = new CustomEvent('table-border-click', { + bubbles: true, + detail: { + axis: 'column', + position: { + top: tableTop - menuHeight - gap, + left: cellRect.left - editorRect.left + cellRect.width / 2, + }, + cellInfo: { + element: firstCell, + rowIndex: 0, + colIndex, + isFirstRow: true, + isIndividualCell: false, + isMultiCellSelection: false, + }, + }, + }) + editorElement.dispatchEvent(columnEvent) + window.dispatchEvent(columnEvent) + return + } + + // Partial selection - show multi-cell menu + const selectedCellsDOM = view.dom.querySelectorAll('.selectedCell') + let minLeft = Infinity + let maxRight = -Infinity + let minTop = Infinity + let maxBottom = -Infinity + + selectedCellsDOM.forEach((cell) => { + const cellEl = cell as HTMLElement + const rect = cellEl.getBoundingClientRect() + minLeft = Math.min(minLeft, rect.left) + maxRight = Math.max(maxRight, rect.right) + minTop = Math.min(minTop, rect.top) + maxBottom = Math.max(maxBottom, rect.bottom) + }) + + const centerX = (minLeft + maxRight) / 2 + const spaceAbove = minTop - tableRect.top + const spaceBelow = tableRect.bottom - maxBottom + + let finalTop: number + if (spaceAbove >= menuHeight + gap) { + finalTop = minTop - menuHeight - gap + } else if (spaceBelow >= menuHeight + gap) { + finalTop = maxBottom + gap + } else { + finalTop = minTop - menuHeight - gap + } + + const finalLeft = centerX + + // Convert viewport coordinates to editor-relative coordinates (accounting for scroll) + const editorRelativeTop = finalTop - editorRect.top + editorScrollTop + const editorRelativeLeft = finalLeft - editorRect.left + editorScrollLeft + + const isMultiCellSelection = selectedCellsDOM.length > 1 + + const cellEvent = new CustomEvent('table-border-click', { + bubbles: true, + detail: { + axis: 'cell', + position: { + top: editorRelativeTop, + left: editorRelativeLeft, + }, + cellInfo: { + element: firstCell, + rowIndex: minRow, + colIndex: minCol, + isIndividualCell: !isMultiCellSelection, + isMultiCellSelection: true, + }, + }, + }) + editorElement.dispatchEvent(cellEvent) + window.dispatchEvent(cellEvent) + } + + const showSingleCellMenuFromSelection = () => { + const { selection } = view.state + if (!(selection instanceof TextSelection)) return + if (selection.empty) return + if (document.body.classList.contains('resizing-table')) return + + const pos = selection.from + const domInfo = view.domAtPos(pos) + const node = domInfo.node as HTMLElement + const cell = + (node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : node.parentElement)?.closest('td, th') || + null + if (!cell) return + + const table = cell.closest('table') as HTMLTableElement | null + if (!table) return + + let editorElement = view.dom.parentElement + while (editorElement && getComputedStyle(editorElement).position === 'static') { + editorElement = editorElement.parentElement + } + if (!editorElement) { + editorElement = view.dom.parentElement! + } + + const editorRect = editorElement.getBoundingClientRect() + const editorScrollTop = editorElement.scrollTop + const editorScrollLeft = editorElement.scrollLeft + const cellRect = cell.getBoundingClientRect() + + const row = cell.closest('tr') as HTMLTableRowElement | null + if (!row) return + + const rowIndex = Array.from(table.querySelectorAll('tr')).indexOf(row) + const colIndex = Array.from(row.querySelectorAll('td, th')).indexOf(cell as HTMLTableCellElement) + + const centerX = cellRect.left + cellRect.width / 2 + const menuHeight = 30 + const gap = 8 + const finalTop = cellRect.top - menuHeight - gap + const finalLeft = centerX + + // Convert viewport coordinates to editor-relative coordinates (accounting for scroll) + const editorRelativeTop = finalTop - editorRect.top + editorScrollTop + const editorRelativeLeft = finalLeft - editorRect.left + editorScrollLeft + + const cellEvent = new CustomEvent('table-border-click', { + bubbles: true, + detail: { + axis: 'cell', + position: { + top: editorRelativeTop, + left: editorRelativeLeft, + }, + cellInfo: { + element: cell, + rowIndex, + colIndex, + isIndividualCell: true, + isMultiCellSelection: false, + }, + }, + }) + editorElement.dispatchEvent(cellEvent) + window.dispatchEvent(cellEvent) + } + + const checkCellSelection = (forceCheck = false) => { + if (isMouseDown && !forceCheck) return + + // Don't show multi-cell menu if row/column handler or cell trigger was just clicked + if (rowColHandlerJustClicked || cellTriggerJustClicked) { + return + } + + const { selection } = view.state + const isCellSelection = selection instanceof CellSelection + + if (isCellSelection && selection.$anchorCell && selection.$headCell) { + const anchorPos = selection.$anchorCell.pos + const headPos = selection.$headCell.pos + + const selectionChanged = lastSelection === null || + lastSelection.$anchorCell?.pos !== anchorPos || + lastSelection.$headCell?.pos !== headPos + + if (selectionChanged || forceCheck) { + if (cellSelectionTimeout) { + clearTimeout(cellSelectionTimeout) + } + + cellSelectionTimeout = setTimeout(() => { + if (!isMouseDown && !isResizingHandles && !rowColHandlerJustClicked && !cellTriggerJustClicked) { + showMultiCellMenu() + } + }, forceCheck ? 80 : 220) + } + } else { + if (cellSelectionTimeout) { + clearTimeout(cellSelectionTimeout) + cellSelectionTimeout = null + } + if (forceCheck && !isResizingHandles && !cellTriggerJustClicked) { + showSingleCellMenuFromSelection() + } + } + + lastSelection = selection + } + + view.dom.addEventListener('mousedown', handleMouseDown) + document.addEventListener('mouseup', handleMouseUp) + + const resizeCheckInterval = setInterval(checkResizing, 50) + + return { + update(view, prevState) { + checkResizing() + if (view.state.selection !== prevState.selection && !isResizing) { + checkCellSelection() + } + }, + destroy() { + hideAllHandles() + if (cellSelectionTimeout) { + clearTimeout(cellSelectionTimeout) + } + if (resizeCheckInterval) { + clearInterval(resizeCheckInterval) + } + view.dom.removeEventListener('mousedown', handleMouseDown) + document.removeEventListener('mouseup', handleMouseUp) + }, + } + }, + }) +} diff --git a/src/components/TextEditor/extensions/tables/table-cell-extension.ts b/src/components/TextEditor/extensions/tables/table-cell-extension.ts new file mode 100644 index 000000000..2dce26b0a --- /dev/null +++ b/src/components/TextEditor/extensions/tables/table-cell-extension.ts @@ -0,0 +1,80 @@ +import { TableCell } from '@tiptap/extension-table' + +export const TableCellExtension = TableCell.extend({ + addAttributes() { + return { + ...this.parent?.(), + backgroundColor: { + parseHTML: (element) => { + const tag = element.tagName.toLowerCase() + if (tag !== 'td' && tag !== 'th') { + return null + } + }, + renderHTML(attributes) { + if (!attributes.backgroundColor) { + return {} + } + return { + class: `${attributes.backgroundColor}`, + } + }, + }, + borderColor: { + parseHTML: (element) => { + const tag = element.tagName.toLowerCase() + if (tag !== 'td' && tag !== 'th') { + return null + } + }, + renderHTML(attributes) { + if (!attributes.borderColor) { + return {} + } + return { + class: `${attributes.borderColor}-border`, + } + }, + }, + borderWidth: { + default: null, + parseHTML: (element) => { + const tag = element.tagName.toLowerCase() + if (tag !== 'td' && tag !== 'th') { + return null + } + const style = element.getAttribute('style') || '' + const borderWidthMatch = style.match(/border-width:\s*(\d+px)/i) + if (borderWidthMatch) { + return borderWidthMatch[1] + } + const classList = element.classList + const borderWidthClassMatch = Array.from(classList).find( + (cls) => + typeof cls === 'string' && + cls.startsWith('border-') && + /^border-\d+$/.test(cls), + ) + if (borderWidthClassMatch) { + const width = (borderWidthClassMatch as string).replace( + 'border-', + '', + ) + return `${width}px` + } + return null + }, + renderHTML(attributes) { + if (!attributes.borderWidth) { + return {} + } + return { + style: `border-width: ${attributes.borderWidth};`, + } + }, + }, + } + }, +}) + +export default TableCellExtension diff --git a/src/components/TextEditor/extensions/tables/table-extension.ts b/src/components/TextEditor/extensions/tables/table-extension.ts new file mode 100644 index 000000000..e248963b5 --- /dev/null +++ b/src/components/TextEditor/extensions/tables/table-extension.ts @@ -0,0 +1,82 @@ +import { Table } from '@tiptap/extension-table' +import { columnResizing } from '@tiptap/pm/tables' +import { tableBorderMenuPlugin } from './table-border-menu-plugin' + +export const TableExtension = Table.extend({ + addAttributes() { + return { + backgroundColor: { + parseHTML: (element) => { + if ( + !element.closest('table') && + element.tagName.toLowerCase() !== 'table' + ) { + return null + } + }, + renderHTML(attributes) { + if (!attributes.backgroundColor) { + return {} + } + return { + class: `${attributes.backgroundColor}`, + } + }, + }, + borderColor: { + default: null, + parseHTML: (element) => {}, + renderHTML(attributes) { + if (!attributes.borderColor) { + return {} + } + return { + class: `${attributes.borderColor}!`, + } + }, + }, + borderWidth: { + parseHTML: (element) => { + if ( + !element.closest('table') && + element.tagName.toLowerCase() !== 'table' + ) { + return null + } + const classList = element.classList + const borderWidthClassMatch = Array.from(classList).find( + (cls) => cls.startsWith('border-') && /^border-\d+$/.test(cls), + ) + if (borderWidthClassMatch) { + return borderWidthClassMatch.replace('border-', '') + } + return null + }, + renderHTML(attributes) { + if (!attributes.borderWidth) { + return {} + } + return { + class: `border-${attributes.borderWidth}`, + } + }, + }, + } + }, + + addProseMirrorPlugins() { + return [ + tableBorderMenuPlugin(this.editor), + ...(this.parent?.() ?? []), + columnResizing({ + handleWidth: this.options.handleWidth, + cellMinWidth: this.options.cellMinWidth, + defaultCellMinWidth: this.options.cellMinWidth, + View: this.options.View, + lastColumnResizable: this.options.lastColumnResizable, + }), + ] + }, +}) + +export default TableExtension diff --git a/src/components/TextEditor/extensions/tables/table-header-extension.ts b/src/components/TextEditor/extensions/tables/table-header-extension.ts new file mode 100644 index 000000000..8343205cf --- /dev/null +++ b/src/components/TextEditor/extensions/tables/table-header-extension.ts @@ -0,0 +1,77 @@ +import { TableHeader } from '@tiptap/extension-table' + +export const TableHeaderExtension = TableHeader.extend({ + addAttributes() { + return { + ...this.parent?.(), + backgroundColor: { + parseHTML: (element) => { + if (element.tagName.toLowerCase() !== 'th') { + return null + } + }, + renderHTML(attributes) { + if (!attributes.backgroundColor) { + return {} + } + return { + class: `${attributes.backgroundColor}`, + } + }, + }, + borderColor: { + parseHTML: (element) => { + if (element.tagName.toLowerCase() !== 'th') { + return null + } + }, + renderHTML(attributes) { + if (!attributes.borderColor) { + return {} + } + return { + class: `${attributes.borderColor}-border`, + } + }, + }, + borderWidth: { + default: null, + parseHTML: (element) => { + if (element.tagName.toLowerCase() !== 'th') { + return null + } + const style = element.getAttribute('style') || '' + const borderWidthMatch = style.match(/border-width:\s*(\d+px)/i) + if (borderWidthMatch) { + return borderWidthMatch[1] + } + const classList = element.classList + const borderWidthClassMatch = Array.from(classList).find( + (cls) => + typeof cls === 'string' && + cls.startsWith('border-') && + /^border-\d+$/.test(cls), + ) + if (borderWidthClassMatch) { + const width = (borderWidthClassMatch as string).replace( + 'border-', + '', + ) + return `${width}px` + } + return null + }, + renderHTML(attributes) { + if (!attributes.borderWidth) { + return {} + } + return { + style: `border-width: ${attributes.borderWidth};`, + } + }, + }, + } + }, +}) + +export default TableHeaderExtension diff --git a/src/components/TextEditor/extensions/tables/table-individual-cell-plugin.ts b/src/components/TextEditor/extensions/tables/table-individual-cell-plugin.ts new file mode 100644 index 000000000..b69a6caa5 --- /dev/null +++ b/src/components/TextEditor/extensions/tables/table-individual-cell-plugin.ts @@ -0,0 +1,266 @@ +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { Editor } from '@tiptap/core' +import LucideCircle from '~icons/lucide/circle?raw' +import { CellSelection } from 'prosemirror-tables' + +export const tableIndividualCellPluginKey = new PluginKey('tableCellMenu') + +let isResizing = false + +if (typeof window !== 'undefined') { + window.addEventListener('table-resize-start', () => { + isResizing = true + }) + window.addEventListener('table-resize-end', () => { + isResizing = false + }) +} + +export function tableBorderMenuPlugin(editor: Editor) { + let currentCellHandle: HTMLElement | null = null + let currentTableId: string | null = null + let hideTimeout: NodeJS.Timeout | null = null + + const clearHandles = () => { + if (hideTimeout) clearTimeout(hideTimeout) + hideTimeout = setTimeout(() => { + currentCellHandle?.remove() + currentCellHandle = null + currentTableId = null + }, 100) + } + + const cancelClear = () => { + if (hideTimeout) { + clearTimeout(hideTimeout) + hideTimeout = null + } + } + + const removeHandleImmediately = () => { + if (hideTimeout) { + clearTimeout(hideTimeout) + hideTimeout = null + } + currentCellHandle?.remove() + currentCellHandle = null + if (typeof document !== 'undefined') { + document.querySelectorAll('.table-row-handle-overlay').forEach(el => { + const hasCircle = el.querySelector('svg circle') + if (hasCircle) { + (el as HTMLElement).remove() + } + }) + } + } + + return new Plugin({ + key: tableIndividualCellPluginKey, + props: { + handleDOMEvents: { + mousemove(view, event) { + if (isResizing) { + if (currentCellHandle) { + removeHandleImmediately() + } + return false + } + + const target = event.target as HTMLElement + + if (!editor.isEditable) { + clearHandles() + return false + } + + if ( + target.closest('.table-row-handle-overlay') || + target.closest('.table-col-handle-overlay') + ) { + cancelClear() + return false + } + + const cell = target.closest('td, th') + if (!cell || !cell.closest('.ProseMirror table')) { + clearHandles() + return false + } + + cancelClear() + + const row = cell.closest('tr')! + const table = cell.closest('table')! + + const tableId = Array.from( + view.dom.querySelectorAll('.ProseMirror table'), + ) + .indexOf(table as HTMLTableElement) + .toString() + + if (currentTableId && currentTableId !== tableId) { +currentCellHandle?.remove() + currentCellHandle = null + currentTableId = null + } + + currentTableId = tableId + + const rowIndex = Array.from(table.querySelectorAll('tr')).indexOf( + row as HTMLTableRowElement, + ) + const colIndex = Array.from(row.querySelectorAll('td, th')).indexOf( + cell as HTMLTableCellElement, + ) + + if ( + currentCellHandle && + (currentCellHandle.getAttribute('data-row-id') !== + String(rowIndex) || + currentCellHandle.getAttribute('data-table-id') !== tableId) + ) { + currentCellHandle.remove() + currentCellHandle = null + } + + const editorElement = view.dom.parentElement! + const editorRect = editorElement.getBoundingClientRect() + const tableRect = table.getBoundingClientRect() + const editorScrollLeft = editorElement.scrollLeft + const editorScrollTop = editorElement.scrollTop + const rowRect = row.getBoundingClientRect() + + // Calculate position for the handle + const handleLeft = tableRect.left - editorRect.left + editorScrollLeft - 7 + const handleTop = rowRect.top - editorRect.top + editorScrollTop + rowRect.height / 2 - 10 + + if ( + !currentCellHandle || + currentCellHandle.getAttribute('data-row-id') !== String(rowIndex) || + currentCellHandle.getAttribute('data-table-id') !== tableId + ) { + currentCellHandle?.remove() + + currentCellHandle = document.createElement('div') + currentCellHandle.className = 'table-row-handle-overlay' + + let iconContainer = document.createElement('div') + iconContainer.innerHTML = LucideCircle as unknown as string + currentCellHandle.appendChild(iconContainer) + const svg = iconContainer.querySelector('svg') + if (svg) { + svg.style.width = '13px' + svg.style.height = '13px' + } + currentCellHandle.setAttribute('data-row-id', String(rowIndex)) + currentCellHandle.setAttribute('data-table-id', tableId) + + currentCellHandle.style.cssText = ` + position: absolute; + left: ${handleLeft}px; + top: ${handleTop}px; + height: 16px; + width: 12px; + display: flex; + align-items: center; + justify-content: center; + color: var(--ink-gray-7); + cursor: pointer; + z-index: 10; + user-select: none; + background-color: var(--surface-white); + border: 1px solid var(--outline-gray-2); + border-radius: 4px; + box-shadow: 0 1px 2px rgba(0,0,0,0.05); + ` + + currentCellHandle.addEventListener('mouseenter', function () { + this.style.backgroundColor = 'var(--surface-gray-2)' + this.style.borderColor = 'var(--outline-gray-3)' + this.style.color = 'var(--surface-gray-7)' + cancelClear() + }) + + currentCellHandle.addEventListener('mouseleave', function () { + this.style.backgroundColor = 'var(--surface-white)' + this.style.borderColor = 'var(--outline-gray-2)' + this.style.color = 'var(--ink-gray-7)' + clearHandles() + }) + + currentCellHandle.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + + const cellEl = row.querySelector('td, th') + if (!cellEl) return + + const cellRect = cellEl.getBoundingClientRect() + const editorRect = editorElement.getBoundingClientRect() + const menuHeight = 40 + const gap = 8 + + const { selection } = view.state + const isCellSelection = selection instanceof CellSelection + + if (!isCellSelection) { + const cellPos = view.posAtDOM(cellEl as Node, 0) + editor.commands.focus() + editor.commands.setTextSelection(cellPos) + editor.commands.selectRow(rowIndex) + } else { + editor.commands.focus() + editor.commands.selectRow(rowIndex) + } + + const rowEvent = new CustomEvent('table-border-click', { + bubbles: true, + detail: { + axis: 'row', + position: { + top: cellRect.top - editorRect.top + editorScrollTop - menuHeight - gap, + left: cellRect.left - editorRect.left + editorScrollLeft, + }, + cellInfo: { + element: cellEl, + rowIndex, + colIndex: 0, + }, + }, + }) + editorElement.dispatchEvent(rowEvent) + window.dispatchEvent(rowEvent) + }) + + editorElement.appendChild(currentCellHandle) + } else { + // Update position instantly for existing handle + currentCellHandle.style.left = `${handleLeft}px` + currentCellHandle.style.top = `${handleTop}px` + } + return false + }, + }, + }, + view() { + const onResizeStart = () => { + removeHandleImmediately() + } + + const onResizeEnd = () => { + } + + window.addEventListener('table-resize-start', onResizeStart) + window.addEventListener('table-resize-end', onResizeEnd) + + return { + destroy() { + window.removeEventListener('table-resize-start', onResizeStart) + window.removeEventListener('table-resize-end', onResizeEnd) + currentCellHandle?.remove() + currentCellHandle = null + }, + } + }, + }) +} diff --git a/src/components/TextEditor/extensions/tables/table-row-extension.ts b/src/components/TextEditor/extensions/tables/table-row-extension.ts new file mode 100644 index 000000000..c79ba9321 --- /dev/null +++ b/src/components/TextEditor/extensions/tables/table-row-extension.ts @@ -0,0 +1,70 @@ +import { TableRow } from '@tiptap/extension-table' + +export const TableRowExtension = TableRow.extend({ + addAttributes() { + return { + ...this.parent?.(), + backgroundColor: { + default: null, + parseHTML: (element) => { + if (element.tagName.toLowerCase() !== 'tr') { + return null + } + }, + renderHTML(attributes) { + if (!attributes.backgroundColor) { + return {} + } + return { + class: `${attributes.backgroundColor}`, + } + }, + }, + borderColor: { + default: null, + parseHTML: (element) => { + if (element.tagName.toLowerCase() !== 'tr') { + return null + } + }, + renderHTML(attributes) { + if (!attributes.borderColor) { + return {} + } + return { + class: `${attributes.borderColor}!`, + } + }, + }, + borderWidth: { + default: null, + parseHTML: (element) => { + if (element.tagName.toLowerCase() !== 'tr') { + return null + } + const classList = element.classList + const borderWidthClassMatch = Array.from(classList).find( + (cls) => + typeof cls === 'string' && + cls.startsWith('border-') && + /^border-\d+$/.test(cls), + ) + if (borderWidthClassMatch) { + return (borderWidthClassMatch as string).replace('border-', '') + } + return null + }, + renderHTML(attributes) { + if (!attributes.borderWidth) { + return {} + } + return { + class: `border-${attributes.borderWidth}`, + } + }, + }, + } + }, +}) + +export default TableRowExtension diff --git a/src/components/TextEditor/extensions/tables/table-selection-extension.ts b/src/components/TextEditor/extensions/tables/table-selection-extension.ts new file mode 100644 index 000000000..f9ccd4830 --- /dev/null +++ b/src/components/TextEditor/extensions/tables/table-selection-extension.ts @@ -0,0 +1,148 @@ +import { Extension } from '@tiptap/core' +import { findTable, TableMap, CellSelection } from '@tiptap/pm/tables' + +declare module '@tiptap/core' { + interface Commands { + tableCommands: { + selectRow: (row?: number) => ReturnType + selectColumn: (col?: number) => ReturnType + } + } +} + +function getCellsInRow(rowIndex: number, table: any, map: TableMap) { + const cells = [] + const seenPositions = new Set() + const rowStart = rowIndex * map.width + + for (let i = 0; i < map.width; i++) { + const cellPos = map.map[rowStart + i] + if (seenPositions.has(cellPos)) { + continue + } + const cell = table.node.nodeAt(cellPos) + if (cell) { + seenPositions.add(cellPos) + cells.push({ + pos: table.start + cellPos, + node: cell, + }) + } + } + + return cells +} + +function getCellsInColumn(colIndex: number, table: any, map: TableMap) { + const cells = [] + const seenPositions = new Set() + + for (let i = 0; i < map.height; i++) { + const cellPos = map.map[i * map.width + colIndex] + if (seenPositions.has(cellPos)) { + continue + } + + const cell = table.node.nodeAt(cellPos) + if (cell) { + seenPositions.add(cellPos) + cells.push({ + pos: table.start + cellPos, + node: cell, + }) + } + } + + return cells +} + +export const TableCommandsExtension = Extension.create({ + name: 'tableCommands', + + addCommands() { + return { + selectRow: + (row?: number) => + ({ tr, state, dispatch }) => { + let table = findTable(state.selection.$from) + + if (!table && row !== undefined) { + let foundTable = false + state.doc.descendants((node, pos) => { + if (node.type.name === 'table' && !foundTable) { + foundTable = true + const $pos = state.doc.resolve(pos + 1) + table = findTable($pos) + return false + } + }) + } + + if (!table) return false + + const map = TableMap.get(table.node) + + let rowIndex = row ?? 0 + if (row === undefined) { + const cellPos = state.selection.$from.pos - table.start + const rect = map.findCell(cellPos) + rowIndex = rect.top + } + + const cells = getCellsInRow(rowIndex, table, map) + if (!cells || cells.length === 0) return false + + if (dispatch) { + const firstCell = cells[0] + const lastCell = cells[cells.length - 1] + const cellSelection = CellSelection.create(tr.doc, firstCell.pos, lastCell.pos) + tr.setSelection(cellSelection) + } + + return true + }, + + selectColumn: + (col?: number) => + ({ tr, state, dispatch }) => { + let table = findTable(state.selection.$from) + + if (!table && col !== undefined) { + let foundTable = false + state.doc.descendants((node, pos) => { + if (node.type.name === 'table' && !foundTable) { + foundTable = true + const $pos = state.doc.resolve(pos + 1) + table = findTable($pos) + return false + } + }) + } + + if (!table) return false + + const map = TableMap.get(table.node) + + let colIndex = col ?? 0 + if (col === undefined) { + const cellPos = state.selection.$from.pos - table.start + const rect = map.findCell(cellPos) + colIndex = rect.left + } + + const cells = getCellsInColumn(colIndex, table, map) + if (!cells || cells.length === 0) return false + + if (dispatch) { + cells.sort((a, b) => a.pos - b.pos) + const firstCell = cells[0] + const lastCell = cells[cells.length - 1] + const cellSelection = CellSelection.create(tr.doc, firstCell.pos, lastCell.pos) + tr.setSelection(cellSelection) + } + + return true + }, + } + }, +}) \ No newline at end of file diff --git a/src/components/TextEditor/extensions/tables/table-styles.css b/src/components/TextEditor/extensions/tables/table-styles.css new file mode 100644 index 000000000..78180086d --- /dev/null +++ b/src/components/TextEditor/extensions/tables/table-styles.css @@ -0,0 +1,232 @@ +.prose table p { + margin: 0; +} + +.ProseMirror table td, +.ProseMirror table th { + position: relative; +} + +.table-row-handle-overlay { + pointer-events: auto; +} + +.ProseMirror table .selectedCell:after { + z-index: 2; + position: absolute; + content: ''; + left: 0; + right: 0; + top: 0; + bottom: 0; + pointer-events: none; + background: theme('colors.blue.200'); + opacity: 0.3; + overflow-x: auto; + display: block; + -webkit-overflow-scrolling: touch; + max-width: 100%; +} + +.ProseMirror table .column-resize-handle { + position: absolute; + right: -1px; + top: 0; + bottom: -2px; + width: 2px; + z-index:5; + background-color: theme('colors.blue.200'); + pointer-events: none; +} + +:root { + --table-bg-red: var(--red-200); + --table-bg-orange: var(--orange-200); + --table-bg-yellow: var(--yellow-200); + --table-bg-green: var(--green-200); + --table-bg-teal: var(--teal-200); + --table-bg-cyan: var(--cyan-200); + --table-bg-blue: var(--blue-200); + --table-bg-purple: var(--purple-200); + --table-bg-pink: var(--pink-200); + --table-bg-gray: var(--gray-200); + --table-border-red: var(--red-500); + --table-border-orange: var(--orange-500); + --table-border-yellow: var(--yellow-500); + --table-border-green: var(--green-500); + --table-border-teal: var(--teal-500); + --table-border-cyan: var(--cyan-500); + --table-border-blue: var(--blue-500); + --table-border-purple: var(--purple-500); + --table-border-pink: var(--pink-500); + --table-border-gray: var(--gray-500); +} + +.writer-table-handle { + pointer-events: auto; + user-select: none; +} +.writer-table-handle:hover { + background: var(--surface-gray-1); + border-color: var(--outline-gray-1); +} + +.ProseMirror table th, +.ProseMirror table td { + position: relative; +} + +.ProseMirror table td.red, +.ProseMirror table th.red { + background-color: var(--table-bg-red); +} +.ProseMirror table td.orange, +.ProseMirror table th.orange { + background-color: var(--table-bg-orange); +} +.ProseMirror table td.amber, +.ProseMirror table th.amber { + background-color: var(--table-bg-yellow); +} +.ProseMirror table td.yellow, +.ProseMirror table th.yellow { + background-color: var(--table-bg-yellow); +} +.ProseMirror table td.lime, +.ProseMirror table th.lime { + background-color: var(--table-bg-green); +} +.ProseMirror table td.green, +.ProseMirror table th.green { + background-color: var(--table-bg-green); +} +.ProseMirror table td.emerald, +.ProseMirror table th.emerald { + background-color: var(--table-bg-teal); +} +.ProseMirror table td.teal, +.ProseMirror table th.teal { + background-color: var(--table-bg-teal); +} +.ProseMirror table td.cyan, +.ProseMirror table th.cyan { + background-color: var(--table-bg-cyan); +} +.ProseMirror table td.sky, +.ProseMirror table th.sky { + background-color: var(--table-bg-cyan); +} +.ProseMirror table td.blue, +.ProseMirror table th.blue { + background-color: var(--table-bg-blue); +} +.ProseMirror table td.indigo, +.ProseMirror table th.indigo { + background-color: var(--table-bg-blue); +} +.ProseMirror table td.violet, +.ProseMirror table th.violet { + background-color: var(--table-bg-purple); +} +.ProseMirror table td.purple, +.ProseMirror table th.purple { + background-color: var(--table-bg-purple); +} +.ProseMirror table td.fuchsia, +.ProseMirror table th.fuchsia { + background-color: var(--table-bg-pink); +} +.ProseMirror table td.pink, +.ProseMirror table th.pink { + background-color: var(--table-bg-pink); +} +.ProseMirror table td.rose, +.ProseMirror table th.rose { + background-color: var(--table-bg-pink); +} +.ProseMirror table td.gray, +.ProseMirror table th.gray { + background-color: var(--table-bg-gray); +} + +.ProseMirror table td.red-border, +.ProseMirror table th.red-border { + border: 2px solid var(--table-border-red); +} +.ProseMirror table td.orange-border, +.ProseMirror table th.orange-border { + border: 2px solid var(--table-border-orange); +} +.ProseMirror table td.amber-border, +.ProseMirror table th.amber-border { + border: 2px solid var(--table-border-yellow); +} +.ProseMirror table td.yellow-border, +.ProseMirror table th.yellow-border { + border: 2px solid var(--table-border-yellow); +} +.ProseMirror table td.lime-border, +.ProseMirror table th.lime-border { + border: 2px solid var(--table-border-green); +} +.ProseMirror table td.green-border, +.ProseMirror table th.green-border { + border: 2px solid var(--table-border-green); +} +.ProseMirror table td.emerald-border, +.ProseMirror table th.emerald-border { + border: 2px solid var(--table-border-teal); +} +.ProseMirror table td.teal-border, +.ProseMirror table th.teal-border { + border: 2px solid var(--table-border-teal); +} +.ProseMirror table td.cyan-border, +.ProseMirror table th.cyan-border { + border: 2px solid var(--table-border-cyan); +} +.ProseMirror table td.sky-border, +.ProseMirror table th.sky-border { + border: 2px solid var(--table-border-cyan); +} +.ProseMirror table td.blue-border, +.ProseMirror table th.blue-border { + border: 2px solid var(--table-border-blue); +} +.ProseMirror table td.indigo-border, +.ProseMirror table th.indigo-border { + border: 2px solid var(--table-border-blue); +} +.ProseMirror table td.violet-border, +.ProseMirror table th.violet-border { + border: 2px solid var(--table-border-purple); +} +.ProseMirror table td.purple-border, +.ProseMirror table th.purple-border { + border: 2px solid var(--table-border-purple); +} +.ProseMirror table td.fuchsia-border, +.ProseMirror table th.fuchsia-border { + border: 2px solid var(--table-border-pink); +} +.ProseMirror table td.pink-border, +.ProseMirror table th.pink-border { + border: 2px solid var(--table-border-pink); +} +.ProseMirror table td.rose-border, +.ProseMirror table th.rose-border { + border: 2px solid var(--table-border-pink); +} +.ProseMirror table td.transparent-border, +.ProseMirror table th.transparent-border { + border: 2px solid transparent; +} +/* .ProseMirror table td.transparent-border:hover, +.ProseMirror table th.transparent-border:hover { + border: 1px solid rgb(98, 179, 255) !important; + transition: border-bottom 0.5s ease-out, transform 0.5s ease-in; +} */ +.ProseMirror table td.gray-border, +.ProseMirror table th.gray-border { + border: 2px solid var(--table-border-gray); +} diff --git a/src/components/TextEditor/extensions/tables/use-table-menu.ts b/src/components/TextEditor/extensions/tables/use-table-menu.ts new file mode 100644 index 000000000..8c20ffc53 --- /dev/null +++ b/src/components/TextEditor/extensions/tables/use-table-menu.ts @@ -0,0 +1,362 @@ +import { ref, computed, onMounted, onBeforeUnmount, type Ref } from 'vue' +import type { Editor } from '@tiptap/vue-3' +import { CellSelection, findTable, TableMap } from '@tiptap/pm/tables' + +export interface TableCellInfo { + element: HTMLElement | null + rowIndex: number + colIndex: number + isFirstRow: boolean +} + +export interface TableBorderMenuPosition { + top: number + left: number +} + +export function useTableMenu(editor: Ref) { + const showTableBorderMenu = ref(false) + const tableBorderAxis = ref<'row' | 'column' | null>(null) + const tableBorderMenuPos = ref({ top: 0, left: 0 }) + const tableCellInfo = ref(null) + let menuJustOpened = false + let isChangingColor = false + + const onBorderClick = (e: Event) => { + const { axis, position, cellInfo } = (e as CustomEvent).detail + + // If we're currently changing colors, ignore new menu events + if (isChangingColor) { + return + } + + // If menu is already showing for a row or column, don't switch to cell menu + // This prevents the menu from switching when changing colors from row/column menu + if ( + showTableBorderMenu.value && + (tableBorderAxis.value === 'row' || tableBorderAxis.value === 'column') && + axis === 'cell' + ) { + return + } + + // If menu is already showing for a cell and we receive another cell event for the same cell, + // don't update the position (prevents position jumping when selection changes) + if ( + showTableBorderMenu.value && + tableBorderAxis.value === 'cell' && + axis === 'cell' && + tableCellInfo.value && + cellInfo && + tableCellInfo.value.rowIndex === cellInfo.rowIndex && + tableCellInfo.value.colIndex === cellInfo.colIndex + ) { + return + } + + tableBorderAxis.value = axis + tableBorderMenuPos.value = position + tableCellInfo.value = cellInfo + showTableBorderMenu.value = true + + // Store current menu axis globally so plugin can check it + ;(window as any).__currentTableMenuAxis = axis + + // Prevent immediate closing when menu is just opened + menuJustOpened = true + setTimeout(() => { + menuJustOpened = false + }, 100) + } + + const closeMenu = (e: MouseEvent) => { + // Don't close if menu was just opened (prevents immediate closing on row handler click) + if (menuJustOpened) { + return + } + + const target = e.target as HTMLElement + if ( + !target.closest('.table-border-menu') && + !target.closest('.table-row-handle-overlay') && + !target.closest('.table-col-handle-overlay') && + !target.closest('.table-cell-trigger-overlay') + ) { + showTableBorderMenu.value = false + ;(window as any).__currentTableMenuAxis = null + } + } + + const clearCellSelection = () => { + if (!editor.value) return + // Use setTimeout to ensure the table operation is complete + setTimeout(() => { + const { state } = editor.value! + const { selection } = state + // If there's a cell selection, convert it to a text selection + if (selection instanceof CellSelection && selection.$anchorCell) { + // Get position inside the anchor cell + // $anchorCell.pos points to the position before the cell node + // We add 1 to get inside the cell (after the cell node opening) + const cellPos = selection.$anchorCell.pos + const textPos = cellPos + 1 + // Set text selection to clear the cell selection + editor.value!.chain().setTextSelection(textPos).run() + } + }, 0) + } + + const addRowBefore = () => { + const rows = getSelectedRowCount(editor) + for (let i = 0; i < rows; i++) + editor.value?.chain().focus().addRowBefore().run() + + clearCellSelection() + showTableBorderMenu.value = false + } + + const addRowAfter = () => { + const rows = getSelectedRowCount(editor) + for (let i = 0; i < rows; i++) + editor.value?.chain().focus().addRowAfter().run() + clearCellSelection() + showTableBorderMenu.value = false + } + + const deleteRow = () => { + editor.value?.chain().focus().deleteRow().run() + clearCellSelection() + showTableBorderMenu.value = false + } + + const addColumnBefore = () => { + const columns = getSelectedColumnCount(editor) + for (let i = 0; i < columns; i++) + editor.value?.chain().focus().addColumnBefore().run() + clearCellSelection() + showTableBorderMenu.value = false + } + + const addColumnAfter = () => { + const columns = getSelectedColumnCount(editor) + for (let i = 0; i < columns; i++) + editor.value?.chain().focus().addColumnAfter().run() + clearCellSelection() + showTableBorderMenu.value = false + } + + const deleteColumn = () => { + editor.value?.chain().focus().deleteColumn().run() + clearCellSelection() + showTableBorderMenu.value = false + } + + const mergeCells = () => { + editor.value?.chain().focus().mergeCells().run() + } + + const toggleHeader = () => { + editor.value?.chain().focus().toggleHeaderCell().run() + clearCellSelection() + showTableBorderMenu.value = false + } + + const setBackgroundColor = (color: string | null) => { + // Preserve current menu state when changing colors from row/column menu + const currentAxis = tableBorderAxis.value + const currentPos = { ...tableBorderMenuPos.value } + const currentCellInfo = tableCellInfo.value + ? { ...tableCellInfo.value } + : null + + // Set flag to prevent menu switch + isChangingColor = true + ;(window as any).__currentTableMenuAxis = currentAxis + + editor.value + ?.chain() + .focus() + .setCellAttribute('backgroundColor', color) + .run() + + // Restore menu state if it was row or column + if (currentAxis === 'row' || currentAxis === 'column') { + // Use a longer delay to ensure all updates complete + setTimeout(() => { + tableBorderAxis.value = currentAxis + tableBorderMenuPos.value = currentPos + if (currentCellInfo) { + tableCellInfo.value = currentCellInfo + } + showTableBorderMenu.value = true + ;(window as any).__currentTableMenuAxis = currentAxis + isChangingColor = false + }, 300) + } else { + isChangingColor = false + } + } + + const setBorderColor = (color: string | null) => { + // Preserve current menu state when changing colors from row/column menu + const currentAxis = tableBorderAxis.value + const currentPos = { ...tableBorderMenuPos.value } + const currentCellInfo = tableCellInfo.value + ? { ...tableCellInfo.value } + : null + + // Set flag to prevent menu switch + isChangingColor = true + ;(window as any).__currentTableMenuAxis = currentAxis + + editor.value?.chain().focus().setCellAttribute('borderColor', color).run() + + // Restore menu state if it was row or column + if (currentAxis === 'row' || currentAxis === 'column') { + // Use a longer delay to ensure all updates complete + setTimeout(() => { + tableBorderAxis.value = currentAxis + tableBorderMenuPos.value = currentPos + if (currentCellInfo) { + tableCellInfo.value = currentCellInfo + } + showTableBorderMenu.value = true + ;(window as any).__currentTableMenuAxis = currentAxis + isChangingColor = false + }, 300) + } else { + isChangingColor = false + } + } + + const setBorderWidth = (width: number | null) => { + // Preserve current menu state when changing border width from row/column menu + const currentAxis = tableBorderAxis.value + const currentPos = { ...tableBorderMenuPos.value } + const currentCellInfo = tableCellInfo.value + ? { ...tableCellInfo.value } + : null + + // Set flag to prevent menu switch + isChangingColor = true + ;(window as any).__currentTableMenuAxis = currentAxis + + const borderWidthValue = width ? `${width}px` : null + editor.value + ?.chain() + .focus() + .setCellAttribute('borderWidth', borderWidthValue) + .run() + + // Restore menu state if it was row or column + if (currentAxis === 'row' || currentAxis === 'column') { + // Use a longer delay to ensure all updates complete + setTimeout(() => { + tableBorderAxis.value = currentAxis + tableBorderMenuPos.value = currentPos + if (currentCellInfo) { + tableCellInfo.value = currentCellInfo + } + showTableBorderMenu.value = true + ;(window as any).__currentTableMenuAxis = currentAxis + isChangingColor = false + }, 300) + } else { + isChangingColor = false + } + } + + const canMergeCells = computed(() => { + return editor.value?.can().mergeCells() ?? false + }) + + const handleBorderAttributeChanging = () => { + borderAttributeChanging = true + } + + const handleBorderAttributeChanged = () => { + borderAttributeChanging = false + } + + onMounted(() => { + window.addEventListener('table-border-click', onBorderClick) + document.addEventListener('click', closeMenu) + }) + + onBeforeUnmount(() => { + window.removeEventListener('table-border-click', onBorderClick) + document.removeEventListener('click', closeMenu) + }) + + return { + showTableBorderMenu, + tableBorderAxis, + tableBorderMenuPos, + tableCellInfo, + canMergeCells, + addRowBefore, + addRowAfter, + deleteRow, + addColumnBefore, + addColumnAfter, + deleteColumn, + mergeCells, + toggleHeader, + setBackgroundColor, + setBorderColor, + setBorderWidth, + } +} + +const getSelectedRowCount = (editor: Ref) => { + if (!editor.value) return 0 + + const { state } = editor.value + const { selection } = state + + if (selection instanceof CellSelection) { + const { $anchorCell, $headCell } = selection + + // Get the table + const table = findTable($anchorCell) + if (!table) return 0 + + const map = TableMap.get(table.node) + const anchorRect = map.findCell($anchorCell.pos - table.start) + const headRect = map.findCell($headCell.pos - table.start) + + // Calculate row span + const minRow = Math.min(anchorRect.top, headRect.top) + const maxRow = Math.max(anchorRect.bottom - 1, headRect.bottom - 1) + + return maxRow - minRow + 1 + } + + return 1 // Single cell/row selected +} +const getSelectedColumnCount = (editor) => { + if (!editor.value) return 0 + + const { state } = editor.value + const { selection } = state + + if (selection instanceof CellSelection) { + const { $anchorCell, $headCell } = selection + + // Get the table + const table = findTable($anchorCell) + if (!table) return 0 + + const map = TableMap.get(table.node) + const anchorRect = map.findCell($anchorCell.pos - table.start) + const headRect = map.findCell($headCell.pos - table.start) + + // Calculate column span + const minCol = Math.min(anchorRect.left, headRect.left) + const maxCol = Math.max(anchorRect.right - 1, headRect.right - 1) + + return maxCol - minCol + 1 + } + + return 1 // Single cell/column selected +} diff --git a/src/components/TextEditor/style.css b/src/components/TextEditor/style.css index 03a9c8167..6e67d3135 100644 --- a/src/components/TextEditor/style.css +++ b/src/components/TextEditor/style.css @@ -1,5 +1,6 @@ @import './extensions/color/color-styles.css'; @import './extensions/highlight/highlight-styles.css'; +@import './extensions/tables/table-styles.css'; .ProseMirror { outline: none;