Files
gitmost/docs/footnotes-plan.md
vvzvlad 6bbddc636f docs: add footnotes feature plan and list it on the roadmap
Add docs/footnotes-plan.md describing the footnotes feature design:
reference + definitions model (Markdown/pandoc-style) chosen over the
self-contained inline-atom approach for Yjs/Hocuspocus collaboration
safety. Covers the three nodes (footnoteReference / footnotesList /
footnoteDefinition), decoration-based numbering, hover-popover reading,
HTML/Markdown round-trip, MCP schema mirror + commentsToFootnotes
migration, edge cases, affected files, phased plan and test strategy.
2026-06-18 18:46:33 +03:00

27 KiB

Сноски (footnotes) — проект фичи

Статус: проработанный план, готов к реализации. Ключевые решения приняты.

  • Архитектура: reference + definitions (модель Markdown/pandoc), а не «самодостаточный inline-атом со вложенным под-редактором».
  • Объём: полная интеграция — редактор + коллаборация (Yjs/Hocuspocus) + Markdown round-trip + зеркало схемы в MCP + AI-хелпер.

Исходный кейс: переводы технических статей (например, про дефлокуляцию при шликерном литье) требуют сносок переводчика и ссылок на источники. Сейчас их некуда деть, кроме инлайновых комментариев или костыля [1] руками.

1. Цели и требования

  1. Читать сноску прямо в тексте — навёл/кликнул на надстрочный номер → всплывающее окно с текстом сноски, не уходя со строки.
  2. Определения внизу страницы как часть текста — текст сносок живёт реальным редактируемым блоком в конце документа (выделяется, копируется, экспортируется), а не виртуальной отрисовкой.
  3. Авто-нумерация — номера проставляются и пересчитываются автоматически при вставке/удалении/перемещении.
  4. Безопасно для совместного редактирования — работает поверх Hocuspocus/Yjs без расхождений между клиентами.
  5. Переживает Markdown — экспорт/импорт страниц со сносками (формат pandoc/GFM [^id]).
  6. Доступно AI-агенту и MCP — агент и MCP-инструменты умеют читать/создавать сноски; существующий хелпер commentsToFootnotes переводится на настоящие ноды.

2. Развилка (решена): почему НЕ «классический» footnote-атом

Есть два принципиально разных способа хранить текст сноски в ProseMirror/Tiptap.

Вариант A — самодостаточный inline-атом (официальный пример ProseMirror)

Текст сноски лежит внутри inline-атома (inline: true, atom: true, content: "text*"), редактируется во вложенном под-редакторе в тултипе. См. prosemirror.net/examples/footnote и расширение tiptap-extension-footnote.

Минусы для нашего стека:

  • Несовместим с коллаборацией. Вложенный под-редактор синхронизирует шаги транзакций вручную (dispatchInner, флаг fromOutside). Поверх Hocuspocus/Yjs (TiptapTransformer) это даёт конфликты/расхождения — известная больная точка. У нас коллаборация — это ядро (collaboration.gateway.ts, yjs.util.ts).
  • Текст нельзя «положить вниз как часть текста». Он заперт в атоме; нижний список пришлось бы рисовать виртуально (CSS/декорации) — он не выделяется и плохо экспортируется.
  • Само расширение помечено ALPHA, DO NOT USE FOR PRODUCTION.

Вариант B — reference + definitions (ВЫБРАН)

Маркер в тексте и текст сноски — разные обычные ноды, связанные по id:

  • inline-атом-ссылка без контента (просто надстрочный номер);
  • блок определений внизу страницы из обычных редактируемых нод.

Плюсы — это ровно то, что нужно:

  • Только обычные ноды → Yjs обрабатывает их нативно, без вложенных редакторов. Главный выигрыш для коллаборативного стека.
  • Нижний блок — реальная часть документа: выделяется, копируется, экспортируется (требование 2).
  • Чтение в тексте — read-only поповер, который просто читает определение по id; под-редактор не нужен (требование 1).
  • 1:1 ложится на Markdown-сноски pandoc/GFM ([^id][^id]: …) → импорт/экспорт и хелпер commentsToFootnotes выравниваются естественно (требования 5, 6).

