From 0a3f4b8f271bff18e35c8f1e2e106814bd376477 Mon Sep 17 00:00:00 2001 From: John Factotum <50942278+johnfactotum@users.noreply.github.com> Date: Mon, 15 May 2023 20:00:35 +0800 Subject: [PATCH] Use Shadow DOM --- README.md | 33 ++++----- fixed-layout.js | 107 +++++++++++++--------------- paginator.js | 185 ++++++++++++++++++++++++++---------------------- reader.js | 4 +- view.js | 31 ++++---- 5 files changed, 182 insertions(+), 178 deletions(-) diff --git a/README.md b/README.md index 8cd89e0..5d17a2b 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,7 @@ The repo also includes a still higher level reader, though strictly speaking, `r ### Basic Usage ```js -import { View } from './view.js' -customElements.define('foliate-view', View) +import from './view.js' const view = document.createElement('foliate-view') document.body.append(view) @@ -154,7 +153,15 @@ The paginator uses the same pagination strategy as [Epub.js](https://github.com/ To simplify things, it has a totally separate renderer for fixed layout books. As such there's no support for mixed layout books. -Both renderers has the style class `.foliate-filter`, which you can apply CSS filters to, to e.g. invert colors or adjust brightness. By using this class, you can apply filters only to the book itself, leaving overlaid elements such as highlights unaffected. +Both renderers have the [`part`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/part) named `filter`, which you can apply CSS filters to, to e.g. invert colors or adjust brightness: + +```css +foliate-view::part(filter) { + filter: invert(1) hue-rotate(180deg); +} +``` + +The filter only applies to the book itself, leaving overlaid elements such as highlights unaffected. ### The Paginator @@ -165,24 +172,14 @@ The layout can be configured from the `.layout` object, which has the following - `.maxColumnWidth`: number, in pixels. The maximum width of the text in each column. - `.maxColumns`: integer. The maximum number of columns. Has no effect in scrolled mode. -It has built-in header and footer regions accessible via the `.heads` and `.feet` properties of the paginator instance. These can be used to display running heads and reading progress. They are only available in paginated mode, and there will be one element for each column. They are contained in parent divs that have the style classes `.foliate-header` and `.foliate-footer` respectively. These don't have any styles by default, but you will probably want to set something like the following: +It has built-in header and footer regions accessible via the `.heads` and `.feet` properties of the paginator instance. These can be used to display running heads and reading progress. They are only available in paginated mode, and there will be one element for each column. They are styleable with `::part(head)` and `::part(foot)`. E.g., to add a border under the running heads, ```css -.foliate-header > *, .foliate-footer > * { - display: flex; - min-width: 0; - align-items: center; - text-align: center; - font-size: .75em; - opacity: .6; +foliate-view::part(head) { + padding-bottom: 4px; + border-bottom: 1px solid graytext; } -.foliate-header > * > *, .foliate-footer > * > * { - flex: 1; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} -``` +`` ### EPUB CFI diff --git a/fixed-layout.js b/fixed-layout.js index bb8da98..41269ef 100644 --- a/fixed-layout.js +++ b/fixed-layout.js @@ -29,9 +29,11 @@ const getViewport = (doc, viewport) => { return { width: 1000, height: 2000 } } -class Container { - #observer = new ResizeObserver(() => this.render()) - #element = document.createElement('div') +export class FixedLayout extends HTMLElement { + #root = this.attachShadow({ mode: 'closed' }) + #observer = new ResizeObserver(() => this.#render()) + #spreads + #index = -1 defaultViewport spread #portrait = false @@ -40,20 +42,19 @@ class Container { #center #side constructor() { - Object.assign(this.#element.style, { - width: '100%', - height: '100%', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - }) - this.#observer.observe(this.#element) - } - get element() { - return this.#element - } - get side() { - return this.#side + super() + + const sheet = new CSSStyleSheet() + this.#root.adoptedStyleSheets = [sheet] + sheet.replaceSync(`:host { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + }`) + + this.#observer.observe(this) } async #createFrame({ index, src }) { const element = document.createElement('div') @@ -68,14 +69,14 @@ class Container { // https://bugs.webkit.org/show_bug.cgi?id=218086 iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts') iframe.setAttribute('scrolling', 'no') - iframe.classList.add('foliate-filter') - this.#element.append(element) + iframe.setAttribute('part', 'filter') + this.#root.append(element) if (!src) return { blank: true, element, iframe } return new Promise(resolve => { const onload = () => { iframe.removeEventListener('load', onload) const doc = iframe.contentDocument - this.onLoad?.(doc, index) + this.dispatchEvent(new CustomEvent('load', { detail: { doc, index } })) const { width, height } = getViewport(doc, this.defaultViewport) resolve({ element, iframe, @@ -87,12 +88,12 @@ class Container { iframe.src = src }) } - render(side = this.#side) { + #render(side = this.#side) { if (!side) return const left = this.#left ?? {} const right = this.#center ?? this.#right const target = side === 'left' ? left : right - const { width, height } = this.#element.getBoundingClientRect() + const { width, height } = this.getBoundingClientRect() const portrait = this.spread !== 'both' && this.spread !== 'portrait' && height > width this.#portrait = portrait @@ -135,23 +136,23 @@ class Container { transform(right) } } - async showSpread({ left, right, center, side }) { - this.#element.replaceChildren() + async #showSpread({ left, right, center, side }) { + this.#root.replaceChildren() this.#left = null this.#right = null this.#center = null if (center) { this.#center = await this.#createFrame(center) this.#side = 'center' - this.render() + this.#render() } else { this.#left = await this.#createFrame(left) this.#right = await this.#createFrame(right) this.#side = side - this.render() + this.#render() } } - goLeft() { + #goLeft() { if (this.#center) return if (this.#left?.blank) return true if (this.#portrait && this.#left?.element?.style?.display === 'none') { @@ -161,7 +162,7 @@ class Container { return true } } - goRight() { + #goRight() { if (this.#center) return if (this.#right?.blank) return true if (this.#portrait && this.#right?.element?.style?.display === 'none') { @@ -171,24 +172,11 @@ class Container { return true } } - destroy() { - this.#observer.unobserve(this.#element) - this.#element.remove() - } -} - -export class FixedLayout { - #spreads - #index = -1 - #container = new Container() - constructor({ book, onLoad, onRelocate }) { + open(book) { this.book = book - this.#container.onLoad = onLoad - this.onRelocate = onRelocate - const { rendition } = book - this.#container.spread = rendition?.spread - this.#container.defaultViewport = rendition?.viewport + this.spread = rendition?.spread + this.defaultViewport = rendition?.viewport const rtl = book.dir === 'rtl' const ltr = !rtl @@ -227,15 +215,16 @@ export class FixedLayout { return arr }, [{}]) } - get element() { - return this.#container.element - } get index() { const spread = this.#spreads[this.#index] - const section = spread?.center ?? (this.#container.side === 'left' + const section = spread?.center ?? (this.side === 'left' ? spread.left ?? spread.right : spread.right ?? spread.left) return this.book.sections.indexOf(section) } + #reportLocation() { + this.dispatchEvent(new CustomEvent('relocate', { detail: + { range: null, index: this.index, fraction: 0, size: 1 } })) + } getSpreadOf(section) { const spreads = this.#spreads for (let index = 0; index < spreads.length; index++) { @@ -248,7 +237,7 @@ export class FixedLayout { async goToSpread(index, side) { if (index < 0 || index > this.#spreads.length - 1) return if (index === this.#index) { - this.#container.render(side) + this.#render(side) return } this.#index = index @@ -256,7 +245,7 @@ export class FixedLayout { if (spread.center) { const index = this.book.sections.indexOf(spread.center) const src = await spread.center?.load?.() - await this.#container.showSpread({ center: { index, src } }) + await this.#showSpread({ center: { index, src } }) } else { const indexL = this.book.sections.indexOf(spread.left) const indexR = this.book.sections.indexOf(spread.right) @@ -264,9 +253,9 @@ export class FixedLayout { const srcR = await spread.right?.load?.() const left = { index: indexL, src: srcL } const right = { index: indexR, src: srcR } - await this.#container.showSpread({ left, right, side }) + await this.#showSpread({ left, right, side }) } - this.onRelocate?.(null, this.index, 0, 1) + this.#reportLocation() } async select(target) { await this.goTo(target) @@ -281,20 +270,22 @@ export class FixedLayout { await this.goToSpread(index, side) } async next() { - const s = this.rtl ? this.#container.goLeft() : this.#container.goRight() - if (s) this.onRelocate?.(null, this.index, 0, 1) + const s = this.rtl ? this.#goLeft() : this.#goRight() + if (s) this.#reportLocation() else return this.goToSpread(this.#index + 1, this.rtl ? 'right' : 'left') } async prev() { - const s = this.rtl ? this.#container.goRight() : this.#container.goLeft() - if (s) this.onRelocate?.(null, this.index, 0, 1) + const s = this.rtl ? this.#goRight() : this.#goLeft() + if (s) this.#reportLocation() else return this.goToSpread(this.#index - 1, this.rtl ? 'left' : 'right') } deselect() { - for (const frame of this.#container.element.querySelectorAll('iframe')) + for (const frame of this.#root.querySelectorAll('iframe')) frame.contentWindow.getSelection().removeAllRanges() } destroy() { - this.#container.destroy() + this.#observer.unobserve(this) } } + +customElements.define('foliate-fxl', FixedLayout) diff --git a/paginator.js b/paginator.js index 7acb55d..048b0c0 100644 --- a/paginator.js +++ b/paginator.js @@ -128,10 +128,11 @@ const getBackground = doc => { : bodyStyle.background } -const makeMarginals = length => Array.from({ length }, () => { +const makeMarginals = (length, part) => Array.from({ length }, () => { const div = document.createElement('div') const child = document.createElement('div') div.append(child) + child.setAttribute('part', part) return div }) @@ -148,7 +149,7 @@ class View { constructor({ container, onExpand }) { this.container = container this.onExpand = onExpand - this.#iframe.classList.add('foliate-filter') + this.#iframe.setAttribute('part', 'filter') this.#element.append(this.#iframe) Object.assign(this.#element.style, { boxSizing: 'content-box', @@ -328,17 +329,17 @@ class View { } // NOTE: everything here assumes the so-called "negative scroll type" for RTL -export class Paginator { +export class Paginator extends HTMLElement { + #root = this.attachShadow({ mode: 'closed' }) #gap = 0 #shouldUpdateGap = true #observer = new ResizeObserver(() => this.render()) - #element = document.createElement('div') - #background = document.createElement('div') - #maxWidthContainer = document.createElement('div') - #maxHeightContainer = document.createElement('div') - #container = document.createElement('div') - #header = document.createElement('div') - #footer = document.createElement('div') + #background + #maxWidthContainer + #maxHeightContainer + #container + #header + #footer #view #vertical = false #rtl = false @@ -351,75 +352,82 @@ export class Paginator { gap: 0.05, maxColumnWidth: 700, } - constructor({ book, onLoad, onRelocate, createOverlayer }) { - this.bookDir = book.dir - this.sections = book.sections - this.onLoad = onLoad - this.onRelocate = onRelocate - this.createOverlayer = createOverlayer - Object.assign(this.#element.style, { - boxSizing: 'border-box', - display: 'flex', - width: '100%', - height: '100%', - position: 'relative', - overflow: 'hidden', - }) - - this.#element.append(this.#background) - Object.assign(this.#background.style, { - width: '100%', - height: '100%', - position: 'absolute', - top: '0', left: '0', - }) - this.#background.classList.add('foliate-filter') - - this.#element.append(this.#maxWidthContainer) - Object.assign(this.#maxWidthContainer.style, { - width: '100%', - height: '100%', - margin: 'auto', - position: 'relative', - display: 'flex', - }) - this.#maxWidthContainer.append(this.#maxHeightContainer) - Object.assign(this.#maxHeightContainer.style, { - width: '100%', - height: '100%', - margin: 'auto', - }) - this.#maxHeightContainer.append(this.#container) - Object.assign(this.#container.style, { - width: '100%', - height: '100%', - margin: 'auto', - }) - - const marginalStyle = { - position: 'absolute', left: '0', right: '0', - display: 'grid', - margin: 'auto', + constructor() { + super() + this.#root.innerHTML = ` +
+
+ +
+
+
+ +
+ ` - this.#observer.observe(this.#element) + this.#background = this.#root.getElementById('background') + this.#maxWidthContainer = this.#root.getElementById('max-width') + this.#maxHeightContainer = this.#root.getElementById('max-height') + this.#container = this.#root.getElementById('container') + this.#header = this.#root.getElementById('header') + this.#footer = this.#root.getElementById('footer') + + this.#observer.observe(this) this.#container.addEventListener('scroll', debounce(() => { if (this.scrolled) this.#afterScroll('scroll') }, 250)) } - get element() { - return this.#element + open(book) { + this.bookDir = book.dir + this.sections = book.sections } #createView() { if (this.#view) this.#container.removeChild(this.#view.element) this.#view = new View({ - container: this.#element, + container: this, onExpand: this.#scrollToAnchor.bind(this), }) this.#container.append(this.#view.element) @@ -437,8 +445,8 @@ export class Paginator { if (flow === 'scrolled') { // FIXME: vertical-rl only, not -lr - this.#element.setAttribute('dir', vertical ? 'rtl' : 'ltr') - this.#element.style.padding = '0' + this.setAttribute('dir', vertical ? 'rtl' : 'ltr') + this.style.padding = '0' this.#container.style.overflow ='scroll' this.#maxWidthContainer.style.maxWidth = 'none' this.#maxHeightContainer.style.maxHeight = 'none' @@ -473,7 +481,7 @@ export class Paginator { ? Math.max(margin - gap / 2, gap / 2) : margin}px` this.#gap = gap - this.#element.style.padding = `${paddingV} ${paddingH}` + this.style.padding = `${paddingV} ${paddingH}` this.#header.style.top = `-${paddingV}` this.#footer.style.bottom = `-${paddingV}` @@ -491,7 +499,7 @@ export class Paginator { const size = vertical ? height : width const divisor = Math.ceil(size / maxColumnWidth) const columnWidth = (size / divisor) - gap - this.#element.setAttribute('dir', rtl ? 'rtl' : 'ltr') + this.setAttribute('dir', rtl ? 'rtl' : 'ltr') this.#container.style.overflow ='hidden' const marginalDivisor = vertical @@ -507,8 +515,8 @@ export class Paginator { } Object.assign(this.#header.style, marginalStyle) Object.assign(this.#footer.style, marginalStyle) - const heads = makeMarginals(marginalDivisor) - const feet = makeMarginals(marginalDivisor) + const heads = makeMarginals(marginalDivisor, 'head') + const feet = makeMarginals(marginalDivisor, 'foot') this.heads = heads.map(el => el.children[0]) this.feet = feet.map(el => el.children[0]) this.#header.replaceChildren(...heads) @@ -657,13 +665,15 @@ export class Paginator { if (reason !== 'anchor') this.#anchor = range const index = this.#index - if (this.scrolled) - this.onRelocate?.(range, index, this.start / this.viewSize) + const detail = { range, index } + if (this.scrolled) detail.fraction = this.start / this.viewSize else if (this.pages > 0) { const { page, pages } = this this.#header.style.visibility = page > 0 ? 'visible' : 'hidden' - this.onRelocate?.(range, index, page / pages, 1 / pages) + detail.fraction = page / pages + detail.size = 1 / pages } + this.dispatchEvent(new CustomEvent('relocate', { detail })) } async #display(promise) { const { index, src, anchor, onLoad, select } = await promise @@ -678,12 +688,16 @@ export class Paginator { doc.head.append($style) this.#styleMap.set(doc, [$styleBefore, $style]) } - onLoad?.(doc, index) + onLoad?.({ doc, index }) } const beforeRender = this.#beforeRender.bind(this) await view.load(src, afterLoad, beforeRender) - const overlayer = this.createOverlayer?.(view.document, index) - if (overlayer) view.overlayer = overlayer + this.dispatchEvent(new CustomEvent('create-overlayer', { + detail: { + doc: view.document, index, + attach: overlayer => view.overlayer = overlayer, + }, + })) this.#view = view } this.#anchor = (typeof anchor === 'function' @@ -732,9 +746,9 @@ export class Paginator { if (index === this.#index) await this.#display({ index, anchor, select }) else { const oldIndex = this.#index - const onLoad = (...args) => { + const onLoad = detail => { this.sections[oldIndex]?.unload?.() - this.onLoad?.(...args) + this.dispatchEvent(new CustomEvent('load', { detail })) } await this.#display(Promise.resolve(this.sections[index].load()) .then(src => ({ index, src, anchor, onLoad, select })) @@ -801,7 +815,8 @@ export class Paginator { sel.removeAllRanges() } destroy() { - this.#observer.unobserve(this.#element) - this.#element.remove() + this.#observer.unobserve(this) } } + +customElements.define('foliate-paginator', Paginator) diff --git a/reader.js b/reader.js index dbb4473..18bc892 100644 --- a/reader.js +++ b/reader.js @@ -1,9 +1,7 @@ -import { View } from './view.js' +import './view.js' import { createTOCView } from './ui/tree.js' import { createMenu } from './ui/menu.js' -customElements.define('foliate-view', View) - const isZip = async file => { const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer()) return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04 diff --git a/view.js b/view.js index f249dea..784ee16 100644 --- a/view.js +++ b/view.js @@ -41,6 +41,7 @@ const languageInfo = lang => { } export class View extends HTMLElement { + #root = this.attachShadow({ mode: 'closed' }) #sectionProgress #tocProgress #pageProgress @@ -61,21 +62,21 @@ export class View extends HTMLElement { toc: book.pageList ?? [], ids, splitHref, getFragment }) } - const opts = { - book: this.book, - onLoad: this.#onLoad.bind(this), - onRelocate: this.#onRelocate.bind(this), - createOverlayer: this.#createOverlayer.bind(this), - } this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated' if (this.isFixedLayout) { - const { FixedLayout } = await import('./fixed-layout.js') - this.renderer = new FixedLayout(opts) + await import('./fixed-layout.js') + this.renderer = document.createElement('foliate-fxl') } else { - const { Paginator } = await import('./paginator.js') - this.renderer = new Paginator(opts) + await import('./paginator.js') + this.renderer = document.createElement('foliate-paginator') } - this.append(this.renderer.element) + this.renderer.setAttribute('exportparts', 'head,foot,filter') + this.renderer.addEventListener('load', e => this.#onLoad(e.detail)) + this.renderer.addEventListener('relocate', e => this.#onRelocate(e.detail)) + this.renderer.addEventListener('create-overlayer', e => + e.detail.attach(this.#createOverlayer(e.detail))) + this.renderer.open(book) + this.#root.append(this.renderer) } async init({ lastLocation }) { if (lastLocation) { @@ -87,7 +88,7 @@ export class View extends HTMLElement { #emit(name, detail, cancelable) { return this.dispatchEvent(new CustomEvent(name, { detail, cancelable })) } - #onRelocate(range, index, fraction, size) { + #onRelocate({ range, index, fraction, size }) { if (!this.#sectionProgress) return const progress = this.#sectionProgress.getProgress(index, fraction, size) const tocItem = this.#tocProgress.getProgress(index, range) @@ -95,7 +96,7 @@ export class View extends HTMLElement { const cfi = this.getCFI(index, range) this.#emit('relocate', { ...progress, tocItem, pageItem, cfi, range }) } - #onLoad(doc, index) { + #onLoad({ doc, index }) { // set language and dir if not already set doc.documentElement.lang ||= this.language.canonical ?? '' if (!this.language.isCJK) @@ -146,7 +147,7 @@ export class View extends HTMLElement { const obj = this.renderer.getOverlayer() if (obj.index === index) return obj } - #createOverlayer(doc, index) { + #createOverlayer({ doc, index }) { const overlayer = new Overlayer() doc.addEventListener('click', e => { const [value, range] = overlayer.hitTest(e) @@ -278,3 +279,5 @@ export class View extends HTMLElement { this.renderer?.destroy?.() } } + +customElements.define('foliate-view', View)