[bug][editor] Удаление строки/колонки таблицы из всплывающего меню нельзя отменить через Ctrl+Z (теряется фокус редактора) #269

Open
opened 2026-06-30 18:34:07 +03:00 by vvzvlad · 0 comments
Owner

Симптом

Удаление строки или колонки таблицы через всплывающие меню (меню «ручек» строки/колонки и меню шеврона ячейки) невозможно отменить через Ctrl+Z — нажатие не даёт никакого эффекта.

Важно: данные на самом деле отменяемы. Если после удаления кликнуть в любую ячейку (вернуть фокус в редактор) и нажать Ctrl+Z — отмена срабатывает. То есть транзакция корректно попадает в историю; ломается именно доставка горячей клавиши до редактора.

Шаги воспроизведения

  1. Открыть страницу с таблицей.
  2. Навести курсор на строку/колонку → нажать «ручку» (grip) → в открывшемся меню выбрать Delete row / Delete column (то же воспроизводится из меню шеврона ячейки).
  3. Сразу нажать Ctrl+Z → ничего не происходит, удаление не отменяется.
  4. Кликнуть в любую ячейку таблицы и снова нажать Ctrl+Z → отмена работает (подтверждает, что причина — фокус, а не история).

Корневая причина

Все эти меню — Mantine <Menu> (v8). По умолчанию у Menu стоит trapFocus: true, и при закрытии он передаёт returnFocus: true (см. @mantine/core/.../Menu/Menu.mjs). «Ручки» строки/колонки и шеврон ячейки рендерятся в плавающем слое (withinPortal) вне contenteditable редактора.

Последовательность при клике на «Delete column»:

  1. editor.chain().focus().deleteColumn().run() — фокусирует редактор и удаляет колонку (транзакция уходит в историю Yjs/UndoManager);
  2. closeOnItemClick мгновенно закрывает меню, а returnFocus: true возвращает фокус обратно на ручку — DOM-элемент <div role="button">, который находится вне редактора;
  3. в итоге фокус оказывается вне contenteditable.

Undo (Ctrl+Z) в ProseMirror — это keymap, который срабатывает только когда фокус внутри редактора. Фокус на ручке → событие Ctrl+Z до ProseMirror не доходит → отмены нет.

Это объясняет, почему:

  • баг не проявляется в старом bubble-меню table-menu.tsx (на tippy/floating) — там фокус после клика не перехватывается;
  • страдает не только удаление: все действия из этих меню (insert/move/background color/sort/clear cells) после клика оставляют фокус на ручке, просто для удаления от��ену пытаются сделать чаще всего.

Затронутые места

  • apps/client/src/features/editor/components/table/handle/column-handle.tsx — Mantine Menu колонки
  • apps/client/src/features/editor/components/table/handle/row-handle.tsx — Mantine Menu строки
  • apps/client/src/features/editor/components/table/handle/cell-chevron.tsx — Mantine Menu ячейки (свой onClose)
  • apps/client/src/features/editor/components/table/handle/hooks/use-column-row-menu-lifecycle.ts — общий onOpen/onClose для строк и колонок
  • Пункты меню: menus/column-handle-menu.tsx, menus/row-handle-menu.tsx, menus/cell-chevron-menu.tsx (все вызывают editor.chain().focus().deleteColumn()/deleteRow())

Предлагаемое исправление

Возвращать фокус в редактор после закрытия меню — в обработчике onClose (он уже есть и вызывает unfreezeHandles). Делать это отложенно (requestAnimationFrame), чтобы сработать после возврата фокуса Mantine на ручку, и с защитой, чтобы не «красть» фокус, если пользователь намеренно перешёл в другое поле:

// Mantine returns focus to the menu trigger (the table handle / cell chevron),
// which lives OUTSIDE the editor's contenteditable. After a menu action the
// editor is blurred, so ProseMirror keymaps (undo/redo via Ctrl+Z) stop firing.
// Restore editor focus on the next frame — after Mantine's returnFocus — unless
// focus intentionally moved to another input/editable.
function refocusEditorAfterMenuClose(editor: Editor) {
  requestAnimationFrame(() => {
    if (editor.isDestroyed) return;
    const active = document.activeElement as HTMLElement | null;
    if (active && editor.view.dom.contains(active)) return; // already inside editor
    const tag = active?.tagName;
    if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || active?.isContentEditable) return;
    editor.view.focus(); // pure DOM focus, no extra transaction
  });
}

Точки подключения (общий хук покрывает строки и колонки одновременно):

  • use-column-row-menu-lifecycle.ts → в onClose после unfreezeHandles;
  • cell-chevron.tsx → в onClose после unfreezeHandles.

Альтернатива (менее предпочтительна): задать returnFocus={false} на <Menu> — но из-за trapFocus синхронный .focus() в onClick может не «прилипнуть», поэтому отложенный возврат фокуса всё равно нужен.

Критерии приёмки (DoD)

  • После «Delete row»/«Delete column» из всплывающего меню Ctrl+Z сразу отменяет удаление (без предварительного клика в ячейку).
  • То же поведение для остальных действий меню (insert / move / background color / sort / clear cells) — после действия фокус остаётся в редакторе.
  • Дисмисс меню кликом в другое поле (например, заголовок страницы) не перетягивает фокус обратно в редактор.
  • Поведение покрыто регрессионным тестом/проверкой в стиле проекта.

Окружение

  • gitmost (форк Docmost), коллаборативный редактор TipTap v3 + Yjs Collaboration (undo через Yjs UndoManager).
  • @mantine/core 8.3.18.
  • Ветка: develop.
