Download static/foliate-reader/reader.js from VoiceOfML/Search: direct link, hf CLI and curl.
- Browser
- Download file 14 kB
-
https://huggingface.co/spaces/VoiceOfML/Search/resolve/main/static/foliate-reader/reader.js
- Command line
-
hf download hf://spaces/VoiceOfML/Search/static/foliate-reader/reader.js
-
curl -L -o reader.js https://huggingface.co/spaces/VoiceOfML/Search/resolve/main/static/foliate-reader/reader.js
14 kB
| import './view.js?reader-v1' | |
| import { createTOCView } from './ui/tree.js' | |
| import { createMenu } from './ui/menu.js' | |
| import { Overlayer } from './overlayer.js' | |
| const getCSS = ({ spacing, justify, hyphenate }) => ` | |
| @namespace epub "http://www.idpf.org/2007/ops"; | |
| html { | |
| color-scheme: light dark; | |
| } | |
| /* https://github.com/whatwg/html/issues/5426 */ | |
| @media (prefers-color-scheme: dark) { | |
| a:link { | |
| color: lightblue; | |
| } | |
| } | |
| p, li, blockquote, dd { | |
| line-height: ${spacing}; | |
| text-align: ${justify ? 'justify' : 'start'}; | |
| -webkit-hyphens: ${hyphenate ? 'auto' : 'manual'}; | |
| hyphens: ${hyphenate ? 'auto' : 'manual'}; | |
| -webkit-hyphenate-limit-before: 3; | |
| -webkit-hyphenate-limit-after: 2; | |
| -webkit-hyphenate-limit-lines: 2; | |
| hanging-punctuation: allow-end last; | |
| widows: 2; | |
| } | |
| /* prevent the above from overriding the align attribute */ | |
| [align="left"] { text-align: left; } | |
| [align="right"] { text-align: right; } | |
| [align="center"] { text-align: center; } | |
| [align="justify"] { text-align: justify; } | |
| pre { | |
| white-space: pre-wrap !important; | |
| } | |
| aside[epub|type~="endnote"], | |
| aside[epub|type~="footnote"], | |
| aside[epub|type~="note"], | |
| aside[epub|type~="rearnote"] { | |
| display: none; | |
| } | |
| ` | |
| const $ = document.querySelector.bind(document) | |
| const locales = 'en' | |
| const percentFormat = new Intl.NumberFormat(locales, { style: 'percent' }) | |
| const listFormat = new Intl.ListFormat(locales, { style: 'short', type: 'conjunction' }) | |
| const formatLanguageMap = x => { | |
| if (!x) return '' | |
| if (typeof x === 'string') return x | |
| const keys = Object.keys(x) | |
| return x[keys[0]] | |
| } | |
| const formatOneContributor = contributor => typeof contributor === 'string' | |
| ? contributor : formatLanguageMap(contributor?.name) | |
| const formatContributor = contributor => Array.isArray(contributor) | |
| ? listFormat.format(contributor.map(formatOneContributor)) | |
| : formatOneContributor(contributor) | |
| class Reader { | |
| #tocView | |
| style = { | |
| spacing: 1.4, | |
| justify: true, | |
| hyphenate: true, | |
| } | |
| annotations = new Map() | |
| annotationsByValue = new Map() | |
| closeSideBar() { | |
| $('#dimming-overlay').classList.remove('show') | |
| $('#side-bar').classList.remove('show') | |
| } | |
| constructor() { | |
| $('#side-bar-button').addEventListener('click', () => { | |
| $('#dimming-overlay').classList.add('show') | |
| $('#side-bar').classList.add('show') | |
| }) | |
| $('#dimming-overlay').addEventListener('click', () => this.closeSideBar()) | |
| const menu = createMenu([ | |
| { | |
| name: 'layout', | |
| label: 'Layout', | |
| type: 'radio', | |
| items: [ | |
| ['Paginated', 'paginated'], | |
| ['Scrolled', 'scrolled'], | |
| ], | |
| onclick: value => { | |
| this.view?.renderer.setAttribute('flow', value) | |
| }, | |
| }, | |
| ]) | |
| menu.element.classList.add('menu') | |
| $('#menu-button').append(menu.element) | |
| $('#menu-button > button').addEventListener('click', () => | |
| menu.element.classList.toggle('show')) | |
| menu.groups.layout.select('scrolled') | |
| } | |
| async open(file) { | |
| this.view = document.createElement('foliate-view') | |
| document.body.append(this.view) | |
| await this.view.open(file) | |
| this.view.renderer.setAttribute('flow', 'scrolled') | |
| let moving = false | |
| let previousStart = null | |
| const moveSection = async direction => { | |
| const renderer = this.view.renderer | |
| if (moving || !renderer.scrolled || !direction) return | |
| const start = renderer.start | |
| const end = renderer.end | |
| const viewSize = renderer.viewSize | |
| if (direction > 0 && end < viewSize - 2) return | |
| if (direction < 0 && start > 2) return | |
| moving = true | |
| try { | |
| if (direction > 0) await this.view.next() | |
| else await this.view.prev() | |
| } finally { | |
| moving = false | |
| previousStart = null | |
| } | |
| } | |
| const moveAcrossSections = () => { | |
| const renderer = this.view.renderer | |
| const start = renderer.start | |
| const direction = previousStart == null ? 0 : start - previousStart | |
| previousStart = start | |
| moveSection(direction) | |
| } | |
| let boundContainer = null | |
| const bindScrollContainer = () => { | |
| const container = this.view.renderer.shadowRoot?.querySelector('#container') || this.view.renderer | |
| if (container === boundContainer) return | |
| boundContainer = container | |
| container.addEventListener('scroll', moveAcrossSections) | |
| container.addEventListener('wheel', event => { | |
| if (event.deltaY) moveSection(event.deltaY > 0 ? 1 : -1) | |
| }, { passive: true }) | |
| } | |
| bindScrollContainer() | |
| this.view.renderer.addEventListener('load', bindScrollContainer) | |
| setInterval(bindScrollContainer, 200) | |
| let touchStartY = null | |
| this.view.renderer.addEventListener('touchstart', event => { | |
| touchStartY = event.touches[0]?.clientY ?? null | |
| }, { passive: true }) | |
| this.view.renderer.addEventListener('touchend', event => { | |
| if (touchStartY == null) return | |
| const delta = touchStartY - (event.changedTouches[0]?.clientY ?? touchStartY) | |
| touchStartY = null | |
| if (Math.abs(delta) > 24) moveSection(delta > 0 ? 1 : -1) | |
| }, { passive: true }) | |
| this.view.addEventListener('load', this.#onLoad.bind(this)) | |
| this.view.addEventListener('relocate', this.#onRelocate.bind(this)) | |
| const { book } = this.view | |
| book.transformTarget?.addEventListener('data', ({ detail }) => { | |
| detail.data = Promise.resolve(detail.data).catch(e => { | |
| console.error(new Error(`Failed to load ${detail.name}`, { cause: e })) | |
| return '' | |
| }) | |
| }) | |
| this.view.renderer.setStyles?.(getCSS(this.style)) | |
| await Promise.race([ | |
| this.view.goTo(0), | |
| new Promise(resolve => setTimeout(resolve, 3000)), | |
| ]) | |
| $('#header-bar').style.visibility = 'visible' | |
| $('#nav-bar').style.visibility = 'visible' | |
| $('#left-button').addEventListener('click', () => this.view.goLeft()) | |
| $('#right-button').addEventListener('click', () => this.view.goRight()) | |
| const slider = $('#progress-slider') | |
| slider.dir = book.dir | |
| slider.addEventListener('input', e => | |
| this.view.goToFraction(parseFloat(e.target.value))) | |
| for (const fraction of this.view.getSectionFractions()) { | |
| const option = document.createElement('option') | |
| option.value = fraction | |
| $('#tick-marks').append(option) | |
| } | |
| document.addEventListener('keydown', this.#handleKeydown.bind(this)) | |
| const title = formatLanguageMap(book.metadata?.title) || 'Untitled Book' | |
| document.title = title | |
| $('#side-bar-title').innerText = title | |
| $('#side-bar-author').innerText = formatContributor(book.metadata?.author) | |
| Promise.resolve(book.getCover?.())?.then(blob => | |
| blob ? $('#side-bar-cover').src = URL.createObjectURL(blob) : null) | |
| const toc = book.toc | |
| if (toc) { | |
| this.#tocView = createTOCView(toc, href => { | |
| this.view.goTo(href).catch(e => console.error(e)) | |
| this.closeSideBar() | |
| }) | |
| $('#toc-view').append(this.#tocView.element) | |
| const entries = [] | |
| const append = (items, depth = 0) => { | |
| for (const item of items || []) { | |
| entries.push({ label: item.label || '未命名章节', href: item.href, depth }) | |
| append(item.subitems, depth + 1) | |
| } | |
| } | |
| append(toc) | |
| if (window.parent !== window) window.parent.postMessage({ type: 'voice-foliate-toc', entries }, location.origin) | |
| } | |
| // load and show highlights embedded in the file by Calibre | |
| const bookmarks = await book.getCalibreBookmarks?.() | |
| if (bookmarks) { | |
| const { fromCalibreHighlight } = await import('./epubcfi.js') | |
| for (const obj of bookmarks) { | |
| if (obj.type === 'highlight') { | |
| const value = fromCalibreHighlight(obj) | |
| const color = obj.style.which | |
| const note = obj.notes | |
| const annotation = { value, color, note } | |
| const list = this.annotations.get(obj.spine_index) | |
| if (list) list.push(annotation) | |
| else this.annotations.set(obj.spine_index, [annotation]) | |
| this.annotationsByValue.set(value, annotation) | |
| } | |
| } | |
| this.view.addEventListener('create-overlay', e => { | |
| const { index } = e.detail | |
| const list = this.annotations.get(index) | |
| if (list) for (const annotation of list) | |
| this.view.addAnnotation(annotation) | |
| }) | |
| this.view.addEventListener('draw-annotation', e => { | |
| const { draw, annotation } = e.detail | |
| const { color } = annotation | |
| draw(Overlayer.highlight, { color }) | |
| }) | |
| this.view.addEventListener('show-annotation', e => { | |
| const annotation = this.annotationsByValue.get(e.detail.value) | |
| if (annotation.note) alert(annotation.note) | |
| }) | |
| } | |
| } | |
| #handleKeydown(event) { | |
| const k = event.key | |
| if (k === 'ArrowLeft' || k === 'h') this.view.goLeft() | |
| else if(k === 'ArrowRight' || k === 'l') this.view.goRight() | |
| } | |
| #onLoad({ detail: { doc } }) { | |
| doc.addEventListener('keydown', this.#handleKeydown.bind(this)) | |
| } | |
| #onRelocate({ detail }) { | |
| const { fraction, location, tocItem, pageItem, cfi } = detail | |
| const percent = percentFormat.format(fraction) | |
| const loc = pageItem | |
| ? `Page ${pageItem.label}` | |
| : `Loc ${location.current}` | |
| const slider = $('#progress-slider') | |
| slider.style.visibility = 'visible' | |
| slider.value = fraction | |
| slider.title = `${percent} · ${loc}` | |
| if (tocItem?.href) this.#tocView?.setCurrentHref?.(tocItem.href) | |
| if (window.parent !== window) window.parent.postMessage({ | |
| type: 'voice-foliate-relocate', fraction, | |
| cfi: cfi || '', href: tocItem?.href || '' | |
| }, globalThis.location.origin) | |
| } | |
| } | |
| const open = async file => { | |
| document.body.removeChild($('#drop-target')) | |
| const reader = new Reader() | |
| globalThis.reader = reader | |
| await reader.open(file) | |
| } | |
| const dragOverHandler = e => e.preventDefault() | |
| const dropHandler = e => { | |
| e.preventDefault() | |
| const item = Array.from(e.dataTransfer.items) | |
| .find(item => item.kind === 'file') | |
| if (item) { | |
| const entry = item.webkitGetAsEntry() | |
| open(entry.isFile ? item.getAsFile() : entry).catch(e => console.error(e)) | |
| } | |
| } | |
| const dropTarget = $('#drop-target') | |
| dropTarget.addEventListener('drop', dropHandler) | |
| dropTarget.addEventListener('dragover', dragOverHandler) | |
| $('#file-input').addEventListener('change', e => | |
| open(e.target.files[0]).catch(e => console.error(e))) | |
| $('#file-button').addEventListener('click', () => $('#file-input').click()) | |
| const params = new URLSearchParams(location.search) | |
| const url = params.get('url') | |
| const proxy = params.get('proxy') | |
| if (params.get('embed') === '1') document.documentElement.classList.add('embedded') | |
| const fileNameHint = params.get('file_name') || '' | |
| globalThis.foliateFileNameHint = fileNameHint | |
| window.addEventListener('message', event => { | |
| if (event.source !== window.parent || event.data?.type !== 'voice-foliate-goto') return | |
| if (event.data.href) globalThis.reader?.view?.goTo(event.data.href).catch(e => console.error(e)) | |
| }) | |
| window.addEventListener('message', event => { | |
| if (event.source !== window.parent) return | |
| const type = event.data?.type | |
| if (type === 'voice-foliate-seek') globalThis.reader?.view?.goToFraction(Number(event.data.percent) / 100) | |
| else if (type === 'voice-foliate-prev') globalThis.reader?.view?.prev() | |
| else if (type === 'voice-foliate-next') globalThis.reader?.view?.next() | |
| }) | |
| window.addEventListener('message', event => { | |
| if (event.source !== window.parent || event.data?.type !== 'voice-foliate-goto-cfi') return | |
| if (event.data.cfi) globalThis.reader?.view?.goTo(event.data.cfi).catch(e => console.error(e)) | |
| }) | |
| window.addEventListener('message', async event => { | |
| if (event.source !== window.parent || event.data?.type !== 'voice-foliate-search') return | |
| const results = [] | |
| for await (const group of globalThis.reader?.view?.search?.({ query: event.data.query }) || []) { | |
| for (const item of group.subitems || []) results.push({ label: group.label || '电子书位置', cfi: item.cfi, excerpt: item.excerpt || { pre: '', match: event.data.query, post: '' } }) | |
| if (results.length >= 100) break | |
| } | |
| window.parent.postMessage({ type: 'voice-foliate-search-results', results: results.slice(0, 100) }, location.origin) | |
| }) | |
| if (url) open(proxy || url).then(() => window.parent !== window && window.parent.postMessage({ type: 'voice-foliate-ready' }, location.origin)).catch(e => { console.error(e); if (window.parent !== window) window.parent.postMessage({ type: 'voice-foliate-error', message: e?.message || 'FOLIATE_LOAD_FAILED' }, location.origin) }) | |
| else dropTarget.style.visibility = 'visible' | |