Build image / build-and-push (push) Successful in 11s
Gadfly on #99, the real ones — all in the mode/focus/rail interplay: - (3 models) Closing a journal/assistant rail while a bed was focused hard-set mode='fixtures', docking the OBJECT palette inside the focused bed with the seed tray unreachable. Derive it: closing a panel returns to Plants if still focused, else Fixtures. The canvas-mode effect now also follows UN-focus (plants→fixtures) and won't override a panel mode. - (2 models) Plants mode on a focused non-plantable object (reachable via a ?focus= deep link) showed the misleading "tap a bed" hint and no way out on mobile. Now it says what's wrong and offers Done (exit focus). - Selecting an object leaves a panel mode, so closing the inspector can't strand the bar on Journal/Assistant with nothing open. - Tapping Fixtures steps out of a focused bed (you're arranging again). - Viewer mode bar drops Fixtures/Plants (a viewer can't place anything), leaving Journal + Assistant. - Safety: if the assistant capability flips off live while it's the active mode, fall back to a canvas mode and close the orphaned chat rail. Maintainability: extracted the shared seed-tray + Done + Clear cluster into PlantPlacementTools (was duplicated between the desktop focus toolbar and the mobile Plants strip); DEFAULT_MODE const replaces the twice-hardcoded 'fixtures'; selectMode reads the reactive railTab, not getState(). Verified live at 390px: focus a bed → Plants; open journal → close → back to Plants (tray), not the Fixtures palette; tap Fixtures → exits focus. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
153 lines
5.7 KiB
TypeScript
153 lines
5.7 KiB
TypeScript
import { create } from 'zustand'
|
|
import type { Viewport } from '@/lib/geometry'
|
|
import type { Plant } from '@/lib/plants'
|
|
import type { EditorPlanting } from '@/lib/plantings'
|
|
import type { EditorObject } from './types'
|
|
|
|
// Ephemeral editor state only (per DESIGN § State): the viewport, the current
|
|
// selection, the object being live-edited mid-gesture, and the palette kind
|
|
// armed for tap-to-place. All server state stays in react-query.
|
|
|
|
export const MIN_SCALE = 0.05 // px per cm — fully zoomed out
|
|
export const MAX_SCALE = 20 // px per cm — fully zoomed in
|
|
|
|
// The editor's one primary activity (#99). On mobile this is the segmented
|
|
// control at the bottom of the screen; it decides which tools dock there —
|
|
// placing fixtures, placing plants, the journal, or the assistant — so the four
|
|
// activities stop competing for the same cramped strip. Desktop keeps its
|
|
// side-column layout and treats this as a lighter hint.
|
|
export type EditorMode = 'fixtures' | 'plants' | 'journal' | 'assistant'
|
|
|
|
// Where the editor starts, and where it returns on a garden switch.
|
|
export const DEFAULT_MODE: EditorMode = 'fixtures'
|
|
|
|
interface EditorState {
|
|
viewport: Viewport
|
|
setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void
|
|
|
|
// The primary editor mode (mobile mode bar). Ephemeral — which tool you were
|
|
// last using is not a property of the garden.
|
|
mode: EditorMode
|
|
setMode: (mode: EditorMode) => void
|
|
|
|
// The selected object OR plop (mutually exclusive; selecting one clears the
|
|
// other). selectedId is a garden object; selectedPlantingId is a plop.
|
|
selectedId: number | null
|
|
select: (id: number | null) => void
|
|
|
|
selectedPlantingId: number | null
|
|
selectPlanting: (id: number | null) => void
|
|
|
|
focusedObjectId: number | null
|
|
setFocusedObject: (id: number | null) => void
|
|
|
|
// Which rail tab is showing, or null when the rail is closed. Selecting an
|
|
// object switches this to 'inspector' (see GardenEditorPage), so editing never
|
|
// starts with a click on the rail itself.
|
|
railTab: string | null
|
|
setRailTab: (tab: string | null) => void
|
|
|
|
// Which season the canvas is showing: null is "now" (live and editable), a
|
|
// year is a read-only view of what was in the ground that year. Ephemeral —
|
|
// which year you were last looking at is not a property of the garden.
|
|
seasonYear: number | null
|
|
setSeasonYear: (year: number | null) => void
|
|
|
|
// Which bed the journal tab is filtered to, or null for the whole garden.
|
|
// Separate from selectedId deliberately: you can scope the journal to a bed
|
|
// and then select something else without the list moving under you.
|
|
journalObjectId: number | null
|
|
setJournalObjectId: (id: number | null) => void
|
|
|
|
// The plant armed for placing plops (set after the PlantPicker choice); stays
|
|
// armed for repeat-placement until cleared (Escape / done). null = not placing.
|
|
armedPlant: Plant | null
|
|
// Which seed lot placements should be attributed to, when the armed plant has
|
|
// one worth naming. Cleared with the plant.
|
|
armedLotId: number | null
|
|
setArmedPlant: (p: Plant | null, lotId?: number | null) => void
|
|
|
|
// During a move/resize/rotate, the object's live geometry is held here so the
|
|
// canvas renders it instantly; the PATCH fires only on gesture end.
|
|
liveObject: EditorObject | null
|
|
setLiveObject: (o: EditorObject | null) => void
|
|
|
|
// The same, for a plop being moved/resized.
|
|
livePlanting: EditorPlanting | null
|
|
setLivePlanting: (p: EditorPlanting | null) => void
|
|
|
|
// The palette kind armed for tap-to-place (mobile-friendly; also set while
|
|
// dragging a kind from the palette). null when nothing is armed.
|
|
armedKind: string | null
|
|
setArmedKind: (kind: string | null) => void
|
|
|
|
// True while an object move/resize/rotate is in progress, so the viewport's
|
|
// pan gesture stands down (both listen on the same svg).
|
|
objectDragging: boolean
|
|
setObjectDragging: (v: boolean) => void
|
|
|
|
// Clear all transient (non-persisted) editor state at once — selection, focus,
|
|
// armed plant/kind, and any in-flight live geometry — when entering a garden
|
|
// view fresh (e.g. the public read-only page on mount). The viewport is left
|
|
// alone; the canvas re-fits it from the loaded garden.
|
|
resetTransient: () => void
|
|
}
|
|
|
|
export const useEditorStore = create<EditorState>((set) => ({
|
|
viewport: { tx: 0, ty: 0, scale: 1 },
|
|
setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })),
|
|
|
|
mode: DEFAULT_MODE,
|
|
setMode: (mode) => set({ mode }),
|
|
|
|
selectedId: null,
|
|
select: (id) => set({ selectedId: id, selectedPlantingId: null }),
|
|
|
|
selectedPlantingId: null,
|
|
selectPlanting: (id) => set({ selectedPlantingId: id, selectedId: null }),
|
|
|
|
focusedObjectId: null,
|
|
setFocusedObject: (id) => set({ focusedObjectId: id }),
|
|
|
|
railTab: null,
|
|
setRailTab: (tab) => set({ railTab: tab }),
|
|
|
|
seasonYear: null,
|
|
setSeasonYear: (year) => set({ seasonYear: year }),
|
|
|
|
journalObjectId: null,
|
|
setJournalObjectId: (id) => set({ journalObjectId: id }),
|
|
|
|
armedPlant: null,
|
|
armedLotId: null,
|
|
setArmedPlant: (p, lotId = null) => set({ armedPlant: p, armedLotId: p ? lotId : null }),
|
|
|
|
liveObject: null,
|
|
setLiveObject: (o) => set({ liveObject: o }),
|
|
|
|
livePlanting: null,
|
|
setLivePlanting: (p) => set({ livePlanting: p }),
|
|
|
|
armedKind: null,
|
|
setArmedKind: (kind) => set({ armedKind: kind }),
|
|
|
|
objectDragging: false,
|
|
setObjectDragging: (v) => set({ objectDragging: v }),
|
|
|
|
resetTransient: () =>
|
|
set({
|
|
selectedId: null,
|
|
selectedPlantingId: null,
|
|
focusedObjectId: null,
|
|
armedPlant: null,
|
|
armedLotId: null,
|
|
armedKind: null,
|
|
liveObject: null,
|
|
livePlanting: null,
|
|
railTab: null,
|
|
seasonYear: null,
|
|
journalObjectId: null,
|
|
mode: DEFAULT_MODE,
|
|
}),
|
|
}))
|