EZQMS-381 Table rows / columns drag and drop (#4176)

Signed-off-by: Alexander Onnikov <alexander.onnikov@xored.com>
This commit is contained in:
Alexander Onnikov 2023-12-12 22:09:05 +07:00 committed by GitHub
parent 4c5d490228
commit d54422d737
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 799 additions and 241 deletions

View File

@ -104,21 +104,22 @@
.table-wrapper { .table-wrapper {
position: relative; position: relative;
display: flex; display: flex;
margin: 1.25rem 0; padding: 1.25rem 0;
&::before { &::before {
content: ''; content: '';
position: absolute; position: absolute;
top: -1.25rem; top: 0;
bottom: -1.25rem; bottom: 0;
left: -1.25rem; left: 0;
right: -1.25rem; right: 0;
} }
&.table-selected { &.table-selected {
&::before { &::before {
border: 1.25rem var(--theme-button-default) solid; border: 1.25rem var(--theme-button-default) solid;
border-radius: 1.25rem; border-radius: 1.25rem;
inset: 0 -1.25rem;
} }
} }
@ -137,7 +138,7 @@
} }
&__row { &__row {
bottom: -1.25rem; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;

View File

@ -0,0 +1,81 @@
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import type { Transaction } from '@tiptap/pm/state'
import { TableMap } from '@tiptap/pm/tables'
import type { TableNodeLocation } from '../types'
type TableRow = Array<ProseMirrorNode | null>
type TableRows = TableRow[]
export function moveColumn (table: TableNodeLocation, from: number, to: number, tr: Transaction): Transaction {
const cols = transpose(tableToCells(table))
moveRowInplace(cols, from, to)
tableFromCells(table, transpose(cols), tr)
return tr
}
export function moveRow (table: TableNodeLocation, from: number, to: number, tr: Transaction): Transaction {
const rows = tableToCells(table)
moveRowInplace(rows, from, to)
tableFromCells(table, rows, tr)
return tr
}
function moveRowInplace (rows: TableRows, from: number, to: number): void {
rows.splice(to, 0, rows.splice(from, 1)[0])
}
function transpose (rows: TableRows): TableRows {
return rows[0].map((_, colIdx) => rows.map((row) => row[colIdx]))
}
function tableToCells (table: TableNodeLocation): TableRows {
const { map, width, height } = TableMap.get(table.node)
const rows = []
for (let row = 0; row < height; row++) {
const cells = []
for (let col = 0; col < width; col++) {
const pos = map[row * width + col]
cells.push(table.node.nodeAt(pos))
}
rows.push(cells)
}
return rows
}
function tableFromCells (table: TableNodeLocation, rows: TableRows, tr: Transaction): void {
const { map, width, height } = TableMap.get(table.node)
const mapStart = tr.mapping.maps.length
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
const pos = map[row * width + col]
const oldCell = table.node.nodeAt(pos)
const newCell = rows[row][col]
if (oldCell !== null && newCell !== null && oldCell !== newCell) {
const start = tr.mapping.slice(mapStart).map(table.start + pos)
const end = start + oldCell.nodeSize
tr.replaceWith(start, end, newCell)
}
}
}
}

View File

@ -0,0 +1,139 @@
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
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 { type TableNodeLocation } from '../types'
import { isColumnSelected, selectColumn } from '../utils'
import { moveColumn } from './actions'
import { handleSvg } from './icons'
import {
dropMarkerWidthPx,
getColDragMarker,
getDropMarker,
hideDragMarker,
hideDropMarker,
updateColDropMarker,
updateColDragMarker
} from './tableDragMarkerDecoration'
import { getTableCellWidgetDecorationPos, getTableWidthPx } from './utils'
interface TableColumn {
leftPx: number
widthPx: number
}
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 handle = document.createElement('div')
handle.classList.add('table-col-handle')
if (isColumnSelected(col, state.selection)) {
handle.classList.add('table-col-handle__selected')
}
handle.innerHTML = handleSvg
handle.addEventListener('mousedown', (e) => {
handleMouseDown(col, table, e, editor)
})
decorations.push(Decoration.widget(pos, handle))
}
return decorations
}
const handleMouseDown = (col: number, table: TableNodeLocation, event: MouseEvent, editor: Editor): void => {
event.stopPropagation()
event.preventDefault()
// select column
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
tr = selectColumn(table, dropIndex, tr)
tr = moveColumn(table, col, 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 - dropMarkerWidthPx / 2, dropMarkerWidthPx)
updateColDragMarker(dragMarker, dragMarkerLeftPx, dragMarkerWidthPx)
}
}
window.addEventListener('mouseup', handleFinish)
window.addEventListener('mousemove', handleMove)
}
function calculateColumnDropIndex (col: number, columns: TableColumn[], left: number): number {
const colCenterPx = left + columns[col].widthPx / 2
const index = columns.findIndex((p) => colCenterPx < p.leftPx + p.widthPx / 2)
return index !== -1 ? (index > col ? index - 1 : index) : columns.length - 1
}
function getTableColumns (table: TableNodeLocation, editor: Editor): TableColumn[] {
const result = []
let leftPx = 0
const { map, width } = TableMap.get(table.node)
for (let col = 0; col < width; col++) {
const dom = editor.view.domAtPos(table.start + map[col] + 1)
if (dom.node instanceof HTMLElement) {
if (col === 0) {
leftPx = dom.node.offsetLeft
}
result.push({
leftPx: dom.node.offsetLeft - leftPx,
widthPx: dom.node.offsetWidth
})
}
}
return result
}

