feat: block-index show icons option

scarlett
NGPixel 4 weeks ago
parent 1aefb34d92
commit 9a1ca3b135
No known key found for this signature in database

@ -1,6 +1,30 @@
import { LitElement, html, css } from 'lit'
import { unsafeSVG } from 'lit/directives/unsafe-svg.js'
import { fetchIcon, iconImageUrl } from '../shared/icons.js'
import { DarkMode } from '../shared/theme.js'
/**
* 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 `showIcons="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)
}
}
/**
* What to draw for a page carrying no icon of its own.
*
* The same one the app gives a new page (`DEFAULT_PAGE_ICON` in the page store), so that a listing
* mixing pages made in the editor with pages made through the API still lines up down the left.
*/
const DEFAULT_PAGE_ICON = 'mdi:file-document-outline'
/**
* Block Index
*/
@ -69,6 +93,14 @@ export class BlockIndexElement extends LitElement {
hint: 'How many folders below the path to include. 0 is the folder itself.',
default: 0
},
{
name: 'showIcons',
type: 'boolean',
label: 'Show Icons',
hint: "Draw each page's icon to the left of its title.",
// -> Stated, so that a toggle switched on and then off again writes nothing into the page
default: false
},
{
name: 'noResultMsg',
type: 'string',
@ -140,23 +172,60 @@ export class BlockIndexElement extends LitElement {
background-image: linear-gradient(to bottom,#1e232a, #161b22);
border-left-color: var(--q-primary);
}
/*
-> The row runs across rather than down, so an icon can sit beside the writing rather than
above it. The title and its description stack inside .text, which is the column the
anchor itself used to be.
*/
li a {
display: flex;
color: var(--q-primary);
padding: 1rem;
/* -> Vertical only: the horizontal inset is what the arrow's own offset is set against */
padding: 0.75rem 1rem;
text-decoration: none;
flex: 1;
flex-direction: row;
align-items: center;
gap: 14px;
position: relative;
}
.text {
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
/* -> The row less the icon. min-width is what lets a long title wrap inside the card
rather than pushing the row wider than it. */
flex: 1;
min-width: 0;
}
li a > span {
.text span {
display: block;
color: #666;
font-size: .8em;
font-weight: normal;
pointer-events: none;
}
/*
The page's own icon. Sized in em so it keeps its place beside writing at whatever size the
article is set in, and left to take the anchor's colour: an Iconify SVG paints with
currentColor, which is the whole reason it is inlined rather than pointed at with an an <img>.
*/
/*
-> The width is on the slot as well as on the drawing, so a row whose icon could not be had
keeps its place in the column rather than sliding its writing left of every other row's.
*/
.icon {
display: flex;
align-items: center;
flex: none;
width: 1.75em;
}
.icon svg,
.icon img {
width: 1.75em;
height: 1.75em;
}
li a > svg {
width: 32px;
position: absolute;
@ -227,6 +296,12 @@ export class BlockIndexElement extends LitElement {
*/
noResultMsg: { type: String },
/**
* Whether each page's icon is drawn beside its title
* @type {boolean}
*/
showIcons: boolean,
// Internal Properties
_loading: { state: true },
_pages: { state: true }
@ -244,6 +319,7 @@ export class BlockIndexElement extends LitElement {
this.orderByDirection = 'asc'
this.depth = 0
this.noResultMsg = 'No pages matching your query.'
this.showIcons = false
// -> Puts `dark` on this element for the styles above to key off
this._darkMode = new DarkMode(this)
}
@ -265,12 +341,45 @@ export class BlockIndexElement extends LitElement {
}
}).json()
this._pages = pages.map((p) => ({ ...p, href: `/${p.path}` }))
if (this.showIcons) {
await this._loadIcons()
}
} catch (err) {
console.warn(err)
}
this._loading = false
}
/**
* Fetch the icons the listing is about to draw.
*
* All of them at once rather than one after another, since the shared cache collapses the repeats:
* a listing of pages that never had an icon chosen for them is one request for the default, however
* many rows there are. An `img:` icon is a file to point at and needs nothing fetched.
*
* Failures are already an empty string, so a row whose icon could not be had is a row without one.
*/
async _loadIcons() {
await Promise.all(
this._pages.map(async (page) => {
const reference = page.icon || DEFAULT_PAGE_ICON
if (!iconImageUrl(reference)) {
page.svg = await fetchIcon(reference)
}
})
)
// -> The pages were mutated rather than replaced, which Lit has no way of noticing on its own
this.requestUpdate()
}
/** One page's icon: an inlined SVG, or an `<img>` for a reference that names a file. */
_icon(page) {
const image = iconImageUrl(page.icon || DEFAULT_PAGE_ICON)
return html`<span class="icon">
${image ? html`<img src="${image}" alt="" />` : page.svg ? unsafeSVG(page.svg) : null}
</span>`
}
render() {
return this._pages.length > 0 || this._loading
? html`
@ -279,7 +388,10 @@ export class BlockIndexElement extends LitElement {
(p) =>
html`<li>
<a href="${p.href}" @click="${this._navigate}">
${p.title} ${p.description ? html`<span>${p.description}</span>` : null}
${this.showIcons ? this._icon(p) : null}
<div class="text">
${p.title} ${p.description ? html`<span>${p.description}</span>` : null}
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
@ -296,9 +408,15 @@ export class BlockIndexElement extends LitElement {
: html` <div class="no-links">${this.noResultMsg}</div> `
}
/*
-> `currentTarget` is the anchor the handler is bound to; `target` is whatever was clicked, which
is the anchor only for a click that landed on the title. The rest of the row got there by
being marked `pointer-events: none`, one declaration at a time -- an icon is one more thing
inside the anchor, and asking the element it was bound to is what makes that unnecessary.
*/
_navigate(e) {
e.preventDefault()
WIKI_ROUTER.push(e.target.getAttribute('href'))
WIKI_ROUTER.push(e.currentTarget.getAttribute('href'))
}
// createRenderRoot() {

@ -1,5 +1,6 @@
import { LitElement, html, css } from 'lit'
import { unsafeSVG } from 'lit/directives/unsafe-svg.js'
import { fetchIcon } from '../shared/icons.js'
import { DarkMode } from '../shared/theme.js'
/**
@ -11,31 +12,6 @@ import { DarkMode } from '../shared/theme.js'
*/
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
*/

@ -0,0 +1,56 @@
/**
* Icons, for blocks.
*
* A block draws an icon from the same reference the rest of the app uses `mdi:account-edit` and
* gets it from this instance's own `/_icons`, which serves the part of the Iconify API protocol the
* frontend speaks. Nothing here reaches Iconify itself: the server is what decides whether an icon
* can be had, and an instance that is offline still answers for every icon it has been asked for
* before.
*
* Shared because more than one block needs it, and one cache across all of them means a page whose
* every row carries the same icon asks for it once.
*/
/** Icons already fetched, by `prefix:name`. Holds the promise, so concurrent callers share a request. */
const iconCache = new Map()
/**
* Fetch an icon as inline SVG.
*
* Inline rather than an `<img>` so the drawing takes the colour of whatever 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.
*
* An empty string for anything that is not a `prefix:name` reference, an icon the server will not
* serve, or a request that failed: a missing icon is a row without one, not a row that breaks.
*
* @param {string} reference An Iconify reference, e.g. `mdi:home`.
* @returns {Promise<string>} The SVG markup, or an empty string.
*/
export 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
}
/**
* The address an `img:` reference points at, or null for one that is not an image.
*
* The icon picker's other tab hands back `img:/_assets/icons/…`, which is a file to point an `<img>`
* at rather than an icon to resolve so it is the caller's to draw, and its colour is its own.
*
* @param {string} reference
* @returns {string|null}
*/
export function iconImageUrl(reference) {
return reference.startsWith('img:') ? reference.slice(4) : null
}

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#98ccfd" d="M20,38.5c-4.395-0.001-10.279-0.106-12.53-0.298c-3.679-0.324-3.795-4.391-3.917-8.696 c-0.016-0.533-0.03-1.062-0.053-1.58c0-2.354,0.233-5.083,0.608-7.1c0.715-3.026,2.36-3.078,5.348-3.172 c0.447-0.014,0.916-0.029,1.403-0.056c0.004,0,0.273-0.004,0.557-0.01c1.171-0.027,3.913-0.089,8.482-0.089 c0,0,0.177,0.001,0.211,0.001c4.602,0,7.313,0.062,8.475,0.088c0.307,0.007,0.5,0.011,0.584,0.011 c0.46,0.026,0.929,0.041,1.376,0.055c2.987,0.094,4.633,0.146,5.353,3.195c0.37,1.993,0.604,4.722,0.604,7.098 c-0.022,0.496-0.037,1.025-0.053,1.558c-0.122,4.305-0.238,8.372-3.918,8.696C30.279,38.394,24.395,38.499,20,38.5L20,38.5z"/><path fill="#4788c7" d="M19.886,18h0.05h0.001l0.076,0.001L20.063,18h0.044c4.56,0,7.296,0.062,8.465,0.089 c0.281,0.006,0.469,0.01,0.566,0.011c0.482,0.026,0.946,0.041,1.389,0.055c2.918,0.092,4.25,0.133,4.872,2.764 c0.37,1.99,0.6,4.684,0.601,6.986c-0.023,0.521-0.038,1.053-0.053,1.589c-0.137,4.787-0.452,7.945-3.46,8.21 C30.25,37.895,24.384,37.999,20,38c-4.384-0.001-10.251-0.105-12.485-0.296c-3.011-0.266-3.326-3.423-3.463-8.21 C4.037,28.958,4.022,28.425,4,27.948c0-2.346,0.23-5.039,0.591-6.983c0.631-2.677,1.963-2.719,4.882-2.81 c0.443-0.014,0.907-0.029,1.389-0.055c0.097-0.001,0.285-0.005,0.566-0.011C12.597,18.062,15.335,18,19.886,18 M20.102,17 c-0.036,0-0.067,0.001-0.102,0.001S19.933,17,19.898,17c-5.772,0-8.671,0.1-9.066,0.1c-3.711,0.204-6.286-0.3-7.214,3.635 C3.205,22.953,3,25.78,3,27.948C3.205,32.646,2.796,38.291,7.427,38.7C9.791,38.902,15.926,38.999,20,39 c4.074-0.001,10.209-0.098,12.573-0.3c4.631-0.408,4.222-6.054,4.427-10.752c0-2.168-0.205-4.994-0.617-7.212 c-0.928-3.936-3.503-3.432-7.214-3.635C28.773,17.1,25.874,17,20.102,17L20.102,17z"/><path fill="#fff" d="M19,35h-2v-1.375C16.75,34,16.125,34.999,14.791,35C14.125,35,13,34.5,13,33v-9h2v8.625 c0,0.23,0.018,0.632,0.75,0.625c0.813-0.008,1.25-1,1.25-1V24h2V35z"/><polygon fill="#fff" points="12,23 10,23 10,35 8,35 8,23 6,23 6,21 12,21"/><path fill="#fff" d="M27,26.56c0-0.863-0.273-1.448-0.622-1.889C26.032,24.23,25.522,24,24.873,24 c-0.325,0-0.649,0.09-0.973,0.255c-0.325,0.162-0.67,0.445-0.9,0.805V21h-2v14h2v-1.375c0.386,0.451,0.949,1.386,2,1.375 c1.5,0,2-1.375,2-2.5V26.56z M25,32.023C25,32.575,24.552,33,24,33s-0.991-0.212-1-0.5v-5.94c0.009-0.503,0.448-1,1-1 s1,0.439,1,1.44V32.023z"/><path fill="#fff" d="M34,30v-3.116C34,25.886,33.5,24,31,24c-2.375,0-2.997,1.98-2.997,2.884v4.708 c0,1.023,0.278,1.813,0.808,2.392c0.535,0.579,1.276,0.858,2.227,0.858c1.038,0,1.83-0.258,2.338-0.815 C33.912,33.494,34,32.685,34,31.592V31h-2v0.5c0,0.603-0.059,1.066-0.199,1.231C31.663,32.915,31.406,32.996,31,33 c-0.367,0.004-0.646-0.098-0.784-0.328C30.075,32.463,30,32.103,30,31.546V30H34z M30,27c0.009-0.537,0.448-1,1-1s1,0.385,1,1v1h-2 V27z"/><polygon fill="#4788c7" points="15,1 13,6.6 11,1 9,1 12,9.4 12,15 14,15 14,9.4 17,1"/><path fill="#4788c7" d="M28,4.5v7.75c0,0-0.437,0.992-1.25,1C26.018,13.257,26,12.855,26,12.625V4.5h-2V13 c0,1.5,1.125,2,1.791,2c1.334,0,1.959-1,2.209-1.375v1.25h2V4.5H28z"/><path fill="#4788c7" d="M19.75,15c-1.654,0-3-1.374-3-3.063v-4.5c0-1.688,1.346-3.063,3-3.063s3,1.374,3,3.063v4.5 C22.75,13.626,21.404,15,19.75,15z M19.75,6.25c-0.552,0-1,0.495-1,1.104v4.667c0,0.609,0.448,1.104,1,1.104s1-0.495,1-1.104V7.354 C20.75,6.745,20.302,6.25,19.75,6.25z"/></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

Loading…
Cancel
Save