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; } /** * 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. What a placed composition shows answers as the placement, * once, where its frontmost part lies (#417). */ export function stackAt(items: readonly DrawItem[], hits: readonly PointerHit[]): string[] { const rank = new Map(); 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 }); } const ids = ranked.sort((a, b) => b.rank - a.rank).map((r) => items[index.get(r.id)!]!.sealedIn ?? r.id); return [...new Set(ids)]; } /** * 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)); }