mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 11:29:14 -04:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user