Skip to content

Commit 8bf9cc0

Browse files
cdrinischu96
andcommitted
Code review feedback + small bug fixes
Co-authored-by: Sandy Chu <schu96@users.noreply.github.com>
1 parent d7116ab commit 8bf9cc0

5 files changed

Lines changed: 114 additions & 98 deletions

File tree

src/css/_TextSelection.scss

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@
152152
display: none;
153153
}
154154

155-
.br-selection-menu__root {
155+
.br-select-menu__root {
156156
display: none;
157157
border-radius: 20px;
158158
background-color: #333;
@@ -161,15 +161,15 @@
161161
overflow: hidden;
162162
transition: border-radius 0.2s;
163163
}
164-
.br-selection-menu__root:hover {
164+
.br-select-menu__root:hover {
165165
border-radius: 8px;
166166
}
167167

168-
.br-selection-menu__root:hover .textFragmentButton {
168+
.br-select-menu__root:hover .br-select-menu__option {
169169
border-radius: 6px;
170170
}
171171

172-
.textFragmentButton {
172+
.br-select-menu__option {
173173
--iconWidth: 15px;
174174
--iconHeight: 15px;
175175
--iconFillColor: currentColor;
@@ -185,18 +185,18 @@
185185
padding: 4px;
186186
}
187187

188-
.textFragmentButton:hover {
188+
.br-select-menu__option:hover {
189189
background-color: rgba(255, 255, 255, 0.1);
190190
}
191191

192-
.textFragmentButton ia-icon-share {
192+
.br-select-menu__icon {
193193
display: block;
194194
flex-shrink: 0;
195195
width: 17px;
196196
height: 17px;
197197
}
198198

199-
.menuOptionTitle {
199+
.br-select-menu__label {
200200
width: 0px;
201201
opacity: 0;
202202
interpolate-size: allow-keywords;
@@ -205,7 +205,7 @@
205205
}
206206

207207

208-
.br-selection-menu__root:hover .menuOptionTitle {
208+
.br-select-menu__root:hover .br-select-menu__label {
209209
width: auto;
210210
margin-left: 4px;
211211
opacity: 1;

src/plugins/plugin.experiments.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,9 @@ export class ExperimentsPlugin extends BookReaderPlugin {
139139
for (const experiment of this.allExperiments) {
140140
// TODO: imagesBaseURL should be replaced with assetRoot everywhere
141141
experiment.assetRoot = this.br.options.imagesBaseURL.replace(/images\/$/, '');
142-
experiment.icon = experiment.buildAssetPath(experiment.icon);
142+
if (experiment.icon) {
143+
experiment.icon = experiment.buildAssetPath(experiment.icon);
144+
}
143145
experiment.br = this.br;
144146
}
145147

@@ -285,7 +287,7 @@ export class BrExperimentToggle extends LitElement {
285287
return html`
286288
<div class="experiment-card" style="margin-bottom: 10px;">
287289
<div style="display: flex; align-items: center; gap: 10px;">
288-
<img src="${this.icon}" style="width: 20px; height: 20px;" alt="" />
290+
${this.icon ? html`<img src="${this.icon}" style="width: 20px; height: 20px;" alt="" />` : ''}
289291
<div style="flex-grow: 1; font-weight: bold;">${this.title}</div>
290292
</div>
291293
<p style="opacity: 0.9">

src/plugins/url/plugin.url.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ BookReader.prototype.urlUpdateFragment = function() {
147147
// eg 'page/3/mode/2up'; no query params (in hash mode, it might have /search/term)
148148
// Does NOT have the :~:text fragment
149149
const newFragment = this.fragmentFromParams(params, this.options.urlMode);
150+
const newFragmentWithSlash = newFragment === '' ? '' : `/${newFragment}`;
150151
// eg 'page/3/mode/2up'; no query params
151152
// WILL CONTAIN the :~:text fragment in hash mode (!)
152153
const currFragment = this.urlReadFragment();
@@ -168,7 +169,6 @@ BookReader.prototype.urlUpdateFragment = function() {
168169
this.options.urlMode = 'hash';
169170
} else {
170171
const baseWithoutSlash = this.options.urlHistoryBasePath.replace(/\/+$/, '');
171-
const newFragmentWithSlash = newFragment === '' ? '' : `/${newFragment}`;
172172
const textFragment = this.urlPlugin.retrieveTextFragment(newQueryString);
173173
const newUrlPath = `${baseWithoutSlash}${newFragmentWithSlash}${newQueryString}`;
174174
const extractedPage = this.urlPlugin.urlStringToUrlState(newFragmentWithSlash)?.page;
@@ -192,10 +192,8 @@ BookReader.prototype.urlUpdateFragment = function() {
192192
if (this.options.urlMode === 'hash') {
193193
const newQueryStringSearch = this.urlParamsFiltersOnlySearch(this.readQueryString());
194194
let textFragment = this.urlPlugin.retrieveTextFragment(this.readQueryString());
195-
let extractedPage = newFragment.match(/(?<=\/)n?\d+(?=\/)/);
196-
if (extractedPage) {
197-
extractedPage = extractedPage[0];
198-
}
195+
const extractedPage = this.urlPlugin.urlStringToUrlState(newFragmentWithSlash)?.page;
196+
199197
if (textFragment) {
200198
textFragment = `:~:text=${textFragment[0]}`;
201199
} else {

src/util/TextSelectionManager.js

Lines changed: 75 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { customElement } from 'lit/decorators.js';
55
import '@internetarchive/icon-share';
66

77
export class TextSelectionManager {
8-
/** @type {BRSelectionMenu} */
9-
highlightBar;
8+
/** @type {BRSelectMenu} */
9+
selectMenu;
1010
selectionMenuEnabled;
1111
options = {
1212
// Current Translation plugin implementation does not have words, will limit to one BRlineElement for now
@@ -29,9 +29,9 @@ export class TextSelectionManager {
2929
this.selectionElement = selectionElement;
3030
this.selectionObserver = new SelectionObserver(this.layer, this._onSelectionChange);
3131
this.options.maxProtectedWords = maxWords ? maxWords : 200;
32-
this.highlightBar = new BRSelectionMenu(br);
32+
this.selectMenu = new BRSelectMenu(br);
3333

34-
this.highlightBar.className = "br-selection-menu__root";
34+
this.selectMenu.className = "br-select-menu__root";
3535
}
3636

3737
init() {
@@ -44,21 +44,21 @@ export class TextSelectionManager {
4444
// Set a class on the page to avoid hiding it when zooming/etc
4545
this.br.refs.$br.find('.BRpagecontainer--hasSelection').removeClass('BRpagecontainer--hasSelection');
4646
$(window.getSelection().anchorNode).closest('.BRpagecontainer').addClass('BRpagecontainer--hasSelection');
47-
this.highlightBar.showMenu();
47+
this.selectMenu.showMenu();
4848

4949
}
5050

5151
if (selectEvent == 'focusChanged') {
5252
// hide the button as user changes their selection
5353
if (this.mouseIsDown) {
54-
this.highlightBar.hideMenu();
54+
this.selectMenu.hideMenu();
5555
} else if (window.getSelection().toString()) {
56-
this.highlightBar.showMenu();
56+
this.selectMenu.showMenu();
5757
}
5858
}
5959

6060
if (selectEvent == 'cleared') {
61-
this.highlightBar.hideMenu();
61+
this.selectMenu.hideMenu();
6262
}
6363
}).attach();
6464
}
@@ -93,9 +93,9 @@ export class TextSelectionManager {
9393
}
9494

9595
renderSelectionMenu() {
96-
if (document.querySelector('.textFragmentBar')) return;
96+
if (document.querySelector('.br-select-menu__option')) return;
9797
if (this.selectionMenuEnabled) {
98-
document.body.append(this.highlightBar);
98+
document.body.append(this.selectMenu);
9999
}
100100
}
101101
/**
@@ -160,15 +160,15 @@ export class TextSelectionManager {
160160
// blocking selection
161161
$(textLayer).on("mousedown.textSelectPluginHandler", (event) => {
162162
this.mouseIsDown = true;
163-
this.highlightBar.hideMenu();
163+
this.selectMenu.hideMenu();
164164
if ($(event.target).is(this.selectionElement.join(", "))) {
165165
event.stopPropagation();
166166
}
167167
});
168168

169169
$(textLayer).on("mouseup.textSelectPluginHandler", (event) => {
170170
this.mouseIsDown = false;
171-
this.highlightBar.hideMenu();
171+
this.selectMenu.hideMenu();
172172
textLayer.style.pointerEvents = "none";
173173
if (skipNextMouseup) {
174174
skipNextMouseup = false;
@@ -194,15 +194,15 @@ export class TextSelectionManager {
194194
if (event.which != 1) return;
195195
this.mouseIsDown = true;
196196
event.stopPropagation();
197-
this.highlightBar.hideMenu();
197+
this.selectMenu.hideMenu();
198198
});
199199

200200
// Prevent page flip on click
201201
$(textLayer).on('mouseup.textSelectPluginHandler', (event) => {
202202
this.mouseIsDown = false;
203203
if (event.which != 1) return;
204204
event.stopPropagation();
205-
this.highlightBar.showMenu();
205+
this.selectMenu.showMenu();
206206
});
207207
}
208208

@@ -256,8 +256,8 @@ export class TextSelectionManager {
256256
* @returns {string} - i.e. http://127.0.0.1:8000/BookReaderDemo/demo-internetarchive.html?ocaid=adventureofsherl0000unse&text=undefined,undefined#page/10/mode/2up
257257
*/
258258
export function createTextFragmentUrlParam(selection, pageLayer) {
259+
// :~:text=[prefix-,]textStart[,textEnd][,-suffix]
259260
const highlightedText = selection.toString().replace(/[\s]+/g, " ").trim().split(" ");
260-
let textStart, textEnd; // :~:text=[prefix-,]textStart[,textEnd][,-suffix]
261261
const direction = selection.direction;
262262
const startNode = direction == 'backward' ? selection.focusNode : selection.anchorNode;
263263
const endNode = direction == 'backward' ? selection.anchorNode : selection.focusNode;
@@ -269,86 +269,55 @@ export function createTextFragmentUrlParam(selection, pageLayer) {
269269
const textEndRe = RegExp.escape(endWord);
270270

271271
// 's' regex modifier ensures the `.` also captures newline characters
272-
const phraseMatchRe = new RegExp(String.raw`(?<=(${textStartRe}).*?)(${textEndRe})`, "gis");
272+
// Need to use lookahead/lookbehind assertions to allow for overlapping quotes (i.e. multiple "Holmes" on the same page)
273+
const startPhraseMatchRe = new RegExp(String.raw`(?<=(${textStartRe}).*?)(${textEndRe})`, "gis");
274+
const endPhraseMatchRe = new RegExp(String.raw`(${textStartRe})(?=.*?(${textEndRe}))`, "gis");
275+
273276
// Duplicated spaces in pageLayer.textContent for some reason
274-
const wholePageText = pageLayer.textContent.replaceAll(" ", " ");
275-
const foundMatches = wholePageText.matchAll(phraseMatchRe).toArray();
276-
if (foundMatches.length == 1) {
277+
const wholePageText = pageLayer.textContent.replace(/\s+/g, " ");
278+
const startPhraseFoundMatches = wholePageText.matchAll(startPhraseMatchRe).toArray();
279+
const endPhraseFoundMatches = wholePageText.matchAll(endPhraseMatchRe).toArray();
280+
if (startPhraseFoundMatches.length == 1 && endPhraseFoundMatches.length == 1) {
277281
// If `startWord...endWord` quote is unambiguous and only occurs once, no prefix-/-suffix is needed for the URL param
278282
return `text=${encodeURIComponent(startWord)},${encodeURIComponent(endWord)}`;
279283
}
284+
280285
// Need to add some additional context to `startWord...endWord` by including surrounding words before and after the keywords
281286
const preStartRange = document.createRange();
287+
preStartRange.setStart(pageLayer.firstElementChild, 0);
288+
preStartRange.setEnd(startNode, 0);
282289
const postEndRange = document.createRange();
283-
if (direction == 'backward') {
284-
preStartRange.setStart(pageLayer.firstElementChild, 0);
285-
preStartRange.setEnd(selection.focusNode, 0);
286-
287-
postEndRange.setStart(selection.anchorNode, selection.anchorNode.textContent.length);
288-
postEndRange.setEnd(pageLayer.lastElementChild, pageLayer.lastElementChild.childElementCount);
289-
290-
} else {
291-
preStartRange.setStart(pageLayer.firstElementChild, 0);
292-
preStartRange.setEnd(selection.anchorNode, 0);
293-
294-
postEndRange.setStart(selection.focusNode, selection.focusNode.textContent.length);
295-
postEndRange.setEnd(pageLayer.lastElementChild, pageLayer.lastElementChild.childElementCount);
296-
}
290+
postEndRange.setStart(endNode, endNode.textContent.length);
291+
postEndRange.setEnd(pageLayer.lastElementChild, pageLayer.lastElementChild.childElementCount);
297292

298293
// prefixes/suffixes cannot contain paragraph breaks, words that are from more than one line break away should not be included
299-
const prefixRe = new RegExp(String.raw`(\s+\S+){1,3}\s*?$`);
300-
const suffixRe = new RegExp(String.raw`^\S*?(\s+\S+){1,3}`);
301-
302-
const textFragmentArr = [];
303-
let [prefixes, suffixes] = "";
304-
const getFirstWords = (sentence, patternRe) => {
305-
if (sentence.toString().match(patternRe)) {
306-
return sentence.toString().match(patternRe)[0];
307-
} else {
308-
return sentence.toString();
309-
}
310-
};
311-
312-
if (getFirstWords(preStartRange, prefixRe)) {
313-
prefixes = `${getFirstWords(preStartRange.toString(), prefixRe)
314-
.replace(/[ ]+/g, " ")
315-
.trim()
316-
.replace(/^[^\n]*\n/gm, "")}-`;
317-
}
318-
if (getFirstWords(postEndRange, suffixRe)) {
319-
suffixes = `-${postEndRange.toString().match(suffixRe)[0]
320-
.replace(/[ ]+/g, " ")
321-
.trim()
322-
.replace(/\n[^\n]*$/gm, "")}`;
323-
}
324-
325-
if (textStart === textEnd) {
326-
// if just one word ("Holmes") is selected, the browser API for text fragment will try to look for matches for strings like "Holmes, Holmes"
327-
// providing the prefix and suffix should be enough to locate it on the page
328-
textEnd = "";
329-
}
294+
const prefix = getLastWords(3, preStartRange.toString())
295+
.replace(/[ ]+/g, " ")
296+
.trim()
297+
.replace(/^[^\n]*\n/gm, "");
298+
const suffix = getFirstWords(3, postEndRange.toString())
299+
.replace(/[ ]+/g, " ")
300+
.trim()
301+
.replace(/\n[^\n]*$/gm, "");
330302

331303
// Partially selected words need to be captured completely
332-
const constructHighlight = selection.toString().replace(/[\s]+/g, " ").split(/[ ]+/g);
333-
if (direction == 'backward') {
334-
constructHighlight[0] = selection.focusNode.textContent;
335-
constructHighlight[constructHighlight.length - 1] = selection.anchorNode.textContent;
336-
} else {
337-
constructHighlight[0] = selection.anchorNode.textContent;
338-
constructHighlight[constructHighlight.length - 1] = selection.focusNode.textContent;
339-
}
340-
const fullHighlight = constructHighlight.join(" ").trim().split(" ");
304+
const fullHighlight = selection.toString().replace(/\s+/g, " ").trim().split(/\s/g);
305+
fullHighlight[0] = startNode.textContent;
306+
fullHighlight[fullHighlight.length - 1] = endNode.textContent;
307+
341308
let quote = [fullHighlight.join(" ")];
342309
if (fullHighlight.length > 6) {
343310
quote = [fullHighlight.slice(0, 3).join(" "), fullHighlight.slice(-3).join(" ")];
344311
}
345-
if (prefixes) textFragmentArr.push(prefixes);
312+
313+
const textFragmentArr = [];
314+
if (prefix) textFragmentArr.push(`${prefix}-`);
346315
textFragmentArr.push(...quote);
347-
if (suffixes.length != 0) {
348-
textFragmentArr.push(suffixes ? suffixes : "");
349-
}
316+
if (suffix) textFragmentArr.push(`-${suffix}`);
317+
350318
return `text=${textFragmentArr.map(encodeURIComponent).join(',')}`;
351319
}
320+
352321
/**
353322
* @template T
354323
* Get the i-th element of an iterable
@@ -424,8 +393,8 @@ export function* walkBetweenNodes(start, end) {
424393
yield* walk(start);
425394
}
426395

427-
@customElement('br-highlight-bar')
428-
class BRSelectionMenu extends LitElement {
396+
@customElement('br-select-menu')
397+
class BRSelectMenu extends LitElement {
429398
/** @type {import('../BookReader.js').default} */
430399
br;
431400

@@ -442,9 +411,9 @@ class BRSelectionMenu extends LitElement {
442411

443412
render() {
444413
return html`
445-
<button @click=${this.handleCopyLinkToHighlight} class="textFragmentButton">
446-
<ia-icon-share aria-hidden="true"></ia-icon-share>
447-
<span class="menuOptionTitle">Copy Link to Highlight</span>
414+
<button @click=${this.handleCopyLinkToHighlight} class="br-select-menu__option">
415+
<ia-icon-share class="br-select-menu__icon" aria-hidden="true"></ia-icon-share>
416+
<span class="br-select-menu__label">Copy Link to Highlight</span>
448417
</button>
449418
`;
450419
}
@@ -499,3 +468,27 @@ class BRSelectionMenu extends LitElement {
499468
return;
500469
}
501470
}
471+
472+
/**
473+
* @param {number} numWords
474+
* @param {string} text
475+
* @return {string}
476+
*/
477+
export function getFirstWords(numWords, text) {
478+
text = text.trim();
479+
const re = new RegExp(String.raw`^(\S+(\s+|$)){1,${numWords}}`);
480+
const m = text.match(re);
481+
return m ? m[0].trim() : "";
482+
}
483+
484+
/**
485+
* @param {number} numWords
486+
* @param {string} text
487+
* @return {string}
488+
*/
489+
export function getLastWords(numWords, text) {
490+
text = text.trim();
491+
const re = new RegExp(String.raw`((^|\s+)\S+){1,${numWords}}\s*?$`);
492+
const m = text.match(re);
493+
return m ? m[0].trim() : "";
494+
}

0 commit comments

Comments
 (0)