mirror of https://github.com/requarks/wiki
parent
9408accc00
commit
3d13e50be8
@ -0,0 +1,289 @@
|
||||
import { LitElement, html, css } from 'lit'
|
||||
|
||||
/**
|
||||
* Block Countdown
|
||||
*/
|
||||
export class BlockCountdownElement 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: 'countdown',
|
||||
name: 'Countdown',
|
||||
description: 'Counts down to a date and time.',
|
||||
icon: 'timer',
|
||||
props: [
|
||||
{
|
||||
name: 'date',
|
||||
type: 'string',
|
||||
label: 'Target Date',
|
||||
hint: 'ISO date and time, e.g. 2026-12-25T09:00. Read in the timezone below unless it carries an offset of its own.',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'timezone',
|
||||
type: 'string',
|
||||
label: 'Timezone',
|
||||
hint: "IANA name, e.g. Europe/Paris. The reader's own timezone when empty.",
|
||||
default: 'UTC'
|
||||
},
|
||||
{
|
||||
name: 'label',
|
||||
type: 'string',
|
||||
label: 'Label',
|
||||
hint: 'What is being counted down to. Shown above the numbers.'
|
||||
},
|
||||
{
|
||||
name: 'expiredMsg',
|
||||
type: 'string',
|
||||
label: 'Ended Message',
|
||||
hint: 'Shown once the target has passed.',
|
||||
default: 'The countdown has ended.'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/*
|
||||
The gap below a block lives on this element, not on :host.
|
||||
|
||||
The app resets the margin on every element, and a rule in the page beats a :host rule in the
|
||||
shadow tree whatever its specificity -- so a margin set on the host is simply dropped. Set
|
||||
inside the shadow root it is out of that rule's reach, and collapses out through the host,
|
||||
which carries no padding or border of its own.
|
||||
*/
|
||||
.countdown,
|
||||
.error {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 5px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
background-image: linear-gradient(to bottom, #fff, #fafafa);
|
||||
}
|
||||
:host-context(body.body--dark) .countdown {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
background-image: linear-gradient(to bottom, #161b22, #0d1117);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.segments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.segment {
|
||||
min-width: 72px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 5px;
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
:host-context(body.body--dark) .segment {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--q-primary, #1976d2);
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.target {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ended {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--q-negative, #c10015);
|
||||
border: 1px dashed color-mix(in srgb, currentColor 50%, transparent);
|
||||
border-radius: 5px;
|
||||
padding: 1rem;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
/**
|
||||
* Target date and time, ISO 8601
|
||||
* @type {string}
|
||||
*/
|
||||
date: { type: String },
|
||||
|
||||
/**
|
||||
* IANA timezone the target is expressed in
|
||||
* @type {string}
|
||||
*/
|
||||
timezone: { type: String },
|
||||
|
||||
/**
|
||||
* What the countdown is for
|
||||
* @type {string}
|
||||
*/
|
||||
label: { type: String },
|
||||
|
||||
/**
|
||||
* Shown once the target has passed
|
||||
* @type {string}
|
||||
*/
|
||||
expiredMsg: { type: String },
|
||||
|
||||
// Internal Properties
|
||||
_remaining: { state: true },
|
||||
_error: { state: true }
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.date = ''
|
||||
this.timezone = 'UTC'
|
||||
this.label = ''
|
||||
this.expiredMsg = 'The countdown has ended.'
|
||||
this._remaining = null
|
||||
this._error = ''
|
||||
this._target = null
|
||||
this._timer = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the target into a zoned instant.
|
||||
*
|
||||
* A date carrying its own offset — `2026-12-25T09:00-05:00`, or a trailing `Z` — is an exact moment
|
||||
* and the timezone only decides how it is displayed. Without one it is a wall-clock time, which is
|
||||
* what an author writing "the ninth of December at nine" means, and the timezone is what turns it
|
||||
* into a moment. Both then count down to the same instant for every reader, wherever they are.
|
||||
*/
|
||||
_resolveTarget(zone) {
|
||||
try {
|
||||
return Temporal.Instant.from(this.date).toZonedDateTimeISO(zone)
|
||||
} catch {
|
||||
return Temporal.PlainDateTime.from(this.date).toZonedDateTime(zone)
|
||||
}
|
||||
}
|
||||
|
||||
_tick() {
|
||||
const now = Temporal.Now.zonedDateTimeISO(this._target.timeZoneId)
|
||||
if (Temporal.ZonedDateTime.compare(now, this._target) >= 0) {
|
||||
this._remaining = null
|
||||
this._stop()
|
||||
return
|
||||
}
|
||||
// -> Through ZonedDateTime rather than Instant, so that a day is a day across a DST change and
|
||||
// not always exactly 24 hours
|
||||
this._remaining = now.until(this._target, { largestUnit: 'day', smallestUnit: 'second' })
|
||||
}
|
||||
|
||||
_stop() {
|
||||
clearInterval(this._timer)
|
||||
this._timer = null
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback()
|
||||
// -> An empty timezone means the reader's own, which is also what an unknown one must not
|
||||
// silently become: a countdown to the wrong moment is worse than a visible mistake
|
||||
const zone = this.timezone?.trim() || Temporal.Now.timeZoneId()
|
||||
try {
|
||||
Temporal.Now.zonedDateTimeISO(zone)
|
||||
} catch {
|
||||
this._error = `"${zone}" is not a known timezone.`
|
||||
return
|
||||
}
|
||||
try {
|
||||
this._target = this._resolveTarget(zone)
|
||||
} catch {
|
||||
this._error = `"${this.date}" is not a date this can count down to.`
|
||||
return
|
||||
}
|
||||
this._tick()
|
||||
if (this._remaining) {
|
||||
this._timer = setInterval(() => this._tick(), 1000)
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback()
|
||||
this._stop()
|
||||
}
|
||||
|
||||
_segment(value, unit) {
|
||||
return html`
|
||||
<div class="segment">
|
||||
<div class="value">${value}</div>
|
||||
<div class="unit">${value === 1 ? unit : `${unit}s`}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this._error) {
|
||||
return html`<div class="error">${this._error}</div>`
|
||||
}
|
||||
if (!this._target) {
|
||||
return null
|
||||
}
|
||||
/*
|
||||
Spelled out field by field rather than with `dateStyle` / `timeStyle`, which cannot be combined
|
||||
with `timeZoneName` — and the zone is the point: a reader in another country needs to see which
|
||||
clock the target is on, not just a time that does not match their own.
|
||||
*/
|
||||
const at = this._target.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
})
|
||||
return html`
|
||||
<div class="countdown">
|
||||
${this.label ? html`<div class="label">${this.label}</div>` : null}
|
||||
${this._remaining
|
||||
? html`
|
||||
<div class="segments">
|
||||
${this._remaining.days > 0 ? this._segment(this._remaining.days, 'Day') : null}
|
||||
${this._segment(this._remaining.hours, 'Hour')}
|
||||
${this._segment(this._remaining.minutes, 'Minute')}
|
||||
${this._segment(this._remaining.seconds, 'Second')}
|
||||
</div>
|
||||
`
|
||||
: html`<div class="ended">${this.expiredMsg}</div>`}
|
||||
<div class="target">${at}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('block-countdown', BlockCountdownElement)
|
||||
@ -0,0 +1,217 @@
|
||||
import { LitElement, html } from 'lit'
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js'
|
||||
|
||||
/** How many includes may nest before the chain is treated as a mistake. */
|
||||
const MAX_DEPTH = 3
|
||||
|
||||
/**
|
||||
* Strip a path down to the form the server stores, so that `/Foo/Bar/` and `foo/bar` are one page
|
||||
* when the chain below is checked for a cycle.
|
||||
*/
|
||||
function normalizePath(path) {
|
||||
return (path ?? '').trim().replace(/^\/+/, '').replace(/\/+$/, '').toLowerCase() || 'home'
|
||||
}
|
||||
|
||||
/**
|
||||
* Block Include
|
||||
*/
|
||||
export class BlockIncludeElement 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: 'include',
|
||||
name: 'Include',
|
||||
description: 'Transclude the contents of another page inside this one.',
|
||||
icon: 'duplicate',
|
||||
props: [
|
||||
{
|
||||
name: 'path',
|
||||
type: 'string',
|
||||
label: 'Page Path',
|
||||
hint: 'Path of the page to include, without a leading slash.',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'locale',
|
||||
type: 'string',
|
||||
label: 'Locale',
|
||||
hint: "Locale of the page to include. This page's own locale when empty."
|
||||
},
|
||||
{
|
||||
name: 'showTitle',
|
||||
type: 'boolean',
|
||||
label: 'Show Title',
|
||||
hint: "Draw the included page's title above it."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
/**
|
||||
* Path of the page to include
|
||||
* @type {string}
|
||||
*/
|
||||
path: { type: String },
|
||||
|
||||
/**
|
||||
* Locale of the page to include
|
||||
* @type {string}
|
||||
*/
|
||||
locale: { type: String },
|
||||
|
||||
/**
|
||||
* Whether to draw the included page's title above it
|
||||
* @type {boolean}
|
||||
*/
|
||||
showTitle: { type: Boolean },
|
||||
|
||||
// Internal Properties
|
||||
_loading: { state: true },
|
||||
_title: { state: true },
|
||||
_render: { state: true },
|
||||
_error: { state: true }
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Rendered into the light DOM, unlike every other block: what comes back is page content, and page
|
||||
content is styled by the stylesheet the article itself is drawn with. In a shadow root it would
|
||||
arrive unstyled, and the whole point is that an included page reads as part of the page including
|
||||
it. It also puts nested blocks where the DOM walk below can see them.
|
||||
*/
|
||||
createRenderRoot() {
|
||||
// -> A box of its own, set inline because the page resets the margin and display of everything
|
||||
// in it and a light-DOM block has no `:host` rule to be styled by. The spacing below comes
|
||||
// from the included content's own last element, which is page content like any other.
|
||||
this.style.display = 'block'
|
||||
return this
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this._loading = true
|
||||
this._title = ''
|
||||
this._render = ''
|
||||
this._error = ''
|
||||
this.path = ''
|
||||
this.locale = ''
|
||||
this.showTitle = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Every page already on screen above this element, innermost first.
|
||||
*
|
||||
* A loop — a page including itself, or two pages including each other — would otherwise fetch and
|
||||
* draw forever, since each copy arrives carrying the element that fetched it.
|
||||
*
|
||||
* The page being read counts as the outermost link, and that is the part that matters: without it
|
||||
* a mutual pair only trips on the second lap, after fetching and drawing one pointless extra copy
|
||||
* of each page. With it, the loop is refused at the exact point it would close, before a request
|
||||
* goes out.
|
||||
*/
|
||||
_ancestorPaths() {
|
||||
const paths = []
|
||||
let parent = this.parentElement?.closest('block-include')
|
||||
while (parent) {
|
||||
paths.push(normalizePath(parent.getAttribute('path')))
|
||||
parent = parent.parentElement?.closest('block-include')
|
||||
}
|
||||
paths.push(normalizePath(WIKI_STATE.page.path))
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the components for any block the included page brought with it.
|
||||
*
|
||||
* The page view scans for undefined elements once, when it loads a page, so anything arriving
|
||||
* afterwards has to ask for itself. Same contract: the element's tag names the file to fetch.
|
||||
*/
|
||||
async _loadNestedBlocks() {
|
||||
for (const el of this.querySelectorAll(':not(:defined)')) {
|
||||
const tag = el.tagName.toLowerCase()
|
||||
if (!tag.startsWith('block-')) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await import(/* @vite-ignore */ `/_blocks/${tag}.js`)
|
||||
} catch (err) {
|
||||
console.warn(`Failed to load ${tag}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback()
|
||||
|
||||
const path = normalizePath(this.path)
|
||||
const chain = this._ancestorPaths()
|
||||
if (chain.includes(path)) {
|
||||
// -> A page naming itself is its author's own doing; anything longer went round other pages,
|
||||
// and saying which one closes the loop is the part that helps
|
||||
this._error =
|
||||
chain.length === 1
|
||||
? 'This page includes itself.'
|
||||
: `Including "${path}" here would loop: it is already open above.`
|
||||
} else if (chain.length > MAX_DEPTH) {
|
||||
this._error = `Includes are nested more than ${MAX_DEPTH} pages deep.`
|
||||
} else {
|
||||
try {
|
||||
const page = await API_CLIENT.get(`sites/${WIKI_STATE.site.id}/pages/include`, {
|
||||
searchParams: {
|
||||
path,
|
||||
locale: this.locale || WIKI_STATE.page.locale
|
||||
}
|
||||
}).json()
|
||||
if (page.isLocked) {
|
||||
// -> Withheld by the server, which is the same answer this reader gets by opening the page.
|
||||
// The unlock prompt lives there, so this points at it rather than asking for a password.
|
||||
this._error = `The page "${path}" is password protected. Open it to enter the password.`
|
||||
} else {
|
||||
this._title = page.title
|
||||
this._render = page.render
|
||||
}
|
||||
} catch (err) {
|
||||
this._error =
|
||||
err.response?.status === 404
|
||||
? `There is no page at "${path}".`
|
||||
: `The page "${path}" could not be included.`
|
||||
}
|
||||
}
|
||||
|
||||
this._loading = false
|
||||
if (this._render) {
|
||||
// -> After the render lands in the DOM, since that is what it walks
|
||||
await this.updateComplete
|
||||
await this._loadNestedBlocks()
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this._loading) {
|
||||
return null
|
||||
}
|
||||
if (this._error) {
|
||||
return html`
|
||||
<div
|
||||
style="
|
||||
color: var(--q-negative, #c10015);
|
||||
border: 1px dashed color-mix(in srgb, currentColor 50%, transparent);
|
||||
border-radius: 5px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 16px;
|
||||
">
|
||||
${this._error}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
return html`
|
||||
${this.showTitle ? html`<h2>${this._title}</h2>` : null}${unsafeHTML(this._render)}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('block-include', BlockIncludeElement)
|
||||
@ -1,30 +0,0 @@
|
||||
query blockIndexFetchPages (
|
||||
$siteId: UUID!
|
||||
$locale: String
|
||||
$parentPath: String
|
||||
$tags: [String]
|
||||
$limit: Int
|
||||
$orderBy: TreeOrderBy
|
||||
$orderByDirection: OrderByDirection
|
||||
$depth: Int
|
||||
) {
|
||||
tree(
|
||||
siteId: $siteId
|
||||
locale: $locale
|
||||
parentPath: $parentPath
|
||||
tags: $tags
|
||||
limit: $limit
|
||||
types: [page]
|
||||
orderBy: $orderBy
|
||||
orderByDirection: $orderByDirection
|
||||
depth: $depth
|
||||
) {
|
||||
id
|
||||
folderPath
|
||||
fileName
|
||||
title
|
||||
...on TreeItemPage {
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,360 @@
|
||||
import { LitElement, html, css } from 'lit'
|
||||
import { load as parseYaml } from 'js-yaml'
|
||||
|
||||
/**
|
||||
* Yes and no, drawn rather than spelled out.
|
||||
*
|
||||
* A column of "true"/"false" is read word by word; a tick and a cross are read at a glance, which is
|
||||
* what an infobox is for. Inline, because they are the same two pictures on every infobox there is,
|
||||
* and labelled, since the shape alone means nothing to a screen reader.
|
||||
*/
|
||||
const YES_SVG = html`
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" role="img" aria-label="Yes" class="yes">
|
||||
<path fill="currentColor" d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
`
|
||||
|
||||
const NO_SVG = html`
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" role="img" aria-label="No" class="no">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
</svg>
|
||||
`
|
||||
|
||||
/**
|
||||
* One value, as it is shown.
|
||||
*
|
||||
* A list reads as one line, since an infobox row is a line: "French, English" rather than a bullet
|
||||
* list squeezed into half a column.
|
||||
*/
|
||||
function valueOf(value) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? YES_SVG : NO_SVG
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
// -> Joined by hand rather than with `join`, so that a boolean among them is still drawn
|
||||
return value.map((entry, index) => html`${index > 0 ? ', ' : ''}${valueOf(entry)}`)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows a value turns into.
|
||||
*
|
||||
* A nested mapping becomes a group of its own with a heading, which is how an infobox shows a cluster
|
||||
* of related facts. Anything else is a single row.
|
||||
*/
|
||||
function rowsOf(value) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return Object.entries(value).map(([label, nested]) => ({ label, value: nested }))
|
||||
}
|
||||
return [{ value }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Block Infobox
|
||||
*/
|
||||
export class BlockInfoboxElement 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: 'infobox',
|
||||
name: 'Infobox',
|
||||
description: 'A summary box beside the text, filled in from a list of facts.',
|
||||
icon: 'data-sheet',
|
||||
template: `City: Montreal
|
||||
Country: Canada
|
||||
Metro: true
|
||||
"Key with space": foo-bar`,
|
||||
props: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
label: 'Name',
|
||||
hint: 'Heading at the top of the box.',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
type: 'string',
|
||||
label: 'Image URL',
|
||||
hint: 'Path or URL of a picture to show under the heading.'
|
||||
},
|
||||
{
|
||||
name: 'imageCaption',
|
||||
type: 'string',
|
||||
label: 'Image Caption',
|
||||
hint: 'Shown under the picture.'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
/*
|
||||
Floated, so the article runs down its left and closes under it — the whole point of an
|
||||
infobox. The margin carries !important because the app resets the margin of everything in a
|
||||
page, and a rule in the page beats a :host rule however specific; a declaration marked
|
||||
important in a shadow tree is the one thing that outranks it. See block-index for the usual
|
||||
way round this, which does not work on a float: a float collapses no margins.
|
||||
*/
|
||||
:host {
|
||||
display: block;
|
||||
float: right;
|
||||
clear: right;
|
||||
width: 320px;
|
||||
max-width: 100%;
|
||||
margin: 4px 0 16px 24px !important;
|
||||
/*
|
||||
A layer of its own, above the article's own decoration. A heading draws its rule as an
|
||||
absolutely positioned pseudo-element spanning the whole column, and a positioned element
|
||||
paints over a float whichever way round the two are written — so the rule ran straight
|
||||
across the box. This is the right way round anyway: the box is a card sitting on the page,
|
||||
and the rule belongs to the text it is sitting on.
|
||||
*/
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* -> Below a certain width the column cannot spare 320px, and a full-width card reads better */
|
||||
@media (max-width: 800px) {
|
||||
:host {
|
||||
float: none;
|
||||
width: auto;
|
||||
margin: 0 0 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.infobox {
|
||||
border: 1px solid var(--infobox-border);
|
||||
border-radius: 6px;
|
||||
background-color: var(--infobox-bg);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.name {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--infobox-border);
|
||||
background-color: var(--infobox-head);
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0;
|
||||
padding: 12px 12px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
padding-top: 6px;
|
||||
font-size: 0.9em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(6em, auto) 1fr;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
dt,
|
||||
dd {
|
||||
margin: 0;
|
||||
padding: 7px 12px;
|
||||
border-top: 1px solid var(--infobox-rule);
|
||||
}
|
||||
dl > :is(dt, dd):is(:first-child, :nth-child(2)) {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
dd {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* -> A nested mapping: its own heading across both columns, then its rows under it */
|
||||
.group {
|
||||
grid-column: 1 / -1;
|
||||
padding: 7px 12px;
|
||||
border-top: 1px solid var(--infobox-rule);
|
||||
background-color: var(--infobox-head);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.yes {
|
||||
color: var(--q-positive, #02c39a);
|
||||
vertical-align: -3px;
|
||||
}
|
||||
|
||||
.no {
|
||||
color: var(--q-negative, #c10015);
|
||||
vertical-align: -3px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 10px 12px;
|
||||
color: var(--q-negative, #c10015);
|
||||
}
|
||||
|
||||
:host {
|
||||
--infobox-border: #d5d5d5;
|
||||
--infobox-bg: #f8f9fa;
|
||||
--infobox-head: #eaecf0;
|
||||
--infobox-rule: #e3e5e8;
|
||||
}
|
||||
:host-context(body.body--dark) {
|
||||
--infobox-border: rgba(255, 255, 255, 0.15);
|
||||
--infobox-bg: #161b22;
|
||||
--infobox-head: #1e232a;
|
||||
--infobox-rule: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
/**
|
||||
* Heading at the top of the box
|
||||
* @type {string}
|
||||
*/
|
||||
name: { type: String },
|
||||
|
||||
/**
|
||||
* Path or URL of a picture
|
||||
* @type {string}
|
||||
*/
|
||||
image: { type: String },
|
||||
|
||||
/**
|
||||
* Caption under the picture
|
||||
* @type {string}
|
||||
*/
|
||||
imageCaption: { type: String },
|
||||
|
||||
// Internal Properties
|
||||
_entries: { state: true },
|
||||
_error: { state: true }
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.name = ''
|
||||
this.image = ''
|
||||
this.imageCaption = ''
|
||||
this._entries = []
|
||||
this._error = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the facts out of the block's body.
|
||||
*
|
||||
* The body has been through markdown by the time it gets here, so what is left of `city: Montreal`
|
||||
* is its text — which is all YAML needs. Markdown does leave its mark: a value written with
|
||||
* emphasis or a link keeps the words and loses the markup, and anything markdown reads as
|
||||
* structure of its own (a line opening with `-`, `#` or `>`) arrives rearranged. A fenced code
|
||||
* block is the way out of that, since its contents reach here exactly as they were typed.
|
||||
*/
|
||||
/**
|
||||
* Hand the first line of the column back its place at the top.
|
||||
*
|
||||
* The content stylesheet drops the top margin of the first element in a page, because the space
|
||||
* above it belongs to the container. A floated infobox at the very top takes that reset with it and
|
||||
* leaves the heading behind it holding a full margin — so the heading, which is what a reader sees
|
||||
* as the start of the page, sits an inch below the box beside it. Passed on to whatever follows,
|
||||
* since that is the element the rule was written for.
|
||||
*
|
||||
* Two pixels rather than none: the box's own top margin and border sit in that space, and the two
|
||||
* together put the rule under a page title on the rule under the box's name — the line the eye
|
||||
* follows across from one to the other.
|
||||
*/
|
||||
_alignWithTop() {
|
||||
if (this.previousElementSibling) {
|
||||
return
|
||||
}
|
||||
this.nextElementSibling?.style.setProperty('margin-top', '2px')
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback()
|
||||
this._alignWithTop()
|
||||
const source = (this.querySelector('pre') ?? this).textContent ?? ''
|
||||
if (!source.trim()) {
|
||||
return
|
||||
}
|
||||
let parsed
|
||||
try {
|
||||
parsed = parseYaml(source)
|
||||
} catch (err) {
|
||||
// -> Naming the fence, because it is the answer nine times out of ten: markdown reads an
|
||||
// indented line as structure of its own and hands this the text without the indentation
|
||||
this._error = `This infobox could not be read: ${err.reason ?? err.message}. Anything indented — a list, or a nested group — has to go inside a fenced code block.`
|
||||
return
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
this._error = 'An infobox is a list of "key: value" lines.'
|
||||
return
|
||||
}
|
||||
this._entries = Object.entries(parsed)
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<aside class="infobox">
|
||||
<div class="name">${this.name}</div>
|
||||
${this.image
|
||||
? html`
|
||||
<figure>
|
||||
<img src="${this.image}" alt="${this.imageCaption || this.name}" />
|
||||
${this.imageCaption ? html`<figcaption>${this.imageCaption}</figcaption>` : null}
|
||||
</figure>
|
||||
`
|
||||
: null}
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : null}
|
||||
${this._entries.length > 0
|
||||
? html`
|
||||
<dl>
|
||||
${this._entries.map(([label, value]) => {
|
||||
const rows = rowsOf(value)
|
||||
const isGroup = rows.length > 1 || rows[0].label !== undefined
|
||||
return html`
|
||||
${isGroup ? html`<div class="group">${label}</div>` : null}
|
||||
${rows.map(
|
||||
(row) => html`
|
||||
<dt>${isGroup ? row.label : label}</dt>
|
||||
<dd>${valueOf(row.value)}</dd>
|
||||
`
|
||||
)}
|
||||
`
|
||||
})}
|
||||
</dl>
|
||||
`
|
||||
: null}
|
||||
</aside>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('block-infobox', BlockInfoboxElement)
|
||||
@ -0,0 +1,228 @@
|
||||
import { LitElement, html, css, unsafeCSS } from 'lit'
|
||||
// -> The ESM build by name: leaflet's `main` is still the UMD bundle, which rollup can only take
|
||||
// apart with a commonjs plugin, and it has no `exports` map to pick the module build for us
|
||||
import * as L from 'leaflet/dist/leaflet-src.esm.js'
|
||||
import leafletCss from 'leaflet/dist/leaflet.css'
|
||||
|
||||
/**
|
||||
* The marker, drawn rather than fetched.
|
||||
*
|
||||
* Leaflet's default icon is a pair of PNGs it builds a URL for at runtime, which does not survive
|
||||
* bundling — and an inline pin is one less request for a block that already asks for map tiles.
|
||||
*/
|
||||
const MARKER_SVG = `
|
||||
<svg viewBox="0 0 24 36" width="24" height="36" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 0C5.4 0 0 5.4 0 12c0 9 12 24 12 24s12-15 12-24c0-6.6-5.4-12-12-12z" fill="#c62828"/>
|
||||
<circle cx="12" cy="12" r="4.5" fill="#fff"/>
|
||||
</svg>
|
||||
`
|
||||
|
||||
/**
|
||||
* Block Map
|
||||
*/
|
||||
export class BlockMapElement 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: 'map',
|
||||
name: 'Map',
|
||||
description: 'Shows a location on an OpenStreetMap map.',
|
||||
icon: 'geography',
|
||||
props: [
|
||||
{
|
||||
name: 'lat',
|
||||
type: 'number',
|
||||
label: 'Latitude',
|
||||
hint: 'Decimal degrees, e.g. 45.5019.',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'lon',
|
||||
type: 'number',
|
||||
label: 'Longitude',
|
||||
hint: 'Decimal degrees, e.g. -73.5674.',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'zoom',
|
||||
type: 'number',
|
||||
label: 'Zoom',
|
||||
hint: '1 is the whole world, 19 is a single building.',
|
||||
default: 13
|
||||
},
|
||||
{
|
||||
name: 'height',
|
||||
type: 'number',
|
||||
label: 'Height',
|
||||
hint: 'Height of the map in pixels.',
|
||||
default: 400
|
||||
},
|
||||
{
|
||||
name: 'label',
|
||||
type: 'string',
|
||||
label: 'Marker Label',
|
||||
hint: 'Shown in a popup when the marker is clicked. The marker is drawn either way.'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
unsafeCSS(leafletCss),
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/*
|
||||
The gap below a block lives on this element, not on :host.
|
||||
|
||||
The app resets the margin on every element, and a rule in the page beats a :host rule in the
|
||||
shadow tree whatever its specificity -- so a margin set on the host is simply dropped. Set
|
||||
inside the shadow root it is out of that rule's reach, and collapses out through the host,
|
||||
which carries no padding or border of its own.
|
||||
*/
|
||||
.map,
|
||||
.error {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.map {
|
||||
width: 100%;
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background-color: #f2efe9;
|
||||
}
|
||||
:host-context(body.body--dark) .map {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
background-color: #16130f;
|
||||
}
|
||||
|
||||
/* -> The tiles are somebody else's work and the licence asks for the credit to be visible */
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--q-negative, #c10015);
|
||||
border: 1px dashed color-mix(in srgb, currentColor 50%, transparent);
|
||||
border-radius: 5px;
|
||||
padding: 1rem;
|
||||
}
|
||||
`
|
||||
]
|
||||
}
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
/**
|
||||
* Latitude in decimal degrees
|
||||
* @type {number}
|
||||
*/
|
||||
lat: { type: Number },
|
||||
|
||||
/**
|
||||
* Longitude in decimal degrees
|
||||
* @type {number}
|
||||
*/
|
||||
lon: { type: Number },
|
||||
|
||||
/**
|
||||
* Zoom level, 1 (world) to 19 (building)
|
||||
* @type {number}
|
||||
*/
|
||||
zoom: { type: Number },
|
||||
|
||||
/**
|
||||
* Height of the map in pixels
|
||||
* @type {number}
|
||||
*/
|
||||
height: { type: Number },
|
||||
|
||||
/**
|
||||
* Popup text for the marker
|
||||
* @type {string}
|
||||
*/
|
||||
label: { type: String },
|
||||
|
||||
// Internal Properties
|
||||
_error: { state: true }
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.lat = null
|
||||
this.lon = null
|
||||
this.zoom = 13
|
||||
this.height = 400
|
||||
this.label = ''
|
||||
this._error = ''
|
||||
this._map = null
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
const lat = Number(this.lat)
|
||||
const lon = Number(this.lon)
|
||||
if (
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lon) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
Math.abs(lon) > 180
|
||||
) {
|
||||
this._error =
|
||||
'This map needs a latitude between -90 and 90 and a longitude between -180 and 180.'
|
||||
return
|
||||
}
|
||||
|
||||
const container = this.renderRoot.querySelector('.map')
|
||||
this._map = L.map(container, {
|
||||
center: [lat, lon],
|
||||
zoom: Math.min(Math.max(Number(this.zoom) || 13, 1), 19),
|
||||
// -> A map in the middle of an article must not swallow the wheel while the reader is scrolling
|
||||
// past it. Clicking the map is the reader saying they meant to use it.
|
||||
scrollWheelZoom: false
|
||||
})
|
||||
this._map.on('click', () => this._map.scrollWheelZoom.enable())
|
||||
this._map.on('mouseout', () => this._map.scrollWheelZoom.disable())
|
||||
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).addTo(this._map)
|
||||
|
||||
const marker = L.marker([lat, lon], {
|
||||
icon: L.divIcon({
|
||||
html: MARKER_SVG,
|
||||
className: '',
|
||||
iconSize: [24, 36],
|
||||
iconAnchor: [12, 36],
|
||||
popupAnchor: [0, -32]
|
||||
}),
|
||||
// -> The map is a picture of a place, not a form: there is nothing to be gained by moving it
|
||||
keyboard: false
|
||||
}).addTo(this._map)
|
||||
if (this.label) {
|
||||
marker.bindPopup(this.label)
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback()
|
||||
// -> Leaflet keeps listeners on window and a resize observer, which outlive the element otherwise
|
||||
this._map?.remove()
|
||||
this._map = null
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this._error) {
|
||||
return html`<div class="error">${this._error}</div>`
|
||||
}
|
||||
return html`<div class="map" style="height: ${Number(this.height) || 400}px"></div>`
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('block-map', BlockMapElement)
|
||||
@ -0,0 +1,165 @@
|
||||
import { LitElement, html, css } from 'lit'
|
||||
import { unsafeSVG } from 'lit/directives/unsafe-svg.js'
|
||||
import { renderSVG } from 'uqr'
|
||||
|
||||
/**
|
||||
* Block QR Code
|
||||
*/
|
||||
export class BlockQrCodeElement 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: 'qr-code',
|
||||
name: 'QR Code',
|
||||
description: 'Shows a QR code for a link or a piece of text.',
|
||||
icon: 'scan-stock',
|
||||
props: [
|
||||
{
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
label: 'Content',
|
||||
hint: 'Text or URL to encode. The address of this page when left empty.'
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
type: 'number',
|
||||
label: 'Size',
|
||||
hint: 'Width of the code in pixels.',
|
||||
default: 180
|
||||
},
|
||||
{
|
||||
name: 'caption',
|
||||
type: 'string',
|
||||
label: 'Caption',
|
||||
hint: 'Shown under the code.'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* -> The gap below the block. On this element rather than :host: see block-index. */
|
||||
.qr {
|
||||
margin-bottom: 16px;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 5px;
|
||||
/*
|
||||
White in both themes, and padded: a code is read by a camera looking for dark squares on a
|
||||
light field, so inverting it for dark mode would make it harder to scan, not easier.
|
||||
*/
|
||||
background-color: #fff;
|
||||
}
|
||||
:host-context(body.body--dark) .qr {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* -> The drawing is sized here, so the box grows by its own padding rather than eating into it */
|
||||
.qr svg {
|
||||
display: block;
|
||||
width: var(--qr-size);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.caption {
|
||||
max-width: var(--qr-size);
|
||||
color: #424242;
|
||||
font-size: 0.8em;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--q-negative, #c10015);
|
||||
border: 1px dashed color-mix(in srgb, currentColor 50%, transparent);
|
||||
border-radius: 5px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
/**
|
||||
* Text or URL to encode
|
||||
* @type {string}
|
||||
*/
|
||||
value: { type: String },
|
||||
|
||||
/**
|
||||
* Width of the code in pixels
|
||||
* @type {number}
|
||||
*/
|
||||
size: { type: Number },
|
||||
|
||||
/**
|
||||
* Text shown under the code
|
||||
* @type {string}
|
||||
*/
|
||||
caption: { type: String },
|
||||
|
||||
// Internal Properties
|
||||
_svg: { state: true },
|
||||
_error: { state: true }
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.value = ''
|
||||
this.size = 180
|
||||
this.caption = ''
|
||||
this._svg = ''
|
||||
this._error = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* What the code stands for.
|
||||
*
|
||||
* An empty `value` means this page, which is the common case — a printed page, or a screen someone
|
||||
* wants to carry on their phone. Taken from the address bar rather than built from the site config,
|
||||
* so it is the URL the reader is actually looking at, and without the fragment, which points at a
|
||||
* place on the page rather than at the page.
|
||||
*/
|
||||
_encoded() {
|
||||
return this.value?.trim() || `${window.location.origin}${window.location.pathname}`
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback()
|
||||
try {
|
||||
// -> Drawn at a fixed scale and sized by CSS, so the same markup is crisp at any width
|
||||
this._svg = renderSVG(this._encoded(), { border: 1, pixelSize: 8 })
|
||||
} catch {
|
||||
// -> Every symbol size has a ceiling, and a long enough string clears the largest of them
|
||||
this._error = 'This is too long to fit in a QR code.'
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this._error) {
|
||||
return html`<div class="error">${this._error}</div>`
|
||||
}
|
||||
const size = `${Math.min(Math.max(Number(this.size) || 180, 80), 600)}px`
|
||||
return html`
|
||||
<div class="qr" style="--qr-size: ${size}">
|
||||
${unsafeSVG(this._svg)}
|
||||
${this.caption ? html`<div class="caption">${this.caption}</div>` : null}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('block-qr-code', BlockQrCodeElement)
|
||||
@ -0,0 +1,186 @@
|
||||
import { LitElement, html, css } from 'lit'
|
||||
|
||||
/** A crossed-out eye, drawn rather than fetched: it is the same picture on every spoiler there is. */
|
||||
const EYE_OFF_SVG = html`
|
||||
<svg viewBox="0 0 24 24" width="32" height="32" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M2 5.27 3.28 4 20 20.72 18.73 22l-3.08-3.08A11.4 11.4 0 0 1 12 19.5c-5 0-9.27-3.11-11-7.5a12.2 12.2 0 0 1 4.06-5.17zm10 3.23a3.5 3.5 0 0 1 3.5 3.5c0 .47-.1.92-.27 1.33l-4.56-4.56c.41-.17.86-.27 1.33-.27M12 4.5c5 0 9.27 3.11 11 7.5a12.1 12.1 0 0 1-3.19 4.53l-2.72-2.72c.26-.55.41-1.16.41-1.81a5.5 5.5 0 0 0-5.5-5.5c-.65 0-1.26.15-1.81.41L7.96 4.96A11.4 11.4 0 0 1 12 4.5M6.5 12a5.5 5.5 0 0 0 5.5 5.5c.42 0 .83-.05 1.22-.14l-6.58-6.58c-.09.39-.14.8-.14 1.22" />
|
||||
</svg>
|
||||
`
|
||||
|
||||
/**
|
||||
* Block Spoiler
|
||||
*/
|
||||
export class BlockSpoilerElement 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: 'spoiler',
|
||||
name: 'Spoiler',
|
||||
description: 'Hides content behind a cover until it is clicked.',
|
||||
icon: 'visualy-impaired',
|
||||
template: 'The content to hide.',
|
||||
props: [
|
||||
{
|
||||
name: 'label',
|
||||
type: 'string',
|
||||
label: 'Label',
|
||||
hint: 'Heading on the cover.',
|
||||
default: 'Spoiler'
|
||||
},
|
||||
{
|
||||
name: 'hint',
|
||||
type: 'string',
|
||||
label: 'Hint',
|
||||
hint: 'Line under the label.',
|
||||
default: 'Click to show content'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/*
|
||||
The content is laid out either way and only hidden from view, so the box is exactly as tall
|
||||
covered as it is revealed and nothing below it moves when a reader opens it. Hiding it by
|
||||
visibility is what does that: display:none would collapse the box, and a blur or a mask leaves
|
||||
the text on screen for anyone who looks closely enough at the pixels.
|
||||
*/
|
||||
.spoiler {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
min-height: 76px;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid var(--spoiler-border);
|
||||
border-radius: 6px;
|
||||
background-color: var(--spoiler-bg);
|
||||
}
|
||||
.spoiler.is-covered .content {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.cover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background-color: transparent;
|
||||
color: var(--spoiler-fg);
|
||||
font: inherit;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.cover:hover {
|
||||
background-color: var(--spoiler-hover);
|
||||
}
|
||||
.cover:focus-visible {
|
||||
outline: 2px solid var(--q-primary, #1976d2);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.8em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
:host {
|
||||
--spoiler-border: #e0e0e0;
|
||||
--spoiler-bg: #f5f5f5;
|
||||
--spoiler-fg: #424242;
|
||||
--spoiler-hover: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
:host-context(body.body--dark) {
|
||||
--spoiler-border: rgba(255, 255, 255, 0.15);
|
||||
--spoiler-bg: #161b22;
|
||||
--spoiler-fg: rgba(255, 255, 255, 0.75);
|
||||
--spoiler-hover: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
/**
|
||||
* Heading on the cover
|
||||
* @type {string}
|
||||
*/
|
||||
label: { type: String },
|
||||
|
||||
/**
|
||||
* Line under the label
|
||||
* @type {string}
|
||||
*/
|
||||
hint: { type: String },
|
||||
|
||||
// Internal Properties
|
||||
_covered: { state: true }
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.label = 'Spoiler'
|
||||
this.hint = 'Click to show content'
|
||||
this._covered = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the outermost margins of the content, the way `block-tabs` does: the box supplies the
|
||||
* padding, and a heading adding its own on top of it would push the cover's text off centre.
|
||||
*/
|
||||
_trimEdgeMargins() {
|
||||
this.firstElementChild?.style.setProperty('margin-top', '0')
|
||||
this.lastElementChild?.style.setProperty('margin-bottom', '0')
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback()
|
||||
this._trimEdgeMargins()
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="spoiler ${this._covered ? 'is-covered' : ''}">
|
||||
<div class="content"><slot></slot></div>
|
||||
${this._covered
|
||||
? html`
|
||||
<button
|
||||
type="button"
|
||||
class="cover"
|
||||
aria-expanded="false"
|
||||
@click="${() => {
|
||||
this._covered = false
|
||||
}}">
|
||||
${EYE_OFF_SVG}
|
||||
<span class="label">${this.label}</span>
|
||||
<span class="hint">${this.hint}</span>
|
||||
</button>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('block-spoiler', BlockSpoilerElement)
|
||||
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Block Tab
|
||||
*
|
||||
* One panel of a `block-tabs`. It draws nothing and knows nothing: the parent reads its `label` and
|
||||
* `icon`, builds the strip from them and shows or hides it. Its content is ordinary page content,
|
||||
* left in the light DOM so the article's own stylesheet reaches it.
|
||||
*
|
||||
* It is registered as an element of its own so that the page view, which fetches a component for
|
||||
* every undefined element it finds in a page, has something to fetch.
|
||||
*/
|
||||
export class BlockTabElement extends HTMLElement {
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* `isChild` keeps it out of both: a tab on its own is not something to insert into a page, and not
|
||||
* something to switch off separately from the tabs it belongs to. The definition is still what
|
||||
* lets the tag and its attributes survive being saved, which is the reason it is declared at all.
|
||||
*/
|
||||
static definition = {
|
||||
block: 'tab',
|
||||
name: 'Tab',
|
||||
description: 'One panel of a set of tabs.',
|
||||
icon: 'subtitles',
|
||||
isChild: true,
|
||||
props: [
|
||||
{
|
||||
name: 'label',
|
||||
type: 'string',
|
||||
label: 'Label',
|
||||
hint: 'What the tab is called in the strip.',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'icon',
|
||||
type: 'string',
|
||||
label: 'Icon',
|
||||
hint: 'Iconify reference drawn to the left of the label, e.g. mdi:language-python.'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
/*
|
||||
A box of its own, set inline because the app resets the display of everything in a page.
|
||||
|
||||
Only when nothing has been set already: the two components arrive in separate files and in
|
||||
either order, and whichever runs second must not undo the first. The parent hides the panels it
|
||||
is not showing, so overwriting that here would leave every panel on screen at once.
|
||||
|
||||
Visible rather than hidden by default, so a page whose tab strip never arrives is a page with
|
||||
all its content stacked up and readable, rather than a page with none of it.
|
||||
*/
|
||||
if (!this.style.display) {
|
||||
this.style.display = 'block'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('block-tab', BlockTabElement)
|
||||
@ -0,0 +1,359 @@
|
||||
import { LitElement, html, css } from 'lit'
|
||||
import { unsafeSVG } from 'lit/directives/unsafe-svg.js'
|
||||
|
||||
/**
|
||||
* Asked of a block that might be hiding the element the event was dispatched on.
|
||||
*
|
||||
* The app sends it at a heading before scrolling to it — see `helpers/anchors.js` — so that a heading
|
||||
* inside a panel that is not showing is opened rather than scrolled at. Matched by name only: a block
|
||||
* answers it or ignores it, and neither side has to know about the other.
|
||||
*/
|
||||
const REVEAL_EVENT = 'block-reveal'
|
||||
|
||||
/** Icons already fetched, by `prefix:name`, so a page of tabs asks for each one once. */
|
||||
const iconCache = new Map()
|
||||
|
||||
/**
|
||||
* Fetch an icon as inline SVG.
|
||||
*
|
||||
* Inline rather than an `<img>` so the drawing takes the colour of the tab it sits in — Iconify's
|
||||
* SVGs paint with `currentColor`, which an image cannot see. The instance serves them from its own
|
||||
* `/_icons`, cached hard, so this is a local request.
|
||||
*/
|
||||
async function fetchIcon(reference) {
|
||||
if (iconCache.has(reference)) {
|
||||
return iconCache.get(reference)
|
||||
}
|
||||
const [prefix, name] = reference.split(':')
|
||||
if (!prefix || !name) {
|
||||
return ''
|
||||
}
|
||||
const promise = fetch(`/_icons/${encodeURIComponent(prefix)}/${encodeURIComponent(name)}.svg`)
|
||||
.then((resp) => (resp.ok ? resp.text() : ''))
|
||||
.catch(() => '')
|
||||
iconCache.set(reference, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Block Tabs
|
||||
*/
|
||||
export class BlockTabsElement 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.
|
||||
*
|
||||
* `template` is the body the picker writes into the page along with the opening line. A block that
|
||||
* has one is fenced with `:::`, so that the `::block-tab` children inside it are read as blocks of
|
||||
* their own rather than as the end of this one.
|
||||
*/
|
||||
static definition = {
|
||||
block: 'tabs',
|
||||
name: 'Tabs',
|
||||
description: 'Groups content into tabbed panels.',
|
||||
icon: 'right-navigation-toolbar',
|
||||
template: `::block-tab{label="First tab"}
|
||||
Content of the first tab.
|
||||
::
|
||||
|
||||
::block-tab{label="Second tab"}
|
||||
Content of the second tab.
|
||||
::`
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/*
|
||||
One raised card: the border and the rounded corners belong to the outer box, and clipping to
|
||||
it is what rounds the strip's top corners and the panel's bottom ones without either of them
|
||||
having to know where it sits.
|
||||
|
||||
-> It also carries the gap below the block. On this element rather than :host: see block-index.
|
||||
*/
|
||||
.tabs {
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--tabs-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 1px 3px rgb(0 0 0 / 0.1),
|
||||
0 1px 2px rgb(0 0 0 / 0.06);
|
||||
}
|
||||
:host-context(body.body--dark) .tabs {
|
||||
box-shadow:
|
||||
0 1px 3px rgb(0 0 0 / 0.5),
|
||||
0 1px 2px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
|
||||
/*
|
||||
The whole row is the unselected surface, tabs and the space past the last one alike, so the
|
||||
gradient is drawn once here and the tabs sit on it rather than repeating it. The line along
|
||||
the bottom is the panel's top edge; the tabs are pulled down onto it so the active one can
|
||||
paint over its own stretch and open the seam into the panel.
|
||||
*/
|
||||
.strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-bottom: 1px solid var(--tabs-border);
|
||||
background-image: var(--tabs-strip-bg);
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: -1px;
|
||||
padding: 10px 18px;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--tabs-border);
|
||||
border-bottom: 1px solid transparent;
|
||||
border-top: 3px solid transparent;
|
||||
background-color: transparent;
|
||||
color: var(--tabs-inactive-fg);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
.tab:hover:not(.is-active) {
|
||||
background-color: rgb(255 255 255 / 0.5);
|
||||
color: var(--tabs-active-fg);
|
||||
}
|
||||
:host-context(body.body--dark) .tab:hover:not(.is-active) {
|
||||
background-color: rgb(255 255 255 / 0.05);
|
||||
}
|
||||
.tab:focus-visible {
|
||||
outline: 2px solid var(--tabs-active-fg);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
/* -> Flat panel colour, which is what lifts it out of the row's gradient */
|
||||
.tab.is-active {
|
||||
border-top-color: var(--tabs-active-fg);
|
||||
border-bottom-color: var(--tabs-panel-bg);
|
||||
background-color: var(--tabs-panel-bg);
|
||||
background-image: none;
|
||||
color: var(--tabs-active-fg);
|
||||
}
|
||||
|
||||
.tab svg {
|
||||
width: 1.15em;
|
||||
height: 1.15em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 16px 20px;
|
||||
background-color: var(--tabs-panel-bg);
|
||||
}
|
||||
|
||||
/* -> The panel owns the spacing, so the content inside it does not add its own at the edges */
|
||||
::slotted(block-tab) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:host {
|
||||
--tabs-border: #e0e0e0;
|
||||
--tabs-strip-bg: linear-gradient(to bottom, #fdfdfd, #eeeeee);
|
||||
--tabs-inactive-fg: #424242;
|
||||
--tabs-active-fg: var(--q-primary, #1976d2);
|
||||
--tabs-panel-bg: #fff;
|
||||
}
|
||||
:host-context(body.body--dark) {
|
||||
--tabs-border: rgba(255, 255, 255, 0.15);
|
||||
--tabs-strip-bg: linear-gradient(to bottom, #1b212a, #12161d);
|
||||
--tabs-inactive-fg: rgba(255, 255, 255, 0.7);
|
||||
--tabs-panel-bg: #1e232a;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
_tabs: { state: true },
|
||||
_active: { state: true }
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this._tabs = []
|
||||
this._active = 0
|
||||
// -> Bound once, so that removing the listener later takes the same function that was added
|
||||
this._onReveal = this._onReveal.bind(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the panels the page gave this block, and start showing the first.
|
||||
*
|
||||
* The panels stay in the light DOM, slotted in below the strip: their content is page content and
|
||||
* is styled by the article's own stylesheet, the way an included page is.
|
||||
*/
|
||||
_collectTabs() {
|
||||
const panels = [...this.querySelectorAll(':scope > block-tab')]
|
||||
this._tabs = panels.map((panel, index) => {
|
||||
this._trimEdgeMargins(panel)
|
||||
return {
|
||||
panel,
|
||||
label: panel.getAttribute('label') || `Tab ${index + 1}`,
|
||||
icon: panel.getAttribute('icon') || '',
|
||||
svg: ''
|
||||
}
|
||||
})
|
||||
this._showActive()
|
||||
this._loadIcons()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the outermost margins of a panel's content.
|
||||
*
|
||||
* The panel supplies the padding; the content adding its own on top of it leaves a gap under the
|
||||
* strip that reads as a mistake — a heading, whose margin is the largest of any element, most of
|
||||
* all. Set on the element rather than in the stylesheet because the content is slotted: it lives in
|
||||
* the page, styled by the page, and `::slotted()` reaches only the panel itself, never inside it.
|
||||
*/
|
||||
_trimEdgeMargins(panel) {
|
||||
panel.firstElementChild?.style.setProperty('margin-top', '0')
|
||||
panel.lastElementChild?.style.setProperty('margin-bottom', '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the strip on screen when something inside a panel is scrolled to.
|
||||
*
|
||||
* A heading carries a `scroll-margin-top` so it does not land flush against the top edge, but that
|
||||
* margin knows nothing about the strip standing above it — following a link to a heading in a tab
|
||||
* would scroll the tabs themselves out of view, leaving the reader in a panel with no way to see
|
||||
* which one they were in. Set on the elements because the content is slotted, and measured because
|
||||
* the strip is as tall as the labels wrapped onto however many rows.
|
||||
*/
|
||||
_applyScrollMargin() {
|
||||
const strip = this.renderRoot.querySelector('.strip')
|
||||
if (!strip) {
|
||||
return
|
||||
}
|
||||
const margin = `${strip.offsetHeight + 20}px`
|
||||
for (const { panel } of this._tabs) {
|
||||
for (const child of panel.children) {
|
||||
child.style.setProperty('scroll-margin-top', margin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_showActive() {
|
||||
this._tabs.forEach(({ panel }, index) => {
|
||||
panel.style.display = index === this._active ? 'block' : 'none'
|
||||
})
|
||||
}
|
||||
|
||||
async _loadIcons() {
|
||||
for (const tab of this._tabs.filter((t) => t.icon)) {
|
||||
tab.svg = await fetchIcon(tab.icon)
|
||||
this.requestUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
_select(index) {
|
||||
this._active = index
|
||||
this._showActive()
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the panel holding a given node, if it is one of these.
|
||||
*
|
||||
* Both ways in end up here: the app asking for a heading it is about to scroll to, and the reader
|
||||
* arriving on a URL whose fragment names a heading in a panel that is not the first.
|
||||
*/
|
||||
_reveal(node) {
|
||||
const index = this._tabs.findIndex(({ panel }) => panel.contains(node))
|
||||
if (index >= 0 && index !== this._active) {
|
||||
this._select(index)
|
||||
}
|
||||
return index >= 0
|
||||
}
|
||||
|
||||
_onReveal(event) {
|
||||
this._reveal(event.target)
|
||||
}
|
||||
|
||||
/** The panel holding the heading the URL points at, if the URL points at one. */
|
||||
_revealFromHash() {
|
||||
const id = decodeURIComponent(window.location.hash.replace(/^#/, ''))
|
||||
const target = id ? document.getElementById(id) : null
|
||||
if (target) {
|
||||
this._reveal(target)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Left and right walk the strip, as they do in every other set of tabs — the panels are a single
|
||||
* stop in the tab order, so the arrow keys are how a keyboard reaches the other ones.
|
||||
*/
|
||||
_onKeydown(event) {
|
||||
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
|
||||
if (!step) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const next = (this._active + step + this._tabs.length) % this._tabs.length
|
||||
this._select(next)
|
||||
this.renderRoot.querySelectorAll('.tab')[next]?.focus()
|
||||
}
|
||||
|
||||
updated() {
|
||||
this._applyScrollMargin()
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback()
|
||||
this._collectTabs()
|
||||
// -> On arrival, and again whenever the fragment changes under a reader using back and forward
|
||||
this._revealFromHash()
|
||||
this._onHashChange = () => this._revealFromHash()
|
||||
window.addEventListener('hashchange', this._onHashChange)
|
||||
this.addEventListener(REVEAL_EVENT, this._onReveal)
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback()
|
||||
window.removeEventListener('hashchange', this._onHashChange)
|
||||
this.removeEventListener(REVEAL_EVENT, this._onReveal)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this._tabs.length < 1) {
|
||||
return html`<slot></slot>`
|
||||
}
|
||||
return html`
|
||||
<div class="tabs">
|
||||
<div class="strip" role="tablist" @keydown="${this._onKeydown}">
|
||||
${this._tabs.map(
|
||||
(tab, index) => html`
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="tab ${index === this._active ? 'is-active' : ''}"
|
||||
aria-selected="${index === this._active}"
|
||||
tabindex="${index === this._active ? 0 : -1}"
|
||||
@click="${() => this._select(index)}">
|
||||
${tab.svg ? unsafeSVG(tab.svg) : null}${tab.label}
|
||||
</button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
<div class="panel" role="tabpanel"><slot></slot></div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('block-tabs', BlockTabsElement)
|
||||
@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<w-layout view="hHh lpR fFf" container>
|
||||
<w-header class="card-header px-4 py-2">
|
||||
<w-icon name="img:/_assets/icons/fluent-rfid-tag.svg" left size="md" />
|
||||
<span>{{ t('editor.blockPicker.title') }}</span>
|
||||
<w-space />
|
||||
<w-btn
|
||||
class="mr-2"
|
||||
flat
|
||||
rounded
|
||||
color="white"
|
||||
:aria-label="t(`common.actions.viewDocs`)"
|
||||
icon="la:question-circle"
|
||||
:href="siteStore.docsBase + `/editor/markdown`"
|
||||
target="_blank"
|
||||
type="a" />
|
||||
<w-btn-group push>
|
||||
<w-btn
|
||||
push
|
||||
color="white"
|
||||
text-color="grey-7"
|
||||
:label="t(`common.actions.cancel`)"
|
||||
:aria-label="t(`common.actions.cancel`)"
|
||||
icon="la:times"
|
||||
@click="close" />
|
||||
<w-btn
|
||||
push
|
||||
color="positive"
|
||||
text-color="white"
|
||||
:label="t(`editor.blockPicker.insert`)"
|
||||
:aria-label="t(`editor.blockPicker.insert`)"
|
||||
icon="la:check"
|
||||
:disabled="!canInsert"
|
||||
@click="insert" />
|
||||
</w-btn-group>
|
||||
</w-header>
|
||||
<w-page-container>
|
||||
<w-page class="block-picker flex flex-nowrap items-stretch">
|
||||
<!-- ----------------------- -->
|
||||
<!-- The blocks -->
|
||||
<!-- ----------------------- -->
|
||||
<div class="block-picker-catalog w-2/3">
|
||||
<w-scroll-area style="height: 100%">
|
||||
<div class="p-4">
|
||||
<w-inner-loading :showing="state.isLoading" size="32px" />
|
||||
<div
|
||||
v-if="!state.isLoading && blocks.length < 1"
|
||||
class="text-caption p-6 text-center text-black/60 dark:text-white/70">
|
||||
{{ t('editor.blockPicker.noBlocks') }}
|
||||
</div>
|
||||
<div class="block-picker-grid">
|
||||
<button
|
||||
v-for="block of blocks"
|
||||
:key="block.id"
|
||||
type="button"
|
||||
class="block-picker-card"
|
||||
:class="{ 'is-selected': state.selected?.id === block.id }"
|
||||
@click="select(block)">
|
||||
<w-icon
|
||||
:name="`img:/_assets/icons/ultraviolet-${block.isCustom ? 'plugin' : block.icon}.svg`"
|
||||
size="40px" />
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="text-body2">
|
||||
<strong>{{ block.name }}</strong>
|
||||
</div>
|
||||
<div class="text-caption opacity-70">{{ block.description }}</div>
|
||||
<div class="text-caption font-robotomono mt-1 opacity-60">
|
||||
<block-{{ block.block }}>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</w-scroll-area>
|
||||
</div>
|
||||
<w-separator vertical />
|
||||
<!-- ----------------------- -->
|
||||
<!-- Its properties -->
|
||||
<!-- ----------------------- -->
|
||||
<div class="block-picker-form w-1/3">
|
||||
<w-scroll-area style="height: 100%">
|
||||
<!-- A section header draws its own horizontal inset, so this pads vertically only -->
|
||||
<div class="py-4">
|
||||
<div
|
||||
v-if="!state.selected"
|
||||
class="text-caption p-6 text-center text-black/60 dark:text-white/70">
|
||||
{{ t('editor.blockPicker.selectHint') }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="w-section-header">{{ state.selected.name }}</div>
|
||||
<!--
|
||||
A block with nothing to fill in is not a broken form: it is inserted as it stands. A
|
||||
custom block reports no props at all, since only the compiled manifest carries them.
|
||||
-->
|
||||
<div
|
||||
v-if="state.selected.props.length < 1"
|
||||
class="text-caption mt-4 px-4 text-black/60 dark:text-white/70">
|
||||
{{ t('editor.blockPicker.noProps') }}
|
||||
</div>
|
||||
<w-form v-else class="gap-4 px-4 pt-4">
|
||||
<template v-for="prop of state.selected.props" :key="prop.name">
|
||||
<w-select
|
||||
v-if="prop.type === `select`"
|
||||
v-model="state.values[prop.name]"
|
||||
:options="prop.options ?? []"
|
||||
outlined
|
||||
dense
|
||||
options-dense
|
||||
:label="prop.label ?? prop.name"
|
||||
:aria-label="prop.label ?? prop.name"
|
||||
:required="prop.required"
|
||||
:hint="prop.hint" />
|
||||
<w-toggle
|
||||
v-else-if="prop.type === `boolean`"
|
||||
v-model="state.values[prop.name]"
|
||||
dense
|
||||
:label="prop.label ?? prop.name" />
|
||||
<w-input
|
||||
v-else
|
||||
v-model="state.values[prop.name]"
|
||||
outlined
|
||||
dense
|
||||
:type="prop.type === `number` ? `number` : `text`"
|
||||
:label="prop.label ?? prop.name"
|
||||
:aria-label="prop.label ?? prop.name"
|
||||
:required="prop.required"
|
||||
:hint="prop.hint" />
|
||||
</template>
|
||||
</w-form>
|
||||
<!-- -> The markup itself, since that is what lands in the page -->
|
||||
<div class="w-section-header mt-6">{{ t('editor.blockPicker.markdown') }}</div>
|
||||
<!-- The same 16px all round, so it sits inside the panel the way the fields do -->
|
||||
<pre class="block-picker-output m-4">{{ markdown }}</pre>
|
||||
</template>
|
||||
</div>
|
||||
</w-scroll-area>
|
||||
</div>
|
||||
</w-page>
|
||||
</w-page-container>
|
||||
</w-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { notify } from '@/composables/notify'
|
||||
import { blockMarkdown } from '@/helpers/blocks'
|
||||
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
/**
|
||||
* Picks a block and what to give it, and hands the editor the MDC markup for it.
|
||||
*
|
||||
* Only metadata is used here — the name, the icon, and the props the block declares. The component
|
||||
* itself is never imported: a block's code is fetched when its tag turns up in a page (see
|
||||
* `commonStore.loadBlocks`), and a picker that pulled in every block to show a list of them would
|
||||
* defeat that.
|
||||
*
|
||||
* `::block-name{prop="value"}` is MDC block syntax, which the renderer turns into
|
||||
* `<block-name prop="value">` — the element the component registers itself as.
|
||||
*/
|
||||
|
||||
// STORES
|
||||
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
blocks: [],
|
||||
selected: null,
|
||||
/** Field values for the selected block, by prop name. */
|
||||
values: {},
|
||||
isLoading: false
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
|
||||
/** Only blocks this site has switched on: the rest cannot render, so offering them is a trap. */
|
||||
const blocks = computed(() => state.blocks.filter((block) => block.isEnabled))
|
||||
|
||||
const markdown = computed(() => (state.selected ? blockMarkdown(state.selected, state.values) : ''))
|
||||
|
||||
const canInsert = computed(() => {
|
||||
if (!state.selected) {
|
||||
return false
|
||||
}
|
||||
// -> A required prop with nothing in it would insert a block that cannot draw anything
|
||||
return state.selected.props
|
||||
.filter((prop) => prop.required)
|
||||
.every((prop) => String(state.values[prop.name] ?? '').length > 0)
|
||||
})
|
||||
|
||||
// METHODS
|
||||
|
||||
function select(block) {
|
||||
state.selected = block
|
||||
// -> Started at the block's own defaults, so the form shows what it would do if left alone
|
||||
state.values = Object.fromEntries(block.props.map((prop) => [prop.name, prop.default ?? '']))
|
||||
}
|
||||
|
||||
function insert() {
|
||||
EVENT_BUS.emit('insertBlock', markdown.value)
|
||||
close()
|
||||
}
|
||||
|
||||
function close() {
|
||||
siteStore.$patch({ overlay: '' })
|
||||
}
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(async () => {
|
||||
state.isLoading = true
|
||||
try {
|
||||
state.blocks = (await API_CLIENT.get(`sites/${siteStore.id}/blocks`).json()) ?? []
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('editor.blockPicker.loadFailed'),
|
||||
caption: err.message
|
||||
})
|
||||
}
|
||||
state.isLoading = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.block-picker {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
|
||||
/*
|
||||
Nothing here sits on a `w-card`, and that is where the app's dark text colour comes from -- so the
|
||||
panels have to state it themselves or everything inheriting `color` stays black on a dark surface.
|
||||
*/
|
||||
@at-root .body--light & {
|
||||
color: $grey-9;
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/*
|
||||
In dark mode the catalog is the darkest surface in the pair, so the cards read as lifted off it,
|
||||
and the form is the lighter panel beside it. Stated outright rather than left to whatever sits
|
||||
behind the overlay, since the two panels are only legible relative to each other.
|
||||
*/
|
||||
&-catalog {
|
||||
height: 100%;
|
||||
|
||||
@at-root .body--dark & {
|
||||
background-color: $dark-6;
|
||||
}
|
||||
}
|
||||
|
||||
&-form {
|
||||
height: 100%;
|
||||
|
||||
@at-root .body--light & {
|
||||
background-color: $grey-1;
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
background-color: $dark-4;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Two columns at most, however wide the overlay gets: a card carries a name, a sentence and a tag
|
||||
name, so it reads better wide than tiled. The `max()` is what caps the count -- a track asking
|
||||
for half the row (less its share of the gap) can only ever fit twice -- while the 280px floor
|
||||
takes over on a panel too narrow for two of them and drops the grid to a single column.
|
||||
*/
|
||||
&-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(max(280px, calc(50% - 6px)), 1fr));
|
||||
}
|
||||
|
||||
/*
|
||||
-> A card is the whole hit target, so the icon and the text are both part of choosing it
|
||||
|
||||
It floats on its shadow rather than sitting in a border: deeper on hover, and ringed by a glow of
|
||||
the site's primary colour once picked. Selection is a shadow too, so nothing reflows as it moves
|
||||
between cards. Dark mode takes the raised surface `w-card` uses instead of staying white, which at
|
||||
this size would glare and would need its own text colour to stay readable.
|
||||
*/
|
||||
&-card {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background-color: #fff;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 1px 3px rgb(0 0 0 / 0.12),
|
||||
0 1px 2px rgb(0 0 0 / 0.06);
|
||||
transition: box-shadow 0.15s var(--ease-standard);
|
||||
|
||||
&:hover {
|
||||
box-shadow:
|
||||
0 5px 12px rgb(0 0 0 / 0.16),
|
||||
0 2px 4px rgb(0 0 0 / 0.08);
|
||||
}
|
||||
|
||||
&.is-selected,
|
||||
&.is-selected:hover {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--color-primary),
|
||||
0 0 14px 2px color-mix(in srgb, var(--color-primary) 45%, transparent);
|
||||
}
|
||||
|
||||
@at-root .body--dark & {
|
||||
background-color: $dark-3;
|
||||
box-shadow:
|
||||
0 1px 3px rgb(0 0 0 / 0.5),
|
||||
0 1px 2px rgb(0 0 0 / 0.35);
|
||||
|
||||
&:hover {
|
||||
box-shadow:
|
||||
0 5px 14px rgb(0 0 0 / 0.6),
|
||||
0 2px 5px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
&.is-selected,
|
||||
&.is-selected:hover {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--color-primary),
|
||||
0 0 16px 3px color-mix(in srgb, var(--color-primary) 55%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-output {
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Roboto Mono', Consolas, 'Liberation Mono', Courier, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
@at-root .body--light & {
|
||||
background-color: $grey-3;
|
||||
color: $grey-9;
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
background-color: $dark-6;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Getting to a heading inside a rendered page.
|
||||
*
|
||||
* Three things make this more than `scrollIntoView`. The render arrives after the browser has already
|
||||
* tried the fragment in the URL, so an anchor a reader followed from elsewhere lands nowhere; a
|
||||
* heading can sit inside a block that is not showing it — a tab that is not the open one — where it
|
||||
* has no box to scroll to; and the page goes on changing height for a while after it is drawn, as
|
||||
* each block fetches its component and settles into its real size.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Asked of a block that might be hiding the element the event was dispatched on.
|
||||
*
|
||||
* Bubbles and crosses shadow boundaries, so the block that answers is whichever one happens to be
|
||||
* above the heading: the app does not need to know which kinds of block can hide things, and a new
|
||||
* one only has to listen. `block-tabs` answers it by opening the panel the heading is in.
|
||||
*/
|
||||
export const REVEAL_EVENT = 'block-reveal'
|
||||
|
||||
/** How often the heading's position is sampled while waiting for the page to stop moving. */
|
||||
const SAMPLE_MS = 60
|
||||
|
||||
/** How many samples in a row must agree before the page counts as settled. */
|
||||
const STABLE_SAMPLES = 3
|
||||
|
||||
/** How long to wait for a smooth scroll to finish, where the browser cannot say when it has. */
|
||||
const SETTLE_MS = 1200
|
||||
|
||||
/** How far the heading may sit from where it was aimed before it is worth correcting, in pixels. */
|
||||
const DRIFT_TOLERANCE = 4
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
/** The heading a `#slug` refers to, or null. */
|
||||
export function anchorTarget(hash) {
|
||||
const id = decodeURIComponent(String(hash ?? '').replace(/^#/, ''))
|
||||
// -> `getElementById` rather than a selector, which would have to escape a slug that is not a
|
||||
// valid CSS identifier
|
||||
return id ? document.getElementById(id) : null
|
||||
}
|
||||
|
||||
/** Whether an element has a box on the page — false while it sits in a panel that is not showing. */
|
||||
function isVisible(el) {
|
||||
return Boolean(el.offsetParent ?? el.getClientRects().length)
|
||||
}
|
||||
|
||||
/** Ask whatever is above the element to bring it into view. */
|
||||
function reveal(el) {
|
||||
el.dispatchEvent(new CustomEvent(REVEAL_EVENT, { bubbles: true, composed: true }))
|
||||
}
|
||||
|
||||
/**
|
||||
* The box the element actually scrolls in.
|
||||
*
|
||||
* The article has its own scroller rather than the window — the shell stays put and the column moves
|
||||
* — so the position of the heading has to be read against that box, not the viewport.
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/** Where the heading sits in the document, independent of how far the page is scrolled. */
|
||||
function positionOf(el, scroller) {
|
||||
return Math.round(el.getBoundingClientRect().top + scroller.scrollTop)
|
||||
}
|
||||
|
||||
/** How far the heading is from where a scroll aiming at it would put it. */
|
||||
function driftOf(el, scroller) {
|
||||
const margin = Number.parseFloat(getComputedStyle(el).scrollMarginTop) || 0
|
||||
const wanted = scroller.getBoundingClientRect().top + margin
|
||||
return el.getBoundingClientRect().top - wanted
|
||||
}
|
||||
|
||||
function scrollTo(el, smooth) {
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
el.scrollIntoView({ behavior: smooth && !reduceMotion ? 'smooth' : 'auto', block: 'start' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the heading stops moving.
|
||||
*
|
||||
* Blocks land after the page is drawn and change its height as they do — a set of tabs is at its
|
||||
* tallest before its component arrives, with every panel stacked up, and collapses to one when it
|
||||
* does. Scrolling into that leaves the reader somewhere below the heading they asked for, so this
|
||||
* waits for the page to hold still before aiming at anything.
|
||||
*/
|
||||
async function whenStill(el, scroller, deadline) {
|
||||
let previous = null
|
||||
let agreed = 0
|
||||
while (performance.now() < deadline) {
|
||||
const position = positionOf(el, scroller)
|
||||
agreed = position === previous ? agreed + 1 : 0
|
||||
if (agreed >= STABLE_SAMPLES) {
|
||||
return
|
||||
}
|
||||
previous = position
|
||||
await delay(SAMPLE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait for a scroll to come to rest, by the event where there is one and by the clock where not. */
|
||||
function whenScrollEnded(scroller) {
|
||||
if (!('onscrollend' in window)) {
|
||||
return delay(SETTLE_MS)
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const done = () => {
|
||||
clearTimeout(timer)
|
||||
scroller.removeEventListener('scrollend', done)
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(done, SETTLE_MS)
|
||||
scroller.addEventListener('scrollend', done, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll a heading into view, asking whatever is above it to reveal it first.
|
||||
*
|
||||
* For a page that is already settled — a click on the contents list, say. See
|
||||
* `scrollToAnchorWhenReady` for one that has only just been rendered.
|
||||
*
|
||||
* @returns Whether there was a heading to scroll to
|
||||
*/
|
||||
export function scrollToAnchor(hash, { smooth = false } = {}) {
|
||||
const target = anchorTarget(hash)
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
reveal(target)
|
||||
if (!isVisible(target)) {
|
||||
return false
|
||||
}
|
||||
scrollTo(target, smooth)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The same, for a page that has only just been rendered: wait for the heading, then for the page to
|
||||
* settle, then animate to it — and check afterwards, in case something arrived late enough to move
|
||||
* it while the scroll was under way.
|
||||
*
|
||||
* Animated rather than jumped, so a reader who followed a link into the middle of a long page sees
|
||||
* where they were taken instead of being asked to work out where the top went.
|
||||
*/
|
||||
export async function scrollToAnchorWhenReady(hash, { timeout = 5000 } = {}) {
|
||||
if (!hash) {
|
||||
return
|
||||
}
|
||||
const deadline = performance.now() + timeout
|
||||
|
||||
// -> The heading itself may not exist yet: a block fetches its component, and an included page its
|
||||
// content, after the page around them is drawn
|
||||
let target = anchorTarget(hash)
|
||||
while (performance.now() < deadline) {
|
||||
if (target) {
|
||||
reveal(target)
|
||||
if (isVisible(target)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
await delay(SAMPLE_MS)
|
||||
target = anchorTarget(hash)
|
||||
}
|
||||
if (!target || !isVisible(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
const scroller = scrollerOf(target)
|
||||
await whenStill(target, scroller, deadline)
|
||||
scrollTo(target, true)
|
||||
|
||||
// -> One correction, without animation: the reader has already watched the page travel, and what
|
||||
// is left is a few pixels of something that loaded on the way
|
||||
await whenScrollEnded(scroller)
|
||||
if (Math.abs(driftOf(target, scroller)) > DRIFT_TOLERANCE && isVisible(target)) {
|
||||
scrollTo(target, false)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* The MDC markup for a block, as the editor writes it into a page.
|
||||
*
|
||||
* Shared rather than living in the block picker, because the picker is not the only way a block gets
|
||||
* inserted — the toolbar has a shortcut for the tabset, which has to produce exactly what picking
|
||||
* Tabs from the list would have produced.
|
||||
*
|
||||
* `::block-name{prop="value"}` is what the renderer turns into `<block-name prop="value">`, the
|
||||
* element the component registers itself as.
|
||||
*
|
||||
* @param {{ block: string, props?: Array, template?: string }} block A block as the API describes it.
|
||||
* @param {Record<string, unknown>} [values] What the author filled in, by prop name.
|
||||
* @returns {string} The markup, opening and closing lines included.
|
||||
*/
|
||||
export function blockMarkdown(block, values = {}) {
|
||||
const attributes = (block.props ?? [])
|
||||
.filter((prop) => {
|
||||
/*
|
||||
Only what is worth writing out: anything given a value that is not already the block's own
|
||||
default. A block reading its default from its own code does not need to be told it in every
|
||||
page.
|
||||
*/
|
||||
const value = values[prop.name]
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return false
|
||||
}
|
||||
return String(value) !== String(prop.default ?? '')
|
||||
})
|
||||
// -> A double quote in a value would close the attribute; MDC has no escape for it, so it goes
|
||||
.map((prop) => `${prop.name}="${String(values[prop.name]).replaceAll('"', "'")}"`)
|
||||
.join(' ')
|
||||
const suffix = attributes ? `{${attributes}}` : ''
|
||||
|
||||
/*
|
||||
A block that comes with a body to start from writes it between the two lines. One holding blocks
|
||||
of its own is fenced with three colons rather than two, since against a two-colon fence the first
|
||||
`::` inside it would read as the end of this one.
|
||||
*/
|
||||
if (block.template) {
|
||||
const fence = /^::/m.test(block.template) ? ':::' : '::'
|
||||
return `${fence}block-${block.block}${suffix}\n${block.template}\n${fence}`
|
||||
}
|
||||
return `::block-${block.block}${suffix}\n::`
|
||||
}
|
||||
Loading…
Reference in new issue