bak
This commit is contained in:
1 parent
03f57cbaee
commit
a9603aa0e8
11 files changed
+1010
-93
No files matched your search
+332
-11
@@ -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(' '); }
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user