Compare commits
11 Commits
feature/of
...
feat/221-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57308bc3f3 | ||
|
|
1ddb386214 | ||
|
|
43af3dd5f1 | ||
|
|
b02101b58a | ||
|
|
932bfce1d9 | ||
|
|
d39b7ae67c | ||
|
|
c124fb1f2c | ||
|
|
d3ebae48cf | ||
|
|
607aed5997 | ||
|
|
dc14a9a540 | ||
|
|
2aa482f62d |
@@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Editable captions for images.** Images gain an optional caption shown
|
||||
below them, edited inline from the image bubble menu and stored as a `caption` attribute. Captions round-trip
|
||||
losslessly through markdown as a `data-caption` attribute on the image, so
|
||||
they survive export/import unchanged. (#221)
|
||||
|
||||
- **Quick-create regular and temporary notes from the Home and Space screens.**
|
||||
The Home screen now shows a second action next to "New note" that creates a
|
||||
*temporary* note (one that auto-moves to Trash after the workspace lifetime),
|
||||
|
||||
@@ -286,6 +286,9 @@
|
||||
"Alt text": "Alt text",
|
||||
"Describe this for accessibility.": "Describe this for accessibility.",
|
||||
"Add a description": "Add a description",
|
||||
"Caption": "Caption",
|
||||
"Add a caption": "Add a caption",
|
||||
"Shown below the image.": "Shown below the image.",
|
||||
"Justify": "Justify",
|
||||
"Merge cells": "Merge cells",
|
||||
"Split cell": "Split cell",
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconAlt } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useImageTextFieldControl } from "@/features/editor/components/common/use-image-text-field-control.tsx";
|
||||
|
||||
const ALT_MAX_LENGTH = 300;
|
||||
|
||||
@@ -27,113 +18,25 @@ type UseAltTextControlArgs = {
|
||||
currentAlt: string;
|
||||
};
|
||||
|
||||
// Thin wrapper over the shared image text-field popover; see
|
||||
// useImageTextFieldControl. The t("...") literals stay here so they remain
|
||||
// statically extractable for i18n.
|
||||
export function useAltTextControl({
|
||||
editor,
|
||||
nodeName,
|
||||
currentAlt,
|
||||
}: UseAltTextControlArgs) {
|
||||
const { t } = useTranslation();
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const open = useCallback(() => {
|
||||
setDraft(currentAlt || "");
|
||||
setShowInput(true);
|
||||
}, [currentAlt]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (!editor.isActive(nodeName)) {
|
||||
setShowInput(false);
|
||||
}
|
||||
};
|
||||
editor.on("selectionUpdate", handler);
|
||||
return () => {
|
||||
editor.off("selectionUpdate", handler);
|
||||
};
|
||||
}, [editor, nodeName]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setShowInput(false);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.updateAttributes(nodeName, { alt: sanitizeAlt(draft) || undefined })
|
||||
.run();
|
||||
setShowInput(false);
|
||||
}, [editor, nodeName, draft]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
},
|
||||
[save, cancel],
|
||||
);
|
||||
|
||||
const button = (
|
||||
<Tooltip position="top" label={t("Alt text")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={open}
|
||||
size="lg"
|
||||
aria-label={t("Alt text")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconAlt size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const panel = showInput ? (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius={6}
|
||||
p="sm"
|
||||
w={320}
|
||||
style={{ position: "relative", zIndex: 100 }}
|
||||
>
|
||||
<Text size="sm" fw={600} mb={2}>
|
||||
{t("Alt text")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t("Describe this for accessibility.")}
|
||||
</Text>
|
||||
<Textarea
|
||||
size="xs"
|
||||
placeholder={t("Add a description")}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoFocus
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={5}
|
||||
maxLength={ALT_MAX_LENGTH}
|
||||
/>
|
||||
<Group justify="space-between" align="center" mt="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{draft.length}/{ALT_MAX_LENGTH}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button size="compact-xs" variant="default" onClick={cancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="compact-xs" onClick={save}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null;
|
||||
|
||||
return { button, panel, isEditing: showInput };
|
||||
return useImageTextFieldControl({
|
||||
editor,
|
||||
nodeName,
|
||||
currentValue: currentAlt,
|
||||
attrName: "alt",
|
||||
sanitize: sanitizeAlt,
|
||||
maxLength: ALT_MAX_LENGTH,
|
||||
icon: <IconAlt size={18} />,
|
||||
label: t("Alt text"),
|
||||
description: t("Describe this for accessibility."),
|
||||
placeholder: t("Add a description"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { sanitizeCaption } from "@/features/editor/components/common/use-caption-control.tsx";
|
||||
|
||||
/**
|
||||
* `sanitizeCaption` = collapse every whitespace run to a single space + trim +
|
||||
* cap at 500 chars. Captions are plain visible text, so this is a softer
|
||||
* normalization than alt-text sanitization.
|
||||
*/
|
||||
describe("sanitizeCaption", () => {
|
||||
it("trims leading and trailing whitespace", () => {
|
||||
expect(sanitizeCaption(" hello ")).toBe("hello");
|
||||
});
|
||||
|
||||
it("collapses internal whitespace runs to a single space", () => {
|
||||
expect(sanitizeCaption("a b c")).toBe("a b c");
|
||||
});
|
||||
|
||||
it("treats tab, newline and CRLF as whitespace", () => {
|
||||
expect(sanitizeCaption("a\tb")).toBe("a b");
|
||||
expect(sanitizeCaption("a\nb")).toBe("a b");
|
||||
expect(sanitizeCaption("a\r\nb")).toBe("a b");
|
||||
expect(sanitizeCaption("line1\n\n\nline2")).toBe("line1 line2");
|
||||
});
|
||||
|
||||
it("treats unicode whitespace (no-break space) as a separator", () => {
|
||||
// U+00A0 NO-BREAK SPACE is matched by the \s class.
|
||||
expect(sanitizeCaption("a b")).toBe("a b");
|
||||
});
|
||||
|
||||
it("returns empty string for whitespace-only input", () => {
|
||||
expect(sanitizeCaption(" ")).toBe("");
|
||||
expect(sanitizeCaption("")).toBe("");
|
||||
});
|
||||
|
||||
it("keeps a caption at the 500-char limit unchanged", () => {
|
||||
const exact = "x".repeat(500);
|
||||
expect(sanitizeCaption(exact)).toHaveLength(500);
|
||||
expect(sanitizeCaption(exact)).toBe(exact);
|
||||
});
|
||||
|
||||
it("slices a caption longer than 500 chars down to 500", () => {
|
||||
const tooLong = "y".repeat(600);
|
||||
const result = sanitizeCaption(tooLong);
|
||||
expect(result).toHaveLength(500);
|
||||
expect(result).toBe("y".repeat(500));
|
||||
});
|
||||
|
||||
it("collapses whitespace before applying the 500-char cap", () => {
|
||||
// 120 "a b " groups (600 raw chars) collapse to "a b a b ..." = 479 chars
|
||||
// after trimming the trailing space, which stays under the 500 cap — so only
|
||||
// the collapse is exercised here, no slice. (See the dedicated >500 test
|
||||
// above for the slice boundary.)
|
||||
const input = "a b ".repeat(120); // lots of double spaces
|
||||
const result = sanitizeCaption(input);
|
||||
expect(result).toHaveLength(479);
|
||||
expect(result.length).toBeLessThanOrEqual(500);
|
||||
expect(result).not.toMatch(/\s{2,}/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { IconTextCaption } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useImageTextFieldControl } from "@/features/editor/components/common/use-image-text-field-control.tsx";
|
||||
|
||||
const CAPTION_MAX_LENGTH = 500;
|
||||
|
||||
// Caption is plain visible text (not a markdown link target like alt), so it is
|
||||
// sanitized more softly than alt: collapse runs of whitespace/newlines into a
|
||||
// single space and trim, keeping the limit generous.
|
||||
export function sanitizeCaption(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim().slice(0, CAPTION_MAX_LENGTH);
|
||||
}
|
||||
|
||||
type UseCaptionControlArgs = {
|
||||
editor: Editor;
|
||||
nodeName: string;
|
||||
currentCaption: string;
|
||||
};
|
||||
|
||||
// Thin wrapper over the shared image text-field popover; see
|
||||
// useImageTextFieldControl. The t("...") literals stay here so they remain
|
||||
// statically extractable for i18n.
|
||||
export function useCaptionControl({
|
||||
editor,
|
||||
nodeName,
|
||||
currentCaption,
|
||||
}: UseCaptionControlArgs) {
|
||||
const { t } = useTranslation();
|
||||
return useImageTextFieldControl({
|
||||
editor,
|
||||
nodeName,
|
||||
currentValue: currentCaption,
|
||||
attrName: "caption",
|
||||
sanitize: sanitizeCaption,
|
||||
maxLength: CAPTION_MAX_LENGTH,
|
||||
icon: <IconTextCaption size={18} />,
|
||||
label: t("Caption"),
|
||||
description: t("Shown below the image."),
|
||||
placeholder: t("Add a caption"),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Shared logic+UI for the image bubble-menu text-field popovers (alt text,
|
||||
// caption, ...). Each field is the same popover — an ActionIcon that opens a
|
||||
// titled Paper with a counted Textarea and Cancel/Save — differing only in the
|
||||
// node attribute it writes, its sanitizer, length cap, icon and labels. The
|
||||
// label/description/placeholder are passed already translated so the literal
|
||||
// t("...") calls stay in the thin wrappers and remain extractable; the shared
|
||||
// Cancel/Save strings are translated here.
|
||||
type UseImageTextFieldControlArgs = {
|
||||
editor: Editor;
|
||||
nodeName: string;
|
||||
currentValue: string;
|
||||
attrName: string;
|
||||
sanitize: (value: string) => string;
|
||||
maxLength: number;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
description: string;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
export function useImageTextFieldControl({
|
||||
editor,
|
||||
nodeName,
|
||||
currentValue,
|
||||
attrName,
|
||||
sanitize,
|
||||
maxLength,
|
||||
icon,
|
||||
label,
|
||||
description,
|
||||
placeholder,
|
||||
}: UseImageTextFieldControlArgs) {
|
||||
const { t } = useTranslation();
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const open = useCallback(() => {
|
||||
setDraft(currentValue || "");
|
||||
setShowInput(true);
|
||||
}, [currentValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (!editor.isActive(nodeName)) {
|
||||
setShowInput(false);
|
||||
}
|
||||
};
|
||||
editor.on("selectionUpdate", handler);
|
||||
return () => {
|
||||
editor.off("selectionUpdate", handler);
|
||||
};
|
||||
}, [editor, nodeName]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setShowInput(false);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.updateAttributes(nodeName, { [attrName]: sanitize(draft) || undefined })
|
||||
.run();
|
||||
setShowInput(false);
|
||||
}, [editor, nodeName, attrName, sanitize, draft]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
},
|
||||
[save, cancel],
|
||||
);
|
||||
|
||||
const button = (
|
||||
<Tooltip position="top" label={label} withinPortal={false}>
|
||||
<ActionIcon onClick={open} size="lg" aria-label={label} variant="subtle">
|
||||
{icon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const panel = showInput ? (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius={6}
|
||||
p="sm"
|
||||
w={320}
|
||||
style={{ position: "relative", zIndex: 100 }}
|
||||
>
|
||||
<Text size="sm" fw={600} mb={2}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{description}
|
||||
</Text>
|
||||
<Textarea
|
||||
size="xs"
|
||||
placeholder={placeholder}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoFocus
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={5}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
<Group justify="space-between" align="center" mt="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{draft.length}/{maxLength}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button size="compact-xs" variant="default" onClick={cancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="compact-xs" onClick={save}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null;
|
||||
|
||||
return { button, panel, isEditing: showInput };
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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="image-caption"
|
||||
>
|
||||
{captionText}
|
||||
</Text>
|
||||
)}
|
||||
</figure>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.image-caption {
|
||||
text-align: center;
|
||||
font-size: 0.875em;
|
||||
color: var(--mantine-color-dimmed);
|
||||
margin-top: 0.4em;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.uploading-text {
|
||||
font-size: var(--mantine-font-size-md);
|
||||
line-height: var(--mantine-line-height-md);
|
||||
|
||||
46
packages/editor-ext/src/lib/image/image-markdown.test.ts
Normal file
46
packages/editor-ext/src/lib/image/image-markdown.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { htmlToMarkdown } from "../markdown/utils/turndown.utils";
|
||||
import { markdownToHtml } from "../markdown/utils/marked.utils";
|
||||
|
||||
// Lossless markdown round-trip for image captions (issue #221). An image WITH a
|
||||
// caption can't be expressed as ``, so it is emitted as a raw <img>
|
||||
// (carrying data-caption) wrapped in a block <div>, the same trick the <video>
|
||||
// rule uses. marked passes the raw HTML through, so markdownToHtml keeps the
|
||||
// data-caption, and the image extension's parseHTML restores the attribute.
|
||||
describe("image caption markdown round-trip", () => {
|
||||
it("HTML -> Markdown emits a raw <img data-caption> for captioned images", () => {
|
||||
const html = `<p><img src="/files/a.png" alt="cat" data-caption="A grey cat"></p>`;
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).toContain("data-caption=\"A grey cat\"");
|
||||
expect(md).toContain('src="/files/a.png"');
|
||||
expect(md).toContain('alt="cat"');
|
||||
// It must NOT degrade to the lossy ![]() form.
|
||||
expect(md).not.toContain("![cat]");
|
||||
});
|
||||
|
||||
it("Markdown -> HTML restores data-caption on the <img>", async () => {
|
||||
const html = `<p><img src="/files/a.png" alt="cat" data-caption="A grey cat"></p>`;
|
||||
const md = htmlToMarkdown(html);
|
||||
const back = await markdownToHtml(md);
|
||||
expect(back).toContain('data-caption="A grey cat"');
|
||||
expect(back).toContain('src="/files/a.png"');
|
||||
});
|
||||
|
||||
it("special characters in the caption survive the round-trip (escaped)", async () => {
|
||||
const html = `<p><img src="/files/a.png" data-caption='Tom & "Jerry"'></p>`;
|
||||
const md = htmlToMarkdown(html);
|
||||
const back = await markdownToHtml(md);
|
||||
// parse5 keeps the entity-encoded form inside the attribute value.
|
||||
expect(back).toContain("data-caption=");
|
||||
expect(back).toContain("Jerry");
|
||||
expect(back).toContain("Tom");
|
||||
});
|
||||
|
||||
it("caption-less images stay a clean  with no raw HTML", () => {
|
||||
const html = `<p><img src="/files/a.png" alt="cat"></p>`;
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).toContain("");
|
||||
expect(md).not.toContain("data-caption");
|
||||
expect(md).not.toContain("<img");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,16 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { applyAlignment } from "./image";
|
||||
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
|
||||
@@ -65,3 +76,56 @@ describe("applyAlignment", () => {
|
||||
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 & "quotes"">`;
|
||||
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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface ImageOptions extends DefaultImageOptions {
|
||||
export interface ImageAttributes {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
align?: string;
|
||||
attachmentId?: string;
|
||||
size?: number;
|
||||
@@ -125,6 +126,13 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
||||
alt: attributes.alt,
|
||||
}),
|
||||
},
|
||||
caption: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-caption") || undefined,
|
||||
// Emit data-caption only when set, so caption-less images stay clean.
|
||||
renderHTML: (attributes: ImageAttributes) =>
|
||||
attributes.caption ? { "data-caption": attributes.caption } : {},
|
||||
},
|
||||
attachmentId: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-id"),
|
||||
@@ -304,6 +312,10 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
||||
el.alt = updatedNode.attrs.alt || "";
|
||||
}
|
||||
|
||||
if (updatedNode.attrs.caption !== currentNode.attrs.caption) {
|
||||
applyCaption(updatedNode.attrs.caption);
|
||||
}
|
||||
|
||||
const w = updatedNode.attrs.width;
|
||||
const h = updatedNode.attrs.height;
|
||||
if (w != null) {
|
||||
@@ -335,6 +347,28 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
||||
|
||||
const dom = nodeView.dom as HTMLElement;
|
||||
|
||||
// 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. The
|
||||
// <figure> stays the single flex child of the container, so applyAlignment
|
||||
// and the float modes keep working. This path also drives read-only/share.
|
||||
const figure = document.createElement("figure");
|
||||
figure.style.margin = "0";
|
||||
figure.style.display = "inline-block"; // shrink-to-fit to image width
|
||||
figure.appendChild(nodeView.wrapper);
|
||||
dom.appendChild(figure);
|
||||
|
||||
const figcaption = document.createElement("figcaption");
|
||||
figcaption.className = "image-caption";
|
||||
const applyCaption = (text?: string) => {
|
||||
const value = (text || "").trim();
|
||||
figcaption.textContent = value;
|
||||
figcaption.style.display = value ? "block" : "none";
|
||||
};
|
||||
applyCaption(node.attrs.caption);
|
||||
figure.appendChild(figcaption);
|
||||
|
||||
// Apply initial alignment
|
||||
applyAlignment(dom, node.attrs.align || "center");
|
||||
|
||||
|
||||
@@ -12,6 +12,14 @@ function sanitizeMdLinkText(value: string): string {
|
||||
.replace(/[\r\n]+/g, ' ');
|
||||
}
|
||||
|
||||
// Escape a value placed inside a double-quoted HTML attribute (img src/alt/
|
||||
// data-caption in the raw-HTML image fallback). Only & and " are special in
|
||||
// that context; escaping them is idempotent because parse5/marked decode them
|
||||
// back on re-import.
|
||||
function escapeHtmlAttr(value: string): string {
|
||||
return value.replace(/&/g, '&').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Tags turndown treats as void (self-closing). Footnote references render as an
|
||||
// empty <sup data-footnote-ref> whose meaning lives entirely in its data-id;
|
||||
// without marking it void, turndown's blank-node removal drops it before our
|
||||
@@ -258,6 +266,17 @@ function image(turndownService: _TurndownService) {
|
||||
replacement: function (_content: string, node: HTMLInputElement) {
|
||||
const src = node.getAttribute('src') || '';
|
||||
if (!src) return '';
|
||||
const caption = node.getAttribute('data-caption') || '';
|
||||
if (caption) {
|
||||
// ![]() can't carry a caption, so emit a raw <img> wrapped in a block
|
||||
// <div>. marked passes it through and the image extension's parseHTML
|
||||
// restores the caption from data-caption.
|
||||
const parts = [`src="${escapeHtmlAttr(src)}"`];
|
||||
const alt = node.getAttribute('alt') || '';
|
||||
if (alt) parts.push(`alt="${escapeHtmlAttr(alt)}"`);
|
||||
parts.push(`data-caption="${escapeHtmlAttr(caption)}"`);
|
||||
return `<div><img ${parts.join(' ')}></div>`;
|
||||
}
|
||||
const alt = sanitizeMdLinkText(node.getAttribute('alt') || '');
|
||||
const title = node.getAttribute('title') || '';
|
||||
const titlePart = title ? ' "' + title.replace(/"/g, '\\"') + '"' : '';
|
||||
|
||||
@@ -1070,7 +1070,24 @@ export const docmostExtensions = [
|
||||
heading: {},
|
||||
link: { openOnClick: false },
|
||||
}),
|
||||
Image.configure({ inline: false }),
|
||||
// Stock @tiptap/extension-image has no caption attribute, so a round-trip
|
||||
// through this schema would drop the data-caption the client TiptapImage
|
||||
// emits. Mirror editor-ext image.ts: add a caption attribute that parses
|
||||
// data-caption and re-renders it only when set (caption-less images stay
|
||||
// clean), keeping the MCP markdown round-trip lossless.
|
||||
Image.extend({
|
||||
addAttributes() {
|
||||
const parent = this.parent?.() ?? {};
|
||||
return {
|
||||
...parent,
|
||||
caption: {
|
||||
default: undefined,
|
||||
parseHTML: (el) => el.getAttribute("data-caption") || undefined,
|
||||
renderHTML: (attrs) => attrs.caption ? { "data-caption": attrs.caption } : {},
|
||||
},
|
||||
};
|
||||
},
|
||||
}).configure({ inline: false }),
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
// Highlight stores its color unescaped and Docmost interpolates it into
|
||||
|
||||
@@ -207,16 +207,27 @@ export function convertProseMirrorToMarkdown(content) {
|
||||
// Two trailing spaces before the newline encode a markdown hard break;
|
||||
// a bare "\n" would be reimported as a soft break and lost.
|
||||
return " \n";
|
||||
case "image":
|
||||
case "image": {
|
||||
const imgAlt = node.attrs?.alt || "";
|
||||
const imgCaption = node.attrs?.caption || "";
|
||||
if (imgCaption) {
|
||||
// ![]() can't carry a caption, so (symmetric to video) emit a raw
|
||||
// <img> wrapped in a block <div>. On import marked.parse keeps the raw
|
||||
// HTML and generateJSON runs the image extension's parseHTML, which
|
||||
// restores the caption from data-caption.
|
||||
const parts = [`src="${escapeAttr(node.attrs?.src ?? "")}"`];
|
||||
if (imgAlt)
|
||||
parts.push(`alt="${escapeAttr(imgAlt)}"`);
|
||||
parts.push(`data-caption="${escapeAttr(imgCaption)}"`);
|
||||
return `<div><img ${parts.join(" ")}></div>`;
|
||||
}
|
||||
// Neutralize characters that could break out of the markdown image
|
||||
// URL: spaces/newlines and parentheses would terminate the (...) target
|
||||
// and let a stored src inject following markdown/HTML. Percent-encode
|
||||
// them so the URL stays a single inert token.
|
||||
const imgSrc = encodeMdUrl(node.attrs?.src);
|
||||
// No "caption" attribute exists in the Docmost image schema, so we do
|
||||
// not emit one (the previous caption branch was dead).
|
||||
return ``;
|
||||
}
|
||||
case "video": {
|
||||
// Emit the schema-matching <video> element so generateJSON rebuilds the
|
||||
// node with its attrs intact. The schema's parseHTML reads src/aria-label
|
||||
@@ -618,6 +629,8 @@ export function convertProseMirrorToMarkdown(content) {
|
||||
const parts = [`src="${escapeAttr(attrs.src ?? "")}"`];
|
||||
if (attrs.alt)
|
||||
parts.push(`alt="${escapeAttr(attrs.alt)}"`);
|
||||
if (attrs.caption)
|
||||
parts.push(`data-caption="${escapeAttr(attrs.caption)}"`);
|
||||
if (attrs.title)
|
||||
parts.push(`title="${escapeAttr(attrs.title)}"`);
|
||||
if (attrs.width != null)
|
||||
|
||||
@@ -1164,7 +1164,26 @@ export const docmostExtensions = [
|
||||
heading: {},
|
||||
link: { openOnClick: false },
|
||||
}),
|
||||
Image.configure({ inline: false }),
|
||||
// Stock @tiptap/extension-image has no caption attribute, so a round-trip
|
||||
// through this schema would drop the data-caption the client TiptapImage
|
||||
// emits. Mirror editor-ext image.ts: add a caption attribute that parses
|
||||
// data-caption and re-renders it only when set (caption-less images stay
|
||||
// clean), keeping the MCP markdown round-trip lossless.
|
||||
Image.extend({
|
||||
addAttributes() {
|
||||
const parent = this.parent?.() ?? {};
|
||||
return {
|
||||
...parent,
|
||||
caption: {
|
||||
default: undefined,
|
||||
parseHTML: (el: HTMLElement) =>
|
||||
el.getAttribute("data-caption") || undefined,
|
||||
renderHTML: (attrs: Record<string, any>) =>
|
||||
attrs.caption ? { "data-caption": attrs.caption } : {},
|
||||
},
|
||||
};
|
||||
},
|
||||
}).configure({ inline: false }),
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
// Highlight stores its color unescaped and Docmost interpolates it into
|
||||
|
||||
@@ -228,16 +228,26 @@ export function convertProseMirrorToMarkdown(content: any): string {
|
||||
// a bare "\n" would be reimported as a soft break and lost.
|
||||
return " \n";
|
||||
|
||||
case "image":
|
||||
case "image": {
|
||||
const imgAlt = node.attrs?.alt || "";
|
||||
const imgCaption = node.attrs?.caption || "";
|
||||
if (imgCaption) {
|
||||
// ![]() can't carry a caption, so (symmetric to video) emit a raw
|
||||
// <img> wrapped in a block <div>. On import marked.parse keeps the raw
|
||||
// HTML and generateJSON runs the image extension's parseHTML, which
|
||||
// restores the caption from data-caption.
|
||||
const parts: string[] = [`src="${escapeAttr(node.attrs?.src ?? "")}"`];
|
||||
if (imgAlt) parts.push(`alt="${escapeAttr(imgAlt)}"`);
|
||||
parts.push(`data-caption="${escapeAttr(imgCaption)}"`);
|
||||
return `<div><img ${parts.join(" ")}></div>`;
|
||||
}
|
||||
// Neutralize characters that could break out of the markdown image
|
||||
// URL: spaces/newlines and parentheses would terminate the (...) target
|
||||
// and let a stored src inject following markdown/HTML. Percent-encode
|
||||
// them so the URL stays a single inert token.
|
||||
const imgSrc = encodeMdUrl(node.attrs?.src);
|
||||
// No "caption" attribute exists in the Docmost image schema, so we do
|
||||
// not emit one (the previous caption branch was dead).
|
||||
return ``;
|
||||
}
|
||||
|
||||
case "video": {
|
||||
// Emit the schema-matching <video> element so generateJSON rebuilds the
|
||||
@@ -678,6 +688,8 @@ export function convertProseMirrorToMarkdown(content: any): string {
|
||||
const attrs = node.attrs || {};
|
||||
const parts: string[] = [`src="${escapeAttr(attrs.src ?? "")}"`];
|
||||
if (attrs.alt) parts.push(`alt="${escapeAttr(attrs.alt)}"`);
|
||||
if (attrs.caption)
|
||||
parts.push(`data-caption="${escapeAttr(attrs.caption)}"`);
|
||||
if (attrs.title) parts.push(`title="${escapeAttr(attrs.title)}"`);
|
||||
if (attrs.width != null) parts.push(`width="${escapeAttr(attrs.width)}"`);
|
||||
if (attrs.height != null) parts.push(`height="${escapeAttr(attrs.height)}"`);
|
||||
|
||||
@@ -149,3 +149,37 @@ test("empty task item still emits its marker", () => {
|
||||
|
||||
assert.equal(convertProseMirrorToMarkdown(input), "- [ ]\n- [x]");
|
||||
});
|
||||
|
||||
// Image captions (issue #221). An image WITHOUT a caption stays the lossy-free
|
||||
// ``; WITH a caption it is emitted as a raw <img data-caption>
|
||||
// wrapped in a block <div> (symmetric to video) so the round-trip md -> html ->
|
||||
// json restores the caption via the image extension's parseHTML.
|
||||
test("image without a caption emits plain ", () => {
|
||||
const input = doc({
|
||||
type: "image",
|
||||
attrs: { src: "/files/a.png", alt: "cat" },
|
||||
});
|
||||
assert.equal(convertProseMirrorToMarkdown(input), "");
|
||||
});
|
||||
|
||||
test("image with a caption emits a raw <img data-caption> in a block div", () => {
|
||||
const input = doc({
|
||||
type: "image",
|
||||
attrs: { src: "/files/a.png", alt: "cat", caption: "A grey cat" },
|
||||
});
|
||||
assert.equal(
|
||||
convertProseMirrorToMarkdown(input),
|
||||
'<div><img src="/files/a.png" alt="cat" data-caption="A grey cat"></div>',
|
||||
);
|
||||
});
|
||||
|
||||
test("image caption escapes & and \" in the data-caption attribute", () => {
|
||||
const input = doc({
|
||||
type: "image",
|
||||
attrs: { src: "/files/a.png", caption: 'Tom & "Jerry"' },
|
||||
});
|
||||
assert.equal(
|
||||
convertProseMirrorToMarkdown(input),
|
||||
'<div><img src="/files/a.png" data-caption="Tom & "Jerry""></div>',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -142,3 +142,31 @@ test("round-trip: pdf node survives markdown export with src + name + attachment
|
||||
assert.equal(found[0].attrs?.name, "x.pdf");
|
||||
assert.equal(found[0].attrs?.attachmentId, "a4");
|
||||
});
|
||||
|
||||
// The converter emits captioned images as a raw <img data-caption="...">; for
|
||||
// the caption to survive the PM -> markdown -> PM round-trip the docmost-schema
|
||||
// Image node must parse data-caption back into the `caption` attr. Without that
|
||||
// (stock @tiptap/extension-image), the caption is silently lost — these guard
|
||||
// the "lossless" claim.
|
||||
test("round-trip: image caption survives markdown export (data-caption restored)", async () => {
|
||||
const found = await roundtrip(
|
||||
{ type: "image", attrs: { src: "/api/files/cat.png", alt: "cat", caption: "A grey cat" } },
|
||||
"image",
|
||||
);
|
||||
assert.equal(found.length, 1, "image node should survive");
|
||||
assert.equal(found[0].attrs?.src, "/api/files/cat.png");
|
||||
assert.equal(found[0].attrs?.caption, "A grey cat", "caption must round-trip");
|
||||
});
|
||||
|
||||
test("round-trip: image caption with special chars survives markdown export", async () => {
|
||||
const found = await roundtrip(
|
||||
{ type: "image", attrs: { src: "/api/files/cat.png", caption: 'Tom & "Jerry"' } },
|
||||
"image",
|
||||
);
|
||||
assert.equal(found.length, 1, "image node should survive");
|
||||
assert.equal(
|
||||
found[0].attrs?.caption,
|
||||
'Tom & "Jerry"',
|
||||
"special-char caption must round-trip unescaped",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -82,6 +82,24 @@ test("round-trip: image inside a column survives as an image node (not literal m
|
||||
assert.ok(!JSON.stringify(out).includes("![pic]"), "image must not become literal markdown text");
|
||||
});
|
||||
|
||||
test("round-trip: captioned image inside a column preserves its caption (imageToHtml branch)", async () => {
|
||||
// A captioned image in a column is emitted via the imageToHtml helper (raw
|
||||
// HTML container), a different path from the top-level image case. Special
|
||||
// chars in the caption exercise attribute escaping on the way out and in.
|
||||
const caption = 'Tom & "Jerry"';
|
||||
const input = doc({
|
||||
type: "columns",
|
||||
content: [
|
||||
{ type: "column", content: [{ type: "image", attrs: { src: "/api/files/a/p.png", alt: "pic", caption } }] },
|
||||
{ type: "column", content: [para(text("right"))] },
|
||||
],
|
||||
});
|
||||
const out = await roundtrip(input);
|
||||
const imgs = findNodes(out, "image");
|
||||
assert.equal(imgs.length, 1, "captioned image inside a column must survive");
|
||||
assert.equal(imgs[0].attrs?.caption, caption, "caption (incl. special chars) must be preserved");
|
||||
});
|
||||
|
||||
test("round-trip: blockquote inside a column survives as a blockquote node", async () => {
|
||||
const input = doc({
|
||||
type: "columns",
|
||||
|
||||
Reference in New Issue
Block a user