Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-textalign-portable-text.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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,
};
}
Expand All @@ -184,6 +188,7 @@ function convertTextBlock(block: PortableTextTextBlock): ProseMirrorNode | null
default:
return {
type: "paragraph",
attrs: alignAttr,
content: content.length > 0 ? content : undefined,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -110,6 +125,7 @@ function convertParagraph(node: ProseMirrorNode): PortableTextTextBlock | null {
_type: "block",
_key: generateKey(),
style: "normal",
textAlign: extractTextAlign(node),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] convertParagraph (here) and convertHeading (line 170) now forward textAlign, but the two other paths that build a text block from an inner paragraph node still drop it. convertBlockquote (line 266) and convertListItem (line 213) push their Portable Text blocks without calling extractTextAlign, so alignment set on a paragraph inside a blockquote or list item is silently lost on save.

This is reachable in the editor: the align toolbar is rendered unconditionally (packages/admin/src/components/PortableTextEditor.tsx:3040) and TextAlign.configure({ types: ["heading", "paragraph"] }) (PortableTextEditor.tsx:2237) applies the attr to paragraph nodes — including the paragraphs nested inside blockquotes and list items. A user can center-align a blockquote paragraph, save, and have it snap back to left on reload: the exact bug this PR fixes, one node path over.

The reverse direction has the matching gap, so even a hand-edited aligned block won't round-trip: convertTextBlock's blockquote case ignores alignAttr (portable-text-to-prosemirror.ts:176), and convertListItem (portable-text-to-prosemirror.ts:239) doesn't forward item.textAlign onto its inner paragraph.

Mirroring convertParagraph in convertBlockquote closes the save side:

blocks.push({
    _type: "block",
    _key: generateKey(),
    style: "blockquote",
    textAlign: extractTextAlign(child),
    children,
    markDefs: markDefs.length > 0 ? markDefs : undefined,
});

The same textAlign: extractTextAlign(child) in convertListItem (line 213), plus applying alignAttr in the two reverse-direction spots, would make alignment round-trip consistently across all paragraph-bearing nodes.

children,
markDefs: markDefs.length > 0 ? markDefs : undefined,
};
Expand Down Expand Up @@ -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,
};
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/content/converters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
Expand Down
75 changes: 75 additions & 0 deletions packages/core/tests/unit/converters/text-align-round-trip.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading