Improved table performance in the text editor

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
Victor Ilyushchenko 2025-05-08 00:55:31 +03:00
parent 6c5a6aae27
commit f91001e056
No known key found for this signature in database
GPG Key ID: CEC5BF115E7283DF
8 changed files with 729 additions and 368 deletions

View File

@ -13,18 +13,17 @@
// limitations under the License.
//
import { type Editor } from '@tiptap/core'
import { type EditorState } from '@tiptap/pm/state'
import { CellSelection, TableMap } from '@tiptap/pm/tables'
import { Decoration } from '@tiptap/pm/view'
import textEditor from '@hcengineering/text-editor'
import { type Editor } from '@tiptap/core'
import { CellSelection, TableMap } from '@tiptap/pm/tables'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { type TableNodeLocation } from '../types'
import { findTable, getSelectedColumns, isColumnSelected, selectColumn } from '../utils'
import { findTable, getSelectedColumns, haveTableRelatedChanges, isColumnSelected, selectColumn } from '../utils'
import { duplicateColumns, moveSelectedColumns } from './actions'
import DeleteCol from '../../../icons/table/DeleteCol.svelte'
import Duplicate from '../../../icons/table/Duplicate.svelte'
import { duplicateColumns, moveSelectedColumns } from './actions'
import { createCellsHandle, type OptionItem } from './cellsHandle'
import {
dropMarkerWidthPx,
@ -32,14 +31,176 @@ import {
getDropMarker,
hideDragMarker,
hideDropMarker,
updateColDropMarker,
updateColDragMarker
updateColDragMarker,
updateColDropMarker
} from './tableDragMarkerDecoration'
import { getTableCellWidgetDecorationPos, getTableWidthPx } from './utils'
interface TableColumn {
leftPx: number
widthPx: number
import { Plugin, PluginKey } from '@tiptap/pm/state'
interface TableColumnHandlerDecorationPluginState {
decorations?: DecorationSet
}
export const TableColumnHandlerDecorationPlugin = (editor: Editor): Plugin<TableColumnHandlerDecorationPluginState> => {
const key = new PluginKey('tableColumnHandlerDecorationPlugin')
return new Plugin<TableColumnHandlerDecorationPluginState>({
key,
state: {
init: () => {
return {}
},
apply (tr, prev, oldState, newState) {
const table = findTable(newState.selection)
if (!haveTableRelatedChanges(editor, table, oldState, newState, tr)) {
return table !== undefined ? prev : {}
}
const tableMap = TableMap.get(table.node)
let isStale = false
const mapped = prev.decorations?.map(tr.mapping, tr.doc)
for (let col = 0; col < tableMap.width; col++) {
const pos = getTableCellWidgetDecorationPos(table, tableMap, col)
if (mapped?.find(pos, pos + 1)?.length !== 1) {
isStale = true
break
}
}
if (!isStale) {
return { decorations: mapped }
}
const decorations: Decoration[] = []
for (let col = 0; col < tableMap.width; col++) {
const pos = getTableCellWidgetDecorationPos(table, tableMap, col)
const handler = new ColumnHandler(editor, { col })
decorations.push(Decoration.widget(pos, () => handler.build(), { destroy: () => handler.destroy?.() }))
}
return { decorations: DecorationSet.create(newState.doc, decorations) }
}
},
props: {
decorations (state) {
return key.getState(state).decorations
}
}
})
}
interface ColumnHandlerProps {
col: number
}
class ColumnHandler {
editor: Editor
props: ColumnHandlerProps
destroy?: () => void
constructor (editor: Editor, props: ColumnHandlerProps) {
this.editor = editor
this.props = props
}
build (): HTMLElement {
const editor = this.editor
const col = this.props.col
const handle = createCellsHandle(createOptionItems(editor))
handle.classList.add('table-col-handle')
const selectionUpdate = (): boolean => {
const isSelected = isColumnSelected(col, editor.state.selection)
if (isSelected) {
handle.classList.add('table-col-handle__selected')
} else {
handle.classList.remove('table-col-handle__selected')
}
return isSelected
}
editor.on('selectionUpdate', selectionUpdate)
if (this.destroy !== undefined) {
this.destroy()
}
this.destroy = (): void => {
editor.off('selectionUpdate', selectionUpdate)
}
handle.addEventListener('mousedown', (event) => {
event.stopPropagation()
event.preventDefault()
const table = findTable(editor.state.selection)
if (table === undefined) {
return
}
const isSelected = selectionUpdate()
event.stopPropagation()
event.preventDefault()
// select column
if (!isSelected) {
editor.view.dispatch(selectColumn(table, col, editor.state.tr))
}
// drag column
const tableWidthPx = getTableWidthPx(table, editor)
const columns = getTableColumns(table, editor)
let dropIndex = col
const startLeft = columns[col].leftPx ?? 0
const startX = event.clientX
const dropMarker = getDropMarker()
const dragMarker = getColDragMarker()
const handleFinish = (): void => {
if (dropMarker !== null) hideDropMarker(dropMarker)
if (dragMarker !== null) hideDragMarker(dragMarker)
if (col !== dropIndex) {
let tr = editor.state.tr
const selection = editor.state.selection
if (selection instanceof CellSelection) {
const table = findTable(selection)
if (table !== undefined) {
tr = moveSelectedColumns(editor, table, selection, dropIndex, tr)
}
}
editor.view.dispatch(tr)
}
window.removeEventListener('mouseup', handleFinish)
window.removeEventListener('mousemove', handleMove)
}
const handleMove = (event: MouseEvent): void => {
if (dropMarker !== null && dragMarker !== null) {
const currentLeft = startLeft + event.clientX - startX
dropIndex = calculateColumnDropIndex(col, columns, currentLeft)
const dragMarkerWidthPx = columns[col].widthPx
const dragMarkerLeftPx = Math.max(0, Math.min(currentLeft, tableWidthPx - dragMarkerWidthPx))
const dropMarkerLeftPx =
dropIndex <= col ? columns[dropIndex].leftPx : columns[dropIndex].leftPx + columns[dropIndex].widthPx
updateColDropMarker(dropMarker, dropMarkerLeftPx - Math.floor(dropMarkerWidthPx / 2) - 1, dropMarkerWidthPx)
updateColDragMarker(dragMarker, dragMarkerLeftPx, dragMarkerWidthPx)
}
}
window.addEventListener('mouseup', handleFinish)
window.addEventListener('mousemove', handleMove)
})
return handle
}
}
const createOptionItems = (editor: Editor): OptionItem[] => [
@ -65,88 +226,9 @@ const createOptionItems = (editor: Editor): OptionItem[] => [
}
]
export const columnHandlerDecoration = (state: EditorState, table: TableNodeLocation, editor: Editor): Decoration[] => {
const decorations: Decoration[] = []
const tableMap = TableMap.get(table.node)
for (let col = 0; col < tableMap.width; col++) {
const pos = getTableCellWidgetDecorationPos(table, tableMap, col)
const isSelected = isColumnSelected(col, state.selection)
const handle = createCellsHandle(createOptionItems(editor))
handle.classList.add('table-col-handle')
if (isSelected) {
handle.classList.add('table-col-handle__selected')
}
handle.addEventListener('mousedown', (e) => {
handleMouseDown(col, table, e, editor, isSelected)
})
decorations.push(Decoration.widget(pos, handle))
}
return decorations
}
const handleMouseDown = (
col: number,
table: TableNodeLocation,
event: MouseEvent,
editor: Editor,
isSelected: boolean
): void => {
event.stopPropagation()
event.preventDefault()
// select column
if (!isSelected) {
editor.view.dispatch(selectColumn(table, col, editor.state.tr))
}
// drag column
const tableWidthPx = getTableWidthPx(table, editor)
const columns = getTableColumns(table, editor)
let dropIndex = col
const startLeft = columns[col].leftPx ?? 0
const startX = event.clientX
const dropMarker = getDropMarker()
const dragMarker = getColDragMarker()
function handleFinish (): void {
if (dropMarker !== null) hideDropMarker(dropMarker)
if (dragMarker !== null) hideDragMarker(dragMarker)
if (col !== dropIndex) {
let tr = editor.state.tr
const selection = editor.state.selection
if (selection instanceof CellSelection) {
tr = moveSelectedColumns(editor, table, selection, dropIndex, tr)
}
editor.view.dispatch(tr)
}
window.removeEventListener('mouseup', handleFinish)
window.removeEventListener('mousemove', handleMove)
}
function handleMove (event: MouseEvent): void {
if (dropMarker !== null && dragMarker !== null) {
const currentLeft = startLeft + event.clientX - startX
dropIndex = calculateColumnDropIndex(col, columns, currentLeft)
const dragMarkerWidthPx = columns[col].widthPx
const dragMarkerLeftPx = Math.max(0, Math.min(currentLeft, tableWidthPx - dragMarkerWidthPx))
const dropMarkerLeftPx =
dropIndex <= col ? columns[dropIndex].leftPx : columns[dropIndex].leftPx + columns[dropIndex].widthPx
updateColDropMarker(dropMarker, dropMarkerLeftPx - Math.floor(dropMarkerWidthPx / 2) - 1, dropMarkerWidthPx)
updateColDragMarker(dragMarker, dragMarkerLeftPx, dragMarkerWidthPx)
}
}
window.addEventListener('mouseup', handleFinish)
window.addEventListener('mousemove', handleMove)
interface TableColumn {
leftPx: number
widthPx: number
}
function calculateColumnDropIndex (col: number, columns: TableColumn[], left: number): number {

View File

@ -14,57 +14,130 @@
//
import { type Editor } from '@tiptap/core'
import { type EditorState } from '@tiptap/pm/state'
import { TableMap } from '@tiptap/pm/tables'
import { Decoration } from '@tiptap/pm/view'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { findTable, haveTableRelatedChanges, insertColumn } from '../utils'
import { addSvg } from './icons'
import { type TableNodeLocation } from '../types'
import { insertColumn, isColumnSelected } from '../utils'
import { getTableCellWidgetDecorationPos, getTableHeightPx } from './utils'
export const columnInsertDecoration = (state: EditorState, table: TableNodeLocation, editor: Editor): Decoration[] => {
const decorations: Decoration[] = []
import { Plugin, PluginKey } from '@tiptap/pm/state'
const { selection } = state
interface TableColumnInsertDecorationPluginState {
decorations?: DecorationSet
}
const tableMap = TableMap.get(table.node)
const { width } = tableMap
export const TableColumnInsertDecorationPlugin = (editor: Editor): Plugin<TableColumnInsertDecorationPluginState> => {
const key = new PluginKey('tableColumnInsertDecorationPlugin')
return new Plugin<TableColumnInsertDecorationPluginState>({
key,
state: {
init: () => {
return {}
},
apply (tr, prev, oldState, newState) {
const table = findTable(newState.selection)
if (!haveTableRelatedChanges(editor, table, oldState, newState, tr)) {
return table !== undefined ? prev : {}
}
const tableHeightPx = getTableHeightPx(table, editor)
const decorations: Decoration[] = []
for (let col = 0; col < width; col++) {
const show = col < width - 1 && !isColumnSelected(col, selection) && !isColumnSelected(col + 1, selection)
const tableMap = TableMap.get(table.node)
const { width } = tableMap
if (show) {
const insert = document.createElement('div')
insert.classList.add('table-col-insert')
let isStale = false
const mapped = prev.decorations?.map(tr.mapping, tr.doc)
for (let col = 0; col < width - 1; col++) {
const pos = getTableCellWidgetDecorationPos(table, tableMap, col)
if (mapped?.find(pos, pos + 1)?.length !== 1) {
isStale = true
break
}
}
const button = document.createElement('button')
button.className = 'table-insert-button'
button.innerHTML = addSvg
button.addEventListener('mousedown', (e) => {
handleMouseDown(col, table, e, editor)
})
insert.appendChild(button)
if (!isStale) {
return { decorations: mapped }
}
const marker = document.createElement('div')
marker.className = 'table-insert-marker'
marker.style.height = tableHeightPx + 'px'
insert.appendChild(marker)
for (let col = 0; col < width - 1; col++) {
const pos = getTableCellWidgetDecorationPos(table, tableMap, col)
const handler = new ColumnInsertHandler(editor, { col })
decorations.push(Decoration.widget(pos, () => handler.build(), { destroy: handler.destroy }))
}
const pos = getTableCellWidgetDecorationPos(table, tableMap, col)
decorations.push(Decoration.widget(pos, insert))
return { decorations: DecorationSet.create(newState.doc, decorations) }
}
},
props: {
decorations (state) {
return key.getState(state).decorations
}
}
})
}
interface ColumnInsertHandlerProps {
col: number
}
class ColumnInsertHandler {
editor: Editor
props: ColumnInsertHandlerProps
destroy?: () => void
constructor (editor: Editor, props: ColumnInsertHandlerProps) {
this.editor = editor
this.props = props
}
return decorations
}
build (): HTMLElement {
const editor = this.editor
const col = this.props.col
const handleMouseDown = (col: number, table: TableNodeLocation, event: Event, editor: Editor): void => {
event.stopPropagation()
event.preventDefault()
const handle = document.createElement('div')
handle.classList.add('table-col-insert')
editor.view.dispatch(insertColumn(table, col + 1, editor.state.tr))
const button = document.createElement('button')
button.className = 'table-insert-button'
button.innerHTML = addSvg
button.addEventListener('mousedown', (event) => {
event.stopPropagation()
event.preventDefault()
const table = findTable(editor.state.selection)
if (table === undefined) {
return
}
editor.view.dispatch(insertColumn(table, col + 1, editor.state.tr))
})
handle.appendChild(button)
const marker = document.createElement('div')
marker.className = 'table-insert-marker'
handle.appendChild(marker)
const updateMarkerHeight = (): void => {
const table = findTable(editor.state.selection)
if (table === undefined) {
return
}
const tableHeightPx = getTableHeightPx(table, editor)
marker.style.height = tableHeightPx + 'px'
}
updateMarkerHeight()
editor.on('update', updateMarkerHeight)
if (this.destroy !== undefined) {
this.destroy()
}
this.destroy = () => {
editor.off('update', updateMarkerHeight)
}
return handle
}
}

View File

@ -13,18 +13,18 @@
// limitations under the License.
//
import { type Editor } from '@tiptap/core'
import { type EditorState } from '@tiptap/pm/state'
import { CellSelection, TableMap } from '@tiptap/pm/tables'
import { Decoration } from '@tiptap/pm/view'
import textEditor from '@hcengineering/text-editor'
import { type Editor } from '@tiptap/core'
import { CellSelection, TableMap } from '@tiptap/pm/tables'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { type TableNodeLocation } from '../types'
import { findTable, getSelectedRows, isRowSelected, selectRow } from '../utils'
import { findTable, getSelectedRows, haveTableRelatedChanges, isRowSelected, selectRow } from '../utils'
import { duplicateRows, moveSelectedRows } from './actions'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import DeleteRow from '../../../icons/table/DeleteRow.svelte'
import Duplicate from '../../../icons/table/Duplicate.svelte'
import { duplicateRows, moveSelectedRows } from './actions'
import { createCellsHandle, type OptionItem } from './cellsHandle'
import {
dropMarkerWidthPx,
@ -32,11 +32,176 @@ import {
getRowDragMarker,
hideDragMarker,
hideDropMarker,
updateRowDropMarker,
updateRowDragMarker
updateRowDragMarker,
updateRowDropMarker
} from './tableDragMarkerDecoration'
import { getTableCellWidgetDecorationPos, getTableHeightPx } from './utils'
interface TableRowHandlerDecorationPluginState {
decorations?: DecorationSet
}
export const TableRowHandlerDecorationPlugin = (editor: Editor): Plugin<TableRowHandlerDecorationPluginState> => {
const key = new PluginKey('tableRowHandlerDecorationPlugin')
return new Plugin<TableRowHandlerDecorationPluginState>({
key,
state: {
init: () => {
return {}
},
apply (tr, prev, oldState, newState) {
const table = findTable(newState.selection)
if (!haveTableRelatedChanges(editor, table, oldState, newState, tr)) {
return table !== undefined ? prev : {}
}
const tableMap = TableMap.get(table.node)
let isStale = false
const mapped = prev.decorations?.map(tr.mapping, tr.doc)
for (let row = 0; row < tableMap.height; row++) {
const pos = getTableCellWidgetDecorationPos(table, tableMap, row * tableMap.width)
if (mapped?.find(pos, pos + 1)?.length !== 1) {
isStale = true
break
}
}
if (!isStale) {
return { decorations: mapped }
}
const decorations: Decoration[] = []
for (let row = 0; row < tableMap.height; row++) {
const pos = getTableCellWidgetDecorationPos(table, tableMap, row * tableMap.width)
const handler = new RowHandler(editor, { row })
decorations.push(Decoration.widget(pos, () => handler.build(), { destroy: () => handler.destroy?.() }))
}
return { decorations: DecorationSet.create(newState.doc, decorations) }
}
},
props: {
decorations (state) {
return key.getState(state).decorations
}
}
})
}
interface RowHandlerProps {
row: number
}
class RowHandler {
editor: Editor
props: RowHandlerProps
destroy?: () => void
constructor (editor: Editor, props: RowHandlerProps) {
this.editor = editor
this.props = props
}
build (): HTMLElement {
const editor = this.editor
const selection = editor.state.selection
const row = this.props.row
const handle = createCellsHandle(createOptionItems(editor))
handle.classList.add('table-row-handle')
const selectionUpdate = (): boolean => {
const isSelected = isRowSelected(row, editor.state.selection)
if (isSelected) {
handle.classList.add('table-row-handle__selected')
} else {
handle.classList.add('table-row-handle__selected')
}
return isSelected
}
editor.on('selectionUpdate', selectionUpdate)
if (this.destroy !== undefined) {
this.destroy()
}
this.destroy = (): void => {
editor.off('selectionUpdate', selectionUpdate)
}
handle.addEventListener('mousedown', (event) => {
event.stopPropagation()
event.preventDefault()
const table = findTable(editor.state.selection)
if (table === undefined) {
return
}
const isSelected = isRowSelected(row, selection)
// select row
if (!isSelected) {
editor.view.dispatch(selectRow(table, row, editor.state.tr))
}
// drag row
const tableHeightPx = getTableHeightPx(table, editor)
const rows = getTableRows(table, editor)
console.log(rows)
let dropIndex = row
const startTop = rows[row].topPx ?? 0
const startY = event.clientY
const dropMarker = getDropMarker()
const dragMarker = getRowDragMarker()
const handleFinish = (): void => {
if (dropMarker !== null) hideDropMarker(dropMarker)
if (dragMarker !== null) hideDragMarker(dragMarker)
if (row !== dropIndex) {
let tr = editor.state.tr
const selection = editor.state.selection
if (selection instanceof CellSelection) {
const table = findTable(selection)
if (table !== undefined) {
tr = moveSelectedRows(editor, table, selection, dropIndex, tr)
}
}
editor.view.dispatch(tr)
}
window.removeEventListener('mouseup', handleFinish)
window.removeEventListener('mousemove', handleMove)
}
const handleMove = (event: MouseEvent): void => {
if (dropMarker !== null && dragMarker !== null) {
const cursorTop = startTop + event.clientY - startY
dropIndex = calculateRowDropIndex(row, rows, cursorTop)
const dragMarkerHeightPx = rows[row].heightPx
const dragMarkerTopPx = Math.max(0, Math.min(cursorTop, tableHeightPx - dragMarkerHeightPx))
const dropMarkerTopPx =
dropIndex <= row ? rows[dropIndex].topPx : rows[dropIndex].topPx + rows[dropIndex].heightPx
updateRowDropMarker(dropMarker, dropMarkerTopPx - dropMarkerWidthPx / 2, dropMarkerWidthPx)
updateRowDragMarker(dragMarker, dragMarkerTopPx, dragMarkerHeightPx)
}
}
window.addEventListener('mouseup', handleFinish)
window.addEventListener('mousemove', handleMove)
})
return handle
}
}
interface TableRow {
topPx: number
heightPx: number
@ -65,90 +230,6 @@ const createOptionItems = (editor: Editor): OptionItem[] => [
}
]
export const rowHandlerDecoration = (state: EditorState, table: TableNodeLocation, editor: Editor): Decoration[] => {
const decorations: Decoration[] = []
const tableMap = TableMap.get(table.node)
for (let row = 0; row < tableMap.height; row++) {
const pos = getTableCellWidgetDecorationPos(table, tableMap, row * tableMap.width)
const isSelected = isRowSelected(row, state.selection)
const handle = createCellsHandle(createOptionItems(editor))
handle.classList.add('table-row-handle')
if (isSelected) {
handle.classList.add('table-row-handle__selected')
}
handle.addEventListener('mousedown', (e) => {
handleMouseDown(row, table, e, editor, isSelected)
})
decorations.push(Decoration.widget(pos, handle))
}
return decorations
}
const handleMouseDown = (
row: number,
table: TableNodeLocation,
event: MouseEvent,
editor: Editor,
isSelected: boolean
): void => {
event.stopPropagation()
event.preventDefault()
// select row
if (!isSelected) {
editor.view.dispatch(selectRow(table, row, editor.state.tr))
}
// drag row
const tableHeightPx = getTableHeightPx(table, editor)
const rows = getTableRows(table, editor)
let dropIndex = row
const startTop = rows[row].topPx ?? 0
const startY = event.clientY
const dropMarker = getDropMarker()
const dragMarker = getRowDragMarker()
function handleFinish (): void {
if (dropMarker !== null) hideDropMarker(dropMarker)
if (dragMarker !== null) hideDragMarker(dragMarker)
if (row !== dropIndex) {
let tr = editor.state.tr
const selection = editor.state.selection
if (selection instanceof CellSelection) {
tr = moveSelectedRows(editor, table, selection, dropIndex, tr)
}
editor.view.dispatch(tr)
}
window.removeEventListener('mouseup', handleFinish)
window.removeEventListener('mousemove', handleMove)
}
function handleMove (event: MouseEvent): void {
if (dropMarker !== null && dragMarker !== null) {
const cursorTop = startTop + event.clientY - startY
dropIndex = calculateRowDropIndex(row, rows, cursorTop)
const dragMarkerHeightPx = rows[row].heightPx
const dragMarkerTopPx = Math.max(0, Math.min(cursorTop, tableHeightPx - dragMarkerHeightPx))
const dropMarkerTopPx =
dropIndex <= row ? rows[dropIndex].topPx : rows[dropIndex].topPx + rows[dropIndex].heightPx
updateRowDropMarker(dropMarker, dropMarkerTopPx - dropMarkerWidthPx / 2, dropMarkerWidthPx)
updateRowDragMarker(dragMarker, dragMarkerTopPx, dragMarkerHeightPx)
}
}
window.addEventListener('mouseup', handleFinish)
window.addEventListener('mousemove', handleMove)
}
function calculateRowDropIndex (row: number, rows: TableRow[], top: number): number {
const rowCenterPx = top + rows[row].heightPx / 2
const index = rows.findIndex((p) => rowCenterPx <= p.topPx + p.heightPx)
@ -159,9 +240,9 @@ function getTableRows (table: TableNodeLocation, editor: Editor): TableRow[] {
const result = []
let topPx = 0
const { map, height } = TableMap.get(table.node)
for (let row = 0; row < height; row++) {
const dom = editor.view.domAtPos(table.start + map[row] + 1)
const tableMap = TableMap.get(table.node)
for (let row = 0; row < tableMap.height; row++) {
const dom = editor.view.domAtPos(table.start + tableMap.map[row * tableMap.width])
if (dom.node instanceof HTMLElement) {
const heightPx = dom.node.offsetHeight
result.push({ topPx, heightPx })

View File

@ -14,57 +14,129 @@
//
import { type Editor } from '@tiptap/core'
import { type EditorState } from '@tiptap/pm/state'
import { TableMap } from '@tiptap/pm/tables'
import { Decoration } from '@tiptap/pm/view'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { findTable, haveTableRelatedChanges, insertRow } from '../utils'
import { addSvg } from './icons'
import { type TableNodeLocation } from '../types'
import { insertRow, isRowSelected } from '../utils'
import { getTableCellWidgetDecorationPos, getTableWidthPx } from './utils'
export const rowInsertDecoration = (state: EditorState, table: TableNodeLocation, editor: Editor): Decoration[] => {
const decorations: Decoration[] = []
import { Plugin, PluginKey } from '@tiptap/pm/state'
const { selection } = state
interface TableRowInsertDecorationPluginState {
decorations?: DecorationSet
}
const tableMap = TableMap.get(table.node)
const { height } = tableMap
export const TableRowInsertDecorationPlugin = (editor: Editor): Plugin<TableRowInsertDecorationPluginState> => {
const key = new PluginKey('tableRowInsertDecorationPlugin')
return new Plugin<TableRowInsertDecorationPluginState>({
key,
state: {
init: () => {
return {}
},
apply (tr, prev, oldState, newState) {
const table = findTable(newState.selection)
if (!haveTableRelatedChanges(editor, table, oldState, newState, tr)) {
return table !== undefined ? prev : {}
}
const tableWidthPx = getTableWidthPx(table, editor)
const tableMap = TableMap.get(table.node)
const { height } = tableMap
for (let row = 0; row < height; row++) {
const show = row < height - 1 && !isRowSelected(row, selection) && !isRowSelected(row + 1, selection)
let isStale = false
const mapped = prev.decorations?.map(tr.mapping, tr.doc)
for (let row = 0; row < height - 1; row++) {
const pos = getTableCellWidgetDecorationPos(table, tableMap, row * tableMap.width)
if (mapped?.find(pos, pos + 1)?.length !== 1) {
isStale = true
break
}
}
if (show) {
const dot = document.createElement('div')
dot.classList.add('table-row-insert')
if (!isStale) {
return { decorations: mapped }
}
const button = document.createElement('button')
button.className = 'table-insert-button'
button.innerHTML = addSvg
button.addEventListener('mousedown', (e) => {
handleMouseDown(row, table, e, editor)
})
dot.appendChild(button)
const decorations: Decoration[] = []
const marker = document.createElement('div')
marker.className = 'table-insert-marker'
marker.style.width = tableWidthPx + 'px'
dot.appendChild(marker)
for (let row = 0; row < height - 1; row++) {
const pos = getTableCellWidgetDecorationPos(table, tableMap, row * tableMap.width)
const handler = new RowInsertHandler(editor, { row })
decorations.push(Decoration.widget(pos, () => handler.build(), { destroy: handler.destroy }))
}
const pos = getTableCellWidgetDecorationPos(table, tableMap, row * tableMap.width)
decorations.push(Decoration.widget(pos, dot))
return { decorations: DecorationSet.create(newState.doc, decorations) }
}
},
props: {
decorations (state) {
return key.getState(state).decorations
}
}
})
}
interface RowInsertHandlerProps {
row: number
}
class RowInsertHandler {
editor: Editor
props: RowInsertHandlerProps
destroy?: () => void
constructor (editor: Editor, props: RowInsertHandlerProps) {
this.editor = editor
this.props = props
}
return decorations
}
build (): HTMLElement {
const editor = this.editor
const row = this.props.row
const handleMouseDown = (row: number, table: TableNodeLocation, event: Event, editor: Editor): void => {
event.stopPropagation()
event.preventDefault()
const handle = document.createElement('div')
handle.classList.add('table-row-insert')
editor.view.dispatch(insertRow(table, row + 1, editor.state.tr))
const button = document.createElement('button')
button.className = 'table-insert-button'
button.innerHTML = addSvg
button.addEventListener('mousedown', (event) => {
const table = findTable(editor.state.selection)
if (table === undefined) {
return
}
event.stopPropagation()
event.preventDefault()
editor.view.dispatch(insertRow(table, row + 1, editor.state.tr))
})
handle.appendChild(button)
const marker = document.createElement('div')
marker.className = 'table-insert-marker'
handle.appendChild(marker)
const updateMarkerHeight = (): void => {
const table = findTable(editor.state.selection)
if (table === undefined) {
return
}
const tableWidthPx = getTableWidthPx(table, editor)
marker.style.width = tableWidthPx + 'px'
}
updateMarkerHeight()
editor.on('update', updateMarkerHeight)
if (this.destroy !== undefined) {
this.destroy()
}
this.destroy = () => {
editor.off('update', updateMarkerHeight)
}
return handle
}
}

View File

@ -13,11 +13,12 @@
// limitations under the License.
//
import { type EditorState } from '@tiptap/pm/state'
import { Decoration } from '@tiptap/pm/view'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { type Editor } from '@tiptap/core'
import { findTable, haveTableRelatedChanges } from '../utils'
import { handleSvg } from './icons'
import { type TableNodeLocation } from '../types'
export const dropMarkerId = 'table-drop-marker'
export const colDragMarkerId = 'table-col-drag-marker'
@ -25,30 +26,85 @@ export const rowDragMarkerId = 'table-row-drag-marker'
export const dropMarkerWidthPx = 1
export const tableDragMarkerDecoration = (state: EditorState, table: TableNodeLocation): Decoration[] => {
const dropMarker = document.createElement('div')
dropMarker.id = dropMarkerId
dropMarker.classList.add('table-drop-marker')
interface TableDragMarkerDecorationPluginState {
decorations?: DecorationSet
}
const colDragMarker = document.createElement('div')
colDragMarker.id = colDragMarkerId
colDragMarker.classList.add('table-col-drag-marker')
colDragMarker.style.display = 'none'
const colDragMarkerBtn = colDragMarker.appendChild(document.createElement('button'))
colDragMarkerBtn.innerHTML = handleSvg
export const TableDragMarkerDecorationPlugin = (editor: Editor): Plugin<TableDragMarkerDecorationPluginState> => {
const key = new PluginKey('table-cell-drag-marker-decoration-plugin')
return new Plugin<TableDragMarkerDecorationPluginState>({
key,
state: {
init: () => {
return {}
},
apply (tr, prev, oldState, newState) {
const table = findTable(newState.selection)
if (!haveTableRelatedChanges(editor, table, oldState, newState, tr)) {
return table !== undefined ? prev : {}
}
const rowDragMarker = document.createElement('div')
rowDragMarker.id = rowDragMarkerId
rowDragMarker.classList.add('table-row-drag-marker')
rowDragMarker.style.display = 'none'
const rowDragMarkerBtn = rowDragMarker.appendChild(document.createElement('button'))
rowDragMarkerBtn.innerHTML = handleSvg
if (prev.decorations !== undefined) {
const mapped = prev.decorations.map(tr.mapping, tr.doc)
const existing = mapped.find(table.start, table.start + 1)
if (existing.length > 0) {
return { decorations: mapped }
}
}
return [
Decoration.widget(table.start, dropMarker),
Decoration.widget(table.start, colDragMarker),
Decoration.widget(table.start, rowDragMarker)
]
const decorations = DecorationSet.create(newState.doc, [
Decoration.widget(table.start, () => createMarkerContainer())
])
return { decorations }
}
},
props: {
decorations (state) {
return key.getState(state).decorations
}
}
})
}
function createMarkerContainer (): HTMLElement {
const el = document.createElement('div')
el.classList.add('table-drag-marker-container')
el.appendChild(createDropMarker())
el.appendChild(createColDragMarker())
el.appendChild(createRowDragMarker())
return el
}
function createDropMarker (): DropMarkerHTMLElement {
const el = document.createElement('div')
el.id = dropMarkerId
el.classList.add('table-drop-marker')
return el
}
function createColDragMarker (): DragMarkerHTMLElement {
const el = document.createElement('div')
el.id = colDragMarkerId
el.classList.add('table-col-drag-marker')
el.style.display = 'none'
const btn = el.appendChild(document.createElement('button'))
btn.innerHTML = handleSvg
return el
}
function createRowDragMarker (): DragMarkerHTMLElement {
const el = document.createElement('div')
el.id = rowDragMarkerId
el.classList.add('table-row-drag-marker')
el.style.display = 'none'
const btn = el.appendChild(document.createElement('button'))
btn.innerHTML = handleSvg
return el
}
export type DropMarkerHTMLElement = HTMLElement

View File

@ -13,43 +13,71 @@
// limitations under the License.
//
import { type EditorState } from '@tiptap/pm/state'
import { CellSelection, TableMap } from '@tiptap/pm/tables'
import { Decoration } from '@tiptap/pm/view'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { type TableNodeLocation } from '../types'
import { type Editor } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
export const tableSelectionDecoration = (state: EditorState, table: TableNodeLocation): Decoration[] => {
const decorations: Decoration[] = []
import { findTable, haveTableRelatedChanges } from '../utils'
const { selection } = state
interface TableSelectionDecorationPluginState {
decorations?: DecorationSet
}
const tableMap = TableMap.get(table.node)
export const TableSelectionDecorationPlugin = (editor: Editor): Plugin<TableSelectionDecorationPluginState> => {
const key = new PluginKey('tableSelectionDecorationPlugin')
return new Plugin<TableSelectionDecorationPluginState>({
key,
state: {
init: () => {
return {}
},
apply (tr, prev, oldState, newState) {
const table = findTable(newState.selection)
if (!haveTableRelatedChanges(editor, table, oldState, newState, tr)) {
return table !== undefined ? prev : {}
}
if (selection instanceof CellSelection) {
const selected: number[] = []
const { selection } = newState
if (!(selection instanceof CellSelection)) {
return {}
}
selection.forEachCell((_node, pos) => {
const start = pos - table.pos - 1
selected.push(start)
})
const decorations: Decoration[] = []
selection.forEachCell((node, pos) => {
const start = pos - table.pos - 1
const borders = getTableCellBorders(start, selected, tableMap)
const tableMap = TableMap.get(table.node)
const classes = ['table-cell-selected']
const selected: number[] = []
if (borders.top) classes.push('table-cell-selected__border-top')
if (borders.bottom) classes.push('table-cell-selected__border-bottom')
if (borders.left) classes.push('table-cell-selected__border-left')
if (borders.right) classes.push('table-cell-selected__border-right')
selection.forEachCell((_node, pos) => {
const start = pos - table.pos - 1
selected.push(start)
})
decorations.push(Decoration.node(pos, pos + node.nodeSize, { class: classes.join(' ') }))
})
}
selection.forEachCell((node, pos) => {
const start = pos - table.pos - 1
const borders = getTableCellBorders(start, selected, tableMap)
return decorations
const classes = ['table-cell-selected']
if (borders.top) classes.push('table-cell-selected__border-top')
if (borders.bottom) classes.push('table-cell-selected__border-bottom')
if (borders.left) classes.push('table-cell-selected__border-left')
if (borders.right) classes.push('table-cell-selected__border-right')
decorations.push(Decoration.node(pos, pos + node.nodeSize, { class: classes.join(' ') }))
})
return { decorations: DecorationSet.create(newState.doc, decorations) }
}
},
props: {
decorations (state) {
return key.getState(state).decorations
}
}
})
}
function getTableCellBorders (

View File

@ -13,75 +13,34 @@
// limitations under the License.
//
import { type Editor } from '@tiptap/core'
import TiptapTableCell from '@tiptap/extension-table-cell'
import { Plugin, PluginKey, type Selection } from '@tiptap/pm/state'
import { DecorationSet } from '@tiptap/pm/view'
import { Plugin } from '@tiptap/pm/state'
import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables'
import { columnHandlerDecoration } from './decorations/columnHandlerDecoration'
import { columnInsertDecoration } from './decorations/columnInsertDecoration'
import { rowHandlerDecoration } from './decorations/rowHandlerDecoration'
import { rowInsertDecoration } from './decorations/rowInsertDecoration'
import { tableDragMarkerDecoration } from './decorations/tableDragMarkerDecoration'
import { tableSelectionDecoration } from './decorations/tableSelectionDecoration'
import { findTable } from './utils'
import { type Node } from '@tiptap/pm/model'
import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables'
import { TableColumnHandlerDecorationPlugin } from './decorations/columnHandlerDecoration'
import { TableColumnInsertDecorationPlugin } from './decorations/columnInsertDecoration'
import { TableRowHandlerDecorationPlugin } from './decorations/rowHandlerDecoration'
import { TableRowInsertDecorationPlugin } from './decorations/rowInsertDecoration'
import { TableDragMarkerDecorationPlugin } from './decorations/tableDragMarkerDecoration'
import { TableSelectionDecorationPlugin } from './decorations/tableSelectionDecoration'
import { findTable } from './utils'
export const TableCell = TiptapTableCell.extend({
addProseMirrorPlugins () {
return [tableCellDecorationPlugin(this.editor), tableSelectionNormalizer()]
return [
TableSelectionNormalizerPlugin(),
TableSelectionDecorationPlugin(this.editor),
TableDragMarkerDecorationPlugin(this.editor),
TableColumnHandlerDecorationPlugin(this.editor),
TableColumnInsertDecorationPlugin(this.editor),
TableRowHandlerDecorationPlugin(this.editor),
TableRowInsertDecorationPlugin(this.editor)
]
}
})
interface TableCellDecorationPluginState {
decorations?: DecorationSet
selection?: Selection
}
const tableCellDecorationPlugin = (editor: Editor): Plugin<TableCellDecorationPluginState> => {
const key = new PluginKey('table-cell-decoration-plugin')
return new Plugin({
key,
state: {
init: (): TableCellDecorationPluginState => {
return {}
},
apply (tr, prev, oldState, newState) {
if (!editor.isEditable) {
return { selection: newState.selection, decorations: DecorationSet.empty }
}
const newTable = findTable(newState.selection)
if (newTable === undefined) {
return {}
}
if (prev.selection === newState.selection) {
return prev
}
const decorations = DecorationSet.create(newState.doc, [
...tableSelectionDecoration(newState, newTable),
...tableDragMarkerDecoration(newState, newTable),
...columnHandlerDecoration(newState, newTable, editor),
...columnInsertDecoration(newState, newTable, editor),
...rowHandlerDecoration(newState, newTable, editor),
...rowInsertDecoration(newState, newTable, editor)
])
return { selection: newState.selection, decorations }
}
},
props: {
decorations (state) {
return key.getState(state).decorations
}
}
})
}
const tableSelectionNormalizer = (): Plugin<any> => {
const TableSelectionNormalizerPlugin = (): Plugin<any> => {
return new Plugin({
appendTransaction: (transactions, oldState, newState) => {
const selection = newState.selection

View File

@ -13,11 +13,11 @@
// limitations under the License.
//
import { findParentNode } from '@tiptap/core'
import { type Selection, type Transaction } from '@tiptap/pm/state'
import { type Editor, findParentNode } from '@tiptap/core'
import { type EditorState, type Selection, type Transaction } from '@tiptap/pm/state'
import { CellSelection, type Rect, TableMap, addColumn, addRow } from '@tiptap/pm/tables'
import { TableSelection, type TableNodeLocation } from './types'
import { type TableNodeLocation, TableSelection } from './types'
export function insertColumn (table: TableNodeLocation, index: number, tr: Transaction): Transaction {
const map = TableMap.get(table.node)
@ -138,3 +138,13 @@ export const isRectSelected = (rect: Rect, selection: CellSelection): boolean =>
export const findTable = (selection: Selection): TableNodeLocation | undefined => {
return findParentNode((node) => node.type.spec.tableRole === 'table')(selection)
}
export function haveTableRelatedChanges (
editor: Editor,
table: TableNodeLocation | undefined,
oldState: EditorState,
newState: EditorState,
tr: Transaction
): table is TableNodeLocation {
return editor.isEditable && table !== undefined && (tr.docChanged || !newState.selection.eq(oldState.selection))
}