All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / test-sqlite (push) Successful in 3m2s
CI/CD / build-and-deploy (push) Successful in 27s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
1807 lines
69 KiB
JavaScript
1807 lines
69 KiB
JavaScript
/**
|
||
* Notes — Surface Entry Point (v0.9.0)
|
||
*
|
||
* Markdown notes surface using the SDK:
|
||
* sw.api.ext('notes') — scoped API client
|
||
* sw.ui.* — primitive components
|
||
* sw.shell.topbar — shell topbar API
|
||
* window.CM — CodeMirror 6 bundle (optional)
|
||
*/
|
||
(async function () {
|
||
'use strict';
|
||
|
||
var mount = document.getElementById('extension-mount');
|
||
if (!mount) return;
|
||
|
||
var base = window.__BASE__ || '';
|
||
var ver = window.__VERSION__ || '0';
|
||
|
||
// ── Dynamic Script Loader ────────────────────
|
||
function _loadScript(src) {
|
||
return new Promise(function(resolve, reject) {
|
||
if (document.querySelector('script[src*="' + src.split('?')[0] + '"]')) {
|
||
resolve();
|
||
return;
|
||
}
|
||
var s = document.createElement('script');
|
||
s.src = base + src;
|
||
s.onload = resolve;
|
||
s.onerror = function() { resolve(); }; // Non-fatal
|
||
document.head.appendChild(s);
|
||
});
|
||
}
|
||
|
||
// ── Boot SDK ───────────────────────────────
|
||
try {
|
||
if (!window.preact) {
|
||
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
|
||
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
|
||
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
|
||
window.preact = { h, render };
|
||
window.hooks = hooksModule;
|
||
window.html = htmModule.default.bind(h);
|
||
}
|
||
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
|
||
await sdk.boot();
|
||
} catch (e) {
|
||
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
|
||
return;
|
||
}
|
||
|
||
// ── Load CM6 bundle (optional — falls back to textarea) ──
|
||
await _loadScript('/vendor/codemirror/codemirror.bundle.js?v=' + ver);
|
||
var hasCM = !!(window.CM && window.CM.noteEditor);
|
||
|
||
var { html } = window;
|
||
var { useState, useEffect, useCallback, useRef, useMemo } = hooks;
|
||
var { render } = preact;
|
||
|
||
// ── SDK modules ────────────────────────────
|
||
var api = sw.api.ext('notes');
|
||
var { Button, Spinner } = sw.ui;
|
||
|
||
// ── Debounce helper ────────────────────────
|
||
var _timers = {};
|
||
function debounce(key, fn, ms) {
|
||
clearTimeout(_timers[key]);
|
||
_timers[key] = setTimeout(fn, ms);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// Markdown rendering via sw.markdown
|
||
// ═══════════════════════════════════════════
|
||
// Preload marked + DOMPurify so renderSync works in useMemo
|
||
sw.markdown.preload();
|
||
|
||
function _escAttr(s) {
|
||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════
|
||
// TagInput — editor tag management
|
||
// ═══════════════════════════════════════════
|
||
|
||
function TagInput({ tags, allTags, onChange }) {
|
||
var [input, setInput] = useState('');
|
||
var [showAC, setShowAC] = useState(false);
|
||
var inputRef = useRef(null);
|
||
|
||
var suggestions = useMemo(function() {
|
||
if (!input.trim()) return [];
|
||
var q = input.trim().toLowerCase();
|
||
return (allTags || []).filter(function(t) {
|
||
return t.toLowerCase().indexOf(q) !== -1 && (tags || []).indexOf(t) === -1;
|
||
}).slice(0, 8);
|
||
}, [input, allTags, tags]);
|
||
|
||
function addTag(tag) {
|
||
var t = tag.trim().toLowerCase();
|
||
if (!t) return;
|
||
var current = tags || [];
|
||
if (current.indexOf(t) === -1) {
|
||
onChange(current.concat([t]));
|
||
}
|
||
setInput('');
|
||
setShowAC(false);
|
||
if (inputRef.current) inputRef.current.focus();
|
||
}
|
||
|
||
function removeTag(tag) {
|
||
onChange((tags || []).filter(function(t) { return t !== tag; }));
|
||
}
|
||
|
||
function handleKeyDown(e) {
|
||
if (e.key === 'Enter' || e.key === ',') {
|
||
e.preventDefault();
|
||
if (input.trim()) addTag(input);
|
||
}
|
||
if (e.key === 'Backspace' && !input && tags && tags.length > 0) {
|
||
removeTag(tags[tags.length - 1]);
|
||
}
|
||
if (e.key === 'Escape') {
|
||
setShowAC(false);
|
||
}
|
||
}
|
||
|
||
function handleInput(e) {
|
||
var val = e.target.value.replace(',', '');
|
||
setInput(val);
|
||
setShowAC(val.trim().length > 0);
|
||
}
|
||
|
||
return html`
|
||
<div class="ext-notes-tag-input">
|
||
${(tags || []).map(function(tag) {
|
||
return html`
|
||
<span key=${tag} class="ext-notes-tag-pill">
|
||
${tag}
|
||
<span class="ext-notes-tag-pill__remove" onClick=${function() { removeTag(tag); }}>×</span>
|
||
</span>
|
||
`;
|
||
})}
|
||
<input ref=${inputRef} class="ext-notes-tag-input__field" type="text"
|
||
placeholder=${(tags && tags.length) ? 'Add tag…' : 'Add tags (comma to separate)…'}
|
||
value=${input}
|
||
onInput=${handleInput}
|
||
onKeyDown=${handleKeyDown}
|
||
onFocus=${function() { if (input.trim()) setShowAC(true); }}
|
||
onBlur=${function() { setTimeout(function() { setShowAC(false); }, 150); }} />
|
||
${showAC && suggestions.length > 0 && html`
|
||
<div class="ext-notes-tag-autocomplete">
|
||
${suggestions.map(function(s) {
|
||
return html`<div key=${s} class="ext-notes-tag-autocomplete__item"
|
||
onClick=${function() { addTag(s); }}>${s}</div>`;
|
||
})}
|
||
</div>
|
||
`}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════
|
||
// TagFilter — sidebar tag filter
|
||
// ═══════════════════════════════════════════
|
||
|
||
function TagFilter({ tags, activeTag, onSelect }) {
|
||
if (!tags || tags.length === 0) return null;
|
||
|
||
return html`
|
||
<div class="ext-notes-tag-filter">
|
||
<div class="ext-notes-tag-filter__label">
|
||
<span>Tags</span>
|
||
${activeTag && html`
|
||
<button class="ext-notes-tag-filter__clear" onClick=${function() { onSelect(null); }}>Clear</button>
|
||
`}
|
||
</div>
|
||
<div class="ext-notes-tag-filter__pills">
|
||
${tags.map(function(tag) {
|
||
return html`
|
||
<span key=${tag} class="ext-notes-tag-pill ${activeTag === tag ? 'ext-notes-tag-pill--active' : ''}"
|
||
onClick=${function() { onSelect(activeTag === tag ? null : tag); }}>
|
||
${tag}
|
||
</span>
|
||
`;
|
||
})}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════
|
||
// NoteCard — sidebar list item (draggable)
|
||
// ═══════════════════════════════════════════
|
||
|
||
function NoteCard({ note, active, onClick }) {
|
||
var [dragging, setDragging] = useState(false);
|
||
var dateStr = note.updated_at || note.created_at || '';
|
||
if (dateStr) {
|
||
try { dateStr = new Date(dateStr).toLocaleDateString(); } catch(e) { /* keep raw */ }
|
||
}
|
||
var tags = note.tags || [];
|
||
var showTags = tags.slice(0, 3);
|
||
var overflow = tags.length - 3;
|
||
|
||
function handleDragStart(e) {
|
||
e.dataTransfer.setData('text/plain', note.id);
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
setDragging(true);
|
||
}
|
||
|
||
function handleDragEnd() {
|
||
setDragging(false);
|
||
}
|
||
|
||
return html`
|
||
<div class="ext-notes-note-card ${active ? 'ext-notes-note-card--active' : ''} ${dragging ? 'ext-notes-note-card--dragging' : ''}"
|
||
onClick=${onClick}
|
||
draggable="true"
|
||
onDragStart=${handleDragStart}
|
||
onDragEnd=${handleDragEnd}>
|
||
<div class="ext-notes-note-card__title">
|
||
${note.pinned ? html`<span class="ext-notes-note-card__pin">📌</span>` : null}
|
||
${note.title || 'Untitled'}
|
||
</div>
|
||
${note.snippet && html`<div class="ext-notes-note-card__snippet">${note.snippet}</div>`}
|
||
${showTags.length > 0 && html`
|
||
<div class="ext-notes-note-card__tags">
|
||
${showTags.map(function(t) { return html`<span key=${t} class="ext-notes-tag-pill">${t}</span>`; })}
|
||
${overflow > 0 && html`<span class="ext-notes-tag-pill ext-notes-tag-pill--overflow">+${overflow}</span>`}
|
||
</div>
|
||
`}
|
||
${dateStr && html`<div class="ext-notes-note-card__date">${dateStr}</div>`}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════
|
||
// FolderContextMenu — right-click menu
|
||
// ═══════════════════════════════════════════
|
||
|
||
function FolderContextMenu({ x, y, onRename, onDelete, onAddSub, onClose }) {
|
||
var menuRef = useRef(null);
|
||
|
||
useEffect(function() {
|
||
function handleClick(e) {
|
||
if (menuRef.current && !menuRef.current.contains(e.target)) onClose();
|
||
}
|
||
document.addEventListener('mousedown', handleClick);
|
||
return function() { document.removeEventListener('mousedown', handleClick); };
|
||
}, [onClose]);
|
||
|
||
return html`
|
||
<div ref=${menuRef} class="ext-notes-folder-context-menu" style="top: ${y}px; left: ${x}px">
|
||
<div class="ext-notes-folder-context-menu__item" onClick=${onAddSub}>Add subfolder</div>
|
||
<div class="ext-notes-folder-context-menu__item" onClick=${onRename}>Rename</div>
|
||
<div class="ext-notes-folder-context-menu__item ext-notes-folder-context-menu__item--danger" onClick=${onDelete}>Delete</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════
|
||
// FolderNode — recursive tree node (drop target)
|
||
// ═══════════════════════════════════════════
|
||
|
||
function FolderNode({ node, depth, activeFolderId, onSelect, onRename, onDelete, onCreateSub, onDropNote }) {
|
||
var [expanded, setExpanded] = useState(true);
|
||
var [editing, setEditing] = useState(false);
|
||
var [editName, setEditName] = useState(node.folder.name);
|
||
var [dropOver, setDropOver] = useState(false);
|
||
var [menu, setMenu] = useState(null);
|
||
var hasChildren = node.children.length > 0;
|
||
var isActive = activeFolderId === node.folder.id;
|
||
var indent = 20 + depth * 16;
|
||
|
||
function handleToggle(e) {
|
||
e.stopPropagation();
|
||
setExpanded(!expanded);
|
||
}
|
||
|
||
function handleRename() {
|
||
if (editName.trim() && editName !== node.folder.name) {
|
||
onRename(node.folder.id, editName.trim());
|
||
}
|
||
setEditing(false);
|
||
}
|
||
|
||
function handleContext(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setMenu({ x: e.clientX, y: e.clientY });
|
||
}
|
||
|
||
function handleDragOver(e) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
setDropOver(true);
|
||
}
|
||
function handleDragLeave() { setDropOver(false); }
|
||
function handleDrop(e) {
|
||
e.preventDefault();
|
||
setDropOver(false);
|
||
var noteId = e.dataTransfer.getData('text/plain');
|
||
if (noteId && onDropNote) onDropNote(noteId, node.folder.id);
|
||
}
|
||
|
||
return html`
|
||
<div>
|
||
<div class="ext-notes-folder-tree__item ${isActive ? 'ext-notes-folder-tree__item--active' : ''} ${dropOver ? 'ext-notes-folder-tree__item--drop-target' : ''}"
|
||
style="padding-left: ${indent}px"
|
||
onClick=${function() { onSelect(node.folder.id); }}
|
||
onContextMenu=${handleContext}
|
||
onDragOver=${handleDragOver}
|
||
onDragLeave=${handleDragLeave}
|
||
onDrop=${handleDrop}>
|
||
<span class="ext-notes-folder-tree__toggle" onClick=${handleToggle}>
|
||
${hasChildren ? (expanded ? '▼' : '▶') : '·'}
|
||
</span>
|
||
<span class="ext-notes-folder-tree__icon">${isActive ? '📂' : '📁'}</span>
|
||
${editing
|
||
? html`<input class="ext-notes-folder-tree__edit" value=${editName}
|
||
onInput=${function(e) { setEditName(e.target.value); }}
|
||
onBlur=${handleRename}
|
||
onKeyDown=${function(e) { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setEditing(false); }}
|
||
onClick=${function(e) { e.stopPropagation(); }}
|
||
ref=${function(el) { if (el) el.focus(); }} />`
|
||
: html`<span class="ext-notes-folder-tree__name">${node.folder.name}</span>`
|
||
}
|
||
</div>
|
||
${menu && html`<${FolderContextMenu} x=${menu.x} y=${menu.y}
|
||
onRename=${function() { setMenu(null); setEditing(true); setEditName(node.folder.name); }}
|
||
onDelete=${function() { setMenu(null); onDelete(node.folder.id); }}
|
||
onAddSub=${function() { setMenu(null); onCreateSub(node.folder.id); }}
|
||
onClose=${function() { setMenu(null); }} />`}
|
||
${expanded && node.children.map(function(child) {
|
||
return html`<${FolderNode} key=${child.folder.id} node=${child} depth=${depth + 1}
|
||
activeFolderId=${activeFolderId}
|
||
onSelect=${onSelect} onRename=${onRename}
|
||
onDelete=${onDelete} onCreateSub=${onCreateSub}
|
||
onDropNote=${onDropNote} />`;
|
||
})}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════
|
||
// FolderTree — navigation panel (drop targets)
|
||
// ═══════════════════════════════════════════
|
||
|
||
function FolderTree({ folders, activeFolderId, showUnfiled, onSelectFolder, onSelectAll, onSelectUnfiled, onCreateFolder, onRenameFolder, onDeleteFolder, onDropNote }) {
|
||
var [dropAll, setDropAll] = useState(false);
|
||
var [dropUnfiled, setDropUnfiled] = useState(false);
|
||
|
||
// build tree from flat list
|
||
var tree = useMemo(function() {
|
||
var byId = {};
|
||
var roots = [];
|
||
for (var i = 0; i < folders.length; i++) {
|
||
byId[folders[i].id] = { folder: folders[i], children: [] };
|
||
}
|
||
for (var i = 0; i < folders.length; i++) {
|
||
var f = folders[i];
|
||
var node = byId[f.id];
|
||
if (f.parent_id && byId[f.parent_id]) {
|
||
byId[f.parent_id].children.push(node);
|
||
} else {
|
||
roots.push(node);
|
||
}
|
||
}
|
||
function sortNodes(arr) {
|
||
arr.sort(function(a, b) {
|
||
if (a.folder.sort_order !== b.folder.sort_order) return a.folder.sort_order - b.folder.sort_order;
|
||
return (a.folder.name || '').localeCompare(b.folder.name || '');
|
||
});
|
||
for (var j = 0; j < arr.length; j++) sortNodes(arr[j].children);
|
||
}
|
||
sortNodes(roots);
|
||
return roots;
|
||
}, [folders]);
|
||
|
||
// drop handlers for "All Notes" / "Unfiled" (move to unfiled = folder_id "")
|
||
function makeDropHandlers(setOver) {
|
||
return {
|
||
onDragOver: function(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setOver(true); },
|
||
onDragLeave: function() { setOver(false); },
|
||
onDrop: function(e) {
|
||
e.preventDefault(); setOver(false);
|
||
var noteId = e.dataTransfer.getData('text/plain');
|
||
if (noteId && onDropNote) onDropNote(noteId, '');
|
||
}
|
||
};
|
||
}
|
||
var allDrop = makeDropHandlers(setDropAll);
|
||
var unfiledDrop = makeDropHandlers(setDropUnfiled);
|
||
|
||
return html`
|
||
<div class="ext-notes-folder-tree">
|
||
<div class="ext-notes-folder-tree__item ${!activeFolderId && !showUnfiled ? 'ext-notes-folder-tree__item--active' : ''} ${dropAll ? 'ext-notes-folder-tree__item--drop-target' : ''}"
|
||
onClick=${onSelectAll}
|
||
onDragOver=${allDrop.onDragOver} onDragLeave=${allDrop.onDragLeave} onDrop=${allDrop.onDrop}>
|
||
<span class="ext-notes-folder-tree__icon">📋</span>
|
||
<span class="ext-notes-folder-tree__name">All Notes</span>
|
||
</div>
|
||
<div class="ext-notes-folder-tree__item ${showUnfiled ? 'ext-notes-folder-tree__item--active' : ''} ${dropUnfiled ? 'ext-notes-folder-tree__item--drop-target' : ''}"
|
||
onClick=${onSelectUnfiled}
|
||
onDragOver=${unfiledDrop.onDragOver} onDragLeave=${unfiledDrop.onDragLeave} onDrop=${unfiledDrop.onDrop}>
|
||
<span class="ext-notes-folder-tree__icon">📄</span>
|
||
<span class="ext-notes-folder-tree__name">Unfiled</span>
|
||
</div>
|
||
${tree.map(function(node) {
|
||
return html`<${FolderNode} key=${node.folder.id} node=${node} depth=${0}
|
||
activeFolderId=${activeFolderId}
|
||
onSelect=${onSelectFolder} onRename=${onRenameFolder}
|
||
onDelete=${onDeleteFolder} onCreateSub=${onCreateFolder}
|
||
onDropNote=${onDropNote} />`;
|
||
})}
|
||
<div class="ext-notes-folder-tree__add" onClick=${function() { onCreateFolder(''); }}>
|
||
+ New Folder
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════
|
||
// BacklinksPanel — incoming wikilinks
|
||
// ═══════════════════════════════════════════
|
||
|
||
function BacklinksPanel({ noteId, onNavigate }) {
|
||
var [backlinks, setBacklinks] = useState([]);
|
||
var [collapsed, setCollapsed] = useState(false);
|
||
|
||
useEffect(function() {
|
||
if (!noteId) { setBacklinks([]); return; }
|
||
api.get('/backlinks/' + noteId).then(function(res) {
|
||
var items = (res && res.data) || res || [];
|
||
setBacklinks(Array.isArray(items) ? items : []);
|
||
}).catch(function() { setBacklinks([]); });
|
||
}, [noteId]);
|
||
|
||
if (!backlinks.length) return null;
|
||
|
||
return html`
|
||
<div class="ext-notes-backlinks-panel">
|
||
<div class="ext-notes-backlinks-panel__header" onClick=${function() { setCollapsed(!collapsed); }}>
|
||
<span class="ext-notes-backlinks-panel__toggle">${collapsed ? '▶' : '▼'}</span>
|
||
<span>Backlinks</span>
|
||
<span class="ext-notes-backlinks-panel__count">${backlinks.length}</span>
|
||
</div>
|
||
${!collapsed && backlinks.map(function(bl) {
|
||
return html`
|
||
<div key=${bl.source_id} class="ext-notes-backlinks-panel__item"
|
||
onClick=${function() { if (onNavigate) onNavigate(bl.source_id); }}>
|
||
<span class="ext-notes-backlinks-panel__title">${bl.source_title}</span>
|
||
<span class="ext-notes-backlinks-panel__context">via [[${bl.link_text}]]</span>
|
||
</div>
|
||
`;
|
||
})}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════
|
||
// EditorPane — note editor + preview + tags
|
||
// ═══════════════════════════════════════════
|
||
|
||
// ═══════════════════════════════════════════
|
||
// Export / Import helpers
|
||
// ═══════════════════════════════════════════
|
||
|
||
function exportNoteAsMarkdown(note, tags) {
|
||
var parts = ['---'];
|
||
parts.push('title: ' + (note.title || 'Untitled'));
|
||
if (tags && tags.length) parts.push('tags: [' + tags.join(', ') + ']');
|
||
if (note.created_at) parts.push('created: ' + note.created_at);
|
||
parts.push('---');
|
||
parts.push('');
|
||
parts.push(note.body || '');
|
||
var text = parts.join('\n');
|
||
var blob = new Blob([text], { type: 'text/markdown;charset=utf-8' });
|
||
var url = URL.createObjectURL(blob);
|
||
var a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = (note.title || 'Untitled').replace(/[^a-zA-Z0-9_\- ]/g, '').trim() + '.md';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function parseFrontmatter(text) {
|
||
var result = { title: '', tags: [], body: text };
|
||
if (!text.startsWith('---')) return result;
|
||
var end = text.indexOf('\n---', 3);
|
||
if (end === -1) return result;
|
||
var fm = text.substring(3, end).trim();
|
||
var bodyStart = end + 4;
|
||
// skip leading newline after frontmatter
|
||
if (text[bodyStart] === '\n') bodyStart++;
|
||
result.body = text.substring(bodyStart);
|
||
var lines = fm.split('\n');
|
||
for (var i = 0; i < lines.length; i++) {
|
||
var line = lines[i].trim();
|
||
if (line.startsWith('title:')) {
|
||
result.title = line.substring(6).trim().replace(/^["']|["']$/g, '');
|
||
} else if (line.startsWith('tags:')) {
|
||
var tagStr = line.substring(5).trim();
|
||
// parse [tag1, tag2] or tag1, tag2
|
||
tagStr = tagStr.replace(/^\[|\]$/g, '');
|
||
result.tags = tagStr.split(',').map(function(t) { return t.trim().toLowerCase(); }).filter(Boolean);
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════
|
||
// Heading parser + Document Outline
|
||
// ═══════════════════════════════════════════
|
||
|
||
function parseHeadings(body) {
|
||
if (!body) return [];
|
||
var lines = body.split('\n');
|
||
var result = [];
|
||
var inCode = false;
|
||
for (var i = 0; i < lines.length; i++) {
|
||
var line = lines[i];
|
||
if (line.trim().indexOf('```') === 0) { inCode = !inCode; continue; }
|
||
if (inCode) continue;
|
||
var m = line.match(/^(#{1,6})\s+(.+)/);
|
||
if (m) {
|
||
result.push({ level: m[1].length, text: m[2].replace(/[*_`\[\]]/g, '').trim(), line: i });
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// SidebarTabs — Notes / Outline tab header
|
||
// ═══════════════════════════════════════════
|
||
|
||
function SidebarTabs({ activeTab, onTabChange, noteSelected }) {
|
||
return html`
|
||
<div class="ext-ext-notes-sidebar-tabs">
|
||
<button class="ext-ext-notes-sidebar-tabs__tab ${activeTab === 'notes' ? 'ext-ext-notes-sidebar-tabs__tab--active' : ''}"
|
||
onClick=${function() { onTabChange('notes'); }}>Notes</button>
|
||
<button class="ext-ext-notes-sidebar-tabs__tab ${activeTab === 'outline' ? 'ext-ext-notes-sidebar-tabs__tab--active' : ''} ${!noteSelected ? 'ext-ext-notes-sidebar-tabs__tab--disabled' : ''}"
|
||
onClick=${function() { if (noteSelected) onTabChange('outline'); }}>Outline</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════
|
||
// SidebarOutline — heading tree in sidebar
|
||
// ═══════════════════════════════════════════
|
||
|
||
function SidebarOutline({ headings, activeHeadingIdx, onScrollToHeading }) {
|
||
if (!headings || headings.length === 0) {
|
||
return html`<div class="ext-ext-notes-sidebar-outline__empty">No headings found</div>`;
|
||
}
|
||
|
||
return html`
|
||
<div class="ext-ext-notes-sidebar-outline">
|
||
${headings.map(function(h, i) {
|
||
return html`
|
||
<div key=${i} class="ext-ext-notes-sidebar-outline__item ext-ext-notes-sidebar-outline__item--h${h.level} ${i === activeHeadingIdx ? 'ext-ext-notes-sidebar-outline__item--active' : ''}"
|
||
style="padding-left: ${10 + (h.level - 1) * 14}px"
|
||
onClick=${function() { onScrollToHeading(h); }}>
|
||
${h.text}
|
||
</div>
|
||
`;
|
||
})}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
var _modeStore = sw.storage.local('notes');
|
||
|
||
function EditorPane({ note, folders, allTags, onSave, onDelete, onRefresh, onTagsChanged, onNavigate, onNavigateToTitle, onHeadingsChange, scrollToHeadingRef, onActiveHeadingChange }) {
|
||
var [title, setTitle] = useState(note ? note.title : '');
|
||
var [body, setBody] = useState(note ? note.body : '');
|
||
var [noteTags, setNoteTags] = useState([]);
|
||
var [viewMode, setViewMode] = useState(_modeStore.get('editor_mode') || 'rendered');
|
||
var [dirty, setDirty] = useState(false);
|
||
var [saving, setSaving] = useState(false);
|
||
var [saveCount, setSaveCount] = useState(0);
|
||
var textareaRef = useRef(null);
|
||
var cmContainerRef = useRef(null);
|
||
var cmEditorRef = useRef(null);
|
||
|
||
// keep mutable refs for the latest title/body so CM6 callbacks see current values
|
||
var titleRef = useRef(title);
|
||
var bodyRef = useRef(body);
|
||
titleRef.current = title;
|
||
bodyRef.current = body;
|
||
|
||
// sync when note changes
|
||
useEffect(function() {
|
||
if (note) {
|
||
setTitle(note.title || '');
|
||
setBody(note.body || '');
|
||
titleRef.current = note.title || '';
|
||
bodyRef.current = note.body || '';
|
||
setNoteTags(note.tags || []);
|
||
setDirty(false);
|
||
setViewMode(_modeStore.get('editor_mode') || 'rendered');
|
||
// update CM6 content if editor exists
|
||
if (cmEditorRef.current) {
|
||
cmEditorRef.current.setValue(note.body || '');
|
||
}
|
||
}
|
||
}, [note ? note.id : null]);
|
||
|
||
// ── CM6 lifecycle ─────────────────────────
|
||
var needsCM = viewMode !== 'rendered';
|
||
useEffect(function() {
|
||
if (!hasCM || !needsCM || !note) return;
|
||
var el = cmContainerRef.current;
|
||
if (!el) return;
|
||
|
||
// destroy previous
|
||
if (cmEditorRef.current) {
|
||
cmEditorRef.current.destroy();
|
||
cmEditorRef.current = null;
|
||
}
|
||
|
||
var editor = CM.noteEditor(el, {
|
||
value: bodyRef.current,
|
||
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
|
||
onChange: function(text) {
|
||
bodyRef.current = text;
|
||
setBody(text);
|
||
setDirty(true);
|
||
debounce('save', function() { doSave(titleRef.current, text); }, 1000);
|
||
},
|
||
onLink: function(linkTitle) {
|
||
if (onNavigateToTitle) onNavigateToTitle(linkTitle);
|
||
},
|
||
linkCompleter: async function(query) {
|
||
try {
|
||
var res = await api.get('/search?q=' + encodeURIComponent(query));
|
||
var items = (res && res.data) || res || [];
|
||
if (!Array.isArray(items)) items = [];
|
||
return items.map(function(n) { return { label: n.title, id: n.id }; });
|
||
} catch (e) { return []; }
|
||
},
|
||
});
|
||
|
||
cmEditorRef.current = editor;
|
||
|
||
// Ctrl/Cmd+S via CM6 keymap injection
|
||
var view = editor.getView();
|
||
if (view) {
|
||
var handleSaveKey = function(e) {
|
||
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
||
e.preventDefault();
|
||
doSave(titleRef.current, bodyRef.current);
|
||
}
|
||
};
|
||
view.dom.addEventListener('keydown', handleSaveKey);
|
||
}
|
||
|
||
editor.focus();
|
||
|
||
return function() {
|
||
if (cmEditorRef.current) {
|
||
cmEditorRef.current.destroy();
|
||
cmEditorRef.current = null;
|
||
}
|
||
};
|
||
}, [note ? note.id : null, needsCM]);
|
||
|
||
function handleTitleChange(e) {
|
||
setTitle(e.target.value);
|
||
titleRef.current = e.target.value;
|
||
setDirty(true);
|
||
debounce('save', function() { doSave(e.target.value, bodyRef.current); }, 1000);
|
||
}
|
||
|
||
function handleBodyChange(e) {
|
||
setBody(e.target.value);
|
||
bodyRef.current = e.target.value;
|
||
setDirty(true);
|
||
debounce('save', function() { doSave(titleRef.current, e.target.value); }, 1000);
|
||
}
|
||
|
||
async function doSave(t, b) {
|
||
if (!note) return;
|
||
setSaving(true);
|
||
try {
|
||
await api.put('/notes/' + note.id, { title: t || 'Untitled', body: b });
|
||
setDirty(false);
|
||
setSaveCount(function(c) { return c + 1; });
|
||
onRefresh();
|
||
} catch (e) {
|
||
console.error('Save failed:', e);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleTagsChange(newTags) {
|
||
if (!note) return;
|
||
setNoteTags(newTags);
|
||
try {
|
||
await api.put('/tags/' + note.id, { tags: newTags });
|
||
if (onTagsChanged) onTagsChanged();
|
||
} catch (e) {
|
||
console.error('Save tags failed:', e);
|
||
}
|
||
}
|
||
|
||
async function handlePin() {
|
||
if (!note) return;
|
||
await api.put('/notes/' + note.id, { pinned: note.pinned ? 0 : 1 });
|
||
onRefresh();
|
||
}
|
||
|
||
async function handleArchive() {
|
||
if (!note || !await sw.confirm('Archive this note?', { destructive: true })) return;
|
||
await api.del('/notes/' + note.id);
|
||
onDelete();
|
||
}
|
||
|
||
function handleExport() {
|
||
if (!note) return;
|
||
exportNoteAsMarkdown(note, noteTags);
|
||
}
|
||
|
||
function handleKeyDown(e) {
|
||
// Tab inserts two spaces
|
||
if (e.key === 'Tab') {
|
||
e.preventDefault();
|
||
var ta = e.target;
|
||
var start = ta.selectionStart;
|
||
var end = ta.selectionEnd;
|
||
var val = ta.value;
|
||
ta.value = val.substring(0, start) + ' ' + val.substring(end);
|
||
ta.selectionStart = ta.selectionEnd = start + 2;
|
||
setBody(ta.value);
|
||
bodyRef.current = ta.value;
|
||
setDirty(true);
|
||
debounce('save', function() { doSave(title, ta.value); }, 1000);
|
||
}
|
||
// Ctrl/Cmd+S force save
|
||
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
||
e.preventDefault();
|
||
doSave(title, body);
|
||
}
|
||
}
|
||
|
||
// ── Outgoing links for resolved/unresolved styling ──
|
||
var [outgoingLinks, setOutgoingLinks] = useState([]);
|
||
useEffect(function() {
|
||
if (!note) { setOutgoingLinks([]); return; }
|
||
api.get('/links/' + note.id).then(function(res) {
|
||
var items = (res && res.data) || res || [];
|
||
setOutgoingLinks(Array.isArray(items) ? items : []);
|
||
}).catch(function() { setOutgoingLinks([]); });
|
||
}, [note ? note.id : null, saveCount]);
|
||
|
||
var previewHtml = useMemo(function() {
|
||
var h = sw.markdown.renderSync(body, { sanitize: true });
|
||
// mark unresolved wikilinks
|
||
for (var i = 0; i < outgoingLinks.length; i++) {
|
||
var link = outgoingLinks[i];
|
||
if (!link.target_id) {
|
||
var search = 'class="ext-notes-wikilink" data-wikilink="' + _escAttr(link.link_text) + '"';
|
||
var replace = 'class="ext-notes-wikilink ext-notes-wikilink--unresolved" data-wikilink="' + _escAttr(link.link_text) + '"';
|
||
h = h.split(search).join(replace);
|
||
}
|
||
}
|
||
return h;
|
||
}, [body, outgoingLinks]);
|
||
|
||
// ── Wikilink click handling in preview ──
|
||
var previewRef = useRef(null);
|
||
useEffect(function() {
|
||
var el = previewRef.current;
|
||
if (!el || viewMode === 'edit') return;
|
||
function handleClick(e) {
|
||
var target = e.target;
|
||
if (target.classList && target.classList.contains('ext-notes-wikilink')) {
|
||
e.preventDefault();
|
||
var linkText = target.getAttribute('data-wikilink');
|
||
if (linkText && onNavigateToTitle) onNavigateToTitle(linkText);
|
||
}
|
||
}
|
||
el.addEventListener('click', handleClick);
|
||
return function() { el.removeEventListener('click', handleClick); };
|
||
}, [viewMode, onNavigateToTitle]);
|
||
|
||
// ── Run post-renderers (mermaid, katex, etc.) on preview ──
|
||
useEffect(function() {
|
||
var el = previewRef.current;
|
||
if (!el || viewMode === 'edit') return;
|
||
sw.renderers.runPostRenderers(el);
|
||
}, [previewHtml, viewMode]);
|
||
|
||
// ── Synced scroll in split mode ──
|
||
useEffect(function() {
|
||
if (viewMode !== 'split' || !cmEditorRef.current) return;
|
||
var view = cmEditorRef.current.getView();
|
||
if (!view) return;
|
||
var scrollDOM = view.scrollDOM;
|
||
var previewEl = previewRef.current;
|
||
if (!scrollDOM || !previewEl) return;
|
||
var syncing = false;
|
||
function onEditorScroll() {
|
||
if (syncing) return;
|
||
syncing = true;
|
||
var ratio = scrollDOM.scrollTop / (scrollDOM.scrollHeight - scrollDOM.clientHeight || 1);
|
||
previewEl.scrollTop = ratio * (previewEl.scrollHeight - previewEl.clientHeight);
|
||
syncing = false;
|
||
}
|
||
scrollDOM.addEventListener('scroll', onEditorScroll);
|
||
return function() { scrollDOM.removeEventListener('scroll', onEditorScroll); };
|
||
}, [viewMode, note ? note.id : null]);
|
||
|
||
// ── Document outline headings ──
|
||
var [headings, setHeadings] = useState([]);
|
||
useEffect(function() {
|
||
debounce('outline', function() {
|
||
var h = parseHeadings(body);
|
||
setHeadings(h);
|
||
if (onHeadingsChange) onHeadingsChange(h);
|
||
}, 300);
|
||
}, [body]);
|
||
|
||
// ── Expose scrollToHeading to parent via ref ──
|
||
useEffect(function() {
|
||
if (!scrollToHeadingRef) return;
|
||
scrollToHeadingRef.current = function(heading) {
|
||
// scroll preview in rendered / split
|
||
if (viewMode !== 'edit' && previewRef.current) {
|
||
var targets = previewRef.current.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||
for (var i = 0; i < targets.length; i++) {
|
||
if (targets[i].textContent.trim() === heading.text) {
|
||
targets[i].scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
// scroll CM6 in edit / split
|
||
if (viewMode !== 'rendered' && cmEditorRef.current) {
|
||
var view = cmEditorRef.current.getView();
|
||
if (!view) return;
|
||
var lineNum = Math.min(heading.line + 1, view.state.doc.lines);
|
||
var lineObj = view.state.doc.line(lineNum);
|
||
view.dispatch({ effects: CM.EditorView.scrollIntoView(lineObj.from, { y: 'start' }) });
|
||
}
|
||
};
|
||
}, [viewMode]);
|
||
|
||
// ── Active heading tracking on scroll ──
|
||
useEffect(function() {
|
||
if (!onActiveHeadingChange || !note) return;
|
||
var scrollEl = null;
|
||
var usePreview = viewMode !== 'edit';
|
||
if (usePreview && previewRef.current) {
|
||
scrollEl = previewRef.current;
|
||
} else if (viewMode !== 'rendered' && cmEditorRef.current) {
|
||
var view = cmEditorRef.current.getView();
|
||
if (view) scrollEl = view.scrollDOM;
|
||
}
|
||
if (!scrollEl) return;
|
||
|
||
var ticking = false;
|
||
function onScroll() {
|
||
if (ticking) return;
|
||
ticking = true;
|
||
requestAnimationFrame(function() {
|
||
ticking = false;
|
||
if (usePreview && previewRef.current) {
|
||
var els = previewRef.current.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||
var active = -1;
|
||
for (var i = 0; i < els.length; i++) {
|
||
if (els[i].getBoundingClientRect().top <= previewRef.current.getBoundingClientRect().top + 30) {
|
||
active = i;
|
||
}
|
||
}
|
||
onActiveHeadingChange(active);
|
||
} else if (cmEditorRef.current) {
|
||
var v = cmEditorRef.current.getView();
|
||
if (!v) return;
|
||
var topLine = v.state.doc.lineAt(v.viewport.from).number - 1;
|
||
var active = -1;
|
||
for (var i = 0; i < headings.length; i++) {
|
||
if (headings[i].line <= topLine) active = i;
|
||
}
|
||
onActiveHeadingChange(active);
|
||
}
|
||
});
|
||
}
|
||
scrollEl.addEventListener('scroll', onScroll);
|
||
onScroll(); // initial check
|
||
return function() { scrollEl.removeEventListener('scroll', onScroll); };
|
||
}, [viewMode, note ? note.id : null, headings]);
|
||
|
||
// build indented folder list for the select dropdown
|
||
var folderOptions = useMemo(function() {
|
||
var list = folders || [];
|
||
var byId = {};
|
||
var roots = [];
|
||
for (var i = 0; i < list.length; i++) {
|
||
byId[list[i].id] = { folder: list[i], children: [] };
|
||
}
|
||
for (var i = 0; i < list.length; i++) {
|
||
var f = list[i];
|
||
if (f.parent_id && byId[f.parent_id]) {
|
||
byId[f.parent_id].children.push(byId[f.id]);
|
||
} else {
|
||
roots.push(byId[f.id]);
|
||
}
|
||
}
|
||
var flat = [];
|
||
function walk(nodes, depth) {
|
||
nodes.sort(function(a, b) {
|
||
if (a.folder.sort_order !== b.folder.sort_order) return a.folder.sort_order - b.folder.sort_order;
|
||
return (a.folder.name || '').localeCompare(b.folder.name || '');
|
||
});
|
||
for (var j = 0; j < nodes.length; j++) {
|
||
var prefix = '';
|
||
for (var k = 0; k < depth; k++) prefix += '\u00A0\u00A0';
|
||
if (depth > 0) prefix += '\u2514 ';
|
||
flat.push({ id: nodes[j].folder.id, label: prefix + nodes[j].folder.name });
|
||
walk(nodes[j].children, depth + 1);
|
||
}
|
||
}
|
||
walk(roots, 0);
|
||
return flat;
|
||
}, [folders]);
|
||
|
||
if (!note) {
|
||
return html`<div class="ext-notes-editor__empty">Select a note or create a new one</div>`;
|
||
}
|
||
|
||
// ── Render editor body ─────────────────────
|
||
function renderEditorBody() {
|
||
if (viewMode === 'rendered') {
|
||
return html`<div class="ext-notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`;
|
||
}
|
||
if (viewMode === 'split') {
|
||
var editor = hasCM
|
||
? html`<div class="ext-notes-editor__cm" ref=${cmContainerRef} />`
|
||
: html`<textarea class="ext-notes-editor__textarea" ref=${textareaRef}
|
||
value=${body} onInput=${handleBodyChange}
|
||
onKeyDown=${handleKeyDown}
|
||
placeholder="Start writing in Markdown…" />`;
|
||
return [
|
||
editor,
|
||
html`<div class="ext-notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
|
||
];
|
||
}
|
||
// edit mode
|
||
if (hasCM) {
|
||
return html`<div class="ext-notes-editor__cm" ref=${cmContainerRef} />`;
|
||
}
|
||
return html`<textarea class="ext-notes-editor__textarea" ref=${textareaRef}
|
||
value=${body} onInput=${handleBodyChange}
|
||
onKeyDown=${handleKeyDown}
|
||
placeholder="Start writing in Markdown…" />`;
|
||
}
|
||
|
||
return html`
|
||
<div class="ext-notes-editor">
|
||
<div class="ext-notes-editor__header">
|
||
<input class="ext-notes-editor__title-input" type="text"
|
||
value=${title} onInput=${handleTitleChange}
|
||
placeholder="Note title" />
|
||
<div class="ext-notes-editor__actions">
|
||
<select class="ext-notes-editor__folder-select"
|
||
value=${note.folder_id || ''}
|
||
onChange=${async function(e) {
|
||
await api.post('/notes/move', { note_id: note.id, folder_id: e.target.value });
|
||
onRefresh();
|
||
}}>
|
||
<option value="">Unfiled</option>
|
||
${folderOptions.map(function(f) {
|
||
return html`<option key=${f.id} value=${f.id}>${f.label}</option>`;
|
||
})}
|
||
</select>
|
||
<span class=${dirty ? 'ext-notes-saved ext-notes-saved--dirty' : 'ext-notes-saved'}>
|
||
${saving ? 'Saving…' : (dirty ? 'Unsaved' : 'Saved')}
|
||
</span>
|
||
<button class="ext-notes-toggle" onClick=${function() {
|
||
var next = viewMode === 'rendered' ? 'edit' : viewMode === 'edit' ? 'split' : 'rendered';
|
||
_modeStore.set('editor_mode', next);
|
||
setViewMode(next);
|
||
}}>
|
||
${viewMode === 'rendered' ? 'Edit' : viewMode === 'edit' ? 'Split' : 'Read'}
|
||
</button>
|
||
<button class="ext-notes-btn" onClick=${handleExport} title="Export as .md">Export</button>
|
||
<button class="ext-notes-btn" onClick=${handlePin}
|
||
title=${note.pinned ? 'Unpin' : 'Pin'}>
|
||
${note.pinned ? '📌' : '📍'}
|
||
</button>
|
||
<button class="ext-notes-btn ext-notes-btn--danger" onClick=${handleArchive}
|
||
title="Archive">🗑</button>
|
||
</div>
|
||
</div>
|
||
<${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} />
|
||
<div class="ext-notes-editor__content">
|
||
<div class="ext-notes-editor__body ${viewMode === 'split' ? 'ext-notes-editor__body--split' : ''}">
|
||
${renderEditorBody()}
|
||
</div>
|
||
</div>
|
||
<${BacklinksPanel} noteId=${note.id} onNavigate=${onNavigate} />
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════
|
||
// GraphPane — force-directed note graph
|
||
// ═══════════════════════════════════════════
|
||
|
||
var GRAPH_PALETTE = [
|
||
'#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f',
|
||
'#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ac'
|
||
];
|
||
|
||
function GraphPane(props) {
|
||
var folders = props.folders || [];
|
||
var activeTag = props.activeTag;
|
||
var onSelectNote = props.onSelectNote;
|
||
var onFocusNote = props.onFocusNote;
|
||
|
||
var [graphData, setGraphData] = useState(null);
|
||
var [loading, setLoading] = useState(true);
|
||
var [focusedIds, setFocusedIds] = useState(null); // Set or null
|
||
var [hideOrphans, setHideOrphans] = useState(false);
|
||
var [hoveredNode, setHoveredNode] = useState(null);
|
||
var [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||
|
||
var canvasRef = useRef(null);
|
||
var simRef = useRef({
|
||
nodes: [], edges: [], adj: {},
|
||
positions: [], velocities: [],
|
||
offsetX: 0, offsetY: 0, scale: 1,
|
||
dragging: false, dragStartX: 0, dragStartY: 0,
|
||
converged: false, frameCount: 0
|
||
});
|
||
var animRef = useRef(null);
|
||
|
||
// build folder→color map
|
||
var folderColorMap = useMemo(function() {
|
||
var map = { '': '#888' };
|
||
folders.forEach(function(f, i) {
|
||
map[f.id] = GRAPH_PALETTE[i % GRAPH_PALETTE.length];
|
||
});
|
||
return map;
|
||
}, [folders]);
|
||
|
||
// fetch graph data
|
||
useEffect(function() {
|
||
var cancelled = false;
|
||
(async function() {
|
||
try {
|
||
var res = await api.get('/graph');
|
||
if (cancelled) return;
|
||
var nodes = res.nodes || [];
|
||
var edges = res.edges || [];
|
||
|
||
// build adjacency map
|
||
var adj = {};
|
||
nodes.forEach(function(n) { adj[n.id] = new Set(); });
|
||
edges.forEach(function(e) {
|
||
if (adj[e.source]) adj[e.source].add(e.target);
|
||
if (adj[e.target]) adj[e.target].add(e.source);
|
||
});
|
||
|
||
// initial positions in a circle
|
||
var radius = Math.sqrt(nodes.length) * 50 + 50;
|
||
var positions = nodes.map(function(_, i) {
|
||
var angle = (2 * Math.PI * i) / (nodes.length || 1);
|
||
return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius };
|
||
});
|
||
var velocities = nodes.map(function() { return { x: 0, y: 0 }; });
|
||
|
||
var sim = simRef.current;
|
||
sim.nodes = nodes;
|
||
sim.edges = edges;
|
||
sim.adj = adj;
|
||
sim.positions = positions;
|
||
sim.velocities = velocities;
|
||
sim.converged = false;
|
||
sim.frameCount = 0;
|
||
|
||
setGraphData({ nodes: nodes, edges: edges, adj: adj });
|
||
} catch (e) {
|
||
console.error('Graph load failed:', e);
|
||
} finally {
|
||
if (!cancelled) setLoading(false);
|
||
}
|
||
})();
|
||
return function() { cancelled = true; };
|
||
}, []);
|
||
|
||
// force simulation + rendering loop
|
||
useEffect(function() {
|
||
var canvas = canvasRef.current;
|
||
if (!canvas || !graphData) return;
|
||
var ctx = canvas.getContext('2d');
|
||
var sim = simRef.current;
|
||
// resolve CSS vars for canvas (which can't use var())
|
||
var cs = getComputedStyle(canvas.parentElement);
|
||
var colorText = cs.getPropertyValue('--text').trim() || '#ccc';
|
||
var colorText3 = cs.getPropertyValue('--text-3').trim() || '#999';
|
||
var colorAccent = cs.getPropertyValue('--accent').trim() || '#4e79a7';
|
||
|
||
function resize() {
|
||
var rect = canvas.parentElement.getBoundingClientRect();
|
||
var dpr = window.devicePixelRatio || 1;
|
||
canvas.width = rect.width * dpr;
|
||
canvas.height = rect.height * dpr;
|
||
canvas.style.width = rect.width + 'px';
|
||
canvas.style.height = rect.height + 'px';
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
}
|
||
resize();
|
||
var ro = new ResizeObserver(resize);
|
||
ro.observe(canvas.parentElement);
|
||
|
||
function step() {
|
||
var nodes = sim.nodes;
|
||
var edges = sim.edges;
|
||
var pos = sim.positions;
|
||
var vel = sim.velocities;
|
||
var N = nodes.length;
|
||
|
||
// force simulation (skip if converged)
|
||
if (!sim.converged && N > 0) {
|
||
// reset forces
|
||
var forces = [];
|
||
for (var i = 0; i < N; i++) forces.push({ x: 0, y: 0 });
|
||
|
||
// repulsion between all pairs
|
||
for (var i = 0; i < N; i++) {
|
||
for (var j = i + 1; j < N; j++) {
|
||
var dx = pos[i].x - pos[j].x;
|
||
var dy = pos[i].y - pos[j].y;
|
||
var distSq = dx * dx + dy * dy + 1;
|
||
var f = 5000 / distSq;
|
||
var fx = dx / Math.sqrt(distSq) * f;
|
||
var fy = dy / Math.sqrt(distSq) * f;
|
||
forces[i].x += fx; forces[i].y += fy;
|
||
forces[j].x -= fx; forces[j].y -= fy;
|
||
}
|
||
}
|
||
|
||
// attraction along edges
|
||
for (var e = 0; e < edges.length; e++) {
|
||
var si = -1, ti = -1;
|
||
for (var k = 0; k < N; k++) {
|
||
if (nodes[k].id === edges[e].source) si = k;
|
||
if (nodes[k].id === edges[e].target) ti = k;
|
||
}
|
||
if (si < 0 || ti < 0) continue;
|
||
var dx = pos[ti].x - pos[si].x;
|
||
var dy = pos[ti].y - pos[si].y;
|
||
var dist = Math.sqrt(dx * dx + dy * dy) + 0.1;
|
||
var f = 0.02 * (dist - 100);
|
||
forces[si].x += (dx / dist) * f;
|
||
forces[si].y += (dy / dist) * f;
|
||
forces[ti].x -= (dx / dist) * f;
|
||
forces[ti].y -= (dy / dist) * f;
|
||
}
|
||
|
||
// center gravity
|
||
for (var i = 0; i < N; i++) {
|
||
forces[i].x -= pos[i].x * 0.001;
|
||
forces[i].y -= pos[i].y * 0.001;
|
||
}
|
||
|
||
// integrate
|
||
var maxV = 0;
|
||
for (var i = 0; i < N; i++) {
|
||
vel[i].x = (vel[i].x + forces[i].x) * 0.85;
|
||
vel[i].y = (vel[i].y + forces[i].y) * 0.85;
|
||
pos[i].x += vel[i].x;
|
||
pos[i].y += vel[i].y;
|
||
var v = Math.abs(vel[i].x) + Math.abs(vel[i].y);
|
||
if (v > maxV) maxV = v;
|
||
}
|
||
|
||
sim.frameCount++;
|
||
if ((maxV < 0.5 && sim.frameCount > 50) || sim.frameCount > 500) {
|
||
sim.converged = true;
|
||
}
|
||
}
|
||
|
||
// render
|
||
var w = canvas.width / (window.devicePixelRatio || 1);
|
||
var h = canvas.height / (window.devicePixelRatio || 1);
|
||
ctx.clearRect(0, 0, w, h);
|
||
ctx.save();
|
||
ctx.translate(sim.offsetX + w / 2, sim.offsetY + h / 2);
|
||
ctx.scale(sim.scale, sim.scale);
|
||
|
||
var focused = focusedIds;
|
||
var nodes = sim.nodes;
|
||
var edges = sim.edges;
|
||
var pos = sim.positions;
|
||
var adj = sim.adj;
|
||
|
||
// build index lookup for edge drawing
|
||
var idxMap = {};
|
||
for (var i = 0; i < nodes.length; i++) idxMap[nodes[i].id] = i;
|
||
|
||
// filter orphans
|
||
var visibleSet = null;
|
||
if (hideOrphans) {
|
||
visibleSet = new Set();
|
||
for (var i = 0; i < nodes.length; i++) {
|
||
if (adj[nodes[i].id] && adj[nodes[i].id].size > 0) visibleSet.add(nodes[i].id);
|
||
}
|
||
}
|
||
|
||
// draw edges
|
||
for (var e = 0; e < edges.length; e++) {
|
||
var si = idxMap[edges[e].source];
|
||
var ti = idxMap[edges[e].target];
|
||
if (si == null || ti == null) continue;
|
||
if (visibleSet && (!visibleSet.has(edges[e].source) || !visibleSet.has(edges[e].target))) continue;
|
||
|
||
var edgeFocused = !focused || (focused.has(edges[e].source) && focused.has(edges[e].target));
|
||
ctx.globalAlpha = edgeFocused ? 0.4 : 0.06;
|
||
ctx.strokeStyle = edgeFocused && focused ? colorAccent : '#999';
|
||
ctx.lineWidth = edgeFocused && focused ? 1.5 : 0.8;
|
||
ctx.beginPath();
|
||
ctx.moveTo(pos[si].x, pos[si].y);
|
||
ctx.lineTo(pos[ti].x, pos[ti].y);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// draw nodes
|
||
for (var i = 0; i < nodes.length; i++) {
|
||
var node = nodes[i];
|
||
if (visibleSet && !visibleSet.has(node.id)) continue;
|
||
var edgeCount = adj[node.id] ? adj[node.id].size : 0;
|
||
var isOrphan = edgeCount === 0;
|
||
|
||
// opacity based on focus + tag filter
|
||
var nodeFocused = !focused || focused.has(node.id);
|
||
var tagMatch = !activeTag || (node.tags && node.tags.indexOf(activeTag) !== -1);
|
||
ctx.globalAlpha = nodeFocused ? (tagMatch ? 1 : 0.3) : 0.15;
|
||
|
||
var color = folderColorMap[node.folder_id] || '#888';
|
||
var r = 8;
|
||
|
||
// circle
|
||
ctx.beginPath();
|
||
ctx.arc(pos[i].x, pos[i].y, r, 0, Math.PI * 2);
|
||
ctx.fillStyle = color;
|
||
ctx.fill();
|
||
|
||
if (isOrphan) {
|
||
ctx.setLineDash([3, 2]);
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 1.5;
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
|
||
// label
|
||
ctx.fillStyle = nodeFocused && tagMatch ? colorText : colorText3;
|
||
ctx.font = '11px -apple-system, BlinkMacSystemFont, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
var label = node.title.length > 20 ? node.title.slice(0, 18) + '…' : node.title;
|
||
ctx.fillText(label, pos[i].x, pos[i].y + r + 13);
|
||
}
|
||
|
||
ctx.restore();
|
||
ctx.globalAlpha = 1;
|
||
|
||
animRef.current = requestAnimationFrame(step);
|
||
}
|
||
|
||
animRef.current = requestAnimationFrame(step);
|
||
return function() {
|
||
cancelAnimationFrame(animRef.current);
|
||
ro.disconnect();
|
||
};
|
||
}, [graphData, focusedIds, hideOrphans, activeTag, folderColorMap]);
|
||
|
||
// hit-test helper
|
||
function hitTest(ex, ey) {
|
||
var sim = simRef.current;
|
||
var canvas = canvasRef.current;
|
||
if (!canvas) return null;
|
||
var w = canvas.width / (window.devicePixelRatio || 1);
|
||
var h = canvas.height / (window.devicePixelRatio || 1);
|
||
var wx = (ex - sim.offsetX - w / 2) / sim.scale;
|
||
var wy = (ey - sim.offsetY - h / 2) / sim.scale;
|
||
var best = null, bestDist = (12 / sim.scale) * (12 / sim.scale);
|
||
for (var i = 0; i < sim.nodes.length; i++) {
|
||
var dx = sim.positions[i].x - wx;
|
||
var dy = sim.positions[i].y - wy;
|
||
var d = dx * dx + dy * dy;
|
||
if (d < bestDist) { bestDist = d; best = i; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function handleClick(e) {
|
||
var rect = canvasRef.current.getBoundingClientRect();
|
||
var ex = e.clientX - rect.left;
|
||
var ey = e.clientY - rect.top;
|
||
var idx = hitTest(ex, ey);
|
||
if (idx === null) {
|
||
setFocusedIds(null);
|
||
return;
|
||
}
|
||
var node = simRef.current.nodes[idx];
|
||
var adj = simRef.current.adj;
|
||
var neighborhood = new Set([node.id]);
|
||
if (adj[node.id]) adj[node.id].forEach(function(id) { neighborhood.add(id); });
|
||
|
||
if (e.shiftKey && focusedIds) {
|
||
// union with existing focus
|
||
var merged = new Set(focusedIds);
|
||
neighborhood.forEach(function(id) { merged.add(id); });
|
||
setFocusedIds(merged);
|
||
} else {
|
||
setFocusedIds(neighborhood);
|
||
}
|
||
if (onFocusNote) onFocusNote(node.id);
|
||
}
|
||
|
||
function handleDblClick(e) {
|
||
var rect = canvasRef.current.getBoundingClientRect();
|
||
var idx = hitTest(e.clientX - rect.left, e.clientY - rect.top);
|
||
if (idx !== null && onSelectNote) {
|
||
onSelectNote(simRef.current.nodes[idx].id);
|
||
}
|
||
}
|
||
|
||
function handleMouseMove(e) {
|
||
var rect = canvasRef.current.getBoundingClientRect();
|
||
var ex = e.clientX - rect.left;
|
||
var ey = e.clientY - rect.top;
|
||
|
||
// panning
|
||
if (simRef.current.dragging) {
|
||
simRef.current.offsetX += e.movementX;
|
||
simRef.current.offsetY += e.movementY;
|
||
return;
|
||
}
|
||
|
||
var idx = hitTest(ex, ey);
|
||
if (idx !== null) {
|
||
setHoveredNode(simRef.current.nodes[idx]);
|
||
setMousePos({ x: ex, y: ey });
|
||
} else {
|
||
setHoveredNode(null);
|
||
}
|
||
}
|
||
|
||
function handleMouseDown(e) {
|
||
var rect = canvasRef.current.getBoundingClientRect();
|
||
var idx = hitTest(e.clientX - rect.left, e.clientY - rect.top);
|
||
if (idx === null) {
|
||
simRef.current.dragging = true;
|
||
}
|
||
}
|
||
|
||
function handleMouseUp() {
|
||
simRef.current.dragging = false;
|
||
}
|
||
|
||
function handleWheel(e) {
|
||
e.preventDefault();
|
||
var sim = simRef.current;
|
||
var canvas = canvasRef.current;
|
||
var rect = canvas.getBoundingClientRect();
|
||
var mx = e.clientX - rect.left;
|
||
var my = e.clientY - rect.top;
|
||
var w = rect.width;
|
||
var h = rect.height;
|
||
|
||
var zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
||
var newScale = Math.max(0.1, Math.min(5.0, sim.scale * zoomFactor));
|
||
// zoom toward cursor (relative to center)
|
||
var cx = mx - w / 2;
|
||
var cy = my - h / 2;
|
||
sim.offsetX = cx - (cx - sim.offsetX) * (newScale / sim.scale);
|
||
sim.offsetY = cy - (cy - sim.offsetY) * (newScale / sim.scale);
|
||
sim.scale = newScale;
|
||
}
|
||
|
||
// attach wheel with passive:false
|
||
useEffect(function() {
|
||
var canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
canvas.addEventListener('wheel', handleWheel, { passive: false });
|
||
return function() { canvas.removeEventListener('wheel', handleWheel); };
|
||
}, []);
|
||
|
||
if (loading) {
|
||
return html`<div class="ext-notes-graph-pane"><div class="ext-notes-graph-empty"><${Spinner} size="md" /></div></div>`;
|
||
}
|
||
if (!graphData || graphData.nodes.length === 0) {
|
||
return html`<div class="ext-notes-graph-pane"><div class="ext-notes-graph-empty">No notes to graph. Create notes with [[wikilinks]] to see connections.</div></div>`;
|
||
}
|
||
|
||
var adj = graphData.adj;
|
||
|
||
return html`
|
||
<div class="ext-notes-graph-pane">
|
||
<div class="ext-notes-graph-toolbar">
|
||
<label>
|
||
<input type="checkbox" checked=${hideOrphans}
|
||
onChange=${function(e) { setHideOrphans(e.target.checked); }} />
|
||
Hide orphans
|
||
</label>
|
||
</div>
|
||
<canvas ref=${canvasRef}
|
||
onClick=${handleClick}
|
||
onDblClick=${handleDblClick}
|
||
onMouseMove=${handleMouseMove}
|
||
onMouseDown=${handleMouseDown}
|
||
onMouseUp=${handleMouseUp}
|
||
onMouseLeave=${handleMouseUp} />
|
||
${hoveredNode && html`
|
||
<div class="ext-notes-graph-tooltip" style=${{ left: (mousePos.x + 14) + 'px', top: (mousePos.y - 10) + 'px' }}>
|
||
<div class="ext-notes-graph-tooltip__title">${hoveredNode.title}</div>
|
||
${hoveredNode.tags && hoveredNode.tags.length > 0 && html`
|
||
<div class="ext-notes-graph-tooltip__tags">
|
||
${hoveredNode.tags.map(function(t) { return html`<span class="ext-notes-tag-pill" key=${t}>${t}</span>`; })}
|
||
</div>
|
||
`}
|
||
<div class="ext-notes-graph-tooltip__edges">${(adj[hoveredNode.id] ? adj[hoveredNode.id].size : 0)} connections</div>
|
||
</div>
|
||
`}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
|
||
// ═══════════════════════════════════════════
|
||
// NotesApp — root component
|
||
// ═══════════════════════════════════════════
|
||
|
||
function NotesApp() {
|
||
var [notes, setNotes] = useState([]);
|
||
var [loading, setLoading] = useState(true);
|
||
var [activeId, setActiveId] = useState(null);
|
||
var [activeNote, setActiveNote] = useState(null);
|
||
var [searchTerm, setSearchTerm] = useState('');
|
||
var [stats, setStats] = useState(null);
|
||
var [folders, setFolders] = useState([]);
|
||
var [activeFolderId, setActiveFolderId] = useState(null);
|
||
var [showUnfiled, setShowUnfiled] = useState(false);
|
||
var [allTags, setAllTags] = useState([]);
|
||
var [activeTag, setActiveTag] = useState(null);
|
||
var [sidebarTab, setSidebarTab] = useState('notes');
|
||
var [sidebarHeadings, setSidebarHeadings] = useState([]);
|
||
var [activeHeadingIdx, setActiveHeadingIdx] = useState(-1);
|
||
var [showGraph, setShowGraph] = useState(false);
|
||
var scrollToHeadingRef = useRef(null);
|
||
|
||
// ── Load note list ────────────────────────
|
||
var loadNotes = useCallback(async function() {
|
||
try {
|
||
var res;
|
||
if (searchTerm.trim()) {
|
||
res = await api.get('/search?q=' + encodeURIComponent(searchTerm.trim()));
|
||
} else if (activeFolderId) {
|
||
res = await api.get('/notes?folder_id=' + encodeURIComponent(activeFolderId));
|
||
} else {
|
||
res = await api.get('/notes');
|
||
}
|
||
var items = (res && res.data) || res || [];
|
||
if (!Array.isArray(items)) items = [];
|
||
// filter to unfiled when that view is active
|
||
if (showUnfiled && !searchTerm.trim() && !activeFolderId) {
|
||
items = items.filter(function(n) { return !n.folder_id; });
|
||
}
|
||
// filter by active tag (client-side)
|
||
if (activeTag) {
|
||
items = items.filter(function(n) {
|
||
return n.tags && n.tags.indexOf(activeTag) !== -1;
|
||
});
|
||
}
|
||
// sort: pinned first, then by updated_at desc
|
||
items.sort(function(a, b) {
|
||
if (a.pinned !== b.pinned) return b.pinned - a.pinned;
|
||
return (b.updated_at || b.created_at || '').localeCompare(a.updated_at || a.created_at || '');
|
||
});
|
||
setNotes(items);
|
||
} catch (e) {
|
||
console.error('Load notes failed:', e);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [searchTerm, activeFolderId, showUnfiled, activeTag]);
|
||
|
||
// ── Load single note (full body) ──────────
|
||
var loadNote = useCallback(async function(id) {
|
||
if (!id) { setActiveNote(null); return; }
|
||
try {
|
||
var note = await api.get('/notes/' + id);
|
||
setActiveNote(note);
|
||
} catch (e) {
|
||
console.error('Load note failed:', e);
|
||
setActiveNote(null);
|
||
}
|
||
}, []);
|
||
|
||
// ── Load folders ──────────────────────────
|
||
var loadFolders = useCallback(async function() {
|
||
try {
|
||
var res = await api.get('/folders');
|
||
var items = (res && res.data) || res || [];
|
||
setFolders(Array.isArray(items) ? items : []);
|
||
} catch (e) { console.error('Load folders failed:', e); }
|
||
}, []);
|
||
|
||
// ── Load stats ────────────────────────────
|
||
var loadStats = useCallback(async function() {
|
||
try {
|
||
var s = await api.get('/stats');
|
||
setStats(s);
|
||
} catch (e) { /* ignore */ }
|
||
}, []);
|
||
|
||
// ── Load all tags ─────────────────────────
|
||
var loadTags = useCallback(async function() {
|
||
try {
|
||
var res = await api.get('/tags');
|
||
var items = (res && res.data) || res || [];
|
||
setAllTags(Array.isArray(items) ? items : []);
|
||
} catch (e) { console.error('Load tags failed:', e); }
|
||
}, []);
|
||
|
||
useEffect(function() { loadNotes(); loadFolders(); loadStats(); loadTags(); }, [loadNotes]);
|
||
|
||
// select note
|
||
function handleSelect(id) {
|
||
setActiveId(id);
|
||
loadNote(id);
|
||
}
|
||
|
||
// ── Create note ───────────────────────────
|
||
async function handleNew() {
|
||
try {
|
||
var payload = { title: 'Untitled', body: '' };
|
||
if (activeFolderId) payload.folder_id = activeFolderId;
|
||
var note = await api.post('/notes', payload);
|
||
if (note && note.id) {
|
||
setActiveId(note.id);
|
||
loadNote(note.id);
|
||
loadNotes();
|
||
loadStats();
|
||
}
|
||
} catch (e) {
|
||
console.error('Create failed:', e);
|
||
}
|
||
}
|
||
|
||
// ── Refresh after save ────────────────────
|
||
function handleRefresh() {
|
||
loadNotes();
|
||
if (activeId) loadNote(activeId);
|
||
loadStats();
|
||
}
|
||
|
||
// ── After delete ──────────────────────────
|
||
function handleDelete() {
|
||
setActiveId(null);
|
||
setActiveNote(null);
|
||
setSidebarTab('notes');
|
||
setSidebarHeadings([]);
|
||
setActiveHeadingIdx(-1);
|
||
loadNotes();
|
||
loadStats();
|
||
loadTags();
|
||
}
|
||
|
||
// ── Tags changed in editor ────────────────
|
||
function handleTagsChanged() {
|
||
loadTags();
|
||
loadNotes();
|
||
loadStats();
|
||
}
|
||
|
||
// ── Tag filter ────────────────────────────
|
||
function handleTagFilter(tag) {
|
||
setActiveTag(tag);
|
||
}
|
||
|
||
// ── Drag-and-drop move note to folder ─────
|
||
async function handleDropNote(noteId, folderId) {
|
||
try {
|
||
await api.post('/notes/move', { note_id: noteId, folder_id: folderId });
|
||
loadNotes();
|
||
loadStats();
|
||
if (activeId === noteId) loadNote(activeId);
|
||
} catch (e) { console.error('Move note failed:', e); }
|
||
}
|
||
|
||
// ── Folder actions ───────────────────────
|
||
function handleSelectFolder(folderId) {
|
||
setActiveFolderId(folderId);
|
||
setShowUnfiled(false);
|
||
setSearchTerm('');
|
||
}
|
||
function handleSelectAll() {
|
||
setActiveFolderId(null);
|
||
setShowUnfiled(false);
|
||
}
|
||
function handleSelectUnfiled() {
|
||
setActiveFolderId(null);
|
||
setShowUnfiled(true);
|
||
}
|
||
async function handleCreateFolder(parentId) {
|
||
var name = await sw.prompt('Folder name:');
|
||
if (!name || !name.trim()) return;
|
||
try {
|
||
await api.post('/folders', { name: name.trim(), parent_id: parentId || '' });
|
||
loadFolders();
|
||
loadStats();
|
||
} catch (e) { console.error('Create folder failed:', e); }
|
||
}
|
||
async function handleRenameFolder(folderId, newName) {
|
||
try {
|
||
await api.put('/folders/' + folderId, { name: newName });
|
||
loadFolders();
|
||
} catch (e) { console.error('Rename folder failed:', e); }
|
||
}
|
||
async function handleDeleteFolder(folderId) {
|
||
if (!await sw.confirm('Delete this folder? Notes will be moved to Unfiled.', { destructive: true })) return;
|
||
try {
|
||
await api.del('/folders/' + folderId);
|
||
if (activeFolderId === folderId) setActiveFolderId(null);
|
||
loadFolders();
|
||
loadNotes();
|
||
loadStats();
|
||
} catch (e) { console.error('Delete folder failed:', e); }
|
||
}
|
||
|
||
// ── Wikilink navigation by title ─────────
|
||
async function handleNavigateToTitle(title) {
|
||
try {
|
||
var res = await api.get('/search?q=' + encodeURIComponent(title.trim()));
|
||
var items = (res && res.data) || res || [];
|
||
if (!Array.isArray(items)) items = [];
|
||
var match = null;
|
||
for (var i = 0; i < items.length; i++) {
|
||
if ((items[i].title || '').toLowerCase().trim() === title.toLowerCase().trim()) {
|
||
match = items[i];
|
||
break;
|
||
}
|
||
}
|
||
if (match) {
|
||
handleSelect(match.id);
|
||
} else {
|
||
// create the note and navigate to it
|
||
var sourceId = activeId;
|
||
var newNote = await api.post('/notes', { title: title.trim(), body: '' });
|
||
if (newNote && newNote.id) {
|
||
// re-save source note so _sync_links resolves the new target
|
||
if (sourceId && activeNote) {
|
||
await api.put('/notes/' + sourceId, { body: activeNote.body || '' });
|
||
}
|
||
setActiveId(newNote.id);
|
||
loadNote(newNote.id);
|
||
loadNotes();
|
||
loadStats();
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Navigate to title failed:', e);
|
||
}
|
||
}
|
||
|
||
// ── Import .md file ─────────────────────
|
||
function handleImport() {
|
||
var input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = '.md,.markdown,.txt';
|
||
input.style.display = 'none';
|
||
input.onchange = async function() {
|
||
var file = input.files && input.files[0];
|
||
if (!file) return;
|
||
try {
|
||
var text = await new Promise(function(resolve, reject) {
|
||
var reader = new FileReader();
|
||
reader.onload = function() { resolve(reader.result); };
|
||
reader.onerror = reject;
|
||
reader.readAsText(file);
|
||
});
|
||
var parsed = parseFrontmatter(text);
|
||
var noteTitle = parsed.title || file.name.replace(/\.(md|markdown|txt)$/i, '') || 'Imported';
|
||
var payload = { title: noteTitle, body: parsed.body };
|
||
if (activeFolderId) payload.folder_id = activeFolderId;
|
||
var newNote = await api.post('/notes', payload);
|
||
if (newNote && newNote.id) {
|
||
if (parsed.tags.length) {
|
||
await api.put('/tags/' + newNote.id, { tags: parsed.tags });
|
||
}
|
||
setActiveId(newNote.id);
|
||
loadNote(newNote.id);
|
||
loadNotes();
|
||
loadStats();
|
||
loadTags();
|
||
}
|
||
} catch (e) {
|
||
console.error('Import failed:', e);
|
||
}
|
||
document.body.removeChild(input);
|
||
};
|
||
document.body.appendChild(input);
|
||
input.click();
|
||
}
|
||
|
||
// ── Search ────────────────────────────────
|
||
function handleSearch(e) {
|
||
var val = e.target.value;
|
||
setSearchTerm(val);
|
||
debounce('search', function() { loadNotes(); }, 300);
|
||
}
|
||
|
||
// ── Shell topbar ───────────────────────────
|
||
useEffect(() => {
|
||
if (!sw.shell?.topbar) return;
|
||
sw.shell.topbar.setTitle('Notes');
|
||
sw.shell.topbar.setSlot(html`
|
||
<${Button} variant="primary" size="sm" onClick=${handleNew}>+ New Note<//>
|
||
<${Button} variant="secondary" size="sm" onClick=${handleImport}>Import .md<//>
|
||
<${Button} variant=${showGraph ? 'primary' : 'secondary'} size="sm"
|
||
onClick=${function() { setShowGraph(!showGraph); }}>Graph<//>
|
||
`);
|
||
}, [showGraph]);
|
||
|
||
return html`
|
||
<div class="ext-notes-app">
|
||
<div class="ext-notes-sidebar">
|
||
<${SidebarTabs} activeTab=${sidebarTab} onTabChange=${setSidebarTab} noteSelected=${!!activeNote} />
|
||
${sidebarTab === 'notes' && html`
|
||
<${FolderTree} folders=${folders}
|
||
activeFolderId=${activeFolderId} showUnfiled=${showUnfiled}
|
||
onSelectFolder=${handleSelectFolder} onSelectAll=${handleSelectAll}
|
||
onSelectUnfiled=${handleSelectUnfiled}
|
||
onCreateFolder=${handleCreateFolder}
|
||
onRenameFolder=${handleRenameFolder}
|
||
onDeleteFolder=${handleDeleteFolder}
|
||
onDropNote=${handleDropNote} />
|
||
<${TagFilter} tags=${allTags} activeTag=${activeTag} onSelect=${handleTagFilter} />
|
||
<div class="ext-notes-search">
|
||
<input type="text" placeholder="Search notes…"
|
||
value=${searchTerm} onInput=${handleSearch} />
|
||
</div>
|
||
<div class="ext-notes-list">
|
||
${loading && html`<div class="ext-notes-list__loading"><${Spinner} size="md" /></div>`}
|
||
${!loading && notes.length === 0 && html`
|
||
<div class="ext-notes-list__empty">
|
||
${searchTerm ? 'No matching notes' : (activeTag ? 'No notes with tag "' + activeTag + '"' : 'No notes yet. Click + New Note to start.')}
|
||
</div>
|
||
`}
|
||
${!loading && notes.map(function(n) {
|
||
return html`
|
||
<${NoteCard} key=${n.id} note=${n}
|
||
active=${n.id === activeId}
|
||
onClick=${() => handleSelect(n.id)} />
|
||
`;
|
||
})}
|
||
</div>
|
||
`}
|
||
${sidebarTab === 'outline' && html`
|
||
<${SidebarOutline} headings=${sidebarHeadings}
|
||
activeHeadingIdx=${activeHeadingIdx}
|
||
onScrollToHeading=${function(h) { if (scrollToHeadingRef.current) scrollToHeadingRef.current(h); }} />
|
||
`}
|
||
</div>
|
||
${showGraph
|
||
? html`<${GraphPane} folders=${folders} allTags=${allTags}
|
||
activeTag=${activeTag}
|
||
onSelectNote=${function(id) { handleSelect(id); setShowGraph(false); }}
|
||
onFocusNote=${handleSelect} />`
|
||
: html`<${EditorPane} note=${activeNote} folders=${folders} allTags=${allTags}
|
||
onSave=${handleRefresh}
|
||
onDelete=${handleDelete}
|
||
onRefresh=${handleRefresh}
|
||
onTagsChanged=${handleTagsChanged}
|
||
onNavigate=${handleSelect}
|
||
onNavigateToTitle=${handleNavigateToTitle}
|
||
onHeadingsChange=${setSidebarHeadings}
|
||
scrollToHeadingRef=${scrollToHeadingRef}
|
||
onActiveHeadingChange=${setActiveHeadingIdx} />`
|
||
}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ── Mount ──────────────────────────────────
|
||
render(html`<${NotesApp} />`, mount);
|
||
|
||
})();
|