Feature: подписи к изображениям (image captions) #221
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Добавить подписи к изображениям (
<figcaption>): видимый текст под картинкой, редактируемый из бабл-меню картинки, сохраняемый во всех форматах (нативное хранилище Yjs/JSON, HTML-экспорт и Markdown).Сейчас у
image-узла есть толькоalt(alt-текст для доступности) и выравнивание — видимой подписи нет.Принятые решения
captionна существующемimage-узле. Узел остаётсяatom, форма схемы не меняется. Редактирование — панель в бабл-меню по образцу alt-текста (useAltTextControl). Подпись — простой текст без форматирования.<img data-caption="...">, завёрнутый в блочный<div>(тот же приём, что уже используется для<video>). Картинки без подписи остаются чистым.Архитектура
HTML↔JSON — «бесплатно»
Сервер (
apps/server/src/collaboration/collaboration.util.ts) гоняет документ черезgenerateHTML/generateJSONс тем же расширениемTiptapImage. Поэтому атрибутcaptionсparseHTML/renderHTML(читает/пишетdata-captionна<img>) автоматически:jsonToHtml);htmlToJson);Серверный код менять не нужно.
Два пути node-view (ключевая тонкость)
addNodeViewвpackages/editor-ext/src/lib/image/image.tsимеет два пути:image-view.tsx) — пока нетsrc(загрузка/плейсхолдер);ResizableNodeView(императивный DOM) — когдаsrcесть. Он же используется в read-only/share (хендлеры не навешиваются приeditable=false), значит подпись на готовых картинках рисует именно он.Подпись нужно рендерить в обоих.
Главное ограничение resize-пути:
figcaptionдолжен лежать внеnodeView.wrapper(где<img>+ хендлеры), потому что:onCommitпишетwidth/heightизel.offsetWidth/offsetHeight— подпись внутри измеряемого блока исказит сохранённую высоту/aspectRatio;directions: ["left","right"]) растягиваются на всю высотуwrapper— должны покрывать только картинку.Решение: после создания
nodeViewперенестиnodeView.wrapperвнутрь нового<figure>(он остаётся единственным flex-ребёнкомcontainer, поэтомуapplyAlignmentи float-режимы продолжают работать), аfigcaptionположить вfigureподwrapper. Vendored-классResizableNodeViewне трогаем (wrapper/dom— публичные свойства).План реализации (декомпозиция)
packages/editor-ext/src/lib/image/image.tsImageAttributes: добавитьcaption?: string.addAttributes(): атрибутcaption(зеркалоalt):setImageCaption(+ запись вdeclare module):new ResizableNodeView(...)перенестиwrapperв<figure>и добавить<figcaption>:onUpdate:apps/client/src/features/editor/components/image/image-view.tsx(React-путь): достатьcaptionизnode.attrs, обернуть в<figure>, добавить<Text component="figcaption">при наличии подписи (Mantine экранирует содержимое).Новый хук
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.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;.packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts(image-правило): при наличииdata-captionотдавать lossless-вариант:packages/mcp/src/lib/markdown-converter.ts(image-кейс): симметрично video — приcaptionотдавать<div><img src alt data-caption></div>через существующийescapeAttr; иначе. Проверить обратный путь (md→json): если он на общемmarkdownToHtml+htmlToJson,data-captionвосстановится сам.i18n
apps/client/public/locales/en-US/translation.json: ключи"Caption","Add a caption", подсказка панели (напр."Shown below the image."). Остальные локали — через Crowdin.Тесты:
image.spec.ts: round-trip атрибута (parseHTML(<img data-caption="x">)→attrs.caption === "x";renderHTMLс/безcaption).footnote-markdown.test.ts):htmlToMarkdown→ содержитdata-caption;markdownToHtmlобратно → HTML содержитdata-caption; пустая подпись →без сырого HTML.caption→<div><img ... data-caption></div>; без —.Edge cases
caption: undefined→figcaptionскрыт, Markdown остаётся чистым.<>&→ экранируютсяescapeAttr(turndown/MCP) и автоматически вgenerateHTML.figure, выравнивается/float'ится вместе с картинкой.wrapper, не искажает сохранённыеwidth/height/aspectRatio.jsonToText) → атрибуты в текст не попадают; включение подписи в поисковый текст — опциональное улучшение, вне скоупа.Риски
figcaptionв императивныйResizableNodeView(переносwrapperвfigure). Снижается тем, что vendored-класс не меняем, подпись держим внеwrapper, покрываем ручной проверкой resize/выравнивания/float + тестами round-trip.![]()) — минимальный риск регресса экспорта.Ghost referenced this issue2026-06-28 04:37:21 +03:00