diff --git a/src/layout/GestureIndicator.css b/src/layout/GestureIndicator.css index ef4f077a..851f534c 100644 --- a/src/layout/GestureIndicator.css +++ b/src/layout/GestureIndicator.css @@ -18,8 +18,7 @@ transform: translateX(-50%); background: var(--background-secondary, #2d2d2d); border: 1px solid var(--background-modifier-border, #3d3d3d); - /* Square — matches the rectilinear chrome (per the file header - * comment, which previously promised this but set 6px). */ + /* Square corners match the rectilinear chrome. */ border-radius: 0; opacity: calc(0.5 + 0.5 * var(--gesture-progress, 0)); pointer-events: none; @@ -39,6 +38,17 @@ bottom: 36px; /* Above the status bar (~24px) + gap */ } +@media (max-width: 767px) { + .gesture-indicator[data-edge="bottom"] { + top: calc( + var(--reams-visual-viewport-offset-top, 0px) + + var(--reams-visual-viewport-height, 100dvh) + - 70px + ); + bottom: auto; + } +} + /* Chevron arrow — rotates to point in pull direction */ .gesture-indicator-chevron { width: 14px; diff --git a/src/layout/__tests__/GestureIndicator-css.test.ts b/src/layout/__tests__/GestureIndicator-css.test.ts new file mode 100644 index 00000000..ea1f71dc --- /dev/null +++ b/src/layout/__tests__/GestureIndicator-css.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import * as fs from 'fs' +import * as path from 'path' + +describe('GestureIndicator.css', () => { + it('positions the mobile bottom indicator against the visual viewport', () => { + const css = fs.readFileSync(path.resolve(__dirname, '../GestureIndicator.css'), 'utf-8') + + expect(css).toMatch(/@media\s*\(\s*max-width:\s*767px\s*\)/) + expect(css).toContain('.gesture-indicator[data-edge="bottom"]') + expect(css).toContain('var(--reams-visual-viewport-offset-top') + expect(css).toContain('var(--reams-visual-viewport-height') + expect(css).toMatch(/bottom:\s*auto/) + }) +}) diff --git a/src/layout/hooks/__tests__/useSwipeGesture.test.ts b/src/layout/hooks/__tests__/useSwipeGesture.test.ts index 0a411e5a..690eb627 100644 --- a/src/layout/hooks/__tests__/useSwipeGesture.test.ts +++ b/src/layout/hooks/__tests__/useSwipeGesture.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useSwipeGesture, resetCloseCooldown } from '../useSwipeGesture' +import { loadGestureSettings } from '../../gesture-settings' vi.mock('../../gesture-settings', () => ({ loadGestureSettings: vi.fn(() => ({ @@ -24,6 +25,15 @@ describe('useSwipeGesture', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(loadGestureSettings).mockReturnValue({ + enabled: true, + leftEdge: true, + rightEdge: true, + topEdge: true, + bottomEdge: true, + topCommand: 'test:top-cmd', + bottomCommand: 'test:bottom-cmd', + }) resetCloseCooldown() container = document.createElement('div') document.body.appendChild(container) @@ -74,8 +84,75 @@ describe('useSwipeGesture', () => { return { clientX: x, clientY: y, identifier: 0, target: container } as unknown as Touch } + function makeTouchFor(target: EventTarget, x: number, y: number): Touch { + return { clientX: x, clientY: y, identifier: 0, target } as unknown as Touch + } + + function makeTouchEvent(target: EventTarget, type: string, x: number, y: number, touches = true) { + return new TouchEvent(type, { + bubbles: true, + cancelable: true, + touches: touches ? [makeTouchFor(target, x, y)] : [], + changedTouches: [makeTouchFor(target, x, y)], + }) + } + + function createScrollableContent(scrollTop: number) { + const scroller = document.createElement('div') + scroller.style.overflowY = 'auto' + const content = document.createElement('div') + scroller.appendChild(content) + container.appendChild(scroller) + Object.defineProperties(scroller, { + scrollTop: { value: scrollTop, configurable: true }, + clientHeight: { value: 400, configurable: true }, + scrollHeight: { value: 1200, configurable: true }, + }) + return { scroller, content } + } + + function simulateVerticalSwipeFromTarget(target: HTMLElement, startX: number, startY: number, endX: number, endY: number) { + const midX = (startX + endX) / 2 + const midY = (startY + endY) / 2 + act(() => { + target.dispatchEvent(new TouchEvent('touchstart', { + bubbles: true, + touches: [makeTouchFor(target, startX, startY)], + changedTouches: [makeTouchFor(target, startX, startY)], + })) + }) + act(() => { + target.dispatchEvent(new TouchEvent('touchmove', { + bubbles: true, + touches: [makeTouchFor(target, midX, midY)], + changedTouches: [makeTouchFor(target, midX, midY)], + })) + }) + act(() => { + target.dispatchEvent(new TouchEvent('touchmove', { + bubbles: true, + touches: [makeTouchFor(target, midX, midY + (endY > startY ? 5 : -5))], + changedTouches: [makeTouchFor(target, midX, midY + (endY > startY ? 5 : -5))], + })) + }) + act(() => { + target.dispatchEvent(new TouchEvent('touchmove', { + bubbles: true, + touches: [makeTouchFor(target, endX, endY)], + changedTouches: [makeTouchFor(target, endX, endY)], + })) + }) + act(() => { + target.dispatchEvent(new TouchEvent('touchend', { + bubbles: true, + touches: [], + changedTouches: [makeTouchFor(target, endX, endY)], + })) + }) + } + /** Simulate a vertical swipe using touch events. - * Vertical gestures are driven by passive touch events. + * Vertical gestures observe normal scroll but can cancel owned bottom-chrome pulls. * Sends 3 touchmoves: direction lock → boundary detection → progress. */ function simulateVerticalSwipe(startX: number, startY: number, endX: number, endY: number) { const midX = (startX + endX) / 2 @@ -160,6 +237,197 @@ describe('useSwipeGesture', () => { expect(mockOnBottomSwipe).toHaveBeenCalledTimes(1) }) + it('claims upward bottom-chrome pulls so the browser cannot pan the viewport', () => { + const toolbar = document.createElement('div') + toolbar.className = 'formatting-toolbar' + container.appendChild(toolbar) + renderSwipe() + + act(() => { + toolbar.dispatchEvent(makeTouchEvent(toolbar, 'touchstart', 200, 650)) + }) + const ownershipMove = makeTouchEvent(toolbar, 'touchmove', 200, 610) + act(() => { + toolbar.dispatchEvent(ownershipMove) + }) + const ownedMove = makeTouchEvent(toolbar, 'touchmove', 200, 570) + act(() => { + toolbar.dispatchEvent(ownedMove) + }) + + expect(ownershipMove.defaultPrevented).toBe(true) + expect(ownedMove.defaultPrevented).toBe(true) + expect(mockOnProgressChange).toHaveBeenCalledWith( + expect.objectContaining({ active: true, edge: 'bottom' }) + ) + }) + + it('claims bottom-chrome pulls that start on text nodes', () => { + const statusBar = document.createElement('div') + statusBar.className = 'status-bar' + const text = document.createTextNode('status') + statusBar.appendChild(text) + container.appendChild(statusBar) + renderSwipe() + + act(() => { + text.dispatchEvent(makeTouchEvent(text, 'touchstart', 200, 650)) + }) + const ownershipMove = makeTouchEvent(text, 'touchmove', 200, 610) + act(() => { + text.dispatchEvent(ownershipMove) + }) + + expect(ownershipMove.defaultPrevented).toBe(true) + }) + + it('does not claim upward pulls from normal editor content', () => { + const { content } = createScrollableContent(800) + renderSwipe() + + act(() => { + content.dispatchEvent(makeTouchEvent(content, 'touchstart', 200, 650)) + }) + const ownershipMove = makeTouchEvent(content, 'touchmove', 200, 610) + act(() => { + content.dispatchEvent(ownershipMove) + }) + const progressMove = makeTouchEvent(content, 'touchmove', 200, 570) + act(() => { + content.dispatchEvent(progressMove) + }) + + expect(ownershipMove.defaultPrevented).toBe(false) + expect(progressMove.defaultPrevented).toBe(false) + expect(mockOnProgressChange).toHaveBeenCalledWith( + expect.objectContaining({ active: true, edge: 'bottom' }) + ) + }) + + it('does not claim downward drags from bottom chrome', () => { + const toolbar = document.createElement('div') + toolbar.className = 'formatting-toolbar' + container.appendChild(toolbar) + renderSwipe() + + act(() => { + toolbar.dispatchEvent(makeTouchEvent(toolbar, 'touchstart', 200, 570)) + }) + const move = makeTouchEvent(toolbar, 'touchmove', 200, 610) + act(() => { + toolbar.dispatchEvent(move) + }) + + expect(move.defaultPrevented).toBe(false) + expect(mockOnProgressChange).not.toHaveBeenCalledWith( + expect.objectContaining({ active: true, edge: 'top' }) + ) + }) + + it('does not claim horizontal drags from bottom chrome', () => { + const toolbar = document.createElement('div') + toolbar.className = 'formatting-toolbar' + container.appendChild(toolbar) + renderSwipe() + + act(() => { + toolbar.dispatchEvent(makeTouchEvent(toolbar, 'touchstart', 200, 650)) + }) + const move = makeTouchEvent(toolbar, 'touchmove', 260, 645) + act(() => { + toolbar.dispatchEvent(move) + }) + + expect(move.defaultPrevented).toBe(false) + expect(mockOnProgressChange).not.toHaveBeenCalledWith( + expect.objectContaining({ active: true }) + ) + }) + + it('does not show bottom-chrome progress when the bottom edge is disabled', () => { + vi.mocked(loadGestureSettings).mockReturnValue({ + enabled: true, + leftEdge: true, + rightEdge: true, + topEdge: true, + bottomEdge: false, + topCommand: 'test:top-cmd', + bottomCommand: 'test:bottom-cmd', + }) + const toolbar = document.createElement('div') + toolbar.className = 'formatting-toolbar' + container.appendChild(toolbar) + renderSwipe() + + act(() => { + toolbar.dispatchEvent(makeTouchEvent(toolbar, 'touchstart', 200, 650)) + }) + const move = makeTouchEvent(toolbar, 'touchmove', 200, 610) + act(() => { + toolbar.dispatchEvent(move) + }) + + expect(move.defaultPrevented).toBe(false) + expect(mockOnProgressChange).not.toHaveBeenCalledWith( + expect.objectContaining({ active: true, edge: 'bottom' }) + ) + }) + + it('releases bottom-chrome pan ownership on reversal past the gesture start', () => { + const toolbar = document.createElement('div') + toolbar.className = 'formatting-toolbar' + container.appendChild(toolbar) + renderSwipe() + + act(() => { + toolbar.dispatchEvent(makeTouchEvent(toolbar, 'touchstart', 200, 650)) + }) + const ownershipMove = makeTouchEvent(toolbar, 'touchmove', 200, 610) + act(() => { + toolbar.dispatchEvent(ownershipMove) + }) + const reversalMove = makeTouchEvent(toolbar, 'touchmove', 200, 660) + act(() => { + toolbar.dispatchEvent(reversalMove) + }) + + expect(ownershipMove.defaultPrevented).toBe(true) + expect(reversalMove.defaultPrevented).toBe(false) + expect(mockOnProgressChange).toHaveBeenCalledWith( + expect.objectContaining({ active: false, edge: 'bottom', progress: 0 }) + ) + }) + + it('registers touchmove as non-passive so owned bottom pulls can cancel browser pan', () => { + const addEventListenerSpy = vi.spyOn(container, 'addEventListener') + + const { unmount } = renderSwipe() + + expect(addEventListenerSpy).toHaveBeenCalledWith('touchstart', expect.any(Function), { passive: true }) + expect(addEventListenerSpy).toHaveBeenCalledWith('touchmove', expect.any(Function), { passive: false }) + expect(addEventListenerSpy).toHaveBeenCalledWith('touchend', expect.any(Function), { passive: true }) + expect(addEventListenerSpy).toHaveBeenCalledWith('touchcancel', expect.any(Function), { passive: true }) + unmount() + }) + + it('does not call onBottomSwipe from the viewport bottom while scrollable content can still scroll down', () => { + const { content } = createScrollableContent(100) + renderSwipe() + + simulateVerticalSwipeFromTarget(content, 200, 650, 205, 530) + + expect(mockOnBottomSwipe).not.toHaveBeenCalled() + }) + + it('calls onBottomSwipe from scrollable content when it is actually at the bottom', () => { + const { content } = createScrollableContent(800) + renderSwipe() + + simulateVerticalSwipeFromTarget(content, 200, 650, 205, 530) + + expect(mockOnBottomSwipe).toHaveBeenCalledTimes(1) + }) + it('reports progress during vertical swipe', () => { renderSwipe() act(() => { @@ -257,6 +525,27 @@ describe('useSwipeGesture', () => { expect(mockOnTopSwipe).not.toHaveBeenCalled() }) + it('does not trigger vertical gestures from text inside interactive elements', () => { + const btn = document.createElement('button') + btn.appendChild(document.createTextNode('Click me')) + container.appendChild(btn) + const text = btn.firstChild! + renderSwipe() + + act(() => { + text.dispatchEvent(makeTouchEvent(text, 'touchstart', 200, 300)) + }) + act(() => { + text.dispatchEvent(makeTouchEvent(text, 'touchmove', 200, 370)) + }) + act(() => { + text.dispatchEvent(makeTouchEvent(text, 'touchend', 200, 370, false)) + }) + + expect(mockOnTopSwipe).not.toHaveBeenCalled() + expect(mockOnBottomSwipe).not.toHaveBeenCalled() + }) + // --- Contenteditable suppression --- it('does not trigger horizontal gesture when mouse drags inside contenteditable', () => { @@ -290,6 +579,35 @@ describe('useSwipeGesture', () => { expect(mockOnSwipeAction).not.toHaveBeenCalled() }) + it('does not trigger mouse drag gestures from text inside interactive elements', () => { + const btn = document.createElement('button') + btn.appendChild(document.createTextNode('Click me')) + container.appendChild(btn) + const text = btn.firstChild! + renderSwipe() + + act(() => { + text.dispatchEvent(new PointerEvent('pointerdown', { + clientX: 200, clientY: 300, pointerId: 1, bubbles: true, + pointerType: 'mouse', + })) + }) + act(() => { + text.dispatchEvent(new PointerEvent('pointermove', { + clientX: 260, clientY: 305, pointerId: 1, bubbles: true, + pointerType: 'mouse', + })) + }) + act(() => { + text.dispatchEvent(new PointerEvent('pointerup', { + clientX: 260, clientY: 305, pointerId: 1, bubbles: true, + pointerType: 'mouse', + })) + }) + + expect(mockOnSwipeAction).not.toHaveBeenCalled() + }) + it('allows touch vertical gesture from contenteditable (mobile swipe)', () => { const editableDiv = document.createElement('div') editableDiv.setAttribute('contenteditable', 'true') diff --git a/src/layout/hooks/gesture-detection.ts b/src/layout/hooks/gesture-detection.ts index b0add278..e5da3741 100644 --- a/src/layout/hooks/gesture-detection.ts +++ b/src/layout/hooks/gesture-detection.ts @@ -117,6 +117,11 @@ const INTERACTIVE_SELECTOR = 'button, input, select, textarea, a, [role="button" */ const MOUSE_INTERACTIVE_SELECTOR = 'button, input, select, textarea, a, [role="button"], [role="tab"], [contenteditable="true"]' +function eventTargetElement(target: EventTarget | null): Element | null { + if (!target || !(target instanceof Node)) return null + return target instanceof Element ? target : target.parentElement +} + /** * Check if an event target is (or is inside) an interactive element. * Used to prevent gestures from interfering with header buttons, @@ -125,8 +130,7 @@ const MOUSE_INTERACTIVE_SELECTOR = 'button, input, select, textarea, a, [role="b * Does NOT include contenteditable — see `isMouseInteractiveTarget` for that. */ export function isInteractiveTarget(target: EventTarget | null): boolean { - if (!target || !(target instanceof Element)) return false - return target.closest(INTERACTIVE_SELECTOR) !== null + return eventTargetElement(target)?.closest(INTERACTIVE_SELECTOR) !== null } /** @@ -142,8 +146,7 @@ export function isInteractiveTarget(target: EventTarget | null): boolean { * @see https://github.com/reams/reams/issues/322 — touch gestures broken by contenteditable check */ export function isMouseInteractiveTarget(target: EventTarget | null): boolean { - if (!target || !(target instanceof Element)) return false - return target.closest(MOUSE_INTERACTIVE_SELECTOR) !== null + return eventTargetElement(target)?.closest(MOUSE_INTERACTIVE_SELECTOR) !== null } /** diff --git a/src/layout/hooks/useSwipeGesture.ts b/src/layout/hooks/useSwipeGesture.ts index 21d7144b..f55bff99 100644 --- a/src/layout/hooks/useSwipeGesture.ts +++ b/src/layout/hooks/useSwipeGesture.ts @@ -9,15 +9,15 @@ // There's no scroll conflict on the horizontal axis, so pointer events // flow reliably on both touch and desktop. // -// Vertical gestures are driven by passive touch events. We do NOT call -// preventDefault() — instead, we rely on CSS `overscroll-behavior-y: contain` -// on the body and `overscroll-behavior-y: none` on scroll containers to -// disable native pull-to-refresh and overscroll effects. When at a scroll -// boundary, the browser has nothing to do with the touch, so touch events -// flow freely and we read them to drive the gesture indicator. +// Vertical gestures are driven by touch events. Most vertical drags remain +// observational: we do not cancel normal editor scrolling. The exception is +// an upward pull that starts on bottom chrome (formatting toolbar/status/ribbon +// footer). Once that drag direction-locks into a bottom gesture, touchmove is +// canceled so Firefox Android cannot pan/collapse browser chrome underneath +// Reams while our bottom gesture indicator continues. // // This separation means: -// - No fighting the browser's compositor thread +// - No fighting the browser's compositor thread during normal editor scroll // - No pointercancel race conditions // - No synthetic scrolling needed (browser scroll stays active) // - Reversal mid-gesture naturally resumes browser scrolling @@ -62,17 +62,17 @@ interface SwipeGestureOptions { /** Boundary tolerance (px) for scroll position checks */ const BOUNDARY_TOLERANCE = 2 -/** - * Bottom edge zone (px from bottom of viewport). - * Swiping up from within this zone always triggers the bottom command, - * even if the document hasn't been scrolled to the end. Makes the - * gesture discoverable and usable on long documents. - */ -const BOTTOM_EDGE_ZONE = 80 - /** CSS class names of side panel containers where vertical gestures should be suppressed */ const SIDE_PANEL_CLASSES = ['app-shell-sidebar', 'app-shell-right-panel'] +/** Bottom chrome surfaces that act as a pull-up gesture affordance on mobile. */ +const BOTTOM_CHROME_SELECTOR = [ + '.formatting-toolbar', + '.status-bar', + '.ribbon-footer', + '.ribbon-toolbar-spacer', +].join(',') + /** * Check if an event target is inside a side panel (sidebar or right panel). * Vertical gestures should not trigger when interacting with side panels @@ -85,6 +85,12 @@ function isInSidePanel(target: EventTarget | null): boolean { return SIDE_PANEL_CLASSES.some(cls => el.closest(`.${cls}`) !== null) } +function isInBottomChrome(target: EventTarget | null): boolean { + if (!target || !(target instanceof Node)) return false + const el = target instanceof Element ? target : target.parentElement + return el?.closest(BOTTOM_CHROME_SELECTOR) !== null +} + /** * Check if any overlay/modal is currently open. * Vertical gestures should be suppressed when overlays are visible to prevent @@ -98,7 +104,9 @@ function isOverlayOpen(): boolean { * Unified swipe gesture hook. * * Horizontal: pointer events (capture-based, works on touch + desktop). - * Vertical: passive touch events (no preventDefault, relies on overscroll-behavior CSS). + * Vertical: touch events. Normal editor scrolling is not canceled; upward + * bottom-chrome pulls are canceled once owned so the browser viewport cannot + * pan underneath the gesture. */ export function useSwipeGesture( containerRef: RefObject, @@ -134,6 +142,7 @@ export function useSwipeGesture( let touchEdge: 'top' | 'bottom' = 'top' let touchScrollEl: HTMLElement | null = null let touchAtBoundary = false + let touchStartedInBottomChrome = false let touchInitialDySign = 0 // +1 = pulling down, -1 = pulling up // Y position where the gesture started (reset when boundary is first reached) let gestureStartY = 0 @@ -146,6 +155,7 @@ export function useSwipeGesture( touchEdge = 'top' touchScrollEl = null touchAtBoundary = false + touchStartedInBottomChrome = false touchInitialDySign = 0 gestureStartY = 0 prevTouchY = 0 @@ -153,14 +163,16 @@ export function useSwipeGesture( // ══════════════════════════════════════════════ // TOUCH HANDLERS — drive vertical gestures - // All listeners are PASSIVE (no preventDefault) + // touchmove is non-passive only so owned bottom-chrome pulls can cancel + // native browser viewport panning; normal editor scroll is not canceled. // ══════════════════════════════════════════════ function handleTouchStart(e: TouchEvent) { resetTouch() if (e.touches.length !== 1) return - if (isInteractiveTarget(e.target)) return + touchStartedInBottomChrome = isInBottomChrome(e.target) + if (!touchStartedInBottomChrome && isInteractiveTarget(e.target)) return if (isInAndroidBackZone(e.touches[0].clientX)) return // Don't start vertical gestures inside side panels — let them scroll normally if (isInSidePanel(e.target)) return @@ -222,11 +234,6 @@ export function useSwipeGesture( // catch the moment the user reaches the top/bottom while still // touching (e.g. scroll momentum settles at boundary). // - // Special case: swiping up from near the bottom of the viewport - // always triggers the bottom gesture, even mid-document. This makes - // the gesture accessible on long documents without scrolling to the end. - const nearViewportBottom = startY > (window.innerHeight - BOTTOM_EDGE_ZONE) - if (touchScrollEl) { const { scrollTop, scrollHeight, clientHeight } = touchScrollEl const atTop = scrollTop <= BOUNDARY_TOLERANCE @@ -238,8 +245,8 @@ export function useSwipeGesture( touchEdge = 'top' gestureStartY = touchY touchInitialDySign = 1 - } else if ((atBottom || nearViewportBottom) && instantDy < 0) { - // At bottom (or near viewport bottom), finger moving up → start pull-up gesture + } else if (atBottom && instantDy < 0) { + // At bottom, finger moving up → start pull-up gesture touchAtBoundary = true touchEdge = 'bottom' gestureStartY = touchY @@ -249,13 +256,24 @@ export function useSwipeGesture( return } } else { - // No scrollable ancestor (short doc, empty state) — always allow gesture. + // No scrollable ancestor (short doc, empty state) — allow gesture. // Use original startY so the full distance counts toward activation. + if (touchStartedInBottomChrome && instantDy >= 0) { + return + } touchAtBoundary = true touchEdge = instantDy > 0 ? 'top' : 'bottom' gestureStartY = startY touchInitialDySign = instantDy > 0 ? 1 : -1 } + const settings = loadGestureSettings() + if ((touchEdge === 'top' && !settings.topEdge) || (touchEdge === 'bottom' && !settings.bottomEdge)) { + resetTouch() + return + } + if (touchStartedInBottomChrome && touchEdge === 'bottom' && e.cancelable) { + e.preventDefault() + } return // First frame at boundary — don't show indicator yet } @@ -278,6 +296,10 @@ export function useSwipeGesture( return } + if (touchStartedInBottomChrome && touchEdge === 'bottom' && e.cancelable) { + e.preventDefault() + } + // Compute progress from gesture start const absDy = Math.abs(gestureDy) const progress = Math.min(absDy / MIN_VERTICAL_DISTANCE, 1) @@ -441,9 +463,11 @@ export function useSwipeGesture( } // Register listeners - // Touch: ALL passive — we never call preventDefault + // Touchmove is non-passive so bottom-chrome pull gestures can claim the + // drag once direction-locked; otherwise Firefox Android may pan the visual + // viewport/browser chrome underneath Reams while our gesture continues. el.addEventListener('touchstart', handleTouchStart, { passive: true }) - el.addEventListener('touchmove', handleTouchMove, { passive: true }) + el.addEventListener('touchmove', handleTouchMove, { passive: false }) el.addEventListener('touchend', handleTouchEnd, { passive: true }) el.addEventListener('touchcancel', handleTouchCancel, { passive: true }) // Pointer: for horizontal gestures