feat(editor): image captions (figcaption) with lossless markdown round-trip (#221) #233

Open
Ghost wants to merge 11 commits from feat/221-image-captions into develop

Closes #221.

Подписи к картинкам (<figcaption>): видимый текст под картинкой, редактируется из бабл-меню, сохраняется во всех форматах.

  • Модель: строковый атрибут caption на узле image (остаётся atom, схема не меняется → сервер round-трипит через generateHTML/JSON без правок).
  • Оба node-view пути рендерят <figure>+<figcaption> (resize/imperative — handles/offsetHeight по картинке; React-placeholder).
  • Бабл-меню панель use-caption-control (по образцу alt-text).
  • Markdown lossless в обе стороны: export html→md и json→md (MCP) эмитят <div><img data-caption>, import восстанавливает через parseHTML.

Тесты: editor-ext 148 (parseHTML/renderHTML round-trip, full HTML→JSON→HTML, md round-trip), mcp 297. client tsc 0. Поиск по caption — вне скоупа по issue.

Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com

🤖 Generated with Claude Code

Closes #221. Подписи к картинкам (`<figcaption>`): видимый текст под картинкой, редактируется из бабл-меню, сохраняется во всех форматах. - Модель: строковый атрибут `caption` на узле `image` (остаётся atom, схема не меняется → сервер round-трипит через generateHTML/JSON без правок). - Оба node-view пути рендерят `<figure>`+`<figcaption>` (resize/imperative — handles/offsetHeight по картинке; React-placeholder). - Бабл-меню панель `use-caption-control` (по образцу alt-text). - Markdown lossless в обе стороны: export html→md и json→md (MCP) эмитят `<div><img data-caption>`, import восстанавливает через `parseHTML`. Тесты: editor-ext 148 (parseHTML/renderHTML round-trip, full HTML→JSON→HTML, md round-trip), mcp 297. client tsc 0. Поиск по caption — вне скоупа по issue. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- state:review reviewed_head: 57308bc3f3ca6f0f3b7397cf5954f3fc80a732a5 baseline_head: 57308bc3f3ca6f0f3b7397cf5954f3fc80a732a5 verdict: approved round: 5 max_rounds: 6 open_findings: [] reopened: {} -->
Ghost added 1 commit 2026-06-27 06:37:02 +03:00
Add a visible caption (<figcaption>) under images, editable from the
image bubble-menu and persisted across all formats: native Yjs/JSON,
HTML export, and Markdown.

- image node: new plain-text `caption` attribute (parse/render
  `data-caption` on <img>, emitted only when set) + `setImageCaption`
  command. The node stays an atom; the schema shape is unchanged, so the
  server's generateHTML/generateJSON path round-trips it for free.
- resize node-view: re-parent the resizable wrapper into a <figure> and
  render the caption in a <figcaption> BELOW it, outside nodeView.wrapper
  (so onCommit's offsetHeight measurement and the left/right resize
  handles still cover the image only). This path also drives read-only /
  share rendering. React placeholder view renders the caption too.
- bubble-menu: new useCaptionControl panel modeled on useAltTextControl
  (own icon, Caption strings, softer sanitizer, ~500 char limit).
- markdown lossless round-trip: a captioned image is emitted as a raw
  <img data-caption> wrapped in a block <div> (same trick as <video>) in
  both the editor-ext turndown rule and the MCP converter; caption-less
  images stay clean ![alt](src). Import restores the caption via the
  shared markdownToHtml + parseHTML.
- styles + i18n keys; tests for the schema attr round-trip, markdown
  round-trip (editor-ext) and the MCP converter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Owner

Code review — image captions (figcaption) with lossless markdown round-trip (#221)

Вердикт: Request changes — по существу изменение корректное, аккуратное и готовое к мержу: проблем безопасности, регрессий и багов стабильности не найдено (8 аспектов проверены параллельно). Единственный must‑fix — механический: отсутствует запись в CHANGELOG.md, который в проекте ведётся для каждой пользовательской фичи. После её добавления — Approve.

Базовая ветка: develop (106df7c9). Проверены все 13 файлов диффа (+427/−8).

Обязательно исправить перед мержем

  • [documentation] Добавить запись в CHANGELOG.md [Unreleased] → ### Added про подписи к картинкам (#221)CHANGELOG.md:11-43.
    Проект ведёт «Keep a Changelog»: секция ## [Unreleased] документирует каждую пользовательскую фичу с номером issue (#198, #222, #226/#227). Это изменение — явно пользовательская фича (новая команда setImageCaption, атрибут caption, UI редактирования в бабл‑меню картинки, lossless data-caption в markdown), но grep -ni "caption\|#221" CHANGELOG.md ничего не находит. Исправление: добавить запись под ## [Unreleased] → ### Added в стиле соседних (короткий жирный лид + 1–2 предложения + (#221)).

  • [suggestion][test-coverage] Экспортировать и покрыть юнит‑тестом sanitizeCaption (схлопывание пробелов / trim / граница 500 символов)apps/client/src/features/editor/components/common/use-caption-control.tsx:40-42.
    Это единственная нетривиальная чистая логика нового хука и точка целостности данных (её результат уходит в updateAttributes): ни одна ветка (collapse \s+, trim, slice(0,500)) не покрыта. Проект тестирует именно такие нормализаторы строк (normalizeLabelName в normalize-label.test.ts), так что пробел реальный. Сейчас функция приватная — её нельзя протестировать (как и сестринский sanitizeAlt, который тоже не покрыт, поэтому не блокирующее). Исправление: экспортировать sanitizeCaption и добавить маленький vitest по образцу normalize-label.test.ts.

  • [suggestion][simplification] Удалить дублирующий CSS‑модульный класс .imageCaption, оставив только глобальный .image-captionapps/client/src/features/editor/components/image/image-view.module.css:10-19.
    .imageCaption (image‑view.module.css) и .image-caption (styles/media.css) — побайтово идентичны, и React‑ImageView вешает оба на один элемент (image-view.tsx:75). Глобальный .image-caption (скоуп .ProseMirror) уже стилизует и React‑путь, и императивный ResizableNodeView, и read‑only/share. Исправление: удалить блок .imageCaption и убрать classes.imageCaption из clsx в image-view.tsx:75, оставив один источник правды в media.css.

  • [suggestion][documentation] Поправить комментарий «(like the video rule)» в turndown‑правиле картинкиpackages/editor-ext/src/lib/markdown/utils/turndown.utils.ts:271-273.
    Комментарий говорит, что captioned‑картинка эмитится «raw <img> wrapped in a block <div> (like the video rule)», но в этом файле собственное правило video (строки ~344‑351) эмитит markdown‑ссылку [name](src), а не <div>. Симметрия с video справедлива только в MCP‑конвертере (markdown-converter.ts), не в turndown. Исправление: убрать «(like the video rule)» из turndown‑комментария или уточнить, что аналогия относится к MCP‑конвертеру.

Покрытие тестами

Покрытие новой логики хорошее. Три добавленных файла осмысленно проверяют три разных пути с точными ассертами:

  • image.spec.ts — контракт caption: parseHTML восстанавливает, renderHTML эмитит data-caption только при наличии, caption‑less остаётся чистым, полный HTML→JSON→HTML round‑trip с энтити‑экранированием, image остаётся atom.
  • image-markdown.test.ts — turndown/marked round‑trip: raw <img data-caption>, caption‑less остаётся ![alt](src), спецсимволы выживают.
  • markdown-converter.test.mjs — MCP PM→MD: ![alt](src) без подписи, <div><img data-caption> с подписью, экранирование & и ".

Сознательно НЕ покрыто (приемлемо по планке проекта): команда setImageCaption (однострочная обёртка над updateAttributes, симметрична уже непокрытой setImageAlign) и императивный node‑view DOM applyCaption/onUpdate (проект не юнит‑тестит императивный DOM node‑view). Единственный реально стоящий пробел — sanitizeCaption (см. выше).

Архитектура и дизайн


## Code review — image captions (`figcaption`) with lossless markdown round-trip (#221) **Вердикт: Request changes** — по существу изменение корректное, аккуратное и готовое к мержу: проблем безопасности, регрессий и багов стабильности не найдено (8 аспектов проверены параллельно). Единственный must‑fix — механический: отсутствует запись в `CHANGELOG.md`, который в проекте ведётся для каждой пользовательской фичи. После её добавления — Approve. Базовая ветка: `develop` (`106df7c9`). Проверены все 13 файлов диффа (+427/−8). ### Обязательно исправить перед мержем - **[documentation] Добавить запись в `CHANGELOG.md` `[Unreleased] → ### Added` про подписи к картинкам (#221)** — `CHANGELOG.md:11-43`. Проект ведёт «Keep a Changelog»: секция `## [Unreleased]` документирует каждую пользовательскую фичу с номером issue (#198, #222, #226/#227). Это изменение — явно пользовательская фича (новая команда `setImageCaption`, атрибут `caption`, UI редактирования в бабл‑меню картинки, lossless `data-caption` в markdown), но `grep -ni "caption\|#221" CHANGELOG.md` ничего не находит. Исправление: добавить запись под `## [Unreleased] → ### Added` в стиле соседних (короткий жирный лид + 1–2 предложения + `(#221)`). - **[suggestion][test-coverage] Экспортировать и покрыть юнит‑тестом `sanitizeCaption` (схлопывание пробелов / trim / граница 500 символов)** — `apps/client/src/features/editor/components/common/use-caption-control.tsx:40-42`. Это единственная нетривиальная чистая логика нового хука и точка целостности данных (её результат уходит в `updateAttributes`): ни одна ветка (collapse `\s+`, trim, `slice(0,500)`) не покрыта. Проект тестирует именно такие нормализаторы строк (`normalizeLabelName` в `normalize-label.test.ts`), так что пробел реальный. Сейчас функция приватная — её нельзя протестировать (как и сестринский `sanitizeAlt`, который тоже не покрыт, поэтому не блокирующее). Исправление: экспортировать `sanitizeCaption` и добавить маленький vitest по образцу `normalize-label.test.ts`. - **[suggestion][simplification] Удалить дублирующий CSS‑модульный класс `.imageCaption`, оставив только глобальный `.image-caption`** — `apps/client/src/features/editor/components/image/image-view.module.css:10-19`. `.imageCaption` (image‑view.module.css) и `.image-caption` (styles/media.css) — побайтово идентичны, и React‑`ImageView` вешает оба на один элемент (`image-view.tsx:75`). Глобальный `.image-caption` (скоуп `.ProseMirror`) уже стилизует и React‑путь, и императивный `ResizableNodeView`, и read‑only/share. Исправление: удалить блок `.imageCaption` и убрать `classes.imageCaption` из `clsx` в `image-view.tsx:75`, оставив один источник правды в `media.css`. - **[suggestion][documentation] Поправить комментарий «(like the video rule)» в turndown‑правиле картинки** — `packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts:271-273`. Комментарий говорит, что captioned‑картинка эмитится «raw `<img>` wrapped in a block `<div>` (like the video rule)», но в этом файле собственное правило `video` (строки ~344‑351) эмитит markdown‑ссылку `[name](src)`, а не `<div>`. Симметрия с video справедлива только в MCP‑конвертере (`markdown-converter.ts`), не в turndown. Исправление: убрать «(like the video rule)» из turndown‑комментария или уточнить, что аналогия относится к MCP‑конвертеру. ### Покрытие тестами Покрытие новой логики хорошее. Три добавленных файла осмысленно проверяют три разных пути с точными ассертами: - `image.spec.ts` — контракт `caption`: parseHTML восстанавливает, renderHTML эмитит `data-caption` только при наличии, caption‑less остаётся чистым, полный HTML→JSON→HTML round‑trip с энтити‑экранированием, image остаётся atom. - `image-markdown.test.ts` — turndown/marked round‑trip: raw `<img data-caption>`, caption‑less остаётся `![alt](src)`, спецсимволы выживают. - `markdown-converter.test.mjs` — MCP PM→MD: `![alt](src)` без подписи, `<div><img data-caption>` с подписью, экранирование `&` и `"`. Сознательно НЕ покрыто (приемлемо по планке проекта): команда `setImageCaption` (однострочная обёртка над `updateAttributes`, симметрична уже непокрытой `setImageAlign`) и императивный node‑view DOM `applyCaption`/`onUpdate` (проект не юнит‑тестит императивный DOM node‑view). Единственный реально стоящий пробел — `sanitizeCaption` (см. выше). ### Архитектура и дизайн ---
Ghost force-pushed feat/221-image-captions from 5fbd655441 to dc14a9a540 2026-06-28 04:36:51 +03:00 Compare
Ghost added the review/changes-requested label 2026-06-28 22:24:19 +03:00
Ghost added 4 commits 2026-06-28 23:40:03 +03:00
Stock @tiptap/extension-image carries no caption attribute, so
markdownToProseMirror through docmostExtensions dropped the
data-caption the client emits, breaking the lossless claim. Extend the
Image node (mirroring editor-ext image.ts and the nearby Highlight
extend) to parse/render data-caption. Rebuilt build/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add PM -> markdown -> PM round-trip assertions for image caption
(plain and special-char), which fail without F1 and pass with it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The comment claimed 250 groups -> 499 chars -> slice past 500; the
input is 120 "a  b " groups collapsing to 479 chars, under the cap
with no slice. Correct the comment and assert the 479 length.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the ~110 duplicated lines into one parameterized
useImageTextFieldControl and make useAltTextControl/useCaptionControl
thin wrappers. Behavior identical; t("...") literals stay in the
wrappers so i18n extraction keeps working. sanitizeCaption still
exported for its unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added review/needs and removed review/changes-requested labels 2026-06-28 23:40:32 +03:00
Ghost added the status/in-progress label 2026-06-29 00:17:06 +03:00
Ghost added review/changes-requested and removed review/needs labels 2026-06-29 00:44:17 +03:00
Ghost added 3 commits 2026-06-29 01:43:37 +03:00
The setImageCaption command and its Commands<> declaration were dead:
captions are written via the generic updateAttributes in
useImageTextFieldControl, and a repo-wide grep finds zero callers.
Remove the speculative implementation (image.ts) and its type
declaration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The comment referenced markdownToHtml, which does not exist in the mcp
package; the import path is marked.parse + generateJSON (which runs the
image extension's parseHTML). Describe the actual step and regenerate the
build artifact in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A captioned image in a column is emitted via the imageToHtml helper, a
separate path from the top-level image case whose data-caption branch was
untested. Add a round-trip test with special chars (Tom & "Jerry") that
fails if the imageToHtml caption branch breaks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-29 01:47:05 +03:00
Ghost added review/needs and removed review/changes-requested labels 2026-06-29 01:47:33 +03:00
Ghost added review/changes-requested and removed review/needs labels 2026-06-29 01:55:43 +03:00
Ghost added 1 commit 2026-06-29 02:07:57 +03:00
Ghost added review/needs and removed review/changes-requested labels 2026-06-29 02:08:19 +03:00
Ghost added review/approved and removed review/needs labels 2026-06-29 02:29:28 +03:00
Collaborator

Ревью 57308bc3f — переревью ПОЛНЫМ РАЗДЕЛЬНЫМ веером (8 отдельных субагентов: security / stability / regressions / test-coverage / conventions / documentation / simplification / architecture) на полном диффе. Вердикт: approved.
Все аспекты LGTM. Подтверждены закрытыми все прошлые находки (F1–F8). XSS нет (caption рендерится как textContent/JSX, экранирование атрибутов корректно), round-trip lossless на обоих путях (editor-ext turndown+marked и MCP docmost-schema), обратная совместимость изображений без подписи цела, покрытие тестами полное (вкл. подпись в колонке/imageToHtml, спецсимволы), дублирование схемы editor-ext↔mcp — задокументированный осознанный паттерн.
(Прошлый round был сделан объединённым субагентом — переревьюено раздельным веером по требованию. С этого момента ревью постит аккаунт agent_reviewer.)

Ревью 57308bc3f — переревью ПОЛНЫМ РАЗДЕЛЬНЫМ веером (8 отдельных субагентов: security / stability / regressions / test-coverage / conventions / documentation / simplification / architecture) на полном диффе. Вердикт: approved. Все аспекты LGTM. Подтверждены закрытыми все прошлые находки (F1–F8). XSS нет (caption рендерится как textContent/JSX, экранирование атрибутов корректно), round-trip lossless на обоих путях (editor-ext turndown+marked и MCP docmost-schema), обратная совместимость изображений без подписи цела, покрытие тестами полное (вкл. подпись в колонке/imageToHtml, спецсимволы), дублирование схемы editor-ext↔mcp — задокументированный осознанный паттерн. (Прошлый round был сделан объединённым субагентом — переревьюено раздельным веером по требованию. С этого момента ревью постит аккаунт agent_reviewer.)
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/221-image-captions:feat/221-image-captions
git checkout feat/221-image-captions
Sign in to join this conversation.
No Reviewers
3 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#233