Skip to content
7 changes: 7 additions & 0 deletions .changeset/honor-image-alignment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"emdash": patch
---

Honor image alignment from WordPress imports at render and in the editor, and surface display-size controls for migrated images.

The Gutenberg to Portable Text importer already captured image `alignment`, but it was dropped by the renderer and not editable. `Image.astro` now emits `emdash-image--align-*` classes (left/right float, center, wide/full), the admin editor threads `alignment` through the PortableText/TipTap serializer and image node and adds an alignment control that reflects in the node view, and the Display Size panel now shows for migrated images that carry only display dimensions.
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 @@ -641,6 +643,7 @@ function convertPTBlock(block: PortableTextBlock): unknown {
height: imageBlock.height,
displayWidth: imageBlock.displayWidth,
displayHeight: imageBlock.displayHeight,
alignment: imageBlock.alignment,
},
};
}
Expand Down
164 changes: 114 additions & 50 deletions packages/admin/src/components/editor/ImageDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export interface ImageAttributes {
displayWidth?: number;
/** Display height for this instance (defaults to original) */
displayHeight?: number;
/** Alignment for this image instance (e.g. from a WordPress import) */
alignment?: "left" | "center" | "right" | "wide" | "full";
}

export interface ImageDetailPanelProps {
Expand Down Expand Up @@ -77,6 +79,9 @@ export function ImageDetailPanel({
attributes.displayHeight ?? attributes.height,
);
const [lockAspectRatio, setLockAspectRatio] = React.useState(true);
const [alignment, setAlignment] = React.useState<ImageAttributes["alignment"]>(
attributes.alignment,
);

// Calculate aspect ratio from original dimensions
const aspectRatio =
Expand Down Expand Up @@ -127,9 +132,10 @@ export function ImageDetailPanel({
caption !== (attributes.caption ?? "") ||
title !== (attributes.title ?? "") ||
displayWidth !== originalDisplayWidth ||
displayHeight !== originalDisplayHeight
displayHeight !== originalDisplayHeight ||
alignment !== attributes.alignment
);
}, [attributes, alt, caption, title, displayWidth, displayHeight]);
}, [attributes, alt, caption, title, displayWidth, displayHeight, alignment]);

