// ── 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) => `
${esc(c.title)} ${c.bucket_name ? `${esc(c.bucket_name)}` : ''}
`).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(); } body.addEventListener('input', () => { const info = getLinkQuery(); info ? showLinkAc(info.query) : hideLinkAc(); updatePreview(); }); 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 (e.key === 'Tab') { e.preventDefault(); insertMd(' '); } }); body.addEventListener('blur', () => setTimeout(hideLinkAc, 150)); // ── File upload (paste + drag-drop) ── async function uploadFile(file) { const isAudio = file.type.startsWith('audio/'); const isImage = file.type.startsWith('image/'); if (!isAudio && !isImage) return; const ext = file.name?.split('.').pop() || file.type.split('/')[1] || 'bin'; const placeholder = 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(); console.log('Upload response:', res.status, text.substring(0, 300)); const data = JSON.parse(text); if (data.error) throw new Error(data.error); if (data.url) { const md = isAudio ? `` : `![${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 (err) { console.error('Upload failed:', err); 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/')) { 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/')) { e.preventDefault(); uploadFile(file); return; } } }); // ── Image resize ── document.addEventListener('mousedown', e => { const img = e.target.closest('.prose img'); if (!img) 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], `${mdMatch[1]}`); } else { const tagRe = new RegExp('(]*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); });