bug(editor): каретка/выделение сдвигается вверх в NodeView со служебным contentEditable=false перед NodeViewContent (code block + сноски) #146

Closed
opened 2026-06-23 20:56:34 +03:00 by vvzvlad · 0 comments
Owner

TL;DR

В нескольких редактируемых NodeView редактора служебный contentEditable={false}‑блок отрисован в потоке, перед редактируемым NodeViewContent. Из‑за этого браузерный hit‑testing клика (view.posAtCoords()caretRangeFromPoint) промахивается мимо contentDOM, и ProseMirror «примагничивает» каретку/выделение к ближайшей валидной позиции — выше по документу. Симптомы:

  • A. Code block: выделение/каретка в сниппете кода смещены на одну строку вверх.
  • B. Сноски (footnotes): после первой потери выделения определение сноски «не редактируется» — повторный клик ставит каретку на несколько строк выше, в тело страницы.

Это один и тот же архитектурный дефект в двух (фактически трёх) местах, а не два независимых бага.


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

NodeView Служебный contentEditable=false перед контентом В потоке/оверлей Статус
code-block-view.tsx — меню (язык + copy) да в потоке 🔴 баг A
footnotes-list-view.tsx — разделитель + заголовок «Footnotes» да в потоке 🔴 баг B
footnote-definition-view.tsx — маркер‑номер да в потоке (inline) 🔴 баг B
transclusion-view.tsx — панель управления да absolute‑оверлей 🟢 не баг (эталон)
callout-view.tsx — иконка слева нет (без contentEditable=false) flex‑строка 🟡 низкий риск

Не затронуто (нет NodeViewContent, т.е. редактировать внутри нечего): mermaid-view.tsx, page-embed-view.tsx, transclusion-reference-view.tsx.

Ссылки на исходники (ветка develop):


Корневая причина (как это работает)

Блоки рисуются как React‑NodeView. Внутри NodeViewWrapper есть две зоны:

  • NodeViewContent — редактируемый contentDOM (его ProseMirror отображает на позиции документа);
  • любой JSX c 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)

  1. Создать страницу, вставить блок кода (/code), язык любой/auto.
  2. Набрать несколько строк:
    line0
    line1
    line2
    line3
    
  3. Кликнуть мышью точно по символу в line3.
  4. Ожидаемо: каретка в line3 там, где кликнули.
  5. Фактически: каретка/выделение оказываются на строку выше (line2). При выделении протяжкой синяя подсветка рисуется на строку выше реальных глифов.

Баг B — footnote (red)

Предусловие: страница с несколькими абзацами тела и хотя бы одной сноской (есть список сносок внизу с редактируемым определением).

  1. Прокрутить к списку сносок. Кликнуть в текст определения, напечатать символ — работает (первая правка проходит).
  2. Кликнуть в тело страницы (или иной блок) — сноска теряет выделение.
  3. Кликнуть повторно прямо в текст того ��е определения сноски.
  4. Ожидаемо: каретка внутри этого определения.
  5. Фактически: каретка/выделение встают на несколько строк выше — в теле над заголовком «Footnotes»; ввод правит не то место, сноска кажется «нередактируемой».

