// ── 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 += `
${i + 1}
`; } gutter.innerHTML = html; currentLine = active; } const lineHl = $('editor-line-hl'); function updateActiveLine() { const line = getCurrentLineNum(); if (line === currentLine) return; currentLine = line; // Gutter highlight if (gutter && linesVisible) { gutter.querySelector('.gutter-active')?.classList.remove('gutter-active'); const divs = gutter.children; if (divs[line - 1]) divs[line - 1].classList.add('gutter-active'); } // Line highlight overlay updateLineHighlight(); } function updateLineHighlight() { if (!lineHl) return; const line = getCurrentLineNum(); if (linesVisible && gutter?.children[line - 1]) { const gutterDiv = gutter.children[line - 1]; const gutterRect = gutterDiv.getBoundingClientRect(); const wrapperRect = lineHl.parentElement.getBoundingClientRect(); lineHl.style.display = 'block'; lineHl.style.top = (gutterRect.top - wrapperRect.top) + 'px'; lineHl.style.height = gutterRect.height + 'px'; } else { // Use mirror to measure correct position even with wrapping const m = getMirror(); const lines = body.value.split('\n'); m.innerHTML = ''; const divs = []; for (const l of lines) { const d = document.createElement('div'); d.textContent = l || '\u200b'; m.appendChild(d); divs.push(d); } const idx = line - 1; if (!divs[idx]) { lineHl.style.display = 'none'; return; } const top = divs[idx].offsetTop; const h = (idx + 1 < divs.length) ? divs[idx + 1].offsetTop - top : divs[idx].offsetHeight; const pad = parseFloat(getComputedStyle(body).paddingTop) || 16; lineHl.style.display = 'block'; lineHl.style.top = (top + pad - body.scrollTop) + 'px'; lineHl.style.height = h + 'px'; } } function syncGutterScroll() { if (gutter && linesVisible) gutter.scrollTop = body.scrollTop; updateLineHighlight(); } function toggleLineNumbers() { linesVisible = !linesVisible; localStorage.setItem('hyprcrd-lines', linesVisible ? '1' : '0'); gutter?.classList.toggle('hidden', !linesVisible); $('toggle-lines')?.classList.toggle('text-t-accent', linesVisible); if (linesVisible) updateGutter(); } if (linesVisible && gutter) { gutter.classList.remove('hidden'); $('toggle-lines')?.classList.add('text-t-accent'); } $('toggle-lines')?.addEventListener('click', () => { toggleLineNumbers(); updateActiveLine(); }); body.addEventListener('scroll', syncGutterScroll); body.addEventListener('input', () => { if (linesVisible) updateGutter(); currentLine = -1; updateActiveLine(); }); body.addEventListener('click', updateActiveLine); body.addEventListener('keyup', updateActiveLine); window.addEventListener('resize', () => { if (linesVisible) updateGutter(); }); export function closeModal({ animate = true } = {}) { 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 ? `

${esc(title)}

` : '') + 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) => `
${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(); } 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) => `
?${esc(s.key)} ${esc(s.label)} ${esc(s.desc)}
`).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) => `
#${esc(t)}
`).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 ? `` : `![${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 = ''; }); // ── 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 = `
`; const gridId = 'asset-picker-grid'; picker.innerHTML = searchHtml + `
`; 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 = `
${files.length ? 'no matches' : 'no assets'}
`; return; } grid.innerHTML = filtered.map(f => { const type = assetType(f.name); const label = f.name.replace(/\.[^.]+$/, ''); if (type === 'image') { return `
`; } 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 `
${esc(label)}
`; }).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 = ``; 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], `${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); });