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