Merge remote-tracking branch 'origin/develop' into staging

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2025-01-08 16:38:57 +07:00
commit cc2c0c31b2
No known key found for this signature in database
GPG Key ID: BD80F68D68D8F7F2
14 changed files with 244 additions and 59 deletions

View File

@ -207,4 +207,11 @@
<path d="M0.5 7.99988L3 10.4999L3.705 9.79488L2.415 8.49988H6.5C6.77614 8.49988 7 8.27602 7 7.99988C7 7.72374 6.77614 7.49988 6.5 7.49988H2.415L3.705 6.20488L3 5.49988L0.5 7.99988Z"/>
<path d="M12.295 6.20488L13.585 7.49988H9.5C9.22386 7.49988 9 7.72374 9 7.99988C9 8.27602 9.22386 8.49988 9.5 8.49988H13.585L12.295 9.79488L13 10.4999L15.5 7.99988L13 5.49988L12.295 6.20488Z"/>
</symbol>
<symbol id="union" viewBox="0 0 32 32" fill="none">
<path d="M17 3H6C4.34315 3 3 4.34315 3 6V17C3 18.6569 4.34315 20 6 20H12V26C12 27.6569 13.3431 29 15 29H26C27.6569 29 29 27.6569 29 26V15C29 13.3431 27.6569 12 26 12H20V6C20 4.34315 18.6569 3 17 3Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
</symbol>
<symbol id="divide" viewBox="0 0 32 32" fill="none">
<path d="M3 6C3 4.34315 4.34315 3 6 3H17C18.6569 3 20 4.34315 20 6V10.5556V17C20 18.6569 18.6569 20 17 20H10.5556H6C4.34314 20 3 18.6569 3 17V6Z" stroke="currentColor" stroke-width="2"/>
<path d="M12 15C12 13.3431 13.3431 12 15 12H26C27.6569 12 29 13.3431 29 15V26C29 27.6569 27.6569 29 26 29H15C13.3431 29 12 27.6569 12 26V15Z" stroke="currentColor" stroke-width="2"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -46,9 +46,12 @@
"AddRowAfter": "Add after",
"DeleteRow": "Delete",
"DeleteTable": "Delete",
"MergeCells": "Merge cells",
"SplitCells": "Split cells",
"Duplicate": "Duplicate",
"CategoryRow": "Rows",
"CategoryColumn": "Columns",
"CategoryCell": "Cells",
"Table": "Table",
"InsertTable": "Insert table",
"TableOptions": "Customize table",

View File

@ -46,9 +46,12 @@
"AddRowAfter": "Добавить после",
"DeleteRow": "Удалить",
"DeleteTable": "Удалить",
"MergeCells": "Объединить ячейки",
"SplitCells": "Разделить ячейки",
"Duplicate": "Дублировать",
"CategoryRow": "Строки",
"CategoryColumn": "Колонки",
"CategoryCell": "Ячейки",
"Table": "Таблица",
"InsertTable": "Добавить таблицу",
"TableOptions": "Настроить таблицу",

View File

@ -41,5 +41,7 @@ loadMetadata(textEditor.icon, {
Download: `${icons}#download`,
Note: `${icons}#note`,
Comment: `${icons}#comment`,
SelectTable: `${icons}#move`
SelectTable: `${icons}#move`,
MergeCells: `${icons}#union`,
SplitCells: `${icons}#divide`
})

View File

