From d54422d737ba7fe547dfa9a095e92ce0881f4864 Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Tue, 12 Dec 2023 22:09:05 +0700 Subject: [PATCH] EZQMS-381 Table rows / columns drag and drop (#4176) Signed-off-by: Alexander Onnikov --- .../extension/table/TableNodeView.svelte | 13 +- .../extension/table/decorations/actions.ts | 81 ++++++ .../decorations/columnHandlerDecoration.ts | 139 +++++++++++ .../decorations/columnInsertDecoration.ts | 70 ++++++ .../table/{ => decorations}/icons.ts | 0 .../table/decorations/rowHandlerDecoration.ts | 135 ++++++++++ .../table/decorations/rowInsertDecoration.ts | 70 ++++++ .../decorations/tableDragMarkerDecoration.ts | 106 ++++++++ .../decorations/tableSelectionDecoration.ts | 74 ++++++ .../extension/table/decorations/utils.ts | 32 +++ .../components/extension/table/tableCell.ts | 233 +----------------- packages/theme/styles/_colors.scss | 2 + packages/theme/styles/prose.scss | 85 +++++-- 13 files changed, 799 insertions(+), 241 deletions(-) create mode 100644 packages/text-editor/src/components/extension/table/decorations/actions.ts create mode 100644 packages/text-editor/src/components/extension/table/decorations/columnHandlerDecoration.ts create mode 100644 packages/text-editor/src/components/extension/table/decorations/columnInsertDecoration.ts rename packages/text-editor/src/components/extension/table/{ => decorations}/icons.ts (100%) create mode 100644 packages/text-editor/src/components/extension/table/decorations/rowHandlerDecoration.ts create mode 100644 packages/text-editor/src/components/extension/table/decorations/rowInsertDecoration.ts create mode 100644 packages/text-editor/src/components/extension/table/decorations/tableDragMarkerDecoration.ts create mode 100644 packages/text-editor/src/components/extension/table/decorations/tableSelectionDecoration.ts create mode 100644 packages/text-editor/src/components/extension/table/decorations/utils.ts diff --git a/packages/text-editor/src/components/extension/table/TableNodeView.svelte b/packages/text-editor/src/components/extension/table/TableNodeView.svelte index a508595318..0fb41c457a 100644 --- a/packages/text-editor/src/components/extension/table/TableNodeView.svelte +++ b/packages/text-editor/src/components/extension/table/TableNodeView.svelte @@ -104,21 +104,22 @@ .table-wrapper { position: relative; display: flex; - margin: 1.25rem 0; + padding: 1.25rem 0; &::before { content: ''; position: absolute; - top: -1.25rem; - bottom: -1.25rem; - left: -1.25rem; - right: -1.25rem; + top: 0; + bottom: 0; + left: 0; + right: 0; } &.table-selected { &::before { border: 1.25rem var(--theme-button-default) solid; border-radius: 1.25rem; + inset: 0 -1.25rem; } } @@ -137,7 +138,7 @@ } &__row { - bottom: -1.25rem; + bottom: 0; left: 0; right: 0; diff --git a/packages/text-editor/src/components/extension/table/decorations/actions.ts b/packages/text-editor/src/components/extension/table/decorations/actions.ts new file mode 100644 index 0000000000..1a41ec35c9 --- /dev/null +++ b/packages/text-editor/src/components/extension/table/decorations/actions.ts @@ -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 +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) + } + } + } +} diff --git a/packages/text-editor/src/components/extension/table/decorations/columnHandlerDecoration.ts b/packages/text-editor/src/components/extension/table/decorations/columnHandlerDecoration.ts new file mode 100644 index 0000000000..589d4fd5d0 --- /dev/null +++ b/packages/text-editor/src/components/extension/table/decorations/columnHandlerDecoration.ts @@ -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 +} diff --git a/packages/text-editor/src/components/extension/table/decorations/columnInsertDecoration.ts b/packages/text-editor/src/components/extension/table/decorations/columnInsertDecoration.ts new file mode 100644 index 0000000000..46a23cc1b6 --- /dev/null +++ b/packages/text-editor/src/components/extension/table/decorations/columnInsertDecoration.ts @@ -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)) +} diff --git a/packages/text-editor/src/components/extension/table/icons.ts b/packages/text-editor/src/components/extension/table/decorations/icons.ts similarity index 100% rename from packages/text-editor/src/components/extension/table/icons.ts rename to packages/text-editor/src/components/extension/table/decorations/icons.ts diff --git a/packages/text-editor/src/components/extension/table/decorations/rowHandlerDecoration.ts b/packages/text-editor/src/components/extension/table/decorations/rowHandlerDecoration.ts new file mode 100644 index 0000000000..5746245669 --- /dev/null +++ b/packages/text-editor/src/components/extension/table/decorations/rowHandlerDecoration.ts @@ -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 +} diff --git a/packages/text-editor/src/components/extension/table/decorations/rowInsertDecoration.ts b/packages/text-editor/src/components/extension/table/decorations/rowInsertDecoration.ts new file mode 100644 index 0000000000..f56c8cd868 --- /dev/null +++ b/packages/text-editor/src/components/extension/table/decorations/rowInsertDecoration.ts @@ -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)) +} diff --git a/packages/text-editor/src/components/extension/table/decorations/tableDragMarkerDecoration.ts b/packages/text-editor/src/components/extension/table/decorations/tableDragMarkerDecoration.ts new file mode 100644 index 0000000000..bc03e5d981 --- /dev/null +++ b/packages/text-editor/src/components/extension/table/decorations/tableDragMarkerDecoration.ts @@ -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' +} diff --git a/packages/text-editor/src/components/extension/table/decorations/tableSelectionDecoration.ts b/packages/text-editor/src/components/extension/table/decorations/tableSelectionDecoration.ts new file mode 100644 index 0000000000..9266ee3f98 --- /dev/null +++ b/packages/text-editor/src/components/extension/table/decorations/tableSelectionDecoration.ts @@ -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) + } +} diff --git a/packages/text-editor/src/components/extension/table/decorations/utils.ts b/packages/text-editor/src/components/extension/table/decorations/utils.ts new file mode 100644 index 0000000000..8e5afe7b08 --- /dev/null +++ b/packages/text-editor/src/components/extension/table/decorations/utils.ts @@ -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 +} diff --git a/packages/text-editor/src/components/extension/table/tableCell.ts b/packages/text-editor/src/components/extension/table/tableCell.ts index d774a77496..b77d3f7342 100644 --- a/packages/text-editor/src/components/extension/table/tableCell.ts +++ b/packages/text-editor/src/components/extension/table/tableCell.ts @@ -15,13 +15,16 @@ import { type Editor } from '@tiptap/core' import TiptapTableCell from '@tiptap/extension-table-cell' -import { type EditorState, Plugin, PluginKey, type Selection } from '@tiptap/pm/state' -import { CellSelection, TableMap } from '@tiptap/pm/tables' -import { Decoration, DecorationSet } from '@tiptap/pm/view' +import { Plugin, PluginKey, type Selection } from '@tiptap/pm/state' +import { DecorationSet } from '@tiptap/pm/view' -import { addSvg, handleSvg } from './icons' -import { type TableNodeLocation } from './types' -import { insertColumn, insertRow, findTable, isColumnSelected, isRowSelected, selectColumn, selectRow } from './utils' +import { findTable } from './utils' +import { columnHandlerDecoration } from './decorations/columnHandlerDecoration' +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({ addProseMirrorPlugins () { @@ -58,11 +61,12 @@ const tableCellDecorationPlugin = (editor: Editor): Plugin { - 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) - } -} diff --git a/packages/theme/styles/_colors.scss b/packages/theme/styles/_colors.scss index 9c1287b29a..26bfd19c9e 100644 --- a/packages/theme/styles/_colors.scss +++ b/packages/theme/styles/_colors.scss @@ -298,6 +298,7 @@ --text-editor-toc-default-color: rgba(255, 255, 255, 0.1); --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-shadow: inset 0 -3px 10px #aaa; @@ -524,6 +525,7 @@ --text-editor-toc-default-color: rgba(0, 0, 0, 0.1); --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-shadow: inset 0 -3px 10px #000; diff --git a/packages/theme/styles/prose.scss b/packages/theme/styles/prose.scss index 467c96604a..2675204cb3 100644 --- a/packages/theme/styles/prose.scss +++ b/packages/theme/styles/prose.scss @@ -15,14 +15,21 @@ /* Table */ table.proseTable { - --table-selection-border-indent: -0.125rem; - --table-selection-border-radius: 0.125rem; - --table-selection-border-width: 0.125rem; + --table-selection-border-width: 2px; + --table-selection-border-indent: -2px; + --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; table-layout: fixed; - width: 100%; position: relative; + width: 100%; margin: 0; td, @@ -59,7 +66,7 @@ table.proseTable { border-radius: var(--table-selection-border-radius); pointer-events: none; position: absolute; - z-index: 110; + z-index: var(--table-selection-z-index); top: var(--table-selection-border-indent); bottom: var(--table-selection-border-indent); left: var(--table-selection-border-indent); @@ -91,7 +98,6 @@ table.proseTable { svg { color: var(--theme-button-contrast-hovered); opacity: 0; - z-index: 120; } &__selected { @@ -102,7 +108,7 @@ table.proseTable { border-radius: var(--table-selection-border-radius); pointer-events: none; position: absolute; - z-index: 110; + z-index: var(--table-handlers-z-index); top: var(--table-selection-border-indent); bottom: var(--table-selection-border-indent); left: var(--table-selection-border-indent); @@ -110,6 +116,7 @@ table.proseTable { } svg { + z-index: var(--table-handlers-z-index); color: white; opacity: 1; } @@ -119,8 +126,8 @@ table.proseTable { .table-col-handle { position: absolute; width: calc(100% + 1px); - height: 1.25rem; - top: calc(-1.25rem - 1px); + height: var(--table-handle-size); + top: var(--table-handle-indent); left: 0; &:hover { @@ -134,18 +141,21 @@ table.proseTable { &__selected { &::before { right: -1px; + top: 0; bottom: 0; border-bottom-width: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } } } .table-row-handle { position: absolute; - width: 1.25rem; + width: var(--table-handle-size); height: calc(100% + 1px); top: 0; - left: calc(-1.25rem - 1px); + left: var(--table-handle-indent); svg { transform: rotate(90deg); @@ -164,8 +174,11 @@ table.proseTable { &__selected { &::before { bottom: -1px; + left: 0; right: 0; border-right-width: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; } } } @@ -183,7 +196,7 @@ table.proseTable { flex-direction: column; justify-content: flex-start; align-items: center; - top: -1.25rem; + top: var(--table-handle-indent); right: -0.625rem; width: 1.25rem; @@ -197,7 +210,7 @@ table.proseTable { flex-direction: row; justify-content: flex-start; align-items: center; - left: -1.25rem; + left: var(--table-handle-indent); bottom: -0.625rem; height: 1.25rem; @@ -241,6 +254,50 @@ table.proseTable { 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 { @@ -273,4 +330,4 @@ pre.proseCodeBlock > pre.proseCode { border-radius: 0; } -.proseHeading { line-height: 110%; } \ No newline at end of file +.proseHeading { line-height: 110%; }