Минусы (управляемые, см. §4–§5): нужно держать ссылки и определения в синхроне (сироты/висячие ссылки) и считать номера/порядок плагином.

3. Модель документа

Три новые ноды. Источник истины — ссылка: есть footnoteReference → есть парное footnoteDefinition; удаление ссылки каскадно удаляет определение в той же транзакции (один Ctrl+Z восстанавливает оба).

// 1) Маркер в тексте — inline atom, без контента, только id.
//    Видимый номер НЕ хранится в документе (см. §4).
{ "type": "footnoteReference", "attrs": { "id": "fn_a1b2c3" } }

// 2) Контейнер внизу страницы — реальный блок, всегда последний в документе.
{ "type": "footnotesList", "content": [ /* footnoteDefinition+ */ ] }

// 3) Одно определение — обычный редактируемый блок с id, привязывающим к ссылке.
{ "type": "footnoteDefinition",
  "attrs": { "id": "fn_a1b2c3" },
  "content": [ { "type": "paragraph", "content": [ /* текст сноски, inline */ ] } ] }

Почему нода, а не mark

Ссылка на сноску — это вставляемый в точку курсора надстрочный глиф, а не выделение существующего текста. Mark (как у комментариев в comment.ts) оборачивает диапазон; нам нужна точечная inline-нода-атом — образец mention.ts (inline: true, atom: true, selectable: true).

Схемные ограничения

Нода Параметры схемы Где разрешена / что внутри
footnoteReference group: "inline", inline: true, atom: true, selectable: true, draggable: false в любом inline-контексте, кроме code-block и кроме содержимого footnoteDefinition (запрет вложенных сносок)
footnotesList group: "block", content: "footnoteDefinition+", isolating: true, selectable: false единственный экземпляр, всегда последний дочерний узел документа
footnoteDefinition content: "paragraph+" (или block+ без вложенных сносок), defining: true, isolating: true только внутри footnotesList; атрибут id обязателен

id генерируется как uuidv7 (как у mention/unique-id), хранится в data-*-атрибуте для HTML round-trip.

4. Нумерация и порядок — ключевая тонкость

Решение: номера НЕ хранятся в документе. Их вычисляет ProseMirror-плагин, проходя footnoteReference в порядке документа, и отрисовывает декорациями (на надстрочнике и на маркере определения).

Почему так:

  • Детерминированность: каждый клиент считает одинаковые номера из одного и того же документа → никаких расхождений в коллаборации, никаких appendTransaction в ответ на чужие шаги (что и есть источник конфликтов).
  • Дёшево: пересчёт на каждый рендер, без мутаций документа.

Порядок определений внизу

Чтобы нижний список визуально шёл 1, 2, 3, реальные ноды footnoteDefinition должны лежать в порядке ссылок (декорации не переставляют DOM). Стратегия:

  1. На создании — команда setFootnote вставляет определение в правильную позицию (считает, сколько ссылок идёт до точки вставки, и кладёт определение по этому индексу). Покрывает и добавление в конец, и вставку в середину.
  2. Нормализация — плагин-нормализатор приводит порядок определений к порядку ссылок, если он нарушился (например, пользователь вырезал и переставил абзац со ссылкой). Это чистая функция от состояния документа → все клиенты вычисляют одинаковую перестановку и сходятся. Чтобы два клиента не дёргали нормализацию одновременно, выполнять её в appendTransaction с guard-метой и идемпотентно (no-op, если порядок уже верный).

Главный риск реализации — именно нормализация порядка при перемещении ссылок в коллаборации. Для MVP достаточно правильной вставки на создании (п.1) + нормализации только на локальных транзакциях; перемещение ссылок между местами — редкий кейс, его можно довести во вторую очередь.

Визуальные номера можно при желании продублировать CSS-счётчиками (counter-reset/counter-increment, как в alpha-расширении), но decoration-подход надёжнее в коллаборации и не зависит от порядка узлов.

5. Жизненный цикл, команды и UX

