Skip to content

Commit d31ec19

Browse files
authored
feat: Optimize toolbar performance (#156)
1 parent 8fd9e24 commit d31ec19

File tree

3 files changed

+127
-76
lines changed

3 files changed

+127
-76
lines changed

private/css/cms.toolbar.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ html.cms-toolbar-expanded {
172172
vertical-align: middle;
173173
line-height: 1.2;
174174
padding: 6px 4px !important;
175-
&:active, &.active {
175+
&:active, &.active, &:has(.active):not(:has([data-action^="Heading"], [data-action="Paragraph"])) {
176176
background: var(--dca-gray-lighter, var(--selected-bg)) !important;
177177
}
178178
&:hover:not(:disabled):not([data-action="TextColor"]),&.show {

private/js/tiptap_plugins/cms.tiptap.toolbar.js

Lines changed: 91 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,27 @@ function renderTableClassOptions(editor, item) {
4545
.join('');
4646
}
4747

48+
// Schema-based checks: static per editor instance, avoid dry-run transactions
49+
function _hasMark(editor, name) {
50+
return !!editor.schema.marks[name];
51+
}
52+
53+
function _hasNode(editor, name) {
54+
return !!editor.schema.nodes[name];
55+
}
56+
57+
// Two-phase block action: try toggle first, fall back to insertContent
58+
function _toggleOrInsert(editor, toggleCmd, nodeType, attrs) {
59+
if (editor.can()[toggleCmd](attrs)) {
60+
editor.chain().focus()[toggleCmd](attrs).run();
61+
} else {
62+
const content = attrs ? { type: nodeType, attrs } : { type: nodeType };
63+
editor.chain().focus().insertContent(content).run();
64+
}
65+
}
66+
4867
const TiptapToolbar = {
68+
// Undo/Redo: must use can() — depends on history state
4969
Undo: {
5070
action: (editor) => editor.chain().focus().undo().run(),
5171
enabled: (editor) => editor.can().undo(),
@@ -56,41 +76,42 @@ const TiptapToolbar = {
5676
enabled: (editor) => editor.can().redo(),
5777
type: 'mark',
5878
},
79+
// Marks: schema check only — marks can always be toggled on text selections
5980
Bold: {
6081
action: (editor) => editor.chain().focus().toggleBold().run(),
61-
enabled: (editor) => editor.can().toggleBold(),
82+
enabled: (editor) => _hasMark(editor, 'bold'),
6283
active: (editor) => editor.isActive('bold'),
6384
type: 'mark',
6485
},
6586
Italic: {
6687
action: (editor) => editor.chain().focus().toggleItalic().run(),
67-
enabled: (editor) => editor.can().toggleItalic(),
88+
enabled: (editor) => _hasMark(editor, 'italic'),
6889
active: (editor) => editor.isActive('italic'),
6990
title: 'Italic',
7091
type: 'mark',
7192
},
7293
Underline: {
7394
action: (editor) => editor.chain().focus().toggleUnderline().run(),
74-
enabled: (editor) => editor.can().toggleUnderline(),
95+
enabled: (editor) => _hasMark(editor, 'underline'),
7596
active: (editor) => editor.isActive('underline'),
7697
title: 'Underline',
7798
type: 'mark',
7899
},
79100
Strike: {
80101
action: (editor) => editor.chain().focus().toggleStrike().run(),
81-
enabled: (editor) => editor.can().toggleStrike(),
102+
enabled: (editor) => _hasMark(editor, 'strike'),
82103
active: (editor) => editor.isActive('strike'),
83104
type: 'mark',
84105
},
85106
Subscript: {
86107
action: (editor) => editor.chain().focus().toggleSubscript().run(),
87-
enabled: (editor) => editor.can().toggleSubscript(),
108+
enabled: (editor) => _hasMark(editor, 'subscript'),
88109
active: (editor) => editor.isActive('subscript'),
89110
type: 'mark',
90111
},
91112
Superscript: {
92113
action: (editor) => editor.chain().focus().toggleSuperscript().run(),
93-
enabled: (editor) => editor.can().toggleSuperscript(),
114+
enabled: (editor) => _hasMark(editor, 'superscript'),
94115
active: (editor) => editor.isActive('superscript'),
95116
type: 'mark',
96117
},
@@ -102,7 +123,7 @@ const TiptapToolbar = {
102123
}
103124
editor.chain().focus().toggleTextColor(button?.dataset?.class || 'text-primary').run();
104125
},
105-
enabled: (editor) => editor.can().toggleTextColor(),
126+
enabled: (editor) => _hasMark(editor, 'textcolor'),
106127
active: (editor, button) => editor.isActive('textcolor', {class: button.dataset?.class}),
107128
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-fonts" viewBox="0 0 16 16"><path d="M12.258 3h-8.51l-.083 2.46h.479c.26-1.544.758-1.783 2.693-1.845l.424-.013v7.827c0 .663-.144.82-1.3.923v.52h4.082v-.52c-1.162-.103-1.306-.26-1.306-.923V3.602l.431.013c1.934.062 2.434.301 2.693 1.846h.479z"/></svg>',
108129
type: 'mark',
@@ -116,7 +137,7 @@ const TiptapToolbar = {
116137
editor.chain().focus().setMark('Q').run();
117138
}
118139
},
119-
enabled: (editor) => editor.can().toggleMark('Q'),
140+
enabled: (editor) => _hasMark(editor, 'Q'),
120141
active: (editor) => editor.isActive('Q'),
121142
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-quote" viewBox="0 0 16 16">' +
122143
'<path d="M12 12a1 1 0 0 0 1-1V8.558a1 1 0 0 0-1-1h-1.388q0-.527.062-1.054.093-.558.31-.992t.559-.683q.34-.279.868-.279V3q-.868 0-1.52.372a3.3 3.3 0 0 0-1.085.992 4.9 4.9 0 0 0-.62 1.458A7.7 7.7 0 0 0 9 7.558V11a1 1 0 0 0 1 1zm-6 0a1 1 0 0 0 1-1V8.558a1 1 0 0 0-1-1H4.612q0-.527.062-1.054.094-.558.31-.992.217-.434.559-.683.34-.279.868-.279V3q-.868 0-1.52.372a3.3 3.3 0 0 0-1.085.992 4.9 4.9 0 0 0-.62 1.458A7.7 7.7 0 0 0 3 7.558V11a1 1 0 0 0 1 1z"/>' +
@@ -132,7 +153,7 @@ const TiptapToolbar = {
132153
editor.chain().focus().setMark('Highlight').run();
133154
}
134155
},
135-
enabled: (editor) => editor.can().toggleMark('Highlight'),
156+
enabled: (editor) => _hasMark(editor, 'Highlight'),
136157
active: (editor) => editor.isActive('Highlight'),
137158
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-highlighter" viewBox="0 0 16 16">\n' +
138159
' <path fill-rule="evenodd" d="M11.096.644a2 2 0 0 1 2.791.036l1.433 1.433a2 2 0 0 1 .035 2.791l-.413.435-8.07 8.995a.5.5 0 0 1-.372.166h-3a.5.5 0 0 1-.234-.058l-.412.412A.5.5 0 0 1 2.5 15h-2a.5.5 0 0 1-.354-.854l1.412-1.412A.5.5 0 0 1 1.5 12.5v-3a.5.5 0 0 1 .166-.372l8.995-8.07zm-.115 1.47L2.727 9.52l3.753 3.753 7.406-8.254zm3.585 2.17.064-.068a1 1 0 0 0-.017-1.396L13.18 1.387a1 1 0 0 0-1.396-.018l-.068.065zM5.293 13.5 2.5 10.707v1.586L3.707 13.5z"/>\n' +
@@ -147,7 +168,7 @@ const TiptapToolbar = {
147168
},
148169
enabled: (editor, button) => {
149170
if (button?.dataset?.id !== undefined) {
150-
return editor.can().toggleInlineStyle(button.dataset.id);
171+
return _hasMark(editor, 'inlinestyle');
151172
}
152173
const ext = editor.extensionManager.extensions.find(e => e.name === 'inlinestyle');
153174
const styles = editor.options.inlineStyles || ext?.options?.styles || [];
@@ -162,7 +183,7 @@ const TiptapToolbar = {
162183
action: (editor, button) => editor.chain().focus().toggleBlockStyle(button?.dataset?.id || 0).run(),
163184
enabled: (editor, button) => {
164185
if (button?.dataset?.id !== undefined) {
165-
return editor.can().toggleBlockStyle(button.dataset.id);
186+
return _hasNode(editor, 'blockstyle') || !!editor.extensionManager.extensions.find(e => e.name === 'blockstyle');
166187
}
167188
const ext = editor.extensionManager.extensions.find(e => e.name === 'blockstyle');
168189
const styles = editor.options.blockStyles || ext?.options?.styles || [];
@@ -172,56 +193,65 @@ const TiptapToolbar = {
172193
},
173194
RemoveFormat: {
174195
action: (editor) => editor.chain().focus().unsetAllMarks().run(),
175-
enabled: (editor) => editor.can().unsetAllMarks(),
196+
enabled: () => true,
176197
type: 'mark',
177198
},
199+
// Text alignment: schema check via extension presence
178200
JustifyLeft: {
179201
action: (editor) => editor.chain().focus().setTextAlign('left').run(),
180-
enabled: (editor) => editor.can().setTextAlign('left'),
202+
enabled: (editor) => !!editor.extensionManager.extensions.find(e => e.name === 'textAlign'),
181203
active: (editor) => editor.isActive({textAlign: 'left'}),
182204
type: 'block',
183205
},
184206
JustifyCenter: {
185207
action: (editor) => editor.chain().focus().setTextAlign('center').run(),
186-
enabled: (editor) => editor.can().setTextAlign('center'),
208+
enabled: (editor) => !!editor.extensionManager.extensions.find(e => e.name === 'textAlign'),
187209
active: (editor) => editor.isActive(({textAlign: 'center'})),
188210
type: 'block',
189211
},
190212
JustifyRight: {
191213
action: (editor) => editor.chain().focus().setTextAlign('right').run(),
192-
enabled: (editor) => editor.can().setTextAlign('right'),
214+
enabled: (editor) => !!editor.extensionManager.extensions.find(e => e.name === 'textAlign'),
193215
active: (editor) => editor.isActive({textAlign: 'right'}),
194216
type: 'block',
195217
},
196218
JustifyBlock: {
197219
action: (editor) => editor.chain().focus().setTextAlign('justify').run(),
198-
enabled: (editor) => editor.can().setTextAlign('justify'),
220+
enabled: (editor) => !!editor.extensionManager.extensions.find(e => e.name === 'textAlign'),
199221
active: (editor) => editor.isActive({textAlign: 'justify'}),
200222
type: 'block',
201223
},
224+
// Block nodes: schema check + toggle-or-insert actions
202225
HorizontalRule: {
203-
action: (editor) => editor.chain().focus().setHorizontalRule().run(),
204-
enabled: (editor) => editor.can().setHorizontalRule(),
226+
action: (editor) => {
227+
if (editor.can().setHorizontalRule()) {
228+
editor.chain().focus().setHorizontalRule().run();
229+
} else {
230+
editor.chain().focus().insertContent({ type: 'horizontalRule' }).run();
231+
}
232+
},
233+
enabled: (editor) => _hasNode(editor, 'horizontalRule'),
205234
type: 'block',
206235
},
207236
NumberedList: {
208237
action: (editor) => editor.chain().focus().toggleOrderedList().run(),
209-
enabled: (editor) => editor.can().toggleOrderedList(),
238+
enabled: (editor) => _hasNode(editor, 'orderedList'),
210239
active: (editor) => editor.isActive('orderedList'),
211240
type: 'block',
212241
},
213242
BulletedList: {
214243
action: (editor) => editor.chain().focus().toggleBulletList().run(),
215-
enabled: (editor) => editor.can().toggleBulletList(),
244+
enabled: (editor) => _hasNode(editor, 'bulletList'),
216245
active: (editor) => editor.isActive('bulletList'),
217246
type: 'block',
218247
},
219248
Blockquote: {
220-
action: (editor) => editor.chain().focus().toggleBlockquote().run(),
221-
enabled: (editor) => editor.can().toggleBlockquote(),
249+
action: (editor) => _toggleOrInsert(editor, 'toggleBlockquote', 'blockquote'),
250+
enabled: (editor) => _hasNode(editor, 'blockquote'),
222251
active: (editor) => editor.isActive('blockquote'),
223252
type: 'block',
224253
},
254+
// Link/Unlink: link uses schema check, unlink checks if link is active
225255
Link: {
226256
action: (editor) => {
227257
if (editor.isActive('link')) {
@@ -240,7 +270,7 @@ const TiptapToolbar = {
240270
editor.chain().focus().setLink(link).run();
241271
}
242272
},
243-
enabled: (editor) => editor.can().setLink({href: '#'}),
273+
enabled: (editor) => _hasMark(editor, 'link'),
244274
active: (editor) => editor.isActive('link'),
245275
attributes: (editor) => {
246276
let attrs = editor.getAttributes('link');
@@ -252,74 +282,75 @@ const TiptapToolbar = {
252282
},
253283
Unlink: {
254284
action: (editor) => editor.chain().focus().unsetLink().run(),
255-
enabled: (editor) => editor.can().unsetLink(),
285+
enabled: (editor) => editor.isActive('link'),
256286
type: 'mark',
257287
},
288+
// Table: schema check for insert, isActive('table') for sub-commands
258289
Table: {
259290
action: (editor, button) => {
260291
const rows = parseInt(button?.dataset?.rows ||3);
261292
const cols = parseInt(button?.dataset?.cols || 3);
262293
const classes = button?.dataset?.attr || getDefaultTableClass(editor.options.tableClasses);
263294
editor.chain().focus().insertTable({ rows: rows, cols: cols}).updateAttributes('table', { addClasses: classes }).run();
264295
},
265-
enabled: (editor) => editor.can().insertTable({ rows: 3, cols: 3 }),
296+
enabled: (editor) => _hasNode(editor, 'table'),
266297
type: 'mark',
267298
items: generateTableMenu,
268299
class: 'tt-table',
269300
},
270301
toggleHeaderColumn: {
271302
action: (editor, button) => editor.chain().focus().toggleHeaderColumn().run(),
272-
enabled: (editor) => editor.can().toggleHeaderColumn(),
303+
enabled: (editor) => editor.isActive('table'),
273304
active: (editor) => editor.isActive('headerColumn'),
274305
type: 'mark',
275306
},
276307
toggleHeaderRow: {
277308
action: (editor, button) => editor.chain().focus().toggleHeaderRow().run(),
278-
enabled: (editor) => editor.can().toggleHeaderRow(),
309+
enabled: (editor) => editor.isActive('table'),
279310
active: (editor) => editor.isActive('headerRow'),
280311
type: 'mark',
281312
},
282313
addColumnBefore: {
283314
action: (editor, button) => editor.chain().focus().addColumnBefore().run(),
284-
enabled: (editor) => editor.can().addColumnBefore(),
315+
enabled: (editor) => editor.isActive('table'),
285316
type: 'mark',
286317
},
287318
addColumnAfter: {
288319
action: (editor, button) => editor.chain().focus().addColumnAfter().run(),
289-
enabled: (editor) => editor.can().addColumnAfter(),
320+
enabled: (editor) => editor.isActive('table'),
290321
type: 'mark',
291322
},
292323
deleteColumn: {
293324
action: (editor, button) => editor.chain().focus().deleteColumn().run(),
294-
enabled: (editor) => editor.can().deleteColumn(),
325+
enabled: (editor) => editor.isActive('table'),
295326
type: 'mark',
296327
},
297328
addRowBefore: {
298329
action: (editor, button) => editor.chain().focus().addRowBefore().run(),
299-
enabled: (editor) => editor.can().addRowBefore(),
330+
enabled: (editor) => editor.isActive('table'),
300331
type: 'mark',
301332
},
302333
addRowAfter: {
303334
action: (editor, button) => editor.chain().focus().addRowAfter().run(),
304-
enabled: (editor) => editor.can().addRowAfter(),
335+
enabled: (editor) => editor.isActive('table'),
305336
type: 'mark',
306337
},
307338
deleteRow: {
308339
action: (editor, button) => editor.chain().focus().deleteRow().run(),
309-
enabled: (editor) => editor.can().deleteRow(),
340+
enabled: (editor) => editor.isActive('table'),
310341
type: 'mark',
311342
},
312343
mergeOrSplit: {
313344
action: (editor, button) => editor.chain().focus().mergeOrSplit().run(),
314-
enabled: (editor) => editor.can().mergeOrSplit(),
345+
enabled: (editor) => editor.isActive('table'),
315346
type: 'mark',
316347
},
317348
tableClass: {
318349
action: (editor, button) => {
319350
const classes = button?.dataset.attr || getDefaultTableClass(editor.options.tableClasses);
320351
editor.chain().focus().updateAttributes('table', { addClasses: classes }).run();
321352
},
322-
enabled: (editor, button) => editor.can().updateAttributes('table', { addClasses: button?.dataset.attr || ''}),
353+
enabled: (editor) => editor.isActive('table'),
323354
active: (editor, button) => {
324355
const classes = editor.getAttributes('table').addClasses;
325356
return classes && classes === button.dataset.attr;
@@ -328,20 +359,21 @@ const TiptapToolbar = {
328359
},
329360
Code: {
330361
action: (editor) => editor.chain().focus().toggleCode().run(),
331-
enabled: (editor) => editor.can().toggleCode(),
362+
enabled: (editor) => _hasMark(editor, 'code'),
332363
active: (editor) => editor.isActive('code'),
333364
type: 'mark',
334365
},
335366
CodeBlock: {
336-
action: (editor) => editor.chain().focus().toggleCodeBlock().run(),
337-
enabled: (editor) => editor.can().toggleCodeBlock(),
367+
action: (editor) => _toggleOrInsert(editor, 'toggleCodeBlock', 'codeBlock'),
368+
enabled: (editor) => _hasNode(editor, 'codeBlock'),
338369
active: (editor) => editor.isActive('codeBlock'),
339370
type: 'block',
340371
},
341372
HardBreak: {
342373
action: (editor) => editor.chain().focus().setHardBreak().run(),
343-
enabled: (editor) => editor.can().setHardBreak(),
374+
enabled: (editor) => _hasNode(editor, 'hardBreak'),
344375
},
376+
// Format dropdown and headings: toggle-or-insert
345377
Format: {
346378
insitu: ['Paragraph', 'Heading1', 'Heading2', 'Heading3', 'Heading4', 'Heading5', 'Heading6', '|'],
347379
iconx: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-type" viewBox="0 0 16 16">\n' +
@@ -350,48 +382,54 @@ const TiptapToolbar = {
350382
type: 'block',
351383
},
352384
Heading1: {
353-
action: (editor) => editor.chain().focus().setHeading({ level: 1 }).run(),
354-
enabled: (editor) => editor.can().setHeading({ level: 1 }),
385+
action: (editor) => _toggleOrInsert(editor, 'toggleHeading', 'heading', { level: 1 }),
386+
enabled: (editor) => _hasNode(editor, 'heading'),
355387
active: (editor) => editor.isActive('heading', { level: 1 }),
356388
type: 'block',
357389
},
358390
Heading2: {
359-
action: (editor) => editor.chain().focus().setHeading({ level: 2 }).run(),
360-
enabled: (editor) => editor.can().setHeading({ level: 2 }),
391+
action: (editor) => _toggleOrInsert(editor, 'toggleHeading', 'heading', { level: 2 }),
392+
enabled: (editor) => _hasNode(editor, 'heading'),
361393
active: (editor) => editor.isActive('heading', { level: 2 }),
362394
type: 'block',
363395
},
364396
Heading3: {
365-
action: (editor) => editor.chain().focus().setHeading({ level: 3 }).run(),
366-
enabled: (editor) => editor.can().setHeading({ level: 3 }),
397+
action: (editor) => _toggleOrInsert(editor, 'toggleHeading', 'heading', { level: 3 }),
398+
enabled: (editor) => _hasNode(editor, 'heading'),
367399
active: (editor) => editor.isActive('heading', { level: 3 }),
368400
title: 'Heading 3',
369401
type: 'block',
370402
},
371403
Heading4: {
372-
action: (editor) => editor.chain().focus().setHeading({ level: 4 }).run(),
373-
enabled: (editor) => editor.can().setHeading({ level: 4 }),
404+
action: (editor) => _toggleOrInsert(editor, 'toggleHeading', 'heading', { level: 4 }),
405+
enabled: (editor) => _hasNode(editor, 'heading'),
374406
active: (editor) => editor.isActive('heading', { level: 4 }),
375407
title: 'Heading 4',
376408
type: 'block',
377409
},
378410
Heading5: {
379-
action: (editor) => editor.chain().focus().setHeading({ level: 5 }).run(),
380-
enabled: (editor) => editor.can().setHeading({ level: 5 }),
411+
action: (editor) => _toggleOrInsert(editor, 'toggleHeading', 'heading', { level: 5 }),
412+
enabled: (editor) => _hasNode(editor, 'heading'),
381413
active: (editor) => editor.isActive('heading', { level: 5 }),
382414
title: 'Heading 5',
383415
type: 'block',
384416
},
385417
Heading6: {
386-
action: (editor) => editor.chain().focus().setHeading({ level: 6 }).run(),
387-
enabled: (editor) => editor.can().setHeading({ level: 6 }),
418+
action: (editor) => _toggleOrInsert(editor, 'toggleHeading', 'heading', { level: 6 }),
419+
enabled: (editor) => _hasNode(editor, 'heading'),
388420
active: (editor) => editor.isActive('heading', { level: 6 }),
389421
title: 'Heading 6',
390422
type: 'block',
391423
},
392424
Paragraph: {
393-
action: (editor) => editor.chain().focus().setParagraph().run(),
394-
enabled: (editor) => editor.can().setParagraph(),
425+
action: (editor) => {
426+
if (editor.can().setParagraph()) {
427+
editor.chain().focus().setParagraph().run();
428+
} else {
429+
editor.chain().focus().insertContent({ type: 'paragraph' }).run();
430+
}
431+
},
432+
enabled: (editor) => _hasNode(editor, 'paragraph'),
395433
active: (editor) => editor.isActive('paragraph'),
396434
title: 'Paragraph',
397435
type: 'block',

0 commit comments

Comments
 (0)