import { memo, type KeyboardEvent, type PointerEvent } from 'react' import { objectTransform } from './shared' import { kindDef, objectDisplayName } from './kinds' import type { EditorObject } from './types' const DEFAULT_FILL = '#8a8a8a' // Default fills by kind (overridable per object via object.color). Muted, earthy // tones so plops (added in #15) read clearly on top. const kindColors: Record = { bed: '#8a6d4b', grow_bag: '#9c7a52', container: '#6b7f8a', in_ground: '#7a6a4a', tree: '#4f7a4f', path: '#b8b0a0', structure: DEFAULT_FILL, } // Label font size = this fraction of the object's smaller side, clamped to a // readable cm range. Corner radius = this fraction of the smaller half-side. const LABEL_FONT_FACTOR = 0.28 const LABEL_FONT_MIN_CM = 8 const LABEL_FONT_MAX_CM = 40 const CORNER_RADIUS_FACTOR = 0.06 function fillFor(o: EditorObject): string { return o.color ?? kindColors[o.kind] ?? DEFAULT_FILL } /** * One garden object in world (cm) space: a centered rect or ellipse (a circle * when width == height), rotated about its center, with the object's name. The * parent world applies the viewport scale, so geometry is authored in cm and * strokes use vector-effect=non-scaling-stroke to stay a constant pixel width at * any zoom. memo'd so a pan/zoom (which only changes the world transform) * doesn't re-render every object. Full move/resize/rotate come in #11. */ export const ObjectShape = memo(function ObjectShape({ object, selected, onSelect, }: { object: EditorObject selected: boolean onSelect: (id: number) => void }) { const fill = fillFor(object) const halfW = Math.max(0, object.widthCm / 2) const halfH = Math.max(0, object.heightCm / 2) const fontCm = Math.max( LABEL_FONT_MIN_CM, Math.min(LABEL_FONT_MAX_CM, Math.min(object.widthCm, object.heightCm) * LABEL_FONT_FACTOR), ) function handleDown(e: PointerEvent) { e.stopPropagation() // don't let the canvas treat this as an empty-space pan/deselect onSelect(object.id) } // Keyboard path into selection (#84): the arrow-key nudge handler already // exists but only ever acted on a pointer selection, so it was unreachable // without a mouse. Enter/Space on a focused object selects it, which is the // step that was missing. function handleKey(e: KeyboardEvent) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() e.stopPropagation() onSelect(object.id) } } const stroke = selected ? '#2f7a3e' : '#00000033' const strokeWidth = selected ? 2 : 1 // A concise accessible name: the object's label plus its kind's canonical // label, e.g. "North Bed, In-ground" — reusing kindDef so it never diverges // from what the UI shows (an ad-hoc kind.replace() gave "in ground"). The // dimensions aren't included; they need the garden's unit context this // component doesn't hold, so they're a follow-up. const kindLabel = kindDef(object.kind)?.label ?? object.kind const label = `${objectDisplayName(object)}, ${kindLabel}` // Keyboard focus needs to be VISIBLE — that's the point of making the canvas // keyboard-reachable. The `object-shape` class carries a :focus-visible rule // (styles/index.css) that draws a dashed ring; :focus-visible means it shows // for keyboard focus but NOT a mouse click, which is exactly what we want. CSS // rather than React state because onFocus on an SVG is unreliable and a // presentation attribute is overridden by any CSS rule. return ( {object.shape === 'circle' ? ( ) : ( )} {object.name && ( {object.name} )} ) })