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.
This commit is contained in:
John Factotum
2023-05-28 22:17:56 +08:00
parent 21744e3c85
commit d5d1541d3b
4 changed files with 130 additions and 117 deletions
+9 -6
View File
@@ -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. 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: The layout can be configured by setting the following attributes:
- `.flow`: either `'paginated'` or `'scrolled'`. - `flow`: either `paginated` or `scrolled`.
- `.margin`: number, in pixels. The height of the header and footer. - `margin`: a CSS `<length>`. The unit must be `px`. The height of the header and footer.
- `.gap`: number between 0 and 1. The size of the space between columns, relative to page size. - `gap`: a CSS `<percentage>`. The size of the space between columns, relative to page size.
- `.maxColumnWidth`: number, in pixels. The maximum width of the text in each column. - `max-inline-size`: a CSS `<length>`. The unit must be `px`. The maximum inline size of the text (column width in paginated mode).
- `.maxColumns`: integer. The maximum number of columns. Has no effect in scrolled 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, 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,
+119 -88
View File
@@ -351,60 +351,92 @@ class View {
// NOTE: everything here assumes the so-called "negative scroll type" for RTL // NOTE: everything here assumes the so-called "negative scroll type" for RTL
export class Paginator extends HTMLElement { export class Paginator extends HTMLElement {
static observedAttributes = [
'flow', 'gap', 'margin',
'max-inline-size', 'max-block-size', 'max-column-count',
]
#root = this.attachShadow({ mode: 'closed' }) #root = this.attachShadow({ mode: 'closed' })
#gap = 0
#shouldUpdateGap = true
#observer = new ResizeObserver(() => this.render()) #observer = new ResizeObserver(() => this.render())
#background #background
#maxWidthContainer
#maxHeightContainer
#container #container
#header #header
#footer #footer
#view #view
#vertical = false #vertical = false
#rtl = false #rtl = false
#margin = 0
#index = -1 #index = -1
#anchor = 0 // anchor view to a fraction (0-1), Range, or Element #anchor = 0 // anchor view to a fraction (0-1), Range, or Element
#locked = false // while true, prevent any further navigation #locked = false // while true, prevent any further navigation
#styles
#styleMap = new WeakMap() #styleMap = new WeakMap()
#scrollBounds #scrollBounds
#touchState #touchState
pageAnimation = true pageAnimation = true
layout = {
margin: 48,
gap: 0.05,
maxColumnWidth: 700,
}
constructor() { constructor() {
super() super()
this.#root.innerHTML = `<style> this.#root.innerHTML = `<style>
:host { :host {
--_gap: 7%;
--_margin: 48px;
--_max-inline-size: 720px;
--_max-block-size: 1440px;
--_max-column-count: 2;
--_vertical: 0;
--_half-gap: calc(var(--_gap) / 2);
--_max-width: calc(
var(--_vertical) * var(--_max-block-size)
+ (1 - var(--_vertical)) * var(--_max-inline-size) * var(--_max-column-count)
);
--_max-height: calc(
var(--_vertical) * var(--_max-inline-size) * var(--_max-column-count)
+ (1 - var(--_vertical)) * var(--_max-block-size)
);
display: grid;
grid-template-columns:
minmax(var(--_half-gap), 1fr)
minmax(0, var(--_max-width))
minmax(var(--_half-gap), 1fr);
grid-template-rows:
minmax(var(--_margin), 1fr)
minmax(0, var(--_max-height))
minmax(var(--_margin), 1fr);
box-sizing: border-box; box-sizing: border-box;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
display: flex;
}
#background {
position: absolute;
top: 0;
left: 0;
}
#max-width {
position: relative;
display: flex;
}
:host, #background, #max-width, #max-height, #container {
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: auto; }
#background {
grid-column-start: 1;
grid-column-end: 4;
grid-row-start: 1;
grid-row-end: 4;
}
#container {
grid-column-start: 2;
grid-row-start: 2;
overflow: hidden;
}
:host([flow="scrolled"]) #container {
grid-column-start: 1;
grid-column-end: 4;
grid-row-start: 1;
grid-row-end: 4;
overflow: auto;
}
#header {
grid-column-start: 2;
grid-row-start: 1;
}
#footer {
grid-column-start: 2;
grid-row-start: 3;
align-self: end;
} }
#header, #footer { #header, #footer {
position: absolute;
left: 0;
right: 0;
display: grid; display: grid;
margin: auto; height: var(--_margin);
} }
:is(#header, #footer) > * { :is(#header, #footer) > * {
display: flex; display: flex;
@@ -422,23 +454,17 @@ export class Paginator extends HTMLElement {
} }
</style> </style>
<div id="background" part="filter"></div> <div id="background" part="filter"></div>
<div id="max-width"> <div id="header"></div>
<div id="max-height"> <div id="container"></div>
<div id="container"></div> <div id="footer"></div>
</div>
<div id="header"></div>
<div id="footer"></div>
</div>
` `
this.#background = this.#root.getElementById('background') 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.#container = this.#root.getElementById('container')
this.#header = this.#root.getElementById('header') this.#header = this.#root.getElementById('header')
this.#footer = this.#root.getElementById('footer') this.#footer = this.#root.getElementById('footer')
this.#observer.observe(this) this.#observer.observe(this.#container)
this.#container.addEventListener('scroll', debounce(() => { this.#container.addEventListener('scroll', debounce(() => {
if (this.scrolled) this.#afterScroll('scroll') if (this.scrolled) this.#afterScroll('scroll')
}, 250)) }, 250))
@@ -453,6 +479,24 @@ export class Paginator extends HTMLElement {
doc.addEventListener('touchend', this.#onTouchEnd.bind(this)) 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) { open(book) {
this.bookDir = book.dir this.bookDir = book.dir
this.sections = book.sections this.sections = book.sections
@@ -469,25 +513,46 @@ export class Paginator extends HTMLElement {
#beforeRender({ vertical, rtl, background }) { #beforeRender({ vertical, rtl, background }) {
this.#vertical = vertical this.#vertical = vertical
this.#rtl = rtl this.#rtl = rtl
this.style.setProperty('--_vertical', vertical ? 1 : 0)
// set background to `doc` background // set background to `doc` background
// this is needed because the iframe does not fill the whole element // this is needed because the iframe does not fill the whole element
this.#background.style.background = background 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') { if (flow === 'scrolled') {
// FIXME: vertical-rl only, not -lr // FIXME: vertical-rl only, not -lr
this.setAttribute('dir', vertical ? 'rtl' : 'ltr') this.setAttribute('dir', vertical ? 'rtl' : 'ltr')
this.style.padding = '0' this.style.padding = '0'
this.#container.style.overflow ='auto' const columnWidth = maxInlineSize
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)
this.heads = null this.heads = null
this.feet = null this.feet = null
@@ -497,50 +562,14 @@ export class Paginator extends HTMLElement {
return { flow, margin, gap, columnWidth } return { flow, margin, gap, columnWidth }
} }
const maxSize = `${maxColumns * maxColumnWidth}px` const divisor = Math.ceil(size / maxInlineSize)
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 columnWidth = (size / divisor) - gap const columnWidth = (size / divisor) - gap
this.setAttribute('dir', rtl ? 'rtl' : 'ltr') this.setAttribute('dir', rtl ? 'rtl' : 'ltr')
this.#container.style.overflow ='hidden'
const marginalDivisor = vertical const marginalDivisor = vertical
? Math.min(2, Math.ceil(width / maxColumnWidth)) ? Math.min(2, Math.ceil(width / maxInlineSize))
: divisor : divisor
const marginalStyle = { const marginalStyle = {
maxWidth: vertical ? 'none' : maxSize,
height: `${margin}px`,
gridTemplateColumns: `repeat(${marginalDivisor}, 1fr)`, gridTemplateColumns: `repeat(${marginalDivisor}, 1fr)`,
gap: `${gap}px`, gap: `${gap}px`,
padding: vertical ? '0' : `0 ${gap / 2}px`, padding: vertical ? '0' : `0 ${gap / 2}px`,
@@ -566,7 +595,7 @@ export class Paginator extends HTMLElement {
this.#scrollToAnchor() this.#scrollToAnchor()
} }
get scrolled() { get scrolled() {
return this.layout.flow === 'scrolled' return this.getAttribute('flow') === 'scrolled'
} }
get scrollProp() { get scrollProp() {
const { scrolled } = this const { scrolled } = this
@@ -658,7 +687,7 @@ export class Paginator extends HTMLElement {
#getRectMapper() { #getRectMapper() {
if (this.scrolled) { if (this.scrolled) {
const size = this.viewSize const size = this.viewSize
const margin = this.layout.margin const margin = this.#margin
return this.#vertical return this.#vertical
? ({ left, right }) => ? ({ left, right }) =>
({ left: size - right - margin, right: size - left - margin }) ({ left: size - right - margin, right: size - left - margin })
@@ -674,11 +703,11 @@ export class Paginator extends HTMLElement {
} }
async #scrollToRect(rect, reason) { async #scrollToRect(rect, reason) {
if (this.scrolled) { 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) return this.#scrollTo(offset, reason)
} }
const offset = this.#getRectMapper()(rect).left 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) return this.#scrollToPage(Math.floor(offset / this.size) + (this.#rtl ? -1 : 1), reason)
} }
async #scrollTo(offset, reason, smooth) { async #scrollTo(offset, reason, smooth) {
@@ -801,6 +830,7 @@ export class Paginator extends HTMLElement {
const oldIndex = this.#index const oldIndex = this.#index
const onLoad = detail => { const onLoad = detail => {
this.sections[oldIndex]?.unload?.() this.sections[oldIndex]?.unload?.()
this.setStyles(this.#styles)
this.dispatchEvent(new CustomEvent('load', { detail })) this.dispatchEvent(new CustomEvent('load', { detail }))
} }
await this.#display(Promise.resolve(this.sections[index].load()) await this.#display(Promise.resolve(this.sections[index].load())
@@ -890,7 +920,8 @@ export class Paginator extends HTMLElement {
}] }]
return [] return []
} }
setStyle(styles) { setStyles(styles) {
this.#styles = styles
const $$styles = this.#styleMap.get(this.#view?.document) const $$styles = this.#styleMap.get(this.#view?.document)
if (!$$styles) return if (!$$styles) return
const [$beforeStyle, $style] = $$styles const [$beforeStyle, $style] = $$styles
+2 -14
View File
@@ -147,12 +147,6 @@ class Reader {
justify: true, justify: true,
hyphenate: true, hyphenate: true,
} }
layout = {
margin: 48,
gap: 0.05,
maxColumns: 2,
maxColumnWidth: 720,
}
annotations = new Map() annotations = new Map()
annotationsByValue = new Map() annotationsByValue = new Map()
closeSideBar() { closeSideBar() {
@@ -176,8 +170,7 @@ class Reader {
['Scrolled', 'scrolled'], ['Scrolled', 'scrolled'],
], ],
onclick: value => { onclick: value => {
this.layout.flow = value this.view?.renderer.setAttribute('flow', value)
this.setAppearance()
}, },
}, },
]) ])
@@ -194,7 +187,7 @@ class Reader {
this.view.addEventListener('relocate', this.#onRelocate.bind(this)) this.view.addEventListener('relocate', this.#onRelocate.bind(this))
const { book } = this.view const { book } = this.view
this.setAppearance() this.view.renderer.setStyles?.(getCSS(this.style))
this.view.renderer.next() this.view.renderer.next()
$('#header-bar').style.visibility = 'visible' $('#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) { #handleKeydown(event) {
const k = event.key const k = event.key
if (k === 'ArrowLeft' || k === 'h') this.view.goLeft() if (k === 'ArrowLeft' || k === 'h') this.view.goLeft()
-9
View File
@@ -83,7 +83,6 @@ export class View extends HTMLElement {
#sectionProgress #sectionProgress
#tocProgress #tocProgress
#pageProgress #pageProgress
#css
isFixedLayout = false isFixedLayout = false
lastLocation lastLocation
history = new History() history = new History()
@@ -162,7 +161,6 @@ export class View extends HTMLElement {
if (!this.language.isCJK) if (!this.language.isCJK)
doc.documentElement.dir ||= this.language.direction ?? '' doc.documentElement.dir ||= this.language.direction ?? ''
this.renderer.setStyle?.(this.#css)
this.#handleLinks(doc, index) this.#handleLinks(doc, index)
this.#emit('load', { doc, index }) this.#emit('load', { doc, index })
@@ -315,13 +313,6 @@ export class View extends HTMLElement {
goRight() { goRight() {
return this.book.dir === 'rtl' ? this.prev() : this.next() 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) { async * #searchSection(matcher, query, index) {
const doc = await this.book.sections[index].createDocument() const doc = await this.book.sections[index].createDocument()
for (const { range, excerpt } of matcher(doc, query)) for (const { range, excerpt } of matcher(doc, query))