Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/plugins/plugin.text_selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ export class TextSelectionPlugin extends BookReaderPlugin {
paragEl.style.marginTop = `${newTop}px`;
yAdded += newTop;
textLayer.appendChild(paragEl);
textLayer.appendChild(document.createTextNode('\n'));
}
$container.append(textLayer);
this.textSelectionManager.stopPageFlip($container);
Expand Down
54 changes: 31 additions & 23 deletions src/plugins/url/UrlPlugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,36 +156,44 @@ export class UrlPlugin {
this.oldLocationHash = urlStrPath;
}

/**
getHash = () => {
const text = window.location.search.match(/(?<=[&?]text=)[^&]*/);
if (text) {
return `${window.location.hash.slice(1)}:~:text=${text[0]}`;
}
return window.location.hash.slice(1);
}

/**
* Get the url and check if it has changed
* If it was changeed, update the urlState
*/
listenForHashChanges() {
this.oldLocationHash = window.location.hash.substr(1);
if (this.urlLocationPollId) {
clearInterval(this.urlLocationPollId);
this.urlLocationPollId = null;
}
listenForHashChanges() {
this.oldLocationHash = this.getHash();
if (this.urlLocationPollId) {
clearInterval(this.urlLocationPollId);
this.urlLocationPollId = null;
}

// check if the URL changes
const updateHash = () => {
const newFragment = window.location.hash.substr(1);
const hasFragmentChange = newFragment != this.oldLocationHash;
// check if the URL changes
const updateHash = () => {
const newFragment = this.getHash();
const hasFragmentChange = newFragment != this.oldLocationHash;

if (!hasFragmentChange) { return; }
if (!hasFragmentChange) { return; }

this.urlState = this.urlStringToUrlState(newFragment);
};
this.urlLocationPollId = setInterval(updateHash, 500);
}
this.urlState = this.urlStringToUrlState(newFragment);
};
this.urlLocationPollId = setInterval(updateHash, 500);
}

/**
/**
* Will read either the hash or URL and return the bookreader fragment
*/
pullFromAddressBar (location = window.location) {
const path = this.urlMode === 'history'
? (location.pathname.substr(this.urlHistoryBasePath.length) + location.search)
: location.hash.substr(1);
this.urlState = this.urlStringToUrlState(path);
}
pullFromAddressBar (location = window.location) {
const path = this.urlMode === 'history'
? (location.pathname.substr(this.urlHistoryBasePath.length) + location.search)
: location.hash.substr(1);
this.urlState = this.urlStringToUrlState(path);
}
}
22 changes: 19 additions & 3 deletions src/plugins/url/plugin.url.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ jQuery.extend(BookReader.defaultOptions, {
urlHistoryBasePath: '/',

/** Only these params will be reflected onto the URL */
urlTrackedParams: ['page', 'search', 'mode', 'region', 'highlight', 'view'],
urlTrackedParams: ['page', 'search', 'mode', 'region', 'highlight', 'view', 'text'],

/** If true, don't update the URL when `page == n0 (eg "/page/n0")` */
urlTrackIndex0: false,
shareHighlight: null,
});

/** @override */
Expand Down Expand Up @@ -180,9 +181,15 @@ BookReader.prototype.urlUpdateFragment = function() {
* @param {string} url
* @return {string}
* */

// testing with this URL http://127.0.0.1:8000/BookReaderDemo/demo-internetarchive.html?ocaid=adventureofsherl0000unse&text=Well%2C I found my plans very seriously menaced.&q=breaking the law#page/18/mode/2up
BookReader.prototype.urlParamsFiltersOnlySearch = function(url) {
const text = url.match(/(?<=&text=)[^&]*/);
const params = new URLSearchParams(url);
return params.has('q') ? `?${new URLSearchParams({ q: params.get('q') })}` : '';
let output = '';
output += params.has('q') ? `?${new URLSearchParams({ q: params.get('q') })}` : '';
output += text ? `:~:text=${text[0]}` : '';
return output;
};


Expand All @@ -195,7 +202,7 @@ BookReader.prototype.urlReadFragment = function() {
if (urlMode === 'history') {
return window.location.pathname.substr(urlHistoryBasePath.length);
} else {
return window.location.hash.substr(1);
return this.urlPlugin.getHash();
}
};

Expand All @@ -210,6 +217,15 @@ export class BookreaderUrlPlugin extends BookReader {
init() {
if (this.options.enableUrlPlugin) {
this.urlPlugin = new UrlPlugin(this.options);
const location = this.getLocationSearch();
if (location.includes("text=")) {
const extractText = location.match(/(text=[\w\d\W]*)/);
const textFragment = `${extractText}`;
this.options.shareHighlight = textFragment;
this.on('textLayerRendered', (_, {pageIndex, container}) => {
window.location.replace(`#${this.oldLocationHash}`);
});
}
this.bind(BookReader.eventNames.PostInit, () => {
const { urlMode } = this.options;

Expand Down
216 changes: 216 additions & 0 deletions src/util/TextSelectionManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { SelectionObserver } from "../BookReader/utils/SelectionObserver.js";

export class TextSelectionManager {
hlightBarEl;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inside the constructor, we can construct a single copy of this. Then the click handler should never have to reconstruct it ; it'll just change its position, and remove/append into the document.body or instead display:none .

this.highlightBar = document.createElement('br-highlight-bar');

options = {
// Current Translation plugin implementation does not have words, will limit to one BRlineElement for now
maxProtectedWords: 200,
Expand Down Expand Up @@ -156,17 +157,86 @@ export class TextSelectionManager {
$(textLayer).off(".textSelectPluginHandler");

$(textLayer).on("mousedown.textSelectPluginHandler", (event) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mousedown/mouseup appear not to fire on mobile ; not getting the menu appearing on firefox/chrome on android. Maybe we can instead use the helper SelectionObserver class? That fires a started event and a cleared event we can use to show/hide the toolbar.

if (event.which != 1) return;
this.mouseIsDown = true;
if (this.hlightBarEl) {
this.hlightBarEl.remove();
window.getSelection().empty(); // selection is checked at mouseup, cleared here to prevent button from lingering
}
event.stopPropagation();
});

// Prevent page flip on click
$(textLayer).on('mouseup.textSelectPluginHandler', (event) => {
this.mouseIsDown = false;
event.stopPropagation();
if (event.which != 1) return;
this.hlightBarEl?.remove();
if (window.getSelection().toString()) {
this.highlightToolbar(event);
} else {
this.hlightBarEl?.remove();
}
});

$(document.body).on('mouseup', (_) => {
this.hlightBarEl?.remove();
});
Comment on lines +182 to 184
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll want to make sure this is only attached once, otherwise it'll cause a memory leak, with the event listener created every time there's a new text layer made.

}

highlightToolbar(_) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we will want this toolbar to be a lit element, eg @customElement('br-highlight-bar') class HighlightBar extends LitElement {...}.

That will let us use reactivity, and do some other things. Useful things to note for lit:

  1. Use can use the connectedCallback and disconnectedCallback methods to fire when the element is eg "mounted" and "unmounted" from the DOM. This would be a good place to attach the document.body click listener.
  2. In BookReader we tend not to use shadow dom, so you'll want to add createRenderRoot() { return null; } to disable it for now. This will let you use logic similar to what you currently have, and let you put all the css inside our normal existing scss files. Note since we're not using the shadow dom, IDs and css classes will be global/accessible from the scss, like the default.

Then all the rendering logic will become

const highlightBar = document.createElement('br-highlight-bar');

const hlButton = document.createElement('button');

if (this.hlightBarEl) {
this.hlightBarEl.remove();
}
this.hlightBarEl = hlButton;
const currentSelection = window.getSelection();
const start = currentSelection.anchorNode.parentElement;
const end = currentSelection.focusNode.parentElement; // will always be a text node
const height = 30;
const width = 60;
const selectionTextLayer = start.closest('.BRtextLayer');
const startBoundingRect = start.getBoundingClientRect();
const endBoundingRect = end.getBoundingClientRect();
let hlButtonTop = startBoundingRect.top - height;
let hlButtonLeft = startBoundingRect.left - width;
if (currentSelection.direction == 'backward') {
hlButtonTop = endBoundingRect.top - height;
hlButtonLeft = endBoundingRect.left - width;
}
hlButton.className = "textFragmentCopy";

$(hlButton).css({
'top': `${hlButtonTop}px`,
'left': `${hlButtonLeft}px`,
'width': `${width}px`,
'height': `${height}px`,
'position': 'absolute',
'z-index': 1,
'background': `url(..${this.br.imagesBaseURL}translate.svg)`,
'background-position': 'center',
'background-repeat': 'no-repeat',
'background-color': 'darksalmon',
});
$(hlButton).on('mousedown', (event) => {
event.stopPropagation();
const currentUrl = window.location;
const textContentParams = document.getSelection();
let currentParams = this.br.readQueryString();
if (currentParams.includes('text')) {
currentParams = currentParams.replace(/(text=)[\w\W\d%]+/, createParam(textContentParams, selectionTextLayer));
} else {
currentParams = `${currentParams}&${createParam(document.getSelection(), selectionTextLayer)}`;
}
navigator.clipboard.writeText(`${currentUrl.origin}${currentUrl.pathname}${currentParams}${currentUrl?.hash}`);
});

$(hlButton).on('mouseup', (event) => {
event.stopPropagation();
});
document.body.append(hlButton);
}

_limitSelection = () => {
const selection = window.getSelection();
Expand Down Expand Up @@ -206,6 +276,118 @@ export class TextSelectionManager {
};
}

/** TODO ->
* Can import something that handles this more gracefully? see - https://web.dev/articles/text-fragments#:~:text=In%20its%20simplest%20form%2C%20the%20syntax%20of,percent%2Dencoded%20text%20I%20want%20to%20link%20to.
*/
/**
*
* @param {*} text - document.getSelection()
* @param {*} pageLayer - anchorNode.parentElement.closest('.BRtextLayer')
* @returns
*/
export function createParam(text, pageLayer = null) {
const highlightedText = text.toString().replaceAll(/[\s]+/g, " ").trim().split(" ");
const anchorWord = text.anchorNode.textContent;
const focusWord = text.focusNode.textContent;
let textStart, textEnd; // :~:text=[prefix-,]textStart[,textEnd][,-suffix]
const direction = text.direction;

// Duplicated spaces in pageLayer.textContent for some reason
const wholePageText = pageLayer.textContent.replaceAll(" ", " ");
if (direction == 'backward') {
textStart = focusWord.replaceAll(/[\s]+/g, "") ? focusWord : highlightedText[0];
textEnd = anchorWord.replaceAll(/[\s]+/g, "") ? anchorWord : highlightedText[highlightedText.length - 1];
} else {
textStart = anchorWord.replaceAll(/[\s]+/g, "") ? anchorWord : highlightedText[0];
textEnd = focusWord.replaceAll(/[\s]+/g, "") ? focusWord : highlightedText[highlightedText.length - 1];
}

const escapedStart = RegExp.escape(textStart);
const escapedEnd = RegExp.escape(textEnd);
const testRegExp = new RegExp(String.raw`(?=(${escapedStart}).*?(?:(${escapedEnd})))`, "gi");

const foundMatches = wholePageText.matchAll(testRegExp).toArray();
if (foundMatches.length == 1) {
return `text=${textStart},${textEnd}`;
}
const preStartRange = document.createRange();
const postEndRange = document.createRange();
if (direction == 'backward') {
preStartRange.setStart(pageLayer.firstElementChild, 0);
preStartRange.setEnd(text.focusNode, 0);

postEndRange.setStart(text.anchorNode, text.anchorNode.textContent.length);
postEndRange.setEnd(pageLayer.lastElementChild, pageLayer.lastElementChild.childElementCount);

} else {
preStartRange.setStart(pageLayer.firstElementChild, 0);
preStartRange.setEnd(text.anchorNode, 0);

postEndRange.setStart(text.focusNode, text.focusNode.textContent.length);
postEndRange.setEnd(pageLayer.lastElementChild, pageLayer.lastElementChild.childElementCount);
}

const startRegex = new RegExp(String.raw`(\s+\S+){1,3}\s*?$`);
const endRegex = new RegExp(String.raw`^\S*?(\s+\S+){1,3}`);
// prefixes/suffixes cannot contain paragraph breaks
const textFragmentArr = [];
let [prefixes, suffixes] = ["", ""];
if (preStartRange.toString().length !== 0) {
if (!preStartRange.toString().match(startRegex)) {
prefixes = `${preStartRange.toString()
.replace(/[ ]+/g, " ")
.trim()
.replace(/^[^\n]*\n/gm, "")}-`;
} else {
prefixes = `${preStartRange.toString().match(startRegex)[0]
.replace(/[ ]+/g, " ")
.trim()
.replace(/^[^\n]*\n/gm, "")}-`;
}
textFragmentArr.push(prefixes);
}
if (postEndRange.toString().length !== 0) {
if (!postEndRange.toString().match(endRegex)) {
suffixes = `-${postEndRange.toString()
.replace(/[ ]+/g, " ")
.trim()
.replace(/^[^\n]*\n/gm, "")}`;
} else {
suffixes = `-${postEndRange.toString().match(endRegex)[0]
.replace(/[ ]+/g, " ")
.trim()
.replace(/\n[^\n]*$/gm, "")}`;
}
}

if (textStart === textEnd) {
textEnd = "";
}

const selection = window.getSelection();
const constructHighlight = selection.toString().replace(/[\s]+/g, " ").split(/[ ]+/g);
if (direction == 'backward') {
constructHighlight[0] = selection.focusNode.textContent;
constructHighlight[constructHighlight.length - 1] = selection.anchorNode.textContent;
} else {
constructHighlight[0] = selection.anchorNode.textContent;
constructHighlight[constructHighlight.length - 1] = selection.focusNode.textContent;
}
const fullHighlight = constructHighlight.join(" ").trim().split(" ");
let quote = [fullHighlight.join(" ")];
if (fullHighlight.length > 6) {
if (direction == 'backward') {
quote = [fullHighlight.slice(0, 3).join(" "), fullHighlight.slice(-3).join(" ")];
} else {
quote = [fullHighlight.slice(0, 3).join(" "), fullHighlight.slice(-3).join(" ")];
}
}
textFragmentArr.push(...quote);
if (suffixes.length != 0) {
textFragmentArr.push(suffixes);
}
return `text=${textFragmentArr.map(encodeURIComponent).join(',')}`;
}
/**
* @template T
* Get the i-th element of an iterable
Expand Down Expand Up @@ -280,3 +462,37 @@ export function* walkBetweenNodes(start, end) {

yield* walk(start);
}


export class TextFragment {
/** @type {string | null} */
prefix
/** @type {string} */
textStart
/** @type {string | null} */
textEnd
/** @type {string | null} */
suffix

/** @returns {string} */
toString() {}

/**
* @param {string} urlString
* @returns {TextFragment}
**/
static fromString(urlString) {}

/**
* @param {Selection} selection
* @returns {TextFragment}
*/
static fromSelection(selection) {}
}


/**
* Prefix “That is easily-,
* textstart %20 got.” “ And%20
* Suffix - help.”“That is
*/
1 change: 1 addition & 0 deletions tests/jest/plugins/url/plugin.url.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ describe('Plugin: URL controller', () => {
});

test('initializes polling for url changes if using hash', () => {
BookReader.prototype.urlReadFragment = jest.fn(() => '/page/2/mode/1up');
BookReader.prototype.urlStartLocationPolling = jest.fn();
br.init();

Expand Down
Loading