feat(client): сноски внизу страницы — рендер без сдвига (текст к левому полю) и уменьшенным шрифтом #420

Open
opened 2026-07-08 00:12:33 +03:00 by agent_vscode · 0 comments
Collaborator

Проблема

Блок сносок внизу страницы (секция FOOTNOTES) рендерится с двумя визуальными недостатками:

  1. Сдвиг (hanging indent). Текст каждой сноски отодвинут вправо от левого поля: номер N. живёт в отдельной узкой колонке слева, а текст (и все перенесённые строки) выровнены по правому краю этой колонки. Из-за этого сноски «уезжают» вправо относительно заголовка FOOTNOTES и основного текста.
  2. Одинаковый с телом размер шрифта. Сноски набраны тем же кеглем, что и основной текст (--mantine-font-size-md, 16px), хотя это вспомогательный аппарат и должен быть мельче.

Нужно: рендерить сноски без сдвига (номер и текст, включая перенесённые строки, прижаты к левому полю) и шрифтом меньше основного.

Причина (текущая реализация)

Рендер живёт в клиенте, в NodeView сноски-определения и его CSS-модуле:

Сейчас .definition — это флекс-строка [content][marker][backlink], где маркер визуально выносится влево через order: -1, а его колонка задаёт отступ:

.definition { display: flex; align-items: flex-start; gap: 0.4em; }        /* ~0.4em зазор */
.definitionMarker {
  order: -1;            /* номер слева, хотя в DOM он после контента (#146) */
  min-width: 1.5em;     /* фиксированная колонка -> сдвиг ~1.9em */
  text-align: right;
}

Итого текст начинается на ~1.5em + 0.4em ≈ 1.9em от левого поля, и перенесённые строки висят на том же отступе. Размер шрифта наследуется от .ProseMirror { font-size: var(--mantine-font-size-md) } — как у тела.

Почему так сделано (важный контекст, ломать нельзя):

  • #146: редактируемый NodeViewContent обязан быть первым дочерним элементом в DOM — иначе браузерное hit-testing клика (posAtCoords) промахивается мимо contentDOM и ставит каретку выше, в тело. Поэтому контент идёт первым, а маркер/бэклинк — после него и раскладываются влево/вправо через CSS order. Инвариант проверяется тестом footnote-views.structure.test.tsx.
  • #168: у сноски с несколькими ссылками бэклинк превращается в строку букв ↩ a b c — эту логику трогать не нужно, но шрифт/выравнивание должны остаться корректными.

Желаемое поведение

  • Номер сноски N. и текст прижаты к тому же левому полю, что заголовок FOOTNOTES и основной текст.
  • Перенос длинной сноски на вторую строку возвращается к левому полю (никакого hanging indent).
  • Кегль сносок меньше основного текста.
  • Обратные ссылки / ↩ a b c остаются на месте и кликабельны.
  • Изменение видно и в режиме редактирования, и в read-only (компонент общий).

Предлагаемое решение (рекомендуется)

Убрать отдельную колонку-маркер и рендерить номер как инлайн-контент в начале первой строки текста через CSS ::before (тот же приём уже используется для маркера-ссылки: .reference::after { content: var(--footnote-number) }). Номер декоративный и берётся из CSS-переменной --footnote-number, которая уже проставляется на обёртке .definition инлайн-стилем — то есть в модель документа он не попадает и на экспорт/маркдаун не влияет.

