feat: insert link, code, table, emoji dialogs

scarlett
NGPixel 1 month ago
parent c10e988c3a
commit 9408accc00
No known key found for this signature in database

@ -150,7 +150,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: 'Search pages', summary: 'Search pages',
description: description:
'Postgres full-text search over the pages of a site, ranked by relevance. `query` may be left out, in which case the filters alone decide the results — which is what a search for nothing but tags is.\n\nReadable without a session, for the same reason reading a page is: an anonymous request only matches published pages. Drafts are included only for someone who may write pages, and password-protected pages only for someone who may edit them, since a result carries an excerpt of the page text. A page marked as not searchable never appears, whoever is asking.\n\n`highlight` is an excerpt with the matched terms wrapped in `<b>`, and is the only field carrying markup — the excerpt is escaped before those are added. It is absent unless term highlighting is enabled in the search settings.', 'Postgres full-text search over the pages of a site, ranked by relevance. `query` may be left out, in which case the filters alone decide the results — which is what a search for nothing but tags is.\n\nReadable without a session, for the same reason reading a page is: an anonymous request only matches published pages. Drafts are included only for someone who may write pages. A page marked as not searchable never appears, whoever is asking.\n\nA password-protected page is listed like any other — its title and description are not what the password covers — but for a searcher who would have to enter that password it can only be matched on those two, never on the text behind the lock, and it comes back with no `highlight`.\n\n`highlight` is an excerpt with the matched terms wrapped in `<b>`, and is the only field carrying markup — the excerpt is escaped before those are added. It is absent unless term highlighting is enabled in the search settings.',
tags: ['Pages'], tags: ['Pages'],
params: siteIdParam, params: siteIdParam,
querystring: { querystring: {
@ -262,9 +262,10 @@ async function routes(app: FastifyInstance) {
includeDrafts: ['write:pages', 'manage:pages', 'manage:system'].some((p) => includeDrafts: ['write:pages', 'manage:pages', 'manage:system'].some((p) =>
permissions.includes(p) permissions.includes(p)
), ),
// -> Same rule as the page view: a protected page's text is for whoever holds the password, // -> Same rule as the page view: a protected page's text is for whoever holds the password, and
// and a search excerpt is that text // a search excerpt is that text. Its title and description are not covered, so the page is
hideProtected: !mayBypassPassword(req) // still listed — see `hideProtectedContent`.
hideProtectedContent: !mayBypassPassword(req)
}) })
} }
) )

@ -1513,8 +1513,6 @@
"common.page.locked": "This page is password protected.", "common.page.locked": "This page is password protected.",
"common.page.lockedHint": "Enter the password to read it.", "common.page.lockedHint": "Enter the password to read it.",
"common.page.lockedWrongPassword": "That password is not correct.", "common.page.lockedWrongPassword": "That password is not correct.",
"common.page.unlock": "Unlock",
"common.page.unlockTitle": "Unlock this page",
"common.page.printFormat": "Print Format", "common.page.printFormat": "Print Format",
"common.page.private": "Private", "common.page.private": "Private",
"common.page.published": "Published", "common.page.published": "Published",
@ -1524,6 +1522,8 @@
"common.page.tags": "Tags", "common.page.tags": "Tags",
"common.page.tagsMatching": "Pages matching tags", "common.page.tagsMatching": "Pages matching tags",
"common.page.toc": "Table of Contents", "common.page.toc": "Table of Contents",
"common.page.unlock": "Unlock",
"common.page.unlockTitle": "Unlock this page",
"common.page.unpublished": "Unpublished", "common.page.unpublished": "Unpublished",
"common.page.unpublishedWarning": "This page is not published.", "common.page.unpublishedWarning": "This page is not published.",
"common.page.versionId": "Version ID {id}", "common.page.versionId": "Version ID {id}",
@ -1590,6 +1590,8 @@
"editor.assets.uploadFailed": "File upload failed.", "editor.assets.uploadFailed": "File upload failed.",
"editor.backToEditor": "Back to Editor", "editor.backToEditor": "Back to Editor",
"editor.ckeditor.stats": "{chars} chars, {words} words", "editor.ckeditor.stats": "{chars} chars, {words} words",
"editor.codeBlock.filter": "Filter languages...",
"editor.codeBlock.noResults": "No language matches that.",
"editor.conflict.editable": "(editable)", "editor.conflict.editable": "(editable)",
"editor.conflict.infoGeneric": "A more recent version of this page was saved by {authorName}, {date}", "editor.conflict.infoGeneric": "A more recent version of this page was saved by {authorName}, {date}",
"editor.conflict.leftPanelInfo": "Your current edit, based on page version from {date}", "editor.conflict.leftPanelInfo": "Your current edit, based on page version from {date}",
@ -1613,6 +1615,20 @@
"editor.conflict.whatToDoLocal": "Use your current local version and ignore the latest changes.", "editor.conflict.whatToDoLocal": "Use your current local version and ignore the latest changes.",
"editor.conflict.whatToDoRemote": "Use the remote version (latest) and discard your changes.", "editor.conflict.whatToDoRemote": "Use the remote version (latest) and discard your changes.",
"editor.createPage": "Create Page", "editor.createPage": "Create Page",
"editor.emoji.activities": "Activities",
"editor.emoji.animalsNature": "Animals & Nature",
"editor.emoji.flags": "Flags",
"editor.emoji.foodDrink": "Food & Drink",
"editor.emoji.frequentlyUsed": "Frequently Used",
"editor.emoji.noResults": "No emoji matches that.",
"editor.emoji.objects": "Objects",
"editor.emoji.peopleBody": "People & Body",
"editor.emoji.pick": "Pick an emoji...",
"editor.emoji.results": "Results",
"editor.emoji.search": "Search",
"editor.emoji.smileysEmotion": "Smileys & Emotion",
"editor.emoji.symbols": "Symbols",
"editor.emoji.travelPlaces": "Travel & Places",
"editor.markup.admonitionDanger": "Danger / Important Admonition", "editor.markup.admonitionDanger": "Danger / Important Admonition",
"editor.markup.admonitionInfo": "Info / Note Admonition", "editor.markup.admonitionInfo": "Info / Note Admonition",
"editor.markup.admonitionSuccess": "Tip / Success Admonition", "editor.markup.admonitionSuccess": "Tip / Success Admonition",
@ -1767,6 +1783,18 @@
"editor.settings.markdownFontSizeHint": "The font size to use in the editor.", "editor.settings.markdownFontSizeHint": "The font size to use in the editor.",
"editor.settings.markdownPreviewShown": "Display Render Preview", "editor.settings.markdownPreviewShown": "Display Render Preview",
"editor.settings.markdownPreviewShownHint": "Whether to display a preview of the rendered content.", "editor.settings.markdownPreviewShownHint": "Whether to display a preview of the rendered content.",
"editor.tableEditor.addColumn": "Add Column",
"editor.tableEditor.addRow": "Add Row",
"editor.tableEditor.align": "Alignment",
"editor.tableEditor.alignCenter": "Center",
"editor.tableEditor.alignLeft": "Left",
"editor.tableEditor.alignRight": "Right",
"editor.tableEditor.bodyCell": "Row {row}, column {column}",
"editor.tableEditor.headerCell": "Heading of column {column}",
"editor.tableEditor.markdown": "Markdown",
"editor.tableEditor.pasteHint": "Paste a table from a spreadsheet into any cell to fill the grid.",
"editor.tableEditor.removeColumn": "Remove Column",
"editor.tableEditor.removeRow": "Remove Row",
"editor.tableEditor.title": "Table Editor", "editor.tableEditor.title": "Table Editor",
"editor.togglePreviewPane": "Toggle Preview Pane", "editor.togglePreviewPane": "Toggle Preview Pane",
"editor.toggleScrollSync": "Toggle Scroll Sync", "editor.toggleScrollSync": "Toggle Scroll Sync",
@ -1890,6 +1918,14 @@
"iconPicker.selection": "Selected icon", "iconPicker.selection": "Selected icon",
"iconPicker.set": "Set", "iconPicker.set": "Set",
"iconPicker.setsFailed": "Failed to load the icon sets.", "iconPicker.setsFailed": "Failed to load the icon sets.",
"linkPicker.emptyFolder": "There are no pages in this folder.",
"linkPicker.linkUrl": "Link URL",
"linkPicker.loadFailed": "Failed to load the page tree.",
"linkPicker.openInNewTab": "Open in a new tab",
"linkPicker.page": "Page",
"linkPicker.selection": "Link target",
"linkPicker.title": "Insert Link",
"linkPicker.url": "URL",
"navEdit.clearItems": "Clear All Items", "navEdit.clearItems": "Clear All Items",
"navEdit.editMenuItems": "Edit Menu Items", "navEdit.editMenuItems": "Edit Menu Items",
"navEdit.emptyMenuText": "Click the Add button to add your first menu item.", "navEdit.emptyMenuText": "Click the Add button to add your first menu item.",

