mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-27 15:16:53 +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
230 lines
6.8 KiB
TypeScript
230 lines
6.8 KiB
TypeScript
import { idAttribute } from "@webstudio-is/react-sdk";
|
|
import {
|
|
$blockChildOutline,
|
|
$hoveredInstanceSelector,
|
|
$textEditingInstanceSelector,
|
|
} from "~/shared/nano-states";
|
|
import { $instances } from "~/shared/sync/data-stores";
|
|
import { $hoveredInstanceOutline } from "~/shared/nano-states";
|
|
import {
|
|
getAllElementsBoundingBox,
|
|
getVisibleElementsByInstanceSelector,
|
|
getInstanceSelectorFromElement,
|
|
} from "~/shared/dom-utils";
|
|
import { subscribeScrollState } from "./shared/scroll-state";
|
|
import {
|
|
isDescendantOrSelf,
|
|
type InstanceSelector,
|
|
} from "@webstudio-is/project-build/runtime";
|
|
import { findBlockChildSelector } from "@webstudio-is/project-build/runtime";
|
|
import { $selectedInstanceSelector } from "~/shared/nano-states";
|
|
|
|
type TimeoutId = undefined | ReturnType<typeof setTimeout>;
|
|
|
|
export const subscribeInstanceHovering = ({
|
|
signal,
|
|
}: {
|
|
signal: AbortSignal;
|
|
}) => {
|
|
let hoveredElement: undefined | Element = undefined;
|
|
let updateOnMouseMove = false;
|
|
let isScrolling = false;
|
|
|
|
let mouseOutTimeoutId: TimeoutId = undefined;
|
|
|
|
const eventOptions = { passive: true, signal };
|
|
|
|
window.addEventListener(
|
|
"mouseover",
|
|
(event) => {
|
|
if (event.target instanceof Element) {
|
|
const element = event.target.closest(`[${idAttribute}]`) ?? undefined;
|
|
if (element !== undefined) {
|
|
clearTimeout(mouseOutTimeoutId);
|
|
// store hovered element locally to update outline when scroll ends
|
|
hoveredElement = element;
|
|
updateOnMouseMove = true;
|
|
}
|
|
}
|
|
},
|
|
eventOptions
|
|
);
|
|
|
|
const updateEditableOutline = () => {
|
|
// We want the hover outline to appear only if a mouse or trackpad action caused it, not from keyboard navigation.
|
|
// Otherwise, when we leave the Lexical editor using the keyboard,
|
|
// the mouseover event triggers on elements created after Lexical loses focus.
|
|
// This causes an outline to appear on the element under the now-invisible mouse pointer
|
|
// (as the browser hides the pointer on blur), creating some visual distraction.
|
|
if (hoveredElement !== undefined) {
|
|
const instanceSelector = getInstanceSelectorFromElement(hoveredElement);
|
|
if (instanceSelector) {
|
|
if (updateOnMouseMove) {
|
|
$hoveredInstanceSelector.set(instanceSelector);
|
|
updateEditableChildOutline(instanceSelector);
|
|
} else {
|
|
const textSelector = $textEditingInstanceSelector.get()?.selector;
|
|
|
|
// We need to update the editable child's outline even if the mouseover event is not triggered.
|
|
// This can happen if the user enters text editing mode, presses any key, and then moves the mouse.
|
|
// In this case, the mouseover event does not occur, but we still need to show the editable child's outline.
|
|
if (
|
|
textSelector &&
|
|
isDescendantOrSelf(instanceSelector, textSelector) && // optimisation
|
|
$blockChildOutline.get() === undefined
|
|
) {
|
|
updateEditableChildOutline(instanceSelector);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
updateOnMouseMove = false;
|
|
};
|
|
|
|
window.addEventListener(
|
|
"click",
|
|
() => {
|
|
// Fixes the bug if initial editable instance is empty and has collapsed paddings
|
|
setTimeout(updateEditableOutline, 0);
|
|
},
|
|
eventOptions
|
|
);
|
|
|
|
window.addEventListener("mousemove", updateEditableOutline, eventOptions);
|
|
|
|
window.addEventListener(
|
|
"mouseout",
|
|
() => {
|
|
updateOnMouseMove = false;
|
|
hoveredElement = undefined;
|
|
|
|
mouseOutTimeoutId = setTimeout(() => {
|
|
$blockChildOutline.set(undefined);
|
|
$hoveredInstanceSelector.set(undefined);
|
|
$hoveredInstanceOutline.set(undefined);
|
|
}, 100);
|
|
|
|
// Fixes the bug, that new hover occures during timeout
|
|
const unsubscribe = $hoveredInstanceSelector.listen(() => {
|
|
clearTimeout(mouseOutTimeoutId);
|
|
unsubscribe();
|
|
});
|
|
},
|
|
eventOptions
|
|
);
|
|
|
|
const updateEditableChildOutline = (instanceSelector: InstanceSelector) => {
|
|
if (isScrolling) {
|
|
return;
|
|
}
|
|
|
|
if (instanceSelector.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const blockChildSelector = findBlockChildSelector({
|
|
instanceSelector,
|
|
instances: $instances.get(),
|
|
});
|
|
|
|
if (blockChildSelector === undefined) {
|
|
$blockChildOutline.set(undefined);
|
|
return;
|
|
}
|
|
|
|
const blockChildElements =
|
|
getVisibleElementsByInstanceSelector(blockChildSelector);
|
|
const blockChildRect = getAllElementsBoundingBox(blockChildElements);
|
|
|
|
if (blockChildRect === undefined) {
|
|
$blockChildOutline.set(undefined);
|
|
return;
|
|
}
|
|
|
|
$blockChildOutline.set({
|
|
selector: blockChildSelector,
|
|
hoveredSelector: instanceSelector,
|
|
rect: blockChildRect,
|
|
});
|
|
};
|
|
|
|
const updateHoveredRect = (instanceSelector: Readonly<InstanceSelector>) => {
|
|
if (isScrolling) {
|
|
return;
|
|
}
|
|
|
|
if (instanceSelector.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const elements = getVisibleElementsByInstanceSelector(instanceSelector);
|
|
|
|
if (elements.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const [instanceId] = instanceSelector;
|
|
const instances = $instances.get();
|
|
const instance = instances.get(instanceId);
|
|
if (instance === undefined) {
|
|
return;
|
|
}
|
|
|
|
$hoveredInstanceOutline.set({
|
|
instanceId: instance.id,
|
|
rect: getAllElementsBoundingBox(elements),
|
|
});
|
|
};
|
|
|
|
// remove hover outline when scroll starts
|
|
// and show it with new rect when scroll ends
|
|
const unsubscribeScrollState = subscribeScrollState({
|
|
onScrollStart() {
|
|
isScrolling = true;
|
|
$hoveredInstanceOutline.set(undefined);
|
|
$blockChildOutline.set(undefined);
|
|
},
|
|
onScrollEnd() {
|
|
isScrolling = false;
|
|
if (hoveredElement !== undefined) {
|
|
const instanceSelector = getInstanceSelectorFromElement(hoveredElement);
|
|
|
|
if (instanceSelector === undefined) {
|
|
return;
|
|
}
|
|
|
|
updateHoveredRect(instanceSelector);
|
|
}
|
|
},
|
|
});
|
|
|
|
// update rect whenever hovered instance is changed
|
|
const unsubscribeHoveredInstanceId = $hoveredInstanceSelector.subscribe(
|
|
(instanceSelector) => {
|
|
if (instanceSelector) {
|
|
updateHoveredRect(instanceSelector);
|
|
return;
|
|
}
|
|
$hoveredInstanceOutline.set(undefined);
|
|
}
|
|
);
|
|
|
|
// selected instance selection can change hovered instance outlines (example Block/Template/Child)
|
|
const usubscribeSelectedInstanceSelector =
|
|
$selectedInstanceSelector.subscribe(() => {
|
|
const instanceSelector = $hoveredInstanceSelector.get();
|
|
if (instanceSelector) {
|
|
updateHoveredRect(instanceSelector);
|
|
return;
|
|
}
|
|
$hoveredInstanceOutline.set(undefined);
|
|
});
|
|
|
|
signal.addEventListener("abort", () => {
|
|
unsubscribeScrollState();
|
|
clearTimeout(mouseOutTimeoutId);
|
|
unsubscribeHoveredInstanceId();
|
|
usubscribeSelectedInstanceSelector();
|
|
});
|
|
};
|