// ── Detail view ── import { $, esc, api } from './util.js'; import { state, getChildren, getGroupCards, getRecent, markRecent } from './state.js'; import { renderMd, firstImage } from './markdown.js'; import { pushCardDelete, pushCardEdit, snapshotCard } from './undo.js'; import { showDialog } from './dialog.js'; const detail = $('detail'); const scroll = $('detail-scroll'); const titleEl = $('detail-title'); const bodyEl = $('detail-body'); const metaEl = $('detail-meta'); let morphTimer = null; let morphGhost = null; let imageFsOverlay = null; let imageFsImg = null; let fsScale = 1; let fsTx = 0; let fsTy = 0; let fsPanning = false; let fsStartX = 0; let fsStartY = 0; let fsStartTx = 0; let fsStartTy = 0; const FS_MIN_SCALE = 0.35; const FS_MAX_SCALE = 8; // ── Zoom ── const ZOOM_KEY = 'hyprcrd-detail-zoom'; let zoom = parseFloat(localStorage.getItem(ZOOM_KEY) || '1') || 1; function applyZoom() { if (!titleEl) return; titleEl.style.fontSize = `${(1.125 * zoom).toFixed(3)}rem`; bodyEl.style.fontSize = `${(0.875 * zoom).toFixed(3)}rem`; metaEl.style.fontSize = `${Math.max(0.625, 0.6875 * zoom).toFixed(3)}rem`; titleEl.style.lineHeight = `${Math.max(1.35, 1.55 + (zoom - 1) * 0.3).toFixed(3)}`; bodyEl.style.lineHeight = `${Math.max(1.5, 1.7 + (zoom - 1) * 0.35).toFixed(3)}`; metaEl.style.lineHeight = `${Math.max(1.2, 1.35 + (zoom - 1) * 0.25).toFixed(3)}`; } applyZoom(); scroll?.addEventListener('wheel', e => { if (!detail.classList.contains('open') || !e.shiftKey || Math.abs(e.deltaY) < Math.abs(e.deltaX)) return; e.preventDefault(); zoom = Math.min(2.2, Math.max(0.75, zoom + (e.deltaY < 0 ? 0.08 : -0.08))); zoom = Math.round(zoom * 100) / 100; localStorage.setItem(ZOOM_KEY, String(zoom)); applyZoom(); }, { passive: false }); // ── Open / close ── export function openDetail(id, options = {}) { const card = state.cards.find(c => c.id == id); if (!card) return; markRecent(id); const el = $('board')?.querySelector(`[data-id="${id}"]`); if (el) el.style.borderBottom = `3px solid ${card.color}`; if (!detail.classList.contains('open')) { state.openedFromSearch = $('search')?.classList.contains('open') || false; state.openedFromGallery = !!options.fromGallery; state.galleryOrigin = options.fromGallery ? (options.galleryOrigin || null) : null; } else if (options.fromGallery) { state.openedFromGallery = true; state.galleryOrigin = options.galleryOrigin || state.galleryOrigin || null; } state.viewingCard = card; renderSidebar(card); showContent(card); detail.classList.add('open'); } function clearMorphGhost() { if (morphTimer) { clearTimeout(morphTimer); morphTimer = null; } if (morphGhost) { morphGhost.remove(); morphGhost = null; } } function ensureImageFullscreenOverlay() { if (imageFsOverlay) return imageFsOverlay; const overlay = document.createElement('div'); overlay.className = 'detail-image-fs-overlay'; overlay.innerHTML = `
`; overlay.addEventListener('click', e => { if (e.target === overlay) closeImageFullscreen(); }); overlay.querySelector('.detail-image-fs-close')?.addEventListener('click', closeImageFullscreen); document.body.appendChild(overlay); imageFsOverlay = overlay; imageFsImg = overlay.querySelector('.detail-image-fs-img'); if (imageFsImg) { imageFsImg.draggable = false; imageFsImg.addEventListener('mousedown', onImageFsMouseDown); } overlay.addEventListener('wheel', onImageFsWheel, { passive: false }); document.addEventListener('mousemove', onImageFsMouseMove); document.addEventListener('mouseup', onImageFsMouseUp); return overlay; } function applyImageFsTransform() { if (!imageFsImg) return; imageFsImg.style.transform = `translate(${fsTx}px, ${fsTy}px) scale(${fsScale})`; } function resetImageFsTransform() { fsScale = 1; fsTx = 0; fsTy = 0; fsPanning = false; imageFsOverlay?.classList.remove('dragging'); applyImageFsTransform(); } function onImageFsWheel(e) { if (!imageFsOverlay?.classList.contains('open') || !imageFsImg) return; e.preventDefault(); const old = fsScale; const factor = Math.exp(-e.deltaY * 0.0015); const next = Math.min(FS_MAX_SCALE, Math.max(FS_MIN_SCALE, old * factor)); if (Math.abs(next - old) < 0.0001) return; const r = imageFsOverlay.getBoundingClientRect(); const cx = r.left + r.width / 2; const cy = r.top + r.height / 2; const px = e.clientX - cx; const py = e.clientY - cy; fsTx = px - ((px - fsTx) / old) * next; fsTy = py - ((py - fsTy) / old) * next; fsScale = next; applyImageFsTransform(); } function onImageFsMouseDown(e) { if (!imageFsOverlay?.classList.contains('open')) return; if (e.button !== 0) return; e.preventDefault(); fsPanning = true; fsStartX = e.clientX; fsStartY = e.clientY; fsStartTx = fsTx; fsStartTy = fsTy; imageFsOverlay.classList.add('dragging'); } function onImageFsMouseMove(e) { if (!fsPanning || !imageFsOverlay?.classList.contains('open')) return; fsTx = fsStartTx + (e.clientX - fsStartX); fsTy = fsStartTy + (e.clientY - fsStartY); applyImageFsTransform(); } function onImageFsMouseUp() { if (!fsPanning) return; fsPanning = false; imageFsOverlay?.classList.remove('dragging'); } function openImageFullscreen(src, alt = '') { if (!src) return; const overlay = ensureImageFullscreenOverlay(); resetImageFsTransform(); if (imageFsImg) { imageFsImg.src = src; imageFsImg.alt = alt || 'image'; } overlay.classList.add('open'); document.body.classList.add('detail-image-fs-open'); } function closeImageFullscreen() { if (!imageFsOverlay) return; fsPanning = false; imageFsOverlay.classList.remove('dragging'); imageFsOverlay.classList.remove('open'); document.body.classList.remove('detail-image-fs-open'); if (imageFsImg) { imageFsImg.removeAttribute('src'); imageFsImg.alt = ''; imageFsImg.style.transform = ''; } fsScale = 1; fsTx = 0; fsTy = 0; } function resolveMorphTarget(cardId) { if (state.openedFromGallery) { const grid = $('gallery-grid'); if (grid) { if (state.galleryOrigin) { const exact = Array.from(grid.querySelectorAll('[data-gallery-origin]')) .find(el => el.dataset.galleryOrigin === state.galleryOrigin); if (exact) return exact; } const byCard = grid.querySelector(`[data-card-id="${cardId}"]`); if (byCard) return byCard; } } const board = $('board'); if (!board || !cardId) return null; const candidates = []; const pushCandidate = id => { if (!id) return; const s = String(id); if (!candidates.includes(s)) candidates.push(s); }; // 1) direct card pushCandidate(cardId); const card = state.cards.find(c => c.id == cardId); if (card) { if (card.parent_id) { // 2) group parent (typical board representation for grouped cards) pushCandidate(card.parent_id); const parent = state.cards.find(c => c.id == card.parent_id); if (parent) { // 3) any visible member in expanded group view pushCandidate(parent.id); getChildren(parent.id).forEach(ch => pushCandidate(ch.id)); } } else { const children = getChildren(card.id); if (children.length) { // parent with children: include visible members for expanded group view pushCandidate(card.id); children.forEach(ch => pushCandidate(ch.id)); } } } for (const id of candidates) { const el = board.querySelector(`[data-id="${id}"]`); if (el) return el; } return null; } function startCloseMorph(cardId) { if (!cardId) return; if ($('search')?.classList.contains('open')) return; const card = state.cards.find(c => c.id == cardId); const panel = detail?.firstElementChild; const target = resolveMorphTarget(cardId); if (!panel || !target) return; const from = panel.getBoundingClientRect(); const to = target.getBoundingClientRect(); if (from.width < 40 || from.height < 40 || to.width < 20 || to.height < 20) return; // Skip if target card is off-screen; morph would look broken. const visible = to.bottom > 0 && to.top < window.innerHeight && to.right > 0 && to.left < window.innerWidth; if (!visible) return; clearMorphGhost(); const frame = document.createElement('div'); frame.className = 'detail-close-frame'; const targetCard = target.dataset.id ? state.cards.find(c => c.id == target.dataset.id) : card; Object.assign(frame.style, { left: `${from.left}px`, top: `${from.top}px`, width: `${from.width}px`, height: `${from.height}px`, borderColor: targetCard?.color || card?.color || 'var(--t-accent2)', position: 'fixed', inset: 'auto', margin: '0', }); document.body.appendChild(frame); morphGhost = frame; requestAnimationFrame(() => { frame.style.left = `${to.left}px`; frame.style.top = `${to.top}px`; frame.style.width = `${to.width}px`; frame.style.height = `${to.height}px`; frame.style.opacity = '0.32'; frame.style.borderRadius = '4px'; }); target.classList.add('card-morph-target'); setTimeout(() => target.classList.remove('card-morph-target'), 360); const done = () => { if (frame === morphGhost) morphGhost = null; frame.remove(); }; frame.addEventListener('transitionend', done, { once: true }); morphTimer = setTimeout(done, 430); } export function closeDetail(options = {}) { const animate = options?.animate !== false; const restoreGallery = options?.restoreGallery !== false; const cardId = state.viewingCard?.id; if (animate) startCloseMorph(cardId); else clearMorphGhost(); closeImageFullscreen(); detail.classList.remove('open', 'zen'); document.documentElement.classList.remove('detail-zen-open'); document.body.classList.remove('detail-zen-open'); const icon = $('detail-zen')?.querySelector('i'); if (icon) icon.className = 'ph ph-corners-out text-sm'; state.viewingCard = null; if (state.openedFromSearch) { state.openedFromSearch = false; $('search-input')?.focus(); } if (state.openedFromGallery) { const galleryOpen = $('gallery')?.classList.contains('open'); state.openedFromGallery = false; state.galleryOrigin = null; if (restoreGallery && !galleryOpen) window._openGallery?.(); } } export function showDetailCard(id) { const card = state.cards.find(c => c.id == id); if (!card) return; state.viewingCard = card; markRecent(id); document.querySelectorAll('#detail-sidebar-list .sidebar-card').forEach(sc => { const active = sc.dataset.sid == id; sc.classList.toggle('border-t-accent', active); sc.classList.toggle('bg-t-raised', active); sc.classList.toggle('border-transparent', !active); }); showContent(card); } // ── Sidebar ── function renderSidebar(card) { const sidebar = $('detail-sidebar'); const list = $('detail-sidebar-list'); const groupCards = getGroupCards(card); if (groupCards && groupCards.length > 1) { sidebar.classList.remove('hidden'); $('detail-sidebar-count').textContent = groupCards.length; list.innerHTML = groupCards.map(gc => { const active = gc.id == card.id; const img = firstImage(gc.body); return ` `; }).join(''); list.querySelectorAll('.sidebar-card').forEach(sc => { sc.addEventListener('click', () => showDetailCard(sc.dataset.sid)); }); } else { sidebar.classList.add('hidden'); } } // ── Content ── function showContent(card) { $('detail-color').style.background = card.color; titleEl.textContent = card.title; bodyEl.innerHTML = renderMd(card.body); applyReadingResizeHandles(bodyEl); bindDetailImageFullscreen(bodyEl); // Interactive checkboxes bodyEl.querySelectorAll('input[type="checkbox"]').forEach((cb, idx) => { cb.disabled = false; cb.addEventListener('change', async () => { let n = 0; const body = card.body.replace(/- \[([ x])\]/g, (m, s) => n++ === idx ? `- [${s === 'x' ? ' ' : 'x'}]` : m); card.body = body; await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } }); const c = state.cards.find(x => x.id == card.id); if (c) c.body = body; window._renderBoard(); }); }); metaEl.textContent = '> created: ' + card.created_at + ' | modified: ' + card.updated_at + (card.bucket_name ? ' | bucket: ' + card.bucket_name : ''); applyZoom(); // Backlinks const pattern = `[[${card.title}]]`.toLowerCase(); const backlinks = state.cards.filter(c => c.id != card.id && (c.body || '').toLowerCase().includes(pattern)); const blBox = $('detail-backlinks'); const blList = $('detail-backlinks-list'); if (backlinks.length) { blBox.classList.remove('hidden'); blList.innerHTML = backlinks.map(b => ` `).join(''); blList.querySelectorAll('[data-open-card]').forEach(btn => { btn.addEventListener('click', () => openDetail(btn.dataset.openCard)); }); } else { blBox.classList.add('hidden'); } } function applyReadingResizeHandles(root) { if (!root) return; // Reading aid only: temporary per-block font-size scaling (not persisted). root.querySelectorAll('p, blockquote, pre').forEach(el => { el.classList.add('detail-reading-resize'); const line = document.createElement('button'); line.type = 'button'; line.className = 'detail-font-dragline'; line.title = 'drag to resize text'; line.setAttribute('aria-label', 'resize text'); el.appendChild(line); line.addEventListener('mousedown', e => { e.preventDefault(); e.stopPropagation(); const startY = e.clientY; const startFont = parseFloat(getComputedStyle(el).fontSize) || 14; const startLineHeightPx = parseFloat(getComputedStyle(el).lineHeight); const lineRatio = Number.isFinite(startLineHeightPx) && startLineHeightPx > 0 ? startLineHeightPx / startFont : 1.65; const prevSelect = document.body.style.userSelect; const min = 12; const max = 44; el.classList.add('resizing'); document.body.style.userSelect = 'none'; const onMove = ev => { const delta = ev.clientY - startY; const next = Math.min(max, Math.max(min, startFont + delta * 0.08)); el.style.fontSize = `${next.toFixed(2)}px`; el.style.lineHeight = `${(next * lineRatio).toFixed(2)}px`; }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.userSelect = prevSelect; el.classList.remove('resizing'); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); }); } function bindDetailImageFullscreen(root) { if (!root) return; root.querySelectorAll('img').forEach(img => { img.title = 'double-click to open fullscreen'; img.addEventListener('dblclick', e => { e.preventDefault(); e.stopPropagation(); openImageFullscreen(img.getAttribute('src'), img.getAttribute('alt') || 'image'); }); }); } // ── Event listeners ── // Delegated card-link clicks (replaces inline onclick) detail.addEventListener('click', e => { const link = e.target.closest('.card-link[data-card-id]'); if (link) { openDetail(link.dataset.cardId); return; } if (e.target === detail) closeDetail(); }); $('detail-close')?.addEventListener('click', closeDetail); document.addEventListener('keydown', e => { if (e.key !== 'Escape') return; if (!imageFsOverlay?.classList.contains('open')) return; e.preventDefault(); e.stopImmediatePropagation(); closeImageFullscreen(); }, true); // Zen mode $('detail-zen')?.addEventListener('click', () => { detail.classList.toggle('zen'); const isZen = detail.classList.contains('zen'); document.documentElement.classList.toggle('detail-zen-open', isZen); document.body.classList.toggle('detail-zen-open', isZen); const icon = $('detail-zen')?.querySelector('i'); if (icon) icon.className = isZen ? 'ph ph-corners-in text-sm' : 'ph ph-corners-out text-sm'; }); $('detail-edit')?.addEventListener('click', () => { if (!state.viewingCard) return; const card = state.viewingCard; const fromGallery = state.openedFromGallery; state.openedFromSearch = false; state.openedFromGallery = false; state.galleryOrigin = null; state.returnToGalleryAfterEdit = fromGallery; closeDetail({ animate: false, restoreGallery: false }); $('gallery')?.classList.remove('open'); window._closeSearch(); window._openModal(card); }); $('detail-delete')?.addEventListener('click', async () => { if (!state.viewingCard) return; const ok = await showDialog({ title: 'delete_card', message: `"${state.viewingCard.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' }); if (!ok) return; pushCardDelete('delete', snapshotCard(state.viewingCard)); await api(`api/cards/${state.viewingCard.id}`, { method: 'DELETE' }); state.galleryOrigin = null; closeDetail({ animate: false, restoreGallery: false }); await window._reloadAll(); }); export async function ungroupCard(id) { await api(`api/cards/${id}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); if (state.viewingCard) openDetail(state.viewingCard.id == id ? id : state.viewingCard.id); }