// ── Slideshow Plugin ── // // Stellt eine Karten-Gruppe (Parent + Children) als Präsentation dar. // - Text-Folien: normaler Markdown-Content wird fullscreen gerendert. // - Bild-Folien: Card-Body enthält einen ```slide-image block mit Bild + Hotspot-Rects. // Rects sind unsichtbar, aber klickbar und springen zu anderen Folien. // // Datenformat (fenced block ```slide-image```): // // src: /uploads/foo.png // 10,20,30,15 -> card:42 // 50,60,20,10 -> slide:next // // Koordinaten in Prozent. Targets: // card: → springt zu Karte mit (wenn in der Gruppe) // slide:next → nächste Folie // slide:prev → vorherige Folie // slide:first → Startfolie // slide: → Folie mit Index n (0-basiert) const hyprcrd = window.hyprcrd; if (!hyprcrd) throw new Error('[slideshow] window.hyprcrd not found'); const $ = (id) => document.getElementById(id); const escHtml = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); // ── Fenced block parser/serializer ─────────────────────────────── function parseSlideBlock(content) { const lines = String(content || '').split('\n'); let src = ''; const hotspots = []; for (const raw of lines) { const line = raw.trim(); if (!line) continue; if (/^src\s*:/i.test(line)) { src = line.replace(/^src\s*:\s*/i, '').trim(); continue; } const m = line.match(/^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*(?:->|→)\s*(.+)$/); if (m) hotspots.push({ x: +m[1], y: +m[2], w: +m[3], h: +m[4], target: m[5].trim() }); } return { src, hotspots }; } function serializeSlideBlock({ src, hotspots }) { const r = (n) => Math.round(Number(n) * 100) / 100; const out = [`src: ${src || ''}`]; for (const h of hotspots) { out.push(`${r(h.x)},${r(h.y)},${r(h.w)},${r(h.h)} -> ${h.target}`); } return out.join('\n'); } // Find an existing ```slide-image``` block that contains the cursor position // (or returns the first one if no cursor given). Returns { start, end, content } or null. function findSlideBlock(value, cursor) { const re = /```slide-image\s*\n([\s\S]*?)(?:\n```|$)/g; let m; let first = null; while ((m = re.exec(value)) !== null) { const start = m.index; const end = m.index + m[0].length; const entry = { start, end, content: m[1] }; if (first === null) first = entry; if (cursor != null && cursor >= start && cursor <= end) return entry; } // Fall back to the first block in the body so the user can edit it from anywhere. return first; } // Detect if a card body is a "pure image slide" — body's rendered slide block is the primary content. function extractCardSlide(body) { const b = String(body || ''); const m = b.match(/```slide-image\s*\n([\s\S]*?)(?:\n```|$)/); if (!m) return null; return parseSlideBlock(m[1]); } // ── Card title cache (for resolving hotspot targets to human labels) ─── const cardTitleCache = new Map(); // id → title async function refreshCardTitles() { try { const cards = await hyprcrd.api('api/cards/meta'); if (Array.isArray(cards)) { cardTitleCache.clear(); for (const c of cards) cardTitleCache.set(String(c.id), c.title || ''); } } catch {} } function resolveTargetLabel(target) { const t = String(target || '').trim(); if (!t) return ''; if (t === 'slide:next') return 'next slide'; if (t === 'slide:prev') return 'previous slide'; if (t === 'slide:first') return 'first slide'; const slideMatch = t.match(/^slide:(\d+)$/); if (slideMatch) return `slide ${slideMatch[1]}`; const cardMatch = t.match(/^card:(.+)$/); if (cardMatch) { const id = cardMatch[1]; return cardTitleCache.get(String(id)) || ''; } return t; } // Fire off initial cache load (non-blocking) refreshCardTitles(); // ── Markdown fenced block renderer (preview in normal card views) ── hyprcrd.markdown.registerFencedBlock('slide-image', (content, { esc }) => { const { src, hotspots } = parseSlideBlock(content); if (!src) return `
slide-image: missing src
`; const rects = hotspots.map(h => { const style = `left:${h.x}%;top:${h.y}%;width:${h.w}%;height:${h.h}%`; const label = resolveTargetLabel(h.target); const labelAttr = label ? ` data-label="${esc(label)}"` : ''; return `
`; }).join(''); return `
${rects}
`; }); // ── Viewer ───────────────────────────────────────────────────────── const viewer = $('slideshow-viewer'); const viewerSlide = $('slideshow-slide'); const viewerTitle = $('slideshow-title'); const viewerCounter = $('slideshow-counter'); const viewerPrev = $('slideshow-prev'); const viewerNext = $('slideshow-next'); const viewerClose = $('slideshow-close'); let vSlides = []; let vCurrent = 0; let vOpen = false; function openViewer(slides, startIdx = 0) { if (!viewer || !slides?.length) return; vSlides = slides; vCurrent = Math.max(0, Math.min(startIdx, slides.length - 1)); vOpen = true; viewer.classList.remove('hidden'); document.body.style.overflow = 'hidden'; renderViewer(); document.addEventListener('keydown', onViewerKey, true); } function closeViewer() { if (!vOpen) return; vOpen = false; viewer.classList.add('hidden'); document.body.style.overflow = ''; document.removeEventListener('keydown', onViewerKey, true); viewerSlide.innerHTML = ''; vSlides = []; } function renderViewer() { const card = vSlides[vCurrent]; if (!card) return; viewerTitle.textContent = card.title || ''; viewerCounter.textContent = `${vCurrent + 1} / ${vSlides.length}`; const slide = extractCardSlide(card.body); if (slide && slide.src) { renderImageSlide(slide); } else { renderTextSlide(card); } } function renderTextSlide(card) { const mdRender = hyprcrd.markdown?.render; const html = mdRender ? mdRender(card.body || '') : `
${escHtml(card.body || '')}
`; viewerSlide.innerHTML = `
${card.title ? `

${escHtml(card.title)}

` : ''}
${html}
`; } function renderImageSlide(slide) { const rects = slide.hotspots.map((h, i) => { const style = `left:${h.x}%;top:${h.y}%;width:${h.w}%;height:${h.h}%`; const label = resolveTargetLabel(h.target) || h.target || ''; return `
`; }).join(''); viewerSlide.innerHTML = `
${rects}
`; viewerSlide.querySelectorAll('[data-hs]').forEach(el => { const idx = parseInt(el.dataset.hs, 10); el.addEventListener('click', async (e) => { e.stopPropagation(); e.preventDefault(); const target = slide.hotspots[idx]?.target; if (target) await navigateTarget(target); }); }); } async function navigateTarget(target) { const t = String(target).trim(); if (t === 'slide:next') return nextSlide(); if (t === 'slide:prev') return prevSlide(); if (t === 'slide:first') { vCurrent = 0; renderViewer(); return; } const slideMatch = t.match(/^slide:(\d+)$/); if (slideMatch) { const idx = parseInt(slideMatch[1], 10); if (idx >= 0 && idx < vSlides.length) { vCurrent = idx; renderViewer(); } return; } const cardMatch = t.match(/^card:(.+)$/); if (cardMatch) { const id = cardMatch[1]; let idx = vSlides.findIndex(c => String(c.id) === String(id)); if (idx < 0) { // Cross-group jump: load the target card from the API and append it to the slide list try { const cards = await hyprcrd.api('api/cards?ids=' + encodeURIComponent(id)); const card = Array.isArray(cards) ? cards.find(c => String(c.id) === String(id)) : null; if (card) { vSlides.push(card); idx = vSlides.length - 1; } } catch {} } if (idx >= 0) { vCurrent = idx; renderViewer(); } else console.warn('[slideshow] target card not found:', id); return; } console.warn('[slideshow] unknown target:', target); } function nextSlide() { if (vCurrent < vSlides.length - 1) { vCurrent++; renderViewer(); } } function prevSlide() { if (vCurrent > 0) { vCurrent--; renderViewer(); } } function onViewerKey(e) { if (!vOpen) return; if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeViewer(); return; } if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); nextSlide(); return; } if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); e.stopPropagation(); prevSlide(); return; } if (e.key === 'Home') { e.preventDefault(); e.stopPropagation(); vCurrent = 0; renderViewer(); return; } if (e.key === 'End') { e.preventDefault(); e.stopPropagation(); vCurrent = vSlides.length - 1; renderViewer(); return; } if (e.key === 'h' || e.key === 'H') { viewer.classList.toggle('debug-hotspots'); e.preventDefault(); } } viewerPrev?.addEventListener('click', (e) => { e.stopPropagation(); prevSlide(); }); viewerNext?.addEventListener('click', (e) => { e.stopPropagation(); nextSlide(); }); viewerClose?.addEventListener('click', (e) => { e.stopPropagation(); closeViewer(); }); // ── Start viewer from group-bar button ─────────────────────────── async function startPresentationForGroup(parentId) { if (parentId == null) return; // Fetch full cards so bodies are available const all = await hyprcrd.api('api/cards'); if (!Array.isArray(all)) return; // Refresh title cache from the same payload (saves a round-trip) cardTitleCache.clear(); for (const c of all) cardTitleCache.set(String(c.id), c.title || ''); const parent = all.find(c => String(c.id) === String(parentId)); if (!parent) return; const children = all .filter(c => String(c.parent_id) === String(parentId)) .sort((a, b) => (a.position || 0) - (b.position || 0)); const slides = [parent, ...children]; openViewer(slides, 0); } // ── Group-bar button injection ─────────────────────────────────── function injectGroupBarButton() { const bar = $('group-bar'); if (!bar) return; if (bar.classList.contains('hidden')) return; if (!bar.dataset.groupId) return; if (bar.querySelector('.slideshow-present-btn')) return; const btn = document.createElement('button'); btn.className = 'slideshow-present-btn'; btn.type = 'button'; btn.innerHTML = 'present'; btn.addEventListener('click', async (e) => { e.stopPropagation(); const pid = bar.dataset.groupId; if (pid) await startPresentationForGroup(pid); }); bar.appendChild(btn); } const groupBar = $('group-bar'); if (groupBar) { const mo = new MutationObserver(() => injectGroupBarButton()); mo.observe(groupBar, { childList: true, subtree: false, attributes: true, attributeFilter: ['class'] }); injectGroupBarButton(); } // ── Hotspot editor ─────────────────────────────────────────────── const ed = { overlay: $('slideshow-editor'), img: $('slideshow-ed-img'), stage: $('slideshow-ed-stage'), layer: $('slideshow-ed-layer'), srcInput: $('slideshow-ed-src'), galleryBtn: $('slideshow-ed-gallery'), pickBtn: $('slideshow-ed-pick'), fileInput: $('slideshow-ed-file'), deleteBtn: $('slideshow-ed-delete'), okBtn: $('slideshow-ed-ok'), cancelBtn: $('slideshow-ed-cancel'), }; let edHotspots = []; let edSelected = -1; let edDrag = null; // { startX, startY, draft } let edOnDone = null; // callback({src, hotspots}) or null for cancel function edOpen({ src, hotspots }, onDone) { edHotspots = hotspots.map(h => ({ ...h })); edSelected = -1; edOnDone = onDone; ed.srcInput.value = src || ''; ed.img.src = src || ''; ed.overlay.classList.remove('hidden'); renderEdRects(); updateEdDeleteBtn(); // Refresh card titles so labels resolve correctly refreshCardTitles().then(renderEdRects); } function edClose(commit) { ed.overlay.classList.add('hidden'); if (commit && edOnDone) { edOnDone({ src: ed.srcInput.value.trim(), hotspots: edHotspots }); } edOnDone = null; edHotspots = []; edSelected = -1; edDrag = null; } const EDGE_HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']; function renderEdRects() { ed.layer.innerHTML = ''; edHotspots.forEach((h, i) => { const el = document.createElement('div'); const isSelected = i === edSelected; el.className = 'ed-rect' + (isSelected ? ' selected' : ''); el.style.left = h.x + '%'; el.style.top = h.y + '%'; el.style.width = h.w + '%'; el.style.height = h.h + '%'; el.dataset.idx = String(i); let handles = ''; if (isSelected) { handles = EDGE_HANDLES.map(d => `
`).join(''); } const labelText = resolveTargetLabel(h.target) || h.target || '?'; el.innerHTML = `${escHtml(labelText)}${handles}`; el.addEventListener('mousedown', (e) => onRectMouseDown(e, i)); el.addEventListener('dblclick', (e) => { e.stopPropagation(); edSelected = i; openPicker((target) => { if (target) { edHotspots[i].target = target; renderEdRects(); } }); }); ed.layer.appendChild(el); }); } function updateEdDeleteBtn() { if (!ed.deleteBtn) return; if (edSelected >= 0) ed.deleteBtn.removeAttribute('disabled'); else ed.deleteBtn.setAttribute('disabled', 'disabled'); } function edLayerPct(e) { const imgRect = ed.img.getBoundingClientRect(); const x = ((e.clientX - imgRect.left) / imgRect.width) * 100; const y = ((e.clientY - imgRect.top) / imgRect.height) * 100; return { x: Math.max(0, Math.min(100, x)), y: Math.max(0, Math.min(100, y)), }; } function clampPct(n, min, max) { return Math.max(min, Math.min(max, n)); } function onRectMouseDown(e, idx) { if (e.button !== 0) return; e.stopPropagation(); e.preventDefault(); const handleEl = e.target.closest('.ed-handle'); edSelected = idx; const p = edLayerPct(e); const startRect = { ...edHotspots[idx] }; if (handleEl) { edDrag = { mode: 'resize', startX: p.x, startY: p.y, startRect, idx, handle: handleEl.dataset.handle }; } else { edDrag = { mode: 'move', startX: p.x, startY: p.y, startRect, idx }; } renderEdRects(); updateEdDeleteBtn(); } function onEdMouseDown(e) { if (e.button !== 0) return; if (e.target.closest('.ed-rect')) return; if (!ed.img.src || !ed.img.complete) return; e.preventDefault(); edSelected = -1; updateEdDeleteBtn(); // Re-render existing rects first so the prior selection clears visually — // doing this AFTER creating the draft would wipe it since renderEdRects // replaces ed.layer.innerHTML. renderEdRects(); const p = edLayerPct(e); const draft = document.createElement('div'); draft.className = 'ed-rect-draft'; draft.style.left = p.x + '%'; draft.style.top = p.y + '%'; draft.style.width = '0%'; draft.style.height = '0%'; ed.layer.appendChild(draft); edDrag = { mode: 'create', startX: p.x, startY: p.y, draft }; } function onEdMouseMove(e) { if (!edDrag) return; const p = edLayerPct(e); if (edDrag.mode === 'create') { const x = Math.min(edDrag.startX, p.x); const y = Math.min(edDrag.startY, p.y); const w = Math.abs(p.x - edDrag.startX); const h = Math.abs(p.y - edDrag.startY); edDrag.draft.style.left = x + '%'; edDrag.draft.style.top = y + '%'; edDrag.draft.style.width = w + '%'; edDrag.draft.style.height = h + '%'; return; } // move / resize on existing rect const dx = p.x - edDrag.startX; const dy = p.y - edDrag.startY; const s = edDrag.startRect; let r = { x: s.x, y: s.y, w: s.w, h: s.h }; if (edDrag.mode === 'move') { r.x = clampPct(s.x + dx, 0, 100 - s.w); r.y = clampPct(s.y + dy, 0, 100 - s.h); } else if (edDrag.mode === 'resize') { const h = edDrag.handle; if (h.includes('w')) { r.x = s.x + dx; r.w = s.w - dx; } if (h.includes('e')) { r.w = s.w + dx; } if (h.includes('n')) { r.y = s.y + dy; r.h = s.h - dy; } if (h.includes('s')) { r.h = s.h + dy; } // Flip when dragged past the opposite edge if (r.w < 0) { r.x = r.x + r.w; r.w = -r.w; } if (r.h < 0) { r.y = r.y + r.h; r.h = -r.h; } // Clamp inside the image if (r.x < 0) { r.w = Math.max(0, r.w + r.x); r.x = 0; } if (r.y < 0) { r.h = Math.max(0, r.h + r.y); r.y = 0; } if (r.x + r.w > 100) r.w = 100 - r.x; if (r.y + r.h > 100) r.h = 100 - r.y; } edHotspots[edDrag.idx] = { ...r, target: s.target }; const el = ed.layer.querySelector(`.ed-rect[data-idx="${edDrag.idx}"]`); if (el) { el.style.left = r.x + '%'; el.style.top = r.y + '%'; el.style.width = r.w + '%'; el.style.height = r.h + '%'; } } function onEdMouseUp(e) { if (!edDrag) return; if (edDrag.mode === 'create') { const p = edLayerPct(e); const x = Math.min(edDrag.startX, p.x); const y = Math.min(edDrag.startY, p.y); const w = Math.abs(p.x - edDrag.startX); const h = Math.abs(p.y - edDrag.startY); edDrag.draft.remove(); edDrag = null; if (w < 1 || h < 1) { renderEdRects(); return; } openPicker((target) => { if (!target) { renderEdRects(); return; } edHotspots.push({ x, y, w, h, target }); edSelected = edHotspots.length - 1; renderEdRects(); updateEdDeleteBtn(); }); return; } // move / resize: prune degenerate rect, keep selection const idx = edDrag.idx; edDrag = null; const r = edHotspots[idx]; if (r && (r.w < 0.5 || r.h < 0.5)) { edHotspots.splice(idx, 1); edSelected = -1; updateEdDeleteBtn(); } renderEdRects(); } ed.stage?.addEventListener('mousedown', onEdMouseDown); window.addEventListener('mousemove', (e) => { if (ed.overlay && !ed.overlay.classList.contains('hidden')) onEdMouseMove(e); }); window.addEventListener('mouseup', (e) => { if (ed.overlay && !ed.overlay.classList.contains('hidden')) onEdMouseUp(e); }); ed.deleteBtn?.addEventListener('click', () => { if (edSelected < 0) return; edHotspots.splice(edSelected, 1); edSelected = -1; renderEdRects(); updateEdDeleteBtn(); }); ed.okBtn?.addEventListener('click', () => edClose(true)); ed.cancelBtn?.addEventListener('click', () => edClose(false)); ed.overlay?.addEventListener('click', (e) => { if (e.target === ed.overlay) edClose(false); }); ed.srcInput?.addEventListener('change', () => { ed.img.src = ed.srcInput.value.trim(); }); ed.pickBtn?.addEventListener('click', () => ed.fileInput?.click()); ed.fileInput?.addEventListener('change', async (e) => { const file = e.target.files?.[0]; if (!file) return; const url = await uploadImage(file); if (url) { ed.srcInput.value = url; ed.img.src = url; } e.target.value = ''; }); ed.galleryBtn?.addEventListener('click', () => { openGalleryPicker((url) => { if (url) { ed.srcInput.value = url; ed.img.src = url; } }); }); async function uploadImage(file) { const form = new FormData(); form.append('file', file, file.name || 'slide.png'); try { const res = await fetch('api/upload', { method: 'POST', body: form, headers: { 'X-CSRF-Token': hyprcrd.util.csrf() }, }); const data = await res.json(); return data?.url || null; } catch { return null; } } // ── Gallery picker (for hotspot editor) ────────────────────────── const gp = { overlay: $('slideshow-gallery-picker'), grid: $('slideshow-gp-grid'), count: $('slideshow-gp-count'), uploadBtn: $('slideshow-gp-upload'), fileInput: $('slideshow-gp-file'), cancelBtn: $('slideshow-gp-cancel'), }; let gpOnDone = null; async function openGalleryPicker(onDone) { gpOnDone = onDone; gp.overlay.classList.remove('hidden'); await refreshGalleryPicker(); } function closeGalleryPicker(url) { gp.overlay.classList.add('hidden'); const cb = gpOnDone; gpOnDone = null; if (cb) cb(url || null); } async function refreshGalleryPicker() { let files = []; try { files = await hyprcrd.api('api/uploads') || []; } catch {} const images = files.filter(f => !/\.(mp3|ogg|wav|m4a)$/i.test(f.url || '')); gp.count.textContent = `${images.length} image${images.length !== 1 ? 's' : ''}`; if (!images.length) { gp.grid.innerHTML = `

