This commit is contained in:
th committed 2026-04-07 17:47:09 +02:00
1 parent 03f57cbaee
commit a9603aa0e8
11 files changed
+1010 -93

No files matched your search

+195 -5
View File
@@ -1,8 +1,8 @@
// ── Detail view ──
import { $, esc, api } from './util.js';
import { $, esc, api, fuzzyMatch } from './util.js';
import { state, getChildren, getGroupCards, getRecent, markRecent } from './state.js';
import { renderMd, firstImage } from './markdown.js';
import { renderMd, stripMd, firstImage, addCodeCopyButtons } from './markdown.js';
import { pushCardDelete, pushCardEdit, snapshotCard } from './undo.js';
import { showDialog } from './dialog.js';
@@ -270,7 +270,15 @@ function startCloseMorph(cardId) {
const target = resolveMorphTarget(cardId);
if (!panel || !target) return;
const from = panel.getBoundingClientRect();
// 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;
@@ -286,19 +294,21 @@ function startCloseMorph(cardId) {
? 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)',
position: 'fixed',
inset: 'auto',
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`;
@@ -331,6 +341,7 @@ export function closeDetail(options = {}) {
detail.classList.remove('open', 'zen');
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;
@@ -400,6 +411,7 @@ function showContent(card) {
$('detail-color').style.background = card.color;
titleEl.textContent = card.title;
bodyEl.innerHTML = renderMd(card.body);
addCodeCopyButtons(bodyEl);
applyReadingResizeHandles(bodyEl);
bindDetailImageFullscreen(bodyEl);
@@ -522,6 +534,23 @@ document.addEventListener('keydown', e => {
}, true);
// Zen mode
// Tree view toggle
// Extract selection to new card
$('detail-extract')?.addEventListener('click', async () => {
const sel = window.getSelection();
const text = sel?.toString();
if (!text?.trim()) return;
await window._extractToCard(text);
});
$('detail-tree')?.addEventListener('click', () => {
const body = $('detail-body');
if (!body) return;
body.classList.toggle('tree');
$('detail-tree')?.classList.toggle('text-t-accent', body.classList.contains('tree'));
$('detail-tree')?.classList.toggle('border-t-accent', body.classList.contains('tree'));
});
$('detail-zen')?.addEventListener('click', () => {
detail.classList.toggle('zen');
const isZen = detail.classList.contains('zen');
@@ -531,6 +560,167 @@ $('detail-zen')?.addEventListener('click', () => {
if (icon) icon.className = isZen ? 'ph ph-corners-in text-sm' : 'ph ph-corners-out text-sm';
});
// ── Split pane (right) ──
const splitPane = $('split-right');
const splitSearch = $('split-search');
const splitPicks = $('split-picks');
const splitContent = $('split-content');
function findRelatedCards(card) {
if (!card) return [];
const id = card.id;
const title = (card.title || '').toLowerCase();
const body = (card.body || '').toLowerCase();
// Extract significant words (3+ chars) from title and body
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();
// Backlink: this card references the other
if (body.includes(`[[${c.title.toLowerCase()}]]`)) score += 80;
// Backlink: the other references this card
if ((c.body || '').toLowerCase().includes(`[[${title}]]`)) score += 80;
// Same bucket
if (card.bucket_id && c.bucket_id == card.bucket_id) score += 15;
// Same group
if (card.parent_id && c.parent_id == card.parent_id) score += 20;
// Word overlap
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 renderSplitPicks(cards, label) {
if (!cards.length) {
splitPicks.innerHTML = `<div class="px-3 py-4 text-center text-t-dim font-pixel text-[9px]">no matches</div>`;
return;
}
splitPicks.innerHTML =
`<div class="px-3 pt-2 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">${label}</span></div>` +
cards.map(c => `<div class="split-pick" data-split-card="${c.id}">
<span class="sp-dot" style="background:${c.color}"></span>
<span class="font-pixel text-[10px] truncate flex-1">${esc(c.title)}</span>
${c.bucket_name ? `<span class="text-[8px] text-t-dim font-pixel">${esc(c.bucket_name)}</span>` : ''}
</div>`).join('');
splitPicks.querySelectorAll('[data-split-card]').forEach(el => {
el.addEventListener('click', () => loadSplitCard(el.dataset.splitCard));
});
}
function searchAllCards(query) {
const q = query.toLowerCase().trim();
return state.cards
.filter(c => c.id != state.viewingCard?.id)
.map(c => {
const t = (c.title || '').toLowerCase();
const b = stripMd(c.body || '').toLowerCase();
let score = 0;
if (t.includes(q)) score += 50;
if (t.startsWith(q)) score += 20;
if (b.includes(q)) score += 10;
// fuzzy on title
const fm = fuzzyMatch(q, 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;
function loadSplitCard(id) {
const card = state.cards.find(c => c.id == id);
if (!card) return;
splitCardId = id;
splitPicks.innerHTML = `<div class="flex items-center border-b border-t-border">
<button id="split-back" class="flex items-center gap-1.5 flex-1 px-3 py-2 text-xs text-t-muted hover:text-t-accent hover:bg-t-raised transition-colors">
<i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">related cards</span>
</button>
<button id="split-promote" class="flex items-center gap-1.5 px-3 py-2 text-xs text-t-muted hover:text-t-accent hover:bg-t-raised transition-colors" title="open as main card">
<i class="ph ph-arrow-line-left text-sm"></i><span class="font-pixel text-[10px]">focus</span>
</button>
</div>`;
splitPicks.querySelector('#split-back').addEventListener('click', openSplitPane);
splitPicks.querySelector('#split-promote').addEventListener('click', () => promoteSplitCard(id));
splitSearch.value = '';
splitContent.innerHTML = `
<div class="card-bar w-16 mb-3 rounded-sm" style="background:${card.color}"></div>
<h2 class="font-pixel text-t-bright text-sm mb-3 leading-relaxed">${esc(card.title)}</h2>
<div class="prose text-xs text-t-text">${renderMd(card.body)}</div>
<div class="mt-6 pt-3 border-t border-t-border text-[10px] text-t-dim font-pixel">
> ${card.created_at}${card.bucket_name ? ' | ' + esc(card.bucket_name) : ''}
</div>`;
splitContent.querySelectorAll('.card-link[data-card-id]').forEach(link => {
link.addEventListener('click', () => loadSplitCard(link.dataset.cardId));
});
addCodeCopyButtons(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 = '';
splitContent.innerHTML = '';
const related = findRelatedCards(state.viewingCard);
renderSplitPicks(related, related.length ? 'related' : 'no related cards');
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();
if (!q) {
const related = findRelatedCards(state.viewingCard);
renderSplitPicks(related, 'related');
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;