Skip to content

Commit 313a199

Browse files
committed
feat(mobile): improve responsive panes, gestures, zoom range and iPad export
1 parent 26483ec commit 313a199

13 files changed

Lines changed: 415 additions & 142 deletions

src/App.smoke.test.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,16 @@ vi.mock('./ui/canvas/BeadPreviewCanvas', () => ({
3030
BeadPreviewCanvas: () => <div data-testid="bead-preview-canvas" />,
3131
}))
3232

33-
function setMatchMedia(isCompactTabsMode: boolean): void {
33+
function setMatchMedia(mode: 'desktop' | 'compact' | 'single'): void {
3434
vi.stubGlobal(
3535
'matchMedia',
3636
vi.fn((query: string) => ({
3737
matches:
38-
isCompactTabsMode &&
38+
(mode === 'compact' || mode === 'single') &&
3939
(
40-
query === '(max-width: 1200px)' ||
41-
query === '(max-width: 980px)' ||
42-
query === '(max-width: 980px) and (orientation: portrait)'
43-
),
40+
query === '(max-width: 1024px)'
41+
) ||
42+
(mode === 'single' && query === '(max-width: 500px)'),
4443
media: query,
4544
onchange: null,
4645
addListener: () => {},
@@ -56,7 +55,7 @@ describe('App smoke', () => {
5655
beforeEach(() => {
5756
useEditorStore.getState().reset()
5857
useEditorStore.getState().setDocument(createEmptyDocument())
59-
setMatchMedia(false)
58+
setMatchMedia('desktop')
6059
})
6160

6261
it('renders and opens help dialog', async () => {
@@ -71,7 +70,7 @@ describe('App smoke', () => {
7170
})
7271

7372
it('keeps a single visible pane on initial mobile portrait render', async () => {
74-
setMatchMedia(true)
73+
setMatchMedia('single')
7574
render(
7675
<I18nProvider>
7776
<App />

src/App.tsx

Lines changed: 75 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
import { DEFAULT_BEAD_SYMBOLS } from './domain/defaults'
33
import { useEditorStore } from './domain/editorStore'
44
import { buildReportSummary } from './domain/report'
5+
import { MAX_ZOOM_INDEX } from './domain/zoom'
56
import { useI18n } from './i18n/I18nProvider'
67
import type { AppLocale } from './i18n/translations'
78
import {
@@ -25,6 +26,7 @@ import { useDocumentFileActions } from './ui/hooks/useDocumentFileActions'
2526
import { useEditorShortcuts } from './ui/hooks/useEditorShortcuts'
2627
import { useMobilePortraitSinglePane } from './ui/hooks/useMobilePortraitSinglePane'
2728
import { usePaneSync } from './ui/hooks/usePaneSync'
29+
import { useCanvasGestures } from './ui/hooks/useCanvasGestures'
2830
import { usePointerEditing } from './ui/hooks/usePointerEditing'
2931
import { usePointerDismiss } from './ui/hooks/usePointerDismiss'
3032
import { useProjectBootstrap } from './ui/hooks/useProjectBootstrap'
@@ -236,7 +238,7 @@ function App() {
236238
const drawColors = document.view.drawColors
237239
const drawSymbols = document.view.drawSymbols
238240
const zoomIndex = document.view.zoom
239-
const canZoomIn = zoomIndex < 7
241+
const canZoomIn = zoomIndex < MAX_ZOOM_INDEX
240242
const canZoomOut = zoomIndex > 0
241243
const hasCanvasPaneVisible = isDraftVisible || isCorrectedVisible || isSimulationVisible
242244
const hasAnyPaneVisible = hasCanvasPaneVisible || isReportVisible
@@ -286,6 +288,25 @@ function App() {
286288
setZoom,
287289
})
288290

291+
const gesturePaneRefs = useMemo(
292+
() => [draftScrollRef, correctedScrollRef, simulationScrollRef],
293+
[correctedScrollRef, draftScrollRef, simulationScrollRef],
294+
)
295+
296+
useCanvasGestures({
297+
panes: gesturePaneRefs,
298+
onZoomIn: () => {
299+
setIsZoomFitMode(false)
300+
zoomIn()
301+
},
302+
onZoomOut: () => {
303+
setIsZoomFitMode(false)
304+
zoomOut()
305+
},
306+
onShiftLeft: () => shiftLeft(),
307+
onShiftRight: () => shiftRight(),
308+
})
309+
289310
const reportSummary = useMemo(
290311
() =>
291312
buildReportSummary(document, openFileName, {
@@ -447,22 +468,12 @@ function App() {
447468
setIsMetadataDialogOpen(true)
448469
}, [document.author, document.notes, document.organization])
449470

450-
const onSelectMobileView = useCallback(
451-
(pane: ViewPaneId) => {
452-
setViewVisibility('draft', pane === 'draft')
453-
setViewVisibility('corrected', pane === 'corrected')
454-
setViewVisibility('simulation', pane === 'simulation')
455-
setViewVisibility('report', pane === 'report')
456-
},
457-
[setViewVisibility],
458-
)
459-
460-
useMobilePortraitSinglePane({
471+
const { isCompactTabsMode, maxVisiblePanes } = useMobilePortraitSinglePane({
461472
isDraftVisible,
462473
isCorrectedVisible,
463474
isSimulationVisible,
464475
isReportVisible,
465-
onSelectMobileView,
476+
onSetPaneVisibility: setViewVisibility,
466477
})
467478

468479
const onApplyPreferences = useCallback(async () => {
@@ -542,13 +553,55 @@ function App() {
542553
simulation: isSimulationVisible,
543554
report: isReportVisible,
544555
}
545-
const mobileActivePane: ViewPaneId = isDraftVisible
546-
? 'draft'
547-
: isCorrectedVisible
548-
? 'corrected'
549-
: isSimulationVisible
550-
? 'simulation'
551-
: 'report'
556+
const onToggleMobileView = useCallback(
557+
(pane: ViewPaneId) => {
558+
const visibleByPane: Record<ViewPaneId, boolean> = {
559+
draft: isDraftVisible,
560+
corrected: isCorrectedVisible,
561+
simulation: isSimulationVisible,
562+
report: isReportVisible,
563+
}
564+
const orderedPanes: ViewPaneId[] = ['draft', 'corrected', 'simulation', 'report']
565+
const visiblePanes = orderedPanes.filter((id) => visibleByPane[id])
566+
const isVisible = visibleByPane[pane]
567+
568+
if (!isCompactTabsMode) {
569+
// Desktop behavior: open one pane and keep others unchanged is confusing.
570+
// Keep mobile tab interactions no-op outside compact mode.
571+
return
572+
}
573+
574+
if (isVisible) {
575+
if (visiblePanes.length <= 1) {
576+
return
577+
}
578+
setViewVisibility(pane, false)
579+
return
580+
}
581+
582+
if (maxVisiblePanes === 1) {
583+
orderedPanes.forEach((id) => {
584+
setViewVisibility(id, id === pane)
585+
})
586+
return
587+
}
588+
589+
if (visiblePanes.length >= maxVisiblePanes) {
590+
// Keep the latest selection by dropping the first visible pane.
591+
setViewVisibility(visiblePanes[0], false)
592+
}
593+
setViewVisibility(pane, true)
594+
},
595+
[
596+
isCompactTabsMode,
597+
isCorrectedVisible,
598+
isDraftVisible,
599+
isReportVisible,
600+
isSimulationVisible,
601+
maxVisiblePanes,
602+
setViewVisibility,
603+
],
604+
)
552605

553606
const paneLabels = useMemo(
554607
() => ({
@@ -634,13 +687,13 @@ function App() {
634687
hasAnyPaneVisible={hasAnyPaneVisible}
635688
recentFilesCount={recentFiles.length}
636689
isMobileActionsMenuOpen={isMobileActionsMenuOpen}
637-
mobileActivePane={mobileActivePane}
690+
mobilePaneVisibilityById={paneVisibilityById}
638691
panes={panes}
639692
mobileActionsMenuRef={mobileActionsMenuRef}
640693
openFileInputRef={openFileInputRef}
641694
onToggleMobileActionsMenu={() => setIsMobileActionsMenuOpen((value) => !value)}
642695
onCloseMobileActionsMenu={() => setIsMobileActionsMenuOpen(false)}
643-
onSelectMobileView={onSelectMobileView}
696+
onToggleMobileView={onToggleMobileView}
644697
onNewDocument={onNewDocument}
645698
onOpenDocument={onOpenDocument}
646699
onOpenRecentDialog={onOpenRecentDialog}

src/domain/editorStore.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { beforeEach, describe, expect, it } from 'vitest'
22
import { createEmptyDocument, DEFAULT_BEAD_SYMBOLS } from './defaults'
33
import { useEditorStore } from './editorStore'
4+
import { MAX_ZOOM_INDEX, NORMAL_ZOOM_INDEX } from './zoom'
45

56
describe('editor store', () => {
67
beforeEach(() => {
@@ -459,20 +460,20 @@ describe('editor store', () => {
459460
expect(useEditorStore.getState().document.view.zoom).toBe(3)
460461

461462
useEditorStore.getState().zoomNormal()
462-
expect(useEditorStore.getState().document.view.zoom).toBe(3)
463+
expect(useEditorStore.getState().document.view.zoom).toBe(NORMAL_ZOOM_INDEX)
463464

464465
for (let index = 0; index < 20; index += 1) {
465466
useEditorStore.getState().zoomIn()
466467
}
467-
expect(useEditorStore.getState().document.view.zoom).toBe(7)
468+
expect(useEditorStore.getState().document.view.zoom).toBe(MAX_ZOOM_INDEX)
468469

469470
for (let index = 0; index < 20; index += 1) {
470471
useEditorStore.getState().zoomOut()
471472
}
472473
expect(useEditorStore.getState().document.view.zoom).toBe(0)
473474

474475
useEditorStore.getState().setZoom(99)
475-
expect(useEditorStore.getState().document.view.zoom).toBe(7)
476+
expect(useEditorStore.getState().document.view.zoom).toBe(MAX_ZOOM_INDEX)
476477

477478
useEditorStore.getState().setZoom(-3)
478479
expect(useEditorStore.getState().document.view.zoom).toBe(0)

src/domain/editorStore.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { create } from 'zustand'
22
import { createEmptyDocument, DEFAULT_BEAD_SYMBOLS } from './defaults'
33
import { getLinePoints, normalizeRect, snapLineEnd, type NormalizedRect } from './gridMath'
4+
import { MAX_ZOOM_INDEX, MIN_ZOOM_INDEX, NORMAL_ZOOM_INDEX } from './zoom'
45
import type { CellPoint, JBeadDocument, RgbaColor, SelectionRect, ToolId, ViewPaneId } from './types'
56

67
interface EditorState {
@@ -78,10 +79,6 @@ function clamp(value: number, min: number, max: number): number {
7879
return Math.max(min, Math.min(max, value))
7980
}
8081

81-
const MIN_ZOOM_INDEX = 0
82-
const MAX_ZOOM_INDEX = 7
83-
const NORMAL_ZOOM_INDEX = 3
84-
8582
function isInside(document: JBeadDocument, point: CellPoint): boolean {
8683
const rows = document.model.rows
8784
const height = rows.length

src/domain/zoom.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const ZOOM_TABLE = [4, 6, 8, 10, 12, 14, 16, 18, 20, 24, 28, 32, 36, 40] as const
2+
3+
export const MIN_ZOOM_INDEX = 0
4+
export const MAX_ZOOM_INDEX = ZOOM_TABLE.length - 1
5+
export const NORMAL_ZOOM_INDEX = 5
6+
7+
export function getCellSize(zoomIndex: number): number {
8+
return ZOOM_TABLE[Math.max(MIN_ZOOM_INDEX, Math.min(zoomIndex, MAX_ZOOM_INDEX))]
9+
}

src/styles/responsive.css

Lines changed: 53 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,10 @@
1-
@media (max-width: 1200px) {
1+
@media (max-width: 1024px) {
22
.mobile-view-tabs {
33
display: grid;
44
grid-template-columns: repeat(4, minmax(0, 1fr));
55
gap: 0.35rem;
66
}
7-
}
87

9-
@media (max-width: 1200px) {
10-
.app-header {
11-
flex-wrap: wrap;
12-
}
13-
14-
.header-controls {
15-
width: 100%;
16-
align-items: flex-start;
17-
}
18-
19-
.header-actions {
20-
justify-content: flex-start;
21-
}
22-
23-
.file-status {
24-
text-align: left;
25-
}
26-
27-
.workspace {
28-
grid-template-columns: 1fr;
29-
height: auto;
30-
overflow: auto;
31-
}
32-
33-
.toolbar-view {
34-
margin-left: 0;
35-
justify-content: flex-start;
36-
}
37-
38-
.preview-grid {
39-
grid-template-columns: 1fr;
40-
grid-auto-rows: auto;
41-
height: auto;
42-
}
43-
44-
.preview-with-scrollbar {
45-
grid-template-columns: 1fr;
46-
height: auto;
47-
}
48-
49-
.preview-with-scrollbar.has-canvas.no-report,
50-
.preview-with-scrollbar.has-canvas.has-report {
51-
grid-template-columns: 1fr;
52-
}
53-
54-
.shared-scrollbar-panel {
55-
display: none;
56-
}
57-
58-
.sidebar {
59-
order: -1;
60-
overflow: visible;
61-
}
62-
63-
.canvas-panel {
64-
min-height: auto;
65-
}
66-
}
67-
68-
@media (max-width: 1200px) {
698
.app-shell {
709
grid-template-rows: auto auto auto minmax(0, 1fr);
7110
}
@@ -173,5 +112,57 @@
173112

174113
.preview-with-scrollbar {
175114
min-width: 0;
115+
grid-template-columns: 1fr;
116+
height: 100%;
117+
}
118+
119+
.preview-with-scrollbar.has-canvas.no-report {
120+
grid-template-columns: 1fr;
121+
}
122+
123+
.preview-with-scrollbar.has-canvas.has-report {
124+
grid-template-columns: minmax(0, 1fr) minmax(220px, 280px);
125+
}
126+
127+
.preview-grid {
128+
grid-template-columns: repeat(2, minmax(0, 1fr));
129+
grid-auto-rows: minmax(0, 1fr);
130+
min-height: 0;
131+
height: 100%;
132+
}
133+
134+
.shared-scrollbar-panel {
135+
display: none;
136+
}
137+
138+
.sidebar {
139+
order: -1;
140+
overflow: visible;
141+
}
142+
143+
.canvas-panel {
144+
min-height: 0;
145+
}
146+
}
147+
148+
@media (max-width: 500px) {
149+
.mobile-view-tabs {
150+
grid-template-columns: 1fr;
151+
}
152+
153+
.preview-grid {
154+
grid-template-columns: 1fr;
155+
grid-auto-rows: auto;
156+
height: auto;
157+
}
158+
159+
.preview-with-scrollbar {
160+
grid-template-columns: 1fr;
161+
height: auto;
162+
}
163+
164+
.preview-with-scrollbar.has-canvas.has-report,
165+
.preview-with-scrollbar.has-canvas.no-report {
166+
grid-template-columns: 1fr;
176167
}
177168
}

0 commit comments

Comments
 (0)