The Parametric EQ has a tab of its own since #370, but Play is in the sound FX's tab. A tab that can float keeps both in view. There is one editor per kind, so the kind's pane is lent to the window and given back on Dock, the way fullscreen moves the game view. - A right-click on a document's tab opens a menu with Float and Close. `DocumentTab` passes the press to `TabStrip`'s new `onMenu`. - `FloatWindow` sits on the overlay. Its title bar moves it, its lower right corner sizes it, Dock and the x close it. A press brings it to the front. - `floating.ts` is the pure part: where a window opens, held whole on the screen, sized down to 320 x 200. Nothing of it is remembered. - One window per kind. Another document of that kind opens there and closes the one it had. Its tab, if it had one, leaves the row. - When the row showed the kind, it shows the nearest document whose kind is not in a window, else the scene (`neighbourOf`). - Delete, Copy and Paste go to the window pressed last, and back to the tab showing after a press in the editor (`keyTab`). - Previews tick and redraw for a kind in a window as for the tab showing. Fullscreen hides the windows. Loading a project or signing out closes them. - Only documents float, Scene and Game stay in the row. - Hooks: `floatWindows`, `floatDocument`, `keyTarget`. Tests: - Unit documents: 16 green, 5 new. Counter-check: `neighbourOf` without the window check turns its test red. - e2e 44 (melektaus-atx, after steps 1-12 and 37): ALL PASSED, 505 checks in the chain, 6 new for float, move, size, another texture, keys, Dock and x. - Gate (melektaus-atx): CHECK EXIT 0, 274 files, 4236 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
87 lines
3.2 KiB
TypeScript
87 lines
3.2 KiB
TypeScript
import { CanvasTextMetrics, type FederatedPointerEvent, Rectangle, type Text } from 'pixi.js';
|
||
import { centreY, Theme, textStyle, crispText } from '../ui';
|
||
import { TabButton } from './TabButton';
|
||
|
||
const GAP = 4;
|
||
const CLOSE = '×';
|
||
/** The × is small; the hit around it is not, or closing a tab would take aim. */
|
||
const CLOSE_SLOP = 4;
|
||
/** A long name is cut here rather than pushing every other tab behind the `»` button. */
|
||
const MAX_NAME_WIDTH = 160;
|
||
|
||
/**
|
||
* A tab that stands for an open document (#171): the icon of its kind before the name, so three
|
||
* assets called "Player" can be told apart, and an × that closes it – as does a middle click. A
|
||
* right-click opens what else can be done with it (#371).
|
||
*/
|
||
export class DocumentTab extends TabButton {
|
||
private readonly closeText: Text;
|
||
|
||
constructor(
|
||
label: string,
|
||
private readonly kind: string,
|
||
icon: string,
|
||
onPick: () => void,
|
||
private readonly onClose: () => void,
|
||
onMenu?: (globalX: number, globalY: number) => void,
|
||
) {
|
||
super(label, { align: 'left', icon, onClick: onPick });
|
||
// On the press, as a row of the asset panel opens its menu.
|
||
this.on('pointerdown', (e: FederatedPointerEvent) => {
|
||
if (e.button === 2) onMenu?.(e.global.x, e.global.y);
|
||
});
|
||
this.closeText = crispText({ text: CLOSE, style: textStyle('muted') });
|
||
this.closeText.eventMode = 'static';
|
||
this.closeText.cursor = Theme.hoverCursor;
|
||
// Stopped on the way down as well, or the tab under it would count the press as its own click.
|
||
this.closeText.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
|
||
this.closeText.on('pointerup', (e: FederatedPointerEvent) => {
|
||
if (e.button !== 0) return;
|
||
e.stopPropagation();
|
||
this.onClose();
|
||
});
|
||
this.on('pointerup', (e: FederatedPointerEvent) => {
|
||
if (e.button === 1) this.onClose();
|
||
});
|
||
this.addChild(this.closeText);
|
||
}
|
||
|
||
/** The × in global coordinates, for the check scripts. */
|
||
closePoint(): { x: number; y: number } {
|
||
const at = this.closeText.getGlobalPosition();
|
||
return { x: at.x + this.closeText.width / 2, y: at.y + this.closeText.height / 2 };
|
||
}
|
||
|
||
/** The kind the icon stands for, as a word – the icon itself says nothing to a check script. */
|
||
get kindLabel(): string {
|
||
return this.kind;
|
||
}
|
||
|
||
override measure(): { w: number; h: number } {
|
||
const own = super.measure();
|
||
const cap = MAX_NAME_WIDTH + this.padding * 2 + this.iconSpace();
|
||
return { w: Math.min(own.w, cap) + this.closeWidth(), h: own.h };
|
||
}
|
||
|
||
protected override labelSlot(): number {
|
||
return super.labelSlot() - this.closeWidth();
|
||
}
|
||
|
||
override redraw(): void {
|
||
super.redraw();
|
||
this.closeText.style = textStyle(this.toggled ? 'body' : 'muted');
|
||
const closeX = this.w - this.padding - this.closeText.width;
|
||
this.closeText.position.set(closeX, centreY(this.h, this.closeText.height));
|
||
this.closeText.hitArea = new Rectangle(
|
||
-CLOSE_SLOP,
|
||
-CLOSE_SLOP,
|
||
this.closeText.width + CLOSE_SLOP * 2,
|
||
this.closeText.height + CLOSE_SLOP * 2,
|
||
);
|
||
}
|
||
|
||
private closeWidth(): number {
|
||
return Math.ceil(CanvasTextMetrics.measureText(CLOSE, textStyle('muted')).width) + GAP;
|
||
}
|
||
}
|