@internetarchive/bookreader 5.0.0-98 → 5.0.0-99
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BookReader/BookReader.css +15 -11
- package/BookReader/BookReader.js +1 -1
- package/BookReader/BookReader.js.map +1 -1
- package/BookReader/ia-bookreader-bundle.js +156 -156
- package/BookReader/ia-bookreader-bundle.js.map +1 -1
- package/BookReader/plugins/plugin.text_selection.js +1 -1
- package/BookReader/plugins/plugin.text_selection.js.map +1 -1
- package/BookReader/plugins/plugin.translate.js +135 -1
- package/BookReader/plugins/plugin.translate.js.map +1 -1
- package/package.json +1 -1
- package/src/BookReader.js +8 -4
- package/src/css/_BRpages.scss +1 -9
- package/src/css/_TextSelection.scss +16 -2
- package/src/plugins/plugin.text_selection.js +5 -168
- package/src/plugins/translate/TranslationManager.js +13 -21
- package/src/plugins/translate/plugin.translate.js +58 -22
- package/src/util/TextSelectionManager.js +282 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { SelectionObserver } from "../BookReader/utils/SelectionObserver.js";
|
|
3
|
+
|
|
4
|
+
export class TextSelectionManager {
|
|
5
|
+
options = {
|
|
6
|
+
// Current Translation plugin implementation does not have words, will limit to one BRlineElement for now
|
|
7
|
+
maxProtectedWords: 200,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} layer Selector for the text layer to manage
|
|
12
|
+
* @param {import('../BookReader.js').default} br
|
|
13
|
+
* @param {Object} options
|
|
14
|
+
* @param {string[]} options.selectionElement CSS selector for elements that count as "words" for selection limiting
|
|
15
|
+
* @param {number} [maxWords] Maximum number of words allowed to be selected
|
|
16
|
+
*/
|
|
17
|
+
constructor (layer, br, { selectionElement }, maxWords) {
|
|
18
|
+
/** @type {string} */
|
|
19
|
+
this.layer = layer;
|
|
20
|
+
/** @type {import('../BookReader.js').default} */
|
|
21
|
+
this.br = br;
|
|
22
|
+
/** @type {string[]} */
|
|
23
|
+
this.selectionElement = selectionElement;
|
|
24
|
+
this.selectionObserver = new SelectionObserver(this.layer, this._onSelectionChange);
|
|
25
|
+
this.options.maxProtectedWords = maxWords ? maxWords : 200;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
init() {
|
|
29
|
+
this.attach();
|
|
30
|
+
new SelectionObserver(this.layer, (selectEvent) => {
|
|
31
|
+
// Track how often selection is used
|
|
32
|
+
if (selectEvent == 'started') {
|
|
33
|
+
this.br.plugins.archiveAnalytics?.sendEvent('BookReader', 'SelectStart');
|
|
34
|
+
|
|
35
|
+
// Set a class on the page to avoid hiding it when zooming/etc
|
|
36
|
+
this.br.refs.$br.find('.BRpagecontainer--hasSelection').removeClass('BRpagecontainer--hasSelection');
|
|
37
|
+
$(window.getSelection().anchorNode).closest('.BRpagecontainer').addClass('BRpagecontainer--hasSelection');
|
|
38
|
+
}
|
|
39
|
+
}).attach();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Need attach + detach methods to toggle w/ Translation plugin
|
|
43
|
+
attach() {
|
|
44
|
+
this.selectionObserver.attach();
|
|
45
|
+
if (this.br.protected) {
|
|
46
|
+
document.addEventListener('selectionchange', this._limitSelection);
|
|
47
|
+
// Prevent right clicking when selected text
|
|
48
|
+
$(document.body).on('contextmenu dragstart copy', (e) => {
|
|
49
|
+
const selection = document.getSelection();
|
|
50
|
+
if (selection?.toString()) {
|
|
51
|
+
const intersectsTextLayer = $(this.layer)
|
|
52
|
+
.toArray()
|
|
53
|
+
.some(el => selection.containsNode(el, true));
|
|
54
|
+
if (intersectsTextLayer) {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
detach() {
|
|
64
|
+
this.selectionObserver.detach();
|
|
65
|
+
if (this.br.protected) {
|
|
66
|
+
document.removeEventListener('selectionchange', this._limitSelection);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {'started' | 'cleared'} type
|
|
72
|
+
* @param {HTMLElement} target
|
|
73
|
+
*/
|
|
74
|
+
_onSelectionChange = (type, target) => {
|
|
75
|
+
if (type === 'started') {
|
|
76
|
+
this.textSelectingMode(target);
|
|
77
|
+
} else if (type === 'cleared') {
|
|
78
|
+
this.defaultMode(target);
|
|
79
|
+
} else {
|
|
80
|
+
throw new Error(`Unknown type ${type}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Intercept copied text to remove any styling applied to it
|
|
86
|
+
* @param {JQuery} $container
|
|
87
|
+
*/
|
|
88
|
+
interceptCopy ($container) {
|
|
89
|
+
$container[0].addEventListener('copy', (event) => {
|
|
90
|
+
const selection = document.getSelection();
|
|
91
|
+
event.clipboardData.setData('text/plain', selection.toString());
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Initializes text selection modes if there is a text layer on the page
|
|
98
|
+
* @param {JQuery} $container
|
|
99
|
+
*/
|
|
100
|
+
stopPageFlip($container) {
|
|
101
|
+
/** @type {JQuery<HTMLElement>} */
|
|
102
|
+
const $textLayer = $container.find(this.layer);
|
|
103
|
+
if (!$textLayer.length) return;
|
|
104
|
+
$textLayer.each((i, s) => this.defaultMode(s));
|
|
105
|
+
if (!this.br.protected) {
|
|
106
|
+
this.interceptCopy($container);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Applies mouse events when in default mode
|
|
112
|
+
* @param {HTMLElement} textLayer
|
|
113
|
+
*/
|
|
114
|
+
defaultMode (textLayer) {
|
|
115
|
+
const $pageContainer = $(textLayer).closest('.BRpagecontainer');
|
|
116
|
+
textLayer.style.pointerEvents = "none";
|
|
117
|
+
$pageContainer.find("img").css("pointer-events", "auto");
|
|
118
|
+
|
|
119
|
+
$(textLayer).off(".textSelectPluginHandler");
|
|
120
|
+
const startedMouseDown = this.mouseIsDown;
|
|
121
|
+
let skipNextMouseup = this.mouseIsDown;
|
|
122
|
+
if (startedMouseDown) {
|
|
123
|
+
textLayer.style.pointerEvents = "auto";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Need to stop propagation to prevent DragScrollable from
|
|
127
|
+
// blocking selection
|
|
128
|
+
$(textLayer).on("mousedown.textSelectPluginHandler", (event) => {
|
|
129
|
+
this.mouseIsDown = true;
|
|
130
|
+
if ($(event.target).is(this.selectionElement.join(", "))) {
|
|
131
|
+
event.stopPropagation();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
$(textLayer).on("mouseup.textSelectPluginHandler", (event) => {
|
|
136
|
+
this.mouseIsDown = false;
|
|
137
|
+
textLayer.style.pointerEvents = "none";
|
|
138
|
+
if (skipNextMouseup) {
|
|
139
|
+
skipNextMouseup = false;
|
|
140
|
+
event.stopPropagation();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* This mode is active while there is a selection on the given textLayer
|
|
147
|
+
* @param {HTMLElement} textLayer
|
|
148
|
+
*/
|
|
149
|
+
textSelectingMode(textLayer) {
|
|
150
|
+
const $pageContainer = $(textLayer).closest('.BRpagecontainer');
|
|
151
|
+
// Make text layer consume all events
|
|
152
|
+
textLayer.style.pointerEvents = "all";
|
|
153
|
+
// Block img from getting long-press to save while selecting
|
|
154
|
+
$pageContainer.find("img").css("pointer-events", "none");
|
|
155
|
+
|
|
156
|
+
$(textLayer).off(".textSelectPluginHandler");
|
|
157
|
+
|
|
158
|
+
$(textLayer).on("mousedown.textSelectPluginHandler", (event) => {
|
|
159
|
+
this.mouseIsDown = true;
|
|
160
|
+
event.stopPropagation();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Prevent page flip on click
|
|
164
|
+
$(textLayer).on('mouseup.textSelectPluginHandler', (event) => {
|
|
165
|
+
this.mouseIsDown = false;
|
|
166
|
+
event.stopPropagation();
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
_limitSelection = () => {
|
|
172
|
+
const selection = window.getSelection();
|
|
173
|
+
if (!selection.rangeCount) return;
|
|
174
|
+
|
|
175
|
+
const range = selection.getRangeAt(0);
|
|
176
|
+
|
|
177
|
+
// Check if range.startContainer is inside the sub-tree of .BRContainer
|
|
178
|
+
const startInBr = !!range.startContainer.parentElement.closest('.BRcontainer');
|
|
179
|
+
const endInBr = !!range.endContainer.parentElement.closest('.BRcontainer');
|
|
180
|
+
if (!startInBr && !endInBr) return;
|
|
181
|
+
if (!startInBr || !endInBr) {
|
|
182
|
+
// weird case, just clear the selection
|
|
183
|
+
selection.removeAllRanges();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Find the last allowed word in the selection
|
|
188
|
+
const lastAllowedWord = genAt(
|
|
189
|
+
genFilter(
|
|
190
|
+
walkBetweenNodes(range.startContainer, range.endContainer),
|
|
191
|
+
(node) => node.classList?.contains(
|
|
192
|
+
this.selectionElement[0].replace(".", ""),
|
|
193
|
+
),
|
|
194
|
+
),
|
|
195
|
+
this.options.maxProtectedWords - 1,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
if (!lastAllowedWord || range.endContainer.parentNode == lastAllowedWord) return;
|
|
199
|
+
|
|
200
|
+
const newRange = document.createRange();
|
|
201
|
+
newRange.setStart(range.startContainer, range.startOffset);
|
|
202
|
+
newRange.setEnd(lastAllowedWord.firstChild, lastAllowedWord.textContent.length);
|
|
203
|
+
|
|
204
|
+
selection.removeAllRanges();
|
|
205
|
+
selection.addRange(newRange);
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* @template T
|
|
211
|
+
* Get the i-th element of an iterable
|
|
212
|
+
* @param {Iterable<T>} iterable
|
|
213
|
+
* @param {number} index
|
|
214
|
+
*/
|
|
215
|
+
export function genAt(iterable, index) {
|
|
216
|
+
let i = 0;
|
|
217
|
+
for (const x of iterable) {
|
|
218
|
+
if (i == index) {
|
|
219
|
+
return x;
|
|
220
|
+
}
|
|
221
|
+
i++;
|
|
222
|
+
}
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @template T
|
|
228
|
+
* Generator version of filter
|
|
229
|
+
* @param {Iterable<T>} iterable
|
|
230
|
+
* @param {function(T): boolean} fn
|
|
231
|
+
*/
|
|
232
|
+
export function* genFilter(iterable, fn) {
|
|
233
|
+
for (const x of iterable) {
|
|
234
|
+
if (fn(x)) yield x;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Depth traverse the DOM tree starting at `start`, and ending at `end`.
|
|
240
|
+
* @param {Node} start
|
|
241
|
+
* @param {Node} end
|
|
242
|
+
* @returns {Generator<Node>}
|
|
243
|
+
*/
|
|
244
|
+
export function* walkBetweenNodes(start, end) {
|
|
245
|
+
let done = false;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* @param {Node} node
|
|
249
|
+
*/
|
|
250
|
+
function* walk(node, {children = true, parents = true, siblings = true} = {}) {
|
|
251
|
+
if (node === end) {
|
|
252
|
+
done = true;
|
|
253
|
+
yield node;
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// yield self
|
|
258
|
+
yield node;
|
|
259
|
+
|
|
260
|
+
// First iterate children (depth-first traversal)
|
|
261
|
+
if (children && node.firstChild) {
|
|
262
|
+
yield* walk(node.firstChild, {children: true, parents: false, siblings: true});
|
|
263
|
+
if (done) return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Then iterate siblings
|
|
267
|
+
if (siblings) {
|
|
268
|
+
for (let sib = node.nextSibling; sib; sib = sib.nextSibling) {
|
|
269
|
+
yield* walk(sib, {children: true, parents: false, siblings: false});
|
|
270
|
+
if (done) return;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Finally, move up the tree
|
|
275
|
+
if (parents && node.parentNode) {
|
|
276
|
+
yield* walk(node.parentNode, {children: false, parents: true, siblings: true});
|
|
277
|
+
if (done) return;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
yield* walk(start);
|
|
282
|
+
}
|