@ -175,7 +175,7 @@
}
&__col {
right: -1.5rem;
right: calc(var(--table-offscreen-spacing) - 1.5rem);
top: 0;
bottom: 0;
margin: 1.25rem 0;
@ -188,7 +188,7 @@
&__row {
bottom: -0.25rem;
left: var(--table-offscreen-spacing);
right: 0;
right: var(--table-offscreen-spacing);
.table-button {
height: 1.25rem;

View File

@ -13,25 +13,76 @@
// limitations under the License.
//
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Fragment, type Node, type Node as ProseMirrorNode } from '@tiptap/pm/model'
import type { Transaction } from '@tiptap/pm/state'
import { TableMap } from '@tiptap/pm/tables'
import { type CellSelection, TableMap } from '@tiptap/pm/tables'
import type { TableNodeLocation } from '../types'
import { type Editor } from '@tiptap/core'
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)
export function moveSelectedColumns (
editor: Editor,
table: TableNodeLocation,
selection: CellSelection,
to: number,
tr: Transaction
): Transaction {
const tableMap = TableMap.get(table.node)
let columnStart = -1
let columnEnd = -1
selection.forEachCell((node, pos) => {
const cell = tableMap.findCell(pos - table.pos - 1)
for (let i = cell.left; i < cell.right; i++) {
columnStart = columnStart >= 0 ? Math.min(cell.left, columnStart) : cell.left
columnEnd = columnEnd >= 0 ? Math.max(cell.right, columnEnd) : cell.right
}
})
if (to < 0 || to > tableMap.width || (to >= columnStart && to < columnEnd)) return tr
const rows = tableToCells(table)
for (const row of rows) {
const range = row.splice(columnStart, columnEnd - columnStart)
const offset = to > columnStart ? to - (columnEnd - columnStart - 1) : to
row.splice(offset, 0, ...range)
}
tableFromCells(editor, table, rows, tr)
return tr
}
export function moveRow (table: TableNodeLocation, from: number, to: number, tr: Transaction): Transaction {
export function moveSelectedRows (
editor: Editor,
table: TableNodeLocation,
selection: CellSelection,
to: number,
tr: Transaction
): Transaction {
const tableMap = TableMap.get(table.node)
let rowStart = -1
let rowEnd = -1
selection.forEachCell((node, pos) => {
const cell = tableMap.findCell(pos - table.pos - 1)
for (let i = cell.top; i < cell.bottom; i++) {
rowStart = rowStart >= 0 ? Math.min(cell.top, rowStart) : cell.top
rowEnd = rowEnd >= 0 ? Math.max(cell.bottom, rowEnd) : cell.bottom
}
})
if (to < 0 || to > tableMap.height || (to >= rowStart && to < rowEnd)) return tr
const rows = tableToCells(table)
moveRowInplace(rows, from, to)
tableFromCells(table, rows, tr)
const range = rows.splice(rowStart, rowEnd - rowStart)
const offset = to > rowStart ? to - (rowEnd - rowStart - 1) : to
rows.splice(offset, 0, ...range)
tableFromCells(editor, table, rows, tr)
return tr
}
@ -78,23 +129,17 @@ export function duplicateColumns (table: TableNodeLocation, columnIndices: numbe
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 visitedCells = new Set<number>()
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))
cells.push(!visitedCells.has(pos) ? table.node.nodeAt(pos) : null)
visitedCells.add(pos)
}
rows.push(cells)
}
@ -102,23 +147,11 @@ function tableToCells (table: TableNodeLocation): TableRows {
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)
}
}
}
function tableFromCells (editor: Editor, table: TableNodeLocation, rows: TableRows, tr: Transaction): void {
const schema = editor.schema.nodes
const newRowNodes = rows.map((row) =>
schema.tableRow.create(null, row.filter((cell) => cell !== null) as readonly Node[])
)
const newTableNode = table.node.copy(Fragment.from(newRowNodes))
tr.replaceWith(table.pos, table.pos + table.node.nodeSize, newTableNode)
}

View File