Команды (в ноде, через addCommands + declare module "@tiptap/core")

  • setFootnote() — в одной транзакции: вставляет footnoteReference с новым id в позицию курсора + создаёт footnotesList (если его нет, в самом конце документа) + добавляет туда пустое footnoteDefinition с тем же id в правильную позицию + переносит фокус в это определение, чтобы сразу печатать текст.
  • removeFootnote(id) — удаляет ссылку и её определение (каскад в одной транзакции). Если определений не осталось — удаляет пустой footnotesList.
  • scrollToFootnote(id) / scrollToReference(id) — навигация «ссылка ↔ определение» (для кнопки в поповере и «↩» в определении).

Ввод

  • Slash-меню /footnote (или /сноска) — пункт в slash-menu, вызывает setFootnote.
  • Кнопка тулбара и шорткат (например Mod-Alt-F).
  • Опционально input-rule (по образцу wrappingInputRule в callout) — например [^ → вставка сноски; решить при реализации, не обязательно для MVP.

Плагин синхронизации (addProseMirrorPlugins)

Минимальный, guard’нутый, идемпотентный:

  • Подчистка сирот: footnoteDefinition без парной ссылки — удалить (или пометить, см. §12).
  • Вставка/коллизии при paste: ссылка без определения → создать пустое определение; определение без ссылки → удалить; при вставке с конфликтом id — регенерировать id у пары.
  • Пустой контейнер: нет определений → удалить footnotesList.
  • Read-only / share: плагин не мутирует документ (только декорации нумерации), чтобы не трогать общий документ при простом просмотре.

6. Чтение в тексте (поповер)

NodeView надстрочника (ReactNodeViewRenderer, образец mention/callout) по hover/click открывает поповер через @floating-ui/dom — тот же паттерн, что в render-items.ts и mention-suggestion.ts (offset/flip/shift, autoUpdate, закрытие по outside-click).

Поповер показывает read-only текст определения, найденного по id прямо в editor.state (никакого под-редактора). Кнопка «редактировать»/«перейти» вызывает scrollToFootnote(id) и фокусит определение внизу. Работает и в read-only/share-режиме — там используется тот же mainExtensions (extensions.ts, readonly-page-editor.tsx).

7. Нижний блок (footnotesList)

NodeView контейнера рисует визуальный разделитель: верхняя граница + заголовок («Footnotes» / «Примечания», локализуется), список footnoteDefinition. Каждое определение — NodeViewContent (редактируемый контент) + декоративный номер (из §4) + «↩» для возврата к ссылке. Стили — CSS-модули + Mantine, как у остальных NodeView (components/callout).

8. HTML round-trip (parseHTML / renderHTML)

Для лосслесс HTML↔JSON (экспорт, generateHTML, серверный рендер, зеркало MCP) у каждой ноды строгие parseHTML/renderHTML:

Нода renderHTML (примерно) parseHTML
footnoteReference <sup data-footnote-ref data-id="…"> (атом, без контента; номер ставит CSS/декорация) sup[data-footnote-ref]
footnotesList <section data-footnotes>…</section> (или <ol>) section[data-footnotes]
footnoteDefinition <div data-footnote-def data-id="…">…0…</div> (0 — дырка под контент) div[data-footnote-def]

9. Markdown

Маппинг на сноски pandoc/GFM:

  • footnoteReference[^id] в тексте;
  • footnoteDefinition[^id]: текст в конце документа.

Точки правки:

  • Экспорт HTML→Markdown (клиент/сервер): правило turndown в turndown.utils.ts (образец — правило callout).
  • Импорт Markdown→JSON: плагин/расширение marked в marked.utils.ts, плюс ноды должны быть в схеме generateJSON.
  • MCP JSON→Markdown: case в markdown-converter.ts (образцы — mention/callout).
  • Fallback: при экспорте в формат без сносок — деградация в инлайновые [n] + список (текущее поведение commentsToFootnotes).

10. Сервер и коллаборация

Новые ноды обязаны попасть в серверный список расширений tiptapExtensions (collaboration.util.ts) — иначе:

  • сервер вырежет ноды при сохранении/коллаборации (getSchema в yjs.util.ts);
  • сломается серверный рендер HTML (generateHTML.ts) и экспорт (export.service.ts).

Поскольку это обычные ноды (а не атом с под-редактором), Yjs/TiptapTransformer обрабатывает их автоматически — отдельной регистрации в Yjs не нужно. Миграции БД не требуется (это уровень ProseMirror-документа, не схемы Postgres).

11. MCP: зеркало схемы и конвертер

packages/mcp не импортирует editor-ext, а держит собственное зеркало схемы. Синхронизировать вручную:

  • определения трёх нод (parseHTML/renderHTML, атрибуты) — в docmost-schema.ts;
  • сериализацию в Markdown — в markdown-converter.ts;
  • перевод существующего хелпера commentsToFootnotes (transforms.ts) с текстовых [N] + orderedList на настоящие ноды footnoteReference/footnotesList/footnoteDefinition; обновить подсчёт маркеров в diff.ts.

⚠️ При любом изменении схемы документа держать packages/mcp/src/lib/ и packages/editor-ext в синхроне — это явное требование CLAUDE.md.

12. Краевые случаи и решения

Случай Решение
Удалили ссылку Каскадно удалить определение в той же транзакции (undo восстанавливает оба)
Удалили последнюю ссылку Удалить весь footnotesList
Paste ссылки без определения Создать пустое определение
Paste определения без ссылки Удалить (сирота) — либо v2: пометить «осиротевшим»
Коллизия id при paste Регенерировать id у вставленной пары
Перемещение ссылки (cut/paste абзаца) Нормализатор переупорядочивает определения (§4)
Вложенная сноска (ссылка внутри определения) Запретить схемой
Ссылка в code-block Запретить
Несколько ссылок на одну сноску v2 (MVP: строго 1:1)
Экспорт в формат без сносок Fallback на [n] + список
Read-only / share Только декорации нумерации, без мутаций документа

13. Затрагиваемые файлы (полный список)

Редактор (editor-ext):

  • packages/editor-ext/src/lib/footnote/ — новые: три ноды, плагин нумерации/нормализации, команды, NodeView’ы (новый каталог).
  • packages/editor-ext/src/index.ts — экспорт.

Клиент:

Сервер / коллаборация:

Markdown round-trip:

MCP:

14. План реализации по фазам

  1. Схема (editor-ext): три ноды + команды + input-rule + экспорт в index.ts. Минимальный плагин нумерации (декорации). Это фундамент, от него зависит всё.
  2. Клиент UI: NodeView надстрочника + поповер чтения (floating-ui), NodeView нижнего блока, slash-меню, CSS, регистрация в extensions.ts. Проверить read-only/share.
  3. Сервер/коллаборация: регистрация в tiptapExtensions; проверить сохранение, коллаборацию двух клиентов, серверный рендер/экспорт HTML.
  4. Markdown round-trip: turndown + marked; тест «JSON → MD → JSON» без потерь.
  5. MCP: зеркало схемы + конвертер + перевод commentsToFootnotes на ноды + diff.ts.
  6. Шлифовка: нормализация порядка при перемещении ссылок, edge-cases из §12, доступность (ARIA для надстрочника/поповера).

15. Тестирование

  • Unit (mcp, node --test): JSON↔Markdown round-trip сносок; commentsToFootnotes → ноды; нумерация/нормализация как чистая функция.
  • Unit (editor-ext): команды setFootnote/removeFootnote, каскадное удаление, вставка определения в правильную позицию.
  • Client (Vitest): рендер надстрочника и поповера, навигация ссылка↔определение.
  • Ручной/e2e: два коллаборативных клиента (одновременная вставка сносок, отсутствие расхождений нумерации), экспорт в PDF/Markdown, публичная шара (поповер в read-only).

16. Открытые вопросы / v2

  • Повторное использование одной сноски несколькими ссылками (pandoc допускает) — отложено.
  • Сноски-сироты: удалять молча или показывать предупреждение/«осиротевший» бейдж.
  • Концевые сноски (endnotes) на уровне спейса/книги vs постраничные — вне объёма.
  • Доп. форматы экспорта (DOCX и т.п.) — отдельно.

Ссылки на код