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
6 changes: 6 additions & 0 deletions .changeset/image-alignment-round-trip.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions packages/admin/src/components/PortableTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ interface PortableTextImageBlock {
height?: number;
displayWidth?: number;
displayHeight?: number;
alignment?: "left" | "center" | "right" | "wide" | "full";
}

interface PortableTextCodeBlock {
Expand Down Expand Up @@ -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"],
};
}

Expand Down Expand Up @@ -654,6 +656,7 @@ function convertPTBlock(block: PortableTextBlock): unknown {
height: imageBlock.height,
displayWidth: imageBlock.displayWidth,
displayHeight: imageBlock.displayHeight,
alignment: imageBlock.alignment,
},
};
}
Expand Down
4 changes: 4 additions & 0 deletions packages/admin/src/components/editor/ImageNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
},

Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
28 changes: 26 additions & 2 deletions packages/core/src/components/Image.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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";
};
}

Expand All @@ -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;
Expand Down Expand Up @@ -168,7 +170,7 @@ const baseStyle = aspectRatio
const imgStyle = placeholderStyle ? `${baseStyle} ${placeholderStyle}` : baseStyle;
---

<figure class="emdash-image">
<figure class:list={["emdash-image", alignment && `emdash-image--align-${alignment}`]}>
<img
src={src}
srcset={srcset}
Expand Down Expand Up @@ -197,4 +199,26 @@ const imgStyle = placeholderStyle ? `${baseStyle} ${placeholderStyle}` : baseSty
margin-top: 0.5rem;
text-align: center;
}
/* Alignment captured from imported content (e.g. WordPress/Gutenberg). */
.emdash-image--align-left {
float: inline-start;
max-width: 50%;
margin-inline: 0 1.5rem;
}
.emdash-image--align-right {
float: inline-end;
max-width: 50%;
margin-inline: 1.5rem 0;
}
.emdash-image--align-center {
margin-inline: auto;
width: fit-content;
}
.emdash-image--align-wide {
width: min(100%, 1200px);
margin-inline: auto;
}
.emdash-image--align-full {
width: 100%;
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ function convertImage(block: PortableTextImageBlock): ProseMirrorNode {
height: block.height,
displayWidth: block.displayWidth,
displayHeight: block.displayHeight,
alignment: block.alignment,
},
};
}
Expand All @@ -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: {
Expand All @@ -446,6 +449,7 @@ function convertMalformedImage(block: PortableTextBlock): ProseMirrorNode {
height,
displayWidth,
displayHeight,
alignment,
},
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -316,6 +329,7 @@ function convertImage(node: ProseMirrorNode): PortableTextImageBlock {
height: height || undefined,
displayWidth: displayWidth || undefined,
displayHeight: displayHeight || undefined,
alignment: extractImageAlignment(node),
};
}

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 @@ -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";
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading