mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
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.
242 lines
6.1 KiB
TypeScript
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();
|
|
});
|
|
});
|