Files
webstudio/apps/builder/app/canvas/selected-instance-effects.test.ts
Oleg Isonen 08e1e19b3f fix: expose existing text bindings in mixed content (#6008)
Closes #5992

## Summary

Expose the existing **Text content** binding control when a directly
text-capable selected instance contains expressions alongside other
content.

The supplied reading-time span keeps its existing child sequence:

- static prefix text
- bound expression

No data migration or new Settings editor is introduced. Normalization
happens only when the user explicitly resets a binding.
Expression-bearing nodes are excluded from canvas text editing because
the canvas editor cannot represent bindings safely. Literal descendants
in mixed trees remain canvas-editable, while bindings remain editable
through Settings.

## Implementation

- Reuse the existing scalar Text content and binding UI.
- Target expression children with indexed `instances.updateText`
mutations, preserving instance boundaries and unrelated siblings.
- Fail closed when the selected instance is missing or no longer has a
safe text target.
- Evaluate expressions through the binding control's existing live
scope.
- Centralize text-content target classification in project-build for
reuse by the engine and Builder.
- Reset every expression atomically through the engine's
`instances.setTextContent` operation, validate the complete replacement
set, and coalesce adjacent text nodes without crossing nested-instance
boundaries.
- Centralize expression-free canvas text-editor targeting in the
project-build lookup module.
- Route double-click, Enter, post-insert editing, cursor generation, and
keyboard navigation through the shared lookup rules.
- Exclude single-expression and mixed/nested expression targets from the
canvas editor in every mode.
- Preserve canvas editing for literal descendants inside otherwise mixed
content.
- Share canvas fixtures and simplify binding-removal capability to an
optional handler.

## Verification

- [x] Settings exposes and edits mixed text/expression targets without
replacing siblings.
- [x] Reset binding converts every expression on the instance and
normalizes adjacent text.
- [x] Missing, unsupported, stale, and incomplete replacement sets fail
closed.
- [x] Double-click and Enter do not open bound nodes in the canvas
editor.
- [x] Literal descendants within mixed trees remain canvas-editable.
- [x] Complete Builder suite passes (2,497 tests).
- [x] Complete project-build suite passes (2,070 tests).
- [x] New targeted Builder and project-build regression tests pass (175
tests).
- [x] Builder and project-build typechecks pass.
- [x] Repository lint passes.
- [x] Package-boundary validation passes.
- [x] Generated runtime operation contracts are updated from source.
- [x] Fixture inputs and outputs are unaffected.
- [x] Documentation impact reviewed: no documentation update is needed.
- [ ] Interactive browser smoke test was unavailable because the in-app
browser failed to initialize.
- [ ] Agent evaluations were not run because they require separate
approval.

## CI note

The previous head's Main workflow failed while pulling the E2E database
image with Docker Hub `toomanyrequests: Rate exceeded`. Lost Pixel
failed separately with a certificate error. Both are external
infrastructure failures; branch formatting, lint, package boundaries,
generated contracts/docs, tests, and typechecks were successful in that
run.

Supersedes #5993, which GitHub closed automatically when its head branch
was renamed.
2026-08-04 15:36:19 +02:00

242 lines
6.1 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
$allSelectedInstanceSelectors,
$selectedInstanceOutline,
$selectedInstanceOutlines,
selectInstances,
} from "~/shared/nano-states";
import { $instances, $styleSourceSelections } from "~/shared/sync/data-stores";
import { subscribeSelected } from "./selected-instance-effects";
import { subscribeInstanceSelection } from "./instance-selection-events";
import { createCanvasElement } from "./test-utils";
const createInstance = (id: string) => ({
type: "instance" as const,
id,
component: "Box",
children: [],
});
class ResizeObserver {
observe() {}
disconnect() {}
}
class TestDOMRect {
left: number;
top: number;
width: number;
height: number;
x: number;
y: number;
right: number;
bottom: number;
constructor(x = 0, y = 0, width = 0, height = 0) {
this.left = x;
this.top = y;
this.width = width;
this.height = height;
this.x = x;
this.y = y;
this.right = x + width;
this.bottom = y + height;
}
static fromRect(rect: Partial<globalThis.DOMRectReadOnly>) {
return new TestDOMRect(rect.x, rect.y, rect.width, rect.height);
}
toJSON() {
return this;
}
}
class TestDOMMatrix {
inverse() {
return this;
}
}
class TestDOMPoint {
x: number;
y: number;
constructor(x = 0, y = 0) {
this.x = x;
this.y = y;
}
matrixTransform() {
return this;
}
}
let unsubscribe = () => {};
let abortController: AbortController;
beforeEach(() => {
vi.stubGlobal("ResizeObserver", ResizeObserver);
vi.stubGlobal("DOMRect", TestDOMRect);
vi.stubGlobal("DOMMatrix", TestDOMMatrix);
vi.stubGlobal("DOMPoint", TestDOMPoint);
vi.stubGlobal("requestAnimationFrame", () => 0);
$instances.set(
new Map([
["box", createInstance("box")],
["heading", createInstance("heading")],
])
);
$styleSourceSelections.set(new Map());
$allSelectedInstanceSelectors.set([]);
$selectedInstanceOutline.set(undefined);
$selectedInstanceOutlines.set([]);
document.body.innerHTML = "";
abortController = new AbortController();
});
afterEach(() => {
abortController.abort();
unsubscribe();
unsubscribe = () => {};
vi.unstubAllGlobals();
document.body.innerHTML = "";
$instances.set(new Map());
$allSelectedInstanceSelectors.set([]);
$selectedInstanceOutline.set(undefined);
$selectedInstanceOutlines.set([]);
});
describe("subscribeSelected", () => {
test("creates both outline stores for one selected instance", () => {
createCanvasElement({
selector: "box,body",
rect: { left: 10, top: 20, width: 100, height: 50 },
});
unsubscribe = subscribeSelected((callback) => callback());
selectInstances([["box", "body"]]);
expect($selectedInstanceOutlines.get()).toEqual([
expect.objectContaining({
selector: ["box", "body"],
instanceId: "box",
}),
]);
expect(Object.keys($selectedInstanceOutlines.get()[0].rect)).toEqual([
"top",
"left",
"width",
"height",
]);
expect($selectedInstanceOutline.get()).toEqual(
expect.objectContaining({
instanceId: "box",
})
);
});
test("creates an outline for every selected instance without single outline", () => {
createCanvasElement({
selector: "box,body",
rect: { left: 10, top: 20, width: 100, height: 50 },
});
createCanvasElement({
selector: "heading,body",
rect: { left: 20, top: 40, width: 140, height: 70 },
});
unsubscribe = subscribeSelected((callback) => callback());
selectInstances([
["box", "body"],
["heading", "body"],
]);
expect(
$selectedInstanceOutlines.get().map((outline) => outline.selector)
).toEqual([
["box", "body"],
["heading", "body"],
]);
expect($selectedInstanceOutline.get()).toBeUndefined();
});
test("does not fall back to parent rect for zero-size multi-selected instances", () => {
const parent = createCanvasElement({
selector: "parent,body",
rect: { left: 5, top: 10, width: 500, height: 300 },
});
createCanvasElement({
selector: "box,parent,body",
rect: { left: 0, top: 0, width: 0, height: 0 },
parent,
});
createCanvasElement({
selector: "heading,parent,body",
rect: { left: 20, top: 40, width: 140, height: 70 },
parent,
});
unsubscribe = subscribeSelected((callback) => callback());
selectInstances([
["box", "parent", "body"],
["heading", "parent", "body"],
]);
expect($selectedInstanceOutlines.get()[0]).toEqual(
expect.objectContaining({
selector: ["box", "parent", "body"],
rect: expect.objectContaining({
left: 0,
top: 0,
width: 0,
height: 0,
}),
})
);
expect(
$selectedInstanceOutlines
.get()
.some(
(outline) => outline.rect.width === 500 && outline.rect.height === 300
)
).toBe(false);
});
test("creates outlines when instances are selected by canvas clicks", () => {
const box = createCanvasElement({
selector: "box,body",
rect: { left: 10, top: 20, width: 100, height: 50 },
});
const heading = createCanvasElement({
selector: "heading,body",
rect: { left: 20, top: 40, width: 140, height: 70 },
});
unsubscribe = subscribeSelected((callback) => callback());
subscribeInstanceSelection({ signal: abortController.signal });
box.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(
$selectedInstanceOutlines.get().map((outline) => outline.selector)
).toEqual([["box", "body"]]);
expect($selectedInstanceOutline.get()).toEqual(
expect.objectContaining({
instanceId: "box",
})
);
heading.dispatchEvent(
new MouseEvent("click", { bubbles: true, metaKey: true })
);
expect(
$selectedInstanceOutlines.get().map((outline) => outline.selector)
).toEqual([
["box", "body"],
["heading", "body"],
]);
expect($selectedInstanceOutline.get()).toBeUndefined();
});
});