Skip to content

Commit c789c72

Browse files
committed
fix: preserve text selection across pages
1 parent a4f639e commit c789c72

3 files changed

Lines changed: 70 additions & 3 deletions

File tree

‎src/features/reader/ReadingTextLayer.vue‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ let layer,
3838
highlights,
3939
frameHost,
4040
layoutZoom = 1;
41-
let unbindSelection, retentionTimer;
41+
let unbindSelection, retentionTimer, selectionRelease;
4242
const originals = new WeakMap();
4343
const emphasisKeys = new WeakMap();
4444
const topicSentenceClassifier = inject('topicSentenceClassifier', null);
@@ -246,6 +246,10 @@ watch(
246246
],
247247
async () => {
248248
clearTimeout(retentionTimer);
249+
if (selectionRelease) {
250+
host.value?.ownerDocument.removeEventListener('selectionchange', selectionRelease);
251+
selectionRelease = null;
252+
}
249253
// Retain completed and in-flight text in the bounded virtual page window.
250254
// A transient hide during zoom must not restart font measurement.
251255
if (
@@ -259,6 +263,32 @@ watch(
259263
// DOM text. A page genuinely scrolled away releases its native layout.
260264
retentionTimer = setTimeout(() => {
261265
if (props.active) return;
266+
const selection = host.value?.ownerDocument.getSelection();
267+
if (selection?.rangeCount && selection.getRangeAt(0).intersectsNode(host.value)) {
268+
// Release the retained layer as soon as the selection moves away.
269+
selectionRelease = () => {
270+
const current = host.value?.ownerDocument.getSelection();
271+
if (
272+
host.value &&
273+
current?.rangeCount &&
274+
current.getRangeAt(0).intersectsNode(host.value)
275+
)
276+
return;
277+
host.value?.ownerDocument.removeEventListener('selectionchange', selectionRelease);
278+
selectionRelease = null;
279+
if (!props.active) {
280+
generation++;
281+
unbindSelection?.();
282+
unbindSelection = null;
283+
layer?.cancel();
284+
layer = null;
285+
rendered = false;
286+
host.value?.replaceChildren();
287+
}
288+
};
289+
host.value.ownerDocument.addEventListener('selectionchange', selectionRelease);
290+
return;
291+
}
262292
generation++;
263293
unbindSelection?.();
264294
unbindSelection = null;
@@ -368,6 +398,8 @@ watch(
368398
);
369399
onBeforeUnmount(() => {
370400
clearTimeout(retentionTimer);
401+
if (selectionRelease)
402+
host.value?.ownerDocument.removeEventListener('selectionchange', selectionRelease);
371403
generation++;
372404
unbindSelection?.();
373405
layer?.cancel();

‎src/features/reader/reader-viewport.mjs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@ import { visibleReaderWindow } from './reader-layout.mjs';
22

33
export function createReaderViewport({ renderState, session, motion, view, activity, actions }) {
44
function setRenderWindow(numbers) {
5+
// A native range needs its page nodes to remain mounted while scrolling.
6+
const selection = renderState.layoutElement.value?.ownerDocument?.getSelection();
7+
if (selection?.rangeCount) {
8+
const range = selection.getRangeAt(0);
9+
for (const [number, page] of renderState.pageEls)
10+
if (page.isConnected && range.intersectsNode(page) && !numbers.includes(number))
11+
numbers.push(number);
12+
numbers.sort((a, b) => a - b);
13+
}
514
const current = renderState.renderWindow.value;
615
if (current.size !== numbers.length || numbers.some((n) => !current.has(n)))
716
renderState.renderWindow.value = new Set(numbers);

‎src/features/reader/text-layer-selection.mjs‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,24 @@ export function bindTextLayerSelection(host, getSpans) {
136136
const document = host.ownerDocument;
137137
const view = document.defaultView;
138138
let gesture;
139+
let scrollFrame, pointer;
140+
const scroller = host.closest('.reader');
141+
function autoScroll() {
142+
scrollFrame = 0;
143+
if (!gesture || !pointer || !scroller) return;
144+
const bounds = scroller.getBoundingClientRect();
145+
const edge = 48;
146+
const speed =
147+
pointer.y < bounds.top + edge
148+
? -Math.min(24, (bounds.top + edge - pointer.y) / 3)
149+
: pointer.y > bounds.bottom - edge
150+
? Math.min(24, (pointer.y - bounds.bottom + edge) / 3)
151+
: 0;
152+
if (!speed) return;
153+
scroller.scrollTop += speed;
154+
extend({ clientX: pointer.x, clientY: pointer.y, preventDefault() {} }, false);
155+
scrollFrame = view.requestAnimationFrame(autoScroll);
156+
}
139157
// A caret in PDF whitespace must follow the nearest visual run rather than
140158
// jump to a later DOM node in another column.
141159
function caret(x, y) {
@@ -196,16 +214,23 @@ export function bindTextLayerSelection(host, getSpans) {
196214
.getSelection()
197215
?.setBaseAndExtent(anchor.node, anchor.offset, anchor.node, anchor.offset);
198216
}
199-
function extend(event) {
217+
function extend(event, scheduleScroll = true) {
200218
if (!gesture) return;
219+
pointer = { x: event.clientX, y: event.clientY };
220+
if (scheduleScroll && !scrollFrame) scrollFrame = view.requestAnimationFrame(autoScroll);
201221
const focus = caret(event.clientX, event.clientY);
202222
if (!focus || !gesture.node.isConnected) return;
203223
event.preventDefault();
204224
document
205225
.getSelection()
206226
?.setBaseAndExtent(gesture.node, gesture.offset, focus.node, focus.offset);
207227
}
208-
const finish = () => (gesture = null);
228+
const finish = () => {
229+
gesture = null;
230+
pointer = null;
231+
view.cancelAnimationFrame(scrollFrame);
232+
scrollFrame = 0;
233+
};
209234
host.addEventListener('mousedown', begin, true);
210235
textRuns.set(host, getSpans);
211236
document.addEventListener('mousemove', extend);
@@ -228,6 +253,7 @@ export function bindTextLayerSelection(host, getSpans) {
228253
layers.set(host, end);
229254
if (!listeners) installSharedListeners(document);
230255
return () => {
256+
finish();
231257
host.removeEventListener('mousedown', begin, true);
232258
document.removeEventListener('mousemove', extend);
233259
document.removeEventListener('mouseup', finish);

0 commit comments

Comments
 (0)