-
Add:
+
+
+
+ @click="add(type, blocks.length)">{{ 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); }