Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
271 lines
11 KiB
JavaScript
271 lines
11 KiB
JavaScript
// Windows and panels that live above the app.
|
|
|
|
import { define } from "../internals/register.js";
|
|
import { el } from "../internals/el.js";
|
|
import { setText } from "../internals/text.js";
|
|
import { uid } from "../internals/ids.js";
|
|
import { mount, unmount } from "../internals/portal.js";
|
|
import { claimFor } from "../internals/zlayer.js";
|
|
import { push } from "../internals/dismiss.js";
|
|
import { trap } from "../internals/focus.js";
|
|
import { drag } from "../internals/drag.js";
|
|
|
|
function nodeOf(control, where) {
|
|
if (!control) return null;
|
|
if (control.node && control.node.nodeType === 1) return control.node;
|
|
throw new TypeError(`${where} takes a Composer element handle, not a ${typeof control}.`);
|
|
}
|
|
|
|
const remember = (id, box) => { try { localStorage.setItem(`cx.win.${id}`, JSON.stringify(box)); } catch { /* private mode */ } };
|
|
const recall = (id) => { try { return JSON.parse(localStorage.getItem(`cx.win.${id}`)) || null; } catch { return null; } };
|
|
|
|
/**
|
|
* An OS-style window: drag by the titlebar, roll up, maximise, resize from the
|
|
* corner, click to front. Position and size are remembered per `id`.
|
|
*/
|
|
define("floating", "window", ({
|
|
id = uid("win"), title, subtitle, body,
|
|
width = 420, height = 320, x = 80, y = 80,
|
|
resizable = true, rollup = true, onClose,
|
|
} = {}) => {
|
|
const saved = recall(id) || {};
|
|
const box = { x: saved.x ?? x, y: saved.y ?? y, width: saved.width ?? width, height: saved.height ?? height };
|
|
|
|
const titleId = uid("win-title");
|
|
const node = el("div", { cls: "cx-window",
|
|
attrs: { role: "dialog", "aria-labelledby": titleId } });
|
|
// Bound, because another window coming to the front shifts this one's z.
|
|
const layer = claimFor("floatingWindow", node);
|
|
|
|
// Enough of the window to grab: a strip of titlebar wide enough to hit and
|
|
// tall enough to see. Everything that positions the window goes through here,
|
|
// because the one path that did not was the one that lost it.
|
|
const GRABBABLE = 80;
|
|
const TITLEBAR = 32;
|
|
const MIN_SIZE = 120;
|
|
|
|
// True only while the user is dragging or resizing THIS window. A window may
|
|
// be put partly off the right edge on purpose; it may not end up there because
|
|
// the screen changed under it.
|
|
let direct = false;
|
|
|
|
const clampIntoView = () => {
|
|
// SIZE first, because position is clamped against it. Resizing had no upper
|
|
// bound at all: a corner drag grew the window to 4937px and carried its own
|
|
// close and maximise buttons off the screen with it. Position was clamped
|
|
// and the controls were still unreachable, so "a window can never be put
|
|
// somewhere it cannot be recovered from" was only ever half true.
|
|
box.width = Math.max(MIN_SIZE, Math.min(box.width, window.innerWidth));
|
|
box.height = Math.max(MIN_SIZE, Math.min(box.height, window.innerHeight));
|
|
|
|
// Two rules, because the two situations are not the same one.
|
|
//
|
|
// Dragging: a grabbable strip is guaranteed and the rest may hang off the
|
|
// edge, because that is a placement the user just made.
|
|
//
|
|
// Everything else -- opening, restoring a remembered box, the viewport
|
|
// shrinking -- is not a placement anybody made HERE, so the window comes
|
|
// fully back on screen. Clamping the width against the viewport and the
|
|
// position against GRABBABLE are two budgets that do not add up: a 900px
|
|
// window remembered at x=1000 came back at x=720 of an 800px screen, still
|
|
// carrying its close and maximise buttons past the right edge. The size
|
|
// clamp above guarantees the window FITS, so it can be placed whole.
|
|
//
|
|
// Math.max(0, ...) matters on a viewport narrower than the reachable strip:
|
|
// without it the upper bound goes negative and the clamp pushes the window
|
|
// further off rather than back on.
|
|
const leftMost = direct ? -box.width + GRABBABLE : 0;
|
|
const rightMost = direct ? window.innerWidth - GRABBABLE : window.innerWidth - box.width;
|
|
const lowest = direct ? window.innerHeight - TITLEBAR : window.innerHeight - box.height;
|
|
box.x = Math.min(Math.max(box.x, leftMost), Math.max(0, rightMost));
|
|
box.y = Math.min(Math.max(box.y, 0), Math.max(0, lowest));
|
|
};
|
|
|
|
const apply = () => {
|
|
// Clamped on EVERY apply, not only while dragging. A position is remembered
|
|
// per window id, so a window moved to the right of a wide screen and reopened
|
|
// on a narrow one opened entirely off it -- no titlebar to grab, nothing to
|
|
// drag back, and the only way out was clearing localStorage by hand.
|
|
clampIntoView();
|
|
node.style.left = `${box.x}px`;
|
|
node.style.top = `${box.y}px`;
|
|
node.style.width = `${box.width}px`;
|
|
node.style.height = rolled ? "auto" : `${box.height}px`;
|
|
};
|
|
|
|
const titlebar = el("div", { cls: "cx-window-bar", children: [
|
|
el("div", { cls: "cx-window-titles", children: [
|
|
el("span", { cls: "cx-window-title", text: title, attrs: { id: titleId } }),
|
|
subtitle ? el("span", { cls: "cx-hint", text: subtitle }) : null,
|
|
].filter(Boolean) }),
|
|
] });
|
|
|
|
const controls = el("div", { cls: "cx-window-controls" });
|
|
let rolled = false;
|
|
let maximised = false;
|
|
let restoreBox = null;
|
|
|
|
if (rollup) {
|
|
controls.append(el("button", { cls: ["cx-icon-btn", "cx-icon-btn-sm", "cx-focusable"], text: "—",
|
|
attrs: { type: "button", "aria-label": "Roll up" },
|
|
on: { click: () => { rolled = !rolled; node.classList.toggle("cx-rolled", rolled); apply(); } } }));
|
|
}
|
|
controls.append(el("button", { cls: ["cx-icon-btn", "cx-icon-btn-sm", "cx-focusable"], text: "▢",
|
|
attrs: { type: "button", "aria-label": "Maximise" },
|
|
on: { click: () => {
|
|
if (maximised) { Object.assign(box, restoreBox); maximised = false; }
|
|
else {
|
|
restoreBox = { ...box };
|
|
Object.assign(box, { x: 8, y: 8, width: window.innerWidth - 16, height: window.innerHeight - 16 });
|
|
maximised = true;
|
|
}
|
|
apply();
|
|
} } }));
|
|
controls.append(el("button", { cls: ["cx-icon-btn", "cx-icon-btn-sm", "cx-focusable"], text: "✕",
|
|
attrs: { type: "button", "aria-label": "Close" },
|
|
on: { click: () => handle.close("close-button") } }));
|
|
titlebar.append(controls);
|
|
|
|
const content = el("div", { cls: "cx-window-body", children: nodeOf(body, "floating.window") });
|
|
node.append(titlebar, content);
|
|
|
|
const grip = resizable ? el("div", { cls: "cx-window-grip", attrs: { "aria-hidden": "true" } }) : null;
|
|
if (grip) node.append(grip);
|
|
|
|
apply();
|
|
mount(node);
|
|
|
|
// Click anywhere in the window brings it to the front of its rung.
|
|
const toFront = () => { layer.raise(); };
|
|
node.addEventListener("pointerdown", toFront);
|
|
|
|
// A window open while the viewport shrinks has the same problem as one
|
|
// reopened on a smaller screen.
|
|
const onViewportResize = () => apply();
|
|
window.addEventListener("resize", onViewportResize);
|
|
|
|
let start = null;
|
|
const stopDrag = drag(titlebar, {
|
|
onStart: () => { start = { ...box }; direct = true; toFront(); },
|
|
onMove: ({ dx, dy }) => {
|
|
// apply() does the clamping, so a window can never be dragged somewhere
|
|
// it cannot be dragged back from -- and the rule lives in one place.
|
|
box.x = start.x + dx;
|
|
box.y = start.y + dy;
|
|
apply();
|
|
},
|
|
onEnd: () => { direct = false; remember(id, box); },
|
|
});
|
|
|
|
const stopResize = grip ? drag(grip, {
|
|
onStart: () => { start = { ...box }; direct = true; },
|
|
onMove: ({ dx, dy }) => {
|
|
// Resizing may not push the window's own controls off the screen. The
|
|
// grip is bottom-right and the close button is top-right, so growing
|
|
// right takes the buttons with it -- a corner drag reached 4937px wide
|
|
// in a 900px viewport and left Escape as the only way out.
|
|
//
|
|
// Different rule from dragging on purpose: a window may be DRAGGED partly
|
|
// off (that is ordinary, and a grabbable strip is guaranteed), but it may
|
|
// not be GROWN past the edge it is anchored to.
|
|
const roomRight = Math.max(MIN_SIZE, window.innerWidth - Math.max(0, box.x));
|
|
const roomBelow = Math.max(MIN_SIZE, window.innerHeight - Math.max(0, box.y));
|
|
box.width = Math.min(Math.max(220, start.width + dx), roomRight);
|
|
box.height = Math.min(Math.max(120, start.height + dy), roomBelow);
|
|
apply();
|
|
},
|
|
onEnd: () => { direct = false; remember(id, box); },
|
|
}) : () => {};
|
|
|
|
// Escape closes, but a window does not close on an outside click: it is a
|
|
// window, not a menu, and clicking the app behind it is normal.
|
|
const dismissal = push({ nodes: node, closeOnOutside: false, onDismiss: (r) => handle.close(r) });
|
|
|
|
let closed = false;
|
|
const handle = {
|
|
node,
|
|
isOverlay: true,
|
|
toFront,
|
|
close(reason = "manual") {
|
|
if (closed) return;
|
|
closed = true;
|
|
remember(id, box);
|
|
dismissal.release();
|
|
stopDrag(); stopResize();
|
|
window.removeEventListener("resize", onViewportResize);
|
|
layer.release();
|
|
unmount(node);
|
|
if (onClose) onClose(reason);
|
|
},
|
|
destroy() { handle.close("destroy"); },
|
|
};
|
|
return handle;
|
|
});
|
|
|
|
/** A full-screen "please wait" that genuinely blocks. */
|
|
define("overlay", "blocking", ({ message, progress, cancel } = {}) => {
|
|
const words = el("p", { cls: "cx-t cx-t-sm", text: message });
|
|
const card = el("div", { cls: "cx-blocking-card", children: [
|
|
el("span", { cls: ["cx-spinner", "cx-spinner-md"], attrs: { "aria-hidden": "true" } }),
|
|
words,
|
|
progress && progress.node ? progress.node : null,
|
|
cancel && cancel.node ? cancel.node : null,
|
|
].filter(Boolean) });
|
|
|
|
const node = el("div", { cls: "cx-blocking",
|
|
attrs: { role: "alertdialog", "aria-busy": "true", "aria-label": message },
|
|
children: card });
|
|
const layer = claimFor("wizard", node);
|
|
mount(node);
|
|
const release = trap(card);
|
|
|
|
let closed = false;
|
|
const handle = {
|
|
node, isOverlay: true,
|
|
// What it is waiting for can change while it waits -- "restarting" becomes
|
|
// "it has not come back", and a spinner that cannot say so is a spinner
|
|
// somebody watches for ten minutes.
|
|
setMessage(next) {
|
|
setText(words, next);
|
|
node.setAttribute("aria-label", next);
|
|
},
|
|
close() { if (closed) return; closed = true; release(); layer.release(); unmount(node); },
|
|
destroy() { handle.close(); },
|
|
};
|
|
return handle;
|
|
});
|
|
|
|
/** A panel that slides in from an edge. Simple mode uses these over the preview. */
|
|
define("slideOver", "md", ({ side = "right", title, body, onClose } = {}) => {
|
|
const titleId = uid("slide-title");
|
|
|
|
const panel = el("aside", { cls: ["cx-slideover", `cx-slideover-${side}`],
|
|
attrs: { role: "dialog", "aria-labelledby": titleId } });
|
|
panel.append(el("header", { cls: "cx-slideover-head", children: [
|
|
el("h2", { cls: "cx-panel-title", text: title, attrs: { id: titleId } }),
|
|
el("button", { cls: ["cx-icon-btn", "cx-icon-btn-md", "cx-focusable"], text: "✕",
|
|
attrs: { type: "button", "aria-label": "Close" }, on: { click: () => handle.close("close-button") } }),
|
|
] }));
|
|
panel.append(el("div", { cls: "cx-slideover-body", children: nodeOf(body, "slideOver.md") }));
|
|
|
|
const root = el("div", { cls: "cx-slideover-layer", children: [el("div", { cls: "cx-backdrop" }), panel] });
|
|
const layer = claimFor("modal", root);
|
|
mount(root);
|
|
|
|
const release = trap(panel);
|
|
const dismissal = push({ nodes: panel, onDismiss: (r) => handle.close(r) });
|
|
|
|
let closed = false;
|
|
const handle = {
|
|
node: panel, isOverlay: true,
|
|
close(reason = "manual") {
|
|
if (closed) return;
|
|
closed = true;
|
|
dismissal.release(); release(); layer.release(); unmount(root);
|
|
if (onClose) onClose(reason);
|
|
},
|
|
destroy() { handle.close("destroy"); },
|
|
};
|
|
return handle;
|
|
});
|