mirror of https://github.com/requarks/wiki
parent
9a1ca3b135
commit
c182d2c974
@ -0,0 +1,585 @@
|
||||
import { LitElement, html, css } from 'lit'
|
||||
|
||||
import { DarkMode } from '../shared/theme.js'
|
||||
|
||||
/** Where an uploaded file is served from, and so what a bare path in the body is taken to mean. */
|
||||
const FILES_PREFIX = '/_files/'
|
||||
|
||||
/**
|
||||
* An address that already says where it points: a full URL, a protocol-relative one, a data URI —
|
||||
* or one of the wiki's own `/_` routes, `/_files/` among them.
|
||||
*/
|
||||
const ABSOLUTE = /^(?:[a-z][a-z0-9+.-]*:|\/\/|\/_)/i
|
||||
|
||||
/** Icons, as the path of a 24x24 MDI glyph. */
|
||||
const ICONS = {
|
||||
previous: 'M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z',
|
||||
next: 'M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z',
|
||||
close:
|
||||
'M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z'
|
||||
}
|
||||
|
||||
/**
|
||||
* The address a line of the body points at.
|
||||
*
|
||||
* Anything that names its own location is left exactly as written. Everything else is a path into the
|
||||
* file manager, which is where the images on a wiki page live — so `photos/summer.jpg` and
|
||||
* `/photos/summer.jpg` both mean `/_files/photos/summer.jpg`, and an author can paste the path the
|
||||
* file manager shows without having to remember the prefix. Wiki routes are spared that: they all
|
||||
* start with `/_`, and `/_files/` is one of them, so a path already carrying the prefix is not given
|
||||
* a second one.
|
||||
*/
|
||||
function resolveSource(value) {
|
||||
const address = value.trim()
|
||||
if (ABSOLUTE.test(address)) {
|
||||
return address
|
||||
}
|
||||
return FILES_PREFIX + address.replace(/^\/+/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* The box the block actually scrolls in.
|
||||
*
|
||||
* The article has its own scroller rather than the window — the shell stays put and the column moves
|
||||
* — so that is the element a lightbox has to hold still while it is open. Same walk as
|
||||
* `helpers/anchors.js` on the frontend, for the same reason.
|
||||
*/
|
||||
function scrollerOf(el) {
|
||||
for (let node = el.parentElement; node; node = node.parentElement) {
|
||||
const { overflowY } = getComputedStyle(node)
|
||||
if (/(auto|scroll|overlay)/.test(overflowY) && node.scrollHeight > node.clientHeight + 1) {
|
||||
return node
|
||||
}
|
||||
}
|
||||
return document.scrollingElement ?? document.documentElement
|
||||
}
|
||||
|
||||
/**
|
||||
* What to call an image, for a screen reader and for a browser drawing the alt text of one that did
|
||||
* not load. The file name is all a list of addresses carries.
|
||||
*/
|
||||
function labelFor(address) {
|
||||
const path = address.split(/[?#]/)[0]
|
||||
const name = path.split('/').filter(Boolean).at(-1) ?? address
|
||||
try {
|
||||
return decodeURIComponent(name)
|
||||
} catch {
|
||||
// -> A stray `%` in a file name, which is not an escape and not worth failing over
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block Gallery
|
||||
*
|
||||
* A grid of thumbnails from a list of addresses, and a lightbox over the whole site to look at any
|
||||
* one of them full size. The list is the block's body, one address per line:
|
||||
*
|
||||
* ::block-gallery
|
||||
* https://example.com/photo-1.jpg
|
||||
* /photos/photo-2.jpg
|
||||
* ::
|
||||
*/
|
||||
export class BlockGalleryElement extends LitElement {
|
||||
/**
|
||||
* Metadata for the admin area and the editor's block picker. Collected at build time into
|
||||
* `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be
|
||||
* plain literals. See `props` in `block-index` for what the picker does with that list.
|
||||
*/
|
||||
static definition = {
|
||||
block: 'gallery',
|
||||
name: 'Image Gallery',
|
||||
description: 'Displays a grid of images, each opening full size in a lightbox.',
|
||||
icon: 'image',
|
||||
props: [
|
||||
{
|
||||
name: 'thumbnailSize',
|
||||
type: 'number',
|
||||
label: 'Thumbnail Size',
|
||||
hint: 'Smallest a thumbnail may be, in pixels. The grid fits as many as the width allows.',
|
||||
default: 180
|
||||
},
|
||||
{
|
||||
name: 'fit',
|
||||
type: 'select',
|
||||
label: 'Thumbnail Fit',
|
||||
options: ['cover', 'contain'],
|
||||
hint: 'Whether a thumbnail is cropped to fill its tile, or shown whole inside it.',
|
||||
default: 'cover'
|
||||
}
|
||||
],
|
||||
template: `https://example.com/photo-1.jpg
|
||||
https://example.com/photo-2.jpg`
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
--gallery-border: #e0e0e0;
|
||||
--gallery-tile-bg: #f1f3f5;
|
||||
--gallery-fg: #424242;
|
||||
}
|
||||
:host([dark]) {
|
||||
--gallery-border: rgba(255, 255, 255, 0.15);
|
||||
--gallery-tile-bg: #12161d;
|
||||
--gallery-fg: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/*
|
||||
The grid, and the gap below the block. On this element rather than :host: see block-index.
|
||||
|
||||
-> min() rather than the thumbnail size on its own, so a gallery asked for at 300 on a phone
|
||||
is one column the width of the phone instead of pushing the page sideways.
|
||||
*/
|
||||
.gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(var(--gallery-thumb), 100%), 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: block;
|
||||
padding: 0;
|
||||
border: 1px solid var(--gallery-border);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
background-color: var(--gallery-tile-bg);
|
||||
aspect-ratio: 1;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.tile:focus-visible {
|
||||
outline: 2px solid var(--q-primary, #1976d2);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tile img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: var(--gallery-fit);
|
||||
/* -> The alt text of an image that did not load, which has no room to be centred in */
|
||||
font-size: 12px;
|
||||
color: var(--gallery-fg);
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
.tile:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tile img {
|
||||
transition: none;
|
||||
}
|
||||
.tile:hover img {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The lightbox is a modal dialog, which is what puts it over the whole site.
|
||||
|
||||
An element in the top layer is drawn above the page whatever the block is nested in -- where a
|
||||
fixed-position overlay in the shadow root is still clipped by the first ancestor with a
|
||||
transform, a filter or an overflow of its own, and the app has all three between the page and
|
||||
a block. Opened this way it also comes with most of what a lightbox has to do anyway: Escape
|
||||
closes it, the page behind cannot be tabbed into or clicked, and focus returns to the
|
||||
thumbnail that was opened. Scrolling is the exception -- see _holdPage below.
|
||||
*/
|
||||
.lightbox {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
overlay 150ms allow-discrete,
|
||||
display 150ms allow-discrete;
|
||||
}
|
||||
.lightbox[open] {
|
||||
opacity: 1;
|
||||
}
|
||||
/* -> Where the fade starts from. Without it the dialog is simply there, which is no worse. */
|
||||
@starting-style {
|
||||
.lightbox[open] {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
/*
|
||||
-> A shade lighter than a flat backdrop would be, since the blur is doing some of the work of
|
||||
putting the page away. Not much lighter: dark enough on its own that a browser without
|
||||
backdrop-filter loses the softness and nothing else.
|
||||
*/
|
||||
.lightbox::backdrop {
|
||||
background-color: rgb(0 0 0 / 0.82);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
/*
|
||||
The clickable ground the image sits on: anywhere off the image closes the lightbox.
|
||||
|
||||
-> border-box, because the padding is what keeps the image clear of the chevrons over it and
|
||||
a content-box stage is the width of the dialog plus that padding, which pushes what it is
|
||||
centring off to one side. The app's own reset does not reach in here.
|
||||
*/
|
||||
.stage {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 4rem;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.stage {
|
||||
padding: 3.5rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.stage img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chrome {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background-color: rgb(255 255 255 / 0.1);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chrome:hover {
|
||||
background-color: rgb(255 255 255 / 0.25);
|
||||
}
|
||||
.chrome:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.chrome svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.chrome.is-close {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
.chrome.is-previous {
|
||||
top: 50%;
|
||||
left: 12px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.chrome.is-next {
|
||||
top: 50%;
|
||||
right: 12px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.counter {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
background-color: rgb(0 0 0 / 0.5);
|
||||
color: rgb(255 255 255 / 0.85);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-bottom: 16px;
|
||||
padding: 1rem;
|
||||
border: 1px dashed color-mix(in srgb, currentColor 50%, transparent);
|
||||
border-radius: 5px;
|
||||
color: var(--q-negative, #c10015);
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
/**
|
||||
* Smallest a thumbnail may be, in pixels
|
||||
* @type {number}
|
||||
*/
|
||||
thumbnailSize: { type: Number },
|
||||
|
||||
/**
|
||||
* How a thumbnail fills its tile: `cover` or `contain`
|
||||
* @type {string}
|
||||
*/
|
||||
fit: { type: String },
|
||||
|
||||
// Internal Properties
|
||||
_images: { state: true },
|
||||
/** Which image the lightbox is showing, or -1 while it is closed. */
|
||||
_index: { state: true }
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.thumbnailSize = 180
|
||||
this.fit = 'cover'
|
||||
this._images = []
|
||||
this._index = -1
|
||||
// -> Puts `dark` on this element for the styles above to key off
|
||||
this._darkMode = new DarkMode(this)
|
||||
/** What the page was doing before the lightbox held it still. See `_holdPage`. */
|
||||
this._held = null
|
||||
}
|
||||
|
||||
get _dialog() {
|
||||
return this.renderRoot?.querySelector('.lightbox') ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the list of images out of the block's body.
|
||||
*
|
||||
* The body has been through markdown by the time it gets here, which for a list of addresses leaves
|
||||
* the addresses themselves: linkified or not, the text of the paragraph is what was typed. It is
|
||||
* split on whitespace rather than on line endings alone, so a body whose lines markdown joined into
|
||||
* one still reads as the list it was written as.
|
||||
*
|
||||
* Images markdown drew for itself are collected too, since `` is the other way an
|
||||
* author writes an image and arrives here as an `img` carrying no text at all. A fenced code block
|
||||
* wins outright, as everywhere else: it is the way to hand a block a body markdown has not touched.
|
||||
*/
|
||||
firstUpdated() {
|
||||
const fence = this.querySelector('pre')
|
||||
const source = ((fence ?? this).textContent ?? '').trim()
|
||||
const found = source.split(/\s+/).filter(Boolean).map(resolveSource)
|
||||
if (!fence) {
|
||||
for (const image of this.querySelectorAll('img')) {
|
||||
found.push(resolveSource(image.getAttribute('src') ?? ''))
|
||||
}
|
||||
}
|
||||
// -> An address written once and drawn twice -- as a link and as the image it points at -- is one
|
||||
// photo, and the order they were written in is the order the gallery shows them in
|
||||
this._images = [...new Set(found)]
|
||||
}
|
||||
|
||||
/** Keep the neighbours of what is showing ready, so a chevron is a step rather than a load. */
|
||||
_preloadNeighbours() {
|
||||
for (const step of [-1, 1]) {
|
||||
const image = new Image()
|
||||
image.src = this._images[this._wrap(this._index + step)]
|
||||
}
|
||||
}
|
||||
|
||||
/** An index brought back into the gallery, so that the last photo is followed by the first. */
|
||||
_wrap(index) {
|
||||
const count = this._images.length
|
||||
return (index + count) % count
|
||||
}
|
||||
|
||||
async _show(index) {
|
||||
this._index = index
|
||||
// -> Shown only once the image it is showing has been rendered, so the lightbox never opens empty
|
||||
await this.updateComplete
|
||||
this._dialog?.showModal()
|
||||
this._holdPage(true)
|
||||
this._preloadNeighbours()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the page moving under the lightbox, and let it go again afterwards.
|
||||
*
|
||||
* The one thing a modal dialog does not do for itself: the page behind it cannot be clicked or
|
||||
* tabbed into, but a wheel still scrolls it — so the reader closes the lightbox somewhere other
|
||||
* than where they opened it. The offset is put back along with the overflow, because an element
|
||||
* that has spent a moment not scrolling does not reliably keep the position it was scrolled to.
|
||||
*
|
||||
* Nothing of this is visible while it happens: the backdrop covers the page it is done to.
|
||||
*/
|
||||
_holdPage(held) {
|
||||
if (held) {
|
||||
const scroller = scrollerOf(this)
|
||||
this._held = { scroller, overflow: scroller.style.overflow, top: scroller.scrollTop }
|
||||
scroller.style.overflow = 'hidden'
|
||||
return
|
||||
}
|
||||
if (this._held) {
|
||||
const { scroller, overflow, top } = this._held
|
||||
scroller.style.overflow = overflow
|
||||
scroller.scrollTop = top
|
||||
this._held = null
|
||||
}
|
||||
}
|
||||
|
||||
_step(delta) {
|
||||
this._index = this._wrap(this._index + delta)
|
||||
this._preloadNeighbours()
|
||||
}
|
||||
|
||||
_previous() {
|
||||
this._step(-1)
|
||||
}
|
||||
|
||||
_next() {
|
||||
this._step(1)
|
||||
}
|
||||
|
||||
_close() {
|
||||
this._dialog?.close()
|
||||
}
|
||||
|
||||
/** However it was closed — the X, a click beside the image, or Escape, which is the dialog's own. */
|
||||
_onClose() {
|
||||
this._index = -1
|
||||
this._holdPage(false)
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback()
|
||||
// -> A block taken off the page while its lightbox is open would otherwise leave the article
|
||||
// unable to scroll, with nothing left to close
|
||||
this._holdPage(false)
|
||||
}
|
||||
|
||||
/** Escape is the dialog's own; the arrow keys are what a gallery adds to it. */
|
||||
_onKeydown(ev) {
|
||||
if (ev.key === 'ArrowLeft') {
|
||||
ev.preventDefault()
|
||||
this._previous()
|
||||
} else if (ev.key === 'ArrowRight') {
|
||||
ev.preventDefault()
|
||||
this._next()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A click on the ground the image sits on, rather than on the image or a button over it.
|
||||
*
|
||||
* The dialog itself is included: it is the whole viewport, and a stage narrower than the window --
|
||||
* which is what a portrait window leaves -- puts the edges of the backdrop there.
|
||||
*/
|
||||
_onStageClick(ev) {
|
||||
if (ev.target === ev.currentTarget || ev.target.classList.contains('stage')) {
|
||||
this._close()
|
||||
}
|
||||
}
|
||||
|
||||
_icon(path) {
|
||||
return html`<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${path}" /></svg>`
|
||||
}
|
||||
|
||||
/**
|
||||
* The lightbox, empty until it is opened.
|
||||
*
|
||||
* The dialog itself is always in the shadow tree, since it is what `showModal` is called on, but
|
||||
* nothing inside it is built for a lightbox nobody has opened — an image the reader may never ask
|
||||
* for is a photo fetched per gallery on every page it appears on.
|
||||
*/
|
||||
_renderLightbox() {
|
||||
const address = this._images[this._index]
|
||||
return html`
|
||||
<dialog
|
||||
class="lightbox"
|
||||
aria-label="Image viewer"
|
||||
@click=${this._onStageClick}
|
||||
@keydown=${this._onKeydown}
|
||||
@close=${this._onClose}>
|
||||
${
|
||||
address
|
||||
? html`
|
||||
<div class="stage">
|
||||
<img src=${address} alt=${labelFor(address)} />
|
||||
</div>
|
||||
${
|
||||
this._images.length > 1
|
||||
? html`
|
||||
<button
|
||||
class="chrome is-previous"
|
||||
type="button"
|
||||
title="Previous image"
|
||||
aria-label="Previous image"
|
||||
@click=${this._previous}>
|
||||
${this._icon(ICONS.previous)}
|
||||
</button>
|
||||
<button
|
||||
class="chrome is-next"
|
||||
type="button"
|
||||
title="Next image"
|
||||
aria-label="Next image"
|
||||
@click=${this._next}>
|
||||
${this._icon(ICONS.next)}
|
||||
</button>
|
||||
<div class="counter">${this._index + 1} / ${this._images.length}</div>
|
||||
`
|
||||
: null
|
||||
}
|
||||
<!-- -> Focused on opening, so the lightbox is closable from the keyboard straight away -->
|
||||
<button
|
||||
class="chrome is-close"
|
||||
type="button"
|
||||
autofocus
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
@click=${this._close}>
|
||||
${this._icon(ICONS.close)}
|
||||
</button>
|
||||
`
|
||||
: null
|
||||
}
|
||||
</dialog>
|
||||
`
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this._images.length < 1) {
|
||||
return html`
|
||||
<div class="error">
|
||||
This gallery is empty. Its images go in the body of the block, one address per line.
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const size = Number(this.thumbnailSize)
|
||||
const style = [
|
||||
`--gallery-thumb: ${Number.isFinite(size) && size > 0 ? size : 180}px`,
|
||||
`--gallery-fit: ${this.fit === 'contain' ? 'contain' : 'cover'}`
|
||||
].join('; ')
|
||||
|
||||
return html`
|
||||
<div class="gallery" style=${style}>
|
||||
${this._images.map(
|
||||
(address, index) => html`
|
||||
<button
|
||||
class="tile"
|
||||
type="button"
|
||||
title=${labelFor(address)}
|
||||
aria-label="View ${labelFor(address)} full size"
|
||||
@click=${() => this._show(index)}>
|
||||
<img src=${address} alt=${labelFor(address)} loading="lazy" decoding="async" />
|
||||
</button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
${this._renderLightbox()}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('block-gallery', BlockGalleryElement)
|
||||
Loading…
Reference in new issue