- tags: accept #birthday and #appointment as English synonyms for
#geburtstag and #termin. Normalised to the German canonical form at
extractTags() write-time so every downstream check (parseTags /
calendar logic / board filters) keeps working without per-call-site
aliasing. Editor body-regex extended so the "new appointment"
heuristic and auto-sync trigger on either variant.
- website: replace #termin/#geburtstag with #appointment/#birthday in
tag chips, calendar feature list, and calendar legend.
- tests: fix flaky "Undo: Editor Save → Ctrl+Z reverts body change"
that raced doSave(). The test polled the server for the patched body
and fired Ctrl+Z as soon as it landed, but the undo shortcut is
guarded by !guards.isOpen('modal'), and closeModal() only runs after
the post-patch _reloadAll — on fast servers the key was swallowed.
Poll now also waits for the modal `.open` class to drop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1435 lines
59 KiB
JavaScript
1435 lines
59 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';
|
|
import { showDialog } from './dialog.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 scratchpadPanel = $('editor-scratchpad');
|
|
const scratchpadBody = $('scratchpad-body');
|
|
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.dataset.tip = 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 ──
|
|
|
|
// ── Autobackup (draft recovery) ──
|
|
const DRAFT_KEY = 'hyprcrd-editor-draft';
|
|
let draftSaveTimer = null;
|
|
|
|
function draftKeyFor(cardId) {
|
|
return DRAFT_KEY + ':' + (cardId || 'new');
|
|
}
|
|
|
|
function loadDraft(cardId) {
|
|
try {
|
|
const raw = localStorage.getItem(draftKeyFor(cardId));
|
|
return raw ? JSON.parse(raw) : null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
function saveDraftDebounced() {
|
|
clearTimeout(draftSaveTimer);
|
|
draftSaveTimer = setTimeout(() => {
|
|
const id = state.editingId || 'new';
|
|
const data = {
|
|
title: inpTitle.value,
|
|
body: body.value,
|
|
scratchpad: scratchpadBody?.value || '',
|
|
savedAt: Date.now(),
|
|
};
|
|
// Don't store empty drafts
|
|
if (!data.title.trim() && !data.body.trim() && !data.scratchpad.trim()) {
|
|
localStorage.removeItem(draftKeyFor(id));
|
|
return;
|
|
}
|
|
try { localStorage.setItem(draftKeyFor(id), JSON.stringify(data)); } catch {}
|
|
}, 800);
|
|
}
|
|
|
|
function clearDraft(cardId) {
|
|
clearTimeout(draftSaveTimer);
|
|
try { localStorage.removeItem(draftKeyFor(cardId || 'new')); } catch {}
|
|
}
|
|
|
|
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'];
|
|
// New appointments (#termin / #geburtstag pre-filled from the
|
|
// calendar's day-popup template) must never inherit the active
|
|
// bucket — they belong to a calendar, not a kanban column. For
|
|
// every other new card, inherit the active real bucket if we're
|
|
// in one so the user doesn't have to re-pick it.
|
|
// `card` can be `null` (add-button new) or a shell `{id: null, ...}`
|
|
// from the calendar day-popup — both count as "new", so key on
|
|
// card?.id not the card ref itself.
|
|
const isNewAppointment = !card?.id && /#(termin|appointment|geburtstag|birthday)\b/.test(body.value);
|
|
inpBucket.value = card?.bucket_id ? card.bucket_id
|
|
: (isNewAppointment ? ''
|
|
: (!virtualBuckets.includes(state.activeBucket) ? state.activeBucket : ''));
|
|
selectColor(card ? card.color : '#38bdf8');
|
|
setDueDate(card?.due_date || '');
|
|
updateDoneToggle();
|
|
if (scratchpadBody) scratchpadBody.value = card?.scratchpad || '';
|
|
showSidePanel('preview');
|
|
modal.classList.add('open');
|
|
if (linesVisible) updateGutter();
|
|
currentLine = -1;
|
|
updateActiveLine();
|
|
|
|
// Check for unsaved draft (newer than what we just loaded).
|
|
// Skipped during test runs — tests would block on the dialog.
|
|
const draft = loadDraft(card?.id);
|
|
if (draft && !window._testRunning) {
|
|
const cardModified = card ? Date.parse(card.updated_at + 'Z') || 0 : 0;
|
|
const draftIsNewer = draft.savedAt > cardModified;
|
|
const draftHasContent = draft.title || draft.body || draft.scratchpad;
|
|
const differsFromLoaded = draft.title !== inpTitle.value || draft.body !== body.value || draft.scratchpad !== (scratchpadBody?.value || '');
|
|
if (draftIsNewer && draftHasContent && differsFromLoaded) {
|
|
const age = Math.round((Date.now() - draft.savedAt) / 60000);
|
|
const ageText = age < 1 ? 'just now' : age < 60 ? `${age} min ago` : `${Math.round(age / 60)} h ago`;
|
|
const cardId = card?.id;
|
|
// Async confirm — fires after openModal returns
|
|
(async () => {
|
|
const restore = await showDialog({
|
|
title: 'unsaved draft',
|
|
message: `Unsaved draft from ${ageText} found.\n\nRestore it?`,
|
|
okLabel: 'Restore',
|
|
cancelLabel: 'Discard',
|
|
});
|
|
if (!modal.classList.contains('open') || state.editingId != cardId) return;
|
|
if (restore) {
|
|
inpTitle.value = draft.title || '';
|
|
body.value = draft.body || '';
|
|
if (scratchpadBody) scratchpadBody.value = draft.scratchpad || '';
|
|
updatePreview();
|
|
} else {
|
|
clearDraft(cardId);
|
|
}
|
|
})();
|
|
}
|
|
} else if (draft && window._testRunning) {
|
|
clearDraft(card?.id);
|
|
}
|
|
|
|
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(); saveDraftDebounced(); });
|
|
inpTitle.addEventListener('keydown', e => {
|
|
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); body.focus(); }
|
|
});
|
|
body.addEventListener('input', saveDraftDebounced);
|
|
scratchpadBody?.addEventListener('input', saveDraftDebounced);
|
|
|
|
// ── 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();
|
|
closeAssetPicker();
|
|
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 scratch = scratchpadBody?.value || null;
|
|
const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null, due_date: inpDueValue?.value || null, scratchpad: scratch };
|
|
if (!data.title.trim()) return;
|
|
const returnToGallery = state.returnToGalleryAfterEdit;
|
|
const wasEditingId = state.editingId;
|
|
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 });
|
|
clearDraft(state.editingId);
|
|
} 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));
|
|
}
|
|
clearDraft(null); // 'new' draft
|
|
}
|
|
await window._reloadAll();
|
|
// Auto-sync if card has #termin or #geburtstag
|
|
if (data.body && /#(termin|appointment|geburtstag|birthday)\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`;
|
|
}
|
|
|
|
function showSidePanel(which) {
|
|
const showPreview = which === 'preview';
|
|
const showScratch = which === 'scratchpad';
|
|
|
|
state.previewVisible = showPreview;
|
|
preview.classList.toggle('hidden', !showPreview);
|
|
scratchpadPanel?.classList.toggle('hidden', !showScratch);
|
|
editorDivider?.classList.toggle('hidden', !showPreview && !showScratch);
|
|
$('toggle-preview')?.classList.toggle('text-t-accent', showPreview);
|
|
$('toggle-scratchpad')?.classList.toggle('text-t-accent', showScratch);
|
|
|
|
if (showPreview || showScratch) {
|
|
applyEditorSplit();
|
|
if (showScratch) scratchpadPanel.style.flex = preview.style.flex || '1 1 0%';
|
|
} else {
|
|
if (editorPane) editorPane.style.flex = '1 1 0%';
|
|
preview.style.flex = '';
|
|
if (scratchpadPanel) scratchpadPanel.style.flex = '';
|
|
}
|
|
if (showPreview) updatePreview();
|
|
}
|
|
|
|
$('toggle-preview')?.addEventListener('click', () => {
|
|
showSidePanel(state.previewVisible ? null : 'preview');
|
|
});
|
|
|
|
$('toggle-scratchpad')?.addEventListener('click', () => {
|
|
const isOpen = !scratchpadPanel?.classList.contains('hidden');
|
|
showSidePanel(isOpen ? null : 'scratchpad');
|
|
if (!isOpen) scratchpadBody?.focus();
|
|
});
|
|
|
|
$('scratchpad-clear')?.addEventListener('click', () => {
|
|
if (scratchpadBody) scratchpadBody.value = '';
|
|
});
|
|
|
|
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(); e.stopPropagation(); closeFindBar(); }
|
|
});
|
|
replInput?.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') { e.preventDefault(); replaceOne(); }
|
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); 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>`
|
|
: ``;
|
|
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 = '';
|
|
});
|
|
|
|
// ── Asset picker (insert from uploads) ──
|
|
|
|
const ASSET_IMAGE_EXT = /\.(png|jpe?g|gif|webp)$/i;
|
|
const ASSET_AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i;
|
|
const ASSET_PDF_EXT = /\.pdf$/i;
|
|
|
|
function assetType(name) {
|
|
if (ASSET_IMAGE_EXT.test(name)) return 'image';
|
|
if (ASSET_AUDIO_EXT.test(name)) return 'audio';
|
|
if (ASSET_PDF_EXT.test(name)) return 'pdf';
|
|
return 'other';
|
|
}
|
|
|
|
let assetPickerEl = null;
|
|
|
|
function closeAssetPicker() {
|
|
if (assetPickerEl) { assetPickerEl.remove(); assetPickerEl = null; }
|
|
}
|
|
|
|
$('md-asset-picker')?.addEventListener('click', async () => {
|
|
if (assetPickerEl) { closeAssetPicker(); return; }
|
|
|
|
let files = [];
|
|
try { files = await api('api/uploads'); } catch {}
|
|
if (!Array.isArray(files)) files = [];
|
|
|
|
const btn = $('md-asset-picker');
|
|
const rect = btn.getBoundingClientRect();
|
|
|
|
const picker = document.createElement('div');
|
|
picker.id = 'asset-picker-flyout';
|
|
picker.style.cssText = `position:fixed;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;width:340px;max-height:400px;background:var(--t-surface);border:1px solid var(--t-border);border-radius:6px;box-shadow:0 12px 40px rgba(0,0,0,0.5);z-index:200;display:flex;flex-direction:column;overflow:hidden;`;
|
|
|
|
const searchHtml = `<div style="padding:8px;border-bottom:1px solid var(--t-border);">
|
|
<input id="asset-picker-search" type="text" placeholder="search assets..." autocomplete="off"
|
|
style="width:100%;background:var(--t-bg);border:1px solid var(--t-border);color:var(--t-bright);padding:5px 8px;font-size:11px;font-family:var(--t-font-content,'JetBrains Mono',monospace);border-radius:4px;outline:none;"
|
|
class="focus:border-t-accent">
|
|
</div>`;
|
|
|
|
const gridId = 'asset-picker-grid';
|
|
picker.innerHTML = searchHtml + `<div id="${gridId}" style="flex:1;overflow-y:auto;padding:8px;display:grid;grid-template-columns:repeat(4,1fr);gap:6px;"></div>`;
|
|
document.body.appendChild(picker);
|
|
assetPickerEl = picker;
|
|
|
|
const grid = picker.querySelector(`#${gridId}`);
|
|
const searchInput = picker.querySelector('#asset-picker-search');
|
|
|
|
function renderTiles(filter) {
|
|
const q = (filter || '').toLowerCase();
|
|
const filtered = q ? files.filter(f => f.name.toLowerCase().includes(q)) : files;
|
|
if (!filtered.length) {
|
|
grid.innerHTML = `<div style="grid-column:1/-1;text-align:center;padding:24px 0;color:var(--t-dim);font-size:10px;font-style:italic;">${files.length ? 'no matches' : 'no assets'}</div>`;
|
|
return;
|
|
}
|
|
grid.innerHTML = filtered.map(f => {
|
|
const type = assetType(f.name);
|
|
const label = f.name.replace(/\.[^.]+$/, '');
|
|
if (type === 'image') {
|
|
return `<div class="asset-pick-tile" data-url="${esc(f.url)}" data-name="${esc(f.name)}" data-type="image" data-tip="${esc(f.name)}" style="cursor:pointer;border-radius:4px;overflow:hidden;aspect-ratio:1;background:var(--t-bg);border:1px solid var(--t-border);">
|
|
<img src="${esc(f.url)}" style="width:100%;height:100%;object-fit:cover;" loading="lazy">
|
|
</div>`;
|
|
}
|
|
const icon = type === 'audio' ? 'ph-music-note' : type === 'pdf' ? 'ph-file-pdf' : 'ph-file';
|
|
const iconColor = type === 'pdf' ? 'color:var(--t-danger)' : 'color:var(--t-dim)';
|
|
return `<div class="asset-pick-tile" data-url="${esc(f.url)}" data-name="${esc(f.name)}" data-type="${type}" data-tip="${esc(f.name)}" style="cursor:pointer;border-radius:4px;overflow:hidden;aspect-ratio:1;background:var(--t-bg);border:1px solid var(--t-border);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;padding:4px;">
|
|
<i class="ph ${icon}" style="font-size:20px;${iconColor}"></i>
|
|
<span style="font-size:7px;color:var(--t-muted);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--t-font-pixel,'Silkscreen',monospace);">${esc(label)}</span>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
grid.querySelectorAll('.asset-pick-tile').forEach(tile => {
|
|
tile.addEventListener('click', () => {
|
|
const url = tile.dataset.url;
|
|
const name = tile.dataset.name;
|
|
const type = tile.dataset.type;
|
|
let md;
|
|
if (type === 'image') md = `![${name.replace(/\.[^.]+$/, '')}](${url})`;
|
|
else if (type === 'pdf') md = `[📄 ${name}](${url})`;
|
|
else if (type === 'audio') md = `<audio controls src="${url}"></audio>`;
|
|
else md = `[${name}](${url})`;
|
|
insertMd(md);
|
|
closeAssetPicker();
|
|
});
|
|
tile.addEventListener('mouseenter', () => { tile.style.borderColor = 'var(--t-accent)'; });
|
|
tile.addEventListener('mouseleave', () => { tile.style.borderColor = 'var(--t-border)'; });
|
|
});
|
|
}
|
|
|
|
renderTiles('');
|
|
setTimeout(() => searchInput.focus(), 0);
|
|
searchInput.addEventListener('input', () => renderTiles(searchInput.value));
|
|
searchInput.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeAssetPicker(); }
|
|
});
|
|
|
|
const onClickOutside = e => {
|
|
if (!picker.contains(e.target) && e.target !== btn && !btn.contains(e.target)) {
|
|
closeAssetPicker();
|
|
document.removeEventListener('mousedown', onClickOutside);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', onClickOutside);
|
|
});
|
|
|
|
// ── Image resize ──
|
|
|
|
document.addEventListener('mousedown', e => {
|
|
if (e.button !== 0) return;
|
|
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);
|
|
});
|