Конкретно:

  1. CSS (footnote.module.css):
    • На .definition задать меньший кегль: font-size: var(--mantine-font-size-sm) (14px против 16px тела — совпадает с кеглем .listHeading). Оставить display: flex только для пары [content][backlink], зазор уменьшить.
    • Удалить блок .definitionMarker (колонка-маркер больше не нужна).
    • Номер рендерить инлайн в начале первого абзаца контента, чтобы он был на одной строке с текстом, а переносы возвращались к левому полю:
      .definitionContent > :first-child::before {
        content: var(--footnote-number, "?") ". ";
        color: var(--mantine-color-dimmed);
        font-variant-numeric: tabular-nums;   /* ровные многозначные номера 10, 11… */
        user-select: none;
      }
      
    • .definitionContent остаётся flex: 1 1 auto во всю ширину (начинается от левого поля => сдвига нет). Правило .definitionContent > :first-child { margin-top: 0 } сохранить.
  2. JSX (footnote-definition-view.tsx):
    • Убрать <span className={classes.definitionMarker}>{number}.</span> (номер теперь из ::before). Переменную --footnote-number на обёртке .definition оставить — из неё берётся значение.
    • Блок бэклинков / ↩ a b c (#168) не трогать.

Почему так, а не иначе: генерируемый ::before виден визуально перед контентом, при этом реальный contentDOM остаётся первым в DOM — инвариант #146 сохраняется автоматически, а костыль с order: -1 на маркере уходит совсем (структура даже упрощается).

Более лёгкий fallback (если ::before внутри редактируемого контента даст проблемы с кареткой)

Оставить <span>-маркер, но: font-size: var(--mantine-font-size-sm) на .definition и .definitionMarker { min-width: 0 } + минимальный gap. Это уменьшает сдвиг, но не убирает hanging indent для многострочных сносок (переносы всё равно висят на ширине номера). Применять только если основной вариант ломает редактирование.

Ограничения и краевые случаи (проверить при реализации)

  • #146 (каретка/клик): после переноса номера в ::before проверить, что клик в начало текста сноски и постановка каретки работают, ничего не «прыгает» в тело. Прогнать footnote-views.structure.test.tsx — инвариант «contentDOM первый, вся нередактируемая обвязка после него» должен сохраниться (бэклинк остаётся contenteditable=false, так что проверка nonEditable.length > 0 не падает).
  • #168: многократные сноски ↩ a b c — проверить выравнивание и что мельче стал и текст, и буквенные бэклинки (.backLink/.backLinkArrow заданы в относительных em — унаследуют новый кегль).
  • Многозначные номера (10, 11, …): сохранить font-variant-numeric: tabular-nums.
  • Тема: цвет номера через var(--mantine-color-dimmed) — уже корректен в light/dark.
  • Read-only vs edit: компонент один — убедиться, что оба режима выглядят одинаково.
  • Экспорт/копирование: номер из ::before не входит в модель документа и в маркдаун-экспорт (номер и так вычисляемый, не хранится) — регрессий экспорта быть не должно; допустимо, что при выделении текста мышью в номер N. попадёт в буфер (косметика).
  • Popover сноски (.popoverBody) уже sm — не трогаем, консистентно.

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

  • Номер и текст сноски выровнены по левому полю (совпадают с левым краем заголовка FOOTNOTES); визуального сдвига текста вправо нет.
  • Перенос длинной сноски на вторую строку возвращается к левому полю (нет hanging indent).
  • Кегль сносок меньше основного текста (sm).
  • Обратные ссылки / ↩ a b c работают и корректно выровнены; постановка каретки и редактирование текста сноски не сломаны (#146).
  • Тесты структуры/сносок зелёные; при необходимости тест обновлён (без ослабления инварианта #146).
  • Проверено в light и dark, в режимах edit и read-only.
## Проблема Блок сносок внизу страницы (секция `FOOTNOTES`) рендерится с двумя визуальными недостатками: 1. **Сдвиг (hanging indent).** Текст каждой сноски отодвинут вправо от левого поля: номер `N.` живёт в отдельной узкой колонке слева, а текст (и все перенесённые строки) выровнены по правому краю этой колонки. Из-за этого сноски «уезжают» вправо относительно заголовка `FOOTNOTES` и основного текста. 2. **Одинаковый с телом размер шрифта.** Сноски набраны тем же кеглем, что и основной текст (`--mantine-font-size-md`, 16px), хотя это вспомогательный аппарат и должен быть мельче. Нужно: рендерить сноски **без сдвига** (номер и текст, включая перенесённые строки, прижаты к левому полю) и **шрифтом меньше основного**. ## Причина (текущая реализация) Рендер живёт в клиенте, в NodeView сноски-определения и его CSS-модуле: - [footnote.module.css](apps/client/src/features/editor/components/footnote/footnote.module.css) — селекторы `.definition`, `.definitionMarker`, `.definitionContent`. - [footnote-definition-view.tsx](apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx) — JSX узла (`.definitionMarker` рендерится отдельным `<span>`). Сейчас `.definition` — это флекс-строка `[content][marker][backlink]`, где маркер визуально выносится влево через `order: -1`, а его колонка задаёт отступ: ```css .definition { display: flex; align-items: flex-start; gap: 0.4em; } /* ~0.4em зазор */ .definitionMarker { order: -1; /* номер слева, хотя в DOM он после контента (#146) */ min-width: 1.5em; /* фиксированная колонка -> сдвиг ~1.9em */ text-align: right; } ``` Итого текст начинается на ~`1.5em + 0.4em ≈ 1.9em` от левого поля, и перенесённые строки висят на том же отступе. Размер шрифта наследуется от `.ProseMirror { font-size: var(--mantine-font-size-md) }` — как у тела. Почему так сделано (важный контекст, ломать нельзя): - **#146:** редактируемый `NodeViewContent` обязан быть **первым** дочерним элементом в DOM — иначе браузерное hit-testing клика (`posAtCoords`) промахивается мимо `contentDOM` и ставит каретку выше, в тело. Поэтому контент идёт первым, а маркер/бэклинк — после него и раскладываются влево/вправо через CSS `order`. Инвариант проверяется тестом [footnote-views.structure.test.tsx](apps/client/src/features/editor/components/footnote/footnote-views.structure.test.tsx). - **#168:** у сноски с несколькими ссылками бэклинк превращается в строку букв `↩ a b c` — эту логику трогать не нужно, но шрифт/выравнивание должны остаться корректными. ## Желаемое поведение - Номер сноски `N.` и текст прижаты к тому же левому полю, что заголовок `FOOTNOTES` и основной текст. - Перенос длинной сноски на вторую строку возвращается к левому полю (никакого hanging indent). - Кегль сносок меньше основного текста. - Обратные ссылки `↩` / `↩ a b c` остаются на месте и кликабельны. - Изменение видно и в режиме редактирования, и в read-only (компонент общий). ## Предлагаемое решение (рекомендуется) Убрать отдельную колонку-маркер и рендерить номер как **инлайн-контент в начале первой строки текста** через CSS `::before` (тот же приём уже используется для маркера-ссылки: `.reference::after { content: var(--footnote-number) }`). Номер декоративный и берётся из CSS-переменной `--footnote-number`, которая уже проставляется на обёртке `.definition` инлайн-стилем — то есть в модель документа он не попадает и на экспорт/маркдаун не влияет. Конкретно: 1. **CSS** ([footnote.module.css](apps/client/src/features/editor/components/footnote/footnote.module.css)): - На `.definition` задать меньший кегль: `font-size: var(--mantine-font-size-sm)` (14px против 16px тела — совпадает с кеглем `.listHeading`). Оставить `display: flex` только для пары `[content][backlink]`, зазор уменьшить. - **Удалить** блок `.definitionMarker` (колонка-маркер больше не нужна). - Номер рендерить инлайн в начале первого абзаца контента, чтобы он был на одной строке с текстом, а переносы возвращались к левому полю: ```css .definitionContent > :first-child::before { content: var(--footnote-number, "?") ". "; color: var(--mantine-color-dimmed); font-variant-numeric: tabular-nums; /* ровные многозначные номера 10, 11… */ user-select: none; } ``` - `.definitionContent` остаётся `flex: 1 1 auto` во всю ширину (начинается от левого поля => сдвига нет). Правило `.definitionContent > :first-child { margin-top: 0 }` сохранить. 2. **JSX** ([footnote-definition-view.tsx](apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx)): - Убрать `<span className={classes.definitionMarker}>{number}.</span>` (номер теперь из `::before`). Переменную `--footnote-number` на обёртке `.definition` **оставить** — из неё берётся значение. - Блок бэклинков `↩` / `↩ a b c` (#168) не трогать. Почему так, а не иначе: генерируемый `::before` виден визуально *перед* контентом, при этом реальный `contentDOM` остаётся первым в DOM — инвариант #146 сохраняется автоматически, а костыль с `order: -1` на маркере уходит совсем (структура даже упрощается). ### Более лёгкий fallback (если `::before` внутри редактируемого контента даст проблемы с кареткой) Оставить `<span>`-маркер, но: `font-size: var(--mantine-font-size-sm)` на `.definition` и `.definitionMarker { min-width: 0 }` + минимальный `gap`. Это уменьшает сдвиг, но **не убирает hanging indent** для многострочных сносок (переносы всё равно висят на ширине номера). Применять только если основной вариант ломает редактирование. ## Ограничения и краевые случаи (проверить при реализации) - **#146 (каретка/клик):** после переноса номера в `::before` проверить, что клик в начало текста сноски и постановка каретки работают, ничего не «прыгает» в тело. Прогнать [footnote-views.structure.test.tsx](apps/client/src/features/editor/components/footnote/footnote-views.structure.test.tsx) — инвариант «contentDOM первый, вся нередактируемая обвязка после него» должен сохраниться (бэклинк `↩` остаётся `contenteditable=false`, так что проверка `nonEditable.length > 0` не падает). - **#168:** многократные сноски `↩ a b c` — проверить выравнивание и что мельче стал и текст, и буквенные бэклинки (`.backLink`/`.backLinkArrow` заданы в относительных `em` — унаследуют новый кегль). - **Многозначные номера** (10, 11, …): сохранить `font-variant-numeric: tabular-nums`. - **Тема:** цвет номера через `var(--mantine-color-dimmed)` — уже корректен в light/dark. - **Read-only vs edit:** компонент один — убедиться, что оба режима выглядят одинаково. - **Экспорт/копирование:** номер из `::before` не входит в модель документа и в маркдаун-экспорт (номер и так вычисляемый, не хранится) — регрессий экспорта быть не должно; допустимо, что при выделении текста мышью в номер `N.` попадёт в буфер (косметика). - **Popover** сноски (`.popoverBody`) уже `sm` — не трогаем, консистентно. ## Критерии приёмки (DoD) - [ ] Номер и текст сноски выровнены по левому полю (совпадают с левым краем заголовка `FOOTNOTES`); визуального сдвига текста вправо нет. - [ ] Перенос длинной сноски на вторую строку возвращается к левому полю (нет hanging indent). - [ ] Кегль сносок меньше основного текста (`sm`). - [ ] Обратные ссылки `↩` / `↩ a b c` работают и корректно выровнены; постановка каретки и редактирование текста сноски не сломаны (#146). - [ ] Тесты структуры/сносок зелёные; при необходимости тест обновлён (без ослабления инварианта #146). - [ ] Проверено в light и dark, в режимах edit и read-only.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#420