Dragging an edge now rings the port it would connect to and snaps the line there. A drop on a node's body takes that node's first free compatible port, or the last compatible one when none is free, replacing its link. A drop into empty space opens the palette filtered to the node types the edge could reach at all, and the picked node arrives wired - node and link as one undo step, so a single undo takes back the whole gesture. The decision of what fits what lives in core/graph/dropTarget.ts as pure functions, tested directly; the canvas only draws the answer. The palette takes an optional filter rather than knowing about drags. Verified remote on the Windows box (GPU): ALL PASSED, 284 checks.
195 lines
8.7 KiB
TypeScript
195 lines
8.7 KiB
TypeScript
import { type Application, Color, type ColorSource, type Container, Rectangle, Text } from 'pixi.js';
|
||
import { resolveObjects } from '../core/model/instance';
|
||
import { findObjectHolder } from '../core/model/Project';
|
||
import { Button, ui, Widget } from '../ui';
|
||
import type { CentreTab, Editor } from './Editor';
|
||
import type { EditorState } from './state';
|
||
|
||
/**
|
||
* Installs `window.__gm`, which the check scripts in tools/ use to find widgets by their
|
||
* visible text and to read editor state.
|
||
*/
|
||
export function installDebugHook(app: Application, state: EditorState, editor: Editor): void {
|
||
const { viewport, gameView, graphEditor, textureEditor, containerEditor, hierarchy } = editor;
|
||
const centreOf = (widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }) => {
|
||
const p = widget.getGlobalPosition();
|
||
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
|
||
};
|
||
/** Centre of the first (or, with `last`, the topmost/last-drawn) visible text equal to `text`. */
|
||
const locate = (text: string, last = false): { x: number; y: number } | null => {
|
||
let found: Text | null = null;
|
||
const walk = (node: Container): void => {
|
||
if ((found && !last) || !node.visible) return;
|
||
if (node instanceof Text && node.text === text) {
|
||
found = node;
|
||
return;
|
||
}
|
||
for (const child of node.children) walk(child);
|
||
};
|
||
walk(app.stage);
|
||
// TypeScript does not track the assignment inside `walk`, hence the disable and the cast.
|
||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||
if (!found) return null;
|
||
const node = found as Text;
|
||
const p = node.getGlobalPosition();
|
||
return { x: p.x + node.width / 2, y: p.y + node.height / 2 };
|
||
};
|
||
/** Fill colour of a visible text, as `#rrggbb` – what tells an override label from a plain one. */
|
||
const textColor = (text: string): string | null => {
|
||
let found: string | null = null;
|
||
const walk = (node: Container): void => {
|
||
if (found !== null || !node.visible) return;
|
||
if (node instanceof Text && node.text === text) {
|
||
found = new Color(node.style.fill as ColorSource).toHex();
|
||
return;
|
||
}
|
||
for (const child of node.children) walk(child);
|
||
};
|
||
walk(app.stage);
|
||
return found;
|
||
};
|
||
/**
|
||
* The visible text of the overlay layer, in drawing order – everything an open popup shows.
|
||
* Menus hide filtered-out entries, so this is also what a search box currently leaves.
|
||
*/
|
||
const overlayTexts = (): string[] => {
|
||
const out: string[] = [];
|
||
const walk = (node: Container): void => {
|
||
if (!node.visible) return;
|
||
if (node instanceof Text) {
|
||
if (node.text) out.push(node.text);
|
||
return;
|
||
}
|
||
for (const child of node.children) walk(child);
|
||
};
|
||
walk(ui().overlay);
|
||
return out;
|
||
};
|
||
/**
|
||
* Visible texts drawn wider than the widget that owns them. The owner's box is its declared
|
||
* `w`/`h`, not `getBounds()`: a Pixi container's bounds are the union of its children –
|
||
* including the overflowing text – so every overflow would measure as zero.
|
||
*/
|
||
const textOverflows = (all = false): { text: string; owner: string; over: number }[] => {
|
||
const out: { text: string; owner: string; over: number }[] = [];
|
||
const walk = (node: Container, owner: Widget | null, clipped: boolean): void => {
|
||
if (!node.visible) return;
|
||
const isWidget = node instanceof Widget;
|
||
const box = isWidget ? node : owner;
|
||
// A mask further up does not excuse a label spilling out of its own control, so the
|
||
// question restarts at every widget. `Boolean`, not `!== null`: Pixi's mask getter
|
||
// returns `undefined` when nothing masks.
|
||
const masked = (isWidget ? false : clipped) || Boolean(node.mask);
|
||
if (node instanceof Text) {
|
||
if (!box || !node.text || (masked && !all)) return;
|
||
const text = node.getBounds();
|
||
const origin = box.getGlobalPosition();
|
||
const over = Math.max(text.x + text.width - (origin.x + box.w), origin.x - text.x);
|
||
if (over > 1 || all) {
|
||
out.push({ text: node.text, owner: box.constructor.name, over: Math.round(over) });
|
||
}
|
||
return;
|
||
}
|
||
for (const child of node.children) walk(child, box, masked);
|
||
};
|
||
walk(app.stage, null, false);
|
||
return out;
|
||
};
|
||
/**
|
||
* The counterpart of `textOverflows`: buttons whose label was shortened away to nothing, which
|
||
* leaves a control that says nothing at all. An empty `Text` is invisible to the overflow walk.
|
||
*/
|
||
const labelsDropped = (): { text: string; owner: string; w: number }[] => {
|
||
const out: { text: string; owner: string; w: number }[] = [];
|
||
const walk = (node: Container): void => {
|
||
if (!node.visible) return;
|
||
if (node instanceof Button && node.text && !node.shownText) {
|
||
out.push({ text: node.text, owner: node.constructor.name, w: node.w });
|
||
}
|
||
for (const child of node.children) walk(child);
|
||
};
|
||
walk(app.stage);
|
||
return out;
|
||
};
|
||
/** Screen rectangle of the open popup (the backdrop is not a widget), or null when none is. */
|
||
const overlayRect = (): { x: number; y: number; w: number; h: number } | null => {
|
||
const popups = ui().overlay.children.filter((node): node is Widget => node instanceof Widget);
|
||
const popup = popups[popups.length - 1];
|
||
if (!popup) return null;
|
||
const p = popup.getGlobalPosition();
|
||
return { x: p.x, y: p.y, w: popup.w, h: popup.h };
|
||
};
|
||
/**
|
||
* A holder's objects the way the editor sees them: the content of placed containers is resolved
|
||
* from the template, so it is nowhere in `holder.objects` (see `model/instance.ts`).
|
||
*/
|
||
const objectsOf = (holderId: string) => {
|
||
const holder = findObjectHolder(state.project, holderId);
|
||
return holder ? resolveObjects(state.project, holder) : [];
|
||
};
|
||
(window as unknown as { __gm: unknown }).__gm = {
|
||
state,
|
||
objectsOf,
|
||
sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []),
|
||
locate,
|
||
textColor,
|
||
overlayTexts,
|
||
overlayRect,
|
||
textOverflows,
|
||
labelsDropped,
|
||
viewportCenter: () => centreOf(viewport),
|
||
viewportInfo: () => viewport.debugInfo(),
|
||
gameInfo: () => gameView.debugInfo(),
|
||
layerOf: (id: string) => viewport.layerOf(id),
|
||
drawOrder: () => viewport.drawOrder(),
|
||
objectScreen: (id: string) => viewport.objectScreen(id),
|
||
objectRect: (id: string) => viewport.objectRect(id),
|
||
/** The same for the Game tab – through the camera, so what the player sees. */
|
||
gameScreen: (id: string) => gameView.objectScreen(id),
|
||
graph: {
|
||
current: () => graphEditor.currentAssetId,
|
||
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
||
graphEditor.canvas.portGlobal(nodeId, port, isOutput),
|
||
nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId),
|
||
dropTarget: () => graphEditor.canvas.pendingDropTarget(),
|
||
// The canvas, not the whole tab: a centre measured through the header sits above the
|
||
// drawing area.
|
||
center: () => centreOf(graphEditor.canvas),
|
||
pickerWidth: () => graphEditor.pickerWidth,
|
||
},
|
||
texture: {
|
||
current: () => textureEditor.currentAssetId,
|
||
pickerWidth: () => textureEditor.pickerWidth,
|
||
/** Where an image pixel of the shown sheet sits on screen (frames are dragged in image coordinates). */
|
||
pointAt: (x: number, y: number) => textureEditor.imageToScreen(x, y),
|
||
},
|
||
container: {
|
||
current: () => containerEditor.currentAssetId,
|
||
pickerWidth: () => containerEditor.pickerWidth,
|
||
center: () => centreOf(containerEditor),
|
||
rect: () => containerEditor.canvasRect(),
|
||
/** Where a template object is drawn, so a check can click or sample it. */
|
||
objectScreen: (id: string) => containerEditor.objectScreen(id),
|
||
},
|
||
world: () => editor.worldSnapshot(),
|
||
/** Rows the hierarchy shows – collapsed subtrees are not in here. */
|
||
hierarchyOrder: () => hierarchy.rowOrder(),
|
||
hierarchyChevron: (id: string) => hierarchy.chevronPosition(id),
|
||
hierarchyRowColor: (id: string) => hierarchy.rowColor(id),
|
||
hierarchyExpandAll: () => hierarchy.expandAll(),
|
||
hierarchyCollapseAll: () => hierarchy.collapseAll(),
|
||
/** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */
|
||
pixelAt: (x: number, y: number) => {
|
||
const { pixels } = app.renderer.extract.pixels({
|
||
target: app.stage,
|
||
frame: new Rectangle(x, y, 1, 1),
|
||
});
|
||
const hex = (value: number | undefined) => (value ?? 0).toString(16).padStart(2, '0');
|
||
return `#${hex(pixels[0])}${hex(pixels[1])}${hex(pixels[2])}`;
|
||
},
|
||
fps: () => editor.fps,
|
||
tab: () => editor.tab,
|
||
showTab: (tab: CentreTab) => editor.showTab(tab),
|
||
};
|
||
}
|