Files
pansy/web/src/editor/store.ts
T
steveandClaude Opus 4.8 9ec626302b
Build image / build-and-push (push) Successful in 10s
Gadfly review (reusable) / review (pull_request) Successful in 8m55s
Adversarial Review (Gadfly) / review (pull_request) Successful in 8m56s
Editor mode model: mobile Fixtures/Plants/Journal/Assistant bar (#99)
On a phone the editor stacked a control column (title, share, season, a
7-chip palette wrapping to 3 rows, journal/history/assistant) ABOVE the
canvas, crushing the garden — the point of the app — into a short strip.
There was no single "mode" switch: fixtures lived in the palette, plants
in a floating focus toolbar, and journal/assistant in the rail.

Mobile-first now (#99): the canvas is the whole screen, and a bottom mode
bar switches the tools docked beneath it —
- Fixtures  → the object palette (arm → tap to place)
- Plants    → the seed tray, once a bed is focused; focusing a bed enters
              this mode. No bed focused → a "tap a bed, then Plant here"
              hint. Done planting / Clear ride along.
- Journal   → opens the journal rail sheet
- Assistant → opens the chat rail sheet (hidden with no model configured)

A slim mobile top strip keeps the garden name / season / share that lived
in the desktop column. Closing a panel rail returns to a canvas mode so
the bar reappears; History stays a rail sub-tab (not a fifth mode).

Desktop is untouched: the mode bar is md:hidden and the side-column layout
stands; `mode` is an inert hint there. Store gains `mode`/`setMode`
(reset with the rest of transient state on garden switch).

Verified live at 390px (each mode + the select-bed → Plant here → seed-tray
flow, canvas now dominant) and 1280px (desktop unchanged). tsc + vitest
(92) + build green. DESIGN.md updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 01:16:44 -04:00

150 lines
5.6 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'
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: 'fixtures',
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: 'fixtures',
}),
}))