Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ed4f6fe
feat(builder): one mapping between the canvas frame and the host
mobeenabdullah Aug 11, 2026
7852bb1
chore(release): add the changeset for the frame geometry module
mobeenabdullah Aug 11, 2026
b3a17c0
fix(builder): match the geometry module exactly, and let e2e measure …
mobeenabdullah Aug 11, 2026
4c56456
docs(builder): say what the geometry guard checks, and what review holds
mobeenabdullah Aug 11, 2026
4162735
fix(builder): match the geometry module by path, and typecheck e2e wi…
mobeenabdullah Aug 11, 2026
1771e39
fix(builder): follow every source extension, and map from the content…
mobeenabdullah Aug 11, 2026
6fe5d98
fix(builder): decide test files from one list the runner also reads
mobeenabdullah Aug 11, 2026
c8868f8
fix(builder): scale the frame's border inset with the frame
mobeenabdullah Aug 11, 2026
b8ad32a
fix(builder): hash the extensions the guards read, and see an aliased…
mobeenabdullah Aug 11, 2026
594701f
fix(builder): route the indicator reader through the driver's content…
mobeenabdullah Aug 11, 2026
10dc87f
docs(builder): describe the guard's reach, not its history
mobeenabdullah Aug 11, 2026
bd9b334
fix(builder): map the nearest-zone centre with the shared helper
mobeenabdullah Aug 11, 2026
10989ca
docs(builder): call the scan a review aid at the top of the file too
mobeenabdullah Aug 12, 2026
a2182e6
docs(builder): qualify what the release note claims the guard does
mobeenabdullah Aug 12, 2026
ada39f5
docs(builder): state why the origin is built here, not how it used to be
mobeenabdullah Aug 12, 2026
b277381
fix(builder): report a collapsed frame's scale instead of substitutin…
mobeenabdullah Aug 12, 2026
02c1a9f
refactor(builder): both guards find their files by one rule
mobeenabdullah Aug 12, 2026
348c63d
test(builder): pin that the walk covers every extension it claims
mobeenabdullah Aug 12, 2026
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
27 changes: 27 additions & 0 deletions .changeset/builder-frame-geometry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"nextly": patch
"create-nextly-app": patch
"@nextlyhq/admin": patch
"@nextlyhq/admin-css": patch
"@nextlyhq/blocks-engine": patch
"@nextlyhq/blocks-react": patch
"@nextlyhq/ui": patch
"@nextlyhq/adapter-drizzle": patch
"@nextlyhq/adapter-postgres": patch
"@nextlyhq/adapter-mysql": patch
"@nextlyhq/adapter-sqlite": patch
"@nextlyhq/storage-s3": patch
"@nextlyhq/storage-uploadthing": patch
"@nextlyhq/storage-vercel-blob": patch
"@nextlyhq/plugin-form-builder": patch
"@nextlyhq/plugin-page-builder": patch
"@nextlyhq/plugin-seo": patch
"@nextlyhq/plugin-sdk": patch
"@nextlyhq/eslint-config": patch
"@nextlyhq/prettier-config": patch
"@nextlyhq/telemetry": patch
"@nextlyhq/tsconfig": patch
"@nextlyhq/builder": patch
---

Add the builder's host-canvas coordinate mapping: one module converts between the canvas frame and the host page, including the scaled border inset that places the frame's content origin. A sibling test scans for cross-frame rectangle reads elsewhere in the package, recognising a bounded set of spellings; it narrows the paths taken by accident rather than enforcing single ownership.
1 change: 1 addition & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@nextlyhq/adapter-drizzle": "workspace:*",
"@nextlyhq/adapter-sqlite": "workspace:*",
"@nextlyhq/admin": "workspace:*",
"@nextlyhq/builder": "workspace:*",
"@nextlyhq/eslint-config": "workspace:*",
"@nextlyhq/plugin-form-builder": "workspace:*",
"@nextlyhq/plugin-page-builder": "workspace:*",
Expand Down
87 changes: 84 additions & 3 deletions e2e/tests/canvas/coordinate-mapping.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import { expect, test } from "@playwright/test";

import { FLAT_LIST_FIXTURE, seedPage } from "./fixtures";
import {
frameContentOrigin,
mapFramePointToHost,
mapFrameRectToHost,
mapHostPointToFrame,
type FrameInset,
} from "./coordinate-mapping";
import { createPocDriver } from "./poc-driver";

