feat: add block-links-grid content block

This commit is contained in:
NGPixel committed 2026-10-04 23:10:24 -04:00
1 parent dd8f8ee712
commit 381f4c9df7
5 files changed
+614 -31

No files matched your search

+3 -29
View File
@@ -1,16 +1,8 @@
import { LitElement, html, css } from 'lit'
import { resolveFilePath } from '../shared/files.js'
import { DarkMode } from '../shared/theme.js'
/** Where an uploaded file is served from, and so what a bare path in the body is taken to mean. */
const FILES_PREFIX = '/_files/'
/**
* An address that already says where it points: a full URL, a protocol-relative one, a data URI —
* or one of the wiki's own `/_` routes, `/_files/` among them.
*/
const ABSOLUTE = /^(?:[a-z][a-z0-9+.-]*:|\/\/|\/_)/i
/**
* An attribute that means "off" when it says so.
*
@@ -33,24 +25,6 @@ const ICONS = {
'M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z'
}
/**
* The address a line of the body points at.
*
* Anything that names its own location is left exactly as written. Everything else is a path into the
* file manager, which is where the images on a wiki page live — so `photos/summer.jpg` and
* `/photos/summer.jpg` both mean `/_files/photos/summer.jpg`, and an author can paste the path the
* file manager shows without having to remember the prefix. Wiki routes are spared that: they all
* start with `/_`, and `/_files/` is one of them, so a path already carrying the prefix is not given
* a second one.
*/
function resolveSource(value) {
const address = value.trim()
if (ABSOLUTE.test(address)) {
return address
}
return FILES_PREFIX + address.replace(/^\/+/, '')
}
/**
* The box the block actually scrolls in.
*
@@ -416,10 +390,10 @@ https://example.com/photo-2.jpg`
firstUpdated() {
const fence = this.querySelector('pre')
const source = ((fence ?? this).textContent ?? '').trim()
const found = source.split(/\s+/).filter(Boolean).map(resolveSource)
const found = source.split(/\s+/).filter(Boolean).map(resolveFilePath)
if (!fence) {
for (const image of this.querySelectorAll('img')) {
found.push(resolveSource(image.getAttribute('src') ?? ''))
found.push(resolveFilePath(image.getAttribute('src') ?? ''))
}
}
// -> An address written once and drawn twice -- as a link and as the image it points at -- is one
+7 -2
View File
@@ -210,8 +210,13 @@ export class BlockIndexElement extends LitElement {
li a {
display: flex;
color: var(--q-primary);
/* -> Vertical only: the horizontal inset is what the arrow's own offset is set against */
padding: 0.75rem 1rem;
/*
-> The right inset clears the arrow, which is positioned out of the flow and so takes no
room in the row on its own: its 16px offset, its 32px width, and the same 14px gap the
icon keeps on the left. Without it a long title or description runs under the arrow
instead of wrapping before it.
*/
padding: 0.75rem calc(16px + 32px + 14px) 0.75rem 1rem;
text-decoration: none;
flex: 1;
flex-direction: row;
+567
View File
@@ -0,0 +1,567 @@
import { LitElement, html, css, nothing } from 'lit'
import { styleMap } from 'lit/directives/style-map.js'
import { unsafeSVG } from 'lit/directives/unsafe-svg.js'
import { load as parseYaml } from 'js-yaml'
import { resolveFilePath } from '../shared/files.js'
import { fetchIcon, iconImageUrl } from '../shared/icons.js'
import { DarkMode } from '../shared/theme.js'
/**
* The accent a card's `color` may name: Tailwind's palette at shade 500, copied from
* `tailwindcss/theme.css`.
*
* Copied rather than read off `--color-*-500`, because Tailwind only emits the theme variables the
* app's own stylesheet uses — most of these are not declared on any page, and a name that resolved
* on one build and not the next is worse than a list that is fixed.
*/
const COLORS = {
red: 'oklch(63.7% 0.237 25.331)',
orange: 'oklch(70.5% 0.213 47.604)',
amber: 'oklch(76.9% 0.188 70.08)',
yellow: 'oklch(79.5% 0.184 86.047)',
lime: 'oklch(76.8% 0.233 130.85)',
green: 'oklch(72.3% 0.219 149.579)',
emerald: 'oklch(69.6% 0.17 162.48)',
teal: 'oklch(70.4% 0.14 182.503)',
cyan: 'oklch(71.5% 0.143 215.221)',
sky: 'oklch(68.5% 0.169 237.323)',
blue: 'oklch(62.3% 0.214 259.815)',
indigo: 'oklch(58.5% 0.233 277.117)',
violet: 'oklch(60.6% 0.25 292.717)',
purple: 'oklch(62.7% 0.265 303.9)',
fuchsia: 'oklch(66.7% 0.295 322.15)',
pink: 'oklch(65.6% 0.241 354.308)',
rose: 'oklch(64.5% 0.246 16.439)',
slate: 'oklch(55.4% 0.046 257.417)',
gray: 'oklch(55.1% 0.027 264.364)',
zinc: 'oklch(55.2% 0.016 285.938)',
neutral: 'oklch(55.6% 0 none)',
stone: 'oklch(55.3% 0.013 58.071)',
mauve: 'oklch(54.2% 0.034 322.5)',
olive: 'oklch(58% 0.031 107.3)',
mist: 'oklch(56% 0.021 213.5)',
taupe: 'oklch(54.7% 0.021 43.1)'
}
/**
* The schemes a card may link to.
*
* A closed list, because the body is text that anybody who may edit the page can write, and an href
* is somewhere a script can hide: `javascript:` on a card would be a way round `write:scripts`. The
* scheme is read off the parsed URL rather than off the text, since the URL parser drops tabs and
* newlines from the middle of one — `java script:` is `javascript:` by the time a browser follows it.
*/
const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:'])
/**
* What a card's `url` becomes as an href, or null for one it may not link to.
*
* Relative addresses are resolved against the page the block is on, as any link in it would be.
*/
function hrefOf(value) {
const text = String(value ?? '').trim()
if (!text) {
return null
}
let url
try {
url = new URL(text, globalThis.location?.href)
} catch {
return null
}
return SAFE_PROTOCOLS.has(url.protocol) ? text : null
}
/**
* Whether an href is a page of this wiki, and so the router's to open rather than the browser's.
*
* The wiki's own `/_` routes are server endpoints — a file under `/_files/` is a download, not a page
* — so those load for real.
*/
function isWikiPage(href) {
return href.startsWith('/') && !href.startsWith('//') && !href.startsWith('/_')
}
/** A YAML value as text: an empty string for one left out, and a number or a boolean spelled out. */
function textOf(value) {
return value === undefined || value === null ? '' : String(value)
}
/** A card's `tags`: a list, or a single tag written as a string. Empty entries are dropped. */
function tagsOf(value) {
const list = Array.isArray(value) ? value : [value]
return list.map((tag) => textOf(tag).trim()).filter(Boolean)
}
/**
* Block Links Grid
*
* A grid of cards, each one a link, listed as YAML in the block's body:
*
* ::block-links-grid
* ```yaml
* - title: Page A
* url: /page-a
* ```
* ::
*/
export class BlockLinksGridElement 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: 'links-grid',
name: 'Links Grid',
description: 'A custom grid of cards, each linking to a page or an external URL.',
icon: 'grid',
template: `\`\`\`yaml
- title: Getting Started
url: /getting-started
description: Everything you need on your first day.
icon: 'mdi:rocket-launch-outline'
color: blue
- title: Guides
url: /guides
description: Step-by-step instructions for common tasks.
icon: 'mdi:book-open-variant'
color: amber
tags:
- How-to
- Reference
\`\`\``,
/*
The body is a source an author types, edited as one: YAML on the left, this block on the right.
As for block-infobox, it is also the only way to edit the block in the Visual editor, which
parses a block whose body is a single fence as an atom.
*/
contentEditor: 'code',
props: [
{
name: 'minWidth',
type: 'string',
label: 'Min Card Width',
hint: 'Narrowest a card may be, as a CSS length. The grid fits as many as the width allows.',
default: '300px'
},
{
name: 'maxWidth',
type: 'string',
label: 'Max Card Width',
hint: 'Widest a card may be, as a CSS length. 1fr shares the leftover space between cards.',
default: '1fr'
},
{
name: 'maxHeight',
type: 'string',
label: 'Max Card Height',
hint: 'Tallest a card may be, as a CSS length. A picture is cropped shorter to fit; the writing under it is not. Empty means no limit.'
}
]
}
static get styles() {
return css`
:host {
display: block;
}
/*
The gap below the block lives here rather than on :host, which the app's margin reset in the
page would beat. See block-index.
The columns themselves come from the props, as an inline style on this element.
*/
ul {
display: grid;
gap: 0.75rem;
padding: 0;
margin: 0 0 16px;
list-style: none;
}
/*
Drawn as block-index draws a row: the same ground, the same hairline right and bottom, the
same shadow. Clipped so a picture at the top takes the card's rounded corners.
*/
/* -> The cap comes from the maxHeight prop, set on the list by render() */
li {
display: flex;
max-height: var(--card-max-height, none);
overflow: hidden;
background-color: #fafafa;
background-image: linear-gradient(to bottom, #fff, #fafafa);
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: 5px;
box-shadow: 0 3px 8px 0 rgba(116, 129, 141, 0.1);
transition: box-shadow 0.15s ease;
}
:host([dark]) li {
background-color: #222;
background-image: linear-gradient(to bottom, #161b22, #0d1117);
border-color: rgba(0, 0, 0, 0.5);
box-shadow: 0 3px 8px 0 rgba(0, 0, 0, 0.25);
}
/*
-> The width block-index gives its left border, in the colour the card asked for. Repeated
under :host([dark]), whose border-color above outranks a bare li.has-accent and would
otherwise paint the accent over in the dark theme's hairline.
*/
li.has-accent,
:host([dark]) li.has-accent {
border-left: 5px solid var(--card-accent);
}
li:hover {
box-shadow: 0 4px 14px 0 rgba(116, 129, 141, 0.25);
}
:host([dark]) li:hover {
background-image: linear-gradient(to bottom, #1e232a, #161b22);
box-shadow: 0 4px 14px 0 rgba(0, 0, 0, 0.45);
}
/* -> The whole card is the link, so everything below is laid out inside the anchor */
a {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
color: var(--q-primary);
text-decoration: none;
}
a:focus-visible {
outline: 2px solid var(--q-primary);
outline-offset: -2px;
border-radius: 5px;
}
/*
Cropped to fill, at a fixed shape so a row of cards carrying pictures of different sizes
still lines up. On a card with nothing but the picture it grows to fill the card instead,
which is as tall as whatever else shares its row.
The picture is what gives way to a card's max height, and the writing is not: it shrinks
(min-height: 0 is what lets an image with an aspect ratio go below the height that ratio
gives it) while .body holds its own height, so a capped card still shows its title.
*/
.cover {
display: block;
flex: 0 1 auto;
min-height: 0;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
li.is-image-only .cover {
flex: 1 1 auto;
}
/*
A grid rather than a row, for the sake of the tags: they sit under the description and line
up with its left edge, but the icon is centred on the title and description alone. So the
icon and the writing share the first row, and the tags take the second row of the writing's
column, which the icon does not span. A card without an icon has the one column.
align-content centres the rows as a group when the card is stretched taller than they are,
which is what the row of a flex box used to do.
*/
.body {
display: grid;
flex: 1 0 auto;
grid-template-columns: minmax(0, 1fr);
align-items: center;
align-content: center;
column-gap: 14px;
padding: 0.75rem 1rem;
}
.body.has-icon {
grid-template-columns: auto minmax(0, 1fr);
}
.body > .text,
.body > .tags {
grid-column: -2;
}
/*
Sized and spaced as block-index's, so the two blocks side by side read as one family. Drawn in
the card's accent when it has one, so the icon and the border beside it are the same colour,
and in the link's primary otherwise. An Iconify SVG paints with currentColor; an img: icon
keeps its own colours.
*/
.icon {
display: flex;
flex: none;
align-items: center;
width: 1.75em;
color: var(--card-accent, var(--q-primary));
}
.icon svg,
.icon img {
width: 1.75em;
height: 1.75em;
}
.text {
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
min-width: 0;
}
.title {
font-weight: 500;
overflow-wrap: anywhere;
}
/* -> The lightened brand shade on a dark card, as block-index does. See its note on .text */
:host([dark]) .title {
color: var(--color-primary-light);
}
.description {
color: #666;
font-size: 0.8em;
overflow-wrap: anywhere;
}
:host([dark]) .description {
color: rgba(255, 255, 255, 0.6);
}
/*
The card's accent, or primary on a card without one, as the icon is. The writing is the
accent darkened in the light theme and lightened in the dark one: a shade 500 on a pale wash
of itself is too faint to read for the lighter hues, amber and yellow above all.
*/
.tags {
--badge: var(--card-accent, var(--q-primary));
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.tags span {
padding: 1px 8px;
border: 1px solid color-mix(in oklch, var(--badge) 35%, transparent);
border-radius: 999px;
background-color: color-mix(in oklch, var(--badge) 12%, transparent);
color: color-mix(in oklch, var(--badge) 70%, black);
font-size: 0.7em;
font-weight: 500;
line-height: 1.6;
white-space: nowrap;
}
:host([dark]) .tags span {
background-color: color-mix(in oklch, var(--badge) 20%, transparent);
color: color-mix(in oklch, var(--badge) 60%, white);
}
.error {
margin-bottom: 16px;
color: var(--q-negative);
border: 1px dashed color-mix(in srgb, currentColor 50%, transparent);
border-radius: 5px;
padding: 1rem;
}
`
}
static get properties() {
return {
/**
* Narrowest a card may be, as a CSS length
* @type {string}
*/
minWidth: { type: String },
/**
* Widest a card may be, as a CSS length or a fraction
* @type {string}
*/
maxWidth: { type: String },
/**
* Tallest a card may be, as a CSS length, or empty for no limit
* @type {string}
*/
maxHeight: { type: String },
// Internal Properties
_cards: { state: true },
_error: { state: true }
}
}
constructor() {
super()
this.minWidth = '300px'
this.maxWidth = '1fr'
this.maxHeight = ''
this._cards = []
this._error = ''
// -> Puts `dark` on this element for the styles above to key off
this._darkMode = new DarkMode(this)
}
/**
* Read the cards out of the block's body.
*
* A fenced code block is the way to write it: markdown reads a line opening with `-` as a list of
* its own and hands this the text with the indentation gone, which is no longer the YAML that was
* typed. The text of the body is tried anyway, so a single flat card written without a fence works.
*/
connectedCallback() {
super.connectedCallback()
const source = (this.querySelector('pre') ?? this).textContent ?? ''
if (!source.trim()) {
return
}
let parsed
try {
parsed = parseYaml(source)
} catch (err) {
this._error = `These cards could not be read: ${err.reason ?? err.message}. The list has to go inside a fenced code block.`
return
}
if (!Array.isArray(parsed)) {
this._error = 'A links grid is a list of cards, each one starting with "- url:".'
return
}
const cards = []
const refused = []
parsed.forEach((entry, index) => {
const href = entry && typeof entry === 'object' ? hrefOf(entry.url) : null
if (!href) {
refused.push(index + 1)
return
}
cards.push({
href,
title: textOf(entry.title),
description: textOf(entry.description),
// -> A bare path is a file from the file manager, as it is in block-gallery
image: entry.image ? resolveFilePath(textOf(entry.image)) : '',
icon: textOf(entry.icon),
tags: tagsOf(entry.tags),
accent: COLORS[textOf(entry.color).trim().toLowerCase()] ?? null
})
})
// -> Named by position, since a card with no usable url may well have nothing else to go by
if (refused.length > 0) {
this._error = `${refused.length > 1 ? 'Cards' : 'Card'} ${refused.join(', ')} ${refused.length > 1 ? 'have' : 'has'} no url, or one that is not a page, a web address, an email address or a phone number.`
}
this._cards = cards
this._loadIcons()
}
/**
* Fetch the icons the cards carry, all at once. The shared cache collapses repeats, and a failure
* is an empty string — a card whose icon could not be had is drawn without one.
*/
async _loadIcons() {
await Promise.all(
this._cards.map(async (card) => {
if (card.icon && !iconImageUrl(card.icon)) {
card.svg = await fetchIcon(card.icon)
}
})
)
// -> The cards were mutated rather than replaced, which Lit has no way of noticing on its own
this.requestUpdate()
}
_icon(card) {
const image = iconImageUrl(card.icon)
return html`<span class="icon">
${image ? html`<img src="${image}" alt="" />` : card.svg ? unsafeSVG(card.svg) : null}
</span>`
}
/*
-> A card that is only a picture has no words for its link, so it is named by where it goes. One
with writing is named by that writing, which is what a screen reader then reads, description
included.
*/
_card(card) {
const hasText = card.title || card.description
const hasBody = hasText || card.icon || card.tags.length > 0
const classes = [
card.accent ? 'has-accent' : '',
card.image && !hasBody ? 'is-image-only' : ''
].join(' ')
return html`<li
class="${classes}"
style="${styleMap(card.accent ? { '--card-accent': card.accent } : {})}">
<a
href="${card.href}"
aria-label="${hasBody ? nothing : card.href}"
@click="${this._navigate}">
${card.image ? html`<img class="cover" src="${card.image}" alt="" />` : null}
${
hasBody
? html`<div class="body ${card.icon ? 'has-icon' : ''}">
${card.icon ? this._icon(card) : null}
${
hasText
? html`<div class="text">
${card.title ? html`<span class="title">${card.title}</span>` : null}
${
card.description
? html`<span class="description">${card.description}</span>`
: null
}
</div>`
: null
}
${
card.tags.length > 0
? html`<div class="tags">
${card.tags.map((tag) => html`<span>${tag}</span>`)}
</div>`
: null
}
</div>`
: null
}
</a>
</li>`
}
render() {
/*
min() on the narrow end, so a column never asks for more than the block has: a 300px minimum on
a 280px phone would otherwise push the card out past the edge of the page.
*/
const columns = `repeat(auto-fit, minmax(min(${this.minWidth || '300px'}, 100%), ${this.maxWidth || '1fr'}))`
return html`
${this._error ? html`<div class="error">${this._error}</div>` : null}
${
this._cards.length > 0
? html`<ul
style="${styleMap({
gridTemplateColumns: columns,
// -> Left off entirely when empty, so the rule falls back to no limit
'--card-max-height': this.maxHeight || undefined
})}">
${this._cards.map((card) => this._card(card))}
</ul>`
: null
}
`
}
/**
* A page of this wiki opens through the router, as a link in the article would. Anything else —
* another site, a file, a click with a modifier asking for a new tab — is left to the browser.
*/
_navigate(e) {
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return
}
const href = e.currentTarget.getAttribute('href')
if (!isWikiPage(href)) {
return
}
e.preventDefault()
WIKI_ROUTER.push(href)
}
}
window.customElements.define('block-links-grid', BlockLinksGridElement)
+36
View File
@@ -0,0 +1,36 @@
/**
* File addresses, for blocks.
*
* Shared because more than one block takes a picture by its path, and an author who learned to write
* one in one block should not have to learn it again in the next.
*/
/** Where an uploaded file is served from, and so what a bare path is taken to mean. */
const FILES_PREFIX = '/_files/'
/**
* An address that already says where it points: a full URL, a protocol-relative one, a data URI —
* or one of the wiki's own `/_` routes, `/_files/` among them.
*/
const ABSOLUTE = /^(?:[a-z][a-z0-9+.-]*:|\/\/|\/_)/i
/**
* The address a path written into a block points at.
*
* Anything that names its own location is left exactly as written. Everything else is a path into the
* file manager, which is where the images on a wiki page live — so `photos/summer.jpg` and
* `/photos/summer.jpg` both mean `/_files/photos/summer.jpg`, and an author can paste the path the
* file manager shows without having to remember the prefix. Wiki routes are spared that: they all
* start with `/_`, and `/_files/` is one of them, so a path already carrying the prefix is not given
* a second one.
*
* @param {string} value
* @returns {string}
*/
export function resolveFilePath(value) {
const address = value.trim()
if (ABSOLUTE.test(address)) {
return address
}
return FILES_PREFIX + address.replace(/^\/+/, '')
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#dff0fe" d="M6,35.5c-1.379,0-2.5-1.121-2.5-2.5V6c0-1.379,1.121-2.5,2.5-2.5h27c1.379,0,2.5,1.121,2.5,2.5v27 c0,1.379-1.121,2.5-2.5,2.5H6z"/><path fill="#4788c7" d="M33,4c1.103,0,2,0.897,2,2v27c0,1.103-0.897,2-2,2H6c-1.103,0-2-0.897-2-2V6c0-1.103,0.897-2,2-2 H33 M33,3H6C4.343,3,3,4.343,3,6v27c0,1.657,1.343,3,3,3h27c1.657,0,3-1.343,3-3V6C36,4.343,34.657,3,33,3L33,3z"/><path fill="#98ccfd" d="M9,13.5c-0.827,0-1.5-0.673-1.5-1.5V9c0-0.827,0.673-1.5,1.5-1.5h3c0.827,0,1.5,0.673,1.5,1.5v3 c0,0.827-0.673,1.5-1.5,1.5H9z"/><path fill="#4788c7" d="M12,8c0.551,0,1,0.449,1,1v3c0,0.551-0.449,1-1,1H9c-0.551,0-1-0.449-1-1V9c0-0.551,0.449-1,1-1H12 M12,7H9C7.895,7,7,7.895,7,9v3c0,1.105,0.895,2,2,2h3c1.105,0,2-0.895,2-2V9C14,7.895,13.105,7,12,7L12,7z"/><path fill="#98ccfd" d="M18,13.5c-0.827,0-1.5-0.673-1.5-1.5V9c0-0.827,0.673-1.5,1.5-1.5h3c0.827,0,1.5,0.673,1.5,1.5v3 c0,0.827-0.673,1.5-1.5,1.5H18z"/><path fill="#4788c7" d="M21,8c0.551,0,1,0.449,1,1v3c0,0.551-0.449,1-1,1h-3c-0.551,0-1-0.449-1-1V9c0-0.551,0.449-1,1-1 H21 M21,7h-3c-1.105,0-2,0.895-2,2v3c0,1.105,0.895,2,2,2h3c1.105,0,2-0.895,2-2V9C23,7.895,22.105,7,21,7L21,7z"/><path fill="#98ccfd" d="M27,13.5c-0.827,0-1.5-0.673-1.5-1.5V9c0-0.827,0.673-1.5,1.5-1.5h3c0.827,0,1.5,0.673,1.5,1.5v3 c0,0.827-0.673,1.5-1.5,1.5H27z"/><path fill="#4788c7" d="M30,8c0.551,0,1,0.449,1,1v3c0,0.551-0.449,1-1,1h-3c-0.551,0-1-0.449-1-1V9c0-0.551,0.449-1,1-1 H30 M30,7h-3c-1.105,0-2,0.895-2,2v3c0,1.105,0.895,2,2,2h3c1.105,0,2-0.895,2-2V9C32,7.895,31.105,7,30,7L30,7z"/><path fill="#98ccfd" d="M9,22.5c-0.827,0-1.5-0.673-1.5-1.5v-3c0-0.827,0.673-1.5,1.5-1.5h3c0.827,0,1.5,0.673,1.5,1.5v3 c0,0.827-0.673,1.5-1.5,1.5H9z"/><path fill="#4788c7" d="M12,17c0.551,0,1,0.449,1,1v3c0,0.551-0.449,1-1,1H9c-0.551,0-1-0.449-1-1v-3c0-0.551,0.449-1,1-1 H12 M12,16H9c-1.105,0-2,0.895-2,2v3c0,1.105,0.895,2,2,2h3c1.105,0,2-0.895,2-2v-3C14,16.895,13.105,16,12,16L12,16z"/><g><path fill="#98ccfd" d="M18,22.5c-0.827,0-1.5-0.673-1.5-1.5v-3c0-0.827,0.673-1.5,1.5-1.5h3c0.827,0,1.5,0.673,1.5,1.5v3 c0,0.827-0.673,1.5-1.5,1.5H18z"/><path fill="#4788c7" d="M21,17c0.551,0,1,0.449,1,1v3c0,0.551-0.449,1-1,1h-3c-0.551,0-1-0.449-1-1v-3c0-0.551,0.449-1,1-1 H21 M21,16h-3c-1.105,0-2,0.895-2,2v3c0,1.105,0.895,2,2,2h3c1.105,0,2-0.895,2-2v-3C23,16.895,22.105,16,21,16L21,16z"/></g><g><path fill="#98ccfd" d="M27,22.5c-0.827,0-1.5-0.673-1.5-1.5v-3c0-0.827,0.673-1.5,1.5-1.5h3c0.827,0,1.5,0.673,1.5,1.5v3 c0,0.827-0.673,1.5-1.5,1.5H27z"/><path fill="#4788c7" d="M30,17c0.551,0,1,0.449,1,1v3c0,0.551-0.449,1-1,1h-3c-0.551,0-1-0.449-1-1v-3c0-0.551,0.449-1,1-1 H30 M30,16h-3c-1.105,0-2,0.895-2,2v3c0,1.105,0.895,2,2,2h3c1.105,0,2-0.895,2-2v-3C32,16.895,31.105,16,30,16L30,16z"/></g><g><path fill="#98ccfd" d="M9,31.5c-0.827,0-1.5-0.673-1.5-1.5v-3c0-0.827,0.673-1.5,1.5-1.5h3c0.827,0,1.5,0.673,1.5,1.5v3 c0,0.827-0.673,1.5-1.5,1.5H9z"/><path fill="#4788c7" d="M12,26c0.551,0,1,0.449,1,1v3c0,0.551-0.449,1-1,1H9c-0.551,0-1-0.449-1-1v-3c0-0.551,0.449-1,1-1 H12 M12,25H9c-1.105,0-2,0.895-2,2v3c0,1.105,0.895,2,2,2h3c1.105,0,2-0.895,2-2v-3C14,25.895,13.105,25,12,25L12,25z"/></g><g><path fill="#98ccfd" d="M18,31.5c-0.827,0-1.5-0.673-1.5-1.5v-3c0-0.827,0.673-1.5,1.5-1.5h3c0.827,0,1.5,0.673,1.5,1.5v3 c0,0.827-0.673,1.5-1.5,1.5H18z"/><path fill="#4788c7" d="M21,26c0.551,0,1,0.449,1,1v3c0,0.551-0.449,1-1,1h-3c-0.551,0-1-0.449-1-1v-3c0-0.551,0.449-1,1-1 H21 M21,25h-3c-1.105,0-2,0.895-2,2v3c0,1.105,0.895,2,2,2h3c1.105,0,2-0.895,2-2v-3C23,25.895,22.105,25,21,25L21,25z"/></g><g><path fill="#98ccfd" d="M27,31.5c-0.827,0-1.5-0.673-1.5-1.5v-3c0-0.827,0.673-1.5,1.5-1.5h3c0.827,0,1.5,0.673,1.5,1.5v3 c0,0.827-0.673,1.5-1.5,1.5H27z"/><path fill="#4788c7" d="M30,26c0.551,0,1,0.449,1,1v3c0,0.551-0.449,1-1,1h-3c-0.551,0-1-0.449-1-1v-3c0-0.551,0.449-1,1-1 H30 M30,25h-3c-1.105,0-2,0.895-2,2v3c0,1.105,0.895,2,2,2h3c1.105,0,2-0.895,2-2v-3C32,25.895,31.105,25,30,25L30,25z"/></g></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB