Skip to content

Commit c43c412

Browse files
SailingGregemdashbot[bot]claudeascorbic
authored
feat(image): honor alignment + surface display size for migrated images (#1404) (#1406)
* test(repro): Image.astro src resolution for migrated nodes (#1404 render follow-up) * test(repro): faithful container render of migrated image node (#1404 render follow-up) Renders the real RAFYC image node through astro-portabletext via the production dispatch (type.image -> Image.astro), default vs delegating override vs no-locals. All three emit a valid src: the override receives the full node intact and delegation to <Image> does NOT render empty. Confirms alignment is dropped. * feat(image): honor image alignment + surface display size for migrated images (#1404) Renderer (core/Image.astro): add alignment to the PT image node props and emit an emdash-image--align-{left,right,center,wide,full} figure class with default CSS (floats wrap text; wide/full span the column). Editor (admin): thread alignment through the PortableText<->TipTap serializer (both directions), the TipTap image node attribute/commands, and add an alignment selector to ImageDetailPanel. Also relax the Display Size gate from (width && height) to (src) so migrated WordPress images, which carry only displayWidth/displayHeight and no original dims, can still be resized; the aspect-ratio lock and reset-to-original now degrade gracefully when originals are unknown. Repro test asserts the renderer now emits the alignment class. * feat(image): reflect alignment in the editor node view (#1404) Float left/right (text wraps) and size center/wide/full in the TipTap image NodeView so the editor preview matches the published <Image> output. * style: format * chore: add changeset for image alignment fix (#1404) * test: genericize #1404 repro comments (drop downstream references) * feat(image): un-float aligned images on narrow screens (#1404) Add a max-width:640px media query so left/right-aligned images drop to full-width blocks on phones instead of staying floated and cramped. * test(core): stop render tests breaking the plain-node unit run The #1404 follow-up added tests/repro/*.render.test.ts (which import .astro components) plus vitest.repro.config.ts (Astro Vite plugin), but never wired the runner up. The default vitest.config.ts swept the render test into its plain-node run, which cannot transform .astro, so the whole core suite failed to load (0 tests collected). - vitest.config.ts: exclude tests/repro/**/*.render.test.ts (needs Astro) - package.json: add test:repro script (mirrors test:smoke/test:integration) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: run repro render tests in the Tests job 5c26f31 split the Astro render test out of the plain-node test:unit run (vitest.repro.config.ts + test:repro script) but the CI invocation was never committed, so the #1404 alignment/display-size render coverage would not execute in CI. Run the repro config right after test:unit in the Tests job. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(core): address review — drop fake repro test, de-number titles Per @ascorbic review on #1406: - delete tests/repro/image-render.test.ts: it only exercised local reimplementations (buildRenderMediaUrl), not the real component; the real coverage lives in image-render.render.test.ts. - strip the 1./2./3. numbering from the render test titles. - drop the throwaway #1404-follow-up header line. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Matt Kane <mkane@cloudflare.com>
1 parent b4d7228 commit c43c412

11 files changed

Lines changed: 295 additions & 53 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Honor image alignment from WordPress imports at render and in the editor, and surface display-size controls for migrated images.
6+
7+
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.

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ jobs:
124124
- run: pnpm test:unit
125125
env:
126126
EMDASH_TEST_PG: postgres://postgres:test@localhost:5432/emdash_test
127+
# Render tests use the Astro Vite plugin (vitest.repro.config.ts);
128+
# they can't run under the plain-node config in test:unit.
129+
- run: pnpm --filter emdash exec vitest run --config vitest.repro.config.ts
127130

128131
test-smoke:
129132
name: Smoke Tests

packages/admin/src/components/PortableTextEditor.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ interface PortableTextImageBlock {
142142
height?: number;
143143
displayWidth?: number;
144144
displayHeight?: number;
145+
alignment?: "left" | "center" | "right" | "wide" | "full";
145146
}
146147

147148
interface PortableTextCodeBlock {
@@ -312,6 +313,7 @@ function convertPMNode(node: {
312313
height: attrNum(attrs.height),
313314
displayWidth: attrNum(attrs.displayWidth),
314315
displayHeight: attrNum(attrs.displayHeight),
316+
alignment: attrStr(attrs.alignment) as PortableTextImageBlock["alignment"],
315317
};
316318
}
317319

@@ -654,6 +656,7 @@ function convertPTBlock(block: PortableTextBlock): unknown {
654656
height: imageBlock.height,
655657
displayWidth: imageBlock.displayWidth,
656658
displayHeight: imageBlock.displayHeight,
659+
alignment: imageBlock.alignment,
657660
},
658661
};
659662
}

packages/admin/src/components/editor/ImageDetailPanel.tsx

Lines changed: 114 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export interface ImageAttributes {
3737
displayWidth?: number;
3838
/** Display height for this instance (defaults to original) */
3939
displayHeight?: number;
40+
/** Alignment for this image instance (e.g. from a WordPress import) */
41+
alignment?: "left" | "center" | "right" | "wide" | "full";
4042
}
4143

4244
export interface ImageDetailPanelProps {
@@ -77,6 +79,9 @@ export function ImageDetailPanel({
7779
attributes.displayHeight ?? attributes.height,
7880
);
7981
const [lockAspectRatio, setLockAspectRatio] = React.useState(true);
82+
const [alignment, setAlignment] = React.useState<ImageAttributes["alignment"]>(
83+
attributes.alignment,
84+
);
8085

8186
// Calculate aspect ratio from original dimensions
8287
const aspectRatio =
@@ -127,9 +132,10 @@ export function ImageDetailPanel({
127132
caption !== (attributes.caption ?? "") ||
128133
title !== (attributes.title ?? "") ||
129134
displayWidth !== originalDisplayWidth ||
130-
displayHeight !== originalDisplayHeight
135+
displayHeight !== originalDisplayHeight ||
136+
alignment !== attributes.alignment
131137
);
132-
}, [attributes, alt, caption, title, displayWidth, displayHeight]);
138+
}, [attributes, alt, caption, title, displayWidth, displayHeight, alignment]);
133139