@ -91,10 +91,11 @@ export interface SearchPagesParams {
/** Whether unpublished pages belong in the results, which is an editor's view of the wiki. */ /** Whether unpublished pages belong in the results, which is an editor's view of the wiki. */
includeDrafts?: boolean includeDrafts?: boolean
/** /**
* Leave out password-protected pages. Set for anyone who would have to enter the password to read * Keep a password-protected page's *body* out of the results, for a searcher who would have to enter
* one, because a result carries an excerpt of the page text see `highlight` below. * the password to read it. The page itself still appears its title and description are not what
* the password covers but it can only be matched on those, and comes back with no excerpt.
*/ */
hideProtected?: boolean hideProtectedContent?: boolean
} }
/** /**
@ -218,7 +219,7 @@ class Search {
limit = 25, limit = 25,
publicOnly = false, publicOnly = false,
includeDrafts = false, includeDrafts = false,
hideProtected = true hideProtectedContent = true
}: SearchPagesParams): Promise<SearchPagesResult> { }: SearchPagesParams): Promise<SearchPagesResult> {
const terms = query.trim() const terms = query.trim()
const hasQuery = terms.length > 0 const hasQuery = terms.length > 0
@ -243,15 +244,21 @@ class Search {
} else if (!includeDrafts) { } else if (!includeDrafts) {
conditions.push(sql`p."publishState" <> 'draft'`) conditions.push(sql`p."publishState" <> 'draft'`)
} }
if (hideProtected) { if (hideProtectedContent && hasQuery) {
/* /*
A result is not just a title: `highlight` below is an excerpt of the page's own text, cut from A protected page is findable by name, not by what it says.
`searchContent`. Handing that to someone who would be shown a lock screen on the page itself
would give away through search exactly what the password withholds so a protected page is `indexPage` stores the three parts of a page under distinct weights title `A`, description
absent from their results entirely rather than present without its excerpt, which would still `B`, body `C` so `ts_filter` can drop the body and ask whether the query still matches. A
confirm that a page matching their terms is there. protected page therefore surfaces when the terms are in its title or description, both of which
it shows to everyone anyway, and stays out when they are only in the text behind the password.
Otherwise a search for a distinctive phrase would confirm the phrase is in there, which is the
thing the password is for.
Written with the cheap test first: for a page with no password the OR short-circuits and
`ts_filter` never runs.
*/ */
conditions.push(sql`p.password IS NULL`) conditions.push(sql`(p.password IS NULL OR ts_filter(p.ts, '{a,b}') @@ ${tsQuery})`)
} }
if (publishState) { if (publishState) {
conditions.push(sql`p."publishState" = ${publishState}`) conditions.push(sql`p."publishState" = ${publishState}`)
@ -281,11 +288,19 @@ class Search {
}[effectiveOrderBy] }[effectiveOrderBy]
const { termHighlighting } = this.getConfig() const { termHighlighting } = this.getConfig()
const headline = sql`ts_headline(${dict}, coalesce(p."searchContent", ''), ${tsQuery},
${`StartSel=${HL_START},StopSel=${HL_STOP},MaxWords=25,MinWords=10,MaxFragments=1`})`
/*
The excerpt is cut from the page's own text, so a protected page has none to give a searcher who
would be shown a lock screen on the page itself. `CASE` rather than a filter on the rows: the page
still belongs in the results, it just arrives without the part the password covers.
*/
const highlight = const highlight =
hasQuery && termHighlighting !hasQuery || !termHighlighting
? sql`ts_headline(${dict}, coalesce(p."searchContent", ''), ${tsQuery}, ? sql`NULL`
${`StartSel=${HL_START},StopSel=${HL_STOP},MaxWords=25,MinWords=10,MaxFragments=1`})` : hideProtectedContent
: sql`NULL` ? sql`CASE WHEN p.password IS NULL THEN ${headline} ELSE NULL END`
: headline
const rows = await WIKI.db.execute(sql` const rows = await WIKI.db.execute(sql`
SELECT SELECT

@ -60,7 +60,6 @@
"socket.io-client": "4.8.3", "socket.io-client": "4.8.3",
"sortablejs": "1.15.7", "sortablejs": "1.15.7",
"sortablejs-vue3": "1.3.0", "sortablejs-vue3": "1.3.0",
"tabulator-tables": "6.4.0",
"tailwindcss": "4.3.3", "tailwindcss": "4.3.3",
"temporal-polyfill": "1.0.1", "temporal-polyfill": "1.0.1",
"tippy.js": "6.3.7", "tippy.js": "6.3.7",
@ -78,6 +77,7 @@
"devDependencies": { "devDependencies": {
"@iconify-json/la": "1.2.1", "@iconify-json/la": "1.2.1",
"@iconify-json/mdi": "1.2.3", "@iconify-json/mdi": "1.2.3",
"@iconify-json/tabler": "1.2.38",
"@intlify/unplugin-vue-i18n": "11.2.3", "@intlify/unplugin-vue-i18n": "11.2.3",
"@types/lodash": "4.17.24", "@types/lodash": "4.17.24",
"@vitejs/plugin-vue": "6.0.8", "@vitejs/plugin-vue": "6.0.8",
@ -88,6 +88,7 @@
"oxfmt": "0.54.0", "oxfmt": "0.54.0",
"oxlint": "1.69.0", "oxlint": "1.69.0",
"sass": "1.101.0", "sass": "1.101.0",
"unicode-emoji-json": "0.9.0",
"vite": "8.1.5", "vite": "8.1.5",
"vite-plugin-vue-devtools": "8.1.2" "vite-plugin-vue-devtools": "8.1.2"
}, },
@ -907,6 +908,16 @@
"@iconify/types": "*" "@iconify/types": "*"
} }
}, },
"node_modules/@iconify-json/tabler": {
"version": "1.2.38",
"resolved": "https://registry.npmjs.org/@iconify-json/tabler/-/tabler-1.2.38.tgz",
"integrity": "sha512-9v6ooRU/bKOK/Whv4w2aiK5gBnfCV9Ei+uD1iDDeLu2b+EnF3G3ZKkRKWEOOLG8bJyfKn6XEia6gUQ9oO7cF+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify/types": { "node_modules/@iconify/types": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
@ -7545,12 +7556,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/tabulator-tables": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabulator-tables/-/tabulator-tables-6.4.0.tgz",
"integrity": "sha512-Lxh+leFNoBo/Yyr4USs6gxqbfo8anYUaUMmoT91pfVLtoUgl/dE+qV7ahnFrKVMCYYqGG33aIMPR7FzpPBaNYA==",
"license": "MIT"
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
@ -7783,6 +7788,13 @@
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-emoji-json": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/unicode-emoji-json/-/unicode-emoji-json-0.9.0.tgz",
"integrity": "sha512-HdrQW/7SdbXUsTd8uTATv7LvQJkCSgbAgRCDCtOhQ6xn3EAx+xZHQtRniOXWJK3ywIqPegkNhP4U4EbuFsiQng==",
"dev": true,
"license": "MIT"
},
"node_modules/unplugin": { "node_modules/unplugin": {
"version": "2.3.11", "version": "2.3.11",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",

@ -12,7 +12,9 @@
"ncu": "ncu -i", "ncu": "ncu -i",
"ncu-u": "ncu -u", "ncu-u": "ncu -u",
"icons": "node scripts/generate-icons.mjs", "icons": "node scripts/generate-icons.mjs",
"icons:check": "node scripts/generate-icons.mjs --check" "icons:check": "node scripts/generate-icons.mjs --check",
"emoji": "node scripts/generate-emoji.mjs",
"emoji:check": "node scripts/generate-emoji.mjs --check"
}, },
"dependencies": { "dependencies": {
"@quasar/extras": "2.0.1", "@quasar/extras": "2.0.1",
@ -67,7 +69,6 @@
"socket.io-client": "4.8.3", "socket.io-client": "4.8.3",
"sortablejs": "1.15.7", "sortablejs": "1.15.7",
"sortablejs-vue3": "1.3.0", "sortablejs-vue3": "1.3.0",
"tabulator-tables": "6.4.0",
"tailwindcss": "4.3.3", "tailwindcss": "4.3.3",
"temporal-polyfill": "1.0.1", "temporal-polyfill": "1.0.1",
"tippy.js": "6.3.7", "tippy.js": "6.3.7",
@ -85,6 +86,7 @@
"devDependencies": { "devDependencies": {
"@iconify-json/la": "1.2.1", "@iconify-json/la": "1.2.1",
"@iconify-json/mdi": "1.2.3", "@iconify-json/mdi": "1.2.3",
"@iconify-json/tabler": "1.2.38",
"@intlify/unplugin-vue-i18n": "11.2.3", "@intlify/unplugin-vue-i18n": "11.2.3",
"@types/lodash": "4.17.24", "@types/lodash": "4.17.24",
"@vitejs/plugin-vue": "6.0.8", "@vitejs/plugin-vue": "6.0.8",
@ -95,6 +97,7 @@
"oxfmt": "0.54.0", "oxfmt": "0.54.0",
"oxlint": "1.69.0", "oxlint": "1.69.0",
"sass": "1.101.0", "sass": "1.101.0",
"unicode-emoji-json": "0.9.0",
"vite": "8.1.5", "vite": "8.1.5",
"vite-plugin-vue-devtools": "8.1.2" "vite-plugin-vue-devtools": "8.1.2"
}, },

@ -0,0 +1,123 @@
/*
Generates `src/assets/emoji.generated.js` the grouping the emoji picker is built from.
Two datasets have to meet for a picker to work here:
- `markdown-it-emoji` is the renderer's own vocabulary: `:shortcode:` is what gets written into a
page, and anything outside that map would go in as text that never becomes an emoji. It has no
notion of categories.
- `unicode-emoji-json` carries the CLDR grouping and ordering Smileys & Emotion, Animals & Nature,
and so on which is what the picker's tabs are, and no notion of shortcodes.
So this writes out the intersection: each group, in Unicode's own order, as `[shortcode, character]`
pairs the shortcode is what gets written into the page, the character is what the picker draws in its
grid. The picker cannot offer an emoji the renderer would not draw, because every pair came from the
renderer's own map.
The characters are written here rather than looked up at runtime, even though the editor already
bundles that map for rendering. Reading it would mean importing `markdown-it-emoji/lib/data/full.mjs`
from app code a path into the package's internals rather than an entry point it publishes, and one
more dependency for the bundler to discover. A few kB of duplicated characters is the cheaper side of
that trade.
`unicode-emoji-json` is a devDependency for that reason: its data ends up here, and here is committed.
Usage: node scripts/generate-emoji.mjs [--check]
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import emojiByGroup from 'unicode-emoji-json/data-by-group.json' with { type: 'json' }
import shortcodeToEmoji from 'markdown-it-emoji/lib/data/full.mjs'
const ROOT = fileURLToPath(new URL('../', import.meta.url))
const OUT = path.join(ROOT, 'src/assets/emoji.generated.js')
/**
* One shortcode per emoji, since several can point at the same character `laughing` and `satisfied`
* are both 😆, `+1` and `thumbsup` are both 👍.
*
* The first one wins, except that a name starting with a letter beats one that does not: the map's own
* order is close to canonical, and `thumbsup` reads better in a page's source than `+1`.
*/
function buildShortcodeIndex() {
const index = new Map()
for (const [shortcode, emoji] of Object.entries(shortcodeToEmoji)) {
const current = index.get(emoji)
if (!current || (!/^[a-z]/.test(current) && /^[a-z]/.test(shortcode))) {
index.set(emoji, shortcode)
}
}
return index
}
function build() {
const index = buildShortcodeIndex()
const groups = []
const claimed = new Set()
for (const group of emojiByGroup) {
const emoji = []
for (const entry of group.emojis) {
const shortcode = index.get(entry.emoji)
// -> Skipped rather than invented: an emoji the renderer has no shortcode for cannot be written
// into a page as an emoji, so it has no business in a picker
if (shortcode && !claimed.has(shortcode)) {
claimed.add(shortcode)
emoji.push([shortcode, entry.emoji])
}
}
if (emoji.length > 0) {
groups.push({ slug: group.slug, name: group.name, emoji })
}
}
return { groups, claimed }
}
function serialize({ groups, claimed }) {
const body = groups
.map((group) => {
const pairs = group.emoji.map(([code, char]) => `['${code}', '${char}']`).join(', ')
// -> Single quotes, so the generated file matches what oxfmt would write for the rest of `src`
return ` {
slug: '${group.slug}',
name: '${group.name}',
emoji: [${pairs}]
}`
})
.join(',\n')
return `/*
GENERATED by scripts/generate-emoji.mjs do not edit.
The CLDR emoji groups, in Unicode's order, as \`[shortcode, character]\` pairs. The shortcode is one
\`markdown-it-emoji\` accepts — what a page stores and what the renderer draws — and the character is
what a picker shows. Regenerate with \`npm run emoji\`.
${claimed.size} emoji in ${groups.length} groups.
*/
export const EMOJI_GROUPS = [
${body}
]
`
}
const data = build()
const output = serialize(data)
if (process.argv.includes('--check')) {
const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : ''
if (current !== output) {
console.error('emoji.generated.js is out of date — run `npm run emoji`')
process.exit(1)
}
console.log(`OK ${data.claimed.size} emoji, data up to date`)
} else {
fs.mkdirSync(path.dirname(OUT), { recursive: true })
fs.writeFileSync(OUT, output)
console.log(
`wrote ${data.claimed.size} emoji in ${data.groups.length} groups to src/assets/emoji.generated.js (${Buffer.byteLength(output).toLocaleString()} B)`
)
}

@ -26,8 +26,15 @@ const ROOT = fileURLToPath(new URL('../', import.meta.url))
const SRC = path.join(ROOT, 'src') const SRC = path.join(ROOT, 'src')
const OUT = path.join(SRC, 'assets/icons.generated.js') const OUT = path.join(SRC, 'assets/icons.generated.js')
/** Icon sets we bundle from. A prefix not listed here is left to resolve at runtime. */ /**
const SETS = ['mdi', 'la'] * Icon sets we bundle from. A prefix not listed here is left to resolve at runtime which for an icon
* written into this repo's own source means it does not render at all unless an administrator happens to
* have added that set, so a new set has to be listed HERE and installed as `@iconify-json/<prefix>`.
*
* The skip is silent on purpose: `prefix:name` also describes every permission string in the frontend
* (`write:pages`, `manage:system`), and those must not be mistaken for icons.
*/
const SETS = ['mdi', 'la', 'tabler']
/** /**
* A quoted string that is EXACTLY an Iconify reference. Requiring the whole literal to match is what * A quoted string that is EXACTLY an Iconify reference. Requiring the whole literal to match is what

File diff suppressed because one or more lines are too long

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts. removing an icon; `check-icons.mjs` fails the build if this drifts.
240 icons. 249 icons.
*/ */
export const BUNDLED_ICONS = { export const BUNDLED_ICONS = {
"la:angle-double-right": {"body":"<path fill=\"currentColor\" d=\"M9.094 4.781L7.688 6.22l9.78 9.78l-9.78 9.781l1.406 1.438L20.313 16zm7 0L14.687 6.22L24.47 16l-9.782 9.781l1.407 1.438L27.312 16z\"/>","width":32,"height":32}, "la:angle-double-right": {"body":"<path fill=\"currentColor\" d=\"M9.094 4.781L7.688 6.22l9.78 9.78l-9.78 9.781l1.406 1.438L20.313 16zm7 0L14.687 6.22L24.47 16l-9.782 9.781l1.407 1.438L27.312 16z\"/>","width":32,"height":32},
@ -37,7 +37,6 @@ export const BUNDLED_ICONS = {
"la:clipboard": {"body":"<path fill=\"currentColor\" d=\"M16 3c-1.258 0-2.152.89-2.594 2H6v23h20V5h-7.406C18.152 3.89 17.258 3 16 3m0 2c.555 0 1 .445 1 1v1h3v2h-8V7h3V6c0-.555.445-1 1-1M8 7h2v4h12V7h2v19H8z\"/>","width":32,"height":32}, "la:clipboard": {"body":"<path fill=\"currentColor\" d=\"M16 3c-1.258 0-2.152.89-2.594 2H6v23h20V5h-7.406C18.152 3.89 17.258 3 16 3m0 2c.555 0 1 .445 1 1v1h3v2h-8V7h3V6c0-.555.445-1 1-1M8 7h2v4h12V7h2v19H8z\"/>","width":32,"height":32},
"la:clipboard-list": {"body":"<path fill=\"currentColor\" d=\"M16 2c-1.26 0-2.15.89-2.59 2H5v25h22V4h-8.41c-.44-1.11-1.33-2-2.59-2m0 2c.55 0 1 .45 1 1v1h3v2h-8V6h3V5c0-.55.45-1 1-1M7 6h3v4h12V6h3v21H7zm2 7v2h2v-2zm4 0v2h10v-2zm-4 4v2h2v-2zm4 0v2h10v-2zm-4 4v2h2v-2zm4 0v2h10v-2z\"/>","width":32,"height":32}, "la:clipboard-list": {"body":"<path fill=\"currentColor\" d=\"M16 2c-1.26 0-2.15.89-2.59 2H5v25h22V4h-8.41c-.44-1.11-1.33-2-2.59-2m0 2c.55 0 1 .45 1 1v1h3v2h-8V6h3V5c0-.55.45-1 1-1M7 6h3v4h12V6h3v21H7zm2 7v2h2v-2zm4 0v2h10v-2zm-4 4v2h2v-2zm4 0v2h10v-2zm-4 4v2h2v-2zm4 0v2h10v-2z\"/>","width":32,"height":32},
"la:clock": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12s12-5.383 12-12S22.617 4 16 4m0 2c5.535 0 10 4.465 10 10s-4.465 10-10 10S6 21.535 6 16S10.465 6 16 6m-1 2v9h7v-2h-5V8z\"/>","width":32,"height":32}, "la:clock": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12s12-5.383 12-12S22.617 4 16 4m0 2c5.535 0 10 4.465 10 10s-4.465 10-10 10S6 21.535 6 16S10.465 6 16 6m-1 2v9h7v-2h-5V8z\"/>","width":32,"height":32},
"la:cloud-download-alt": {"body":"<path fill=\"currentColor\" d=\"M16 6c-2.648 0-4.95 1.238-6.594 3.063C9.27 9.046 9.148 9 9 9c-2.2 0-4 1.8-4 4c-1.73 1.055-3 2.836-3 5c0 3.3 2.7 6 6 6h5v-2H8c-2.219 0-4-1.781-4-4a4.01 4.01 0 0 1 2.438-3.688l.687-.28l-.094-.75A6 6 0 0 1 7 13a1.984 1.984 0 0 1 2.469-1.938l.625.157l.375-.5A7 7 0 0 1 16 8c3.277 0 6.012 2.254 6.781 5.281l.188.781l.843-.03c.211-.012.258-.032.188-.032c2.219 0 4 1.781 4 4s-1.781 4-4 4h-5v2h5c3.3 0 6-2.7 6-6c0-3.156-2.488-5.684-5.594-5.906C23.184 8.574 19.926 6 16 6m-1 12v8h-3l4 4l4-4h-3v-8z\"/>","width":32,"height":32},
"la:cloud-upload-alt": {"body":"<path fill=\"currentColor\" d=\"M16 7c-2.648 0-4.95 1.238-6.594 3.063C9.27 10.046 9.148 10 9 10c-2.2 0-4 1.8-4 4c-1.73 1.055-3 2.836-3 5c0 3.3 2.7 6 6 6h5v-2H8c-2.219 0-4-1.781-4-4a4.01 4.01 0 0 1 2.438-3.688l.687-.28l-.094-.75A6 6 0 0 1 7 14a1.984 1.984 0 0 1 2.469-1.938l.625.157l.375-.5A7 7 0 0 1 16 9c3.277 0 6.012 2.254 6.781 5.281l.188.781l.843-.03c.211-.012.258-.032.188-.032c2.219 0 4 1.781 4 4s-1.781 4-4 4h-5v2h5c3.3 0 6-2.7 6-6c0-3.156-2.488-5.684-5.594-5.906C23.184 9.574 19.926 7 16 7m0 8l-4 4h3v8h2v-8h3z\"/>","width":32,"height":32}, "la:cloud-upload-alt": {"body":"<path fill=\"currentColor\" d=\"M16 7c-2.648 0-4.95 1.238-6.594 3.063C9.27 10.046 9.148 10 9 10c-2.2 0-4 1.8-4 4c-1.73 1.055-3 2.836-3 5c0 3.3 2.7 6 6 6h5v-2H8c-2.219 0-4-1.781-4-4a4.01 4.01 0 0 1 2.438-3.688l.687-.28l-.094-.75A6 6 0 0 1 7 14a1.984 1.984 0 0 1 2.469-1.938l.625.157l.375-.5A7 7 0 0 1 16 9c3.277 0 6.012 2.254 6.781 5.281l.188.781l.843-.03c.211-.012.258-.032.188-.032c2.219 0 4 1.781 4 4s-1.781 4-4 4h-5v2h5c3.3 0 6-2.7 6-6c0-3.156-2.488-5.684-5.594-5.906C23.184 9.574 19.926 7 16 7m0 8l-4 4h3v8h2v-8h3z\"/>","width":32,"height":32},
"la:code": {"body":"<path fill=\"currentColor\" d=\"m18 5l-6 22h2l6-22zM7.937 6.406l-6.75 9L.75 16l.438.594l6.75 9l1.625-1.188L3.25 16l6.313-8.406zm16.125 0l-1.625 1.188L28.75 16l-6.313 8.406l1.625 1.188l6.75-9L31.25 16l-.438-.594z\"/>","width":32,"height":32}, "la:code": {"body":"<path fill=\"currentColor\" d=\"m18 5l-6 22h2l6-22zM7.937 6.406l-6.75 9L.75 16l.438.594l6.75 9l1.625-1.188L3.25 16l6.313-8.406zm16.125 0l-1.625 1.188L28.75 16l-6.313 8.406l1.625 1.188l6.75-9L31.25 16l-.438-.594z\"/>","width":32,"height":32},
"la:cog": {"body":"<path fill=\"currentColor\" d=\"m13.188 3l-.157.813l-.594 2.968a10 10 0 0 0-2.593 1.532l-2.906-1l-.782-.25l-.406.718l-2 3.438l-.406.719l.594.53l2.25 1.97C6.104 14.948 6 15.46 6 16s.105 1.05.188 1.563l-2.25 1.968l-.594.532l.406.718l2 3.438l.406.718l.782-.25l2.906-1a10 10 0 0 0 2.594 1.532l.593 2.968l.156.813h5.626l.156-.813l.593-2.968a10 10 0 0 0 2.594-1.532l2.907 1l.78.25l.407-.718l2-3.438l.406-.718l-.593-.532l-2.25-1.968C25.895 17.05 26 16.538 26 16c0-.54-.105-1.05-.188-1.563l2.25-1.968l.594-.531l-.406-.72l-2-3.437l-.406-.718l-.782.25l-2.906 1a10 10 0 0 0-2.593-1.532l-.594-2.968L18.812 3zm1.624 2h2.376l.5 2.594l.125.593l.562.188a8 8 0 0 1 3.031 1.75l.438.406l.562-.187l2.532-.875l1.187 2.031l-2 1.781l-.469.375l.157.594c.128.57.187 1.152.187 1.75s-.059 1.18-.188 1.75l-.125.594l.438.375l2 1.781l-1.188 2.031l-2.53-.875l-.563-.187l-.438.406a8 8 0 0 1-3.031 1.75l-.563.188l-.125.593l-.5 2.594h-2.375l-.5-2.594l-.124-.593l-.563-.188a8 8 0 0 1-3.031-1.75l-.438-.406l-.562.187l-2.531.875L5.875 20.5l2-1.781l.469-.375l-.156-.594A8 8 0 0 1 8 16c0-.598.059-1.18.188-1.75l.156-.594l-.469-.375l-2-1.781l1.188-2.031l2.53.875l.563.187l.438-.406a8 8 0 0 1 3.031-1.75l.563-.188l.124-.593zM16 11c-2.75 0-5 2.25-5 5s2.25 5 5 5s5-2.25 5-5s-2.25-5-5-5m0 2c1.668 0 3 1.332 3 3s-1.332 3-3 3s-3-1.332-3-3s1.332-3 3-3\"/>","width":32,"height":32}, "la:cog": {"body":"<path fill=\"currentColor\" d=\"m13.188 3l-.157.813l-.594 2.968a10 10 0 0 0-2.593 1.532l-2.906-1l-.782-.25l-.406.718l-2 3.438l-.406.719l.594.53l2.25 1.97C6.104 14.948 6 15.46 6 16s.105 1.05.188 1.563l-2.25 1.968l-.594.532l.406.718l2 3.438l.406.718l.782-.25l2.906-1a10 10 0 0 0 2.594 1.532l.593 2.968l.156.813h5.626l.156-.813l.593-2.968a10 10 0 0 0 2.594-1.532l2.907 1l.78.25l.407-.718l2-3.438l.406-.718l-.593-.532l-2.25-1.968C25.895 17.05 26 16.538 26 16c0-.54-.105-1.05-.188-1.563l2.25-1.968l.594-.531l-.406-.72l-2-3.437l-.406-.718l-.782.25l-2.906 1a10 10 0 0 0-2.593-1.532l-.594-2.968L18.812 3zm1.624 2h2.376l.5 2.594l.125.593l.562.188a8 8 0 0 1 3.031 1.75l.438.406l.562-.187l2.532-.875l1.187 2.031l-2 1.781l-.469.375l.157.594c.128.57.187 1.152.187 1.75s-.059 1.18-.188 1.75l-.125.594l.438.375l2 1.781l-1.188 2.031l-2.53-.875l-.563-.187l-.438.406a8 8 0 0 1-3.031 1.75l-.563.188l-.125.593l-.5 2.594h-2.375l-.5-2.594l-.124-.593l-.563-.188a8 8 0 0 1-3.031-1.75l-.438-.406l-.562.187l-2.531.875L5.875 20.5l2-1.781l.469-.375l-.156-.594A8 8 0 0 1 8 16c0-.598.059-1.18.188-1.75l.156-.594l-.469-.375l-2-1.781l1.188-2.031l2.53.875l.563.187l.438-.406a8 8 0 0 1 3.031-1.75l.563-.188l.124-.593zM16 11c-2.75 0-5 2.25-5 5s2.25 5 5 5s5-2.25 5-5s-2.25-5-5-5m0 2c1.668 0 3 1.332 3 3s-1.332 3-3 3s-3-1.332-3-3s1.332-3 3-3\"/>","width":32,"height":32},
@ -149,7 +148,9 @@ export const BUNDLED_ICONS = {
"mdi:alert-box": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m8 10V7h-2v6zm0 4v-2h-2v2z\"/>","width":24,"height":24}, "mdi:alert-box": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m8 10V7h-2v6zm0 4v-2h-2v2z\"/>","width":24,"height":24},
"mdi:alpha-t-box-outline": {"body":"<path fill=\"currentColor\" d=\"M9 7h6v2h-2v8h-2V9H9zM5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m0 2v14h14V5z\"/>","width":24,"height":24}, "mdi:alpha-t-box-outline": {"body":"<path fill=\"currentColor\" d=\"M9 7h6v2h-2v8h-2V9H9zM5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m0 2v14h14V5z\"/>","width":24,"height":24},
"mdi:arrow-vertical-lock": {"body":"<path fill=\"currentColor\" d=\"M18.8 11V9.5C18.8 8.1 17.4 7 16 7s-2.8 1.1-2.8 2.5V11c-.6 0-1.2.6-1.2 1.2v3.5c0 .7.6 1.3 1.2 1.3h5.5c.7 0 1.3-.6 1.3-1.2v-3.5c0-.7-.6-1.3-1.2-1.3m-1.3 0h-3V9.5c0-.8.7-1.3 1.5-1.3s1.5.5 1.5 1.3zM9 6h3L8 2L4 6h3v12H4l4 4l4-4H9z\"/>","width":24,"height":24}, "mdi:arrow-vertical-lock": {"body":"<path fill=\"currentColor\" d=\"M18.8 11V9.5C18.8 8.1 17.4 7 16 7s-2.8 1.1-2.8 2.5V11c-.6 0-1.2.6-1.2 1.2v3.5c0 .7.6 1.3 1.2 1.3h5.5c.7 0 1.3-.6 1.3-1.2v-3.5c0-.7-.6-1.3-1.2-1.3m-1.3 0h-3V9.5c0-.8.7-1.3 1.5-1.3s1.5.5 1.5 1.3zM9 6h3L8 2L4 6h3v12H4l4 4l4-4H9z\"/>","width":24,"height":24},
"mdi:basketball": {"body":"<path fill=\"currentColor\" d=\"M2.34 14.63c.6-.22 1.22-.33 1.88-.33q2.01 0 3.51 1.26L4.59 18.7a10.6 10.6 0 0 1-2.25-4.07M15.56 9.8c1.97 1.47 4.1 1.83 6.38 1.08c.03.21.06.59.06 1.12c0 1.03-.25 2.18-.72 3.45c-.47 1.26-1.05 2.28-1.73 3.05l-6.33-6.31zm-6.79 6.84c1.06 1.53 1.28 3.2.65 5.02c-1.42-.41-2.69-1.05-3.75-1.93zm3.42-3.42l6.31 6.33c-2.17 1.9-4.72 2.7-7.62 2.39c.21-.66.32-1.38.32-2.16c0-.62-.14-1.35-.42-2.18s-.61-1.51-.98-2.04zM8.81 14.5a6.7 6.7 0 0 0-3.23-1.59c-1.22-.23-2.39-.16-3.52.22c-.03-.22-.06-.6-.06-1.13c0-1.03.25-2.18.72-3.45c.47-1.26 1.05-2.28 1.73-3.05l6.66 6.69zm6.75-6.77c-1.34-1.65-1.65-3.45-.93-5.39c.62.16 1.33.46 2.13.92c.79.45 1.44.9 1.94 1.33zm6.1 1.65c-.6.21-1.22.32-1.88.32c-1.09 0-2.14-.32-3.14-.98l3.09-3.05c.88 1.1 1.52 2.33 1.93 3.71m-9.47 1.73L5.5 4.45c2.17-1.9 4.72-2.7 7.63-2.39q-.33.99-.33 2.16c0 .72.16 1.53.49 2.44c.33.9.71 1.62 1.21 2.15z\"/>","width":24,"height":24},
"mdi:book-plus": {"body":"<path fill=\"currentColor\" d=\"M13 19c0 1.1.3 2.12.81 3H6c-1.11 0-2-.89-2-2V4a2 2 0 0 1 2-2h1v7l2.5-1.5L12 9V2h6a2 2 0 0 1 2 2v9.09c-.33-.05-.66-.09-1-.09c-3.31 0-6 2.69-6 6m7-1v-3h-2v3h-3v2h3v3h2v-3h3v-2z\"/>","width":24,"height":24}, "mdi:book-plus": {"body":"<path fill=\"currentColor\" d=\"M13 19c0 1.1.3 2.12.81 3H6c-1.11 0-2-.89-2-2V4a2 2 0 0 1 2-2h1v7l2.5-1.5L12 9V2h6a2 2 0 0 1 2 2v9.09c-.33-.05-.66-.09-1-.09c-3.31 0-6 2.69-6 6m7-1v-3h-2v3h-3v2h3v3h2v-3h3v-2z\"/>","width":24,"height":24},
"mdi:car": {"body":"<path fill=\"currentColor\" d=\"m5 11l1.5-4.5h11L19 11m-1.5 5a1.5 1.5 0 0 1-1.5-1.5a1.5 1.5 0 0 1 1.5-1.5a1.5 1.5 0 0 1 1.5 1.5a1.5 1.5 0 0 1-1.5 1.5m-11 0A1.5 1.5 0 0 1 5 14.5A1.5 1.5 0 0 1 6.5 13A1.5 1.5 0 0 1 8 14.5A1.5 1.5 0 0 1 6.5 16M18.92 6c-.2-.58-.76-1-1.42-1h-11c-.66 0-1.22.42-1.42 1L3 12v8a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-1h12v1a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-8z\"/>","width":24,"height":24},
"mdi:chart-multiline": {"body":"<path fill=\"currentColor\" d=\"M22 6.92L20.59 5.5l-2.85 3.22C15.68 6.4 12.83 5 9.61 5C6.72 5 4.07 6.16 2 8l1.42 1.42C5.12 7.93 7.27 7 9.61 7c2.74 0 5.09 1.26 6.77 3.24L13.5 13.5l-4-4L2 17l1.5 1.5l6-6l4 4l4.05-4.57c.75 1.35 1.25 2.9 1.45 4.57h2c-.22-2.32-.95-4.41-2.04-6.16z\"/>","width":24,"height":24}, "mdi:chart-multiline": {"body":"<path fill=\"currentColor\" d=\"M22 6.92L20.59 5.5l-2.85 3.22C15.68 6.4 12.83 5 9.61 5C6.72 5 4.07 6.16 2 8l1.42 1.42C5.12 7.93 7.27 7 9.61 7c2.74 0 5.09 1.26 6.77 3.24L13.5 13.5l-4-4L2 17l1.5 1.5l6-6l4 4l4.05-4.57c.75 1.35 1.25 2.9 1.45 4.57h2c-.22-2.32-.95-4.41-2.04-6.16z\"/>","width":24,"height":24},
"mdi:check": {"body":"<path fill=\"currentColor\" d=\"M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z\"/>","width":24,"height":24}, "mdi:check": {"body":"<path fill=\"currentColor\" d=\"M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z\"/>","width":24,"height":24},
"mdi:check-circle": {"body":"<path fill=\"currentColor\" d=\"M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10s10-4.5 10-10S17.5 2 12 2m-2 15l-5-5l1.41-1.41L10 14.17l7.59-7.59L19 8z\"/>","width":24,"height":24}, "mdi:check-circle": {"body":"<path fill=\"currentColor\" d=\"M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10s10-4.5 10-10S17.5 2 12 2m-2 15l-5-5l1.41-1.41L10 14.17l7.59-7.59L19 8z\"/>","width":24,"height":24},
@ -159,23 +160,28 @@ export const BUNDLED_ICONS = {
"mdi:chevron-left": {"body":"<path fill=\"currentColor\" d=\"M15.41 16.58L10.83 12l4.58-4.59L14 6l-6 6l6 6z\"/>","width":24,"height":24}, "mdi:chevron-left": {"body":"<path fill=\"currentColor\" d=\"M15.41 16.58L10.83 12l4.58-4.59L14 6l-6 6l6 6z\"/>","width":24,"height":24},
"mdi:chevron-right": {"body":"<path fill=\"currentColor\" d=\"M8.59 16.58L13.17 12L8.59 7.41L10 6l6 6l-6 6z\"/>","width":24,"height":24}, "mdi:chevron-right": {"body":"<path fill=\"currentColor\" d=\"M8.59 16.58L13.17 12L8.59 7.41L10 6l6 6l-6 6z\"/>","width":24,"height":24},
"mdi:clipboard-text-outline": {"body":"<path fill=\"currentColor\" d=\"M19 3h-4.18C14.25 1.44 12.53.64 11 1.2c-.86.3-1.5.96-1.82 1.8H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-7 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1M7 7h10V5h2v14H5V5h2zm10 4H7V9h10zm-2 4H7v-2h8z\"/>","width":24,"height":24}, "mdi:clipboard-text-outline": {"body":"<path fill=\"currentColor\" d=\"M19 3h-4.18C14.25 1.44 12.53.64 11 1.2c-.86.3-1.5.96-1.82 1.8H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-7 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1M7 7h10V5h2v14H5V5h2zm10 4H7V9h10zm-2 4H7v-2h8z\"/>","width":24,"height":24},
"mdi:clock-outline": {"body":"<path fill=\"currentColor\" d=\"M12 20a8 8 0 0 0 8-8a8 8 0 0 0-8-8a8 8 0 0 0-8 8a8 8 0 0 0 8 8m0-18a10 10 0 0 1 10 10a10 10 0 0 1-10 10C6.47 22 2 17.5 2 12A10 10 0 0 1 12 2m.5 5v5.25l4.5 2.67l-.75 1.23L11 13V7z\"/>","width":24,"height":24},
"mdi:close": {"body":"<path fill=\"currentColor\" d=\"M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z\"/>","width":24,"height":24}, "mdi:close": {"body":"<path fill=\"currentColor\" d=\"M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z\"/>","width":24,"height":24},
"mdi:close-box": {"body":"<path fill=\"currentColor\" d=\"M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-3.4 14L12 13.4L8.4 17L7 15.6l3.6-3.6L7 8.4L8.4 7l3.6 3.6L15.6 7L17 8.4L13.4 12l3.6 3.6z\"/>","width":24,"height":24}, "mdi:close-box": {"body":"<path fill=\"currentColor\" d=\"M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-3.4 14L12 13.4L8.4 17L7 15.6l3.6-3.6L7 8.4L8.4 7l3.6 3.6L15.6 7L17 8.4L13.4 12l3.6 3.6z\"/>","width":24,"height":24},
"mdi:code-json": {"body":"<path fill=\"currentColor\" d=\"M5 3h2v2H5v5a2 2 0 0 1-2 2a2 2 0 0 1 2 2v5h2v2H5c-1.07-.27-2-.9-2-2v-4a2 2 0 0 0-2-2H0v-2h1a2 2 0 0 0 2-2V5a2 2 0 0 1 2-2m14 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2a2 2 0 0 1-2-2V5h-2V3zm-7 12a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1m-4 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1m8 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1\"/>","width":24,"height":24}, "mdi:code-json": {"body":"<path fill=\"currentColor\" d=\"M5 3h2v2H5v5a2 2 0 0 1-2 2a2 2 0 0 1 2 2v5h2v2H5c-1.07-.27-2-.9-2-2v-4a2 2 0 0 0-2-2H0v-2h1a2 2 0 0 0 2-2V5a2 2 0 0 1 2-2m14 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2a2 2 0 0 1-2-2V5h-2V3zm-7 12a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1m-4 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1m8 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1\"/>","width":24,"height":24},
"mdi:code-tags": {"body":"<path fill=\"currentColor\" d=\"m14.6 16.6l4.6-4.6l-4.6-4.6L16 6l6 6l-6 6zm-5.2 0L4.8 12l4.6-4.6L8 6l-6 6l6 6z\"/>","width":24,"height":24}, "mdi:code-tags": {"body":"<path fill=\"currentColor\" d=\"m14.6 16.6l4.6-4.6l-4.6-4.6L16 6l6 6l-6 6zm-5.2 0L4.8 12l4.6-4.6L8 6l-6 6l6 6z\"/>","width":24,"height":24},
"mdi:cog": {"body":"<path fill=\"currentColor\" d=\"M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5a3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97s-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1s.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64z\"/>","width":24,"height":24}, "mdi:cog": {"body":"<path fill=\"currentColor\" d=\"M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5a3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97s-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1s.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64z\"/>","width":24,"height":24},
"mdi:cog-box": {"body":"<path fill=\"currentColor\" d=\"M17.25 12c0 .23-.02.46-.05.68l1.48 1.16c.13.11.17.29.08.45l-1.4 2.42c-.09.15-.27.21-.43.15l-1.74-.7c-.36.28-.76.51-1.19.69l-.25 1.85c-.03.17-.18.3-.35.3h-2.8c-.17 0-.32-.13-.35-.3L10 16.85c-.44-.18-.83-.41-1.19-.69l-1.74.7c-.16.06-.34 0-.43-.15l-1.4-2.42a.35.35 0 0 1 .08-.45l1.48-1.16c-.03-.22-.05-.45-.05-.68s.02-.46.05-.68l-1.48-1.16a.35.35 0 0 1-.08-.45l1.4-2.42c.09-.16.27-.22.43-.16l1.74.71c.36-.28.75-.52 1.19-.69l.25-1.86c.03-.16.18-.29.35-.29h2.8c.17 0 .32.13.35.29L14 7.15c.43.17.83.41 1.19.69l1.74-.71c.16-.06.34 0 .43.16l1.4 2.42c.09.15.05.34-.08.45l-1.48 1.16c.03.22.05.45.05.68M19 3H5c-1.11 0-2 .89-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-7 7c-1.11 0-2 .89-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2\"/>","width":24,"height":24}, "mdi:cog-box": {"body":"<path fill=\"currentColor\" d=\"M17.25 12c0 .23-.02.46-.05.68l1.48 1.16c.13.11.17.29.08.45l-1.4 2.42c-.09.15-.27.21-.43.15l-1.74-.7c-.36.28-.76.51-1.19.69l-.25 1.85c-.03.17-.18.3-.35.3h-2.8c-.17 0-.32-.13-.35-.3L10 16.85c-.44-.18-.83-.41-1.19-.69l-1.74.7c-.16.06-.34 0-.43-.15l-1.4-2.42a.35.35 0 0 1 .08-.45l1.48-1.16c-.03-.22-.05-.45-.05-.68s.02-.46.05-.68l-1.48-1.16a.35.35 0 0 1-.08-.45l1.4-2.42c.09-.16.27-.22.43-.16l1.74.71c.36-.28.75-.52 1.19-.69l.25-1.86c.03-.16.18-.29.35-.29h2.8c.17 0 .32.13.35.29L14 7.15c.43.17.83.41 1.19.69l1.74-.71c.16-.06.34 0 .43.16l1.4 2.42c.09.15.05.34-.08.45l-1.48 1.16c.03.22.05.45.05.68M19 3H5c-1.11 0-2 .89-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-7 7c-1.11 0-2 .89-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2\"/>","width":24,"height":24},
"mdi:cookie-plus": {"body":"<path fill=\"currentColor\" d=\"M15 14.53v-.03c0-.83.67-1.5 1.5-1.5c.3 0 .58.09.82.24A6.2 6.2 0 0 1 19 13c.66 0 1.3.12 1.89.32a8.4 8.4 0 0 0-.02-2.82C20.6 10 20 10 20 10h-2V9c0-1-1-1-1-1h-2V7c0-1-1-1-1-1h-1V4c0-1-1-1-1-1a9 9 0 0 0 0 18c.45 0 .89-.04 1.32-.11c-.2-.59-.32-1.23-.32-1.89c0-1.78.78-3.37 2-4.47M6.5 13c-.83 0-1.5-.67-1.5-1.5S5.67 10 6.5 10s1.5.67 1.5 1.5S7.33 13 6.5 13M8 7.5C8 6.67 8.67 6 9.5 6s1.5.67 1.5 1.5S10.33 9 9.5 9S8 8.33 8 7.5M11 19c-.83 0-1.5-.67-1.5-1.5S10.17 16 11 16s1.5.67 1.5 1.5S11.83 19 11 19m.5-5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5s1.5.67 1.5 1.5s-.67 1.5-1.5 1.5M23 18v2h-3v3h-2v-3h-3v-2h3v-3h2v3z\"/>","width":24,"height":24},
"mdi:crop-square": {"body":"<path fill=\"currentColor\" d=\"M18 18H6V6h12m0-2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2\"/>","width":24,"height":24}, "mdi:crop-square": {"body":"<path fill=\"currentColor\" d=\"M18 18H6V6h12m0-2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2\"/>","width":24,"height":24},
"mdi:database-refresh": {"body":"<path fill=\"currentColor\" d=\"M12 3c4.42 0 8 1.79 8 4s-3.58 4-8 4s-8-1.79-8-4s3.58-4 8-4M4 9c0 2.21 3.58 4 8 4c1.11 0 2.18-.11 3.14-.32c-.95.86-1.64 1.99-1.96 3.28L12 16c-4.42 0-8-1.79-8-4zm16 0v2h-.5l-.6.03c.7-.6 1.1-1.29 1.1-2.03M4 14c0 2.21 3.58 4 8 4l1-.03c.09 1.06.42 2.03.95 2.91L12 21c-4.42 0-8-1.79-8-4zm15-.5c1.11 0 2.11.45 2.83 1.17L23 13.5v4h-4l1.77-1.77A2.5 2.5 0 1 0 21 19h1.71A3.99 3.99 0 0 1 19 21.5c-2.21 0-4-1.79-4-4s1.79-4 4-4\"/>","width":24,"height":24}, "mdi:database-refresh": {"body":"<path fill=\"currentColor\" d=\"M12 3c4.42 0 8 1.79 8 4s-3.58 4-8 4s-8-1.79-8-4s3.58-4 8-4M4 9c0 2.21 3.58 4 8 4c1.11 0 2.18-.11 3.14-.32c-.95.86-1.64 1.99-1.96 3.28L12 16c-4.42 0-8-1.79-8-4zm16 0v2h-.5l-.6.03c.7-.6 1.1-1.29 1.1-2.03M4 14c0 2.21 3.58 4 8 4l1-.03c.09 1.06.42 2.03.95 2.91L12 21c-4.42 0-8-1.79-8-4zm15-.5c1.11 0 2.11.45 2.83 1.17L23 13.5v4h-4l1.77-1.77A2.5 2.5 0 1 0 21 19h1.71A3.99 3.99 0 0 1 19 21.5c-2.21 0-4-1.79-4-4s1.79-4 4-4\"/>","width":24,"height":24},
"mdi:dice-5": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m2 2a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m10 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m0-10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m-5 5a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m-5 5a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2\"/>","width":24,"height":24}, "mdi:dice-5": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m2 2a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m10 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m0-10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m-5 5a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m-5 5a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2\"/>","width":24,"height":24},
"mdi:dog": {"body":"<path fill=\"currentColor\" d=\"M18 4c-1.71 0-2.75.33-3.35.61C13.88 4.23 13 4 12 4s-1.88.23-2.65.61C8.75 4.33 7.71 4 6 4c-3 0-5 8-5 10c0 .83 1.32 1.59 3.14 1.9c.64 2.24 3.66 3.95 7.36 4.1v-4.28c-.59-.37-1.5-1.04-1.5-1.72c0-1 2-1 2-1s2 0 2 1c0 .68-.91 1.35-1.5 1.72V20c3.7-.15 6.72-1.86 7.36-4.1C21.68 15.59 23 14.83 23 14c0-2-2-10-5-10M4.15 13.87c-.5-.12-.89-.26-1.15-.37c.25-2.77 2.2-7.1 3.05-7.5c.54 0 .95.06 1.32.11c-2.1 2.31-2.93 5.93-3.22 7.76M9 12a1 1 0 0 1-1-1c0-.54.45-1 1-1a1 1 0 0 1 1 1c0 .56-.45 1-1 1m6 0a1 1 0 0 1-1-1c0-.54.45-1 1-1a1 1 0 0 1 1 1c0 .56-.45 1-1 1m4.85 1.87c-.29-1.83-1.12-5.45-3.22-7.76c.37-.05.78-.11 1.32-.11c.85.4 2.8 4.73 3.05 7.5c-.25.11-.64.25-1.15.37\"/>","width":24,"height":24},
"mdi:drag-horizontal": {"body":"<path fill=\"currentColor\" d=\"M3 15v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2z\"/>","width":24,"height":24}, "mdi:drag-horizontal": {"body":"<path fill=\"currentColor\" d=\"M3 15v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2zm4 4v-2h2v2zm0-4V9h2v2z\"/>","width":24,"height":24},
"mdi:emoticon-outline": {"body":"<path fill=\"currentColor\" d=\"M12 17.5c2.33 0 4.3-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5M8.5 11A1.5 1.5 0 0 0 10 9.5A1.5 1.5 0 0 0 8.5 8A1.5 1.5 0 0 0 7 9.5A1.5 1.5 0 0 0 8.5 11m7 0A1.5 1.5 0 0 0 17 9.5A1.5 1.5 0 0 0 15.5 8A1.5 1.5 0 0 0 14 9.5a1.5 1.5 0 0 0 1.5 1.5M12 20a8 8 0 0 1-8-8a8 8 0 0 1 8-8a8 8 0 0 1 8 8a8 8 0 0 1-8 8m0-18C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2\"/>","width":24,"height":24},
"mdi:emoticon-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M15 18h3v-3h2v3h3v2h-3v3h-2v-3h-3zm-3-.5c-2.33 0-4.31-1.46-5.11-3.5h8.8a5.94 5.94 0 0 0-2.46 3.36c-.4.09-.81.14-1.23.14M8.5 11C7.67 11 7 10.33 7 9.5S7.67 8 8.5 8s1.5.67 1.5 1.5S9.33 11 8.5 11m7 0c-.83 0-1.5-.67-1.5-1.5S14.67 8 15.5 8s1.5.67 1.5 1.5s-.67 1.5-1.5 1.5M12 20l1.07-.07c.11.68.33 1.33.65 1.92c-.56.1-1.14.15-1.72.15c-5.53 0-10-4.5-10-10S6.47 2 12 2c5.5 0 10 4.5 10 10c0 .59-.05 1.16-.15 1.72c-.59-.32-1.23-.54-1.92-.65L20 12c0-4.42-3.58-8-8-8s-8 3.58-8 8s3.58 8 8 8\"/>","width":24,"height":24},
"mdi:exclamation": {"body":"<path fill=\"currentColor\" d=\"M11 4h2v11h-2zm2 14v2h-2v-2z\"/>","width":24,"height":24}, "mdi:exclamation": {"body":"<path fill=\"currentColor\" d=\"M11 4h2v11h-2zm2 14v2h-2v-2z\"/>","width":24,"height":24},
"mdi:eye": {"body":"<path fill=\"currentColor\" d=\"M12 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3a3 3 0 0 0 3-3a3 3 0 0 0-3-3m0 8a5 5 0 0 1-5-5a5 5 0 0 1 5-5a5 5 0 0 1 5 5a5 5 0 0 1-5 5m0-12.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5\"/>","width":24,"height":24}, "mdi:eye": {"body":"<path fill=\"currentColor\" d=\"M12 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3a3 3 0 0 0 3-3a3 3 0 0 0-3-3m0 8a5 5 0 0 1-5-5a5 5 0 0 1 5-5a5 5 0 0 1 5 5a5 5 0 0 1-5 5m0-12.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5\"/>","width":24,"height":24},
"mdi:eye-off": {"body":"<path fill=\"currentColor\" d=\"M11.83 9L15 12.16V12a3 3 0 0 0-3-3zm-4.3.8l1.55 1.55c-.05.21-.08.42-.08.65a3 3 0 0 0 3 3c.22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53a5 5 0 0 1-5-5c0-.79.2-1.53.53-2.2M2 4.27l2.28 2.28l.45.45C3.08 8.3 1.78 10 1 12c1.73 4.39 6 7.5 11 7.5c1.55 0 3.03-.3 4.38-.84l.43.42L19.73 22L21 20.73L3.27 3M12 7a5 5 0 0 1 5 5c0 .64-.13 1.26-.36 1.82l2.93 2.93c1.5-1.25 2.7-2.89 3.43-4.75c-1.73-4.39-6-7.5-11-7.5c-1.4 0-2.74.25-4 .7l2.17 2.15C10.74 7.13 11.35 7 12 7\"/>","width":24,"height":24}, "mdi:eye-off": {"body":"<path fill=\"currentColor\" d=\"M11.83 9L15 12.16V12a3 3 0 0 0-3-3zm-4.3.8l1.55 1.55c-.05.21-.08.42-.08.65a3 3 0 0 0 3 3c.22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53a5 5 0 0 1-5-5c0-.79.2-1.53.53-2.2M2 4.27l2.28 2.28l.45.45C3.08 8.3 1.78 10 1 12c1.73 4.39 6 7.5 11 7.5c1.55 0 3.03-.3 4.38-.84l.43.42L19.73 22L21 20.73L3.27 3M12 7a5 5 0 0 1 5 5c0 .64-.13 1.26-.36 1.82l2.93 2.93c1.5-1.25 2.7-2.89 3.43-4.75c-1.73-4.39-6-7.5-11-7.5c-1.4 0-2.74.25-4 .7l2.17 2.15C10.74 7.13 11.35 7 12 7\"/>","width":24,"height":24},
"mdi:eye-off-outline": {"body":"<path fill=\"currentColor\" d=\"M2 5.27L3.28 4L20 20.72L18.73 22l-3.08-3.08c-1.15.38-2.37.58-3.65.58c-5 0-9.27-3.11-11-7.5c.69-1.76 1.79-3.31 3.19-4.54zM12 9a3 3 0 0 1 3 3a3 3 0 0 1-.17 1L11 9.17A3 3 0 0 1 12 9m0-4.5c5 0 9.27 3.11 11 7.5a11.8 11.8 0 0 1-4 5.19l-1.42-1.43A9.86 9.86 0 0 0 20.82 12A9.82 9.82 0 0 0 12 6.5c-1.09 0-2.16.18-3.16.5L7.3 5.47c1.44-.62 3.03-.97 4.7-.97M3.18 12A9.82 9.82 0 0 0 12 17.5c.69 0 1.37-.07 2-.21L11.72 15A3.064 3.064 0 0 1 9 12.28L5.6 8.87c-.99.85-1.82 1.91-2.42 3.13\"/>","width":24,"height":24}, "mdi:eye-off-outline": {"body":"<path fill=\"currentColor\" d=\"M2 5.27L3.28 4L20 20.72L18.73 22l-3.08-3.08c-1.15.38-2.37.58-3.65.58c-5 0-9.27-3.11-11-7.5c.69-1.76 1.79-3.31 3.19-4.54zM12 9a3 3 0 0 1 3 3a3 3 0 0 1-.17 1L11 9.17A3 3 0 0 1 12 9m0-4.5c5 0 9.27 3.11 11 7.5a11.8 11.8 0 0 1-4 5.19l-1.42-1.43A9.86 9.86 0 0 0 20.82 12A9.82 9.82 0 0 0 12 6.5c-1.09 0-2.16.18-3.16.5L7.3 5.47c1.44-.62 3.03-.97 4.7-.97M3.18 12A9.82 9.82 0 0 0 12 17.5c.69 0 1.37-.07 2-.21L11.72 15A3.064 3.064 0 0 1 9 12.28L5.6 8.87c-.99.85-1.82 1.91-2.42 3.13\"/>","width":24,"height":24},
"mdi:file-document-outline": {"body":"<path fill=\"currentColor\" d=\"M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm0 2h7v5h5v11H6zm2 8v2h8v-2zm0 4v2h5v-2z\"/>","width":24,"height":24}, "mdi:file-document-outline": {"body":"<path fill=\"currentColor\" d=\"M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm0 2h7v5h5v11H6zm2 8v2h8v-2zm0 4v2h5v-2z\"/>","width":24,"height":24},
"mdi:file-search-outline": {"body":"<path fill=\"currentColor\" d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h7c-.41-.25-.8-.56-1.14-.9c-.33-.33-.61-.7-.86-1.1H6V4h7v5h5v1.18c.71.16 1.39.43 2 .82V8zm6.31 16.9c1.33-2.11.69-4.9-1.4-6.22c-2.11-1.33-4.91-.68-6.22 1.4c-1.34 2.11-.69 4.89 1.4 6.22c1.46.93 3.32.93 4.79.02L22 23.39L23.39 22zm-3.81.1a2.5 2.5 0 0 1-2.5-2.5a2.5 2.5 0 0 1 2.5-2.5a2.5 2.5 0 0 1 2.5 2.5a2.5 2.5 0 0 1-2.5 2.5\"/>","width":24,"height":24}, "mdi:file-search-outline": {"body":"<path fill=\"currentColor\" d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h7c-.41-.25-.8-.56-1.14-.9c-.33-.33-.61-.7-.86-1.1H6V4h7v5h5v1.18c.71.16 1.39.43 2 .82V8zm6.31 16.9c1.33-2.11.69-4.9-1.4-6.22c-2.11-1.33-4.91-.68-6.22 1.4c-1.34 2.11-.69 4.89 1.4 6.22c1.46.93 3.32.93 4.79.02L22 23.39L23.39 22zm-3.81.1a2.5 2.5 0 0 1-2.5-2.5a2.5 2.5 0 0 1 2.5-2.5a2.5 2.5 0 0 1 2.5 2.5a2.5 2.5 0 0 1-2.5 2.5\"/>","width":24,"height":24},
"mdi:flag-outline": {"body":"<path fill=\"currentColor\" d=\"m12.36 6l.4 2H18v6h-3.36l-.4-2H7V6zM14 4H5v17h2v-7h5.6l.4 2h7V6h-5.6\"/>","width":24,"height":24},
"mdi:food-apple-outline": {"body":"<path fill=\"currentColor\" d=\"M20 10a5.27 5.27 0 0 0-7-2V3h-2v5a5.27 5.27 0 0 0-7 2c-2 3 3 12 5 12s2-1 3-1s1 1 3 1s7-9 5-12m-1.75 3.38c-.62 2.47-1.84 4.74-3.55 6.62c-.2 0-.43-.1-.6-.25a3.34 3.34 0 0 0-4.2 0c-.17.15-.4.25-.6.25a15.3 15.3 0 0 1-3.55-6.61c-.25-.73-.3-1.52-.09-2.27A3.37 3.37 0 0 1 8.5 9.4c.56.01 1.11.14 1.61.39l.89.45h2l.89-.45c.5-.25 1.05-.38 1.61-.39c1.18.03 2.26.68 2.84 1.71c.21.75.16 1.54-.09 2.27M11 5C5.38 8.07 4.11 3.78 4.11 3.78S6.77.19 11 5\"/>","width":24,"height":24},
"mdi:format-align-center": {"body":"<path fill=\"currentColor\" d=\"M3 3h18v2H3zm4 4h10v2H7zm-4 4h18v2H3zm4 4h10v2H7zm-4 4h18v2H3z\"/>","width":24,"height":24}, "mdi:format-align-center": {"body":"<path fill=\"currentColor\" d=\"M3 3h18v2H3zm4 4h10v2H7zm-4 4h18v2H3zm4 4h10v2H7zm-4 4h18v2H3z\"/>","width":24,"height":24},
"mdi:format-align-justify": {"body":"<path fill=\"currentColor\" d=\"M3 3h18v2H3zm0 4h18v2H3zm0 4h18v2H3zm0 4h18v2H3zm0 4h18v2H3z\"/>","width":24,"height":24}, "mdi:format-align-justify": {"body":"<path fill=\"currentColor\" d=\"M3 3h18v2H3zm0 4h18v2H3zm0 4h18v2H3zm0 4h18v2H3zm0 4h18v2H3z\"/>","width":24,"height":24},
"mdi:format-align-left": {"body":"<path fill=\"currentColor\" d=\"M3 3h18v2H3zm0 4h12v2H3zm0 4h18v2H3zm0 4h12v2H3zm0 4h18v2H3z\"/>","width":24,"height":24}, "mdi:format-align-left": {"body":"<path fill=\"currentColor\" d=\"M3 3h18v2H3zm0 4h12v2H3zm0 4h18v2H3zm0 4h12v2H3zm0 4h18v2H3z\"/>","width":24,"height":24},
@ -204,6 +210,7 @@ export const BUNDLED_ICONS = {
"mdi:format-subscript": {"body":"<path fill=\"currentColor\" d=\"M16 7.41L11.41 12L16 16.59L14.59 18L10 13.41L5.41 18L4 16.59L8.59 12L4 7.41L5.41 6L10 10.59L14.59 6zm5.85 13.62h-4.88v-1l.89-.8c.76-.65 1.32-1.19 1.7-1.63c.37-.44.56-.85.57-1.24a.9.9 0 0 0-.27-.7c-.18-.16-.47-.28-.86-.28c-.31 0-.58.06-.84.18l-.66.38l-.45-1.17c.27-.21.59-.39.98-.53s.82-.24 1.29-.24c.78.04 1.38.25 1.78.66s.62.93.62 1.57c-.01.56-.19 1.08-.54 1.55c-.34.47-.76.92-1.27 1.36l-.64.52v.02h2.58z\"/>","width":24,"height":24}, "mdi:format-subscript": {"body":"<path fill=\"currentColor\" d=\"M16 7.41L11.41 12L16 16.59L14.59 18L10 13.41L5.41 18L4 16.59L8.59 12L4 7.41L5.41 6L10 10.59L14.59 6zm5.85 13.62h-4.88v-1l.89-.8c.76-.65 1.32-1.19 1.7-1.63c.37-.44.56-.85.57-1.24a.9.9 0 0 0-.27-.7c-.18-.16-.47-.28-.86-.28c-.31 0-.58.06-.84.18l-.66.38l-.45-1.17c.27-.21.59-.39.98-.53s.82-.24 1.29-.24c.78.04 1.38.25 1.78.66s.62.93.62 1.57c-.01.56-.19 1.08-.54 1.55c-.34.47-.76.92-1.27 1.36l-.64.52v.02h2.58z\"/>","width":24,"height":24},
"mdi:format-superscript": {"body":"<path fill=\"currentColor\" d=\"M16 7.41L11.41 12L16 16.59L14.59 18L10 13.41L5.41 18L4 16.59L8.59 12L4 7.41L5.41 6L10 10.59L14.59 6zM21.85 9h-4.88V8l.89-.82c.76-.64 1.32-1.18 1.7-1.63q.555-.66.57-1.23a.88.88 0 0 0-.27-.7c-.18-.19-.47-.28-.86-.29c-.31.01-.58.07-.84.17l-.66.39l-.45-1.17c.27-.22.59-.39.98-.53S18.85 2 19.32 2c.78 0 1.38.2 1.78.61c.4.39.62.93.62 1.57c-.01.56-.19 1.08-.54 1.55c-.34.48-.76.93-1.27 1.36l-.64.52v.02h2.58z\"/>","width":24,"height":24}, "mdi:format-superscript": {"body":"<path fill=\"currentColor\" d=\"M16 7.41L11.41 12L16 16.59L14.59 18L10 13.41L5.41 18L4 16.59L8.59 12L4 7.41L5.41 6L10 10.59L14.59 6zM21.85 9h-4.88V8l.89-.82c.76-.64 1.32-1.18 1.7-1.63q.555-.66.57-1.23a.88.88 0 0 0-.27-.7c-.18-.19-.47-.28-.86-.29c-.31.01-.58.07-.84.17l-.66.39l-.45-1.17c.27-.22.59-.39.98-.53S18.85 2 19.32 2c.78 0 1.38.2 1.78.61c.4.39.62.93.62 1.57c-.01.56-.19 1.08-.54 1.55c-.34.48-.76.93-1.27 1.36l-.64.52v.02h2.58z\"/>","width":24,"height":24},
"mdi:format-title": {"body":"<path fill=\"currentColor\" d=\"M5 4v3h5.5v12h3V7H19V4z\"/>","width":24,"height":24}, "mdi:format-title": {"body":"<path fill=\"currentColor\" d=\"M5 4v3h5.5v12h3V7H19V4z\"/>","width":24,"height":24},
"mdi:hand-wave-outline": {"body":"<path fill=\"currentColor\" d=\"M7.03 4.95L3.5 8.5c-3.33 3.31-3.33 8.69 0 12s8.69 3.33 12 0l6-6c1-.97 1-2.56 0-3.54c-.1-.12-.23-.23-.37-.32l.37-.39c1-.97 1-2.56 0-3.54c-.14-.16-.33-.3-.5-.41c.38-.92.21-2.02-.54-2.77c-.87-.87-2.22-.96-3.2-.28a2.517 2.517 0 0 0-3.88-.42l-2.51 2.51c-.09-.14-.2-.27-.32-.39a2.53 2.53 0 0 0-3.52 0m1.41 1.42c.2-.2.51-.2.71 0s.2.51 0 .71l-3.18 3.18a3 3 0 0 1 0 4.24l1.41 1.41a5 5 0 0 0 1.12-5.36l6.3-6.3c.2-.2.51-.2.7 0s.21.51 0 .71l-4.59 4.6l1.41 1.41l6.01-6.01c.2-.2.51-.2.71 0s.2.51 0 .71l-6.01 6.01l1.41 1.41l4.95-4.95c.2-.2.51-.2.71 0s.2.51 0 .71l-5.66 5.65l1.41 1.42l3.54-3.54c.2-.2.51-.2.71 0s.2.51 0 .71l-6 6.01c-2.54 2.54-6.65 2.54-9.19 0s-2.54-6.65 0-9.19zM23 17c0 3.31-2.69 6-6 6v-1.5c2.5 0 4.5-2 4.5-4.5zM1 7c0-3.31 2.69-6 6-6v1.5c-2.5 0-4.5 2-4.5 4.5z\"/>","width":24,"height":24},
"mdi:image-plus": {"body":"<path fill=\"currentColor\" d=\"M18 15v3h-3v2h3v3h2v-3h3v-2h-3v-3zm-4.7 6H5c-1.1 0-2-.9-2-2V5c0-1.1.9-2 2-2h14c1.1 0 2 .9 2 2v8.3c-.6-.2-1.3-.3-2-.3c-1.1 0-2.2.3-3.1.9L14.5 12L11 16.5l-2.5-3L5 18h8.1c-.1.3-.1.7-.1 1c0 .7.1 1.4.3 2\"/>","width":24,"height":24}, "mdi:image-plus": {"body":"<path fill=\"currentColor\" d=\"M18 15v3h-3v2h3v3h2v-3h3v-2h-3v-3zm-4.7 6H5c-1.1 0-2-.9-2-2V5c0-1.1.9-2 2-2h14c1.1 0 2 .9 2 2v8.3c-.6-.2-1.3-.3-2-.3c-1.1 0-2.2.3-3.1.9L14.5 12L11 16.5l-2.5-3L5 18h8.1c-.1.3-.1.7-.1 1c0 .7.1 1.4.3 2\"/>","width":24,"height":24},
"mdi:image-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M13 19c0 .7.13 1.37.35 2H5a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h14a2 2 0 0 1 2 2v8.35c-.63-.22-1.3-.35-2-.35V5H5v14zm.96-6.71l-2.75 3.54l-1.96-2.36L6.5 17h6.85c.4-1.12 1.12-2.09 2.05-2.79zM20 18v-3h-2v3h-3v2h3v3h2v-3h3v-2z\"/>","width":24,"height":24}, "mdi:image-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M13 19c0 .7.13 1.37.35 2H5a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h14a2 2 0 0 1 2 2v8.35c-.63-.22-1.3-.35-2-.35V5H5v14zm.96-6.71l-2.75 3.54l-1.96-2.36L6.5 17h6.85c.4-1.12 1.12-2.09 2.05-2.79zM20 18v-3h-2v3h-3v2h3v3h2v-3h3v-2z\"/>","width":24,"height":24},
"mdi:image-sync-outline": {"body":"<path fill=\"currentColor\" d=\"M13.18 19c.17.72.46 1.39.85 2H5a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h14a2 2 0 0 1 2 2v6.18c-.5-.11-1-.18-1.5-.18c-.17 0-.33 0-.5.03V5H5v14zm-1.97-3.17l-1.96-2.36L6.5 17h6.53c.11-1.46.7-2.78 1.61-3.81l-.68-.9zM19 13.5V12l-2.25 2.25L19 16.5V15a2.5 2.5 0 0 1 2.5 2.5c0 .4-.09.78-.26 1.12l1.09 1.09c.42-.63.67-1.39.67-2.21c0-2.21-1.79-4-4-4m0 6.5a2.5 2.5 0 0 1-2.5-2.5c0-.4.09-.78.26-1.12l-1.09-1.09c-.42.63-.67 1.39-.67 2.21c0 2.21 1.79 4 4 4V23l2.25-2.25L19 18.5z\"/>","width":24,"height":24}, "mdi:image-sync-outline": {"body":"<path fill=\"currentColor\" d=\"M13.18 19c.17.72.46 1.39.85 2H5a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h14a2 2 0 0 1 2 2v6.18c-.5-.11-1-.18-1.5-.18c-.17 0-.33 0-.5.03V5H5v14zm-1.97-3.17l-1.96-2.36L6.5 17h6.53c.11-1.46.7-2.78 1.61-3.81l-.68-.9zM19 13.5V12l-2.25 2.25L19 16.5V15a2.5 2.5 0 0 1 2.5 2.5c0 .4-.09.78-.26 1.12l1.09 1.09c.42-.63.67-1.39.67-2.21c0-2.21-1.79-4-4-4m0 6.5a2.5 2.5 0 0 1-2.5-2.5c0-.4.09-.78.26-1.12l-1.09-1.09c-.42.63-.67 1.39-.67 2.21c0 2.21 1.79 4 4 4V23l2.25-2.25L19 18.5z\"/>","width":24,"height":24},
@ -211,6 +218,7 @@ export const BUNDLED_ICONS = {
"mdi:information": {"body":"<path fill=\"currentColor\" d=\"M13 9h-2V7h2m0 10h-2v-6h2m-1-9A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2\"/>","width":24,"height":24}, "mdi:information": {"body":"<path fill=\"currentColor\" d=\"M13 9h-2V7h2m0 10h-2v-6h2m-1-9A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2\"/>","width":24,"height":24},
"mdi:information-box": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14c0 .53-.21 1.04-.59 1.41c-.37.38-.88.59-1.41.59H5c-.53 0-1.04-.21-1.41-.59C3.21 20.04 3 19.53 3 19V5c0-1.11.89-2 2-2m8 6V7h-2v2zm0 8v-6h-2v6z\"/>","width":24,"height":24}, "mdi:information-box": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14c0 .53-.21 1.04-.59 1.41c-.37.38-.88.59-1.41.59H5c-.53 0-1.04-.21-1.41-.59C3.21 20.04 3 19.53 3 19V5c0-1.11.89-2 2-2m8 6V7h-2v2zm0 8v-6h-2v6z\"/>","width":24,"height":24},
"mdi:keyboard-variant": {"body":"<path fill=\"currentColor\" d=\"M6 16h12v2H6zm0-3v2H2v-2zm1 2v-2h3v2zm4 0v-2h2v2zm3 0v-2h3v2zm4 0v-2h4v2zM2 10h3v2H2zm17 2v-2h3v2zm-1 0h-2v-2h2zM8 12H6v-2h2zm4 0H9v-2h3zm3 0h-2v-2h2zM2 9V7h2v2zm3 0V7h2v2zm3 0V7h2v2zm3 0V7h2v2zm3 0V7h2v2zm3 0V7h5v2z\"/>","width":24,"height":24}, "mdi:keyboard-variant": {"body":"<path fill=\"currentColor\" d=\"M6 16h12v2H6zm0-3v2H2v-2zm1 2v-2h3v2zm4 0v-2h2v2zm3 0v-2h3v2zm4 0v-2h4v2zM2 10h3v2H2zm17 2v-2h3v2zm-1 0h-2v-2h2zM8 12H6v-2h2zm4 0H9v-2h3zm3 0h-2v-2h2zM2 9V7h2v2zm3 0V7h2v2zm3 0V7h2v2zm3 0V7h2v2zm3 0V7h2v2zm3 0V7h5v2z\"/>","width":24,"height":24},
"mdi:lightbulb-outline": {"body":"<path fill=\"currentColor\" d=\"M12 2a7 7 0 0 1 7 7c0 2.38-1.19 4.47-3 5.74V17a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 0 1 7-7M9 21v-1h6v1a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1m3-17a5 5 0 0 0-5 5c0 2.05 1.23 3.81 3 4.58V16h4v-2.42c1.77-.77 3-2.53 3-4.58a5 5 0 0 0-5-5\"/>","width":24,"height":24},
"mdi:line-scan": {"body":"<path fill=\"currentColor\" d=\"M17 22v-2h3v-3h2v3.5c0 .4-.2.7-.5 1s-.7.5-1 .5zM7 22H3.5c-.4 0-.7-.2-1-.5s-.5-.7-.5-1V17h2v3h3zM17 2h3.5c.4 0 .7.2 1 .5s.5.6.5 1V7h-2V4h-3zM7 2v2H4v3H2V3.5c0-.4.2-.7.5-1s.6-.5 1-.5zm12 9H5v2h14z\"/>","width":24,"height":24}, "mdi:line-scan": {"body":"<path fill=\"currentColor\" d=\"M17 22v-2h3v-3h2v3.5c0 .4-.2.7-.5 1s-.7.5-1 .5zM7 22H3.5c-.4 0-.7-.2-1-.5s-.5-.7-.5-1V17h2v3h3zM17 2h3.5c.4 0 .7.2 1 .5s.5.6.5 1V7h-2V4h-3zM7 2v2H4v3H2V3.5c0-.4.2-.7.5-1s.6-.5 1-.5zm12 9H5v2h14z\"/>","width":24,"height":24},
"mdi:link-variant": {"body":"<path fill=\"currentColor\" d=\"M10.59 13.41c.41.39.41 1.03 0 1.42c-.39.39-1.03.39-1.42 0a5.003 5.003 0 0 1 0-7.07l3.54-3.54a5.003 5.003 0 0 1 7.07 0a5.003 5.003 0 0 1 0 7.07l-1.49 1.49c.01-.82-.12-1.64-.4-2.42l.47-.48a2.98 2.98 0 0 0 0-4.24a2.98 2.98 0 0 0-4.24 0l-3.53 3.53a2.98 2.98 0 0 0 0 4.24m2.82-4.24c.39-.39 1.03-.39 1.42 0a5.003 5.003 0 0 1 0 7.07l-3.54 3.54a5.003 5.003 0 0 1-7.07 0a5.003 5.003 0 0 1 0-7.07l1.49-1.49c-.01.82.12 1.64.4 2.43l-.47.47a2.98 2.98 0 0 0 0 4.24a2.98 2.98 0 0 0 4.24 0l3.53-3.53a2.98 2.98 0 0 0 0-4.24a.973.973 0 0 1 0-1.42\"/>","width":24,"height":24}, "mdi:link-variant": {"body":"<path fill=\"currentColor\" d=\"M10.59 13.41c.41.39.41 1.03 0 1.42c-.39.39-1.03.39-1.42 0a5.003 5.003 0 0 1 0-7.07l3.54-3.54a5.003 5.003 0 0 1 7.07 0a5.003 5.003 0 0 1 0 7.07l-1.49 1.49c.01-.82-.12-1.64-.4-2.42l.47-.48a2.98 2.98 0 0 0 0-4.24a2.98 2.98 0 0 0-4.24 0l-3.53 3.53a2.98 2.98 0 0 0 0 4.24m2.82-4.24c.39-.39 1.03-.39 1.42 0a5.003 5.003 0 0 1 0 7.07l-3.54 3.54a5.003 5.003 0 0 1-7.07 0a5.003 5.003 0 0 1 0-7.07l1.49-1.49c-.01.82.12 1.64.4 2.43l-.47.47a2.98 2.98 0 0 0 0 4.24a2.98 2.98 0 0 0 4.24 0l3.53-3.53a2.98 2.98 0 0 0 0-4.24a.973.973 0 0 1 0-1.42\"/>","width":24,"height":24},
"mdi:link-variant-plus": {"body":"<path fill=\"currentColor\" d=\"M10.6 13.4a1 1 0 0 1-1.4 1.4a4.8 4.8 0 0 1 0-7l3.5-3.6a5.1 5.1 0 0 1 7.1 0a5.1 5.1 0 0 1 0 7.1l-1.5 1.5a6.4 6.4 0 0 0-.4-2.4l.5-.5a3.2 3.2 0 0 0 0-4.3a3.2 3.2 0 0 0-4.3 0l-3.5 3.6a2.9 2.9 0 0 0 0 4.2M23 18v2h-3v3h-2v-3h-3v-2h3v-3h2v3m-3.8-4.3a4.8 4.8 0 0 0-1.4-4.5a1 1 0 0 0-1.4 1.4a2.9 2.9 0 0 1 0 4.2l-3.5 3.6a3.2 3.2 0 0 1-4.3 0a3.2 3.2 0 0 1 0-4.3l.5-.4a7.3 7.3 0 0 1-.4-2.5l-1.5 1.5a5.1 5.1 0 0 0 0 7.1a5.1 5.1 0 0 0 7.1 0l1.8-1.8a6 6 0 0 1 3.1-4.3\"/>","width":24,"height":24}, "mdi:link-variant-plus": {"body":"<path fill=\"currentColor\" d=\"M10.6 13.4a1 1 0 0 1-1.4 1.4a4.8 4.8 0 0 1 0-7l3.5-3.6a5.1 5.1 0 0 1 7.1 0a5.1 5.1 0 0 1 0 7.1l-1.5 1.5a6.4 6.4 0 0 0-.4-2.4l.5-.5a3.2 3.2 0 0 0 0-4.3a3.2 3.2 0 0 0-4.3 0l-3.5 3.6a2.9 2.9 0 0 0 0 4.2M23 18v2h-3v3h-2v-3h-3v-2h3v-3h2v3m-3.8-4.3a4.8 4.8 0 0 0-1.4-4.5a1 1 0 0 0-1.4 1.4a2.9 2.9 0 0 1 0 4.2l-3.5 3.6a3.2 3.2 0 0 1-4.3 0a3.2 3.2 0 0 1 0-4.3l.5-.4a7.3 7.3 0 0 1-.4-2.5l-1.5 1.5a5.1 5.1 0 0 0 0 7.1a5.1 5.1 0 0 0 7.1 0l1.8-1.8a6 6 0 0 1 3.1-4.3\"/>","width":24,"height":24},
@ -222,6 +230,7 @@ export const BUNDLED_ICONS = {
"mdi:minus": {"body":"<path fill=\"currentColor\" d=\"M19 13H5v-2h14z\"/>","width":24,"height":24}, "mdi:minus": {"body":"<path fill=\"currentColor\" d=\"M19 13H5v-2h14z\"/>","width":24,"height":24},
"mdi:near-me": {"body":"<path fill=\"currentColor\" d=\"M21 3L3 10.53v.97l6.84 2.66L12.5 21h.96z\"/>","width":24,"height":24}, "mdi:near-me": {"body":"<path fill=\"currentColor\" d=\"M21 3L3 10.53v.97l6.84 2.66L12.5 21h.96z\"/>","width":24,"height":24},
"mdi:palette": {"body":"<path fill=\"currentColor\" d=\"M17.5 12a1.5 1.5 0 0 1-1.5-1.5A1.5 1.5 0 0 1 17.5 9a1.5 1.5 0 0 1 1.5 1.5a1.5 1.5 0 0 1-1.5 1.5m-3-4A1.5 1.5 0 0 1 13 6.5A1.5 1.5 0 0 1 14.5 5A1.5 1.5 0 0 1 16 6.5A1.5 1.5 0 0 1 14.5 8m-5 0A1.5 1.5 0 0 1 8 6.5A1.5 1.5 0 0 1 9.5 5A1.5 1.5 0 0 1 11 6.5A1.5 1.5 0 0 1 9.5 8m-3 4A1.5 1.5 0 0 1 5 10.5A1.5 1.5 0 0 1 6.5 9A1.5 1.5 0 0 1 8 10.5A1.5 1.5 0 0 1 6.5 12M12 3a9 9 0 0 0-9 9a9 9 0 0 0 9 9a1.5 1.5 0 0 0 1.5-1.5c0-.39-.15-.74-.39-1c-.23-.27-.38-.62-.38-1a1.5 1.5 0 0 1 1.5-1.5H16a5 5 0 0 0 5-5c0-4.42-4.03-8-9-8\"/>","width":24,"height":24}, "mdi:palette": {"body":"<path fill=\"currentColor\" d=\"M17.5 12a1.5 1.5 0 0 1-1.5-1.5A1.5 1.5 0 0 1 17.5 9a1.5 1.5 0 0 1 1.5 1.5a1.5 1.5 0 0 1-1.5 1.5m-3-4A1.5 1.5 0 0 1 13 6.5A1.5 1.5 0 0 1 14.5 5A1.5 1.5 0 0 1 16 6.5A1.5 1.5 0 0 1 14.5 8m-5 0A1.5 1.5 0 0 1 8 6.5A1.5 1.5 0 0 1 9.5 5A1.5 1.5 0 0 1 11 6.5A1.5 1.5 0 0 1 9.5 8m-3 4A1.5 1.5 0 0 1 5 10.5A1.5 1.5 0 0 1 6.5 9A1.5 1.5 0 0 1 8 10.5A1.5 1.5 0 0 1 6.5 12M12 3a9 9 0 0 0-9 9a9 9 0 0 0 9 9a1.5 1.5 0 0 0 1.5-1.5c0-.39-.15-.74-.39-1c-.23-.27-.38-.62-.38-1a1.5 1.5 0 0 1 1.5-1.5H16a5 5 0 0 0 5-5c0-4.42-4.03-8-9-8\"/>","width":24,"height":24},
"mdi:percent-outline": {"body":"<path fill=\"currentColor\" d=\"m18.5 3.5l2 2l-15 15l-2-2zM7 4c1.66 0 3 1.34 3 3s-1.34 3-3 3s-3-1.34-3-3s1.34-3 3-3m10 10c1.66 0 3 1.34 3 3s-1.34 3-3 3s-3-1.34-3-3s1.34-3 3-3M7 6c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m10 10c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":24,"height":24},
"mdi:play": {"body":"<path fill=\"currentColor\" d=\"M8 5.14v14l11-7z\"/>","width":24,"height":24}, "mdi:play": {"body":"<path fill=\"currentColor\" d=\"M8 5.14v14l11-7z\"/>","width":24,"height":24},
"mdi:playlist-edit": {"body":"<path fill=\"currentColor\" d=\"M3 6v2h11V6zm0 4v2h11v-2zm17 .1c-.1 0-.3.1-.4.2l-1 1l2.1 2.1l1-1c.2-.2.2-.6 0-.8l-1.3-1.3c-.1-.1-.2-.2-.4-.2m-1.9 1.8l-6.1 6V20h2.1l6.1-6.1zM3 14v2h7v-2z\"/>","width":24,"height":24}, "mdi:playlist-edit": {"body":"<path fill=\"currentColor\" d=\"M3 6v2h11V6zm0 4v2h11v-2zm17 .1c-.1 0-.3.1-.4.2l-1 1l2.1 2.1l1-1c.2-.2.2-.6 0-.8l-1.3-1.3c-.1-.1-.2-.2-.4-.2m-1.9 1.8l-6.1 6V20h2.1l6.1-6.1zM3 14v2h7v-2z\"/>","width":24,"height":24},
"mdi:redo-variant": {"body":"<path fill=\"currentColor\" d=\"M10.5 7A6.5 6.5 0 0 0 4 13.5a6.5 6.5 0 0 0 6.5 6.5H14v-2h-3.5C8 18 6 16 6 13.5S8 9 10.5 9h5.67l-3.08 3.09l1.41 1.41L20 8l-5.5-5.5l-1.42 1.41L16.17 7zM18 18h-2v2h2z\"/>","width":24,"height":24}, "mdi:redo-variant": {"body":"<path fill=\"currentColor\" d=\"M10.5 7A6.5 6.5 0 0 0 4 13.5a6.5 6.5 0 0 0 6.5 6.5H14v-2h-3.5C8 18 6 16 6 13.5S8 9 10.5 9h5.67l-3.08 3.09l1.41 1.41L20 8l-5.5-5.5l-1.42 1.41L16.17 7zM18 18h-2v2h2z\"/>","width":24,"height":24},

@ -0,0 +1,204 @@
<template>
<w-menu
ref="menuRef"
class="translucent-menu"
:anchor="props.anchor"
:self="props.self"
@show="onShow">
<div class="code-block-menu">
<div class="p-2">
<!-- -> `transparent`: the panel behind this is acrylic, and an opaque field on it reads as a
slab with the floating label straddling its edge -->
<w-input
ref="iptFilter"
v-model="state.filter"
dense
outlined
transparent
clearable
hide-bottom-space
:label="t(`editor.codeBlock.filter`)"
:aria-label="t(`editor.codeBlock.filter`)"
@keyup:enter="chooseFirst">
<template #prepend><w-icon name="la:search" /></template>
</w-input>
</div>
<w-separator />
<w-scroll-area class="code-block-menu-list">
<w-list dense>
<!--
The handful worth reaching without typing, above the full set. Only while nothing is being
filtered: with a filter on, two lists to read is worse than one, and every one of these is
in the list below anyway.
-->
<template v-if="!isFiltering">
<w-item
v-for="language of COMMON_LANGUAGES"
:key="`common-${language.id}`"
clickable
@click="choose(language.id)">
<w-item-section>
<w-item-label>{{ language.label }}</w-item-label>
</w-item-section>
<!-- -> A dash for plain text, which goes on the fence as nothing at all -->
<w-item-section side>
<div class="text-caption font-robotomono">{{ language.id || '—' }}</div>
</w-item-section>
</w-item>
<w-separator class="my-1" />
</template>
<w-item
v-for="language of filtered"
:key="language.id"
clickable
@click="choose(language.id)">
<w-item-section>
<w-item-label>{{ language.label }}</w-item-label>
</w-item-section>
<!-- -> The id, because that is what ends up on the fence and what a reader of the source
will see -->
<w-item-section side>
<div class="text-caption font-robotomono">{{ language.id }}</div>
</w-item-section>
</w-item>
<div
v-if="filtered.length < 1"
class="text-caption p-4 text-center text-black/60 dark:text-white/70">
{{ t('editor.codeBlock.noResults') }}
</div>
</w-list>
</w-scroll-area>
</div>
</w-menu>
</template>
<script setup>
import { computed, nextTick, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import hljs from 'highlight.js'
/**
* Picks the language for a fenced code block.
*
* The list is whatever highlight.js has registered asked at runtime rather than kept as a copy here,
* so it cannot drift from what the renderer will actually highlight. Each entry carries the id that
* goes on the fence and the name hljs calls it; the filter matches either, plus the aliases, so `md`
* finds Markdown and `js` finds JavaScript.
*/
// PROPS
const props = defineProps({
anchor: {
type: String,
default: 'bottom left'
},
self: {
type: String,
default: 'top left'
}
})
// EMITS
const emit = defineEmits(['select'])
// I18N
const { t } = useI18n()
/**
* The five worth having at the top. Labelled here rather than taking hljs's own names, which for these
* read as `Plain text`, `HTML, XML` and `Bash` precise, and not what someone scanning a shortlist is
* looking for.
*
* Two of the ids are not what the list below would show either:
* - `sh` and `md` are aliases rather than registered ids, so they are absent from that list; hljs
* resolves them to Bash and Markdown, which is what highlights the block.
* - Plain text has no id at all. A bare fence is how markdown says "no language", and it is what the
* renderer already treats as unhighlighted so there is nothing to put after the backticks. The
* menu shows a dash where the others show their id.
*/
const COMMON_LANGUAGES = [
{ id: '', label: 'Plain Text' },
{ id: 'json', label: 'JSON' },
{ id: 'md', label: 'Markdown' },
{ id: 'sh', label: 'Bash Shell' },
{ id: 'xml', label: 'XML' }
]
/** Every registered language, by display name. Built once: the set cannot change at runtime. */
const ALL_LANGUAGES = hljs
.listLanguages()
.map((id) => {
const definition = hljs.getLanguage(id)
return {
id,
label: definition?.name ?? id,
// -> Searched but never shown: `sh` and `zsh` are how someone looks for Bash
aliases: definition?.aliases ?? []
}
})
.sort((a, b) => a.label.localeCompare(b.label))
// REFS
const menuRef = ref(null)
const iptFilter = ref(null)
// DATA
const state = reactive({
filter: ''
})
// COMPUTED
const isFiltering = computed(() => state.filter.trim().length > 0)
const filtered = computed(() => {
if (!isFiltering.value) {
return ALL_LANGUAGES
}
const needle = state.filter.trim().toLowerCase()
return ALL_LANGUAGES.filter(
(language) =>
language.label.toLowerCase().includes(needle) ||
language.id.includes(needle) ||
language.aliases.some((alias) => alias.includes(needle))
)
})
// METHODS
/** Opens on the whole list with the caret in the filter, whatever the last visit left behind. */
async function onShow() {
state.filter = ''
await nextTick()
iptFilter.value?.focus()
}
function choose(id) {
emit('select', id)
menuRef.value?.hide()
}
/** Enter takes the top match, so a language can be chosen without leaving the keyboard. */
function chooseFirst() {
const first = isFiltering.value ? filtered.value[0] : COMMON_LANGUAGES[0]
if (first) {
choose(first.id)
}
}
</script>
<style scoped>
.code-block-menu {
width: 300px;
}
.code-block-menu-list {
height: 320px;
}
</style>

@ -0,0 +1,421 @@
<template>
<w-menu
ref="menuRef"
class="translucent-menu"
:anchor="props.anchor"
:self="props.self"
@show="onShow">
<div class="emoji-menu">
<!--
The tabs scroll the list rather than switching what is in it: one list with headings is what
makes a search across everything, and a "Frequently Used" section above the groups, simple
enough to be obviously correct.
-->
<div class="emoji-menu-tabs">
<button
v-for="tab of tabs"
:key="tab.key"
type="button"
class="emoji-menu-tab"
:class="{ 'is-active': state.activeTab === tab.key }"
:aria-label="tab.label"
@click="scrollToSection(tab.key)">
<w-icon :name="tab.icon" size="20px" />
<w-tooltip>{{ tab.label }}</w-tooltip>
</button>
</div>
<div class="p-2">
<!-- -> `transparent`, for the same reason as the code block menu's filter: the panel is acrylic -->
<w-input
ref="iptSearch"
v-model="state.search"
dense
outlined
transparent
clearable
hide-bottom-space
:label="t(`editor.emoji.search`)"
:aria-label="t(`editor.emoji.search`)"
@keyup:enter="chooseFirst">
<template #prepend><w-icon name="la:search" /></template>
</w-input>
</div>
<w-separator />
<w-scroll-area ref="scrollRef" class="emoji-menu-list" @scroll="onScroll">
<template v-if="isSearching">
<div class="emoji-menu-heading">{{ t('editor.emoji.results') }}</div>
<div class="emoji-menu-grid">
<button
v-for="[shortcode, character] of searchResults"
:key="`found-${shortcode}`"
type="button"
class="emoji-menu-cell"
:aria-label="shortcode"
@click="choose(shortcode)"
@mouseenter="state.preview = [shortcode, character]"
@focus="state.preview = [shortcode, character]">
{{ character }}
</button>
</div>
<div v-if="searchResults.length < 1" class="emoji-menu-empty">
{{ t('editor.emoji.noResults') }}
</div>
</template>
<template v-else>
<template v-for="section of sections" :key="section.key">
<div :ref="(el) => setSectionRef(section.key, el)" class="emoji-menu-heading">
{{ section.label }}
</div>
<div class="emoji-menu-grid">
<button
v-for="[shortcode, character] of section.emoji"
:key="`${section.key}-${shortcode}`"
type="button"
class="emoji-menu-cell"
:aria-label="shortcode"
@click="choose(shortcode)"
@mouseenter="state.preview = [shortcode, character]"
@focus="state.preview = [shortcode, character]">
{{ character }}
</button>
</div>
</template>
</template>
</w-scroll-area>
<w-separator />
<!-- -> What the pointer is over, spelled out: the grid is 1,800 lookalikes and the shortcode is
what actually lands in the page -->
<div class="emoji-menu-preview">
<div class="emoji-menu-preview-emoji">{{ state.preview ? state.preview[1] : '☝️' }}</div>
<div class="min-w-0 flex-1 truncate">
<div v-if="state.preview" class="text-body2 font-robotomono">
:{{ state.preview[0] }}:
</div>
<div v-else class="text-body2 text-black/54 dark:text-white/70">
{{ t('editor.emoji.pick') }}
</div>
</div>
</div>
</div>
</w-menu>
</template>
<script setup>
import { computed, nextTick, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { EMOJI_GROUPS } from '@/assets/emoji.generated'
/**
* Picks an emoji, as the `:shortcode:` the renderer understands.
*
* What goes into a page is the shortcode, not the character: that is what `markdown-it-emoji` replaces
* and what the renderer then draws as a twemoji SVG, so a page reads the same everywhere regardless of
* the fonts on the machine. The grid shows the characters, which is the one place the system font is
* exactly what is wanted.
*
* The groups come from `assets/emoji.generated.js` see `scripts/generate-emoji.mjs` for why the
* grouping is generated rather than fetched or hand-kept.
*/
// PROPS
const props = defineProps({
anchor: {
type: String,
default: 'bottom left'
},
self: {
type: String,
default: 'top left'
}
})
// EMITS
const emit = defineEmits(['select'])
// I18N
const { t } = useI18n()
/** How many recent picks to keep, and where. Shared across editors and pages by design. */
const RECENT_KEY = 'wiki.emoji.recent'
const RECENT_MAX = 27
/** The tab strip, in the order the sections appear. Recents first, then Unicode's own order. */
const GROUP_TABS = {
smileys_emotion: { icon: 'mdi:emoticon-outline', label: 'editor.emoji.smileysEmotion' },
people_body: { icon: 'mdi:hand-wave-outline', label: 'editor.emoji.peopleBody' },
animals_nature: { icon: 'mdi:dog', label: 'editor.emoji.animalsNature' },
food_drink: { icon: 'mdi:food-apple-outline', label: 'editor.emoji.foodDrink' },
travel_places: { icon: 'mdi:car', label: 'editor.emoji.travelPlaces' },
activities: { icon: 'mdi:basketball', label: 'editor.emoji.activities' },
objects: { icon: 'mdi:lightbulb-outline', label: 'editor.emoji.objects' },
symbols: { icon: 'mdi:percent-outline', label: 'editor.emoji.symbols' },
flags: { icon: 'mdi:flag-outline', label: 'editor.emoji.flags' }
}
const RECENT_TAB = { icon: 'mdi:clock-outline', label: 'editor.emoji.frequentlyUsed' }
// REFS
const menuRef = ref(null)
const iptSearch = ref(null)
const scrollRef = ref(null)
/** Each section's heading element, which is what a tab scrolls to and what the tabs track. */
const sectionEls = new Map()
/** WScrollArea is the scrolling element itself, so its root is what has to be scrolled. */
function scrollEl() {
return scrollRef.value?.$el ?? null
}
// DATA
const state = reactive({
search: '',
/** The `[shortcode, character]` under the pointer, shown in the footer. Null when nothing is. */
preview: null,
recent: [],
activeTab: 'smileys_emotion'
})
// COMPUTED
const isSearching = computed(() => state.search.trim().length > 0)
const tabs = computed(() => [
...(state.recent.length > 0
? [{ key: 'recent', icon: RECENT_TAB.icon, label: t(RECENT_TAB.label) }]
: []),
...EMOJI_GROUPS.map((group) => ({
key: group.slug,
icon: GROUP_TABS[group.slug]?.icon ?? 'mdi:emoticon-outline',
// -> The generated English name is the fallback, for a group the dataset adds later
label: GROUP_TABS[group.slug] ? t(GROUP_TABS[group.slug].label) : group.name
}))
])
const sections = computed(() => [
...(state.recent.length > 0
? [{ key: 'recent', label: t(RECENT_TAB.label), emoji: state.recent }]
: []),
...EMOJI_GROUPS.map((group) => ({
key: group.slug,
label: GROUP_TABS[group.slug] ? t(GROUP_TABS[group.slug].label) : group.name,
emoji: group.emoji
}))
])
/*
Matched on the shortcode alone, which is both the name and what gets written. Underscores are treated
as spaces so that `open mouth` finds `open_mouth`, and every result is a shortcode the renderer knows,
since that is where the list came from.
*/
const searchResults = computed(() => {
const needle = state.search.trim().toLowerCase().replaceAll(' ', '_')
return EMOJI_GROUPS.flatMap((group) =>
group.emoji.filter(([shortcode]) => shortcode.includes(needle))
)
})
// METHODS
function setSectionRef(key, el) {
if (el) {
sectionEls.set(key, el)
} else {
sectionEls.delete(key)
}
}
/** Every pair by shortcode, for turning the stored recents back into something to draw. */
const BY_SHORTCODE = new Map(EMOJI_GROUPS.flatMap((group) => group.emoji))
function readRecent() {
try {
const stored = JSON.parse(globalThis.localStorage?.getItem(RECENT_KEY) ?? '[]')
if (!Array.isArray(stored)) {
return []
}
/*
Only shortcodes are stored, and only ones still in the data survive being read back: a name that
has since gone would otherwise draw an empty cell nobody could explain.
*/
return stored
.filter((shortcode) => BY_SHORTCODE.has(shortcode))
.map((shortcode) => [shortcode, BY_SHORTCODE.get(shortcode)])
} catch {
return []
}
}
function rememberRecent(shortcode) {
const pairs = [
[shortcode, BY_SHORTCODE.get(shortcode)],
...state.recent.filter(([code]) => code !== shortcode)
].slice(0, RECENT_MAX)
state.recent = pairs
try {
globalThis.localStorage?.setItem(RECENT_KEY, JSON.stringify(pairs.map(([code]) => code)))
} catch {
// -> A browser refusing storage costs the recents list and nothing else
}
}
/** Opens on the search field, with recents as they were left. */
async function onShow() {
state.search = ''
state.preview = null
state.recent = readRecent()
state.activeTab = state.recent.length > 0 ? 'recent' : 'smileys_emotion'
await nextTick()
iptSearch.value?.focus()
}
/*
Both of these measure with `getBoundingClientRect`, deliberately.
`offsetTop` is relative to the nearest POSITIONED ancestor, which for these headings is the menu's
floating panel rather than the scroll container -- so comparing it against the container's `scrollTop`
was off by the panel's own offset, and every click landed on the section before the one asked for.
Rects are in viewport space for both sides, so the difference is the real distance.
*/
function scrollToSection(key) {
state.activeTab = key
const list = scrollEl()
const el = sectionEls.get(key)
if (list && el) {
list.scrollTop += el.getBoundingClientRect().top - list.getBoundingClientRect().top
}
}
/** Keeps the tab strip in step with what is on screen while scrolling. */
function onScroll(event) {
const listTop = event.target.getBoundingClientRect().top
let active = sections.value[0]?.key
for (const section of sections.value) {
const el = sectionEls.get(section.key)
// -> A heading counts as reached once it is at or above the top of the visible area
if (el && el.getBoundingClientRect().top - listTop <= 8) {
active = section.key
}
}
state.activeTab = active
}
function choose(shortcode) {
rememberRecent(shortcode)
emit('select', shortcode)
menuRef.value?.hide()
}
/** Enter takes the first match, so an emoji can be picked without leaving the keyboard. */
function chooseFirst() {
const first = isSearching.value ? searchResults.value[0] : sections.value[0]?.emoji?.[0]
if (first) {
choose(first[0])
}
}
</script>
<style scoped lang="scss">
.emoji-menu {
width: 340px;
}
.emoji-menu-tabs {
display: flex;
flex-wrap: nowrap;
padding: 0 4px;
}
/*
A tab is an icon and an underline, which is all the strip needs: the label lives in the tooltip and in
the heading the tab scrolls to.
*/
.emoji-menu-tab {
display: flex;
flex: 1 1 0;
align-items: center;
justify-content: center;
padding: 8px 0 6px;
border-bottom: 2px solid transparent;
color: inherit;
opacity: 0.55;
cursor: pointer;
transition:
opacity 0.15s var(--ease-standard),
border-color 0.15s var(--ease-standard);
&:hover {
opacity: 0.9;
}
&.is-active {
border-bottom-color: var(--color-primary);
opacity: 1;
}
}
.emoji-menu-list {
height: 300px;
}
.emoji-menu-heading {
padding: 8px 10px 4px;
font-size: 12px;
font-weight: 600;
opacity: 0.6;
}
.emoji-menu-grid {
display: grid;
grid-template-columns: repeat(9, 1fr);
padding: 0 6px;
}
.emoji-menu-cell {
display: flex;
aspect-ratio: 1;
align-items: center;
justify-content: center;
border-radius: 4px;
/* -> The emoji itself, at a size worth aiming at; the system font is the point here */
font-size: 20px;
line-height: 1;
cursor: pointer;
&:hover,
&:focus-visible {
background-color: rgb(0 0 0 / 0.08);
outline: none;
@at-root .body--dark & {
background-color: rgb(255 255 255 / 0.14);
}
}
}
.emoji-menu-empty {
padding: 24px 12px;
font-size: 12px;
text-align: center;
opacity: 0.6;
}
.emoji-menu-preview {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 10px;
padding: 8px 10px;
}
.emoji-menu-preview-emoji {
font-size: 26px;
line-height: 1;
}
</style>

@ -5,63 +5,60 @@
<!-- ------------------------------------------------------- --> <!-- ------------------------------------------------------- -->
<!-- SIDE TOOLBAR --> <!-- SIDE TOOLBAR -->
<!-- ------------------------------------------------------- --> <!-- ------------------------------------------------------- -->
<w-btn icon="mdi:link-variant-plus" padding="sm sm" flat @click="notImplemented"> <w-btn icon="mdi:link-variant-plus" padding="sm sm" flat @click="insertLink">
<w-tooltip anchor="center right" self="center left">{{ t('editor.markup.insertLink') }}</w-tooltip> <w-tooltip anchor="center right" self="center left">{{
t('editor.markup.insertLink')
}}</w-tooltip>
</w-btn> </w-btn>
<w-btn icon="mdi:image-plus-outline" padding="sm sm" flat> <!-- -> Straight to the File Manager. The menu this replaces offered two other sources: a remote
<!-- URL, which was never implemented, and the clipboard see `getAssetFromClipboard`, which
Only two of the three rows dismiss the menu, which is what the original did: the first now has no caller. -->
opens the file manager over the top of it. `auto-close` was on the list, where it has <w-btn icon="mdi:image-plus-outline" padding="sm sm" flat @click="insertAssets">
never meant anything -- the list component has no such prop, so it rendered as a stray <w-tooltip anchor="center right" self="center left">{{
attribute -- and the closing was done by a Quasar directive that cannot see a w-menu. t('editor.markup.insertAssets')
--> }}</w-tooltip>
<w-menu ref="assetMenuRef" anchor="top right" self="top left">
<w-list separator>
<w-item clickable @click="insertAssets">
<w-item-section side>
<w-icon name="la:folder-open" color="positive" />
</w-item-section>
<w-item-section><w-item-label>From File Manager...</w-item-label></w-item-section>
</w-item>
<w-item clickable @click="getAssetFromClipboard(); assetMenuRef.hide()">
<w-item-section side>
<w-icon name="la:clipboard" color="brown" />
</w-item-section>
<w-item-section><w-item-label>From Clipboard...</w-item-label></w-item-section>
</w-item>
<w-item clickable @click="notImplemented(); assetMenuRef.hide()">
<w-item-section side>
<w-icon name="la:cloud-download-alt" color="blue" />
</w-item-section>
<w-item-section><w-item-label>From Remote URL...</w-item-label></w-item-section>
</w-item>
</w-list>
</w-menu>
<w-tooltip anchor="center right" self="center left">{{ t('editor.markup.insertAssets') }}</w-tooltip>
</w-btn> </w-btn>
<w-btn icon="mdi:code-json" padding="sm sm" flat @click="notImplemented"> <w-btn icon="mdi:code-json" padding="sm sm" flat>
<w-tooltip anchor="center right" self="center left">{{ t('editor.markup.insertCodeBlock') }}</w-tooltip> <editor-code-block-menu anchor="top right" self="top left" @select="insertCodeBlock" />
<w-tooltip anchor="center right" self="center left">{{
t('editor.markup.insertCodeBlock')
}}</w-tooltip>
</w-btn> </w-btn>
<w-btn icon="mdi:table-large-plus" padding="sm sm" flat @click="insertTable"> <w-btn icon="mdi:table-large-plus" padding="sm sm" flat @click="insertTable">
<w-tooltip anchor="center right" self="center left">{{ t('editor.markup.insertTable') }}</w-tooltip> <w-tooltip anchor="center right" self="center left">{{
t('editor.markup.insertTable')
}}</w-tooltip>
</w-btn> </w-btn>
<w-btn icon="mdi:tab-plus" padding="sm sm" flat @click="notImplemented"> <w-btn icon="mdi:tab-plus" padding="sm sm" flat @click="notImplemented">
<w-tooltip anchor="center right" self="center left">{{ t('editor.markup.insertTabset') }}</w-tooltip> <w-tooltip anchor="center right" self="center left">{{
t('editor.markup.insertTabset')
}}</w-tooltip>
</w-btn> </w-btn>
<w-btn icon="mdi:toy-brick-plus" padding="sm sm" flat @click="notImplemented"> <w-btn icon="mdi:toy-brick-plus" padding="sm sm" flat @click="notImplemented">
<w-tooltip anchor="center right" self="center left">{{ t('editor.markup.insertBlock') }}</w-tooltip> <w-tooltip anchor="center right" self="center left">{{
t('editor.markup.insertBlock')
}}</w-tooltip>
</w-btn> </w-btn>
<w-btn icon="mdi:chart-multiline" padding="sm sm" flat @click="notImplemented"> <w-btn icon="mdi:chart-multiline" padding="sm sm" flat @click="notImplemented">
<w-tooltip anchor="center right" self="center left">{{ t('editor.markup.insertDiagram') }}</w-tooltip> <w-tooltip anchor="center right" self="center left">{{
t('editor.markup.insertDiagram')
}}</w-tooltip>
</w-btn> </w-btn>
<w-btn icon="mdi:book-plus" padding="sm sm" flat @click="notImplemented"> <w-btn icon="mdi:book-plus" padding="sm sm" flat @click="notImplemented">
<w-tooltip anchor="center right" self="center left">{{ t('editor.markup.insertFootnote') }}</w-tooltip> <w-tooltip anchor="center right" self="center left">{{
t('editor.markup.insertFootnote')
}}</w-tooltip>
</w-btn> </w-btn>
<w-btn icon="mdi:cookie-plus" padding="sm sm" @click="notImplemented" flat> <w-btn icon="mdi:emoticon-plus-outline" padding="sm sm" flat>
<w-tooltip anchor="center right" self="center left">{{ t('editor.markup.insertEmoji') }}</w-tooltip> <editor-emoji-menu anchor="top right" self="top left" @select="insertEmoji" />
<w-tooltip anchor="center right" self="center left">{{
t('editor.markup.insertEmoji')
}}</w-tooltip>
</w-btn> </w-btn>
<w-btn icon="mdi:line-scan" padding="sm sm" flat @click="insertHorizontalBar"> <w-btn icon="mdi:line-scan" padding="sm sm" flat @click="insertHorizontalBar">
<w-tooltip anchor="center right" self="center left">{{ t('editor.markup.insertHorizontalBar') }}</w-tooltip> <w-tooltip anchor="center right" self="center left">{{
t('editor.markup.insertHorizontalBar')
}}</w-tooltip>
</w-btn> </w-btn>
<w-space /> <w-space />
<span class="editor-markdown-type">Markdown</span> <span class="editor-markdown-type">Markdown</span>
@ -72,31 +69,41 @@
<!-- ------------------------------------------------------- --> <!-- ------------------------------------------------------- -->
<div class="editor-markdown-toolbar"> <div class="editor-markdown-toolbar">
<w-btn icon="mdi:format-bold" padding="xs sm" flat @click="toggleMarkup({ start: `**` })"> <w-btn icon="mdi:format-bold" padding="xs sm" flat @click="toggleMarkup({ start: `**` })">
<w-tooltip anchor="top middle" self="bottom middle">{{ t('editor.markup.bold') }}</w-tooltip> <w-tooltip anchor="top middle" self="bottom middle">{{
t('editor.markup.bold')
}}</w-tooltip>
</w-btn> </w-btn>
<w-btn <w-btn
icon="mdi:format-italic" icon="mdi:format-italic"
padding="xs sm" padding="xs sm"
flat flat
@click="toggleMarkup({ start: `*` })"> @click="toggleMarkup({ start: `*` })">
<w-tooltip anchor="top middle" self="bottom middle">{{ t('editor.markup.italic') }}</w-tooltip> <w-tooltip anchor="top middle" self="bottom middle">{{
t('editor.markup.italic')
}}</w-tooltip>
</w-btn> </w-btn>
<w-btn <w-btn
icon="mdi:format-strikethrough" icon="mdi:format-strikethrough"
padding="xs sm" padding="xs sm"
flat flat
@click="toggleMarkup({ start: `~~` })"> @click="toggleMarkup({ start: `~~` })">
<w-tooltip anchor="top middle" self="bottom middle">{{ t('editor.markup.strikethrough') }}</w-tooltip> <w-tooltip anchor="top middle" self="bottom middle">{{
t('editor.markup.strikethrough')
}}</w-tooltip>
</w-btn> </w-btn>
<w-btn icon="mdi:format-header-pound" padding="xs sm" flat> <w-btn icon="mdi:format-header-pound" padding="xs sm" flat>
<w-tooltip anchor="top middle" self="bottom middle">{{ t('editor.markup.header') }}</w-tooltip> <w-tooltip anchor="top middle" self="bottom middle">{{
t('editor.markup.header')
}}</w-tooltip>
<w-menu auto-close> <w-menu auto-close>
<w-list separator> <w-list separator>
<w-item v-for="lvl in 6" clickable @click="setHeaderLine(lvl)"> <w-item v-for="lvl in 6" clickable @click="setHeaderLine(lvl)">
<w-item-section side> <w-item-section side>
<w-icon :name="HEADER_ICONS[lvl - 1]" /> <w-icon :name="HEADER_ICONS[lvl - 1]" />
</w-item-section> </w-item-section>
<w-item-section>{{ t('editor.markup.headerLevel', { level: lvl }) }}</w-item-section> <w-item-section>{{
t('editor.markup.headerLevel', { level: lvl })
}}</w-item-section>
</w-item> </w-item>
</w-list> </w-list>
</w-menu> </w-menu>
@ -106,26 +113,32 @@
padding="xs sm" padding="xs sm"
flat flat
@click="toggleMarkup({ start: `~` })"> @click="toggleMarkup({ start: `~` })">
<w-tooltip anchor="top middle" self="bottom middle">{{ t('editor.markup.subscript') }}</w-tooltip> <w-tooltip anchor="top middle" self="bottom middle">{{
t('editor.markup.subscript')
}}</w-tooltip>
</w-btn> </w-btn>
<w-btn <w-btn
icon="mdi:format-superscript" icon="mdi:format-superscript"
padding="xs sm" padding="xs sm"
flat flat
@click="toggleMarkup({ start: `^` })"> @click="toggleMarkup({ start: `^` })">
<w-tooltip anchor="top middle" self="bottom middle">{{ t('editor.markup.superscript') }}</w-tooltip> <w-tooltip anchor="top middle" self="bottom middle">{{
t('editor.markup.superscript')
}}</w-tooltip>
</w-btn> </w-btn>
<w-btn icon="mdi:alpha-t-box-outline" padding="xs sm" flat> <w-btn icon="mdi:alpha-t-box-outline" padding="xs sm" flat>
<w-tooltip anchor="top middle" self="bottom middle">{{ t('editor.markup.blockquoteAdmonitions') }}</w-tooltip> <w-tooltip anchor="top middle" self="bottom middle">{{
t('editor.markup.blockquoteAdmonitions')
}}</w-tooltip>
<w-menu auto-close> <w-menu auto-close>
<w-list separator> <w-list separator>
<w-item clickable @click="insertBeforeEachLine({ content: `> `})"> <w-item clickable @click="insertBeforeEachLine({ content: `> ` })">
<w-item-section side><w-icon name="mdi:format-quote-close" /></w-item-section> <w-item-section side><w-icon name="mdi:format-quote-close" /></w-item-section>
<w-item-section>{{ t('editor.markup.blockquote') }}</w-item-section> <w-item-section>{{ t('editor.markup.blockquote') }}</w-item-section>
</w-item> </w-item>
<w-item <w-item
clickable clickable
@click="insertBeforeEachLine({ content: `> `, after: `{.is-info}`})"> @click="insertBeforeEachLine({ content: `> `, after: `{.is-info}` })">
<w-item-section side> <w-item-section side>
<w-icon name="mdi:information-box" color="blue-7" /> <w-icon name="mdi:information-box" color="blue-7" />
</w-item-section> </w-item-section>
@ -133,7 +146,7 @@
</w-item> </w-item>
<w-item <w-item
clickable clickable
@click="insertBeforeEachLine({ content: `> `, after: `{.is-success}`})"> @click="insertBeforeEachLine({ content: `> `, after: `{.is-success}` })">
<w-item-section side> <w-item-section side>
<w-icon name="mdi:check-circle" color="positive" /> <w-icon name="mdi:check-circle" color="positive" />
</w-item-section> </w-item-section>
@ -141,7 +154,7 @@
</w-item> </w-item>
<w-item <w-item
clickable clickable
@click="insertBeforeEachLine({ content: `> `, after: `{.is-warning}`})"> @click="insertBeforeEachLine({ content: `> `, after: `{.is-warning}` })">
<w-item-section side> <w-item-section side>
<w-icon name="mdi:alert-box" color="orange" /> <w-icon name="mdi:alert-box" color="orange" />
</w-item-section> </w-item-section>
@ -149,7 +162,7 @@
</w-item> </w-item>
<w-item <w-item
clickable clickable
@click="insertBeforeEachLine({ content: `> `, after: `{.is-danger}`})"> @click="insertBeforeEachLine({ content: `> `, after: `{.is-danger}` })">
<w-item-section side> <w-item-section side>
<w-icon name="mdi:close-box" color="negative" /> <w-icon name="mdi:close-box" color="negative" />
</w-item-section> </w-item-section>
@ -162,40 +175,50 @@
icon="mdi:format-list-bulleted" icon="mdi:format-list-bulleted"
padding="xs sm" padding="xs sm"
flat flat
@click="insertBeforeEachLine({ content: `- `})"> @click="insertBeforeEachLine({ content: `- ` })">
<w-tooltip anchor="top middle" self="bottom middle">{{ t('editor.markup.unorderedList') }}</w-tooltip> <w-tooltip anchor="top middle" self="bottom middle">{{
t('editor.markup.unorderedList')
}}</w-tooltip>
</w-btn> </w-btn>
<w-btn <w-btn
icon="mdi:format-list-numbered" icon="mdi:format-list-numbered"
padding="xs sm" padding="xs sm"
flat flat
@click="insertBeforeEachLine({ content: `1. `})"> @click="insertBeforeEachLine({ content: `1. ` })">
<w-tooltip anchor="top middle" self="bottom middle">{{ t('editor.markup.orderedList') }}</w-tooltip> <w-tooltip anchor="top middle" self="bottom middle">{{
t('editor.markup.orderedList')
}}</w-tooltip>
</w-btn> </w-btn>
<w-btn icon="mdi:format-list-checks" padding="xs sm" flat> <w-btn icon="mdi:format-list-checks" padding="xs sm" flat>
<w-tooltip anchor="top middle" self="bottom middle">{{ t('editor.markup.taskList') }}</w-tooltip> <w-tooltip anchor="top middle" self="bottom middle">{{
t('editor.markup.taskList')
}}</w-tooltip>
<w-menu auto-close> <w-menu auto-close>
<w-list separator> <w-list separator>
<w-item clickable @click="insertBeforeEachLine({ content: `- [ ] `})"> <w-item clickable @click="insertBeforeEachLine({ content: `- [ ] ` })">
<w-item-section side><w-icon name="mdi:checkbox-blank-outline" /></w-item-section> <w-item-section side><w-icon name="mdi:checkbox-blank-outline" /></w-item-section>
<w-item-section>{{ t('editor.markup.taskListUnchecked') }}</w-item-section> <w-item-section>{{ t('editor.markup.taskListUnchecked') }}</w-item-section>
</w-item> </w-item>
<w-item clickable @click="insertBeforeEachLine({ content: `- [x] `})"> <w-item clickable @click="insertBeforeEachLine({ content: `- [x] ` })">
<w-item-section side><w-icon name="mdi:checkbox-outline" /></w-item-section> <w-item-section side><w-icon name="mdi:checkbox-outline" /></w-item-section>
<w-item-section>{{ t('editor.markup.taskListChecked') }}</w-item-section> <w-item-section>{{ t('editor.markup.taskListChecked') }}</w-item-section>
</w-item> </w-item>
</w-list> </w-list>
</w-menu> </w-menu>
</w-btn> </w-btn>
<w-btn icon="mdi:code-tags" padding="xs sm" flat @click='toggleMarkup({ start: "`" })'> <w-btn icon="mdi:code-tags" padding="xs sm" flat @click="toggleMarkup({ start: '`' })">
<w-tooltip anchor="top middle" self="bottom middle">{{ t('editor.markup.inlineCode') }}</w-tooltip> <w-tooltip anchor="top middle" self="bottom middle">{{
t('editor.markup.inlineCode')
}}</w-tooltip>
</w-btn> </w-btn>
<w-btn <w-btn
icon="mdi:keyboard-variant" icon="mdi:keyboard-variant"
padding="xs sm" padding="xs sm"
flat flat
@click="toggleMarkup({ start: `<kbd>`, end: `</kbd>` })"> @click="toggleMarkup({ start: `<kbd>`, end: `</kbd>` })">
<w-tooltip anchor="top middle" self="bottom middle">{{ t('editor.markup.keyboardKey') }}</w-tooltip> <w-tooltip anchor="top middle" self="bottom middle">{{
t('editor.markup.keyboardKey')
}}</w-tooltip>
</w-btn> </w-btn>
</div> </div>
<!-- ------------------------------------------------------- --> <!-- ------------------------------------------------------- -->
@ -206,7 +229,9 @@
<transition name="editor-markdown-preview"> <transition name="editor-markdown-preview">
<div class="editor-markdown-preview" v-if="state.previewShown"> <div class="editor-markdown-preview" v-if="state.previewShown">
<div class="editor-markdown-preview-toolbar"> <div class="editor-markdown-preview-toolbar">
<strong><em>{{ t('editor.renderPreview') }}</em></strong> <strong
><em>{{ t('editor.renderPreview') }}</em></strong
>
<w-separator class="ml-4 mr-2" vertical inset /> <w-separator class="ml-4 mr-2" vertical inset />
<w-btn <w-btn
icon="mdi:arrow-vertical-lock" icon="mdi:arrow-vertical-lock"
@ -214,14 +239,18 @@
flat flat
@click="state.previewScrollSync = !state.previewScrollSync" @click="state.previewScrollSync = !state.previewScrollSync"
:color="state.previewScrollSync ? `primary` : null"> :color="state.previewScrollSync ? `primary` : null">
<w-tooltip anchor="top middle" self="bottom middle">{{ t('editor.toggleScrollSync') }}</w-tooltip> <w-tooltip anchor="top middle" self="bottom middle">{{
t('editor.toggleScrollSync')
}}</w-tooltip>
</w-btn> </w-btn>
<w-btn <w-btn
icon="mdi:eye-off-outline" icon="mdi:eye-off-outline"
padding="xs sm" padding="xs sm"
flat flat
@click="state.previewShown = false"> @click="state.previewShown = false">
<w-tooltip anchor="top middle" self="bottom middle">{{ t('editor.togglePreviewPane') }}</w-tooltip> <w-tooltip anchor="top middle" self="bottom middle">{{
t('editor.togglePreviewPane')
}}</w-tooltip>
</w-btn> </w-btn>
</div> </div>
<!-- <!--
@ -245,8 +274,13 @@
import { reactive, ref, shallowRef, nextTick, onMounted, watch, onBeforeUnmount } from 'vue' import { reactive, ref, shallowRef, nextTick, onMounted, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { dialog } from '@/composables/dialog'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import EditorCodeBlockMenu from '@/components/EditorCodeBlockMenu.vue'
import EditorEmojiMenu from '@/components/EditorEmojiMenu.vue'
import LinkPickerDialog from '@/components/LinkPickerDialog.vue'
import { useCommonStore } from '@/stores/common' import { useCommonStore } from '@/stores/common'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { usePageStore } from '@/stores/page' import { usePageStore } from '@/stores/page'
@ -259,7 +293,6 @@ import * as monaco from 'monaco-editor'
import { Position, Range } from 'monaco-editor' import { Position, Range } from 'monaco-editor'
import { MarkdownRenderer } from '@/renderers/markdown' import { MarkdownRenderer } from '@/renderers/markdown'
// STORES // STORES
const commonStore = useCommonStore() const commonStore = useCommonStore()
@ -275,6 +308,8 @@ const { t } = useI18n()
let editor let editor
let md let md
/** Where the paste listener ended up, so it can be taken off the same node. See the note in onMounted. */
let pasteCaptureNode = null
const monacoRef = ref(null) const monacoRef = ref(null)
const editorPreviewContainerRef = ref(null) const editorPreviewContainerRef = ref(null)
@ -291,8 +326,6 @@ const HEADER_ICONS = [
'mdi:format-header-6' 'mdi:format-header-6'
] ]
const assetMenuRef = ref(null)
const state = reactive({ const state = reactive({
previewShown: true, previewShown: true,
previewScrollSync: true previewScrollSync: true
@ -323,12 +356,100 @@ function insertAssetClb(opts) {
}, 500) }, 500)
} }
/**
* A fenced code block in the chosen language.
*
* Wraps the selection when there is one marking a few lines and picking a language reads as "this is
* code" and otherwise opens an empty block with the caret on the line inside it, ready to type.
*
* The fence has to start a line of its own, so a cursor sitting mid-sentence breaks out of it first.
*/
function insertCodeBlock(language) {
const model = editor.getModel()
const selection = editor.getSelection()
const selected = model.getValueInRange(selection)
const startLine = model.getLineContent(selection.startLineNumber)
const endLine = model.getLineContent(selection.endLineNumber)
const before = startLine.slice(0, selection.startColumn - 1).trim().length > 0 ? '\n\n' : ''
const after = endLine.slice(selection.endColumn - 1).trim().length > 0 ? '\n\n' : '\n'
editor.executeEdits('', [
{
range: selection,
text: `${before}\`\`\`${language}\n${selected}\n\`\`\`${after}`,
forceMoveMarkers: true
}
])
if (!selected) {
// -> Onto the empty line between the fences, which is the only place typing makes sense next
const openerLine = selection.startLineNumber + (before ? 2 : 0)
editor.setPosition({ lineNumber: openerLine + 1, column: 1 })
}
editor.focus()
}
/**
* The chosen emoji, as its shortcode.
*
* `:tada:` rather than 🎉, because that is what the renderer replaces see `renderers/markdown.js`,
* where the emoji plugin's tokens are the only ones handed to twemoji. A raw character would survive
* into the page and be drawn by whatever font the reader happens to have.
*/
function insertEmoji(shortcode) {
insertAtCursor({ content: `:${shortcode}:` })
}
function insertTable() { function insertTable() {
siteStore.$patch({ siteStore.$patch({
overlay: 'TableEditor' overlay: 'TableEditor'
}) })
} }
/**
* The table the overlay built, at the cursor.
*
* Kept on its own line: a table only parses as one when its first row starts a line, so inserting into
* the middle of a sentence has to break out of it. The blank line after is what separates it from
* whatever the cursor was sitting in front of.
*/
function insertTableClb(markdown) {
const position = editor.getPosition()
const line = editor.getModel().getLineContent(position.lineNumber)
const before = line.slice(0, position.column - 1).trim().length > 0 ? '\n\n' : ''
const after = line.slice(position.column - 1).trim().length > 0 ? '\n\n' : '\n'
insertAtCursor({ content: `${before}${markdown}${after}` })
}
/**
* Insert a link, from the shared picker.
*
* Whatever is selected becomes the link's text, so marking a phrase and pressing the button reads as
* "make this a link". With nothing selected the picker's own answer supplies it: the title of the page
* that was chosen, or the URL itself, which is at least something to type over.
*
* `{target="_blank"}` is markdown-it-attrs syntax, and `target` is one of the three attributes the
* stored render is allowed to keep see `renderers/markdown.js` and `models/rendering.ts`.
*/
function insertLink() {
dialog({ component: LinkPickerDialog }).onOk(({ href, openInNewTab, title }) => {
const selection = editor.getSelection()
const selected = editor.getModel().getValueInRange(selection)
const label = selected || title || href
const attributes = openInNewTab ? '{target="_blank"}' : ''
/*
One edit for both cases: a selection is replaced, and an empty selection -- which is all a bare
cursor is -- inserts. `insertAtCursor` cannot do the first, since it builds its own empty range.
*/
editor.executeEdits('', [
{
range: selection,
text: `[${label}](${href})${attributes}`,
forceMoveMarkers: true
}
])
editor.focus()
})
}
/** /**
* Set current line as header * Set current line as header
*/ */
@ -531,37 +652,80 @@ function openEditorSettings() {
siteStore.$patch({ overlay: 'EditorMarkdownConfig' }) siteStore.$patch({ overlay: 'EditorMarkdownConfig' })
} }
async function getAssetFromClipboard() { /**
try { * Take files the author brought in pasted or dropped and write markdown for them at the cursor.
const permission = await navigator.permissions.query({ *
name: 'clipboard-read' * Nothing is uploaded here. Each one becomes a pending asset held against a `blob:` URL that the
}) * markdown points at, and `UploadPendingAssetsDialog` sends them on save and rewrites those URLs to
if (permission.state === 'denied') { * wherever they actually landed. So the editor shows the image immediately and the page never stores a
throw new Error('Not allowed to read clipboard.') * blob URL.
} *
const clipboardContents = await navigator.clipboard.read() * An image goes in as one, anything else as a link with its file name for text a dropped PDF is a
let hasValidItem = false * link to a PDF, not a broken picture. The name is the image's alt text as well, which is both what the
for (const item of clipboardContents) { * handler this replaces did and better than nothing for a reader who cannot see it.
const imageType = item.types.find((t) => t.startsWith('image/')) */
if (imageType) { function insertFilesAsAssets(files) {
hasValidItem = true const markup = files.map((file) => {
const blob = await item.getType(imageType) const blobUrl = editorStore.addPendingAsset(file)
const blobUrl = editorStore.addPendingAsset(blob) return `${file.type.startsWith('image/') ? '!' : ''}[${file.name}](${blobUrl})`
insertAtCursor({ })
content: `![](${blobUrl})` // -> One per line: two images on the same line is rarely what was meant by dropping two files
}) insertAtCursor({ content: markup.join('\n') })
} }
}
if (!hasValidItem) { /** Whether a paste or drop is carrying files, as opposed to text. */
throw new Error('No supported content found in the Clipboard.') function hasFiles(transfer) {
} return (transfer?.files?.length ?? 0) > 0
} catch (err) { }
return notify({
type: 'negative', /*
message: 'Unable to copy from Clipboard', Pasting a file inserts it; pasting anything else is left alone.
caption: err.message
}) Text wins when both are on the clipboard. Copying from a spreadsheet or a design tool puts a bitmap
there ALONGSIDE the text, and an editor that answered those pastes with a screenshot would be
infuriating -- so the image is only taken when there is no text to prefer.
*/
function onEditorPaste(event) {
if (!hasFiles(event.clipboardData)) {
return
}
if ((event.clipboardData.getData('text/plain') ?? '').trim().length > 0) {
return
}
/*
Taken over completely. `stopPropagation` as well as `preventDefault`, because this runs in capture
ABOVE the editor: letting it travel on would hand the same files to Monaco's paste-as feature, which
would answer the paste a second time in its own way.
*/
event.preventDefault()
event.stopPropagation()
insertFilesAsAssets([...event.clipboardData.files])
}
/*
A drop has to be claimed twice: `dragover` is what tells the browser this is a valid target -- without
it there is no drop at all, just the browser navigating away to the file -- and `drop` is where it
arrives.
*/
function onEditorDragOver(event) {
if (!hasFiles(event.dataTransfer) && !(event.dataTransfer?.types ?? []).includes('Files')) {
return
}
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}
function onEditorDrop(event) {
if (!hasFiles(event.dataTransfer)) {
return
}
event.preventDefault()
// -> Dropped text lands where it was dropped, and so should a file: the cursor moves to meet it
const target = editor.getTargetAtClientPoint(event.clientX, event.clientY)
if (target?.position) {
editor.setPosition(target.position)
} }
insertFilesAsAssets([...event.dataTransfer.files])
} }
function reloadEditorContent() { function reloadEditorContent() {
@ -728,22 +892,23 @@ onMounted(async () => {
}, 500) }, 500)
) )
// -> Handle asset drop /*
editor.getContainerDomNode().addEventListener('drop', (ev) => { Files arriving by paste or by drop.
ev.preventDefault()
for (const file of ev.dataTransfer.files) { Paste is CAPTURED on the element above the editor, and that is the whole trick. Monaco's own
const blobUrl = editorStore.addPendingAsset(file) paste-as feature (`CopyPasteController`) listens in the capture phase on the editor's container and
if (file.type.startsWith('image')) { calls `stopImmediatePropagation()` for every paste it claims -- which includes any paste carrying
insertAtCursor({ files. A listener on that container or below it, in either phase, is simply never reached. Capture
content: `![${file.name}](${blobUrl})` runs outside-in, so one level up goes first and can decide before Monaco sees it.
})
} else { The drop half replaces a listener that could not fire either, for a different reason: without
insertAtCursor({ `dragover` claiming the target, the browser treats a file dropped on a page as a navigation and
content: `[${file.name}](${blobUrl})` opens it, and the drop event never reaches anything here.
}) */
} pasteCaptureNode = monacoRef.value.parentElement ?? monacoRef.value
} pasteCaptureNode.addEventListener('paste', onEditorPaste, true)
}) monacoRef.value.addEventListener('dragover', onEditorDragOver)
monacoRef.value.addEventListener('drop', onEditorDrop)
// -> Post init // -> Post init
@ -754,6 +919,7 @@ onMounted(async () => {
}) })
EVENT_BUS.on('insertAsset', insertAssetClb) EVENT_BUS.on('insertAsset', insertAssetClb)
EVENT_BUS.on('insertTable', insertTableClb)
EVENT_BUS.on('openEditorSettings', openEditorSettings) EVENT_BUS.on('openEditorSettings', openEditorSettings)
EVENT_BUS.on('reloadEditorContent', reloadEditorContent) EVENT_BUS.on('reloadEditorContent', reloadEditorContent)
@ -792,8 +958,12 @@ onMounted(async () => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
EVENT_BUS.off('insertAsset', insertAssetClb) EVENT_BUS.off('insertAsset', insertAssetClb)
EVENT_BUS.off('insertTable', insertTableClb)
EVENT_BUS.off('openEditorSettings', openEditorSettings) EVENT_BUS.off('openEditorSettings', openEditorSettings)
EVENT_BUS.off('reloadEditorContent', reloadEditorContent) EVENT_BUS.off('reloadEditorContent', reloadEditorContent)
pasteCaptureNode?.removeEventListener('paste', onEditorPaste, true)
monacoRef.value?.removeEventListener('dragover', onEditorDragOver)
monacoRef.value?.removeEventListener('drop', onEditorDrop)
if (editor) { if (editor) {
editor.dispose() editor.dispose()
} }

@ -0,0 +1,443 @@
<template>
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
<w-card class="link-picker" style="width: 860px; max-width: 90vw">
<w-card-section class="card-header">
<w-icon name="la:link" size="sm" class="mr-2" />
<span>{{ props.title ?? t('linkPicker.title') }}</span>
</w-card-section>
<!-- -> Inset from the card's edges, as in the icon picker: the strip is a segmented control with
a track of its own, so it sits ON the card rather than spanning it edge to edge -->
<w-tabs class="m-2" v-model="state.currentTab" no-caps inline-label>
<w-tab name="page" icon="la:file-alt" :label="t(`linkPicker.page`)" />
<w-tab name="url" icon="la:globe" :label="t(`linkPicker.url`)" />
</w-tabs>
<w-separator />
<w-tab-panels v-model="state.currentTab">
<!-- ----------------------- -->
<!-- A page of this wiki -->
<!-- ----------------------- -->
<w-tab-panel class="p-0" name="page">
<div class="link-picker-browser flex flex-nowrap">
<div class="link-picker-tree w-1/3">
<w-scroll-area style="height: 300px">
<!-- -> No side padding: the rows carry their own and span the column, as in the File
Manager. Padding here would inset the highlight band as well. -->
<div>
<tree
ref="treeComp"
v-model:selected="state.currentFolderId"
:nodes="state.treeNodes"
:roots="state.treeRoots"
:use-lazy-load="true"
:context-action-list="[]"
@lazy-load="treeLazyLoad" />
</div>
</w-scroll-area>
</div>
<div class="w-2/3">
<w-scroll-area style="height: 300px">
<w-list class="link-picker-list" dense>
<w-item
v-for="item of state.items"
:key="item.id"
clickable
active-class="active"
:active="item.type === `page` && item.path === state.path"
@click="selectItem(item)">
<w-item-section side>
<w-icon :name="item.icon" size="sm" />
</w-item-section>
<w-item-section>
<w-item-label>{{ item.title }}</w-item-label>
<w-item-label caption class="font-robotomono">/{{ item.path }}</w-item-label>
</w-item-section>
</w-item>
</w-list>
<div
v-if="state.items.length < 1 && !state.isFetching"
class="text-caption text-center p-6 text-black/60 dark:text-white/70">
{{ t('linkPicker.emptyFolder') }}
</div>
</w-scroll-area>
</div>
</div>
</w-tab-panel>
<!-- ----------------------- -->
<!-- Anywhere else -->
<!-- ----------------------- -->
<w-tab-panel class="p-4" name="url">
<w-input
ref="iptUrl"
v-model="state.url"
outlined
dense
hide-bottom-space
:label="t(`linkPicker.linkUrl`)"
:aria-label="t(`linkPicker.linkUrl`)"
placeholder="https://example.com/page" />
<w-checkbox
v-if="props.newTabOption"
class="mt-4"
v-model="state.openInNewTab"
:label="t(`linkPicker.openInNewTab`)" />
</w-tab-panel>
</w-tab-panels>
<w-separator />
<!-- -> The same footer the icon picker has: what is about to be committed, spelled out, since
both tabs can be half-filled and only one of them is the answer -->
<w-card-section class="flex flex-nowrap items-center py-2">
<w-icon
:name="state.currentTab === `page` ? `la:file-alt` : `la:globe`"
size="sm"
color="primary" />
<div class="min-w-0 flex-1 pl-3">
<div class="text-caption text-grey">{{ t('linkPicker.selection') }}</div>
<div class="text-body2 font-robotomono link-picker-href">{{ href || '—' }}</div>
</div>
</w-card-section>
<w-separator />
<w-card-actions class="card-actions">
<w-space />
<w-btn
class="acrylic-btn"
flat
icon="la:times"
:label="t(`common.actions.cancel`)"
color="grey-7"
padding="xs md"
@click="onDialogCancel" />
<w-btn
icon="la:check"
:label="props.okLabel ?? t(`common.actions.insert`)"
unelevated
color="primary"
padding="xs md"
:disabled="!canSubmit"
@click="submit" />
</w-card-actions>
</w-card>
</w-dialog>
</template>
<script setup>
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import fileTypes from '@/helpers/fileTypes'
import Tree from '@/components/TreeNav.vue'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
/**
* Picks a link target: a page of this wiki, or any URL.
*
* Written to be opened from anywhere that needs one the markdown editor's Insert Link, the target of
* a page relation so it decides nothing about what the link is FOR. It answers with
* `{ href, openInNewTab, title }` and leaves the caller to render that as markdown, store it on a
* relation, or whatever else.
*
* dialog({ component: LinkPickerDialog }).onOk(({ href }) => ...)
*/
// PROPS
const props = defineProps({
/** Card heading. The insert-a-link wording by default. */
title: {
type: String,
default: null
},
/** Label on the confirm button, for a caller that is selecting rather than inserting. */
okLabel: {
type: String,
default: null
},
/** An href to open on, so re-opening the picker starts where the last choice left it. */
initialHref: {
type: String,
default: ''
},
/**
* Whether the URL tab offers "open in a new tab". Off for a caller with nowhere to put the answer
* a control whose effect is discarded is worse than no control.
*/
newTabOption: {
type: Boolean,
default: true
}
})
// EMITS
defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
// STORES
const pageStore = usePageStore()
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// REFS
const treeComp = ref(null)
const iptUrl = ref(null)
// DATA
const state = reactive({
currentTab: 'page',
/** Folder whose contents the right-hand pane lists. Null is the site root. */
currentFolderId: null,
treeNodes: {},
treeRoots: [],
items: [],
/** The chosen page, as a slash path with no leading slash. Only a row in the list sets it. */
path: '',
/** Title of the page the path came from, which a caller can use as the link's text. */
pageTitle: '',
isFetching: false,
url: 'https://',
openInNewTab: false
})
// COMPUTED
const href = computed(() =>
state.currentTab === 'page' ? (state.path ? `/${state.path}` : '') : state.url.trim()
)
const canSubmit = computed(() => {
if (state.currentTab === 'page') {
return state.path.length > 0
}
// -> The scheme alone is what the field is prefilled with, so it does not count as an answer
return href.value.length > 0 && !/^[a-z][a-z0-9+.-]*:\/*$/i.test(href.value)
})
// WATCHERS
watch(
() => state.currentFolderId,
(folderId) => loadTree({ parentId: folderId })
)
// METHODS
/**
* The message an API failure should be reported with the server's own if it sent one, since ky
* throws before the caller ever sees the body.
*/
async function apiErrorMessage(err, fallback) {
const message = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
return message || err.message || fallback
}
/**
* Loads one folder into the tree, and when that folder is the selected one into the list beside it.
*
* `initLoad` also asks for the folders above the one being listed, so that opening on a page buried a
* few levels down draws its whole branch from a single request. Those extra entries come back flagged
* `isAncestor` and belong in the tree only, never in the list.
*/
async function loadTree({ parentId = null, parentPath = null, initLoad = false }) {
if (state.isFetching) {
return
}
state.isFetching = true
const isCurrentFolder = (parentId ?? null) === state.currentFolderId
if (isCurrentFolder) {
state.items = []
}
try {
const entries = await API_CLIENT.get(`sites/${siteStore.id}/tree`, {
searchParams: {
...(parentId ? { parentId } : {}),
...(parentPath ? { parentPath } : {}),
types: 'folder,page',
includeAncestors: initLoad,
includeRootFolders: initLoad
}
}).json()
for (const entry of entries ?? []) {
const path = entry.folderPath ? `${entry.folderPath}/${entry.fileName}` : entry.fileName
if (entry.type === 'folder') {
state.treeNodes[entry.id] = {
folderPath: entry.folderPath,
fileName: entry.fileName,
title: entry.title,
children: state.treeNodes[entry.id]?.children ?? []
}
if (entry.folderPath) {
const parentOfEntry = parentId ?? findFolderIdByPath(entry.folderPath)
if (
entry.id !== parentOfEntry &&
!state.treeNodes[parentOfEntry]?.children?.includes(entry.id)
) {
state.treeNodes[parentOfEntry]?.children?.push(entry.id)
}
} else if (!state.treeRoots.includes(entry.id)) {
state.treeRoots.push(entry.id)
}
}
// -> An ancestor is drawn in the tree to give the branch its shape; it is not IN this folder
if (isCurrentFolder && !entry.isAncestor) {
state.items.push({
id: entry.id,
type: entry.type,
title: entry.title,
path,
icon: entry.type === 'folder' ? fileTypes.folder.icon : fileTypes.page.icon
})
}
}
// -> Folders first, as the File Manager lists them, then by what they are called
state.items.sort((a, b) =>
a.type === b.type ? a.title.localeCompare(b.title) : a.type === 'folder' ? -1 : 1
)
} catch (err) {
notify({
type: 'negative',
message: t('linkPicker.loadFailed'),
caption: await apiErrorMessage(err, 'An unexpected error occured.')
})
}
if (parentId) {
treeComp.value?.setLoaded(parentId)
}
state.isFetching = false
}
function treeLazyLoad(nodeId, isCurrent, { done }) {
loadTree({ parentId: nodeId }).then(done)
}
/** The id of an already-loaded folder, addressed the way a path addresses it. */
function findFolderIdByPath(path) {
if (!path) {
return null
}
const entry = Object.entries(state.treeNodes).find(
([, node]) => (node.folderPath ? `${node.folderPath}/${node.fileName}` : node.fileName) === path
)
return entry?.[0] ?? null
}
/** A folder is somewhere to look; a page is the answer. */
function selectItem(item) {
if (item.type === 'folder') {
state.currentFolderId = item.id
treeComp.value?.setOpened(item.id)
return
}
state.path = item.path
state.pageTitle = item.title
}
function submit() {
onDialogOK({
href: href.value,
// -> Only ever true for a URL: a page of this wiki opens in the tab the reader is already in
openInNewTab: state.currentTab === 'url' && props.newTabOption && state.openInNewTab,
title: state.currentTab === 'page' ? state.pageTitle : ''
})
}
// MOUNTED
onMounted(async () => {
/*
An href that is already set decides which tab opens and what it starts on, so re-opening the picker
on a link that exists starts from that link rather than from nothing. A page shows up as the
highlighted row once its folder is listed, and in the footer either way.
*/
if (props.initialHref) {
if (/^[a-z][a-z0-9+.-]*:/i.test(props.initialHref) || props.initialHref.startsWith('//')) {
state.currentTab = 'url'
state.url = props.initialHref
} else {
state.path = props.initialHref.replace(/^\/+/, '')
}
}
// -> Opens on the folder holding the page being edited, which is where a link is most often going
const startFolder = pageStore.folderPath
await loadTree({ parentPath: startFolder, initLoad: true })
const startFolderId = findFolderIdByPath(startFolder)
if (startFolderId) {
const parts = startFolder.split('/')
for (let i = 1; i <= parts.length; i++) {
const ancestorId = findFolderIdByPath(parts.slice(0, i).join('/'))
if (ancestorId) {
treeComp.value?.setOpened(ancestorId)
}
}
state.currentFolderId = startFolderId
} else {
// -> Already at the root, which the watcher above will not fire for
await loadTree({})
}
if (state.currentTab === 'url') {
await nextTick()
iptUrl.value?.focus()
}
})
</script>
<style lang="scss">
.link-picker {
&-browser {
height: 300px;
max-height: 90vh;
}
/* -> The tree column carries the recessed surface, as it does in the File Manager */
&-tree {
height: 300px;
@at-root .body--light & {
background-color: $blue-grey-1;
}
@at-root .body--dark & {
background-color: $dark-4;
}
}
&-list {
padding: 8px 12px;
> .w-item {
padding: 4px 6px;
border-radius: 4px;
&.active {
background-color: var(--color-primary);
color: #fff;
.w-item-label--caption {
color: rgba(255, 255, 255, 0.7);
}
}
}
}
&-href {
overflow-wrap: anywhere;
}
}
</style>

@ -13,52 +13,53 @@
style="min-height: 64px"> style="min-height: 64px">
<!-- -> 64px, the size the icon has when the page is merely being read; see the branch below --> <!-- -> 64px, the size the icon has when the page is merely being read; see the branch below -->
<w-icon :name="pageStore.icon" size="64px" /> <w-icon :name="pageStore.icon" size="64px" />
<w-badge color="grey" floating rounded>
<w-icon name="la:pen" size="xs" padding="xs xs" />
</w-badge>
<w-menu content-class="shadow-7"><icon-picker-dialog v-model="pageStore.icon" /></w-menu> <w-menu content-class="shadow-7"><icon-picker-dialog v-model="pageStore.icon" /></w-menu>
</w-btn> </w-btn>
<w-icon class="rounded" v-else :name="pageStore.icon" size="64px" color="primary" /> <w-icon class="rounded" v-else :name="pageStore.icon" size="64px" color="primary" />
</div> </div>
<!-- PAGE HEADER --> <!-- PAGE HEADER -->
<!--
In the editor the title and the description are the fields, edited where they sit rather than
through a button and a popup. They keep the heading type they have when the page is being read:
what tells an author they can type here is the hover tint and the caret, not a box drawn around
the text.
The text is NOT interpolated into the editable elements. Vue would rewrite the text node on every
keystroke as the store echoes it back, and a rewritten text node puts the caret at the start of
it. `syncEditable` writes it instead, and only when the two have actually diverged.
-->
<div class="min-w-0 flex-1 p-4"> <div class="min-w-0 flex-1 p-4">
<div class="text-h4 page-header-title"> <div class="text-h4 page-header-title">
<span>{{ pageStore.title }}</span> <span
<template v-if="editorStore.isActive"> v-if="editorStore.isActive"
<span class="text-grey" v-if="!pageStore.title">{{ t(`editor.props.title`) }}</span> ref="titleEl"
<w-btn class="acrylic-btn ml-4" icon="la:pen" flat padding="xs" size="sm"> class="page-header-editable"
<w-popup-edit v-model="pageStore.title" auto-save v-slot="scope"> :class="{ 'is-empty': !pageStore.title }"
<w-input contenteditable="plaintext-only"
outlined role="textbox"
style="width: 450px" aria-multiline="false"
v-model="scope.value" :aria-label="t(`editor.props.title`)"
dense :data-placeholder="t(`editor.props.title`)"
autofocus @input="onEditableInput(`title`, $event)"
@keyup.enter="scope.set" @blur="onEditableBlur(`title`, $event)"
:label="t(`editor.props.title`)" /> @keydown.enter.prevent="$event.target.blur()" />
</w-popup-edit> <span v-else>{{ pageStore.title }}</span>
</w-btn>
</template>
</div> </div>
<div class="text-subtitle2 page-header-subtitle"> <div class="text-subtitle2 page-header-subtitle">
<span>{{ pageStore.description }}</span> <span
<template v-if="editorStore.isActive"> v-if="editorStore.isActive"
<span class="text-grey" v-if="!pageStore.description">{{ ref="descriptionEl"
t(`editor.props.shortDescription`) class="page-header-editable"
}}</span> :class="{ 'is-empty': !pageStore.description }"
<w-btn class="acrylic-btn ml-4" icon="la:pen" flat padding="none xs" size="xs"> contenteditable="plaintext-only"
<w-popup-edit v-model="pageStore.description" auto-save v-slot="scope"> role="textbox"
<w-input aria-multiline="false"
outlined :aria-label="t(`editor.props.shortDescription`)"
style="width: 450px" :data-placeholder="t(`editor.props.shortDescription`)"
v-model="scope.value" @input="onEditableInput(`description`, $event)"
dense @blur="onEditableBlur(`description`, $event)"
autofocus @keydown.enter.prevent="$event.target.blur()" />
@keyup.enter="scope.set" <span v-else>{{ pageStore.description }}</span>
:label="t(`editor.props.shortDescription`)" />
</w-popup-edit>
</w-btn>
</template>
</div> </div>
</div> </div>
<!-- PAGE ACTIONS --> <!-- PAGE ACTIONS -->
@ -191,7 +192,7 @@
</template> </template>
<script setup> <script setup>
import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue' import { computed, defineAsyncComponent, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@ -224,8 +225,85 @@ const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
// REFS
/** The two in-place fields, which only exist while the editor is open. */
const titleEl = ref(null)
const descriptionEl = ref(null)
// WATCHERS
/*
Opening the editor is what mounts the two fields, so it is also what fills them. `immediate` covers
arriving with the editor already open, where the elements appear in the same tick as this runs.
*/
watch(
() => editorStore.isActive,
(isActive) => isActive && seedEditables(),
{ immediate: true }
)
/*
Changed from somewhere else -- the properties panel edits both of these, and a save replaces the whole
page -- so the field follows. A change that came FROM the field is already in the element and
`syncEditable` leaves it alone.
*/
watch(
() => pageStore.title,
(title) => syncEditable(titleEl.value, title)
)
watch(
() => pageStore.description,
(description) => syncEditable(descriptionEl.value, description)
)
// METHODS // METHODS
/**
* Put a value into a contenteditable without disturbing a caret that is already in it.
*
* Writing `textContent` replaces the node's text and collapses the selection to its start, so it is
* only done when the element and the store have actually diverged which is never mid-keystroke,
* since the keystroke is where the store's value came from.
*/
function syncEditable(el, value) {
if (el && el.textContent !== (value ?? '')) {
el.textContent = value ?? ''
}
}
/** Both fields, from the store. Call after they mount; they do not exist outside the editor. */
async function seedEditables() {
await nextTick()
syncEditable(titleEl.value, pageStore.title)
syncEditable(descriptionEl.value, pageStore.description)
}
function onEditableInput(field, event) {
// -> Clearing the field leaves a browser-inserted `<br>` behind, which contributes nothing to the
// text but does hold a second line open under the placeholder
if (event.target.textContent === '' && event.target.innerHTML !== '') {
event.target.innerHTML = ''
}
pageStore[field] = event.target.textContent
// -> What the tag editor and the properties panel do for their own edits: the header is a place a
// page gets changed, so it owes the same "unsaved changes" signal
editorStore.lastChangeTimestamp = Temporal.Now.instant()
}
/*
Tidied on the way out rather than as it is typed: a pasted line break or a run of spaces has no
business in a title, but collapsing them under the caret would move it while someone is still going.
*/
function onEditableBlur(field, event) {
const tidied = event.target.textContent.replace(/\s+/g, ' ').trim()
if (tidied !== pageStore[field]) {
pageStore[field] = tidied
editorStore.lastChangeTimestamp = Temporal.Now.instant()
}
syncEditable(event.target, tidied)
}
function openEditorSettings() { function openEditorSettings() {
EVENT_BUS.emit('openEditorSettings') EVENT_BUS.emit('openEditorSettings')
} }
@ -409,3 +487,53 @@ function notImplemented() {
}) })
} }
</script> </script>
<style scoped lang="scss">
/*
The two headings, while they are also the fields.
No border and no focus ring: at rest each one has to read exactly as it does when the page is being
read, which is the whole point of editing them where they sit. What says "type here" is the hover
tint, the caret, and -- while a field is empty -- the placeholder below.
The padding is cancelled by an equal negative margin, so the text keeps the position it has outside
the editor and only the tint is inset from it.
*/
.page-header-editable {
display: inline-block;
padding: 0 4px;
margin: 0 -4px;
border-radius: 4px;
outline: none;
cursor: text;
transition: background-color 0.15s var(--ease-standard);
&:hover {
background-color: rgb(0 0 0 / 0.06);
}
&:focus {
background-color: rgb(0 0 0 / 0.09);
}
@at-root .body--dark & {
&:hover {
background-color: rgb(255 255 255 / 0.08);
}
&:focus {
background-color: rgb(255 255 255 / 0.12);
}
}
}
/*
Keyed off the store rather than `:empty`, which a cleared field can fail: the browser leaves a `<br>`
behind and the element stops counting as empty even though it looks it.
*/
.page-header-editable.is-empty::before {
content: attr(data-placeholder);
opacity: 0.4;
/* -> Decoration: it must not be selectable, and it must never end up in the value */
pointer-events: none;
user-select: none;
}
</style>

