Skip to content

Commit c25457e

Browse files
authored
Merge pull request #3 from pythonicode/feat/responsive-layout
Responsive shell: fit map on resize, compact status bar and toolbar for phones and tablets
2 parents a325bf7 + 880dc43 commit c25457e

7 files changed

Lines changed: 201 additions & 39 deletions

File tree

‎src/App.css‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2503,3 +2503,101 @@ a.statusbar-legal-link {
25032503
transform: scale(1);
25042504
}
25052505
}
2506+
2507+
/* ---- Responsive shell: status bar, toolbars and short screens ---- */
2508+
2509+
.statusbar {
2510+
white-space: nowrap;
2511+
}
2512+
2513+
.statusbar-zoom {
2514+
display: inline-flex;
2515+
align-items: center;
2516+
gap: 6px;
2517+
padding: 2px 6px;
2518+
font: inherit;
2519+
color: var(--text-h);
2520+
background: transparent;
2521+
border: 1px solid transparent;
2522+
border-radius: 6px;
2523+
cursor: pointer;
2524+
}
2525+
2526+
.statusbar-zoom:hover:not(:disabled),
2527+
.statusbar-zoom:focus-visible {
2528+
background: var(--code-bg);
2529+
border-color: var(--border);
2530+
outline: none;
2531+
}
2532+
2533+
.statusbar-zoom:disabled {
2534+
opacity: 0.5;
2535+
cursor: default;
2536+
}
2537+
2538+
.app-shell[data-layout='touch'] .statusbar {
2539+
gap: 12px;
2540+
}
2541+
2542+
.style-sheet-text-size {
2543+
display: flex;
2544+
align-items: center;
2545+
gap: 10px;
2546+
padding: 8px 0;
2547+
font: 13px var(--mono);
2548+
color: var(--text);
2549+
}
2550+
2551+
.style-sheet-text-size .statusbar-font-slider {
2552+
flex: 1;
2553+
width: auto;
2554+
}
2555+
2556+
@media (max-width: 420px) {
2557+
.bottom-toolbar {
2558+
gap: 2px;
2559+
padding: 4px;
2560+
}
2561+
2562+
.bottom-tool-button {
2563+
min-width: 40px;
2564+
padding: 4px 2px;
2565+
}
2566+
2567+
.bottom-tool-label {
2568+
font-size: 9px;
2569+
}
2570+
}
2571+
2572+
/* Landscape phones and other short screens: give the canvas the height */
2573+
@media (max-height: 500px) {
2574+
.app-shell[data-layout='touch'] .touch-hints {
2575+
display: none;
2576+
}
2577+
2578+
.app-shell[data-layout='touch'] .bottom-tool-button {
2579+
flex-direction: row;
2580+
min-height: 40px;
2581+
}
2582+
2583+
.app-shell[data-layout='touch'] .bottom-tool-label {
2584+
display: none;
2585+
}
2586+
2587+
.app-shell[data-layout='touch'] .statusbar {
2588+
padding-top: 2px;
2589+
padding-bottom: 2px;
2590+
}
2591+
}
2592+
2593+
/* Keep the rotation slider from stretching across the status bar */
2594+
.statusbar .canvas-rotation-control-compact {
2595+
flex: 0 1 260px;
2596+
width: auto;
2597+
min-width: 140px;
2598+
}
2599+
2600+
.statusbar .canvas-rotation-control-compact .canvas-rotation-slider {
2601+
min-width: 0;
2602+
flex: 1;
2603+
}

‎src/components/BottomToolBar.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useAppStore } from '../store'
55
import BottomSheet from './BottomSheet'
66
import StrokeSettings from './StrokeSettings'
77
import CanvasRotationControl from './CanvasRotationControl'
8+
import TextSizeSlider from './TextSizeSlider'
89

