Files
gitmost/packages/editor-ext/src/lib/image/image.spec.ts
T
claude code agent 227 2aa482f62d 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>
2026-06-28 04:33:00 +03:00

132 lines
5.0 KiB
TypeScript

import { describe, it, expect, beforeEach } from "vitest";
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { Document } from "@tiptap/extension-document";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import { applyAlignment, TiptapImage } from "./image";
// CONTRACT tests for the image node's `caption` attribute (issue #221). The
// caption is a plain-text string stored on the image atom and serialized as
// `data-caption` on the <img>. If this mapping drifts, captions saved to HTML
// (and thus to native storage / search / markdown) are silently lost.
const extensions = [Document, Paragraph, Text, TiptapImage];
// applyAlignment is a pure DOM mutation: it sets the float / padding /
// justify-content / data-image-align on an image node-view container per the
// resolved `align`. Tested directly (issue #145 review) since the five-way
// branch, the reset-then-apply guard, and the data-image-align mirror (which the
// responsive @media rule keys off) are otherwise uncovered.
describe("applyAlignment", () => {
let el: HTMLElement;
beforeEach(() => {
el = document.createElement("div");
});
it("floatLeft -> float:left + right padding, mirrored on data-image-align", () => {
applyAlignment(el, "floatLeft");
expect(el.style.cssFloat).toBe("left");
expect(el.style.padding).toBe("0px 10px 0px 0px");
expect(el.dataset.imageAlign).toBe("floatLeft");
expect(el.style.justifyContent).toBe("flex-start");
});
it("floatRight -> float:right + left padding", () => {
applyAlignment(el, "floatRight");
expect(el.style.cssFloat).toBe("right");
expect(el.style.padding).toBe("0px 0px 0px 10px");
expect(el.dataset.imageAlign).toBe("floatRight");
expect(el.style.justifyContent).toBe("flex-end");
});
it("left -> justify flex-start, no float", () => {
applyAlignment(el, "left");
expect(el.style.justifyContent).toBe("flex-start");
expect(el.style.cssFloat).toBe("");
expect(el.style.padding).toBe("");
expect(el.dataset.imageAlign).toBe("left");
});
it("right -> justify flex-end, no float", () => {
applyAlignment(el, "right");
expect(el.style.justifyContent).toBe("flex-end");
expect(el.style.cssFloat).toBe("");
expect(el.dataset.imageAlign).toBe("right");
});
it("center (default) -> justify center, no float", () => {
applyAlignment(el, "center");
expect(el.style.justifyContent).toBe("center");
expect(el.style.cssFloat).toBe("");
expect(el.style.padding).toBe("");
expect(el.dataset.imageAlign).toBe("center");
});
it("clears a previous float when switching floatLeft -> left (reset-then-apply)", () => {
applyAlignment(el, "floatLeft");
expect(el.style.cssFloat).toBe("left");
expect(el.style.padding).toBe("0px 10px 0px 0px");
// Switching to a block alignment must drop the float and its padding, not
// leak them (the bug the reset guard prevents).
applyAlignment(el, "left");
expect(el.style.cssFloat).toBe("");
expect(el.style.padding).toBe("");
expect(el.dataset.imageAlign).toBe("left");
expect(el.style.justifyContent).toBe("flex-start");
});
});
describe("image schema", () => {
it("registers the image node and keeps it an atom", () => {
const schema = getSchema(extensions);
expect(schema.nodes.image).toBeTruthy();
expect(schema.nodes.image.spec.atom).toBe(true);
});
});
describe("image caption parse/render round-trip", () => {
it("recovers caption from data-caption on parse (HTML -> JSON)", () => {
const html = `<img src="/files/a.png" alt="cat" data-caption="A grey cat">`;
const json = generateJSON(html, extensions);
const node = json.content?.[0];
expect(node?.type).toBe("image");
expect(node?.attrs?.caption).toBe("A grey cat");
expect(node?.attrs?.alt).toBe("cat");
});
it("emits data-caption on render when set (JSON -> HTML)", () => {
const json = {
type: "doc",
content: [
{
type: "image",
attrs: { src: "/files/a.png", alt: "cat", caption: "A grey cat" },
},
],
};
const html = generateHTML(json, extensions);
expect(html).toContain('data-caption="A grey cat"');
});
it("omits data-caption when there is no caption (caption-less images stay clean)", () => {
const json = {
type: "doc",
content: [{ type: "image", attrs: { src: "/files/a.png", alt: "cat" } }],
};
const html = generateHTML(json, extensions);
expect(html).not.toContain("data-caption");
});
it("full HTML -> JSON -> HTML round-trip preserves the caption", () => {
const html = `<img src="/files/a.png" alt="cat" data-caption="Caption with &amp; &quot;quotes&quot;">`;
const json = generateJSON(html, extensions);
expect(json.content?.[0]?.attrs?.caption).toBe('Caption with & "quotes"');
const out = generateHTML(json, extensions);
const back = generateJSON(out, extensions);
expect(back.content?.[0]?.attrs?.caption).toBe('Caption with & "quotes"');
});
});