mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-26 21:17:40 +09:00
## Summary This improves how agents discover, author, inspect, and verify Webstudio projects while reducing unnecessary MCP context usage. ## What changed - Adds an **Agents** section to Project Settings with ready-to-run setup commands and client configuration for Claude Code, Codex, Cursor, and VS Code. - Makes large list and schema responses compact and paginated by default, with **verbose** available when complete records or diagnostics are needed. - Applies consistent compact output to assets, instances, styles, design tokens, CLI schemas, and project-session metadata. - Returns actionable expression warnings for props, resources, and Collection bindings, including unknown variables and risky render-time mutation resources. - Improves Collection expression guidance so agents can bind array items with the correct scoped variables. - Reports rendered audit outcomes explicitly as complete, partial, confirmation-required, or failed. - Expands runtime output schemas so MCP clients can validate responses reliably. - Strengthens the packaged CLI smoke workflow with a realistic seven-page stdio MCP scenario. ## Architecture - Consolidates `project-build` from 65 file-oriented entrypoints to 11 architecture-owned surfaces. - Separates the core model, contracts, runtime authoring, transfer, state, project sessions, MCP, visual tooling, persistence codecs, and authenticated server services. - Isolates Node-only JSX evaluation from browser-safe transfer utilities and authenticated server modules. - Keeps persistence codecs independent from authenticated database services, preventing unrelated module initialization. - Defines shared instance-path types once and reuses them across runtime modules. - Preserves Immer initialization through explicit package side-effect metadata. - Adds package tests that prevent a return to source-file entrypoints or generic exposure of Node-only surfaces. ## Validation - Full repository checks, tests, typechecks, generated-file checks, and fixtures - All 163 Builder test files and 2,141 tests - All 71 project-build test files and 1,794 tests - Lint and package-boundary validation - Full packed CLI release smoke test, including install, preview, agent setup, seven-page stdio MCP workflow, rendered audit, and structured commands
416 lines
11 KiB
TypeScript
416 lines
11 KiB
TypeScript
import type { Instance } from "@webstudio-is/sdk";
|
|
import { idAttribute, selectorIdAttribute } from "@webstudio-is/react-sdk";
|
|
import type { InstanceSelector } from "@webstudio-is/project-build/runtime";
|
|
import { getIsVisuallyHidden } from "./visually-hidden";
|
|
|
|
export const getInstanceIdFromElement = (
|
|
element: Element
|
|
): Instance["id"] | undefined => {
|
|
return element.getAttribute(idAttribute) ?? undefined;
|
|
};
|
|
|
|
// traverse dom to the root and find all instances
|
|
export const getInstanceSelectorFromElement = (element: Element) => {
|
|
// Change logic to support Portals
|
|
const matched: undefined | Element =
|
|
element.closest(`[${idAttribute}]`) ?? undefined;
|
|
|
|
const instanceSelector: InstanceSelector =
|
|
matched?.getAttribute(selectorIdAttribute)?.split(",") ?? [];
|
|
|
|
if (instanceSelector.length === 0) {
|
|
return;
|
|
}
|
|
return instanceSelector;
|
|
};
|
|
|
|
export const getElementByInstanceSelector = (
|
|
instanceSelector: InstanceSelector | Readonly<InstanceSelector>
|
|
) => {
|
|
return (
|
|
document.querySelector<HTMLElement>(
|
|
`[${selectorIdAttribute}="${instanceSelector.join(",")}"]`
|
|
) ?? undefined
|
|
);
|
|
};
|
|
|
|
// Determine if the element is detached, or lacks visual layout.
|
|
// We want to exclude elements that are display: none, option tags, or are not in the DOM
|
|
const hasLayout = (element: HTMLElement) => {
|
|
// Detached element
|
|
if (false === document.documentElement.contains(element)) {
|
|
return false;
|
|
}
|
|
|
|
if (element.tagName.toLowerCase() === "option") {
|
|
return false;
|
|
}
|
|
|
|
// Display none
|
|
if (getComputedStyle(element)?.display?.toLowerCase() === "none") {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
export const getVisibleElementsByInstanceSelector = (
|
|
instanceSelector: InstanceSelector | Readonly<InstanceSelector>
|
|
) => {
|
|
return getElementsByInstanceSelector(instanceSelector, true);
|
|
};
|
|
|
|
export const getAllElementsByInstanceSelector = (
|
|
instanceSelector: InstanceSelector | Readonly<InstanceSelector>
|
|
) => {
|
|
return getElementsByInstanceSelector(instanceSelector, false);
|
|
};
|
|
|
|
/**
|
|
* Get root visible elements, even if instance
|
|
**/
|
|
const getElementsByInstanceSelector = (
|
|
instanceSelector: InstanceSelector | Readonly<InstanceSelector>,
|
|
skipHidden: boolean
|
|
) => {
|
|
const descendantsOrSelf = [
|
|
...document.querySelectorAll<HTMLElement>(
|
|
`[${selectorIdAttribute}$="${instanceSelector.join(",")}"]`
|
|
),
|
|
].filter((element) =>
|
|
skipHidden ? getIsVisuallyHidden(element) === false : true
|
|
);
|
|
|
|
const visibleIdSelectors = descendantsOrSelf.map(
|
|
(element) => element.getAttribute(selectorIdAttribute) ?? ""
|
|
);
|
|
|
|
// Find root selectors (i.e. selectors that are not descendants of other selectors)
|
|
let rootSelectors = [...visibleIdSelectors];
|
|
const isDescendant = (testSelector: string, selector: string) =>
|
|
testSelector.endsWith(`,${selector}`);
|
|
|
|
for (const selector of visibleIdSelectors) {
|
|
rootSelectors = rootSelectors.filter(
|
|
(rootSelector) => isDescendant(rootSelector, selector) === false
|
|
);
|
|
}
|
|
|
|
const rootSelectorSet = new Set(rootSelectors);
|
|
|
|
const rootElements = descendantsOrSelf.filter((element) =>
|
|
rootSelectorSet.has(element.getAttribute(selectorIdAttribute) ?? "")
|
|
);
|
|
|
|
return rootElements.map((element) => {
|
|
let elementResult: HTMLElement = element;
|
|
|
|
while (
|
|
skipHidden &&
|
|
false === hasLayout(elementResult) &&
|
|
elementResult.parentElement !== null
|
|
) {
|
|
elementResult = elementResult.parentElement;
|
|
}
|
|
|
|
return elementResult;
|
|
});
|
|
};
|
|
|
|
type Rect = {
|
|
top: number;
|
|
right: number;
|
|
bottom: number;
|
|
left: number;
|
|
};
|
|
|
|
const sumRects = (first: Rect, second: Rect) => {
|
|
return {
|
|
top: Math.min(first.top, second.top),
|
|
right: Math.max(first.right, second.right),
|
|
bottom: Math.max(first.bottom, second.bottom),
|
|
left: Math.min(first.left, second.left),
|
|
};
|
|
};
|
|
|
|
type BoundingBoxOptions = {
|
|
fallbackToParent?: boolean;
|
|
};
|
|
|
|
const getElementsBoundingBox = (
|
|
elements: Element[],
|
|
options: BoundingBoxOptions,
|
|
depth: number
|
|
): DOMRect => {
|
|
const rects: Rect[] = [];
|
|
|
|
if (elements.length === 0) {
|
|
return DOMRect.fromRect({ width: 0, height: 0, x: 0, y: 0 });
|
|
}
|
|
|
|
for (const element of elements) {
|
|
const rect = element.getBoundingClientRect();
|
|
|
|
// possible display: contents
|
|
if (rect.width !== 0 || rect.height !== 0) {
|
|
rects.push(rect);
|
|
continue;
|
|
}
|
|
|
|
if (element.children.length === 0) {
|
|
const textNode = element.firstChild;
|
|
|
|
if (textNode?.nodeType === Node.TEXT_NODE) {
|
|
// Create a range object
|
|
const range = document.createRange();
|
|
// Set the range to encompass the text node
|
|
range.selectNodeContents(textNode);
|
|
// Get the bounding rectangle
|
|
const rect = range.getBoundingClientRect();
|
|
|
|
if (rect.width !== 0 || rect.height !== 0) {
|
|
rects.push(rect);
|
|
range.detach();
|
|
continue;
|
|
}
|
|
range.detach();
|
|
}
|
|
}
|
|
|
|
if (element.children.length > 0) {
|
|
const childRect = getElementsBoundingBox(
|
|
[...element.children],
|
|
options,
|
|
depth + 1
|
|
);
|
|
if (childRect.width !== 0 || childRect.height !== 0) {
|
|
const { top, right, bottom, left } = childRect;
|
|
rects.push({ top, right, bottom, left });
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (depth > 0 || options.fallbackToParent === false) {
|
|
continue;
|
|
}
|
|
|
|
// We here, let's try ancestor size
|
|
const parentElement = element.parentElement;
|
|
if (parentElement === null) {
|
|
continue;
|
|
}
|
|
const parentRect = getElementsBoundingBox([parentElement], {}, 0);
|
|
|
|
if (parentRect.width !== 0 || parentRect.height !== 0) {
|
|
const { top, right, bottom, left } = parentRect;
|
|
rects.push({ top, right, bottom, left });
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (rects.length === 0) {
|
|
// To preserve position even if width/height is 0
|
|
return elements[0].getBoundingClientRect();
|
|
}
|
|
|
|
const { top, right, bottom, left } = rects.reduce(sumRects);
|
|
|
|
return DOMRect.fromRect({
|
|
x: left,
|
|
y: top,
|
|
height: bottom - top,
|
|
width: right - left,
|
|
});
|
|
};
|
|
|
|
export const getAllElementsBoundingBox = (
|
|
elements: Element[],
|
|
options: BoundingBoxOptions = {}
|
|
): DOMRect => {
|
|
return getElementsBoundingBox(elements, options, 0);
|
|
};
|
|
|
|
const doNotTrackMutationAttribute = "data-ws-do-not-track-mutation";
|
|
|
|
export const doNotTrackMutation = (element: Element) => {
|
|
element.setAttribute(doNotTrackMutationAttribute, "true");
|
|
};
|
|
|
|
export const hasDoNotTrackMutationAttribute = (element: Element) => {
|
|
return element.hasAttribute(doNotTrackMutationAttribute);
|
|
};
|
|
|
|
export const hasDoNotTrackMutationRecord = (
|
|
mutationRecords: MutationRecord[]
|
|
) => {
|
|
return mutationRecords.some((record) =>
|
|
record.type === "childList"
|
|
? [...record.addedNodes.values()].some(
|
|
(node) =>
|
|
node instanceof Element &&
|
|
node.hasAttribute(doNotTrackMutationAttribute)
|
|
)
|
|
: false
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Get a DOMMatrix mapping the container's local coords to viewport coords.
|
|
* This uses one test DIV (width=100, height=100) placed at (0,0) in container space.
|
|
*/
|
|
const getLocalToViewportMatrix = (container: Element): DOMMatrix => {
|
|
const rectSize = 100;
|
|
|
|
const testDiv = document.createElement("div");
|
|
Object.assign(testDiv.style, {
|
|
position: "absolute",
|
|
left: "0px",
|
|
top: "0px",
|
|
width: `${rectSize}px`,
|
|
height: `${rectSize}px`,
|
|
pointerEvents: "none",
|
|
background: "transparent",
|
|
visibility: "hidden",
|
|
});
|
|
container.appendChild(testDiv);
|
|
testDiv.setAttribute(doNotTrackMutationAttribute, "true");
|
|
|
|
const { left, top, width, height } = testDiv.getBoundingClientRect();
|
|
container.removeChild(testDiv);
|
|
|
|
const x1 = left;
|
|
const y1 = top;
|
|
const x2 = left + width;
|
|
const y2 = top;
|
|
const x3 = left;
|
|
const y3 = top + height;
|
|
|
|
const a = (x2 - x1) / rectSize;
|
|
const b = (y2 - y1) / rectSize;
|
|
const c = (x3 - x1) / rectSize;
|
|
const d = (y3 - y1) / rectSize;
|
|
const e = x1;
|
|
const f = y1;
|
|
|
|
return new DOMMatrix([a, b, c, d, e, f]);
|
|
};
|
|
|
|
const getViewportToLocalMatrix = (container: Element): DOMMatrix => {
|
|
return getLocalToViewportMatrix(container).inverse();
|
|
};
|
|
|
|
const transformDOMRect = (rect: DOMRect, matrix: DOMMatrix) => {
|
|
const topLeft = new DOMPoint(rect.x, rect.y).matrixTransform(matrix);
|
|
const topRight = new DOMPoint(rect.x + rect.width, rect.y).matrixTransform(
|
|
matrix
|
|
);
|
|
const bottomLeft = new DOMPoint(rect.x, rect.y + rect.height).matrixTransform(
|
|
matrix
|
|
);
|
|
const bottomRight = new DOMPoint(
|
|
rect.x + rect.width,
|
|
rect.y + rect.height
|
|
).matrixTransform(matrix);
|
|
|
|
const xs = [topLeft.x, topRight.x, bottomLeft.x, bottomRight.x];
|
|
const ys = [topLeft.y, topRight.y, bottomLeft.y, bottomRight.y];
|
|
const minX = Math.min(...xs);
|
|
const maxX = Math.max(...xs);
|
|
const minY = Math.min(...ys);
|
|
const maxY = Math.max(...ys);
|
|
|
|
return new DOMRect(minX, minY, maxX - minX, maxY - minY);
|
|
};
|
|
|
|
/**
|
|
* `overflow: hidden` can be scrolled
|
|
*/
|
|
const isScrollable = (
|
|
node: HTMLElement,
|
|
checkForOverflow: boolean
|
|
): boolean => {
|
|
if (!node) {
|
|
return false;
|
|
}
|
|
const style = window.getComputedStyle(node);
|
|
|
|
let isScrollable = /(auto|scroll|hidden)/.test(
|
|
style.overflow + style.overflowX + style.overflowY
|
|
);
|
|
|
|
if (isScrollable && checkForOverflow) {
|
|
isScrollable =
|
|
node.scrollHeight !== node.clientHeight ||
|
|
node.scrollWidth !== node.clientWidth;
|
|
}
|
|
|
|
return isScrollable;
|
|
};
|
|
|
|
const getScrollParent = (
|
|
node: HTMLElement,
|
|
checkForOverflow: boolean
|
|
): HTMLElement | null => {
|
|
for (
|
|
let scrollableNode = node.parentElement;
|
|
scrollableNode !== null;
|
|
scrollableNode = scrollableNode.parentElement
|
|
) {
|
|
if (isScrollable(scrollableNode, checkForOverflow)) {
|
|
return scrollableNode;
|
|
}
|
|
}
|
|
|
|
return document.documentElement;
|
|
};
|
|
|
|
/**
|
|
* We scroll using rectangle and anchor calculations because `scrollIntoView` does not work
|
|
* reliably for certain elements, such as those with `display: contents`.
|
|
* For these elements, we display a selected or hovered outline on the canvas using the
|
|
* bounding rectangles of their children or the selection range.
|
|
* Here, we ensure scrolling works for these elements as well.
|
|
*/
|
|
export const scrollIntoView = (anchor: HTMLElement, rect: DOMRect) => {
|
|
const scrollParent = getScrollParent(anchor, true);
|
|
|
|
if (false === scrollParent instanceof HTMLElement) {
|
|
return;
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
const savedPosition = (scrollParent as HTMLElement).style.position;
|
|
// avoid updating <html> to prevent full page repaint and freeze on big projects
|
|
if (scrollParent.tagName !== "HTML") {
|
|
(scrollParent as HTMLElement).style.position = "relative";
|
|
}
|
|
|
|
const matrix = getViewportToLocalMatrix(scrollParent);
|
|
|
|
const transformedRect = transformDOMRect(rect, matrix);
|
|
|
|
const scrollDiv = document.createElement("div");
|
|
|
|
Object.assign(scrollDiv.style, {
|
|
position: "absolute",
|
|
left: `${transformedRect.left}px`,
|
|
top: `${transformedRect.top}px`,
|
|
width: `${transformedRect.width}px`,
|
|
height: `${transformedRect.height}px`,
|
|
pointerEvents: "none",
|
|
background: "transparent",
|
|
scrollMargin: "20px",
|
|
});
|
|
scrollParent.appendChild(scrollDiv);
|
|
|
|
scrollDiv.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
|
|
scrollParent.removeChild(scrollDiv);
|
|
|
|
// avoid updating <html> to prevent full page repaint and freeze on big projects
|
|
if (scrollParent.tagName !== "HTML") {
|
|
(scrollParent as HTMLElement).style.position = savedPosition;
|
|
}
|
|
});
|
|
};
|