// ── Editor (card create/edit) ── import { $, api, escRe, getCsrf } from './util.js'; import { state, CARD_COLORS } from './state.js'; import { renderMd } from './markdown.js'; import { pushCardEdit, snapshotCard } from './undo.js'; import { fuzzyMatch } from './util.js'; import { esc } from './util.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'); let linkAcVisible = false, linkAcItems = [], linkAcActive = 0; // ── 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); }); function selectColor(hex) { inpColor.value = 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'; inpTitle.value = card ? card.title : ''; body.value = card ? card.body : ''; inpBucket.value = card?.bucket_id ? card.bucket_id : (state.activeBucket !== 'all' && state.activeBucket !== 'none' ? state.activeBucket : ''); selectColor(card ? card.color : '#38bdf8'); state.previewVisible = true; preview.classList.remove('hidden'); $('toggle-preview').classList.add('text-t-accent'); updatePreview(); modal.classList.add('open'); inpTitle.focus(); } export function closeModal() { modal.classList.remove('open'); state.editingId = null; state.returnToGalleryAfterEdit = false; } function updatePreview() { if (state.previewVisible) previewContent.innerHTML = renderMd(body.value); } export async function saveCard() { const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null }; if (!data.title.trim()) return; const returnToGallery = state.returnToGalleryAfterEdit; 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 }); 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; } await api('api/cards', { method: 'POST', body: data }); } closeModal(); await window._reloadAll(); if (returnToGallery) window._openGallery?.(); } // ── 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(); } $('md-toolbar')?.addEventListener('click', e => { const btn = e.target.closest('[data-md]'); if (!btn) return; const cmds = { heading: () => insertMd('## ', '', 'Heading'), bold: () => insertMd('**', '**', 'bold'), italic: () => insertMd('*', '*', 'italic'), strikethrough: () => insertMd('~~', '~~', 'text'), code: () => insertMd('`', '`', 'code'), codeblock: () => insertMd('```\n', '\n```', 'code'), ul: () => insertMd('- ', '', 'item'), ol: () => insertMd('1. ', '', 'item'), quote: () => insertMd('> ', '', 'quote'), todo: () => insertMd('- [ ] ', '', 'task'), link: () => insertMd('[', '](url)', 'text'), cardlink: () => { insertMd('[[', ']]', ''); showLinkAc(''); }, hr: () => insertMd('\n---\n'), }; cmds[btn.dataset.md]?.(); }); $('toggle-preview')?.addEventListener('click', () => { state.previewVisible = !state.previewVisible; preview.classList.toggle('hidden', !state.previewVisible); $('toggle-preview').classList.toggle('text-t-accent', state.previewVisible); updatePreview(); }); $('add-btn')?.addEventListener('click', () => openModal()); $('modal-cancel')?.addEventListener('click', closeModal); $('modal-save')?.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) => `