diff --git a/plugins/guides/frontend/views/GuideEditor.vue b/plugins/guides/frontend/views/GuideEditor.vue index 5d5dd462..3afbbdab 100644 --- a/plugins/guides/frontend/views/GuideEditor.vue +++ b/plugins/guides/frontend/views/GuideEditor.vue @@ -68,12 +68,71 @@ + +
+
+
+ Link to a guide or article + +
+ +

Searching...

+

+ {{ refQuery ? 'Nothing matches that.' : 'Type to search.' }} +

+ +
+
+
-
+ +
+
+ +
+
+ + +
+ +
+ + + + {{ block.type }} +
@@ -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); }