mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 19:39:13 -04:00
Turn View into a custom element, and use DOM events
Also change "relocated" -> "relocate"
This commit is contained in:
@@ -44,14 +44,29 @@ There are mainly three kinds of modules:
|
|||||||
- `progress.js`, for getting reading progress
|
- `progress.js`, for getting reading progress
|
||||||
- `search.js`, for searching
|
- `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 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 `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 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.
|
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
|
### 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).
|
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
@@ -181,10 +181,10 @@ export class FixedLayout {
|
|||||||
#spreads
|
#spreads
|
||||||
#index = -1
|
#index = -1
|
||||||
#container = new Container()
|
#container = new Container()
|
||||||
constructor({ book, onLoad, onRelocated }) {
|
constructor({ book, onLoad, onRelocate }) {
|
||||||
this.book = book
|
this.book = book
|
||||||
this.#container.onLoad = onLoad
|
this.#container.onLoad = onLoad
|
||||||
this.onRelocated = onRelocated
|
this.onRelocate = onRelocate
|
||||||
|
|
||||||
const { rendition } = book
|
const { rendition } = book
|
||||||
this.#container.spread = rendition?.spread
|
this.#container.spread = rendition?.spread
|
||||||
@@ -266,7 +266,7 @@ export class FixedLayout {
|
|||||||
const right = { index: indexR, src: srcR }
|
const right = { index: indexR, src: srcR }
|
||||||
await this.#container.showSpread({ left, right, side })
|
await this.#container.showSpread({ left, right, side })
|
||||||
}
|
}
|
||||||
this.onRelocated?.(null, this.index, 0, 1)
|
this.onRelocate?.(null, this.index, 0, 1)
|
||||||
}
|
}
|
||||||
async select(target) {
|
async select(target) {
|
||||||
await this.goTo(target)
|
await this.goTo(target)
|
||||||
@@ -282,12 +282,12 @@ export class FixedLayout {
|
|||||||
}
|
}
|
||||||
async next() {
|
async next() {
|
||||||
const s = this.rtl ? this.#container.goLeft() : this.#container.goRight()
|
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')
|
else return this.goToSpread(this.#index + 1, this.rtl ? 'right' : 'left')
|
||||||
}
|
}
|
||||||
async prev() {
|
async prev() {
|
||||||
const s = this.rtl ? this.#container.goRight() : this.#container.goLeft()
|
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')
|
else return this.goToSpread(this.#index - 1, this.rtl ? 'left' : 'right')
|
||||||
}
|
}
|
||||||
deselect() {
|
deselect() {
|
||||||
|
|||||||
+4
-4
@@ -351,11 +351,11 @@ export class Paginator {
|
|||||||
gap: 0.05,
|
gap: 0.05,
|
||||||
maxColumnWidth: 700,
|
maxColumnWidth: 700,
|
||||||
}
|
}
|
||||||
constructor({ book, onLoad, onRelocated, createOverlayer }) {
|
constructor({ book, onLoad, onRelocate, createOverlayer }) {
|
||||||
this.bookDir = book.dir
|
this.bookDir = book.dir
|
||||||
this.sections = book.sections
|
this.sections = book.sections
|
||||||
this.onLoad = onLoad
|
this.onLoad = onLoad
|
||||||
this.onRelocated = onRelocated
|
this.onRelocate = onRelocate
|
||||||
this.createOverlayer = createOverlayer
|
this.createOverlayer = createOverlayer
|
||||||
Object.assign(this.#element.style, {
|
Object.assign(this.#element.style, {
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
@@ -658,11 +658,11 @@ export class Paginator {
|
|||||||
|
|
||||||
const index = this.#index
|
const index = this.#index
|
||||||
if (this.scrolled)
|
if (this.scrolled)
|
||||||
this.onRelocated?.(range, index, this.start / this.viewSize)
|
this.onRelocate?.(range, index, this.start / this.viewSize)
|
||||||
else if (this.pages > 0) {
|
else if (this.pages > 0) {
|
||||||
const { page, pages } = this
|
const { page, pages } = this
|
||||||
this.#header.style.visibility = page > 0 ? 'visible' : 'hidden'
|
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) {
|
async #display(promise) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { View } from './view.js'
|
|||||||
import { createTOCView } from './ui/tree.js'
|
import { createTOCView } from './ui/tree.js'
|
||||||
import { createMenu } from './ui/menu.js'
|
import { createMenu } from './ui/menu.js'
|
||||||
|
|
||||||
|
customElements.define('foliate-view', View)
|
||||||
|
|
||||||
const isZip = async file => {
|
const isZip = async file => {
|
||||||
const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer())
|
const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer())
|
||||||
return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04
|
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'
|
type === 'application/x-zip-compressed-fb2'
|
||||||
|| name.endsWith('.fb2.zip') || name.endsWith('.fbz')
|
|| name.endsWith('.fb2.zip') || name.endsWith('.fbz')
|
||||||
|
|
||||||
const getView = async (file, emit) => {
|
const getView = async file => {
|
||||||
let book
|
let book
|
||||||
if (file.isDirectory) {
|
if (file.isDirectory) {
|
||||||
const loader = await makeDirectoryLoader(file)
|
const loader = await makeDirectoryLoader(file)
|
||||||
@@ -89,9 +91,9 @@ const getView = async (file, emit) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!book) throw new Error('File type not supported')
|
if (!book) throw new Error('File type not supported')
|
||||||
const view = new View(book, emit)
|
const view = document.createElement('foliate-view')
|
||||||
const element = await view.display()
|
document.body.append(view)
|
||||||
document.body.append(element)
|
await view.open(book)
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +188,10 @@ class Reader {
|
|||||||
menu.groups.layout.select('paginated')
|
menu.groups.layout.select('paginated')
|
||||||
}
|
}
|
||||||
async open(file) {
|
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
|
const { book } = this.view
|
||||||
this.setAppearance()
|
this.setAppearance()
|
||||||
this.view.renderer.next()
|
this.view.renderer.next()
|
||||||
@@ -240,23 +245,16 @@ class Reader {
|
|||||||
const scrolled = this.layout.flow === 'scrolled'
|
const scrolled = this.layout.flow === 'scrolled'
|
||||||
document.documentElement.classList.toggle('scrolled', 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) {
|
#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()
|
||||||
else if(k === 'ArrowRight' || k === 'l') this.view.goRight()
|
else if(k === 'ArrowRight' || k === 'l') this.view.goRight()
|
||||||
}
|
}
|
||||||
#onLoaded({ doc }) {
|
#onLoad({ detail: { doc } }) {
|
||||||
doc.addEventListener('keydown', this.#handleKeydown.bind(this))
|
doc.addEventListener('keydown', this.#handleKeydown.bind(this))
|
||||||
}
|
}
|
||||||
#onRelocated(obj) {
|
#onRelocate({ detail }) {
|
||||||
const { fraction, location, tocItem, pageItem } = obj
|
const { fraction, location, tocItem, pageItem } = detail
|
||||||
const percent = percentFormat.format(fraction)
|
const percent = percentFormat.format(fraction)
|
||||||
const loc = pageItem
|
const loc = pageItem
|
||||||
? `Page ${pageItem.label}`
|
? `Page ${pageItem.label}`
|
||||||
|
|||||||
@@ -26,29 +26,29 @@ const textWalker = function* (doc, func) {
|
|||||||
for (const match of func(strs, makeRange)) yield match
|
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
|
#sectionProgress
|
||||||
#tocProgress
|
#tocProgress
|
||||||
#pageProgress
|
#pageProgress
|
||||||
#css
|
#css
|
||||||
language = 'en'
|
|
||||||
textDirection = ''
|
|
||||||
isCJK = false
|
|
||||||
isFixedLayout = false
|
isFixedLayout = false
|
||||||
constructor(book, emit) {
|
async open(book) {
|
||||||
this.book = book
|
this.book = book
|
||||||
this.emit = emit
|
this.language = languageInfo(book.metadata?.language)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (book.splitTOCHref && book.getTOCFragment) {
|
if (book.splitTOCHref && book.getTOCFragment) {
|
||||||
const ids = book.sections.map(s => s.id)
|
const ids = book.sections.map(s => s.id)
|
||||||
@@ -60,12 +60,11 @@ export class View {
|
|||||||
this.#pageProgress = new TOCProgress({
|
this.#pageProgress = new TOCProgress({
|
||||||
toc: book.pageList ?? [], ids, splitHref, getFragment })
|
toc: book.pageList ?? [], ids, splitHref, getFragment })
|
||||||
}
|
}
|
||||||
}
|
|
||||||
async display() {
|
|
||||||
const opts = {
|
const opts = {
|
||||||
book: this.book,
|
book: this.book,
|
||||||
onLoad: this.#onLoad.bind(this),
|
onLoad: this.#onLoad.bind(this),
|
||||||
onRelocated: this.#onRelocated.bind(this),
|
onRelocate: this.#onRelocate.bind(this),
|
||||||
createOverlayer: this.#createOverlayer.bind(this),
|
createOverlayer: this.#createOverlayer.bind(this),
|
||||||
}
|
}
|
||||||
this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated'
|
this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated'
|
||||||
@@ -76,7 +75,7 @@ export class View {
|
|||||||
const { Paginator } = await import('./paginator.js')
|
const { Paginator } = await import('./paginator.js')
|
||||||
this.renderer = new Paginator(opts)
|
this.renderer = new Paginator(opts)
|
||||||
}
|
}
|
||||||
return this.renderer.element
|
this.append(this.renderer.element)
|
||||||
}
|
}
|
||||||
async init({ lastLocation }) {
|
async init({ lastLocation }) {
|
||||||
if (lastLocation) {
|
if (lastLocation) {
|
||||||
@@ -85,25 +84,29 @@ export class View {
|
|||||||
else await this.renderer.next()
|
else await this.renderer.next()
|
||||||
} 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
|
if (!this.#sectionProgress) return
|
||||||
const progress = this.#sectionProgress.getProgress(index, fraction, size)
|
const progress = this.#sectionProgress.getProgress(index, fraction, size)
|
||||||
const tocItem = this.#tocProgress.getProgress(index, range)
|
const tocItem = this.#tocProgress.getProgress(index, range)
|
||||||
const pageItem = this.#pageProgress.getProgress(index, range)
|
const pageItem = this.#pageProgress.getProgress(index, range)
|
||||||
const cfi = this.getCFI(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) {
|
#onLoad(doc, index) {
|
||||||
// set language and dir if not already set
|
// set language and dir if not already set
|
||||||
doc.documentElement.lang ||= this.language
|
doc.documentElement.lang ||= this.language.canonical ?? ''
|
||||||
doc.documentElement.dir ||= this.isCJK ? '' : this.textDirection
|
if (!this.language.isCJK)
|
||||||
|
doc.documentElement.dir ||= this.language.direction ?? ''
|
||||||
|
|
||||||
this.renderer.setStyle?.(this.#css)
|
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 { book } = this
|
||||||
const section = book.sections[index]
|
const section = book.sections[index]
|
||||||
for (const a of doc.querySelectorAll('a[href]'))
|
for (const a of doc.querySelectorAll('a[href]'))
|
||||||
@@ -112,11 +115,11 @@ export class View {
|
|||||||
const href_ = a.getAttribute('href')
|
const href_ = a.getAttribute('href')
|
||||||
const href = section?.resolveHref?.(href_) ?? href_
|
const href = section?.resolveHref?.(href_) ?? href_
|
||||||
if (book?.isExternal?.(href))
|
if (book?.isExternal?.(href))
|
||||||
Promise.resolve(emit?.({ type: 'external-link', a, href }))
|
Promise.resolve(this.#emit('external-link', { a, href }, true))
|
||||||
.then(x => x ? null : window.open(href, '_blank'))
|
.then(x => x ? globalThis.open(href, '_blank') : null)
|
||||||
.catch(e => console.error(e))
|
.catch(e => console.error(e))
|
||||||
else Promise.resolve(emit?.({ type: 'link', a, href }))
|
else Promise.resolve(this.#emit('link', { a, href }, true))
|
||||||
.then(x => x ? null : this.goTo(href))
|
.then(x => x ? this.goTo(href) : null)
|
||||||
.catch(e => console.error(e))
|
.catch(e => console.error(e))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -129,9 +132,8 @@ export class View {
|
|||||||
overlayer.remove(value)
|
overlayer.remove(value)
|
||||||
if (!remove) {
|
if (!remove) {
|
||||||
const range = doc ? anchor(doc) : anchor
|
const range = doc ? anchor(doc) : anchor
|
||||||
const [func, opts] = this
|
const draw = (func, opts) => overlayer.add(value, range, func, opts)
|
||||||
.emit({ type: 'draw-annotation', annotation, doc, range })
|
this.#emit('draw-annotation', { draw, annotation, doc, range })
|
||||||
overlayer.add(value, range, func, opts)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const label = this.#tocProgress.getProgress(index)?.label ?? ''
|
const label = this.#tocProgress.getProgress(index)?.label ?? ''
|
||||||
@@ -149,10 +151,10 @@ export class View {
|
|||||||
doc.addEventListener('click', e => {
|
doc.addEventListener('click', e => {
|
||||||
const [value, range] = overlayer.hitTest(e)
|
const [value, range] = overlayer.hitTest(e)
|
||||||
if (value) {
|
if (value) {
|
||||||
this.emit?.({ type: 'show-annotation', value, range })
|
this.#emit('show-annotation', { value, range })
|
||||||
}
|
}
|
||||||
}, false)
|
}, false)
|
||||||
this.emit?.({ type: 'create-overlay', index })
|
this.#emit('create-overlay', { index })
|
||||||
return overlayer
|
return overlayer
|
||||||
}
|
}
|
||||||
async showAnnotation(annotation) {
|
async showAnnotation(annotation) {
|
||||||
@@ -160,7 +162,7 @@ export class View {
|
|||||||
const { index, anchor } = await this.goTo(value)
|
const { index, anchor } = await this.goTo(value)
|
||||||
const { doc } = this.#getOverlayer(index)
|
const { doc } = this.#getOverlayer(index)
|
||||||
const range = anchor(doc)
|
const range = anchor(doc)
|
||||||
this.emit?.({ type: 'show-annotation', value, range })
|
this.#emit('show-annotation', { value, range })
|
||||||
}
|
}
|
||||||
getCFI(index, range) {
|
getCFI(index, range) {
|
||||||
const baseCFI = this.book.sections[index].cfi ?? CFI.fake.fromIndex(index)
|
const baseCFI = this.book.sections[index].cfi ?? CFI.fake.fromIndex(index)
|
||||||
|
|||||||
Reference in New Issue
Block a user