Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8ed59a3
Shift from text param to local highlight
schu96 Apr 20, 2026
ed1c9a6
Functionality change to createTextFragmentUrlParam, highlight quotes …
schu96 Apr 27, 2026
8402b81
Move Highlight and Annotations behind experiment
schu96 Apr 29, 2026
896b0ac
Push up changes for review
schu96 May 6, 2026
00770ea
Remove old text fragment url hash approach + code review tweaks
cdrini May 6, 2026
8fc0785
Copy link to highlight param handling revision
schu96 May 14, 2026
b6f396e
Switch to a single text argument, with optional page index
cdrini May 19, 2026
cf1183a
Move BookReaderTextFragment to TextSelectionManager
cdrini May 19, 2026
4867cd2
Finalize move of walkBetweenNodes function into TextSelectionManager
cdrini May 19, 2026
40b28d8
Tighten up localstorage reading + use stock markRange method from pol…
cdrini May 20, 2026
fcb49ca
Apply code review feedback
cdrini May 20, 2026
f5d0f6b
Tweak experiment wording + disable saved highlights for now
cdrini May 20, 2026
be7c7b7
Keep textLayer visible during scrolling/animation if it has annotations
cdrini May 20, 2026
aec7bba
Always include page number in &text parameter to avoid awkward experi…
cdrini May 20, 2026
523ff97
Fix copy link not working when selecting multline paragraph
cdrini May 20, 2026
e6b5710
Apply code review feedback
cdrini May 27, 2026
79fdd34
Apply code review feedback
cdrini May 27, 2026
929aa2e
Apply more code review feedback
cdrini May 27, 2026
e6522c0
More small code review fixes
cdrini May 29, 2026
dbb786c
Handle selection ending at start of non-TextNode
cdrini May 29, 2026
a273ad7
Add some tests
cdrini May 29, 2026
074b4ac
Fix cannot select target text
cdrini May 29, 2026
02b848c
Fix "Copied" message shorter than actual button
cdrini May 29, 2026
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
7 changes: 5 additions & 2 deletions src/BookReader.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { ModeThumb } from './BookReader/ModeThumb.js';
import { ImageCache } from './BookReader/ImageCache.js';
import { PageContainer } from './BookReader/PageContainer.js';
import { NAMED_REDUCE_SETS } from './BookReader/ReduceSet.js';
import {BookReaderTextFragment} from './util/TextSelectionManager.js';

