feat(editor): board shell with iframe frame and selection overlay
React Flow board (pan/zoom + frame drag are pure editor-side transforms — zero canvas-protocol traffic, <16ms budget) hosting one iframe frame node on the adapter origin. The frame waits for runtime.ready (queue-drain) behind an explicit 'Starting preview…' skeleton, then requests a single Element via render-frame (core-runner-protocol schema) plus geometry; agent-reported geometry stays frame-local and click reports drive store selection. The selection ring renders as an editor-side overlay from reported geometry — chrome never inside the customer's document (ADR-028). Stories + jsdom component tests for board/frame/overlay; Storybook stories glob extended to packages/editor (verified via static build). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
@@ -19,6 +19,25 @@ export type PostedMessage = {
|
||||
targetOrigin: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Script one agent envelope onto the REAL window (jsdom) — for component
|
||||
* tests where the canvas client listens on the default editor window.
|
||||
* `MessageEvent` carries the given origin explicitly; `source` stays
|
||||
* `null`, which the client tolerates (it cannot know the source when the
|
||||
* scripted agent is not a window).
|
||||
*/
|
||||
export function postAgentEnvelopeToWindow(
|
||||
message: CanvasAgentMessage,
|
||||
origin: string,
|
||||
): void {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: { protocolVersion: PROTOCOL_VERSION, message },
|
||||
origin,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
type PostOverrides = {
|
||||
/** Origin the event claims to come from. Defaults to the double's origin. */
|
||||
origin?: string;
|
||||
|
||||
49
packages/editor/src/ui/components/editor-board.stories.tsx
Normal file
49
packages/editor/src/ui/components/editor-board.stories.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useEditorStore } from "../../store/editor-store";
|
||||
import { EditorBoard } from "./editor-board";
|
||||
|
||||
/**
|
||||
* The infinite board (ADR-028): scroll pans, pinch zooms, the frame drags
|
||||
* by its name tab — all pure React Flow transforms, zero canvas-protocol
|
||||
* traffic. The frame hosts the adapter origin in an iframe; without a live
|
||||
* adapter it stays in its honest "Starting preview…" state.
|
||||
*/
|
||||
const ADAPTER_ORIGIN = "https://adapter.veect.localhost:5199";
|
||||
|
||||
const meta = {
|
||||
title: "Editor/EditorBoard",
|
||||
component: EditorBoard,
|
||||
tags: ["autodocs"],
|
||||
args: { adapterOrigin: ADAPTER_ORIGIN },
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: "100%", height: 600 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof EditorBoard>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const WithFrame: Story = {
|
||||
decorators: [
|
||||
(Story) => {
|
||||
useEditorStore.setState({
|
||||
registry: [{ id: "button", name: "Button" }],
|
||||
selectedNodeId: null,
|
||||
});
|
||||
return <Story />;
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const EmptyRegistry: Story = {
|
||||
decorators: [
|
||||
(Story) => {
|
||||
useEditorStore.setState({ registry: [], selectedNodeId: null });
|
||||
return <Story />;
|
||||
},
|
||||
],
|
||||
};
|
||||
115
packages/editor/src/ui/components/editor-board.test.tsx
Normal file
115
packages/editor/src/ui/components/editor-board.test.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { act } from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { postAgentEnvelopeToWindow } from "@/canvas-protocol/scripted-agent.mock";
|
||||
import { useEditorStore } from "@/store/editor-store";
|
||||
import { mockReactFlow } from "./react-flow.mock";
|
||||
import { EditorBoard } from "./editor-board";
|
||||
|
||||
const ADAPTER_ORIGIN = "https://adapter.veect.test:5199";
|
||||
|
||||
mockReactFlow();
|
||||
|
||||
function renderBoard() {
|
||||
return render(
|
||||
<div style={{ width: "1200px", height: "800px" }}>
|
||||
<EditorBoard adapterOrigin={ADAPTER_ORIGIN} />
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useEditorStore.setState({ registry: [], selectedNodeId: null });
|
||||
});
|
||||
|
||||
describe("EditorBoard — empty registry", () => {
|
||||
it("renders an empty board: canvas present, no frame", () => {
|
||||
const { container } = renderBoard();
|
||||
expect(screen.getByTestId("editor-board")).toBeInTheDocument();
|
||||
expect(container.querySelector(".react-flow")).not.toBeNull();
|
||||
expect(container.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditorBoard — one frame from the registry", () => {
|
||||
beforeEach(() => {
|
||||
useEditorStore.setState({
|
||||
registry: [
|
||||
{ id: "button", name: "Button" },
|
||||
{ id: "card", name: "Card" },
|
||||
],
|
||||
selectedNodeId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("hosts one iframe frame node for the FIRST discovered component", async () => {
|
||||
renderBoard();
|
||||
const iframe = (await screen.findByTitle(
|
||||
"Button frame",
|
||||
)) as HTMLIFrameElement;
|
||||
expect(iframe).toHaveAttribute("src", ADAPTER_ORIGIN);
|
||||
// walking-skeleton scope: a single frame — the second registry entry
|
||||
// does not get a frame.
|
||||
expect(screen.queryByTitle("Card frame")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks the frame node draggable by its handle only", async () => {
|
||||
const { container } = renderBoard();
|
||||
await screen.findByTitle("Button frame");
|
||||
expect(container.querySelector(".frame-drag-handle")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("clears the selection on pane click — editor-side, no protocol traffic", async () => {
|
||||
const { container } = renderBoard();
|
||||
const iframe = (await screen.findByTitle(
|
||||
"Button frame",
|
||||
)) as HTMLIFrameElement;
|
||||
const post = vi.spyOn(iframe.contentWindow!, "postMessage");
|
||||
act(() => useEditorStore.getState().select("el-1"));
|
||||
|
||||
const pane = container.querySelector(".react-flow__pane");
|
||||
expect(pane).not.toBeNull();
|
||||
fireEvent.click(pane!);
|
||||
|
||||
expect(useEditorStore.getState().selectedNodeId).toBeNull();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pan/zoom gestures on the pane produce zero canvas-protocol traffic (<16 ms budget, ADR-028)", async () => {
|
||||
const { container } = renderBoard();
|
||||
const iframe = (await screen.findByTitle(
|
||||
"Button frame",
|
||||
)) as HTMLIFrameElement;
|
||||
const post = vi.spyOn(iframe.contentWindow!, "postMessage");
|
||||
// Drain the ready handshake first so the assertion below isolates the
|
||||
// gesture path.
|
||||
act(() =>
|
||||
postAgentEnvelopeToWindow({ type: "runtime.ready" }, ADAPTER_ORIGIN),
|
||||
);
|
||||
expect(post).toHaveBeenCalledTimes(2);
|
||||
post.mockClear();
|
||||
|
||||
const pane = container.querySelector(".react-flow__pane")!;
|
||||
fireEvent.wheel(pane, { deltaY: -120 }); // panOnScroll
|
||||
fireEvent.wheel(pane, { deltaY: 80, ctrlKey: true }); // zoomOnPinch
|
||||
// (Empty-pane mouse-drag panning can't be simulated in jsdom — it
|
||||
// refuses MouseEvent's `view` member — but frame dragging is the same
|
||||
// structural path: a pure rerender, proven protocol-silent in
|
||||
// frame-node.test.tsx.)
|
||||
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resyncs the board when the registry changes", async () => {
|
||||
renderBoard();
|
||||
await screen.findByTitle("Button frame");
|
||||
|
||||
act(() =>
|
||||
useEditorStore.getState().setRegistry([{ id: "badge", name: "Badge" }]),
|
||||
);
|
||||
|
||||
expect(await screen.findByTitle("Badge frame")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("Button frame")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
101
packages/editor/src/ui/components/editor-board.tsx
Normal file
101
packages/editor/src/ui/components/editor-board.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
applyNodeChanges,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
ReactFlow,
|
||||
type Node,
|
||||
type NodeChange,
|
||||
type NodeTypes,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { useEditorStore } from "../../store/editor-store";
|
||||
import { FrameNode, type FrameNodeData } from "./frame-node";
|
||||
|
||||
/**
|
||||
* The board shell (ADR-028): an infinite React Flow canvas whose frames are
|
||||
* iframe containers. Pan/zoom and frame dragging are pure React Flow
|
||||
* transforms on those containers — zero canvas-protocol traffic on drag
|
||||
* (<16 ms/frame budget); only the frame's name tab drags
|
||||
* (`dragHandle: ".frame-drag-handle"`).
|
||||
*
|
||||
* Walking-skeleton scope: one frame, rendering the FIRST discovered
|
||||
* registry component from the editor store. An empty registry renders an
|
||||
* empty board. Pane clicks clear the selection editor-side.
|
||||
*/
|
||||
type BoardNode = Node<FrameNodeData, "frame">;
|
||||
|
||||
const nodeTypes: NodeTypes = { frame: FrameNode };
|
||||
|
||||
export type EditorBoardProps = {
|
||||
/** The adapter's origin — pinned targetOrigin for every frame's canvas protocol. */
|
||||
adapterOrigin: string;
|
||||
};
|
||||
|
||||
export function EditorBoard({ adapterOrigin }: EditorBoardProps) {
|
||||
const registry = useEditorStore((s) => s.registry);
|
||||
const select = useEditorStore((s) => s.select);
|
||||
const component = registry[0];
|
||||
|
||||
const initialNodes = useMemo<BoardNode[]>(
|
||||
() =>
|
||||
component === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: `frame:${component.id}`,
|
||||
type: "frame",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
frame: {
|
||||
name: component.name,
|
||||
componentId: component.id,
|
||||
adapterOrigin,
|
||||
},
|
||||
},
|
||||
dragHandle: ".frame-drag-handle",
|
||||
},
|
||||
],
|
||||
[component, adapterOrigin],
|
||||
);
|
||||
|
||||
const [nodes, setNodes] = useState<BoardNode[]>(initialNodes);
|
||||
|
||||
// Resync when the registry (or adapter origin) changes.
|
||||
useEffect(() => setNodes(initialNodes), [initialNodes]);
|
||||
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange<BoardNode>[]) =>
|
||||
setNodes((current) => applyNodeChanges(changes, current)),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="editor-board"
|
||||
style={{ position: "relative", width: "100%", height: "100%" }}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={[]}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onPaneClick={() => select(null)}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.18, maxZoom: 1 }}
|
||||
minZoom={0.08}
|
||||
maxZoom={2.5}
|
||||
panOnScroll
|
||||
zoomOnScroll={false}
|
||||
zoomOnPinch
|
||||
panOnDrag={[0, 1]}
|
||||
nodesFocusable={false}
|
||||
deleteKeyCode={null}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={26} size={1} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
packages/editor/src/ui/components/frame-node.stories.tsx
Normal file
62
packages/editor/src/ui/components/frame-node.stories.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { PROTOCOL_VERSION } from "@repo/core-runner-protocol";
|
||||
import type { CanvasAgentMessage } from "../../canvas-protocol/messages";
|
||||
import { FrameNode } from "./frame-node";
|
||||
|
||||
/**
|
||||
* One frame hosting the runner adapter's origin through a sandboxed
|
||||
* cross-origin iframe (ADR-028). Storybook has no live adapter, so the
|
||||
* frame shows its honest cold-start state — the explicit
|
||||
* "Starting preview…" skeleton, never presented as the real render. The
|
||||
* scripted story below plays the agent side of the canvas protocol to
|
||||
* drive the ready → geometry → click selection round-trip.
|
||||
*/
|
||||
const ADAPTER_ORIGIN = "https://adapter.veect.localhost:5199";
|
||||
|
||||
function agentSays(message: CanvasAgentMessage) {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: { protocolVersion: PROTOCOL_VERSION, message },
|
||||
origin: ADAPTER_ORIGIN,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Editor/FrameNode",
|
||||
component: FrameNode,
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<typeof FrameNode>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const StartingPreview: Story = {
|
||||
args: {
|
||||
data: {
|
||||
frame: {
|
||||
name: "Button",
|
||||
componentId: "button",
|
||||
adapterOrigin: ADAPTER_ORIGIN,
|
||||
width: 640,
|
||||
height: 400,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectionRoundTrip: Story = {
|
||||
args: StartingPreview.args,
|
||||
play: async () => {
|
||||
// Scripted agent: runtime.ready → geometry.report → click.target.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
agentSays({ type: "runtime.ready" });
|
||||
agentSays({
|
||||
type: "geometry.report",
|
||||
targets: [
|
||||
{ nodeId: "el-1", rect: { x: 48, y: 40, width: 320, height: 72 } },
|
||||
],
|
||||
});
|
||||
agentSays({ type: "click.target", nodeId: "el-1" });
|
||||
},
|
||||
};
|
||||
197
packages/editor/src/ui/components/frame-node.test.tsx
Normal file
197
packages/editor/src/ui/components/frame-node.test.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { act } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { postAgentEnvelopeToWindow } from "@/canvas-protocol/scripted-agent.mock";
|
||||
import { useEditorStore } from "@/store/editor-store";
|
||||
import { FrameNode, type FrameNodeData } from "./frame-node";
|
||||
|
||||
const ADAPTER_ORIGIN = "https://adapter.veect.test:5199";
|
||||
const EVIL_ORIGIN = "https://evil.example.com";
|
||||
|
||||
const data: FrameNodeData = {
|
||||
frame: {
|
||||
name: "Button",
|
||||
componentId: "button",
|
||||
adapterOrigin: ADAPTER_ORIGIN,
|
||||
width: 640,
|
||||
height: 480,
|
||||
},
|
||||
};
|
||||
|
||||
function getIframe(): HTMLIFrameElement {
|
||||
return screen.getByTitle("Button frame") as HTMLIFrameElement;
|
||||
}
|
||||
|
||||
function spyOnAgentWindow() {
|
||||
const contentWindow = getIframe().contentWindow;
|
||||
if (contentWindow === null) throw new Error("iframe has no contentWindow");
|
||||
return vi.spyOn(contentWindow, "postMessage");
|
||||
}
|
||||
|
||||
function agentSays(message: Parameters<typeof postAgentEnvelopeToWindow>[0]) {
|
||||
act(() => postAgentEnvelopeToWindow(message, ADAPTER_ORIGIN));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useEditorStore.setState({ registry: [], selectedNodeId: null });
|
||||
});
|
||||
|
||||
describe("FrameNode — cold start", () => {
|
||||
it("hosts the adapter origin in a sandboxed iframe with a drag-handle tab", () => {
|
||||
render(<FrameNode data={data} />);
|
||||
const iframe = getIframe();
|
||||
expect(iframe).toHaveAttribute("src", ADAPTER_ORIGIN);
|
||||
expect(iframe).toHaveAttribute(
|
||||
"sandbox",
|
||||
"allow-scripts allow-same-origin",
|
||||
);
|
||||
expect(screen.getByTitle("Drag to move")).toHaveClass("frame-drag-handle");
|
||||
expect(screen.getByText("Button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the explicit "Starting preview…" skeleton until runtime.ready (ADR-028)', () => {
|
||||
render(<FrameNode data={data} />);
|
||||
expect(screen.getByText("Starting preview…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends nothing before runtime.ready — queue-drain semantics", () => {
|
||||
render(<FrameNode data={data} />);
|
||||
const post = spyOnAgentWindow();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FrameNode — runtime.ready", () => {
|
||||
it("requests a single Element via render-frame (default props) then geometry, pinned to the adapter origin", () => {
|
||||
render(<FrameNode data={data} />);
|
||||
const post = spyOnAgentWindow();
|
||||
|
||||
agentSays({ type: "runtime.ready" });
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(2);
|
||||
expect(post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{
|
||||
protocolVersion: "0",
|
||||
message: { type: "render-frame", componentId: "button" },
|
||||
},
|
||||
ADAPTER_ORIGIN,
|
||||
);
|
||||
expect(post).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
protocolVersion: "0",
|
||||
message: { type: "geometry.request" },
|
||||
},
|
||||
ADAPTER_ORIGIN,
|
||||
);
|
||||
expect(screen.queryByText("Starting preview…")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores runtime.ready from a wrong origin — still starting, nothing sent", () => {
|
||||
render(<FrameNode data={data} />);
|
||||
const post = spyOnAgentWindow();
|
||||
|
||||
act(() =>
|
||||
postAgentEnvelopeToWindow({ type: "runtime.ready" }, EVIL_ORIGIN),
|
||||
);
|
||||
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Starting preview…")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FrameNode — selection round-trip from reported geometry", () => {
|
||||
it("click report selects in the store and the overlay renders at the reported rect", () => {
|
||||
render(<FrameNode data={data} />);
|
||||
agentSays({ type: "runtime.ready" });
|
||||
agentSays({
|
||||
type: "geometry.report",
|
||||
targets: [
|
||||
{ nodeId: "el-1", rect: { x: 16, y: 32, width: 240, height: 56 } },
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("selection-overlay")).not.toBeInTheDocument();
|
||||
|
||||
agentSays({ type: "click.target", nodeId: "el-1" });
|
||||
|
||||
expect(useEditorStore.getState().selectedNodeId).toBe("el-1");
|
||||
expect(screen.getByTestId("selection-overlay")).toHaveStyle({
|
||||
left: "16px",
|
||||
top: "32px",
|
||||
width: "240px",
|
||||
height: "56px",
|
||||
});
|
||||
});
|
||||
|
||||
it("background click deselects and hides the overlay", () => {
|
||||
render(<FrameNode data={data} />);
|
||||
agentSays({ type: "runtime.ready" });
|
||||
agentSays({
|
||||
type: "geometry.report",
|
||||
targets: [
|
||||
{ nodeId: "el-1", rect: { x: 0, y: 0, width: 10, height: 10 } },
|
||||
],
|
||||
});
|
||||
agentSays({ type: "click.target", nodeId: "el-1" });
|
||||
expect(screen.getByTestId("selection-overlay")).toBeInTheDocument();
|
||||
|
||||
agentSays({ type: "click.target", nodeId: null });
|
||||
|
||||
expect(useEditorStore.getState().selectedNodeId).toBeNull();
|
||||
expect(screen.queryByTestId("selection-overlay")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("no overlay when the selected node has no reported geometry yet", () => {
|
||||
render(<FrameNode data={data} />);
|
||||
agentSays({ type: "runtime.ready" });
|
||||
agentSays({ type: "click.target", nodeId: "el-unmeasured" });
|
||||
expect(useEditorStore.getState().selectedNodeId).toBe("el-unmeasured");
|
||||
expect(screen.queryByTestId("selection-overlay")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FrameNode — drag path stays off the protocol", () => {
|
||||
it("rerendering (as frame drag does) produces zero protocol traffic", () => {
|
||||
const { rerender } = render(<FrameNode data={data} />);
|
||||
const post = spyOnAgentWindow();
|
||||
agentSays({ type: "runtime.ready" });
|
||||
expect(post).toHaveBeenCalledTimes(2);
|
||||
|
||||
// React Flow drag rerenders the node with fresh props; data values
|
||||
// are unchanged, so the canvas client must not be recreated and no
|
||||
// message may be sent (<16 ms/frame budget, ADR-028).
|
||||
rerender(<FrameNode data={{ frame: { ...data.frame } }} />);
|
||||
rerender(<FrameNode data={{ frame: { ...data.frame } }} />);
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("stops listening on unmount — late agent messages no longer reach the store", () => {
|
||||
const { unmount } = render(<FrameNode data={data} />);
|
||||
agentSays({ type: "runtime.ready" });
|
||||
unmount();
|
||||
|
||||
agentSays({ type: "click.target", nodeId: "el-late" });
|
||||
|
||||
expect(useEditorStore.getState().selectedNodeId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FrameNode — defaults", () => {
|
||||
it("falls back to the 800x600 default viewport", () => {
|
||||
render(
|
||||
<FrameNode
|
||||
data={{
|
||||
frame: {
|
||||
name: "Card",
|
||||
componentId: "card",
|
||||
adapterOrigin: ADAPTER_ORIGIN,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/800 × 600/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
142
packages/editor/src/ui/components/frame-node.tsx
Normal file
142
packages/editor/src/ui/components/frame-node.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createCanvasClient } from "../../canvas-protocol/canvas-client";
|
||||
import type { GeometryTarget } from "../../canvas-protocol/messages";
|
||||
import { useEditorStore } from "../../store/editor-store";
|
||||
import { SelectionOverlay } from "./selection-overlay";
|
||||
|
||||
/**
|
||||
* One frame on the board: a cross-origin iframe hosting the runner
|
||||
* adapter's origin (ADR-028). The frame speaks the canvas protocol through
|
||||
* `createCanvasClient` — `targetOrigin` pinned both directions — and:
|
||||
*
|
||||
* - waits for `runtime.ready` before requesting anything (queue-drain,
|
||||
* spec §9), showing an explicit "Starting preview…" skeleton until then
|
||||
* (never presented as the real render);
|
||||
* - then asks the adapter to render a single Element — the discovered
|
||||
* component with default props — via `render-frame`
|
||||
* (`@repo/core-runner-protocol` schema) and requests geometry;
|
||||
* - keeps agent-reported geometry as frame-local state (the store holds
|
||||
* registry + selection ONLY) and feeds click-target reports into the
|
||||
* store's selection;
|
||||
* - renders the selection ring as an editor-side overlay from that
|
||||
* reported geometry.
|
||||
*
|
||||
* Dragging the frame only rerenders with a new position — the canvas
|
||||
* client is keyed on `adapterOrigin`/`componentId`, so drag/pan/zoom
|
||||
* produce zero protocol round-trips (<16 ms/frame budget, ADR-028).
|
||||
* Only the name tab (`.frame-drag-handle`) drags, so pointer events inside
|
||||
* the iframe stay with the customer's document.
|
||||
*/
|
||||
export type FrameDescriptor = {
|
||||
/** Display name on the frame's drag-handle tab. */
|
||||
name: string;
|
||||
/** Registry id of the discovered component this frame renders. */
|
||||
componentId: string;
|
||||
/** The adapter's origin — pinned targetOrigin for the canvas protocol. */
|
||||
adapterOrigin: string;
|
||||
/** Viewport size of the frame; defaults to 800x600. */
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export type FrameNodeData = {
|
||||
frame: FrameDescriptor;
|
||||
};
|
||||
|
||||
export const DEFAULT_FRAME_WIDTH = 800;
|
||||
export const DEFAULT_FRAME_HEIGHT = 600;
|
||||
|
||||
export function FrameNode({ data }: { data: FrameNodeData }) {
|
||||
const { frame } = data;
|
||||
const width = frame.width ?? DEFAULT_FRAME_WIDTH;
|
||||
const height = frame.height ?? DEFAULT_FRAME_HEIGHT;
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [targets, setTargets] = useState<readonly GeometryTarget[]>([]);
|
||||
const selectedNodeId = useEditorStore((s) => s.selectedNodeId);
|
||||
const select = useEditorStore((s) => s.select);
|
||||
|
||||
useEffect(() => {
|
||||
const client = createCanvasClient({
|
||||
adapterOrigin: frame.adapterOrigin,
|
||||
getAgentWindow: () => iframeRef.current?.contentWindow ?? null,
|
||||
handlers: {
|
||||
onRuntimeReady: () => {
|
||||
setReady(true);
|
||||
client.renderFrame(frame.componentId);
|
||||
client.requestGeometry();
|
||||
},
|
||||
onGeometry: setTargets,
|
||||
onClick: select,
|
||||
},
|
||||
});
|
||||
client.start();
|
||||
return () => client.stop();
|
||||
}, [frame.adapterOrigin, frame.componentId, select]);
|
||||
|
||||
const selectedRect =
|
||||
targets.find((target) => target.nodeId === selectedNodeId)?.rect ?? null;
|
||||
|
||||
return (
|
||||
<div style={{ width }}>
|
||||
<div
|
||||
className="frame-drag-handle"
|
||||
title="Drag to move"
|
||||
style={{
|
||||
cursor: "grab",
|
||||
userSelect: "none",
|
||||
whiteSpace: "nowrap",
|
||||
fontFamily: "ui-monospace, monospace",
|
||||
fontSize: 12,
|
||||
padding: "2px 0 6px",
|
||||
color: "var(--veect-text-muted, #6b7280)",
|
||||
}}
|
||||
>
|
||||
{frame.name}{" "}
|
||||
<span style={{ fontSize: 10.5, opacity: 0.7 }}>
|
||||
{frame.componentId} · {width} × {height}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width,
|
||||
height,
|
||||
background: "var(--veect-frame-bg, #ffffff)",
|
||||
border: "1px solid var(--veect-line, #e5e7eb)",
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={frame.adapterOrigin}
|
||||
title={`${frame.name} frame`}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
style={{ width: "100%", height: "100%", border: 0, display: "block" }}
|
||||
/>
|
||||
{!ready && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "var(--veect-frame-bg, #ffffff)",
|
||||
color: "var(--veect-text-muted, #6b7280)",
|
||||
fontFamily: "ui-monospace, monospace",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Starting preview…
|
||||
</div>
|
||||
)}
|
||||
<SelectionOverlay rect={selectedRect} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
packages/editor/src/ui/components/react-flow.mock.ts
Normal file
86
packages/editor/src/ui/components/react-flow.mock.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* jsdom shims React Flow needs to render in component tests, adapted from
|
||||
* the official React Flow testing guide
|
||||
* (https://reactflow.dev/learn/advanced-use/testing). jsdom implements
|
||||
* neither `ResizeObserver` nor `DOMMatrixReadOnly`, and its elements have
|
||||
* no layout, so `offsetWidth`/`offsetHeight` fall back to inline styles.
|
||||
*
|
||||
* Call `mockReactFlow()` once per test file (module scope or `beforeAll`)
|
||||
* before rendering a board. Idempotent.
|
||||
*/
|
||||
let initialized = false;
|
||||
|
||||
export function mockReactFlow(): void {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
class ResizeObserverMock {
|
||||
private readonly callback: ResizeObserverCallback;
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
observe(target: Element): void {
|
||||
const width = (target as HTMLElement).offsetWidth || 1;
|
||||
const height = (target as HTMLElement).offsetHeight || 1;
|
||||
const contentRect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: width,
|
||||
bottom: height,
|
||||
width,
|
||||
height,
|
||||
toJSON: () => ({ width, height }),
|
||||
} as DOMRectReadOnly;
|
||||
this.callback(
|
||||
[{ target, contentRect } as ResizeObserverEntry],
|
||||
this as unknown as ResizeObserver,
|
||||
);
|
||||
}
|
||||
|
||||
unobserve(): void {
|
||||
// jsdom shim — nothing to release.
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
// jsdom shim — nothing to release.
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver =
|
||||
ResizeObserverMock as unknown as typeof ResizeObserver;
|
||||
|
||||
class DOMMatrixReadOnlyMock {
|
||||
readonly m22: number;
|
||||
|
||||
constructor(transform?: string) {
|
||||
const scale = transform?.match(/scale\(([\d.]+)\)/)?.[1];
|
||||
this.m22 = scale === undefined ? 1 : Number(scale);
|
||||
}
|
||||
}
|
||||
globalThis.DOMMatrixReadOnly =
|
||||
DOMMatrixReadOnlyMock as unknown as typeof DOMMatrixReadOnly;
|
||||
|
||||
Object.defineProperties(globalThis.HTMLElement.prototype, {
|
||||
offsetHeight: {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return Number.parseFloat(this.style.height) || 1;
|
||||
},
|
||||
},
|
||||
offsetWidth: {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return Number.parseFloat(this.style.width) || 1;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
(
|
||||
globalThis.SVGElement.prototype as SVGElement & {
|
||||
getBBox: () => DOMRect;
|
||||
}
|
||||
).getBBox = () => ({ x: 0, y: 0, width: 0, height: 0 }) as DOMRect;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { SelectionOverlay } from "./selection-overlay";
|
||||
|
||||
/**
|
||||
* Editor-side selection chrome (ADR-028): the ring is positioned from
|
||||
* agent-reported geometry and never renders inside the customer's iframe
|
||||
* document. The gray box below stands in for a frame's iframe viewport.
|
||||
*/
|
||||
const meta = {
|
||||
title: "Editor/SelectionOverlay",
|
||||
component: SelectionOverlay,
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: 480,
|
||||
height: 240,
|
||||
background: "#f3f4f6",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof SelectionOverlay>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Selected: Story = {
|
||||
args: { rect: { x: 48, y: 64, width: 240, height: 56 } },
|
||||
};
|
||||
|
||||
export const FullBleedElement: Story = {
|
||||
args: { rect: { x: 0, y: 0, width: 480, height: 240 } },
|
||||
};
|
||||
|
||||
export const NoSelection: Story = {
|
||||
args: { rect: null },
|
||||
};
|
||||
31
packages/editor/src/ui/components/selection-overlay.test.tsx
Normal file
31
packages/editor/src/ui/components/selection-overlay.test.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SelectionOverlay } from "./selection-overlay";
|
||||
|
||||
describe("SelectionOverlay", () => {
|
||||
it("renders nothing when rect is null (no selection)", () => {
|
||||
render(<SelectionOverlay rect={null} />);
|
||||
expect(screen.queryByTestId("selection-overlay")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("positions the ring from the agent-reported rect", () => {
|
||||
render(
|
||||
<SelectionOverlay rect={{ x: 24, y: 12, width: 320, height: 48 }} />,
|
||||
);
|
||||
const overlay = screen.getByTestId("selection-overlay");
|
||||
expect(overlay).toHaveStyle({
|
||||
position: "absolute",
|
||||
left: "24px",
|
||||
top: "12px",
|
||||
width: "320px",
|
||||
height: "48px",
|
||||
});
|
||||
});
|
||||
|
||||
it("is chrome, not content: hidden from a11y and inert to pointers (ADR-028)", () => {
|
||||
render(<SelectionOverlay rect={{ x: 0, y: 0, width: 10, height: 10 }} />);
|
||||
const overlay = screen.getByTestId("selection-overlay");
|
||||
expect(overlay).toHaveAttribute("aria-hidden", "true");
|
||||
expect(overlay).toHaveStyle({ pointerEvents: "none" });
|
||||
});
|
||||
});
|
||||
36
packages/editor/src/ui/components/selection-overlay.tsx
Normal file
36
packages/editor/src/ui/components/selection-overlay.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Rect } from "../../canvas-protocol/messages";
|
||||
|
||||
/**
|
||||
* Editor-side selection ring, positioned from agent-reported geometry
|
||||
* (ADR-028): chrome never renders inside the customer's iframe document.
|
||||
* The overlay lives in the frame node's coordinate space (the iframe's
|
||||
* top-left is the origin), so React Flow's pan/zoom transform carries it
|
||||
* along with the iframe for free — no protocol traffic on drag.
|
||||
*/
|
||||
export type SelectionOverlayProps = {
|
||||
/** Bounding rect of the selected node, in iframe CSS pixels; `null` hides the ring. */
|
||||
rect: Rect | null;
|
||||
};
|
||||
|
||||
export function SelectionOverlay({ rect }: SelectionOverlayProps) {
|
||||
if (rect === null) return null;
|
||||
return (
|
||||
<div
|
||||
data-testid="selection-overlay"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: rect.x,
|
||||
top: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
pointerEvents: "none",
|
||||
boxSizing: "border-box",
|
||||
border: "1.5px solid var(--veect-accent, #4f46e5)",
|
||||
borderRadius: 2,
|
||||
boxShadow:
|
||||
"0 0 0 3px var(--veect-accent-soft, rgba(79, 70, 229, 0.18))",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
// Public UI surface of @repo/editor: apps import components/hooks from
|
||||
// `@repo/editor/ui` and contracts from `@repo/editor`. The React Flow
|
||||
// board, iframe frame node, and selection overlay land here with the
|
||||
// board-shell slice.
|
||||
// `@repo/editor/ui` and contracts from `@repo/editor`.
|
||||
export { useEditorStore, type EditorStoreState } from "../store/editor-store";
|
||||
export { EditorBoard, type EditorBoardProps } from "./components/editor-board";
|
||||
export {
|
||||
FrameNode,
|
||||
type FrameNodeData,
|
||||
type FrameDescriptor,
|
||||
DEFAULT_FRAME_WIDTH,
|
||||
DEFAULT_FRAME_HEIGHT,
|
||||
} from "./components/frame-node";
|
||||
export {
|
||||
SelectionOverlay,
|
||||
type SelectionOverlayProps,
|
||||
} from "./components/selection-overlay";
|
||||
|
||||
Reference in New Issue
Block a user