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 && (
wrapped in a block
- //