Merge branch 'develop' into staging-new

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev
2025-10-17 10:03:57 +07:00
9 changed files with 321 additions and 134 deletions
@@ -56,6 +56,8 @@ function posAtLeftMenuElement (view: EditorView, leftMenuElement: HTMLElement, o
function LeftMenu (options: LeftMenuOptions): Plugin {
let leftMenuElement: HTMLElement | null = null
const offsetX = options.width + options.marginX
let rafId: number | null = null
let styleCache = new WeakMap<HTMLElement, { lineHeight: number, paddingTop: number, marginTop: number }>()
function hideLeftMenu (): void {
if (leftMenuElement !== null) {
@@ -69,6 +71,19 @@ function LeftMenu (options: LeftMenuOptions): Plugin {
}
}
function getCachedStyle (node: HTMLElement): { lineHeight: number, paddingTop: number, marginTop: number } {
let cached = styleCache.get(node)
if (cached === undefined) {
const compStyle = window.getComputedStyle(node)
const lineHeight = parseInt(compStyle.lineHeight, 10)
const paddingTop = parseInt(compStyle.paddingTop, 10)
const marginTop = parseInt(compStyle.marginTop, 10)
cached = { lineHeight, paddingTop, marginTop }
styleCache.set(node, cached)
}
return cached
}
return new Plugin({
key: new PluginKey('left-menu'),
view: (view) => {
@@ -117,8 +132,13 @@ function LeftMenu (options: LeftMenuOptions): Plugin {
return {
destroy: () => {
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
leftMenuElement?.remove?.()
leftMenuElement = null
styleCache = new WeakMap()
}
}
},
@@ -129,52 +149,65 @@ function LeftMenu (options: LeftMenuOptions): Plugin {
return
}
const node = nodeDOMAtCoords({
x: event.clientX + offsetX,
y: event.clientY
if (rafId !== null) {
return
}
rafId = requestAnimationFrame(() => {
rafId = null
const node = nodeDOMAtCoords({
x: event.clientX + offsetX,
y: event.clientY
})
if (!(node instanceof HTMLElement) || node.nodeName === 'HR') {
hideLeftMenu()
return
}
const parent = node?.parentElement
if (!(parent instanceof HTMLElement)) {
hideLeftMenu()
return
}
// For some reason the offsetTop value for all elements is shifted by the first element's margin
// so taking it into account here
let firstMargin = 0
const firstChild = parent.firstChild
if (firstChild !== null && firstChild instanceof HTMLElement) {
const { marginTop } = getCachedStyle(firstChild)
firstMargin = marginTop
}
const { lineHeight, paddingTop } = getCachedStyle(node)
const left = -offsetX
let top = node.offsetTop
top += (lineHeight - options.height) / 2
top += paddingTop
top += firstMargin
if (leftMenuElement === null) return
leftMenuElement.style.left = `${left}px`
leftMenuElement.style.top = `${top}px`
showLeftMenu()
})
if (!(node instanceof HTMLElement) || node.nodeName === 'HR') {
hideLeftMenu()
return
}
const parent = node?.parentElement
if (!(parent instanceof HTMLElement)) {
hideLeftMenu()
return
}
const compStyle = window.getComputedStyle(node)
const lineHeight = parseInt(compStyle.lineHeight, 10)
const paddingTop = parseInt(compStyle.paddingTop, 10)
// For some reason the offsetTop value for all elements is shifted by the first element's margin
// so taking it into account here
let firstMargin = 0
const firstChild = parent.firstChild
if (firstChild !== null) {
const firstChildCompStyle = window.getComputedStyle(firstChild as HTMLElement)
firstMargin = parseInt(firstChildCompStyle.marginTop, 10)
}
const left = -offsetX
let top = node.offsetTop
top += (lineHeight - options.height) / 2
top += paddingTop
top += firstMargin
if (leftMenuElement === null) return
leftMenuElement.style.left = `${left}px`
leftMenuElement.style.top = `${top}px`
showLeftMenu()
},
keydown: () => {
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
hideLeftMenu()
},
mousewheel: () => {
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
hideLeftMenu()
},
mouseleave: (view, event) => {
@@ -15,6 +15,7 @@
import textEditor from '@hcengineering/text-editor'
import { type Editor } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { CellSelection, TableMap } from '@tiptap/pm/tables'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
@@ -34,12 +35,12 @@ import {
updateColDragMarker,
updateColDropMarker
} from './tableDragMarkerDecoration'
import { TableCachePluginKey } from './plugins'
import { getTableCellWidgetDecorationPos, getTableWidthPx } from './utils'
import { Plugin, PluginKey } from '@tiptap/pm/state'
interface TableColumnHandlerDecorationPluginState {
decorations?: DecorationSet
debounceTimeout?: ReturnType<typeof setTimeout>
}
export const TableColumnHandlerDecorationPlugin = (editor: Editor): Plugin<TableColumnHandlerDecorationPluginState> => {
@@ -52,11 +53,35 @@ export const TableColumnHandlerDecorationPlugin = (editor: Editor): Plugin<Table
},
apply (tr, prev, oldState, newState) {
const table = findTable(newState.selection)
if (table === undefined && prev.debounceTimeout !== undefined) {
clearTimeout(prev.debounceTimeout)
return {}
}
if (!haveTableRelatedChanges(editor, table, oldState, newState, tr)) {
return table !== undefined ? prev : {}
}
const tableMap = TableMap.get(table.node)
const cache = TableCachePluginKey.getState(newState)
const tableMap = cache?.tableMap ?? TableMap.get(table.node)
if (tr.docChanged && tr.steps.length === 1 && !(newState.selection instanceof CellSelection)) {
if (prev.debounceTimeout !== undefined) {
clearTimeout(prev.debounceTimeout)
}
const debounceTimeout = setTimeout(() => {
editor.view.updateState(editor.state)
}, 100)
const mapped = prev.decorations?.map(tr.mapping, tr.doc)
return { decorations: mapped, debounceTimeout }
}
if (prev.debounceTimeout !== undefined) {
clearTimeout(prev.debounceTimeout)
}
let isStale = false
const mapped = prev.decorations?.map(tr.mapping, tr.doc)
@@ -87,7 +112,15 @@ export const TableColumnHandlerDecorationPlugin = (editor: Editor): Plugin<Table
decorations (state) {
return key.getState(state).decorations
}
}
},
view: () => ({
destroy: () => {
const state = key.getState(editor.state)
if (state?.debounceTimeout !== undefined) {
clearTimeout(state.debounceTimeout)
}
}
})
})
}
@@ -176,8 +209,6 @@ class ColumnHandler {
}
editor.view.dispatch(tr)
}
window.removeEventListener('mouseup', handleFinish)
window.removeEventListener('mousemove', handleMove)
}
const handleMove = (event: MouseEvent): void => {
@@ -195,7 +226,7 @@ class ColumnHandler {
}
}
window.addEventListener('mouseup', handleFinish)
window.addEventListener('mouseup', handleFinish, { once: true })
window.addEventListener('mousemove', handleMove)
})
@@ -14,16 +14,16 @@
//
import { type Editor } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { TableMap } from '@tiptap/pm/tables'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { findTable, haveTableRelatedChanges, insertColumn } from '../utils'
import { addSvg } from './icons'
import { TableCachePluginKey } from './plugins'
import { getTableCellWidgetDecorationPos, getTableHeightPx } from './utils'
import { Plugin, PluginKey } from '@tiptap/pm/state'
interface TableColumnInsertDecorationPluginState {
decorations?: DecorationSet
}
@@ -44,7 +44,8 @@ export const TableColumnInsertDecorationPlugin = (editor: Editor): Plugin<TableC
const decorations: Decoration[] = []
const tableMap = TableMap.get(table.node)
const cache = TableCachePluginKey.getState(newState)
const tableMap = cache?.tableMap ?? TableMap.get(table.node)
const { width } = tableMap
let isStale = false
@@ -99,9 +100,24 @@ class ColumnInsertHandler {
const handle = document.createElement('div')
handle.classList.add('table-col-insert')
const marker = document.createElement('div')
marker.className = 'table-insert-marker'
handle.appendChild(marker)
const button = document.createElement('button')
button.className = 'table-insert-button'
button.innerHTML = addSvg
handle.appendChild(button)
button.addEventListener('mouseenter', () => {
const table = findTable(editor.state.selection)
if (table === undefined) {
return
}
const tableHeightPx = getTableHeightPx(table, editor)
marker.style.height = tableHeightPx + 'px'
})
button.addEventListener('mousedown', (event) => {
event.stopPropagation()
event.preventDefault()
@@ -112,31 +128,10 @@ class ColumnInsertHandler {
}
editor.view.dispatch(insertColumn(table, col + 1, editor.state.tr))
})
handle.appendChild(button)
const marker = document.createElement('div')
marker.className = 'table-insert-marker'
handle.appendChild(marker)
const updateMarkerHeight = (): void => {
const table = findTable(editor.state.selection)
if (table === undefined) {
return
}
const tableHeightPx = getTableHeightPx(table, editor)
marker.style.height = tableHeightPx + 'px'
}
updateMarkerHeight()
editor.on('update', updateMarkerHeight)
if (this.destroy !== undefined) {
this.destroy()
}
this.destroy = () => {
editor.off('update', updateMarkerHeight)
}
return handle
}
@@ -0,0 +1,49 @@
//
// Copyright © 2025 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 { Plugin, PluginKey } from '@tiptap/pm/state'
import { TableMap } from '@tiptap/pm/tables'
import { findTable } from '../utils'
export interface TableCachePluginState {
tableMap?: TableMap
tablePos?: number
}
export const TableCachePluginKey = new PluginKey<TableCachePluginState>('tableCache')
export const TableCachePlugin = (): Plugin<TableCachePluginState> => {
return new Plugin<TableCachePluginState>({
key: TableCachePluginKey,
state: {
init: () => ({}),
apply (tr, prev, _oldState, newState) {
const table = findTable(newState.selection)
if (table === undefined) {
return {}
}
if (prev.tablePos === table.pos && !tr.docChanged) {
return prev
}
return {
tableMap: TableMap.get(table.node),
tablePos: table.pos
}
}
}
})
}
@@ -35,10 +35,12 @@ import {
updateRowDragMarker,
updateRowDropMarker
} from './tableDragMarkerDecoration'
import { TableCachePluginKey } from './plugins'
import { getTableCellWidgetDecorationPos, getTableHeightPx } from './utils'
interface TableRowHandlerDecorationPluginState {
decorations?: DecorationSet
debounceTimeout?: ReturnType<typeof setTimeout>
}
export const TableRowHandlerDecorationPlugin = (editor: Editor): Plugin<TableRowHandlerDecorationPluginState> => {
@@ -51,11 +53,35 @@ export const TableRowHandlerDecorationPlugin = (editor: Editor): Plugin<TableRow
},
apply (tr, prev, oldState, newState) {
const table = findTable(newState.selection)
if (table === undefined && prev.debounceTimeout !== undefined) {
clearTimeout(prev.debounceTimeout)
return {}
}
if (!haveTableRelatedChanges(editor, table, oldState, newState, tr)) {
return table !== undefined ? prev : {}
}
const tableMap = TableMap.get(table.node)
const cache = TableCachePluginKey.getState(newState)
const tableMap = cache?.tableMap ?? TableMap.get(table.node)
if (tr.docChanged && tr.steps.length === 1 && !(newState.selection instanceof CellSelection)) {
if (prev.debounceTimeout !== undefined) {
clearTimeout(prev.debounceTimeout)
}
const debounceTimeout = setTimeout(() => {
editor.view.updateState(editor.state)
}, 100)
const mapped = prev.decorations?.map(tr.mapping, tr.doc)
return { decorations: mapped, debounceTimeout }
}
if (prev.debounceTimeout !== undefined) {
clearTimeout(prev.debounceTimeout)
}
let isStale = false
const mapped = prev.decorations?.map(tr.mapping, tr.doc)
@@ -87,7 +113,15 @@ export const TableRowHandlerDecorationPlugin = (editor: Editor): Plugin<TableRow
decorations (state) {
return key.getState(state).decorations
}
}
},
view: () => ({
destroy: () => {
const state = key.getState(editor.state)
if (state?.debounceTimeout !== undefined) {
clearTimeout(state.debounceTimeout)
}
}
})
})
}
@@ -175,8 +209,6 @@ class RowHandler {
}
editor.view.dispatch(tr)
}
window.removeEventListener('mouseup', handleFinish)
window.removeEventListener('mousemove', handleMove)
}
const handleMove = (event: MouseEvent): void => {
@@ -194,7 +226,7 @@ class RowHandler {
}
}
window.addEventListener('mouseup', handleFinish)
window.addEventListener('mouseup', handleFinish, { once: true })
window.addEventListener('mousemove', handleMove)
})
@@ -14,16 +14,16 @@
//
import { type Editor } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { TableMap } from '@tiptap/pm/tables'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { findTable, haveTableRelatedChanges, insertRow } from '../utils'
import { addSvg } from './icons'
import { TableCachePluginKey } from './plugins'
import { getTableCellWidgetDecorationPos, getTableWidthPx } from './utils'
import { Plugin, PluginKey } from '@tiptap/pm/state'
interface TableRowInsertDecorationPluginState {
decorations?: DecorationSet
}
@@ -42,7 +42,8 @@ export const TableRowInsertDecorationPlugin = (editor: Editor): Plugin<TableRowI
return table !== undefined ? prev : {}
}
const tableMap = TableMap.get(table.node)
const cache = TableCachePluginKey.getState(newState)
const tableMap = cache?.tableMap ?? TableMap.get(table.node)
const { height } = tableMap
let isStale = false
@@ -99,9 +100,24 @@ class RowInsertHandler {
const handle = document.createElement('div')
handle.classList.add('table-row-insert')
const marker = document.createElement('div')
marker.className = 'table-insert-marker'
handle.appendChild(marker)
const button = document.createElement('button')
button.className = 'table-insert-button'
button.innerHTML = addSvg
handle.appendChild(button)
button.addEventListener('mouseenter', () => {
const table = findTable(editor.state.selection)
if (table === undefined) {
return
}
const tableWidthPx = getTableWidthPx(table, editor)
marker.style.width = tableWidthPx + 'px'
})
button.addEventListener('mousedown', (event) => {
const table = findTable(editor.state.selection)
if (table === undefined) {
@@ -112,30 +128,10 @@ class RowInsertHandler {
editor.view.dispatch(insertRow(table, row + 1, editor.state.tr))
})
handle.appendChild(button)
const marker = document.createElement('div')
marker.className = 'table-insert-marker'
handle.appendChild(marker)
const updateMarkerHeight = (): void => {
const table = findTable(editor.state.selection)
if (table === undefined) {
return
}
const tableWidthPx = getTableWidthPx(table, editor)
marker.style.width = tableWidthPx + 'px'
}
updateMarkerHeight()
editor.on('update', updateMarkerHeight)
if (this.destroy !== undefined) {
this.destroy()
}
this.destroy = () => {
editor.off('update', updateMarkerHeight)
}
return handle
}
@@ -19,6 +19,7 @@ import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { type Editor } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { TableCachePluginKey } from './plugins'
import { findTable, haveTableRelatedChanges } from '../utils'
interface TableSelectionDecorationPluginState {
@@ -46,7 +47,8 @@ export const TableSelectionDecorationPlugin = (editor: Editor): Plugin<TableSele
const decorations: Decoration[] = []
const tableMap = TableMap.get(table.node)
const cache = TableCachePluginKey.getState(newState)
const tableMap = cache?.tableMap ?? TableMap.get(table.node)
const selected: number[] = []
@@ -15,9 +15,10 @@
import TiptapTableCell from '@tiptap/extension-table-cell'
import { Plugin } from '@tiptap/pm/state'
import { type Node } from '@tiptap/pm/model'
import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables'
import { CellSelection, TableMap } from '@tiptap/pm/tables'
import { TableCachePlugin } from './decorations/plugins'
import { TableColumnHandlerDecorationPlugin } from './decorations/columnHandlerDecoration'
import { TableColumnInsertDecorationPlugin } from './decorations/columnInsertDecoration'
import { TableRowHandlerDecorationPlugin } from './decorations/rowHandlerDecoration'
@@ -29,6 +30,7 @@ import { findTable } from './utils'
export const TableCell = TiptapTableCell.extend({
addProseMirrorPlugins () {
return [
TableCachePlugin(),
TableSelectionNormalizerPlugin(),
TableSelectionDecorationPlugin(this.editor),
TableDragMarkerDecorationPlugin(this.editor),
@@ -51,43 +53,70 @@ const TableSelectionNormalizerPlugin = (): Plugin<any> => {
const tableMap = TableMap.get(table.node)
let rect: Rect | undefined
// Single pass to build initial rect and collect selected positions
let minLeft = Infinity
let minTop = Infinity
let maxRight = -Infinity
let maxBottom = -Infinity
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)
}
}
const selectedPositions = new Set<number>()
selection.forEachCell((_node, pos) => {
walkCell(pos - table.pos - 1)
})
if (rect === undefined) return
const relPos = pos - table.pos - 1
selectedPositions.add(relPos)
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])
const cell = tableMap.findCell(relPos)
if (cell === undefined) return
minLeft = Math.min(minLeft, cell.left)
minTop = Math.min(minTop, cell.top)
maxRight = Math.max(maxRight, cell.right)
maxBottom = Math.max(maxBottom, cell.bottom)
})
if (!isFinite(minLeft)) return
// Expand rect to include all cells in the rectangular region
// and check if we need to expand further
let needsExpansion = false
const { width } = tableMap
for (let row = minTop; row < maxBottom; row++) {
for (let col = minLeft; col < maxRight; col++) {
const pos = tableMap.map[row * width + col]
if (!selectedPositions.has(pos)) {
// Found a cell in rect that's not selected, need to expand
const cell = tableMap.findCell(pos)
if (cell !== undefined) {
minLeft = Math.min(minLeft, cell.left)
minTop = Math.min(minTop, cell.top)
maxRight = Math.max(maxRight, cell.right)
maxBottom = Math.max(maxBottom, cell.bottom)
needsExpansion = true
}
}
}
}
rectSelection.forEach((pos) => {
walkCell(pos)
})
if (rect === undefined) return
// If we expanded, we need to check again (for merged cells)
if (needsExpansion) {
for (let row = minTop; row < maxBottom; row++) {
for (let col = minLeft; col < maxRight; col++) {
const pos = tableMap.map[row * width + col]
const cell = tableMap.findCell(pos)
if (cell !== undefined) {
minLeft = Math.min(minLeft, cell.left)
minTop = Math.min(minTop, cell.top)
maxRight = Math.max(maxRight, cell.right)
maxBottom = Math.max(maxBottom, cell.bottom)
}
}
}
}
// 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)
// Original promemirror implementation of TableMap.positionAt skips rowspan cells, which leads to unpredictable selection behaviour
const firstCellOffset = cellPositionAt(tableMap, maxBottom - 1, maxRight - 1, table.node)
const lastCellOffset = cellPositionAt(tableMap, minTop, minLeft, table.node)
const firstCellPos = newState.doc.resolve(table.start + firstCellOffset)
const lastCellPos = newState.doc.resolve(table.start + lastCellOffset)
@@ -146,5 +146,25 @@ export function haveTableRelatedChanges (
newState: EditorState,
tr: Transaction
): table is TableNodeLocation {
return editor.isEditable && table !== undefined && (tr.docChanged || !newState.selection.eq(oldState.selection))
if (!editor.isEditable || table === undefined) return false
const selectionChanged = !newState.selection.eq(oldState.selection)
if (!tr.docChanged) {
return selectionChanged
}
const tableStart = table.pos
const tableEnd = table.pos + table.node.nodeSize
let hasTableChange = false
tr.mapping.maps.forEach((map) => {
map.forEach((oldStart, oldEnd) => {
if (oldStart < tableEnd && oldEnd > tableStart) {
hasTableChange = true
}
})
})
return hasTableChange || selectionChanged
}