diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f9e4193..fed203c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,14 +55,15 @@ jobs: run: pnpm --filter @unlayer/react-elements typecheck # ── Bundle size budget ────────────────────────────────────── - # Fail if the react-elements ESM bundle exceeds 60KB. - # Current size: ~49KB (14+ components). Budget gives room for - # growth but catches accidental dependency bundling. + # Fail if the react-elements ESM bundle exceeds 68KB. + # Current size: ~63KB (14+ components + image width-pinning geometry). + # The budget tracks the unminified ESM output as a proxy for code volume; + # it still flags accidental dependency bundling (any real dep is 10KB+). - name: Check bundle size run: | BUNDLE="packages/react/dist/index.js" SIZE=$(wc -c < "$BUNDLE" | tr -d ' ') - MAX_SIZE=60000 + MAX_SIZE=68000 echo "Bundle: $BUNDLE" echo "Size: $SIZE bytes (budget: $MAX_SIZE bytes)" if [ "$SIZE" -gt "$MAX_SIZE" ]; then diff --git a/packages/react/src/components/Image.test.tsx b/packages/react/src/components/Image.test.tsx index dd2d277..eb2117d 100644 --- a/packages/react/src/components/Image.test.tsx +++ b/packages/react/src/components/Image.test.tsx @@ -143,10 +143,13 @@ describe("Image Component", () => { expect(imageSrc().autoWidth).toBe(true); }); - it("a flat px / number width is the natural size, still responsive", () => { + it("a flat px / number width pins to a percent of the content slot", () => { + // Display intent, not natural size: 300 / (500 default contentWidth − 20px + // container padding) = 62.5%, stored as autoWidth:false so it survives a + // Builder round-trip. const src = imageSrc(); - expect(src.autoWidth).toBe(true); - expect(src.width).toBe(300); + expect(src.autoWidth).toBe(false); + expect(src.maxWidth).toBe("62.5%"); }); it("a percent maxWidth is a fixed display size (autoWidth:false)", () => { diff --git a/packages/react/src/components/Image.tsx b/packages/react/src/components/Image.tsx index 62e7aef..f2722d8 100644 --- a/packages/react/src/components/Image.tsx +++ b/packages/react/src/components/Image.tsx @@ -55,13 +55,17 @@ const Image = createItemComponent({ name: "Image", defaultValues: DEFAULT_VALUES, propMapper: (props) => { - const { alt, src, ...rest } = props; + // `width`/`maxWidth` are DISPLAY intent — pull them out so mapSemanticProps + // can't fold them into the natural-size `src.width` field. They drive + // autoWidth + maxWidth below; the natural dimensions come only from an + // object `src` / the loaded image. + const { alt, src, width: widthProp, maxWidth: maxWidthProp, ...rest } = props; // Normalize a string `values.src` to { url } before mapping. mapSemanticProps - // merges flat src props (width=, …) onto the src group by spreading; if the - // escape-hatch src is a string, spreading it character-spreads the URL into - // numeric keys ({0:"h",1:"t",…}) and loses the url. Wrapping it first keeps - // the merge object-to-object. + // merges flat src props onto the src group by spreading; if the escape-hatch + // src is a string, spreading it character-spreads the URL into numeric keys + // ({0:"h",1:"t",…}) and loses the url. Wrapping it first keeps the merge + // object-to-object. const restValues = (rest as { values?: { src?: unknown } }).values; const normalizedRest = restValues && typeof restValues.src === "string" @@ -81,11 +85,11 @@ const Image = createItemComponent({ // Build the src value. Note: ImageValues.src is typed as string (codegen // bug) but the exporter expects { url, autoWidth?, maxWidth?, width?, ... }. - // The user can provide src three ways — the `src` prop (string or object), - // flat semantic props (width=, maxWidth=, …), and the `values.src` escape - // hatch — the latter two land on `base.src` via mapSemanticProps. Combine - // all user-provided src fields (defensively, since base.src may be a string - // or non-object), then apply the width pin. + // Natural src fields come from the `src` prop (string or object) and the + // `values.src` escape hatch (which lands on `base.src` via mapSemanticProps); + // the display `width`/`maxWidth` props were pulled out above. Combine the + // natural fields (defensively, since base.src may be a string or non-object), + // then apply the display intent. const baseSrc = (base as Record).src; const fromValues: Record = baseSrc && typeof baseSrc === "object" && !Array.isArray(baseSrc) @@ -108,35 +112,55 @@ const Image = createItemComponent({ : { ...DEFAULT_VALUES.src }; const merged = { ...start, ...userSrc } as Record; - // In Unlayer's value model, `src.width`/`height` are the NATURAL image - // size and never set the display width. Display size = autoWidth + maxWidth: - // the default (and "100%") is responsive (autoWidth:true, capped at the - // natural size); a fixed display size is autoWidth:false + `maxWidth` as a - // PERCENT of the container. An explicit autoWidth is honored. + // In Unlayer's value model, `src.width`/`height` are the NATURAL image size + // and never set the display size. Display size = autoWidth + maxWidth: the + // default (and "100%") is responsive (autoWidth:true); a fixed display size + // is autoWidth:false + `maxWidth` as a PERCENT of the column's content slot + // (see image-sizing.ts for why a percent, not the natural-size field). A + // px/number pin is kept here as a placeholder and converted to that percent + // by the width-aware pass in renderToHtml / renderToJson, where the column + // geometry is known. An explicit autoWidth is honored. const pctRe = /^\d+(?:\.\d+)?%$/; + const asPercent = (v: unknown): string | undefined => + typeof v === "string" && pctRe.test(v.trim()) ? v.trim() : undefined; + const asPx = (v: unknown): number | undefined => { + if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "string") { + const t = v.trim(); + if (pctRe.test(t)) return undefined; + const m = /^(\d+(?:\.\d+)?)(?:px)?$/.exec(t); + if (m) return parseFloat(m[1]); + } + return undefined; + }; - // A `width` that is a percent is a DISPLAY width → route it to maxWidth. - // A px / bare-number `width` is the NATURAL size (a number); the natural - // cap then gives an "up to px, responsive" display for free. - if (typeof merged.width === "string") { - const t = merged.width.trim(); - if (pctRe.test(t)) { - if (userSrc.maxWidth === undefined) merged.maxWidth = t; - delete merged.width; - } else { - const px = /^(\d+(?:\.\d+)?)(?:px)?$/.exec(t); - if (px) merged.width = parseFloat(px[1]); + // Resolve display intent in priority order: flat `width` → flat `maxWidth` + // → escape-hatch values.src.maxWidth. + let displayPct: string | undefined; + let displayPx: number | undefined; + for (const candidate of [widthProp, maxWidthProp, userSrc.maxWidth]) { + if (candidate === undefined) continue; + const pct = asPercent(candidate); + if (pct) { + displayPct = pct; + break; + } + const px = asPx(candidate); + if (px != null) { + displayPx = px; + break; } } - const displayPct = - typeof merged.maxWidth === "string" && pctRe.test(merged.maxWidth.trim()) - ? merged.maxWidth.trim() - : undefined; + // An explicit escape-hatch `autoWidth` is honored as-is (its own maxWidth + // stays); otherwise the display intent decides. if (userSrc.autoWidth === undefined) { if (displayPct && displayPct !== "100%") { merged.autoWidth = false; merged.maxWidth = displayPct; + } else if (displayPx != null) { + merged.autoWidth = false; + merged.maxWidth = displayPx; } else { merged.autoWidth = true; merged.maxWidth = "100%"; diff --git a/packages/react/src/components/Image.width-roundtrip.test.tsx b/packages/react/src/components/Image.width-roundtrip.test.tsx new file mode 100644 index 0000000..bec3f6c --- /dev/null +++ b/packages/react/src/components/Image.width-roundtrip.test.tsx @@ -0,0 +1,282 @@ +import { describe, it, expect } from "vitest"; +import React from "react"; +import Email from "./Email"; +import Page from "./Page"; +import Image from "./Image"; +import Row from "./Row"; +import { Column } from "./Column"; +import { ColumnLayouts } from "../layouts/ColumnLayouts"; +import { renderToJson } from "../utils/render-to-json"; +import { renderToHtml } from "../utils/render-to-html"; +import { bodyContentWidthPx } from "../utils/image-sizing"; + +// Regression guard for the design-JSON round-trip: a fixed image width must pin +// as autoWidth:false + a PERCENT maxWidth (of the column's content slot) so an +// editor keeps it instead of falling back to the image's natural dimensions. A +// responsive image (no width) must stay autoWidth:true. + +/** Pull the first image's resolved src out of a renderToJson design. */ +function imgSrc(element: React.ReactElement): Record { + const json = renderToJson(element); + return json.body.rows[0].columns[0].contents[0].values.src as Record; +} + +describe("Image fixed-width round-trip (renderToJson)", () => { + it("numeric width pins: autoWidth:false + percent of the column slot", () => { + const src = imgSrc( + + + + + + + + ); + // 600 content − 10px×2 default container padding = 580 slot → 300/580 ≈ 51.72% + expect(src.autoWidth).toBe(false); + expect(src.maxWidth).toBe("51.72%"); + }); + + it("px-string width pins identically to the numeric form", () => { + const src = imgSrc( + + + + + + + + ); + expect(src.autoWidth).toBe(false); + expect(src.maxWidth).toBe("51.72%"); + }); + + it("a bare numeric-string contentWidth is treated as px, matching the renderer", () => { + const src = imgSrc( + + + + + + + + ); + // "600" must size against 600 (not the 500 fallback) → same as "600px". + expect(src.autoWidth).toBe(false); + expect(src.maxWidth).toBe("51.72%"); + }); + + it("a percent contentWidth falls back to the responsive base width (not the % value)", () => { + const src = imgSrc( + + + + + + + + ); + // % isn't a fixed px slot → base 500 → 300/(500−20) = 62.5%. + expect(src.autoWidth).toBe(false); + expect(src.maxWidth).toBe("62.5%"); + }); + + it("percent width stays a percent (already canonical)", () => { + const src = imgSrc( + + + + + + + + ); + expect(src.autoWidth).toBe(false); + expect(src.maxWidth).toBe("50%"); + }); + + it("no width stays responsive (autoWidth:true) — no regression", () => { + const src = imgSrc( + + + + + + + + ); + expect(src.autoWidth).toBe(true); + expect(src.maxWidth).toBe("100%"); + }); + + it("a pin wider than its column clamps to 100% (3-equal columns)", () => { + const src = imgSrc( + + + + + + + + + + + + + + ); + // 600/3 = 200 − 20 padding = 180 slot; 300 > 180 → clamps to 100% + expect(src.autoWidth).toBe(false); + expect(src.maxWidth).toBe("100%"); + }); + + it("the pin scales with column share (second of two columns)", () => { + const json = renderToJson( + + + + + + + + + + + ); + const src = json.body.rows[0].columns[1].contents[0].values.src as Record; + // 600/2 = 300 − 20 = 280 slot → 120/280 ≈ 42.86% + expect(src.autoWidth).toBe(false); + expect(src.maxWidth).toBe("42.86%"); + }); + + it("leaves a non-px maxWidth on a pinned src untouched (no bogus percent)", () => { + const src = imgSrc( + + + + + + + + ); + // A non-px unit is not a px pin → the conversion pass must leave it alone, + // not parseFloat it into a tiny percent. + expect(src.autoWidth).toBe(false); + expect(src.maxWidth).toBe("1.5em"); + }); + + it("a px width does not pollute the natural src dimensions", () => { + const src = imgSrc( + + + + + + + + ); + expect(src.width).toBe(1200); // natural width untouched + expect(src.height).toBe(600); + expect(src.autoWidth).toBe(false); + expect(src.maxWidth).toBe("51.72%"); + }); + + it("an explicit percent maxWidth on the src escape hatch is preserved (not overwritten by natural width)", () => { + const src = imgSrc( + + + + + + + + ); + expect(src.width).toBe(1600); + expect(src.autoWidth).toBe(false); + expect(src.maxWidth).toBe("50%"); + }); +}); + +describe("Image fixed-width rendering (renderToHtml)", () => { + it("renders a responsive percent width, never a hard px that overflows", () => { + const html = renderToHtml( + + + + + + + + ); + // The pin renders as `width: %` (caps at the slot via max-width), so the + // image shrinks with a narrow column instead of forcing a fixed 300px box. + expect(html).toMatch(/width:\s*51\.72%/); + expect(html).not.toMatch(/width:\s*300px/); + }); + + it("caps a too-wide pin at 100% in a narrow column", () => { + const html = renderToHtml( + + + + + + + + + + + + + + ); + expect(html).toMatch(/width:\s*100%/); + expect(html).not.toMatch(/width:\s*300px/); + }); +}); + +describe("bodyContentWidthPx (one px-parse shared by Row grid CSS + image geometry)", () => { + it("number / px-string / bare-numeric-string resolve to px", () => { + expect(bodyContentWidthPx(600)).toBe(600); + expect(bodyContentWidthPx("600px")).toBe(600); + expect(bodyContentWidthPx("600")).toBe(600); + }); + + it("percent / auto / missing fall back (never a parseInt artifact like 50)", () => { + expect(bodyContentWidthPx("50%")).toBe(500); + expect(bodyContentWidthPx("auto")).toBe(500); + expect(bodyContentWidthPx(undefined)).toBe(500); + }); + + it("honors a custom fallback", () => { + expect(bodyContentWidthPx("50%", 600)).toBe(600); + }); +}); + +describe("renderToJson row cells default to the Column count", () => { + it("a stray non-Column child is not counted as a cell (correct column-share math)", () => { + const json = renderToJson( + + + + + + + + + {/* invalid: a stray non-Column child (warned + skipped) */} + + + + ); + const row = json.body.rows[0]; + expect(row.cells).toEqual([1, 1]); // 2 Columns, not 3 children + // First column sized against the 2-col slot (600/2 − 20 = 280): 120/280 = + // 42.86%, not 66.67% (which a 3-cell miscount would produce). + const src = row.columns[0].contents[0].values.src as Record; + expect(src.autoWidth).toBe(false); + expect(src.maxWidth).toBe("42.86%"); + }); +}); diff --git a/packages/react/src/components/Row.tsx b/packages/react/src/components/Row.tsx index 55067bf..8343ac7 100644 --- a/packages/react/src/components/Row.tsx +++ b/packages/react/src/components/Row.tsx @@ -5,6 +5,7 @@ import type { ColumnLayout } from "@unlayer-internal/shared-elements"; import { RowExporters } from "@unlayer/exporters"; import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props"; import { nextHtmlId } from "../utils/create-component"; +import { bodyContentWidthPx } from "../utils/image-sizing"; import type { SizeInput } from "../types"; import { ROW_DEFAULTS, BODY_DEFAULTS } from "../utils/container-defaults"; @@ -137,20 +138,17 @@ type ContainerExporterFunction = (innerHTML: string, values: Record /** * Resolve the row's content width (px number) from body values. contentWidth - * may be "600px" (string) or 600 (number); the grid CSS needs a number. - * In EMAIL mode this drives the per-column desktop widths and the stacking - * breakpoint — so without it, multi-column emails were pinned to 600px - * regardless of . Web mode uses percentages, so this is - * a no-op there. + * may be "600px" (string), 600 (number), or "600" (bare numeric string); the + * grid CSS needs a number. In EMAIL mode this drives the per-column desktop + * widths and the stacking breakpoint — so without it, multi-column emails were + * pinned to 600px regardless of . Web mode uses percentages, + * so this is a no-op there. + * + * Uses the same strict px parse as the image slot geometry (a non-px value like + * "50%" → fallback, not a parseInt artifact like 50) so the two stay in sync. */ function toContentWidthPx(bodyValues: any, fallback = 500): number { - const raw = bodyValues?.contentWidth; - if (typeof raw === "number" && Number.isFinite(raw)) return raw; - if (typeof raw === "string") { - const n = parseInt(raw, 10); - if (Number.isFinite(n)) return n; - } - return fallback; + return bodyContentWidthPx(bodyValues?.contentWidth, fallback); } function renderRowToHtml(innerHTML: string, values: any, bodyValues: any, mode: RenderMode, cells: number[], collection: string = "rows"): string { diff --git a/packages/react/src/dx-behaviors.test.tsx b/packages/react/src/dx-behaviors.test.tsx index f1a44e5..d74a962 100644 --- a/packages/react/src/dx-behaviors.test.tsx +++ b/packages/react/src/dx-behaviors.test.tsx @@ -45,17 +45,19 @@ const columnValues = (el: React.ReactElement) => ) as any).body.rows[0].columns[0].values; -describe("DX: image sizing (Unlayer model — width is natural, display is a percent)", () => { - it("a numeric width is the natural size, stays responsive (autoWidth:true)", () => { +describe("DX: image sizing (a fixed width pins to the editor's canonical percent)", () => { + // Default Body contentWidth is 500px; minus the 10px×2 default container + // padding the content slot is 480px, so a 300px pin → 300/480 = 62.5%. + it("a numeric width pins (autoWidth:false + percent of the content slot)", () => { const src = itemValues().src; - expect(src.autoWidth).toBe(true); - expect(src.width).toBe(300); + expect(src.autoWidth).toBe(false); + expect(src.maxWidth).toBe("62.5%"); }); - it("a px-string width is the natural size, stays responsive", () => { + it("a px-string width pins identically to the numeric form", () => { const src = itemValues().src; - expect(src.autoWidth).toBe(true); - expect(src.width).toBe(300); + expect(src.autoWidth).toBe(false); + expect(src.maxWidth).toBe("62.5%"); }); it("a percent width is a fixed display size (autoWidth:false + maxWidth percent)", () => { @@ -85,8 +87,10 @@ describe("DX: image sizing (Unlayer model — width is natural, display is a per expect(html()).toMatch(/]*src="https:\/\/x\/a\.png"/); }); - it("a non-percent maxWidth does not pin (only a percent sets a fixed display)", () => { - expect(itemValues().src.autoWidth).toBe(true); + it("a px maxWidth pins as a percent of the content slot", () => { + const src = itemValues().src; + expect(src.autoWidth).toBe(false); + expect(src.maxWidth).toBe("62.5%"); }); // Guards the column-overflow regression: a dimensioned image (object src.width diff --git a/packages/react/src/utils/create-component.tsx b/packages/react/src/utils/create-component.tsx index dec14ce..8d993c7 100644 --- a/packages/react/src/utils/create-component.tsx +++ b/packages/react/src/utils/create-component.tsx @@ -13,6 +13,7 @@ import type { ExporterName } from "@unlayer/types"; type ItemExporters = Partial string>>; import type { RenderMode, UnlayerConfig } from "@unlayer-internal/shared-elements"; import type { SizeInput } from "../types"; +import { contentSlotWidth, pinImageSrc } from "./image-sizing"; import { mergeValues, generateHtmlFromTextJson, @@ -309,6 +310,26 @@ export function createItemComponent< config.name ); + // 5b. Convert a fixed (px) image pin to the editor's canonical percent now + // that the column geometry is known (Column threads columnValues/cells/ + // bodyValues). Guarded on `src.autoWidth === false`, so only pinned + // images are touched; responsive images and non-image blocks pass through. + const exportSrc = (valuesForExporter as Record).src; + if (exportSrc && typeof exportSrc === "object" && exportSrc.autoWidth === false) { + const availableWidth = contentSlotWidth({ + bodyValues: safeBodyValues, + rowValues, + rowCells: cells, + columnIndex: colIndex, + columnValues, + containerPadding: + (props as { containerPadding?: unknown; values?: { containerPadding?: unknown } }) + .containerPadding ?? + (props as { values?: { containerPadding?: unknown } }).values?.containerPadding, + }); + (valuesForExporter as Record).src = pinImageSrc(exportSrc, availableWidth); + } + // 6. Resolve exporter for this mode (fallback to web) const exporter = (config.exporters[mode] || config.exporters.web)!; diff --git a/packages/react/src/utils/image-sizing.ts b/packages/react/src/utils/image-sizing.ts new file mode 100644 index 0000000..ae94561 --- /dev/null +++ b/packages/react/src/utils/image-sizing.ts @@ -0,0 +1,172 @@ +/** + * Image display-width resolution. + * + * In Unlayer's value model an image's display size is `autoWidth` + `maxWidth`, + * kept separate from the natural `src.width`/`height`. A fixed display size is + * `autoWidth: false` with `maxWidth` as a PERCENT of the column's content slot + * (e.g. `"50%"`) — not the natural-size field. Encoding a fixed width that way + * keeps it stable when the design JSON is opened in an editor, which treats + * `src.width`/`height` as the intrinsic dimensions and may refresh them. + * + * Authors pass pixels, so a px pin is captured as `{ autoWidth:false, + * maxWidth: }` and converted here to the equivalent percent using the same + * available-width geometry the renderers use (contentWidth × column share, minus + * paddings/borders), so the emitted value renders at the requested on-screen size. + */ + +/** Parse a CSS length to px, strictly: a number or a numeric string with an + * optional px unit ("12" / "12px"). Non-px units ("1.5em", "calc(…)") return + * undefined so the pinning pass leaves them untouched instead of misreading + * them as px (SizeInput allows non-px CSS strings). */ +function toPx(value: unknown): number | undefined { + if (typeof value === "number") return Number.isFinite(value) ? value : undefined; + if (typeof value !== "string") return undefined; + const m = /^(\d+(?:\.\d+)?)(?:px)?$/.exec(value.trim()); + return m ? parseFloat(m[1]) : undefined; +} + +// NOTE: the geometry parsers below use parseFloat on each token, intentionally +// mirroring the renderer's explodePaddingsOrMargins / explodeBorder (which the +// image exporter subtracts from the available width). The renderer reads a token +// like "10%" as its numeric value (10), so the slot math must too — switching to +// strict px parsing here would make the pinned percent diverge from what the +// editor actually renders. Strict px parsing is only for the display-pin value +// (toPx, in pinImageSrc), never for the geometry. + +/** Left/right edge sizes from a CSS box shorthand (padding/margin), parsed the + * same way the renderer's explodePaddingsOrMargins does (parseFloat per token). */ +function edges(value: unknown): { left: number; right: number } { + if (value == null) return { left: 0, right: 0 }; + if (typeof value === "number") return { left: value, right: value }; + const parts = String(value) + .trim() + .split(/\s+/) + .map((p) => parseFloat(p) || 0); + // CSS order: 1 = all; 2 = [v h]; 3 = [t h b]; 4 = [t r b l]. + if (parts.length === 1) return { left: parts[0], right: parts[0] }; + if (parts.length === 2 || parts.length === 3) + return { left: parts[1], right: parts[1] }; + return { left: parts[3] || 0, right: parts[1] || 0 }; +} + +/** Left/right border widths from a per-side border object, parsed the same way + * the renderer's explodeBorder does (parseFloat per width). */ +function borderEdges(border: unknown): { left: number; right: number } { + if (!border || typeof border !== "object") return { left: 0, right: 0 }; + const b = border as Record; + const width = (v: unknown) => parseFloat(`${v ?? ""}`) || 0; + return { + left: width(b.borderLeftWidth), + right: width(b.borderRightWidth), + }; +} + +/** A body `contentWidth` is "fixed px" only when it's a number or a numeric + * string with an optional px unit (`600`, `"600px"`, or `"600"`). Percentages + * and keywords like `"auto"` are NOT fixed → `undefined`. */ +function fixedContentWidth(contentWidth: unknown): number | undefined { + if (typeof contentWidth === "number") + return Number.isFinite(contentWidth) ? contentWidth : undefined; + if (typeof contentWidth === "string") { + const m = /^(\d+(?:\.\d+)?)(?:px)?$/.exec(contentWidth.trim()); + if (m) return parseFloat(m[1]); + } + return undefined; +} + +/** Unlayer's body content width fallback when `contentWidth` isn't fixed px. */ +const FALLBACK_BODY_CONTENT_WIDTH = 500; +/** Unlayer's default content-block padding when a block sets none. */ +const DEFAULT_CONTAINER_PADDING = "10px"; + +/** + * A body `contentWidth` resolved to px: the fixed px value, or `fallback` + * (500, Unlayer's base width) for non-px values like `"50%"` / `"auto"`. + * + * Shared with Row's grid CSS (`toContentWidthPx`) so the slot geometry here and + * the renderer's column math agree on what counts as a fixed width — and so a + * non-px `contentWidth` collapses to the same base everywhere instead of being + * `parseInt`-ed into a bogus px value (e.g. `"50%"` → 50). + */ +export function bodyContentWidthPx( + contentWidth: unknown, + fallback: number = FALLBACK_BODY_CONTENT_WIDTH +): number { + return fixedContentWidth(contentWidth) ?? fallback; +} + +export interface SlotContext { + bodyValues?: { contentWidth?: number | string; padding?: unknown; border?: unknown }; + rowValues?: { padding?: unknown; border?: unknown }; + rowCells?: number[]; + columnIndex?: number; + columnValues?: { padding?: unknown; border?: unknown }; + /** The image block's own containerPadding (defaults to 10px like the editor). */ + containerPadding?: unknown; +} + +/** + * Width in px available to a content block, mirroring the renderers' + * available-width math: contentWidth, minus body/row/column/container paddings + * and borders, scaled by the column's share of the row. + */ +export function contentSlotWidth(ctx: SlotContext): number { + const { bodyValues = {}, rowValues = {}, columnValues = {} } = ctx; + const rowCells = ctx.rowCells && ctx.rowCells.length ? ctx.rowCells : [1]; + const columnIndex = ctx.columnIndex ?? 0; + + const bodyWidth = bodyContentWidthPx(bodyValues.contentWidth); + + const bp = edges(bodyValues.padding); + const bb = borderEdges(bodyValues.border); + const bodyAvail = bodyWidth - bp.left - bp.right - bb.left - bb.right; + + const rp = edges(rowValues.padding); + const rb = borderEdges(rowValues.border); + const rowAvail = bodyAvail - rp.left - rp.right - rb.left - rb.right; + + const rowSpan = rowCells.reduce((a, b) => a + b, 0) || 1; + const colSpan = rowCells[columnIndex] || 1; + const colWidth = (colSpan / rowSpan) * rowAvail; + + const cp = edges(columnValues.padding); + const cb = borderEdges(columnValues.border); + const colAvail = colWidth - cp.left - cp.right - cb.left - cb.right; + + const ip = edges(ctx.containerPadding ?? DEFAULT_CONTAINER_PADDING); + return colAvail - ip.left - ip.right; +} + +const PERCENT = /^\d+(?:\.\d+)?%$/; + +/** Round to 2 decimals, matching the editor's percent precision. */ +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +/** + * Convert a px display pin on an image `src` to the editor's canonical percent. + * + * Only touches a pinned image (`autoWidth === false`) whose `maxWidth` is a + * px/number placeholder; a percent `maxWidth` is already canonical and is left + * as-is, and a responsive image (`autoWidth !== false`) is untouched. A pin + * wider than the slot clamps to 100%, mirroring the editor. + */ +export function pinImageSrc | undefined>( + src: T, + availableWidth: number | undefined +): T { + if (!src || typeof src !== "object") return src; + if ((src as Record).autoWidth !== false) return src; + + const maxWidth = (src as Record).maxWidth; + if (typeof maxWidth === "string" && PERCENT.test(maxWidth.trim())) return src; + + const pinPx = toPx(maxWidth); + if (pinPx == null) return src; + + const avail = availableWidth && availableWidth > 0 ? availableWidth : undefined; + const pct = avail ? (pinPx >= avail ? 100 : round2((pinPx / avail) * 100)) : 100; + + return { ...src, autoWidth: false, maxWidth: `${pct}%` } as T; +} diff --git a/packages/react/src/utils/render-to-json.ts b/packages/react/src/utils/render-to-json.ts index 05852e6..a383647 100644 --- a/packages/react/src/utils/render-to-json.ts +++ b/packages/react/src/utils/render-to-json.ts @@ -27,6 +27,14 @@ const schemaVersion: number = _schemaVersion ?? 24; import { mapSemanticProps } from "./semantic-props"; import { UNLAYER_CONFIG_KEY } from "./create-component"; import { BODY_DEFAULTS, ROW_DEFAULTS, COLUMN_DEFAULTS } from "./container-defaults"; +import { contentSlotWidth, pinImageSrc, type SlotContext } from "./image-sizing"; + +/** Layout context threaded down the walk so an image can be sized against the + * real column slot (contentWidth × column share, minus paddings/borders). */ +type LayoutContext = Pick< + SlotContext, + "bodyValues" | "rowValues" | "rowCells" | "columnIndex" | "columnValues" +>; // ============================================ // Tree helpers (inlined) @@ -186,7 +194,8 @@ function extractTextFromTextJson(textJson: string): string { function processItem( element: React.ReactElement, - counters: Record + counters: Record, + layout: LayoutContext = {} ): DesignContent { const componentType = element.type as any; const config = componentType[UNLAYER_CONFIG_KEY]; @@ -232,12 +241,25 @@ function processItem( hideable: true, }; + // Convert a fixed (px) image pin to a percent of the column slot using the + // threaded geometry, so the display width survives the JSON round-trip into an + // editor. Guarded on `src.autoWidth === false`, so only pinned images change. + const itemSrc = (values as Record).src; + if (itemSrc && typeof itemSrc === "object" && itemSrc.autoWidth === false) { + const availableWidth = contentSlotWidth({ + ...layout, + containerPadding: (values as Record).containerPadding, + }); + (values as Record).src = pinImageSrc(itemSrc, availableWidth); + } + return { type: contentType, values }; } function processColumn( element: React.ReactElement, - counters: Record + counters: Record, + layout: Omit = {} ): DesignColumn { const count = nextCounter(counters, "u_column"); const id = makeId("u_column", count); @@ -261,8 +283,9 @@ function processColumn( const contents: DesignContent[] = []; const children = collectChildren(element.props.children); + const itemLayout: LayoutContext = { ...layout, columnValues: valuesWithMeta }; for (const child of children) { - contents.push(processItem(child, counters)); + contents.push(processItem(child, counters, itemLayout)); } return { contents, values: valuesWithMeta }; @@ -270,7 +293,8 @@ function processColumn( function processRow( element: React.ReactElement, - counters: Record + counters: Record, + parentLayout: Pick = {} ): DesignRow { const count = nextCounter(counters, "u_row"); const id = makeId("u_row", count); @@ -283,10 +307,15 @@ function processRow( } else if (propsCells) { cells = propsCells; } else { - // Default: one cell per Column child + // Default: one equal cell per Column child. Count only children — + // any stray non-Column child is warned and skipped below, so including it + // would make `cells` longer than the column list and distort both the row + // layout and the column-share math used for image sizing. const columnCount = Math.max( 1, - collectChildren(element.props.children).length + collectChildren(element.props.children).filter( + (child) => getDisplayName(child) === "Column" + ).length ); cells = Array(columnCount).fill(1); } @@ -317,10 +346,19 @@ function processRow( const columns: DesignColumn[] = []; const children = collectChildren(element.props.children); + const columnLayout: Omit = { + bodyValues: parentLayout.bodyValues, + rowValues: valuesWithMeta, + rowCells: cells, + }; + let columnIndex = 0; for (const child of children) { const name = getDisplayName(child); if (name === "Column") { - columns.push(processColumn(child, counters)); + columns.push( + processColumn(child, counters, { ...columnLayout, columnIndex }) + ); + columnIndex += 1; } else { console.warn( `[Unlayer] renderToJson: <${name}> is not a valid Row child. Only is allowed.` @@ -367,7 +405,7 @@ function processBody( for (const child of children) { const name = getDisplayName(child); if (name === "Row") { - rows.push(processRow(child, counters)); + rows.push(processRow(child, counters, { bodyValues: valuesWithMeta })); } else { console.warn( `[Unlayer] renderToJson: <${name}> is not a valid Body child. Only is allowed.`