Feature: подписи к изображениям (image captions) #221

Open
opened 2026-06-26 19:21:06 +03:00 by Ghost · 0 comments

Summary

Добавить подписи к изображениям (<figcaption>): видимый текст под картинкой, редактируемый из бабл-меню картинки, сохраняемый во всех форматах (нативное хранилище Yjs/JSON, HTML-экспорт и Markdown).

Сейчас у image-узла есть только alt (alt-текст для доступности) и выравнивание — видимой подписи нет.

Принятые решения

  • Модель данных: подпись — строковый атрибут caption на существующем image-узле. Узел остаётся atom, форма схемы не меняется. Редактирование — панель в бабл-меню по образцу alt-текста (useAltTextControl). Подпись — простой текст без форматирования.
  • Markdown round-trip — lossless: подпись сохраняется и при экспорте, и при импорте через сырой <img data-caption="...">, завёрнутый в блочный <div> (тот же приём, что уже используется для <video>). Картинки без подписи остаются чистым ![alt](src).

Архитектура

HTML↔JSON — «бесплатно»

Сервер (apps/server/src/collaboration/collaboration.util.ts) гоняет документ через generateHTML/generateJSON с тем же расширением TiptapImage. Поэтому атрибут caption с parseHTML/renderHTML (читает/пишет data-caption на <img>) автоматически:

  • попадает в HTML-экспорт и индексирование (jsonToHtml);
  • восстанавливается при импорте HTML (htmlToJson);
  • экранируется сериализатором ProseMirror (без XSS).

Серверный код менять не нужно.

Два пути node-view (ключевая тонкость)

addNodeView в packages/editor-ext/src/lib/image/image.ts имеет два пути:

  • React-view (image-view.tsx) — пока нет src (загрузка/плейсхолдер);
  • ResizableNodeView (императивный DOM) — когда src есть. Он же используется в read-only/share (хендлеры не навешиваются при editable=false), значит подпись на готовых картинках рисует именно он.

Подпись нужно рендерить в обоих.

Главное ограничение resize-пути: figcaption должен лежать вне nodeView.wrapper (где <img> + хендлеры), потому что:

  • onCommit пишет width/height из el.offsetWidth/offsetHeight — подпись внутри измеряемого блока исказит сохранённую высоту/aspectRatio;
  • хендлеры resize (directions: ["left","right"]) растягиваются на всю высоту wrapper — должны покрывать только картинку.

Решение: после создания nodeView перенести nodeView.wrapper внутрь нового <figure> (он остаётся единственным flex-ребёнком container, поэтому applyAlignment и float-режимы продолжают работать), а figcaption положить в figure под wrapper. Vendored-класс ResizableNodeView не трогаем (wrapper/dom — публичные свойства).

