New tooltip.js module with styled tooltips (data-tip) and multi-line inline help (data-help). Migrates all ~70 native title attributes across index.php, JS modules, and all plugins. Adds inline help on 9 strategic UI elements (split, superview, zen, extract, assets, etc). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
793 lines
29 KiB
JavaScript
793 lines
29 KiB
JavaScript
// ── 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:<id> → springt zu Karte mit <id> (wenn in der Gruppe)
|
|
// slide:next → nächste Folie
|
|
// slide:prev → vorherige Folie
|
|
// slide:first → Startfolie
|
|
// slide:<n> → 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 `<div class="slide-image-preview" style="padding:8px;color:var(--t-dim);font-family:var(--t-font-pixel,monospace);font-size:10px;line-height:1.2;">slide-image: missing src</div>`;
|
|
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 `<div class="slide-hotspot" style="${style}"${labelAttr}></div>`;
|
|
}).join('');
|
|
return `<div class="slide-image-preview"><img src="${esc(src)}" alt="">${rects}</div>`;
|
|
});
|
|
|
|
// ── 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 || '') : `<pre>${escHtml(card.body || '')}</pre>`;
|
|
viewerSlide.innerHTML = `
|
|
<div class="slide-text">
|
|
${card.title ? `<h1 class="slide-title">${escHtml(card.title)}</h1>` : ''}
|
|
<div class="slide-prose prose">${html}</div>
|
|
</div>`;
|
|
}
|
|
|
|
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 `<div class="slide-hotspot-live" data-hs="${i}" style="${style}" data-tip="${escHtml(label)}"></div>`;
|
|
}).join('');
|
|
viewerSlide.innerHTML = `
|
|
<div class="slide-image-wrap">
|
|
<img src="${escHtml(slide.src)}" alt="">
|
|
${rects}
|
|
</div>`;
|
|
|
|
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 = '<i class="ph ph-projector-screen text-sm"></i><span>present</span>';
|
|
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();
|
|
}
|
|
|
|
// ── Detail-view sidebar button (shown when browsing a group card) ──
|
|
|
|
function injectDetailSidebarButton() {
|
|
const sidebar = $('detail-sidebar');
|
|
if (!sidebar) return;
|
|
if (sidebar.querySelector('.slideshow-present-btn')) return;
|
|
// Sidebar header is the first direct child div
|
|
const header = sidebar.firstElementChild;
|
|
if (!header) return;
|
|
const btn = document.createElement('button');
|
|
btn.className = 'slideshow-present-btn slideshow-present-btn-sidebar';
|
|
btn.type = 'button';
|
|
btn.title = 'present this group';
|
|
btn.innerHTML = '<i class="ph ph-projector-screen text-sm"></i>';
|
|
btn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
const pid = sidebar.dataset.groupId;
|
|
if (pid) await startPresentationForGroup(pid);
|
|
});
|
|
header.appendChild(btn);
|
|
}
|
|
|
|
if ($('detail-sidebar')) injectDetailSidebarButton();
|
|
else document.addEventListener('DOMContentLoaded', injectDetailSidebarButton, { once: true });
|
|
|
|
// ── 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 => `<div class="ed-handle" data-handle="${d}"></div>`).join('');
|
|
}
|
|
const labelText = resolveTargetLabel(h.target) || h.target || '?';
|
|
el.innerHTML = `<span class="ed-label">${escHtml(labelText)}</span>${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 = `<div class="col-span-full text-center py-16" style="grid-column:1/-1;">
|
|
<i class="ph ph-image text-4xl text-t-dim mb-2 block"></i>
|
|
<p class="font-pixel text-[10px] text-t-dim cursor-blink">no images yet — upload above</p>
|
|
</div>`;
|
|
return;
|
|
}
|
|
gp.grid.innerHTML = images.map(f => `
|
|
<div class="slideshow-gp-item" data-url="${escHtml(f.url)}">
|
|
<img src="${escHtml(f.url)}" loading="lazy" alt="">
|
|
<div class="slideshow-gp-name">${escHtml(f.name || '')}</div>
|
|
</div>`).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 `<button class="slideshow-pick-row" data-pick-target="card:${escHtml(c.id)}"><span style="display:inline-block;width:8px;height:8px;${col}"></span><span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escHtml(c.title || '(untitled)')}</span></button>`;
|
|
}).join('') || `<div class="font-pixel text-[10px] text-t-dim px-2 py-2 cursor-blink">no matches</div>`;
|
|
}
|
|
|
|
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 = '<i class="ph ph-image-square text-sm"></i>';
|
|
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.',
|
|
});
|