mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 11:29:14 -04:00
Reader: add sidebar and TOC
This commit is contained in:
+122
-14
@@ -4,9 +4,6 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' blob:; script-src 'self'; style-src 'self' blob: 'unsafe-inline'; img-src 'self' blob: data:; connect-src 'self' blob: data:; frame-src blob: data:; object-src 'none'; form-action 'none';">
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self' blob:; script-src 'self'; style-src 'self' blob: 'unsafe-inline'; img-src 'self' blob: data:; connect-src 'self' blob: data:; frame-src blob: data:; object-src 'none'; form-action 'none';">
|
||||||
<style>
|
<style>
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
@@ -17,10 +14,15 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
#nav-bar {
|
.icon {
|
||||||
visibility: hidden;
|
display: block;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentcolor;
|
||||||
|
stroke-width: 2px;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
box-sizing: border-box;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -30,33 +32,139 @@ body {
|
|||||||
padding: 6px;
|
padding: 6px;
|
||||||
opacity: .25;
|
opacity: .25;
|
||||||
transition: opacity 250ms ease;
|
transition: opacity 250ms ease;
|
||||||
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
#nav-bar:hover {
|
.toolbar:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
#nav-bar button {
|
.toolbar button {
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: none;
|
background: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
#nav-bar button:hover {
|
.toolbar button:hover {
|
||||||
background: rgba(0, 0, 0, .1);
|
background: rgba(0, 0, 0, .1);
|
||||||
}
|
}
|
||||||
|
#header-bar {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
#nav-bar {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
#progress-label {
|
#progress-label {
|
||||||
font-size: small;
|
font-size: small;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.icon {
|
#side-bar {
|
||||||
|
visibility: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 320px;
|
||||||
|
transform: translateX(-320px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: Canvas;
|
||||||
|
color: CanvasText;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, .2), 0 0 40px rgba(0, 0, 0, .2);
|
||||||
|
transition: visibility 0s linear 300ms, transform 300ms ease;
|
||||||
|
}
|
||||||
|
#side-bar.show {
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateX(0);
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
#dimming-overlay {
|
||||||
|
visibility: hidden;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, .2);
|
||||||
|
opacity: 0;
|
||||||
|
transition: visibility 0s linear 300ms, opacity 300ms ease;
|
||||||
|
}
|
||||||
|
#dimming-overlay.show {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
#toc-view {
|
||||||
|
padding: .5rem;
|
||||||
|
overflow-y: scroll;
|
||||||
|
--active-bg: rgba(0, 0, 0, .05);
|
||||||
|
}
|
||||||
|
@supports (color-scheme: light dark) {
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
#toc-view {
|
||||||
|
--active-bg: rgba(255, 255, 255, .1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#toc-view li, #toc-view ol {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
#toc-view a, #toc-view span {
|
||||||
display: block;
|
display: block;
|
||||||
fill: none;
|
border-radius: 6px;
|
||||||
stroke: currentcolor;
|
padding: 8px;
|
||||||
stroke-width: 2px;
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
#toc-view a {
|
||||||
|
color: CanvasText;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
#toc-view a:hover {
|
||||||
|
background: var(--active-bg);
|
||||||
|
}
|
||||||
|
#toc-view span {
|
||||||
|
color: GrayText;
|
||||||
|
}
|
||||||
|
#toc-view svg {
|
||||||
|
margin-inline-start: -24px;
|
||||||
|
padding-inline-start: 5px;
|
||||||
|
padding-inline-end: 6px;
|
||||||
|
fill: CanvasText;
|
||||||
|
cursor: default;
|
||||||
|
transition: transform .2s ease;
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
#toc-view svg:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
#toc-view [aria-current] {
|
||||||
|
font-weight: bold;
|
||||||
|
background: var(--active-bg);
|
||||||
|
}
|
||||||
|
#toc-view [aria-expanded="false"] svg {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
#toc-view [aria-expanded="false"] + [role="group"] {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<input type="file" id="file-input" hidden>
|
<input type="file" id="file-input" hidden>
|
||||||
<div id="drop-target" class="filter">Drop a file here to open it.</div>
|
<div id="drop-target" class="filter">Drop a file here to open it.</div>
|
||||||
<div id="nav-bar">
|
<div id="dimming-overlay" aria-hidden="true"></div>
|
||||||
|
<div id="side-bar">
|
||||||
|
<div id="toc-view"></div>
|
||||||
|
</div>
|
||||||
|
<div id="header-bar" class="toolbar">
|
||||||
|
<button id="side-bar-button" aria-label="Show sidebar">
|
||||||
|
<svg class="icon" width="24" height="24" aria-hidden="true">
|
||||||
|
<path d="M 4 6 h 16 M 4 12 h 16 M 4 18 h 16"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="nav-bar" class="toolbar">
|
||||||
<button id="left-button" aria-label="Turn left page">
|
<button id="left-button" aria-label="Turn left page">
|
||||||
<svg class="icon" width="24" height="24" aria-hidden="true">
|
<svg class="icon" width="24" height="24" aria-hidden="true">
|
||||||
<path d="M 15 6 L 9 12 L 15 18"/>
|
<path d="M 15 6 L 9 12 L 15 18"/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/* global zip: false, fflate: false */
|
/* global zip: false, fflate: false */
|
||||||
import { View } from './view.js'
|
import { View } from './view.js'
|
||||||
|
import { createTOCView } from './ui/tree.js'
|
||||||
|
|
||||||
const { ZipReader, BlobReader, TextWriter, BlobWriter } = zip
|
const { ZipReader, BlobReader, TextWriter, BlobWriter } = zip
|
||||||
zip.configure({ useWebWorkers: false })
|
zip.configure({ useWebWorkers: false })
|
||||||
@@ -102,16 +103,19 @@ const $ = document.querySelector.bind(document)
|
|||||||
const locales = 'en'
|
const locales = 'en'
|
||||||
const percentFormat = new Intl.NumberFormat(locales, { style: 'percent' })
|
const percentFormat = new Intl.NumberFormat(locales, { style: 'percent' })
|
||||||
|
|
||||||
|
let setCurrentHref
|
||||||
|
|
||||||
const emit = obj => {
|
const emit = obj => {
|
||||||
console.debug(obj)
|
console.debug(obj)
|
||||||
switch (obj.type) {
|
switch (obj.type) {
|
||||||
case 'relocated': {
|
case 'relocated': {
|
||||||
const { fraction, location, pageItem } = obj
|
const { fraction, location, tocItem, pageItem } = obj
|
||||||
const percent = percentFormat.format(fraction)
|
const percent = percentFormat.format(fraction)
|
||||||
const loc = pageItem
|
const loc = pageItem
|
||||||
? `Page ${pageItem.label}`
|
? `Page ${pageItem.label}`
|
||||||
: `Loc ${location.current}`
|
: `Loc ${location.current}`
|
||||||
$('#progress-label').innerText = `${percent} · ${loc}`
|
$('#progress-label').innerText = `${percent} · ${loc}`
|
||||||
|
if (tocItem?.href) setCurrentHref?.(tocItem.href)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,6 +137,30 @@ const dropHandler = e => {
|
|||||||
}
|
}
|
||||||
view.setAppearance({ css: getCSS(style) })
|
view.setAppearance({ css: getCSS(style) })
|
||||||
view.renderer.next()
|
view.renderer.next()
|
||||||
|
|
||||||
|
const closeSideBar = () => {
|
||||||
|
$('#dimming-overlay').classList.remove('show')
|
||||||
|
$('#side-bar').classList.remove('show')
|
||||||
|
}
|
||||||
|
|
||||||
|
const toc = view.book.toc
|
||||||
|
if (toc) {
|
||||||
|
const onclick = href => {
|
||||||
|
view.goTo(href).catch(e => console.error(e))
|
||||||
|
closeSideBar()
|
||||||
|
}
|
||||||
|
const tocView = createTOCView(toc, onclick)
|
||||||
|
setCurrentHref = tocView.setCurrentHref
|
||||||
|
$('#toc-view').append(tocView.element)
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#header-bar').style.visibility = 'visible'
|
||||||
|
$('#side-bar-button').addEventListener('click', () => {
|
||||||
|
$('#dimming-overlay').classList.add('show')
|
||||||
|
$('#side-bar').classList.add('show')
|
||||||
|
})
|
||||||
|
$('#dimming-overlay').addEventListener('click', closeSideBar)
|
||||||
|
|
||||||
$('#nav-bar').style.visibility = 'visible'
|
$('#nav-bar').style.visibility = 'visible'
|
||||||
$('#left-button').addEventListener('click', () => view.goLeft())
|
$('#left-button').addEventListener('click', () => view.goLeft())
|
||||||
$('#right-button').addEventListener('click', () => view.goRight())
|
$('#right-button').addEventListener('click', () => view.goRight())
|
||||||
|
|||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
const createSVGElement = tag =>
|
||||||
|
document.createElementNS('http://www.w3.org/2000/svg', tag)
|
||||||
|
|
||||||
|
const createExpanderIcon = () => {
|
||||||
|
const svg = createSVGElement('svg')
|
||||||
|
svg.setAttribute('viewBox', '0 0 13 10')
|
||||||
|
svg.setAttribute('width', '13')
|
||||||
|
svg.setAttribute('height', '13')
|
||||||
|
const polygon = createSVGElement('polygon')
|
||||||
|
polygon.setAttribute('points', '2 1, 12 1, 7 9')
|
||||||
|
svg.append(polygon)
|
||||||
|
return svg
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTOCItemElement = (list, map, onclick) => {
|
||||||
|
let count = 0
|
||||||
|
const makeID = () => `toc-element-${count++}`
|
||||||
|
const createItem = ({ label, href, subitems }, depth = 0) => {
|
||||||
|
const a = document.createElement(href ? 'a' : 'span')
|
||||||
|
a.innerText = label
|
||||||
|
a.setAttribute('role', 'treeitem')
|
||||||
|
a.tabIndex = -1
|
||||||
|
a.style.paddingInlineStart = `${(depth + 1) * 24}px`
|
||||||
|
list.push(a)
|
||||||
|
if (href) {
|
||||||
|
if (!map.has(href)) map.set(href, a)
|
||||||
|
a.href = href
|
||||||
|
a.onclick = event => {
|
||||||
|
event.preventDefault()
|
||||||
|
onclick(href)
|
||||||
|
}
|
||||||
|
} else a.onclick = event => a.firstElementChild?.onclick(event)
|
||||||
|
|
||||||
|
const li = document.createElement('li')
|
||||||
|
li.setAttribute('role', 'none')
|
||||||
|
li.append(a)
|
||||||
|
if (subitems?.length) {
|
||||||
|
a.setAttribute('aria-expanded', 'false')
|
||||||
|
const expander = createExpanderIcon()
|
||||||
|
expander.onclick = event => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
const expanded = a.getAttribute('aria-expanded')
|
||||||
|
a.setAttribute('aria-expanded', expanded === 'true' ? 'false' : 'true')
|
||||||
|
}
|
||||||
|
a.prepend(expander)
|
||||||
|
const ol = document.createElement('ol')
|
||||||
|
ol.id = makeID()
|
||||||
|
ol.setAttribute('role', 'group')
|
||||||
|
a.setAttribute('aria-owns', ol.id)
|
||||||
|
ol.replaceChildren(...subitems.map(item => createItem(item, depth + 1)))
|
||||||
|
li.append(ol)
|
||||||
|
}
|
||||||
|
return li
|
||||||
|
}
|
||||||
|
return createItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://www.w3.org/TR/wai-aria-practices-1.2/examples/treeview/treeview-navigation.html
|
||||||
|
export const createTOCView = (toc, onclick) => {
|
||||||
|
const $toc = document.createElement('ol')
|
||||||
|
$toc.setAttribute('role', 'tree')
|
||||||
|
const list = []
|
||||||
|
const map = new Map()
|
||||||
|
const createItem = createTOCItemElement(list, map, onclick)
|
||||||
|
$toc.replaceChildren(...toc.map(item => createItem(item)))
|
||||||
|
|
||||||
|
const isTreeItem = item => item?.getAttribute('role') === 'treeitem'
|
||||||
|
const getParents = function* (el) {
|
||||||
|
for (let parent = el.parentNode; parent !== $toc; parent = parent.parentNode) {
|
||||||
|
const item = parent.previousElementSibling
|
||||||
|
if (isTreeItem(item)) yield item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentItem, currentVisibleParent
|
||||||
|
$toc.addEventListener('focusout', () => {
|
||||||
|
if (!currentItem) return
|
||||||
|
// reset parent focus from last time
|
||||||
|
if (currentVisibleParent) currentVisibleParent.tabIndex = -1
|
||||||
|
// if current item is visible, let it have the focus
|
||||||
|
if (currentItem.offsetParent) {
|
||||||
|
currentItem.tabIndex = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// current item is hidden; give focus to the nearest visible parent
|
||||||
|
for (const item of getParents(currentItem)) {
|
||||||
|
if (item.offsetParent) {
|
||||||
|
item.tabIndex = 0
|
||||||
|
currentVisibleParent = item
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const setCurrentHref = href => {
|
||||||
|
if (currentItem) {
|
||||||
|
currentItem.removeAttribute('aria-current')
|
||||||
|
currentItem.tabIndex = -1
|
||||||
|
}
|
||||||
|
const el = map.get(href)
|
||||||
|
if (!el) {
|
||||||
|
currentItem = list[0]
|
||||||
|
currentItem.tabIndex = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const item of getParents(el))
|
||||||
|
item.setAttribute('aria-expanded', 'true')
|
||||||
|
el.setAttribute('aria-current', 'page')
|
||||||
|
el.tabIndex = 0
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
currentItem = el
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptNode = node => isTreeItem(node) && node.offsetParent
|
||||||
|
? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
|
||||||
|
const iter = document.createTreeWalker($toc, 1, { acceptNode })
|
||||||
|
const getIter = current => (iter.currentNode = current, iter)
|
||||||
|
|
||||||
|
for (const el of list) el.addEventListener('keydown', event => {
|
||||||
|
let stop = false
|
||||||
|
const { currentTarget, key } = event
|
||||||
|
switch (key) {
|
||||||
|
case ' ':
|
||||||
|
case 'Enter':
|
||||||
|
currentTarget.click()
|
||||||
|
stop = true
|
||||||
|
break
|
||||||
|
case 'ArrowDown':
|
||||||
|
getIter(currentTarget).nextNode()?.focus()
|
||||||
|
stop = true
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
getIter(currentTarget).previousNode()?.focus()
|
||||||
|
stop = true
|
||||||
|
break
|
||||||
|
case 'ArrowLeft':
|
||||||
|
if (currentTarget.getAttribute('aria-expanded') === 'true')
|
||||||
|
currentTarget.setAttribute('aria-expanded', 'false')
|
||||||
|
else getParents(currentTarget).next()?.value?.focus()
|
||||||
|
stop = true
|
||||||
|
break
|
||||||
|
case 'ArrowRight':
|
||||||
|
if (currentTarget.getAttribute('aria-expanded') === 'true')
|
||||||
|
getIter(currentTarget).nextNode()?.focus()
|
||||||
|
else if (currentTarget.getAttribute('aria-owns'))
|
||||||
|
currentTarget.setAttribute('aria-expanded', 'true')
|
||||||
|
stop = true
|
||||||
|
break
|
||||||
|
case 'Home':
|
||||||
|
list[0].focus()
|
||||||
|
stop = true
|
||||||
|
break
|
||||||
|
case 'End': {
|
||||||
|
const last = list[list.length - 1]
|
||||||
|
if (last.offsetParent) last.focus()
|
||||||
|
else getIter(last).previousNode()?.focus()
|
||||||
|
stop = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stop) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { element: $toc, setCurrentHref }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user