Files
hyprcrd/public/js/modules/editor.js
T
thandClaude Opus 4.6 d1b26e625a search: integrate find/replace into search overlay, add toolbar buttons
Moves board-wide find/replace from standalone overlay into the search
panel as a toggleable replace row. Adds editor toolbar find/replace
button. Ctrl+Shift+H now opens search in replace mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:53:11 +02:00

1217 lines
48 KiB
JavaScript

// ── Editor (card create/edit) ──
import { $, esc, api, escRe, getCsrf, fuzzyMatch } from './util.js';
import { state, getChildren, CARD_COLORS } from './state.js';
import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js';
import { pushCardEdit, snapshotCard } from './undo.js';
const modal = $('modal');
const body = $('editor-body');
const preview = $('editor-preview');
const previewContent = $('preview-content');
const inpTitle = $('inp-title');
const inpColor = $('inp-color');
const inpBucket = $('inp-bucket');
const presets = $('color-presets');
const linkAc = $('link-ac');
const tagAc = $('tag-ac');
let linkAcVisible = false, linkAcItems = [], linkAcActive = 0;
let tagAcVisible = false, tagAcItems = [], tagAcActive = 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 ──
CARD_COLORS.forEach(c => {
const btn = document.createElement('button');
btn.type = 'button'; btn.title = c.name;
btn.dataset.color = c.hex;
btn.className = 'w-5 h-5 rounded-sm border-2 border-transparent transition-all hover:scale-125 color-swatch';
btn.style.background = c.hex;
btn.addEventListener('click', () => selectColor(c.hex));
presets.appendChild(btn);
});
const inpDueValue = $('inp-due-value');
const inpDueLabel = $('inp-due-label');
const inpDueBtn = $('inp-due-date');
function setDueDate(date) {
if (inpDueValue) inpDueValue.value = date || '';
if (inpDueLabel) inpDueLabel.textContent = date || 'No due';
if (inpDueBtn) {
inpDueBtn.classList.toggle('text-t-accent', !!date);
inpDueBtn.classList.toggle('border-t-accent', !!date);
inpDueBtn.classList.toggle('text-t-dim', !date);
}
}
inpDueBtn?.addEventListener('click', async () => {
const { showDialog } = await import('./dialog.js');
const current = inpDueValue?.value || '';
const result = await showDialog({
title: current ? 'change due date' : 'set due date',
input: 'date',
defaultValue: current || new Date().toISOString().substring(0, 10),
okLabel: current ? 'Update' : 'Set',
});
if (result === null) return;
setDueDate(result || '');
});
const inpDoneToggle = $('inp-done-toggle');
const inpDoneLabel = $('inp-done-label');
function updateDoneToggle() {
const isDone = body.value.includes('#done');
if (inpDoneToggle) {
inpDoneToggle.classList.toggle('text-t-accent', isDone);
inpDoneToggle.classList.toggle('border-t-accent', isDone);
inpDoneToggle.classList.toggle('text-t-dim', !isDone);
}
if (inpDoneLabel) inpDoneLabel.textContent = isDone ? 'Undone' : 'Done';
}
inpDoneToggle?.addEventListener('click', () => {
const isDone = body.value.includes('#done');
if (isDone) {
body.value = body.value.replace(/\s*#done\b/g, '');
} else {
body.value = body.value.trimEnd() + ' #done';
setDueDate('');
}
updateDoneToggle();
updatePreview();
});
function selectColor(hex) {
inpColor.value = hex;
const bar = $('editor-color');
if (bar) bar.style.background = hex;
presets.querySelectorAll('.color-swatch').forEach(el => {
const a = el.dataset.color === hex;
el.classList.toggle('border-t-bright', a);
el.classList.toggle('border-transparent', !a);
el.classList.toggle('scale-125', a);
});
}
// ── Open / close ──
export function openModal(card = null) {
state.editingId = card ? card.id : null;
$('modal-title').textContent = card ? 'edit_card' : 'new_card';
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
: (!virtualBuckets.includes(state.activeBucket) ? state.activeBucket : '');
selectColor(card ? card.color : '#38bdf8');
setDueDate(card?.due_date || '');
updateDoneToggle();
state.previewVisible = true;
preview.classList.remove('hidden');
editorDivider?.classList.remove('hidden');
$('toggle-preview').classList.add('text-t-accent');
applyEditorSplit();
updatePreview();
modal.classList.add('open');
if (linesVisible) updateGutter();
currentLine = -1;
updateActiveLine();
inpTitle.focus();
// Plugins (collab-buckets) hook into this to heartbeat an edit lock
window.dispatchEvent(new CustomEvent('hyprcrd:editor-open', { detail: { card } }));
}
inpTitle.addEventListener('input', updatePreview);
inpTitle.addEventListener('keydown', e => {
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); body.focus(); }
});
// ── 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;
// Use clientWidth to exclude scrollbar, matching the actual text flow area
mirror.style.width = body.clientWidth + '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);
// Use the first short line's height as the base line height
const baseH = divs[0] ? (divs.length > 1 ? divs[1].offsetTop - divs[0].offsetTop : sentinel.offsetTop - divs[0].offsetTop) : 22;
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;
// Outer div: exact height, no line-height influence. Inner span: base line-height so number sits on first line.
html += `<div style="display:block;height:${h}px;line-height:0;overflow:hidden" class="${i + 1 === active ? 'gutter-active' : ''}"><span style="display:inline-block;line-height:${baseH}px">${i + 1}</span></div>`;
}
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 } = {}) {
const closingId = state.editingId;
if (animate) startCloseMorph();
else clearMorphGhost();
hideLinkAc();
hideTagAc();
hideSnippetAc();
closeFindBar();
modal.classList.remove('open');
state.editingId = null;
state.returnToGalleryAfterEdit = false;
if (state._returnToCalendar) {
state._returnToCalendar = false;
window._openCalendar?.();
}
window.dispatchEvent(new CustomEvent('hyprcrd:editor-close', { detail: { cardId: closingId } }));
// Return focus to body so board shortcuts (arrows, bare keys) work
// immediately after closing without the user having to click first.
if (document.activeElement && document.activeElement !== document.body) {
document.activeElement.blur();
}
}
function updatePreview() {
if (!state.previewVisible) return;
const title = inpTitle.value.trim();
previewContent.innerHTML = (title ? `<h1>${esc(title)}</h1>` : '') + renderMd(body.value);
addCodeCopyButtons(previewContent);
formatTodoItems(previewContent);
// Interactive checkboxes in editor preview — toggle in textarea
previewContent.querySelectorAll('.todo-item').forEach((item, idx) => {
item.addEventListener('click', () => {
let n = 0;
body.value = body.value.replace(/- \[([ x])\]/g, (m, s) => n++ === idx ? `- [${s === 'x' ? ' ' : 'x'}]` : m);
updatePreview();
});
});
}
async function doSave(close, { animate = true } = {}) {
const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null, due_date: inpDueValue?.value || null };
if (!data.title.trim()) return;
const returnToGallery = state.returnToGalleryAfterEdit;
if (state.editingId) {
const prev = snapshotCard(state.cards.find(c => c.id == state.editingId));
if (prev) pushCardEdit('edit', state.editingId, { title: prev.title, body: prev.body, color: prev.color, bucket_id: prev.bucket_id, due_date: prev.due_date ?? null });
await api(`api/cards/${state.editingId}`, { method: 'PATCH', body: data });
} else {
if (state.activeGroup) {
const parent = state.cards.find(c => c.id == state.activeGroup);
const groupCards = parent ? [parent, ...state.cards.filter(c => c.parent_id == parent.id)] : [];
const maxPos = groupCards.reduce((max, c) => Math.max(max, Number(c.position) || 0), -1);
data.parent_id = parent ? parent.id : state.activeGroup;
data.position = maxPos + 1;
} else {
data.position = -1;
}
const created = await api('api/cards', { method: 'POST', body: data });
if (created?.id) {
if (!close) state.editingId = created.id;
state._lastSavedId = created.id;
state.recentlyCreatedIds?.add(String(created.id));
}
}
await window._reloadAll();
// Auto-sync if card has #termin or #geburtstag
if (data.body && /#(termin|geburtstag)\b/.test(data.body)) {
window._triggerSync?.();
}
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 ──
function insertMd(before, after = '', placeholder = '') {
body.focus();
const s = body.selectionStart, e = body.selectionEnd;
const sel = body.value.substring(s, e) || placeholder;
body.setRangeText(before + sel + after, s, e, 'select');
body.selectionStart = s + before.length;
body.selectionEnd = s + before.length + sel.length;
updatePreview();
}
// ── Built-in editor commands ──
// All commands flow through the plugin command registry. Plugins can
// register their own data-md="..." commands via window.hyprcrd.editor.
const _hyprEd = (typeof window !== 'undefined' && window.hyprcrd?.editor) || null;
const _registerCmd = (name, fn) => {
if (_hyprEd) _hyprEd.registerCommand(name, fn);
};
_registerCmd('heading', () => insertMd('## ', '', 'Heading'));
_registerCmd('bold', () => insertMd('**', '**', 'bold'));
_registerCmd('italic', () => insertMd('*', '*', 'italic'));
_registerCmd('strikethrough', () => insertMd('~~', '~~', 'text'));
_registerCmd('code', () => insertMd('`', '`', 'code'));
_registerCmd('codeblock', () => insertMd('```\n', '\n```', 'code'));
_registerCmd('ul', () => insertMd('- ', '', 'item'));
_registerCmd('ol', () => insertMd('1. ', '', 'item'));
_registerCmd('tree', () => insertMd('```tree\n- ', '\n - child\n', 'parent'));
_registerCmd('quote', () => insertMd('> ', '', 'quote'));
_registerCmd('todo', () => {
const s = body.selectionStart, e = body.selectionEnd;
const sel = body.value.substring(s, e);
if (sel && sel.includes('\n') || (sel && /^[\s]*[-*+]\s/m.test(sel))) {
const lines = sel.split('\n');
const hasTodos = lines.some(l => /^\s*- \[[ x]\]/.test(l));
const converted = lines.map(l => {
const trimmed = l.trim();
if (!trimmed) return l;
if (hasTodos) return l.replace(/^(\s*)- \[[ x]\] /, '$1- ');
return l.replace(/^(\s*)(?:[-*+]|\d+\.)\s/, '$1').replace(/^(\s*)(.+)$/, '$1- [ ] $2');
}).join('\n');
body.setRangeText(converted, s, e, 'select');
body.selectionStart = s;
body.selectionEnd = s + converted.length;
updatePreview();
} else {
insertMd('- [ ] ', '', 'task');
}
});
_registerCmd('link', () => insertMd('[', '](url)', 'text'));
_registerCmd('cardlink', () => { insertMd('[[', ']]', ''); showLinkAc(''); });
_registerCmd('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);
const link = `[[${title}]]`;
body.setRangeText(link, body.selectionStart, body.selectionEnd, 'end');
updatePreview();
});
_registerCmd('hr', () => insertMd('\n---\n'));
// Expose editor API to plugins (textarea, insertMd, preview refresh)
if (_hyprEd) {
_hyprEd.api = {
body,
insertMd,
updatePreview,
};
}
// Toolbar dispatch routes through the plugin registry
$('md-toolbar')?.addEventListener('click', e => {
const btn = e.target.closest('[data-md]');
if (!btn) return;
const fn = _hyprEd?.getCommand(btn.dataset.md);
if (fn) fn();
});
const editorDivider = $('editor-divider');
const EDITOR_SPLIT_KEY = 'hyprcrd-editor-split';
let editorSplit = parseFloat(localStorage.getItem(EDITOR_SPLIT_KEY)) || 50;
const editorPane = $('editor-pane');
function applyEditorSplit() {
if (!state.previewVisible) return;
// Use grow-based splits with flex-basis:0 so both panes share the available
// space AFTER the divider is subtracted. Using `0 0 X%` overflows the container
// because X% + (100-X)% + divider > 100%, which clips content on the right.
if (editorPane) editorPane.style.flex = `${editorSplit} 1 0`;
preview.style.flex = `${100 - editorSplit} 1 0`;
}
$('toggle-preview')?.addEventListener('click', () => {
state.previewVisible = !state.previewVisible;
preview.classList.toggle('hidden', !state.previewVisible);
editorDivider?.classList.toggle('hidden', !state.previewVisible);
$('toggle-preview').classList.toggle('text-t-accent', state.previewVisible);
if (state.previewVisible) {
applyEditorSplit();
} else {
if (editorPane) editorPane.style.flex = '1 1 0%';
preview.style.flex = '';
}
updatePreview();
});
if (editorDivider) {
const bar = editorDivider.firstElementChild;
editorDivider.addEventListener('mouseenter', () => { if (bar) bar.style.opacity = '0.5'; });
editorDivider.addEventListener('mouseleave', () => { if (bar && !editorDivider._dragging) bar.style.opacity = '0.12'; });
editorDivider.addEventListener('mousedown', e => {
e.preventDefault();
editorDivider._dragging = true;
if (bar) bar.style.opacity = '0.7';
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const container = editorDivider.parentElement;
const startX = e.clientX;
const startSplit = editorSplit;
const onMove = ev => {
const rect = container.getBoundingClientRect();
const dx = ev.clientX - startX;
const pct = startSplit + (dx / rect.width) * 100;
editorSplit = Math.max(25, Math.min(75, pct));
applyEditorSplit();
};
const onUp = () => {
editorDivider._dragging = false;
if (bar) bar.style.opacity = '0.12';
document.body.style.cursor = '';
document.body.style.userSelect = '';
localStorage.setItem(EDITOR_SPLIT_KEY, String(editorSplit));
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
$('add-btn')?.addEventListener('click', () => openModal());
$('modal-cancel')?.addEventListener('click', closeModal);
$('modal-save')?.addEventListener('click', saveOnly);
$('modal-save-view')?.addEventListener('click', saveAndView);
$('modal-save-close')?.addEventListener('click', saveCard);
// ── Link autocomplete ([[) ──
function getLinkQuery() {
const before = body.value.substring(0, body.selectionStart);
const idx = before.lastIndexOf('[[');
if (idx === -1) return null;
const between = before.substring(idx + 2);
if (between.includes(']]')) return null;
return { start: idx, query: between };
}
function showLinkAc(query) {
const q = query.toLowerCase();
linkAcItems = state.cards.filter(c => c.id != state.editingId).map(c => {
const t = c.title.toLowerCase();
let score = t.startsWith(q) ? 100 : t.includes(q) ? 50 : (fuzzyMatch(q, t) !== null ? 10 : (!q ? 1 : -1));
return score >= 0 ? { card: c, score } : null;
}).filter(Boolean).sort((a, b) => b.score - a.score).slice(0, 8).map(s => s.card);
if (!linkAcItems.length) { hideLinkAc(); return; }
linkAcActive = 0;
linkAc.innerHTML = linkAcItems.map((c, i) => `
<div class="ac-item ${i === 0 ? 'active' : ''}" data-idx="${i}">
<span class="ac-title font-pixel">${esc(c.title)}</span>
${c.bucket_name ? `<span class="ac-bucket font-pixel">${esc(c.bucket_name)}</span>` : ''}
</div>`).join('');
linkAc.querySelectorAll('.ac-item').forEach(el => {
el.addEventListener('mousedown', e => { e.preventDefault(); completeLinkAc(parseInt(el.dataset.idx)); });
});
const rect = body.getBoundingClientRect();
const lines = body.value.substring(0, body.selectionStart).split('\n');
linkAc.style.top = Math.min(rect.top + (lines.length - 1) * 22 - body.scrollTop + 26, innerHeight - 200) + 'px';
linkAc.style.left = Math.min(rect.left + 40, innerWidth - 380) + 'px';
linkAc.classList.remove('hidden');
linkAcVisible = true;
}
function hideLinkAc() { linkAc.classList.add('hidden'); linkAcVisible = false; }
function completeLinkAc(idx) {
const card = linkAcItems[idx];
if (!card) return;
const info = getLinkQuery();
if (!info) { hideLinkAc(); return; }
const before = body.value.substring(0, info.start);
let after = body.value.substring(body.selectionStart);
if (after.startsWith(']]')) after = after.substring(2);
else if (after.startsWith(']')) after = after.substring(1);
const ins = `[[${card.title}]]`;
body.value = before + ins + after;
body.selectionStart = body.selectionEnd = info.start + ins.length;
hideLinkAc(); updatePreview(); body.focus();
}
const SNIPPET_HINTS = [
{ key: 'dat', label: 'date', desc: '2026-04-07' },
{ key: 'time', label: 'time', desc: '22:53' },
{ key: 'now', label: 'date+time', desc: '2026-04-07 22:53' },
{ key: 'week', label: 'calendar week', desc: 'KW15' },
{ key: 'todo', label: 'checkbox', desc: '- [ ]' },
{ key: 'hr', label: 'divider', desc: '---' },
{ key: 'h1', label: 'heading 1', desc: '# ' },
{ key: 'h2', label: 'heading 2', desc: '## ' },
{ key: 'h3', label: 'heading 3', desc: '### ' },
{ key: 'table', label: 'table', desc: '| | |' },
{ key: 'code', label: 'code block', desc: '```' },
];
const snippetAc = $('snippet-ac');
let snippetAcVisible = false, snippetAcItems = [], snippetAcActive = 0;
function getSnippetQuery() {
const before = body.value.substring(0, body.selectionStart);
const m = before.match(/\?([a-z0-9]*)$/);
if (!m) return null;
// Don't show for math expressions (starts with digit)
if (m[1] && /^\d/.test(m[1])) return null;
return { start: before.length - m[0].length, query: m[1] };
}
function showSnippetAc(query) {
const q = query.toLowerCase();
snippetAcItems = SNIPPET_HINTS.filter(s => !q || s.key.includes(q) || s.label.includes(q));
if (!snippetAcItems.length) { hideSnippetAc(); return; }
snippetAcActive = 0;
snippetAc.innerHTML = snippetAcItems.map((s, i) => `
<div class="ac-item ${i === 0 ? 'active' : ''}" data-idx="${i}">
<span class="ac-title font-pixel" style="min-width:40px">?${esc(s.key)}</span>
<span style="font-size:9px;color:var(--t-dim);margin-left:8px">${esc(s.label)}</span>
<span style="font-size:9px;color:var(--t-border);margin-left:auto">${esc(s.desc)}</span>
</div>`).join('');
snippetAc.querySelectorAll('.ac-item').forEach(el => {
el.addEventListener('mousedown', e => { e.preventDefault(); completeSnippetAc(parseInt(el.dataset.idx)); });
});
const rect = body.getBoundingClientRect();
const lines = body.value.substring(0, body.selectionStart).split('\n');
snippetAc.style.top = Math.min(rect.top + (lines.length - 1) * 22 - body.scrollTop + 26, innerHeight - 200) + 'px';
snippetAc.style.left = Math.min(rect.left + 40, innerWidth - 340) + 'px';
snippetAc.classList.remove('hidden');
snippetAcVisible = true;
}
function hideSnippetAc() { snippetAc.classList.add('hidden'); snippetAcVisible = false; }
function completeSnippetAc(idx) {
const item = snippetAcItems[idx];
if (!item) return;
const info = getSnippetQuery();
if (!info) { hideSnippetAc(); return; }
const snippet = getSnippet(item.key);
if (snippet === null) { hideSnippetAc(); return; }
const before = body.value.substring(0, info.start);
const after = body.value.substring(body.selectionStart);
const cursorInside = snippet.includes('\n') ? snippet.indexOf('\n') + 1 : snippet.length;
body.value = before + snippet + after;
body.selectionStart = body.selectionEnd = info.start + cursorInside;
hideSnippetAc(); updatePreview(); body.focus();
}
document.addEventListener('click', e => {
if (snippetAcVisible && !snippetAc.contains(e.target) && e.target !== body) hideSnippetAc();
});
function getSnippet(key) {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
// ISO week number
const jan4 = new Date(d.getFullYear(), 0, 4);
const week = Math.ceil(((d - jan4) / 86400000 + jan4.getDay() + 1) / 7);
const snippets = {
dat: date,
time: time,
now: `${date} ${time}`,
week: `KW${week}`,
todo: '- [ ] ',
hr: '---',
h1: '# ',
h2: '## ',
h3: '### ',
table: '| col1 | col2 | col3 |\n| --- | --- | --- |\n| | | |',
code: '```\n\n```',
};
return snippets[key] ?? null;
}
body.addEventListener('input', e => {
// Inline snippets & calc: ?keyword SPACE or ?expr SPACE
if (e.inputType === 'insertText' && e.data === ' ') {
const pos = body.selectionStart;
const before = body.value.substring(0, pos - 1);
// Text snippets: ?dat, ?time, ?now, ?week, ?todo, ?hr, ?h1-3, ?table, ?code
const sm = before.match(/\?([a-z][a-z0-9]*)$/);
if (sm) {
const snippet = getSnippet(sm[1]);
if (snippet !== null) {
const start = pos - 1 - sm[0].length;
const cursorInside = snippet.includes('\n') ? snippet.indexOf('\n') + 1 : snippet.length;
body.value = body.value.substring(0, start) + snippet + body.value.substring(pos);
body.selectionStart = body.selectionEnd = start + cursorInside;
updatePreview();
return;
}
}
// Math calc: ?3+2 → replaces with result; 34+56? → 34+56=90
const cm = before.match(/\?([0-9][0-9+\-*\/().,%^ ]*[0-9)%])$/);
const cm2 = !cm ? before.match(/([0-9][0-9+\-*\/().,%^ ]*[0-9)%])\?$/) : null;
const calcMatch = cm || cm2;
if (calcMatch) {
try {
const expr = calcMatch[1].replace(/%/g, '/100*').replace(/\^/g, '**');
const result = Function('"use strict"; return (' + expr + ')')();
if (typeof result === 'number' && isFinite(result)) {
const rounded = Math.round(result * 1e10) / 1e10;
const start = pos - 1 - calcMatch[0].length;
if (cm) {
// ?expr → result
body.value = body.value.substring(0, start) + rounded + body.value.substring(pos);
body.selectionStart = body.selectionEnd = start + String(rounded).length;
} else {
// expr? → expr=result
const replacement = calcMatch[1] + '=' + rounded;
body.value = body.value.substring(0, start) + replacement + body.value.substring(pos);
body.selectionStart = body.selectionEnd = start + replacement.length;
}
}
} catch {}
}
}
const snippetInfo = getSnippetQuery();
snippetInfo ? showSnippetAc(snippetInfo.query) : hideSnippetAc();
const linkInfo = getLinkQuery();
linkInfo ? showLinkAc(linkInfo.query) : hideLinkAc();
const tagInfo = getTagQuery();
tagInfo ? showTagAc(tagInfo.query) : hideTagAc();
updatePreview();
});
// ── Tag autocomplete (#) ──
function getTagQuery() {
const before = body.value.substring(0, body.selectionStart);
const idx = before.lastIndexOf('#');
if (idx < 0) return null;
// Must be at start or after whitespace
if (idx > 0 && !/\s/.test(before[idx - 1])) return null;
// Everything after # to cursor must be valid tag chars (or empty)
const query = before.substring(idx + 1);
if (query && /[^a-zA-Z0-9_\/-]/.test(query)) return null;
return { start: idx, query };
}
function getAllTags() {
const set = new Set();
for (const c of state.cards) {
for (const t of (c.tags || '').split(',')) { if (t) set.add(t); }
}
return [...set].sort();
}
function showTagAc(query) {
const q = query.toLowerCase();
const all = getAllTags();
tagAcItems = all.filter(t => !q || t.includes(q));
if (!tagAcItems.length) { hideTagAc(); return; }
tagAcActive = 0;
tagAc.innerHTML = tagAcItems.slice(0, 8).map((t, i) => `
<div class="ac-item ${i === 0 ? 'active' : ''}" data-idx="${i}">
<span class="ac-title font-pixel">#${esc(t)}</span>
</div>`).join('');
tagAcItems = tagAcItems.slice(0, 8);
tagAc.querySelectorAll('.ac-item').forEach(el => {
el.addEventListener('mousedown', e => { e.preventDefault(); completeTagAc(parseInt(el.dataset.idx)); });
});
const rect = body.getBoundingClientRect();
const lines = body.value.substring(0, body.selectionStart).split('\n');
tagAc.style.top = Math.min(rect.top + (lines.length - 1) * 22 - body.scrollTop + 26, innerHeight - 200) + 'px';
tagAc.style.left = Math.min(rect.left + 40, innerWidth - 320) + 'px';
tagAc.classList.remove('hidden');
tagAcVisible = true;
}
function hideTagAc() { tagAc.classList.add('hidden'); tagAcVisible = false; }
document.addEventListener('click', e => {
if (tagAcVisible && !tagAc.contains(e.target) && e.target !== body) hideTagAc();
if (snippetAcVisible && !snippetAc.contains(e.target) && e.target !== body) hideSnippetAc();
});
// Capture phase: intercept ESC before dialog overlay can close the modal
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && (tagAcVisible || snippetAcVisible || linkAcVisible)) {
e.stopImmediatePropagation();
e.stopPropagation();
e.preventDefault();
if (linkAcVisible) hideLinkAc();
if (tagAcVisible) hideTagAc();
if (snippetAcVisible) hideSnippetAc();
}
}, true);
function completeTagAc(idx) {
const tag = tagAcItems[idx];
if (!tag) return;
const info = getTagQuery();
if (!info) { hideTagAc(); return; }
const before = body.value.substring(0, info.start);
const after = body.value.substring(body.selectionStart);
const ins = `#${tag} `;
body.value = before + ins + after;
body.selectionStart = body.selectionEnd = info.start + ins.length;
hideTagAc(); updatePreview(); body.focus();
}
// ── 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); }
else if (e.key === 'ArrowUp') { e.preventDefault(); linkAcActive = Math.max(linkAcActive - 1, 0); }
else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); completeLinkAc(linkAcActive); return; }
else if (e.key === 'Escape') { e.preventDefault(); hideLinkAc(); return; }
linkAc.querySelectorAll('.ac-item').forEach((el, i) => el.classList.toggle('active', i === linkAcActive));
return;
}
if (tagAcVisible) {
if (e.key === 'ArrowDown') { e.preventDefault(); tagAcActive = Math.min(tagAcActive + 1, tagAcItems.length - 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); tagAcActive = Math.max(tagAcActive - 1, 0); }
else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); completeTagAc(tagAcActive); return; }
else if (e.key === 'Escape') { e.preventDefault(); hideTagAc(); return; }
tagAc.querySelectorAll('.ac-item').forEach((el, i) => {
el.classList.toggle('active', i === tagAcActive);
if (i === tagAcActive) el.scrollIntoView({ block: 'nearest' });
});
return;
}
if (snippetAcVisible) {
if (e.key === 'ArrowDown') { e.preventDefault(); snippetAcActive = Math.min(snippetAcActive + 1, snippetAcItems.length - 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); snippetAcActive = Math.max(snippetAcActive - 1, 0); }
else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); completeSnippetAc(snippetAcActive); return; }
else if (e.key === 'Escape') { e.preventDefault(); hideSnippetAc(); return; }
snippetAc.querySelectorAll('.ac-item').forEach((el, i) => {
el.classList.toggle('active', i === snippetAcActive);
if (i === snippetAcActive) el.scrollIntoView({ block: 'nearest' });
});
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(' '); }
// Enter after todo/bullet/numbered list: continue the prefix
if (e.key === 'Enter' && !e.shiftKey) {
const pos = body.selectionStart;
const before = body.value.substring(0, pos);
const lineStart = before.lastIndexOf('\n') + 1;
const line = before.substring(lineStart);
const m = line.match(/^(\s*)(- \[[ x]\] |[-*+] |\d+\. )/);
if (m) {
e.preventDefault();
const indent = m[1];
let prefix = m[2];
// If current line is empty (just prefix), clear it instead
if (line.trim() === prefix.trim()) {
body.setRangeText('', lineStart, pos, 'end');
body.selectionStart = body.selectionEnd = lineStart;
updatePreview();
return;
}
// Increment number for ordered lists
const numMatch = prefix.match(/^(\d+)\. $/);
if (numMatch) prefix = (parseInt(numMatch[1]) + 1) + '. ';
body.setRangeText('\n' + indent + prefix, pos, pos, 'end');
updatePreview();
if (linesVisible) updateGutter();
return;
}
}
});
body.addEventListener('blur', () => { setTimeout(hideLinkAc, 150); setTimeout(hideTagAc, 150); setTimeout(hideSnippetAc, 150); });
// ── Find / Replace (Ctrl+H / Ctrl+F) ──
const findBar = $('editor-find-bar');
const findInput = $('find-input');
const replInput = $('replace-input');
const findCount = $('find-count');
const replRow = $('replace-row');
let findMatches = [];
let findCurrent = -1;
export function openFindBar(withReplace = true) {
if (!findBar) return;
findBar.classList.remove('hidden');
replRow?.classList.toggle('hidden', !withReplace);
findInput?.focus();
if (findInput?.value) runFind();
}
function closeFindBar() {
findBar?.classList.add('hidden');
findMatches = [];
findCurrent = -1;
if (findCount) findCount.textContent = '';
body.focus();
}
function runFind() {
const needle = findInput?.value;
findMatches = [];
findCurrent = -1;
if (!needle) { if (findCount) findCount.textContent = ''; return; }
const text = body.value;
const lower = text.toLowerCase();
const nLower = needle.toLowerCase();
let pos = 0;
while (true) {
const idx = lower.indexOf(nLower, pos);
if (idx === -1) break;
findMatches.push(idx);
pos = idx + 1;
}
if (findCount) findCount.textContent = findMatches.length ? `${findMatches.length} found` : 'no match';
if (findMatches.length) jumpTo(0);
}
function jumpTo(idx) {
findCurrent = idx;
const pos = findMatches[idx];
const needle = findInput?.value || '';
body.focus();
body.setSelectionRange(pos, pos + needle.length);
if (findCount) findCount.textContent = `${idx + 1} / ${findMatches.length}`;
}
function findNext() {
if (!findMatches.length) return;
jumpTo((findCurrent + 1) % findMatches.length);
}
function findPrev() {
if (!findMatches.length) return;
jumpTo((findCurrent - 1 + findMatches.length) % findMatches.length);
}
function replaceOne() {
if (findCurrent < 0 || !findMatches.length) return;
const needle = findInput?.value || '';
const repl = replInput?.value ?? '';
const pos = findMatches[findCurrent];
body.setRangeText(repl, pos, pos + needle.length, 'end');
updatePreview();
runFind();
if (findMatches.length && findCurrent >= findMatches.length) findCurrent = 0;
if (findMatches.length) jumpTo(findCurrent < 0 ? 0 : findCurrent);
}
function replaceAll() {
const needle = findInput?.value;
const repl = replInput?.value ?? '';
if (!needle) return;
// Simple case-insensitive global replace via regex
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
body.value = body.value.replace(new RegExp(escaped, 'gi'), repl);
updatePreview();
runFind();
}
findInput?.addEventListener('input', runFind);
findInput?.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); }
if (e.key === 'Escape') { e.preventDefault(); closeFindBar(); }
});
replInput?.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); replaceOne(); }
if (e.key === 'Escape') { e.preventDefault(); closeFindBar(); }
});
$('find-next')?.addEventListener('click', findNext);
$('find-prev')?.addEventListener('click', findPrev);
$('find-close')?.addEventListener('click', closeFindBar);
$('replace-one')?.addEventListener('click', replaceOne);
$('replace-all')?.addEventListener('click', replaceAll);
// Ctrl+H = find+replace, Ctrl+F = find only (within editor)
document.addEventListener('keydown', e => {
if (!modal.classList.contains('open')) return;
if ((e.key === 'h' || e.key === 'f') && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
// If already open and same mode, toggle off
if (!findBar?.classList.contains('hidden')) {
const wantReplace = e.key === 'h';
const hasReplace = !replRow?.classList.contains('hidden');
if (wantReplace === hasReplace) { closeFindBar(); return; }
}
openFindBar(e.key === 'h');
}
});
// ── File upload (paste + drag-drop) ──
async function uploadFile(file) {
const isAudio = file.type.startsWith('audio/');
const isImage = file.type.startsWith('image/');
const isPdf = file.type === 'application/pdf' || file.name?.toLowerCase().endsWith('.pdf');
if (!isAudio && !isImage && !isPdf) return;
const ext = file.name?.split('.').pop() || file.type.split('/')[1] || 'bin';
const placeholder = isPdf ? '[uploading pdf...]' : isAudio ? '[uploading audio...]' : '![uploading...]()';
const pos = body.selectionStart;
body.setRangeText(placeholder, pos, body.selectionEnd, 'end');
updatePreview();
const form = new FormData();
form.append('file', file, (file.name || 'file.' + ext));
try {
const res = await fetch('api/upload', { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } });
const text = await res.text();
const data = JSON.parse(text);
if (data.error) throw new Error(data.error);
if (data.url) {
const md = isPdf
? `[📄 ${file.name || data.name}](${data.url})`
: isAudio
? `<audio controls preload="metadata" src="${data.url}"></audio>`
: `![${data.name}](${data.url})`;
const idx = body.value.indexOf(placeholder);
if (idx !== -1) {
body.value = body.value.substring(0, idx) + md + body.value.substring(idx + placeholder.length);
body.selectionStart = body.selectionEnd = idx + md.length;
}
}
} catch {
const idx = body.value.indexOf(placeholder);
if (idx !== -1) {
body.value = body.value.substring(0, idx) + body.value.substring(idx + placeholder.length);
body.selectionStart = body.selectionEnd = idx;
}
}
updatePreview();
}
body.addEventListener('paste', e => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/') || item.type.startsWith('audio/') || item.type === 'application/pdf') {
e.preventDefault();
const file = item.getAsFile();
if (file) uploadFile(file);
return;
}
}
});
body.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
body.addEventListener('drop', e => {
const files = e.dataTransfer?.files;
if (!files?.length) return;
for (const file of files) {
if (file.type.startsWith('image/') || file.type.startsWith('audio/') || file.type === 'application/pdf') {
e.preventDefault();
uploadFile(file);
return;
}
}
});
// PDF upload toolbar button
$('md-upload-pdf')?.addEventListener('click', () => $('md-upload-pdf-file')?.click());
$('md-upload-pdf-file')?.addEventListener('change', e => {
const file = e.target.files?.[0];
if (file) uploadFile(file);
e.target.value = '';
});
// ── Image resize ──
document.addEventListener('mousedown', e => {
const img = e.target.closest('.prose img');
if (!img || img.closest('.md-gallery-item')) return;
e.preventDefault();
img.classList.add('resizing');
const startX = e.clientX, startW = img.offsetWidth;
const onMove = ev => { img.style.width = Math.max(50, startW + ev.clientX - startX) + 'px'; img.style.maxWidth = 'none'; };
const onUp = () => {
img.classList.remove('resizing');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
const width = Math.round(img.offsetWidth), src = img.getAttribute('src');
if (src && modal.classList.contains('open')) {
const val = body.value;
const mdRe = new RegExp('!\\[([^\\]]*)\\]\\(' + escRe(src) + '\\)');
const mdMatch = val.match(mdRe);
if (mdMatch) { body.value = val.replace(mdMatch[0], `<img src="${src}" alt="${mdMatch[1]}" width="${width}">`); }
else {
const tagRe = new RegExp('(<img[^>]*src="' + escRe(src) + '"[^>]*?)(?:\\swidth="[^"]*")?([^>]*>)');
const tagMatch = val.match(tagRe);
if (tagMatch) body.value = val.replace(tagMatch[0], tagMatch[1] + ` width="${width}"` + tagMatch[2]);
}
updatePreview();
}
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});