Use Shadow DOM

This commit is contained in:
John Factotum
2023-05-15 20:00:35 +08:00
parent 4acfc050ea
commit 0a3f4b8f27
5 changed files with 182 additions and 178 deletions
+15 -18
View File
@@ -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
+49 -58
View File
@@ -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)
+100 -85
View File
@@ -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 = `<style>
:host {
box-sizing: border-box;
position: relative;
overflow: hidden;
display: flex;
}
Object.assign(this.#header.style, marginalStyle)
Object.assign(this.#footer.style, marginalStyle)
this.#header.classList.add('foliate-header')
this.#footer.classList.add('foliate-footer')
this.#maxWidthContainer.append(this.#header)
this.#maxWidthContainer.append(this.#footer)
#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;
}
#header, #footer {
position: absolute;
left: 0;
right: 0;
display: grid;
margin: auto;
z-index: 1;
}
:is(#header, #footer) > * {
display: flex;
align-items: center;
minWidth: 0;
}
:is(#header, #footer) > * > * {
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
text-align: center;
font-size: .75em;
opacity: .6;
}
</style>
<div id="background" part="filter"></div>
<div id="max-width">
<div id="header"></div>
<div id="max-height">
<div id="container"></div>
</div>
<div id="footer"></div>
</div>
`
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)
+1 -3
View File
@@ -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
+17 -14
View File
@@ -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)