bug(editor): каретка/выделение сдвигается вверх в NodeView со служебным contentEditable=false перед NodeViewContent (code block + сноски) #146
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
TL;DR
В нескольких редактируемых NodeView редактора служебный
contentEditable={false}‑блок отрисован в потоке, перед редактируемымNodeViewContent. Из‑за этого браузерный hit‑testing клика (view.posAtCoords()→caretRangeFromPoint) промахивается мимо contentDOM, и ProseMirror «примагничивает» каретку/выделение к ближайшей валидной позиции — выше по документу. Симптомы:Это один и тот же архитектурный дефект в двух (фактически трёх) местах, а не два независимых бага.
Затронутые места
contentEditable=falseперед контентомcode-block-view.tsx— меню (язык + copy)footnotes-list-view.tsx— разделитель + заголовок «Footnotes»footnote-definition-view.tsx— маркер‑номерtransclusion-view.tsx— панель управленияcallout-view.tsx— иконка слеваНе затронуто (нет
NodeViewContent, т.е. редактировать внутри нечего):mermaid-view.tsx,page-embed-view.tsx,transclusion-reference-view.tsx.Ссылки на исходники (ветка
develop):<Group contentEditable={false}>(L50‑L84) над<pre><NodeViewContent/></pre>(L86‑L96).<div contentEditable={false}>(L14) перед<NodeViewContent/>(L17). CSS.listещё и высокий:margin-top: lg + padding-top: md + border-top.<span contentEditable={false}>(L32) перед<NodeViewContent/>(L35).Корневая причина (как это работает)
Блоки рисуются как React‑NodeView. Внутри
NodeViewWrapperесть две зоны:NodeViewContent— редактируемый contentDOM (его ProseMirror отображает на позиции документа);contentEditable={false}— служебная обёртка, которой в модели документа нет.При клике мышью ProseMirror переводит экранную координату в позицию документа через
view.posAtCoords()(внутри — браузерныйcaretRangeFromPoint/caretPositionFromPoint). Чтобы каретка встала верно, попадание должно прийтись внутрь contentDOM. Когда перед contentDOM в нормальном потоке стоит нередактируемый блок, занимающий вертикальное место, клик у верхней кромки контента (или в зазор) резолвится браузером в позицию вне редактируемой зоны; ProseMirror снапит выделение к ближайшей валидной позиции — в конец предыдущей ноды (выше).Величина прыжка вверх = высота служебного блока над контентом:
Косвенное подтверждение, что виноват именно реальный DOM перед контентом: номер ссылки‑сноски рисуется правильно — через CSS‑псевдоэлемент
.reference::after { content: var(--footnote-number) }(footnote.module.css), а не реальной нодой, и каретку не ломает.Воспроизведение
Баг A — code block (red)
/code), язык любой/auto.line3.line3там, где кликнули.line2). При выделении протяжкой синяя подсветка рисуется на строку выше реальных глифов.Баг B — footnote (red)
Предусловие: страница с несколькими абзацами тела и хотя бы одной сноской (есть список сносок внизу с редактируемым определением).
Эталон корректного паттерна (transclusion)
transclusion-view.tsxструктурно такой же (панель передNodeViewContent), но бага нет, потому что:position: absolute(оверлей по hover), а не блок в потоке → контент вниз не сдвигает;onMouseDown={(e) => e.preventDefault()}→ клик по панели не двигает каретку;isEditable.Это готовый шаблон фикса для остальных.
План red‑green
Уровень 1 — честная репродукция координат (Playwright e2e) — настоящий red→green
Добавить минимальный
@playwright/test. Тест прогоняется по реальной странице, кликает и читаетeditor.state.selectionиз живого инстанса (нужно выставить инстанс редактора вwindowпод тест, напр.window.__editor).Уровень 2 — дешёвый структурный guard (vitest, без раскладки) — ловит сам антипаттерн
Инвариант, не зависящий от CSS/раскладки и проверяемый в jsdom по порядку DOM:
Зелёная фаза — варианты фикса (по убыванию предпочтительности)
NodeViewContentв DOM и спозиционировать визуально через CSS (order/абсолют). Универсально, удовлетворяет уровню 2.onMouseDown preventDefault+ только в editable — как вtransclusion. Хорошо для меню сниппета.::before/::afterдля чисто декоративного текста (как уже сделано для номера ссылки.reference::after) — подходит для маркера‑номера определения и, возможно, заголовка списка.Acceptance criteria / DoD
transclusion) ему удовлетворяют.Связанные манипуляции
docs/manual-qa-test-plan.md(PR #136) — секции редактора (code block / footnotes).Ghost referenced this issue2026-06-24 00:38:33 +03:00
Ghost referenced this issue2026-06-24 02:02:45 +03:00