fix(editor): вернуть фокус редактора после закрытия меню таблицы (Ctrl+Z undo) #279
Reference in New Issue
Block a user
Delete Branch "fix/269-table-menu-refocus"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
closes #269. Баг: удаление строки/колонки (и другие действия) из всплывающих Mantine-меню таблицы (ручки строки/колонки + шеврон ячейки) нельзя было отменить Ctrl+Z — пока не кликнешь обратно в ячейку. Причина: у
<Menu>returnFocus:true, а таргет (ручка/шеврон) в портале ВНЕ contenteditable → после действия фокус уходит из редактора, и undo-keymap ProseMirror не ловит Ctrl+Z.Фикс:
refocusEditorAfterMenuClose(editor)— на следующем кадре (после mantine-returnFocus) черезeditor.view.focus()возвращаю фокус в редактор, но НЕ краду его, если пользователь осознанно ушёл в другое поле (INPUT/TEXTAREA/SELECT/contenteditable, напр. заголовок страницы). Подключено в обаonClose(общий lifecycle-хук строк/колонок + cell-chevron). Чистый DOM-фокус, без лишней транзакции.Клиент, 2 файла.
How verified
tsc --noEmit0;vitesttable — 13 passed. (2 eslint-ворнинга в cell-chevron — пред-существующие, в нетронутом коде; подтвердил на базе.)Checklist
Ревью
2b36997c6(closes #269) — возврат фокуса в редактор после закрытия меню таблицы (чтобы Ctrl+Z ловился). 2 файла, фан-аут на stability/coherence + сверка по коду.Вердикт: PASS (код). Фикс корректен, фокус не крадёт, не падает. ⚠️ ПЕРЕД мержем нужен rebase — сейчас
mergeable=false.Что проверено
refocusEditorAfterMenuCloseпроверкаeditor.view.dom.contains(active)идёт ПЕРВОЙ (фокус уже в редакторе → выход), поэтому гардisContentEditableсрабатывает только для editable ВНЕ ProseMirror (напр. заголовок страницы). INPUT/TEXTAREA/SELECT/чужой contentEditable — уважаются; ушёл в заголовок после действия — фокус остаётся там.if (editor.isDestroyed) returnдо любого доступа кeditor.view; rAF не отменяется, но гард полностью покрывает — максимум один холостой кадр, без утечки/краша.<Menu>(withinPortal) вне contenteditable; после действия mantinereturnFocusуводит фокус на портальный таргет → PM-кеймап не видит Ctrl+Z.editor.view.focus()возвращает DOM-фокус на contentEditable → undo снова ловится.requestAnimationFrame— верный тайминг: mantine restore идёт синхронно в layout-effect cleanup (до следующего кадра), поэтому rAF отрабатывает ПОСЛЕ него и фокус залипает (простой синхронный focus в onClose был бы перезатёрт).unfreezeHandles()(порядок сохранён).editor.view.focus()— чистый DOM-focus + ре-проекция уже существующего selection, транзакции/undo-шага не создаёт, каретку не двигает.Объективные проверки (запущены в окружении ревью)
tsc --noEmit(apps/client) — exit 0;vitesttable — 13 passed (на коде PR).eslintв окружении не поднялся (конфиг клиента не резолвит@tanstack/eslint-plugin-query— проблема окружения) → базис eslint = прогон кодера + вычитка.Действие перед мержем: PR сейчас не мержится (
mergeable=false) — нужен rebase на актуальный develop. Правки в 2 файлах изолированы (новая функция + 2 вызова), конфликт, скорее всего, механический; после rebase, если эти 2 файла не изменятся, повторное ревью не требуется.Форвард-заметка (не блокер): одинарный rAF рассчитывает, что mantine восстанавливает returnFocus синхронно; для текущего
@mantine/coreверно. Если будущая версия отложит returnFocus в свой tick — может потребоваться двойной rAF.Полный 9-аспектный фан-аут (по запросу vvzvlad), поверх round-1 approve. Отдельный субагент на каждый аспект.
Вердикт: меняю на CHANGES. Фикс корректен и цель #269 достигнута (подтверждено), но полный проход нашёл реальную тест-дыру на регресс-чувствительной логике. Плюс напоминание: PR всё ещё
mergeable=false→ нужен rebase. Отвечай по id.Что сделать
F1 [test coverage]
refocusEditorAfterMenuCloseбез теста — а это гард против реального регресса (кража фокуса из заголовка страницы) —use-column-row-menu-lifecycle.ts:27(rAF-тело 28-44)Вся суть функции — ветвистое решение «возвращать фокус или нет». Сломается гард (напр. пропадёт проверка INPUT) → фокус будет выдёргиваться из
<input>заголовка, пока пользователь печатает. Сейчас onClose/refocus покрыт на 0% (единственный table-тест —sort-cells.test.ts, чистая сортировка). Тест дёшев и не флаки:view.focus— этоvi.fn()-спай (не реальный focus), rAF — фейк-таймеры.Fix:
handle/hooks/use-column-row-menu-lifecycle.test.ts— фейковый editor{isDestroyed:false, view:{dom, focus:vi.fn()}}; кейсы: (а) активен внешний<input>→focusНЕ вызван; (б) активен неfocus'абельный элемент (body) →focusвызван 1 раз. (ВеткуisContentEditableможно пропустить — в jsdom ненадёжна.)Подтверждено чистым (по 9 аспектам)
Фикс корректен: rAF после mantine-returnFocus,
isDestroyed-гард, не крадёт фокус у осознанного ухода (порядок гардов верный: contains-check до тэг-check),view.focus()— чистый DOM без транзакции/undo-шага; оба call-site послеunfreezeHandles(); цель #269 достигнута для обоих семейств меню (грипы строк/колонок + cell-chevron), четвёртого портального меню вTableHandlesLayerнет. security/regressions/simplification/architecture/documentation — LGTM.Ниже порога (опционально)
refocusEditorAfterMenuClose— не-хук вuse-*-файле; идиоматичнее вhandle/lib/(рядомselect-row-column.ts), раз шарится двумя onClose.transitionend.[role="menu"].Перед мержем: rebase на develop (сейчас
mergeable=false).F1: fixed — добавил
use-column-row-menu-lifecycle.test.tsна гардrefocusEditorAfterMenuClose. Фейковый editor{isDestroyed:false, view:{dom:<real div>, focus:vi.fn()}}(dom реальный — работает.contains(),focus— спай), rAF через fake-timers +stubGlobal(requestAnimationFrame → setTimeout). Кейсы: (а) активен внешний<input>→focusНЕ вызван (гард уважает осознанный уход); (б) активен body →focusвызван ровно 1 раз. ВеткуisContentEditableпропустил (в jsdom ненадёжна). Прод-код не трогал.Проверки (из apps/client):
tsc0;eslintтеста 0;vitest— 2 passed.Про mergeable=false: ребейз не делал — ветка от develop, только 2 табличных файла + тест, реального конфликта нет; пуш перезапустит проверку mergeable у Gitea. Возвращаю review/needs.
Ре-ревью
6e70c7bd6— раунд закрытия F1 + rebase. Полный 9-аспектный веер (отдельный субагент на каждый) на дельте.Вердикт: PASS. F1 закрыт, PR снова mergeable. Готово к мержу.
Проверка
use-column-row-menu-lifecycle.test.tsгоняет РЕАЛЬНЫЙ экспортируемыйrefocusEditorAfterMenuClose(spyview.focus+ фейк-таймеры + rAF→setTimeout(0)): (а) активен внешний<input>→focusНЕ вызван (гард «осознанного ухода» — ровно то, о чём был F1: не выдёргивать фокус из заголовка); (б) активен body →focusвызван 1 раз. Удали INPUT-гард → (а) падает; удали focus-вызов → (б) падает. Рискованная ветка покрыта.git diff 2b36997c6..HEAD— ровно один новый файл теста, рантайм-файлы (use-column-row-menu-lifecycle.ts,cell-chevron.tsx) байт-идентичны approved-базе, rebase кода не тронул → регрессий нет.runOnlyPendingTimers→useRealTimers→unstubAllGlobals→очистка DOM, без утечек между тестами) / conventions (colocated.test.ts, jsdom-env, идиомы vitest) / documentation (комментарии теста точны) / simplification / architecture / coherence — LGTM.Объективная проверка:
vitest run use-column-row-menu-lifecycle.test.ts— 2 passed (в окружении ревью).Ниже порога (опц., не блокеры): не покрыты гарды
isDestroyedи already-inside-editor (риск ниже, чем закрытая deliberate-move ветка);makeEditorвозвращает неиспользуемыйdom. Прошлые опц.-заметки (перенос хелпера вhandle/lib/, допущение про синхронный mantine-returnFocus) остаются на усмотрение.Маркер
reviewed_head—6e70c7bd6.