diff --git a/.changeset/image-alignment-round-trip.md b/.changeset/image-alignment-round-trip.md new file mode 100644 index 000000000..9258dfa83 --- /dev/null +++ b/.changeset/image-alignment-round-trip.md @@ -0,0 +1,6 @@ +--- +"emdash": patch +"@emdash-cms/admin": patch +--- + +Preserve image alignment on posts imported from WordPress/Gutenberg (#1404). Imported images carry a horizontal alignment (`left`, `right`, `center`, `wide`, `full`), but opening an imported post in the editor and re-saving used to flatten every aligned image back to the default. Alignment now survives editing, and aligned images display as authored on the published page. diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index 418d8f79b..a1d8c8a33 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -142,6 +142,7 @@ interface PortableTextImageBlock { height?: number; displayWidth?: number; displayHeight?: number; + alignment?: "left" | "center" | "right" | "wide" | "full"; } interface PortableTextCodeBlock { @@ -312,6 +313,7 @@ function convertPMNode(node: { height: attrNum(attrs.height), displayWidth: attrNum(attrs.displayWidth), displayHeight: attrNum(attrs.displayHeight), + alignment: attrStr(attrs.alignment) as PortableTextImageBlock["alignment"], }; } @@ -654,6 +656,7 @@ function convertPTBlock(block: PortableTextBlock): unknown { height: imageBlock.height, displayWidth: imageBlock.displayWidth, displayHeight: imageBlock.displayHeight, + alignment: imageBlock.alignment, }, }; } diff --git a/packages/admin/src/components/editor/ImageNode.tsx b/packages/admin/src/components/editor/ImageNode.tsx index e90d7fac7..f70a34f0e 100644 --- a/packages/admin/src/components/editor/ImageNode.tsx +++ b/packages/admin/src/components/editor/ImageNode.tsx @@ -325,6 +325,10 @@ export const ImageExtension = Node.create({ displayHeight: { default: null, }, + /** Horizontal alignment (e.g. preserved from a WordPress/Gutenberg import). */ + alignment: { + default: null, + }, }; }, diff --git a/packages/admin/tests/components/PortableTextEditor.imageAlignment.test.ts b/packages/admin/tests/components/PortableTextEditor.imageAlignment.test.ts new file mode 100644 index 000000000..2243f6bd4 --- /dev/null +++ b/packages/admin/tests/components/PortableTextEditor.imageAlignment.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; + +import { + _portableTextToProsemirror, + _prosemirrorToPortableText, +} from "../../src/components/PortableTextEditor"; + +type ImageBlock = { + _type: "image"; + asset: { _ref: string; url?: string }; + alignment?: string; +}; + +function isImageBlock(b: unknown): b is ImageBlock { + return typeof b === "object" && b !== null && (b as { _type?: unknown })._type === "image"; +} + +describe("PortableTextEditor image alignment round-trip", () => { + it("preserves alignment PT → PM → PT (regression for #1404)", () => { + const block = { + _type: "image" as const, + _key: "img1", + asset: { _ref: "media1", url: "/file/media1" }, + alt: "Imported image", + alignment: "wide" as const, + }; + + const pm = _portableTextToProsemirror([block]); + const imageNode = pm.content.find((n) => n.type === "image"); + expect(imageNode?.attrs?.alignment).toBe("wide"); + + const pt = _prosemirrorToPortableText(pm).find(isImageBlock); + expect(pt?.alignment).toBe("wide"); + }); + + it("omits alignment when unset", () => { + const block = { + _type: "image" as const, + _key: "img2", + asset: { _ref: "media2", url: "/file/media2" }, + alt: "Plain image", + }; + const pt = _prosemirrorToPortableText(_portableTextToProsemirror([block])).find(isImageBlock); + expect(pt?.alignment).toBeUndefined(); + }); +}); diff --git a/packages/core/src/components/Image.astro b/packages/core/src/components/Image.astro index 9f2e452f1..e7d96a337 100644 --- a/packages/core/src/components/Image.astro +++ b/packages/core/src/components/Image.astro @@ -38,6 +38,8 @@ export interface Props { displayWidth?: number; /** Display height for this instance (overrides original) */ displayHeight?: number; + /** Horizontal alignment (e.g. captured from a WordPress/Gutenberg import). */ + alignment?: "left" | "center" | "right" | "wide" | "full"; }; } @@ -63,7 +65,7 @@ if (!node?.asset) { return null; } -const { asset, alt = "", caption, width, height, displayWidth, displayHeight } = node; +const { asset, alt = "", caption, width, height, displayWidth, displayHeight, alignment } = node; // Calculate aspect ratio from original dimensions const aspectRatio = width && height ? width / height : undefined; @@ -168,7 +170,7 @@ const baseStyle = aspectRatio const imgStyle = placeholderStyle ? `${baseStyle} ${placeholderStyle}` : baseStyle; --- -
+
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..25299083b 100644 --- a/packages/core/src/content/converters/portable-text-to-prosemirror.ts +++ b/packages/core/src/content/converters/portable-text-to-prosemirror.ts @@ -410,6 +410,7 @@ function convertImage(block: PortableTextImageBlock): ProseMirrorNode { height: block.height, displayWidth: block.displayWidth, displayHeight: block.displayHeight, + alignment: block.alignment, }, }; } @@ -434,6 +435,8 @@ function convertMalformedImage(block: PortableTextBlock): ProseMirrorNode { "displayHeight" in block && typeof block.displayHeight === "number" ? block.displayHeight : undefined; + const alignment = + "alignment" in block && typeof block.alignment === "string" ? block.alignment : undefined; return { type: "image", attrs: { @@ -446,6 +449,7 @@ function convertMalformedImage(block: PortableTextBlock): ProseMirrorNode { height, displayWidth, displayHeight, + alignment, }, }; } 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..930369ce7 100644 --- a/packages/core/src/content/converters/prosemirror-to-portable-text.ts +++ b/packages/core/src/content/converters/prosemirror-to-portable-text.ts @@ -287,6 +287,19 @@ function convertHtmlBlock(node: ProseMirrorNode): PortableTextHtmlBlock { /** * Convert image to Portable Text */ +const VALID_IMAGE_ALIGNMENTS = ["left", "center", "right", "wide", "full"] as const; +type ImageAlignment = (typeof VALID_IMAGE_ALIGNMENTS)[number]; + +function isImageAlignment(value: unknown): value is ImageAlignment { + return typeof value === "string" && (VALID_IMAGE_ALIGNMENTS as readonly string[]).includes(value); +} + +/** Read a valid image alignment off a ProseMirror image node, if present. */ +function extractImageAlignment(node: ProseMirrorNode): ImageAlignment | undefined { + const value = node.attrs?.alignment; + return isImageAlignment(value) ? value : undefined; +} + function convertImage(node: ProseMirrorNode): PortableTextImageBlock { const attrs = node.attrs; const provider = typeof attrs?.provider === "string" ? attrs.provider : undefined; @@ -316,6 +329,7 @@ function convertImage(node: ProseMirrorNode): PortableTextImageBlock { height: height || undefined, displayWidth: displayWidth || undefined, displayHeight: displayHeight || undefined, + alignment: extractImageAlignment(node), }; } diff --git a/packages/core/src/content/converters/types.ts b/packages/core/src/content/converters/types.ts index 9ff32abdf..bfa564a46 100644 --- a/packages/core/src/content/converters/types.ts +++ b/packages/core/src/content/converters/types.ts @@ -67,6 +67,8 @@ export interface PortableTextImageBlock { displayWidth?: number; /** Display height for this instance (overrides original) */ displayHeight?: number; + /** Horizontal alignment (e.g. captured from a WordPress/Gutenberg import). */ + alignment?: "left" | "center" | "right" | "wide" | "full"; } /** diff --git a/packages/core/tests/unit/converters/image-alignment-round-trip.test.ts b/packages/core/tests/unit/converters/image-alignment-round-trip.test.ts new file mode 100644 index 000000000..d4eab5c5d --- /dev/null +++ b/packages/core/tests/unit/converters/image-alignment-round-trip.test.ts @@ -0,0 +1,59 @@ +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, + PortableTextImageBlock, +} from "../../../src/content/converters/types.js"; + +describe("Image alignment round-trip (core converters)", () => { + it("preserves image alignment through PT → PM → PT", () => { + const imageBlock: PortableTextImageBlock = { + _type: "image", + _key: "img001", + asset: { _ref: "media123", url: "/file/media123" }, + alt: "An aligned image", + alignment: "wide", + }; + + // PT → PM + const pm = portableTextToProsemirror([imageBlock]); + expect(pm.content[0].type).toBe("image"); + expect(pm.content[0].attrs?.alignment).toBe("wide"); + + // PM → PT + const pt = prosemirrorToPortableText(pm); + const restored = pt[0] as PortableTextImageBlock; + expect(restored._type).toBe("image"); + expect(restored.alignment).toBe("wide"); + }); + + it("preserves each supported alignment value", () => { + for (const alignment of ["left", "center", "right", "wide", "full"] as const) { + const doc: ProseMirrorDocument = { + type: "doc", + content: [{ type: "image", attrs: { src: "/x.jpg", alt: "x", alignment } }], + }; + const pt = prosemirrorToPortableText(doc); + expect((pt[0] as PortableTextImageBlock).alignment).toBe(alignment); + } + }); + + it("omits alignment when unset", () => { + const doc: ProseMirrorDocument = { + type: "doc", + content: [{ type: "image", attrs: { src: "/x.jpg", alt: "x" } }], + }; + const pt = prosemirrorToPortableText(doc); + expect((pt[0] as PortableTextImageBlock).alignment).toBeUndefined(); + + const block: PortableTextImageBlock = { + _type: "image", + _key: "img002", + asset: { _ref: "m", url: "/file/m" }, + }; + const pm = portableTextToProsemirror([block]); + expect(pm.content[0].attrs?.alignment).toBeUndefined(); + }); +});