View File

@ -0,0 +1,70 @@
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
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 { 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[] = []
const { selection } = state
const tableMap = TableMap.get(table.node)
const { width } = tableMap
const tableHeightPx = getTableHeightPx(table, editor)
for (let col = 0; col < width; col++) {
const show = col < width - 1 && !isColumnSelected(col, selection) && !isColumnSelected(col + 1, selection)
if (show) {
const insert = document.createElement('div')
insert.classList.add('table-col-insert')
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)
const marker = document.createElement('div')
marker.className = 'table-insert-marker'
marker.style.height = tableHeightPx + 'px'
insert.appendChild(marker)
const pos = getTableCellWidgetDecorationPos(table, tableMap, col)
decorations.push(Decoration.widget(pos, insert))
}
}
return decorations
}
const handleMouseDown = (col: number, table: TableNodeLocation, event: Event, editor: Editor): void => {
event.stopPropagation()
event.preventDefault()
editor.view.dispatch(insertColumn(table, col + 1, editor.state.tr))
}

View File

@ -0,0 +1,135 @@
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
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 { type TableNodeLocation } from '../types'
import { isRowSelected, selectRow } from '../utils'
import { moveRow } from './actions'
import { handleSvg } from './icons'
import {
dropMarkerWidthPx,
getDropMarker,
getRowDragMarker,
hideDragMarker,
hideDropMarker,
updateRowDropMarker,
updateRowDragMarker
} from './tableDragMarkerDecoration'
import { getTableCellWidgetDecorationPos, getTableHeightPx } from './utils'
interface TableRow {
topPx: number
heightPx: number
}
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 handle = document.createElement('div')
handle.classList.add('table-row-handle')
if (isRowSelected(row, state.selection)) {
handle.classList.add('table-row-handle__selected')
}
handle.innerHTML = handleSvg
handle.addEventListener('mousedown', (e) => {
handleMouseDown(row, table, e, editor)
})
decorations.push(Decoration.widget(pos, handle))
}
return decorations
}
const handleMouseDown = (row: number, table: TableNodeLocation, event: MouseEvent, editor: Editor): void => {
event.stopPropagation()
event.preventDefault()
// select row
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
tr = selectRow(table, dropIndex, tr)
tr = moveRow(table, row, 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)
return index !== -1 ? (index > row ? index - 1 : index) : rows.length - 1
}
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)
if (dom.node instanceof HTMLElement) {
const heightPx = dom.node.offsetHeight
result.push({ topPx, heightPx })
topPx += heightPx
}
}
return result
}

View File

@ -0,0 +1,70 @@
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
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 { 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[] = []
const { selection } = state
const tableMap = TableMap.get(table.node)
const { height } = tableMap
const tableWidthPx = getTableWidthPx(table, editor)
for (let row = 0; row < height; row++) {
const show = row < height - 1 && !isRowSelected(row, selection) && !isRowSelected(row + 1, selection)
if (show) {
const dot = document.createElement('div')
dot.classList.add('table-row-insert')
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 marker = document.createElement('div')
marker.className = 'table-insert-marker'
marker.style.width = tableWidthPx + 'px'
dot.appendChild(marker)
const pos = getTableCellWidgetDecorationPos(table, tableMap, row * tableMap.width)
decorations.push(Decoration.widget(pos, dot))
}
}
return decorations
}
const handleMouseDown = (row: number, table: TableNodeLocation, event: Event, editor: Editor): void => {
event.stopPropagation()
event.preventDefault()
editor.view.dispatch(insertRow(table, row + 1, editor.state.tr))
}

View File

@ -0,0 +1,106 @@
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { type EditorState } from '@tiptap/pm/state'
import { Decoration } from '@tiptap/pm/view'
import { handleSvg } from './icons'
import { type TableNodeLocation } from '../types'
export const dropMarkerId = 'table-drop-marker'
export const colDragMarkerId = 'table-col-drag-marker'
export const rowDragMarkerId = 'table-row-drag-marker'
export const dropMarkerWidthPx = 2
export const tableDragMarkerDecoration = (state: EditorState, table: TableNodeLocation): Decoration[] => {
const dropMarker = document.createElement('div')
dropMarker.id = dropMarkerId
dropMarker.classList.add('table-drop-marker')
const colDragMarker = document.createElement('div')
colDragMarker.id = colDragMarkerId
colDragMarker.classList.add('table-col-drag-marker')
colDragMarker.innerHTML = handleSvg
colDragMarker.style.display = 'none'
const rowDragMarker = document.createElement('div')
rowDragMarker.id = rowDragMarkerId
rowDragMarker.classList.add('table-row-drag-marker')
rowDragMarker.innerHTML = handleSvg
rowDragMarker.style.display = 'none'
return [
Decoration.widget(table.start, dropMarker),
Decoration.widget(table.start, colDragMarker),
Decoration.widget(table.start, rowDragMarker)
]
}
export type DropMarkerHTMLElement = HTMLElement
export function getDropMarker (): DropMarkerHTMLElement | null {
return document.getElementById(dropMarkerId) as DropMarkerHTMLElement
}
export function hideDropMarker (element: DropMarkerHTMLElement): void {
element.style.display = 'none'
}
export function updateColDropMarker (element: DropMarkerHTMLElement, left: number, width: number): void {
element.style.height = '100%'
element.style.width = `${width}px`
element.style.top = '0'
element.style.left = `${left}px`
element.style.display = 'block'
}
export function updateRowDropMarker (element: DropMarkerHTMLElement, top: number, height: number): void {
element.style.width = '100%'
element.style.height = `${height}px`
element.style.left = '0'
element.style.top = `${top}px`
element.style.display = 'block'
}
export type DragMarkerHTMLElement = HTMLElement
export function getColDragMarker (): DragMarkerHTMLElement | null {
return document.getElementById(colDragMarkerId) as DragMarkerHTMLElement
}
export function getRowDragMarker (): DragMarkerHTMLElement | null {
return document.getElementById(rowDragMarkerId) as DragMarkerHTMLElement
}
export function getDragMarker (element: DragMarkerHTMLElement): void {
element.style.display = 'none'
}
export function hideDragMarker (element: DragMarkerHTMLElement): void {
element.style.display = 'none'
}
export function updateColDragMarker (element: DragMarkerHTMLElement, left: number, width: number): void {
element.style.width = `${width}px`
element.style.left = `${left}px`
element.style.display = 'block'
}
export function updateRowDragMarker (element: DragMarkerHTMLElement, top: number, height: number): void {
element.style.height = `${height}px`
element.style.top = `${top}px`
element.style.display = 'block'
}

View File

