// ── Detail view ── import { $, esc, api, fuzzyMatch } from './util.js'; import { state, parseTags, tagChipHtml, getChildren, getGroupCards, getRecent, markRecent } from './state.js'; import { renderMd, stripMd, firstImage, addCodeCopyButtons, formatTodoItems } 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'; function applyZoom() { if (!titleEl) return; titleEl.style.fontSize = `${(1.125 * state.zoom).toFixed(3)}rem`; bodyEl.style.fontSize = `${(0.875 * state.zoom).toFixed(3)}rem`; metaEl.style.fontSize = `${Math.max(0.625, 0.6875 * state.zoom).toFixed(3)}rem`; titleEl.style.lineHeight = `${Math.max(1.35, 1.55 + (state.zoom - 1) * 0.3).toFixed(3)}`; bodyEl.style.lineHeight = `${Math.max(1.5, 1.7 + (state.zoom - 1) * 0.35).toFixed(3)}`; metaEl.style.lineHeight = `${Math.max(1.2, 1.35 + (state.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(); state.zoom = Math.min(2.2, Math.max(0.75, state.zoom + (e.deltaY < 0 ? 0.08 : -0.08))); state.zoom = Math.round(state.zoom * 100) / 100; localStorage.setItem(ZOOM_KEY, String(state.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); dismissStaleBanner(); detail.classList.add('open'); // Let plugins (collab-buckets unread-dot, etc.) react to the open. window.hyprcrd?.hooks?.emit?.('detail.opened', { id: card.id }); } // ── Remote-update detection ── // // When a foreign edit arrives for the card the user is currently viewing, // the pull replaces state.cards but the open detail view keeps rendering // the stale object it captured on open. Instead of force-re-rendering // under the user's finger (jarring when they're mid-read), surface a // banner with a manual Reload button. let staleBannerEl = null; function dismissStaleBanner() { if (staleBannerEl) { staleBannerEl.remove(); staleBannerEl = null; } } function showStaleBanner(fresh) { dismissStaleBanner(); const host = detail.querySelector('#detail-controls')?.parentElement; if (!host) return; const bar = document.createElement('div'); bar.className = 'detail-stale-banner'; bar.innerHTML = ` This card was updated${fresh?.updated_at ? ' ' + timeAgo(fresh.updated_at) : ''}. `; host.insertBefore(bar, host.querySelector('#detail-controls').nextSibling); staleBannerEl = bar; bar.querySelector('.detail-stale-reload').addEventListener('click', () => { const current = state.cards.find(c => c.id == state.viewingCard?.id); if (current) { state.viewingCard = current; renderSidebar(current); showContent(current); } dismissStaleBanner(); }); bar.querySelector('.detail-stale-dismiss').addEventListener('click', dismissStaleBanner); } function timeAgo(iso) { const t = new Date(iso).getTime(); if (!isFinite(t)) return ''; const s = Math.max(0, (Date.now() - t) / 1000); if (s < 60) return `${Math.floor(s)}s ago`; if (s < 3600) return `${Math.floor(s / 60)}m ago`; if (s < 86400) return `${Math.floor(s / 3600)}h ago`; return `${Math.floor(s / 86400)}d ago`; } // After every full card reload, check whether the card we're viewing is // newer in the fresh state. If so, show the banner. Dismiss it if the // detail view closed in the meantime. function checkStaleViewingCard() { if (!detail.classList.contains('open') || !state.viewingCard) { dismissStaleBanner(); return; } const viewingId = state.viewingCard.id; const fresh = state.cards.find(c => c.id == viewingId); if (!fresh) return; // card deleted — separate UX, skip banner const old = state.viewingCard; // Compare updated_at OR any field that might have changed. The detail // view captures viewingCard by reference on open, so a replaced // state.cards array (via _reloadAll) gives us a different object — if // any visible field differs, surface the banner. const changed = (fresh.updated_at || '') > (old.updated_at || '') || (fresh.title || '') !== (old.title || '') || (fresh.body || '') !== (old.body || '') || (fresh.color || '') !== (old.color || '') || (fresh.tags || '') !== (old.tags || ''); if (changed) showStaleBanner(fresh); } window.hyprcrd?.hooks?.on?.('cards.reloaded', checkStaleViewingCard); function clearMorphGhost() { if (morphTimer) { clearTimeout(morphTimer); morphTimer = null; } if (morphGhost) { morphGhost.remove(); morphGhost = null; } } let galleryImages = []; let galleryIndex = 0; 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); overlay.querySelector('.detail-image-fs-prev')?.addEventListener('click', e => { e.stopPropagation(); navigateGallery(-1); }); overlay.querySelector('.detail-image-fs-next')?.addEventListener('click', e => { e.stopPropagation(); navigateGallery(1); }); 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 updateGalleryNav() { if (!imageFsOverlay) return; const hasGallery = galleryImages.length > 1; imageFsOverlay.querySelector('.detail-image-fs-prev').style.display = hasGallery ? '' : 'none'; imageFsOverlay.querySelector('.detail-image-fs-next').style.display = hasGallery ? '' : 'none'; const counter = imageFsOverlay.querySelector('.detail-image-fs-counter'); counter.style.display = hasGallery ? '' : 'none'; if (hasGallery) counter.textContent = `${galleryIndex + 1} / ${galleryImages.length}`; } function navigateGallery(dir) { if (galleryImages.length < 2) return; galleryIndex = (galleryIndex + dir + galleryImages.length) % galleryImages.length; resetImageFsTransform(); if (imageFsImg) { imageFsImg.src = galleryImages[galleryIndex]; } updateGalleryNav(); } 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 = '', gallery = []) { if (!src) return; const overlay = ensureImageFullscreenOverlay(); resetImageFsTransform(); galleryImages = gallery.length > 1 ? gallery : []; galleryIndex = galleryImages.indexOf(src); if (galleryIndex < 0) galleryIndex = 0; if (imageFsImg) { imageFsImg.src = src; imageFsImg.alt = alt || 'image'; } updateGalleryNav(); 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; // Use known inset values instead of getBoundingClientRect to avoid offset issues const isZen = detail.classList.contains('zen'); const inset = isZen ? 0 : 40; const from = { left: inset, top: inset, width: window.innerWidth - inset * 2, height: window.innerHeight - inset * 2, }; 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, { position: 'fixed', left: `${from.left}px`, top: `${from.top}px`, width: `${from.width}px`, height: `${from.height}px`, borderColor: targetCard?.color || card?.color || 'var(--t-accent2)', margin: '0', }); document.body.appendChild(frame); morphGhost = frame; // Force reflow so the browser commits the starting position before transitioning void frame.offsetWidth; 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'); dismissStaleBanner(); removeZenHandles(); document.documentElement.classList.remove('detail-zen-open'); document.body.classList.remove('detail-zen-open'); $('split-right')?.classList.add('hidden'); 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?.(); } if (state._returnToCalendar) { state._returnToCalendar = false; window._openCalendar?.(); } // Return focus to body so board shortcuts work immediately. // Skipped when we just focused the search input (openedFromSearch). if (!state.openedFromSearch && document.activeElement && document.activeElement !== document.body) { document.activeElement.blur(); } } 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'); sidebar.dataset.groupId = String(groupCards[0].id); $('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'); delete sidebar.dataset.groupId; } } // ── Content ── function updateDoneButton(card) { const isDone = (card.tags || '').split(',').includes('done'); const btn = $('detail-done'); const label = $('detail-done-label'); if (btn) { btn.classList.toggle('text-t-accent', isDone); btn.classList.toggle('border-t-accent', isDone); } if (label) label.textContent = isDone ? 'Undone' : 'Done'; } function showContent(card) { $('detail-color').style.background = card.color; titleEl.textContent = card.title; updateDoneButton(card); const doneCorner = $('detail-done-corner'); if (doneCorner) doneCorner.classList.toggle('hidden', !(card.tags || '').split(',').includes('done')); bodyEl.innerHTML = renderMd(card.body); addCodeCopyButtons(bodyEl); formatTodoItems(bodyEl); applyReadingResizeHandles(bodyEl); bindDetailImageFullscreen(bodyEl); // Interactive checkboxes bindCheckboxes(bodyEl, card); } export function bindCheckboxes(container, card) { container.querySelectorAll('.todo-item').forEach((item, idx) => { const btn = item.querySelector('.todo-cb'); if (!btn) return; const toggle = 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; btn.classList.toggle('checked'); item.classList.toggle('todo-done'); window._renderBoard(); }; item.addEventListener('click', toggle); }); metaEl.textContent = '> created: ' + card.created_at + ' | modified: ' + card.updated_at + (card.bucket_name ? ' | bucket: ' + card.bucket_name : ''); applyZoom(); // Tags const tags = parseTags(card); const tagEl = $('detail-tags'); if (tagEl) { if (tags.length) { tagEl.classList.remove('hidden'); tagEl.innerHTML = tags.map(t => tagChipHtml(t)).join(''); tagEl.querySelectorAll('.tag-seg[data-tag]').forEach(seg => { seg.addEventListener('click', e => { const tag = seg.dataset.tag; if (e.ctrlKey || e.metaKey) { const idx = state.activeTags.indexOf(tag); if (idx >= 0) state.activeTags.splice(idx, 1); else state.activeTags.push(tag); } else { state.activeTags = [tag]; } window._renderTagBar?.(); window._renderBoard(); }); }); } else { tagEl.classList.add('hidden'); } } // 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.dataset.tip = '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); }); }); } export function bindDetailImageFullscreen(root) { if (!root) return; // Gallery grids: single click, with prev/next root.querySelectorAll('.md-gallery').forEach(gallery => { const srcs = Array.from(gallery.querySelectorAll('img')).map(i => i.getAttribute('src')); gallery.querySelectorAll('.md-gallery-item').forEach(item => { const img = item.querySelector('img'); if (!img) return; item.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); openImageFullscreen(img.getAttribute('src'), img.getAttribute('alt') || 'image', srcs); }); }); }); // Regular images: double-click, no gallery nav root.querySelectorAll('img').forEach(img => { if (img.closest('.md-gallery')) return; img.dataset.tip = 'double-click to 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 (!imageFsOverlay?.classList.contains('open')) return; if (e.key === 'ArrowLeft') { navigateGallery(-1); e.preventDefault(); return; } if (e.key === 'ArrowRight') { navigateGallery(1); e.preventDefault(); return; } if (e.key !== 'Escape') return; e.preventDefault(); e.stopImmediatePropagation(); closeImageFullscreen(); }, true); // Zen mode // Tree view toggle // Extract selection to new card const ZEN_WIDTH_KEY = 'hyprcrd-zen-width'; let zenWidth = parseInt(localStorage.getItem(ZEN_WIDTH_KEY)) || 680; let zenHandles = []; function applyZenWidth() { if (!scroll) return; if (detail.classList.contains('zen')) { scroll.style.maxWidth = zenWidth + 'px'; // Position handles at content edges const margin = Math.max(0, (window.innerWidth - zenWidth) / 2); zenHandles.forEach(h => { h.el.style.left = h.side === 'left' ? (margin - 12) + 'px' : ''; h.el.style.right = h.side === 'right' ? (margin - 12) + 'px' : ''; }); } else { scroll.style.maxWidth = ''; } } function createZenHandles() { removeZenHandles(); ['left', 'right'].forEach(side => { const el = document.createElement('div'); Object.assign(el.style, { position: 'fixed', top: '0', bottom: '0', width: '24px', cursor: 'col-resize', zIndex: '200', }); const bar = document.createElement('div'); Object.assign(bar.style, { position: 'absolute', top: '50%', transform: 'translateY(-50%)', width: '4px', height: '48px', borderRadius: '2px', background: 'var(--t-muted)', opacity: '0.08', transition: 'opacity 0.2s', }); bar.style[side === 'left' ? 'right' : 'left'] = '8px'; el.appendChild(bar); el.addEventListener('mouseenter', () => { bar.style.opacity = '0.5'; }); el.addEventListener('mouseleave', () => { if (!el._dragging) bar.style.opacity = '0.08'; }); let startX, startW; el.addEventListener('mousedown', e => { e.preventDefault(); startX = e.clientX; startW = zenWidth; el._dragging = true; bar.style.opacity = '0.7'; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; const onMove = ev => { const dx = ev.clientX - startX; zenWidth = Math.max(400, Math.min(window.innerWidth - 80, startW + (side === 'left' ? -dx : dx) * 2)); applyZenWidth(); }; const onUp = () => { el._dragging = false; bar.style.opacity = '0.08'; document.body.style.cursor = ''; document.body.style.userSelect = ''; localStorage.setItem(ZEN_WIDTH_KEY, String(zenWidth)); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); document.body.appendChild(el); zenHandles.push({ el, side }); }); applyZenWidth(); } function removeZenHandles() { zenHandles.forEach(h => h.el.remove()); zenHandles = []; } $('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'; if (isZen) createZenHandles(); else { removeZenHandles(); scroll.style.maxWidth = ''; } }); window.addEventListener('resize', () => { if (detail.classList.contains('zen')) applyZenWidth(); }); // ── Split pane (right) ── const splitPane = $('split-right'); const splitSearch = $('split-search'); const splitPicks = $('split-picks'); const splitContent = $('split-content'); function findTaggedCards(card) { if (!card) return []; const myTags = parseTags(card); if (!myTags.length) return []; return state.cards .filter(c => c.id != card.id) .map(c => { const ct = parseTags(c); const shared = myTags.filter(t => ct.some(ct2 => ct2 === t || ct2.startsWith(t + '/') || t.startsWith(ct2 + '/'))); return shared.length ? { card: c, score: shared.length } : null; }) .filter(Boolean) .sort((a, b) => b.score - a.score) .slice(0, 12) .map(s => s.card); } function findRelatedCards(card) { if (!card) return []; const id = card.id; const title = (card.title || '').toLowerCase(); const body = (card.body || '').toLowerCase(); const stopwords = new Set(['the','and','for','that','this','with','from','are','was','were','been','have','has','had','not','but','what','all','can','her','his','how','its','may','new','now','old','see','way','who','did','get','let','say','she','too','use','eine','ein','der','die','das','und','ist','von','mit','den','dem','des','auf','fuer','auch','sich','noch','wie','man','aber','nach','bei','nur','dann','wenn','oder','als','bis','zum','zur','kann','doch','ich','wir','sie','aus','vor']); const words = `${card.title} ${stripMd(card.body)}`.toLowerCase() .split(/[^a-zA-Z0-9äöüß]+/) .filter(w => w.length >= 3 && !stopwords.has(w)); const wordSet = [...new Set(words)]; return state.cards .filter(c => c.id != id) .map(c => { let score = 0; const ct = (c.title || '').toLowerCase(); const cb = stripMd(c.body || '').toLowerCase(); if (body.includes(`[[${c.title.toLowerCase()}]]`)) score += 80; if ((c.body || '').toLowerCase().includes(`[[${title}]]`)) score += 80; if (card.bucket_id && c.bucket_id == card.bucket_id) score += 15; if (card.parent_id && c.parent_id == card.parent_id) score += 20; for (const w of wordSet) { if (ct.includes(w)) score += 6; if (cb.includes(w)) score += 2; } return score > 0 ? { card: c, score } : null; }) .filter(Boolean) .sort((a, b) => b.score - a.score) .slice(0, 12) .map(s => s.card); } function renderSplitGroup(cards, label) { if (!cards.length) return ''; return `
${label}
` + cards.map(c => `
${esc(c.title)} ${c.bucket_name ? `${esc(c.bucket_name)}` : ''}
`).join(''); } function renderSplitPicks(cards, label) { if (!cards.length) { splitPicks.innerHTML = ``; return; } splitPicks.innerHTML = renderSplitGroup(cards, label); splitPicks.querySelectorAll('[data-split-card]').forEach(el => { el.addEventListener('click', () => loadSplitCard(el.dataset.splitCard)); }); } function renderSplitGroups(groups) { const html = groups.filter(g => g.cards.length).map(g => renderSplitGroup(g.cards, g.label)).join(''); if (!html) { splitPicks.innerHTML = ``; return; } splitPicks.innerHTML = html; splitPicks.querySelectorAll('[data-split-card]').forEach(el => { el.addEventListener('click', () => loadSplitCard(el.dataset.splitCard)); }); } function searchAllCards(query) { const q = query.toLowerCase().trim(); const parts = q.split(/\s+/).filter(Boolean); const tagFilters = parts.filter(p => p.startsWith('#') && p.length > 1).map(p => p.substring(1)); const words = parts.filter(p => !p.startsWith('#')); const textQ = words.join(' '); let pool = state.cards.filter(c => c.id != state.viewingCard?.id); if (tagFilters.length) { pool = pool.filter(c => { const ct = parseTags(c); return tagFilters.every(tf => ct.some(t => t === tf || t.startsWith(tf + '/') || t.includes(tf))); }); } if (!words.length) return pool.slice(0, 15); return pool .map(c => { const t = (c.title || '').toLowerCase(); const b = stripMd(c.body || '').toLowerCase(); const tags = (c.tags || '').toLowerCase(); let score = tagFilters.length ? 30 : 0; if (t.includes(textQ)) score += 50; if (t.startsWith(textQ)) score += 20; if (b.includes(textQ)) score += 10; if (tags.includes(textQ)) score += 40; for (const w of words) { if (t.includes(w)) score += 15; if (b.includes(w)) score += 5; if (tags.includes(w)) score += 20; } if (!score) { const fm = fuzzyMatch(textQ, t); if (fm !== null) score += 5 + fm; } return score > 0 ? { card: c, score } : null; }) .filter(Boolean) .sort((a, b) => b.score - a.score) .slice(0, 15) .map(s => s.card); } let splitCardId = null; let splitLastQuery = ''; function restoreSplitList() { if (splitLastQuery) { splitSearch.value = splitLastQuery; const results = searchAllCards(splitLastQuery); renderSplitPicks(results, `${results.length} result${results.length !== 1 ? 's' : ''}`); } else { openSplitPane(); } splitContent.innerHTML = ''; } function loadSplitCard(id) { const card = state.cards.find(c => c.id == id); if (!card) return; splitCardId = id; splitPicks.innerHTML = `
`; splitPicks.querySelector('#split-back').addEventListener('click', restoreSplitList); splitPicks.querySelector('#split-promote').addEventListener('click', () => promoteSplitCard(id)); splitSearch.value = ''; splitContent.innerHTML = `