@ -15,14 +15,14 @@
import { type Editor } from '@tiptap/core'
import { type EditorState } from '@tiptap/pm/state'
import { TableMap } from '@tiptap/pm/tables'
import { CellSelection, TableMap } from '@tiptap/pm/tables'
import { Decoration } from '@tiptap/pm/view'
import textEditor from '@hcengineering/text-editor'
import { type TableNodeLocation } from '../types'
import { findTable, getSelectedColumns, isColumnSelected, selectColumn } from '../utils'
import { duplicateColumns, moveColumn } from './actions'
import { duplicateColumns, moveSelectedColumns } from './actions'
import DeleteCol from '../../../icons/table/DeleteCol.svelte'
import Duplicate from '../../../icons/table/Duplicate.svelte'
import { createCellsHandle, type OptionItem } from './cellsHandle'
@ -120,8 +120,10 @@ const handleMouseDown = (
if (col !== dropIndex) {
let tr = editor.state.tr
tr = selectColumn(table, dropIndex, tr)
tr = moveColumn(table, col, dropIndex, tr)
const selection = editor.state.selection
if (selection instanceof CellSelection) {
tr = moveSelectedColumns(editor, table, selection, dropIndex, tr)
}
editor.view.dispatch(tr)
}
window.removeEventListener('mouseup', handleFinish)

View File

@ -15,14 +15,14 @@
import { type Editor } from '@tiptap/core'
import { type EditorState } from '@tiptap/pm/state'
import { TableMap } from '@tiptap/pm/tables'
import { CellSelection, TableMap } from '@tiptap/pm/tables'
import { Decoration } from '@tiptap/pm/view'
import textEditor from '@hcengineering/text-editor'
import { type TableNodeLocation } from '../types'
import { findTable, getSelectedRows, isRowSelected, selectRow } from '../utils'
import { duplicateRows, moveRow } from './actions'
import { duplicateRows, moveSelectedRows } from './actions'
import DeleteRow from '../../../icons/table/DeleteRow.svelte'
import Duplicate from '../../../icons/table/Duplicate.svelte'
import { createCellsHandle, type OptionItem } from './cellsHandle'
@ -120,8 +120,10 @@ const handleMouseDown = (
if (row !== dropIndex) {
let tr = editor.state.tr
tr = selectRow(table, dropIndex, tr)
tr = moveRow(table, row, dropIndex, tr)
const selection = editor.state.selection
if (selection instanceof CellSelection) {
tr = moveSelectedRows(editor, table, selection, dropIndex, tr)
}
editor.view.dispatch(tr)
}
window.removeEventListener('mouseup', handleFinish)

View File

@ -60,10 +60,17 @@ function getTableCellBorders (
const { width, height } = tableMap
const cellIndex = tableMap.map.indexOf(cell)
const rect = tableMap.findCell(cell)
const cellW = rect.right - rect.left
const cellH = rect.bottom - rect.top
const testRight = cellW
const testBottom = width * cellH
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
const bottomCell = cellIndex < width * height - testBottom ? tableMap.map[cellIndex + testBottom] : undefined
const leftCell = cellIndex % width > 0 ? tableMap.map[cellIndex - 1] : undefined
const rightCell = cellIndex % width < width - testRight ? tableMap.map[cellIndex + testRight] : undefined
return {
top: topCell === undefined || !selection.includes(topCell),

View File

@ -116,6 +116,24 @@ export async function openTableOptions (editor: Editor, event: MouseEvent): Prom
label: textEditor.string.CategoryRow
}
},
{
id: '#mergeCells',
icon: textEditor.icon.MergeCells,
label: textEditor.string.MergeCells,
action: () => editor.commands.mergeCells(),
category: {
label: textEditor.string.CategoryCell
}
},
{
id: '#splitCell',
icon: textEditor.icon.SplitCells,
label: textEditor.string.SplitCells,
action: () => editor.commands.splitCell(),
category: {
label: textEditor.string.CategoryCell
}
},
{
id: '#deleteTable',
icon: DeleteTable,

View File

@ -18,17 +18,19 @@ import TiptapTableCell from '@tiptap/extension-table-cell'
import { Plugin, PluginKey, type Selection } from '@tiptap/pm/state'
import { DecorationSet } from '@tiptap/pm/view'
import { findTable } from './utils'
import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables'
import { columnHandlerDecoration } from './decorations/columnHandlerDecoration'
import { columnInsertDecoration } from './decorations/columnInsertDecoration'
import { rowHandlerDecoration } from './decorations/rowHandlerDecoration'
import { rowInsertDecoration } from './decorations/rowInsertDecoration'
import { tableDragMarkerDecoration } from './decorations/tableDragMarkerDecoration'
import { tableSelectionDecoration } from './decorations/tableSelectionDecoration'
import { rowHandlerDecoration } from './decorations/rowHandlerDecoration'
import { findTable } from './utils'
import { type Node } from '@tiptap/pm/model'
export const TableCell = TiptapTableCell.extend({
addProseMirrorPlugins () {
return [tableCellDecorationPlugin(this.editor)]
return [tableCellDecorationPlugin(this.editor), tableSelectionNormalizer()]
}
})
@ -78,3 +80,80 @@ const tableCellDecorationPlugin = (editor: Editor): Plugin<TableCellDecorationPl
}
})
}
const tableSelectionNormalizer = (): Plugin<any> => {
return new Plugin({
appendTransaction: (transactions, oldState, newState) => {
const selection = newState.selection
if (selection.eq(oldState.selection) || !(selection instanceof CellSelection)) return
const table = findTable(newState.selection)
if (table === undefined) return
const tableMap = TableMap.get(table.node)
let rect: Rect | undefined
const walkCell = (pos: number): void => {
const cell = tableMap.findCell(pos)
if (cell === undefined) return
if (rect === undefined) {
rect = { ...cell }
} else {
rect.left = Math.min(rect.left, cell.left)
rect.top = Math.min(rect.top, cell.top)
rect.right = Math.max(rect.right, cell.right)
rect.bottom = Math.max(rect.bottom, cell.bottom)
}
}
selection.forEachCell((_node, pos) => {
walkCell(pos - table.pos - 1)
})
if (rect === undefined) return
const rectSelection: number[] = []
for (let row = rect.top; row < rect.bottom; row++) {
for (let col = rect.left; col < rect.right; col++) {
rectSelection.push(tableMap.map[row * tableMap.width + col])
}
}
rectSelection.forEach((pos) => {
walkCell(pos)
})
if (rect === undefined) return
// Original promemirror implementation of TableMap.positionAt skips rowspawn cells, which leads to unpredictable selection behaviour
const firstCellOffset = cellPositionAt(tableMap, rect.bottom - 1, rect.right - 1, table.node)
const lastCellOffset = cellPositionAt(tableMap, rect.top, rect.left, table.node)
const firstCellPos = newState.doc.resolve(table.start + firstCellOffset)
const lastCellPos = newState.doc.resolve(table.start + lastCellOffset)
const reverseOrder = selection.$anchorCell.pos > selection.$headCell.pos
const $head = reverseOrder ? lastCellPos : firstCellPos
const $anchor = reverseOrder ? firstCellPos : lastCellPos
const newSelection = new CellSelection($anchor, $head)
if (newSelection.eq(newState.selection)) return
return newState.tr.setSelection(new CellSelection($anchor, $head))
}
})
}
function cellPositionAt (tableMap: TableMap, row: number, col: number, table: Node): number {
for (let i = 0, rowStart = 0; ; i++) {
const rowEnd = rowStart + table.child(i).nodeSize
if (i === row) {
const index = col + row * tableMap.width
const rowEndIndex = (row + 1) * tableMap.width
return index === rowEndIndex ? rowEnd - 1 : tableMap.map[index]
}
rowStart = rowEnd
}
}

