File indexing completed on 2024-12-22 03:53:21

0001 // SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
0002 // SPDX-FileCopyrightText: 2022 John Factotum <50942278+johnfactotum@users.noreply.github.com>
0003 // SPDX-License-Identifier: GPL-3.0-or-later
0004 
0005 // 1024 characters per page is used by Adobe Digital Editions
0006 const CHARACTERS_PER_PAGE = 1024
0007 const CHARACTERS_PER_WORD = lang =>
0008     lang === 'zh' || lang === 'ja' || lang === 'ko' ? 2.5 : 6
0009 const WORDS_PER_MINUTE = 200
0010 
0011 let book = ePub()
0012 let rendition
0013 let cfiToc
0014 let sectionMarks = []
0015 let lineHeight = 24
0016 let enableFootnote = false
0017 let autohideCursor, myScreenX, myScreenY, cursorHidden
0018 let ibooksInternalTheme = 'Light'
0019 let doubleClickTime = 400
0020 let imgEventType = 'click'
0021 let zoomLevel = 1
0022 let windowSize
0023 let windowHeight
0024 const getWindowIsZoomed = () => Math.abs(windowSize - window.innerWidth * zoomLevel) > 2
0025 
0026 const CFI = new ePub.CFI()
0027 
0028 let backend;
0029 window.onload = () => {
0030     new QWebChannel(qt.webChannelTransport, (channel) => {
0031         backend = channel.objects.backend;
0032         dispatch({ type: 'ready' })
0033         backend.progressChanged.connect(() => {
0034             if (!rendition.location || rendition.location.start.percentage !== backend.progress) {
0035                 rendition.display(book.locations.cfiFromPercentage(backend.progress))
0036             }
0037         })
0038     })
0039 }
0040 
0041 const dispatch = action => {
0042     if (backend) {
0043         backend.dispatch(action)
0044     } else {
0045         console.error('Dispatch called before backend initialization', new Error().stack)
0046     }
0047 }
0048 
0049 // create a range cfi from two cfi locations
0050 // adapted from https://github.com/futurepress/epub.js/blob/be24ab8b39913ae06a80809523be41509a57894a/src/epubcfi.js#L502
0051 const makeRangeCfi = (a, b) => {
0052     const start = CFI.parse(a), end = CFI.parse(b)
0053     const cfi = {
0054         range: true,
0055         base: start.base,
0056         path: {
0057             steps: [],
0058             terminal: null
0059         },
0060         start: start.path,
0061         end: end.path
0062     }
0063     const len = cfi.start.steps.length
0064     for (let i = 0; i < len; i++) {
0065         if (CFI.equalStep(cfi.start.steps[i], cfi.end.steps[i])) {
0066             if (i == len - 1) {
0067                 // Last step is equal, check terminals
0068                 if (cfi.start.terminal === cfi.end.terminal) {
0069                     // CFI's are equal
0070                     cfi.path.steps.push(cfi.start.steps[i])
0071                     // Not a range
0072                     cfi.range = false
0073                 }
0074             } else cfi.path.steps.push(cfi.start.steps[i])
0075         } else break
0076     }
0077     cfi.start.steps = cfi.start.steps.slice(cfi.path.steps.length)
0078     cfi.end.steps = cfi.end.steps.slice(cfi.path.steps.length)
0079 
0080     return 'epubcfi(' + CFI.segmentString(cfi.base)
0081         + '!' + CFI.segmentString(cfi.path)
0082         + ',' + CFI.segmentString(cfi.start)
0083         + ',' + CFI.segmentString(cfi.end)
0084         + ')'
0085 }
0086 
0087 const getCfiFromHref = async href => {
0088     const id = href.split('#')[1]
0089     const item = book.spine.get(href)
0090     await item.load(book.load.bind(book))
0091     const el = id ? item.document.getElementById(id) : item.document.body
0092     return item.cfiFromElement(el)
0093 }
0094 const getSectionFromCfi = cfi => {
0095     const index = cfiToc.findIndex(el => el ? CFI.compare(cfi, el.cfi) <= 0 : false)
0096     return cfiToc[(index !== -1 ? index : cfiToc.length) - 1]
0097         || { label: book.package.metadata.title, href: '', cfi: '' }
0098 }
0099 
0100 const getSelections = () => rendition.getContents()
0101     .map(contents => contents.window.getSelection())
0102 const clearSelection = () => getSelections().forEach(s => s.removeAllRanges())
0103 const selectByCfi = cfi => getSelections().forEach(s => s.addRange(rendition.getRange(cfi)))
0104 
0105 class Find {
0106     constructor() {
0107         this.results = []
0108     }
0109     async _findInSection(q, section) {
0110         if (!section) section = book.spine.get(rendition.location.start.cfi)
0111         await section.load(book.load.bind(book))
0112         const results = await section.search(q)
0113         await section.unload()
0114         return results
0115     }
0116     async find(q, inBook, highlight) {
0117         this.clearHighlight()
0118         let results = []
0119         if (inBook) {
0120             const arr = await Promise.all(book.spine.spineItems
0121                 .map(section => this._findInSection(q, section)))
0122             results = arr.reduce((a, b) => a.concat(b), [])
0123         } else {
0124             results = await this._findInSection(q)
0125         }
0126         results.forEach(result =>
0127             result.section = getSectionFromCfi(result.cfi).label)
0128         this.results = results
0129         dispatch({ type: 'find-results', payload: { q, results } })
0130         if (highlight) this.highlight()
0131     }
0132     highlight() {
0133         this.clearHighlight()
0134         this.results.forEach(({ cfi }) =>
0135             rendition.annotations.underline(cfi, {}, () => {}, 'ul', {
0136                 'stroke-width': '3px',
0137                 'stroke': 'red',
0138                 'stroke-opacity': 0.8,
0139                 'mix-blend-mode': 'multiply'
0140             }))
0141     }
0142     clearHighlight() {
0143         this.results.forEach(({ cfi }) =>
0144             rendition.annotations.remove(cfi, 'underline'))
0145     }
0146 }
0147 const find = new Find()
0148 
0149 const dispatchLocation = async () => {
0150     let location
0151     try {
0152         location = await rendition.currentLocation()
0153     } catch (e) {
0154         return
0155     }
0156 
0157     if (location.length === 0) {
0158         return;
0159     }
0160 
0161     if (!location.start) {
0162         return;
0163     }
0164 
0165     const percentage = location.start.percentage
0166     const index = book.spine.get(location.start.cfi).index
0167 
0168     // rough estimate of reading time
0169     // should be reasonable for English and European languages
0170     // will be way off for some languages
0171     const estimate = endPercentage =>
0172         Math.round((endPercentage - percentage) * book.locations.total
0173         * CHARACTERS_PER_PAGE
0174         / CHARACTERS_PER_WORD(book.package.metadata.language)
0175         / WORDS_PER_MINUTE * 1000 * 60)
0176     const nextSectionPercentage = (sectionMarks || []).find(x => x > percentage)
0177 
0178     const startSection = getSectionFromCfi(location.start.cfi)
0179     const endSection = getSectionFromCfi(location.end.cfi)
0180     backend.progress = location.start.percentage
0181 
0182     dispatch({
0183         type: 'relocated',
0184         payload: {
0185             atStart: location.atStart,
0186             atEnd: location.atEnd,
0187             start: {
0188                 cfi: location.start.cfi,
0189                 percentage: location.start.percentage,
0190                 location: book.locations.locationFromCfi(location.start.cfi),
0191                 label: startSection.label
0192             },
0193             end: {
0194                 end: location.end.cfi,
0195                 percentage: location.end.percentage,
0196                 location: book.locations.locationFromCfi(location.end.cfi),
0197                 label: endSection.label
0198             },
0199             sectionHref: endSection.href,
0200             section: index,
0201             sectionTotal: book.spine.length,
0202             locationTotal: book.locations.total,
0203             timeInBook: estimate(1),
0204             timeInChapter: estimate(nextSectionPercentage)
0205         }
0206     })
0207 }
0208 
0209 const addAnnotation = (cfi, color) => {
0210     rendition.annotations.remove(cfi, 'highlight')
0211     rendition.annotations.highlight(cfi, {}, async e => dispatch({
0212         type: 'highlight-menu',
0213         payload: {
0214             position: getRect(e.target),
0215             cfi,
0216             text: await book.getRange(cfi).then(range => range.toString()),
0217             language: book.package.metadata.language
0218         }
0219     }), 'hl', {
0220         fill: color,
0221         'fill-opacity': 0.25,
0222         'mix-blend-mode': 'multiply'
0223     })
0224 }
0225 
0226 const speak = async from => {
0227     // speak selection
0228     const selections = getSelections()
0229         .filter(s => s.rangeCount && !s.getRangeAt(0).collapsed)
0230     if (selections.length) return dispatch({
0231         type: 'speech',
0232         payload: {
0233             text: selections[0].toString(),
0234             nextPage: false
0235         }
0236     })
0237     // otherwise speak current page
0238     const currentLoc = rendition.currentLocation()
0239     if (from) {
0240         const cfi = new ePub.CFI(from)
0241         cfi.collapse(true)
0242         from = cfi.toString()
0243     }
0244     let start = currentLoc.start.cfi
0245     let end = currentLoc.end.cfi
0246     let nextPage = !currentLoc.atEnd
0247     let nextSection = false
0248 
0249     let range = await book.getRange(makeRangeCfi(from || start, end))
0250 
0251     const isScrolled = rendition.settings.flow === 'scrolled'
0252     const isScrolledDoc = rendition.settings.flow === 'scrolled-doc'
0253     if (isScrolled || isScrolledDoc) {
0254         // when in non-paginated mode, read to the end of the section
0255         const section = book.spine.get(currentLoc.start.cfi)
0256         range.setEndAfter(section.document.body.lastChild)
0257 
0258         // "next page" when using scrolled-doc is the same as "next section"
0259         nextPage = section.index + 1 < book.spine.length
0260         if (isScrolled) nextSection = section.index + 1 < book.spine.length
0261     }
0262 
0263     dispatch({
0264         type: 'speech',
0265         payload: {
0266             text: range.toString(),
0267             nextPage,
0268             nextSection
0269         }
0270     })
0271 }
0272 
0273 // redraw annotations on view changes
0274 // so that they would be rendered at the new, correct positions
0275 const redrawAnnotations = () =>
0276     rendition.views().forEach(view => view.pane ? view.pane.render() : null)
0277 
0278 const setStyle = style => {
0279     const {
0280         brightness, fgColor, bgColor, linkColor, selectionFgColor, selectionBgColor, invert,
0281         fontFamily, fontSize, fontWeight, fontStyle, fontStretch,
0282         spacing, margin, maxWidth,
0283         usePublisherFont, hyphenate, justify,
0284     } = style
0285     const paginated = rendition.settings.flow !== 'paginated'
0286 
0287     lineHeight = fontSize * spacing
0288 
0289     ibooksInternalTheme = style.ibooksInternalTheme
0290     rendition.getContents().forEach(contents => contents.document.documentElement
0291         .setAttribute('__ibooks_internal_theme', ibooksInternalTheme))
0292 
0293 
0294     if (rendition.settings.layout === 'pre-paginated') {
0295         document.documentElement.style.padding = 0
0296     } else {
0297         console.error("margin", margin, maxWidth)
0298         const gap = margin;
0299         rendition.layout({
0300             ...rendition.settings.globalLayoutProperties,
0301             gap,
0302         })
0303         const padding = paginated ? margin
0304             : margin / 2
0305         document.documentElement.style.padding = `0 ${padding}px`;
0306         document.body.style.maxWidth = `${maxWidth + gap}px`;
0307         document.body.style.margin = '0 auto';
0308     }
0309 
0310     document.documentElement.style.filter =
0311         invert || brightness !== 1
0312             ? (invert ? 'invert(1) hue-rotate(180deg) ' : '')
0313                 + `brightness(${brightness})`
0314             : ''
0315     document.body.style.color = fgColor
0316     document.body.style.background = bgColor
0317 
0318     const themeName = usePublisherFont ? '__foliate_publisher-font' : '__foliate_custom-font'
0319     const stylesheet = {
0320         [`.${themeName}`]: {
0321             'color': fgColor,
0322             'background': bgColor,
0323             'font-size': `${fontSize}px !important`,
0324             'line-height': `${spacing} !important`,
0325             '-webkit-hyphens': hyphenate ? 'auto' : 'manual',
0326             '-webkit-hyphenate-limit-before': 3,
0327             '-webkit-hyphenate-limit-after': 2,
0328             '-webkit-hyphenate-limit-lines': 2,
0329             'overflow-wrap': 'break-word'
0330         },
0331         [`.${themeName} p`]: {
0332             'line-height': `${spacing} !important`
0333         },
0334         [`.${themeName} pre`]: {
0335             'white-space': 'pre-wrap'
0336         },
0337         [`.${themeName} code, .${themeName} pre`]: {
0338             '-webkit-hyphens': 'none'
0339         },
0340         [`.${themeName} a:link`]: { color: linkColor },
0341         [`.${themeName} a:hover`]: { color: fgColor },
0342         p: {
0343             'text-align': justify ? 'justify' : 'inherit'
0344         },
0345         '::selection': {
0346             'color': selectionFgColor,
0347             'background-color': selectionBgColor
0348         }
0349     }
0350 
0351     if (!usePublisherFont) {
0352         // set custom font
0353         const bodyStyle = stylesheet[`.${themeName}`]
0354         bodyStyle['font-family'] = `"${fontFamily}" !important`
0355         bodyStyle['font-style'] = fontStyle
0356         bodyStyle['font-weight'] = fontWeight
0357         bodyStyle['font-stretch'] = fontStretch
0358 
0359         // force font on everything that isn't code
0360         let notCode = '*:not(code):not(pre)'
0361         stylesheet[`.${themeName} ${notCode}`] = {
0362             'font-family': `"${fontFamily}" !important`
0363         }
0364     }
0365 
0366     rendition.themes.register(themeName, stylesheet)
0367     rendition.themes.select(themeName)
0368     rendition.resize()
0369     redrawAnnotations()
0370 
0371     document.getElementsByTagName("style")[0].innerHTML = `
0372         body {
0373           margin: 0;
0374         }
0375         a:hover {
0376           color: ${fgColor};
0377         }
0378     `;
0379 }
0380 
0381 /*
0382 Steps when opening a book:
0383 open() -> 'book-ready' -> loadLocations()
0384                        -> render() -> 'rendition-ready' -> setStyle()
0385                                                         -> setupRendition()
0386                                                         -> display() -> 'book-displayed'
0387 */
0388 
0389 const open = async (uri, filename, inputType, renderTo, options) => {
0390     // new in Epub.js >= 0.3.89 — scripts are blocked by default;
0391     // but it needs script to handle events in the iframe,
0392     // so scripts really need to be allowed in Epub.js no matter what;
0393     // this is fine as, we already block script with CSP and WebKit settings
0394     options.allowScriptedContent = true
0395 
0396     // force rendering as XHTML
0397     // if method is 'srcdoc' (default) or `write`, it will be rendered as HTML
0398     if (!['directory', 'opf', 'json'].includes(inputType)) options.method = 'blobUrl'
0399 
0400     try {
0401         switch (inputType) {
0402             case 'text': {
0403                 const json = await webpubFromText(uri, filename)
0404                 await book.openJSON(json)
0405                 break
0406             }
0407             case 'fb2': {
0408                 const json = await webpubFromFB2(uri, filename)
0409                 await book.openJSON(json)
0410                 break
0411             }
0412             case 'fb2zip': {
0413                 const json = await webpubFromFB2Zip(uri, filename)
0414                 await book.openJSON(json)
0415                 break
0416             }
0417             case 'html':
0418             case 'xhtml': {
0419                 const json = await webpubFromHTML(uri, filename, inputType)
0420                 await book.openJSON(json)
0421                 break
0422             }
0423             case 'cbz':
0424             case 'cbr':
0425             case 'cb7':
0426             case 'cbt': {
0427                 let layout = 'automatic'
0428                 if (options) {
0429                     if (options.flow === 'paginated' && options.spread === 'none') {
0430                         layout = 'single-column'
0431                     } else if (options.flow === 'scrolled-doc') {
0432                         layout = 'scrolled'
0433                     } else if (options.flow === 'scrolled') {
0434                         layout = 'continuous'
0435                     }
0436                 }
0437 
0438                 // Set `spread` to 'none' for all layouts, except 'automatic'
0439                 if (layout !== 'automatic') {
0440                     options.spread = 'none'
0441                 }
0442 
0443                 const json = await webpubFromComicBookArchive(uri, inputType, layout, filename)
0444                 await book.openJSON(json)
0445                 break
0446             }
0447             default:
0448                 const url = new URL("/book/", "http://localhost:45961");
0449                 url.searchParams.append('url', decodeURI(uri));
0450                 await book.open(url.href, inputType)
0451         }
0452     } catch(e) {
0453         dispatch({
0454             type: 'book-error',
0455             payload: e.message || e.toString()
0456         })
0457     }
0458 
0459     rendition = book.renderTo(renderTo, options)
0460 }
0461 
0462 book.ready.then(async () => {
0463     const hrefList = []
0464 
0465     // set the correct URL based on the path to the nav or ncx file
0466     // fixes https://github.com/futurepress/epub.js/issues/469
0467     const path = book.packaging.navPath || book.packaging.ncxPath
0468     const f = x => {
0469         x.label = x.label.trim()
0470         x.href = resolveURL(x.href, path)
0471         hrefList.push(x)
0472         x.subitems.forEach(f)
0473     }
0474     book.navigation.toc.forEach(f)
0475 
0476     // convert hrefs to CFIs for better TOC with anchor support
0477     cfiToc = await Promise.all(hrefList.map(async ({ label, href }) => {
0478         try {
0479             const result = await getCfiFromHref(href)
0480             const cfi = new ePub.CFI(result)
0481             cfi.collapse(true)
0482             return {
0483                 label,
0484                 href,
0485                 cfi: cfi.toString()
0486             }
0487         } catch (e) {
0488             return undefined
0489         }
0490     }))
0491     cfiToc.sort((a, b) => CFI.compare(a.cfi, b.cfi))
0492 
0493     const metadata = book.packaging.metadata
0494     if (book.packaging.uniqueIdentifier)
0495         metadata.identifier = book.packaging.uniqueIdentifier
0496     if (metadata.description)
0497         metadata.description = toPangoMarkup(metadata.description)
0498     dispatch({ type: 'book-ready' })
0499 })
0500 
0501 const render = () =>
0502     rendition.display().then(() => dispatch({ type: 'rendition-ready' }))
0503 
0504 const loadLocations = async () => {
0505     const locationsReady = () => {
0506         sectionMarks = book.spine.items.map(section => book.locations
0507             .percentageFromCfi('epubcfi(' + section.cfiBase + '!/0)'))
0508         dispatchLocation()
0509     }
0510 
0511     if (backend.locations) {
0512         book.locations.load(backend.locations)
0513         if (book.locations.total < 0) {
0514             return dispatch({ type: 'locations-fallback' })
0515         }
0516         locationsReady()
0517         dispatch({ type: 'locations-ready' })
0518     } else {
0519         await book.locations.generate(CHARACTERS_PER_PAGE)
0520         if (book.locations.total < 0) {
0521             return dispatch({ type: 'locations-fallback' })
0522         }
0523         locationsReady()
0524         dispatch({
0525             type: 'locations-generated',
0526             payload: {
0527                 locations: book.locations.save(),
0528                 key: book.key(),
0529             },
0530         })
0531     }
0532 }
0533 
0534 const display = lastLocation =>
0535     rendition.display(lastLocation)
0536         .then(() => dispatch({ type: 'book-displayed' }))
0537 
0538 // get book cover for "about this book" dialogue
0539 book.loaded.resources
0540     .then(resources => {
0541         if (book.cover.includes(':')) return book.cover
0542         else return resources.createUrl(book.cover)
0543     })
0544     .then(url => fetch(url))
0545     .then(res => res.blob())
0546     .then(blob => {
0547         const reader = new FileReader()
0548         reader.readAsDataURL(blob)
0549         reader.onloadend = () => dispatch({
0550             type: 'cover',
0551             payload: reader.result.split(',')[1]
0552         })
0553     })
0554     .catch(() => dispatch({
0555         type: 'cover',
0556         payload: null
0557     }))
0558 
0559 const getRect = (target, frame) => {
0560     const rect = target.getBoundingClientRect()
0561     const viewElementRect =
0562         frame ? frame.getBoundingClientRect() : { left: 0, top: 0 }
0563     const left = rect.left + viewElementRect.left
0564     const right = rect.right + viewElementRect.left
0565     const top = rect.top + viewElementRect.top
0566     const bottom = rect.bottom + viewElementRect.top
0567     return { left, right, top, bottom }
0568 }
0569 
0570 const setupRendition = () => {
0571     const paginated = rendition.settings.flow === 'paginated'
0572 
0573     const resize = () => {
0574         // set rendition height to window height for vertical books
0575         // verticfal books don't work without explicitly setting height
0576         if (rendition.manager.viewSettings.axis === 'vertical') {
0577             rendition.resize('100%', windowHeight / zoomLevel)
0578         }
0579     }
0580     resize()
0581     rendition.on('layout', resize)
0582 
0583     rendition.on('rendered', redrawAnnotations)
0584     rendition.on('relocated', dispatchLocation)
0585 
0586     // fix location drift when resizing multiple times in a row
0587     // we keep a `location` that doesn't change when rendition has just been resized,
0588     // then, when the resize is done, we correct the location with it,
0589     // but this correction will itself trigger a `relocated` event,
0590     // so we create a further `correcting` variable to track this
0591     let location
0592     let justResized = false
0593     let correcting = false
0594     rendition.on('relocated', () => {
0595         // console.log('relocated')
0596         if (!justResized) {
0597             if (!correcting) {
0598                 // console.log('real relocation')
0599                 location = rendition.currentLocation().start.cfi
0600             } else {
0601                 // console.log('corrected')
0602                 correcting = false
0603             }
0604         } else {
0605             // console.log('correcting')
0606             justResized = false
0607             correcting = true
0608             rendition.display(location)
0609         }
0610     })
0611     rendition.on('resized', () => {
0612         // console.log('resized')
0613         justResized = true
0614     })
0615 
0616     rendition.on('layout', props => dispatch({ type: 'layout', payload: props }))
0617     dispatch({ type: 'layout', payload: rendition._layout.props })
0618 
0619     let isSelecting = false
0620 
0621     rendition.hooks.content.register((contents, /*view*/) => {
0622         const frame = contents.document.defaultView.frameElement
0623 
0624         // set lang attribute based on metadata
0625         // this is needed for auto-hyphenation
0626         const html = contents.document.documentElement
0627         if (!html.getAttribute('lang') && book.package.metadata.language)
0628             html.setAttribute('lang', book.package.metadata.language)
0629 
0630         html.setAttribute('__ibooks_internal_theme', ibooksInternalTheme)
0631 
0632         const refTypes = [
0633             'annoref', // deprecated
0634             'biblioref',
0635             'glossref',
0636             'noteref',
0637         ]
0638         const forbidRefTypes = [
0639             'backlink',
0640             'referrer'
0641         ]
0642         const noteTypes = [
0643             'annotation', // deprecated
0644             'note', // deprecated
0645             'footnote',
0646             'endnote',
0647             'rearnote' // deprecated
0648         ]
0649         // hide EPUB 3 aside notes
0650         const asides = contents.document.querySelectorAll('aside')
0651         Array.from(asides).forEach(aside => {
0652             const type = aside.getAttributeNS(EPUB_NS, 'type')
0653             const types = type ? type.split(' ') : []
0654             if (noteTypes.some(x => types.includes(x)))
0655                 aside.style.display = 'none'
0656         })
0657 
0658         const links = contents.document.querySelectorAll('a:link')
0659         Array.from(links).forEach(link => link.addEventListener('click', async e => {
0660             e.stopPropagation()
0661             e.preventDefault()
0662 
0663             const type = link.getAttributeNS(EPUB_NS, 'type')
0664             const types = type ? type.split(' ') : []
0665             const isRefLink = refTypes.some(x => types.includes(x))
0666 
0667             const href = link.getAttribute('href')
0668             const id = href.split('#')[1]
0669             const pageHref = resolveURL(href,
0670                 book.spine.spineItems[contents.sectionIndex].href)
0671 
0672             const followLink = () => dispatch({
0673                 type: 'link-internal',
0674                 payload: pageHref
0675             })
0676 
0677             if (isExternalURL(href))
0678                 dispatch({ type: 'link-external', payload: href })
0679             else if (!isRefLink && !enableFootnote
0680                 || forbidRefTypes.some(x => types.includes(x)))
0681                 followLink()
0682             else {
0683                 const item = book.spine.get(pageHref)
0684                 if (item) await item.load(book.load.bind(book))
0685 
0686                 let el = (item && item.document ? item.document : contents.document)
0687                     .getElementById(id)
0688                 if (!el) return followLink()
0689 
0690                 let dt
0691                 if (el.nodeName.toLowerCase() === 'dt') {
0692                     const dfn = el.querySelector('dfn')
0693                     if (dfn) dt = dfn
0694                     else dt = el
0695                     el = el.nextElementSibling
0696                 }
0697 
0698                 // this bit deals with situations like
0699                 //     <p><sup><a id="note1" href="link1">1</a></sup> My footnote</p>
0700                 // where simply getting the ID or its parent would not suffice
0701                 // although it would still fail to extract useful texts for some books
0702                 const isFootnote = el => {
0703                     const nodeName = el.nodeName.toLowerCase()
0704                     return [
0705                         'a', 'span', 'sup', 'sub',
0706                         'em', 'strong', 'i', 'b',
0707                         'small', 'big'
0708                     ].every(x => x !== nodeName)
0709                 }
0710                 if (!isFootnote(el)) {
0711                     while (true) {
0712                         const parent = el.parentElement
0713                         if (!parent) break
0714                         el = parent
0715                         if (isFootnote(parent)) break
0716                     }
0717                 }
0718 
0719                 if (item) item.unload()
0720                 if (el.innerText.trim()) {
0721                     const elType = el.getAttributeNS(EPUB_NS, 'type')
0722                     const elTypes = elType ? elType.split(' ') : []
0723 
0724                     // footnotes not matching this would be hidden (see above)
0725                     // and so one cannot navigate to them
0726                     const canLink = !(el.nodeName === 'aside'
0727                         && noteTypes.some(x => elTypes.includes(x)))
0728 
0729                     dispatch({
0730                         type: 'footnote',
0731                         payload: {
0732                             footnote: toPangoMarkup(
0733                                 (dt ? `<strong>${dt.innerHTML}</strong><br/>` : '') + el.innerHTML,
0734                                 pageHref
0735                             ),
0736                             link: canLink ? pageHref : null,
0737                             position: getRect(e.target, frame),
0738                             refTypes: types,
0739                             noteTypes: elTypes
0740                         }
0741                     })
0742                 } else followLink()
0743             }
0744         }, true))
0745 
0746         const imgs = contents.document.querySelectorAll('img')
0747         const eventType = imgEventType === 'middleclick' ? 'click' : imgEventType
0748         if (eventType) {
0749             Array.from(imgs).forEach(img => img.addEventListener(eventType, e => {
0750                 if (imgEventType === 'click' && e.button !== 0) return
0751                 if (imgEventType === 'middleclick' && e.button !== 1) return
0752                 e.preventDefault()
0753                 e.stopPropagation()
0754                 fetch(img.src)
0755                     .then(res => res.blob())
0756                     .then(blob => {
0757                         const reader = new FileReader()
0758                         reader.readAsDataURL(blob)
0759                         reader.onloadend = () => dispatch({
0760                             type: 'img',
0761                             payload: {
0762                                 alt: img.getAttribute('alt'),
0763                                 base64: reader.result.split(',')[1],
0764                                 position: getRect(e.target, frame)
0765                             }
0766                         })
0767                     })
0768             }, true))
0769         }
0770 
0771         // handle selection and clicks
0772         let clickTimeout
0773         const dispatchClick = e => {
0774             const clientX = (e.changedTouches ? e.changedTouches[0] : e).clientX
0775             const left = e.target === document.documentElement ? 0 : frame
0776                 .getBoundingClientRect().left
0777             const f = () => dispatch({
0778                 type: 'click',
0779                 payload: {
0780                     width: window.innerWidth,
0781                     position: clientX + left
0782                 }
0783             })
0784             clickTimeout = setTimeout(f, doubleClickTime)
0785         }
0786 
0787         document.onclick = dispatchClick
0788         contents.document.onmousedown = () => isSelecting = true
0789         contents.document.onclick = e => {
0790             isSelecting = false
0791 
0792             const selection = contents.window.getSelection()
0793             // see https://stackoverflow.com/q/22935320
0794             if (!selection.rangeCount) return dispatchClick(e)
0795 
0796             const range = selection.getRangeAt(0)
0797             if (range.collapsed) return dispatchClick(e)
0798 
0799             clearTimeout(clickTimeout)
0800             dispatch({
0801                 type: 'selection',
0802                 payload: {
0803                     position: getRect(range, frame),
0804                     text: selection.toString(),
0805                     cfi: new ePub.CFI(range, contents.cfiBase).toString(),
0806                     language: book.package.metadata.language
0807                 }
0808             })
0809         }
0810 
0811         // auto-hide cursor
0812         let cursorTimeout
0813         const hideCursor = () => {
0814             contents.document.documentElement.style.cursor = 'none'
0815             cursorHidden = true
0816         }
0817         const showCursor = () =>  {
0818             contents.document.documentElement.style.cursor = 'auto'
0819             cursorHidden = false
0820         }
0821         if (cursorHidden) hideCursor()
0822         contents.document.documentElement.addEventListener('mousemove', e => {
0823             // check whether the mouse actually moved
0824             // or the event is just triggered by something else
0825             if (e.screenX === myScreenX && e.screenY === myScreenY) return
0826             myScreenX = e.screenX, myScreenY = e.screenY
0827             showCursor()
0828             if (cursorTimeout) clearTimeout(cursorTimeout)
0829             if (autohideCursor) cursorTimeout = setTimeout(hideCursor, 1000)
0830         }, false)
0831     })
0832 
0833     const rtl = book.package.metadata.direction === 'rtl'
0834     const goLeft = rtl ? () => rendition.next() : () => rendition.prev()
0835     const goRight = rtl ? () => rendition.prev() : () => rendition.next()
0836 
0837     // keyboard shortcuts
0838     const handleKeydown = event => {
0839         if (getWindowIsZoomed()) return
0840         const k = event.key
0841         if (k === 'ArrowLeft' || k === 'h') goLeft()
0842         else if(k === 'ArrowRight' || k === 'l') goRight()
0843         else if (k === 'Backspace') {
0844             if (paginated) rendition.prev()
0845             else window.scrollBy(0, -window.innerHeight)
0846         } else if (event.shiftKey && k === ' ' || k === 'ArrowUp' || k === 'PageUp') {
0847             if (paginated) rendition.prev()
0848         } else if (k === ' ' || k === 'ArrowDown' || k === 'PageDown') {
0849             if (paginated) rendition.next()
0850         } else if (k === 'j') {
0851             if (paginated) rendition.next()
0852             else window.scrollBy(0, lineHeight)
0853         } else if (k === 'k') {
0854             if (paginated) rendition.prev()
0855             else window.scrollBy(0, -lineHeight)
0856         }
0857     }
0858     rendition.on('keydown', handleKeydown)
0859     document.addEventListener('keydown', handleKeydown, false)
0860 
0861     if (paginated) {
0862         // go to the next page when selecting to the end of a page
0863         // this makes it possible to select across pages
0864         rendition.on('selected', debounce(cfiRange => {
0865             if (!isSelecting) return
0866             const selCfi = new ePub.CFI(cfiRange)
0867             selCfi.collapse()
0868             const compare = CFI.compare(selCfi, rendition.location.end.cfi) >= 0
0869             if (compare) rendition.next()
0870         }, 1000))
0871     }
0872 }