Skip to content

Commit e573b19

Browse files
authored
fix: editor table performance improvements (#10106)
* fix: optimize table decorations Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com> * fix: optimize left menu performance Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com> --------- Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
1 parent b86b3e4 commit e573b19

File tree

9 files changed

+311
-124
lines changed

9 files changed

+311
-124
lines changed

plugins/text-editor-resources/src/components/extension/leftMenu.ts

Lines changed: 68 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ function posAtLeftMenuElement (view: EditorView, leftMenuElement: HTMLElement, o
5656
function LeftMenu (options: LeftMenuOptions): Plugin {
5757
let leftMenuElement: HTMLElement | null = null
5858
const offsetX = options.width + options.marginX
59+
let rafId: number | null = null
60+
let styleCache = new WeakMap<HTMLElement, { lineHeight: number, paddingTop: number, marginTop: number }>()
5961

6062
function hideLeftMenu (): void {
6163
if (leftMenuElement !== null) {
@@ -69,6 +71,19 @@ function LeftMenu (options: LeftMenuOptions): Plugin {
6971
}
7072
}
7173

74+
function getCachedStyle (node: HTMLElement): { lineHeight: number, paddingTop: number, marginTop: number } {
75+
let cached = styleCache.get(node)
76+
if (cached === undefined) {
77+
const compStyle = window.getComputedStyle(node)
78+
const lineHeight = parseInt(compStyle.lineHeight, 10)
79+
const paddingTop = parseInt(compStyle.paddingTop, 10)
80+
const marginTop = parseInt(compStyle.marginTop, 10)
81+
cached = { lineHeight, paddingTop, marginTop }
82+
styleCache.set(node, cached)
83+
}
84+
return cached
85+
}
86+
7287
return new Plugin({
7388
key: new PluginKey('left-menu'),
7489
view: (view) => {
@@ -117,8 +132,13 @@ function LeftMenu (options: LeftMenuOptions): Plugin {
117132

118133
return {
119134
destroy: () => {
135+
if (rafId !== null) {
136+
cancelAnimationFrame(rafId)
137+
rafId = null
138+
}
120139
leftMenuElement?.remove?.()
121140
leftMenuElement = null
141+
styleCache = new WeakMap()
122142
}
123143
}
124144
},
@@ -129,52 +149,65 @@ function LeftMenu (options: LeftMenuOptions): Plugin {
129149
return
130150
}
131151

132-
const node = nodeDOMAtCoords({
133-
x: event.clientX + offsetX,
134-
y: event.clientY
135-
})
136-
137-
if (!(node instanceof HTMLElement) || node.nodeName === 'HR') {
138-
hideLeftMenu()
152+
if (rafId !== null) {
139153
return
140154
}
141155

142-
const parent = node?.parentElement
143-
if (!(parent instanceof HTMLElement)) {
144-
hideLeftMenu()
145-
return
146-
}
147-
148-
const compStyle = window.getComputedStyle(node)
149-
const lineHeight = parseInt(compStyle.lineHeight, 10)
150-
const paddingTop = parseInt(compStyle.paddingTop, 10)
151-
152-
// For some reason the offsetTop value for all elements is shifted by the first element's margin
153-
// so taking it into account here
154-
let firstMargin = 0
155-
const firstChild = parent.firstChild
156-
if (firstChild !== null) {
157-
const firstChildCompStyle = window.getComputedStyle(firstChild as HTMLElement)
158-
firstMargin = parseInt(firstChildCompStyle.marginTop, 10)
159-
}
156+
rafId = requestAnimationFrame(() => {
157+
rafId = null
158+
159+
const node = nodeDOMAtCoords({
160+
x: event.clientX + offsetX,
161+
y: event.clientY
162+
})
163+
164+
if (!(node instanceof HTMLElement) || node.nodeName === 'HR') {
165+
hideLeftMenu()
166+
return
167+
}
168+
169+
const parent = node?.parentElement
170+
if (!(parent instanceof HTMLElement)) {
171+
hideLeftMenu()
172+
return
173+
}
174+
175+
// For some reason the offsetTop value for all elements is shifted by the first element's margin
176+
// so taking it into account here
177+
let firstMargin = 0
178+
const firstChild = parent.firstChild
179+
if (firstChild !== null && firstChild instanceof HTMLElement) {
180+
const { marginTop } = getCachedStyle(firstChild)
181+
firstMargin = marginTop
182+
}
183+
184+
const { lineHeight, paddingTop } = getCachedStyle(node)
185+
const left = -offsetX
186+
let top = node.offsetTop
187+
top += (lineHeight - options.height) / 2
188+
top += paddingTop
189+
top += firstMargin
160190

161-
const left = -offsetX
162-
let top = node.offsetTop
163-
top += (lineHeight - options.height) / 2
164-
top += paddingTop
165-
top += firstMargin
166-
167-
if (leftMenuElement === null) return
191+
if (leftMenuElement === null) return
168192

169-
leftMenuElement.style.left = `${left}px`
170-
leftMenuElement.style.top = `${top}px`
193+
leftMenuElement.style.left = `${left}px`
194+
leftMenuElement.style.top = `${top}px`
171195

172-
showLeftMenu()
196+
showLeftMenu()
197+
})
173198
},
174199
keydown: () => {
200+
if (rafId !== null) {
201+
cancelAnimationFrame(rafId)
202+
rafId = null
203+
}
175204
hideLeftMenu()
176205
},
177206
mousewheel: () => {
207+
if (rafId !== null) {
208+
cancelAnimationFrame(rafId)
209+
rafId = null
210+
}
178211
hideLeftMenu()
179212
},
180213
mouseleave: (view, event) => {

plugins/text-editor-resources/src/components/extension/table/decorations/columnHandlerDecoration.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import textEditor from '@hcengineering/text-editor'
1717
import { type Editor } from '@tiptap/core'
18+
import { Plugin, PluginKey } from '@tiptap/pm/state'
1819
import { CellSelection, TableMap } from '@tiptap/pm/tables'
1920
import { Decoration, DecorationSet } from '@tiptap/pm/view'
2021

@@ -34,12 +35,12 @@ import {
3435
updateColDragMarker,
3536
updateColDropMarker
3637
} from './tableDragMarkerDecoration'
38+
import { TableCachePluginKey } from './plugins'
3739
import { getTableCellWidgetDecorationPos, getTableWidthPx } from './utils'
3840

39-
import { Plugin, PluginKey } from '@tiptap/pm/state'
40-
4141
interface TableColumnHandlerDecorationPluginState {
4242
decorations?: DecorationSet
43+
debounceTimeout?: ReturnType<typeof setTimeout>
4344
}
4445

4546
export const TableColumnHandlerDecorationPlugin = (editor: Editor): Plugin<TableColumnHandlerDecorationPluginState> => {
@@ -52,11 +53,35 @@ export const TableColumnHandlerDecorationPlugin = (editor: Editor): Plugin<Table
5253
},
5354
apply (tr, prev, oldState, newState) {
5455
const table = findTable(newState.selection)
56+
57+
if (table === undefined && prev.debounceTimeout !== undefined) {
58+
clearTimeout(prev.debounceTimeout)
59+
return {}
60+
}
61+
5562
if (!haveTableRelatedChanges(editor, table, oldState, newState, tr)) {
5663
return table !== undefined ? prev : {}
5764
}
5865

59-
const tableMap = TableMap.get(table.node)
66+
const cache = TableCachePluginKey.getState(newState)
67+
const tableMap = cache?.tableMap ?? TableMap.get(table.node)
68+
69+
if (tr.docChanged && tr.steps.length === 1 && !(newState.selection instanceof CellSelection)) {
70+
if (prev.debounceTimeout !== undefined) {
71+
clearTimeout(prev.debounceTimeout)
72+
}
73+
74+
const debounceTimeout = setTimeout(() => {
75+
editor.view.updateState(editor.state)
76+
}, 100)
77+
78+
const mapped = prev.decorations?.map(tr.mapping, tr.doc)
79+
return { decorations: mapped, debounceTimeout }
80+
}
81+
82+
if (prev.debounceTimeout !== undefined) {
83+
clearTimeout(prev.debounceTimeout)
84+
}
6085

6186
let isStale = false
6287
const mapped = prev.decorations?.map(tr.mapping, tr.doc)
@@ -87,7 +112,15 @@ export const TableColumnHandlerDecorationPlugin = (editor: Editor): Plugin<Table
87112
decorations (state) {
88113
return key.getState(state).decorations
89114
}
90-
}
115+
},
116+
view: () => ({
117+
destroy: () => {
118+
const state = key.getState(editor.state)
119+
if (state?.debounceTimeout !== undefined) {
120+
clearTimeout(state.debounceTimeout)
121+
}
122+
}
123+
})
91124
})
92125
}
93126

@@ -176,8 +209,6 @@ class ColumnHandler {
176209
}
177210
editor.view.dispatch(tr)
178211
}
179-
window.removeEventListener('mouseup', handleFinish)
180-
window.removeEventListener('mousemove', handleMove)
181212
}
182213

183214
const handleMove = (event: MouseEvent): void => {
@@ -195,7 +226,7 @@ class ColumnHandler {
195226
}
196227
}
197228

198-
window.addEventListener('mouseup', handleFinish)
229+
window.addEventListener('mouseup', handleFinish, { once: true })
199230
window.addEventListener('mousemove', handleMove)
200231
})
201232

