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;