It struck the heading and left everything underneath looking untouched, which is the opposite of what somebody ticking "this section is done" means. The strike now covers every block under that heading, down to the next heading of the same or higher rank - so a top-level section takes its sub-sections with it. That needed the rank, and the HTML importer was throwing it away: h2, h3 and h4 all arrived as a flat 'heading', so a parent heading had no children to cross out. It was the visible half of a real loss - a converted article kept its words and lost its shape. Headings now carry their level and render at it, and a guide written before this reads as flat, exactly as it did. Code blocks, tables and images inside a finished section fade but are not struck. A line through a command makes it unreadable, and re-reading the command is usually the reason to look back at a step already done. The five imported guides were re-imported so they carry their structure.
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>
|