+
${esc(stripMd(c.body))}
+
${(c.updated_at || c.created_at || '').substring(0, 10)}
${c.bucket_name ? `${esc(c.bucket_name)}` : ''}
@@ -117,6 +190,20 @@ export function renderBoard() {
bindCardEvents();
+ // Restore selection state
+ selectedIds.forEach(id => {
+ const el = board.querySelector(`[data-id="${id}"]`);
+ if (el) {
+ el.classList.add('card-selected');
+ if (!el.querySelector('.card-select-check')) {
+ const check = document.createElement('div');
+ check.className = 'card-select-check';
+ el.appendChild(check);
+ }
+ }
+ });
+ updateSelectBar();
+
// Footer count
const fc = $('footer-count');
if (fc) fc.textContent = `${cards.length} card${cards.length !== 1 ? 's' : ''} · ${state.cards.length} total`;
@@ -226,7 +313,8 @@ function bindCardEvents() {
});
}
- el.addEventListener('click', () => {
+ el.addEventListener('click', (e) => {
+ if (e.ctrlKey || e.metaKey) { e.preventDefault(); toggleSelect(id); return; }
if (clickTimer) return;
clickTimer = setTimeout(() => { clickTimer = null; window._openDetail(id); }, 200);
});
@@ -246,9 +334,17 @@ function bindCardEvents() {
el.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', id);
e.dataTransfer.effectAllowed = 'move';
- el.classList.add('card-dragging');
activeDragEl = el;
+ // Determine all dragged cards (selected or just this one)
+ const isMulti = selectedIds.has(String(id)) && selectedIds.size > 1;
+ const dragIds = isMulti ? [...selectedIds] : [String(id)];
+
+ // Ghost all dragged cards
+ dragIds.forEach(did => {
+ board.querySelector(`[data-id="${did}"]`)?.classList.add('card-dragging');
+ });
+
// Hide native drag image
const empty = document.createElement('div');
empty.style.width = '1px'; empty.style.height = '1px'; empty.style.opacity = '0.01';
@@ -256,19 +352,41 @@ function bindCardEvents() {
e.dataTransfer.setDragImage(empty, 0, 0);
setTimeout(() => empty.remove(), 0);
- // Create custom ghost
+ // Create stacked ghost
const rect = el.getBoundingClientRect();
- const ghost = el.cloneNode(true);
- ghost.style.cssText = `position:fixed;z-index:9998;width:${rect.width}px;pointer-events:none;opacity:0.5;transition:opacity 0.12s;`;
- ghost._offsetX = e.clientX - rect.left;
- ghost._offsetY = e.clientY - rect.top;
- ghost.style.left = (e.clientX - ghost._offsetX) + 'px';
- ghost.style.top = (e.clientY - ghost._offsetY) + 'px';
- document.body.appendChild(ghost);
- dragGhost = ghost;
+ const container = document.createElement('div');
+ container.style.cssText = `position:fixed;z-index:9998;pointer-events:none;transition:opacity 0.12s;opacity:0.5;`;
+ container._offsetX = e.clientX - rect.left;
+ container._offsetY = e.clientY - rect.top;
+
+ if (isMulti) {
+ // Stack effect: offset layers behind
+ const count = Math.min(dragIds.length, 4);
+ for (let i = count - 1; i >= 1; i--) {
+ const layer = document.createElement('div');
+ layer.style.cssText = `position:absolute;top:${i * 4}px;left:${i * 4}px;width:${rect.width}px;height:${rect.height}px;background:var(--t-surface);border:1px solid var(--t-border);border-radius:inherit;opacity:${0.3 + (count - i) * 0.15};`;
+ container.appendChild(layer);
+ }
+ // Badge
+ const badge = document.createElement('div');
+ badge.textContent = dragIds.length;
+ badge.style.cssText = `position:absolute;top:-6px;right:-6px;min-width:20px;height:20px;border-radius:10px;background:var(--t-accent);color:var(--t-bg);font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;padding:0 6px;z-index:5;font-family:var(--t-font-pixel,'Silkscreen',cursive);`;
+ container.appendChild(badge);
+ }
+
+ const clone = el.cloneNode(true);
+ clone.style.cssText = `position:relative;width:${rect.width}px;`;
+ clone.querySelector('.card-select-check')?.remove();
+ container.appendChild(clone);
+
+ container.style.left = (e.clientX - container._offsetX) + 'px';
+ container.style.top = (e.clientY - container._offsetY) + 'px';
+ document.body.appendChild(container);
+ dragGhost = container;
});
el.addEventListener('dragend', () => {
- el.classList.remove('card-dragging');
+ // Remove ghost class from all dragged cards
+ board.querySelectorAll('.card-dragging').forEach(c => c.classList.remove('card-dragging'));
activeDragEl = null;
if (dragGhost) { dragGhost.remove(); dragGhost = null; }
board.querySelectorAll('.card-drop-before,.card-drop-after,.card-drop-group').forEach(x =>
diff --git a/public/js/modules/buckets.js b/public/js/modules/buckets.js
index d108531..2986279 100644
--- a/public/js/modules/buckets.js
+++ b/public/js/modules/buckets.js
@@ -147,9 +147,11 @@ function renderBar() {
const tab = (active) => `bucket-tab ${active ? 'active' : ''} flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors shrink-0`;
bar.innerHTML = `
-
-
-
+
+
+
+
+
${state.buckets.map(b => {
const c = b.color || '#64748b';
const active = state.activeBucket == b.id;
@@ -223,12 +225,23 @@ function renderBar() {
return;
}
- // Card drop on bucket
+ // Card drop on bucket — move dragged card + all selected
const cardId = e.dataTransfer.getData('text/plain');
if (!cardId) return;
- const bucketId = bid === 'none' ? null : ((bid === 'all' || bid === 'current') ? undefined : bid);
+ const selected = window._getSelectedIds?.() || [];
+ const ids = selected.includes(String(cardId)) ? selected : [String(cardId)];
+
+ if (bid === 'favorites') {
+ const { toggleFavorite, isFavorite } = await import('./state.js');
+ for (const id of ids) { if (!isFavorite(id)) toggleFavorite(id); }
+ window._clearSelection?.();
+ window._renderBoard();
+ return;
+ }
+ const bucketId = bid === 'none' ? null : (['all','current','journal'].includes(bid) ? undefined : bid);
if (bucketId === undefined) return;
- await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: bucketId, parent_id: null } });
+ await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucketId, parent_id: null } })));
+ window._clearSelection?.();
await window._reloadAll();
});
});
@@ -256,10 +269,13 @@ function renderBar() {
const cardId = e.dataTransfer.getData('text/plain');
if (!cardId) return;
e.preventDefault();
+ const selected = window._getSelectedIds?.() || [];
+ const ids = selected.includes(String(cardId)) ? selected : [String(cardId)];
const card = state.cards.find(c => String(c.id) === String(cardId));
const created = await api('api/buckets', { method: 'POST', body: { name: autoBucketNameFromCard(card) } });
if (!created?.id) return;
- await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: created.id, parent_id: null } });
+ await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: created.id, parent_id: null } })));
+ window._clearSelection?.();
await loadBuckets();
await window._reloadAll();
});
diff --git a/public/js/modules/context-menu.js b/public/js/modules/context-menu.js
index f2174af..19275be 100644
--- a/public/js/modules/context-menu.js
+++ b/public/js/modules/context-menu.js
@@ -1,7 +1,7 @@
// ── Context menu ──
import { $, esc, api } from './util.js';
-import { state, getChildren, CARD_COLORS } from './state.js';
+import { state, getChildren, isFavorite, toggleFavorite, CARD_COLORS } from './state.js';
import { showDialog } from './dialog.js';
import { pushCardEdit, pushCardDelete, pushBatch, snapshotCard } from './undo.js';
@@ -25,6 +25,7 @@ export function showContextMenu(x, y, card) {
+
@@ -72,6 +73,7 @@ export function showContextMenu(x, y, card) {
if (a === 'open') { hideContextMenu(); window._openDetail(card.id); }
else if (a === 'edit') { hideContextMenu(); window._openModal(card); }
else if (a === 'duplicate') { hideContextMenu(); await api('api/cards', { method: 'POST', body: { title: card.title + ' (copy)', body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } }); await window._reloadAll(); }
+ else if (a === 'favorite') { hideContextMenu(); toggleFavorite(card.id); window._renderBoard(); }
else if (a === 'color') toggle(colors);
else if (a === 'bucket') toggle(buckets);
else if (a === 'group-members') toggle(members);
diff --git a/public/js/modules/detail.js b/public/js/modules/detail.js
index 9a395b9..8b50e73 100644
--- a/public/js/modules/detail.js
+++ b/public/js/modules/detail.js
@@ -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 = `
no matches
`;
+ return;
+ }
+ splitPicks.innerHTML =
+ `
${label}
` +
+ cards.map(c => `
+
+ ${esc(c.title)}
+ ${c.bucket_name ? `${esc(c.bucket_name)}` : ''}
+
`).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 = `
+
+
+
`;
+ splitPicks.querySelector('#split-back').addEventListener('click', openSplitPane);
+ 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));
+ });
+ 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;
diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js
index 6738830..6a3b63b 100644
--- a/public/js/modules/editor.js
+++ b/public/js/modules/editor.js
@@ -1,8 +1,8 @@
// ── Editor (card create/edit) ──
import { $, api, escRe, getCsrf } from './util.js';
-import { state, CARD_COLORS } from './state.js';
-import { renderMd } from './markdown.js';
+import { state, getChildren, CARD_COLORS } from './state.js';
+import { renderMd, addCodeCopyButtons } from './markdown.js';
import { pushCardEdit, snapshotCard } from './undo.js';
import { fuzzyMatch } from './util.js';
import { esc } from './util.js';
@@ -18,6 +18,85 @@ const presets = $('color-presets');
const linkAc = $('link-ac');
let linkAcVisible = false, linkAcItems = [], linkAcActive = 0;
+let morphGhost = null, morphTimer = null;
+
+function clearMorphGhost() {
+ if (morphTimer) { clearTimeout(morphTimer); morphTimer = null; }
+ if (morphGhost) { morphGhost.remove(); morphGhost = null; }
+}
+
+function findMorphTarget(cardId) {
+ const board = $('board');
+ if (!board || !cardId) return null;
+ const el = board.querySelector(`[data-id="${cardId}"]`);
+ if (el) return el;
+ const card = state.cards.find(c => c.id == cardId);
+ if (card?.parent_id) {
+ const p = board.querySelector(`[data-id="${card.parent_id}"]`);
+ if (p) return p;
+ }
+ const children = getChildren(cardId);
+ for (const ch of children) {
+ const ce = board.querySelector(`[data-id="${ch.id}"]`);
+ if (ce) return ce;
+ }
+ return null;
+}
+
+function startCloseMorph() {
+ const cardId = state.editingId;
+ if (!cardId) return;
+ if ($('search')?.classList.contains('open')) return;
+
+ const target = findMorphTarget(cardId);
+ if (!target) return;
+
+ const from = { left: 40, top: 40, width: window.innerWidth - 80, height: window.innerHeight - 80 };
+ const to = target.getBoundingClientRect();
+ if (from.width < 40 || from.height < 40 || to.width < 20 || to.height < 20) return;
+
+ const visible = to.bottom > 0 && to.top < window.innerHeight && to.right > 0 && to.left < window.innerWidth;
+ if (!visible) return;
+
+ clearMorphGhost();
+
+ const card = state.cards.find(c => c.id == cardId);
+ const targetCard = target.dataset.id ? state.cards.find(c => c.id == target.dataset.id) : card;
+ const frame = document.createElement('div');
+ frame.className = 'detail-close-frame';
+ 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;
+ 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);
+}
// ── Color presets ──
@@ -46,30 +125,184 @@ function selectColor(hex) {
export function openModal(card = null) {
state.editingId = card ? card.id : null;
$('modal-title').textContent = card ? 'edit_card' : 'new_card';
- inpTitle.value = card ? card.title : '';
+ const isJournal = !card && state.activeBucket === 'journal';
+ const today = new Date().toISOString().substring(0, 10);
+ inpTitle.value = card ? card.title : (isJournal ? today : '');
body.value = card ? card.body : '';
+ const virtualBuckets = ['all', 'none', 'current', 'favorites', 'journal'];
inpBucket.value = card?.bucket_id ? card.bucket_id
- : (state.activeBucket !== 'all' && state.activeBucket !== 'none' ? state.activeBucket : '');
+ : (!virtualBuckets.includes(state.activeBucket) ? state.activeBucket : '');
selectColor(card ? card.color : '#38bdf8');
state.previewVisible = true;
preview.classList.remove('hidden');
$('toggle-preview').classList.add('text-t-accent');
updatePreview();
modal.classList.add('open');
+ if (linesVisible) updateGutter();
+ currentLine = -1;
+ updateActiveLine();
inpTitle.focus();
}
-export function closeModal() {
+inpTitle.addEventListener('input', updatePreview);
+
+// ── Line numbers ──
+
+const gutter = $('editor-gutter');
+let linesVisible = localStorage.getItem('hyprcrd-lines') === '1';
+
+let currentLine = -1;
+let mirror = null;
+
+function getMirror() {
+ if (!mirror) {
+ mirror = document.createElement('div');
+ mirror.setAttribute('aria-hidden', 'true');
+ mirror.style.cssText = 'position:absolute;top:-9999px;left:-9999px;visibility:hidden;white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;box-sizing:border-box;';
+ document.body.appendChild(mirror);
+ }
+ // Sync all relevant styles from textarea
+ const cs = getComputedStyle(body);
+ mirror.style.font = cs.font;
+ mirror.style.lineHeight = cs.lineHeight;
+ mirror.style.letterSpacing = cs.letterSpacing;
+ mirror.style.tabSize = cs.tabSize;
+ mirror.style.paddingLeft = cs.paddingLeft;
+ mirror.style.paddingRight = cs.paddingRight;
+ mirror.style.borderLeft = cs.borderLeft;
+ mirror.style.borderRight = cs.borderRight;
+ mirror.style.width = body.offsetWidth + 'px';
+ return mirror;
+}
+
+function getCurrentLineNum() {
+ return body.value.substring(0, body.selectionStart).split('\n').length;
+}
+
+function updateGutter() {
+ if (!gutter || !linesVisible) return;
+ const m = getMirror();
+ const lines = body.value.split('\n');
+ const active = getCurrentLineNum();
+
+ // Build all lines as divs, then use offsetTop differences for heights
+ m.innerHTML = '';
+ const divs = [];
+ for (const line of lines) {
+ const d = document.createElement('div');
+ d.textContent = line || '\u200b';
+ m.appendChild(d);
+ divs.push(d);
+ }
+ // Sentinel div at end to get last line's height
+ const sentinel = document.createElement('div');
+ sentinel.textContent = '\u200b';
+ m.appendChild(sentinel);
+
+ let html = '';
+ for (let i = 0; i < divs.length; i++) {
+ const top = divs[i].offsetTop;
+ const nextTop = (i + 1 < divs.length) ? divs[i + 1].offsetTop : sentinel.offsetTop;
+ const h = nextTop - top;
+ html += `
${i + 1}
`;
+ }
+ gutter.innerHTML = html;
+ currentLine = active;
+}
+
+const lineHl = $('editor-line-hl');
+
+function updateActiveLine() {
+ const line = getCurrentLineNum();
+ if (line === currentLine) return;
+ currentLine = line;
+
+ // Gutter highlight
+ if (gutter && linesVisible) {
+ gutter.querySelector('.gutter-active')?.classList.remove('gutter-active');
+ const divs = gutter.children;
+ if (divs[line - 1]) divs[line - 1].classList.add('gutter-active');
+ }
+
+ // Line highlight overlay
+ updateLineHighlight();
+}
+
+function updateLineHighlight() {
+ if (!lineHl) return;
+ const line = getCurrentLineNum();
+
+ if (linesVisible && gutter?.children[line - 1]) {
+ const gutterDiv = gutter.children[line - 1];
+ const gutterRect = gutterDiv.getBoundingClientRect();
+ const wrapperRect = lineHl.parentElement.getBoundingClientRect();
+ lineHl.style.display = 'block';
+ lineHl.style.top = (gutterRect.top - wrapperRect.top) + 'px';
+ lineHl.style.height = gutterRect.height + 'px';
+ } else {
+ // Use mirror to measure correct position even with wrapping
+ const m = getMirror();
+ const lines = body.value.split('\n');
+ m.innerHTML = '';
+ const divs = [];
+ for (const l of lines) {
+ const d = document.createElement('div');
+ d.textContent = l || '\u200b';
+ m.appendChild(d);
+ divs.push(d);
+ }
+ const idx = line - 1;
+ if (!divs[idx]) { lineHl.style.display = 'none'; return; }
+ const top = divs[idx].offsetTop;
+ const h = (idx + 1 < divs.length) ? divs[idx + 1].offsetTop - top : divs[idx].offsetHeight;
+ const pad = parseFloat(getComputedStyle(body).paddingTop) || 16;
+ lineHl.style.display = 'block';
+ lineHl.style.top = (top + pad - body.scrollTop) + 'px';
+ lineHl.style.height = h + 'px';
+ }
+}
+
+function syncGutterScroll() {
+ if (gutter && linesVisible) gutter.scrollTop = body.scrollTop;
+ updateLineHighlight();
+}
+
+function toggleLineNumbers() {
+ linesVisible = !linesVisible;
+ localStorage.setItem('hyprcrd-lines', linesVisible ? '1' : '0');
+ gutter?.classList.toggle('hidden', !linesVisible);
+ $('toggle-lines')?.classList.toggle('text-t-accent', linesVisible);
+ if (linesVisible) updateGutter();
+}
+
+if (linesVisible && gutter) {
+ gutter.classList.remove('hidden');
+ $('toggle-lines')?.classList.add('text-t-accent');
+}
+
+$('toggle-lines')?.addEventListener('click', () => { toggleLineNumbers(); updateActiveLine(); });
+body.addEventListener('scroll', syncGutterScroll);
+body.addEventListener('input', () => { if (linesVisible) updateGutter(); currentLine = -1; updateActiveLine(); });
+body.addEventListener('click', updateActiveLine);
+body.addEventListener('keyup', updateActiveLine);
+window.addEventListener('resize', () => { if (linesVisible) updateGutter(); });
+
+export function closeModal({ animate = true } = {}) {
+ if (animate) startCloseMorph();
+ else clearMorphGhost();
modal.classList.remove('open');
state.editingId = null;
state.returnToGalleryAfterEdit = false;
}
function updatePreview() {
- if (state.previewVisible) previewContent.innerHTML = renderMd(body.value);
+ if (!state.previewVisible) return;
+ const title = inpTitle.value.trim();
+ previewContent.innerHTML = (title ? `
${esc(title)}
` : '') + renderMd(body.value);
+ addCodeCopyButtons(previewContent);
}
-export async function saveCard() {
+async function doSave(close, { animate = true } = {}) {
const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null };
if (!data.title.trim()) return;
const returnToGallery = state.returnToGalleryAfterEdit;
@@ -87,11 +320,27 @@ export async function saveCard() {
} else {
data.position = -1;
}
- await api('api/cards', { method: 'POST', body: data });
+ const created = await api('api/cards', { method: 'POST', body: data });
+ if (created?.id) {
+ if (!close) state.editingId = created.id;
+ state._lastSavedId = created.id;
+ }
}
- closeModal();
await window._reloadAll();
- if (returnToGallery) window._openGallery?.();
+ if (close) {
+ closeModal({ animate });
+ if (returnToGallery) window._openGallery?.();
+ }
+ return state.editingId || state._lastSavedId;
+}
+
+export async function saveCard() { return doSave(true); }
+export async function saveOnly() { return doSave(false); }
+export async function saveAndView() {
+ const id = state.editingId || null;
+ await doSave(true, { animate: false });
+ const viewId = id || state._lastSavedId;
+ if (viewId) window._openDetail(viewId);
}
// ── Markdown toolbar ──
@@ -114,9 +363,20 @@ $('md-toolbar')?.addEventListener('click', e => {
italic: () => insertMd('*', '*', 'italic'), strikethrough: () => insertMd('~~', '~~', 'text'),
code: () => insertMd('`', '`', 'code'), codeblock: () => insertMd('```\n', '\n```', 'code'),
ul: () => insertMd('- ', '', 'item'), ol: () => insertMd('1. ', '', 'item'),
+ tree: () => insertMd('```tree\n- ', '\n - child\n', 'parent'),
quote: () => insertMd('> ', '', 'quote'), todo: () => insertMd('- [ ] ', '', 'task'),
link: () => insertMd('[', '](url)', 'text'),
cardlink: () => { insertMd('[[', ']]', ''); showLinkAc(''); },
+ extract: async () => {
+ const sel = body.value.substring(body.selectionStart, body.selectionEnd);
+ if (!sel.trim()) return;
+ const title = sel.trim().split('\n')[0].replace(/^#+\s*/, '').replace(/^[-*]\s*/, '').trim();
+ await window._extractToCard(sel);
+ // Replace selection with a card link
+ const link = `[[${title}]]`;
+ body.setRangeText(link, body.selectionStart, body.selectionEnd, 'end');
+ updatePreview();
+ },
hr: () => insertMd('\n---\n'),
};
cmds[btn.dataset.md]?.();
@@ -131,7 +391,9 @@ $('toggle-preview')?.addEventListener('click', () => {
$('add-btn')?.addEventListener('click', () => openModal());
$('modal-cancel')?.addEventListener('click', closeModal);
-$('modal-save')?.addEventListener('click', saveCard);
+$('modal-save')?.addEventListener('click', saveOnly);
+$('modal-save-view')?.addEventListener('click', saveAndView);
+$('modal-save-close')?.addEventListener('click', saveCard);
// ── Link autocomplete ([[) ──
@@ -193,6 +455,53 @@ body.addEventListener('input', () => {
updatePreview();
});
+// ── Line helpers ──
+
+function getLineInfo() {
+ const val = body.value;
+ const before = val.substring(0, body.selectionStart);
+ const lineStart = before.lastIndexOf('\n') + 1;
+ const lineEnd = val.indexOf('\n', body.selectionStart);
+ return { lineStart, lineEnd: lineEnd === -1 ? val.length : lineEnd, line: val.substring(lineStart, lineEnd === -1 ? val.length : lineEnd) };
+}
+
+function moveLine(dir) {
+ const val = body.value;
+ const lines = val.split('\n');
+ const before = val.substring(0, body.selectionStart);
+ const lineIdx = before.split('\n').length - 1;
+ const swapIdx = lineIdx + dir;
+ if (swapIdx < 0 || swapIdx >= lines.length) return;
+ const cursorInLine = body.selectionStart - before.lastIndexOf('\n') - 1;
+ [lines[lineIdx], lines[swapIdx]] = [lines[swapIdx], lines[lineIdx]];
+ body.value = lines.join('\n');
+ // Restore cursor on the moved line
+ let pos = 0;
+ for (let i = 0; i < swapIdx; i++) pos += lines[i].length + 1;
+ if (dir === -1) { for (let i = 0; i < lineIdx; i++) pos = 0; pos = lines.slice(0, lineIdx + dir).join('\n').length + 1; }
+ else { pos = lines.slice(0, lineIdx + dir).join('\n').length + 1; }
+ body.selectionStart = body.selectionEnd = pos + Math.min(cursorInLine, lines[swapIdx].length);
+ updatePreview();
+ if (linesVisible) updateGutter();
+ currentLine = -1; updateActiveLine();
+}
+
+function duplicateLine(dir) {
+ const { lineStart, lineEnd, line } = getLineInfo();
+ const val = body.value;
+ const cursorOffset = body.selectionStart - lineStart;
+ if (dir === -1) {
+ body.value = val.substring(0, lineStart) + line + '\n' + val.substring(lineStart);
+ body.selectionStart = body.selectionEnd = lineStart + cursorOffset;
+ } else {
+ body.value = val.substring(0, lineEnd) + '\n' + line + val.substring(lineEnd);
+ body.selectionStart = body.selectionEnd = lineEnd + 1 + cursorOffset;
+ }
+ updatePreview();
+ if (linesVisible) updateGutter();
+ currentLine = -1; updateActiveLine();
+}
+
body.addEventListener('keydown', e => {
if (linkAcVisible) {
if (e.key === 'ArrowDown') { e.preventDefault(); linkAcActive = Math.min(linkAcActive + 1, linkAcItems.length - 1); }
@@ -202,6 +511,18 @@ body.addEventListener('keydown', e => {
linkAc.querySelectorAll('.ac-item').forEach((el, i) => el.classList.toggle('active', i === linkAcActive));
return;
}
+ // Alt+Arrow: move line
+ if (e.altKey && !e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
+ e.preventDefault();
+ moveLine(e.key === 'ArrowUp' ? -1 : 1);
+ return;
+ }
+ // Shift+Alt+Arrow: duplicate line
+ if (e.altKey && e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
+ e.preventDefault();
+ duplicateLine(e.key === 'ArrowUp' ? -1 : 1);
+ return;
+ }
if (e.key === 'Tab') { e.preventDefault(); insertMd(' '); }
});
diff --git a/public/js/modules/markdown.js b/public/js/modules/markdown.js
index 4ca0509..9083be1 100644
--- a/public/js/modules/markdown.js
+++ b/public/js/modules/markdown.js
@@ -11,14 +11,74 @@ const purifyConfig = {
ALLOW_DATA_ATTR: true,
};
+function normalizeListBlock(block) {
+ const lines = String(block || '').replace(/\r\n?/g, '\n').split('\n');
+ while (lines.length && !lines[0].trim()) lines.shift();
+ while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
+ if (!lines.length) return '';
+
+ const minIndent = lines
+ .filter(l => l.trim())
+ .map(l => (l.match(/^[ \t]*/) || [''])[0].replace(/\t/g, ' ').length)
+ .reduce((min, n) => Math.min(min, n), Infinity);
+
+ return lines
+ .map(l => {
+ const expanded = l.replace(/\t/g, ' ');
+ if (!expanded.trim()) return '';
+ return expanded.slice(Math.min(minIndent, expanded.length));
+ })
+ .join('\n');
+}
+
export function renderMd(src) {
- const linked = (src || '').replace(/\[\[([^\]]+)\]\]/g, (_, title) => {
+ let processed = String(src || '').replace(/\r\n?/g, '\n')
+ // Custom fenced list blocks: ```tree / ```bullets with or without closing ```
+ .replace(/```(tree|bullets)\s*\n([\s\S]*?)(?:```|$)/g, (_, kind, content) => {
+ const normalized = normalizeListBlock(content);
+ return `\n\n${normalized}\n`;
+ })
+ // Help parser: ensure a list can start after text without requiring manual blank line
+ .replace(/([^\n])\n(?=(?: {0,3}[-*+]\s| {0,3}\d+\.\s))/g, '$1\n\n')
+ // Preserve extra blank lines as
spacers (3+ newlines = 1+ extra blank lines)
+ .replace(/\n{3,}/g, m => '\n\n' + '
\n'.repeat(m.length - 2))
+ // Ensure hr rules have one clean blank line before and after, keep extra
beyond that
+ .replace(/
\n(---+|___+|\*\*\*+)\n/gm, '\n\n$1\n')
+ .replace(/(---+|___+|\*\*\*+)\n
/gm, '$1\n\n');
+
+ const linked = processed.replace(/\[\[([^\]]+)\]\]/g, (_, title) => {
const card = state.cards.find(c => c.title.toLowerCase() === title.toLowerCase());
if (card) return `
${esc(title)}`;
return `
${esc(title)}`;
});
- const raw = marked.parse(linked);
- return typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw, purifyConfig) : raw;
+
+ let html = marked.parse(linked);
+
+ // Convert list hints before sanitize (DOMPurify strips comments)
+ html = html.replace(/\s*
/g, '');
+ html = html.replace(/\s*/g, '');
+
+ html = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(html, purifyConfig) : html;
+ return html;
+}
+
+export function addCodeCopyButtons(container) {
+ if (!container) return;
+ container.querySelectorAll('pre > code').forEach(code => {
+ const pre = code.parentElement;
+ if (pre.querySelector('.code-copy')) return;
+ const btn = document.createElement('button');
+ btn.className = 'code-copy';
+ btn.type = 'button';
+ btn.innerHTML = '';
+ btn.addEventListener('click', async () => {
+ await navigator.clipboard.writeText(code.textContent);
+ btn.classList.add('copied');
+ btn.innerHTML = '';
+ setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = ''; }, 1500);
+ });
+ pre.appendChild(btn);
+ });
}
export function stripMd(src) {
diff --git a/public/js/modules/state.js b/public/js/modules/state.js
index 970c7be..a4f2007 100644
--- a/public/js/modules/state.js
+++ b/public/js/modules/state.js
@@ -59,3 +59,18 @@ export function markRecent(id) {
r.unshift(String(id));
localStorage.setItem('hyprcrd-recent', JSON.stringify(r.slice(0, 10)));
}
+
+// Favorites
+export function getFavorites() {
+ try { return JSON.parse(localStorage.getItem('hyprcrd-favorites') || '[]'); } catch { return []; }
+}
+export function toggleFavorite(id) {
+ const favs = getFavorites();
+ const sid = String(id);
+ const idx = favs.indexOf(sid);
+ if (idx !== -1) favs.splice(idx, 1);
+ else favs.push(sid);
+ localStorage.setItem('hyprcrd-favorites', JSON.stringify(favs));
+ return favs.includes(sid);
+}
+export function isFavorite(id) { return getFavorites().includes(String(id)); }
diff --git a/public/js/modules/theme.js b/public/js/modules/theme.js
index 766082a..77ef362 100644
--- a/public/js/modules/theme.js
+++ b/public/js/modules/theme.js
@@ -12,7 +12,7 @@ if (list) {
btn.dataset.themeDot = key;
btn.innerHTML = `
${t.label}`;
- btn.onclick = () => window.applyTheme(key);
+ btn.onclick = () => { window.applyTheme(key); window._renderBoard?.(); };
list.appendChild(btn);
}
}