Reader: add sidebar and TOC

This commit is contained in:
John Factotum
2022-10-20 12:50:37 +00:00
committed by GitHub
parent cf9d4cb8a5
commit 9a2bb558fd
3 changed files with 320 additions and 15 deletions
+122 -14
View File
@@ -4,9 +4,6 @@
<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';">
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
@@ -17,10 +14,15 @@ body {
align-items: center;
justify-content: center;
}
#nav-bar {
visibility: hidden;
.icon {
display: block;
fill: none;
stroke: currentcolor;
stroke-width: 2px;
}
.toolbar {
box-sizing: border-box;
position: absolute;
bottom: 0;
z-index: 1;
display: flex;
align-items: center;
@@ -30,33 +32,139 @@ body {
padding: 6px;
opacity: .25;
transition: opacity 250ms ease;
visibility: hidden;
}
#nav-bar:hover {
.toolbar:hover {
opacity: 1;
}
#nav-bar button {
.toolbar button {
padding: 3px;
border-radius: 6px;
background: none;
border: 0;
}
#nav-bar button:hover {
.toolbar button:hover {
background: rgba(0, 0, 0, .1);
}
#header-bar {
top: 0;
}
#nav-bar {
bottom: 0;
}
#progress-label {
font-size: small;
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;
fill: none;
stroke: currentcolor;
stroke-width: 2px;
border-radius: 6px;
padding: 8px;
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>
<input type="file" id="file-input" hidden>
<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">
<svg class="icon" width="24" height="24" aria-hidden="true">
<path d="M 15 6 L 9 12 L 15 18"/>
+29 -1
View File
@@ -1,5 +1,6 @@
/* global zip: false, fflate: false */
import { View } from './view.js'
import { createTOCView } from './ui/tree.js'
const { ZipReader, BlobReader, TextWriter, BlobWriter } = zip
zip.configure({ useWebWorkers: false })
@@ -102,16 +103,19 @@ const $ = document.querySelector.bind(document)
const locales = 'en'
const percentFormat = new Intl.NumberFormat(locales, { style: 'percent' })
let setCurrentHref
const emit = obj => {
console.debug(obj)
switch (obj.type) {
case 'relocated': {
const { fraction, location, pageItem } = obj
const { fraction, location, tocItem, pageItem } = obj
const percent = percentFormat.format(fraction)
const loc = pageItem
? `Page ${pageItem.label}`
: `Loc ${location.current}`
$('#progress-label').innerText = `${percent} · ${loc}`
if (tocItem?.href) setCurrentHref?.(tocItem.href)
break
}
}
@@ -133,6 +137,30 @@ const dropHandler = e => {
}
view.setAppearance({ css: getCSS(style) })
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'
$('#left-button').addEventListener('click', () => view.goLeft())
$('#right-button').addEventListener('click', () => view.goRight())
+169
View File
@@ -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 }
}