From d5d1541d3bcb118caf0691b7b5404c7ec79a41c0 Mon Sep 17 00:00:00 2001 From: John Factotum <50942278+johnfactotum@users.noreply.github.com> Date: Sun, 28 May 2023 22:05:21 +0800 Subject: [PATCH] Paginator: use CSS grid Massively simplify margin/padding and header/footer layout and eliminate unnecessary resizes and resize loops. Also remove layout and style API from View, as these are only applicable to the paginator. --- README.md | 15 ++-- paginator.js | 207 +++++++++++++++++++++++++++++---------------------- reader.js | 16 +--- view.js | 9 --- 4 files changed, 130 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 23f072f..7f50de6 100644 --- a/README.md +++ b/README.md @@ -168,12 +168,15 @@ The filter only applies to the book itself, leaving overlaid elements such as hi There is a basic page transition effect that can be disabled by setting `.pageAnimation` to false. -The layout can be configured from the `.layout` object, which has the following properties: -- `.flow`: either `'paginated'` or `'scrolled'`. -- `.margin`: number, in pixels. The height of the header and footer. -- `.gap`: number between 0 and 1. The size of the space between columns, relative to page size. -- `.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. +The layout can be configured by setting the following attributes: +- `flow`: either `paginated` or `scrolled`. +- `margin`: a CSS ``. The unit must be `px`. The height of the header and footer. +- `gap`: a CSS ``. The size of the space between columns, relative to page size. +- `max-inline-size`: a CSS ``. The unit must be `px`. The maximum inline size of the text (column width in paginated mode). +- `max-block-size`: same as above, but for the size in the block direction. +- `max-column-count`: integer. The maximum number of columns. Has no effect in scrolled mode. + +(Note: there's no JS property API. You must use `.setAttribute()`.) 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, diff --git a/paginator.js b/paginator.js index e0565ab..1ee15ea 100644 --- a/paginator.js +++ b/paginator.js @@ -351,60 +351,92 @@ class View { // NOTE: everything here assumes the so-called "negative scroll type" for RTL export class Paginator extends HTMLElement { + static observedAttributes = [ + 'flow', 'gap', 'margin', + 'max-inline-size', 'max-block-size', 'max-column-count', + ] #root = this.attachShadow({ mode: 'closed' }) - #gap = 0 - #shouldUpdateGap = true #observer = new ResizeObserver(() => this.render()) #background - #maxWidthContainer - #maxHeightContainer #container #header #footer #view #vertical = false #rtl = false + #margin = 0 #index = -1 #anchor = 0 // anchor view to a fraction (0-1), Range, or Element #locked = false // while true, prevent any further navigation + #styles #styleMap = new WeakMap() #scrollBounds #touchState pageAnimation = true - layout = { - margin: 48, - gap: 0.05, - maxColumnWidth: 700, - } constructor() { super() this.#root.innerHTML = `
-
-
-
-
- - -
+ +
+ ` 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.#observer.observe(this.#container) this.#container.addEventListener('scroll', debounce(() => { if (this.scrolled) this.#afterScroll('scroll') }, 250)) @@ -453,6 +479,24 @@ export class Paginator extends HTMLElement { doc.addEventListener('touchend', this.#onTouchEnd.bind(this)) }) } + attributeChangedCallback(name, _, value) { + switch (name) { + case 'flow': + this.render() + break + case 'gap': + case 'margin': + case 'max-block-size': + case 'max-column-count': + this.style.setProperty('--_' + name, value) + break + case 'max-inline-size': + // needs explicit `render()` as it doesn't necessarily resize + this.style.setProperty('--_' + name, value) + this.render() + break + } + } open(book) { this.bookDir = book.dir this.sections = book.sections @@ -469,25 +513,46 @@ export class Paginator extends HTMLElement { #beforeRender({ vertical, rtl, background }) { this.#vertical = vertical this.#rtl = rtl + this.style.setProperty('--_vertical', vertical ? 1 : 0) // set background to `doc` background // this is needed because the iframe does not fill the whole element this.#background.style.background = background - const { flow, margin, gap: gape, maxColumnWidth, maxColumns } = this.layout + const { width, height } = this.#container.getBoundingClientRect() + const size = vertical ? height : width + const style = getComputedStyle(this) + const maxInlineSize = parseFloat(style.getPropertyValue('--_max-inline-size')) + const margin = parseFloat(style.getPropertyValue('--_margin')) + this.#margin = margin + + const g = parseFloat(style.getPropertyValue('--_gap')) / 100 + // The gap will be a percentage of the #container, not the whole view. + // This means the outer padding will be bigger than the column gap. Let + // `a` be the gap percentage. The actual percentage for the column gap + // will be (1 - a) * a. Let us call this `b`. + // + // To make them the same, we start by shrinking the outer padding + // setting to `b`, but keep the column gap setting the same at `a`. Then + // the actual size for the column gap will be (1 - b) * a. Repeating the + // process again and again, we get the sequence + // x₁ = (1 - b) * a + // x₂ = (1 - x₁) * a + // ... + // which converges to x = (1 - x) * a. Solving for x, x = a / (1 + a). + // So to make the spacing even, we must shrink the outer padding with + // f(x) = x / (1 + x). + // But we want to keep the outer padding, and make the inner gap bigger. + // So we apply the inverse, f⁻¹ = -x / (x - 1) to the column gap. + const gap = -g / (g - 1) * size + + const flow = this.getAttribute('flow') if (flow === 'scrolled') { // FIXME: vertical-rl only, not -lr this.setAttribute('dir', vertical ? 'rtl' : 'ltr') this.style.padding = '0' - this.#container.style.overflow ='auto' - this.#maxWidthContainer.style.maxWidth = 'none' - this.#maxHeightContainer.style.maxHeight = 'none' - const columnWidth = this.layout.maxColumnWidth - - const { width, height } = this.#container.getBoundingClientRect() - const size = vertical ? height : width - const gap = Math.trunc(gape * size) + const columnWidth = maxInlineSize this.heads = null this.feet = null @@ -497,50 +562,14 @@ export class Paginator extends HTMLElement { return { flow, margin, gap, columnWidth } } - const maxSize = `${maxColumns * maxColumnWidth}px` - this.#maxWidthContainer.style.maxWidth = vertical ? 'none' : maxSize - this.#maxHeightContainer.style.maxHeight = vertical ? maxSize : 'none' - - if (this.#shouldUpdateGap) { - this.#shouldUpdateGap = false - const { width, height } = this.#container.getBoundingClientRect() - const size = vertical ? height : width - const gap = Math.trunc(gape * size) - - const paddingH = `${vertical - ? Math.min(margin, Math.trunc(gape * width)) - : gap / 2}px` - const paddingV = `${vertical - ? Math.max(margin - gap / 2, gap / 2) - : margin}px` - this.#gap = gap - this.style.padding = `${paddingV} ${paddingH}` - this.#header.style.top = `-${paddingV}` - this.#footer.style.bottom = `-${paddingV}` - - const newRect = this.#container.getBoundingClientRect() - const newSize = vertical ? newRect.height : newRect.width - // if the size is different, don't do anything - // as the resize observer would fire in that case - if (newSize !== size) return - } - - this.#shouldUpdateGap = true - const gap = this.#gap - - const { width, height } = this.#container.getBoundingClientRect() - const size = vertical ? height : width - const divisor = Math.ceil(size / maxColumnWidth) + const divisor = Math.ceil(size / maxInlineSize) const columnWidth = (size / divisor) - gap this.setAttribute('dir', rtl ? 'rtl' : 'ltr') - this.#container.style.overflow ='hidden' const marginalDivisor = vertical - ? Math.min(2, Math.ceil(width / maxColumnWidth)) + ? Math.min(2, Math.ceil(width / maxInlineSize)) : divisor const marginalStyle = { - maxWidth: vertical ? 'none' : maxSize, - height: `${margin}px`, gridTemplateColumns: `repeat(${marginalDivisor}, 1fr)`, gap: `${gap}px`, padding: vertical ? '0' : `0 ${gap / 2}px`, @@ -566,7 +595,7 @@ export class Paginator extends HTMLElement { this.#scrollToAnchor() } get scrolled() { - return this.layout.flow === 'scrolled' + return this.getAttribute('flow') === 'scrolled' } get scrollProp() { const { scrolled } = this @@ -658,7 +687,7 @@ export class Paginator extends HTMLElement { #getRectMapper() { if (this.scrolled) { const size = this.viewSize - const margin = this.layout.margin + const margin = this.#margin return this.#vertical ? ({ left, right }) => ({ left: size - right - margin, right: size - left - margin }) @@ -674,11 +703,11 @@ export class Paginator extends HTMLElement { } async #scrollToRect(rect, reason) { if (this.scrolled) { - const offset = this.#getRectMapper()(rect).left - this.layout.margin + const offset = this.#getRectMapper()(rect).left - this.#margin return this.#scrollTo(offset, reason) } const offset = this.#getRectMapper()(rect).left - + this.layout.margin / 2 + + this.#margin / 2 return this.#scrollToPage(Math.floor(offset / this.size) + (this.#rtl ? -1 : 1), reason) } async #scrollTo(offset, reason, smooth) { @@ -801,6 +830,7 @@ export class Paginator extends HTMLElement { const oldIndex = this.#index const onLoad = detail => { this.sections[oldIndex]?.unload?.() + this.setStyles(this.#styles) this.dispatchEvent(new CustomEvent('load', { detail })) } await this.#display(Promise.resolve(this.sections[index].load()) @@ -890,7 +920,8 @@ export class Paginator extends HTMLElement { }] return [] } - setStyle(styles) { + setStyles(styles) { + this.#styles = styles const $$styles = this.#styleMap.get(this.#view?.document) if (!$$styles) return const [$beforeStyle, $style] = $$styles diff --git a/reader.js b/reader.js index ad3cf9b..33a3f5c 100644 --- a/reader.js +++ b/reader.js @@ -147,12 +147,6 @@ class Reader { justify: true, hyphenate: true, } - layout = { - margin: 48, - gap: 0.05, - maxColumns: 2, - maxColumnWidth: 720, - } annotations = new Map() annotationsByValue = new Map() closeSideBar() { @@ -176,8 +170,7 @@ class Reader { ['Scrolled', 'scrolled'], ], onclick: value => { - this.layout.flow = value - this.setAppearance() + this.view?.renderer.setAttribute('flow', value) }, }, ]) @@ -194,7 +187,7 @@ class Reader { this.view.addEventListener('relocate', this.#onRelocate.bind(this)) const { book } = this.view - this.setAppearance() + this.view.renderer.setStyles?.(getCSS(this.style)) this.view.renderer.next() $('#header-bar').style.visibility = 'visible' @@ -274,11 +267,6 @@ class Reader { }) } } - setAppearance = () => { - this.view?.setAppearance({ css: getCSS(this.style), layout: this.layout }) - const scrolled = this.layout.flow === 'scrolled' - document.documentElement.classList.toggle('scrolled', scrolled) - } #handleKeydown(event) { const k = event.key if (k === 'ArrowLeft' || k === 'h') this.view.goLeft() diff --git a/view.js b/view.js index 8f135fd..ca756b5 100644 --- a/view.js +++ b/view.js @@ -83,7 +83,6 @@ export class View extends HTMLElement { #sectionProgress #tocProgress #pageProgress - #css isFixedLayout = false lastLocation history = new History() @@ -162,7 +161,6 @@ export class View extends HTMLElement { if (!this.language.isCJK) doc.documentElement.dir ||= this.language.direction ?? '' - this.renderer.setStyle?.(this.#css) this.#handleLinks(doc, index) this.#emit('load', { doc, index }) @@ -315,13 +313,6 @@ export class View extends HTMLElement { goRight() { return this.book.dir === 'rtl' ? this.prev() : this.next() } - setAppearance({ layout, css }) { - if (this.isFixedLayout) return - Object.assign(this.renderer.layout, layout) - this.#css = css - this.renderer.setStyle(css) - this.renderer.render() - } async * #searchSection(matcher, query, index) { const doc = await this.book.sections[index].createDocument() for (const { range, excerpt } of matcher(doc, query))