diff --git a/apps/readest-app/src/__tests__/reader/iframe-keyboard-selection.browser.test.ts b/apps/readest-app/src/__tests__/reader/iframe-keyboard-selection.browser.test.ts new file mode 100644 index 00000000..72b36b1f --- /dev/null +++ b/apps/readest-app/src/__tests__/reader/iframe-keyboard-selection.browser.test.ts @@ -0,0 +1,144 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import { extendSelectionFromContents } from '@/utils/sel'; + +// Standard desktop selection shortcuts (#4728). The parent (where focus sits +// after a selection) extends the iframe selection via this helper. Selection.modify() +// needs a real layout engine, so this runs in the Chromium browser lane. + +const iframes: HTMLIFrameElement[] = []; + +// Render an isolated document in an iframe, the way foliate renders a section. +const renderSection = (bodyHtml: string) => { + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + iframes.push(iframe); + const doc = iframe.contentDocument!; + doc.body.innerHTML = bodyHtml; + return { doc, win: iframe.contentWindow! }; +}; + +// Mimic a real left-to-right mouse drag: setBaseAndExtent establishes the +// anchor/focus directionality that Selection.modify() needs. (addRange() leaves +// the selection directionless, so backward modify() would silently no-op — a +// test artifact, not how a user-made selection behaves.) +const selectRange = (win: Window, node: Node, start: number, end: number) => { + const sel = win.getSelection()!; + sel.setBaseAndExtent(node, start, node, end); + return sel; +}; + +afterEach(() => { + while (iframes.length) iframes.pop()!.remove(); +}); + +describe('extendSelectionFromContents (#4728)', () => { + test('Shift+ArrowRight extends the selection by a character', () => { + const { doc, win } = renderSection('

Hello world from Readest

'); + const sel = selectRange(win, doc.querySelector('p')!.firstChild!, 0, 5); // "Hello" + + const handled = extendSelectionFromContents( + [{ doc }], + { key: 'ArrowRight', shiftKey: true }, + true, + ); + + expect(handled).toBe(true); + expect(sel.toString().length).toBe(6); + expect(sel.toString().startsWith('Hello')).toBe(true); + }); + + test('Ctrl+Shift+ArrowRight extends the selection by a word', () => { + const { doc, win } = renderSection('

Hello world from Readest

'); + const sel = selectRange(win, doc.querySelector('p')!.firstChild!, 0, 5); // "Hello" + + extendSelectionFromContents( + [{ doc }], + { key: 'ArrowRight', shiftKey: true, ctrlKey: true }, + true, + ); + + expect(sel.toString()).toContain('world'); + expect(sel.toString().length).toBeGreaterThan(6); + }); + + test('Alt(Option)+Shift+ArrowRight extends by a word (macOS modifier)', () => { + const { doc, win } = renderSection('

Hello world from Readest

'); + const sel = selectRange(win, doc.querySelector('p')!.firstChild!, 0, 5); + + extendSelectionFromContents( + [{ doc }], + { key: 'ArrowRight', shiftKey: true, altKey: true }, + true, + ); + + expect(sel.toString()).toContain('world'); + }); + + test('Shift+ArrowLeft moves the active edge back by a character', () => { + const { doc, win } = renderSection('

Hello world from Readest

'); + const sel = selectRange(win, doc.querySelector('p')!.firstChild!, 0, 5); // "Hello" + + extendSelectionFromContents([{ doc }], { key: 'ArrowLeft', shiftKey: true }, true); + + expect(sel.toString()).toBe('Hell'); + }); + + test('with extend=false it reports the selection but does not modify it', () => { + const { doc, win } = renderSection('

Hello world from Readest

'); + const sel = selectRange(win, doc.querySelector('p')!.firstChild!, 0, 5); + + // Mirrors the iframe-focused case: the browser already extended natively, so + // the parent only needs to confirm a selection exists (to suppress nav). + const handled = extendSelectionFromContents( + [{ doc }], + { key: 'ArrowRight', shiftKey: true }, + false, + ); + + expect(handled).toBe(true); + expect(sel.toString()).toBe('Hello'); // unchanged + }); + + test('returns false when no selection is active so navigation still works', () => { + const { doc, win } = renderSection('