План реализации (декомпозиция)

  1. packages/editor-ext/src/lib/image/image.ts

    • ImageAttributes: добавить caption?: string.
    • addAttributes(): атрибут caption (зеркало alt):
      caption: {
        default: undefined,
        parseHTML: (element) => element.getAttribute("data-caption") || undefined,
        renderHTML: (attributes) =>
          // Emit data-caption only when set, so caption-less images stay clean.
          attributes.caption ? { "data-caption": attributes.caption } : {},
      },
      
    • команда setImageCaption (+ запись в declare module):
      setImageCaption: (caption) =>
        ({ commands }) => commands.updateAttributes("image", { caption }),
      
    • resize-путь: после new ResizableNodeView(...) перенести wrapper в <figure> и добавить <figcaption>:
      // Re-parent the resizable wrapper into a <figure> so the caption sits BELOW
      // the image, OUTSIDE nodeView.wrapper. onCommit measures the img's
      // offsetHeight for the persisted height/aspectRatio, and the left/right
      // resize handles span the wrapper — both must cover the image only.
      const container = nodeView.dom as HTMLElement;
      const figure = document.createElement("figure");
      figure.style.margin = "0";
      figure.style.display = "inline-block"; // shrink-to-fit to image width
      figure.appendChild(nodeView.wrapper);
      container.appendChild(figure);
      
      const figcaption = document.createElement("figcaption");
      figcaption.className = "image-caption";
      const applyCaption = (text) => {
        const value = (text || "").trim();
        figcaption.textContent = value;
        figcaption.style.display = value ? "block" : "none";
      };
      applyCaption(node.attrs.caption);
      figure.appendChild(figcaption);
      
      и в существующем onUpdate:
      if (updatedNode.attrs.caption !== currentNode.attrs.caption) {
        applyCaption(updatedNode.attrs.caption);
      }
      
  2. apps/client/src/features/editor/components/image/image-view.tsx (React-путь): достать caption из node.attrs, обернуть в <figure>, добавить <Text component="figcaption"> при наличии подписи (Mantine экранирует содержимое).

  3. Новый хук apps/client/src/features/editor/components/common/use-caption-control.tsx — копия use-alt-text-control.tsx: своя иконка, строки t("Caption"), вызов updateAttributes(nodeName, { caption: sanitizeCaption(draft) || undefined }). sanitizeCaption мягче sanitizeAlt (схлопывает переводы строк/пробелы, trim, лимит ~500).
    Интеграция в image-menu.tsx: caption в useEditorState, подключение хука рядом с alt, кнопка рядом с altTextButton, показ captionPanel в условии isEditing.

  4. CSS .image-caption (глобально + модульно для React): text-align:center; font-size:0.875em; color:var(--mantine-color-dimmed); margin-top:0.4em; line-height:1.35; word-break:break-word;.

  5. packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts (image-правило): при наличии data-caption отдавать lossless-вариант:

    const caption = node.getAttribute('data-caption') || '';
    if (caption) {
      // ![]() can't carry a caption, so emit a raw <img> wrapped in a block <div>
      // (like the video rule). marked passes it through and parseHTML restores it.
      const parts = [`src="${escapeAttr(src)}"`];
      if (alt) parts.push(`alt="${escapeAttr(node.getAttribute('alt') || '')}"`);
      parts.push(`data-caption="${escapeAttr(caption)}"`);
      return `<div><img ${parts.join(' ')}></div>`;
    }
    // else: existing ![alt](src "title")
    
  6. packages/mcp/src/lib/markdown-converter.ts (image-кейс): симметрично video — при caption отдавать <div><img src alt data-caption></div> через существующий escapeAttr; иначе ![alt](src). Проверить обратный путь (md→json): если он на общем markdownToHtml+htmlToJson, data-caption восстановится сам.

  7. i18n apps/client/public/locales/en-US/translation.json: ключи "Caption", "Add a caption", подсказка панели (напр. "Shown below the image."). Остальные локали — через Crowdin.

  8. Тесты:

    • image.spec.ts: round-trip атрибута (parseHTML(<img data-caption="x">)attrs.caption === "x"; renderHTML с/без caption).
    • Markdown round-trip (по образцу footnote-markdown.test.ts): htmlToMarkdown → содержит data-caption; markdownToHtml обратно → HTML содержит data-caption; пустая подпись → ![alt](src) без сырого HTML.
    • MCP-конвертер: с caption<div><img ... data-caption></div>; без — ![alt](src).

Edge cases

  • Пустая подписьcaption: undefinedfigcaption скрыт, Markdown остаётся чистым ![alt](src).
  • Спецсимволы/кавычки/<>& → экранируются escapeAttr (turndown/MCP) и автоматически в generateHTML.
  • Выравнивание и float → подпись внутри figure, выравнивается/float'ится вместе с картинкой.
  • Resize → подпись вне wrapper, не искажает сохранённые width/height/aspectRatio.
  • Read-only / share → resize-путь рисует подпись без редактирования.
  • Плейсхолдер загрузки (нет src) → подпись рисует React-путь.
  • Поиск (jsonToText) → атрибуты в текст не попадают; включение подписи в поисковый текст — опциональное улучшение, вне скоупа.

Риски

  • Главный — интеграция figcaption в императивный ResizableNodeView (перенос wrapper в figure). Снижается тем, что vendored-класс не меняем, подпись держим вне wrapper, покрываем ручной проверкой resize/выравнивания/float + тестами round-trip.
  • Markdown: для картинок без подписи поведение не меняется (остаётся ![]()) — минимальный риск регресса экспорта.
