Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
2 changes: 2 additions & 0 deletions src/components/BottomToolBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -65,6 +66,7 @@ export default function BottomToolBar() {
{settingsOpen && (
<BottomSheet title="View & stroke settings" onClose={() => setSettingsOpen(false)}>
<CanvasRotationControl compact />
<TextSizeSlider label="Comment text size" className="style-sheet-text-size" />
<StrokeSettings layoutMode="touch" />
</BottomSheet>
)}
Expand Down
33 changes: 26 additions & 7 deletions src/components/CanvasArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,14 @@ export default function CanvasArea({
const [loadingSample, setLoadingSample] = useState(false)
const mapInputRef = useRef<HTMLInputElement>(null)
const lastFittedSrc = useRef<string | null>(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
Expand All @@ -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
Expand Down Expand Up @@ -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({
Expand All @@ -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) => {
Expand Down
54 changes: 22 additions & 32 deletions src/components/StatusBar.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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<LegalDocumentId | null>(null)

Expand All @@ -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
Expand All @@ -88,9 +85,17 @@ export default function StatusBar({

return (
<footer className="statusbar">
<span className="statusbar-zoom">
Zoom: {Math.round(scale * 100)}%
</span>
<button
type="button"
className="statusbar-zoom"
disabled={!hasMap}
onClick={requestFitToView}
title="Fit the map to the screen"
aria-label={`Zoom ${Math.round(scale * 100)}%. Fit map to screen`}
>
<Maximize2 size={12} aria-hidden />
<span>{Math.round(scale * 100)}%</span>
</button>
{showRotation && <CanvasRotationControl compact={layoutMode === 'compact'} />}
{showZoomButtons && (
<span className="statusbar-zoom-controls">
Expand All @@ -112,32 +117,17 @@ export default function StatusBar({
</button>
</span>
)}
<label className="statusbar-font">
<span>Text Size</span>
<input
className="statusbar-font-slider"
type="range"
min={ANNOTATION_TEXT_SCALE_MIN}
max={ANNOTATION_TEXT_SCALE_MAX}
step={ANNOTATION_TEXT_SCALE_STEP}
value={textScalePct}
aria-label="Font size"
aria-valuemin={ANNOTATION_TEXT_SCALE_MIN}
aria-valuemax={ANNOTATION_TEXT_SCALE_MAX}
aria-valuenow={textScalePct}
aria-valuetext={`${textScalePct}%`}
onChange={(e) => setAnnotationTextScalePct(Number(e.target.value))}
/>
<span className="statusbar-font-value">{textScalePct}%</span>
</label>
{showTextSize && <TextSizeSlider label={layoutMode === 'compact' ? 'Text' : 'Text Size'} />}
{showCoords && (
<span>
{pointer
? `X: ${Math.round(pointer.x)} Y: ${Math.round(pointer.y)}`
: 'X: — Y: —'}
</span>
)}
<span className="statusbar-tool">Tool: {getToolLabel(activeTool)}</span>
{showToolName && (
<span className="statusbar-tool">Tool: {getToolLabel(activeTool)}</span>
)}
<SaveIndicator state={saveState} />
{showLegalLinks && (
<nav className="statusbar-legal" aria-label="Legal">
Expand Down
41 changes: 41 additions & 0 deletions src/components/TextSizeSlider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useAppStore } from '../store'
import {
ANNOTATION_TEXT_SCALE_MAX,
ANNOTATION_TEXT_SCALE_MIN,
ANNOTATION_TEXT_SCALE_STEP,
} from '../utils/annotationTextScale'

/** Slider for the comment and sidebar text size. Shared by the status bar and the touch Style sheet. */
export default function TextSizeSlider({
label,
className = 'statusbar-font',
}: {
label: string
className?: string
}) {
const textScalePct = useAppStore((s) => s.annotationTextScalePct)
const setAnnotationTextScalePct = useAppStore(
(s) => s.setAnnotationTextScalePct,
)

return (
<label className={className}>
<span>{label}</span>
<input
className="statusbar-font-slider"
type="range"
min={ANNOTATION_TEXT_SCALE_MIN}
max={ANNOTATION_TEXT_SCALE_MAX}
step={ANNOTATION_TEXT_SCALE_STEP}
value={textScalePct}
aria-label={label}
aria-valuemin={ANNOTATION_TEXT_SCALE_MIN}
aria-valuemax={ANNOTATION_TEXT_SCALE_MAX}
aria-valuenow={textScalePct}
aria-valuetext={`${textScalePct}%`}
onChange={(e) => setAnnotationTextScalePct(Number(e.target.value))}
/>
<span className="statusbar-font-value">{textScalePct}%</span>
</label>
)
}
7 changes: 7 additions & 0 deletions src/components/TouchHints.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { X } from 'lucide-react'
import { useIsTouchLayout } from '../hooks/useLayoutMode'

const STORAGE_KEY = 'touch-hints-dismissed'
const AUTO_HIDE_MS = 12000

export default function TouchHints() {
const isTouch = useIsTouchLayout()
Expand All @@ -12,6 +13,12 @@ export default function TouchHints() {
if (!isTouch) return
if (localStorage.getItem(STORAGE_KEY)) return
setVisible(true)
// Tips are for first use; hide them on their own and remember that they were seen
const timer = window.setTimeout(() => {
localStorage.setItem(STORAGE_KEY, '1')
setVisible(false)
}, AUTO_HIDE_MS)
return () => window.clearTimeout(timer)
}, [isTouch])

if (!visible) return null
Expand Down
5 changes: 5 additions & 0 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ interface AppState extends Snapshot {
setAnnotationTextScalePct: (percent: number) => void
setToastMessage: (message: string | null) => void
setProjectName: (name: string) => void
/** Bumped to ask the canvas to fit the map to the screen */
fitRequest: number
requestFitToView: () => void

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

addTrack: (track) =>
set((s) => ({ ...record(s, null), tracks: [...s.tracks, track] })),
Expand Down
Loading