View File

@ -84,11 +84,14 @@ export default plugin(textEditorId, {
DeleteColumn: '' as IntlString,
AddRowBefore: '' as IntlString,
AddRowAfter: '' as IntlString,
MergeCells: '' as IntlString,
SplitCells: '' as IntlString,
DeleteRow: '' as IntlString,
DeleteTable: '' as IntlString,
Duplicate: '' as IntlString,
CategoryRow: '' as IntlString,
CategoryColumn: '' as IntlString,
CategoryCell: '' as IntlString,
Table: '' as IntlString,
TableOptions: '' as IntlString,
SelectTable: '' as IntlString,
@ -125,6 +128,8 @@ export default plugin(textEditorId, {
Download: '' as Asset,
Note: '' as Asset,
Comment: '' as Asset,
SelectTable: '' as Asset
SelectTable: '' as Asset,
MergeCells: '' as Asset,
SplitCells: '' as Asset
}
})

View File

@ -553,6 +553,13 @@ export interface Session {
query: DocumentQuery<T>,
options?: FindOptions<T>
) => Promise<void>
findAllRaw: <T extends Doc>(
ctx: MeasureContext,
pipeline: Pipeline,
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
) => Promise<FindResult<T>>
searchFulltext: (ctx: ClientSessionCtx, query: SearchQuery, options: SearchOptions) => Promise<void>
tx: (ctx: ClientSessionCtx, tx: Tx) => Promise<void>
loadChunk: (ctx: ClientSessionCtx, domain: Domain, idx?: number) => Promise<void>

View File

@ -716,21 +716,38 @@ class TSessionManager implements SessionManager {
const user = pipeline.context.modelDb.getAccountByEmail(session.getUser())
if (user === undefined) return
const status = (await pipeline.findAll(ctx, core.class.UserStatus, { user: user._id }, { limit: 1 }))[0]
const clientCtx: ClientSessionCtx = {
requestId: undefined,
pipeline,
sendResponse: async (msg) => {
// No response
},
ctx,
sendError: async (msg, error: Status) => {
// Assume no error send
},
sendPong: () => {}
}
const status = (
await session.findAllRaw(ctx, pipeline, core.class.UserStatus, { user: user._id }, { limit: 1 })
)[0]
const txFactory = new TxFactory(user._id, true)
if (status === undefined) {
const tx = txFactory.createTxCreateDoc(core.class.UserStatus, core.space.Space, {
online,
user: user._id
})
await pipeline.tx(ctx, [tx])
await session.tx(clientCtx, tx)
} else if (status.online !== online) {
const tx = txFactory.createTxUpdateDoc(status._class, status.space, status._id, {
online
})
await pipeline.tx(ctx, [tx])
await session.tx(clientCtx, tx)
}
} catch {}
} catch (err: any) {
ctx.error('failed to set status', { err })
Analytics.handleError(err)
}
}
async close (ctx: MeasureContext, ws: ConnectionSocket, wsid: string): Promise<void> {