From 880dc4387fc6fa23aa4e9eeb77c0893b5ba1c021 Mon Sep 17 00:00:00 2001 From: Anthony Riley Date: Wed, 14 Oct 2026 11:01:22 +0200 Subject: [PATCH] feat: responsive shell for tablets and phones - The map refits when the window resizes, until the user zooms or pans manually. A Fit button in the status bar refits on demand. - Touch status bar keeps zoom, Fit and the tool hint off the bar; text size moves into the Style sheet, so nothing wraps or overflows on a phone. - Compact status bar caps the rotation slider so the bar fits at 1024px. - Touch tips hide after 12 seconds and on short landscape screens, where the bottom toolbar drops its labels to give the canvas the height. - Toolbar buttons shrink on narrow screens so all eight fit at 390px. Co-Authored-By: Claude Haiku 5.5 --- src/App.css | 98 +++++++++++++++++++++++++++++++ src/components/BottomToolBar.tsx | 2 + src/components/CanvasArea.tsx | 33 ++++++++--- src/components/StatusBar.tsx | 54 +++++++---------- src/components/TextSizeSlider.tsx | 41 +++++++++++++ src/components/TouchHints.tsx | 7 +++ src/store.ts | 5 ++ 7 files changed, 201 insertions(+), 39 deletions(-) create mode 100644 src/components/TextSizeSlider.tsx diff --git a/src/App.css b/src/App.css index 0cf7a01..433fb2b 100644 --- a/src/App.css +++ b/src/App.css @@ -2503,3 +2503,101 @@ a.statusbar-legal-link { transform: scale(1); } } + +/* ---- Responsive shell: status bar, toolbars and short screens ---- */ + +.statusbar { + white-space: nowrap; +} + +.statusbar-zoom { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 6px; + font: inherit; + color: var(--text-h); + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + cursor: pointer; +} + +.statusbar-zoom:hover:not(:disabled), +.statusbar-zoom:focus-visible { + background: var(--code-bg); + border-color: var(--border); + outline: none; +} + +.statusbar-zoom:disabled { + opacity: 0.5; + cursor: default; +} + +.app-shell[data-layout='touch'] .statusbar { + gap: 12px; +} + +.style-sheet-text-size { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; + font: 13px var(--mono); + color: var(--text); +} + +.style-sheet-text-size .statusbar-font-slider { + flex: 1; + width: auto; +} + +@media (max-width: 420px) { + .bottom-toolbar { + gap: 2px; + padding: 4px; + } + + .bottom-tool-button { + min-width: 40px; + padding: 4px 2px; + } + + .bottom-tool-label { + font-size: 9px; + } +} + +/* Landscape phones and other short screens: give the canvas the height */ +@media (max-height: 500px) { + .app-shell[data-layout='touch'] .touch-hints { + display: none; + } + + .app-shell[data-layout='touch'] .bottom-tool-button { + flex-direction: row; + min-height: 40px; + } + + .app-shell[data-layout='touch'] .bottom-tool-label { + display: none; + } + + .app-shell[data-layout='touch'] .statusbar { + padding-top: 2px; + padding-bottom: 2px; + } +} + +/* Keep the rotation slider from stretching across the status bar */ +.statusbar .canvas-rotation-control-compact { + flex: 0 1 260px; + width: auto; + min-width: 140px; +} + +.statusbar .canvas-rotation-control-compact .canvas-rotation-slider { + min-width: 0; + flex: 1; +} diff --git a/src/components/BottomToolBar.tsx b/src/components/BottomToolBar.tsx index 265d83d..3caa532 100644 --- a/src/components/BottomToolBar.tsx +++ b/src/components/BottomToolBar.tsx @@ -5,6 +5,7 @@ import { useAppStore } from '../store' import BottomSheet from './BottomSheet' import StrokeSettings from './StrokeSettings' import CanvasRotationControl from './CanvasRotationControl' +import TextSizeSlider from './TextSizeSlider' export default function BottomToolBar() { const activeTool = useAppStore((s) => s.activeTool) @@ -65,6 +66,7 @@ export default function BottomToolBar() { {settingsOpen && ( setSettingsOpen(false)}> + )} diff --git a/src/components/CanvasArea.tsx b/src/components/CanvasArea.tsx index 568766c..9fedb9a 100644 --- a/src/components/CanvasArea.tsx +++ b/src/components/CanvasArea.tsx @@ -74,10 +74,14 @@ export default function CanvasArea({ const [loadingSample, setLoadingSample] = useState(false) const mapInputRef = useRef(null) const lastFittedSrc = useRef(null) + /** True until the user zooms or pans, so resizing keeps the map fitted */ + const autoFitRef = useRef(true) + const fitRequest = useAppStore((s) => s.fitRequest) const erasingRef = useRef(false) const commitViewport = useCallback( (viewport: Viewport) => { + autoFitRef.current = false if (zoomFrameRef.current != null) { cancelAnimationFrame(zoomFrameRef.current) zoomFrameRef.current = null @@ -98,6 +102,7 @@ export default function CanvasArea({ ) const previewZoom = useCallback((viewport: Viewport) => { + autoFitRef.current = false const next = clampViewport(viewport) gestureRef.current = true viewportRef.current = next @@ -177,13 +182,13 @@ export default function CanvasArea({ }) }, []) - useEffect(() => { - if (!mapImage || size.width === 0 || size.height === 0) return - if (lastFittedSrc.current === mapImage.src) return - lastFittedSrc.current = mapImage.src + /** Scales and centres the map so it fills the canvas, keeping the current rotation. */ + const fitToView = useCallback(() => { + const image = useAppStore.getState().mapImage + if (!image || size.width === 0 || size.height === 0) return const rotation = useAppStore.getState().viewport.rotation - const center = { x: mapImage.width / 2, y: mapImage.height / 2 } - const bounds = rotatedBounds(mapImage.width, mapImage.height, rotation) + const center = { x: image.width / 2, y: image.height / 2 } + const bounds = rotatedBounds(image.width, image.height, rotation) const scale = Math.min(size.width / bounds.width, size.height / bounds.height) * 0.95 commitViewport({ @@ -192,7 +197,21 @@ export default function CanvasArea({ x: size.width / 2 - center.x * scale, y: size.height / 2 - center.y * scale, }) - }, [mapImage, size, commitViewport]) + autoFitRef.current = true + }, [size, commitViewport]) + + // Fit a newly loaded map, and re-fit when the canvas resizes unless the user has zoomed + useEffect(() => { + if (!mapImage || size.width === 0 || size.height === 0) return + const mapChanged = lastFittedSrc.current !== mapImage.src + if (!mapChanged && !autoFitRef.current) return + lastFittedSrc.current = mapImage.src + fitToView() + }, [mapImage, size, fitToView]) + + useEffect(() => { + if (fitRequest > 0) fitToView() + }, [fitRequest, fitToView]) useEffect(() => { const isEditableTarget = (e: KeyboardEvent) => { diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 39117b0..e0053d7 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -1,17 +1,13 @@ import { useState } from 'react' -import { Minus, Plus } from 'lucide-react' +import { Maximize2, Minus, Plus } from 'lucide-react' import { getToolLabel } from '../config/tools' import type { LegalDocumentId } from '../content/legal' import type { LayoutMode } from '../hooks/useLayoutMode' import { useAppStore } from '../store' import type { SaveStatus } from '../saveStatus' -import { - ANNOTATION_TEXT_SCALE_MAX, - ANNOTATION_TEXT_SCALE_MIN, - ANNOTATION_TEXT_SCALE_STEP, -} from '../utils/annotationTextScale' import { stageRef } from '../stageRef' import { useSaveState } from '../saveStatus' +import TextSizeSlider from './TextSizeSlider' import CanvasRotationControl from './CanvasRotationControl' import LegalModal from './LegalModal' @@ -54,10 +50,8 @@ export default function StatusBar({ const scale = useAppStore((s) => s.viewport.scale) const setViewport = useAppStore((s) => s.setViewport) const pointer = useAppStore((s) => s.pointer) - const textScalePct = useAppStore((s) => s.annotationTextScalePct) - const setAnnotationTextScalePct = useAppStore( - (s) => s.setAnnotationTextScalePct, - ) + const hasMap = useAppStore((s) => s.mapImage !== null) + const requestFitToView = useAppStore((s) => s.requestFitToView) const saveState = useSaveState() const [legalDoc, setLegalDoc] = useState(null) @@ -66,6 +60,9 @@ export default function StatusBar({ const showRotation = layoutMode !== 'touch' const showLegalLinks = layoutMode !== 'touch' const showCredit = layoutMode === 'desktop' + // Narrower layouts keep the bar to the essentials; the Style sheet holds the rest on touch + const showToolName = layoutMode === 'desktop' + const showTextSize = layoutMode !== 'touch' const zoomBy = (direction: 1 | -1) => { const viewport = useAppStore.getState().viewport @@ -88,9 +85,17 @@ export default function StatusBar({ return (