## Симптом Удаление **строки** или **колонки** таблицы через всплывающие меню (меню «ручек» строки/колонки и меню шеврона ячейки) **невозможно отменить через Ctrl+Z** — нажатие не даёт никакого эффекта. Важно: данные на самом деле отменяемы. Если после удаления **кликнуть в любую ячейку** (вернуть фокус в редактор) и нажать Ctrl+Z — отмена срабатывает. То есть транзакция корректно попадает в историю; ломается именно доставка горячей клавиши до редактора. ## Шаги воспроизведения 1. Открыть страницу с таблицей. 2. Навести курсор на строку/колонку → нажать «ручку» (grip) → в открывшемся меню выбрать **Delete row** / **Delete column** (то же воспроизводится из меню шеврона ячейки). 3. Сразу нажать **Ctrl+Z** → ничего не происходит, удаление не отменяется. 4. Кликнуть в любую ячейку таблицы и снова нажать Ctrl+Z → отмена работает (подтверждает, что причина — фокус, а не история). ## Корневая причина Все эти меню — Mantine `<Menu>` (v8). По умолчанию у `Menu` стоит `trapFocus: true`, и при закрытии он передаёт `returnFocus: true` (см. `@mantine/core/.../Menu/Menu.mjs`). «Ручки» строки/колонки и шеврон ячейки рендерятся в плавающем слое (`withinPortal`) **вне** contenteditable редактора. Последовательность при клике на «Delete column»: 1. `editor.chain().focus().deleteColumn().run()` — фокусирует редактор и удаляет колонку (транзакция уходит в историю Yjs/UndoManager); 2. `closeOnItemClick` мгновенно закрывает меню, а `returnFocus: true` **возвращает фокус обратно на ручку** — DOM-элемент `<div role="button">`, который находится вне редактора; 3. в итоге фокус оказывается вне contenteditable. Undo (Ctrl+Z) в ProseMirror — это keymap, который срабатывает только когда фокус внутри редактора. Фокус на ручке → событие Ctrl+Z до ProseMirror не доходит → отмены нет. Это объясняет, почему: - баг не проявляется в старом bubble-меню `table-menu.tsx` (на tippy/floating) — там фокус после клика не перехватывается; - страдает не только удаление: все действия из этих меню (insert/move/background color/sort/clear cells) после клика оставляют фокус на ручке, просто для удаления от��ену пытаются сделать чаще всего. ## Затронутые места - `apps/client/src/features/editor/components/table/handle/column-handle.tsx` — Mantine Menu колонки - `apps/client/src/features/editor/components/table/handle/row-handle.tsx` — Mantine Menu строки - `apps/client/src/features/editor/components/table/handle/cell-chevron.tsx` — Mantine Menu ячейки (свой `onClose`) - `apps/client/src/features/editor/components/table/handle/hooks/use-column-row-menu-lifecycle.ts` — общий `onOpen`/`onClose` для строк и колонок - Пункты меню: `menus/column-handle-menu.tsx`, `menus/row-handle-menu.tsx`, `menus/cell-chevron-menu.tsx` (все вызывают `editor.chain().focus().deleteColumn()/deleteRow()`) ## Предлагаемое исправление Возвращать фокус в редактор после закрытия меню — в обработчике `onClose` (он уже есть и вызывает `unfreezeHandles`). Делать это отложенно (`requestAnimationFrame`), чтобы сработать **после** возврата фокуса Mantine на ручку, и с защитой, чтобы не «красть» фокус, если пользователь намеренно перешёл в другое поле: ```ts // Mantine returns focus to the menu trigger (the table handle / cell chevron), // which lives OUTSIDE the editor's contenteditable. After a menu action the // editor is blurred, so ProseMirror keymaps (undo/redo via Ctrl+Z) stop firing. // Restore editor focus on the next frame — after Mantine's returnFocus — unless // focus intentionally moved to another input/editable. function refocusEditorAfterMenuClose(editor: Editor) { requestAnimationFrame(() => { if (editor.isDestroyed) return; const active = document.activeElement as HTMLElement | null; if (active && editor.view.dom.contains(active)) return; // already inside editor const tag = active?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || active?.isContentEditable) return; editor.view.focus(); // pure DOM focus, no extra transaction }); } ``` Точки подключения (общий хук покрывает строки и колонки одновременно): - `use-column-row-menu-lifecycle.ts` → в `onClose` после `unfreezeHandles`; - `cell-chevron.tsx` → в `onClose` после `unfreezeHandles`. Альтернатива (менее предпочтительна): задать `returnFocus={false}` на `<Menu>` — но из-за `trapFocus` синхронный `.focus()` в onClick может не «прилипнуть», поэтому отложенный возврат фокуса всё равно нужен. ## Критерии приёмки (DoD) - [ ] После «Delete row»/«Delete column» из всплывающего меню Ctrl+Z **сразу** отменяет удаление (без предварительного клика в ячейку). - [ ] То же поведение для остальных действий меню (insert / move / background color / sort / clear cells) — после действия фокус остаётся в редакторе. - [ ] Дисмисс меню кликом в другое поле (например, заголовок страницы) **не** перетягивает фокус обратно в редактор. - [ ] Поведение покрыто регрессионным тестом/проверкой в стиле проекта. ## Окружение - gitmost (форк Docmost), коллаборативный редактор TipTap v3 + Yjs `Collaboration` (undo через Yjs UndoManager). - `@mantine/core` 8.3.18. - Ветка: `develop`.
vvzvlad added the bug label 2026-06-30 18:34:07 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#269