Hello world from Readest

'); + win.getSelection()!.removeAllRanges(); + + const handled = extendSelectionFromContents( + [{ doc }], + { key: 'ArrowRight', shiftKey: true }, + true, + ); + + expect(handled).toBe(false); + }); + + test('returns false when Meta/Cmd is held (reserved for native line selection)', () => { + const { doc, win } = renderSection('

Hello world from Readest

'); + const sel = selectRange(win, doc.querySelector('p')!.firstChild!, 0, 5); + + const handled = extendSelectionFromContents( + [{ doc }], + { key: 'ArrowRight', shiftKey: true, metaKey: true }, + true, + ); + + expect(handled).toBe(false); + expect(sel.toString()).toBe('Hello'); + }); + + test('finds the selection across multiple rendered section documents', () => { + const a = renderSection('

First section

'); + const b = renderSection('

Second section

'); + a.win.getSelection()!.removeAllRanges(); + const sel = selectRange(b.win, b.doc.querySelector('p')!.firstChild!, 0, 6); // "Second" + + const handled = extendSelectionFromContents( + [{ doc: a.doc }, { doc: b.doc }], + { key: 'ArrowRight', shiftKey: true, ctrlKey: true }, + true, + ); + + expect(handled).toBe(true); + expect(sel.toString()).toContain('section'); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/sel-keyboard-adjustment.test.ts b/apps/readest-app/src/__tests__/utils/sel-keyboard-adjustment.test.ts new file mode 100644 index 00000000..3ff5809b --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/sel-keyboard-adjustment.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { getKeyboardSelectionAdjustment } from '@/utils/sel'; + +describe('getKeyboardSelectionAdjustment', () => { + it('maps Shift+ArrowRight to a forward character extension', () => { + expect(getKeyboardSelectionAdjustment({ key: 'ArrowRight', shiftKey: true })).toEqual({ + direction: 'right', + granularity: 'character', + }); + }); + + it('maps Shift+ArrowLeft to a backward character extension', () => { + expect(getKeyboardSelectionAdjustment({ key: 'ArrowLeft', shiftKey: true })).toEqual({ + direction: 'left', + granularity: 'character', + }); + }); + + it('treats Ctrl+Shift+Arrow as a word extension (Windows/Linux)', () => { + expect( + getKeyboardSelectionAdjustment({ key: 'ArrowRight', shiftKey: true, ctrlKey: true }), + ).toEqual({ direction: 'right', granularity: 'word' }); + expect( + getKeyboardSelectionAdjustment({ key: 'ArrowLeft', shiftKey: true, ctrlKey: true }), + ).toEqual({ direction: 'left', granularity: 'word' }); + }); + + it('treats Alt/Option+Shift+Arrow as a word extension (macOS)', () => { + expect( + getKeyboardSelectionAdjustment({ key: 'ArrowRight', shiftKey: true, altKey: true }), + ).toEqual({ direction: 'right', granularity: 'word' }); + }); + + it('returns null without the Shift modifier', () => { + expect(getKeyboardSelectionAdjustment({ key: 'ArrowRight' })).toBeNull(); + expect(getKeyboardSelectionAdjustment({ key: 'ArrowLeft', ctrlKey: true })).toBeNull(); + }); + + it('returns null when the Meta/Cmd key is held (reserved for line-boundary selection)', () => { + expect( + getKeyboardSelectionAdjustment({ key: 'ArrowRight', shiftKey: true, metaKey: true }), + ).toBeNull(); + }); + + it('ignores non-horizontal arrows and other keys', () => { + expect(getKeyboardSelectionAdjustment({ key: 'ArrowUp', shiftKey: true })).toBeNull(); + expect(getKeyboardSelectionAdjustment({ key: 'ArrowDown', shiftKey: true })).toBeNull(); + expect(getKeyboardSelectionAdjustment({ key: 'a', shiftKey: true })).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts index ad8b076d..47da5c78 100644 --- a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts +++ b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts @@ -12,6 +12,7 @@ import { setShortcutsDialogVisible } from '@/components/KeyboardShortcutsHelp'; import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants'; import { getParagraphActionForKey } from '@/utils/paragraphPresentation'; import { getScrollGapAttr } from '@/utils/webtoon'; +import { extendSelectionFromContents, KeyModifiers } from '@/utils/sel'; import { viewPagination } from './usePagination'; import useShortcuts from '@/hooks/useShortcuts'; import useBooksManager from './useBooksManager'; @@ -71,6 +72,19 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = if (sideBarBookKey) setSideBarBookKey(getNextBookKey(sideBarBookKey)); }; + // Standard desktop selection shortcuts (#4728). After a selection the reader + // container holds focus, so Shift+←/→ keystrokes land here in the parent (not + // the iframe). Extend the iframe selection ourselves; for keys forwarded from + // a focused iframe (the browser already extended natively) just report that a + // selection exists. Returning true stops the page-turn shortcut from firing. + const adjustTextSelection = (event?: KeyboardEvent | MessageEvent) => { + const isNative = event instanceof KeyboardEvent; + const src: KeyModifiers | undefined = isNative ? event : event?.data; + if (!src?.key) return false; + const contents = getView(sideBarBookKey)?.renderer?.getContents?.() ?? []; + return extendSelectionFromContents(contents, src, isNative); + }; + const goLeft = () => { const viewSettings = getViewSettings(sideBarBookKey ?? ''); // If paragraph mode is enabled, navigate to previous paragraph instead @@ -357,6 +371,9 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = useShortcuts( { + // Listed first so an active selection intercepts Shift+←/→ before the + // page-navigation actions below can turn the page (#4728). + onAdjustTextSelection: adjustTextSelection, onSwitchSideBar: switchSideBar, onToggleSideBar: toggleSideBar, onToggleNotebook: toggleNotebook, diff --git a/apps/readest-app/src/app/reader/hooks/useTextSelector.ts b/apps/readest-app/src/app/reader/hooks/useTextSelector.ts index b2b4290e..ede8ee46 100644 --- a/apps/readest-app/src/app/reader/hooks/useTextSelector.ts +++ b/apps/readest-app/src/app/reader/hooks/useTextSelector.ts @@ -130,6 +130,11 @@ export const useTextSelector = ( const isTouchStarted = useRef(false); const selectionPosition = useRef(null); const lastPointerType = useRef('mouse'); + // Whether a pointer drag (mouse/touch selection) is currently in progress. + // Desktop selections defer to pointerup, but a keyboard selection adjustment + // (#4728) has no pointer drag — handleSelectionchange uses this to refresh the + // popup/range for keyboard-driven changes while still deferring mid-drag. + const isPointerDown = useRef(false); const isInstantAnnotating = useRef(false); const isInstantAnnotated = useRef(false); const annotationStartPoint = useRef(null); @@ -227,6 +232,7 @@ export const useTextSelector = ( const handlePointerDown = (doc: Document, index: number, ev: PointerEvent) => { lastPointerType.current = ev.pointerType; + isPointerDown.current = true; if (isInstantAnnotationEnabled()) { const handled = handleInstantAnnotationPointerDown(doc, index, ev); @@ -305,6 +311,7 @@ export const useTextSelector = ( }; const handlePointerCancel = (_doc: Document, _index: number, ev: PointerEvent) => { + isPointerDown.current = false; // NB: don't cancel the auto-turn here — on Android pointercancel fires mid // edge-drag (browser takes over for scrolling), which is exactly when the // user is dragging into the corner. Cancel only on a real release. @@ -396,6 +403,7 @@ export const useTextSelector = ( }; const handlePointerUp = async (doc: Document, index: number, ev?: PointerEvent) => { + isPointerDown.current = false; if (isInstantAnnotating.current && ev) { stopInstantAnnotating(ev); const handled = await handleInstantAnnotationPointerUp(doc, index, ev); @@ -553,10 +561,11 @@ export const useTextSelector = ( cancelAutoTurn(); } - if (!isAndroid && !isTouchInput) return; + // Desktop mouse selections defer to pointerup, but a keyboard selection + // adjustment (#4728) has no pointerup — process it as long as a pointer drag + // isn't in progress (mid-drag still defers to pointerup). + if (!isAndroid && !isTouchInput && isPointerDown.current) return; if (isValidSelection(sel)) { - // On desktop with mouse, defer to pointerup for valid selections. - if (!isAndroid && !isTouchInput) return; if (selectionPosition.current === null) { // Save the absolute container scroll, not `renderer.start` — the // latter is section-relative, so restoring it as `containerPosition` diff --git a/apps/readest-app/src/helpers/shortcuts.ts b/apps/readest-app/src/helpers/shortcuts.ts index 17c0c9c1..bce57498 100644 --- a/apps/readest-app/src/helpers/shortcuts.ts +++ b/apps/readest-app/src/helpers/shortcuts.ts @@ -140,6 +140,21 @@ const DEFAULT_SHORTCUTS = { description: _('Proofread Selection'), section: 'Selection', }, + onAdjustTextSelection: { + // Standard desktop shortcuts for refining an active selection (#4728): + // Shift+←/→ by character, Ctrl/Alt(Option)+Shift+←/→ by word. Only act while + // text is selected; otherwise these keys fall through to page navigation. + keys: [ + 'shift+ArrowLeft', + 'shift+ArrowRight', + 'ctrl+shift+ArrowLeft', + 'ctrl+shift+ArrowRight', + 'opt+shift+ArrowLeft', + 'opt+shift+ArrowRight', + ], + description: _('Adjust Text Selection'), + section: 'Selection', + }, onOpenFontLayoutSettings: { keys: ['shift+f', 'ctrl+,', 'cmd+,'], description: _('Open Settings'), diff --git a/apps/readest-app/src/utils/sel.ts b/apps/readest-app/src/utils/sel.ts index 82d99e0f..df166b7a 100644 --- a/apps/readest-app/src/utils/sel.ts +++ b/apps/readest-app/src/utils/sel.ts @@ -375,6 +375,59 @@ export const getPopupPosition = ( return { point: popupPoint, dir: position.dir } as Position; }; +// Standard desktop text-selection shortcuts (#4728): extend the active +// selection with the keyboard. Pure key→intent mapping so it stays testable. +// +// - Shift+←/→ → extend by character +// - Ctrl/Alt+Shift+←/→ → extend by word (Ctrl on Windows/Linux, Option on macOS) +// +// `direction` is visual ('left'/'right') so it matches the arrow key pressed and +// reads correctly in RTL text. Meta/Cmd is left alone so the browser's native +// line-boundary selection (Cmd+Shift+←/→ on macOS) still works. +export interface KeyModifiers { + key: string; + shiftKey?: boolean; + ctrlKey?: boolean; + altKey?: boolean; + metaKey?: boolean; +} + +export interface SelectionAdjustment { + direction: 'left' | 'right'; + granularity: 'character' | 'word'; +} + +export const getKeyboardSelectionAdjustment = (ev: KeyModifiers): SelectionAdjustment | null => { + if (!ev.shiftKey || ev.metaKey) return null; + const direction = ev.key === 'ArrowLeft' ? 'left' : ev.key === 'ArrowRight' ? 'right' : null; + if (!direction) return null; + const granularity = ev.ctrlKey || ev.altKey ? 'word' : 'character'; + return { direction, granularity }; +}; + +// Locate the active (non-collapsed) selection across the rendered section +// documents (foliate's `renderer.getContents()`) and, in `extend` mode, grow or +// shrink it per the keyboard adjustment (#4728). Returns whether a selection was +// found, so the caller can suppress page-turn navigation. With `extend` false it +// only reports presence — used when the iframe itself held focus and the browser +// already extended the selection natively. +export const extendSelectionFromContents = ( + contents: { doc: Document }[], + ev: KeyModifiers, + extend: boolean, +): boolean => { + const adjustment = getKeyboardSelectionAdjustment(ev); + if (!adjustment) return false; + for (const { doc } of contents) { + const sel = doc.defaultView?.getSelection(); + if (sel && !sel.isCollapsed) { + if (extend) sel.modify('extend', adjustment.direction, adjustment.granularity); + return true; + } + } + return false; +}; + export const snapRangeToWords = (range: Range): void => { if (typeof Intl === 'undefined' || !Intl.Segmenter) return;