diff --git a/plugins/text-editor-resources/src/components/extension/table/decorations/columnHandlerDecoration.ts b/plugins/text-editor-resources/src/components/extension/table/decorations/columnHandlerDecoration.ts index 762c4a3dad..ad711e9f5c 100644 --- a/plugins/text-editor-resources/src/components/extension/table/decorations/columnHandlerDecoration.ts +++ b/plugins/text-editor-resources/src/components/extension/table/decorations/columnHandlerDecoration.ts @@ -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 => { + const key = new PluginKey('tableColumnHandlerDecorationPlugin') + return new Plugin({ + 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 { diff --git a/plugins/text-editor-resources/src/components/extension/table/decorations/columnInsertDecoration.ts b/plugins/text-editor-resources/src/components/extension/table/decorations/columnInsertDecoration.ts index 8bbd5f160e..6e8859c0d9 100644 --- a/plugins/text-editor-resources/src/components/extension/table/decorations/columnInsertDecoration.ts +++ b/plugins/text-editor-resources/src/components/extension/table/decorations/columnInsertDecoration.ts @@ -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 => { + const key = new PluginKey('tableColumnInsertDecorationPlugin') + return new Plugin({ + 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 + } } diff --git a/plugins/text-editor-resources/src/components/extension/table/decorations/rowHandlerDecoration.ts b/plugins/text-editor-resources/src/components/extension/table/decorations/rowHandlerDecoration.ts index 4ff95067f7..cc13b46bf4 100644 --- a/plugins/text-editor-resources/src/components/extension/table/decorations/rowHandlerDecoration.ts +++ b/plugins/text-editor-resources/src/components/extension/table/decorations/rowHandlerDecoration.ts @@ -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 => { + const key = new PluginKey('tableRowHandlerDecorationPlugin') + return new Plugin({ + 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 }) diff --git a/plugins/text-editor-resources/src/components/extension/table/decorations/rowInsertDecoration.ts b/plugins/text-editor-resources/src/components/extension/table/decorations/rowInsertDecoration.ts index fec92b46e2..f3d6878a3b 100644 --- a/plugins/text-editor-resources/src/components/extension/table/decorations/rowInsertDecoration.ts +++ b/plugins/text-editor-resources/src/components/extension/table/decorations/rowInsertDecoration.ts @@ -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 => { + const key = new PluginKey('tableRowInsertDecorationPlugin') + return new Plugin({ + 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 + } } diff --git a/plugins/text-editor-resources/src/components/extension/table/decorations/tableDragMarkerDecoration.ts b/plugins/text-editor-resources/src/components/extension/table/decorations/tableDragMarkerDecoration.ts index c11b1ec840..5eb134d067 100644 --- a/plugins/text-editor-resources/src/components/extension/table/decorations/tableDragMarkerDecoration.ts +++ b/plugins/text-editor-resources/src/components/extension/table/decorations/tableDragMarkerDecoration.ts @@ -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 => { + const key = new PluginKey('table-cell-drag-marker-decoration-plugin') + return new Plugin({ + 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 diff --git a/plugins/text-editor-resources/src/components/extension/table/decorations/tableSelectionDecoration.ts b/plugins/text-editor-resources/src/components/extension/table/decorations/tableSelectionDecoration.ts index 6962b79414..58ce8a7190 100644 --- a/plugins/text-editor-resources/src/components/extension/table/decorations/tableSelectionDecoration.ts +++ b/plugins/text-editor-resources/src/components/extension/table/decorations/tableSelectionDecoration.ts @@ -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 => { + const key = new PluginKey('tableSelectionDecorationPlugin') + return new Plugin({ + 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 ( diff --git a/plugins/text-editor-resources/src/components/extension/table/tableCell.ts b/plugins/text-editor-resources/src/components/extension/table/tableCell.ts index 0bbf96dcce..88c02be71c 100644 --- a/plugins/text-editor-resources/src/components/extension/table/tableCell.ts +++ b/plugins/text-editor-resources/src/components/extension/table/tableCell.ts @@ -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 => { - 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 => { +const TableSelectionNormalizerPlugin = (): Plugin => { return new Plugin({ appendTransaction: (transactions, oldState, newState) => { const selection = newState.selection diff --git a/plugins/text-editor-resources/src/components/extension/table/utils.ts b/plugins/text-editor-resources/src/components/extension/table/utils.ts index 3783b3d649..39b5f20dda 100644 --- a/plugins/text-editor-resources/src/components/extension/table/utils.ts +++ b/plugins/text-editor-resources/src/components/extension/table/utils.ts @@ -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)) +}