Guide editor: real arrows, drag to reorder, add a section where it goes
Four things people kept tripping over. The move controls were a literal ^ and v character. They are arrows now. Sections can be dragged. The handle is a grip in the block bar, not the block itself: making the whole container draggable swallows text selection inside its own textarea, so you could no longer select a word to bold it. The type palette lived once, at the bottom of the entire guide. Adding a step in the middle meant adding it at the end and walking it up one press at a time. There is now an insert point in every gap - and at the end, so there is one way to add a section rather than two. Linking to another guide or a KB article meant knowing its slug or id by heart. A picker searches both by title and writes the markdown link at the cursor. It offers published guides only - never send a reader to a draft - and it stores an in-app path rather than an absolute URL, so the link survives the site being reached by another hostname or mounted under a subpath. The knowledge base is a separate plugin that a site may not have installed; its absence costs nothing but its results.
This commit is contained in:
@@ -68,12 +68,71 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Reference picker -->
|
||||
<div v-if="refOpen" class="ge-refmask" @click.self="refOpen = false">
|
||||
<div class="ge-ref">
|
||||
<div class="ge-refhead">
|
||||
<strong>Link to a guide or article</strong>
|
||||
<button type="button" @click="refOpen = false"><X :size="16" /></button>
|
||||
</div>
|
||||
<input ref="refInput" v-model="refQuery" class="ge-refsearch"
|
||||
placeholder="Search by title..." @input="searchReferences">
|
||||
<p v-if="refLoading" class="ge-hint">Searching...</p>
|
||||
<p v-else-if="!refResults.length" class="ge-hint">
|
||||
{{ refQuery ? 'Nothing matches that.' : 'Type to search.' }}
|
||||
</p>
|
||||
<ul v-else class="ge-reflist">
|
||||
<li v-for="item in refResults" :key="item.kind + item.id">
|
||||
<button type="button" @click="insertReference(item)">
|
||||
<span class="ge-refkind">{{ item.kind }}</span>
|
||||
<span class="ge-reftitle">{{ item.title }}</span>
|
||||
<small v-if="item.summary">{{ item.summary }}</small>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ge-split" :class="{ 'ge-haspreview': showPreview }">
|
||||
<div class="ge-editor">
|
||||
<div v-for="(block, index) in blocks" :key="block.id"
|
||||
class="ge-block" :class="{ 'ge-focused': focused === block.id }"
|
||||
@focusin="focused = block.id">
|
||||
<!-- Insert point ABOVE each section.
|
||||
The type palette used to live only at the bottom of the whole
|
||||
guide, so adding a step in the middle meant adding it at the end
|
||||
and walking it up one press at a time. The insert bar puts the
|
||||
palette where the section is actually going. -->
|
||||
<div v-for="(block, index) in blocks" :key="block.id">
|
||||
<div class="ge-insert" :class="{ 'ge-insertopen': insertAt === index }">
|
||||
<button type="button" class="ge-insertbtn"
|
||||
:title="'Add a section here'"
|
||||
@click="insertAt = insertAt === index ? null : index">
|
||||
<Plus :size="14" /> Add section
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="insertAt === index" class="ge-palette">
|
||||
<button v-for="type in blocktypes" :key="type" type="button"
|
||||
@click="add(type, index)">{{ type }}</button>
|
||||
<label class="ge-upload">
|
||||
image
|
||||
<input type="file" accept="image/*" hidden
|
||||
@change="event => onFilePick(event, index)">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="ge-block"
|
||||
:class="{ 'ge-focused': focused === block.id,
|
||||
'ge-dragging': dragIndex === index,
|
||||
'ge-dragover': dragOver === index }"
|
||||
@focusin="focused = block.id"
|
||||
@dragover.prevent="dragOver = index"
|
||||
@dragleave="dragOver === index && (dragOver = null)"
|
||||
@drop.prevent="dropOn(index)">
|
||||
<div class="ge-blockbar">
|
||||
<!-- Dragging is on the HANDLE, not the whole block: a draggable
|
||||
container swallows text selection inside its own textarea. -->
|
||||
<span class="ge-grip" draggable="true" title="Drag to move"
|
||||
@dragstart="startDrag(index, $event)" @dragend="endDrag">
|
||||
<GripVertical :size="14" />
|
||||
</span>
|
||||
<span class="ge-type">{{ block.type }}</span>
|
||||
<select v-if="block.type === 'callout'" v-model="block.variant" class="ge-mini">
|
||||
<option value="info">info</option>
|
||||
@@ -92,16 +151,23 @@
|
||||
<button type="button" title="Italic" @click="wrap(block, '*')"><i>I</i></button>
|
||||
<button type="button" title="Code" @click="wrap(block, '`')"><></button>
|
||||
<button type="button" title="Link" @click="makeLink(block)">link</button>
|
||||
<!-- Linking to another guide or a KB article by hand means
|
||||
knowing its slug or id. This finds it by name and writes the
|
||||
link, so a procedure can point at the one before it. -->
|
||||
<button type="button" title="Reference a guide or KB article"
|
||||
@click="openReference(block)"><Link2 :size="14" /></button>
|
||||
</div>
|
||||
|
||||
<div class="ge-blockbtns">
|
||||
<button type="button" title="Move up" @click="move(index, -1)"
|
||||
:disabled="index === 0">^</button>
|
||||
:disabled="index === 0"><ArrowUp :size="14" /></button>
|
||||
<button type="button" title="Move down" @click="move(index, 1)"
|
||||
:disabled="index === blocks.length - 1">v</button>
|
||||
<button type="button" title="Duplicate" @click="duplicate(index)">copy</button>
|
||||
:disabled="index === blocks.length - 1"><ArrowDown :size="14" /></button>
|
||||
<button type="button" title="Duplicate" @click="duplicate(index)">
|
||||
<Copy :size="14" />
|
||||
</button>
|
||||
<button type="button" class="ge-del" title="Remove"
|
||||
@click="remove(index)">x</button>
|
||||
@click="remove(index)"><X :size="14" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -158,14 +224,24 @@
|
||||
placeholder="One row per line, cells separated by | Header | Header Cell | Cell"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ge-add">
|
||||
<span>Add:</span>
|
||||
<!-- The end of the guide gets the same control as every gap between
|
||||
sections, so there is one way to add a section, not two. -->
|
||||
<div class="ge-insert ge-insertend"
|
||||
:class="{ 'ge-insertopen': insertAt === blocks.length }">
|
||||
<button type="button" class="ge-insertbtn"
|
||||
@click="insertAt = insertAt === blocks.length ? null : blocks.length">
|
||||
<Plus :size="14" /> Add section
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="insertAt === blocks.length" class="ge-palette">
|
||||
<button v-for="type in blocktypes" :key="type" type="button"
|
||||
@click="add(type)">{{ type }}</button>
|
||||
@click="add(type, blocks.length)">{{ type }}</button>
|
||||
<label class="ge-upload">
|
||||
image
|
||||
<input type="file" accept="image/*" hidden @change="onFilePick">
|
||||
<input type="file" accept="image/*" hidden
|
||||
@change="event => onFilePick(event, blocks.length)">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -209,6 +285,8 @@ import GuideBlocks from '../components/GuideBlocks.vue'
|
||||
import { templates } from '../utils/templates.js'
|
||||
import { proof, applyFix } from '../utils/proof.js'
|
||||
import { useGuideImages } from '../utils/images.js'
|
||||
import { ArrowUp, ArrowDown, GripVertical, Copy, X, Plus, Link2 } from 'lucide-vue-next'
|
||||
import { knowledgebaseApi } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -220,6 +298,17 @@ const blocktypes = ['heading', 'paragraph', 'step', 'checklist', 'bullets',
|
||||
const guide = reactive({ guideid: null, title: '', summary: '', tags: '',
|
||||
slug: '', ispublished: false })
|
||||
const blocks = ref([])
|
||||
// Which gap the type palette is open in. null = closed; blocks.length = the end.
|
||||
const insertAt = ref(null)
|
||||
const dragIndex = ref(null)
|
||||
const dragOver = ref(null)
|
||||
const refOpen = ref(false)
|
||||
const refQuery = ref('')
|
||||
const refResults = ref([])
|
||||
const refLoading = ref(false)
|
||||
const refInput = ref(null)
|
||||
let refBlock = null
|
||||
let refTimer = null
|
||||
const revisions = ref([])
|
||||
const saving = ref(false)
|
||||
const message = ref('')
|
||||
@@ -288,17 +377,111 @@ function insertIndex () {
|
||||
return index === -1 ? blocks.value.length : index + 1
|
||||
}
|
||||
|
||||
function add (type) {
|
||||
// `at` is an explicit gap from an insert bar. Without one, fall back to
|
||||
// "after whatever is focused", which is what the old bottom palette did.
|
||||
function add (type, at = null) {
|
||||
const block = { id: newid(), type }
|
||||
if (hasItems(block)) block.items = [{ id: newid(), text: '' }]
|
||||
if (type === 'callout') block.variant = 'info'
|
||||
if (type === 'table') { block.raw = ''; block.header = []; block.rows = [] }
|
||||
const at = insertIndex()
|
||||
blocks.value.splice(at, 0, block)
|
||||
const index = at === null ? insertIndex() : at
|
||||
blocks.value.splice(index, 0, block)
|
||||
insertAt.value = null
|
||||
focused.value = block.id
|
||||
nextTick(() => textareas[block.id]?.focus())
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- dragging ---
|
||||
function startDrag (index, event) {
|
||||
dragIndex.value = index
|
||||
// Firefox will not start a drag without payload, and 'move' is what the
|
||||
// cursor should say.
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
try { event.dataTransfer.setData('text/plain', String(index)) } catch (err) { /* older browsers */ }
|
||||
}
|
||||
|
||||
function endDrag () {
|
||||
dragIndex.value = null
|
||||
dragOver.value = null
|
||||
}
|
||||
|
||||
function dropOn (index) {
|
||||
const from = dragIndex.value
|
||||
endDrag()
|
||||
if (from === null || from === index) return
|
||||
const [moved] = blocks.value.splice(from, 1)
|
||||
blocks.value.splice(index, 0, moved)
|
||||
focused.value = moved.id
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- references ---
|
||||
function openReference (block) {
|
||||
refBlock = block
|
||||
refOpen.value = true
|
||||
refQuery.value = ''
|
||||
refResults.value = []
|
||||
nextTick(() => refInput.value?.focus())
|
||||
}
|
||||
|
||||
function searchReferences () {
|
||||
clearTimeout(refTimer)
|
||||
const term = refQuery.value.trim()
|
||||
if (!term) { refResults.value = []; return }
|
||||
refTimer = setTimeout(async () => {
|
||||
refLoading.value = true
|
||||
try {
|
||||
const results = []
|
||||
// Guides first: a procedure most often points at another procedure.
|
||||
try {
|
||||
const response = await api.get('/guides', { params: { q: term } })
|
||||
for (const guide of (response.data?.data || [])) {
|
||||
if (!guide.ispublished) continue // never link readers at a draft
|
||||
results.push({ kind: 'guide', id: guide.guideid, title: guide.title,
|
||||
summary: guide.summary, url: `/guides/${guide.slug}` })
|
||||
}
|
||||
} catch (err) { /* guides always exist here, but never break the picker */ }
|
||||
|
||||
// The knowledge base is a separate plugin and may not be installed at
|
||||
// this site (ADR-009). Its absence must cost nothing but its results.
|
||||
try {
|
||||
const response = await knowledgebaseApi.list({ search: term, perpage: 20 })
|
||||
const rows = response.data?.data || []
|
||||
for (const article of (Array.isArray(rows) ? rows : rows.items || [])) {
|
||||
results.push({ kind: 'article', id: article.articleid || article.id,
|
||||
title: article.title,
|
||||
summary: article.summary || article.description,
|
||||
url: `/knowledgebase/${article.articleid || article.id}` })
|
||||
}
|
||||
} catch (err) { /* KB plugin absent or disabled */ }
|
||||
|
||||
refResults.value = results.slice(0, 25)
|
||||
} finally {
|
||||
refLoading.value = false
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function insertReference (item) {
|
||||
// An in-app path, not an absolute URL: the link has to keep working when the
|
||||
// site is reached by another hostname or mounted under a subpath.
|
||||
const markdown = `[${item.title}](${item.url})`
|
||||
const area = refBlock ? textareas[refBlock.id] : null
|
||||
if (refBlock && area) {
|
||||
const start = area.selectionStart ?? refBlock.text?.length ?? 0
|
||||
const end = area.selectionEnd ?? start
|
||||
const text = refBlock.text || ''
|
||||
refBlock.text = text.slice(0, start) + markdown + text.slice(end)
|
||||
nextTick(() => {
|
||||
area.focus()
|
||||
const caret = start + markdown.length
|
||||
area.setSelectionRange(caret, caret)
|
||||
})
|
||||
} else if (refBlock) {
|
||||
refBlock.text = (refBlock.text || '') + markdown
|
||||
}
|
||||
refOpen.value = false
|
||||
}
|
||||
|
||||
function duplicate (index) {
|
||||
const source = blocks.value[index]
|
||||
const copy = JSON.parse(JSON.stringify(source))
|
||||
@@ -383,7 +566,7 @@ async function uploadImage (file) {
|
||||
return response.data?.data || response.data
|
||||
}
|
||||
|
||||
async function insertImage (file) {
|
||||
async function insertImage (file, at = null) {
|
||||
error.value = ''
|
||||
status.value = 'Uploading image...'
|
||||
try {
|
||||
@@ -392,7 +575,8 @@ async function insertImage (file) {
|
||||
// for anything that still treats a block as a plain document.
|
||||
const block = { id: newid(), type: 'image', imageid: image.guideimageid,
|
||||
url: image.url, caption: '', alt: '' }
|
||||
blocks.value.splice(insertIndex(), 0, block)
|
||||
blocks.value.splice(at === null ? insertIndex() : at, 0, block)
|
||||
insertAt.value = null
|
||||
focused.value = block.id
|
||||
status.value = ''
|
||||
} catch (err) {
|
||||
@@ -416,9 +600,9 @@ function onDrop (event) {
|
||||
if (file) insertImage(file)
|
||||
}
|
||||
|
||||
function onFilePick (event) {
|
||||
function onFilePick (event, at = null) {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) insertImage(file)
|
||||
if (file) insertImage(file, at)
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
@@ -714,4 +898,77 @@ onMounted(async () => {
|
||||
.ge-rev button { margin-left: auto; font-size: 0.72rem; padding: 0.12rem 0.45rem;
|
||||
border: 1px solid var(--border); background: transparent;
|
||||
color: var(--text-light); border-radius: 3px; cursor: pointer; }
|
||||
|
||||
/* --- insert bars, drag handle, reference picker -------------------------- */
|
||||
|
||||
/* A quiet line that becomes a button on hover: present when wanted, not
|
||||
competing with the guide's own content the rest of the time. */
|
||||
.ge-insert {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 1.5rem; opacity: 0; transition: opacity 0.12s;
|
||||
}
|
||||
.ge-insert:hover, .ge-insertopen, .ge-insertend { opacity: 1; }
|
||||
.ge-insert::before, .ge-insert::after {
|
||||
content: ''; flex: 1; height: 1px; background: var(--border);
|
||||
}
|
||||
.ge-insertbtn {
|
||||
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||
margin: 0 0.5rem; padding: 0.15rem 0.6rem;
|
||||
font-size: 0.75rem; color: var(--text-light);
|
||||
border: 1px solid var(--border); border-radius: 999px;
|
||||
background: var(--bg); cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.ge-insertbtn:hover { color: var(--primary); border-color: var(--primary); }
|
||||
.ge-palette {
|
||||
display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center;
|
||||
margin: 0 0 0.6rem; padding: 0.5rem;
|
||||
border: 1px solid var(--border); border-radius: 5px;
|
||||
}
|
||||
.ge-palette button, .ge-palette .ge-upload {
|
||||
font-size: 0.75rem; padding: 0.2rem 0.5rem; border: 1px solid var(--border);
|
||||
background: transparent; color: var(--text); border-radius: 3px; cursor: pointer;
|
||||
}
|
||||
.ge-palette button:hover, .ge-palette .ge-upload:hover {
|
||||
color: var(--primary); border-color: var(--primary);
|
||||
}
|
||||
|
||||
.ge-grip { display: inline-flex; align-items: center; color: var(--text-light);
|
||||
cursor: grab; padding-right: 0.2rem; }
|
||||
.ge-grip:active { cursor: grabbing; }
|
||||
.ge-dragging { opacity: 0.45; }
|
||||
.ge-dragover { border-top: 2px solid var(--primary); }
|
||||
|
||||
.ge-refmask {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45);
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
padding-top: 8vh; z-index: 50;
|
||||
}
|
||||
.ge-ref {
|
||||
width: min(34rem, 92vw); max-height: 70vh; overflow: auto;
|
||||
background: var(--bg); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 6px; padding: 1rem;
|
||||
}
|
||||
.ge-refhead { display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 0.6rem; }
|
||||
.ge-refhead button { background: transparent; border: none; color: var(--text-light);
|
||||
cursor: pointer; }
|
||||
.ge-refsearch {
|
||||
width: 100%; padding: 0.5rem 0.7rem; font-size: 1rem;
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
background: transparent; color: var(--text); margin-bottom: 0.6rem;
|
||||
}
|
||||
.ge-reflist { list-style: none; margin: 0; padding: 0; }
|
||||
.ge-reflist button {
|
||||
width: 100%; display: flex; flex-direction: column; gap: 0.15rem;
|
||||
padding: 0.5rem 0.6rem; margin-bottom: 0.3rem; text-align: left;
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
background: transparent; color: var(--text); cursor: pointer;
|
||||
}
|
||||
.ge-reflist button:hover { border-color: var(--primary); }
|
||||
.ge-refkind {
|
||||
font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.ge-reftitle { font-weight: 600; }
|
||||
.ge-reflist small { color: var(--text-light); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user