@ -0,0 +1,74 @@
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// 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 { type TableNodeLocation } from '../types'
export const tableSelectionDecoration = (state: EditorState, table: TableNodeLocation): Decoration[] => {
const decorations: Decoration[] = []
const { selection } = state
const tableMap = TableMap.get(table.node)
if (selection instanceof CellSelection) {
const selected: number[] = []
selection.forEachCell((_node, pos) => {
const start = pos - table.pos - 1
selected.push(start)
})
selection.forEachCell((node, pos) => {
const start = pos - table.pos - 1
const borders = getTableCellBorders(start, selected, tableMap)
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
}
function getTableCellBorders (
cell: number,
selection: number[],
tableMap: TableMap
): { top: boolean, bottom: boolean, left: boolean, right: boolean } {
const { width, height } = tableMap
const cellIndex = tableMap.map.indexOf(cell)
const topCell = cellIndex >= width ? tableMap.map[cellIndex - width] : undefined
const bottomCell = cellIndex < width * height - width ? tableMap.map[cellIndex + width] : undefined
const leftCell = cellIndex % width !== 0 ? tableMap.map[cellIndex - 1] : undefined
const rightCell = cellIndex % width !== width - 1 ? tableMap.map[cellIndex + 1] : undefined
return {
top: topCell === undefined || !selection.includes(topCell),
bottom: bottomCell === undefined || !selection.includes(bottomCell),
left: leftCell === undefined || !selection.includes(leftCell),
right: rightCell === undefined || !selection.includes(rightCell)
}
}

View File

@ -0,0 +1,32 @@
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { type Editor } from '@tiptap/core'
import { type TableMap } from '@tiptap/pm/tables'
import { type TableNodeLocation } from '../types'
export function getTableCellWidgetDecorationPos (table: TableNodeLocation, map: TableMap, index: number): number {
return table.start + map.map[index] + 1
}
export function getTableHeightPx (table: TableNodeLocation, editor: Editor): number {
const dom = editor.view.domAtPos(table.start)
return dom.node.parentElement?.offsetHeight ?? 0
}
export function getTableWidthPx (table: TableNodeLocation, editor: Editor): number {
const dom = editor.view.domAtPos(table.start)
return dom.node.parentElement?.offsetWidth ?? 0
}

View File

@ -15,13 +15,16 @@
import { type Editor } from '@tiptap/core' import { type Editor } from '@tiptap/core'
import TiptapTableCell from '@tiptap/extension-table-cell' import TiptapTableCell from '@tiptap/extension-table-cell'
import { type EditorState, Plugin, PluginKey, type Selection } from '@tiptap/pm/state' import { Plugin, PluginKey, type Selection } from '@tiptap/pm/state'
import { CellSelection, TableMap } from '@tiptap/pm/tables' import { DecorationSet } from '@tiptap/pm/view'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { addSvg, handleSvg } from './icons' import { findTable } from './utils'
import { type TableNodeLocation } from './types' import { columnHandlerDecoration } from './decorations/columnHandlerDecoration'
import { insertColumn, insertRow, findTable, isColumnSelected, isRowSelected, selectColumn, selectRow } from './utils' import { columnInsertDecoration } from './decorations/columnInsertDecoration'
import { rowInsertDecoration } from './decorations/rowInsertDecoration'
import { tableDragMarkerDecoration } from './decorations/tableDragMarkerDecoration'
import { tableSelectionDecoration } from './decorations/tableSelectionDecoration'
import { rowHandlerDecoration } from './decorations/rowHandlerDecoration'
export const TableCell = TiptapTableCell.extend({ export const TableCell = TiptapTableCell.extend({
addProseMirrorPlugins () { addProseMirrorPlugins () {
@ -58,11 +61,12 @@ const tableCellDecorationPlugin = (editor: Editor): Plugin<TableCellDecorationPl
} }
const decorations = DecorationSet.create(newState.doc, [ const decorations = DecorationSet.create(newState.doc, [
...tableSelectionDecoration(newState, newTable),
...tableDragMarkerDecoration(newState, newTable),
...columnHandlerDecoration(newState, newTable, editor), ...columnHandlerDecoration(newState, newTable, editor),
...columnInsertDecoration(newState, newTable, editor), ...columnInsertDecoration(newState, newTable, editor),
...rowHandlerDecoration(newState, newTable, editor), ...rowHandlerDecoration(newState, newTable, editor),
...rowInsertDecoration(newState, newTable, editor), ...rowInsertDecoration(newState, newTable, editor)
...selectionDecoration(newState, newTable)
]) ])
return { selection: newState.selection, decorations } return { selection: newState.selection, decorations }
} }
@ -74,216 +78,3 @@ const tableCellDecorationPlugin = (editor: Editor): Plugin<TableCellDecorationPl
} }
}) })
} }
const columnHandlerDecoration = (state: EditorState, table: TableNodeLocation, editor: Editor): Decoration[] => {
const decorations: Decoration[] = []
const { selection } = state
const tableMap = TableMap.get(table.node)
for (let col = 0; col < tableMap.width; col++) {
const pos = getTableCellWidgetDecorationPos(table, tableMap, col)
const handle = document.createElement('div')
handle.classList.add('table-col-handle')
if (isColumnSelected(col, selection)) {
handle.classList.add('table-col-handle__selected')
}
handle.innerHTML = handleSvg
handle.addEventListener('mousedown', (e) => {
handleColHandleMouseDown(col, table, e, editor)
})
decorations.push(Decoration.widget(pos, handle))
}
return decorations
}
const columnInsertDecoration = (state: EditorState, table: TableNodeLocation, editor: Editor): Decoration[] => {
const decorations: Decoration[] = []
const { selection } = state
const tableMap = TableMap.get(table.node)
const { width } = tableMap
const dom = editor.view.domAtPos(table.start)
const tableHeightPx = dom.node.parentElement?.clientHeight ?? 0
for (let col = 0; col < width; col++) {
const show = col < width - 1 && !isColumnSelected(col, selection) && !isColumnSelected(col + 1, selection)
if (show) {
const insert = document.createElement('div')
insert.classList.add('table-col-insert')
const button = document.createElement('button')
button.className = 'table-insert-button'
button.innerHTML = addSvg
button.addEventListener('mousedown', (e) => {
handleColInsertMouseDown(col, table, e, editor)
})
insert.appendChild(button)
const marker = document.createElement('div')
marker.className = 'table-insert-marker'
marker.style.height = tableHeightPx + 'px'
insert.appendChild(marker)
const pos = getTableCellWidgetDecorationPos(table, tableMap, col)
decorations.push(Decoration.widget(pos, insert))
}
}
return decorations
}
const handleColHandleMouseDown = (col: number, table: TableNodeLocation, event: Event, editor: Editor): void => {
event.stopPropagation()
event.preventDefault()
editor.view.dispatch(selectColumn(table, col, editor.state.tr))
}
const handleColInsertMouseDown = (col: number, table: TableNodeLocation, event: Event, editor: Editor): void => {
event.stopPropagation()
event.preventDefault()
editor.view.dispatch(insertColumn(table, col + 1, editor.state.tr))
}
const rowHandlerDecoration = (state: EditorState, table: TableNodeLocation, editor: Editor): Decoration[] => {
const decorations: Decoration[] = []
const { selection } = state
const tableMap = TableMap.get(table.node)
for (let row = 0; row < tableMap.height; row++) {
const pos = getTableCellWidgetDecorationPos(table, tableMap, row * tableMap.width)
const handle = document.createElement('div')
handle.classList.add('table-row-handle')
if (isRowSelected(row, selection)) {
handle.classList.add('table-row-handle__selected')
}
handle.innerHTML = handleSvg
handle.addEventListener('mousedown', (e) => {
handleRowHandleMouseDown(row, table, e, editor)
})
decorations.push(Decoration.widget(pos, handle))
}
return decorations
}
const rowInsertDecoration = (state: EditorState, table: TableNodeLocation, editor: Editor): Decoration[] => {
const decorations: Decoration[] = []
const { selection } = state
const tableMap = TableMap.get(table.node)
const { height } = tableMap
const dom = editor.view.domAtPos(table.start)
const tableWidthPx = dom.node.parentElement?.clientWidth ?? 0
for (let row = 0; row < height; row++) {
const show = row < height - 1 && !isRowSelected(row, selection) && !isRowSelected(row + 1, selection)
if (show) {
const dot = document.createElement('div')
dot.classList.add('table-row-insert')
const button = document.createElement('button')
button.className = 'table-insert-button'
button.innerHTML = addSvg
button.addEventListener('mousedown', (e) => {
handleRowInsertMouseDown(row, table, e, editor)
})
dot.appendChild(button)
const marker = document.createElement('div')
marker.className = 'table-insert-marker'
marker.style.width = tableWidthPx + 'px'
dot.appendChild(marker)
const pos = getTableCellWidgetDecorationPos(table, tableMap, row * tableMap.width)
decorations.push(Decoration.widget(pos, dot))
}
}
return decorations
}
const handleRowHandleMouseDown = (row: number, table: TableNodeLocation, event: Event, editor: Editor): void => {
event.stopPropagation()
event.preventDefault()
editor.view.dispatch(selectRow(table, row, editor.state.tr))
}
const handleRowInsertMouseDown = (row: number, table: TableNodeLocation, event: Event, editor: Editor): void => {
event.stopPropagation()
event.preventDefault()
editor.view.dispatch(insertRow(table, row + 1, editor.state.tr))
}
const selectionDecoration = (state: EditorState, table: TableNodeLocation): Decoration[] => {
const decorations: Decoration[] = []
const { selection } = state
const tableMap = TableMap.get(table.node)
if (selection instanceof CellSelection) {
const selected: number[] = []
selection.forEachCell((_node, pos) => {
const start = pos - table.pos - 1
selected.push(start)
})
selection.forEachCell((node, pos) => {
const start = pos - table.pos - 1
const borders = getTableCellBorders(start, selected, tableMap)
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
}
function getTableCellWidgetDecorationPos (table: TableNodeLocation, map: TableMap, index: number): number {
const pos = table.node.resolve(map.map[index] + 1)
return table.start + pos.start()
}
function getTableCellBorders (
cell: number,
selection: number[],
tableMap: TableMap
): { top: boolean, bottom: boolean, left: boolean, right: boolean } {
const { width, height } = tableMap
const cellIndex = tableMap.map.indexOf(cell)
const topCell = cellIndex >= width ? tableMap.map[cellIndex - width] : undefined
const bottomCell = cellIndex < width * height - width ? tableMap.map[cellIndex + width] : undefined
const leftCell = cellIndex % width !== 0 ? tableMap.map[cellIndex - 1] : undefined
const rightCell = cellIndex % width !== width - 1 ? tableMap.map[cellIndex + 1] : undefined
return {
top: topCell === undefined || !selection.includes(topCell),
bottom: bottomCell === undefined || !selection.includes(bottomCell),
left: leftCell === undefined || !selection.includes(leftCell),
right: rightCell === undefined || !selection.includes(rightCell)
}
}

View File

@ -298,6 +298,7 @@
--text-editor-toc-default-color: rgba(255, 255, 255, 0.1); --text-editor-toc-default-color: rgba(255, 255, 255, 0.1);
--text-editor-toc-hovered-color: rgba(255, 255, 255, 0.4); --text-editor-toc-hovered-color: rgba(255, 255, 255, 0.4);
--text-editor-drag-marker-bg-color: #444248;
--theme-clockface-back: radial-gradient(farthest-corner at 50% 0%, #bbb, #fff 100%); --theme-clockface-back: radial-gradient(farthest-corner at 50% 0%, #bbb, #fff 100%);
--theme-clockface-shadow: inset 0 -3px 10px #aaa; --theme-clockface-shadow: inset 0 -3px 10px #aaa;
@ -524,6 +525,7 @@
--text-editor-toc-default-color: rgba(0, 0, 0, 0.1); --text-editor-toc-default-color: rgba(0, 0, 0, 0.1);
--text-editor-toc-hovered-color: rgba(0, 0, 0, 0.4); --text-editor-toc-hovered-color: rgba(0, 0, 0, 0.4);
--text-editor-drag-marker-bg-color: #444248;
--theme-clockface-back: radial-gradient(farthest-corner at 50% 0%, #606060, #000 100%); --theme-clockface-back: radial-gradient(farthest-corner at 50% 0%, #606060, #000 100%);
--theme-clockface-shadow: inset 0 -3px 10px #000; --theme-clockface-shadow: inset 0 -3px 10px #000;

View File

@ -15,14 +15,21 @@
/* Table */ /* Table */
table.proseTable { table.proseTable {
--table-selection-border-indent: -0.125rem; --table-selection-border-width: 2px;
--table-selection-border-radius: 0.125rem; --table-selection-border-indent: -2px;
--table-selection-border-width: 0.125rem; --table-selection-border-radius: 2px;
--table-selection-border-width: 2px;
--table-handle-size: 1.25rem;
--table-handle-indent: calc(-1.25rem - 1px);
--table-selection-z-index: 100;
--table-drag-and-drop-z-index: 110;
--table-handlers-z-index: 120;
border-collapse: collapse; border-collapse: collapse;
table-layout: fixed; table-layout: fixed;
width: 100%;
position: relative; position: relative;
width: 100%;
margin: 0; margin: 0;
td, td,
@ -59,7 +66,7 @@ table.proseTable {
border-radius: var(--table-selection-border-radius); border-radius: var(--table-selection-border-radius);
pointer-events: none; pointer-events: none;
position: absolute; position: absolute;
z-index: 110; z-index: var(--table-selection-z-index);
top: var(--table-selection-border-indent); top: var(--table-selection-border-indent);
bottom: var(--table-selection-border-indent); bottom: var(--table-selection-border-indent);
left: var(--table-selection-border-indent); left: var(--table-selection-border-indent);
@ -91,7 +98,6 @@ table.proseTable {
svg { svg {
color: var(--theme-button-contrast-hovered); color: var(--theme-button-contrast-hovered);
opacity: 0; opacity: 0;
z-index: 120;
} }
&__selected { &__selected {
@ -102,7 +108,7 @@ table.proseTable {
border-radius: var(--table-selection-border-radius); border-radius: var(--table-selection-border-radius);
pointer-events: none; pointer-events: none;
position: absolute; position: absolute;
z-index: 110; z-index: var(--table-handlers-z-index);
top: var(--table-selection-border-indent); top: var(--table-selection-border-indent);
bottom: var(--table-selection-border-indent); bottom: var(--table-selection-border-indent);
left: var(--table-selection-border-indent); left: var(--table-selection-border-indent);
@ -110,6 +116,7 @@ table.proseTable {
} }
svg { svg {
z-index: var(--table-handlers-z-index);
color: white; color: white;
opacity: 1; opacity: 1;
} }
@ -119,8 +126,8 @@ table.proseTable {
.table-col-handle { .table-col-handle {
position: absolute; position: absolute;
width: calc(100% + 1px); width: calc(100% + 1px);
height: 1.25rem; height: var(--table-handle-size);
top: calc(-1.25rem - 1px); top: var(--table-handle-indent);
left: 0; left: 0;
&:hover { &:hover {
@ -134,18 +141,21 @@ table.proseTable {
&__selected { &__selected {
&::before { &::before {
right: -1px; right: -1px;
top: 0;
bottom: 0; bottom: 0;
border-bottom-width: 0; border-bottom-width: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
} }
} }
} }
.table-row-handle { .table-row-handle {
position: absolute; position: absolute;
width: 1.25rem; width: var(--table-handle-size);
height: calc(100% + 1px); height: calc(100% + 1px);
top: 0; top: 0;
left: calc(-1.25rem - 1px); left: var(--table-handle-indent);
svg { svg {
transform: rotate(90deg); transform: rotate(90deg);
@ -164,8 +174,11 @@ table.proseTable {
&__selected { &__selected {
&::before { &::before {
bottom: -1px; bottom: -1px;
left: 0;
right: 0; right: 0;
border-right-width: 0; border-right-width: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
} }
} }
} }
@ -183,7 +196,7 @@ table.proseTable {
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
top: -1.25rem; top: var(--table-handle-indent);
right: -0.625rem; right: -0.625rem;
width: 1.25rem; width: 1.25rem;
@ -197,7 +210,7 @@ table.proseTable {
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
left: -1.25rem; left: var(--table-handle-indent);
bottom: -0.625rem; bottom: -0.625rem;
height: 1.25rem; height: 1.25rem;
@ -241,6 +254,50 @@ table.proseTable {
opacity: 0; opacity: 0;
} }
} }
.table-drop-marker {
background-color: var(--primary-button-focused);
position: absolute;
z-index: var(--table-drag-and-drop-z-index);
}
.table-col-drag-marker,
.table-row-drag-marker {
position: absolute;
z-index: var(--table-drag-and-drop-z-index);
opacity: 0.5;
background-color: var(--text-editor-drag-marker-bg-color);
border: var(--table-selection-border-width) solid var(--text-editor-drag-marker-bg-color);
border-radius: var(--table-selection-border-radius);
display: flex;
justify-content: center;
align-items: center;
svg {
color: white;
margin: auto;
}
}
.table-col-drag-marker {
height: var(--table-handle-size);
top: calc(var(--table-handle-indent) + 1px);
svg {
width: 100%;
}
}
.table-row-drag-marker {
width: var(--table-handle-size);
left: calc(var(--table-handle-indent) + 1px / 2);
svg {
height: 100%;
transform: rotate(90deg);
}
}
} }
.proseCode { .proseCode {