feat(editor): add editable image captions (#221)

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>
This commit is contained in:
claude code agent 227
2026-06-27 06:25:51 +03:00
committed by a
parent 106df7c907
commit 2aa482f62d
13 changed files with 427 additions and 8 deletions
@@ -23,6 +23,7 @@ import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import { useCaptionControl } from "@/features/editor/components/common/use-caption-control.tsx";
import classes from "../common/toolbar-menu.module.css";
export function ImageMenu({ editor }: EditorMenuProps) {
@@ -47,6 +48,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }),
src: imageAttrs?.src || null,
alt: imageAttrs?.alt || "",
caption: imageAttrs?.caption || "",
};
},
});
@@ -168,6 +170,16 @@ export function ImageMenu({ editor }: EditorMenuProps) {
currentAlt: editorState?.alt || "",
});
const {
button: captionButton,
panel: captionPanel,
isEditing: isEditingCaption,
} = useCaptionControl({
editor,
nodeName: "image",
currentCaption: editorState?.caption || "",
});
return (
<BaseBubbleMenu
editor={editor}
@@ -183,6 +195,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
>
{isEditingAlt ? (
altTextPanel
) : isEditingCaption ? (
captionPanel
) : (
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
@@ -249,6 +263,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
{altTextButton}
{captionButton}
<div className={classes.divider} />
<Tooltip position="top" label={t("Download")} withinPortal={false}>
@@ -7,6 +7,16 @@
overflow: hidden;
}
.imageCaption {
display: block;
text-align: center;
font-size: 0.875em;
color: var(--mantine-color-dimmed);
margin-top: 0.4em;
line-height: 1.35;
word-break: break-word;
}
.skeleton {
animation: pulse 1.2s ease-in-out infinite;
@@ -9,7 +9,9 @@ import { useTranslation } from "react-i18next";
export default function ImageView(props: NodeViewProps) {
const { t } = useTranslation();
const { editor, node, selected } = props;
const { src, width, align, alt, aspectRatio, placeholder } = node.attrs;
const { src, width, align, alt, caption, aspectRatio, placeholder } =
node.attrs;
const captionText = (caption || "").trim();
const alignClass = useMemo(() => {
if (align === "left") return "alignLeft";
if (align === "right") return "alignRight";
@@ -29,6 +31,7 @@ export default function ImageView(props: NodeViewProps) {
return (
<NodeViewWrapper data-drag-handle>
<figure style={{ margin: 0 }}>
<div
className={clsx(
selected && "ProseMirror-selectednode",
@@ -66,6 +69,15 @@ export default function ImageView(props: NodeViewProps) {
</Group>
)}
</div>
{captionText && (
<Text
component="figcaption"
className={clsx(classes.imageCaption, "image-caption")}
>
{captionText}
</Text>
)}
</figure>
</NodeViewWrapper>
);
}