Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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
10 changes: 7 additions & 3 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 @@ -1971,20 +1974,21 @@ BookReader.prototype.queryStringFromParams = function(
newParams.set('q', params.search);
}

let textFragmentParam = '';
/** @type {BookReaderTextFragment | null} */
let textFragmentParam = null;
// Need to pull out text separately to avoid the spaces becoming encoded as +, which
// 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.toUrlString();
}
Comment thread
cdrini marked this conversation as resolved.
if (result) result = '?' + result;

Expand Down
4 changes: 2 additions & 2 deletions src/BookReader/utils/SelectionObserver.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export class SelectionObserver {

/**
* @param {string} selector
* @param {function('started' | 'cleared' | 'focusChanged', HTMLElement): any} handler
* @param {function('started' | 'cleared' | 'changed', HTMLElement): any} handler
*/
constructor(selector, handler) {
this.selector = selector;
Expand Down Expand Up @@ -42,7 +42,7 @@ export class SelectionObserver {

if (this.selecting && (this.lastKnownFocusNode != sel.focusNode || sel.toString() && !sel.isCollapsed)) {
this.lastKnownFocusNode = sel.focusNode;
this.handler('focusChanged', this.target);
this.handler('changed', this.target);
}

if (this.selecting && (sel.isCollapsed || !sel.toString() || !$(sel.anchorNode).closest(this.selector)[0])) {
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;
}
36 changes: 31 additions & 5 deletions src/plugins/plugin.experiments.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import { sleep } from '../BookReader/utils.js';
// @ts-ignore
const BookReader = /** @type {typeof import('@/src/BookReader.js').default} */(window.BookReader);

/** @typedef {'copyLinkToHighlight' | 'annotateHighlight' | 'translate' | 'hypothesis'} ExperimentName */

class ExperimentModel {
/** @type {string} test */
/** @type {ExperimentName} */
name;
/** @type {string} */
title;
Expand Down Expand Up @@ -58,13 +60,29 @@ 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 }) {
this.br.plugins.textSelection.enableSelectionMenu();
if (manual) {
this.br.plugins.textSelection.textSelectionManager.selectMenu.copyLinkToHighlightEnabled = true;
}
}
async disable() {
this.br.plugins.textSelection.textSelectionManager.selectMenu.copyLinkToHighlightEnabled = false;
}
}(),
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 }) {
if (manual) {
this.br.plugins.textSelection.textSelectionManager.selectMenu.highlightAnnotationEnabled = true;
}
}
async disable() {
sleep(0).then(() => {
Expand Down Expand Up @@ -150,6 +168,14 @@ export class ExperimentsPlugin extends BookReaderPlugin {
this._render();
}

/**
* @param {ExperimentName} experimentName
*/
isEnabled(experimentName) {
const experiment = this.allExperiments.find(exp => exp.name === experimentName);
return experiment?.enabled;
}

_loadExperimentStates() {
const savedStates = JSON.parse(localStorage.getItem(this.options.localStorageKey) || '{}');
this.allExperiments.forEach(experiment => {
Expand Down
58 changes: 4 additions & 54 deletions src/plugins/plugin.text_selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,16 @@ export class TextSelectionPlugin extends BookReaderPlugin {
if (!this.options.enabled) return;

this.br.on('pageVisible', (_, {pageContainerEl}) => {
if (pageContainerEl.querySelector('.BRtextLayer')) {
this.br.trigger('textLayerVisible', {pageContainerEl});
const textLayer = pageContainerEl.querySelector('.BRtextLayer');
if (textLayer) {
this.br.trigger('textLayerVisible', {pageContainerEl, textLayer});
}
});

this.loadData();
this.textSelectionManager.init();
}

enableSelectionMenu() {
this.textSelectionManager.selectionMenuEnabled = true;
this.textSelectionManager.renderSelectionMenu();
}

/**
* @override
* @param {PageContainer} pageContainer
Expand Down Expand Up @@ -210,7 +206,7 @@ export class TextSelectionPlugin extends BookReaderPlugin {

// Check if page is visible
if ($container.hasClass('BRpage-visible')) {
this.br.trigger('textLayerVisible', {pageContainerEl: $container[0]});
this.br.trigger('textLayerVisible', {pageContainerEl: $container[0], textLayer});
}
}

Expand Down Expand Up @@ -575,49 +571,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);
}
}
Loading
Loading