308 lines
12 KiB
JavaScript
308 lines
12 KiB
JavaScript
// ── 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) => `
|
|
<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();
|
|
}
|
|
|
|
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
|
|
? `<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 (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], `<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);
|
|
});
|