diff --git a/packages/text-editor/lang/en.json b/packages/text-editor/lang/en.json index 3ea6ef35ef..881fccca8f 100644 --- a/packages/text-editor/lang/en.json +++ b/packages/text-editor/lang/en.json @@ -39,6 +39,7 @@ "AddRowAfter": "Add after", "DeleteRow": "Delete", "DeleteTable": "Delete", + "Duplicate": "Duplicate", "CategoryRow": "Rows", "CategoryColumn": "Columns", diff --git a/packages/text-editor/lang/es.json b/packages/text-editor/lang/es.json index d0be8ca066..023688ed7e 100644 --- a/packages/text-editor/lang/es.json +++ b/packages/text-editor/lang/es.json @@ -38,6 +38,7 @@ "AddRowAfter": "Añadir después", "DeleteRow": "Eliminar", "DeleteTable": "Eliminar", + "Duplicate": "Duplicar", "CategoryRow": "Filas", "CategoryColumn": "Columnas", "Table": "Tabla", diff --git a/packages/text-editor/lang/pt.json b/packages/text-editor/lang/pt.json index 3b14665522..d63f6d2aea 100644 --- a/packages/text-editor/lang/pt.json +++ b/packages/text-editor/lang/pt.json @@ -38,6 +38,7 @@ "AddRowAfter": "Adicionar depois", "DeleteRow": "Eliminar", "DeleteTable": "Eliminar", + "Duplicate": "Duplicar", "CategoryRow": "Linhas", "CategoryColumn": "Colunas", "Table": "Tabela", @@ -49,4 +50,4 @@ "Image": "Imagem", "SeparatorLine": "linha separadora" } -} \ No newline at end of file +} diff --git a/packages/text-editor/lang/ru.json b/packages/text-editor/lang/ru.json index 52cb3bd163..b9cc5679f0 100644 --- a/packages/text-editor/lang/ru.json +++ b/packages/text-editor/lang/ru.json @@ -39,6 +39,7 @@ "AddRowAfter": "Добавить после", "DeleteRow": "Удалить", "DeleteTable": "Удалить", + "Duplicate": "Дублировать", "CategoryRow": "Строки", "CategoryColumn": "Колонки", diff --git a/packages/text-editor/src/components/extension/table/decorations/actions.ts b/packages/text-editor/src/components/extension/table/decorations/actions.ts index 1a41ec35c9..014960b737 100644 --- a/packages/text-editor/src/components/extension/table/decorations/actions.ts +++ b/packages/text-editor/src/components/extension/table/decorations/actions.ts @@ -35,6 +35,49 @@ export function moveRow (table: TableNodeLocation, from: number, to: number, tr: return tr } +function isNotNull (value: T | null): value is T { + return value !== null +} + +export function duplicateRows (table: TableNodeLocation, rowIndices: number[], tr: Transaction): Transaction { + const rows = tableToCells(table) + + const { map, width } = TableMap.get(table.node) + const mapStart = tr.mapping.maps.length + + const lastRowPos = map[rowIndices[rowIndices.length - 1] * width + width - 1] + const nextRowStart = lastRowPos + (table.node.nodeAt(lastRowPos)?.nodeSize ?? 0) + 1 + const insertPos = tr.mapping.slice(mapStart).map(table.start + nextRowStart) + + for (let i = rowIndices.length - 1; i >= 0; i--) { + tr.insert(insertPos, rows[rowIndices[i]].filter(isNotNull)) + } + + return tr +} + +export function duplicateColumns (table: TableNodeLocation, columnIndices: number[], tr: Transaction): Transaction { + const rows = tableToCells(table) + + const { map, width, height } = TableMap.get(table.node) + const mapStart = tr.mapping.maps.length + + for (let row = 0; row < height; row++) { + const lastColumnPos = map[row * width + columnIndices[columnIndices.length - 1]] + const nextColumnStart = lastColumnPos + (table.node.nodeAt(lastColumnPos)?.nodeSize ?? 0) + const insertPos = tr.mapping.slice(mapStart).map(table.start + nextColumnStart) + + for (let i = columnIndices.length - 1; i >= 0; i--) { + const copiedNode = rows[row][columnIndices[i]] + if (copiedNode !== null) { + tr.insert(insertPos, copiedNode) + } + } + } + + return tr +} + function moveRowInplace (rows: TableRows, from: number, to: number): void { rows.splice(to, 0, rows.splice(from, 1)[0]) } diff --git a/packages/text-editor/src/components/extension/table/decorations/cellsHandle.ts b/packages/text-editor/src/components/extension/table/decorations/cellsHandle.ts new file mode 100644 index 0000000000..96e4e09f09 --- /dev/null +++ b/packages/text-editor/src/components/extension/table/decorations/cellsHandle.ts @@ -0,0 +1,30 @@ +import { type AnySvelteComponent, ModernPopup, showPopup } from '@hcengineering/ui' +import { handleSvg } from './icons' + +export interface OptionItem { + id: string + icon: AnySvelteComponent + label: string + action: () => void +} + +export function createCellsHandle (options: OptionItem[]): HTMLElement { + const handle = document.createElement('div') + + const button = document.createElement('button') + button.innerHTML = handleSvg + button.addEventListener('click', () => { + button.classList.add('pressed') + showPopup(ModernPopup, { items: options }, button, (result) => { + const option = options.find((it) => it.id === result) + if (option !== undefined) { + option.action() + } + button.classList.remove('pressed') + }) + }) + + handle.appendChild(button) + + return handle +} diff --git a/packages/text-editor/src/components/extension/table/decorations/columnHandlerDecoration.ts b/packages/text-editor/src/components/extension/table/decorations/columnHandlerDecoration.ts index 589d4fd5d0..0774696f7f 100644 --- a/packages/text-editor/src/components/extension/table/decorations/columnHandlerDecoration.ts +++ b/packages/text-editor/src/components/extension/table/decorations/columnHandlerDecoration.ts @@ -19,10 +19,13 @@ import { TableMap } from '@tiptap/pm/tables' import { Decoration } from '@tiptap/pm/view' import { type TableNodeLocation } from '../types' -import { isColumnSelected, selectColumn } from '../utils' +import { findTable, getSelectedColumns, isColumnSelected, selectColumn } from '../utils' -import { moveColumn } from './actions' -import { handleSvg } from './icons' +import { duplicateColumns, moveColumn } from './actions' +import DeleteCol from '../../../icons/table/DeleteCol.svelte' +import Duplicate from '../../../icons/table/Duplicate.svelte' +import textEditorPlugin from '../../../../plugin' +import { createCellsHandle, type OptionItem } from './cellsHandle' import { dropMarkerWidthPx, getColDragMarker, @@ -39,34 +42,66 @@ interface TableColumn { widthPx: number } +const createOptionItems = (editor: Editor): OptionItem[] => [ + { + id: 'delete', + icon: DeleteCol, + label: textEditorPlugin.string.DeleteColumn, + action: () => editor.commands.deleteColumn() + }, + { + id: 'duplicate', + icon: Duplicate, + label: textEditorPlugin.string.Duplicate, + action: () => { + const table = findTable(editor.state.selection) + if (table !== undefined) { + let tr = editor.state.tr + const selectedColumns = getSelectedColumns(editor.state.selection, TableMap.get(table.node)) + tr = duplicateColumns(table, selectedColumns, tr) + editor.view.dispatch(tr) + } + } + } +] + 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 = document.createElement('div') + const handle = createCellsHandle(createOptionItems(editor)) handle.classList.add('table-col-handle') - if (isColumnSelected(col, state.selection)) { + if (isSelected) { handle.classList.add('table-col-handle__selected') } - handle.innerHTML = handleSvg handle.addEventListener('mousedown', (e) => { - handleMouseDown(col, table, e, editor) + handleMouseDown(col, table, e, editor, isSelected) }) + decorations.push(Decoration.widget(pos, handle)) } return decorations } -const handleMouseDown = (col: number, table: TableNodeLocation, event: MouseEvent, editor: Editor): void => { +const handleMouseDown = ( + col: number, + table: TableNodeLocation, + event: MouseEvent, + editor: Editor, + isSelected: boolean +): void => { event.stopPropagation() event.preventDefault() // select column - editor.view.dispatch(selectColumn(table, col, editor.state.tr)) + if (!isSelected) { + editor.view.dispatch(selectColumn(table, col, editor.state.tr)) + } // drag column const tableWidthPx = getTableWidthPx(table, editor) diff --git a/packages/text-editor/src/components/extension/table/decorations/rowHandlerDecoration.ts b/packages/text-editor/src/components/extension/table/decorations/rowHandlerDecoration.ts index 5746245669..4cbd7ee4c7 100644 --- a/packages/text-editor/src/components/extension/table/decorations/rowHandlerDecoration.ts +++ b/packages/text-editor/src/components/extension/table/decorations/rowHandlerDecoration.ts @@ -19,10 +19,13 @@ import { TableMap } from '@tiptap/pm/tables' import { Decoration } from '@tiptap/pm/view' import { type TableNodeLocation } from '../types' -import { isRowSelected, selectRow } from '../utils' +import { findTable, getSelectedRows, isRowSelected, selectRow } from '../utils' -import { moveRow } from './actions' -import { handleSvg } from './icons' +import { duplicateRows, moveRow } from './actions' +import DeleteRow from '../../../icons/table/DeleteRow.svelte' +import Duplicate from '../../../icons/table/Duplicate.svelte' +import textEditorPlugin from '../../../../plugin' +import { createCellsHandle, type OptionItem } from './cellsHandle' import { dropMarkerWidthPx, getDropMarker, @@ -39,34 +42,66 @@ interface TableRow { heightPx: number } +const createOptionItems = (editor: Editor): OptionItem[] => [ + { + id: 'delete', + icon: DeleteRow, + label: textEditorPlugin.string.DeleteRow, + action: () => editor.commands.deleteRow() + }, + { + id: 'duplicate', + icon: Duplicate, + label: textEditorPlugin.string.Duplicate, + action: () => { + const table = findTable(editor.state.selection) + if (table !== undefined) { + let tr = editor.state.tr + const selectedRows = getSelectedRows(editor.state.selection, TableMap.get(table.node)) + tr = duplicateRows(table, selectedRows, tr) + editor.view.dispatch(tr) + } + } + } +] + 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 = document.createElement('div') + const handle = createCellsHandle(createOptionItems(editor)) handle.classList.add('table-row-handle') - if (isRowSelected(row, state.selection)) { + if (isSelected) { handle.classList.add('table-row-handle__selected') } - handle.innerHTML = handleSvg handle.addEventListener('mousedown', (e) => { - handleMouseDown(row, table, e, editor) + handleMouseDown(row, table, e, editor, isSelected) }) + decorations.push(Decoration.widget(pos, handle)) } return decorations } -const handleMouseDown = (row: number, table: TableNodeLocation, event: MouseEvent, editor: Editor): void => { +const handleMouseDown = ( + row: number, + table: TableNodeLocation, + event: MouseEvent, + editor: Editor, + isSelected: boolean +): void => { event.stopPropagation() event.preventDefault() // select row - editor.view.dispatch(selectRow(table, row, editor.state.tr)) + if (!isSelected) { + editor.view.dispatch(selectRow(table, row, editor.state.tr)) + } // drag row const tableHeightPx = getTableHeightPx(table, editor) diff --git a/packages/text-editor/src/components/extension/table/tableCell.ts b/packages/text-editor/src/components/extension/table/tableCell.ts index b77d3f7342..866f3e88d0 100644 --- a/packages/text-editor/src/components/extension/table/tableCell.ts +++ b/packages/text-editor/src/components/extension/table/tableCell.ts @@ -28,7 +28,7 @@ import { rowHandlerDecoration } from './decorations/rowHandlerDecoration' export const TableCell = TiptapTableCell.extend({ addProseMirrorPlugins () { - return [...(this.parent?.() ?? []), tableCellDecorationPlugin(this.editor)] + return [tableCellDecorationPlugin(this.editor)] } }) diff --git a/packages/text-editor/src/components/extension/table/utils.ts b/packages/text-editor/src/components/extension/table/utils.ts index c00a6ba10d..beb956618d 100644 --- a/packages/text-editor/src/components/extension/table/utils.ts +++ b/packages/text-editor/src/components/extension/table/utils.ts @@ -94,6 +94,29 @@ export const isRowSelected = (rowIndex: number, selection: Selection): boolean = return false } +function getSelectedRect (selection: CellSelection, map: TableMap): Rect { + const start = selection.$anchorCell.start(-1) + return map.rectBetween(selection.$anchorCell.pos - start, selection.$headCell.pos - start) +} + +export const getSelectedRows = (selection: Selection, map: TableMap): number[] => { + if (selection instanceof CellSelection && selection.isRowSelection()) { + const selectedRect = getSelectedRect(selection, map) + return [...Array(selectedRect.bottom - selectedRect.top).keys()].map((idx) => idx + selectedRect.top) + } + + return [] +} + +export const getSelectedColumns = (selection: Selection, map: TableMap): number[] => { + if (selection instanceof CellSelection && selection.isColSelection()) { + const selectedRect = getSelectedRect(selection, map) + return [...Array(selectedRect.right - selectedRect.left).keys()].map((idx) => idx + selectedRect.left) + } + + return [] +} + export const isTableSelected = (selection: Selection): boolean => { if (selection instanceof CellSelection) { const { height, width } = TableMap.get(selection.$anchorCell.node(-1)) @@ -106,11 +129,8 @@ export const isTableSelected = (selection: Selection): boolean => { export const isRectSelected = (rect: Rect, selection: CellSelection): boolean => { const map = TableMap.get(selection.$anchorCell.node(-1)) - const start = selection.$anchorCell.start(-1) const cells = map.cellsInRect(rect) - const selectedCells = map.cellsInRect( - map.rectBetween(selection.$anchorCell.pos - start, selection.$headCell.pos - start) - ) + const selectedCells = map.cellsInRect(getSelectedRect(selection, map)) return cells.every((cell) => selectedCells.includes(cell)) } diff --git a/packages/text-editor/src/components/icons/table/Duplicate.svelte b/packages/text-editor/src/components/icons/table/Duplicate.svelte new file mode 100644 index 0000000000..682ea0051b --- /dev/null +++ b/packages/text-editor/src/components/icons/table/Duplicate.svelte @@ -0,0 +1,17 @@ + + + + + + diff --git a/packages/text-editor/src/plugin.ts b/packages/text-editor/src/plugin.ts index 549bfa9b0c..2d26972e4e 100644 --- a/packages/text-editor/src/plugin.ts +++ b/packages/text-editor/src/plugin.ts @@ -72,6 +72,7 @@ export default plugin(textEditorId, { AddRowAfter: '' as IntlString, DeleteRow: '' as IntlString, DeleteTable: '' as IntlString, + Duplicate: '' as IntlString, CategoryRow: '' as IntlString, CategoryColumn: '' as IntlString, Table: '' as IntlString, diff --git a/packages/theme/styles/prose.scss b/packages/theme/styles/prose.scss index 7cf6921654..8e034b572f 100644 --- a/packages/theme/styles/prose.scss +++ b/packages/theme/styles/prose.scss @@ -1,14 +1,14 @@ // // Copyright © 2022, 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. // @@ -95,9 +95,15 @@ table.proseTable { justify-content: center; align-items: center; - svg { - color: var(--theme-button-contrast-hovered); + button { + background-color: transparent; + border-radius: var(--table-selection-border-radius); opacity: 0; + transition: background-color 0.3s ease-in-out; + + svg { + color: var(--theme-button-contrast-hovered); + } } &__selected { @@ -115,10 +121,22 @@ table.proseTable { right: var(--table-selection-border-indent); } - svg { - z-index: var(--table-handlers-z-index); - color: white; + button { opacity: 1; + z-index: var(--table-handlers-z-index); + + svg { + color: white; + } + + &:hover { + background-color: var(--primary-button-hovered); + } + + &:active, + &.pressed { + background-color: var(--primary-button-pressed); + } } } } @@ -130,12 +148,19 @@ table.proseTable { top: var(--table-handle-indent); left: 0; + button { + height: 70%; + padding: 0 4px; + } + &:hover { &:not(.table-col-handle__selected) { background-color: var(--theme-button-hovered); } - svg { opacity: 1; } + button { + opacity: 1; + } } &__selected { @@ -157,8 +182,13 @@ table.proseTable { top: 0; left: var(--table-handle-indent); - svg { - transform: rotate(90deg); + button { + width: 70%; + padding: 4px 0; + + svg { + transform: rotate(90deg); + } } &:hover { @@ -166,7 +196,7 @@ table.proseTable { background-color: var(--theme-button-hovered); } - svg { + button { opacity: 1; } }