Expand All @@ -39,8 +41,22 @@ async function groundTruth(page: import("@playwright/test").Page) {
return box!;
}

/**
* How the frame's content origin is derived from its measured border box.
*
* `"raw"` adds `clientLeft` to a post-transform corner without scaling it —
* the arithmetic a naive implementation writes. It exists so the bordered test
* below can show its own tolerance is load-bearing, the same way the scale test
* shows the scale term is.
*/
type InsetMode = "scaled" | "raw";

/** Apply our mapping to the probe's frame-local rect. */
async function mapped(page: import("@playwright/test").Page, scale: number) {
async function mapped(
page: import("@playwright/test").Page,
scale: number,
insetMode: InsetMode = "scaled"
) {
const frame = page.frames().find(f => f.url() === "about:blank")!;
const frameRect = await frame.evaluate(id => {
const el = document.querySelector(`[data-nx-id="${id}"]`);
Expand All @@ -50,10 +66,27 @@ async function mapped(page: import("@playwright/test").Page, scale: number) {
}, PROBE_ID);
expect(frameRect).not.toBeNull();

const origin = await page.locator("iframe").boundingBox();
const frameElement = page.locator("iframe");
const origin = await frameElement.boundingBox();
expect(origin).not.toBeNull();
// The content origin: `boundingBox()` gives the border box, and rectangles
// read inside the frame are relative to the content viewport. The two agree
// only while a canvas keeps `border: none`, which is why measuring against
// the border box passed here and drifts on any bordered canvas.
//
// The scale is passed through rather than measured so that `mapped(page, 1)`
// stays a coherent "what a naive implementation computes" — it gets the wrong
// origin AND the wrong mapping, which is what the control below asserts.
const inset = await frameElement.evaluate<FrameInset, HTMLIFrameElement>(
el => ({ left: el.clientLeft, top: el.clientTop })
);

const contentOrigin =
insetMode === "scaled"
? frameContentOrigin(origin!, inset, scale)
: { x: origin!.x + inset.left, y: origin!.y + inset.top };

return mapFrameRectToHost(frameRect!, { x: origin!.x, y: origin!.y }, scale);
return mapFrameRectToHost(frameRect!, contentOrigin, scale);
}

/** Largest absolute difference across all four rect components. */
Expand Down Expand Up @@ -174,6 +207,54 @@ test("point 5: the mapping survives scroll and scale together", async ({
expect(delta).toBeLessThanOrEqual(1);
});

test("point 5: the mapping survives a bordered frame under scale", async ({
page,
request,
}) => {
const fixture = await seedPage(request, FLAT_LIST_FIXTURE);
const driver = createPocDriver(page);
await driver.mountTree(fixture);

// Every other test in this file runs against a canvas with `border: none`,
// where the content origin and the border-box corner are the same point. That
// makes them all agree whether or not the inset is scaled — so none of them
// can see this, and the fault would ship on the first bordered canvas.
await page
.locator("iframe")
.evaluate(
(el: HTMLIFrameElement) => (el.style.border = "8px solid transparent")
);
await driver.setZoom(0.5);

// Precondition, not decoration. If the canvas stylesheet wins over the inline
// border, `clientLeft` is 0, this silently becomes the at-rest case, and it
// passes while testing nothing at all.
const inset = await page
.locator("iframe")
.evaluate((el: HTMLIFrameElement) => el.clientLeft);
expect(
inset,
"the border must actually apply for this to test anything"
).toBe(8);

const scaled = worstDelta(await mapped(page, 0.5), await groundTruth(page));
Comment thread
mobeenabdullah marked this conversation as resolved.
const raw = worstDelta(
await mapped(page, 0.5, "raw"),
await groundTruth(page)
);

test.info().annotations.push({
type: "delta-bordered-scaled",
description: `scaled=${scaled} raw=${raw} inset=${inset}`,
});

// Both halves, for the same reason as the scale test. The first says scaling
// the inset is right; the second says it MATTERS — an 8px border at 50% puts
// the raw sum 4px out, so a regression to it cannot pass this quietly.
expect(scaled).toBeLessThanOrEqual(1);
expect(raw).toBeGreaterThan(1);
});

test("point 5: the two directions are exact inverses", async ({
page,
request,
Expand Down
87 changes: 55 additions & 32 deletions e2e/tests/canvas/coordinate-mapping.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,81 @@
/**
* The canvas↔host mapping the acceptance tests measure against.
*
* **Adapts the editor's own mapping rather than restating it.** The arithmetic
* lives once, in `@nextlyhq/builder`, and this file only changes the call shape:
* these helpers take `(value, frameOrigin, scale)` because that is how a
* Playwright test has the numbers to hand — origin from the frame element's
* box, scale read off the page — while the editor holds them together as one
* `FrameGeometry`.
*
* The reason it is an adapter and not a copy is what the tests are FOR. A
* browser harness carrying its own arithmetic certifies its own arithmetic: the
* two agree on the day they are written, and the first correction to either
* makes the acceptance suite validate a stale copy while reporting that the
* editor is fine. That failure is invisible, because both sides are
* individually self-consistent.
*
* A consequence worth knowing before it surprises someone: a frame that cannot
* describe a mapping — a zero, negative or non-finite scale — now THROWS rather
* than returning `NaN` coordinates, because that is what the editor's mapping
* does. A test measuring an unrendered element gets an error naming the problem
* instead of an assertion failure about numbers that were never meaningful.
*/
import {
frameContentOrigin,
pointToCanvas,
pointToHost,
rectToHost,
type FrameGeometry,
type FrameInset,
} from "@nextlyhq/builder";
Comment thread
mobeenabdullah marked this conversation as resolved.

import type { Point, Rect } from "./driver";

/** The two numbers a Playwright test has, in the shape the editor's mapping takes. */
function frame(frameOrigin: Point, scale: number): FrameGeometry {
return { origin: frameOrigin, scale };
}

/**
* Convert a point inside the canvas frame to the host document's coordinates.
*
* Kept as a pure function so it can be tested against browser-reported
* geometry rather than inferred from whether an overlay happens to look right.
*
* A frame-local point is scaled by whatever transform the frame carries and
* then offset by the frame's own position in the host. The scale term is not
* optional: a canvas offering zoom-to-fit is exactly the case dnd-kit #1706
* covered, and omitting it puts the overlay progressively further out the
* further a point sits from the frame's transform origin.
* The scale term is not optional: a canvas offering zoom-to-fit is exactly the
* case dnd-kit #1706 covered, and omitting it puts the overlay progressively
* further out the further a point sits from the frame's transform origin.
*/
export function mapFramePointToHost(
framePoint: Point,
frameOrigin: Point,
scale = 1
): Point {
return {
x: frameOrigin.x + framePoint.x * scale,
y: frameOrigin.y + framePoint.y * scale,
};
return pointToHost(framePoint, frame(frameOrigin, scale));
}

/**
* The inverse: a host point expressed in the canvas's own coordinates.
*
* Both directions are needed and neither is optional. Drawing an overlay in
* parent chrome maps canvas -> host; deciding which block sits under the
* pointer maps host -> canvas. A canvas that implements only one ends up
* open-coding the other at the call site, which is how the two drift apart.
*/
/** Convert a host-document point back into the canvas frame's coordinates. */
export function mapHostPointToFrame(
hostPoint: Point,
frameOrigin: Point,
scale = 1
): Point {
return {
x: (hostPoint.x - frameOrigin.x) / scale,
y: (hostPoint.y - frameOrigin.y) / scale,
};
return pointToCanvas(hostPoint, frame(frameOrigin, scale));
}

/** The same mapping for a rect, so an indicator can be drawn in parent chrome. */
/** Convert a rectangle inside the frame to the host document's coordinates. */
export function mapFrameRectToHost(
frameRect: Rect,
frameOrigin: Point,
scale = 1
): Rect {
const topLeft = mapFramePointToHost(frameRect, frameOrigin, scale);
return {
x: topLeft.x,
y: topLeft.y,
width: frameRect.width * scale,
height: frameRect.height * scale,
};
return rectToHost(frameRect, frame(frameOrigin, scale));
}

/**
* Where the frame's content viewport starts, from a measured border box.
*
* Re-exported rather than wrapped: this is the arithmetic every caller needs
* after reading `boundingBox()` and `clientLeft`, and writing it at each call
* site is how one of them ends up adding the inset unscaled. The measurement
* stays in the driver; the sums stay in the editor's module.
*/
export { frameContentOrigin, type FrameInset };
68 changes: 55 additions & 13 deletions e2e/tests/canvas/poc-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import { expect, type Frame, type Page } from "@playwright/test";

import { gotoAdmin } from "../support/admin";

import { mapFrameRectToHost } from "./coordinate-mapping";
import {
frameContentOrigin,
mapFramePointToHost,
mapFrameRectToHost,
Comment thread
mobeenabdullah marked this conversation as resolved.
type FrameInset,
} from "./coordinate-mapping";
import type {
ActiveTargetReader,
CanvasDriver,
Expand Down Expand Up @@ -83,12 +88,24 @@ export function createPocDriver(page: Page): CanvasDriver {
return frame;
}

/** The frame's current transform scale; 1 when untransformed. */
/**
* The frame's current transform scale; 1 when untransformed.
*
* Reported as measured, including zero. A collapsed frame maps the whole
* canvas onto a point, and the mapping refuses it — but only if the number
* reaches the mapping, so this must not substitute a usable-looking value for
* an unusable one.
*
* `|| 1` is what did that: it reads as "default when absent" and also fires
* on a measured 0. It is not needed for the untransformed case either, since
* `getComputedStyle` reports `"none"` there and `DOMMatrixReadOnly` parses
* that to the identity, whose `a` is already 1.
*/
async function frameScale(): Promise<number> {
return page.evaluate(() => {
const frame = document.querySelector("iframe");
if (!(frame instanceof HTMLElement)) return 1;
return new DOMMatrixReadOnly(getComputedStyle(frame).transform).a || 1;
return new DOMMatrixReadOnly(getComputedStyle(frame).transform).a;
});
}

Expand Down Expand Up @@ -156,9 +173,26 @@ export function createPocDriver(page: Page): CanvasDriver {
},

async frameOrigin() {
const box = await page.locator("iframe").boundingBox();
const frame = page.locator("iframe");
const box = await frame.boundingBox();
if (!box) throw new Error("canvas iframe has no box");
return { x: box.x, y: box.y };
// The CONTENT origin, not the border-box corner. `boundingBox()` reports
// the border box, while every rectangle read inside the frame is relative
// to the content viewport — so on a frame with any border the two differ
// by `clientLeft`/`clientTop` and every mapped point lands a couple of
// pixels out. A canvas that does not reset the browser's default iframe
// border has that gap from the first render, and it reads as "the
// indicator feels slightly off" rather than as a fault.
//
// Measured here, converted there. `clientLeft` is in the frame's own
// untransformed pixels while the box is post-transform, so the two cannot
// be added without the scale, and doing that sum at the call site is what
// put the same error in two files.
const inset = await frame.evaluate<FrameInset, HTMLIFrameElement>(el => ({
left: el.clientLeft,
top: el.clientTop,
}));
return frameContentOrigin(box, inset, await frameScale());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},

async readBlockBoxes() {
Expand Down Expand Up @@ -212,7 +246,15 @@ export function createPocDriver(page: Page): CanvasDriver {
let best = -1;
let bestDistance = Number.POSITIVE_INFINITY;
rects.forEach((rect, index) => {
const centre = origin.y + (rect.y + rect.height / 2) * scale;
// Mapped by the shared helper rather than multiplied out here. Written
// inline this is two numbers scaled and added, which is exactly the
// shape no import scan can tell from ordinary arithmetic — so it is the
// one that drifts silently when the mapping is corrected.
const centre = mapFramePointToHost(
{ x: 0, y: rect.y + rect.height / 2 },
origin,
scale
).y;
const distance = Math.abs(pointerY - centre);
if (distance < bestDistance) {
bestDistance = distance;
Expand Down Expand Up @@ -453,20 +495,20 @@ export function createPocDriver(page: Page): CanvasDriver {
);
if (!inFrame) return null;

const frameOrigin = await page.locator("iframe").boundingBox();
if (!frameOrigin) return null;
// The driver's own content origin, not a second reading of the frame's
// box. `boundingBox()` reports the BORDER box, so building the origin
// here would place every indicator rectangle `inset * scale` out on any
// bordered canvas — the same fault, in a third place, which is the sign
// that no caller should be assembling this at all.
const origin = await driver.frameOrigin();

// Read the live transform rather than assume 1. Without this the rect
// reported under a scaled canvas is wrong by the scale factor, and a
// geometry assertion against it would fail because the MEASUREMENT is
// wrong, not because the canvas is.
const scale = await frameScale();

return mapFrameRectToHost(
inFrame,
{ x: frameOrigin.x, y: frameOrigin.y },
scale
);
return mapFrameRectToHost(inFrame, origin, scale);
},

async readTreeShape() {
Expand Down
Loading
Loading