@ -50,11 +50,18 @@
<w-menu content-class="shadow-7"><icon-picker-dialog v-model="state.icon" /></w-menu> <w-menu content-class="shadow-7"><icon-picker-dialog v-model="state.icon" /></w-menu>
</w-btn> </w-btn>
<div class="text-overline -mb-3">{{ t('editor.pageRel.target') }}</div> <div class="text-overline -mb-3">{{ t('editor.pageRel.target') }}</div>
<w-btn <div class="flex flex-nowrap items-center gap-3">
class="self-start rounded" <w-btn
:label="t(`editor.pageRel.selectPage`)" class="flex-none rounded"
color="primary" :label="t(`editor.pageRel.selectPage`)"
outline /> color="primary"
outline
@click="selectTarget" />
<!-- -> The chosen target, spelled out: the button says what it does, not what it picked -->
<div class="text-caption font-robotomono min-w-0 flex-1 truncate">
{{ state.target || '—' }}
</div>
</div>
<div class="text-overline -mb-3">{{ t('editor.pageRel.preview') }}</div> <div class="text-overline -mb-3">{{ t('editor.pageRel.preview') }}</div>
<w-btn <w-btn
v-if="state.pos === `left`" v-if="state.pos === `left`"
@ -133,7 +140,11 @@ import { useSiteStore } from '@/stores/site'
import { v4 as uuid } from 'uuid' import { v4 as uuid } from 'uuid'
import { cloneDeep } from 'es-toolkit/object' import { cloneDeep } from 'es-toolkit/object'
import { dialog } from '@/composables/dialog'
import IconPickerDialog from './IconPickerDialog.vue' import IconPickerDialog from './IconPickerDialog.vue'
import LinkPickerDialog from './LinkPickerDialog.vue'
// PROPS // PROPS
@ -198,6 +209,26 @@ watch(
const emit = defineEmits(['close']) const emit = defineEmits(['close'])
/*
The same picker the editor's Insert Link uses, opened on whatever this relation already points at.
No new-tab option: a relation is stored as a target and nothing else see the shape written in
`create()` so offering the choice would be offering to discard it.
*/
function selectTarget() {
dialog({
component: LinkPickerDialog,
componentProps: {
title: t('editor.pageRel.target'),
okLabel: t('common.actions.select'),
initialHref: state.target,
newTabOption: false
}
}).onOk(({ href }) => {
state.target = href
})
}
function create() { function create() {
pageStore.$patch({ pageStore.$patch({
relations: [ relations: [

@ -2,16 +2,16 @@
<w-layout view="hHh lpR fFf" container> <w-layout view="hHh lpR fFf" container>
<w-header class="card-header px-4 py-2"> <w-header class="card-header px-4 py-2">
<w-icon name="img:/_assets/icons/color-data-grid.svg" left size="md" /> <w-icon name="img:/_assets/icons/color-data-grid.svg" left size="md" />
<span>{{t(`editor.tableEditor.title`)}}</span> <span>{{ t(`editor.tableEditor.title`) }}</span>
<w-space /> <w-space />
<w-btn <w-btn
class="mr-2" class="mr-2"
flat flat
rounded rounded
color="white" color="white"
:aria-label="t(`common.actions.refresh`)" :aria-label="t(`common.actions.viewDocs`)"
icon="la:question-circle" icon="la:question-circle"
:href="siteStore.docsBase + `/admin/editors/markdown`" :href="siteStore.docsBase + `/editor/markdown`"
target="_blank" target="_blank"
type="a" /> type="a" />
<w-btn-group push> <w-btn-group push>
@ -27,33 +27,146 @@
push push
color="positive" color="positive"
text-color="white" text-color="white"
:label="t(`common.actions.save`)" :label="t(`common.actions.insert`)"
:aria-label="t(`common.actions.save`)" :aria-label="t(`common.actions.insert`)"
icon="la:check" icon="la:check"
:disabled="state.loading > 0" /> @click="insert" />
</w-btn-group> </w-btn-group>
</w-header> </w-header>
<w-page-container> <w-page-container>
<w-page class="p-4"> <w-page class="p-4">
<div ref="tblRef" /> <div class="flex flex-wrap items-center gap-2">
<w-inner-loading :showing="state.loading > 0"> <w-btn
<w-spinner color="accent" size="lg" /> outline
</w-inner-loading> no-caps
icon="la:plus"
color="primary"
padding="xs sm"
:label="t(`editor.tableEditor.addRow`)"
@click="addRow" />
<w-btn
outline
no-caps
icon="la:plus"
color="primary"
padding="xs sm"
:label="t(`editor.tableEditor.addColumn`)"
@click="addColumn" />
<w-space />
<div class="text-caption text-black/60 dark:text-white/70">
{{ t('editor.tableEditor.pasteHint') }}
</div>
</div>
<!--
A plain table of plain inputs, which is what a markdown table is: a grid of one-line strings
plus an alignment per column. The row above the header holds each column's tools -- its
alignment, which is the only formatting the syntax can carry, and its delete.
-->
<div class="table-editor-grid mt-4">
<table>
<thead>
<tr class="table-editor-tools">
<th v-for="(align, colIndex) of state.align" :key="`tool-${colIndex}`">
<!-- -> Centred over the column rather than pushed to its edges: at the edges the
delete button reads as belonging to the boundary between two columns -->
<div class="flex flex-nowrap items-center justify-center gap-1">
<w-btn
flat
dense
padding="none xs"
size="sm"
color="primary"
:icon="ALIGN_ICONS[align]"
:aria-label="t(`editor.tableEditor.align`)"
@click="cycleAlign(colIndex)">
<w-tooltip>
{{ t('editor.tableEditor.align') }}: {{ t(ALIGN_LABELS[align]) }}
</w-tooltip>
</w-btn>
<w-btn
flat
dense
padding="none xs"
size="sm"
color="negative"
icon="la:times"
:disabled="state.align.length < 2"
:aria-label="t(`editor.tableEditor.removeColumn`)"
@click="removeColumn(colIndex)">
<w-tooltip>{{ t('editor.tableEditor.removeColumn') }}</w-tooltip>
</w-btn>
</div>
</th>
<!-- -> Matches the row-tools column below, so the grid stays square -->
<th class="table-editor-rowtools" />
</tr>
<tr>
<th v-for="(_, colIndex) of state.rows[0]" :key="`head-${colIndex}`">
<input
v-model="state.rows[0][colIndex]"
class="table-editor-cell table-editor-cell--head"
type="text"
:aria-label="t(`editor.tableEditor.headerCell`, { column: colIndex + 1 })"
@paste="onCellPaste(0, colIndex, $event)" />
</th>
<th class="table-editor-rowtools" />
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) of bodyRows" :key="`row-${rowIndex}`">
<td v-for="(_, colIndex) of row" :key="`cell-${rowIndex}-${colIndex}`">
<input
v-model="state.rows[rowIndex + 1][colIndex]"
class="table-editor-cell"
type="text"
:aria-label="
t(`editor.tableEditor.bodyCell`, { row: rowIndex + 1, column: colIndex + 1 })
"
@paste="onCellPaste(rowIndex + 1, colIndex, $event)" />
</td>
<td class="table-editor-rowtools">
<w-btn
flat
dense
padding="none xs"
size="sm"
color="negative"
icon="la:times"
:disabled="bodyRows.length < 2"
:aria-label="t(`editor.tableEditor.removeRow`)"
@click="removeRow(rowIndex + 1)">
<w-tooltip>{{ t('editor.tableEditor.removeRow') }}</w-tooltip>
</w-btn>
</td>
</tr>
</tbody>
</table>
</div>
<!-- -> The markdown itself, because that is what gets inserted and it is worth seeing before
it lands in the page -->
<div class="text-overline mt-6">{{ t('editor.tableEditor.markdown') }}</div>
<pre class="table-editor-output mt-2">{{ markdown }}</pre>
</w-page> </w-page>
</w-page-container> </w-page-container>
</w-layout> </w-layout>
</template> </template>
<script setup> <script setup>
import { computed, reactive } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { onMounted, reactive, ref } from 'vue'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { Tabulator } from 'tabulator-tables' /**
import { cloneDeep } from 'es-toolkit/object' * Builds a markdown table and hands it to the editor.
import 'tabulator-tables/dist/css/tabulator.css' *
* Handmade rather than a data grid. A markdown table is a small thing one-line strings in a grid,
* plus a per-column alignment, which is the only formatting the syntax carries and the library this
* replaces (`tabulator-tables`) is a sortable, filterable, virtually-rendered spreadsheet whose model
* has no place to put that alignment. Every editing gesture here is a `splice`.
*
* The editor receives the result over the event bus, the same way the File Manager hands back an asset.
*/
// STORES // STORES
@ -63,23 +176,232 @@ const siteStore = useSiteStore()
const { t } = useI18n() const { t } = useI18n()
/** Cycled through by the per-column button, in this order. */
const ALIGNMENTS = ['left', 'center', 'right']
const ALIGN_ICONS = {
left: 'mdi:format-align-left',
center: 'mdi:format-align-center',
right: 'mdi:format-align-right'
}
/* -> Spelled out rather than built from the value: a key assembled at runtime is invisible to the
translation tooling, the same way a concatenated icon name is invisible to the icon scanner. */
const ALIGN_LABELS = {
left: 'editor.tableEditor.alignLeft',
center: 'editor.tableEditor.alignCenter',
right: 'editor.tableEditor.alignRight'
}
/** Narrowest a delimiter cell can be and still show its colons: `:-:`. */
const MIN_WIDTH = 3
// DATA // DATA
/*
`rows[0]` is the header. Keeping it in the same array as the body is what makes a column operation one
splice per row instead of two code paths that have to agree.
*/
const state = reactive({ const state = reactive({
loading: 0 align: ['left', 'left', 'left'],
rows: [
['Column 1', 'Column 2', 'Column 3'],
['', '', ''],
['', '', '']
]
})
// COMPUTED
const bodyRows = computed(() => state.rows.slice(1))
/**
* The table as markdown.
*
* Cells are padded to the width of their column: it costs nothing and it is the difference between a
* source someone can read and a wall of pipes. A cell's own `|` is escaped, and a newline which only
* a paste can produce becomes a space, because there is no way to write either into a table row.
*/
const markdown = computed(() => {
const widths = state.align.map((_, colIndex) =>
Math.max(MIN_WIDTH, ...state.rows.map((row) => escapeCell(row[colIndex]).length))
)
const line = (cells) => `| ${cells.map((cell, i) => cell.padEnd(widths[i])).join(' | ')} |`
const delimiters = state.align.map((align, i) => {
const dashes = '-'.repeat(widths[i] - (align === 'center' ? 2 : 1))
switch (align) {
case 'center': {
return `:${dashes}:`
}
case 'right': {
return `${dashes}:`
}
default: {
return `:${dashes}`
}
}
})
return [
line(state.rows[0].map(escapeCell)),
line(delimiters),
...bodyRows.value.map((row) => line(row.map(escapeCell)))
].join('\n')
}) })
const tblRef = ref(null)
// METHODS // METHODS
function close () { function escapeCell(value) {
siteStore.$patch({ overlay: '' }) return (value ?? '').replaceAll('|', '\\|').replaceAll(/\s+/g, ' ').trim()
}
function cycleAlign(colIndex) {
const next = (ALIGNMENTS.indexOf(state.align[colIndex]) + 1) % ALIGNMENTS.length
state.align[colIndex] = ALIGNMENTS[next]
}
function addRow() {
state.rows.push(state.align.map(() => ''))
}
function removeRow(rowIndex) {
state.rows.splice(rowIndex, 1)
}
function addColumn() {
state.align.push('left')
for (const row of state.rows) {
row.push('')
}
}
function removeColumn(colIndex) {
state.align.splice(colIndex, 1)
for (const row of state.rows) {
row.splice(colIndex, 1)
}
} }
onMounted(() => { /** Grow the table until (rowIndex, colIndex) exists. */
const tbl = new Tabulator(tblRef.value, { function ensureSize(rowCount, colCount) {
clipboard: true, while (state.align.length < colCount) {
height: '100%' addColumn()
}
while (state.rows.length < rowCount) {
addRow()
}
}
/**
* A paste of more than one cell fills the grid from where it was pasted, growing the table to fit.
*
* Tab-separated lines are what a spreadsheet puts on the clipboard, so a table copied out of one lands
* here as a table rather than as a wall of text in a single cell. A paste with no tabs and no newlines
* is an ordinary paste and is left to the field.
*/
function onCellPaste(rowIndex, colIndex, event) {
const text = event.clipboardData?.getData('text/plain') ?? ''
if (!text.includes('\t') && !text.includes('\n')) {
return
}
event.preventDefault()
const grid = text
.replaceAll('\r\n', '\n')
.replaceAll('\r', '\n')
.replace(/\n+$/, '')
.split('\n')
.map((line) => line.split('\t'))
ensureSize(rowIndex + grid.length, colIndex + Math.max(...grid.map((cells) => cells.length)))
grid.forEach((cells, r) => {
cells.forEach((cell, c) => {
state.rows[rowIndex + r][colIndex + c] = cell.trim()
})
}) })
}) }
function insert() {
EVENT_BUS.emit('insertTable', markdown.value)
close()
}
function close() {
siteStore.$patch({ overlay: '' })
}
</script> </script>
<style lang="scss">
.table-editor {
&-grid {
overflow-x: auto;
table {
border-collapse: collapse;
}
th,
td {
padding: 0;
border: 1px solid rgb(0 0 0 / 0.12);
@at-root .body--dark & {
border-color: rgb(255 255 255 / 0.15);
}
}
}
/* -> The tools row is chrome, not content: no border under the buttons, tighter than a data row */
&-tools {
th {
padding: 2px 4px;
border: 0;
}
}
&-rowtools {
width: 32px;
padding: 0 2px !important;
border: 0 !important;
text-align: center;
}
&-cell {
display: block;
width: 220px;
padding: 6px 8px;
background-color: transparent;
color: inherit;
font-size: 14px;
outline: none;
&:focus {
background-color: rgb(0 0 0 / 0.05);
@at-root .body--dark & {
background-color: rgb(255 255 255 / 0.08);
}
}
/* -> The header row is what a reader sees in bold, so it reads that way here too */
&--head {
font-weight: 600;
}
}
&-output {
padding: 12px;
border-radius: 4px;
font-family: 'Roboto Mono', Consolas, 'Liberation Mono', Courier, monospace;
font-size: 13px;
line-height: 1.5;
overflow-x: auto;
@at-root .body--light & {
background-color: $grey-2;
color: $grey-9;
}
@at-root .body--dark & {
background-color: $dark-4;
color: #fff;
}
}
}
</style>

@ -174,6 +174,20 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false default: false
}, },
/**
* Drop the field's own surface and let whatever is behind it show through.
*
* For a field on a surface that is not flat: a translucent (acrylic) menu, where the field's white
* would sit as an opaque slab on a panel meant to be see-through and where the floating label,
* riding the top border, would have that slab on one side of it and the blur on the other.
*
* Only the fill goes. The border, the focus ring and the label's notch are untouched, so the field is
* still obviously a field.
*/
transparent: {
type: Boolean,
default: false
},
dense: { dense: {
type: Boolean, type: Boolean,
default: false default: false
@ -340,8 +354,16 @@ const controlClasses = computed(() => [
The dark value is translucent black rather than a fixed tone so it holds up on each of those The dark value is translucent black rather than a fixed tone so it holds up on each of those
surfaces; the light one can be flat white because that IS the surface a field should present. surfaces; the light one can be flat white because that IS the surface a field should present.
`transparent` opts out, for the surfaces where that reasoning inverts -- see the prop.
*/ */
props.outlined ? 'bg-white dark:bg-black/20' : 'rounded-b-none bg-black/4 dark:bg-white/6', props.transparent
? props.outlined
? ''
: 'rounded-b-none'
: props.outlined
? 'bg-white dark:bg-black/20'
: 'rounded-b-none bg-black/4 dark:bg-white/6',
props.disable || props.disabled ? 'pointer-events-none opacity-60' : '', props.disable || props.disabled ? 'pointer-events-none opacity-60' : '',
/* /*
`relative` for the outline and the label. The margin is the room the floated label needs above the `relative` for the outline and the label. The margin is the room the floated label needs above the

@ -1,97 +0,0 @@
<template>
<w-menu ref="menuRef" :anchor="anchor" :self="self" @hide="onHide">
<div class="p-3">
<!--
The slot edits a WORKING COPY, not the model. `set` commits it and closes; dismissing the
popup any other way discards it. That is the contract the callers are written against --
an inline title editor must not write through on every keystroke.
Handed over as ONE reactive object rather than as separate slot props, because the field
inside binds `v-model="scope.value"` and needs a real setter to write through. Slot props
are rebuilt on every render, so a plain value passed per-key would be a snapshot and the
assignment would go nowhere.
-->
<slot :scope="scope" />
</div>
</w-menu>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import WMenu from './WMenu.vue'
/**
* Edit a value in a popup anchored to the element this sits inside.
*
* Simplification: the component this replaces also offered its own buttons, validation, a title,
* and a `label-set` / `label-cancel` pair. Both callers here supply their own field and commit on
* Enter, so this is the popup and the working copy and nothing else.
*
* `auto-save` is accepted and ignored: it made the original commit on dismissal rather than
* discard. Neither caller depends on that -- both commit explicitly from the field's Enter key --
* and silently saving a half-typed title on a stray click is the worse default.
*/
const props = defineProps({
modelValue: {
type: null,
default: null
},
anchor: {
type: String,
default: 'bottom left'
},
self: {
type: String,
default: 'top left'
},
/** Accepted for call-site compatibility; see the note above. */
autoSave: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'save', 'cancel'])
const menuRef = ref(null)
const draft = ref(props.modelValue)
// -> Re-seed whenever the source changes, so re-opening never shows a stale edit
watch(
() => props.modelValue,
(value) => {
draft.value = value
}
)
const scope = reactive({
value: computed({
get: () => draft.value,
set: (v) => {
draft.value = v
}
}),
initialValue: computed(() => props.modelValue),
set: () => commit(),
cancel: () => cancel()
})
function commit() {
if (draft.value !== props.modelValue) {
emit('update:modelValue', draft.value)
emit('save', draft.value)
}
menuRef.value?.hide()
}
function cancel() {
draft.value = props.modelValue
menuRef.value?.hide()
}
/** Dismissed by click-away or Escape: the working copy goes back to the source, unsaved. */
function onHide() {
draft.value = props.modelValue
emit('cancel')
}
</script>

@ -44,7 +44,6 @@ import WList from './WList.vue'
import WMenu from './WMenu.vue' import WMenu from './WMenu.vue'
import WPage from './WPage.vue' import WPage from './WPage.vue'
import WPageContainer from './WPageContainer.vue' import WPageContainer from './WPageContainer.vue'
import WPopupEdit from './WPopupEdit.vue'
import WPageScroller from './WPageScroller.vue' import WPageScroller from './WPageScroller.vue'
import WPagination from './WPagination.vue' import WPagination from './WPagination.vue'
import WRadio from './WRadio.vue' import WRadio from './WRadio.vue'
@ -105,7 +104,6 @@ export const sharedComponents = {
WMenu, WMenu,
WPage, WPage,
WPageContainer, WPageContainer,
WPopupEdit,
WPageScroller, WPageScroller,
WPagination, WPagination,
WRadio, WRadio,

@ -377,6 +377,13 @@ watch(
and making them press a button first would only add a step. Keyed on the page rather than on the and making them press a button first would only add a step. Keyed on the page rather than on the
flag, so dismissing the prompt does not immediately reopen it -- the lock screen's own button is the flag, so dismissing the prompt does not immediately reopen it -- the lock screen's own button is the
way back in -- while walking to another protected page prompts again. way back in -- while walking to another protected page prompts again.
Deliberately NOT `immediate`. This component is unmounted and remounted around any route outside the
page view (a search, the profile, the admin area), and the store it reads is global: an immediate run
fires against whatever page was on screen BEFORE that detour, so leaving a locked page for the search
screen and coming back to an unprotected one prompted for the earlier page's password. Every real
case still fires here, because `pageLoad` clears the flag as it starts and the reply sets it again --
so a locked page always arrives as a change, mount or no mount.
*/ */
watch( watch(
() => (pageStore.isLocked ? pageStore.id : null), () => (pageStore.isLocked ? pageStore.id : null),
@ -384,8 +391,7 @@ watch(
if (lockedPageId) { if (lockedPageId) {
promptUnlock() promptUnlock()
} }
}, }
{ immediate: true }
) )
watch( watch(

@ -107,7 +107,16 @@ export class MarkdownRenderer {
} }
} }
}) })
.use(mdMdc) /*
MDC's INLINE component syntax is off, and deliberately: `:name` is how it writes one, which is
also how markdown writes an emoji, and MDC parses first. With it on, `:rocket:` came out as
`<rocket>:` and no emoji shortcode in any page ever rendered while this file goes to the
trouble of drawing them as twemoji SVGs, and the editor has a picker for them.
Everything else MDC brings is untouched: block components (`::note`), inline props and inline
spans. Turning this back on means giving up emoji shortcodes again.
*/
.use(mdMdc, { syntax: { inlineComponent: false } })
.use(mdAttrs, { .use(mdAttrs, {
allowedAttributes: ['id', 'class', 'target'] allowedAttributes: ['id', 'class', 'target']
}) })

@ -99,6 +99,15 @@ export const usePageStore = defineStore('page', {
async pageLoad({ path, id, withContent = false }) { async pageLoad({ path, id, withContent = false }) {
const editorStore = useEditorStore() const editorStore = useEditorStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
/*
The lock belongs to the page being loaded, not to the one before it.
Everything else in this store stays put until the reply arrives, deliberately -- blanking it
would flash an empty page on every navigation. `isLocked` cannot be treated that way: it is
read as "the page on screen is protected", and left standing it makes the NEXT page look
protected for as long as the request takes.
*/
this.isLocked = false
try { try {
const pageData = await API_CLIENT.get( const pageData = await API_CLIENT.get(
`sites/${siteStore.id}/pages/${id ?? fastHash(normalizePath(path))}`, `sites/${siteStore.id}/pages/${id ?? fastHash(normalizePath(path))}`,

Loading…
Cancel
Save