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:
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user