plugins/text-editor-resources/src/components/extension/table/decorations/columnInsertDecoration.ts

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,16 @@
1414
//
1515

1616
import { type Editor } from '@tiptap/core'
17+
import { Plugin, PluginKey } from '@tiptap/pm/state'
1718
import { TableMap } from '@tiptap/pm/tables'
1819
import { Decoration, DecorationSet } from '@tiptap/pm/view'
1920

2021
import { findTable, haveTableRelatedChanges, insertColumn } from '../utils'
21-
import { addSvg } from './icons'
2222

23+
import { addSvg } from './icons'
24+
import { TableCachePluginKey } from './plugins'
2325
import { getTableCellWidgetDecorationPos, getTableHeightPx } from './utils'
2426

25-
import { Plugin, PluginKey } from '@tiptap/pm/state'
26-
2727
interface TableColumnInsertDecorationPluginState {
2828
decorations?: DecorationSet
2929
}
@@ -44,7 +44,8 @@ export const TableColumnInsertDecorationPlugin = (editor: Editor): Plugin<TableC
4444

4545
const decorations: Decoration[] = []
4646

47-
const tableMap = TableMap.get(table.node)
47+
const cache = TableCachePluginKey.getState(newState)
48+
const tableMap = cache?.tableMap ?? TableMap.get(table.node)
4849
const { width } = tableMap
4950

