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:
adminandClaude Opus 5.5 committed 2026-09-23 10:20:06 +02:00
1 parent b830f9c747
commit ed29b2f3dc
9 files changed
+1114 -1031

No files matched your search

+89
View File
@@ -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),
},
};
}
+143
View File
@@ -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),
},
};
}
+35
View File
@@ -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,
};
}
+157
View File
@@ -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)]),
),
),
};
},
},
};
}
+159
View File
@@ -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,
};
}
+197
View File
@@ -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(),
},
};
}
+234
View File
@@ -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(),
};
}
+80
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff