diff --git a/CHANGELOG.md b/CHANGELOG.md index 832615d6..2a59dd72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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), diff --git a/apps/client/src/features/editor/components/common/use-caption-control.test.ts b/apps/client/src/features/editor/components/common/use-caption-control.test.ts new file mode 100644 index 00000000..3ce576ac --- /dev/null +++ b/apps/client/src/features/editor/components/common/use-caption-control.test.ts @@ -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,}/); + }); +}); diff --git a/apps/client/src/features/editor/components/common/use-caption-control.tsx b/apps/client/src/features/editor/components/common/use-caption-control.tsx index 96944b55..bde44700 100644 --- a/apps/client/src/features/editor/components/common/use-caption-control.tsx +++ b/apps/client/src/features/editor/components/common/use-caption-control.tsx @@ -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); } diff --git a/apps/client/src/features/editor/components/image/image-view.module.css b/apps/client/src/features/editor/components/image/image-view.module.css index e6745602..987ec0d7 100644 --- a/apps/client/src/features/editor/components/image/image-view.module.css +++ b/apps/client/src/features/editor/components/image/image-view.module.css @@ -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; diff --git a/apps/client/src/features/editor/components/image/image-view.tsx b/apps/client/src/features/editor/components/image/image-view.tsx index 42248fee..02643be8 100644 --- a/apps/client/src/features/editor/components/image/image-view.tsx +++ b/apps/client/src/features/editor/components/image/image-view.tsx @@ -72,7 +72,7 @@ export default function ImageView(props: NodeViewProps) { {captionText && ( {captionText} diff --git a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts index 7dc3dc23..2551a689 100644 --- a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts @@ -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 wrapped in a block - //
(like the video rule). marked passes it through and the image - // extension's parseHTML restores the caption from data-caption. + //
. 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)}"`);