Files
hyprcrd/public/js/modules/editor.js
T
2026-04-07 08:20:48 +02:00

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>`
: `![${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], `<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);
});