feat(overlay): wire the shell to the server over websockets

The last seam: the canvas shell talks to the server over a websocket, each frame
gets an agent binding, and the hook is what the injected bundle actually calls.

This is the commit that makes the overlay a running editor rather than a set of
parts.
This commit is contained in:
Nayan
2026-08-06 09:15:00 +05:30
parent 0736e547f0
commit c0bca4b238
6 changed files with 4342 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+388
View File
@@ -0,0 +1,388 @@
import type { ElementContext, SourceLocation } from "@airship/protocol";
import { AIRSHIP_FRAME_NAME } from "@airship/protocol";
import type { TokenScanResult } from "@airship/protocol/tokens";
import { extractElementInfo } from "@airship/source/browser";
import { PREFIX } from "./dom";
import { SWALLOWED } from "./edit-guard";
import { css as portable, TEXT_EDIT_MARK } from "./styles/portable.css";
import { scanRuntimeTokens } from "./tokens/scan";
/**
* The half of the editor that runs *inside* a frame.
*
* The shell can reach a frame's DOM directly — same origin, so
* `iframe.contentDocument` is just there — and for most work it does exactly
* that. This agent exists for the operations that are not realm-portable:
*
* - `extractElementInfo` walks React's fiber tree and does its own `instanceof`
* checks against the realm it was loaded in. Called from the shell on a frame
* node it would resolve nothing, and would do it quietly. Here it runs in the
* same realm as the React that produced the node, next to the bippy hook the
* proxy injected into this document's `<head>`.
* - Layout notifications need listeners bound to *this* window, since the shell
* cannot observe a scroll or a mutation it is not in the document for.
*
* Style reads and writes are deliberately *not* here. `inspector/style-model.ts`
* resolves `getComputedStyle` against the node's own window (see `../realm.ts`),
* so the shell can drive them directly on a frame node; routing them through
* here as well would be a second way to do the same thing.
*
* The agent mounts no UI, injects no overlay chrome, and opens no control
* socket. There is one WebSocket per session and the shell owns it.
*/
/** The API a frame publishes to the shell. */
export interface FrameAgent {
/** Hit-test a point in *this frame's* viewport coordinates. */
elementAt: (x: number, y: number) => Element | null;
extract: (
node: Element
) => Promise<{ context: ElementContext; source: SourceLocation | null }>;
/**
* Fires when anything in the frame may have moved: scroll, resize, or a DOM
* mutation (which after HMR is how the shell learns to re-anchor its
* outlines). Coalesced to one call per frame of animation.
*/
onLayoutChange: (cb: () => void) => () => void;
/**
* The design tokens this frame's stylesheets declare.
*
* Realm-local for the same reason `extract` is: the shell's document has the
* editor's own `--ap-*` theme loaded and none of the user's app CSS, so
* scanning from up there would return the wrong design system entirely.
*/
scanTokens: () => TokenScanResult;
/**
* Make this frame inert except for the node carrying `TEXT_EDIT_MARK`.
*
* Armed while the frame is live for an in-place text edit. The shell's
* `EditGuard` cannot do this job — it listens in the shell's document, and an
* event inside an iframe never gets there — so the frame runs the same guard
* in its own realm, over the same `SWALLOWED` list, with the marked node as
* the one hatch.
*/
setTextGuard: (on: boolean) => void;
/** The frame's own window — also how the shell identifies which frame this is. */
readonly window: Window;
}
/** A wheel that happened inside a frame, in that frame's own coordinates. */
export interface FrameWheel {
altKey: boolean;
/** Frame-viewport coordinates; the shell maps them to screen space. */
clientX: number;
clientY: number;
ctrlKey: boolean;
/** Units of the deltas: 0 pixels, 1 lines, 2 pages. Meaningless without it. */
deltaMode: number;
deltaX: number;
deltaY: number;
metaKey: boolean;
shiftKey: boolean;
}
/** A click inside a frame that is live for a text edit, in frame coordinates. */
export interface FramePress {
/** Frame-viewport coordinates; the shell maps them to screen space. */
clientX: number;
clientY: number;
ctrlKey: boolean;
/** True for `dblclick` — the gesture that *enters* an edit. */
dbl: boolean;
metaKey: boolean;
shiftKey: boolean;
}
/** How the shell learns a frame is ready — including after an HMR full reload. */
export interface FrameHost {
/**
* Someone pressed inside this frame. Purely a notification — the press is not
* consumed, so the app still receives it. In view mode this is the only way
* the shell can know a frame was clicked, since the event never leaves the
* frame's document.
*/
__airshipOnFramePress?: (win: Window) => void;
__airshipOnFrameReady?: (agent: FrameAgent) => void;
/**
* A click or double-click inside a frame that is live for a text edit, which
* landed outside the node being edited.
*
* The counterpart to `__airshipOnFrameWheel`, and it exists for the same
* reason: while the frame is live its events never reach the shell's document,
* so the picker's own capture listeners are blind to them. Without this, a
* click from one string to another inside the same frame would never commit
* the first — sticky text mode would stop at the frame boundary.
*/
__airshipOnFrameTextPress?: (win: Window, e: FramePress) => void;
/**
* A wheel inside a frame. Returns true if the shell consumed it — a canvas
* pan or zoom, or a scroll it applied to the frame itself — so the frame can
* cancel the default action. Must be synchronous: `preventDefault` is only
* honoured during dispatch.
*/
__airshipOnFrameWheel?: (win: Window, e: FrameWheel) => boolean;
}
/**
* Is this document inside an Airship frame, judged by its window name?
*
* A secondary signal only. `window.name` turns out to be unreliable for this:
* setting `iframe.name` after the element is already in the document does not
* reliably reach `contentWindow.name`, so a frame can come up nameless — and a
* frame that fails to recognise itself boots the *entire* inline overlay inside
* itself, complete with its own docks and its own control socket. The injected
* config is the signal that decides; this is kept for browsers that do not send
* `Sec-Fetch-Dest`, and because a named frame is far easier to debug.
*/
export function isFrameName(name: string): boolean {
return name.startsWith(AIRSHIP_FRAME_NAME);
}
/**
* Styles the frame document needs. Two things, and no more — everything else
* the editor draws lives in the shell:
*
* 1. A frame's scrolling stops at the frame.
* 2. The portable sheet, which is not local because it is not exclusive to
* frames: it styles *page* nodes — the one being edited in place, the one
* being dragged, and the ghost standing in for it — and those are page nodes
* in the inline stage too. See `styles/portable.css.ts` for why it carries
* colour literals rather than `var(--ap-*)`.
*
* The dragged-node rule used to be declared here *as well as* in the shell's own
* stylesheet, as two hand-kept copies of one declaration. It is portable's now,
* which is the only arrangement where they cannot drift.
*/
const FRAME_CSS = `
/* A frame's scrolling stops at the frame. Reaching the end of the page should
not hand the gesture onwards to the canvas behind it — the canvas is not a
bigger version of this page, and having it lurch sideways when you hit the
bottom of an article is disorienting. Belt and braces now: the shell answers
every wheel it is offered (see \`onFrameWheel\`), so the default action this
contains is one that should never run. */
html { overscroll-behavior: contain; }
${portable}`;
function injectFrameStyles(): void {
const id = `${PREFIX}-frame-styles`;
if (document.getElementById(id)) {
return;
}
const style = document.createElement("style");
style.id = id;
style.textContent = FRAME_CSS;
document.head.append(style);
}
function createAgent(): FrameAgent {
const listeners = new Set<() => void>();
let scheduled = 0;
// Coalesce to one notification per animation frame. A React re-render can fire
// hundreds of mutations in a tick and each one would otherwise re-measure and
// repaint every outline in the shell.
const notify = (): void => {
if (scheduled) {
return;
}
scheduled = requestAnimationFrame(() => {
scheduled = 0;
for (const cb of listeners) {
cb();
}
});
};
window.addEventListener("scroll", notify, true);
window.addEventListener("resize", notify);
// Report presses so clicking a frame selects it, the same as clicking its
// title. Passive and non-consuming: the app's own buttons and links keep
// working exactly as they would if the editor were not here.
window.addEventListener(
"pointerdown",
() => {
try {
(window.parent as unknown as FrameHost)?.__airshipOnFramePress?.(
window
);
} catch {
// No reachable shell; nothing to select.
}
},
{ capture: true, passive: true }
);
// Wheel events inside an iframe never reach the parent document — they do not
// cross the boundary at all. Without forwarding, ⌘-wheel over a frame does
// nothing in view mode (where the frame is interactive) and the canvas simply
// appears frozen wherever the app happens to be. Non-passive so the parent's
// answer can still cancel the browser's own page zoom.
window.addEventListener(
"wheel",
(e: WheelEvent) => {
let handled = false;
try {
handled =
(window.parent as unknown as FrameHost)?.__airshipOnFrameWheel?.(
window,
{
altKey: e.altKey,
clientX: e.clientX,
clientY: e.clientY,
ctrlKey: e.ctrlKey,
deltaMode: e.deltaMode,
deltaX: e.deltaX,
deltaY: e.deltaY,
metaKey: e.metaKey,
shiftKey: e.shiftKey,
}
) ?? false;
} catch {
// No reachable shell — leave the wheel to the app.
}
if (handled) {
e.preventDefault();
}
},
{ capture: true, passive: false }
);
new MutationObserver(notify).observe(document.documentElement, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
return {
elementAt: (x, y) => document.elementFromPoint(x, y),
extract: (node) => extractElementInfo(node),
onLayoutChange(cb) {
listeners.add(cb);
return () => listeners.delete(cb);
},
scanTokens: () => scanRuntimeTokens(document, window),
setTextGuard,
window,
};
}
/** Is this node the one being edited in place, or inside it? */
function inEditedText(target: EventTarget | null): boolean {
return (
target instanceof Element && Boolean(target.closest(`[${TEXT_EDIT_MARK}]`))
);
}
/** Everything the in-realm guard listens for while a text edit is live. */
const GUARDED = [...SWALLOWED, "click", "dblclick"] as const;
/** Key events the app must not see while the caret is in the frame. */
const GUARDED_KEYS = ["keydown", "keypress", "keyup"] as const;
/**
* The realm-local half of the text-edit guard.
*
* Stateful at module scope rather than per-agent because there is exactly one
* agent per realm and this listens on that realm's `document` — a second copy
* would double every listener after an HMR reload that somehow reused the realm.
*/
let textGuardOn = false;
function onGuardedPress(e: Event): void {
if (inEditedText(e.target)) {
// The default is the caret, the drag-select, the double-click word. Only the
// propagation is stopped, so the app's own handler on the button you are
// renaming never runs.
e.stopPropagation();
return;
}
e.preventDefault();
e.stopPropagation();
if (e.type !== "click" && e.type !== "dblclick") {
return;
}
const me = e as MouseEvent;
try {
(window.parent as unknown as FrameHost)?.__airshipOnFrameTextPress?.(
window,
{
clientX: me.clientX,
clientY: me.clientY,
ctrlKey: me.ctrlKey,
dbl: e.type === "dblclick",
metaKey: me.metaKey,
shiftKey: me.shiftKey,
}
);
} catch {
// No reachable shell. The press is already swallowed, which is the part
// that matters — the app stays inert either way.
}
}
/**
* Keep the app's *own* keyboard shortcuts off a live edit.
*
* An app with a `document` **capture** keydown handler — a `/`-to-search, a
* `j`/`k` list — would otherwise act on every character typed into the text, and
* in a frame the shell is not even in the room to arbitrate: it listens on its
* own document, which this event never reaches.
*
* This stops the descent at the frame's document, which means the *target* is
* never reached either. That is why `TextEditor` binds its own keydown
* listener to the node's **window** rather than to the node: window capture is
* the first step of the path, so the editor has already had the key by the time
* this runs. Moving either listener without the other silently breaks Escape and
* ⌘Enter inside a live frame.
*/
function onGuardedKey(e: Event): void {
if (inEditedText(e.target)) {
e.stopPropagation();
}
}
function setTextGuard(on: boolean): void {
if (on === textGuardOn) {
return;
}
textGuardOn = on;
for (const type of GUARDED) {
if (on) {
document.addEventListener(type, onGuardedPress, true);
} else {
document.removeEventListener(type, onGuardedPress, true);
}
}
for (const type of GUARDED_KEYS) {
if (on) {
document.addEventListener(type, onGuardedKey, true);
} else {
document.removeEventListener(type, onGuardedKey, true);
}
}
}
/**
* Boot as a frame. Publishes the agent to the shell by *calling into it* rather
* than waiting to be asked: the frame reloads on its own schedule (every HMR
* full reload tears this realm down and builds a new one), so the shell cannot
* know when to re-read `contentWindow`. Pushing means re-registration is
* automatic and the shell's handle is never stale.
*
* The agent carries no frame id. It does not need one — it hands over its own
* `window`, and the shell matches that against its iframes' `contentWindow`.
* Identity by object reference cannot be spoofed by a stale name or lost in a
* reload, which an id passed through `window.name` demonstrably can be.
*/
export function bootFrameAgent(): void {
injectFrameStyles();
const agent = createAgent();
(window as unknown as { __airshipFrame?: FrameAgent }).__airshipFrame = agent;
try {
(window.parent as unknown as FrameHost)?.__airshipOnFrameReady?.(agent);
} catch {
// Cross-origin parent: nothing to register with, and nothing here is worth
// breaking the frame over. The frame still renders; the editor just cannot
// resolve source in it.
}
}
+20
View File
@@ -0,0 +1,20 @@
/**
* @airship/overlay/hook — installs bippy's React DevTools hook BEFORE the host
* app's React renders, so React 19 captures owner-stack source metadata
* (`fiber._debugStack`). Without this, `element-source` can resolve component
* names (plain fiber traversal) but not file/line, and the picker shows
* "source not resolved".
*
* The proxy injects this as a synchronous (non-`defer`) <head> script so it runs
* during HTML parse, ahead of the app's deferred module entry. See
* packages/server/src/proxy.ts (injectOverlay / serveAirshipAsset).
*/
import { instrument } from "bippy";
instrument({
name: "airship",
onCommitFiberRoot() {
// No-op: we only need bippy's hook installed before React renders so owner
// stacks are captured. Element source is resolved lazily on pick, not here.
},
});
+41
View File
@@ -0,0 +1,41 @@
import type { AirshipWindowConfig } from "@airship/protocol";
import { boot } from "./app";
import { bootFrameAgent, isFrameName } from "./frame-agent";
import { bootShell } from "./shell-app";
/**
* One bundle, three surfaces. The proxy injects this same IIFE into the shell
* document, into every frame, and into the app itself when the inline escape
* hatch is used; each picks its role here.
*
* Getting the frame case wrong is not a subtle failure: a frame that does not
* recognise itself falls through to the inline overlay and boots a *second*
* complete editor inside itself — its own docks, its own pointer capture, its
* own control socket — one per frame. So the decision leans on the injected
* config, which the proxy sets from `Sec-Fetch-Dest` and cannot be lost in a
* reload, and treats `window.name` only as a fallback for clients that do not
* send that header.
*/
function start(): void {
const config = (window as unknown as { __PIKA__?: AirshipWindowConfig })
.__PIKA__;
if (config?.mode === "frame" || isFrameName(window.name)) {
bootFrameAgent();
return;
}
if (config?.mode === "shell") {
bootShell(config);
return;
}
// No mode, or an explicit `inline`: the original single-document overlay.
boot();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start, { once: true });
} else {
start();
}
+538
View File
@@ -0,0 +1,538 @@
import type { AirshipWindowConfig } from "@airship/protocol";
import { AirshipApp, type Stage } from "./app";
import { FrameChrome } from "./canvas/frame-chrome";
import { type Frame, FrameManager } from "./canvas/frames";
import { frameScreenRect, type Point, type Rect } from "./canvas/space";
import { CanvasViewport, type SafeInset } from "./canvas/viewport";
import {
axes,
type FrameWheelRoute,
pixelDelta,
routeWheel,
type WheelLike,
type WheelRouteCtx,
} from "./canvas/wheel";
import { ChromeLayer } from "./chrome-layer";
import { cls, PREFIX } from "./dom";
import type { FrameHost, FrameWheel } from "./frame-agent";
import type { Mods, Selection } from "./picker";
import { isElement } from "./realm";
import { injectStyles } from "./styles";
import { CanvasResolver, type SurfaceResolver } from "./surface";
/**
* The canvas stage: a pan/zoom surface holding device frames, each a live copy
* of the app.
*
* This document contains none of the user's code — the proxy serves a bare shell
* here (see `packages/server/src/shell.ts`) and the app runs one realm down, in
* the frames. Everything the editor does to those frames goes through `Surface`,
* so the controllers themselves are the same ones the inline overlay drives.
*/
class CanvasStage implements Stage {
readonly layer = new ChromeLayer();
readonly resolver: SurfaceResolver;
/** Frames are inert in edit mode, so there is nothing to swallow. */
readonly swallowPresses = false;
private readonly canvas: CanvasViewport;
private readonly frames: FrameManager;
private readonly chrome: FrameChrome;
private readonly listeners: (() => void)[] = [];
/** Subscribers to the trailing edge of a pan/zoom — see `isGesturing`. */
private readonly gestureEndListeners: (() => void)[] = [];
/** Per-frame unsubscribers for agent layout notifications. */
private readonly frameUnsubs = new Map<string, () => void>();
private getSelection: (() => Selection | null) | null = null;
/** Where a press inside a live frame goes. Set by `bindFramePress`. */
private reportFramePress:
| ((at: Point, mods: Mods, dbl: boolean) => void)
| null = null;
private editing = true;
/** What the open docks are covering. Written by the app on every toggle and
* splitter drag; read by the viewport whenever it has to aim at the canvas. */
private safeInset: SafeInset = { left: 0, right: 0 };
/** A wheel gesture latched to the selected frame — see `scrollFrame`. */
private frameGesture: {
pending: Point;
raf: number;
route: FrameWheelRoute;
timer: number;
} | null = null;
constructor(config: AirshipWindowConfig) {
const key = `${PREFIX}-canvas:${window.location.pathname}`;
this.canvas = new CanvasViewport({
getContentRects: () => this.frames.worldRects(),
getSafeInset: () => this.safeInset,
getSelectionRect: () => this.selectionWorldRect(),
offerWheelToFrame: (e, point, target) =>
this.offerWheelToFrame(e, point, target),
onChange: () => this.onViewportChange(),
onGestureEnd: () => this.emitGestureEnd(),
storageKey: `${key}:viewport`,
});
this.frames = new FrameManager({
onChanged: () => this.onFramesChanged(),
onFrameReady: (frame) => this.onFrameReady(frame),
pathname: config.pathname ?? "/",
storageKey: `${key}:frames`,
world: this.canvas.world,
});
this.chrome = new FrameChrome({
frames: this.frames,
inCanvas: (point) => this.canvas.contains(point),
layer: this.layer,
onChanged: () => this.onFramesChanged(),
viewport: this.canvas,
});
this.resolver = new CanvasResolver(this.frames, this.canvas);
}
/**
* A wheel that happened inside a frame, forwarded up by that frame's agent.
*
* In view mode the frame is interactive, so the wheel lands in the frame's own
* document and the shell never sees it — which is why ⌘-wheel over a frame did
* nothing at all. The agent hands it over here; the only work is mapping the
* frame's coordinates into screen space, which is both what the zoom anchor
* needs and what lets `routeWheel` answer in the one coordinate space it
* accepts. Who owns the wheel is decided there, not here — see `wheel.ts`.
*
* The frame's scroll is synthesised, never left to the browser. Declining —
* returning false so the uncancelled wheel scrolls the frame natively — was
* tried first, for the OS momentum fling, and is what made a selected frame
* randomly refuse to scroll: for an iframe under the canvas's scaled,
* composited transform, Chrome's wheel hit test intermittently fails to find
* the frame's scroller at all, and an *uncancelled wheel over a scrollable
* document simply does nothing* until some unrelated style invalidation
* inside the frame wakes it (which is why resizing or poking the frame
* "fixed" it). Scrolling the routed target ourselves is deterministic in
* every compositor state, and the fling survives regardless: macOS keeps
* delivering the momentum events, exactly as the canvas pan relies on.
*/
private onFrameWheel(win: Window, e: FrameWheel): boolean {
const frame = this.frames.all.find((f) => f.win === win);
if (!frame) {
return false;
}
// The point arrives in the frame's own client coordinates; `routeWheel`
// takes shell screen space and maps back. Round-tripping it rather than
// adding a second entry point is what stops the two routes disagreeing.
const origin = frameScreenRect(frame.el);
const { scale } = this.canvas;
const screen = {
x: origin.left + e.clientX * scale,
y: origin.top + e.clientY * scale,
};
// Frames may overlap, so route only when *this* frame is the selected one.
// Without it, a wheel in an unselected frame sitting over the selected one
// would pass the geometry test and scroll the wrong frame.
const route =
!this.editing && this.frames.active?.id === frame.id
? (this.latchedRoute(frame.id, e) ??
routeWheel(e, screen, this.wheelCtx()))
: null;
if (route) {
this.scrollFrame(route, e);
return true;
}
return this.canvas.applyWheel(
// The frame is not taking this one, so the intent is a canvas gesture;
// suppress the frame branch in `applyWheel`, which would otherwise hand it
// straight back.
{ ...e, altKey: true },
screen
);
}
/**
* A wheel the *shell* received, offered to the selected frame before the
* canvas takes it.
*
* Everything a frame is covered or edged by is chrome in this document — the
* title, the size badge, the eight resize grips — and a frame that has not
* loaded yet has no document of its own at all. Those wheels never reached
* `onFrameWheel`, so they panned, one pixel from a gesture that scrolled.
*/
private offerWheelToFrame(
e: WheelLike,
screen: Point,
target: EventTarget | null
): boolean {
// Edit mode always pans, including over a frame — see `applyWheel`.
if (this.editing) {
return false;
}
const { active } = this.frames;
const route =
(active && this.latchedRoute(active.id, e)) ??
routeWheel(e, screen, {
...this.wheelCtx(),
onOwnChrome: this.isOwnFrameChrome(target),
});
if (!route) {
return false;
}
this.scrollFrame(route, e);
// Deliberately no `markGesture`: no canvas gesture happened.
return true;
}
/**
* Apply an owned wheel to the frame — one routine for both entry points.
*
* Divided by the canvas scale for the same reason `FrameChrome.onDragMove`
* divides its drag deltas: the gesture happens in screen pixels, but the
* scroll target is laid out in the frame's own units. At 50% zoom a 120px
* wheel tick has to scroll 240 frame pixels for the content to track the
* fingers 1:1 on screen — applied raw, the scroll visibly lagged the gesture
* by exactly the zoom factor.
*
* Beyond the arithmetic, this latches and coalesces, and both are why the
* first version felt laggy. A trackpad delivers wheels faster than frames
* paint, and answering each one from scratch meant a `getBoundingClientRect`
* in the shell plus an `elementFromPoint` and a computed-style walk inside a
* live React app — layout-forcing work, per event, before any pixel moved.
* So: the first owned wheel routes and *latches* its target for the whole
* gesture (the same rule native scrolling and the canvas's `isWheeling`
* already follow — an owner does not change hands mid-fling), and deltas
* accumulate to be flushed as one `scrollBy` per animation frame, which is
* also one scroll event per paint for the app's own listeners instead of
* five.
*/
private scrollFrame(route: FrameWheelRoute, e: WheelLike): void {
const { dx, dy } = axes(pixelDelta(e), e.shiftKey);
let g = this.frameGesture;
if (g?.route.frame.id !== route.frame.id) {
this.dropFrameGesture();
g = {
pending: { x: 0, y: 0 },
raf: 0,
route,
timer: 0,
};
this.frameGesture = g;
}
// Fractional deltas accumulate here rather than being rounded away one
// 2px trackpad event at a time.
const { scale } = this.canvas;
g.pending.x += dx / scale;
g.pending.y += dy / scale;
if (!g.raf) {
g.raf = requestAnimationFrame(() => this.flushFrameScroll());
}
// Same disarm window as the canvas's `markGesture`: the gesture is over
// once the events stop, momentum included.
clearTimeout(g.timer);
g.timer = window.setTimeout(() => this.dropFrameGesture(), 120);
}
/**
* The gesture's latched route, if this wheel still belongs to it. Mirrors
* `routeWheel`'s first guard: zoom chords and the canvas's alt sentinel are
* never part of a scroll gesture, and a latch held for a deselected frame is
* dead — `frameGesture` outliving a selection change is the 120ms tail.
*/
private latchedRoute(frameId: string, e: WheelLike): FrameWheelRoute | null {
if (e.ctrlKey || e.metaKey || e.altKey) {
return null;
}
const g = this.frameGesture;
return g && g.route.frame.id === frameId ? g.route : null;
}
private flushFrameScroll(): void {
const g = this.frameGesture;
if (!g) {
return;
}
g.raf = 0;
const { x, y } = g.pending;
g.pending = { x: 0, y: 0 };
try {
// "instant", not "auto". A native wheel scroll ignores the page's CSS
// `scroll-behavior`; "auto" re-applies it, so an app declaring `smooth`
// turned every flush into an eased animation — and each next flush
// aborted the one before it mid-flight, silently discarding whatever
// distance had not animated yet. A fast gesture lost more than half its
// travel that way, which read as the scroll being "slow".
g.route.target.scrollBy({ behavior: "instant", left: x, top: y });
} catch {
// The frame reloaded or was removed mid-gesture; its realm is gone.
this.dropFrameGesture();
}
}
private dropFrameGesture(): void {
const g = this.frameGesture;
if (!g) {
return;
}
cancelAnimationFrame(g.raf);
clearTimeout(g.timer);
this.frameGesture = null;
}
/**
* Is this node the *selected* frame's own furniture? Read off `data-frame`
* rather than geometry, because the title is drawn above the frame it names
* and a grip straddles its edge — see `FrameChrome.render`.
*/
private isOwnFrameChrome(target: EventTarget | null): boolean {
const { active } = this.frames;
if (!(active && isElement(target))) {
return false;
}
const box = target.closest(`.${cls("fc")}`);
return box?.getAttribute("data-frame") === active.id;
}
private wheelCtx(): WheelRouteCtx {
return {
activeFrame: this.frames.active,
gesturing: this.canvas.isWheeling,
scale: this.canvas.scale,
};
}
/** `F` opens the canvas's own add-frame menu — the `+` button's shortcut. */
addFrame(): void {
this.chrome.openAddMenu();
}
/** The bar's view-mode slot for the selected frame's verbs. */
mountFrameTools(host: HTMLElement): void {
this.chrome.mountFrameTools(host);
}
bindFramePress(report: (at: Point, mods: Mods, dbl: boolean) => void): void {
this.reportFramePress = report;
}
/**
* A node is being edited in place — make its frame live, and arm that frame's
* own press guard so the app underneath still cannot act.
*
* The two halves are inseparable: the first is what makes a caret placeable,
* the second is what replaces the guarantee the capture plane was giving.
* Shipping one without the other would either leave the caret unreachable or
* hand the app back its clicks.
*/
setTextOwner(node: Element | null): void {
const frame = node ? this.frames.frameOf(node) : null;
this.frames.setTextFrame(frame);
for (const f of this.frames.all) {
f.agent?.setTextGuard(f === frame);
}
}
mount(tools: HTMLElement): void {
const host = window as unknown as FrameHost;
host.__airshipOnFrameWheel = (win, e) => this.onFrameWheel(win, e);
// A press inside a frame selects it, matching a press on its title. View
// mode is the only route *and* the only mode this is allowed in: in edit
// mode the frame is inert behind its capture plane so the event should never
// arrive, but the guard is written down rather than left to that — frame
// selection is view-mode-only now, and this is the second door into it.
host.__airshipOnFramePress = (win) => {
if (this.editing) {
return;
}
const frame = this.frames.all.find((f) => f.win === win);
if (frame) {
this.frames.setActive(frame.id);
}
};
// The click-away route out of a frame that is live for a text edit. Mapped
// frame → screen with exactly the transform `onFrameWheel` uses, so the
// point lands back in the space `SelectionController.hitTest` expects and
// both routes stay one code path.
host.__airshipOnFrameTextPress = (win, e) => {
const frame = this.frames.all.find((f) => f.win === win);
if (!frame) {
return;
}
const origin = frameScreenRect(frame.el);
const { scale } = this.canvas;
this.reportFramePress?.(
{
x: origin.left + e.clientX * scale,
y: origin.top + e.clientY * scale,
},
{ meta: e.metaKey || e.ctrlKey, shift: e.shiftKey },
e.dbl
);
};
document.body.append(this.canvas.element);
this.layer.mount(document.body);
this.chrome.mount(tools);
// Read the saved viewport *before* touching frames. Adding a frame fires
// `onChanged`, which persists the layout — and the viewport along with it —
// so asking afterwards would always find the default `{0,0,1}` this session
// had just written and would never fit the canvas on a first run.
const hadViewport = this.canvas.restore();
if (!this.frames.restore()) {
// First run: the two breakpoints worth seeing side by side. Anything more
// opinionated would be guessing at a project we know nothing about.
this.frames.add({ presetId: "desktop" });
this.frames.add({ presetId: "iphone-16" });
}
if (!hadViewport) {
this.canvas.zoomToFit();
}
this.frames.setEditing(this.editing);
this.chrome.setEditing(this.editing);
this.relayout();
}
bindSelection(get: () => Selection | null): void {
this.getSelection = get;
}
/**
* Wheel momentum counts, not just the pointer drag.
*
* This used to report `isPanning` alone, so the moment a trackpad flick lifted
* the fingers hover work resumed — while the canvas was still gliding. Every
* `mousemove` arriving during the deceleration hit-tested into a frame that
* was moving under it, which is the strobe this flag exists to prevent.
*/
isGesturing(): boolean {
return this.canvas.isPanning || this.canvas.isWheeling;
}
onLayoutChange(cb: () => void): void {
this.listeners.push(cb);
}
onGestureEnd(cb: () => void): void {
this.gestureEndListeners.push(cb);
}
setSafeInset(inset: SafeInset): void {
this.safeInset = inset;
}
setHandTool(on: boolean): void {
this.canvas.setHandTool(on);
}
setEditing(on: boolean): void {
this.editing = on;
// A latched scroll must not survive into a mode where frames are inert.
this.dropFrameGesture();
// Two things change, in opposite directions. The frames themselves go inert
// behind their capture planes in edit mode and live in view mode; the
// furniture *around* them goes the other way — interactive in view mode,
// visible but inert in edit. See the note above `FrameChrome.setEditing`.
this.frames.setEditing(on);
this.chrome.setEditing(on);
}
relayout(): void {
// The canvas is full-bleed, so this fence is the whole window; the docks
// keep chrome out by painting over it, not by clipping it (see
// `chrome-layer.ts`). Kept so the layer still tracks the viewport's bounds.
this.layer.setClip(this.canvas.rect);
this.frames.updateMounts(this.canvas.rect);
this.chrome.render();
this.notify();
}
/**
* After an edit lands, every frame reloads itself over HMR — independently,
* and on its own schedule. Each one re-registers its agent as it comes back
* (see `onFrameReady`), which is what re-anchors the chrome; there is nothing
* to do here but persist the layout, since the user has likely been
* rearranging frames while waiting.
*/
afterApply(): void {
this.save();
}
// -- Internals -------------------------------------------------------------
private onViewportChange(): void {
this.frames.updateMounts(this.canvas.rect);
this.chrome.render();
this.notify();
}
private onFramesChanged(): void {
this.frames.updateMounts(this.canvas.rect);
this.chrome.render();
this.notify();
this.save();
}
/**
* A frame published its agent — on first load, and again after every HMR full
* reload, which rebuilds the frame's realm from scratch.
*
* Re-subscribing here rather than once at construction is the point: listeners
* bound to the old realm died with it, and nothing else would tell us. Without
* this, outlines would freeze in place the first time the user edited a file.
*/
private onFrameReady(frame: Frame): void {
this.frameUnsubs.get(frame.id)?.();
const off = frame.agent?.onLayoutChange(() => this.notify());
if (off) {
this.frameUnsubs.set(frame.id, off);
}
this.notify();
}
private notify(): void {
for (const cb of this.listeners) {
cb();
}
}
private emitGestureEnd(): void {
for (const cb of this.gestureEndListeners) {
cb();
}
}
/**
* The selection's rect in world space, for zoom-to-selection.
*
* The selection is measured in its frame's coordinates, so it has to be
* offset by that frame's world position — the same node at the same CSS
* position sits somewhere different on the canvas depending on which frame it
* is in, which is the whole reason frames have coordinates.
*/
private selectionWorldRect(): Rect | null {
const sel = this.getSelection?.();
const frame = this.frames.frameOf(sel?.node ?? null);
if (!(sel && frame)) {
return null;
}
return {
height: sel.rect.height,
left: frame.x + sel.rect.left,
top: frame.y + sel.rect.top,
width: sel.rect.width,
};
}
private save(): void {
this.frames.save();
this.canvas.save();
}
}
export function bootShell(config: AirshipWindowConfig): void {
const w = window as unknown as { __airshipBooted?: boolean };
if (w.__airshipBooted) {
return;
}
w.__airshipBooted = true;
document.documentElement.setAttribute(`data-${PREFIX}-shell`, "");
injectStyles();
const stage = new CanvasStage(config);
const app = new AirshipApp(config, stage);
app.mount();
}
+59
View File
@@ -0,0 +1,59 @@
import type { ClientMessage, ServerEvent } from "@airship/protocol";
type Listener = (event: ServerEvent) => void;
/** Auto-reconnecting client for the Airship control socket. */
export class AirshipSocket {
private ws: WebSocket | null = null;
private readonly listeners = new Set<Listener>();
private readonly path: string;
constructor(path: string) {
this.path = path;
}
connect(): void {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${proto}//${location.host}${this.path}`);
this.ws = ws;
ws.onmessage = (ev) => {
try {
const event = JSON.parse(ev.data as string) as ServerEvent;
for (const l of this.listeners) {
l(event);
}
} catch {
// ignore malformed frames
}
};
ws.onclose = () => {
this.ws = null;
setTimeout(() => this.connect(), 1500);
};
ws.onerror = () => ws.close();
}
/**
* Whether a `send` would actually reach the daemon.
*
* `send` drops silently when the socket is down, which is right for
* fire-and-forget traffic but not for the prompt preview: a dropped request
* and a slow one look identical from the pane, and it would sit on stale text
* presenting it as live.
*/
isOpen(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
on(listener: Listener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
send(message: ClientMessage): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
}