chore(editor): address image-caption review (#221)

- docs: add CHANGELOG Unreleased/Added entry for editable image captions
- test: export sanitizeCaption and add vitest unit coverage
  (whitespace collapse, trim, 500-char boundary)
- refactor: drop duplicate .imageCaption CSS module class, keep the
  global .image-caption as the single source
- docs: fix turndown image-caption comment (video rule emits a markdown
  link, not a <div>)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
a
2026-06-28 04:36:30 +03:00
parent 2aa482f62d
commit dc14a9a540
6 changed files with 66 additions and 14 deletions

View File

@@ -12,6 +12,12 @@ 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 via a new
`setImageCaption` command and 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),

View File

@@ -0,0 +1,56 @@
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", () => {
// 250 "a b" groups => "a b a b ..." which after collapse is 499 chars,
// adding a trailing pair pushes past 500 and gets sliced.
const input = "a b ".repeat(120); // lots of double spaces
const result = sanitizeCaption(input);
expect(result.length).toBeLessThanOrEqual(500);
expect(result).not.toMatch(/\s{2,}/);
});
});

View File

@@ -17,7 +17,7 @@ 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.
function sanitizeCaption(value: string): string {
export function sanitizeCaption(value: string): string {
return value.replace(/\s+/g, " ").trim().slice(0, CAPTION_MAX_LENGTH);
}

View File

@@ -7,16 +7,6 @@
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;

View File

@@ -72,7 +72,7 @@ export default function ImageView(props: NodeViewProps) {
{captionText && (
<Text
component="figcaption"
className={clsx(classes.imageCaption, "image-caption")}
className="image-caption"
>
{captionText}
</Text>

View File

@@ -269,8 +269,8 @@ function image(turndownService: _TurndownService) {
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 the image
// extension's parseHTML restores the caption from data-caption.
// <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)}"`);