Support standard desktop shortcuts for refining an active text selection: Shift+Left/Right by character, Ctrl/Option+Shift+Left/Right by word. Only active while text is selected; otherwise the keys fall through to page navigation as before. Root cause: after a selection the reader container (not the book iframe) holds focus, so Shift+Left/Right keystrokes reach the parent shortcut handler and matched the page-turn shortcuts, turning the page instead of refining the selection. The new onAdjustTextSelection action runs before the navigation actions: when a selection is active it extends the iframe selection via Selection.modify() and suppresses the page turn; an iframe-forwarded key (already extended natively) just suppresses navigation. handleSelectionchange now refreshes the popup/range for keyboard-driven changes (no pointer drag) so the selection toolbar follows the refined selection. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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('<p>Hello world from Readest</p>');
|
||||
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('<p>Hello world from Readest</p>');
|
||||
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('<p>Hello world from Readest</p>');
|
||||
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('<p>Hello world from Readest</p>');
|
||||
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('<p>Hello world from Readest</p>');
|
||||
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('<p>Hello world from Readest</p>');
|
||||
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('<p>Hello world from Readest</p>');
|
||||
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('<p>First section</p>');
|
||||
const b = renderSection('<p>Second section</p>');
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -130,6 +130,11 @@ export const useTextSelector = (
|
||||
const isTouchStarted = useRef(false);
|
||||
const selectionPosition = useRef<number | null>(null);
|
||||
const lastPointerType = useRef<string>('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<Point | null>(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`
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user