134140
const handleSave = () => {
135141
onUpdate({
@@ -138,10 +144,20 @@ export function ImageDetailPanel({
138144
title: title || undefined,
139145
displayWidth,
140146
displayHeight,
147+
alignment,
141148
});
142149
onClose();
143150
};
144151

152+
const alignmentOptions: { value: ImageAttributes["alignment"]; label: string }[] = [
153+
{ value: undefined, label: t`None` },
154+
{ value: "left", label: t`Left` },
155+
{ value: "center", label: t`Center` },
156+
{ value: "right", label: t`Right` },
157+
{ value: "wide", label: t`Wide` },
158+
{ value: "full", label: t`Full` },
159+
];
160+
145161
const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
146162

147163
const handleDelete = () => {
@@ -240,19 +256,21 @@ export function ImageDetailPanel({
240256
)}
241257
</div>
242258

243-
{/* Display Size */}
244-
{attributes.width && attributes.height && (
259+
{/* Display Size — shown for any image; migrated images may lack original dims */}
260+
{attributes.src && (
245261
<div className="p-4 border-b space-y-3">
246262
<div className="flex items-center justify-between">
247263
<Label>{t`Display Size`}</Label>
248-
<Button
249-
variant="ghost"
250-
size="sm"
251-
onClick={handleResetDimensions}
252-
className="h-auto py-1 px-2 text-xs"
253-
>
254-
{t`Reset to original`}
255-
</Button>
264+
{attributes.width && attributes.height && (
265+
<Button
266+
variant="ghost"
267+
size="sm"
268+
onClick={handleResetDimensions}
269+
className="h-auto py-1 px-2 text-xs"
270+
>
271+
{t`Reset to original`}
272+
</Button>
273+
)}
256274
</div>
257275
<div className="flex items-center gap-2">
258276
<div className="flex-1">
@@ -263,20 +281,22 @@ export function ImageDetailPanel({
263281
onChange={(e) => handleWidthChange(e.target.value)}
264282
/>
265283
</div>
266-
<Button
267-
variant="ghost"
268-
shape="square"
269-
className="mt-5"
270-
onClick={() => setLockAspectRatio(!lockAspectRatio)}
271-
title={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
272-
aria-label={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
273-
>
274-
{lockAspectRatio ? (
275-
<LinkSimple className="h-4 w-4" />
276-
) : (
277-
<LinkBreak className="h-4 w-4 text-kumo-subtle" />
278-
)}
279-
</Button>
284+
{aspectRatio && (
285+
<Button
286+
variant="ghost"
287+
shape="square"
288+
className="mt-5"
289+
onClick={() => setLockAspectRatio(!lockAspectRatio)}
290+
title={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
291+
aria-label={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
292+
>
293+
{lockAspectRatio ? (
294+
<LinkSimple className="h-4 w-4" />
295+
) : (
296+
<LinkBreak className="h-4 w-4 text-kumo-subtle" />
297+
)}
298+
</Button>
299+
)}
280300
<div className="flex-1">
281301
<Input
282302
label={t`Height`}
@@ -292,6 +312,26 @@ export function ImageDetailPanel({
292312
</div>
293313
)}
294314

315+
{/* Alignment */}
316+
{attributes.src && (
317+
<div className="p-4 border-b space-y-3">
318+
<Label>{t`Alignment`}</Label>
319+
<div className="flex flex-wrap gap-1">
320+
{alignmentOptions.map((opt) => (
321+
<Button
322+
key={opt.value ?? "none"}
323+
type="button"
324+
size="sm"
325+
variant={alignment === opt.value ? "primary" : "secondary"}
326+
onClick={() => setAlignment(opt.value)}
327+
>
328+
{opt.label}
329+
</Button>
330+
))}
331+
</div>
332+
</div>
333+
)}
334+
295335
{/* Editable Fields */}
296336
<div className="p-4 space-y-4">
297337
<Input
@@ -405,19 +445,21 @@ export function ImageDetailPanel({
405445
</div>
406446
)}
407447

408-
{/* Display Size */}
409-
{attributes.width && attributes.height && (
448+
{/* Display Size — shown for any image; migrated images may lack original dims */}
449+
{attributes.src && (
410450
<div className="p-4 border-b space-y-3">
411451
<div className="flex items-center justify-between">
412452
<Label>{t`Display Size`}</Label>
413-
<Button
414-
variant="ghost"
415-
size="sm"
416-
onClick={handleResetDimensions}
417-
className="h-auto py-1 px-2 text-xs"
418-
>
419-
{t`Reset to original`}
420-
</Button>
453+
{attributes.width && attributes.height && (
454+
<Button
455+
variant="ghost"
456+
size="sm"
457+
onClick={handleResetDimensions}
458+
className="h-auto py-1 px-2 text-xs"
459+
>
460+
{t`Reset to original`}
461+
</Button>
462+
)}
421463
</div>
422464
<div className="flex items-center gap-2">
423465
<div className="flex-1">
@@ -428,20 +470,22 @@ export function ImageDetailPanel({
428470
onChange={(e) => handleWidthChange(e.target.value)}
429471
/>
430472
</div>
431-
<Button
432-
variant="ghost"
433-
shape="square"
434-
className="mt-5"
435-
onClick={() => setLockAspectRatio(!lockAspectRatio)}
436-
title={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
437-
aria-label={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
438-
>
439-
{lockAspectRatio ? (
440-
<LinkSimple className="h-4 w-4" />
441-
) : (
442-
<LinkBreak className="h-4 w-4 text-kumo-subtle" />
443-
)}
444-
</Button>
473+
{aspectRatio && (
474+
<Button
475+
variant="ghost"
476+
shape="square"
477+
className="mt-5"
478+
onClick={() => setLockAspectRatio(!lockAspectRatio)}
479+
title={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
480+
aria-label={lockAspectRatio ? t`Unlock aspect ratio` : t`Lock aspect ratio`}
481+
>
482+
{lockAspectRatio ? (
483+
<LinkSimple className="h-4 w-4" />
484+
) : (
485+
<LinkBreak className="h-4 w-4 text-kumo-subtle" />
486+
)}
487+
</Button>
488+
)}
445489
<div className="flex-1">
446490
<Input
447491
label={t`Height`}
@@ -457,6 +501,26 @@ export function ImageDetailPanel({
457501
</div>
458502
)}
459503

504+
{/* Alignment */}
505+
{attributes.src && (
506+
<div className="p-4 border-b space-y-3">
507+
<Label>{t`Alignment`}</Label>
508+
<div className="flex flex-wrap gap-1">
509+
{alignmentOptions.map((opt) => (
510+
<Button
511+
key={opt.value ?? "none"}
512+
type="button"
513+
size="sm"
514+
variant={alignment === opt.value ? "primary" : "secondary"}
515+
onClick={() => setAlignment(opt.value)}
516+
>
517+
{opt.label}
518+
</Button>
519+
))}
520+
</div>
521+
</div>
522+
)}
523+
460524
{/* Editable Fields */}
461525
<div className="p-4 space-y-4">
462526
<Input

packages/admin/src/components/editor/ImageNode.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ declare module "@tiptap/react" {
3636
height?: number;
3737
displayWidth?: number;
3838
displayHeight?: number;
39+
alignment?: "left" | "center" | "right" | "wide" | "full";
3940
}) => ReturnType;
4041
};
4142
}
@@ -80,6 +81,7 @@ function ImageNodeView({ node, updateAttributes, selected, deleteNode, editor }:
8081
height: node.attrs.height,
8182
displayWidth: node.attrs.displayWidth,
8283
displayHeight: node.attrs.displayHeight,
84+
alignment: node.attrs.alignment,
8385
});
8486

8587
const openSidebar = () => {
@@ -134,8 +136,29 @@ function ImageNodeView({ node, updateAttributes, selected, deleteNode, editor }:
134136
}
135137
}, [selected]);
136138

139+
const alignment = node.attrs.alignment as
140+
| "left"
141+
| "center"
142+
| "right"
143+
| "wide"
144+
| "full"
145+
| undefined;
146+
// Mirror the published <Image> layout so the editor is WYSIWYG: left/right
147+
// float (text wraps), center/wide/full size the block.
148+
const alignmentStyle: React.CSSProperties =
149+
alignment === "left"
150+
? { float: "left", width: "fit-content", maxWidth: "50%", marginInlineEnd: "1.5rem" }
151+
: alignment === "right"
152+
? { float: "right", width: "fit-content", maxWidth: "50%", marginInlineStart: "1.5rem" }
153+
: alignment === "center"
154+
? { width: "fit-content", marginInline: "auto" }
155+
: alignment === "wide" || alignment === "full"
156+
? { width: "100%" }
157+
: {};
158+
137159
return (
138160
<NodeViewWrapper
161+
style={alignmentStyle}
139162
className={cn(
140163
"relative my-4 group",
141164
selected && "ring-2 ring-kumo-brand ring-offset-2 rounded-lg",
@@ -325,6 +348,9 @@ export const ImageExtension = Node.create({
325348
displayHeight: {
326349
default: null,
327350
},
351+
alignment: {
352+
default: null,
353+
},
328354
};
329355
},
330356

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

packages/core/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,8 @@
197197
"check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm --ignore-rules=no-resolution --ignore-rules=internal-resolution-error",
198198
"test": "vitest",
199199
"test:smoke": "vitest run --config vitest.smoke.config.ts",
200-
"test:integration": "vitest run --config vitest.integration.config.ts"
200+
"test:integration": "vitest run --config vitest.integration.config.ts",
201+
"test:repro": "vitest run --config vitest.repro.config.ts"
201202
},
202203
"dependencies": {
203204
"@atcute/lexicons": "catalog:",

0 commit comments

Comments
 (0)