Build image / build-and-push (push) Successful in 11s
Co-Authored-By: Claude Fable 5 <[email protected]>
117 lines
3.9 KiB
TypeScript
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 }),
|
|
}))
|