no images yet — upload above

`; return; } gp.grid.innerHTML = images.map(f => `
${escHtml(f.name || '')}
`).join(''); gp.grid.querySelectorAll('[data-url]').forEach(el => { el.addEventListener('click', () => closeGalleryPicker(el.dataset.url)); }); } gp.cancelBtn?.addEventListener('click', () => closeGalleryPicker(null)); gp.overlay?.addEventListener('click', (e) => { if (e.target === gp.overlay) closeGalleryPicker(null); }); gp.uploadBtn?.addEventListener('click', () => gp.fileInput?.click()); gp.fileInput?.addEventListener('change', async (e) => { const files = Array.from(e.target.files || []); if (!files.length) return; for (const file of files) await uploadImage(file); e.target.value = ''; await refreshGalleryPicker(); }); // ── Target picker ──────────────────────────────────────────────── const picker = { overlay: $('slideshow-picker'), search: $('slideshow-pick-search'), list: $('slideshow-pick-list'), cancelBtn: $('slideshow-pick-cancel'), }; let pickerOnDone = null; let pickerCards = []; async function openPicker(onDone) { pickerOnDone = onDone; picker.overlay.classList.remove('hidden'); picker.search.value = ''; // Lazy-load cards list if (pickerCards.length === 0) { const cards = await hyprcrd.api('api/cards'); if (Array.isArray(cards)) pickerCards = cards; } renderPickerList(''); setTimeout(() => picker.search?.focus(), 10); } function closePicker(target) { picker.overlay.classList.add('hidden'); const cb = pickerOnDone; pickerOnDone = null; if (cb) cb(target || null); } function renderPickerList(query) { const q = (query || '').trim().toLowerCase(); const filtered = q ? pickerCards.filter(c => (c.title || '').toLowerCase().includes(q)) : pickerCards.slice(0, 50); picker.list.innerHTML = filtered.map(c => { const col = c.color ? `background:${c.color};` : ''; return ``; }).join('') || `
no matches
`; } picker.search?.addEventListener('input', () => renderPickerList(picker.search.value)); picker.list?.addEventListener('click', (e) => { const btn = e.target.closest('[data-pick-target]'); if (btn) closePicker(btn.dataset.pickTarget); }); picker.overlay?.addEventListener('click', (e) => { if (e.target === picker.overlay) closePicker(null); }); picker.cancelBtn?.addEventListener('click', () => closePicker(null)); // Relative targets (wired via event delegation on the whole overlay) picker.overlay?.addEventListener('click', (e) => { const btn = e.target.closest('[data-pick-target]'); if (btn && !e.target.closest('#slideshow-pick-list')) closePicker(btn.dataset.pickTarget); }); picker.search?.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); closePicker(null); } else if (e.key === 'Enter') { const first = picker.list?.querySelector('[data-pick-target]'); if (first) closePicker(first.dataset.pickTarget); } }); // ── Editor toolbar command + button ────────────────────────────── hyprcrd.editor.registerCommand('slideHotspots', () => { const api = hyprcrd.editor.api; if (!api?.body) return; const body = api.body; const val = body.value; const pos = body.selectionStart; const existing = findSlideBlock(val, pos); let initial; if (existing) { initial = parseSlideBlock(existing.content); } else { initial = { src: '', hotspots: [] }; } edOpen(initial, (result) => { if (!result) return; const serialized = serializeSlideBlock(result); const block = '```slide-image\n' + serialized + '\n```'; if (existing) { body.setRangeText(block, existing.start, existing.end, 'end'); } else { const s = body.selectionStart; const prefix = s > 0 && val[s - 1] !== '\n' ? '\n' : ''; body.setRangeText(prefix + block + '\n', s, body.selectionEnd, 'end'); } api.updatePreview?.(); }); }); function injectToolbarButton() { const toolbar = $('md-toolbar'); if (!toolbar) return; if (toolbar.querySelector('[data-md="slideHotspots"]')) return; const btn = document.createElement('button'); btn.setAttribute('data-md', 'slideHotspots'); btn.setAttribute('title', 'Image slide / hotspots'); btn.className = 'toolbar-btn'; btn.innerHTML = ''; const spacer = toolbar.querySelector('.flex-1'); if (spacer) toolbar.insertBefore(btn, spacer); else toolbar.appendChild(btn); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', injectToolbarButton); } else { injectToolbarButton(); } // ── Plugin self-registration ───────────────────────────────────── hyprcrd.registerPlugin({ name: 'slideshow', version: '1.0.0', description: 'Presentation mode for card groups with image slides and clickable hotspots.', });