381 lines
15 KiB
Vue
381 lines
15 KiB
Vue
<template>
|
|
<div class="gb-blocks" :class="{ 'gb-interactive': interactive }">
|
|
<template v-for="(block, index) in blocks" :key="block.id || index">
|
|
|
|
<!--
|
|
A heading and a step can be ticked off while reading, not just a
|
|
checklist item. On a long procedure the question is not "what have I
|
|
done" so much as "where was I" - somebody looks away to do the thing
|
|
the guide asked for, and looks back at a wall of text. A struck-through
|
|
section is where they were.
|
|
|
|
Reading only: in the editor a tick would be an edit.
|
|
-->
|
|
<h2 v-if="block.type === 'heading'" :id="anchorFor(block)" class="gb-heading"
|
|
:class="['gb-h' + levelOf(block),
|
|
{ 'gb-sectiondone': isTicked(block.id) || inDoneSection(index) }]">
|
|
<input v-if="interactive" type="checkbox" class="gb-sectiontick"
|
|
:checked="isTicked(block.id)"
|
|
:aria-label="'Mark this section done'"
|
|
@change="toggle(block.id, $event.target.checked)">
|
|
<span v-html="inline(block.text)"></span>
|
|
</h2>
|
|
|
|
<p v-else-if="block.type === 'paragraph'" class="gb-para"
|
|
:class="{ 'gb-sectiondone': inDoneSection(index) }"
|
|
v-html="inline(block.text)"></p>
|
|
|
|
<div v-else-if="block.type === 'step'" class="gb-step"
|
|
:class="{ 'gb-sectiondone': isTicked(block.id) || inDoneSection(index) }">
|
|
<label class="gb-stepnum">
|
|
<input v-if="interactive" type="checkbox" class="gb-sectiontick"
|
|
:checked="isTicked(block.id)"
|
|
:aria-label="'Mark step ' + stepNumber(index) + ' done'"
|
|
@change="toggle(block.id, $event.target.checked)">
|
|
Step {{ stepNumber(index) }}
|
|
</label>
|
|
<h3 v-if="block.title" v-html="inline(block.title)"></h3>
|
|
<div v-if="block.text" v-html="inline(block.text)"></div>
|
|
</div>
|
|
|
|
<!--
|
|
The reason this feature exists. Interactive ONLY when viewing: in the
|
|
editor a tick would be an edit, and an author clicking around their own
|
|
draft would quietly change what the next reader sees as "done".
|
|
-->
|
|
<ul v-else-if="block.type === 'checklist'" class="gb-checklist"
|
|
:class="{ 'gb-sectiondone': inDoneSection(index) }">
|
|
<li v-for="item in itemsOf(block)" :key="item.id" class="gb-check">
|
|
<label>
|
|
<input type="checkbox"
|
|
:checked="isTicked(item.id)"
|
|
:disabled="!interactive"
|
|
@change="toggle(item.id, $event.target.checked)">
|
|
<span :class="{ 'gb-done': interactive && isTicked(item.id) }"
|
|
v-html="inline(item.text)"></span>
|
|
</label>
|
|
</li>
|
|
</ul>
|
|
|
|
<ul v-else-if="block.type === 'bullets'" class="gb-list"
|
|
:class="{ 'gb-sectiondone': inDoneSection(index) }">
|
|
<li v-for="item in itemsOf(block)" :key="item.id" v-html="inline(item.text)"></li>
|
|
</ul>
|
|
|
|
<ol v-else-if="block.type === 'numbers'" class="gb-list"
|
|
:class="{ 'gb-sectiondone': inDoneSection(index) }">
|
|
<li v-for="item in itemsOf(block)" :key="item.id" v-html="inline(item.text)"></li>
|
|
</ol>
|
|
|
|
<div v-else-if="block.type === 'callout'" class="gb-callout"
|
|
:class="['gb-' + (block.variant || 'info'),
|
|
{ 'gb-sectiondone': inDoneSection(index) }]">
|
|
<strong v-if="block.title">{{ block.title }}</strong>
|
|
<div v-html="inline(block.text)"></div>
|
|
</div>
|
|
|
|
<div v-else-if="block.type === 'code'" class="gb-code"
|
|
:class="{ 'gb-sectiondone': inDoneSection(index) }">
|
|
<div class="gb-codehead">
|
|
<span class="gb-lang">{{ block.language || 'text' }}</span>
|
|
<span v-if="block.caption" class="gb-caption">{{ block.caption }}</span>
|
|
<!--
|
|
SELECTS the code; it does not write to the clipboard.
|
|
|
|
A page that programmatically copies a shell command is the exact
|
|
shape of a ClickFix attack - malware that plants a command in your
|
|
clipboard and talks you into pasting it into Run. uBlock Origin
|
|
blocks it, and it was right to: our button wrote PowerShell to the
|
|
clipboard on click.
|
|
|
|
Selecting the text and letting the person press Ctrl+C keeps the
|
|
convenience, moves the decision back to them, and gives security
|
|
tooling nothing to object to. It also selects the code ONLY - no
|
|
caption, no prompt, so nobody ends up pasting "PS C:\>" into a
|
|
terminal.
|
|
-->
|
|
<button type="button" class="gb-copy" @click="select(block)">
|
|
{{ selected === block.id ? 'Selected - press Ctrl+C' : 'Select' }}
|
|
</button>
|
|
</div>
|
|
<pre :ref="el => registerCode(block.id, el)"><code>{{ block.text }}</code></pre>
|
|
</div>
|
|
|
|
<!--
|
|
The image is fetched by script, not by the browser.
|
|
|
|
An <img src> is a plain browser request: it carries no Authorization
|
|
header, and this app keeps its JWT in one. So a direct src against the
|
|
authenticated image route is a 401 and a broken-image icon for every
|
|
reader - which is exactly how this shipped. Fetching through the api
|
|
client sends the token, and it resolves against the client's baseURL, so
|
|
it also survives a subpath mount that a stored '/api/...' string does
|
|
not.
|
|
|
|
A failure says so in words. A guide whose screenshot is missing must not
|
|
look like a guide that never had one.
|
|
-->
|
|
<figure v-else-if="block.type === 'image'" class="gb-figure"
|
|
:class="{ 'gb-sectiondone': inDoneSection(index) }">
|
|
<img v-if="imageState(block).src" :src="imageState(block).src"
|
|
:alt="block.alt || ''">
|
|
<p v-else-if="imageState(block).failed" class="gb-imagefailed">
|
|
Image unavailable{{ block.alt ? ': ' + block.alt : '' }}
|
|
</p>
|
|
<p v-else class="gb-imageloading">Loading image...</p>
|
|
<figcaption v-if="block.caption">{{ block.caption }}</figcaption>
|
|
</figure>
|
|
|
|
<div v-else-if="block.type === 'table'" class="gb-tablewrap"
|
|
:class="{ 'gb-sectiondone': inDoneSection(index) }">
|
|
<table class="gb-table">
|
|
<thead v-if="block.header && block.header.length">
|
|
<tr><th v-for="(cell, c) in block.header" :key="c" v-html="inline(cell)"></th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(row, r) in (block.rows || [])" :key="r">
|
|
<td v-for="(cell, c) in row" :key="c" v-html="inline(cell)"></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- An unknown type renders as nothing rather than as an error. A guide
|
|
written against a newer version must stay readable on an older one. -->
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, toRef } from 'vue'
|
|
import { renderInline } from '../utils/inline.js'
|
|
import { useGuideImages } from '../utils/images.js'
|
|
|
|
const props = defineProps({
|
|
blocks: { type: Array, default: () => [] },
|
|
// Live checkboxes belong to the viewer only.
|
|
interactive: { type: Boolean, default: false },
|
|
ticked: { type: Object, default: () => ({}) },
|
|
})
|
|
const emit = defineEmits(['toggle'])
|
|
|
|
const selected = ref('')
|
|
const codeNodes = {}
|
|
function registerCode (id, el) { if (el) codeNodes[id] = el }
|
|
|
|
// Images are fetched by script, not by <img src>; see utils/images.js.
|
|
const { state: imageState } = useGuideImages(toRef(props, 'blocks'))
|
|
|
|
function inline (text) {
|
|
return renderInline(text)
|
|
}
|
|
|
|
// Items may be plain strings (bullets) or objects with ids (checklists). One
|
|
// shape here so the template does not branch on it.
|
|
function itemsOf (block) {
|
|
return (block.items || []).map((item, index) =>
|
|
typeof item === 'string'
|
|
? { id: (block.id || 'b') + '-' + index, text: item }
|
|
: item)
|
|
}
|
|
|
|
function isTicked (itemid) {
|
|
return Boolean(props.ticked[itemid])
|
|
}
|
|
|
|
function toggle (itemid, checked) {
|
|
if (!props.interactive) return
|
|
emit('toggle', itemid, checked)
|
|
}
|
|
|
|
// Which heading governs each block: the nearest one above it. Ticking a
|
|
// heading crosses off everything under it down to the next heading, because
|
|
// that is what "this section is done" means to somebody reading - striking the
|
|
// title alone leaves the wall of text it was introducing looking untouched.
|
|
// Headings nest. A block belongs to every heading above it that has not been
|
|
// closed by a heading of the same or higher rank, so ticking a top-level
|
|
// section crosses off its sub-sections too - which is what "this section is
|
|
// done" means to somebody who ticked the thing that contained them.
|
|
//
|
|
// A guide written before levels existed has none; those all read as level 2,
|
|
// which is the flat behaviour they already had.
|
|
function levelOf (block) {
|
|
const level = Number(block.level)
|
|
return Number.isFinite(level) && level >= 2 && level <= 6 ? level : 2
|
|
}
|
|
|
|
const ancestors = computed(() => {
|
|
const map = {}
|
|
const stack = []
|
|
props.blocks.forEach((block, index) => {
|
|
if (block.type === 'heading') {
|
|
const level = levelOf(block)
|
|
while (stack.length && stack[stack.length - 1].level >= level) stack.pop()
|
|
map[index] = stack.map(entry => entry.id)
|
|
stack.push({ id: block.id, level })
|
|
} else {
|
|
map[index] = stack.map(entry => entry.id)
|
|
}
|
|
})
|
|
return map
|
|
})
|
|
|
|
function inDoneSection (index) {
|
|
return (ancestors.value[index] || []).some(id => isTicked(id))
|
|
}
|
|
|
|
// Steps number themselves by position among steps, so inserting a paragraph
|
|
// between two of them does not renumber anything by hand.
|
|
const stepIndex = computed(() => {
|
|
const map = {}
|
|
let n = 0
|
|
props.blocks.forEach((block, index) => {
|
|
if (block.type === 'step') { n += 1; map[index] = n }
|
|
})
|
|
return map
|
|
})
|
|
function stepNumber (index) {
|
|
return stepIndex.value[index] || 1
|
|
}
|
|
|
|
function anchorFor (block) {
|
|
return 'g-' + (block.id || '')
|
|
}
|
|
|
|
function select (block) {
|
|
const node = codeNodes[block.id]
|
|
if (!node || !window.getSelection) return
|
|
const range = document.createRange()
|
|
range.selectNodeContents(node)
|
|
const selection = window.getSelection()
|
|
selection.removeAllRanges()
|
|
selection.addRange(range)
|
|
selected.value = block.id
|
|
setTimeout(() => { selected.value = '' }, 4000)
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Every colour is a theme token. Nothing here is a literal, which is what makes
|
|
dark mode work with no dark-mode code. */
|
|
.gb-blocks { line-height: 1.65; }
|
|
.gb-heading { margin: 1.6rem 0 0.6rem; font-size: 1.35rem; color: var(--text); }
|
|
/* Rank is visible, so a reader can see what contains what - and so ticking a
|
|
section off looks like it covered the right amount of page. */
|
|
.gb-h3 { font-size: 1.15rem; margin-top: 1.2rem; }
|
|
.gb-h4, .gb-h5, .gb-h6 { font-size: 1.02rem; margin-top: 1rem; }
|
|
|
|
/* A ticked section is struck through and faded, not hidden: somebody scanning
|
|
back up the page has to be able to read what they already did.
|
|
|
|
The strike lands on the heading and on everything under it. A code block is
|
|
faded but NOT struck - a line through a command makes it unreadable, and the
|
|
reason to look back at a finished step is usually to re-read the command. */
|
|
.gb-sectiondone { text-decoration: line-through; opacity: 0.55; }
|
|
.gb-sectiondone .gb-stepnum { text-decoration: none; }
|
|
.gb-code.gb-sectiondone,
|
|
.gb-sectiondone pre,
|
|
.gb-sectiondone code,
|
|
.gb-figure.gb-sectiondone,
|
|
.gb-tablewrap.gb-sectiondone { text-decoration: none; }
|
|
.gb-sectiontick {
|
|
/* Touch-sized: a guide is read on a shopfloor PC, often with gloves on. */
|
|
width: 1.1rem;
|
|
height: 1.1rem;
|
|
margin-right: 0.5rem;
|
|
vertical-align: middle;
|
|
cursor: pointer;
|
|
}
|
|
.gb-heading .gb-sectiontick { text-decoration: none; }
|
|
.gb-para { margin: 0 0 0.9rem; color: var(--text); }
|
|
|
|
.gb-step {
|
|
border-left: 3px solid var(--primary);
|
|
padding: 0.6rem 0 0.6rem 1rem;
|
|
margin: 0 0 1rem;
|
|
}
|
|
.gb-stepnum {
|
|
display: inline-block; font-size: 0.72rem; text-transform: uppercase;
|
|
letter-spacing: 0.06em; color: var(--primary); font-weight: 600;
|
|
}
|
|
.gb-step h3 { margin: 0.2rem 0 0.4rem; font-size: 1.05rem; }
|
|
|
|
.gb-checklist { list-style: none; padding: 0; margin: 0 0 1rem; }
|
|
.gb-check { margin: 0.35rem 0; }
|
|
.gb-check label { display: flex; gap: 0.6rem; align-items: flex-start; cursor: pointer; }
|
|
.gb-interactive .gb-check label:hover { color: var(--primary); }
|
|
.gb-check input { margin-top: 0.28rem; flex: none; width: 1rem; height: 1rem; }
|
|
.gb-done { opacity: 0.55; text-decoration: line-through; }
|
|
|
|
.gb-list { margin: 0 0 1rem 1.2rem; }
|
|
.gb-list li { margin: 0.25rem 0; }
|
|
|
|
.gb-callout {
|
|
border-left: 4px solid var(--info, var(--primary));
|
|
background: var(--surface-2, rgba(127, 127, 127, 0.08));
|
|
padding: 0.75rem 1rem; margin: 0 0 1rem; border-radius: 4px;
|
|
}
|
|
.gb-callout strong { display: block; margin-bottom: 0.25rem; }
|
|
.gb-warning { border-left-color: var(--warning, #d9822b); }
|
|
.gb-success { border-left-color: var(--success, #2e9e5b); }
|
|
|
|
.gb-code { margin: 0 0 1rem; border: 1px solid var(--border); border-radius: 5px; overflow: hidden; }
|
|
.gb-codehead {
|
|
display: flex; align-items: center; gap: 0.6rem;
|
|
padding: 0.3rem 0.6rem; background: var(--surface-2, rgba(127,127,127,0.08));
|
|
border-bottom: 1px solid var(--border); font-size: 0.75rem;
|
|
}
|
|
.gb-lang { text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-light); }
|
|
.gb-caption { color: var(--text-light); }
|
|
.gb-copy {
|
|
margin-left: auto; font-size: 0.72rem; padding: 0.15rem 0.5rem;
|
|
border: 1px solid var(--border); background: transparent;
|
|
color: var(--text-light); border-radius: 3px; cursor: pointer;
|
|
}
|
|
.gb-copy:hover { border-color: var(--primary); color: var(--primary); }
|
|
.gb-code pre {
|
|
margin: 0; padding: 0.7rem 0.8rem; overflow-x: auto;
|
|
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.84rem;
|
|
/* WRAP rather than clip: these guides are full of long UNC paths, and a
|
|
line that scrolls out of sight is a command somebody copies half of. */
|
|
white-space: pre-wrap; word-break: break-word;
|
|
}
|
|
|
|
.gb-figure { margin: 0 0 1.2rem; }
|
|
.gb-figure img { max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 4px; }
|
|
.gb-imageloading, .gb-imagefailed {
|
|
margin: 0; padding: 0.75rem 1rem; border: 1px dashed var(--border);
|
|
border-radius: 4px; font-size: 0.9rem;
|
|
}
|
|
.gb-imageloading { color: var(--text-light); }
|
|
.gb-imagefailed { color: var(--danger, #b3261e); }
|
|
.gb-figure figcaption { font-size: 0.8rem; color: var(--text-light); margin-top: 0.35rem; }
|
|
|
|
.gb-tablewrap { overflow-x: auto; margin: 0 0 1.2rem; }
|
|
.gb-table { border-collapse: collapse; width: 100%; }
|
|
.gb-table th, .gb-table td {
|
|
border: 1px solid var(--border); padding: 0.45rem 0.6rem; text-align: left;
|
|
vertical-align: top; font-size: 0.9rem;
|
|
}
|
|
.gb-table th { background: var(--surface-2, rgba(127,127,127,0.08)); }
|
|
|
|
@media print {
|
|
/* Paper has no hover, no clipboard and no theme. The light-mode token
|
|
overrides live in GuideView.vue, which can reach :root; this block is only
|
|
about what belongs on paper. */
|
|
.gb-copy { display: none; }
|
|
.gb-step, .gb-callout, .gb-code, .gb-figure, .gb-check { break-inside: avoid; }
|
|
|
|
/* Checkboxes print EMPTY on purpose, even where they are ticked on screen:
|
|
a printed guide is a worksheet somebody carries to the bay and ticks by
|
|
hand, and one person's progress is not part of the procedure. */
|
|
.gb-done { opacity: 1; text-decoration: none; }
|
|
.gb-check input { appearance: none; -webkit-appearance: none;
|
|
width: 0.85rem; height: 0.85rem; border: 1px solid #000;
|
|
border-radius: 2px; background: #fff; }
|
|
|
|
.gb-code pre { white-space: pre-wrap; }
|
|
.gb-stepnum { color: #000; }
|
|
}
|
|
</style>
|