diff --git a/.changeset/fix-textalign-portable-text.md b/.changeset/fix-textalign-portable-text.md new file mode 100644 index 000000000..ce99c9b52 --- /dev/null +++ b/.changeset/fix-textalign-portable-text.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes text alignment being silently dropped when saving rich-text content. Paragraph and heading alignment set with the editor's align toolbar now persists through Portable Text storage and round-trips back into the editor. diff --git a/packages/core/src/content/converters/portable-text-to-prosemirror.ts b/packages/core/src/content/converters/portable-text-to-prosemirror.ts index b8ad91e2f..62cfc9cfc 100644 --- a/packages/core/src/content/converters/portable-text-to-prosemirror.ts +++ b/packages/core/src/content/converters/portable-text-to-prosemirror.ts @@ -148,11 +148,15 @@ function convertBlock(block: PortableTextBlock): ProseMirrorNode | null { * Convert text block to ProseMirror paragraph or heading */ function convertTextBlock(block: PortableTextTextBlock): ProseMirrorNode | null { - const { style = "normal", children, markDefs = [] } = block; + const { style = "normal", textAlign, children, markDefs = [] } = block; // Convert children to ProseMirror nodes const content = convertSpans(children, markDefs); + // The TextAlign extension is configured for paragraph and heading only; + // only forward an explicit, non-default alignment. + const alignAttr = textAlign && textAlign !== "left" ? { textAlign } : undefined; + // Determine node type based on style switch (style) { case "h1": @@ -164,7 +168,7 @@ function convertTextBlock(block: PortableTextTextBlock): ProseMirrorNode | null const level = parseInt(style.substring(1), 10); return { type: "heading", - attrs: { level }, + attrs: { level, ...alignAttr }, content: content.length > 0 ? content : undefined, }; } @@ -184,6 +188,7 @@ function convertTextBlock(block: PortableTextTextBlock): ProseMirrorNode | null default: return { type: "paragraph", + attrs: alignAttr, content: content.length > 0 ? content : undefined, }; } diff --git a/packages/core/src/content/converters/prosemirror-to-portable-text.ts b/packages/core/src/content/converters/prosemirror-to-portable-text.ts index 918cb3d12..8952e97c1 100644 --- a/packages/core/src/content/converters/prosemirror-to-portable-text.ts +++ b/packages/core/src/content/converters/prosemirror-to-portable-text.ts @@ -95,6 +95,21 @@ function convertNode(node: ProseMirrorNode): PortableTextBlock | PortableTextBlo } } +const VALID_TEXT_ALIGNMENTS = new Set(["center", "right", "justify"]); + +/** + * Read a meaningful (non-default) text alignment off a ProseMirror node. + * "left"/unset is the default and is intentionally omitted to keep existing + * content byte-for-byte unchanged. + */ +function extractTextAlign(node: ProseMirrorNode): PortableTextTextBlock["textAlign"] | undefined { + const value = node.attrs?.textAlign; + if (typeof value === "string" && VALID_TEXT_ALIGNMENTS.has(value)) { + return value as PortableTextTextBlock["textAlign"]; + } + return undefined; +} + /** * Convert paragraph to Portable Text block */ @@ -110,6 +125,7 @@ function convertParagraph(node: ProseMirrorNode): PortableTextTextBlock | null { _type: "block", _key: generateKey(), style: "normal", + textAlign: extractTextAlign(node), children, markDefs: markDefs.length > 0 ? markDefs : undefined, }; @@ -151,6 +167,7 @@ function convertHeading(node: ProseMirrorNode): PortableTextTextBlock | null { _type: "block", _key: generateKey(), style, + textAlign: extractTextAlign(node), children, markDefs: markDefs.length > 0 ? markDefs : undefined, }; diff --git a/packages/core/src/content/converters/types.ts b/packages/core/src/content/converters/types.ts index 9ff32abdf..7193362a9 100644 --- a/packages/core/src/content/converters/types.ts +++ b/packages/core/src/content/converters/types.ts @@ -41,6 +41,8 @@ export interface PortableTextTextBlock { style?: "normal" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote"; listItem?: "bullet" | "number"; level?: number; + /** Horizontal text alignment set via the editor toolbar (omitted for the default left alignment). */ + textAlign?: "left" | "center" | "right" | "justify"; children: PortableTextSpan[]; markDefs?: PortableTextMarkDef[]; } diff --git a/packages/core/tests/unit/converters/text-align-round-trip.test.ts b/packages/core/tests/unit/converters/text-align-round-trip.test.ts new file mode 100644 index 000000000..bec1b886f --- /dev/null +++ b/packages/core/tests/unit/converters/text-align-round-trip.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; + +import { portableTextToProsemirror } from "../../../src/content/converters/portable-text-to-prosemirror.js"; +import { prosemirrorToPortableText } from "../../../src/content/converters/prosemirror-to-portable-text.js"; +import type { + ProseMirrorDocument, + PortableTextTextBlock, +} from "../../../src/content/converters/types.js"; + +describe("Text alignment round-trip (core converters)", () => { + it("preserves paragraph textAlign through PM → PT → PM", () => { + const doc: ProseMirrorDocument = { + type: "doc", + content: [ + { + type: "paragraph", + attrs: { textAlign: "center" }, + content: [{ type: "text", text: "Centered" }], + }, + ], + }; + + // PM → PT + const pt = prosemirrorToPortableText(doc); + const block = pt[0] as PortableTextTextBlock; + expect(block._type).toBe("block"); + expect(block.textAlign).toBe("center"); + + // PT → PM + const pm = portableTextToProsemirror(pt); + expect(pm.content[0].type).toBe("paragraph"); + expect(pm.content[0].attrs?.textAlign).toBe("center"); + }); + + it("preserves heading textAlign through PM → PT → PM", () => { + const doc: ProseMirrorDocument = { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 2, textAlign: "right" }, + content: [{ type: "text", text: "Right heading" }], + }, + ], + }; + + const pt = prosemirrorToPortableText(doc); + const block = pt[0] as PortableTextTextBlock; + expect(block.style).toBe("h2"); + expect(block.textAlign).toBe("right"); + + const pm = portableTextToProsemirror(pt); + expect(pm.content[0].type).toBe("heading"); + expect(pm.content[0].attrs?.level).toBe(2); + expect(pm.content[0].attrs?.textAlign).toBe("right"); + }); + + it("does not add textAlign for default-aligned (left / unset) content", () => { + const doc: ProseMirrorDocument = { + type: "doc", + content: [ + { type: "paragraph", attrs: { textAlign: "left" }, content: [{ type: "text", text: "A" }] }, + { type: "paragraph", content: [{ type: "text", text: "B" }] }, + ], + }; + + const pt = prosemirrorToPortableText(doc); + expect((pt[0] as PortableTextTextBlock).textAlign).toBeUndefined(); + expect((pt[1] as PortableTextTextBlock).textAlign).toBeUndefined(); + + // PT without textAlign must not emit the attr back into PM + const pm = portableTextToProsemirror(pt); + expect(pm.content[0].attrs?.textAlign).toBeUndefined(); + }); +});