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 }