const handleSave = () => {
onUpdate({
Expand All @@ -138,10 +144,20 @@ export function ImageDetailPanel({
title: title || undefined,
displayWidth,
displayHeight,
alignment,
});
onClose();
};

const alignmentOptions: { value: ImageAttributes["alignment"]; label: string }[] = [
{ value: undefined, label: t`None` },
{ value: "left", label: t`Left` },
{ value: "center", label: t`Center` },
{ value: "right", label: t`Right` },
{ value: "wide", label: t`Wide` },
{ value: "full", label: t`Full` },
];

const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);

const handleDelete = () => {
Expand Down Expand Up @@ -240,19 +256,21 @@ export function ImageDetailPanel({
)}
</div>

{/* Display Size */}
{attributes.width && attributes.height && (
{/* Display Size — shown for any image; migrated images may lack original dims */}
{attributes.src && (
<div className="p-4 border-b space-y-3">
<div className="flex items-center justify-between">
<Label>{t`Display Size`}</Label>
<Button
variant="ghost"
size="sm"
onClick={handleResetDimensions}
className="h-auto py-1 px-2 text-xs"
>
{t`Reset to original`}
</Button>
{attributes.width && attributes.height && (
<Button
variant="ghost"
size="sm"
onClick={handleResetDimensions}
className="h-auto py-1 px-2 text-xs"
>
{t`Reset to original`}
</Button>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex-1">
Expand All @@ -263,20 +281,22 @@ export function ImageDetailPanel({
onChange={(e) => handleWidthChange(e.target.value)}
/>
</div>
<Button
variant="ghost"
shape="square"
className="mt-5"
onClick={() => setLockAspectRatio(!lockAspectRatio)}
title={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
aria-label={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
>
{lockAspectRatio ? (
<LinkSimple className="h-4 w-4" />
) : (
<LinkBreak className="h-4 w-4 text-kumo-subtle" />
)}
</Button>
{aspectRatio && (
<Button
variant="ghost"
shape="square"
className="mt-5"
onClick={() => setLockAspectRatio(!lockAspectRatio)}
title={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
aria-label={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
>
{lockAspectRatio ? (
<LinkSimple className="h-4 w-4" />
) : (
<LinkBreak className="h-4 w-4 text-kumo-subtle" />
)}
</Button>
)}
<div className="flex-1">
<Input
label={t`Height`}
Expand All @@ -292,6 +312,26 @@ export function ImageDetailPanel({
</div>
)}

{/* Alignment */}
{attributes.src && (
<div className="p-4 border-b space-y-3">
<Label>{t`Alignment`}</Label>
<div className="flex flex-wrap gap-1">
{alignmentOptions.map((opt) => (
<Button
key={opt.value ?? "none"}
type="button"
size="sm"
variant={alignment === opt.value ? "primary" : "secondary"}
onClick={() => setAlignment(opt.value)}
>
{opt.label}
</Button>
))}
</div>
</div>
)}

{/* Editable Fields */}
<div className="p-4 space-y-4">
<Input
Expand Down Expand Up @@ -405,19 +445,21 @@ export function ImageDetailPanel({
</div>
)}

{/* Display Size */}
{attributes.width && attributes.height && (
{/* Display Size — shown for any image; migrated images may lack original dims */}
{attributes.src && (
<div className="p-4 border-b space-y-3">
<div className="flex items-center justify-between">
<Label>{t`Display Size`}</Label>
<Button
variant="ghost"
size="sm"
onClick={handleResetDimensions}
className="h-auto py-1 px-2 text-xs"
>
{t`Reset to original`}
</Button>
{attributes.width && attributes.height && (
<Button
variant="ghost"
size="sm"
onClick={handleResetDimensions}
className="h-auto py-1 px-2 text-xs"
>
{t`Reset to original`}
</Button>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex-1">
Expand All @@ -428,20 +470,22 @@ export function ImageDetailPanel({
onChange={(e) => handleWidthChange(e.target.value)}
/>
</div>
<Button
variant="ghost"
shape="square"
className="mt-5"
onClick={() => setLockAspectRatio(!lockAspectRatio)}
title={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
aria-label={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
>
{lockAspectRatio ? (
<LinkSimple className="h-4 w-4" />
) : (
<LinkBreak className="h-4 w-4 text-kumo-subtle" />
)}
</Button>
{aspectRatio && (
<Button
variant="ghost"
shape="square"
className="mt-5"
onClick={() => setLockAspectRatio(!lockAspectRatio)}
title={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
aria-label={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
>
{lockAspectRatio ? (
<LinkSimple className="h-4 w-4" />
) : (
<LinkBreak className="h-4 w-4 text-kumo-subtle" />
)}
</Button>
)}
<div className="flex-1">
<Input
label={t`Height`}
Expand All @@ -457,6 +501,26 @@ export function ImageDetailPanel({
</div>
)}

{/* Alignment */}
{attributes.src && (
<div className="p-4 border-b space-y-3">
<Label>{t`Alignment`}</Label>
<div className="flex flex-wrap gap-1">
{alignmentOptions.map((opt) => (
<Button
key={opt.value ?? "none"}
type="button"
size="sm"
variant={alignment === opt.value ? "primary" : "secondary"}
onClick={() => setAlignment(opt.value)}
>
{opt.label}
</Button>
))}
</div>
</div>
)}

{/* Editable Fields */}
<div className="p-4 space-y-4">
<Input
Expand Down
27 changes: 27 additions & 0 deletions packages/admin/src/components/editor/ImageNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ declare module "@tiptap/react" {
height?: number;
displayWidth?: number;
displayHeight?: number;
alignment?: "left" | "center" | "right" | "wide" | "full";
}) => ReturnType;
};
}
Expand Down Expand Up @@ -80,6 +81,7 @@ function ImageNodeView({ node, updateAttributes, selected, deleteNode, editor }:
height: node.attrs.height,
displayWidth: node.attrs.displayWidth,
displayHeight: node.attrs.displayHeight,
alignment: node.attrs.alignment,
});

const openSidebar = () => {
Expand Down Expand Up @@ -134,8 +136,29 @@ function ImageNodeView({ node, updateAttributes, selected, deleteNode, editor }:
}
}, [selected]);

const alignment = node.attrs.alignment as
| "left"
| "center"
| "right"
| "wide"
| "full"
| undefined;
// Mirror the published <Image> layout so the editor is WYSIWYG: left/right
// float (text wraps), center/wide/full size the block.
const alignmentStyle: React.CSSProperties =
alignment === "left"
? { float: "left", width: "fit-content", maxWidth: "50%", marginInlineEnd: "1.5rem" }
: alignment === "right"
? { float: "right", width: "fit-content", maxWidth: "50%", marginInlineStart: "1.5rem" }
: alignment === "center"
? { width: "fit-content", marginInline: "auto" }
: alignment === "wide" || alignment === "full"
? { width: "100%" }
: {};

return (
<NodeViewWrapper
style={alignmentStyle}
className={cn(
"relative my-4 group",
selected && "ring-2 ring-kumo-brand ring-offset-2 rounded-lg",
Expand Down Expand Up @@ -325,6 +348,9 @@ export const ImageExtension = Node.create({
displayHeight: {
default: null,
},
alignment: {
default: null,
},
};
},

Expand Down Expand Up @@ -358,6 +384,7 @@ export const ImageExtension = Node.create({
height?: number;
displayWidth?: number;
displayHeight?: number;
alignment?: "left" | "center" | "right" | "wide" | "full";
}) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
({ commands }: any) => {
Expand Down
Loading
Loading