diff --git a/web/src/editor/shared.ts b/web/src/editor/shared.ts index 5fd68e3..5ac4e64 100644 --- a/web/src/editor/shared.ts +++ b/web/src/editor/shared.ts @@ -3,12 +3,17 @@ // one place instead of drifting between files. export const SELECT_COLOR = '#2f7a3e' // selection stroke/handles -// On-screen size of a drag/resize handle. Bigger on a coarse pointer (touch) so -// a fingertip can actually grab a resize corner or the rotate knob — 12px is fine -// for a mouse but frustrating for a thumb (#104). Read once at load; a device -// doesn't switch its primary pointer mid-session. -export const HANDLE_PX = - typeof window !== 'undefined' && window.matchMedia?.('(pointer: coarse)').matches ? 22 : 12 +// Whether the primary pointer is a fingertip rather than a mouse — the one signal +// the touch affordances key off (bigger handles here, the on-screen nudge pad in +// the editor), so they can't disagree about what "touch" means. Read once at +// load; a device doesn't switch its primary pointer mid-session, and the optional +// chain keeps it false (mouse defaults) under test / SSR where matchMedia is absent. +export const isCoarsePointer = + typeof window !== 'undefined' && !!window.matchMedia?.('(pointer: coarse)').matches +// On-screen size of a drag/resize handle. Bigger on touch so a fingertip can +// actually grab a resize corner or the rotate knob — 12px is fine for a mouse but +// frustrating for a thumb (#104). +export const HANDLE_PX = isCoarsePointer ? 22 : 12 export const MIN_RADIUS_CM = 1 // smallest plop radius export const DIMMED_OPACITY = 0.4 // non-focused objects/plops in focus mode diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx index a3fa0b8..1457f5d 100644 --- a/web/src/pages/GardenEditorPage.tsx +++ b/web/src/pages/GardenEditorPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getRouteApi } from '@tanstack/react-router' import { Alert } from '@/components/ui/Alert' import { Button } from '@/components/ui/Button' @@ -18,6 +18,7 @@ import { EditorHint } from '@/editor/EditorHint' import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker' import { objectDisplayName } from '@/editor/kinds' import { useEditorStore, type EditorMode } from '@/editor/store' +import { isCoarsePointer } from '@/editor/shared' import { cn } from '@/lib/cn' import type { EditorGarden } from '@/editor/types' import { ShareGardenModal } from '@/components/gardens/ShareGardenModal' @@ -289,7 +290,11 @@ export function GardenEditorPage() { // object's local bounds. Reads live values from getState/nudgeCtx so it's // correct whichever surface calls it; a commit only fires if its live value is // still present (a drag that cleared it already committed its own PATCH). - const commitLater = (fire: () => void) => { + // Stable across renders (empty deps): both read live values through refs + // (nudgeCtx) / getState, never through closed-over props, so a mount-once + // consumer (the keydown effect) and a memo-friendly one (NudgePad) both get a + // function that stays current without a new identity each render. + const commitLater = useCallback((fire: () => void) => { nudgeFire.current = fire if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current) nudgeTimer.current = window.setTimeout(() => { @@ -298,9 +303,9 @@ export function GardenEditorPage() { nudgeFire.current = null fn?.() }, 400) - } + }, []) - const nudgeSelected = (dx: number, dy: number) => { + const nudgeSelected = useCallback((dx: number, dy: number) => { const { canEdit: canNudge, objects: objs, plantings: plops, updateObject: uo, updatePlanting: up } = nudgeCtx.current if (!canNudge) return @@ -335,7 +340,7 @@ export function GardenEditorPage() { useEditorStore.getState().setLivePlanting(null) }) } - } + }, [commitLater]) // Keyboard nudging (desktop): arrows move the selection 1cm, Shift = 10cm — the // same nudgeSelected the touch pad uses. Mounted once; a pending commit is @@ -636,9 +641,10 @@ export function GardenEditorPage() {