Skip to content

Commit b9667e1

Browse files
authored
fix(editor): scroll keys work on load without clicking the document (#1647) (#1648)
* fix(editor): route scroll keys to the document when the browser has no target (#1647) On load nothing has focus and <main>, not the window, scrolls, so Down, PageDown and Space did nothing until the reader clicked the document. useScrollKeyRouting scrolls the document viewport for unmodified scroll keys only when focus is on body, no earlier listener handled the key, no dialog/popover/menu/listbox is open, and the last click left the browser without a scrollable target. It never moves focus. Disabled on HTML and live-app surfaces and while vim mode owns the keyboard. * fix(ui): match the native page step for routed PageDown/Space (#1647) Chromium, WebKit and Firefox all page by max(87.5% of the viewport, viewport - 40px). Using the fraction alone made the first routed PageDown on load shorter than every native one after a click (746px vs 812px at 900px tall). * test(ui): run the scrollKeyAction mapping tests without a DOM The pure mapping block runs in the non-DOM test job too, where KeyboardEvent is undefined, so both tests failed in CI. scrollKeyAction now takes the keydown fields it reads and the tests pass plain objects.
1 parent 16036b4 commit b9667e1

5 files changed

Lines changed: 362 additions & 1 deletion

File tree

‎.github/workflows/test.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ jobs:
257257
packages/ui/hooks/useViewportEnvironment.test.tsx
258258
packages/ui/hooks/useIsMobile.test.tsx
259259
packages/ui/hooks/useVimDocumentFocus.test.tsx
260+
packages/ui/hooks/useScrollKeyRouting.test.tsx
260261
packages/ui/hooks/useScrollViewport.test.ts
261262
packages/review-editor/components/ExpandedCommentDialog.mobile.test.tsx
262263
packages/review-editor/components/DiffTypePicker.openState.test.tsx

‎packages/editor/App.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ import {
8989
getDocumentScrollViewport,
9090
ScrollViewportProvider,
9191
} from '@plannotator/ui/hooks/useScrollViewport';
92+
import { useScrollKeyRouting } from '@plannotator/ui/hooks/useScrollKeyRouting';
9293
import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport';
9394
import { useCompactTouchLayout, useIsMobile } from '@plannotator/ui/hooks/useIsMobile';
9495
import { useViewportEnvironment } from '@plannotator/ui/hooks/useViewportEnvironment';
@@ -927,6 +928,16 @@ const App: React.FC = () => {
927928
: mainViewportRef.current);
928929
}, [handleViewportReady, usesDocumentScroll]);
929930

931+
// #1647: Down/PageDown/Space do nothing on load because <main> scrolls, not
932+
// the window, and nothing has focus yet. Route those keys to the document
933+
// only while the browser has no scroll target of its own. HTML/live-app
934+
// surfaces are excluded (the framed page owns its keys), and vim owns
935+
// keyboard focus when enabled.
936+
useScrollKeyRouting({
937+
viewport: scrollViewport,
938+
enabled: !isHtmlSurface && !liveApp && !vimModeEnabled,
939+
});
940+
930941
usePrintMode();
931942

