From d7d2699b2c396d291b508321f28c12b459c326ba Mon Sep 17 00:00:00 2001 From: Drini Cami Date: Fri, 13 Sep 2024 22:51:26 +0200 Subject: [PATCH 1/2] Experiment with text mode --- src/BookReader.js | 27 +++-- src/BookReader/BookModel.js | 19 +++ src/BookReader/Mode1UpLit.js | 60 ++++++---- src/BookReader/ModeText.js | 100 ++++++++++++++++ src/BookReader/ModeTextLit.js | 171 +++++++++++++++++++++++++++ src/BookReader/Navbar/Navbar.js | 10 +- src/BookReader/options.js | 6 + src/BookReader/utils.js | 10 ++ src/assets/icons/text.svg | 10 ++ src/css/_BRpages.scss | 94 ++++++++++++++- src/css/_icons.scss | 6 + src/plugins/plugin.text_selection.js | 33 +++++- 12 files changed, 509 insertions(+), 37 deletions(-) create mode 100644 src/BookReader/ModeText.js create mode 100644 src/BookReader/ModeTextLit.js create mode 100644 src/assets/icons/text.svg diff --git a/src/BookReader.js b/src/BookReader.js index c63d0835a..23c8bff36 100644 --- a/src/BookReader.js +++ b/src/BookReader.js @@ -38,6 +38,7 @@ import { BookModel } from './BookReader/BookModel.js'; import { Mode1Up } from './BookReader/Mode1Up.js'; import { Mode2Up } from './BookReader/Mode2Up.js'; import { ModeThumb } from './BookReader/ModeThumb'; +import { ModeText } from './BookReader/ModeText'; import { ImageCache } from './BookReader/ImageCache.js'; import { PageContainer } from './BookReader/PageContainer.js'; import { NAMED_REDUCE_SETS } from './BookReader/ReduceSet'; @@ -62,6 +63,8 @@ BookReader.constMode1up = 1; BookReader.constMode2up = 2; /** thumbnails view */ BookReader.constModeThumb = 3; +/** text view */ +BookReader.constModeText = 4; /** image cache */ BookReader.imageCache = null; @@ -117,6 +120,8 @@ BookReader.prototype.setup = function(options) { this.constMode2up = BookReader.constMode2up; /** thumbnails view */ this.constModeThumb = BookReader.constModeThumb; + /** text view */ + this.constModeText = BookReader.constModeText; // Private properties below. Configuration should be done with options. /** @type {number} TODO: Make private */ @@ -225,6 +230,7 @@ BookReader.prototype.setup = function(options) { mode1Up: new Mode1Up(this, this.book), mode2Up: new Mode2Up(this, this.book), modeThumb: new ModeThumb(this, this.book), + modeText: new ModeText(this, this.book), }; /** Stores classes which we want to expose (selectively) some methods as overridable */ @@ -235,6 +241,7 @@ BookReader.prototype.setup = function(options) { '_modes.mode1Up': this._modes.mode1Up, '_modes.mode2Up': this._modes.mode2Up, '_modes.modeThumb': this._modes.modeThumb, + '_modes.modeText': this._modes.modeText, }; /** Image cache for general image fetching */ @@ -285,6 +292,7 @@ Object.defineProperty(BookReader.prototype, 'activeMode', { 1: this._modes.mode1Up, 2: this._modes.mode2Up, 3: this._modes.modeThumb, + 4: this._modes.modeText, }[this.mode]; }, }); @@ -444,7 +452,7 @@ BookReader.prototype.readQueryString = function() { * Determines the initial mode for starting if a mode is not already * present in the params argument * @param {object} params - * @return {1 | 2 | 3} the initial mode + * @return {1 | 2 | 3 | 4} the initial mode */ BookReader.prototype.getInitialMode = function(params) { // if mobile breakpoint, we always show this.constMode1up mode @@ -1044,14 +1052,10 @@ BookReader.prototype.switchMode = function( // See https://bugs.edge.launchpad.net/gnubook/+bug/416682 // XXX maybe better to preserve zoom in each mode - if (this.constMode1up == mode) { - this._modes.mode1Up.prepare(); - } else if (this.constModeThumb == mode) { + if (this.constModeThumb == mode) { this.reduce = this.quantizeReduce(this.reduce, this.reductionFactors); - this._modes.modeThumb.prepare(); - } else { - this._modes.mode2Up.prepare(); } + this.activeMode.prepare?.(); if (!(this.suppressFragmentChange || suppressFragmentChange)) { this.trigger(BookReader.eventNames.fragmentChange); @@ -1190,7 +1194,7 @@ BookReader.prototype.exitFullScreen = async function () { */ BookReader.prototype.currentIndex = function() { // $$$ we should be cleaner with our idea of which index is active in 1up/2up - if (this.mode == this.constMode1up || this.mode == this.constModeThumb) { + if (this.mode == this.constMode1up || this.mode == this.constModeThumb || this.mode == this.constModeText) { return this.firstIndex; // $$$ TODO page in center of view would be better } else if (this.mode == this.constMode2up) { // Only allow indices that are actually present in book @@ -1380,6 +1384,9 @@ BookReader.prototype.bindNavigationHandlers = function() { twopg: () => { this.switchMode(self.constMode2up); }, + textpg: () => { + this.switchMode(self.constModeText); + }, zoom_in: () => { this.trigger(BookReader.eventNames.stop); this.zoom(1); @@ -1783,6 +1790,8 @@ BookReader.prototype.paramsFromFragment = function(fragment) { params.mode = this.constMode2up; } else if ('thumb' == urlHash['mode']) { params.mode = this.constModeThumb; + } else if ('text' == urlHash['mode']) { + params.mode = this.constModeText; } // Index and page @@ -1842,6 +1851,8 @@ BookReader.prototype.fragmentFromParams = function(params, urlMode = 'hash') { fragments.push('mode', '2up'); } else if (params.mode == this.constModeThumb) { fragments.push('mode', 'thumb'); + } else if (params.mode == this.constModeText) { + fragments.push('mode', 'text'); } else { throw 'fragmentFromParams called with unknown mode ' + params.mode; } diff --git a/src/BookReader/BookModel.js b/src/BookReader/BookModel.js index fbd1325ef..55bcafd98 100644 --- a/src/BookReader/BookModel.js +++ b/src/BookReader/BookModel.js @@ -491,6 +491,15 @@ export class PageModel { .next().value; } + /** Gets the next `count` pages */ + *iterNext({ combineConsecutiveUnviewables = false, count = 1 } = {}) { + for (const page of this.book.pagesIterator({ start: this.index + 1, combineConsecutiveUnviewables })) { + if (count <= 0) return; + yield page; + count--; + } + } + /** * @param {object} [arg0] * @param {boolean} [arg0.combineConsecutiveUnviewables] Whether to only yield the first page @@ -514,6 +523,16 @@ export class PageModel { } } + /** Get the `count` previous pages */ + *iterPrev({ combineConsecutiveUnviewables = false, count = 1 } = {}) { + let page = this; + for (let i = 0; i < count; i++) { + page = page.findPrev({ combineConsecutiveUnviewables }); + if (!page) return; + yield page; + } + } + /** * @param {object} [arg0] * @param {boolean} [arg0.combineConsecutiveUnviewables] Whether to only yield the first page diff --git a/src/BookReader/Mode1UpLit.js b/src/BookReader/Mode1UpLit.js index c5f66bf73..89c580eb9 100644 --- a/src/BookReader/Mode1UpLit.js +++ b/src/BookReader/Mode1UpLit.js @@ -109,6 +109,9 @@ export class Mode1UpLit extends LitElement { /************** CONSTANT PROPERTIES **************/ + /** Number of pages to render around the visible pages */ + BUFFER_AROUND = 2; + /** Vertical space between/around the pages in inches */ SPACING_IN = 0.2; @@ -187,8 +190,9 @@ export class Mode1UpLit extends LitElement { this.worldDimensions = this.computeWorldDimensions(); this.pageTops = this.computePageTops(this.pages, this.SPACING_IN); } - if (changedProps.has('visibleRegion')) { - this.visiblePages = this.computeVisiblePages(); + // FIXME: `actualPositions` should be only in ModeTextLit + if (changedProps.has('visibleRegion') || changedProps.has('actualPositions')) { + this.visiblePages = this.pages.filter(page => this.isPageVisible(page)); } if (changedProps.has('visiblePages')) { this.throttledUpdateRenderedPages(); @@ -260,29 +264,32 @@ export class Mode1UpLit extends LitElement { } /** @param {PageModel} page */ - renderPage = (page) => { + getPageTransform(page) { const wToR = this.coordSpace.worldUnitsToRenderedPixels; - const wToV = this.coordSpace.worldUnitsToVisiblePixels; - const containerWidth = this.coordSpace.visiblePixelsToWorldUnits(this.htmlDimensionsCacher.clientWidth); - const width = wToR(page.widthInches); - const height = wToR(page.heightInches); + const containerWidth = this.coordSpace.visiblePixelsToWorldUnits(this.htmlDimensionsCacher.clientWidth); const left = Math.max(this.SPACING_IN, (containerWidth - page.widthInches) / 2); const top = this.pageTops[page.index]; + return `translate(${wToR(left)}px, ${wToR(top)}px)`; + } + + /** @param {PageModel} page */ + renderPage(page) { + const wToR = this.coordSpace.worldUnitsToRenderedPixels; + const wToV = this.coordSpace.worldUnitsToVisiblePixels; - const transform = `translate(${wToR(left)}px, ${wToR(top)}px)`; const pageContainerEl = this.createPageContainer(page) .update({ dimensions: { - width, - height, + width: wToR(page.widthInches), + height: wToR(page.heightInches), top: 0, left: 0, }, reduce: page.width / wToV(page.widthInches), }).$container[0]; - pageContainerEl.style.transform = transform; + pageContainerEl.style.transform = this.getPageTransform(page); pageContainerEl.classList.toggle('BRpage-visible', this.visiblePages.includes(page)); return pageContainerEl; } @@ -314,10 +321,12 @@ export class Mode1UpLit extends LitElement { computeRenderedPages() { // Also render 1 page before/after // @ts-ignore TS doesn't understand the filtering out of null values + const first = this.visiblePages[0]; + const last = this.visiblePages[this.visiblePages.length - 1]; return [ - this.visiblePages[0]?.prev, + ...(first?.iterPrev({ combineConsecutiveUnviewables: true, count: this.BUFFER_AROUND }) ?? []), ...this.visiblePages, - this.visiblePages[this.visiblePages.length - 1]?.next, + ...(last?.iterNext({ combineConsecutiveUnviewables: true, count: this.BUFFER_AROUND }) ?? []), ] .filter(p => p) // Never render more than 10 pages! Usually means something is wrong @@ -332,11 +341,13 @@ export class Mode1UpLit extends LitElement { /** * @param {PageModel[]} pages * @param {number} spacing + * @param {number} [initialTop] + * @param {Record} [inPlace] */ - computePageTops(pages, spacing) { + computePageTops(pages, spacing, initialTop = spacing, inPlace = null) { /** @type {{ [pageIndex: string]: number }} */ - const result = {}; - let top = spacing; + const result = inPlace || {}; + let top = initialTop; for (const page of pages) { result[page.index] = top; top += page.heightInches + spacing; @@ -363,15 +374,16 @@ export class Mode1UpLit extends LitElement { }; } - computeVisiblePages() { - return this.pages.filter(page => { - const PT = this.pageTops[page.index]; - const PB = PT + page.heightInches; + /** + * @param {PageModel} page + */ + isPageVisible(page) { + const PT = this.pageTops[page.index]; + const PB = PT + page.heightInches; - const VT = this.visibleRegion.top; - const VB = VT + this.visibleRegion.height; - return PT <= VB && PB >= VT; - }); + const VT = this.visibleRegion.top; + const VB = VT + this.visibleRegion.height; + return PT <= VB && PB >= VT; } /************** INPUT HANDLERS **************/ diff --git a/src/BookReader/ModeText.js b/src/BookReader/ModeText.js new file mode 100644 index 000000000..72ec1245a --- /dev/null +++ b/src/BookReader/ModeText.js @@ -0,0 +1,100 @@ +// @ts-check +import { ModeTextLit } from './ModeTextLit.js'; +/** @typedef {import('../BookReader.js').default} BookReader */ +/** @typedef {import('./BookModel.js').BookModel} BookModel */ +/** @typedef {import('./BookModel.js').PageIndex} PageIndex */ + +export class ModeText { + /** + * @param {BookReader} br + * @param {BookModel} bookModel + */ + constructor(br, bookModel) { + this.br = br; + this.book = bookModel; + this.modeTextLit = new ModeTextLit(bookModel, br); + + /** @private */ + this.$el = $(this.modeTextLit) + // We CANNOT use `br-mode-1up` as a class, because it's the same + // as the name of the web component, and the webcomponents polyfill + // uses the name of component as a class for style scoping 😒 + .addClass('br-mode-1up__root BRmode1up br-mode-text__root BRmodeTextLit'); + + /** Has mode1up ever been rendered before? */ + this.everShown = false; + } + + // TODO: Might not need this anymore? Might want to delete. + /** @private */ + get $brContainer() { return this.br.refs.$brContainer; } + + /** + * This is called when we switch to one page view + */ + prepare() { + const startLeaf = this.br.currentIndex(); + this.$brContainer + .empty() + .css({ overflow: 'hidden' }) + .append(this.$el); + + // Need this in a setTimeout so that it happens after the browser has _actually_ + // appended the element to the DOM + setTimeout(async () => { + if (!this.everShown) { + this.modeTextLit.initFirstRender(startLeaf); + this.everShown = true; + this.modeTextLit.requestUpdate(); + await this.modeTextLit.updateComplete; + } + this.modeTextLit.jumpToIndex(startLeaf); + setTimeout(() => { + // Must explicitly call updateVisibleRegion, since no + // scroll event seems to fire. + this.modeTextLit.updateVisibleRegion(); + }); + }); + this.br.updateBrClasses(); + } + + /** + * BREAKING CHANGE: No longer supports pageX/pageY + * @param {PageIndex} index + * @param {number} [pageX] x position on the page (in pixels) to center on + * @param {number} [pageY] y position on the page (in pixels) to center on + * @param {boolean} [noAnimate] + */ + jumpToIndex(index, pageX, pageY, noAnimate) { + // Only smooth for small distances + const distance = Math.abs(this.br.currentIndex() - index); + const smooth = !noAnimate && distance > 0 && distance <= 4; + this.modeTextLit.jumpToIndex(index, { smooth }); + } + + /** + * @param {'in' | 'out'} direction + */ + zoom(direction) { + throw new Error('Not implemented'); + switch (direction) { + case 'in': + this.modeTextLit.zoomIn(); + break; + case 'out': + this.modeTextLit.zoomOut(); + break; + default: + console.error(`Unsupported direction: ${direction}`); + } + } + + /** + * Resize the current one page view + * Note this calls drawLeafs + */ + resizePageView() { + this.modeTextLit.htmlDimensionsCacher.updateClientSizes(); + this.modeTextLit.requestUpdate(); + } +} diff --git a/src/BookReader/ModeTextLit.js b/src/BookReader/ModeTextLit.js new file mode 100644 index 000000000..17e15f800 --- /dev/null +++ b/src/BookReader/ModeTextLit.js @@ -0,0 +1,171 @@ +// @ts-check +import { customElement, property } from 'lit/decorators.js'; +import { Mode1UpLit } from "./Mode1UpLit"; +import { sleep } from './utils'; +/** @typedef {import('./BookModel').PageIndex} PageIndex */ +/** @typedef {import('./BookModel').PageModel} PageModel */ + +@customElement('br-mode-text') +export class ModeTextLit extends Mode1UpLit { + /** + * Whereas `pageTops` from `Mode1UpLit` is the position of the + * physical pages, because when we render the pages as text we + * do not know the exact height of the text until after we render + * it, the values in `pageTaps` are essentially "estimates". The + * _actual_ positions are stored in `actualPositions` as the + * pages are rendered. + * @type {Record} in pixels + **/ + @property({ type: Object }) + actualPositions = {}; + + // override to prevent scale from being set + get scale() { return 1; } + set scale(value) { + // ignore + } + + BUFFER_AROUND = 5; + SPACING_IN = 0; + + /** @override */ + updated(changedProps) { + if (changedProps.has('pages')) { + this.actualPositions = { + 0: { top: 0 } + }; + } + if (changedProps.has('renderedPages')) { + // debugger; + this.updateComplete + .then(() => Promise.all(this.visiblePages.map(p => this.pageContainerCache[p.index].textSelectionLoadingComplete))) + .then(() => { + // Note the exact tops of the rendered pages + this.updateActualPositions(this.visiblePages); + }); + } + + super.updated(changedProps); + } + + /** + * @param {PageModel[]} pages + */ + updateActualPositions(pages) { + const wToR = this.coordSpace.worldUnitsToRenderedPixels; + let changed = false; + for (const p of pages) { + let pos = this.actualPositions[p.index]; + /** @type {DOMRect} */ + let rect = null; + /** @type {PageModel | void} */ + let prev = null; + /** @type {PageModel | void} */ + let next = null; + + let setBottom = false; + let setTop = false; + + if (pos) { + if (typeof pos.bottom === 'undefined') { + rect ||= this.pageContainerCache[p.index].$container[0].getBoundingClientRect(); + pos.bottom = pos.top + rect.height; + setBottom = true; + changed = true; + } + + if (typeof pos.top === 'undefined') { + rect ||= this.pageContainerCache[p.index].$container[0].getBoundingClientRect(); + pos.top = pos.bottom - rect.height; + setTop = true; + changed = true; + } + } else { + rect ||= this.pageContainerCache[p.index].$container[0].getBoundingClientRect(); + pos = this.actualPositions[p.index] = { + top: rect.top, + bottom: rect.bottom, + }; + setTop = setBottom = true; + changed = true; + } + + // Also set the top of the next page + if (setBottom) { + next ||= p.findNext({ combineConsecutiveUnviewables: true }); + if (next) { + this.actualPositions[next.index] ||= {}; + this.actualPositions[next.index].top = pos.bottom + wToR(this.SPACING_IN); + // this.pageTops[next.index] = this.actualPositions[next.index].top; + changed = true; + } + } + + if (setTop) { + // const rToW = this.coordSpace.renderedPixelsToWorldUnits; + // this.computePageTops(this.pages.filter(p2 => p2.index >= p.index), this.SPACING_IN, rToW(pos.top), this.pageTops); + + prev ||= p.findPrev({ combineConsecutiveUnviewables: true }); + if (prev) { + this.actualPositions[prev.index] ||= {}; + this.actualPositions[prev.index].bottom = pos.top - wToR(this.SPACING_IN); + changed = true; + } + } + } + + if (changed) { + this.requestUpdate('actualPositions'); + } + } + + getPageTransform(page) { + if (page.index in this.actualPositions && typeof this.actualPositions[page.index].top !== 'undefined') { + return `translate(0, ${this.actualPositions[page.index].top}px)`; + } else { + const transform = super.getPageTransform(page); + // ignore the x translation + return transform.replace(/translate\(\s*[^,]+,\s*([^,]+)\)/, 'translate(0, $1)'); + } + } + + /** + * @override + * @param {PageModel} page + */ + renderPage(page) { + const pageContainerEl = super.renderPage(page); + pageContainerEl.classList.toggle('BRpage-visible', true); + return pageContainerEl; + } + + /** @overload */ + isPageVisible(page) { + if (page.index in this.actualPositions) { + const pToW = this.coordSpace.renderedPixelsToWorldUnits; + const region = this.actualPositions[page.index]; + const VT = this.visibleRegion.top; + const VB = VT + this.visibleRegion.height; + // top or bottom could be undefined + const PT = pToW(typeof region.top === 'undefined' ? region.bottom - 80 : region.top); + const PB = pToW(typeof region.bottom === 'undefined' ? region.top + 80 : region.bottom); + return (PT <= VB && PB >= VT) || (PT <= VB && PT >= VT) || (PB >= VT && PB <= VB); + } else { + return super.isPageVisible(page); + } + } + + /** + * @param {PageIndex} index + */ + jumpToIndex(index, { smooth = false } = {}) { + if (smooth) { + this.style.scrollBehavior = 'smooth'; + } + this.scrollTop = this.actualPositions[index]?.top ?? this.coordSpace.worldUnitsToVisiblePixels(this.pageTops[index] - this.SPACING_IN / 2); + // TODO: Also h center? + if (smooth) { + setTimeout(() => this.style.scrollBehavior = '', 100); + } + } +} diff --git a/src/BookReader/Navbar/Navbar.js b/src/BookReader/Navbar/Navbar.js index 0ba64c71b..a236103b0 100644 --- a/src/BookReader/Navbar/Navbar.js +++ b/src/BookReader/Navbar/Navbar.js @@ -26,7 +26,7 @@ export class Navbar { ]; /** @type {Object} controls will be switch over "this.minimumControls" */ this.maximumControls = [ - 'book_left', 'book_right', 'zoom_in', 'zoom_out', 'onepg', 'twopg', 'thumb' + 'book_left', 'book_right', 'zoom_in', 'zoom_out', 'onepg', 'twopg', 'thumb', 'textpg', ]; this.updateNavIndexThrottled = throttle(this.updateNavIndex.bind(this), 250, false); @@ -54,6 +54,7 @@ export class Navbar { 'onePage', 'twoPage', 'thumbnail', + 'text', 'viewmode', 'zoomOut', 'zoomIn', @@ -79,7 +80,12 @@ export class Navbar { mode: br.constModeThumb, className: 'thumb', title: 'Thumbnail view', - }].filter((mode) => ( + },{ + mode: br.constModeText, + className: 'textpg', + title: 'Text view', + } + ].filter((mode) => ( !viewModeOptions.excludedModes.includes(mode.mode) )); const viewModeOrder = viewModes.map((m) => m.mode); diff --git a/src/BookReader/options.js b/src/BookReader/options.js index 95b093e15..b5c38b218 100644 --- a/src/BookReader/options.js +++ b/src/BookReader/options.js @@ -251,6 +251,12 @@ export const DEFAULT_OPTIONS = { className: 'thumb', iconClassName: 'thumb' }, + text: { + visible: true, + label: 'Text view (experimental)', + className: 'textpg', + iconClassName: 'textpg' + }, viewmode: { visible: true, className: 'viewmode', diff --git a/src/BookReader/utils.js b/src/BookReader/utils.js index 4306340c6..73fe2a8b4 100644 --- a/src/BookReader/utils.js +++ b/src/BookReader/utils.js @@ -288,3 +288,13 @@ export function promisifyEvent(target, eventType) { export function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string } + +/** + * @param {number[]} nums + * @returns {number} + */ +export function median(nums) { + const sorted = nums.slice().sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} diff --git a/src/assets/icons/text.svg b/src/assets/icons/text.svg new file mode 100644 index 000000000..b68c6d08f --- /dev/null +++ b/src/assets/icons/text.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/css/_BRpages.scss b/src/css/_BRpages.scss index c0b8abbd0..7144b716f 100644 --- a/src/css/_BRpages.scss +++ b/src/css/_BRpages.scss @@ -4,7 +4,6 @@ } .BRmode1up .BRpagecontainer, .BRmodeThumb .BRpagecontainer { - box-shadow: 1px 1px 2px #333; img { border: 0; } @@ -39,6 +38,99 @@ } } +.br-mode-text__root { + // Useful for debugging + // .BRwordElement { + // background: rgba(255, 0, 0, calc(calc(100% - var(--br-conf)) / 3)); + // } + + // Core structural changes + .br-mode-1up__visible-world { + position: static; + } + + .BRpagecontainer { + margin-top: 0px !important; + border-top: 1px dashed rgba(65, 30, 4, 0.4); + min-height: 50px; + background: #E6DFC7; + background: linear-gradient( + to right, + #BBAB8A, + #E6DFC7 calc(calc(100% - 750px) / 2), + #E6DFC7 calc(750px + calc(100% - 750px) / 2), + #BBAB8A + ); + } + + .BRpagecontainer, .BRPageLayer.BRtextLayer { + width: 100% !important; + height: unset !important; + } + + .BRpageimage { + display: none; + } + + // Don't hide text layer when zooming or scrolling + &.BRsmooth-zooming, &.BRscrolling-active { + .BRpagecontainer:not(.BRpagecontainer--hasSelection) .BRtextLayer { + display: block; + } + } + + // Text Layer changes + .BRPageLayer.BRtextLayer { + color: #39313d; + text-shadow: 0.5px 1px 0.5px #b2a7b8; + padding: 0 10px; + box-sizing: border-box; + } + + .BRcentered { + text-align: center !important; + } + + .BRwordElement--hyphen::after { + content: ''; + } + + .BRPageLayer.BRtextLayer { + position: static !important; + transform: unset !important; + max-width: 750px; + margin: 0 auto; + + span { + letter-spacing: unset !important; + padding-left: 0 !important; + } + + .BRlineElement { + display: inline; + margin-right: 0; + white-space: unset; + line-height: 1.5em !important; + } + + p { + margin: 0 !important; + margin-top: 4px !important; + text-indent: 15px; + width: unset !important; + height: unset !important; + + font-size: 18px !important; + font-family: Georgia, 'Times New Roman', Times, serif; + text-align: justify; + } + } + + .BRscrolling-active .BRtextLayer { + display: block !important; + } +} + .BRpagecontainer { position: relative; overflow: hidden; diff --git a/src/css/_icons.scss b/src/css/_icons.scss index 5adb152cb..da9f07ae3 100644 --- a/src/css/_icons.scss +++ b/src/css/_icons.scss @@ -28,6 +28,12 @@ height: 19px; background-image: url("icons/2up.svg"); } +.icon-textpg { + width: 20px; + height: 19px; + background-image: url("icons/text.svg"); + transform: scale(1.7); +} .icon-fullscreen, .icon-fullscreen-exit { diff --git a/src/plugins/plugin.text_selection.js b/src/plugins/plugin.text_selection.js index 577a74e25..306948740 100644 --- a/src/plugins/plugin.text_selection.js +++ b/src/plugins/plugin.text_selection.js @@ -1,5 +1,6 @@ //@ts-check import { createDIVPageLayer } from '../BookReader/PageContainer.js'; +import { median } from '../BookReader/utils.js'; import { SelectionObserver } from '../BookReader/utils/SelectionObserver.js'; import { applyVariables } from '../util/strings.js'; /** @typedef {import('../util/strings.js').StringWithVars} StringWithVars */ @@ -246,6 +247,31 @@ export class TextSelectionPlugin { return el; }); + // Try to detect centered paragraphs + const medianLeft = median( + $(XMLpage).find("LINE").toArray() + .map(l => parseFloat($(l).attr("coords")?.split(',')?.[0])) + .filter(x => !isNaN(x)) + ); + const medianRightDist = pageContainer.page.width - median( + $(XMLpage).find("LINE").toArray() + .map(l => parseFloat($(l).attr("coords")?.split(',')?.[2])) + .filter(x => !isNaN(x)) + ) + + for (const paragEl of paragEls) { + const lines = Array.from(paragEl.querySelectorAll('.BRlineElement')); + const isCentered = lines.length >= 1 && lines.every(line => { + const left = parseFloat(paragEl.style.left); + const right = parseFloat(paragEl.style.left) + parseFloat(paragEl.style.width); + const rightDist = pageContainer.page.width - right; + return (left > medianLeft * 1.8 && rightDist > medianRightDist * 1.8) && left < pageContainer.page.width / 2; + }); + if (isCentered) { + paragEl.classList.add('BRcentered'); + } + } + // Fix up paragraph positions const paragraphRects = determineRealRects(textLayer, '.BRparagraphElement'); let yAdded = 0; @@ -299,6 +325,9 @@ export class TextSelectionPlugin { const wordEl = document.createElement('span'); wordEl.setAttribute("class", "BRwordElement"); + // Set confidence for potential styling + const confidence = parseFloat($(currWord).attr("x-confidence")); + wordEl.setAttribute("style", `--br-conf: ${confidence}%`); wordEl.textContent = currWord.textContent.trim(); if (wordIndex > 0) { @@ -315,7 +344,7 @@ export class TextSelectionPlugin { lineEl.appendChild(wordEl); } - const hasHyphen = line.lastWord.textContent.trim().endsWith('-'); + const hasHyphen = line.lastWord.textContent.trim().endsWith('-') || line.lastWord.textContent.trim().endsWith('¬'); const lastWordEl = lineEl.children[lineEl.children.length - 1]; if (hasHyphen && !isLastLineOfParagraph) { lastWordEl.textContent = lastWordEl.textContent.trim().slice(0, -1); @@ -437,7 +466,7 @@ export class BookreaderWithTextSelection extends BookReader { // Disable if thumb mode; it's too janky // .page can be null for "pre-cover" region if (this.mode !== this.constModeThumb && pageContainer.page) { - this.textSelectionPlugin?.createTextLayer(pageContainer); + pageContainer.textSelectionLoadingComplete = this.textSelectionPlugin?.createTextLayer(pageContainer); } return pageContainer; } From 89a372ea47abb2eee3c37c3133f636b3e1efefb2 Mon Sep 17 00:00:00 2001 From: Drini Cami Date: Fri, 13 Sep 2024 23:18:14 +0200 Subject: [PATCH 2/2] Try (and fail) to fix ModeText loading glitches --- src/BookReader/ModeTextLit.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/BookReader/ModeTextLit.js b/src/BookReader/ModeTextLit.js index 17e15f800..070f23fe8 100644 --- a/src/BookReader/ModeTextLit.js +++ b/src/BookReader/ModeTextLit.js @@ -37,12 +37,18 @@ export class ModeTextLit extends Mode1UpLit { } if (changedProps.has('renderedPages')) { // debugger; - this.updateComplete - .then(() => Promise.all(this.visiblePages.map(p => this.pageContainerCache[p.index].textSelectionLoadingComplete))) - .then(() => { - // Note the exact tops of the rendered pages - this.updateActualPositions(this.visiblePages); - }); + (async () => { + const allDone = await this.updateComplete; + if (!allDone) return; + await sleep(0); + + await Promise.all( + this.renderedPages + .filter(p => this.pageContainerCache[p.index]) + .map(p => this.pageContainerCache[p.index].textSelectionLoadingComplete) + ); + this.updateActualPositions(this.renderedPages); + })(); } super.updated(changedProps);