Нюанс «первый раз редактируется, потом нет» по статическому коду не подтверждён на 100%: предположительно первый клик ставит каретку нативным фокусом, а повторный идёт уже через posAtCoords и срабатывает сдвиг; усугубляет пере‑рендер React‑NodeView из‑за пересчёта node‑декораций нумерации на каждый docChanged (footnote-numbering.ts#L105-L111). Это надо подтвердить в браузере devtools — см. e2e ниже.


Эталон корректного паттерна (transclusion)

transclusion-view.tsx структурно такой же (панель перед NodeViewContent), но бага нет, потому что:

  1. панель — position: absolute (оверлей по hover), а не блок в потоке → контент вниз не сдвигает;
  2. на панели onMouseDown={(e) => e.preventDefault()} → клик по панели не двигает каретку;
  3. панель рендерится только при isEditable.

Это готовый шаблон фикса для остальных.


План red‑green

Важно: тест‑раннер — vitest + jsdom (apps/client/vitest.config.ts, packages/editor-ext/vitest.config.ts); Playwright/e2e в репозитории нет. jsdom не считает раскладку/координаты (getBoundingClientRect → нули), поэтому честно воспроизвести posAtCoords‑сдвиг в vitest нельзя. Нужны два уровня.

Уровень 1 — честная репродукция координат (Playwright e2e) — настоящий red→green

Добавить минимальный @playwright/test. Тест прогоняется по реальной странице, кликает и читает editor.state.selection из живого инстанса (нужно выставить инстанс редактора в window под тест, напр. window.__editor).

// e2e/editor-selection-offset.spec.ts  (RED before fix, GREEN after)
import { test, expect } from "@playwright/test";

test("code block: clicking a line places the caret on THAT line (no upward shift)", async ({ page }) => {
  await openBlankPageWithCodeBlock(page, ["line0", "line1", "line2", "line3"]);
  const code = page.locator(".ProseMirror pre code");
  const box = await code.boundingBox();
  const lineH = box!.height / 4;
  await page.mouse.click(box!.x + 16, box!.y + lineH * 3 + lineH / 2); // aim at line index 3

  const lineIndex = await page.evaluate(() => {
    const ed = (window as any).__editor;
    const { $from } = ed.state.selection;
    return $from.parent.textContent.slice(0, $from.parentOffset).split("\n").length - 1;
  });
  expect(lineIndex).toBe(3); // bug => 2
});

test("footnote: re-clicking a definition after blur edits THAT definition (not the body above)", async ({ page }) => {
  await openPageWithBodyAndFootnote(page); // several paragraphs + one footnote definition
  const def = page.locator("[data-footnote-def] p").first();
  await def.click(); await page.keyboard.type("A");   // first edit works
  await page.locator(".ProseMirror > p").first().click(); // blur into body
  await def.click(); await page.keyboard.type("B");   // re-click the same definition

  const landedIn = await page.evaluate(() => {
    const { $from } = (window as any).__editor.state.selection;
    for (let d = $from.depth; d >= 0; d--) {
      if ($from.node(d).type.name === "footnoteDefinition") return "footnoteDefinition";
    }
    return $from.parent.type.name;
  });
  expect(landedIn).toBe("footnoteDefinition"); // bug => "paragraph" (body, above)
});

Уровень 2 — дешёвый структурный guard (vitest, без раскладки) — ловит сам антипаттерн

Инвариант, не зависящий от CSS/раскладки и проверяемый в jsdom по порядку DOM:

В редактируемом NodeView contentDOM ([data-node-view-content]) должен быть первым element‑child обёртки [data-node-view-wrapper]; никакая нередактируемая «хромка» не должна стоять перед ним в DOM. Хромку размещаем после контента и позиционируем визуально через CSS (или position:absolute, как в transclusion).

// apps/client/src/features/editor/components/__tests__/nodeview-content-first-child.test.tsx
// RED for code block + footnotes today; GREEN after chrome is moved after contentDOM.
import { Editor } from "@tiptap/core";
import { extensions } from "@/features/editor/extensions/extensions"; // or a trimmed set

function mountWithContent(content: any) {
  const element = document.createElement("div");
  document.body.appendChild(element);
  return new Editor({ element, extensions, content });
}

function assertContentIsFirstChild(editor: Editor) {
  for (const wrapper of editor.view.dom.querySelectorAll("[data-node-view-wrapper]")) {
    const content = wrapper.querySelector("[data-node-view-content]");
    if (!content) continue; // leaf/atom node view — not subject to this rule
    const wrapperOfContent = content.closest("[data-node-view-wrapper]");
    if (wrapperOfContent !== wrapper) continue; // nested node view, checked on its own pass
    // No NON-editable element may precede the contentDOM in DOM order.
    let sib = content.previousElementSibling;
    while (sib) {
      expect(sib.getAttribute("contenteditable")).not.toBe("false");
      sib = sib.previousElementSibling;
    }
  }
}
// describe/it: mount a doc containing a codeBlock, then a doc with a footnote, call the assert.

Уровень 2 — это не доказательство фикса (его даёт уровень 1), а защита от регрессий: он гонит все NodeView к безопасной форме. transclusion сегодня тоже кладёт панель перед контентом (визуально ок за счёт absolute) — под этот инвариант его стоит привести к единому виду (перенести хромку после NodeViewContent), это без изменения поведения.

Зелёная фаза — варианты фикса (по убыванию предпочтительности)

  1. Перенести служебный блок после NodeViewContent в DOM и спозиционировать визуально через CSS (order/абсолют). Универсально, удовлетворяет уровню 2.
  2. Absolute‑оверлей + onMouseDown preventDefault + только в editable — как в transclusion. Хорошо для меню сниппета.
  3. Псевдоэлемент ::before/::after для чисто декоративного текста (как уже сделано для номера ссылки .reference::after) — подходит для маркера‑номера определения и, возможно, заголовка списка.

Acceptance criteria / DoD

  • Клик по строке N в блоке кода ставит каретку на строку N (не N‑1); выделение протяжкой подсвечивает кликнутые строки.
  • Повторный клик в определение сноски после потери выделения ставит каретку в это определение; ввод правит сноску, а не тело выше.
  • Добавлен e2e (уровень 1), который был RED до фикса и стал GREEN после — для code block и для сноски.
  • Добавлен структурный vitest‑guard (уровень 2); все редактируемые NodeView (включая transclusion) ему удовлетворяют.
  • Нет регрессий в read‑only/share и в совместном редактировании (decorations нумерации сносок не мутируют документ — поведение сохранено).

Связанные манипуляции

  • Manual QA: добавить кейсы в docs/manual-qa-test-plan.md (PR #136) — секции редактора (code block / footnotes).
## TL;DR В нескольких редактируемых NodeView редактора служебный `contentEditable={false}`‑блок отрисован **в потоке, перед** редактируемым `NodeViewContent`. Из‑за этого браузерный hit‑testing клика (`view.posAtCoords()` → `caretRangeFromPoint`) промахивается мимо contentDOM, и ProseMirror «примагничивает» каретку/выделение к ближайшей валидной позиции — **выше по документу**. Симптомы: - **A. Code block:** выделение/каретка в сниппете кода смещены **на одну строку вверх**. - **B. Сноски (footnotes):** после первой потери выделения определение сноски «не редактируется» — повторный клик ставит каретку **на несколько строк выше**, в тело страницы. Это **один и тот же архитектурный дефект** в двух (фактически трёх) местах, а не два независимых бага. --- ## Затронутые места | NodeView | Служебный `contentEditable=false` перед контентом | В потоке/оверлей | Статус | |---|---|---|---| | `code-block-view.tsx` — меню (язык + copy) | да | **в потоке** | 🔴 баг A | | `footnotes-list-view.tsx` — разделитель + заголовок «Footnotes» | да | **в потоке** | 🔴 баг B | | `footnote-definition-view.tsx` — маркер‑номер | да | в потоке (inline) | 🔴 баг B | | `transclusion-view.tsx` — панель управления | да | **absolute‑оверлей** | 🟢 не баг (эталон) | | `callout-view.tsx` — иконка слева | нет (без contentEditable=false) | flex‑строка | 🟡 низкий риск | Не затронуто (нет `NodeViewContent`, т.е. редактировать внутри нечего): `mermaid-view.tsx`, `page-embed-view.tsx`, `transclusion-reference-view.tsx`. Ссылки на исходники (ветка `develop`): - [code-block-view.tsx#L48-L96](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/src/branch/develop/apps/client/src/features/editor/components/code-block/code-block-view.tsx#L48-L96) — `<Group contentEditable={false}>` (L50‑L84) над `<pre><NodeViewContent/></pre>` (L86‑L96). - [footnotes-list-view.tsx#L12-L19](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/src/branch/develop/apps/client/src/features/editor/components/footnote/footnotes-list-view.tsx#L12-L19) — `<div contentEditable={false}>` (L14) перед `<NodeViewContent/>` (L17). CSS `.list` ещё и высокий: `margin-top: lg + padding-top: md + border-top`. - [footnote-definition-view.tsx#L25-L46](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/src/branch/develop/apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx#L25-L46) — маркер `<span contentEditable={false}>` (L32) перед `<NodeViewContent/>` (L35). - Эталон: [transclusion-view.tsx#L69-L124](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/src/branch/develop/apps/client/src/features/editor/components/transclusion/transclusion-view.tsx#L69-L124) + CSS [transclusion.module.css#L77-L80](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/src/branch/develop/apps/client/src/features/editor/components/transclusion/transclusion.module.css#L77-L80). --- ## Корневая причина (как это работает) Блоки рисуются как React‑**NodeView**. Внутри `NodeViewWrapper` есть две зоны: - `NodeViewContent` — редактируемый **contentDOM** (его ProseMirror отображает на позиции документа); - любой JSX c `contentEditable={false}` — служебная обёртка, которой в модели документа нет. При клике мышью ProseMirror переводит экранную координату в позицию документа через `view.posAtCoords()` (внутри — браузерный `caretRangeFromPoint`/`caretPositionFromPoint`). Чтобы каретка встала верно, **попадание должно прийтись внутрь contentDOM**. Когда перед contentDOM в нормальном потоке стоит нередактируемый блок, занимающий вертикальное место, клик у верхней кромки контента (или в зазор) резолвится браузером в позицию **вне** редактируемой зоны; ProseMirror снапит выделение к ближайшей валидной позиции — **в конец предыдущей ноды (выше)**. Величина прыжка вверх = высота служебного блока над контентом: - сниппет: узкая полоска меню → **одна строка**; - сноски: толстый разделитель + заголовок + (вложенно) маркеры определения, и всё это в самом низу документа → промах **выше заголовка, в тело** → **несколько строк**. Косвенное подтверждение, что виноват именно реальный DOM перед контентом: номер ссылки‑сноски рисуется **правильно** — через CSS‑псевдоэлемент `.reference::after { content: var(--footnote-number) }` ([footnote.module.css](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/src/branch/develop/apps/client/src/features/editor/components/footnote/footnote.module.css)), а не реальной нодой, и каретку не ломает. --- ## Воспроизведение ### Баг A — code block (red) 1. Создать страницу, вставить блок кода (`/code`), язык любой/`auto`. 2. Набрать несколько строк: ``` line0 line1 line2 line3 ``` 3. **Кликнуть мышью точно по символу в `line3`.** 4. Ожидаемо: каретка в `line3` там, где кликнули. 5. **Фактически:** каретка/выделение оказываются **на строку выше** (`line2`). При выделении протяжкой синяя подсветка рисуется на строку выше реальных глифов. ### Баг B — footnote (red) Предусловие: страница с несколькими абзацами тела и хотя бы одной сноской (есть список сносок внизу с редактируемым определением). 1. Прокрутить к списку сносок. Кликнуть в текст определения, напечатать символ — **работает (первая правка проходит)**. 2. Кликнуть в тело страницы (или иной блок) — сноска **теряет выделение**. 3. Кликнуть **повторно прямо в текст того ��е определения** сноски. 4. Ожидаемо: каретка внутри этого определения. 5. **Фактически:** каретка/выделение встают **на несколько строк выше** — в теле над заголовком «Footnotes»; ввод правит не то место, сноска кажется «нередактируемой». > Нюанс «первый раз редактируется, потом нет» по статическому коду не подтверждён на 100%: предположительно первый клик ставит каретку нативным фокусом, а повторный идёт уже через `posAtCoords` и срабатывает сдвиг; усугубляет пере‑рендер React‑NodeView из‑за пересчёта node‑декораций нумерации на каждый docChanged ([footnote-numbering.ts#L105-L111](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/src/branch/develop/packages/editor-ext/src/lib/footnote/footnote-numbering.ts#L105-L111)). Это надо подтвердить в браузере devtools — см. e2e ниже. --- ## Эталон корректного паттерна (transclusion) `transclusion-view.tsx` структурно такой же (панель перед `NodeViewContent`), но **бага нет**, потому что: 1. панель — `position: absolute` (оверлей по hover), а не блок в потоке → контент вниз не сдвигает; 2. на панели `onMouseDown={(e) => e.preventDefault()}` → клик по панели не двигает каретку; 3. панель рендерится только при `isEditable`. Это готовый шаблон фикса для остальных. --- ## План red‑green > Важно: тест‑раннер — **vitest + jsdom** (`apps/client/vitest.config.ts`, `packages/editor-ext/vitest.config.ts`); Playwright/e2e в репозитории нет. **jsdom не считает раскладку/координаты** (`getBoundingClientRect` → нули), поэтому честно воспроизвести `posAtCoords`‑сдвиг в vitest нельзя. Нужны два уровня. ### Уровень 1 — честная репродукция координат (Playwright e2e) — настоящий red→green Добавить минимальный `@playwright/test`. Тест прогоняется по реальной странице, кликает и читает `editor.state.selection` из живого инстанса (нужно выставить инстанс редактора в `window` под тест, напр. `window.__editor`). ```ts // e2e/editor-selection-offset.spec.ts (RED before fix, GREEN after) import { test, expect } from "@playwright/test"; test("code block: clicking a line places the caret on THAT line (no upward shift)", async ({ page }) => { await openBlankPageWithCodeBlock(page, ["line0", "line1", "line2", "line3"]); const code = page.locator(".ProseMirror pre code"); const box = await code.boundingBox(); const lineH = box!.height / 4; await page.mouse.click(box!.x + 16, box!.y + lineH * 3 + lineH / 2); // aim at line index 3 const lineIndex = await page.evaluate(() => { const ed = (window as any).__editor; const { $from } = ed.state.selection; return $from.parent.textContent.slice(0, $from.parentOffset).split("\n").length - 1; }); expect(lineIndex).toBe(3); // bug => 2 }); test("footnote: re-clicking a definition after blur edits THAT definition (not the body above)", async ({ page }) => { await openPageWithBodyAndFootnote(page); // several paragraphs + one footnote definition const def = page.locator("[data-footnote-def] p").first(); await def.click(); await page.keyboard.type("A"); // first edit works await page.locator(".ProseMirror > p").first().click(); // blur into body await def.click(); await page.keyboard.type("B"); // re-click the same definition const landedIn = await page.evaluate(() => { const { $from } = (window as any).__editor.state.selection; for (let d = $from.depth; d >= 0; d--) { if ($from.node(d).type.name === "footnoteDefinition") return "footnoteDefinition"; } return $from.parent.type.name; }); expect(landedIn).toBe("footnoteDefinition"); // bug => "paragraph" (body, above) }); ``` ### Уровень 2 — дешёвый структурный guard (vitest, без раскладки) — ловит сам антипаттерн Инвариант, не зависящий от CSS/раскладки и проверяемый в jsdom по **порядку DOM**: > В редактируемом NodeView contentDOM (`[data-node-view-content]`) **должен быть первым element‑child** обёртки `[data-node-view-wrapper]`; никакая нередактируемая «хромка» не должна стоять перед ним в DOM. Хромку размещаем **после** контента и позиционируем визуально через CSS (или `position:absolute`, как в transclusion). ```ts // apps/client/src/features/editor/components/__tests__/nodeview-content-first-child.test.tsx // RED for code block + footnotes today; GREEN after chrome is moved after contentDOM. import { Editor } from "@tiptap/core"; import { extensions } from "@/features/editor/extensions/extensions"; // or a trimmed set function mountWithContent(content: any) { const element = document.createElement("div"); document.body.appendChild(element); return new Editor({ element, extensions, content }); } function assertContentIsFirstChild(editor: Editor) { for (const wrapper of editor.view.dom.querySelectorAll("[data-node-view-wrapper]")) { const content = wrapper.querySelector("[data-node-view-content]"); if (!content) continue; // leaf/atom node view — not subject to this rule const wrapperOfContent = content.closest("[data-node-view-wrapper]"); if (wrapperOfContent !== wrapper) continue; // nested node view, checked on its own pass // No NON-editable element may precede the contentDOM in DOM order. let sib = content.previousElementSibling; while (sib) { expect(sib.getAttribute("contenteditable")).not.toBe("false"); sib = sib.previousElementSibling; } } } // describe/it: mount a doc containing a codeBlock, then a doc with a footnote, call the assert. ``` > Уровень 2 — это не доказательство фикса (его даёт уровень 1), а защита от регрессий: он гонит все NodeView к безопасной форме. `transclusion` сегодня тоже кладёт панель перед контентом (визуально ок за счёт absolute) — под этот инвариант его стоит привести к единому виду (перенести хромку после `NodeViewContent`), это без изменения поведения. ### Зелёная фаза — варианты фикса (по убыванию предпочтительности) 1. **Перенести служебный блок после `NodeViewContent`** в DOM и спозиционировать визуально через CSS (`order`/абсолют). Универсально, удовлетворяет уровню 2. 2. **Absolute‑оверлей + `onMouseDown preventDefault` + только в editable** — как в `transclusion`. Хорошо для меню сниппета. 3. **Псевдоэлемент `::before`/`::after`** для чисто декоративного текста (как уже сделано для номера ссылки `.reference::after`) — подходит для маркера‑номера определения и, возможно, заголовка списка. --- ## Acceptance criteria / DoD - [ ] Клик по строке N в блоке кода ставит каретку на строку N (не N‑1); выделение протяжкой подсвечивает кликнутые строки. - [ ] Повторный клик в определение сноски после потери выделения ставит каретку **в это определение**; ввод правит сноску, а не тело выше. - [ ] Добавлен e2e (уровень 1), который был RED до фикса и стал GREEN после — для code block и для сноски. - [ ] Добавлен структурный vitest‑guard (уровень 2); все редактируемые NodeView (включая `transclusion`) ему удовлетворяют. - [ ] Нет регрессий в read‑only/share и в совместном редактировании (decorations нумерации сносок не мутируют документ — поведение сохранено). ## Связанные манипуляции - Manual QA: добавить кейсы в `docs/manual-qa-test-plan.md` (PR #136) — секции редактора (code block / footnotes).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#146