import { create } from 'zustand' import type { Plant } from '@/lib/plants' import type { EditorPlanting } from '@/lib/plantings' import type { EditorObject } from './types' // Ephemeral editor state only (DESIGN § State): the camera, the selection, the // focused bed, what's armed for placement, the rail tab / phone mode, and any // in-flight drag geometry. All server state stays in react-query. /** The camera: world (garden cm) → screen is translate(tx,ty) scale(s). */ export interface Viewport { tx: number ty: number s: number } export type Selection = { type: 'object'; id: number } | { type: 'plop'; id: number } /** The right-hand rail's tabs (desktop). */ export type RailTab = 'plot' | 'journal' | 'history' | 'chat' /** The phone's primary mode — which tools dock under the canvas. */ export type PhoneMode = 'build' | 'plants' | 'journal' | 'chat' /** Which thing the journal list is narrowed to, when it is. The composer * attaches to the selection regardless; this is the reading filter. */ export type JournalScope = { type: 'object'; id: number } | { type: 'plop'; id: number } interface EditorState { vp: Viewport /** True while the camera is animating (fit/focus); drags and zooms clear it. */ anim: boolean setVp: (vp: Viewport, anim: boolean) => void setAnim: (anim: boolean) => void sel: Selection | null setSel: (sel: Selection | null) => void focusId: number | null setFocus: (id: number | null) => void armedKind: string | null setArmedKind: (kind: string | null) => void armedPlant: Plant | null /** Which seed lot placements are attributed to, when one is worth naming. */ armedLotId: number | null setArmedPlant: (plant: Plant | null, lotId?: number | null) => void setArmedLotId: (lotId: number | null) => void /** Ghost preview position (world cm, snapped) while a kind is armed. */ ghost: { x: number; y: number } | null setGhost: (g: { x: number; y: number } | null) => void /** null = now (live, editable); a year = that season, read-only. */ seasonYear: number | null setSeasonYear: (year: number | null) => void tab: RailTab setTab: (tab: RailTab) => void mode: PhoneMode setMode: (mode: PhoneMode) => void journalScope: JournalScope | null setJournalScope: (scope: JournalScope | null) => void // During a drag/resize the live geometry is held here so the canvas renders // it instantly; the PATCH fires once on release. liveObject: EditorObject | null setLiveObject: (o: EditorObject | null) => void livePlanting: EditorPlanting | null setLivePlanting: (p: EditorPlanting | null) => void /** Everything transient, cleared when entering or leaving a garden. The * camera is left alone; the canvas re-fits from the loaded garden. */ reset: () => void } const TRANSIENT = { sel: null, focusId: null, armedKind: null, armedPlant: null, armedLotId: null, ghost: null, seasonYear: null, tab: 'plot' as RailTab, mode: 'build' as PhoneMode, journalScope: null, liveObject: null, livePlanting: null, } export const useEditorStore = create((set) => ({ vp: { tx: 40, ty: 40, s: 0.5 }, anim: false, setVp: (vp, anim) => set({ vp, anim }), setAnim: (anim) => set({ anim }), ...TRANSIENT, setSel: (sel) => set({ sel }), setFocus: (id) => set({ focusId: id }), // Disarming drops the ghost too; arming keeps whatever the cursor last set. setArmedKind: (kind) => set((s) => ({ armedKind: kind, ghost: kind ? s.ghost : null })), setArmedPlant: (plant, lotId = null) => set({ armedPlant: plant, armedLotId: plant ? lotId : null }), setArmedLotId: (lotId) => set({ armedLotId: lotId }), setGhost: (ghost) => set({ ghost }), setSeasonYear: (year) => set({ seasonYear: year }), setTab: (tab) => set({ tab }), setMode: (mode) => set({ mode }), setJournalScope: (scope) => set({ journalScope: scope }), setLiveObject: (o) => set({ liveObject: o }), setLivePlanting: (p) => set({ livePlanting: p }), reset: () => set({ ...TRANSIENT }), }))