Files
pansy/web/src/editor/store.ts
T
steveandClaude Fable 5 5a9ae58a2a
Build image / build-and-push (push) Successful in 11s
Tidy: store never writes an undefined ghost; plant card toggle is the face, not the card
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 19:14:08 -04:00

117 lines
3.9 KiB
TypeScript

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<EditorState>((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 }),
}))