/**
* BookReader
Expand Down Expand Up @@ -645,6 +646,8 @@ BookReader.prototype.init = function() {

const params = this.initParams();

// Make a copy of it
this.firstParams = JSON.parse(JSON.stringify(params));
this.firstIndex = params.index ? params.index : 0;

// Setup Navbars and other UI
Expand Down Expand Up @@ -1976,15 +1979,15 @@ BookReader.prototype.queryStringFromParams = function(
// the browser seems not to handle with the text fragment
if (newParams.get('text')) {
newParams.delete('text');
textFragmentParam = `text=${this.urlPlugin.retrieveTextFragment(currQueryString)}`;
textFragmentParam = BookReaderTextFragment.fromUrl(currQueryString, this.book, this.firstParams.index);
}

// https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/toString
// Note: This method returns the query string without the question mark.
let result = newParams.toString();
if (textFragmentParam) {
if (result) result += '&';
result += textFragmentParam;
result += textFragmentParam.toString();
Comment thread
cdrini marked this conversation as resolved.
Outdated
}
Comment thread
cdrini marked this conversation as resolved.
if (result) result = '?' + result;

Expand Down
7 changes: 5 additions & 2 deletions src/css/_BRpages.scss
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,11 @@ svg.BRPageLayer {
}

// Hides page layers during page flip animation
.BRpageFlipping .BRtextLayer {
display: none;
.BRpageFlipping {
// If the text layer has an annotation, don't hide it
.BRpagecontainer:not(:has(hypothesis-highlight)):not(:has(.BRhighlight)) .BRtextLayer {
display: none;
}
}

.br-mode-2up__root {
Expand Down
24 changes: 15 additions & 9 deletions src/css/_TextSelection.scss
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,6 @@
}
}

// Style URI TextFragments, eg #:~:text=example
.BRtextLayer ::target-text {
// Similar colour to the default one used in Safari, Firefox. Note Chrome uses a purple colour
background-color: hsla(45, 80%, 66%, 0.6);
color: transparent;
}

.BRtranslateLayer ::selection {
background: hsla(210, 74%, 62%, 0.4);
}
Expand All @@ -82,7 +75,7 @@
// Hide text layer for performance during zooming & scrolling
.BRsmooth-zooming, .BRscrolling-active {
// If the text layer has an annotation, don't hide it
.BRpagecontainer:not(.BRpagecontainer--hasSelection):not(:has(hypothesis-highlight)) .BRtextLayer {
.BRpagecontainer:not(.BRpagecontainer--hasSelection):not(:has(hypothesis-highlight)):not(:has(.BRhighlight)) .BRtextLayer {
display: none;
}
}
Expand Down Expand Up @@ -209,4 +202,17 @@
width: auto;
margin-left: 4px;
opacity: 1;
}
}

.BRtextLayer .BRhighlight {
background-color: yellow;
pointer-events: all;
color: transparent;
}

// Style URI TextFragments, eg #:~:text=example
.BRtextLayer .BRhighlight--target-text, .BRtextLayer ::target-text {
// Similar colour to the default one used in Safari, Firefox. Note Chrome uses a purple colour
background-color: hsla(45, 80%, 66%, 0.6);
color: transparent;
}
20 changes: 17 additions & 3 deletions src/plugins/plugin.experiments.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,8 @@ export class ExperimentsPlugin extends BookReaderPlugin {
allExperiments = [
new class extends ExperimentModel {
name = 'copyLinkToHighlight';
title = 'Copy to Selection URL';
description = 'Share text selection via URL';
learnMore = 'none';
title = 'Copy Link to Highlight';
description = 'Shareable link to a text selection';
icon = null;
enabled = false;
async enable ({ manual = false }) {
Expand All @@ -72,6 +71,21 @@ export class ExperimentsPlugin extends BookReaderPlugin {
});
}
}(),
new class extends ExperimentModel {
name = 'annotateHighlight';
title = 'Highlight and annotate';
description = 'Create private highlights and annotations for this book';
icon = null;
enabled = false;
async enable ({ manual = false }) {
this.br.plugins.textSelection.enableHighlightMenu();
}
async disable() {
sleep(0).then(() => {
window.location.reload();
});
}
}(),
new class extends ExperimentModel {
name = 'translate';
title = 'Translate Plugin';
Expand Down
51 changes: 5 additions & 46 deletions src/plugins/plugin.text_selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ export class TextSelectionPlugin extends BookReaderPlugin {
this.textSelectionManager.renderSelectionMenu();
}

enableHighlightMenu() {
this.textSelectionManager.highlightAnnotationEnabled = true;
this.textSelectionManager.renderHighlightMenu();
}

/**
* @override
* @param {PageContainer} pageContainer
Expand Down Expand Up @@ -575,49 +580,3 @@ class Rect {
get top() { return this.y; }
get left() { return this.x; }
}

/**
* Depth traverse the DOM tree starting at `start`, and ending at `end`.
* @param {Node} start
* @param {Node} end
* @returns {Generator<Node>}
*/
export function* walkBetweenNodes(start, end) {
let done = false;

/**
* @param {Node} node
*/
function* walk(node, {children = true, parents = true, siblings = true} = {}) {
if (node === end) {
done = true;
yield node;
return;
}

// yield self
yield node;

// First iterate children (depth-first traversal)
if (children && node.firstChild) {
yield* walk(node.firstChild, {children: true, parents: false, siblings: true});
if (done) return;
}

// Then iterate siblings
if (siblings) {
for (let sib = node.nextSibling; sib; sib = sib.nextSibling) {
yield* walk(sib, {children: true, parents: false, siblings: false});
if (done) return;
}
}

// Finally, move up the tree
if (parents && node.parentNode) {
yield* walk(node.parentNode, {children: false, parents: true, siblings: true});
if (done) return;
}
}

yield* walk(start);
}
17 changes: 2 additions & 15 deletions src/plugins/url/UrlPlugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,22 +190,9 @@ export class UrlPlugin {
}

