From 9018f09351ac3339b097a0934802bf6da424ec05 Mon Sep 17 00:00:00 2001
From: th
Date: Tue, 7 Apr 2026 22:42:08 +0200
Subject: [PATCH] fix reorder in virtual buckets, split view tags,
editor/preview cleanup
- Fix card drag reorder in favorites/journal/current (filter logic
now mirrors renderBoard)
- Split view shows tag-matched cards as own group alongside related
- Remove extract/tree-view buttons from detail preview (edit-only)
- Context menu tag input: Enter assigns tag directly
- Tag autocomplete closes on ESC/click-outside globally
- Close modal cleans up both autocomplete popups
Co-Authored-By: Claude Opus 4.6 (1M context)
---
public/index.php | 6 ---
public/js/modules/board.js | 22 ++++++---
public/js/modules/context-menu.js | 19 ++++++++
public/js/modules/detail.js | 75 +++++++++++++++++++------------
public/js/modules/editor.js | 9 ++++
5 files changed, 90 insertions(+), 41 deletions(-)
diff --git a/public/index.php b/public/index.php
index fa87a79..88ef4ed 100644
--- a/public/index.php
+++ b/public/index.php
@@ -720,12 +720,6 @@ if (str_starts_with($route, '/api/')) {
-
-
diff --git a/public/js/modules/board.js b/public/js/modules/board.js
index f404225..7d89ab5 100644
--- a/public/js/modules/board.js
+++ b/public/js/modules/board.js
@@ -448,18 +448,26 @@ async function handleCardDrop(e, el, targetId) {
return;
}
- // Compute new order
+ // Compute new order — must mirror renderBoard's filtering
let filtered;
if (state.activeGroup) {
const parent = state.cards.find(c => c.id == state.activeGroup);
filtered = parent ? sortedCards([parent, ...getChildren(parent.id)]) : [];
} else {
- filtered = sortedCards(state.cards.filter(c => {
- if (c.parent_id) return false;
- if (state.activeBucket === 'none') return !c.bucket_id;
- if (state.activeBucket !== 'all') return c.bucket_id == state.activeBucket;
- return true;
- }));
+ const root = state.cards.filter(c => !c.parent_id);
+ if (state.activeBucket === 'favorites') {
+ const favs = getFavorites();
+ filtered = sortedCards(root.filter(c => favs.includes(String(c.id))));
+ } else if (state.activeBucket === 'journal') {
+ filtered = root.filter(c => /^\d{4}-\d{2}-\d{2}/.test(c.title));
+ filtered.sort((a, b) => b.title.localeCompare(a.title));
+ } else if (state.activeBucket === 'none') {
+ filtered = sortedCards(root.filter(c => !c.bucket_id && !/^\d{4}-\d{2}-\d{2}/.test(c.title)));
+ } else if (state.activeBucket !== 'all' && state.activeBucket !== 'current') {
+ filtered = sortedCards(root.filter(c => c.bucket_id == state.activeBucket));
+ } else {
+ filtered = sortedCards(root);
+ }
}
const ids = filtered.map(c => String(c.id)).filter(i => i !== String(dragId));
ids.splice(wasBefore ? ids.indexOf(String(targetId)) : ids.indexOf(String(targetId)) + 1, 0, String(dragId));
diff --git a/public/js/modules/context-menu.js b/public/js/modules/context-menu.js
index 6958fb9..6a8a39f 100644
--- a/public/js/modules/context-menu.js
+++ b/public/js/modules/context-menu.js
@@ -122,6 +122,25 @@ export function showContextMenu(x, y, card) {
renderCtxTags();
tagInput.value = '';
tagInput.addEventListener('input', () => renderCtxTags(tagInput.value));
+ tagInput.addEventListener('keydown', async e => {
+ if (e.key !== 'Enter') return;
+ e.preventDefault();
+ const raw = tagInput.value.replace(/^#/, '').trim().toLowerCase();
+ if (!raw) return;
+ const tag = raw;
+ if (!cardTags.includes(tag)) {
+ const newBody = (card.body || '').trimEnd() + ' #' + tag;
+ card.body = newBody;
+ cardTags.push(tag);
+ if (!allTags.includes(tag)) { allTags.push(tag); allTags.sort(); }
+ await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } });
+ const c = state.cards.find(x => x.id == card.id);
+ if (c) { c.body = newBody; c.tags = cardTags.join(','); }
+ window._renderBoard();
+ }
+ tagInput.value = '';
+ renderCtxTags('');
+ });
tags.classList.add('hidden');
menu.style.left = Math.min(x, innerWidth - 188) + 'px';
diff --git a/public/js/modules/detail.js b/public/js/modules/detail.js
index afe0f54..6a2d789 100644
--- a/public/js/modules/detail.js
+++ b/public/js/modules/detail.js
@@ -613,20 +613,6 @@ document.addEventListener('keydown', e => {
// 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');
@@ -644,13 +630,29 @@ 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();
- // 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äöüß]+/)
@@ -664,16 +666,11 @@ function findRelatedCards(card) {
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;
@@ -687,19 +684,34 @@ function findRelatedCards(card) {
.map(s => s.card);
}
-function renderSplitPicks(cards, label) {
- if (!cards.length) {
- splitPicks.innerHTML = ` no matches `;
- return;
- }
- splitPicks.innerHTML =
- `${label} ` +
+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 = `no matches `;
+ 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 = `no matches `;
+ return;
+ }
+ splitPicks.innerHTML = html;
splitPicks.querySelectorAll('[data-split-card]').forEach(el => {
el.addEventListener('click', () => loadSplitCard(el.dataset.splitCard));
});
@@ -768,8 +780,15 @@ function openSplitPane() {
splitPane.classList.remove('hidden');
splitSearch.value = '';
splitContent.innerHTML = '';
+ const tagged = findTaggedCards(state.viewingCard);
const related = findRelatedCards(state.viewingCard);
- renderSplitPicks(related, related.length ? 'related' : 'no related cards');
+ // 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();
}
diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js
index db3dace..e91142c 100644
--- a/public/js/modules/editor.js
+++ b/public/js/modules/editor.js
@@ -294,6 +294,8 @@ window.addEventListener('resize', () => { if (linesVisible) updateGutter(); });
export function closeModal({ animate = true } = {}) {
if (animate) startCloseMorph();
else clearMorphGhost();
+ hideLinkAc();
+ hideTagAc();
modal.classList.remove('open');
state.editingId = null;
state.returnToGalleryAfterEdit = false;
@@ -507,6 +509,13 @@ function showTagAc(query) {
function hideTagAc() { tagAc.classList.add('hidden'); tagAcVisible = false; }
+document.addEventListener('click', e => {
+ if (tagAcVisible && !tagAc.contains(e.target) && e.target !== body) hideTagAc();
+});
+document.addEventListener('keydown', e => {
+ if (tagAcVisible && e.key === 'Escape') { hideTagAc(); }
+});
+
function completeTagAc(idx) {
const tag = tagAcItems[idx];
if (!tag) return;
|