932943
// Sidebar (shared TOC + Version Browser)
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/**
2+
* #1647: scroll keys do nothing on load because the document scrolls inside
3+
* <main>, not the window. These tests guard the routing that fills that gap
4+
* and, just as much, every owner it must stay out of the way of.
5+
*
6+
* DOM-gated: run with DOM_TESTS=1 (listed in .github/workflows/test.yml).
7+
*/
8+
import { afterEach, describe, expect, test } from 'bun:test';
9+
import React, { act } from 'react';
10+
import { createRoot, type Root } from 'react-dom/client';
11+
import { scrollKeyAction, useScrollKeyRouting, type ScrollKeyInput } from './useScrollKeyRouting';
12+
13+
const hasDom = typeof document !== 'undefined';
14+
let root: Root | null = null;
15+
let host: HTMLElement | null = null;
16+
17+
interface ScrollCall { readonly top?: number }
18+
19+
/** A viewport that reports overflow and records the scrolls asked of it. */
20+
function makeViewport(): { el: HTMLElement; calls: ScrollCall[] } {
21+
const el = document.createElement('main');
22+
el.style.overflowY = 'auto';
23+
Object.defineProperty(el, 'scrollHeight', { configurable: true, value: 5000 });
24+
Object.defineProperty(el, 'clientHeight', { configurable: true, value: 800 });
25+
const calls: ScrollCall[] = [];
26+
el.scrollBy = ((options: ScrollToOptions) => { calls.push({ top: options.top }); }) as HTMLElement['scrollBy'];
27+
el.scrollTo = ((options: ScrollToOptions) => { calls.push({ top: options.top }); }) as HTMLElement['scrollTo'];
28+
const paragraph = document.createElement('p');
29+
paragraph.textContent = 'Document text';
30+
el.appendChild(paragraph);
31+
document.body.appendChild(el);
32+
return { el, calls };
33+
}
34+
35+
function Harness({ viewport, enabled }: { viewport: HTMLElement; enabled: boolean }) {
36+
useScrollKeyRouting({ viewport, enabled });
37+
return null;
38+
}
39+
40+
async function mount(viewport: HTMLElement, enabled = true): Promise<void> {
41+
host = document.createElement('div');
42+
document.body.appendChild(host);
43+
root = createRoot(host);
44+
await act(async () => root?.render(<Harness viewport={viewport} enabled={enabled} />));
45+
}
46+
47+
function key(target: EventTarget, init: KeyboardEventInit): KeyboardEvent {
48+
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
49+
target.dispatchEvent(event);
50+
return event;
51+
}
52+
53+
function pointerDown(target: EventTarget): void {
54+
target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }));
55+
}
56+
57+
// Plain objects, not `new KeyboardEvent`: this block runs in the non-DOM job too.
58+
const k = (init: Partial<ScrollKeyInput> & { key: string }) => scrollKeyAction({
59+
isComposing: false, ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, ...init,
60+
});
61+
62+
describe('scrollKeyAction', () => {
63+
test('maps the plain scroll keys and Shift+Space', () => {
64+
expect(k({ key: 'ArrowDown' })).toEqual({ kind: 'line', direction: 1 });
65+
expect(k({ key: 'ArrowUp' })).toEqual({ kind: 'line', direction: -1 });
66+
expect(k({ key: 'PageDown' })).toEqual({ kind: 'page', direction: 1 });
67+
expect(k({ key: 'PageUp' })).toEqual({ kind: 'page', direction: -1 });
68+
expect(k({ key: ' ' })).toEqual({ kind: 'page', direction: 1 });
69+
expect(k({ key: ' ', shiftKey: true })).toEqual({ kind: 'page', direction: -1 });
70+
expect(k({ key: 'Home' })).toEqual({ kind: 'edge', edge: 'start' });
71+
expect(k({ key: 'End' })).toEqual({ kind: 'edge', edge: 'end' });
72+
});
73+
74+
test('leaves chords, selection-extending keys and IME composition alone', () => {
75+
expect(k({ key: 'ArrowDown', metaKey: true })).toBeNull();
76+
expect(k({ key: 'ArrowDown', ctrlKey: true })).toBeNull();
77+
expect(k({ key: 'ArrowDown', altKey: true })).toBeNull();
78+
expect(k({ key: 'ArrowDown', shiftKey: true })).toBeNull();
79+
expect(k({ key: 'End', shiftKey: true })).toBeNull();
80+
expect(k({ key: ' ', isComposing: true })).toBeNull();
81+
expect(k({ key: 'Enter' })).toBeNull();
82+
expect(k({ key: 'j' })).toBeNull();
83+
});
84+
});
85+
86+
describe('useScrollKeyRouting', () => {
87+
afterEach(async () => {
88+
const mounted = root;
89+
if (mounted) await act(async () => mounted.unmount());
90+
root = null;
91+
host = null;
92+
if (hasDom) document.body.replaceChildren();
93+
});
94+
95+
test.skipIf(!hasDom)('scrolls the document viewport when nothing owns the key', async () => {
96+
const { el, calls } = makeViewport();
97+
await mount(el);
98+
99+
const pageDown = key(document.body, { key: 'PageDown' });
100+
expect(pageDown.defaultPrevented).toBe(true);
101+
key(document.body, { key: 'ArrowDown' });
102+
key(document.body, { key: ' ', shiftKey: true });
103+
104+
expect(calls.map(c => Math.sign(c.top ?? 0))).toEqual([1, 1, -1]);
105+
expect(calls[0].top).toBeGreaterThan(calls[1].top ?? 0);
106+
});
107+
108+
test.skipIf(!hasDom)('never takes a key from a focused control, input or editor', async () => {
109+
const { el, calls } = makeViewport();
110+
await mount(el);
111+
112+
const input = document.createElement('textarea');
113+
document.body.appendChild(input);
114+
input.focus();
115+
const space = key(input, { key: ' ' });
116+
expect(space.defaultPrevented).toBe(false);
117+
118+
const button = document.createElement('button');
119+
document.body.appendChild(button);
120+
button.focus();
121+
expect(key(button, { key: ' ' }).defaultPrevented).toBe(false);
122+
123+
expect(calls).toEqual([]);
124+
});
125+
126+
test.skipIf(!hasDom)('yields to a listener that already handled the key', async () => {
127+
const { el, calls } = makeViewport();
128+
await mount(el);
129+
const consume = (event: KeyboardEvent) => event.preventDefault();
130+
window.addEventListener('keydown', consume, true);
131+
try {
132+
key(document.body, { key: 'ArrowDown' });
133+
} finally {
134+
window.removeEventListener('keydown', consume, true);
135+
}
136+
expect(calls).toEqual([]);
137+
});
138+
139+
test.skipIf(!hasDom)('does not scroll the document behind a dialog, menu or listbox', async () => {
140+
const { el, calls } = makeViewport();
141+
await mount(el);
142+
143+
for (const role of ['dialog', 'menu', 'listbox']) {
144+
const overlay = document.createElement('div');
145+
overlay.setAttribute('role', role);
146+
document.body.appendChild(overlay);
147+
expect(key(document.body, { key: 'PageDown' }).defaultPrevented).toBe(false);
148+
overlay.remove();
149+
}
150+
const legacyModal = document.createElement('div');
151+
legacyModal.className = 'fixed inset-0';
152+
document.body.appendChild(legacyModal);
153+
key(document.body, { key: ' ' });
154+
legacyModal.remove();
155+
156+
expect(calls).toEqual([]);
157+
});
158+
159+
test.skipIf(!hasDom)('leaves the key to the browser once a click gave it a scroll target', async () => {
160+
const { el, calls } = makeViewport();
161+
await mount(el);
162+
163+
// A click in the document: the browser now scrolls <main> natively, so
164+
// routing too would move it twice.
165+
pointerDown(el.querySelector('p')!);
166+
expect(key(document.body, { key: 'PageDown' }).defaultPrevented).toBe(false);
167+
expect(calls).toEqual([]);
168+
169+
// A click on chrome with nothing scrollable above it (the header).
170+
const header = document.createElement('header');
171+
document.body.appendChild(header);
172+
pointerDown(header);
173+
key(document.body, { key: 'PageDown' });
174+
expect(calls).toHaveLength(1);
175+
176+
// A click on a node that then goes away (a dismissed first-run dialog).
177+
const dialogButton = document.createElement('button');
178+
el.appendChild(dialogButton);
179+
pointerDown(dialogButton);
180+
dialogButton.remove();
181+
key(document.body, { key: 'PageDown' });
182+
expect(calls).toHaveLength(2);
183+
});
184+
185+
test.skipIf(!hasDom)('does nothing while disabled', async () => {
186+
const { el, calls } = makeViewport();
187+
await mount(el, false);
188+
expect(key(document.body, { key: 'PageDown' }).defaultPrevented).toBe(false);
189+
expect(calls).toEqual([]);
190+
});
191+
});
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { useEffect } from 'react';
2+
import { hasBlockingOverlay } from './useVimDocumentFocus';
3+
import { isDocumentScrollViewport } from './useScrollViewport';
4+
5+
/**
6+
* Scroll keys with no browser target (#1647).
7+
*
8+
* The desktop editor scrolls inside an element (`<main>`), not the window. On
9+
* load nothing is focused and the user has not clicked anywhere, so the
10+
* browser's keyboard-scroll starting point is the root scroller, which cannot
11+
* scroll: ArrowDown, PageDown and Space do nothing until the reader clicks into
12+
* the document. The same dead state returns after the node the user last
13+
* clicked is removed (a dismissed first-run dialog) or when that click landed
14+
* on chrome with nothing scrollable above it (the header).
15+
*
16+
* This hook fills exactly that gap and nothing else. It never moves focus, so
17+
* dialogs, popovers, screen readers and `:focus-visible` are untouched. It
18+
* only acts when all of these hold:
19+
* - focus is neutral (body), so no control, input, editor or iframe owns
20+
* the key;
21+
* - no earlier listener consumed the key (`defaultPrevented`);
22+
* - no modifier except Shift+Space (page up), and not mid-composition;
23+
* - no dialog, modal overlay, popover, menu or listbox is open;
24+
* - the browser has no scroll target of its own: the last pointerdown is
25+
* absent, detached, or has no vertically scrollable ancestor. When it has
26+
* one (a click in the document, the annotations panel, the sidebar), the
27+
* native key scroll already works and is left alone.
28+
*/
29+
30+
const LINE_STEP_PX = 40;
31+
/**
32+
* Page step as Chromium and WebKit compute it: the larger of 87.5% of the
33+
* viewport (kMinFractionToStepWhenPaging) and the viewport minus a 40px
34+
* overlap (kMaxOverlapBetweenPages). On a normal window the overlap rule wins,
35+
* so a routed PageDown matches the native one taken after a click.
36+
*/
37+
const PAGE_FRACTION = 0.875;
38+
const PAGE_OVERLAP_PX = 40;
39+
40+
const OPEN_POPUP_SELECTOR = '[role="menu"], [role="listbox"]';
41+
42+
export type ScrollKeyAction =
43+
| { readonly kind: 'line'; readonly direction: 1 | -1 }
44+
| { readonly kind: 'page'; readonly direction: 1 | -1 }
45+
| { readonly kind: 'edge'; readonly edge: 'start' | 'end' };
46+
47+
/** The keydown fields the mapping reads (pure, so it runs without a DOM). */
48+
export type ScrollKeyInput = Pick<
49+
KeyboardEvent,
50+
'key' | 'isComposing' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'
51+
>;
52+
53+
/** Map a keydown to the scroll it asks for, or null when it is not a plain scroll key. */
54+
export function scrollKeyAction(event: ScrollKeyInput): ScrollKeyAction | null {
55+
if (event.isComposing || event.ctrlKey || event.metaKey || event.altKey) return null;
56+
const isSpace = event.key === ' ' || event.key === 'Spacebar';
57+
if (isSpace) return { kind: 'page', direction: event.shiftKey ? -1 : 1 };
58+
if (event.shiftKey) return null;
59+
switch (event.key) {
60+
case 'ArrowDown': return { kind: 'line', direction: 1 };
61+
case 'ArrowUp': return { kind: 'line', direction: -1 };
62+
case 'PageDown': return { kind: 'page', direction: 1 };
63+
case 'PageUp': return { kind: 'page', direction: -1 };
64+
case 'Home': return { kind: 'edge', edge: 'start' };
65+
case 'End': return { kind: 'edge', edge: 'end' };
66+
default: return null;
67+
}
68+
}
69+
70+
function canScrollVertically(element: Element): boolean {
71+
if (!(element instanceof HTMLElement)) return false;
72+
if (element.scrollHeight <= element.clientHeight) return false;
73+
const overflowY = getComputedStyle(element).overflowY;
74+
return overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay';
75+
}
76+
77+
/**
78+
* Whether the browser already has a keyboard-scroll target: the last clicked
79+
* node is still in the page and sits inside a vertically scrollable element.
80+
*/
81+
export function browserHasScrollTarget(lastPointerTarget: Node | null): boolean {
82+
if (!lastPointerTarget || !lastPointerTarget.isConnected) return false;
83+
const root = lastPointerTarget.ownerDocument?.documentElement ?? null;
84+
let node: Node | null = lastPointerTarget;
85+
while (node && node !== root) {
86+
if (node instanceof Element && canScrollVertically(node)) return true;
87+
const parent: Node | null = node.parentNode;
88+
node = parent instanceof ShadowRoot ? parent.host : parent;
89+
}
90+
return false;
91+
}
92+
93+
function focusIsNeutral(event: KeyboardEvent): boolean {
94+
const doc = document;
95+
const active = doc.activeElement;
96+
if (active !== null && active !== doc.body && active !== doc.documentElement) return false;
97+
const origin = event.composedPath?.()[0] ?? event.target;
98+
return origin === doc.body || origin === doc.documentElement || origin === doc || origin === window;
99+
}
100+
101+
function prefersReducedMotion(): boolean {
102+
return typeof window.matchMedia === 'function'
103+
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
104+
}
105+
106+
function applyScroll(viewport: HTMLElement, action: ScrollKeyAction): void {
107+
if (action.kind === 'line') {
108+
viewport.scrollBy({ top: action.direction * LINE_STEP_PX, behavior: 'auto' });
109+
return;
110+
}
111+
const behavior: ScrollBehavior = prefersReducedMotion() ? 'auto' : 'smooth';
112+
if (action.kind === 'edge') {
113+
viewport.scrollTo({ top: action.edge === 'start' ? 0 : viewport.scrollHeight, behavior });
114+
return;
115+
}
116+
const height = viewport.clientHeight;
117+
const page = Math.max(1, Math.round(Math.max(height * PAGE_FRACTION, height - PAGE_OVERLAP_PX)));
118+
viewport.scrollBy({ top: action.direction * page, behavior });
119+
}
120+
121+
export interface UseScrollKeyRoutingOptions {
122+
/** The element that scrolls the document. Page-scrolling viewports are ignored. */
123+
readonly viewport: HTMLElement | null;
124+
/** False where another owner has the keys (vim, iframe surfaces). */
125+
readonly enabled: boolean;
126+
}
127+
128+
/** Route unowned scroll keys to the document viewport. See the module comment. */
129+
export function useScrollKeyRouting({ viewport, enabled }: UseScrollKeyRoutingOptions): void {
130+
useEffect(() => {
131+
if (!enabled || !viewport || isDocumentScrollViewport(viewport)) return;
132+
133+
let lastPointerTarget: Node | null = null;
134+
const handlePointerDown = (event: PointerEvent) => {
135+
const origin = event.composedPath?.()[0] ?? event.target;
136+
lastPointerTarget = origin instanceof Node ? origin : null;
137+
};
138+
139+
const handleKeyDown = (event: KeyboardEvent) => {
140+
if (event.defaultPrevented) return;
141+
const action = scrollKeyAction(event);
142+
if (!action) return;
143+
if (!viewport.isConnected || !focusIsNeutral(event)) return;
144+
if (hasBlockingOverlay() || document.querySelector(OPEN_POPUP_SELECTOR)) return;
145+
if (browserHasScrollTarget(lastPointerTarget)) return;
146+
event.preventDefault();
147+
applyScroll(viewport, action);
148+
};
149+
150+
window.addEventListener('pointerdown', handlePointerDown, true);
151+
window.addEventListener('keydown', handleKeyDown);
152+
return () => {
153+
window.removeEventListener('pointerdown', handlePointerDown, true);
154+
window.removeEventListener('keydown', handleKeyDown);
155+
};
156+
}, [enabled, viewport]);
157+
}

‎packages/ui/hooks/useVimDocumentFocus.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ function pageFocusIsNeutral(): boolean {
3838
return document.activeElement === document.body || document.activeElement === null;
3939
}
4040

41-
function hasBlockingOverlay(): boolean {
41+
/** True while a dialog, modal overlay, or dismissable popover owns the page. */
42+
export function hasBlockingOverlay(): boolean {
4243
return document.querySelector(BLOCKING_OVERLAY_SELECTOR) !== null;
4344
}
4445

0 commit comments

Comments
 (0)