/**
* Get the hash out of the current URL. Also augments it with the text
* from the main part of the URL, since that is not readable by JS
* from the actual hash
* @returns
* Get the hash out of the current URL
*/
getHash() {
const text = this.retrieveTextFragment(window.location.search);
const textFragment = text ? `:~:text=${text[0]}` : '';
return `${window.location.hash.slice(1)}${textFragment}`;
}

/**
* @param {string} urlString
* @returns {string}
*/
retrieveTextFragment(urlString) {
return urlString.match(/(?<=[&?]?text=)[^&]*/);
return window.location.hash.slice(1);
}
}
83 changes: 24 additions & 59 deletions src/plugins/url/plugin.url.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/* global BookReader */

import { UrlPlugin } from "./UrlPlugin.js";
import { sleep } from "../../BookReader/utils.js";

import { BookReaderTextFragment } from "../../util/TextSelectionManager.js";
import { renderHighlight } from "../../util/TextSelectionManager.js";
/**
* Plugin for URL management in BookReader
* Note read more about the url "fragment" here:
Expand Down Expand Up @@ -43,10 +43,6 @@ BookReader.prototype.setup = (function(super_) {
this.locationPollId = null;
this.oldLocationHash = null;
this.oldUserHash = null;
// Should include the :~:text= prefix
this.textFragment = null;
// Tracks the original textFragment page num when first loaded
this.textFragmentPage = null;
};
})(BookReader.prototype.setup);

Expand Down Expand Up @@ -146,22 +142,16 @@ BookReader.prototype.urlUpdateFragment = function() {
}, {});

// eg 'page/3/mode/2up'; no query params (in hash mode, it might have /search/term)
// Does NOT have the :~:text fragment
const newFragment = this.fragmentFromParams(params, this.options.urlMode);
const newFragmentWithSlash = newFragment === '' ? '' : `/${newFragment}`;
// eg 'page/3/mode/2up'; no query params
// WILL CONTAIN the :~:text fragment in hash mode (!)
const currFragment = this.urlReadFragment();
// This should have both ?q=foo&text=bar (and any other params) as an encoded string
const currQueryString = this.getLocationSearch();
// Eg ?q=foo&text=bar; only query params, no fragment
const newQueryString = this.queryStringFromParams(params, currQueryString, this.options.urlMode);

// NOTE: If ?text is in the URL, we will fire fragment change events on every render; which is
// not desireable, but currently don't have a way to handle re-writing ?text to the hash text
// fragment form, :~:text=foo.
const hasTextParam = this.urlPlugin.retrieveTextFragment(currQueryString);
if (currFragment === newFragment && currQueryString === newQueryString && !hasTextParam) {
// Avoid infinite loop if there are no changes
if (currFragment === newFragment && currQueryString === newQueryString) {
return;
}

Expand All @@ -170,19 +160,12 @@ BookReader.prototype.urlUpdateFragment = function() {
this.options.urlMode = 'hash';
} else {
const baseWithoutSlash = this.options.urlHistoryBasePath.replace(/\/+$/, '');
const textFragment = this.urlPlugin.retrieveTextFragment(newQueryString);
this.targetTextFragment = BookReaderTextFragment.fromUrl(newQueryString, this.book, this.firstParams.index);
const newUrlPath = `${baseWithoutSlash}${newFragmentWithSlash}${newQueryString}`;
const extractedPage = this.urlPlugin.urlStringToUrlState(newFragmentWithSlash)?.page;
if (!this.textFragmentPage && textFragment) {
this.textFragmentPage = extractedPage ? extractedPage : null;
this.textFragment = `:~:text=${textFragment}`;
}

try {
window.history.replaceState({}, null, newUrlPath);
this.oldLocationHash = newFragment + newQueryString;
if (textFragment) {
this.oldLocationHash += `:~:text=${textFragment[0]}`;
}
} catch (e) {
// DOMException on Chrome when in sandboxed iframe
this.options.urlMode = 'hash';
Expand All @@ -192,22 +175,9 @@ BookReader.prototype.urlUpdateFragment = function() {

if (this.options.urlMode === 'hash') {
const newQueryStringSearch = this.urlParamsFiltersOnlySearch(this.readQueryString());
let textFragment = this.urlPlugin.retrieveTextFragment(this.readQueryString());
const extractedPage = this.urlPlugin.urlStringToUrlState(newFragmentWithSlash)?.page;

if (textFragment) {
textFragment = `:~:text=${textFragment[0]}`;
} else {
textFragment = '';
}
if (!this.textFragmentPage && textFragment) {
this.textFragmentPage = extractedPage ? extractedPage : null;
this.textFragment = textFragment;
} else if (this.textFragmentPage && extractedPage != this.textFragmentPage) {
textFragment = '';
}
window.location.replace('#' + newFragment + newQueryStringSearch + textFragment);
this.oldLocationHash = newFragment + newQueryStringSearch + textFragment;
this.targetTextFragment = BookReaderTextFragment.fromUrl(this.readQueryString(), this.book, this.firstParams.index);
window.location.replace('#' + newFragment + newQueryStringSearch);
this.oldLocationHash = newFragment + newQueryStringSearch;
}
};

Expand Down Expand Up @@ -245,31 +215,26 @@ BookReader.prototype.urlReadHashFragment = function() {
return window.location.hash.substr(1);
};
export class BookreaderUrlPlugin extends BookReader {
/** @type {BookReaderTextFragment} */
targetTextFragment;

