bak
This commit is contained in:
1 parent
e85b5203cd
commit
0d4d369870
19 files changed
+2258
-1814
No files matched your search
@@ -0,0 +1,260 @@
|
||||
// ── 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; }
|
||||
|
||||
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;
|
||||
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 {
|
||||
data.position = -1;
|
||||
await api('api/cards', { method: 'POST', body: data });
|
||||
}
|
||||
closeModal();
|
||||
await window._reloadAll();
|
||||
}
|
||||
|
||||
// ── 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));
|
||||
|
||||
// ── Clipboard image paste ──
|
||||
|
||||
body.addEventListener('paste', async e => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of items) {
|
||||
if (!item.type.startsWith('image/')) continue;
|
||||
e.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (!file) return;
|
||||
const placeholder = '![uploading...]()';
|
||||
body.setRangeText(placeholder, body.selectionStart, body.selectionEnd, 'end');
|
||||
updatePreview();
|
||||
const form = new FormData();
|
||||
form.append('file', file, 'paste.' + (file.type.split('/')[1] || 'png'));
|
||||
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 = ``;
|
||||
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();
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user