## Summary Добавить **подписи к изображениям** (`<figcaption>`): видимый текст под картинкой, редактируемый из бабл-меню картинки, сохраняемый во всех форматах (нативное хранилище Yjs/JSON, HTML-экспорт и Markdown). Сейчас у `image`-узла есть только `alt` (alt-текст для доступности) и выравнивание — видимой подписи нет. ## Принятые решения - **Модель данных:** подпись — строковый атрибут `caption` на существующем `image`-узле. Узел остаётся `atom`, форма схемы не меняется. Редактирование — панель в бабл-меню по образцу alt-текста (`useAltTextControl`). Подпись — простой текст без форматирования. - **Markdown round-trip — lossless:** подпись сохраняется и при экспорте, и при импорте через сырой `<img data-caption="...">`, завёрнутый в блочный `<div>` (тот же приём, что уже используется для `<video>`). Картинки без подписи остаются чистым `![alt](src)`. ## Архитектура ### HTML↔JSON — «бесплатно» Сервер (`apps/server/src/collaboration/collaboration.util.ts`) гоняет документ через `generateHTML`/`generateJSON` с тем же расширением `TiptapImage`. Поэтому атрибут `caption` с `parseHTML`/`renderHTML` (читает/пишет `data-caption` на `<img>`) автоматически: - попадает в HTML-экспорт и индексирование (`jsonToHtml`); - восстанавливается при импорте HTML (`htmlToJson`); - экранируется сериализатором ProseMirror (без XSS). **Серверный код менять не нужно.** ### Два пути node-view (ключевая тонкость) `addNodeView` в `packages/editor-ext/src/lib/image/image.ts` имеет два пути: - **React-view** (`image-view.tsx`) — пока нет `src` (загрузка/плейсхолдер); - **`ResizableNodeView`** (императивный DOM) — когда `src` есть. **Он же используется в read-only/share** (хендлеры не навешиваются при `editable=false`), значит подпись на готовых картинках рисует именно он. Подпись нужно рендерить в **обоих**. **Главное ограничение resize-пути:** `figcaption` должен лежать **вне** `nodeView.wrapper` (где `<img>` + хендлеры), потому что: - `onCommit` пишет `width/height` из `el.offsetWidth/offsetHeight` — подпись внутри измеряемого блока исказит сохранённую высоту/`aspectRatio`; - хендлеры resize (`directions: ["left","right"]`) растягиваются на всю высоту `wrapper` — должны покрывать только картинку. Решение: после создания `nodeView` перенести `nodeView.wrapper` внутрь нового `<figure>` (он остаётся единственным flex-ребёнком `container`, поэтому `applyAlignment` и float-режимы продолжают работать), а `figcaption` положить в `figure` **под** `wrapper`. Vendored-класс `ResizableNodeView` не трогаем (`wrapper`/`dom` — публичные свойства). ## План реализации (декомпозиция) 1. **`packages/editor-ext/src/lib/image/image.ts`** - `ImageAttributes`: добавить `caption?: string`. - `addAttributes()`: атрибут `caption` (зеркало `alt`): ```ts caption: { default: undefined, parseHTML: (element) => element.getAttribute("data-caption") || undefined, renderHTML: (attributes) => // Emit data-caption only when set, so caption-less images stay clean. attributes.caption ? { "data-caption": attributes.caption } : {}, }, ``` - команда `setImageCaption` (+ запись в `declare module`): ```ts setImageCaption: (caption) => ({ commands }) => commands.updateAttributes("image", { caption }), ``` - resize-путь: после `new ResizableNodeView(...)` перенести `wrapper` в `<figure>` и добавить `<figcaption>`: ```ts // Re-parent the resizable wrapper into a <figure> so the caption sits BELOW // the image, OUTSIDE nodeView.wrapper. onCommit measures the img's // offsetHeight for the persisted height/aspectRatio, and the left/right // resize handles span the wrapper — both must cover the image only. const container = nodeView.dom as HTMLElement; const figure = document.createElement("figure"); figure.style.margin = "0"; figure.style.display = "inline-block"; // shrink-to-fit to image width figure.appendChild(nodeView.wrapper); container.appendChild(figure); const figcaption = document.createElement("figcaption"); figcaption.className = "image-caption"; const applyCaption = (text) => { const value = (text || "").trim(); figcaption.textContent = value; figcaption.style.display = value ? "block" : "none"; }; applyCaption(node.attrs.caption); figure.appendChild(figcaption); ``` и в существующем `onUpdate`: ```ts if (updatedNode.attrs.caption !== currentNode.attrs.caption) { applyCaption(updatedNode.attrs.caption); } ``` 2. **`apps/client/src/features/editor/components/image/image-view.tsx`** (React-путь): достать `caption` из `node.attrs`, обернуть в `<figure>`, добавить `<Text component="figcaption">` при наличии подписи (Mantine экранирует содержимое). 3. **Новый хук `apps/client/src/features/editor/components/common/use-caption-control.tsx`** — копия `use-alt-text-control.tsx`: своя иконка, строки `t("Caption")`, вызов `updateAttributes(nodeName, { caption: sanitizeCaption(draft) || undefined })`. `sanitizeCaption` мягче `sanitizeAlt` (схлопывает переводы строк/пробелы, `trim`, лимит ~500). Интеграция в **`image-menu.tsx`**: `caption` в `useEditorState`, подключение хука рядом с alt, кнопка рядом с `altTextButton`, показ `captionPanel` в условии `isEditing`. 4. **CSS `.image-caption`** (глобально + модульно для React): `text-align:center; font-size:0.875em; color:var(--mantine-color-dimmed); margin-top:0.4em; line-height:1.35; word-break:break-word;`. 5. **`packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts`** (image-правило): при наличии `data-caption` отдавать lossless-вариант: ```ts const caption = node.getAttribute('data-caption') || ''; if (caption) { // ![]() can't carry a caption, so emit a raw <img> wrapped in a block <div> // (like the video rule). marked passes it through and parseHTML restores it. const parts = [`src="${escapeAttr(src)}"`]; if (alt) parts.push(`alt="${escapeAttr(node.getAttribute('alt') || '')}"`); parts.push(`data-caption="${escapeAttr(caption)}"`); return `<div><img ${parts.join(' ')}></div>`; } // else: existing ![alt](src "title") ``` 6. **`packages/mcp/src/lib/markdown-converter.ts`** (image-кейс): симметрично video — при `caption` отдавать `<div><img src alt data-caption></div>` через существующий `escapeAttr`; иначе `![alt](src)`. Проверить обратный путь (md→json): если он на общем `markdownToHtml`+`htmlToJson`, `data-caption` восстановится сам. 7. **i18n `apps/client/public/locales/en-US/translation.json`**: ключи `"Caption"`, `"Add a caption"`, подсказка панели (напр. `"Shown below the image."`). Остальные локали — через Crowdin. 8. **Тесты:** - `image.spec.ts`: round-trip атрибута (`parseHTML(<img data-caption="x">)` → `attrs.caption === "x"`; `renderHTML` с/без `caption`). - Markdown round-trip (по образцу `footnote-markdown.test.ts`): `htmlToMarkdown` → содержит `data-caption`; `markdownToHtml` обратно → HTML содержит `data-caption`; пустая подпись → `![alt](src)` без сырого HTML. - MCP-конвертер: с `caption` → `<div><img ... data-caption></div>`; без — `![alt](src)`. ## Edge cases - **Пустая подпись** → `caption: undefined` → `figcaption` скрыт, Markdown остаётся чистым `![alt](src)`. - **Спецсимволы/кавычки/`<>&`** → экранируются `escapeAttr` (turndown/MCP) и автоматически в `generateHTML`. - **Выравнивание и float** → подпись внутри `figure`, выравнивается/float'ится вместе с картинкой. - **Resize** → подпись вне `wrapper`, не искажает сохранённые `width/height/aspectRatio`. - **Read-only / share** → resize-путь рисует подпись без редактирования. - **Плейсхолдер загрузки (нет src)** → подпись рисует React-путь. - **Поиск (`jsonToText`)** → атрибуты в текст не попадают; включение подписи в поисковый текст — опциональное улучшение, вне скоупа. ## Риски - **Главный** — интеграция `figcaption` в императивный `ResizableNodeView` (перенос `wrapper` в `figure`). Снижается тем, что vendored-класс не меняем, подпись держим вне `wrapper`, покрываем ручной проверкой resize/выравнивания/float + тестами round-trip. - **Markdown:** для картинок без подписи поведение не меняется (остаётся `![]()`) — минимальный риск регресса экспорта.
vvzvlad added the feature label 2026-06-26 21:01:24 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#221