// ── Editor (card create/edit) ──
import { $, api, escRe, getCsrf } from './util.js';
import { state, getChildren, CARD_COLORS } from './state.js';
import { renderMd, addCodeCopyButtons } 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');
const tagAc = $('tag-ac');
let linkAcVisible = false, linkAcItems = [], linkAcActive = 0;
let tagAcVisible = false, tagAcItems = [], tagAcActive = 0;
let morphGhost = null, morphTimer = null;
function clearMorphGhost() {
if (morphTimer) { clearTimeout(morphTimer); morphTimer = null; }
if (morphGhost) { morphGhost.remove(); morphGhost = null; }
}
function findMorphTarget(cardId) {
const board = $('board');
if (!board || !cardId) return null;
const el = board.querySelector(`[data-id="${cardId}"]`);
if (el) return el;
const card = state.cards.find(c => c.id == cardId);
if (card?.parent_id) {
const p = board.querySelector(`[data-id="${card.parent_id}"]`);
if (p) return p;
}
const children = getChildren(cardId);
for (const ch of children) {
const ce = board.querySelector(`[data-id="${ch.id}"]`);
if (ce) return ce;
}
return null;
}
function startCloseMorph() {
const cardId = state.editingId;
if (!cardId) return;
if ($('search')?.classList.contains('open')) return;
const target = findMorphTarget(cardId);
if (!target) return;
const from = { left: 40, top: 40, width: window.innerWidth - 80, height: window.innerHeight - 80 };
const to = target.getBoundingClientRect();
if (from.width < 40 || from.height < 40 || to.width < 20 || to.height < 20) return;
const visible = to.bottom > 0 && to.top < window.innerHeight && to.right > 0 && to.left < window.innerWidth;
if (!visible) return;
clearMorphGhost();
const card = state.cards.find(c => c.id == cardId);
const targetCard = target.dataset.id ? state.cards.find(c => c.id == target.dataset.id) : card;
const frame = document.createElement('div');
frame.className = 'detail-close-frame';
Object.assign(frame.style, {
position: 'fixed',
left: `${from.left}px`,
top: `${from.top}px`,
width: `${from.width}px`,
height: `${from.height}px`,
borderColor: targetCard?.color || card?.color || 'var(--t-accent2)',
margin: '0',
});
document.body.appendChild(frame);
morphGhost = frame;
void frame.offsetWidth;
requestAnimationFrame(() => {
frame.style.left = `${to.left}px`;
frame.style.top = `${to.top}px`;
frame.style.width = `${to.width}px`;
frame.style.height = `${to.height}px`;
frame.style.opacity = '0.32';
frame.style.borderRadius = '4px';
});
target.classList.add('card-morph-target');
setTimeout(() => target.classList.remove('card-morph-target'), 360);
const done = () => {
if (frame === morphGhost) morphGhost = null;
frame.remove();
};
frame.addEventListener('transitionend', done, { once: true });
morphTimer = setTimeout(done, 430);
}
// ── Color presets ──
CARD_COLORS.forEach(c => {
const btn = document.createElement('button');
btn.type = 'button'; btn.title = c.name;
btn.dataset.color = c.hex;
btn.className = 'w-5 h-5 rounded-sm border-2 border-transparent transition-all hover:scale-125 color-swatch';
btn.style.background = c.hex;
btn.addEventListener('click', () => selectColor(c.hex));
presets.appendChild(btn);
});
function selectColor(hex) {
inpColor.value = hex;
const bar = $('editor-color');
if (bar) bar.style.background = hex;
presets.querySelectorAll('.color-swatch').forEach(el => {
const a = el.dataset.color === hex;
el.classList.toggle('border-t-bright', a);
el.classList.toggle('border-transparent', !a);
el.classList.toggle('scale-125', a);
});
}
// ── Open / close ──
export function openModal(card = null) {
state.editingId = card ? card.id : null;
$('modal-title').textContent = card ? 'edit_card' : 'new_card';
const isJournal = !card && state.activeBucket === 'journal';
const today = new Date().toISOString().substring(0, 10);
inpTitle.value = card ? card.title : (isJournal ? today : '');
body.value = card ? card.body : '';
const virtualBuckets = ['all', 'none', 'current', 'favorites', 'journal'];
inpBucket.value = card?.bucket_id ? card.bucket_id
: (!virtualBuckets.includes(state.activeBucket) ? state.activeBucket : '');
selectColor(card ? card.color : '#38bdf8');
state.previewVisible = true;
preview.classList.remove('hidden');
$('toggle-preview').classList.add('text-t-accent');
updatePreview();
modal.classList.add('open');
if (linesVisible) updateGutter();
currentLine = -1;
updateActiveLine();
inpTitle.focus();
}
inpTitle.addEventListener('input', updatePreview);
// ── 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;
mirror.style.width = body.offsetWidth + '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);
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;
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 } = {}) {
if (animate) startCloseMorph();
else clearMorphGhost();
modal.classList.remove('open');
state.editingId = null;
state.returnToGalleryAfterEdit = false;
}
function updatePreview() {
if (!state.previewVisible) return;
const title = inpTitle.value.trim();
previewContent.innerHTML = (title ? `${esc(title)}
` : '') + renderMd(body.value);
addCodeCopyButtons(previewContent);
}
async function doSave(close, { animate = true } = {}) {
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;
}
const created = await api('api/cards', { method: 'POST', body: data });
if (created?.id) {
if (!close) state.editingId = created.id;
state._lastSavedId = created.id;
}
}
await window._reloadAll();
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();
}
$('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'),
tree: () => insertMd('```tree\n- ', '\n - child\n', 'parent'),
quote: () => insertMd('> ', '', 'quote'), todo: () => insertMd('- [ ] ', '', 'task'),
link: () => insertMd('[', '](url)', 'text'),
cardlink: () => { insertMd('[[', ']]', ''); showLinkAc(''); },
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);
// Replace selection with a card link
const link = `[[${title}]]`;
body.setRangeText(link, body.selectionStart, body.selectionEnd, 'end');
updatePreview();
},
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', 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();
}
body.addEventListener('input', () => {
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);
// Find # preceded by whitespace or start of line, not part of heading (# + space)
const m = before.match(/(?:^|[\s])#([a-zA-Z0-9_\/-]*)$/m);
if (!m) return null;
const start = before.length - m[0].length + (m[0].startsWith('#') ? 0 : 1); // skip leading whitespace
return { start, query: m[1] };
}
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; }
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;
}
// 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(' '); }
});
body.addEventListener('blur', () => { setTimeout(hideLinkAc, 150); setTimeout(hideTagAc, 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
? ``
: ``;
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], `
`); }
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);
});