init() {
if (this.options.enableUrlPlugin) {
this.urlPlugin = new UrlPlugin(this.options);
const location = this.getLocationSearch();
if (location.includes("text=")) {
this.on('textLayerVisible', async (_, {pageContainerEl}) => {
const visiblePageNum = pageContainerEl.getAttribute('data-page-num');

// Hack: More time mode 1up page "settle down" from user scrolling
await sleep(this.mode === 1 ? 900 : 100);

// No textFragment found or the textFragment stored doesn't match current visible page loaded
if (!this.textFragment || this.textFragmentPage !== visiblePageNum) return;
if (this.options.urlMode === 'history') {
window.location.replace(`#${this.textFragment}`);
} else {
// for urlMode hash, textFragment is stored in oldLocationHash already
window.location.replace(`#${this.oldLocationHash}`);
}
});
}

this.bind(BookReader.eventNames.PostInit, () => {
const { urlMode } = this.options;
if (this.targetTextFragment) {
this.on('textLayerVisible', async (_, {pageContainerEl}) => {
const pageIndex = this.targetTextFragment.pageNumber ? this.book.getPageIndex(this.targetTextFragment.pageNumber) : this.firstParams.index;
const hasTargetText = pageIndex === parseFloat(pageContainerEl.getAttribute('data-index'));
if (hasTargetText) {
const textLayer = pageContainerEl.querySelector('.BRtextLayer');
Comment thread
cdrini marked this conversation as resolved.
Outdated
renderHighlight(textLayer, this.targetTextFragment, 'BRhighlight--target-text');
}
});
}

if (urlMode === 'hash') {
if (this.options.urlMode === 'hash') {
this.urlPlugin.listenForHashChanges();
}
});
Expand Down
Loading
Loading