From a9603aa0e8a77665c516b9baac7028f8fd96b94d Mon Sep 17 00:00:00 2001 From: th Date: Tue, 7 Apr 2026 17:47:09 +0200 Subject: [PATCH] bak --- public/css/style.css | 132 ++++++++++-- public/index.php | 112 ++++++++-- public/js/app.js | 35 ++- public/js/modules/board.js | 164 ++++++++++++-- public/js/modules/buckets.js | 30 ++- public/js/modules/context-menu.js | 4 +- public/js/modules/detail.js | 200 ++++++++++++++++- public/js/modules/editor.js | 343 +++++++++++++++++++++++++++++- public/js/modules/markdown.js | 66 +++++- public/js/modules/state.js | 15 ++ public/js/modules/theme.js | 2 +- 11 files changed, 1010 insertions(+), 93 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 87fbafe..02a1c09 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -30,9 +30,10 @@ body::after { @keyframes blink { 50% { opacity: 0; } } /* Line clamp */ -.line-clamp-2, .line-clamp-3 { display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; } +.line-clamp-2, .line-clamp-3, .line-clamp-5 { display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; } .line-clamp-2 { -webkit-line-clamp: 2; } .line-clamp-3 { -webkit-line-clamp: 3; } +.line-clamp-5 { -webkit-line-clamp: 5; } /* Cards */ .card-bar { image-rendering: pixelated; height: 3px; } @@ -44,13 +45,19 @@ body::after { background-repeat: no-repeat; overflow: hidden; } +.card-has-cover h3, +.card-has-cover p, +.card-has-cover .font-pixel, +.card-has-cover i { + text-shadow: 0 0 8px var(--t-bg), 0 0 16px var(--t-bg), 0 1px 3px var(--t-bg); +} .card-group.card-has-cover { overflow: visible; } .card-cover-overlay { position: absolute; inset: 0; - background: linear-gradient(180deg, rgba(15, 23, 42, 0.22) 0%, rgba(15, 23, 42, 0.7) 62%, rgba(15, 23, 42, 0.86) 100%); + background: var(--t-cover-gradient, linear-gradient(180deg, rgba(15, 23, 42, 0.22) 0%, rgba(15, 23, 42, 0.7) 62%, rgba(15, 23, 42, 0.86) 100%)); pointer-events: none; z-index: 0; } @@ -128,6 +135,18 @@ body::after { background-repeat: no-repeat; } .card-glow:hover { box-shadow: 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); } +.card-selected { outline: 2px solid var(--t-accent) !important; outline-offset: -2px; } +.card-select-check { + position: absolute; top: 6px; left: 6px; width: 18px; height: 18px; + border-radius: 50%; background: var(--t-accent); z-index: 6; + display: flex; align-items: center; justify-content: center; + pointer-events: none; +} +.card-select-check::after { + content: ''; width: 5px; height: 9px; + border: solid var(--t-bg); border-width: 0 2px 2px 0; + transform: rotate(45deg); margin-top: -2px; +} .card-dragging { opacity: 0.15; transform: scale(0.95); } /* Drop indicators — thick glowing bar */ @@ -215,11 +234,26 @@ body::after { transition: transform 0.2s ease, opacity 0.2s ease; } +/* Split panes */ +.split-pick { + padding: 6px 12px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--t-text); + transition: background 0.1s; +} +.split-pick:hover, .split-pick.active { background: var(--t-raised); } +.split-pick .sp-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } + /* Zen mode */ html.detail-zen-open, body.detail-zen-open { overflow: hidden; } #detail.zen > div { inset: 0 !important; border: none !important; border-radius: 0; overflow-y: auto; } -#detail.zen #detail-sidebar { display: none !important; } +#detail.zen #detail-sidebar, +#detail.zen #split-right { display: none !important; } #detail.zen .card-bar { display: none; } #detail.zen #detail-scroll { max-width: 680px; @@ -232,14 +266,18 @@ body.detail-zen-open { overflow: hidden; } #detail.zen #detail-scroll #detail-meta { display: none; } #detail.zen #detail-backlinks { display: none; } #detail.zen #detail-controls { - position: fixed !important; - top: 1rem !important; - right: 1rem !important; + position: fixed; + top: 0; left: 0; right: 0; z-index: 120; + border: none; + background: transparent; + justify-content: flex-end; + padding: 1rem; opacity: 0.15; transition: opacity 0.2s; } #detail.zen #detail-controls:hover { opacity: 1; } +#detail.zen #split-right-btn { display: none; } /* Detail close morph */ .detail-close-frame { @@ -359,6 +397,8 @@ body.detail-image-fs-open { overflow: hidden; } /* Editor */ #editor-body { tab-size: 4; resize: none; } #editor-body:focus { outline: none; } +#editor-gutter div { padding-right: 2px; box-sizing: border-box; } +.gutter-active { color: var(--t-accent) !important; font-weight: 700; background: var(--t-raised); } /* Search */ #search { backdrop-filter: blur(4px); } @@ -400,21 +440,79 @@ body.detail-image-fs-open { overflow: hidden; } #link-ac .ac-item .ac-bucket { font-size: 9px; color: var(--t-dim); margin-left: 6px; } /* Prose (markdown) */ -.prose { line-height: 1.7; } -.prose h1, .prose h2, .prose h3, .prose h4 { color: var(--t-bright); font-family: var(--t-font-pixel, 'Silkscreen', cursive); margin: 1.25em 0 0.5em; } -.prose h1 { font-size: 1.4em; } -.prose h2 { font-size: 1.15em; } -.prose h3 { font-size: 1em; } -.prose p { margin: 0.6em 0; } -.prose a { color: var(--t-accent); text-decoration: underline; } +.prose { line-height: 1.75; } +.prose h1, .prose h2, .prose h3, .prose h4 { color: var(--t-bright); font-family: var(--t-font-pixel, 'Silkscreen', cursive); margin: 1.5em 0 0.6em; font-weight: 700; } +.prose h1 { font-size: 1.8em; padding-bottom: 0.3em; border-bottom: 1px solid var(--t-border); } +.prose h2 { font-size: 1.4em; } +.prose h3 { font-size: 1.15em; } +.prose h4 { font-size: 1em; color: var(--t-muted); } +.prose p { margin: 0.75em 0; min-height: 1.2em; } +.prose a { color: var(--t-accent); text-decoration: underline; text-underline-offset: 2px; } .prose strong { color: var(--t-bright); font-weight: 700; } .prose em { font-style: italic; } .prose code { background: var(--t-bg); border: 1px solid var(--t-border); padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.88em; } -.prose pre { background: var(--t-bg); border: 1px solid var(--t-border); padding: 0.8em 1em; overflow-x: auto; margin: 0.8em 0; border-radius: 3px; } +.prose pre { background: var(--t-bg); border: 1px solid var(--t-border); padding: 0.8em 1em; overflow-x: auto; margin: 0.8em 0; border-radius: 3px; position: relative; } .prose pre code { background: none; border: none; padding: 0; font-size: 0.85em; } -.prose ul, .prose ol { padding-left: 1.5em; margin: 0.6em 0; } -.prose li { margin: 0.25em 0; } -.prose li::marker { color: var(--t-dim); } +.prose pre .code-copy { + position: absolute; top: 4px; right: 4px; + background: var(--t-surface); border: 1px solid var(--t-border); + color: var(--t-dim); cursor: pointer; + width: 26px; height: 26px; border-radius: 3px; + display: flex; align-items: center; justify-content: center; + opacity: 0; transition: opacity 0.15s, color 0.15s; + font-size: 13px; padding: 0; +} +.prose pre:hover .code-copy { opacity: 1; } +.prose pre .code-copy:hover { color: var(--t-accent); border-color: var(--t-accent); } +.prose pre .code-copy.copied { color: var(--t-accent); opacity: 1; } + +/* Lists — proper bullets */ +.prose ul { list-style: disc; padding-left: 1.6em; margin: 0.75em 0; } +.prose ul ul { list-style: circle; } +.prose ul ul ul { list-style: square; } +.prose ol { list-style: decimal; padding-left: 1.6em; margin: 0.75em 0; } +.prose li { margin: 0.35em 0; padding-left: 0.3em; } +.prose li::marker { color: var(--t-accent); } +.prose li > ul, .prose li > ol { margin: 0.2em 0; } + +/* Tree mode for lists — works on .prose.tree (global) or .tree-list (per-list) */ +.prose.tree ul, .prose .tree-list, .prose .tree-list ul { list-style: none; padding-left: 0; } +.prose.tree ul ul, .prose .tree-list ul { padding-left: 1.4em; } +.prose.tree li, .prose .tree-list li { + position: relative; + padding-left: 1.5em; + margin: 0.15em 0; +} +.prose.tree li::before, .prose .tree-list li::before { + content: ''; + position: absolute; + left: 0.25em; + top: 0; + bottom: 0; + width: 2px; + background: var(--t-dim); + border-radius: 1px; +} +.prose.tree li::after, .prose .tree-list li::after { + content: ''; + position: absolute; + left: 0.25em; + top: 0.7em; + width: 1em; + height: 2px; + background: var(--t-dim); + border-radius: 1px; +} +.prose.tree li:last-child::before, .prose .tree-list li:last-child::before { + bottom: calc(100% - 0.7em); +} +.prose.tree li::marker, .prose .tree-list li::marker { color: transparent; font-size: 0; } + +/* Explicit bullet-list keeps bullets even when global tree is on */ +.prose.tree .bullet-list, .prose.tree .bullet-list ul { list-style: disc; padding-left: 1.6em; } +.prose.tree .bullet-list li { padding-left: 0.3em; } +.prose.tree .bullet-list li::before, .prose.tree .bullet-list li::after { display: none; } +.prose.tree .bullet-list li::marker { color: var(--t-accent); font-size: inherit; } .prose blockquote { border-left: 3px solid var(--t-accent); padding-left: 1em; margin: 0.8em 0; color: var(--t-muted); } .prose hr { border: none; border-top: 1px solid var(--t-border); margin: 1.2em 0; } /* Todo checkboxes */ diff --git a/public/index.php b/public/index.php index 472fa9c..a2d3776 100644 --- a/public/index.php +++ b/public/index.php @@ -275,20 +275,43 @@ if (str_starts_with($route, '/api/')) { exit; } - // Export — ZIP with JSON dump + uploads + // Export — full or selective if ($route === '/api/export' && $method === 'GET') { - $cards = $db->query('SELECT * FROM cards ORDER BY id')->fetchAll(); - $buckets = $db->query('SELECT * FROM buckets ORDER BY id')->fetchAll(); + $ids = isset($_GET['ids']) ? array_filter(array_map('intval', explode(',', $_GET['ids']))) : null; + if ($ids) { + $ph = implode(',', array_fill(0, count($ids), '?')); + $cards = $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id")->execute($ids) ? $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id") : null; + // Re-query properly + $stmt = $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id"); + $stmt->execute($ids); + $cards = $stmt->fetchAll(); + $bucketIds = array_unique(array_filter(array_column($cards, 'bucket_id'))); + $buckets = []; + if ($bucketIds) { + $bph = implode(',', array_fill(0, count($bucketIds), '?')); + $bstmt = $db->prepare("SELECT * FROM buckets WHERE id IN ($bph)"); + $bstmt->execute(array_values($bucketIds)); + $buckets = $bstmt->fetchAll(); + } + } else { + $cards = $db->query('SELECT * FROM cards ORDER BY id')->fetchAll(); + $buckets = $db->query('SELECT * FROM buckets ORDER BY id')->fetchAll(); + } $tmp = tempnam(sys_get_temp_dir(), 'hyprcrd_'); $zip = new ZipArchive(); $zip->open($tmp, ZipArchive::OVERWRITE); - $zip->addFromString('data.json', json_encode(['version' => 1, 'buckets' => $buckets, 'cards' => $cards], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + $zip->addFromString('data.json', json_encode(['version' => 1, 'buckets' => $buckets, 'cards' => $cards, 'selective' => !!$ids], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + // Only include referenced uploads $uploadDir = __DIR__ . '/uploads'; + $allBodies = implode("\n", array_column($cards, 'body')); if (is_dir($uploadDir)) { foreach (glob($uploadDir . '/*') as $f) { - if (is_file($f)) $zip->addFile($f, 'uploads/' . basename($f)); + $name = basename($f); + if (is_file($f) && (!$ids || str_contains($allBodies, $name))) { + $zip->addFile($f, 'uploads/' . $name); + } } } $zip->close(); @@ -328,14 +351,23 @@ if (str_starts_with($route, '/api/')) { echo json_encode(['error' => 'invalid data.json']); exit; } - // Clear existing data - $db->exec('DELETE FROM cards'); - $db->exec('DELETE FROM buckets'); + $merge = !empty($data['selective']) || !empty($_GET['merge']); - // Import buckets + if (!$merge) { + $db->exec('DELETE FROM cards'); + $db->exec('DELETE FROM buckets'); + } + + // Import buckets (merge: match by name, create if missing) $bucketMap = []; foreach ($data['buckets'] as $b) { $oldId = $b['id']; + if ($merge) { + $existing = $db->prepare('SELECT id FROM buckets WHERE name = :name'); + $existing->execute(['name' => $b['name']]); + $found = $existing->fetchColumn(); + if ($found) { $bucketMap[$oldId] = $found; continue; } + } $stmt = $db->prepare('INSERT INTO buckets (name, position, created_at) VALUES (:name, :position, :created_at)'); $stmt->execute(['name' => $b['name'], 'position' => $b['position'] ?? 0, 'created_at' => $b['created_at'] ?? date('Y-m-d H:i:s')]); $bucketMap[$oldId] = $db->lastInsertId(); @@ -397,11 +429,12 @@ if (str_starts_with($route, '/api/')) { - +
@@ -573,22 +606,32 @@ if (str_starts_with($route, '/api/')) {
-
+
+ + +
+
-

+

+ +
@@ -618,6 +671,17 @@ if (str_starts_with($route, '/api/')) { + + +
@@ -654,6 +720,7 @@ if (str_starts_with($route, '/api/')) {
+ @@ -661,11 +728,18 @@ if (str_starts_with($route, '/api/')) { +
+
+
- + +
+ + +
diff --git a/public/js/app.js b/public/js/app.js index 3dd032a..1d781ce 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -7,9 +7,9 @@ import { state, getGroupCards } from './modules/state.js'; import { showDialog, isDialogOpen } from './modules/dialog.js'; import './modules/theme.js'; import { loadBuckets } from './modules/buckets.js'; -import { renderBoard, updateSortUI } from './modules/board.js'; +import { renderBoard, updateSortUI, getSelectedIds, clearSelection } from './modules/board.js'; import { openDetail, closeDetail, showDetailCard, ungroupCard } from './modules/detail.js'; -import { openModal, closeModal, saveCard } from './modules/editor.js'; +import { openModal, closeModal, saveCard, saveOnly } from './modules/editor.js'; import { openSearch, closeSearch } from './modules/search.js'; import { showContextMenu } from './modules/context-menu.js'; import { undo, canUndo } from './modules/undo.js'; @@ -26,8 +26,24 @@ window._closeTasks = closeTasks; window._renderBoard = renderBoard; window._showContextMenu = showContextMenu; window._ungroupCard = ungroupCard; +window._getSelectedIds = getSelectedIds; +window._clearSelection = clearSelection; window._refreshTasks = refreshTasksView; window._reloadAll = async () => { state.cards = await api('api/cards'); renderBoard(); }; +window._extractToCard = async (text) => { + if (!text?.trim()) return; + const lines = text.trim().split('\n'); + const title = lines[0].replace(/^#+\s*/, '').replace(/^\*+/, '').replace(/^-\s*/, '').trim() || 'Extracted'; + const body = text.trim(); + // Inherit context from the source card (viewing or editing) + const source = state.viewingCard || (state.editingId ? state.cards.find(c => c.id == state.editingId) : null); + const data = { title, body, color: source?.color || '#38bdf8', position: -1 }; + if (source?.bucket_id) data.bucket_id = source.bucket_id; + if (source?.parent_id) data.parent_id = source.parent_id; + else if (source && state.cards.some(c => c.parent_id == source.id)) data.parent_id = source.id; + await api('api/cards', { method: 'POST', body: data }); + await window._reloadAll(); +}; // ── Data loading ── @@ -46,11 +62,18 @@ document.getElementById('import-btn')?.addEventListener('click', () => importFil importFile?.addEventListener('change', async e => { const file = e.target.files?.[0]; if (!file) return; - const ok = await showDialog({ title: 'import_data', message: 'Alle bestehenden Daten werden ersetzt. Fortfahren?', danger: true, okLabel: 'import' }); - if (!ok) { e.target.value = ''; return; } + + // Check if it's a selective export (merge) or ask for full replace const form = new FormData(); form.append('file', file); + const mode = await showDialog({ title: 'import_data', message: 'Wie importieren?', okLabel: 'merge (hinzufuegen)', cancelLabel: 'replace (alles ersetzen)' }); + // mode: true = merge, null = replace + if (mode === null) { + const sure = await showDialog({ title: 'replace_all', message: 'Alle bestehenden Daten werden ersetzt!', danger: true, okLabel: 'replace' }); + if (!sure) { e.target.value = ''; return; } + } try { - const res = await fetch('api/import', { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } }); + const url = mode ? 'api/import?merge=1' : 'api/import'; + const res = await fetch(url, { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } }); const data = JSON.parse(await res.text()); if (data.error) throw new Error(data.error); await loadBuckets(); state.cards = await api('api/cards'); renderBoard(); @@ -115,7 +138,7 @@ document.addEventListener('keydown', e => { else if (document.getElementById('gallery')?.classList.contains('open')) closeGallery(); return; } - if (e.key === 's' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); saveCard(); return; } + if (e.key === 's' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); e.shiftKey ? saveCard() : saveOnly(); return; } if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !modal.classList.contains('open') && canUndo()) { e.preventDefault(); undo(); return; } if (e.key === '/' && ![modal, detail, search, tasks].some(el => el?.classList.contains('open')) && document.activeElement === document.body) { e.preventDefault(); openSearch(); } diff --git a/public/js/modules/board.js b/public/js/modules/board.js index bf6d5a3..2f84e81 100644 --- a/public/js/modules/board.js +++ b/public/js/modules/board.js @@ -1,7 +1,7 @@ // ── Board rendering & card interactions ── import { $, esc, api } from './util.js'; -import { state, sortedCards, getChildren, getRecent, SORT_MODES, SORT_ICONS, CARD_COLORS } from './state.js'; +import { state, sortedCards, getChildren, getRecent, getFavorites, isFavorite, SORT_MODES, SORT_ICONS, CARD_COLORS } from './state.js'; import { stripMd, getTodos, firstImage, leadingImage } from './markdown.js'; import { pushReorder, pushCardEdit, pushBatch, snapshotCard } from './undo.js'; @@ -9,6 +9,58 @@ const board = $('board'); let activeDragEl = null; let dragGhost = null; +// ── Multi-select ── +const selectedIds = new Set(); +const selectBar = $('select-bar'); +const selectCount = $('select-count'); + +function updateSelectBar() { + if (!selectBar) return; + if (selectedIds.size) { + selectBar.classList.remove('hidden'); + selectCount.textContent = `${selectedIds.size} selected`; + } else { + selectBar.classList.add('hidden'); + } +} + +function toggleSelect(id) { + const sid = String(id); + const el = board.querySelector(`[data-id="${id}"]`); + if (selectedIds.has(sid)) { + selectedIds.delete(sid); + el?.classList.remove('card-selected'); + el?.querySelector('.card-select-check')?.remove(); + } else { + selectedIds.add(sid); + el?.classList.add('card-selected'); + if (el && !el.querySelector('.card-select-check')) { + const check = document.createElement('div'); + check.className = 'card-select-check'; + el.appendChild(check); + } + } + updateSelectBar(); +} + +function clearSelection() { + selectedIds.clear(); + board.querySelectorAll('.card-selected').forEach(el => { + el.classList.remove('card-selected'); + el.querySelector('.card-select-check')?.remove(); + }); + updateSelectBar(); +} + +export function getSelectedIds() { return [...selectedIds]; } +export { clearSelection }; + +$('select-export')?.addEventListener('click', () => { + if (!selectedIds.size) return; + window.location.href = 'api/export?ids=' + [...selectedIds].join(','); +}); +$('select-clear')?.addEventListener('click', clearSelection); + // Custom drag ghost that fades near header document.addEventListener('dragover', e => { if (!dragGhost) return; @@ -29,8 +81,11 @@ document.addEventListener('dragover', e => { // ── Sort toggle ── export function updateSortUI() { + const btn = $('sort-toggle'); const label = $('sort-label'); const icon = document.querySelector('#sort-toggle i'); + const fixedSort = state.activeBucket === 'current' || state.activeBucket === 'journal'; + if (btn) btn.style.display = fixedSort ? 'none' : ''; if (label) label.textContent = state.sortMode; if (icon) icon.className = `ph ${SORT_ICONS[state.sortMode]} text-xs`; } @@ -50,20 +105,37 @@ export function renderBoard() { const parent = state.cards.find(c => c.id == state.activeGroup); cards = parent ? [parent, ...getChildren(parent.id)] : []; } else { - cards = state.cards.filter(c => !c.parent_id); if (state.activeBucket === 'current') { + // Current shows ALL recently touched cards, including group children const recent = getRecent(); const now = Date.now(); - const freshMs = 48 * 60 * 60 * 1000; // 48h - cards = cards.filter(c => { + const freshMs = 48 * 60 * 60 * 1000; + cards = state.cards.filter(c => { if (recent.includes(String(c.id))) return true; const updated = new Date(c.updated_at || c.created_at || 0).getTime(); return (now - updated) < freshMs; }); - } else if (state.activeBucket === 'none') cards = cards.filter(c => !c.bucket_id); - else if (state.activeBucket !== 'all') cards = cards.filter(c => c.bucket_id == state.activeBucket); + cards.sort((a, b) => (b.updated_at || b.created_at || '').localeCompare(a.updated_at || a.created_at || '')); + } else { + cards = state.cards.filter(c => !c.parent_id); + if (state.activeBucket === 'favorites') { + const favs = getFavorites(); + cards = cards.filter(c => favs.includes(String(c.id))); + } else if (state.activeBucket === 'journal') { + cards = cards.filter(c => /^\d{4}-\d{2}-\d{2}/.test(c.title)); + cards.sort((a, b) => b.title.localeCompare(a.title)); + } else if (state.activeBucket === 'none') { + cards = cards.filter(c => !c.bucket_id && !/^\d{4}-\d{2}-\d{2}/.test(c.title)); + } else if (state.activeBucket !== 'all') { + cards = cards.filter(c => c.bucket_id == state.activeBucket); + } + } } - cards = sortedCards(cards); + // Current and journal have fixed sort order, skip global sort + if (state.activeBucket !== 'current' && state.activeBucket !== 'journal') { + cards = sortedCards(cards); + } + updateSortUI(); window._refreshTasks?.(); hideFan(); @@ -97,17 +169,18 @@ export function renderBoard() {
${isGroup ? `
${children.length + 1}
` : ''} ${thumbImg ? `
` : ''} -
+
+ ${isFavorite(c.id) ? `` : ''} ${todos ? `` : ''} -

${esc(c.title)}

+

${esc(c.title)}

${todos ? `
${todos.done}/${todos.total}
` : ''} -

${esc(stripMd(c.body))}

-
+

${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); } }