// ── 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';
import { activateBucket } from './buckets.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);
// Optional: switch the board to the card's bucket so the user
// sees context. Only when opened via search, wikilink, or
// programmatic call — not when the user clicked a board card
// (they're already in the right bucket). Gated by a setting
// under Behavior → Interactions.
if (card.bucket_id
&& localStorage.getItem('hyprcrd-open-switches-bucket') === '1'
&& state.activeBucket !== 'all'
&& String(state.activeBucket) !== String(card.bucket_id)) {
activateBucket(card.bucket_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);
// Treat the reload as an explicit "I've now seen the new version"
// — re-fire detail.opened so collab-buckets clears the red dot
// on the board card and persists mark-card-seen to the backend.
window.hyprcrd?.hooks?.emit?.('detail.opened', { id: current.id });
}
dismissStaleBanner();
});
bar.querySelector('.detail-stale-dismiss').addEventListener('click', dismissStaleBanner);
}
function timeAgo(iso) {
if (!iso) return '';
// SQLite stores CURRENT_TIMESTAMP as UTC without a tz marker
// ("2026-04-14 12:34:56"). Without an explicit Z, JS parses it as
// local time — off by the tz offset (2h on CEST, hence "2h ago"
// for a 30-second-old update).
const normalized = String(iso).includes('T') || /[Zz]|[+-]\d\d:?\d\d$/.test(iso)
? iso
: iso.replace(' ', 'T') + 'Z';
const t = new Date(normalized).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 `