@internetarchive/bookreader 5.0.0-97 → 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.
@@ -19,38 +19,48 @@ export function isAndroid(userAgent = navigator.userAgent) {
19
19
  return /android/i.test(userAgent);
20
20
  }
21
21
 
22
+ /** @type {{[lang: string]: string}} */
23
+ // Handle odd one-off language pairs that might show up over time
24
+ const specialLangs = {
25
+ "zh-hans": "中文 (Zhōngwén)",
26
+ };
22
27
  /**
23
28
  * @typedef {string} ISO6391
24
29
  * Language code in ISO 639-1 format. e.g. en, fr, zh
30
+ * Can also retrieve language native name + ISO 639-1 code
25
31
  **/
26
32
 
27
33
  /**
28
34
  * @param {string} language in some format
35
+ * @param {boolean?} getName gets Native Name + language code if true
29
36
  * @return {ISO6391?}
30
37
  */
31
- export function toISO6391(language) {
38
+ export function toISO6391(language, getName = false) {
32
39
  if (!language) return null;
33
40
  language = language.toLowerCase();
34
41
 
35
- return searchForISO6391(language, ['iso639_1']) ||
36
- searchForISO6391(language, ['iso639_2T']) ||
37
- searchForISO6391(language, ['iso639_2B', 'nativeName', 'name']);
42
+ return searchForISO6391(language, ['iso639_1'], getName) ||
43
+ searchForISO6391(language, ['iso639_2T'], getName) ||
44
+ searchForISO6391(language, ['iso639_2B', 'nativeName', 'name'], getName);
38
45
  }
39
46
 
40
47
  /**
41
48
  * Searches for the given long in the given columns.
42
49
  * @param {string} language
43
50
  * @param {Array<keyof import('iso-language-codes').Code>} columnsToSearch
51
+ * @param {boolean?} getName
44
52
  * @return {ISO6391?}
45
53
  */
46
- function searchForISO6391(language, columnsToSearch) {
54
+ function searchForISO6391(language, columnsToSearch, getName) {
47
55
  for (const lang of langs) {
48
56
  for (const colName of columnsToSearch) {
49
57
  if (lang[colName].split(', ').map(x => x.toLowerCase()).indexOf(language) != -1) {
58
+ if (getName) return `${lang.nativeName.split(", ")[0]} (${lang.iso639_1})`;
50
59
  return lang.iso639_1;
51
60
  }
52
61
  }
53
62
  }
63
+ if (specialLangs[language]) return `${specialLangs[language]} (${language})`;
54
64
  return null;
55
65
  }
56
66
 
@@ -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
+ }