diff --git a/src/plugins/plugin.text_selection.js b/src/plugins/plugin.text_selection.js index 85f2079c3..a5e519078 100644 --- a/src/plugins/plugin.text_selection.js +++ b/src/plugins/plugin.text_selection.js @@ -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); diff --git a/src/plugins/url/UrlPlugin.js b/src/plugins/url/UrlPlugin.js index 192a95de1..ea864e322 100644 --- a/src/plugins/url/UrlPlugin.js +++ b/src/plugins/url/UrlPlugin.js @@ -156,12 +156,20 @@ 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); + this.oldLocationHash = this.getHash(); if (this.urlLocationPollId) { clearInterval(this.urlLocationPollId); this.urlLocationPollId = null; @@ -169,7 +177,7 @@ export class UrlPlugin { // check if the URL changes const updateHash = () => { - const newFragment = window.location.hash.substr(1); + const newFragment = this.getHash(); const hasFragmentChange = newFragment != this.oldLocationHash; if (!hasFragmentChange) { return; } diff --git a/src/plugins/url/plugin.url.js b/src/plugins/url/plugin.url.js index fb851d6f3..1d746c29c 100644 --- a/src/plugins/url/plugin.url.js +++ b/src/plugins/url/plugin.url.js @@ -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 */ @@ -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; }; @@ -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(); } }; @@ -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; diff --git a/src/util/TextSelectionManager.js b/src/util/TextSelectionManager.js index d836dd135..f7ec860e4 100644 --- a/src/util/TextSelectionManager.js +++ b/src/util/TextSelectionManager.js @@ -2,6 +2,7 @@ import { SelectionObserver } from "../BookReader/utils/SelectionObserver.js"; export class TextSelectionManager { + hlightBarEl; options = { // Current Translation plugin implementation does not have words, will limit to one BRlineElement for now maxProtectedWords: 200, @@ -156,7 +157,12 @@ export class TextSelectionManager { $(textLayer).off(".textSelectPluginHandler"); $(textLayer).on("mousedown.textSelectPluginHandler", (event) => { + 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(); }); @@ -164,9 +170,73 @@ export class TextSelectionManager { $(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(); }); } + highlightToolbar(_) { + 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(); @@ -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 @@ -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 +*/ diff --git a/tests/jest/plugins/url/plugin.url.test.js b/tests/jest/plugins/url/plugin.url.test.js index d762f76a8..a07840e58 100644 --- a/tests/jest/plugins/url/plugin.url.test.js +++ b/tests/jest/plugins/url/plugin.url.test.js @@ -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(); diff --git a/tests/jest/util/TextSelectionManager.test.js b/tests/jest/util/TextSelectionManager.test.js index a2e146509..c71234a41 100644 --- a/tests/jest/util/TextSelectionManager.test.js +++ b/tests/jest/util/TextSelectionManager.test.js @@ -2,6 +2,7 @@ import sinon from 'sinon'; import BookReader from '@/src/BookReader.js'; import '@/src/plugins/plugin.text_selection.js'; +import {createParam} from '@/src/util/TextSelectionManager.js'; // djvu.xml book infos copied from https://ia803103.us.archive.org/14/items/goodytwoshoes00newyiala/goodytwoshoes00newyiala_djvu.xml const FAKE_XML_MULT_LINES = ` @@ -49,29 +50,118 @@ const FAKE_XML_MULT_LINES = ` `; -describe("Generic tests", () => { - document.body.innerHTML = '
'; - const br = window.br = new BookReader({ - data: [ - [ - { width: 800, height: 1200, - uri: '//archive.org/download/BookReader/img/page001.jpg' }, - ], - [ - { width: 800, height: 1200, - uri: '//archive.org/download/BookReader/img/page002.jpg' }, - { width: 800, height: 1200, - uri: '//archive.org/download/BookReader/img/page003.jpg' }, - ], - [ - { width: 800, height: 1200, - uri: '//archive.org/download/BookReader/img/page004.jpg' }, - { width: 800, height: 1200, - uri: '//archive.org/download/BookReader/img/page005.jpg' }, - ], +const MULTIPLE_REPEAT_LINES = ` + + + + “That + clay + and + chalk + mixture + which + I + see + upon + your + toe + + + caps + is + quite + distinctive.” + + + + + “That + is + easily + got.” + + + + + “That + is + not + always + so + easy.” + + + `; + +const FAKE_DIALOGUE = ` + + + + “Stolen.” + + + + + “My + own + seal.” + + + + + “Imitated.” + + + + + “My + photograph.” + + + + + “Bought.” + + + + + “My + photograph.” + + + + + “My + own + seal.” + + + +`; +document.body.innerHTML = '
'; +const br = window.br = new BookReader({ + data: [ + [ + { width: 800, height: 1200, + uri: '//archive.org/download/BookReader/img/page001.jpg' }, ], - }); - br.init(); + [ + { width: 800, height: 1200, + uri: '//archive.org/download/BookReader/img/page002.jpg' }, + { width: 800, height: 1200, + uri: '//archive.org/download/BookReader/img/page003.jpg' }, + ], + [ + { width: 800, height: 1200, + uri: '//archive.org/download/BookReader/img/page004.jpg' }, + { width: 800, height: 1200, + uri: '//archive.org/download/BookReader/img/page005.jpg' }, + ], + ], +}); +br.init(); + +describe("Generic tests", () => { afterEach(() => { sinon.restore(); @@ -141,3 +231,159 @@ describe("Generic tests", () => { }, LONG_PRESS_DURATION); }); }); + + + +describe("TextFragment tests", () => { + + afterEach(() => { + sinon.restore(); + $('.BRtextLayer').remove(); + window.getSelection().direction = null; + }); + + test("Forward and Backward selection without prefix", async () => { + const $container = br.refs.$brContainer; + sinon.stub(br.plugins.textSelection, "getPageText") + .returns($(new DOMParser().parseFromString(FAKE_XML_MULT_LINES, "text/xml"))); + await br.plugins.textSelection.createTextLayer({ $container, page: {index: 3, width: 100, height: 100 }}); + + const forwardRange = document.createRange(); + forwardRange.setStart($container.find(".BRwordElement")[0].firstChild, 0); + forwardRange.setEnd($container.find(".BRwordElement")[2].firstChild, 1); + const backwardRange = document.createRange(); + backwardRange.setStart($container.find(".BRwordElement")[2].firstChild, 0); + backwardRange.setEnd($container.find(".BRwordElement")[2].firstChild, 5); + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(forwardRange); + const forwardTest = createParam(selection, document.querySelector('.BRtextLayer')); + + selection.removeAllRanges(); + backwardRange.collapse(false); + selection.addRange(backwardRange); + selection.extend(forwardRange.startContainer, 0); + window.getSelection().direction = 'backward'; + const backwardTest = createParam(selection, document.querySelector('.BRtextLayer')); + + expect(forwardTest).toMatch("text=way,false"); + expect(backwardTest).toMatch(forwardTest); + }); + + test("Forward and Backward selection without suffix", async () => { + const $container = br.refs.$brContainer; + sinon.stub(br.plugins.textSelection, "getPageText") + .returns($(new DOMParser().parseFromString(FAKE_XML_MULT_LINES, "text/xml"))); + await br.plugins.textSelection.createTextLayer({ $container, page: {index: 3, width: 100, height: 100 }}); + + const forwardRange = document.createRange(); + forwardRange.setStart($container.find(".BRwordElement")[30].firstChild, 0); + forwardRange.setEnd($container.find(".BRwordElement")[32].firstChild, 1); + const backwardRange = document.createRange(); + backwardRange.setStart($container.find(".BRwordElement")[32].firstChild, 0); + backwardRange.setEnd($container.find(".BRwordElement")[32].firstChild, 1); + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(forwardRange); + const forwardTest = createParam(selection, document.querySelector('.BRtextLayer')); + selection.removeAllRanges(); + backwardRange.collapse(false); + selection.addRange(backwardRange); + selection.extend(forwardRange.startContainer, 0); + window.getSelection().direction = 'backward'; + + const backwardTest = createParam(selection, document.querySelector('.BRtextLayer')); + + expect(forwardTest).toMatch("text=various,lastWord"); + expect(backwardTest).toMatch(forwardTest); + }); + + test("Should be able to differentiate overlapping matches", async () => { + const $container = br.refs.$brContainer; + sinon.stub(br.plugins.textSelection, "getPageText") + .returns($(new DOMParser().parseFromString(MULTIPLE_REPEAT_LINES, "text/xml"))); + await br.plugins.textSelection.createTextLayer({ $container, page: {index: 1, width: 100, height: 100 }}); + + const rangeBefore = document.createRange(); + rangeBefore.setStart($($container.find('.BRparagraphElement')[1]).find(".BRwordElement")[0].firstChild, 0); + rangeBefore.setEnd($($container.find('.BRparagraphElement')[1]).find(".BRwordElement")[1].firstChild, 2); + + const sameKeyHighlightRange = document.createRange(); + sameKeyHighlightRange.setStart($($container.find('.BRparagraphElement')[0]).find(".BRwordElement")[0].firstChild, 0); + sameKeyHighlightRange.setEnd($($container.find('.BRparagraphElement')[0]).find(".BRwordElement")[12].firstChild, 2); + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(rangeBefore); + const multipleHighlights = createParam(selection, document.querySelector('.BRtextLayer')); + + selection.removeAllRanges(); + selection.addRange(sameKeyHighlightRange); + const similarHighlight = createParam(selection, document.querySelector('.BRtextLayer')); + + expect(multipleHighlights).toBe(`text=is%20quite%20distinctive.%E2%80%9D-,%E2%80%9CThat%20is,-easily%20got.%E2%80%9D`); + expect(multipleHighlights).not.toBe(similarHighlight); + }); + + test("Create text fragment without spanning multiple new lines", async () => { + const $container = br.refs.$brContainer; + sinon.stub(br.plugins.textSelection, "getPageText") + .returns($(new DOMParser().parseFromString(FAKE_DIALOGUE, "text/xml"))); + await br.plugins.textSelection.createTextLayer({ $container, page: {index: 1, width: 100, height: 100 }}); + + const rangeBefore = document.createRange(); + + rangeBefore.setStart($($container.find(".BRparagraphElement")[1]).find(".BRwordElement")[0].firstChild, 0); + rangeBefore.setEnd($($container.find(".BRparagraphElement")[1]).find(".BRwordElement")[2].firstChild, 6); + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(rangeBefore); + + const multiLineBehavior = createParam(selection, document.querySelector('.BRtextLayer')); + // “Stolen.”-,“My own seal.”,-“Imitated.” + expect(multiLineBehavior).toMatch("text=%E2%80%9CStolen.%E2%80%9D-,%E2%80%9CMy%20own%20seal.%E2%80%9D,-%E2%80%9CImitated.%E2%80%9D"); + }); + + test("Prefix/suffix exists even with < 3 words", async () => { + const $container = br.refs.$brContainer; + sinon.stub(br.plugins.textSelection, "getPageText") + .returns($(new DOMParser().parseFromString(FAKE_DIALOGUE, "text/xml"))); + await br.plugins.textSelection.createTextLayer({ $container, page: {index: 1, width: 100, height: 100 }}); + + const rangeBefore = document.createRange(); + rangeBefore.setStart($($container.find(".BRparagraphElement")[3]).find(".BRwordElement")[0].firstChild, 0); + rangeBefore.setEnd($($container.find(".BRparagraphElement")[3]).find(".BRwordElement")[1].firstChild, 12); + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(rangeBefore); + + // “Imitated.”-, “My photograph.”,-“Bought.” + const endShortSuffix = createParam(selection, document.querySelector('.BRtextLayer')); + + expect(endShortSuffix).toMatch("text=%E2%80%9CImitated.%E2%80%9D-,%E2%80%9CMy%20photograph.%E2%80%9D,-%E2%80%9CBought.%E2%80%9D"); + }); + + test("Able to match one non-unique highlighted word", async() => { + const $container = br.refs.$brContainer; + sinon.stub(br.plugins.textSelection, "getPageText") + .returns($(new DOMParser().parseFromString(FAKE_DIALOGUE, "text/xml"))); + await br.plugins.textSelection.createTextLayer({ $container, page: {index: 1, width: 100, height: 100 }}); + + const rangeBefore = document.createRange(); + rangeBefore.setStart($($container.find(".BRparagraphElement")[3]).find(".BRwordElement")[0].firstChild, 0); + rangeBefore.setEnd($($container.find(".BRparagraphElement")[3]).find(".BRwordElement")[0].firstChild, 3); + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(rangeBefore); + + // “Imitated.”-, “My,-photograph.” + const singleTextFragment = createParam(selection, document.querySelector('.BRtextLayer')); + + expect(singleTextFragment).toMatch("text=%E2%80%9CImitated.%E2%80%9D-,%E2%80%9CMy,-photograph.%E2%80%9D"); + }); +});