Split the debug hook into one file per topic (#215)
debugHook.ts had grown to 1097 lines of unrelated test hooks. They now live in seven files under src/editor/debug/, grouped by topic. debugHook.ts only assembles them and is down to 86 lines. - The shape of window.__gm is unchanged for tools/. Measured in the browser before and after: 120 top-level keys and 313 leaves with their arity, byte-identical JSON. - The Text import is explicit in the helpers. Without it the name fell back to the DOM type and instanceof would never have matched. Findings: - The comment for vectorArt sat above pixelArt. It is back in place. - An orphaned duplicate comment on assetRow is gone. Tests: - ./check.sh: 145 files, 2192 tests, CHECK EXIT 0 (unchanged) - e2e 1-16,37,39-43,47,48: ALL PASSED, 734 checks - The shape probe turns red against a deliberately misnested hook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b830f9c747
commit
ed29b2f3dc
9 files changed
+1114
-1031
No files matched your search
@@ -0,0 +1,89 @@
|
||||
import { findAsset, findAssetOf } from '../../core/model/Project';
|
||||
import { previewObjectId } from '../../core/model/animationPreview';
|
||||
import { currentFramesDialog } from '../animation/FramesDialog';
|
||||
import { currentRowDialog } from '../animation/RowDialog';
|
||||
import { centreOf, type HookContext } from './hookHelpers';
|
||||
|
||||
/** The Texture, Animation, Tile and List tabs. */
|
||||
export function documentHooks({ state, editor }: HookContext) {
|
||||
const { textureEditor } = editor;
|
||||
return {
|
||||
texture: {
|
||||
current: () => textureEditor.currentAssetId,
|
||||
/** 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),
|
||||
rect: () => textureEditor.canvasRect(),
|
||||
/** Whether a press edits the selected frame's collision outline (#141). */
|
||||
collisionMode: () => textureEditor.canvas.collisionMode,
|
||||
/** The open "Generate collision…" dialog (#161), or null. */
|
||||
generateDialog: () => textureEditor.generateDialog.current()?.info ?? null,
|
||||
},
|
||||
animation: {
|
||||
current: () => editor.animationEditor.currentAssetId,
|
||||
info: () => editor.animationEditor.shownInfo,
|
||||
rootPickerAt: () => centreOf(editor.animationEditor.rootPicker),
|
||||
addRowAt: () => centreOf(editor.animationEditor.addRowButton),
|
||||
playAt: () => ({
|
||||
...centreOf(editor.animationEditor.playButton),
|
||||
text: editor.animationEditor.playButton.text,
|
||||
}),
|
||||
/** Where a cell, a row's header and a frame on the ruler sit – no text names a cell. */
|
||||
cellAt: (trackId: string, frame: number) => editor.animationEditor.canvas.cellGlobal(trackId, frame),
|
||||
headerAt: (trackId: string) => editor.animationEditor.canvas.headerGlobal(trackId),
|
||||
rulerAt: (frame: number) => editor.animationEditor.canvas.rulerGlobal(frame),
|
||||
keys: () => editor.animationEditor.canvas.keysDrawn(),
|
||||
rows: () => editor.animationEditor.canvas.rowsDrawn(),
|
||||
view: () => editor.animationEditor.canvas.viewInfo(),
|
||||
scrub: (frame: number) => editor.animationEditor.canvas.scrub(frame),
|
||||
/** The open row dialog: what it offers and holds, and a way to pick without clicking through menus. */
|
||||
rowDialog: () => currentRowDialog()?.debugInfo() ?? null,
|
||||
rowDialogPick: (target: string, property: string) => currentRowDialog()?.pick(target, property),
|
||||
/** Copy/paste and frames (#102): what the clipboard holds, and the open frames dialog. */
|
||||
clipboard: () => structuredClone(state.keyClipboard),
|
||||
framesDialog: () => currentFramesDialog()?.debugInfo() ?? null,
|
||||
framesDialogCount: (count: number) => currentFramesDialog()?.setCount(count),
|
||||
framesDialogConfirm: () => {
|
||||
const dialog = currentFramesDialog();
|
||||
if (!dialog) return null;
|
||||
const g = dialog.confirm.getGlobalPosition();
|
||||
return { x: g.x + dialog.confirm.w / 2, y: g.y + dialog.confirm.h / 2 };
|
||||
},
|
||||
/** The preview above the timeline (#99): where a row's target is drawn and with which values. */
|
||||
preview: {
|
||||
/** The id the preview draws a row's target under (`''` = the root itself). */
|
||||
objectId: (target: string) => {
|
||||
const asset = findAssetOf(state.project, editor.animationEditor.currentAssetId, 'animation');
|
||||
return previewObjectId(asset && findAsset(state.project, asset.root), target);
|
||||
},
|
||||
rect: (objectId: string) => editor.animationEditor.preview.objectRect(objectId),
|
||||
item: (objectId: string) => editor.animationEditor.preview.itemDrawn(objectId),
|
||||
info: () => editor.animationEditor.preview.debugInfo(),
|
||||
fitAt: () => centreOf(editor.animationEditor.fitButton),
|
||||
},
|
||||
},
|
||||
tile: {
|
||||
current: () => editor.tileEditor.currentAssetId,
|
||||
info: () => editor.tileEditor.shownInfo,
|
||||
/** What the board drew: tiles, the picture it is showing and what stands in the way. */
|
||||
board: () => editor.tileEditor.canvas.debugInfo(),
|
||||
pictures: () => editor.tileEditor.strip.debugInfo(),
|
||||
/** A tile of the board: its quarters and where each of them is drawn. */
|
||||
tile: (index: number) => editor.tileEditor.canvas.tileDrawn(index),
|
||||
/** Which board tile a cell of the sample map became – `-1` when that cell is empty. */
|
||||
sampleTile: (row: number, column: number) => editor.tileEditor.canvas.sampleTile(row, column),
|
||||
kindPickerAt: () => centreOf(editor.tileEditor.kindPicker),
|
||||
addPictureAt: () => centreOf(editor.tileEditor.strip.addButton),
|
||||
removePictureAt: () => centreOf(editor.tileEditor.strip.removeButton),
|
||||
pictureRowAt: (index: number) => editor.tileEditor.strip.list.rowGlobal(String(index)),
|
||||
fitAt: () => centreOf(editor.tileEditor.fitButton),
|
||||
},
|
||||
list: {
|
||||
info: () => editor.listEditor.debugInfo(),
|
||||
/** Where an item's widget sits, so a check can click it – a number field has no findable text. */
|
||||
fieldAt: (index: number, field = '') => editor.listEditor.fieldAt(index, field),
|
||||
/** The kind mark of a row, or of one field of a struct item – both are pickers. */
|
||||
markAt: (index: number, field = '') => editor.listEditor.markAt(index, field),
|
||||
nameAt: (index: number, field: string) => editor.listEditor.nameAt(index, field),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { NameTransitionCommand } from '../../core/commands/graphCommands';
|
||||
import { currentNodePalette } from '../graph/NodePalette';
|
||||
import { rectOf } from '../textProbe';
|
||||
import { centreOf, snapButton, type HookContext } from './hookHelpers';
|
||||
|
||||
/** The node graphs: behaviors, state machines, particles and shaders. */
|
||||
export function graphHooks({ state, editor }: HookContext) {
|
||||
const { graphEditor } = editor;
|
||||
return {
|
||||
graph: {
|
||||
current: () => graphEditor.currentAssetId,
|
||||
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
||||
graphEditor.canvas.portGlobal(nodeId, port, isOutput),
|
||||
nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId),
|
||||
chevronAt: (nodeId: string) => graphEditor.canvas.chevronGlobal(nodeId),
|
||||
nodeLabel: (nodeId: string) => graphEditor.canvas.nodeLabel(nodeId),
|
||||
nodeProblem: (nodeId: string) => graphEditor.canvas.nodeProblem(nodeId),
|
||||
nodeSettings: (nodeId: string) => graphEditor.canvas.nodeSettings(nodeId),
|
||||
/** Each input row as drawn: its name, and the inline value beside it while unconnected. */
|
||||
nodeInputs: (nodeId: string) => graphEditor.canvas.nodeInputs(nodeId),
|
||||
/** Each output row as drawn: while playing, the value it last carried in front of its name. */
|
||||
nodeOutputs: (nodeId: string) => graphEditor.canvas.nodeOutputs(nodeId),
|
||||
/** Beads in flight on exec edges as drawn, in graph coordinates; `progress` 0 = source, 1 = target. */
|
||||
beads: () => graphEditor.canvas.beadsDrawn(),
|
||||
/** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */
|
||||
nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId),
|
||||
canvasRect: () => graphEditor.canvas.canvasRect(),
|
||||
/** Bare canvas below every node – where a step right-clicks to open the palette. */
|
||||
emptySpot: () => graphEditor.canvas.emptySpotGlobal(),
|
||||
/**
|
||||
* What the open palette lists, in order. No colour check can see a ranking: the rows all
|
||||
* look alike, only their order carries the answer (#43).
|
||||
*/
|
||||
paletteRows: () => currentNodePalette()?.rowsDrawn() ?? null,
|
||||
selected: () => state.selectedNodeIds,
|
||||
/** Groups and comments as drawn – text and both colours included, since no pixel says them. */
|
||||
annotations: () => graphEditor.canvas.annotationsDrawn(),
|
||||
/** Where one is grabbed: its title bar, or with `corner` the handle that resizes it. */
|
||||
annotationAt: (id: string, corner = false) => graphEditor.canvas.annotationGlobal(id, corner),
|
||||
/** The box as drawn on screen – how a check sees whether a fit brought it into view. */
|
||||
annotationRect: (id: string) => graphEditor.canvas.annotationRect(id),
|
||||
dropTarget: () => graphEditor.canvas.pendingDropTarget(),
|
||||
danglingEdge: () => graphEditor.canvas.danglingEdgeVisible(),
|
||||
edgeDrawn: (edgeId: string) => graphEditor.canvas.edgeDrawn(edgeId),
|
||||
/** The colour it was stroked in: `danger` is an edge whose two ends stopped fitting (#207). */
|
||||
edgeColor: (edgeId: string) => graphEditor.canvas.edgeColor(edgeId),
|
||||
/** The types the node's ports are drawn with, after a shared group had its say (#207). */
|
||||
portTypes: (nodeId: string) => graphEditor.canvas.portTypes(nodeId),
|
||||
foldMark: (edgeId: string, isOutput: boolean) => graphEditor.canvas.foldMarkGlobal(edgeId, isOutput),
|
||||
foldMarks: () => graphEditor.canvas.foldMarksDrawn(),
|
||||
// The canvas, not the whole tab: a centre measured through the header sits above the
|
||||
// drawing area.
|
||||
center: () => centreOf(graphEditor.canvas),
|
||||
snapping: () => graphEditor.canvas.snapEnabled,
|
||||
snapButton: () => snapButton(graphEditor.snap),
|
||||
zoom: () => graphEditor.canvas.zoomLevel,
|
||||
crumbs: () => graphEditor.breadcrumb.crumbsDrawn(),
|
||||
},
|
||||
stateMachine: {
|
||||
current: () => editor.stateMachineEditor.currentAssetId,
|
||||
nodeAt: (nodeId: string) => editor.stateMachineEditor.canvas.nodeGlobal(nodeId),
|
||||
nodeLabel: (nodeId: string) => editor.stateMachineEditor.canvas.nodeLabel(nodeId),
|
||||
nodeRect: (nodeId: string) => editor.stateMachineEditor.canvas.nodeRect(nodeId),
|
||||
canvasRect: () => editor.stateMachineEditor.canvas.canvasRect(),
|
||||
center: () => centreOf(editor.stateMachineEditor.canvas),
|
||||
snapping: () => editor.stateMachineEditor.canvas.snapEnabled,
|
||||
snapButton: () => snapButton(editor.stateMachineEditor.snap),
|
||||
/** Where a state's hidden pin sits; it cannot be grabbed – `portGrabbable` is the check. */
|
||||
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
||||
editor.stateMachineEditor.canvas.portGlobal(nodeId, port, isOutput),
|
||||
portGrabbable: (nodeId: string, port: string, isOutput: boolean) =>
|
||||
editor.stateMachineEditor.canvas.portGrabbable(nodeId, port, isOutput),
|
||||
edgeDrawn: (edgeId: string) => editor.stateMachineEditor.canvas.edgeDrawn(edgeId),
|
||||
transitionLabel: (edgeId: string) => editor.stateMachineEditor.canvas.transitionLabelDrawn(edgeId),
|
||||
transitionLabelAt: (edgeId: string) => editor.stateMachineEditor.canvas.transitionLabelAt(edgeId),
|
||||
hint: () => editor.stateMachineEditor.canvas.shownHint(),
|
||||
/** Names a transition the way the inspector row does, for steps that need one already named. */
|
||||
nameTransition: (edgeId: string, name: string) =>
|
||||
state.execute(new NameTransitionCommand(editor.stateMachineEditor.currentAssetId, edgeId, name)),
|
||||
dropTarget: () => editor.stateMachineEditor.canvas.pendingDropTarget(),
|
||||
danglingEdge: () => editor.stateMachineEditor.canvas.danglingEdgeVisible(),
|
||||
emptySpot: () => editor.stateMachineEditor.canvas.emptySpotGlobal(),
|
||||
selected: () => state.selectedNodeIds,
|
||||
/** The start ring and the live marks as drawn – what no colour check could tell apart. */
|
||||
startRing: (nodeId: string) => editor.stateMachineEditor.canvas.startRingDrawn(nodeId),
|
||||
stateMarks: () => editor.stateMachineEditor.canvas.stateMarksDrawn(),
|
||||
},
|
||||
particles: {
|
||||
current: () => editor.particlesEditor.currentAssetId,
|
||||
info: () => editor.particlesEditor.shownInfo,
|
||||
snapButton: () => snapButton(editor.particlesEditor.snap),
|
||||
nodeAt: (nodeId: string) => editor.particlesEditor.canvas.nodeGlobal(nodeId),
|
||||
nodeLabel: (nodeId: string) => editor.particlesEditor.canvas.nodeLabel(nodeId),
|
||||
nodeRect: (nodeId: string) => editor.particlesEditor.canvas.nodeRect(nodeId),
|
||||
nodeProblem: (nodeId: string) => editor.particlesEditor.canvas.nodeProblem(nodeId),
|
||||
canvasRect: () => editor.particlesEditor.canvas.canvasRect(),
|
||||
emptySpot: () => editor.particlesEditor.canvas.emptySpotGlobal(),
|
||||
center: () => centreOf(editor.particlesEditor.canvas),
|
||||
/** The panel beside the graph: how many particles it draws and which second it stands at. */
|
||||
preview: () => ({
|
||||
count: editor.particlesEditor.preview.count,
|
||||
time: Math.round(editor.particlesEditor.preview.time * 100) / 100,
|
||||
holding: editor.particlesEditor.preview.holding,
|
||||
}),
|
||||
previewRect: () => rectOf(editor.particlesEditor.preview),
|
||||
/** Hold button, time readout and slider as they are shown, not as the preview holds them. */
|
||||
controls: () => editor.particlesEditor.controls,
|
||||
splitterAt: () => centreOf(editor.particlesEditor.splitter),
|
||||
sliderAt: () => centreOf(editor.particlesEditor.slider),
|
||||
/** Jumps the preview to a second – exact, because a particle is a function of its age. */
|
||||
hold: (at: number | null) => editor.particlesEditor.preview.hold(at),
|
||||
},
|
||||
shader: {
|
||||
current: () => editor.shaderEditor.currentAssetId,
|
||||
info: () => editor.shaderEditor.shownInfo,
|
||||
nodeAt: (nodeId: string) => editor.shaderEditor.canvas.nodeGlobal(nodeId),
|
||||
nodeLabel: (nodeId: string) => editor.shaderEditor.canvas.nodeLabel(nodeId),
|
||||
nodeRect: (nodeId: string) => editor.shaderEditor.canvas.nodeRect(nodeId),
|
||||
nodeProblem: (nodeId: string) => editor.shaderEditor.canvas.nodeProblem(nodeId),
|
||||
nodeSettings: (nodeId: string) => editor.shaderEditor.canvas.nodeSettings(nodeId),
|
||||
canvasRect: () => editor.shaderEditor.canvas.canvasRect(),
|
||||
emptySpot: () => editor.shaderEditor.canvas.emptySpotGlobal(),
|
||||
center: () => centreOf(editor.shaderEditor.canvas),
|
||||
snapping: () => editor.shaderEditor.canvas.snapEnabled,
|
||||
snapButton: () => snapButton(editor.shaderEditor.snap),
|
||||
/** The panel beside the graph (#168): what it shows, its clock and the sliders' values. */
|
||||
preview: () => ({
|
||||
...editor.shaderEditor.controls,
|
||||
holding: editor.shaderEditor.preview.holding,
|
||||
seconds: Math.round(editor.shaderEditor.preview.time * 100) / 100,
|
||||
}),
|
||||
previewRect: () => rectOf(editor.shaderEditor.preview),
|
||||
/** Where the picture sits inside the panel, in screen pixels. */
|
||||
pictureRect: () => editor.shaderEditor.preview.pictureRect,
|
||||
sliderAt: (key: string) => {
|
||||
const slider = editor.shaderEditor.sliderOf(key);
|
||||
return slider ? rectOf(slider) : null;
|
||||
},
|
||||
splitterAt: () => centreOf(editor.shaderEditor.splitter),
|
||||
hold: (at: number | null) => editor.shaderEditor.hold(at),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
import type { Editor } from '../Editor';
|
||||
import type { SnapToggle } from '../graph/SnapToggle';
|
||||
import type { EditorState } from '../state';
|
||||
|
||||
/** What every group of test hooks is built from. */
|
||||
export interface HookContext {
|
||||
app: Application;
|
||||
state: EditorState;
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
export function centreOf(widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }): {
|
||||
x: number;
|
||||
y: number;
|
||||
} {
|
||||
const p = widget.getGlobalPosition();
|
||||
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
|
||||
}
|
||||
|
||||
/** A tab's magnet (#194): where to click it, how big it is drawn, and whether it is on. */
|
||||
export function snapButton(snap: SnapToggle): {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
toggled: boolean;
|
||||
} {
|
||||
return {
|
||||
...centreOf(snap.button),
|
||||
w: snap.button.w,
|
||||
h: snap.button.h,
|
||||
toggled: snap.button.toggled,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { Container } from 'pixi.js';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { Button, type Widget } from '../../ui';
|
||||
import { pixelCels } from '../../viewport/PixelCels';
|
||||
import { currentResizeDialog } from '../pixelart/ResizeCanvasDialog';
|
||||
import { centreOf, type HookContext } from './hookHelpers';
|
||||
|
||||
/** The Pixel art tab (#130). */
|
||||
export function pixelArtHooks({ state, editor }: HookContext) {
|
||||
return {
|
||||
/** The Pixel art tab (#130): where its controls are, what it paints with, and the asset's pixels. */
|
||||
pixelArt: {
|
||||
current: () => editor.pixelArtEditor.currentAssetId,
|
||||
info: () => editor.pixelArtEditor.shownInfo,
|
||||
/** Where the centre of canvas pixel (x, y) is on screen. */
|
||||
pixelAt: (x: number, y: number) => editor.pixelArtEditor.canvas.pixelToScreen(x, y),
|
||||
zoom: () => editor.pixelArtEditor.canvas.zoom,
|
||||
tool: () => editor.pixelArtEditor.canvas.tool,
|
||||
color: () => editor.pixelArtEditor.canvas.color,
|
||||
frameId: () => editor.pixelArtEditor.canvas.frameId,
|
||||
layerId: () => editor.pixelArtEditor.canvas.layerId,
|
||||
/** A control of the side panel or the header, scrolled into view: its centre and whether it is enabled. */
|
||||
control: (name: string) => {
|
||||
const side = editor.pixelArtEditor.side;
|
||||
const byName: Record<string, Widget | undefined> = {
|
||||
brush: side.brushButton,
|
||||
filled: side.filledBox,
|
||||
contiguous: side.contiguousBox,
|
||||
color: side.colorField,
|
||||
addColor: side.addColorButton,
|
||||
removeColor: side.removeColorButton,
|
||||
createPalette: side.createPaletteButton,
|
||||
onion: side.onionBox,
|
||||
tiles: side.tilesBox,
|
||||
fps: side.fpsField,
|
||||
palette: side.palette,
|
||||
preview: side.preview,
|
||||
resize: editor.pixelArtEditor.resizeButton,
|
||||
...Object.fromEntries([...side.toolButtons].map(([tool, button]) => [tool, button])),
|
||||
...Object.fromEntries(Object.entries(side.layerButtons).map(([k, b]) => [`layer:${k}`, b])),
|
||||
};
|
||||
// The plus in a list's header is no widget of its own.
|
||||
const list = name === 'layer:add' ? side.layers : name === 'frame:add' ? side.frames : null;
|
||||
if (list) {
|
||||
side.reveal(list);
|
||||
const at = list.addGlobal();
|
||||
return at ? { ...at, enabled: true, text: '+' } : null;
|
||||
}
|
||||
const widget = byName[name];
|
||||
// A tool's options hide in their row (#201), so a hidden parent hides the control as well.
|
||||
for (let at: Container | null = widget ?? null; at; at = at.parent) if (!at.visible) return null;
|
||||
if (!widget) return null;
|
||||
if (widget !== editor.pixelArtEditor.resizeButton) side.reveal(widget);
|
||||
return {
|
||||
...centreOf(widget),
|
||||
w: widget.w,
|
||||
enabled: widget.enabled,
|
||||
text: widget instanceof Button ? widget.text : '',
|
||||
};
|
||||
},
|
||||
/** Where the controls of the side panel may reach, left and right. */
|
||||
sideSpan: () => editor.pixelArtEditor.side.contentSpan(),
|
||||
swatch: (index: number) => {
|
||||
const side = editor.pixelArtEditor.side;
|
||||
side.reveal(side.palette);
|
||||
return side.palette.swatchGlobal(index);
|
||||
},
|
||||
/**
|
||||
* The centre of a layer's or frame's row, scrolled into view, with the centre of its picture;
|
||||
* `eye` is the layer's switch, `duplicate` and `remove` the marks at the end (no cross on the
|
||||
* last one), `name` where a double-click edits the name, `opacity` the field with its text and
|
||||
* `renaming` the field a name is being edited in.
|
||||
*/
|
||||
layerRow: (layerId: string) => {
|
||||
const side = editor.pixelArtEditor.side;
|
||||
side.reveal(side.layers);
|
||||
const row = side.layers.rowGlobal(layerId);
|
||||
const opacity = side.layers.valueField(layerId);
|
||||
const renaming = side.layers.renameField;
|
||||
// On the name: the middle of the row may be the opacity field, which a press would scrub.
|
||||
const name = side.layers.nameGlobal(layerId);
|
||||
return row && name
|
||||
? {
|
||||
x: name.x,
|
||||
y: name.y,
|
||||
thumb: side.layers.thumbGlobal(layerId),
|
||||
name,
|
||||
eye: side.layers.eyeGlobal(layerId),
|
||||
duplicate: side.layers.duplicateGlobal(layerId),
|
||||
remove: side.layers.removeGlobal(layerId),
|
||||
opacity: opacity ? { ...centreOf(opacity), value: opacity.value } : null,
|
||||
renaming: renaming ? { ...centreOf(renaming), value: renaming.value } : null,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
frameRow: (frameId: string) => {
|
||||
const side = editor.pixelArtEditor.side;
|
||||
side.reveal(side.frames);
|
||||
const row = side.frames.rowGlobal(frameId);
|
||||
return row
|
||||
? {
|
||||
x: row.x + row.w / 2,
|
||||
y: row.y + row.h / 2,
|
||||
thumb: side.frames.thumbGlobal(frameId),
|
||||
duplicate: side.frames.duplicateGlobal(frameId),
|
||||
remove: side.frames.removeGlobal(frameId),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
/** The ids as the two lists show them, top first. */
|
||||
layerRows: () => editor.pixelArtEditor.side.layers.shownIds,
|
||||
frameRows: () => editor.pixelArtEditor.side.frames.shownIds,
|
||||
previewIndex: () => editor.pixelArtEditor.side.preview.shownIndex,
|
||||
/** Canvas, layers and frames as the model holds them. */
|
||||
asset: (assetId: string) => {
|
||||
const asset = findAsset(state.project, assetId);
|
||||
return asset?.type === 'pixelart'
|
||||
? (JSON.parse(
|
||||
JSON.stringify({
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
layers: asset.layers,
|
||||
frames: asset.frames,
|
||||
props: asset.props,
|
||||
}),
|
||||
) as unknown)
|
||||
: null;
|
||||
},
|
||||
/** The palette indices of a cel, row by row; null for an empty cel, 'loading' while it loads. */
|
||||
cel: (file: string) => {
|
||||
const cel = pixelCels.read(file);
|
||||
return cel === undefined
|
||||
? 'loading'
|
||||
: cel === null
|
||||
? null
|
||||
: { width: cel.width, height: cel.height, data: [...cel.data] };
|
||||
},
|
||||
/** Cels painted here that the server does not hold yet. */
|
||||
pending: () => pixelCels.pending(state.project).length,
|
||||
resizeDialog: () => {
|
||||
const dialog = currentResizeDialog();
|
||||
if (!dialog) return null;
|
||||
return {
|
||||
...dialog.debugInfo(),
|
||||
width: centreOf(dialog.widthField),
|
||||
height: centreOf(dialog.heightField),
|
||||
confirm: centreOf(dialog.confirm),
|
||||
anchors: Object.fromEntries(
|
||||
([0, 0.5, 1] as const).flatMap((y) =>
|
||||
([0, 0.5, 1] as const).map((x) => [`${x},${y}`, dialog.anchorButton(x, y)]),
|
||||
),
|
||||
),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { type FrameRect, resolvePicture } from '../../core/model/texture';
|
||||
import { effectiveScale, gameClock } from '../../core/time';
|
||||
import { afterFrames, isRendering, renderCount, requestRender } from '../../ui';
|
||||
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import { type Container, Mesh, Rectangle } from 'pixi.js';
|
||||
import type { HookContext } from './hookHelpers';
|
||||
|
||||
/** What was really drawn, and the clocks behind it: pixels, textures, frames, shaders and particles. */
|
||||
export function renderHooks({ app, state, editor }: HookContext) {
|
||||
const { viewport, gameView } = editor;
|
||||
/** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */
|
||||
const pixelAt = (x: number, y: number): string => {
|
||||
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])}`;
|
||||
};
|
||||
return {
|
||||
pixelAt,
|
||||
/**
|
||||
* A horizontal run of pixels in one read. A check that has to tell a repeated pattern from a
|
||||
* stretched one needs dozens of samples, and dozens of round trips would cost more than the
|
||||
* step it belongs to.
|
||||
*/
|
||||
pixelRow: (x: number, y: number, count: number) => {
|
||||
const width = Math.max(1, Math.min(1024, Math.round(count)));
|
||||
const row: string[] = [];
|
||||
// One extract per pixel, not one strip: a wider frame comes back resampled, and a row that
|
||||
// blends its neighbours cannot tell a hard border from a soft one – which is what it is for.
|
||||
for (let i = 0; i < width; i++) row.push(pixelAt(x + i, y));
|
||||
return row;
|
||||
},
|
||||
/** The filter and wrap the GPU sampler actually got, next to the model's – only the driver knows the former. */
|
||||
textureFilter: (file: string, rect: FrameRect | null = null) => {
|
||||
const texture = textureLibrary.peek(file, rect);
|
||||
if (!texture) return null;
|
||||
const source = texture.source;
|
||||
const renderer = app.renderer as unknown as {
|
||||
gl?: WebGL2RenderingContext;
|
||||
uid: number;
|
||||
texture: { bindSource(source: unknown, location: number): void };
|
||||
};
|
||||
const gl = renderer.gl;
|
||||
const uploaded = Boolean(
|
||||
(source as unknown as { _gpuData: Record<number, unknown> })._gpuData[renderer.uid],
|
||||
);
|
||||
let magFilter = 0;
|
||||
let wrapT = 0;
|
||||
if (gl) {
|
||||
// Through Pixi rather than a raw `bindTexture`, so its idea of what is bound stays true.
|
||||
renderer.texture.bindSource(source, 0);
|
||||
magFilter = gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER) as number;
|
||||
wrapT = gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T) as number;
|
||||
}
|
||||
return {
|
||||
scaleMode: source.scaleMode,
|
||||
gpu: magFilter === gl?.NEAREST ? 'nearest' : magFilter === gl?.LINEAR ? 'linear' : 'none',
|
||||
wrap:
|
||||
wrapT === gl?.CLAMP_TO_EDGE
|
||||
? 'clamp'
|
||||
: wrapT === gl?.REPEAT
|
||||
? 'repeat'
|
||||
: wrapT === gl?.MIRRORED_REPEAT
|
||||
? 'mirror'
|
||||
: 'none',
|
||||
uploaded,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* A picture an asset draws itself (#129, #185): the key it is kept under, the canvas the model
|
||||
* says it is, and the size it actually lies on the GPU in – which is the only place the
|
||||
* supersampling of the rasteriser is visible. `kept` false means the key is not in memory,
|
||||
* which is what a check reads to see an old revision let go.
|
||||
*/
|
||||
drawnPicture: (assetId: string) => {
|
||||
const ref = resolvePicture(state.project, assetId);
|
||||
if (!ref) return null;
|
||||
const texture = textureLibrary.peek(ref.file);
|
||||
return {
|
||||
file: ref.file,
|
||||
width: ref.sourceWidth,
|
||||
height: ref.sourceHeight,
|
||||
pixelWidth: texture?.source.pixelWidth ?? 0,
|
||||
pixelHeight: texture?.source.pixelHeight ?? 0,
|
||||
kept: texture !== null,
|
||||
};
|
||||
},
|
||||
fps: () => editor.fps,
|
||||
/**
|
||||
* What the render loop did: frames actually drawn, and whether it is currently awake. The
|
||||
* only way to see a missing repaint from a check script – `pixelAt` re-renders on demand and
|
||||
* would answer correctly even if the screen had been frozen for a minute.
|
||||
*/
|
||||
renderStats: () => ({ drawn: renderCount(), awake: isRendering() }),
|
||||
/** Draw again, whatever happens next – what a screenshot needs before it is taken. */
|
||||
wake: () => requestRender(),
|
||||
/**
|
||||
* Resolves once the editor has drawn `count` more frames. The checks await this instead of
|
||||
* sleeping a guessed number of milliseconds: a frame is the editor saying it has caught up,
|
||||
* where a deadline is only the harness hoping it has.
|
||||
*/
|
||||
afterFrames: (count = 2, timeoutMs = 2000) => afterFrames(count, timeoutMs),
|
||||
/** Visible nodes carrying a filter – one extra render pass each; since #77 a shader is none. */
|
||||
filterCount: () => {
|
||||
let n = 0;
|
||||
const walk = (node: Container): void => {
|
||||
if (!node.visible) return;
|
||||
if (((node.filters as readonly unknown[] | undefined)?.length ?? 0) > 0) n += 1;
|
||||
for (const child of node.children) walk(child);
|
||||
};
|
||||
walk(app.stage);
|
||||
return n;
|
||||
},
|
||||
/** Visible sprite meshes drawing with a shader of their own – one draw call each, no pass (#77). */
|
||||
shaderCount: () => {
|
||||
let n = 0;
|
||||
const walk = (node: Container): void => {
|
||||
if (!node.visible) return;
|
||||
if (node instanceof Mesh && node.shader) n += 1;
|
||||
for (const child of node.children) walk(child);
|
||||
};
|
||||
walk(app.stage);
|
||||
return n;
|
||||
},
|
||||
/** The global clock: the second it stands at, the scale set, and the one a pause – either kind – makes of it. */
|
||||
globalTime: () => ({
|
||||
now: gameClock.now,
|
||||
scale: gameClock.scale,
|
||||
effective: effectiveScale(gameClock.scale, state.paused || editor.playInfo().gamePaused),
|
||||
}),
|
||||
/** Sets the global time scale – there is no slider for it in the GUI yet. */
|
||||
setTimeScale: (scale: number) => {
|
||||
gameClock.scale = scale;
|
||||
},
|
||||
/** Holds the shader clock at `at` seconds, so a Time shader's pixels can be measured; null releases it. */
|
||||
shaderTime: (at: number | null) => shaderLibrary.freezeTime(at),
|
||||
/** The same for particle time, kept apart so one can be held without the other. */
|
||||
particleTime: (at: number | null) => particleLibrary.freezeTime(at),
|
||||
/** Every source's clock back to zero on the current (usually frozen) second. */
|
||||
particlesRestart: () => {
|
||||
viewport.restartParticles(particleLibrary.now());
|
||||
gameView.restartParticles(particleLibrary.now());
|
||||
},
|
||||
/** What a source is drawing in the scene tab, or in the game view while playing. */
|
||||
particlesOf: (objectId: string, where: 'scene' | 'game' = 'scene', limit = 8) =>
|
||||
(where === 'game' ? gameView : viewport).particleInfo(objectId, limit),
|
||||
/** Graphs compiled so far; a moved node must not add one (#65). */
|
||||
shaderCompiles: () => shaderLibrary.compiles,
|
||||
/** Which views redraw every tick because a Time shader is on screen. */
|
||||
animates: () => ({ scene: viewport.animates, game: gameView.animates }),
|
||||
lowLatency: () =>
|
||||
(app.renderer as unknown as { gl?: WebGL2RenderingContext }).gl?.getContextAttributes()
|
||||
?.desynchronized === true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { findAssetOf, findObjectHolder } from '../../core/model/Project';
|
||||
import { resolveObjects } from '../../core/model/instance';
|
||||
import { findLayer, layerZ } from '../../core/model/tilemap';
|
||||
import { currentTilemapWidgets, paintedLayerId, paintedTilemap } from '../panels/TilemapPanel';
|
||||
import type { TilemapTool } from '../state';
|
||||
import { rectOf } from '../textProbe';
|
||||
import { centreOf, type HookContext } from './hookHelpers';
|
||||
|
||||
/** The Scene and Game tabs, the tilemap painted in the scene view, and the Container tab. */
|
||||
export function sceneHooks({ state, editor }: HookContext) {
|
||||
const { viewport, gameView, containerEditor } = editor;
|
||||
/**
|
||||
* 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) : [];
|
||||
};
|
||||
return {
|
||||
sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []),
|
||||
viewportCenter: () => centreOf(viewport),
|
||||
/** Pans the Scene tab to a world point – a step's own lane, away from what earlier steps left. */
|
||||
lookAt: (x: number, y: number) => viewport.lookAt(x, y),
|
||||
/** Its drawn box – a step that wants an empty spot must stay inside what the console left over. */
|
||||
viewportRect: () => {
|
||||
const p = viewport.getGlobalPosition();
|
||||
return { x: p.x, y: p.y, w: viewport.w, h: viewport.h };
|
||||
},
|
||||
/**
|
||||
* A point in the viewport that no object covers – steps click there to deselect. A fixed
|
||||
* offset from the centre lands on an object as soon as the viewport changes size.
|
||||
*/
|
||||
emptySpot: () => {
|
||||
const p = viewport.getGlobalPosition();
|
||||
const scene = state.activeScene;
|
||||
const taken = scene
|
||||
? objectsOf(scene.id)
|
||||
.map((object) => viewport.objectRect(object.id))
|
||||
.filter((rect): rect is NonNullable<typeof rect> => rect !== null)
|
||||
: [];
|
||||
const clear = (x: number, y: number) =>
|
||||
!taken.some((r) => x >= r.x - 12 && x <= r.x + r.w + 12 && y >= r.y - 12 && y <= r.y + r.h + 12);
|
||||
for (let y = p.y + viewport.h - 24; y > p.y + 24; y -= 24) {
|
||||
for (let x = p.x + 24; x < p.x + viewport.w - 24; x += 24) if (clear(x, y)) return { x, y };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
viewportInfo: () => viewport.debugInfo(),
|
||||
/** A full redraw of the Scene tab – what a pan, which only moves the camera, is compared with. */
|
||||
viewportRedraw: () => viewport.redraw(),
|
||||
/** Where a dragged asset would land in the Scene tab, and why not when it may not. */
|
||||
dropPreview: () => viewport.dropInfo(),
|
||||
gameInfo: () => gameView.debugInfo(),
|
||||
/** Selection outlines in the Scene tab: a switched-off object is invisible but still framed. */
|
||||
gizmos: () => viewport.gizmoInfo(),
|
||||
/** The corner handles of the polygon collider being edited, in global pixels (#151). */
|
||||
polygonCorners: () => viewport.cornerInfo(),
|
||||
/** The rays the editor run cast in the last moment, as the Scene tab draws them, in world units (#153). */
|
||||
rays: () => viewport.rayInfo(),
|
||||
layerOf: (id: string) => viewport.layerOf(id),
|
||||
/** The icon a particle source wears in the Scene tab, with its name and its owner link. */
|
||||
sceneIcon: (id: string) => viewport.iconInfo(id),
|
||||
drawOrder: () => viewport.drawOrder(),
|
||||
objectScreen: (id: string) => viewport.objectScreen(id),
|
||||
objectRect: (id: string) => viewport.objectRect(id),
|
||||
/** What a click at a screen point picks from in the Scene tab, front to back (#203). */
|
||||
sceneStack: (x: number, y: number) => viewport.stackInfo(x, y),
|
||||
/** The same for the Game tab – through the camera, so what the player sees. */
|
||||
gameScreen: (id: string) => gameView.objectScreen(id),
|
||||
gameRect: (id: string) => gameView.objectRect(id),
|
||||
gameCenter: () => centreOf(gameView),
|
||||
/** Which typeface a text ended up drawn in, and how wide it made it. */
|
||||
textInfo: (id: string) => viewport.textInfo(id),
|
||||
gameTextInfo: (id: string) => gameView.textInfo(id),
|
||||
tilemap: {
|
||||
/** What the scene view painted a map with, and on which layer (#128). */
|
||||
paint: () => ({ ...state.tilemapPaint }),
|
||||
/** The cells of one layer as the model holds them: `"x,y"` to tile id. */
|
||||
cells: (assetId: string, layerId: string) => {
|
||||
const asset = findAssetOf(state.project, assetId, 'tilemap');
|
||||
const layer = asset && findLayer(asset, layerId);
|
||||
return layer ? { ...layer.cells } : null;
|
||||
},
|
||||
layers: (assetId: string) =>
|
||||
findAssetOf(state.project, assetId, 'tilemap')?.layers.map((layer) => ({
|
||||
id: layer.id,
|
||||
name: layer.name,
|
||||
hidden: layer.hidden === true,
|
||||
z: layerZ(layer),
|
||||
cells: Object.keys(layer.cells).length,
|
||||
})) ?? null,
|
||||
/** What the scene view drew: meshes (one draw call each), cells and visible layers. */
|
||||
info: (objectId: string) => viewport.tilemapInfo(objectId),
|
||||
gameInfo: (objectId: string) => gameView.tilemapInfo(objectId),
|
||||
/** Where the scene view outlines the map as blocking: the merged rectangles, in cells (#132). */
|
||||
solids: (objectId: string) => viewport.solidInfo(objectId),
|
||||
/**
|
||||
* The painting panel beside the scene view (#142): whether it is on screen, its box, the
|
||||
* scene view's box next to it and its splitter – null boxes while it is hidden.
|
||||
*/
|
||||
panel: () => {
|
||||
// The editor hides the panel itself on every other tab, so its own flag is the whole answer.
|
||||
const shown = editor.tilemapPanel.visible;
|
||||
return {
|
||||
shown,
|
||||
title: editor.tilemapPanel.title,
|
||||
panel: shown ? rectOf(editor.tilemapPanel) : null,
|
||||
splitter: shown ? centreOf(editor.tilemapSplitter) : null,
|
||||
viewport: rectOf(viewport),
|
||||
};
|
||||
},
|
||||
/** Where the widgets of the painting panel sit: their texts are on screen elsewhere too. */
|
||||
toolAt: (tool: string) => {
|
||||
const button = currentTilemapWidgets()?.tools.get(tool as TilemapTool);
|
||||
return button ? centreOf(button) : null;
|
||||
},
|
||||
tileButtonAt: (assetId: string) =>
|
||||
currentTilemapWidgets()?.palette?.swatchGlobal(assetId)?.centre ?? null,
|
||||
/** A tile's box in the palette: its centre, where its picture sits and the palette's columns (#139). */
|
||||
tileSwatch: (assetId: string) => {
|
||||
const palette = currentTilemapWidgets()?.palette;
|
||||
const swatch = palette?.swatchGlobal(assetId);
|
||||
return palette && swatch ? { ...swatch, columns: palette.columns } : null;
|
||||
},
|
||||
/** The plus in the layer list's header. */
|
||||
addLayerAt: () => currentTilemapWidgets()?.layers.addGlobal() ?? null,
|
||||
/**
|
||||
* Where the painted layer's name stands in its row – a double-click there edits it (#138) –
|
||||
* with the name the row shows and the field it is being edited in, while it is.
|
||||
*/
|
||||
layerNameAt: () => {
|
||||
const widgets = currentTilemapWidgets();
|
||||
const target = paintedTilemap(state);
|
||||
if (!widgets || !target) return null;
|
||||
const layerId = paintedLayerId(state, target.asset);
|
||||
const at = widgets.layers.nameGlobal(layerId);
|
||||
const editing = widgets.layers.renameField;
|
||||
return at
|
||||
? {
|
||||
...at,
|
||||
value: findLayer(target.asset, layerId)?.name ?? '',
|
||||
editing: editing ? { ...centreOf(editing), value: editing.value } : null,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
/** The Z field in the painted layer's row, with what it holds (#174). */
|
||||
layerZAt: () => {
|
||||
const widgets = currentTilemapWidgets();
|
||||
const target = paintedTilemap(state);
|
||||
const field = widgets && target && widgets.layers.valueField(paintedLayerId(state, target.asset));
|
||||
return field ? { ...centreOf(field), value: field.value } : null;
|
||||
},
|
||||
/** The duplicate mark on the painted layer's row. */
|
||||
duplicateLayerAt: () => {
|
||||
const widgets = currentTilemapWidgets();
|
||||
const target = paintedTilemap(state);
|
||||
return widgets && target ? widgets.layers.duplicateGlobal(paintedLayerId(state, target.asset)) : null;
|
||||
},
|
||||
focusAt: () => {
|
||||
const button = currentTilemapWidgets()?.focus;
|
||||
return button ? { ...centreOf(button), label: button.text } : null;
|
||||
},
|
||||
/** One row of the layer list, top-down as drawn: the layer, where to press it, and its eye. */
|
||||
layerRowAt: (index: number) => {
|
||||
const list = currentTilemapWidgets()?.layers;
|
||||
const id = list?.shownIds[index];
|
||||
// On the name: the middle of the row may be the Z field, which a press would scrub.
|
||||
const name = id ? list.nameGlobal(id) : null;
|
||||
if (!list || !id || !name) return null;
|
||||
// `y` is the row's own line, so a check can tell whether it is inside the panel.
|
||||
return { id, y: name.y, name, visible: list.eyeGlobal(id) };
|
||||
},
|
||||
/** The cross on the painted layer's row; `enabled` is false where the row has none (the last layer). */
|
||||
removeLayerAt: () => {
|
||||
const widgets = currentTilemapWidgets();
|
||||
const target = paintedTilemap(state);
|
||||
if (!widgets || !target) return null;
|
||||
const layerId = paintedLayerId(state, target.asset);
|
||||
const at = widgets.layers.removeGlobal(layerId);
|
||||
const row = widgets.layers.rowGlobal(layerId);
|
||||
return at ? { ...at, enabled: true } : row ? { x: row.x, y: row.y, enabled: false } : null;
|
||||
},
|
||||
/** Where a cell sits on screen – for a click that paints it or a pixel that proves it. */
|
||||
cellAt: (objectId: string, x: number, y: number) => viewport.cellScreen(objectId, x, y),
|
||||
gameCellAt: (objectId: string, x: number, y: number) => gameView.cellScreen(objectId, x, y),
|
||||
},
|
||||
container: {
|
||||
current: () => containerEditor.currentAssetId,
|
||||
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),
|
||||
dropPreview: () => containerEditor.dropInfo(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { AssetType } from '../../core/model/AssetTypes';
|
||||
import { Button, Panel, Widget, activeDrag, currentMenu, ui } from '../../ui';
|
||||
import { Theme } from '../../ui/Theme';
|
||||
import { currentFontPicker } from '../font/FontPicker';
|
||||
import { currentSoundPicker } from '../sound/SoundPicker';
|
||||
import { rectOf, textProbe } from '../textProbe';
|
||||
import { currentImagePicker } from '../texture/ImagePicker';
|
||||
import { Color, type ColorSource, type Container, Text } from 'pixi.js';
|
||||
import { centreOf, type HookContext } from './hookHelpers';
|
||||
|
||||
/** Widgets found by their visible text, the panels, menus, pickers and dialogs around the tabs. */
|
||||
export function uiHooks({ app, state, editor }: HookContext) {
|
||||
const { hierarchy, assets, inspector } = editor;
|
||||
const { eachText, locate, locateAll, locateIcons } = textProbe(app);
|
||||
/** 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;
|
||||
};
|
||||
/**
|
||||
* Centre of the control of an inspector row, addressed by its label. A row aligns its label to a
|
||||
* *minimum* width, so a label wider than the column pushes its control right – which is why a
|
||||
* fixed offset from the label's own text is the wrong way to click one.
|
||||
*/
|
||||
const fieldOf = (label: string, last = false): { x: number; y: number; left: number } | null => {
|
||||
let found: Text | null = null;
|
||||
const walk = (node: Container): void => {
|
||||
if ((found !== null && !last) || !node.visible) return;
|
||||
if (node instanceof Text && node.text === label) {
|
||||
found = node;
|
||||
return;
|
||||
}
|
||||
for (const child of node.children) walk(child);
|
||||
};
|
||||
walk(app.stage);
|
||||
// TypeScript does not track the assignment inside `walk`, hence the cast.
|
||||
let owner: Container | null = (found as Text | null)?.parent ?? null;
|
||||
while (owner && !(owner instanceof Widget)) owner = owner.parent;
|
||||
const row = owner?.parent;
|
||||
if (!row || !owner) return null;
|
||||
const control = row.children[row.children.indexOf(owner) + 1];
|
||||
if (!(control instanceof Widget)) return null;
|
||||
return { ...centreOf(control), left: control.getGlobalPosition().x };
|
||||
};
|
||||
/**
|
||||
* 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, and a
|
||||
* list taller than its popup only reports the rows currently scrolled into it.
|
||||
*/
|
||||
const overlayTexts = (): string[] => {
|
||||
const out: string[] = [];
|
||||
eachText(ui().overlay, (node) => {
|
||||
if (node.text) out.push(node.text);
|
||||
});
|
||||
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;
|
||||
};
|
||||
/** What the last visible button labelled `text` draws – shorter when it was truncated. */
|
||||
const shownLabel = (text: string): string | null => {
|
||||
let shown: string | null = null;
|
||||
const walk = (node: Container): void => {
|
||||
if (!node.visible) return;
|
||||
if (node instanceof Button && node.text === text) shown = node.shownText;
|
||||
for (const child of node.children) walk(child);
|
||||
};
|
||||
walk(app.stage);
|
||||
return shown;
|
||||
};
|
||||
/** 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 };
|
||||
};
|
||||
return {
|
||||
locate,
|
||||
locateAll,
|
||||
/** Pictures on screen drawn from the SVG file with this id (#214), e.g. `reset`, `kind-struct`. */
|
||||
locateIcons,
|
||||
/** The first such picture (or the topmost with `last`), or `null`. */
|
||||
locateIcon: (name: string, last = false) => {
|
||||
const hits = locateIcons(name);
|
||||
return (last ? hits.at(-1) : hits[0]) ?? null;
|
||||
},
|
||||
inspectorField: fieldOf,
|
||||
/** The inspector panel on screen – what tells whether a row of it can be clicked at all. */
|
||||
inspectorRect: () => rectOf(inspector),
|
||||
/** Where the focused widget sits on screen, or `null` – for checks on its focus border. */
|
||||
focusedRect: () => {
|
||||
const widget = ui().focus.focused;
|
||||
return widget ? rectOf(widget) : null;
|
||||
},
|
||||
/** What a panel dragged shut keeps: its header. Follows the theme, so checks need not. */
|
||||
panelHeader: Panel.HEADER_HEIGHT,
|
||||
/** A theme colour as a hex string, so a check names the colour instead of its current value. */
|
||||
themeColor: (name: keyof typeof Theme.colors) => `#${Theme.colors[name].toString(16).padStart(6, '0')}`,
|
||||
inspectorInfo: () => inspector.debugInfo(),
|
||||
textColor,
|
||||
overlayTexts,
|
||||
overlayRect,
|
||||
/** Entries of the open menu in drawn order, `null` for a separator line (#43). */
|
||||
menuRows: () => currentMenu()?.rowsDrawn() ?? null,
|
||||
/** Per entry of `menuRows`, whether an icon stands in front of it. */
|
||||
menuIcons: () => currentMenu()?.iconsDrawn() ?? null,
|
||||
/** Per entry of `menuRows`, its tick box: `true`/`false`, `null` for an entry without one. */
|
||||
menuTicks: () => currentMenu()?.ticksDrawn() ?? null,
|
||||
/** Per entry of `menuRows`, whether it can be picked (a greyed-out one cannot). */
|
||||
menuEnabled: () => currentMenu()?.enabledDrawn() ?? null,
|
||||
textOverflows,
|
||||
labelsDropped,
|
||||
shownLabel,
|
||||
tabRow: () => editor.tabRow(),
|
||||
/** The open "Change password" dialog (#182): its message and whether it is waiting, or null. */
|
||||
passwordDialog: () => editor.accountDialog()?.info() ?? null,
|
||||
/** The project's collision layers (#152), and the names the open dialog holds (null when shut). */
|
||||
collisionLayers: () => ({
|
||||
project: [...state.project.collisionLayers],
|
||||
dialog: editor.projectSettingsDialog()?.layerNames() ?? null,
|
||||
}),
|
||||
/** The "Project settings" dialog (#183): name and start-scene switch as its fields stand. */
|
||||
projectSettings: () => editor.projectSettingsDialog()?.values() ?? null,
|
||||
/** Where to click the start-scene switch inside that dialog. */
|
||||
projectSettingsPlayFromAt: () => {
|
||||
const dialog = editor.projectSettingsDialog();
|
||||
return dialog ? centreOf(dialog.playFromControl) : null;
|
||||
},
|
||||
/** The "Open project" dialog (#183): the projects it lists and the row picked. */
|
||||
projectPicker: () => editor.projectPickerDialog()?.info() ?? null,
|
||||
/** The dividers left and right of the centre column – dragging them narrows the tab row. */
|
||||
panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }),
|
||||
/** The left column: asset panel on top, scene objects below, and the divider between them. */
|
||||
leftColumn: () => ({
|
||||
assets: rectOf(editor.assets),
|
||||
objects: rectOf(editor.hierarchy),
|
||||
splitter: centreOf(editor.columnSplitter),
|
||||
}),
|
||||
/** The panel-to-view drag in flight, if any – a drop that never started looks like a refused one. */
|
||||
dragPayload: () => activeDrag(),
|
||||
/** An asset's row, its group unfolded first as a user would; `assetRowShown` leaves it folded. */
|
||||
assetRow: (id: string) => assets.rowGlobal(id, true),
|
||||
assetRowShown: (id: string) => assets.rowGlobal(id),
|
||||
assetRowColor: (id: string) => assets.rowColor(id),
|
||||
/** The asset panel's groups in order; a heading's row is `assetRow('group:<type>')`. */
|
||||
assetGroups: () => assets.groups(),
|
||||
assetGroupIcon: (type: AssetType) => assets.groupIcon(type),
|
||||
/** The panel's own rectangle – what tells a row in view from one scrolled past its edge. */
|
||||
assetsRect: () => rectOf(assets),
|
||||
/** The console panel: its lines as kept, its drawn height, and the divider above it. */
|
||||
console: {
|
||||
lines: () => editor.console.debugInfo().lines,
|
||||
height: () => editor.console.h,
|
||||
clear: () => editor.console.clear(),
|
||||
scroll: () => editor.console.scrollInfo(),
|
||||
centre: () => centreOf(editor.console),
|
||||
splitterAt: () => centreOf(editor.consoleSplitter),
|
||||
},
|
||||
/** The open image picker: what it offers, what is picked, and where a tile sits. */
|
||||
imagePicker: () => currentImagePicker()?.debugInfo() ?? null,
|
||||
imagePickerTile: (file: string) => currentImagePicker()?.tileGlobal(file) ?? null,
|
||||
/** The same for the font picker, whose rows are the preview. */
|
||||
fontPicker: () => currentFontPicker()?.debugInfo() ?? null,
|
||||
fontPickerRow: (file: string) => currentFontPicker()?.rowGlobal(file) ?? null,
|
||||
/** And for the sound picker, whose rows carry a ▶ of their own. */
|
||||
soundPicker: () => currentSoundPicker()?.debugInfo() ?? null,
|
||||
soundPickerRow: (file: string) => currentSoundPicker()?.rowGlobal(file) ?? null,
|
||||
soundPickerPlay: (file: string) => currentSoundPicker()?.playGlobal(file) ?? null,
|
||||
/** Rows the hierarchy shows – collapsed subtrees are not in here. */
|
||||
hierarchyOrder: () => hierarchy.rowOrder(),
|
||||
hierarchyChevron: (id: string) => hierarchy.chevronPosition(id),
|
||||
/** Centre of a hierarchy row, clear of the chevron – for clicks that must hit the row itself. */
|
||||
hierarchyRow: (id: string) => hierarchy.rowPosition(id),
|
||||
hierarchyRowColor: (id: string) => hierarchy.rowColor(id),
|
||||
/** Centre of the type icon at the right edge of a hierarchy row. */
|
||||
hierarchyIcon: (id: string) => hierarchy.iconPosition(id),
|
||||
hierarchyExpandAll: () => hierarchy.expandAll(),
|
||||
hierarchyCollapseAll: () => hierarchy.collapseAll(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { currentGradientBar, currentShapeList } from '../inspector/shapeSection';
|
||||
import { centreOf, snapButton, type HookContext } from './hookHelpers';
|
||||
|
||||
/** The Vector art tab (#129). */
|
||||
export function vectorArtHooks({ state, editor }: HookContext) {
|
||||
const { vectorArtEditor } = editor;
|
||||
return {
|
||||
/** The Vector art tab (#129, stage 3): where a canvas pixel is, and which tool a press uses. */
|
||||
vectorArt: {
|
||||
current: () => vectorArtEditor.currentAssetId,
|
||||
info: () => vectorArtEditor.shownInfo,
|
||||
pointAt: (x: number, y: number) => vectorArtEditor.canvasToScreen(x, y),
|
||||
rect: () => vectorArtEditor.canvasRect(),
|
||||
tool: () => vectorArtEditor.canvas.currentTool,
|
||||
snapping: () => vectorArtEditor.canvas.snapping,
|
||||
snapButton: () => snapButton(vectorArtEditor.snap),
|
||||
/** Whether the pen has a line open that the next click would add a point to. */
|
||||
drawing: () => vectorArtEditor.canvas.drawing,
|
||||
/** The header's point type (#195): where to click it, what it says, and the type it reads. */
|
||||
pointButton: () => ({
|
||||
...centreOf(vectorArtEditor.pointButton),
|
||||
text: vectorArtEditor.pointButton.text,
|
||||
enabled: vectorArtEditor.pointButton.enabled,
|
||||
kind: vectorArtEditor.canvas.pickedKind,
|
||||
}),
|
||||
/** The shapes as the model holds them, so a check can read points and handles exactly. */
|
||||
shapes: (assetId: string) => {
|
||||
const asset = findAsset(state.project, assetId);
|
||||
if (!asset || asset.type !== 'vectorart') return null;
|
||||
return asset.shapes.map((shape) => ({
|
||||
id: shape.id,
|
||||
name: shape.name,
|
||||
fill: shape.fill,
|
||||
opacity: shape.opacity,
|
||||
stroke: shape.stroke ? (JSON.parse(JSON.stringify(shape.stroke)) as unknown) : null,
|
||||
paint: shape.paint ? (JSON.parse(JSON.stringify(shape.paint)) as unknown) : null,
|
||||
rings: shape.rings.map((ring) => ({
|
||||
closed: ring.closed,
|
||||
points: ring.points.map((point) => ({
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
in: point.in ? { ...point.in } : null,
|
||||
out: point.out ? { ...point.out } : null,
|
||||
mode: point.mode ?? null,
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
},
|
||||
selected: () => (state.selection?.kind === 'shape' ? state.selection.shapeId : ''),
|
||||
selectedIds: () => state.selectedShapeIds,
|
||||
/**
|
||||
* The shape list in the inspector (#129, stage 4): the order it shows, top first, and where a
|
||||
* row sits. A row carries a name that also stands in the tab, so a check cannot click it by text.
|
||||
*/
|
||||
shapeRows: () => currentShapeList()?.shownIds ?? null,
|
||||
shapeRowAt: (shapeId: string) => currentShapeList()?.rowGlobal(shapeId) ?? null,
|
||||
/** The cross on a shape's row. */
|
||||
shapeRemoveAt: (shapeId: string) => currentShapeList()?.removeGlobal(shapeId) ?? null,
|
||||
/** The duplicate mark on a shape's row, and where a double-click edits its name. */
|
||||
shapeDuplicateAt: (shapeId: string) => currentShapeList()?.duplicateGlobal(shapeId) ?? null,
|
||||
shapeNameAt: (shapeId: string) => currentShapeList()?.nameGlobal(shapeId) ?? null,
|
||||
/** The field a shape's name is being edited in, with what it holds; null while none is. */
|
||||
shapeRenaming: () => {
|
||||
const field = currentShapeList()?.renameField;
|
||||
return field ? { ...centreOf(field), value: field.value } : null;
|
||||
},
|
||||
/** The Paint tool (#186): its button, the "Handles" menu, and which part it shows. */
|
||||
paintButton: () => centreOf(vectorArtEditor.paintButton),
|
||||
paintTarget: () => ({
|
||||
...centreOf(vectorArtEditor.paintTargetButton),
|
||||
text: vectorArtEditor.paintTargetButton.text,
|
||||
target: vectorArtEditor.canvas.paintTarget,
|
||||
}),
|
||||
/** A colour stop's marker, or the bar at an offset, in the inspector's fill or outline bar. */
|
||||
stopAt: (part: 'fill' | 'stroke', index: number) => currentGradientBar(part)?.stopGlobal(index) ?? null,
|
||||
barAt: (part: 'fill' | 'stroke', offset: number) => currentGradientBar(part)?.barGlobal(offset) ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
+20
-1031
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user