Turn View into a custom element, and use DOM events

Also change "relocated" -> "relocate"
This commit is contained in:
John Factotum
2023-05-15 15:27:18 +08:00
parent 6f6f6b4969
commit 4acfc050ea
5 changed files with 82 additions and 67 deletions
+20 -5
View File
@@ -44,14 +44,29 @@ There are mainly three kinds of modules:
- `progress.js`, for getting reading progress
- `search.js`, for searching
The modules are designed to be modular. In general, they don't directly depend on each other. Instead they depend on certain interfaces, detailed below. The exception is `view.js`. It is the higher level renderer that strings most of the things together, and you can think of it as the main entry point of the library. Its basic usage is as follows:
- The `View` constructor takes two arguments: `book`, an object that implements the "book" interface, and `emit`, which is a callback that you can use to handle various events. Note that for simplicity, unlike Epub.js or other libraries, there's no event or pub/sub system.
- To render the book, you must first call `.display()`, which is an async function that returns an Element, which you must then append to the DOM yourself, e.g. `document.body.append(await view.display())`.
- To actually display the page, you must then either call `.renderer.next()`, which will display the first linear page of the book, or use `.goTo()` to go to a specific location.
The modules are designed to be modular. In general, they don't directly depend on each other. Instead they depend on certain interfaces, detailed below. The exception is `view.js`. It is the higher level renderer that strings most of the things together, and you can think of it as the main entry point of the library. See "Basic Usage" below.
The repo also includes a still higher level reader, though strictly speaking, `reader.html` (along with `reader.js` and its associated files in `ui/` and `vendor/`) is not considered part of the library itself. It's akin to [Epub.js Reader](https://github.com/futurepress/epubjs-reader). You are expected to modify it or replace it with your own code.
### Basic Usage
```js
import { View } from './view.js'
customElements.define('foliate-view', View)
const view = document.createElement('foliate-view')
document.body.append(view)
view.addEventListener('relocate', e => {
console.log('location changed')
console.log(e.detail)
})
const book = /* an object implementing the "book" interface */
await view.open(book)
await view.goTo(/* path, section index, or CFI */)
```
### Security
Scripting is not supported, as it is currently impossible to do so securely due to the content being served from the same origin (using `blob:` URLs).
+5 -5
View File
@@ -181,10 +181,10 @@ export class FixedLayout {
#spreads
#index = -1
#container = new Container()
constructor({ book, onLoad, onRelocated }) {
constructor({ book, onLoad, onRelocate }) {
this.book = book
this.#container.onLoad = onLoad
this.onRelocated = onRelocated
this.onRelocate = onRelocate
const { rendition } = book
this.#container.spread = rendition?.spread
@@ -266,7 +266,7 @@ export class FixedLayout {
const right = { index: indexR, src: srcR }
await this.#container.showSpread({ left, right, side })
}
this.onRelocated?.(null, this.index, 0, 1)
this.onRelocate?.(null, this.index, 0, 1)
}
async select(target) {
await this.goTo(target)
@@ -282,12 +282,12 @@ export class FixedLayout {
}
async next() {
const s = this.rtl ? this.#container.goLeft() : this.#container.goRight()
if (s) this.onRelocated?.(null, this.index, 0, 1)
if (s) this.onRelocate?.(null, this.index, 0, 1)
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.onRelocated?.(null, this.index, 0, 1)
if (s) this.onRelocate?.(null, this.index, 0, 1)
else return this.goToSpread(this.#index - 1, this.rtl ? 'left' : 'right')
}
deselect() {
+4 -4
View File
@@ -351,11 +351,11 @@ export class Paginator {
gap: 0.05,
maxColumnWidth: 700,
}
constructor({ book, onLoad, onRelocated, createOverlayer }) {
constructor({ book, onLoad, onRelocate, createOverlayer }) {
this.bookDir = book.dir
this.sections = book.sections
this.onLoad = onLoad
this.onRelocated = onRelocated
this.onRelocate = onRelocate
this.createOverlayer = createOverlayer
Object.assign(this.#element.style, {
boxSizing: 'border-box',
@@ -658,11 +658,11 @@ export class Paginator {
const index = this.#index
if (this.scrolled)
this.onRelocated?.(range, index, this.start / this.viewSize)
this.onRelocate?.(range, index, this.start / this.viewSize)
else if (this.pages > 0) {
const { page, pages } = this
this.#header.style.visibility = page > 0 ? 'visible' : 'hidden'
this.onRelocated?.(range, index, page / pages, 1 / pages)
this.onRelocate?.(range, index, page / pages, 1 / pages)
}
}
async #display(promise) {
+13 -15
View File
@@ -2,6 +2,8 @@ import { View } from './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
@@ -55,7 +57,7 @@ const isFBZ = ({ name, type }) =>
type === 'application/x-zip-compressed-fb2'
|| name.endsWith('.fb2.zip') || name.endsWith('.fbz')
const getView = async (file, emit) => {
const getView = async file => {
let book
if (file.isDirectory) {
const loader = await makeDirectoryLoader(file)
@@ -89,9 +91,9 @@ const getView = async (file, emit) => {
}
}
if (!book) throw new Error('File type not supported')
const view = new View(book, emit)
const element = await view.display()
document.body.append(element)
const view = document.createElement('foliate-view')
document.body.append(view)
await view.open(book)
return view
}
@@ -186,7 +188,10 @@ class Reader {
menu.groups.layout.select('paginated')
}
async open(file) {
this.view = await getView(file, this.#handleEvent.bind(this))
this.view = await getView(file)
this.view.addEventListener('load', this.#onLoad.bind(this))
this.view.addEventListener('relocate', this.#onRelocate.bind(this))
const { book } = this.view
this.setAppearance()
this.view.renderer.next()
@@ -240,23 +245,16 @@ class Reader {
const scrolled = this.layout.flow === 'scrolled'
document.documentElement.classList.toggle('scrolled', scrolled)
}
#handleEvent(obj) {
console.debug(obj)
switch (obj.type) {
case 'loaded': this.#onLoaded(obj); break
case 'relocated': this.#onRelocated(obj); break
}
}
#handleKeydown(event) {
const k = event.key
if (k === 'ArrowLeft' || k === 'h') this.view.goLeft()
else if(k === 'ArrowRight' || k === 'l') this.view.goRight()
}
#onLoaded({ doc }) {
#onLoad({ detail: { doc } }) {
doc.addEventListener('keydown', this.#handleKeydown.bind(this))
}
#onRelocated(obj) {
const { fraction, location, tocItem, pageItem } = obj
#onRelocate({ detail }) {
const { fraction, location, tocItem, pageItem } = detail
const percent = percentFormat.format(fraction)
const loc = pageItem
? `Page ${pageItem.label}`
+40 -38
View File
@@ -26,29 +26,29 @@ const textWalker = function* (doc, func) {
for (const match of func(strs, makeRange)) yield match
}
export class View {
const languageInfo = lang => {
if (!lang) return {}
try {
const canonical = Intl.getCanonicalLocales(lang)[0]
const locale = new Intl.Locale(canonical)
const isCJK = ['zh', 'ja', 'kr'].includes(locale.language)
const direction = (locale.getTextInfo?.() ?? locale.textInfo)?.direction
return { canonical, locale, isCJK, direction }
} catch (e) {
console.warn(e)
return {}
}
}
export class View extends HTMLElement {
#sectionProgress
#tocProgress
#pageProgress
#css
language = 'en'
textDirection = ''
isCJK = false
isFixedLayout = false
constructor(book, emit) {
async open(book) {
this.book = book
this.emit = emit
if (book.metadata?.language) try {
const language = book.metadata.language
book.metadata.language = Intl.getCanonicalLocales(language)[0]
const tag = typeof language === 'string' ? language : language[0]
const locale = new Intl.Locale(tag)
this.isCJK = ['zh', 'ja', 'kr'].includes(locale.language)
this.textDirection = (locale.getTextInfo?.() ?? locale.textInfo)?.direction
} catch(e) {
console.warn(e)
}
this.language = languageInfo(book.metadata?.language)
if (book.splitTOCHref && book.getTOCFragment) {
const ids = book.sections.map(s => s.id)
@@ -60,12 +60,11 @@ export class View {
this.#pageProgress = new TOCProgress({
toc: book.pageList ?? [], ids, splitHref, getFragment })
}
}
async display() {
const opts = {
book: this.book,
onLoad: this.#onLoad.bind(this),
onRelocated: this.#onRelocated.bind(this),
onRelocate: this.#onRelocate.bind(this),
createOverlayer: this.#createOverlayer.bind(this),
}
this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated'
@@ -76,7 +75,7 @@ export class View {
const { Paginator } = await import('./paginator.js')
this.renderer = new Paginator(opts)
}
return this.renderer.element
this.append(this.renderer.element)
}
async init({ lastLocation }) {
if (lastLocation) {
@@ -85,25 +84,29 @@ export class View {
else await this.renderer.next()
} else await this.renderer.next()
}
#onRelocated(range, index, fraction, size) {
#emit(name, detail, cancelable) {
return this.dispatchEvent(new CustomEvent(name, { detail, cancelable }))
}
#onRelocate(range, index, fraction, size) {
if (!this.#sectionProgress) return
const progress = this.#sectionProgress.getProgress(index, fraction, size)
const tocItem = this.#tocProgress.getProgress(index, range)
const pageItem = this.#pageProgress.getProgress(index, range)
const cfi = this.getCFI(index, range)
this.emit?.({ type: 'relocated', ...progress, tocItem, pageItem, cfi, range })
this.#emit('relocate', { ...progress, tocItem, pageItem, cfi, range })
}
#onLoad(doc, index) {
// set language and dir if not already set
doc.documentElement.lang ||= this.language
doc.documentElement.dir ||= this.isCJK ? '' : this.textDirection
doc.documentElement.lang ||= this.language.canonical ?? ''
if (!this.language.isCJK)
doc.documentElement.dir ||= this.language.direction ?? ''
this.renderer.setStyle?.(this.#css)
this.handleLinks(doc, index, this.emit)
this.#handleLinks(doc, index)
this.emit?.({ type: 'loaded', doc, index })
this.#emit('load', { doc, index })
}
handleLinks(doc, index, emit) {
#handleLinks(doc, index) {
const { book } = this
const section = book.sections[index]
for (const a of doc.querySelectorAll('a[href]'))
@@ -112,11 +115,11 @@ export class View {
const href_ = a.getAttribute('href')
const href = section?.resolveHref?.(href_) ?? href_
if (book?.isExternal?.(href))
Promise.resolve(emit?.({ type: 'external-link', a, href }))
.then(x => x ? null : window.open(href, '_blank'))
Promise.resolve(this.#emit('external-link', { a, href }, true))
.then(x => x ? globalThis.open(href, '_blank') : null)
.catch(e => console.error(e))
else Promise.resolve(emit?.({ type: 'link', a, href }))
.then(x => x ? null : this.goTo(href))
else Promise.resolve(this.#emit('link', { a, href }, true))
.then(x => x ? this.goTo(href) : null)
.catch(e => console.error(e))
})
}
@@ -129,9 +132,8 @@ export class View {
overlayer.remove(value)
if (!remove) {
const range = doc ? anchor(doc) : anchor
const [func, opts] = this
.emit({ type: 'draw-annotation', annotation, doc, range })
overlayer.add(value, range, func, opts)
const draw = (func, opts) => overlayer.add(value, range, func, opts)
this.#emit('draw-annotation', { draw, annotation, doc, range })
}
}
const label = this.#tocProgress.getProgress(index)?.label ?? ''
@@ -149,10 +151,10 @@ export class View {
doc.addEventListener('click', e => {
const [value, range] = overlayer.hitTest(e)
if (value) {
this.emit?.({ type: 'show-annotation', value, range })
this.#emit('show-annotation', { value, range })
}
}, false)
this.emit?.({ type: 'create-overlay', index })
this.#emit('create-overlay', { index })
return overlayer
}
async showAnnotation(annotation) {
@@ -160,7 +162,7 @@ export class View {
const { index, anchor } = await this.goTo(value)
const { doc } = this.#getOverlayer(index)
const range = anchor(doc)
this.emit?.({ type: 'show-annotation', value, range })
this.#emit('show-annotation', { value, range })
}
getCFI(index, range) {
const baseCFI = this.book.sections[index].cfi ?? CFI.fake.fromIndex(index)