Files
webstudio/apps/builder/app/canvas/test-utils.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

93 lines
2.1 KiB
TypeScript

import type { Instances } from "@webstudio-is/sdk";
import { idAttribute, selectorIdAttribute } from "@webstudio-is/react-sdk";
export const createCanvasElement = ({
selector,
rect,
parent = document.body,
}: {
selector: string;
rect?: { left: number; top: number; width: number; height: number };
parent?: HTMLElement;
}) => {
const element = document.createElement("div");
const [id] = selector.split(",");
element.setAttribute(idAttribute, id);
element.setAttribute(selectorIdAttribute, selector);
if (rect !== undefined) {
element.getBoundingClientRect = () =>
DOMRect.fromRect({
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
});
}
parent.appendChild(element);
return element;
};
export const createBoundTextInstances = (): Instances =>
new Map([
[
"bound-text",
{
type: "instance",
id: "bound-text",
component: "ws:element",
tag: "span",
children: [{ type: "expression", value: "value" }],
},
],
]);
export const createMixedBoundTextInstances = (): Instances =>
new Map([
[
"separator",
{
type: "instance",
id: "separator",
component: "ws:element",
tag: "span",
children: [{ type: "text", value: " · " }],
},
],
[
"reading-time",
{
type: "instance",
id: "reading-time",
component: "ws:element",
tag: "span",
children: [
{ type: "text", value: " · " },
{ type: "expression", value: 'readTime ?? ""' },
],
},
],
[
"paragraph",
{
type: "instance",
id: "paragraph",
component: "ws:element",
tag: "p",
children: [
{ type: "text", value: "" },
{ type: "id", value: "separator" },
{ type: "id", value: "reading-time" },
],
},
],
[
"body",
{
type: "instance",
id: "body",
component: "Body",
children: [{ type: "id", value: "paragraph" }],
},
],
]);