You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wiki/blocks/block-gallery/component.js

633 lines
20 KiB

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
/**
* An attribute that means "off" when it says so.
*
* MDC writes every prop with a value, and Lit's own Boolean converter reads any string at all as
* true — `unlockAspectRatio="false"` included. The picker never writes that one, since it leaves a
* prop out while it holds its default, but a page written by hand can say it and means it.
*/
const boolean = {
converter: {
fromAttribute: (value) => value !== null && value !== 'false',
toAttribute: (value) => (value ? 'true' : null)
}
}
/** 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'
},
{
name: 'unlockAspectRatio',
type: 'boolean',
label: 'Unlock Aspect Ratio',
hint: 'Let each tile take the shape of its image, instead of holding every one square.',
// -> Stated, so that a toggle switched on and then off again writes nothing into the page
default: false
}
],
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;
}
/*
A gallery whose tiles take the shape of their images rather than being held square.
Dropping the ratio is not enough on its own: a grid item stretches to the height of its row,
which hands the image back a definite height to be cropped to -- the tallest photo of the row
deciding the shape of the rest, which is the thing being unlocked. So the row lets go of them
as well, and the image is left to its own height.
*/
.gallery.is-unlocked {
align-items: start;
}
.gallery.is-unlocked .tile {
aspect-ratio: auto;
}
.gallery.is-unlocked .tile img {
height: auto;
}
.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 },
/**
* Whether a tile takes the shape of its image rather than being held square
* @type {boolean}
*/
unlockAspectRatio: boolean,
// 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.unlockAspectRatio = false
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 `![](photo.jpg)` 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 ${this.unlockAspectRatio ? 'is-unlocked' : ''}" style=${style}>
${this._images.map(
(address, index) => html`
<button
class="tile"
type="button"
title="Enlarge Image"
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)