Seven changes, all aimed at the same thing: think about the procedure, not about blocks. Live preview beside the editor, rendered with the SAME component readers get, so what an author sees is what will be published - a separate preview implementation would drift within a month. Formatting buttons wrap the selection, so nobody needs to know the markup is Markdown. New blocks and pasted images land AFTER the block being worked on rather than at the bottom of the document, which removes the scroll-back that made adding a screenshot mid-guide annoying. Enter in a list makes the next item and focuses it, the way every other list in every other tool behaves; backspace on an already-empty item removes it and steps back up. Typing a ten-item checklist no longer needs the mouse. Templates, because the blank page is the real barrier to somebody writing a procedure down at all. The shapes are lifted from the KB articles the team already writes, so guides written here read like the ones written before them. Autosave on a three-second debounce with an honest status line, safe precisely because the server dedups on content - an autosave that changed nothing writes no version. Leaving with unsaved work now asks first. CLIPBOARD: the code block no longer writes to the clipboard. A page that programmatically copies a shell command is the exact shape of a ClickFix attack, where malware plants a command in your clipboard and talks you into pasting it into Run - and uBlock Origin blocked ours, correctly, because it was writing PowerShell on click. The button now SELECTS the code and says to press Ctrl+C: same convenience, decision back with the person, and nothing for security tooling to object to. AUTOCORRECT is on for prose and off for code and table cells. A code block that autocorrects produces a guide telling somebody to run a command that does not work - smart quotes replacing a straight one, an autocapitalised cmdlet - and it still LOOKS right to the author. The person at the bay gets the syntax error.
258 lines
9.9 KiB
Vue
258 lines
9.9 KiB
Vue
<template>
|
|
<div class="gb-blocks" :class="{ 'gb-interactive': interactive }">
|
|
<template v-for="(block, index) in blocks" :key="block.id || index">
|
|
|
|
<h2 v-if="block.type === 'heading'" :id="anchorFor(block)" class="gb-heading"
|
|
v-html="inline(block.text)"></h2>
|
|
|
|
<p v-else-if="block.type === 'paragraph'" class="gb-para"
|
|
v-html="inline(block.text)"></p>
|
|
|
|
<div v-else-if="block.type === 'step'" class="gb-step">
|
|
<span class="gb-stepnum">Step {{ stepNumber(index) }}</span>
|
|
<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">
|
|
<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">
|
|
<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">
|
|
<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')">
|
|
<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">
|
|
<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>
|
|
|
|
<figure v-else-if="block.type === 'image'" class="gb-figure">
|
|
<img :src="block.url" :alt="block.alt || ''" loading="lazy">
|
|
<figcaption v-if="block.caption">{{ block.caption }}</figcaption>
|
|
</figure>
|
|
|
|
<div v-else-if="block.type === 'table'" class="gb-tablewrap">
|
|
<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 } from 'vue'
|
|
import { renderInline } from '../utils/inline.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 }
|
|
|
|
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)
|
|
}
|
|
|
|
// 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); }
|
|
.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-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>
|