This commit is contained in:
th committed 2026-04-07 17:47:09 +02:00
1 parent 03f57cbaee
commit a9603aa0e8
11 files changed
+1010 -93

No files matched your search

+332 -11
View File
@@ -1,8 +1,8 @@
// ── Editor (card create/edit) ──
import { $, api, escRe, getCsrf } from './util.js';
import { state, CARD_COLORS } from './state.js';
import { renderMd } from './markdown.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';
@@ -18,6 +18,85 @@ const presets = $('color-presets');
const linkAc = $('link-ac');
let linkAcVisible = false, linkAcItems = [], linkAcActive = 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 ──
@@ -46,30 +125,184 @@ function selectColor(hex) {
export function openModal(card = null) {
state.editingId = card ? card.id : null;
$('modal-title').textContent = card ? 'edit_card' : 'new_card';
inpTitle.value = card ? card.title : '';
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
: (state.activeBucket !== 'all' && state.activeBucket !== 'none' ? state.activeBucket : '');
: (!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();
}
export function closeModal() {
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 += `<div style="height:${h}px" class="${i + 1 === active ? 'gutter-active' : ''}">${i + 1}</div>`;
}
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) previewContent.innerHTML = renderMd(body.value);
if (!state.previewVisible) return;
const title = inpTitle.value.trim();
previewContent.innerHTML = (title ? `<h1>${esc(title)}</h1>` : '') + renderMd(body.value);
addCodeCopyButtons(previewContent);
}
export async function saveCard() {
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;
@@ -87,11 +320,27 @@ export async function saveCard() {
} else {
data.position = -1;
}
await api('api/cards', { method: 'POST', body: data });
const created = await api('api/cards', { method: 'POST', body: data });
if (created?.id) {
if (!close) state.editingId = created.id;
state._lastSavedId = created.id;
}
}
closeModal();
await window._reloadAll();
if (returnToGallery) window._openGallery?.();
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 ──
@@ -114,9 +363,20 @@ $('md-toolbar')?.addEventListener('click', e => {
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]?.();
@@ -131,7 +391,9 @@ $('toggle-preview')?.addEventListener('click', () => {
$('add-btn')?.addEventListener('click', () => openModal());
$('modal-cancel')?.addEventListener('click', closeModal);
$('modal-save')?.addEventListener('click', saveCard);
$('modal-save')?.addEventListener('click', saveOnly);
$('modal-save-view')?.addEventListener('click', saveAndView);
$('modal-save-close')?.addEventListener('click', saveCard);
// ── Link autocomplete ([[) ──
@@ -193,6 +455,53 @@ body.addEventListener('input', () => {
updatePreview();
});
// ── 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); }
@@ -202,6 +511,18 @@ body.addEventListener('keydown', e => {
linkAc.querySelectorAll('.ac-item').forEach((el, i) => el.classList.toggle('active', i === linkAcActive));
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(' '); }
});