From 6e7cf6b9f06e24d772f00739471af5ab9de566fc Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Thu, 16 Oct 2025 19:44:56 +0700 Subject: [PATCH 1/2] fix: optimize table decorations Signed-off-by: Alexander Onnikov --- .../decorations/columnHandlerDecoration.ts | 45 ++++++++-- .../decorations/columnInsertDecoration.ts | 41 ++++----- .../extension/table/decorations/plugins.ts | 49 +++++++++++ .../table/decorations/rowHandlerDecoration.ts | 42 +++++++-- .../table/decorations/rowInsertDecoration.ts | 42 ++++----- .../decorations/tableSelectionDecoration.ts | 4 +- .../components/extension/table/tableCell.ts | 87 ++++++++++++------- .../src/components/extension/table/utils.ts | 22 ++++- 8 files changed, 243 insertions(+), 89 deletions(-) create mode 100644 plugins/text-editor-resources/src/components/extension/table/decorations/plugins.ts diff --git a/plugins/text-editor-resources/src/components/extension/table/decorations/columnHandlerDecoration.ts b/plugins/text-editor-resources/src/components/extension/table/decorations/columnHandlerDecoration.ts index ad711e9f5c9..f94bfb45c2f 100644 --- a/plugins/text-editor-resources/src/components/extension/table/decorations/columnHandlerDecoration.ts +++ b/plugins/text-editor-resources/src/components/extension/table/decorations/columnHandlerDecoration.ts @@ -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 } export const TableColumnHandlerDecorationPlugin = (editor: Editor): Plugin => { @@ -52,11 +53,35 @@ export const TableColumnHandlerDecorationPlugin = (editor: Editor): Plugin { + 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
({ + 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) }) diff --git a/plugins/text-editor-resources/src/components/extension/table/decorations/columnInsertDecoration.ts b/plugins/text-editor-resources/src/components/extension/table/decorations/columnInsertDecoration.ts index 6e8859c0d91..2ec3386a5f3 100644 --- a/plugins/text-editor-resources/src/components/extension/table/decorations/columnInsertDecoration.ts +++ b/plugins/text-editor-resources/src/components/extension/table/decorations/columnInsertDecoration.ts @@ -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 { 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 { - event.stopPropagation() - event.preventDefault() + handle.appendChild(button) + button.addEventListener('mouseenter', () => { const table = findTable(editor.state.selection) if (table === undefined) { return } - editor.view.dispatch(insertColumn(table, col + 1, editor.state.tr)) + const tableHeightPx = getTableHeightPx(table, editor) + marker.style.height = tableHeightPx + 'px' }) - handle.appendChild(button) - - const marker = document.createElement('div') - marker.className = 'table-insert-marker' - handle.appendChild(marker) + button.addEventListener('mousedown', (event) => { + event.stopPropagation() + event.preventDefault() - 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) + editor.view.dispatch(insertColumn(table, col + 1, editor.state.tr)) + }) if (this.destroy !== undefined) { this.destroy() } - this.destroy = () => { - editor.off('update', updateMarkerHeight) - } return handle } diff --git a/plugins/text-editor-resources/src/components/extension/table/decorations/plugins.ts b/plugins/text-editor-resources/src/components/extension/table/decorations/plugins.ts new file mode 100644 index 00000000000..9a796e5ddc2 --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/table/decorations/plugins.ts @@ -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('tableCache') + +export const TableCachePlugin = (): Plugin => { + return new Plugin({ + 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 + } + } + } + }) +} diff --git a/plugins/text-editor-resources/src/components/extension/table/decorations/rowHandlerDecoration.ts b/plugins/text-editor-resources/src/components/extension/table/decorations/rowHandlerDecoration.ts index cc13b46bf4b..2af89e74494 100644 --- a/plugins/text-editor-resources/src/components/extension/table/decorations/rowHandlerDecoration.ts +++ b/plugins/text-editor-resources/src/components/extension/table/decorations/rowHandlerDecoration.ts @@ -35,10 +35,12 @@ import { updateRowDragMarker, updateRowDropMarker } from './tableDragMarkerDecoration' +import { TableCachePluginKey } from './plugins' import { getTableCellWidgetDecorationPos, getTableHeightPx } from './utils' interface TableRowHandlerDecorationPluginState { decorations?: DecorationSet + debounceTimeout?: ReturnType } export const TableRowHandlerDecorationPlugin = (editor: Editor): Plugin => { @@ -51,11 +53,35 @@ export const TableRowHandlerDecorationPlugin = (editor: Editor): Plugin { + 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 ({ + 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) }) diff --git a/plugins/text-editor-resources/src/components/extension/table/decorations/rowInsertDecoration.ts b/plugins/text-editor-resources/src/components/extension/table/decorations/rowInsertDecoration.ts index f3d6878a3be..09831c0fc59 100644 --- a/plugins/text-editor-resources/src/components/extension/table/decorations/rowInsertDecoration.ts +++ b/plugins/text-editor-resources/src/components/extension/table/decorations/rowInsertDecoration.ts @@ -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 { 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 { + handle.appendChild(button) + + button.addEventListener('mouseenter', () => { const table = findTable(editor.state.selection) if (table === undefined) { return } - event.stopPropagation() - event.preventDefault() - - editor.view.dispatch(insertRow(table, row + 1, editor.state.tr)) + const tableWidthPx = getTableWidthPx(table, editor) + marker.style.width = tableWidthPx + 'px' }) - handle.appendChild(button) - - const marker = document.createElement('div') - marker.className = 'table-insert-marker' - handle.appendChild(marker) - const updateMarkerHeight = (): void => { + button.addEventListener('mousedown', (event) => { const table = findTable(editor.state.selection) if (table === undefined) { return } - const tableWidthPx = getTableWidthPx(table, editor) - marker.style.width = tableWidthPx + 'px' - } + event.stopPropagation() + event.preventDefault() - updateMarkerHeight() - editor.on('update', updateMarkerHeight) + editor.view.dispatch(insertRow(table, row + 1, editor.state.tr)) + }) if (this.destroy !== undefined) { this.destroy() } - this.destroy = () => { - editor.off('update', updateMarkerHeight) - } return handle } diff --git a/plugins/text-editor-resources/src/components/extension/table/decorations/tableSelectionDecoration.ts b/plugins/text-editor-resources/src/components/extension/table/decorations/tableSelectionDecoration.ts index 58ce8a71906..4f6432a66a2 100644 --- a/plugins/text-editor-resources/src/components/extension/table/decorations/tableSelectionDecoration.ts +++ b/plugins/text-editor-resources/src/components/extension/table/decorations/tableSelectionDecoration.ts @@ -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 => { 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 + const selectedPositions = new Set() - if (rect === undefined) { - rect = { ...cell } - } else { - rect.left = Math.min(rect.left, cell.left) - rect.top = Math.min(rect.top, cell.top) + selection.forEachCell((_node, pos) => { + const relPos = pos - table.pos - 1 + selectedPositions.add(relPos) - rect.right = Math.max(rect.right, cell.right) - rect.bottom = Math.max(rect.bottom, cell.bottom) - } - } + const cell = tableMap.findCell(relPos) + if (cell === undefined) return - selection.forEachCell((_node, pos) => { - walkCell(pos - table.pos - 1) + 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 (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]) + 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) diff --git a/plugins/text-editor-resources/src/components/extension/table/utils.ts b/plugins/text-editor-resources/src/components/extension/table/utils.ts index 39b5f20dda9..06985cec18f 100644 --- a/plugins/text-editor-resources/src/components/extension/table/utils.ts +++ b/plugins/text-editor-resources/src/components/extension/table/utils.ts @@ -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 } From 72a97f696bc91d0a8f95e0e66ee0317f2c300515 Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Thu, 16 Oct 2025 19:46:00 +0700 Subject: [PATCH 2/2] fix: optimize left menu performance Signed-off-by: Alexander Onnikov --- .../src/components/extension/leftMenu.ts | 103 ++++++++++++------ 1 file changed, 68 insertions(+), 35 deletions(-) diff --git a/plugins/text-editor-resources/src/components/extension/leftMenu.ts b/plugins/text-editor-resources/src/components/extension/leftMenu.ts index c3b27a1cdf8..a48617b9535 100644 --- a/plugins/text-editor-resources/src/components/extension/leftMenu.ts +++ b/plugins/text-editor-resources/src/components/extension/leftMenu.ts @@ -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() 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 (!(node instanceof HTMLElement) || node.nodeName === 'HR') { - hideLeftMenu() + if (rafId !== null) { 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) - } + 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 - const left = -offsetX - let top = node.offsetTop - top += (lineHeight - options.height) / 2 - top += paddingTop - top += firstMargin - - if (leftMenuElement === null) return + if (leftMenuElement === null) return - leftMenuElement.style.left = `${left}px` - leftMenuElement.style.top = `${top}px` + leftMenuElement.style.left = `${left}px` + leftMenuElement.style.top = `${top}px` - showLeftMenu() + showLeftMenu() + }) }, keydown: () => { + if (rafId !== null) { + cancelAnimationFrame(rafId) + rafId = null + } hideLeftMenu() }, mousewheel: () => { + if (rafId !== null) { + cancelAnimationFrame(rafId) + rafId = null + } hideLeftMenu() }, mouseleave: (view, event) => {