Refactoring round two: the particle and shader compilers share emitGraph.ts (cycle detection, duplicate names, emit order) and keep only their rules. FontLibrary and TextureLibrary sit on FileLibrary (base URL, loading/failed bookkeeping, listeners); SoundLibrary keeps its own contract. The three Create*Command share ImportFileCommand, the three renames share RenameCommand - class names stay. bandBounds() in framing.ts and portNameField() in portSection.ts replace in-file clones, and three comments that told history now state the rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
174 lines
6.5 KiB
TypeScript
174 lines
6.5 KiB
TypeScript
import { type FederatedPointerEvent, Graphics, Rectangle } from 'pixi.js';
|
||
import { drawControlBox } from '../draw';
|
||
import { VStack } from '../layout/Stack';
|
||
import { clamp } from '../math';
|
||
import { Theme } from '../Theme';
|
||
import { ui } from '../UIContext';
|
||
import { type Size, Widget } from '../Widget';
|
||
|
||
export interface PopupOptions {
|
||
padding?: number;
|
||
gap?: number;
|
||
/** Modal popups dim the screen and ignore click-away; only Escape or an action closes them. */
|
||
modal?: boolean;
|
||
}
|
||
|
||
/**
|
||
* Left free around a popup, so it never touches the edge of the canvas. A share of the canvas
|
||
* rather than a constant: the same gap that frames a popup on a laptop looks like a hairline on a
|
||
* large screen, and the height cap is measured against the canvas anyway.
|
||
*/
|
||
const SCREEN_MARGIN = 0.1;
|
||
|
||
/** The gap the layout reserves, in pixels on the current canvas. */
|
||
export function screenMargin(screenHeight: number): number {
|
||
return Math.round(screenHeight * SCREEN_MARGIN);
|
||
}
|
||
|
||
/**
|
||
* Where a popup of `height` ends up when it was asked to open at `wanted`. The margin has to be
|
||
* enforced here, not only in `roomFor`: a popup anchored low is *shifted* up to fit, and shifting
|
||
* it against the raw canvas height would park it on the bottom edge, with the gap above it and none
|
||
* below.
|
||
*
|
||
* Downwards only, though. Above the popup the margin is what the height cap spends, not a distance
|
||
* to keep: a menu hanging off a button near the top of the screen belongs under its button, and
|
||
* pushing it down to the tenth would tear it away from what opened it. So the top edge of the
|
||
* canvas is the only limit upwards, and a popup taller than the canvas starts there instead of
|
||
* being centred out of view.
|
||
*/
|
||
export function clampPopupY(wanted: number, height: number, screenHeight: number): number {
|
||
const margin = screenMargin(screenHeight);
|
||
return clamp(wanted, 0, Math.max(0, screenHeight - margin - height));
|
||
}
|
||
|
||
/** A floating box on the overlay layer with a click-away backdrop. */
|
||
export class Popup extends Widget {
|
||
private readonly backdrop = new Graphics();
|
||
private readonly bg = new Graphics();
|
||
readonly content: VStack;
|
||
onClose?: () => void;
|
||
private readonly modal: boolean;
|
||
/** Where `open()` was asked to put the top-left corner, before clamping to the screen. */
|
||
private openX = 0;
|
||
private openY = 0;
|
||
|
||
constructor(options: PopupOptions = {}) {
|
||
super();
|
||
this.modal = options.modal ?? false;
|
||
this.content = new VStack({ padding: options.padding ?? Theme.padding, gap: options.gap ?? Theme.gap });
|
||
this.addChild(this.bg, this.content);
|
||
this.backdrop.eventMode = 'static';
|
||
this.backdrop.on('pointerdown', (e: FederatedPointerEvent) => {
|
||
e.stopPropagation();
|
||
if (!this.modal) this.close();
|
||
});
|
||
this.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
|
||
}
|
||
|
||
private get isOpen(): boolean {
|
||
return this.parent !== null;
|
||
}
|
||
|
||
/** Mounts at the given global position, shifted to stay on screen. Blurs the focused widget. */
|
||
open(globalX: number, globalY: number): void {
|
||
const { overlay, app, focus } = ui();
|
||
const screen = app.screen;
|
||
this.backdrop
|
||
.clear()
|
||
.rect(0, 0, screen.width, screen.height)
|
||
.fill({ color: 0x000000, alpha: this.modal ? 0.4 : 0.001 });
|
||
this.backdrop.hitArea = new Rectangle(0, 0, screen.width, screen.height);
|
||
overlay.addChild(this.backdrop, this);
|
||
this.openX = globalX;
|
||
this.openY = globalY;
|
||
this.place();
|
||
focus.blur();
|
||
window.addEventListener('keydown', this.onEscape, { capture: true });
|
||
}
|
||
|
||
/** Re-measures an open popup after its content changed size. No-op while closed. */
|
||
refreshLayout(): void {
|
||
if (this.isOpen) this.place();
|
||
}
|
||
|
||
private place(): void {
|
||
const screen = ui().app.screen;
|
||
const size = this.measure();
|
||
this.resize(size.w, size.h);
|
||
const x = clamp(this.openX, 0, Math.max(0, screen.width - size.w));
|
||
const y = clampPopupY(this.openY, size.h, screen.height);
|
||
this.position.set(Math.round(x), Math.round(y));
|
||
}
|
||
|
||
/** Opens left-aligned under `anchor`, and never narrower than it. */
|
||
openBelow(anchor: Widget, gap = 2): void {
|
||
const g = anchor.getGlobalPosition();
|
||
this.minWidth = Math.max(this.minWidth, anchor.w);
|
||
this.open(g.x, g.y + anchor.h + gap);
|
||
}
|
||
|
||
openCentered(): void {
|
||
const screen = ui().app.screen;
|
||
const size = this.measure();
|
||
this.open((screen.width - size.w) / 2, (screen.height - size.h) / 2);
|
||
}
|
||
|
||
/** Safe to call on a popup that is not open. */
|
||
close(): void {
|
||
if (!this.isOpen) return;
|
||
window.removeEventListener('keydown', this.onEscape, { capture: true });
|
||
ui().overlay.removeChild(this.backdrop, this);
|
||
// A field inside the popup would keep the keyboard bridge's DOM focus after the popup is gone,
|
||
// swallowing every later Delete or Ctrl+Z. Blurring here mirrors the blur in `open()`.
|
||
ui().focus.blur();
|
||
this.onClose?.();
|
||
}
|
||
|
||
/**
|
||
* How tall the scrolling part of this popup may become: the canvas, minus a margin, minus the
|
||
* chrome around it. The chrome is *measured* – everything the content holds besides `scroll` –
|
||
* rather than repeated as a constant, so a search field or a row of buttons cannot be forgotten.
|
||
*
|
||
* A fixed cap would either waste a tall screen or overflow a short one; the canvas is the only
|
||
* number that knows which of the two we are on.
|
||
*/
|
||
protected roomFor(scroll: Widget, minimum = Theme.rowHeight * 3): number {
|
||
const chrome = this.content.measure().h - scroll.measure().h;
|
||
const screenHeight = ui().app.screen.height;
|
||
const room = screenHeight - screenMargin(screenHeight) * 2 - chrome;
|
||
return Math.max(minimum, room);
|
||
}
|
||
|
||
/** Closes first, so `action` runs with the overlay already cleared. */
|
||
protected closeThen(action?: () => void): () => void {
|
||
return () => {
|
||
this.close();
|
||
action?.();
|
||
};
|
||
}
|
||
|
||
private readonly onEscape = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') {
|
||
event.stopPropagation();
|
||
this.close();
|
||
}
|
||
};
|
||
|
||
override measure(): Size {
|
||
const c = this.content.measure();
|
||
return { w: Math.max(this.minWidth, c.w), h: Math.max(this.minHeight, c.h) };
|
||
}
|
||
|
||
override layout(): void {
|
||
this.content.resize(this.w, this.h);
|
||
}
|
||
|
||
override redraw(): void {
|
||
const c = Theme.colors;
|
||
this.bg.clear();
|
||
this.bg.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 }); // drop shadow
|
||
drawControlBox(this.bg, this.w, this.h, { fill: c.panelHeader, border: c.border });
|
||
}
|
||
}
|