Choosing the target of an object variable meant finding its name in a dropdown. With many objects, or several with the same sprite, the name alone does not say which one it is. The eyedropper lets you click the object where you see it instead. - Every object dropdown gets an eyedropper button next to it. This covers variables, struct fields and list entries, in the Inspector and in the Variables tab. - The button opens the Scene View or the container tab the object lives in. A strip above the view names the field and the current candidate and offers Take and Cancel. The cursor is a crosshair. - A click picks a candidate and writes nothing yet. The candidate gets its own cyan frame, the selection stays on the object with the variable. - Clicking follows the selection rule. `pickOn` in `src/viewport/pick.ts` uses `pressOn` and only counts objects the dropdown offers. A click on empty space keeps the candidate. - Enter or Take writes through the same handlers as the dropdown. That is one undo step, or none if nothing changes. - Esc, Cancel, a right click in the view or a second click on the button cancel without writing. So do a tab switch, another selection, undo, redo, play and loading a project. - After Take or Cancel the editor goes back to the tab the pick came from. - While the eyedropper runs, the view does not drag, marquee, paint, show handles or open its context menu. Panning and zooming stay. Found on the way: - Started from the Variables tab, the click missed the object. The Scene View is hidden while that tab is open, and the layout skips hidden children. The strip appeared then, the view moved down by 32 px but kept its old height. `Editor.display()` now lays the row out again after showing it. Tests (on melektaus-atx): - unit pick and object-pick: 27 passed, 13 new - gate: 196 files, 2844 tests, CHECK EXIT 0. Two more come from svg-icons, which checks every icon twice. - e2e 57: ALL PASSED, 12 checks, 5 new (57e to 57g) - Counter-checks work. Without the layout fix 57g fails. Picking objects the dropdown does not offer fails 2 tests. Writing on cancel fails 2 more. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
94 lines
4.1 KiB
TypeScript
94 lines
4.1 KiB
TypeScript
import { isSymbolType } from '../core/model/AssetTypes';
|
||
import { drawSlots, slotKey } from './drawOrder';
|
||
import type { DrawItem } from './items';
|
||
|
||
/**
|
||
* An object whose hit area is under the pointer; `layers` names a tilemap's layers with a tile
|
||
* there, and `rows` the row that tile sorts by on each of them sorted by Y (#228).
|
||
*/
|
||
export interface PointerHit {
|
||
id: string;
|
||
layers: readonly string[] | null;
|
||
rows?: ReadonlyMap<string, number>;
|
||
}
|
||
|
||
/**
|
||
* What lies under the pointer, front to back as it is drawn (#203). A tilemap counts only where
|
||
* one of its layers has a tile, and at the height of the highest such layer: an empty cell is
|
||
* empty space, and a sprite drawn above those layers is in front of the map. Symbols are drawn
|
||
* above everything, so they come first.
|
||
*/
|
||
export function stackAt(items: readonly DrawItem[], hits: readonly PointerHit[]): string[] {
|
||
const rank = new Map<string, number>();
|
||
drawSlots(items).forEach((slot, i) => rank.set(slotKey(slot.id, slot.layerId, slot.row), i));
|
||
const symbolsFrom = rank.size;
|
||
const index = new Map(items.map((item, i) => [item.id, i]));
|
||
const ranked: { id: string; rank: number }[] = [];
|
||
for (const hit of hits) {
|
||
const at = index.get(hit.id);
|
||
if (at === undefined) continue;
|
||
const ranks = isSymbolType(items[at]!.type)
|
||
? [symbolsFrom + at]
|
||
: (hit.layers ?? ['']).map(
|
||
(layerId) => rank.get(slotKey(hit.id, layerId, hit.rows?.get(layerId) ?? null)) ?? -1,
|
||
);
|
||
const best = Math.max(-1, ...ranks);
|
||
if (best >= 0) ranked.push({ id: hit.id, rank: best });
|
||
}
|
||
return ranked.sort((a, b) => b.rank - a.rank).map((r) => r.id);
|
||
}
|
||
|
||
/**
|
||
* What a plain press on `stack` does (#203). With a selected object under the pointer – hidden
|
||
* behind others or not – the selection stays and a drag moves it; a click without moving then
|
||
* passes on to the next object below, round to the top again. Otherwise the topmost is selected
|
||
* and dragged at once. Null when nothing is under the pointer.
|
||
*/
|
||
export function pressOn(
|
||
stack: readonly string[],
|
||
selected: readonly string[],
|
||
primary: string | null,
|
||
): { select: string | null; onClick: string | null } | null {
|
||
if (stack.length === 0) return null;
|
||
const held = heldIn(stack, selected, primary);
|
||
if (held === undefined) return { select: stack[0]!, onClick: null };
|
||
const next = stack[(stack.indexOf(held) + 1) % stack.length]!;
|
||
return { select: null, onClick: next === held ? null : next };
|
||
}
|
||
|
||
/**
|
||
* Where a click of the eyedropper moves its candidate (#276): the rule of a plain click, over what
|
||
* may be picked alone – a click on the candidate passes on to the next one below, round to the top
|
||
* again, a click elsewhere takes the topmost. Empty space, and a spot with nothing that may be
|
||
* picked, leave the candidate as it is; `refused` tells the second apart, so the user hears why.
|
||
*/
|
||
export function pickOn(
|
||
stack: readonly string[],
|
||
pickable: (id: string) => boolean,
|
||
candidate: string | null,
|
||
): { candidate: string | null; refused: boolean } {
|
||
const offered = stack.filter(pickable);
|
||
const press = pressOn(offered, candidate === null ? [] : [candidate], candidate);
|
||
if (!press) return { candidate, refused: stack.length > 0 };
|
||
return { candidate: press.select ?? press.onClick ?? candidate, refused: false };
|
||
}
|
||
|
||
/**
|
||
* Which object a right-click on `stack` opens the menu for (#204), by the rule a press follows:
|
||
* a selected one under the pointer keeps the selection, otherwise the topmost is selected first.
|
||
*/
|
||
export function menuTargetOf(
|
||
stack: readonly string[],
|
||
selected: readonly string[],
|
||
primary: string | null,
|
||
): { id: string; select: boolean } | null {
|
||
if (stack.length === 0) return null;
|
||
const held = heldIn(stack, selected, primary);
|
||
return held === undefined ? { id: stack[0]!, select: true } : { id: held, select: false };
|
||
}
|
||
|
||
/** The selected object under the pointer, the primary one first; undefined when none is there. */
|
||
function heldIn(stack: readonly string[], selected: readonly string[], primary: string | null) {
|
||
return primary !== null && stack.includes(primary) ? primary : stack.find((id) => selected.includes(id));
|
||
}
|