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 = '