910
export default function BottomToolBar() {
1011
const activeTool = useAppStore((s) => s.activeTool)
@@ -65,6 +66,7 @@ export default function BottomToolBar() {
6566
{settingsOpen && (
6667
<BottomSheet title="View & stroke settings" onClose={() => setSettingsOpen(false)}>
6768
<CanvasRotationControl compact />
69+
<TextSizeSlider label="Comment text size" className="style-sheet-text-size" />
6870
<StrokeSettings layoutMode="touch" />
6971
</BottomSheet>
7072
)}

‎src/components/CanvasArea.tsx‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,14 @@ export default function CanvasArea({
7474
const [loadingSample, setLoadingSample] = useState(false)
7575
const mapInputRef = useRef<HTMLInputElement>(null)
7676
const lastFittedSrc = useRef<string | null>(null)
77+
/** True until the user zooms or pans, so resizing keeps the map fitted */
78+
const autoFitRef = useRef(true)
79+
const fitRequest = useAppStore((s) => s.fitRequest)
7780
const erasingRef = useRef(false)
7881

7982
const commitViewport = useCallback(
8083
(viewport: Viewport) => {
84+
autoFitRef.current = false
8185
if (zoomFrameRef.current != null) {
8286
cancelAnimationFrame(zoomFrameRef.current)
8387
zoomFrameRef.current = null
@@ -98,6 +102,7 @@ export default function CanvasArea({
98102
)
99103

100104
const previewZoom = useCallback((viewport: Viewport) => {
105+
autoFitRef.current = false
101106
const next = clampViewport(viewport)
102107
gestureRef.current = true
103108
viewportRef.current = next
@@ -177,13 +182,13 @@ export default function CanvasArea({
177182
})
178183
}, [])
179184

180-
useEffect(() => {
181-
if (!mapImage || size.width === 0 || size.height === 0) return
182-
if (lastFittedSrc.current === mapImage.src) return
183-
lastFittedSrc.current = mapImage.src
185+
/** Scales and centres the map so it fills the canvas, keeping the current rotation. */
186+
const fitToView = useCallback(() => {
187+
const image = useAppStore.getState().mapImage
188+
if (!image || size.width === 0 || size.height === 0) return
184189
const rotation = useAppStore.getState().viewport.rotation
185-
const center = { x: mapImage.width / 2, y: mapImage.height / 2 }
186-
const bounds = rotatedBounds(mapImage.width, mapImage.height, rotation)
190+
const center = { x: image.width / 2, y: image.height / 2 }
191+
const bounds = rotatedBounds(image.width, image.height, rotation)
187192
const scale =
188193
Math.min(size.width / bounds.width, size.height / bounds.height) * 0.95
189194
commitViewport({
@@ -192,7 +197,21 @@ export default function CanvasArea({
192197
x: size.width / 2 - center.x * scale,
193198
y: size.height / 2 - center.y * scale,
194199
})
195-
}, [mapImage, size, commitViewport])
200+
autoFitRef.current = true
201+
}, [size, commitViewport])
202+
203+
// Fit a newly loaded map, and re-fit when the canvas resizes unless the user has zoomed
204+
useEffect(() => {
205+
if (!mapImage || size.width === 0 || size.height === 0) return
206+
const mapChanged = lastFittedSrc.current !== mapImage.src
207+
if (!mapChanged && !autoFitRef.current) return
208+
lastFittedSrc.current = mapImage.src
209+
fitToView()
210+
}, [mapImage, size, fitToView])
211+
212+
useEffect(() => {
213+
if (fitRequest > 0) fitToView()
214+
}, [fitRequest, fitToView])
196215

197216
useEffect(() => {
198217
const isEditableTarget = (e: KeyboardEvent) => {

‎src/components/StatusBar.tsx‎

Lines changed: 22 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
import { useState } from 'react'
2-
import { Minus, Plus } from 'lucide-react'
2+
import { Maximize2, Minus, Plus } from 'lucide-react'
33
import { getToolLabel } from '../config/tools'
44
import type { LegalDocumentId } from '../content/legal'
55
import type { LayoutMode } from '../hooks/useLayoutMode'
66
import { useAppStore } from '../store'
77
import type { SaveStatus } from '../saveStatus'
8-
import {
9-
ANNOTATION_TEXT_SCALE_MAX,
10-
ANNOTATION_TEXT_SCALE_MIN,
11-
ANNOTATION_TEXT_SCALE_STEP,
12-
} from '../utils/annotationTextScale'
138
import { stageRef } from '../stageRef'
149
import { useSaveState } from '../saveStatus'
10+
import TextSizeSlider from './TextSizeSlider'
1511
import CanvasRotationControl from './CanvasRotationControl'
1612
import LegalModal from './LegalModal'
1713

@@ -54,10 +50,8 @@ export default function StatusBar({
5450
const scale = useAppStore((s) => s.viewport.scale)
5551
const setViewport = useAppStore((s) => s.setViewport)
5652
const pointer = useAppStore((s) => s.pointer)
57-
const textScalePct = useAppStore((s) => s.annotationTextScalePct)
58-
const setAnnotationTextScalePct = useAppStore(
59-
(s) => s.setAnnotationTextScalePct,
60-
)
53+
const hasMap = useAppStore((s) => s.mapImage !== null)
54+
const requestFitToView = useAppStore((s) => s.requestFitToView)
6155
const saveState = useSaveState()
6256
const [legalDoc, setLegalDoc] = useState<LegalDocumentId | null>(null)
6357

@@ -66,6 +60,9 @@ export default function StatusBar({
6660
const showRotation = layoutMode !== 'touch'
6761
const showLegalLinks = layoutMode !== 'touch'
6862
const showCredit = layoutMode === 'desktop'
63+
// Narrower layouts keep the bar to the essentials; the Style sheet holds the rest on touch
64+
const showToolName = layoutMode === 'desktop'
65+
const showTextSize = layoutMode !== 'touch'
6966

7067
const zoomBy = (direction: 1 | -1) => {
7168
const viewport = useAppStore.getState().viewport
@@ -88,9 +85,17 @@ export default function StatusBar({
8885

8986
return (
9087
<footer className="statusbar">
91-
<span className="statusbar-zoom">
92-
Zoom: {Math.round(scale * 100)}%
93-
</span>
88+
<button
89+
type="button"
90+
className="statusbar-zoom"
91+
disabled={!hasMap}
92+
onClick={requestFitToView}
93+
title="Fit the map to the screen"
94+
aria-label={`Zoom ${Math.round(scale * 100)}%. Fit map to screen`}
95+
>
96+
<Maximize2 size={12} aria-hidden />
97+
<span>{Math.round(scale * 100)}%</span>
98+
</button>
9499
{showRotation && <CanvasRotationControl compact={layoutMode === 'compact'} />}
95100
{showZoomButtons && (
96101
<span className="statusbar-zoom-controls">
@@ -112,32 +117,17 @@ export default function StatusBar({
112117
</button>
113118
</span>
114119
)}
115-
<label className="statusbar-font">
116-
<span>Text Size</span>
117-
<input
118-
className="statusbar-font-slider"
119-
type="range"
120-
min={ANNOTATION_TEXT_SCALE_MIN}
121-
max={ANNOTATION_TEXT_SCALE_MAX}
122-
step={ANNOTATION_TEXT_SCALE_STEP}
123-
value={textScalePct}
124-
aria-label="Font size"
125-
aria-valuemin={ANNOTATION_TEXT_SCALE_MIN}
126-
aria-valuemax={ANNOTATION_TEXT_SCALE_MAX}
127-
aria-valuenow={textScalePct}
128-
aria-valuetext={`${textScalePct}%`}
129-
onChange={(e) => setAnnotationTextScalePct(Number(e.target.value))}
130-
/>
131-
<span className="statusbar-font-value">{textScalePct}%</span>
132-
</label>
120+
{showTextSize && <TextSizeSlider label={layoutMode === 'compact' ? 'Text' : 'Text Size'} />}
133121
{showCoords && (
134122
<span>
135123
{pointer
136124
? `X: ${Math.round(pointer.x)} Y: ${Math.round(pointer.y)}`
137125
: 'X: — Y: —'}
138126
</span>
139127
)}
140-
<span className="statusbar-tool">Tool: {getToolLabel(activeTool)}</span>
128+
{showToolName && (
129+
<span className="statusbar-tool">Tool: {getToolLabel(activeTool)}</span>
130+
)}
141131
<SaveIndicator state={saveState} />
142132
{showLegalLinks && (
143133
<nav className="statusbar-legal" aria-label="Legal">

‎src/components/TextSizeSlider.tsx‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useAppStore } from '../store'
2+
import {
3+
ANNOTATION_TEXT_SCALE_MAX,
4+
ANNOTATION_TEXT_SCALE_MIN,
5+
ANNOTATION_TEXT_SCALE_STEP,
6+
} from '../utils/annotationTextScale'
7+
8+
/** Slider for the comment and sidebar text size. Shared by the status bar and the touch Style sheet. */
9+
export default function TextSizeSlider({
10+
label,
11+
className = 'statusbar-font',
12+
}: {
13+
label: string
14+
className?: string
15+
}) {
16+
const textScalePct = useAppStore((s) => s.annotationTextScalePct)
17+
const setAnnotationTextScalePct = useAppStore(
18+
(s) => s.setAnnotationTextScalePct,
19+
)
20+
21+
return (
22+
<label className={className}>
23+
<span>{label}</span>
24+
<input
25+
className="statusbar-font-slider"
26+
type="range"
27+
min={ANNOTATION_TEXT_SCALE_MIN}
28+
max={ANNOTATION_TEXT_SCALE_MAX}
29+
step={ANNOTATION_TEXT_SCALE_STEP}
30+
value={textScalePct}
31+
aria-label={label}
32+
aria-valuemin={ANNOTATION_TEXT_SCALE_MIN}
33+
aria-valuemax={ANNOTATION_TEXT_SCALE_MAX}
34+
aria-valuenow={textScalePct}
35+
aria-valuetext={`${textScalePct}%`}
36+
onChange={(e) => setAnnotationTextScalePct(Number(e.target.value))}
37+
/>
38+
<span className="statusbar-font-value">{textScalePct}%</span>
39+
</label>
40+
)
41+
}

‎src/components/TouchHints.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { X } from 'lucide-react'
33
import { useIsTouchLayout } from '../hooks/useLayoutMode'
44

55
const STORAGE_KEY = 'touch-hints-dismissed'
6+
const AUTO_HIDE_MS = 12000
67

78
export default function TouchHints() {
89
const isTouch = useIsTouchLayout()
@@ -12,6 +13,12 @@ export default function TouchHints() {
1213
if (!isTouch) return
1314
if (localStorage.getItem(STORAGE_KEY)) return
1415
setVisible(true)
16+
// Tips are for first use; hide them on their own and remember that they were seen
17+
const timer = window.setTimeout(() => {
18+
localStorage.setItem(STORAGE_KEY, '1')
19+
setVisible(false)
20+
}, AUTO_HIDE_MS)
21+
return () => window.clearTimeout(timer)
1522
}, [isTouch])
1623

1724
if (!visible) return null

‎src/store.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ interface AppState extends Snapshot {
7878
setAnnotationTextScalePct: (percent: number) => void
7979
setToastMessage: (message: string | null) => void
8080
setProjectName: (name: string) => void
81+
/** Bumped to ask the canvas to fit the map to the screen */
82+
fitRequest: number
83+
requestFitToView: () => void
8184

8285
addTrack: (track: GpxTrack) => void
8386
/** Pass a coalesceKey to merge rapid consecutive edits (e.g. slider drags) into one undo step */
@@ -191,6 +194,8 @@ export const useAppStore = create<AppState>()(
191194
set({ annotationTextScalePct: clampAnnotationTextScalePct(percent) }),
192195
setToastMessage: (message) => set({ toastMessage: message }),
193196
setProjectName: (name) => set({ projectName: name }),
197+
fitRequest: 0,
198+
requestFitToView: () => set((s) => ({ fitRequest: s.fitRequest + 1 })),
194199

195200
addTrack: (track) =>
196201
set((s) => ({ ...record(s, null), tracks: [...s.tracks, track] })),

0 commit comments

Comments
 (0)