5051
let isStale = false
@@ -99,44 +100,38 @@ class ColumnInsertHandler {
99100
const handle = document.createElement('div')
100101
handle.classList.add('table-col-insert')
101102

103+
const marker = document.createElement('div')
104+
marker.className = 'table-insert-marker'
105+
handle.appendChild(marker)
106+
102107
const button = document.createElement('button')
103108
button.className = 'table-insert-button'
104109
button.innerHTML = addSvg
105-
button.addEventListener('mousedown', (event) => {
106-
event.stopPropagation()
107-
event.preventDefault()
110+
handle.appendChild(button)
108111

112+
button.addEventListener('mouseenter', () => {
109113
const table = findTable(editor.state.selection)
110114
if (table === undefined) {
111115
return
112116
}
113-
editor.view.dispatch(insertColumn(table, col + 1, editor.state.tr))
117+
const tableHeightPx = getTableHeightPx(table, editor)
118+
marker.style.height = tableHeightPx + 'px'
114119
})
115-
handle.appendChild(button)
116-
117-
const marker = document.createElement('div')
118-
marker.className = 'table-insert-marker'
119120

120-
handle.appendChild(marker)
121+
button.addEventListener('mousedown', (event) => {
122+
event.stopPropagation()
123+
event.preventDefault()
121124

122-
const updateMarkerHeight = (): void => {
123125
const table = findTable(editor.state.selection)
124126
if (table === undefined) {
125127
return
126128
}
127-
const tableHeightPx = getTableHeightPx(table, editor)
128-
marker.style.height = tableHeightPx + 'px'
129-
}
130-
131-
updateMarkerHeight()
132-
editor.on('update', updateMarkerHeight)
129+
editor.view.dispatch(insertColumn(table, col + 1, editor.state.tr))
130+
})
133131

134132
if (this.destroy !== undefined) {
135133
this.destroy()
136134
}
137-
this.destroy = () => {
138-
editor.off('update', updateMarkerHeight)
139-
}
140135

141136
return handle
142137
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
//
2+
// Copyright © 2025 Hardcore Engineering Inc.
3+
//
4+
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License. You may
6+
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
//
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
16+
import { Plugin, PluginKey } from '@tiptap/pm/state'
17+
import { TableMap } from '@tiptap/pm/tables'
18+
import { findTable } from '../utils'
19+
20+
export interface TableCachePluginState {
21+
tableMap?: TableMap
22+
tablePos?: number
23+
}
24+
25+
export const TableCachePluginKey = new PluginKey<TableCachePluginState>('tableCache')
26+
27+
export const TableCachePlugin = (): Plugin<TableCachePluginState> => {
28+
return new Plugin<TableCachePluginState>({
29+
key: TableCachePluginKey,
30+
state: {
31+
init: () => ({}),
32+
apply (tr, prev, _oldState, newState) {
33+
const table = findTable(newState.selection)
34+
if (table === undefined) {
35+
return {}
36+
}
37+
38+
if (prev.tablePos === table.pos && !tr.docChanged) {
39+
return prev
40+
}
41+
42+
return {
43+
tableMap: TableMap.get(table.node),
44+
tablePos: table.pos
45+
}
46+
}
47+
}
48+
})
49+
}

0 commit comments

Comments
 (0)