@@ -5,8 +5,8 @@ import { customElement } from 'lit/decorators.js';
55import '@internetarchive/icon-share' ;
66
77export 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 */
258258export 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