refactor: page contents styling (wip)

scarlett
NGPixel 2 days ago
parent 4677a7c532
commit fbf0c1c689
No known key found for this signature in database

@ -110,19 +110,53 @@ async function applyTheme() {
setCssVar('negative', userStore.getAccessibleColor('negative', '#f03a47'))
// -> Highlight.js Theme
if (siteStore.theme.codeBlocksTheme) {
const desiredHljsTheme = userStore.cvd !== 'none' ? 'github' : siteStore.theme.codeBlocksTheme
await applyCodeBlocksTheme()
}
const hljsStyleEl = document.querySelector('#hljs-theme')
if (hljsStyleEl) {
hljsStyleEl.remove()
}
/**
* Every highlight.js theme the admin area offers, as loaders that fetch one on demand.
*
* `?inline` hands back the stylesheet as a STRING rather than injecting it: these have to be scoped to
* the page content before they are applied (see below), which cannot be done to a stylesheet the
* bundler has already added to the document. `**` covers the `base16/` family, since that is how the
* admin's list names half of its options.
*
* Only the theme in use is ever fetched; the rest sit in the build as assets nobody asks for.
*/
const HLJS_THEMES = import.meta.glob('../node_modules/highlight.js/styles/**/*.min.css', {
query: '?inline',
import: 'default'
})
/**
* Paint code blocks in the theme chosen under Admin Theme.
*
* The stylesheet is wrapped in `.page-contents { ... }` and applied through CSS nesting, for two
* reasons: a highlight.js theme is written as bare `.hljs*` rules that would otherwise reach every
* code sample in the interface, and nesting lifts its selectors to the same weight as the fallback
* palette in `_page-contents.scss` -- so this one wins on being applied later, which is exactly the
* relationship wanted. With no theme chosen, nothing is injected and that fallback is what shows.
*/
async function applyCodeBlocksTheme() {
document.querySelector('#hljs-theme')?.remove()
// -> A colour-vision-deficient palette cannot be honoured per theme, so it takes a neutral one
const desiredHljsTheme = userStore.cvd !== 'none' ? 'github' : siteStore.theme.codeBlocksTheme
if (!desiredHljsTheme) {
return
}
const newHljsStyleEl = document.createElement('style')
newHljsStyleEl.id = 'hljs-theme'
// newHljsStyleEl.innerHTML = (await import(`../node_modules/highlight.js/styles/${desiredHljsTheme}.css`)).default
document.head.appendChild(newHljsStyleEl)
const load = HLJS_THEMES[`../node_modules/highlight.js/styles/${desiredHljsTheme}.min.css`]
if (!load) {
// -> A name the admin area offers that highlight.js does not ship; the fallback palette stands in
console.warn(`Unknown code blocks theme: ${desiredHljsTheme}`)
return
}
const styleEl = document.createElement('style')
styleEl.id = 'hljs-theme'
styleEl.textContent = `.page-contents {\n${await load()}\n}`
document.head.appendChild(styleEl)
}
// INIT SITE STORE

@ -224,11 +224,17 @@
<w-tooltip anchor="top middle" self="bottom middle">{{ t('editor.togglePreviewPane') }}</w-tooltip>
</w-btn>
</div>
<!--
The render goes directly into the element carrying `page-contents`, exactly as the page
view does it. The wrapper div this replaces made the headings grandchildren of that
element, so content rules written against its direct children -- the page title's rule
reaching out to the sidebar -- applied on one surface and not the other. Its `ref` was
never read; the scroll-sync and block loading both use the container.
-->
<div
class="editor-markdown-preview-content page-contents"
ref="editorPreviewContainerRef">
<div ref="editorPreview" v-html="pageStore.render" />
</div>
ref="editorPreviewContainerRef"
v-html="pageStore.render" />
</div>
</transition>
</div>
@ -246,6 +252,8 @@ import { useEditorStore } from '@/stores/editor'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import { enhanceRenderedContent } from '@/helpers/renderedContent'
import { debounce } from 'es-toolkit/function'
import * as monaco from 'monaco-editor'
import { Position, Range } from 'monaco-editor'
@ -514,6 +522,8 @@ function processContent(newContent) {
for (const block of editorPreviewContainerRef.value.querySelectorAll(':not(:defined)')) {
commonStore.loadBlocks([block.tagName.toLowerCase()])
}
// -> The render was just replaced, so the copy buttons went with it
enhanceRenderedContent(editorPreviewContainerRef.value)
})
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,172 @@
import { BUNDLED_ICONS } from '@/assets/icons.generated'
import { copyToClipboard } from './clipboard'
import { notify } from '@/composables/notify'
/**
* The affordances a rendered page grows once it is on screen: a copy button on every code block, and a
* pilcrow on every heading that copies a link to it.
*
* Scripted rather than rendered, because a page's HTML arrives through `v-html`: there is no template
* to put a component in, and no Vue instance inside the render to hang one off. So the same treatment
* is applied to whatever the render just produced -- in the page view and in the editor's preview
* alike, both of which call `enhanceRenderedContent` after the content changes.
*
* Idempotent: a decorated element is marked, so re-running over content that has not been replaced
* adds nothing. The controls carry their own listeners and are discarded wholesale when `v-html` next
* writes over them, which is why nothing has to be torn down.
*/
/** Drawn from the same inlined set the interface uses; see `scripts/generate-icons.mjs`. */
const ICON_COPY = 'la:copy'
const ICON_DONE = 'mdi:check'
/** How long a control reports success before offering itself again. */
const COPIED_FOR_MS = 1600
/**
* An inlined icon as SVG markup.
*
* `WIcon` does this in a template; a control built in script cannot use it, so the same record is read
* directly. Missing icons are impossible in practice -- the names above are literals, so the generator
* bundles them -- but an empty string is a nicer failure than a broken template string.
*/
function iconSvg(name) {
const icon = BUNDLED_ICONS[name]
if (!icon) {
return ''
}
return `<svg viewBox="0 0 ${icon.width} ${icon.height}" width="16" height="16" aria-hidden="true" focusable="false">${icon.body}</svg>`
}
/**
* Copy, then have the control say so itself: a toast for something this small would be noise, and the
* pointer is already on the thing that changed.
*/
async function copyWithFeedback({ text, control, restingLabel, restingHtml, doneLabel }) {
try {
await copyToClipboard(text)
} catch (err) {
notify({ type: 'negative', message: 'Could not copy to the clipboard.', caption: err.message })
return
}
control.classList.add('is-copied')
control.innerHTML = iconSvg(ICON_DONE)
setLabel(control, doneLabel)
clearTimeout(control._resetTimer)
control._resetTimer = setTimeout(() => {
control.classList.remove('is-copied')
control.innerHTML = restingHtml
setLabel(control, restingLabel)
}, COPIED_FOR_MS)
}
/**
* One string for the two things that have to say it: the accessible name, and the tooltip a control
* draws for itself (see `.heading-anchor::after`). `data-tooltip` is absent on controls whose icon
* already says what they do, and the stylesheet then has nothing to render.
*/
function setLabel(control, label) {
control.setAttribute('aria-label', label)
if (control.dataset.tooltip !== undefined) {
control.dataset.tooltip = label
}
}
/** The code as the author wrote it, without the line numbers the gutter draws. */
function codeOf(pre) {
const code = pre.querySelector('code')
if (!code) {
return pre.textContent
}
/*
Cloned so the gutter can be dropped without touching what is on screen. Its spans hold no text --
the numbers are drawn by a counter -- but the clone keeps the copy honest if that ever changes.
*/
const copy = code.cloneNode(true)
for (const gutter of copy.querySelectorAll('.line-numbers-rows')) {
gutter.remove()
}
return copy.textContent.replace(/\n$/, '')
}
function addCodeCopyButtons(root) {
for (const pre of root.querySelectorAll('pre.codeblock:not([data-code-copy])')) {
// -> Marks the block as done, and is what the stylesheet keys the button's position off
pre.dataset.codeCopy = ''
const button = document.createElement('button')
button.type = 'button'
button.className = 'code-copy'
setLabel(button, 'Copy code')
button.innerHTML = iconSvg(ICON_COPY)
button.addEventListener('click', () =>
copyWithFeedback({
text: codeOf(pre),
control: button,
restingLabel: 'Copy code',
restingHtml: iconSvg(ICON_COPY),
doneLabel: 'Copied'
})
)
pre.appendChild(button)
}
}
/**
* The link a heading's pilcrow copies.
*
* Built from the address bar rather than from the page store, so it carries whatever the reader is
* actually on -- locale prefix included. The editor is the one place where those diverge: it previews a
* page that lives at its own address, not at `/_edit/…`, so that prefix is dropped.
*/
function headingUrl(id) {
const path = window.location.pathname.replace(/^\/_edit\//, '/')
return `${window.location.origin}${path}#${id}`
}
/** The pilcrow, as a character: no icon set carries it, and every font does. */
const PILCROW = '¶'
function addHeadingAnchors(root) {
const headings = 'h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]'
for (const heading of root.querySelectorAll(headings)) {
if (heading.dataset.headingAnchor !== undefined) {
continue
}
heading.dataset.headingAnchor = ''
const button = document.createElement('button')
button.type = 'button'
button.className = 'heading-anchor'
// -> Declares that this control has a tooltip; `setLabel` keeps the two in step
button.dataset.tooltip = ''
setLabel(button, 'Copy link to this section')
button.textContent = PILCROW
button.addEventListener('click', () =>
copyWithFeedback({
text: headingUrl(heading.id),
control: button,
restingLabel: 'Copy link to this section',
restingHtml: PILCROW,
doneLabel: 'Link copied'
})
)
heading.appendChild(button)
}
}
/**
* @param {HTMLElement|null} root The element the render was written into.
*/
export function enhanceRenderedContent(root) {
if (!root) {
return
}
addCodeCopyButtons(root)
addHeadingAnchors(root)
}

@ -14,6 +14,18 @@ import { initializeHairlines } from './helpers/hairline'
// so no icon webfont is loaded.
import '@quasar/extras/roboto-font/roboto-font.css'
/*
KaTeX's own stylesheet, which the math in a page is drawn with.
Vendor CSS rather than something `_page-contents.scss` could express: it carries the maths fonts and
the geometry of every symbol. Without it a formula renders TWICE -- KaTeX emits both an HTML tree and
a MathML fallback, and the CSS is what hides the one the browser is not using -- which is what made
every equation appear as a broken rendering followed by its own source.
Global, but inert outside content: `.katex` appears nowhere else in the app.
*/
import 'katex/dist/katex.min.css'
import './css/tailwind.css'
import './css/app.scss'

@ -182,6 +182,7 @@ import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading'
import { enhanceRenderedContent } from '@/helpers/renderedContent'
import { flattenToc } from '@/helpers/toc'
import { useCommonStore } from '@/stores/common'
@ -317,6 +318,19 @@ const breadcrumbs = computed(() => [
// WATCHERS
/*
The copy buttons on code blocks are part of the content, so they are re-added whenever the content
is. Keyed on the render rather than on the route: it arrives after the page has already mounted, and
it is replaced again on every save without the route moving at all.
*/
watch(
() => pageStore.render,
() => {
nextTick(() => enhanceRenderedContent(pageContents.value))
},
{ immediate: true }
)
watch(
() => route.path,
async (newValue) => {

@ -39,6 +39,32 @@ const quoteStyles = {
swedish: '””’’'
}
/**
* Whether a link leaves this wiki.
*
* Resolved against the page's own address, so a relative path, an absolute one and a protocol-relative
* URL are all judged the same way -- by the host they end up on. `mailto:`, `tel:` and the rest are not
* pages at all, and are left unmarked: they announce themselves by what they are.
*
* With no document to resolve against -- a render outside a browser -- only an absolute URL can be
* judged, and it is judged external; a relative one fails to parse and comes back internal.
*/
function isExternalHref(href) {
if (!href) {
return false
}
const here = globalThis.location?.href
try {
const url = new URL(href, here)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return false
}
return here ? url.origin !== new URL(here).origin : true
} catch {
return false
}
}
export class MarkdownRenderer {
constructor(config = {}) {
this.md = new MarkdownIt({
@ -174,6 +200,24 @@ export class MarkdownRenderer {
}
}
// --------------------------------
// LINK DESTINATIONS
// --------------------------------
/*
Where a link goes is decided here, at render time, and recorded as a class -- `is-external-link`
-- for the stylesheet to mark. It cannot be decided in CSS: a selector can match on the shape of
an href but not compare its host with the wiki's own, which is the whole question.
The class survives being stored: `models/rendering.ts` keeps `class` on every element.
*/
this.md.renderer.rules.link_open = (tokens, idx, options, env, slf) => {
if (isExternalHref(tokens[idx].attrGet('href'))) {
tokens[idx].attrJoin('class', 'is-external-link')
}
return slf.renderToken(tokens, idx, options, env, slf)
}
// --------------------------------
// TWEMOJI
// --------------------------------

Loading…
Cancel
Save