slideshow
This commit is contained in:
1 parent
988a5410f7
commit
262b853c5b
18 files changed
+1305
-30
No files matched your search
@@ -660,12 +660,35 @@ body.detail-image-fs-open { overflow: hidden; }
|
||||
}
|
||||
.prose img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--t-border);
|
||||
margin: 0.8em 0;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
.prose img.resizing { outline: 2px solid var(--t-accent); }
|
||||
|
||||
/* Preview panes: ensure the prose content never overflows horizontally so
|
||||
images can resolve their `max-width: 100%` against a constrained container.
|
||||
Without this, images with explicit width attrs (e.g. <img width="2000">)
|
||||
push the content box wider than the flex pane. */
|
||||
#editor-preview,
|
||||
#board-preview-body {
|
||||
overflow-x: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
#preview-content,
|
||||
#board-preview-body > * {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
#editor-preview .prose img,
|
||||
#board-preview-body.prose img,
|
||||
#board-preview-body .prose img {
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
.prose table { border-collapse: collapse; width: 100%; margin: 0.8em 0; }
|
||||
.prose th, .prose td { border: 1px solid var(--t-border); padding: 0.4em 0.8em; text-align: left; }
|
||||
.prose th { background: var(--t-raised); color: var(--t-bright); font-weight: 600; }
|
||||
|
||||
@@ -1877,6 +1877,10 @@ if (str_starts_with($route, '/api/')) {
|
||||
<h2 class="font-pixel text-xs text-t-accent">images</h2>
|
||||
<span id="gallery-count" class="font-pixel text-[9px] text-t-dim"></span>
|
||||
<div class="flex-1"></div>
|
||||
<button id="gallery-upload" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
||||
<i class="ph ph-upload-simple text-sm"></i><span class="font-pixel text-[10px]">upload</span>
|
||||
</button>
|
||||
<input type="file" id="gallery-upload-file" accept="image/*,audio/*" multiple class="hidden">
|
||||
<button id="gallery-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
||||
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span>
|
||||
</button>
|
||||
|
||||
+5
-5
@@ -126,10 +126,10 @@ importFile?.addEventListener('change', async e => {
|
||||
|
||||
// Check if it's a selective export (merge) or ask for full replace
|
||||
const form = new FormData(); form.append('file', file);
|
||||
const mode = await showDialog({ title: 'import_data', message: 'Wie importieren?', okLabel: 'merge (hinzufuegen)', cancelLabel: 'replace (alles ersetzen)' });
|
||||
const mode = await showDialog({ title: 'import_data', message: 'How do you want to import?', okLabel: 'merge (add to existing)', cancelLabel: 'replace (wipe all)' });
|
||||
// mode: true = merge, null = replace
|
||||
if (mode === null) {
|
||||
const sure = await showDialog({ title: 'replace_all', message: 'Alle bestehenden Daten werden ersetzt!', danger: true, okLabel: 'replace' });
|
||||
const sure = await showDialog({ title: 'replace_all', message: 'All existing data will be replaced!', danger: true, okLabel: 'replace' });
|
||||
if (!sure) { e.target.value = ''; return; }
|
||||
}
|
||||
try {
|
||||
@@ -161,7 +161,7 @@ importLogseq?.addEventListener('change', async e => {
|
||||
// ── Auth ──
|
||||
|
||||
document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
||||
const ok = await showDialog({ title: 'logout', message: 'Abmelden?', okLabel: 'logout', danger: true });
|
||||
const ok = await showDialog({ title: 'logout', message: 'Log out?', okLabel: 'logout', danger: true });
|
||||
if (!ok) return;
|
||||
await fetch('api/auth/logout', { method: 'POST' });
|
||||
location.reload();
|
||||
@@ -175,7 +175,7 @@ async function checkAuth() {
|
||||
if (data.setup) {
|
||||
let pw = null;
|
||||
while (!pw || pw.length < 4) {
|
||||
pw = await showDialog({ title: 'setup', message: 'Willkommen bei hyprcrd. Setze ein Passwort:', input: 'password', placeholder: '> passwort (min. 4 zeichen)...', okLabel: 'set', cancelLabel: '' });
|
||||
pw = await showDialog({ title: 'setup', message: 'Welcome to hyprcrd. Set a password:', input: 'password', placeholder: '> password (min. 4 chars)...', okLabel: 'set', cancelLabel: '' });
|
||||
if (pw && pw.length < 4) pw = null;
|
||||
}
|
||||
const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
|
||||
@@ -185,7 +185,7 @@ async function checkAuth() {
|
||||
} else if (!data.logged_in) {
|
||||
let ok = false;
|
||||
while (!ok) {
|
||||
const pw = await showDialog({ title: 'login', message: '', input: 'password', placeholder: '> passwort...', okLabel: 'login', cancelLabel: '' });
|
||||
const pw = await showDialog({ title: 'login', message: '', input: 'password', placeholder: '> password...', okLabel: 'login', cancelLabel: '' });
|
||||
if (!pw) continue;
|
||||
const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
|
||||
const d = await r.json();
|
||||
|
||||
@@ -78,7 +78,7 @@ document.addEventListener('keydown', async e => {
|
||||
e.preventDefault();
|
||||
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||
const { showDialog } = await import('./dialog.js');
|
||||
const ok = await showDialog({ title: 'delete cards', message: `${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''} loeschen?`, danger: true, okLabel: 'delete' });
|
||||
const ok = await showDialog({ title: 'delete cards', message: `Delete ${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''}?`, danger: true, okLabel: 'delete' });
|
||||
if (!ok) return;
|
||||
}
|
||||
for (const id of selectedIds) await api(`api/cards/${id}`, { method: 'DELETE' });
|
||||
@@ -215,7 +215,7 @@ $('select-delete')?.addEventListener('click', async () => {
|
||||
if (!selectedIds.size) return;
|
||||
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||
const { showDialog } = await import('./dialog.js');
|
||||
const ok = await showDialog({ title: 'delete cards', message: `${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''} loeschen?`, danger: true, okLabel: 'delete' });
|
||||
const ok = await showDialog({ title: 'delete cards', message: `Delete ${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''}?`, danger: true, okLabel: 'delete' });
|
||||
if (!ok) return;
|
||||
}
|
||||
for (const id of selectedIds) {
|
||||
@@ -771,6 +771,7 @@ function renderGroupBar(cards) {
|
||||
if (state.activeGroup) {
|
||||
const parent = state.cards.find(c => c.id == state.activeGroup);
|
||||
bar.classList.remove('hidden');
|
||||
bar.dataset.groupId = String(state.activeGroup);
|
||||
bar.innerHTML = `
|
||||
<button id="group-back" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
||||
<i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">back</span>
|
||||
@@ -781,6 +782,7 @@ function renderGroupBar(cards) {
|
||||
$('group-back')?.addEventListener('click', () => { state.activeGroup = null; renderBoard(); });
|
||||
} else {
|
||||
bar.classList.add('hidden');
|
||||
delete bar.dataset.groupId;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ async function showBucketCtx(x, y, bucket) {
|
||||
hideBucketCtx();
|
||||
const cards = state.cards.filter(c => c.bucket_id == bucket.id);
|
||||
if (cards.length > 0 || localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||
const ok = await showDialog({ title: 'delete_bucket', message: cards.length ? `"${bucket.name}" loeschen? ${cards.length} Karte(n) bleiben erhalten.` : `"${bucket.name}" loeschen?`, danger: true, okLabel: 'delete' });
|
||||
const ok = await showDialog({ title: 'delete_bucket', message: cards.length ? `Delete "${bucket.name}"? ${cards.length} card(s) will be kept.` : `Delete "${bucket.name}"?`, danger: true, okLabel: 'delete' });
|
||||
if (!ok) return;
|
||||
}
|
||||
if (state.activeBucket == bucket.id) state.activeBucket = 'all';
|
||||
@@ -86,7 +86,7 @@ async function showBucketCtx(x, y, bucket) {
|
||||
else if (a === 'delete-all') {
|
||||
hideBucketCtx();
|
||||
const cards = state.cards.filter(c => c.bucket_id == bucket.id);
|
||||
const ok = await showDialog({ title: 'delete_bucket', message: `"${bucket.name}" und ${cards.length} Karte(n) loeschen?`, danger: true, okLabel: 'delete all' });
|
||||
const ok = await showDialog({ title: 'delete_bucket', message: `Delete "${bucket.name}" and ${cards.length} card(s)?`, danger: true, okLabel: 'delete all' });
|
||||
if (!ok) return;
|
||||
for (const c of cards) await api(`api/cards/${c.id}`, { method: 'DELETE' });
|
||||
if (state.activeBucket == bucket.id) state.activeBucket = 'all';
|
||||
|
||||
@@ -226,7 +226,7 @@ export function showContextMenu(x, y, card) {
|
||||
else if (a === 'expand-group') { hideContextMenu(); state.activeGroup = card.id; window._renderBoard(); }
|
||||
else if (a === 'go-to-group') { hideContextMenu(); const p = state.cards.find(c => c.id == card.parent_id); if (p) { state.activeGroup = p.id; window._renderBoard(); } }
|
||||
else if (a === 'dissolve') {
|
||||
hideContextMenu(); const ok = await showDialog({ title: 'dissolve_group', message: `Gruppe "${card.title}" aufloesen?`, danger: true, okLabel: 'dissolve' });
|
||||
hideContextMenu(); const ok = await showDialog({ title: 'dissolve_group', message: `Dissolve group "${card.title}"?`, danger: true, okLabel: 'dissolve' });
|
||||
if (ok) { const ch = getChildren(card.id); pushBatch('dissolve', ch.map(c => ({ cardId: c.id, prevData: { parent_id: card.id } }))); await Promise.all(ch.map(c => api(`api/cards/${c.id}`, { method: 'PATCH', body: { parent_id: null } }))); await window._reloadAll(); }
|
||||
}
|
||||
else if (a === 'ungroup') { hideContextMenu(); pushCardEdit('ungroup', card.id, { parent_id: card.parent_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }
|
||||
@@ -234,7 +234,7 @@ export function showContextMenu(x, y, card) {
|
||||
hideContextMenu();
|
||||
const needConfirm = localStorage.getItem('hyprcrd-confirm-delete') !== '0';
|
||||
if (needConfirm) {
|
||||
const ok = await showDialog({ title: 'delete_card', message: `"${card.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' });
|
||||
const ok = await showDialog({ title: 'delete_card', message: `Really delete "${card.title}"?`, danger: true, okLabel: 'delete' });
|
||||
if (!ok) return;
|
||||
}
|
||||
const wasSync = !!card.caldav_uid;
|
||||
|
||||
@@ -1015,7 +1015,7 @@ $('detail-done')?.addEventListener('click', async () => {
|
||||
$('detail-delete')?.addEventListener('click', async () => {
|
||||
if (!state.viewingCard) return;
|
||||
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||
const ok = await showDialog({ title: 'delete_card', message: `"${state.viewingCard.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' });
|
||||
const ok = await showDialog({ title: 'delete_card', message: `Really delete "${state.viewingCard.title}"?`, danger: true, okLabel: 'delete' });
|
||||
if (!ok) return;
|
||||
}
|
||||
pushCardDelete('delete', snapshotCard(state.viewingCard));
|
||||
|
||||
@@ -517,8 +517,11 @@ const editorPane = $('editor-pane');
|
||||
|
||||
function applyEditorSplit() {
|
||||
if (!state.previewVisible) return;
|
||||
if (editorPane) editorPane.style.flex = `0 0 ${editorSplit}%`;
|
||||
preview.style.flex = `0 0 ${100 - editorSplit}%`;
|
||||
// Use grow-based splits with flex-basis:0 so both panes share the available
|
||||
// space AFTER the divider is subtracted. Using `0 0 X%` overflows the container
|
||||
// because X% + (100-X)% + divider > 100%, which clips content on the right.
|
||||
if (editorPane) editorPane.style.flex = `${editorSplit} 1 0`;
|
||||
preview.style.flex = `${100 - editorSplit} 1 0`;
|
||||
}
|
||||
|
||||
$('toggle-preview')?.addEventListener('click', () => {
|
||||
|
||||
@@ -28,6 +28,11 @@ function getReferencedUrls() {
|
||||
while ((m = mdRe.exec(body)) !== null) urls.add(m[1].split(/\s/)[0]);
|
||||
const htmlRe = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
||||
while ((m = htmlRe.exec(body)) !== null) urls.add(m[1]);
|
||||
const slideRe = /```slide-image\s*\n([\s\S]*?)(?:\n```|$)/g;
|
||||
while ((m = slideRe.exec(body)) !== null) {
|
||||
const srcMatch = m[1].match(/^\s*src\s*:\s*([^\n]+)/m);
|
||||
if (srcMatch) urls.add(srcMatch[1].trim());
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
@@ -78,6 +83,7 @@ export async function openGallery() {
|
||||
<img src="${f.url}" class="w-full h-full object-cover opacity-60 rounded" loading="lazy">
|
||||
<div style="position:absolute;top:4px;right:4px;display:flex;gap:4px;" class="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-orphan="${f.name}" title="new card"><i class="ph ph-plus text-sm"></i></button>
|
||||
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-slide="${f.name}" title="new slide (background)"><i class="ph ph-image-square text-sm"></i></button>
|
||||
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-danger rounded border-0 cursor-pointer hover:bg-t-danger hover:text-t-bg transition-colors" data-del-orphan="${f.name}" title="delete"><i class="ph ph-trash text-sm"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,13 +138,25 @@ export async function openGallery() {
|
||||
});
|
||||
});
|
||||
|
||||
// Orphan: inline new slide button (creates image slide with slide-image block)
|
||||
grid.querySelectorAll('[data-new-slide]').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const name = btn.dataset.newSlide;
|
||||
const label = name.replace(/\.[^.]+$/, '');
|
||||
closeGallery();
|
||||
const body = '```slide-image\nsrc: uploads/' + name + '\n```';
|
||||
window._openModal({ id: null, title: label, body, color: '#38bdf8', bucket_id: null });
|
||||
});
|
||||
});
|
||||
|
||||
// Orphan: inline delete button
|
||||
grid.querySelectorAll('[data-del-orphan]').forEach(btn => {
|
||||
btn.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
const name = btn.dataset.delOrphan;
|
||||
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||
const ok = await showDialog({ title: 'delete_image', message: `"${name}" loeschen?`, danger: true, okLabel: 'delete' });
|
||||
const ok = await showDialog({ title: 'delete_image', message: `Delete "${name}"?`, danger: true, okLabel: 'delete' });
|
||||
if (!ok) return;
|
||||
}
|
||||
await fetch(`api/uploads/${name}`, { method: 'DELETE', headers: { 'X-CSRF-Token': getCsrf() } });
|
||||
@@ -163,6 +181,9 @@ function openOrphanLightbox(file) {
|
||||
<button class="orphan-lb-create flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent hover:text-t-bg transition-colors" style="border-radius:4px;">
|
||||
<i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">new card</span>
|
||||
</button>
|
||||
<button class="orphan-lb-slide flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent hover:text-t-bg transition-colors" style="border-radius:4px;">
|
||||
<i class="ph ph-image-square text-sm"></i><span class="font-pixel text-[10px]">new slide</span>
|
||||
</button>
|
||||
<button class="orphan-lb-delete flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger hover:text-t-bg transition-colors" style="border-radius:4px;">
|
||||
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete</span>
|
||||
</button>
|
||||
@@ -183,8 +204,16 @@ function openOrphanLightbox(file) {
|
||||
window._openModal({ id: null, title: name, body: ``, color: '#38bdf8', bucket_id: null });
|
||||
});
|
||||
|
||||
overlay.querySelector('.orphan-lb-slide').addEventListener('click', async () => {
|
||||
const name = file.url.split('/').pop().replace(/\.[^.]+$/, '');
|
||||
close();
|
||||
closeGallery();
|
||||
const body = '```slide-image\nsrc: ' + file.url + '\n```';
|
||||
window._openModal({ id: null, title: name, body, color: '#38bdf8', bucket_id: null });
|
||||
});
|
||||
|
||||
overlay.querySelector('.orphan-lb-delete').addEventListener('click', async () => {
|
||||
const ok = await showDialog({ title: 'delete_image', message: `"${file.name}" loeschen?`, danger: true, okLabel: 'delete' });
|
||||
const ok = await showDialog({ title: 'delete_image', message: `Delete "${file.name}"?`, danger: true, okLabel: 'delete' });
|
||||
if (!ok) return;
|
||||
await fetch(`api/uploads/${file.name}`, { method: 'DELETE', headers: { 'X-CSRF-Token': getCsrf() } });
|
||||
close();
|
||||
@@ -201,3 +230,23 @@ export function closeGallery() {
|
||||
$('gallery-close')?.addEventListener('click', closeGallery);
|
||||
$('gallery')?.addEventListener('click', e => { if (e.target === $('gallery')) closeGallery(); });
|
||||
$('gallery-btn')?.addEventListener('click', openGallery);
|
||||
|
||||
// Upload button
|
||||
$('gallery-upload')?.addEventListener('click', () => $('gallery-upload-file')?.click());
|
||||
$('gallery-upload-file')?.addEventListener('change', async e => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
for (const file of files) {
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name);
|
||||
try {
|
||||
await fetch('api/upload', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'X-CSRF-Token': getCsrf() },
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
e.target.value = '';
|
||||
await openGallery();
|
||||
});
|
||||
@@ -243,12 +243,23 @@ export function getTodos(src) {
|
||||
return { total, done: (s.match(/- \[x\]/g) || []).length };
|
||||
}
|
||||
|
||||
// Matches the `src:` line inside a ```slide-image fenced block (slideshow plugin format).
|
||||
const SLIDE_IMAGE_RE = /```slide-image\s*\n([\s\S]*?)(?:\n```|$)/;
|
||||
function slideImageSrc(block) {
|
||||
if (!block) return null;
|
||||
const m = block.match(/^\s*src\s*:\s*([^\n]+)/m);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
export function firstImage(src) {
|
||||
const s = src || '';
|
||||
const md = s.match(/!\[[^\]]*\]\(([^)]+)\)/);
|
||||
if (md) return md[1];
|
||||
const html = s.match(/<img[^>]+src="([^"]+)"/i);
|
||||
return html ? html[1] : null;
|
||||
if (html) return html[1];
|
||||
const slide = s.match(SLIDE_IMAGE_RE);
|
||||
if (slide) return slideImageSrc(slide[1]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function leadingImage(src) {
|
||||
@@ -257,5 +268,9 @@ export function leadingImage(src) {
|
||||
const md = s.match(/^!\[[^\]]*\]\(([^)]+)\)/);
|
||||
if (md) return md[1].split(/\s+/)[0].replace(/^<(.+)>$/, '$1') || null;
|
||||
const html = s.match(/^<img[^>]+src=(['"])(.*?)\1/i);
|
||||
return html ? html[2] : null;
|
||||
if (html) return html[2];
|
||||
// A card whose body *starts* with a slide-image block → use its image as the cover.
|
||||
const slide = s.match(/^```slide-image\s*\n([\s\S]*?)(?:\n```|$)/);
|
||||
if (slide) return slideImageSrc(slide[1]);
|
||||
return null;
|
||||
}
|
||||
@@ -162,15 +162,29 @@ hyprcrd plugin system
|
||||
│ │ ├── loader.js conditionally imports tests.js on ?test or Ctrl+Shift+T
|
||||
│ │ ├── Test files import from '../../js/modules/...' (NOT '../modules/...')
|
||||
│ │ └── Both static AND dynamic imports use that path — sed both when moving files
|
||||
│ └── email/ (~1300 lines, biggest)
|
||||
│ ├── Backend: ImapClient.php (pure-PHP IMAP4) + backend.php (table + routes)
|
||||
│ ├── Schema: email_accounts table, passwords AES-256-CBC with vault key
|
||||
│ ├── Routes: /api/email/{accounts, accounts/{id}, accounts/{id}/test, messages, message/{accId}/{uid}}
|
||||
│ ├── Settings tab: registered via hyprcrd.settings.registerTab({id:'email', label:'mail'})
|
||||
│ │ └── onShow rebuilds form on first show, refreshes accounts list every show
|
||||
│ ├── Welcome view: shown when accounts.length === 0, "configure accounts" → openTab('email')
|
||||
│ ├── Header button "mail" injected via JS into #main-header right group, before #add-btn
|
||||
│ └── Card extraction: POST /api/cards with #email tag, calls window._reloadAll()
|
||||
│ ├── email/ (~1300 lines, biggest)
|
||||
│ │ ├── Backend: ImapClient.php (pure-PHP IMAP4) + backend.php (table + routes)
|
||||
│ │ ├── Schema: email_accounts table, passwords AES-256-CBC with vault key
|
||||
│ │ ├── Routes: /api/email/{accounts, accounts/{id}, accounts/{id}/test, messages, message/{accId}/{uid}}
|
||||
│ │ ├── Settings tab: registered via hyprcrd.settings.registerTab({id:'email', label:'mail'})
|
||||
│ │ │ └── onShow rebuilds form on first show, refreshes accounts list every show
|
||||
│ │ ├── Welcome view: shown when accounts.length === 0, "configure accounts" → openTab('email')
|
||||
│ │ ├── Header button "mail" injected via JS into #main-header right group, before #add-btn
|
||||
│ │ └── Card extraction: POST /api/cards with #email tag, calls window._reloadAll()
|
||||
│ └── slideshow/ (~500 lines)
|
||||
│ ├── No backend — data stored as ```slide-image fenced block in card body
|
||||
│ ├── Slideshow = parent card (start slide) + children (in position order)
|
||||
│ ├── Text slides: normal markdown rendered fullscreen via hyprcrd.markdown.render
|
||||
│ ├── Image slides: fenced block with `src:` + `x,y,w,h -> target` lines (coords in %)
|
||||
│ │ └── Targets: card:<id>, slide:next, slide:prev, slide:first, slide:<index>
|
||||
│ ├── Viewer: fullscreen overlay, ArrowLeft/Right + Esc, HUD with counter
|
||||
│ │ ├── Image slides use object-fit:contain; hotspots positioned relative to img box
|
||||
│ │ ├── Arrow keys always work as fallback, even when hotspots are present
|
||||
│ │ └── 'h' key toggles debug-hotspots class to visualize rects during presentation
|
||||
│ ├── Hotspot editor: drag on image to create rect, picker assigns target, dbl-click rect to re-pick
|
||||
│ ├── Group-bar button: mutation-observed injection; reads parent id from #group-bar[data-group-id]
|
||||
│ │ └── Requires board.js renderGroupBar to write bar.dataset.groupId — small core patch
|
||||
│ └── Toolbar button "slideHotspots" injected into #md-toolbar, detects existing block at cursor
|
||||
│
|
||||
├── Gotchas / lessons learned
|
||||
│ ├── statusHeight vs scrollHeight — textarea.scrollHeight includes padding, mind it
|
||||
|
||||
@@ -44,6 +44,13 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* When empty, collapse so the `.chat-empty` placeholder can fill and center
|
||||
properly instead of being pushed into the lower half of the view. */
|
||||
.chat-list:empty {
|
||||
flex: 0 0 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "slideshow",
|
||||
"version": "1.0.0",
|
||||
"description": "Presentation plugin — render a card group as a slideshow. Image slides with invisible clickable hotspot rects that navigate between slides.",
|
||||
"frontend": {
|
||||
"js": ["slideshow.js"],
|
||||
"css": ["slideshow.css"],
|
||||
"html": ["slideshow.html"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/* ── Slideshow Plugin ── */
|
||||
|
||||
/* ── Fenced block preview (in normal card views) ── */
|
||||
/* `inline-block + max-width:100%` doesn't reliably constrain the container when the
|
||||
img's intrinsic size is larger than the parent, so we use block + fit-content instead.
|
||||
That way the container shrinks to the img while still respecting the parent width cap. */
|
||||
.slide-image-preview {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
line-height: 0;
|
||||
border: 1px solid var(--t-border);
|
||||
background: var(--t-bg);
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
.slide-image-preview img,
|
||||
.prose .slide-image-preview img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
pointer-events: none;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
cursor: default;
|
||||
}
|
||||
.slide-image-preview .slide-hotspot {
|
||||
position: absolute;
|
||||
border: 1px dashed var(--t-accent);
|
||||
background: color-mix(in srgb, var(--t-accent) 12%, transparent);
|
||||
pointer-events: auto;
|
||||
cursor: help;
|
||||
box-sizing: border-box;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.slide-image-preview .slide-hotspot:hover {
|
||||
background: color-mix(in srgb, var(--t-accent) 25%, transparent);
|
||||
border-color: var(--t-bright);
|
||||
}
|
||||
/* Hover tooltip: shows the resolved target label (usually the card title) */
|
||||
.slide-image-preview .slide-hotspot[data-label]::after {
|
||||
content: attr(data-label);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 100%;
|
||||
margin-top: 4px;
|
||||
transform: translateX(-50%);
|
||||
padding: 3px 6px;
|
||||
background: var(--t-surface);
|
||||
border: 1px solid var(--t-accent);
|
||||
color: var(--t-bright);
|
||||
font-family: var(--t-font-pixel, monospace);
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 10;
|
||||
}
|
||||
.slide-image-preview .slide-hotspot[data-label]:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Viewer ── */
|
||||
#slideshow-viewer.hidden { display: none; }
|
||||
|
||||
#slideshow-slide .slide-text {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 6vh 8vw;
|
||||
overflow: auto;
|
||||
color: var(--t-text);
|
||||
background: var(--t-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2vh;
|
||||
}
|
||||
#slideshow-slide .slide-text .slide-title {
|
||||
font-family: var(--t-font-pixel, monospace);
|
||||
font-size: clamp(18px, 3.2vw, 42px);
|
||||
color: var(--t-accent);
|
||||
margin: 0;
|
||||
}
|
||||
#slideshow-slide .slide-text .slide-prose {
|
||||
font-size: clamp(14px, 1.8vw, 22px);
|
||||
line-height: 1.55;
|
||||
max-width: 80ch;
|
||||
}
|
||||
#slideshow-slide .slide-text .slide-prose h1,
|
||||
#slideshow-slide .slide-text .slide-prose h2,
|
||||
#slideshow-slide .slide-text .slide-prose h3 { color: var(--t-bright); margin-top: 0.8em; }
|
||||
#slideshow-slide .slide-text .slide-prose p { margin: 0.5em 0; }
|
||||
#slideshow-slide .slide-text .slide-prose img { max-width: 100%; height: auto; }
|
||||
#slideshow-slide .slide-text .slide-prose pre {
|
||||
background: var(--t-surface);
|
||||
padding: 0.8em 1em;
|
||||
border: 1px solid var(--t-border);
|
||||
overflow: auto;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
#slideshow-slide .slide-text .slide-prose code { font-family: var(--t-font-mono, monospace); }
|
||||
|
||||
#slideshow-slide .slide-image-wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
line-height: 0;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
}
|
||||
#slideshow-slide .slide-image-wrap > img {
|
||||
display: block;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
#slideshow-slide .slide-hotspot-live {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
/* Nearly-invisible fill: transparent divs sometimes don't register hits reliably, this forces them to. */
|
||||
background: rgba(0, 0, 0, 0.001);
|
||||
border: 0;
|
||||
pointer-events: auto;
|
||||
z-index: 5;
|
||||
transition: background 0.15s, border 0.15s;
|
||||
}
|
||||
#slideshow-viewer.debug-hotspots .slide-hotspot-live {
|
||||
background: color-mix(in srgb, var(--t-accent) 18%, transparent);
|
||||
border: 1px dashed var(--t-accent);
|
||||
}
|
||||
#slideshow-slide .slide-hotspot-live:hover {
|
||||
background: color-mix(in srgb, var(--t-accent) 15%, transparent);
|
||||
border: 1px solid var(--t-accent);
|
||||
}
|
||||
|
||||
/* ── Group-bar button ── */
|
||||
#group-bar .slideshow-present-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
font-family: var(--t-font-pixel, monospace);
|
||||
font-size: 10px;
|
||||
color: var(--t-muted);
|
||||
border: 1px solid var(--t-border);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
#group-bar .slideshow-present-btn:hover {
|
||||
color: var(--t-accent);
|
||||
border-color: var(--t-accent);
|
||||
}
|
||||
|
||||
/* ── Hotspot editor ── */
|
||||
#slideshow-editor.hidden { display: none; }
|
||||
#slideshow-editor[disabled], #slideshow-editor [disabled] { opacity: 0.4; pointer-events: none; }
|
||||
#slideshow-ed-stage { cursor: crosshair; }
|
||||
#slideshow-ed-layer { pointer-events: auto; }
|
||||
#slideshow-ed-layer .ed-rect {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--t-accent);
|
||||
background: color-mix(in srgb, var(--t-accent) 10%, transparent);
|
||||
cursor: pointer;
|
||||
}
|
||||
#slideshow-ed-layer .ed-rect.selected {
|
||||
border: 2px solid var(--t-bright);
|
||||
background: color-mix(in srgb, var(--t-bright) 18%, transparent);
|
||||
cursor: move;
|
||||
}
|
||||
#slideshow-ed-layer .ed-rect .ed-label {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 100%;
|
||||
margin-bottom: 2px;
|
||||
font-family: var(--t-font-pixel, monospace);
|
||||
font-size: 9px;
|
||||
padding: 1px 3px;
|
||||
background: var(--t-accent);
|
||||
color: var(--t-bg);
|
||||
white-space: nowrap;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
}
|
||||
#slideshow-ed-layer .ed-handle {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--t-bright);
|
||||
border: 1px solid var(--t-bg);
|
||||
box-sizing: border-box;
|
||||
pointer-events: auto;
|
||||
z-index: 2;
|
||||
}
|
||||
#slideshow-ed-layer .ed-handle[data-handle="nw"] { left: -5px; top: -5px; cursor: nwse-resize; }
|
||||
#slideshow-ed-layer .ed-handle[data-handle="n"] { left: 50%; top: -5px; cursor: ns-resize; transform: translateX(-50%); }
|
||||
#slideshow-ed-layer .ed-handle[data-handle="ne"] { right: -5px; top: -5px; cursor: nesw-resize; }
|
||||
#slideshow-ed-layer .ed-handle[data-handle="e"] { right: -5px; top: 50%; cursor: ew-resize; transform: translateY(-50%); }
|
||||
#slideshow-ed-layer .ed-handle[data-handle="se"] { right: -5px; bottom: -5px; cursor: nwse-resize; }
|
||||
#slideshow-ed-layer .ed-handle[data-handle="s"] { left: 50%; bottom: -5px; cursor: ns-resize; transform: translateX(-50%); }
|
||||
#slideshow-ed-layer .ed-handle[data-handle="sw"] { left: -5px; bottom: -5px; cursor: nesw-resize; }
|
||||
#slideshow-ed-layer .ed-handle[data-handle="w"] { left: -5px; top: 50%; cursor: ew-resize; transform: translateY(-50%); }
|
||||
#slideshow-ed-layer .ed-rect-draft {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed var(--t-bright);
|
||||
background: color-mix(in srgb, var(--t-bright) 8%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Gallery picker ── */
|
||||
#slideshow-gallery-picker.hidden { display: none; }
|
||||
.slideshow-gp-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
background: var(--t-bg);
|
||||
border: 1px solid var(--t-border);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: border 0.15s;
|
||||
}
|
||||
.slideshow-gp-item:hover { border-color: var(--t-accent); }
|
||||
.slideshow-gp-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
.slideshow-gp-item .slideshow-gp-name {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 3px 5px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: var(--t-text);
|
||||
font-family: var(--t-font-pixel, monospace);
|
||||
font-size: 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Target picker ── */
|
||||
#slideshow-picker.hidden { display: none; }
|
||||
.slideshow-pick-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
font-family: var(--t-font-pixel, monospace);
|
||||
font-size: 10px;
|
||||
color: var(--t-text);
|
||||
border: 1px solid var(--t-border);
|
||||
background: var(--t-bg);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.slideshow-pick-row:hover {
|
||||
border-color: var(--t-accent);
|
||||
color: var(--t-accent);
|
||||
}
|
||||
#slideshow-pick-list .slideshow-pick-row {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
#slideshow-pick-list .slideshow-pick-row:hover {
|
||||
border-color: var(--t-accent);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<!-- Slideshow Plugin: Viewer + Hotspot-Editor -->
|
||||
|
||||
<!-- Fullscreen presentation viewer -->
|
||||
<div id="slideshow-viewer" class="hidden fixed inset-0" style="z-index:120;background:#000;">
|
||||
<div id="slideshow-slide" class="absolute inset-0 flex items-center justify-center overflow-hidden"></div>
|
||||
|
||||
<!-- HUD -->
|
||||
<div id="slideshow-hud" class="absolute flex items-center justify-between px-4 py-2 text-t-muted" style="left:0;right:0;bottom:0;background:linear-gradient(to top,rgba(0,0,0,0.6),transparent);pointer-events:none;">
|
||||
<div class="font-pixel text-[10px]" id="slideshow-title"></div>
|
||||
<div class="flex items-center gap-3 font-pixel text-[10px]" style="pointer-events:auto;">
|
||||
<button id="slideshow-prev" class="hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-base"></i></button>
|
||||
<span id="slideshow-counter">1 / 1</span>
|
||||
<button id="slideshow-next" class="hover:text-t-accent transition-colors"><i class="ph ph-caret-right text-base"></i></button>
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<button id="slideshow-close" class="hover:text-t-danger transition-colors"><i class="ph ph-x text-base"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hotspot editor -->
|
||||
<div id="slideshow-editor" class="hidden fixed inset-0" style="z-index:90;background:rgba(0,0,0,0.6);backdrop-filter:blur(2px);">
|
||||
<div class="bg-t-surface border border-t-border shadow-xl flex flex-col" style="width:92vw;max-width:1100px;height:88vh;max-height:820px;margin:auto;position:absolute;inset:0;border-radius:6px;">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b border-t-border shrink-0 flex-wrap">
|
||||
<span class="font-pixel text-[10px] text-t-accent">slide hotspots</span>
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<label class="flex items-center gap-2 text-t-muted text-xs">
|
||||
<span class="font-pixel text-[10px]">image:</span>
|
||||
<input id="slideshow-ed-src" type="text" placeholder="/uploads/foo.png" class="bg-t-bg border border-t-border text-t-text text-xs font-mono" style="width:300px;padding:2px 8px;border-radius:3px;">
|
||||
</label>
|
||||
<button id="slideshow-ed-gallery" class="toolbar-btn text-xs" title="Pick from gallery"><i class="ph ph-images text-sm"></i></button>
|
||||
<button id="slideshow-ed-pick" class="toolbar-btn text-xs" title="Upload new image"><i class="ph ph-upload-simple text-sm"></i></button>
|
||||
<input type="file" id="slideshow-ed-file" accept="image/*" class="hidden">
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<button id="slideshow-ed-delete" class="toolbar-btn text-xs" title="Delete selected hotspot" disabled><i class="ph ph-trash text-sm"></i></button>
|
||||
<div class="flex-1"></div>
|
||||
<span id="slideshow-ed-hint" class="font-pixel text-[9px] text-t-dim">drag on image to draw · click rect to select</span>
|
||||
</div>
|
||||
<!-- Stage -->
|
||||
<div class="flex-1 min-h-0 flex items-center justify-center bg-t-bg p-4" style="overflow:auto;">
|
||||
<div id="slideshow-ed-stage" style="position:relative;display:inline-block;user-select:none;max-width:100%;max-height:100%;">
|
||||
<img id="slideshow-ed-img" alt="" style="display:block;max-width:100%;max-height:70vh;pointer-events:none;">
|
||||
<div id="slideshow-ed-layer" class="absolute inset-0"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-2 px-3 py-2 border-t border-t-border shrink-0">
|
||||
<button id="slideshow-ed-cancel" class="toolbar-btn text-xs px-3 py-1">Cancel</button>
|
||||
<button id="slideshow-ed-ok" class="bg-t-accent text-t-bg text-xs font-bold px-4 py-1 cursor-pointer" style="border-radius:3px;">Insert</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gallery picker for hotspot editor -->
|
||||
<div id="slideshow-gallery-picker" class="hidden fixed inset-0" style="z-index:95;background:rgba(0,0,0,0.55);">
|
||||
<div class="bg-t-surface border border-t-border shadow-xl flex flex-col" style="width:80vw;max-width:900px;height:80vh;max-height:720px;margin:auto;position:absolute;inset:0;border-radius:6px;">
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border shrink-0">
|
||||
<i class="ph ph-images text-t-accent text-base"></i>
|
||||
<span class="font-pixel text-xs text-t-accent">pick image</span>
|
||||
<span id="slideshow-gp-count" class="font-pixel text-[9px] text-t-dim"></span>
|
||||
<div class="flex-1"></div>
|
||||
<button id="slideshow-gp-upload" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
||||
<i class="ph ph-upload-simple text-sm"></i><span class="font-pixel text-[10px]">upload</span>
|
||||
</button>
|
||||
<input type="file" id="slideshow-gp-file" accept="image/*" multiple class="hidden">
|
||||
<button id="slideshow-gp-cancel" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
||||
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="slideshow-gp-grid" class="flex-1 min-h-0 p-4 grid gap-3 content-start select-none" style="overflow:auto;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target-Picker (second overlay on top of editor) -->
|
||||
<div id="slideshow-picker" class="hidden fixed inset-0" style="z-index:95;background:rgba(0,0,0,0.5);">
|
||||
<div class="bg-t-surface border border-t-border shadow-xl flex flex-col" style="width:420px;max-height:70vh;margin:auto;position:absolute;inset:0;border-radius:6px;">
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b border-t-border shrink-0">
|
||||
<i class="ph ph-arrow-elbow-down-right text-sm text-t-accent"></i>
|
||||
<span class="font-pixel text-[10px] text-t-accent">hotspot target</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 p-3 border-b border-t-border shrink-0">
|
||||
<button data-pick-target="slide:next" class="slideshow-pick-row"><i class="ph ph-caret-right text-sm"></i><span>next slide</span></button>
|
||||
<button data-pick-target="slide:prev" class="slideshow-pick-row"><i class="ph ph-caret-left text-sm"></i><span>previous slide</span></button>
|
||||
<button data-pick-target="slide:first" class="slideshow-pick-row"><i class="ph ph-skip-back text-sm"></i><span>first slide</span></button>
|
||||
</div>
|
||||
<div class="px-3 py-2 shrink-0 border-b border-t-border">
|
||||
<input id="slideshow-pick-search" type="text" placeholder="search cards…" class="w-full bg-t-bg border border-t-border text-t-text text-xs" style="padding:4px 8px;border-radius:3px;">
|
||||
</div>
|
||||
<div id="slideshow-pick-list" class="flex-1 min-h-0 p-2 flex flex-col gap-1" style="overflow:auto;"></div>
|
||||
<div class="flex items-center justify-end gap-2 px-3 py-2 border-t border-t-border shrink-0">
|
||||
<button id="slideshow-pick-cancel" class="toolbar-btn text-xs px-3 py-1">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,764 @@
|
||||
// ── 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}" title="${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();
|
||||
}
|
||||
|
||||
// ── 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();
|
||||
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 };
|
||||
renderEdRects();
|
||||
}
|
||||
|
||||
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">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">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.',
|
||||
});
|
||||
@@ -3,7 +3,8 @@ import { describe, it, expect, csrf } from './runner.js';
|
||||
describe('Relay: Push API', () => {
|
||||
it('push without config returns error', async () => {
|
||||
const settings = await fetch('api/relay/settings').then(r => r.json());
|
||||
if (settings.url && settings.has_key) return; // skip if configured
|
||||
// The GET endpoint echoes the secret directly as `key` (single-user local app).
|
||||
if (settings.url && settings.key) return; // skip if configured
|
||||
|
||||
const res = await fetch('api/relay/push', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
@@ -25,7 +26,7 @@ describe('Relay: Push API', () => {
|
||||
describe('Relay: Poll API', () => {
|
||||
it('poll without config returns 0 pulled', async () => {
|
||||
const settings = await fetch('api/relay/settings').then(r => r.json());
|
||||
if (settings.url && settings.has_key) return;
|
||||
if (settings.url && settings.key) return;
|
||||
|
||||
const res = await fetch('api/relay/poll', {
|
||||
method: 'POST', headers: { 'X-CSRF-Token': csrf() },
|
||||
|
||||
@@ -19,7 +19,11 @@ describe('Snippets: Date & Time', () => {
|
||||
const body = $('#editor-body');
|
||||
typeSnippet(body, '?dat');
|
||||
await sleep(100);
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
// Match editor's local-time formatting (not UTC) so the assertion doesn't
|
||||
// drift at midnight depending on timezone offset.
|
||||
const d = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const today = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
expect(body.value).toContain(today);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user