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.
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 `<length>`. The unit must be `px`. The height of the header and footer.
- `gap`: a CSS `<percentage>`. The size of the space between columns, relative to page size.
- `max-inline-size`: a CSS `<length>`. 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,
+117 -86
View File
@@ -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 = `<style>
: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;
position: relative;
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%;
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 {
position: absolute;
left: 0;
right: 0;
display: grid;
margin: auto;
height: var(--_margin);
}
:is(#header, #footer) > * {
display: flex;
@@ -422,23 +454,17 @@ export class Paginator extends HTMLElement {
}
</style>
<div id="background" part="filter"></div>
<div id="max-width">
<div id="max-height">
<div id="container"></div>
</div>
<div id="header"></div>
<div id="container"></div>
<div id="footer"></div>
</div>
`
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
+2 -14
View File
@@ -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()
-9
View File
@@ -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))