${esc(card.title)}

${renderMd(card.body)}
> ${card.created_at}${card.bucket_name ? ' | ' + esc(card.bucket_name) : ''}
`; splitContent.querySelectorAll('.card-link[data-card-id]').forEach(link => { link.addEventListener('click', () => loadSplitCard(link.dataset.cardId)); }); bindCheckboxes(splitContent, card); addCodeCopyButtons(splitContent); formatTodoItems(splitContent); } function promoteSplitCard(id) { // Open the split card as the main detail card, then refresh related in split pane openDetail(id); openSplitPane(); } function openSplitPane() { if (!splitPane) return; splitPane.classList.remove('hidden'); splitSearch.value = ''; splitLastQuery = ''; splitContent.innerHTML = ''; const tagged = findTaggedCards(state.viewingCard); const related = findRelatedCards(state.viewingCard); // Dedupe: remove tagged cards from related const taggedIds = new Set(tagged.map(c => c.id)); const relatedOnly = related.filter(c => !taggedIds.has(c.id)); renderSplitGroups([ { label: 'tags', cards: tagged }, { label: 'related', cards: relatedOnly }, ]); splitSearch.focus(); } $('split-right-btn')?.addEventListener('click', () => { if (splitPane.classList.contains('hidden')) openSplitPane(); }); $('split-close')?.addEventListener('click', () => splitPane.classList.add('hidden')); splitSearch?.addEventListener('input', () => { const q = splitSearch.value.trim(); splitLastQuery = q; splitContent.innerHTML = ''; if (!q) { const tagged = findTaggedCards(state.viewingCard); const related = findRelatedCards(state.viewingCard); const taggedIds = new Set(tagged.map(c => c.id)); const relatedOnly = related.filter(c => !taggedIds.has(c.id)); renderSplitGroups([ { label: 'tags', cards: tagged }, { label: 'related', cards: relatedOnly }, ]); return; } const results = searchAllCards(q); renderSplitPicks(results, `${results.length} result${results.length !== 1 ? 's' : ''}`); }); splitSearch?.addEventListener('keydown', e => { if (e.key === 'Escape') { splitPane.classList.add('hidden'); e.stopPropagation(); } if (e.key === 'Enter') { const first = splitPicks.querySelector('[data-split-card]'); if (first) loadSplitCard(first.dataset.splitCard); } }); $('detail-edit')?.addEventListener('click', () => { if (!state.viewingCard) return; const card = state.viewingCard; const fromGallery = state.openedFromGallery; const fromCalendar = state._returnToCalendar; state.openedFromSearch = false; state.openedFromGallery = false; state.galleryOrigin = null; state.returnToGalleryAfterEdit = fromGallery; state._returnToCalendar = false; closeDetail({ animate: false, restoreGallery: false }); // Hide calendar so modal is visible if (fromCalendar) { $('calendar')?.classList.remove('open'); state._returnToCalendar = true; } $('gallery')?.classList.remove('open'); window._closeSearch(); window._openModal(card); }); $('detail-done')?.addEventListener('click', async () => { const card = state.viewingCard; if (!card) return; const isDone = (card.tags || '').split(',').includes('done'); let newBody; if (isDone) { newBody = card.body.replace(/\s*#done\b/g, ''); } else { newBody = (card.body || '').trimEnd() + ' #done'; } await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody, due_date: isDone ? card.due_date : null } }); card.body = newBody; if (!isDone) card.due_date = null; const c = state.cards.find(x => x.id == card.id); if (c) { c.body = newBody; c.tags = newBody.match(/#[a-z0-9_/-]+/gi)?.map(t => t.substring(1).toLowerCase()).join(',') || ''; if (!isDone) c.due_date = null; } showContent(card); window._renderBoard(); const doneCorner = $('detail-done-corner'); if (doneCorner) doneCorner.classList.toggle('hidden', !newBody.includes('#done')); }); $('detail-delete')?.addEventListener('click', async () => { if (!state.viewingCard) return; if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') { 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)); 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); }