This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/packages/notes/js/main.js
Jeffrey Smith 0af1c51ae9
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 18s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m42s
CI/CD / test-sqlite (pull_request) Successful in 2m59s
CI/CD / build-and-deploy (pull_request) Successful in 1m24s
Feat v0.5.0 realtime primitive + dialog audit + permissions UI
Add realtime pub/sub: Starlark realtime.publish() module gated by
new realtime.publish permission, WS room protocol (room.subscribe/
room.unsubscribe intercepted in readPump with 100-room cap), and
SDK sw.realtime.subscribe() with auto room join/leave and reconnect
recovery.

Migrate 5 bare confirm() calls to sw.confirm() with destructive
styling in tasks, schedules, notes (×2), and editor packages.

Add admin permissions UI: per-permission grant/revoke drawer,
Grant All bulk action, pending_review/suspended status badges,
disabled enable toggle when pending. Closes v0.4.7 permissions
UI gap.

8 new Go tests, all passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:40:24 +00:00

1882 lines
71 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Notes — Surface Entry Point (v0.8.0)
*
* Markdown notes surface using the SDK:
* sw.api.ext('notes') — scoped API client
* sw.ui.* — primitive components
* sw.shell.Topbar — navigation bar
* 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;
var Topbar = sw.shell.Topbar;
// ── Debounce helper ────────────────────────
var _timers = {};
function debounce(key, fn, ms) {
clearTimeout(_timers[key]);
_timers[key] = setTimeout(fn, ms);
}
// ═══════════════════════════════════════════
// Markdown renderer (inline, no deps)
// ═══════════════════════════════════════════
function renderMarkdown(src) {
if (!src) return '';
var lines = src.split('\n');
var out = [];
var inCode = false;
var inList = false;
var listTag = '';
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
// fenced code blocks
if (line.trim().startsWith('```')) {
if (inCode) {
out.push('</code></pre>');
inCode = false;
} else {
if (inList) { out.push('</' + listTag + '>'); inList = false; }
out.push('<pre><code>');
inCode = true;
}
continue;
}
if (inCode) {
out.push(escHtml(line) + '\n');
continue;
}
// blank line
if (!line.trim()) {
if (inList) { out.push('</' + listTag + '>'); inList = false; }
continue;
}
// headings
if (line.startsWith('### ')) { closeList(); out.push('<h3>' + inline(line.slice(4)) + '</h3>'); continue; }
if (line.startsWith('## ')) { closeList(); out.push('<h2>' + inline(line.slice(3)) + '</h2>'); continue; }
if (line.startsWith('# ')) { closeList(); out.push('<h1>' + inline(line.slice(2)) + '</h1>'); continue; }
// hr
if (/^[-*_]{3,}\s*$/.test(line)) { closeList(); out.push('<hr>'); continue; }
// blockquote
if (line.startsWith('> ')) { closeList(); out.push('<blockquote><p>' + inline(line.slice(2)) + '</p></blockquote>'); continue; }
// unordered list
if (/^[\-*+]\s/.test(line)) {
if (!inList || listTag !== 'ul') { closeList(); out.push('<ul>'); inList = true; listTag = 'ul'; }
out.push('<li>' + inline(line.replace(/^[\-*+]\s/, '')) + '</li>');
continue;
}
// ordered list
if (/^\d+\.\s/.test(line)) {
if (!inList || listTag !== 'ol') { closeList(); out.push('<ol>'); inList = true; listTag = 'ol'; }
out.push('<li>' + inline(line.replace(/^\d+\.\s/, '')) + '</li>');
continue;
}
// paragraph
closeList();
out.push('<p>' + inline(line) + '</p>');
}
if (inCode) out.push('</code></pre>');
if (inList) out.push('</' + listTag + '>');
return out.join('\n');
function closeList() { if (inList) { out.push('</' + listTag + '>'); inList = false; } }
}
function escHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function inline(s) {
s = escHtml(s);
// inline code
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
// bold
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
s = s.replace(/__(.+?)__/g, '<strong>$1</strong>');
// italic
s = s.replace(/\*(.+?)\*/g, '<em>$1</em>');
s = s.replace(/_(.+?)_/g, '<em>$1</em>');
// links
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// wikilinks: [[Note Title]] → clickable internal link
s = s.replace(/\[\[([^\]]+)\]\]/g, function(match, linkText) {
return '<a class="wikilink" data-wikilink="' + linkText.trim() + '" href="#">' + linkText.trim() + '</a>';
});
return s;
}
// ═══════════════════════════════════════════
// 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="tag-input">
${(tags || []).map(function(tag) {
return html`
<span key=${tag} class="tag-pill">
${tag}
<span class="tag-pill__remove" onClick=${function() { removeTag(tag); }}>×</span>
</span>
`;
})}
<input ref=${inputRef} class="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="tag-autocomplete">
${suggestions.map(function(s) {
return html`<div key=${s} class="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="tag-filter">
<div class="tag-filter__label">
<span>Tags</span>
${activeTag && html`
<button class="tag-filter__clear" onClick=${function() { onSelect(null); }}>Clear</button>
`}
</div>
<div class="tag-filter__pills">
${tags.map(function(tag) {
return html`
<span key=${tag} class="tag-pill ${activeTag === tag ? '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="note-card ${active ? 'note-card--active' : ''} ${dragging ? 'note-card--dragging' : ''}"
onClick=${onClick}
draggable="true"
onDragStart=${handleDragStart}
onDragEnd=${handleDragEnd}>
<div class="note-card__title">
${note.pinned ? html`<span class="note-card__pin">📌</span>` : null}
${note.title || 'Untitled'}
</div>
${note.snippet && html`<div class="note-card__snippet">${note.snippet}</div>`}
${showTags.length > 0 && html`
<div class="note-card__tags">
${showTags.map(function(t) { return html`<span key=${t} class="tag-pill">${t}</span>`; })}
${overflow > 0 && html`<span class="tag-pill tag-pill--overflow">+${overflow}</span>`}
</div>
`}
${dateStr && html`<div class="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="folder-context-menu" style="top: ${y}px; left: ${x}px">
<div class="folder-context-menu__item" onClick=${onAddSub}>Add subfolder</div>
<div class="folder-context-menu__item" onClick=${onRename}>Rename</div>
<div class="folder-context-menu__item 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="folder-tree__item ${isActive ? 'folder-tree__item--active' : ''} ${dropOver ? '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="folder-tree__toggle" onClick=${handleToggle}>
${hasChildren ? (expanded ? '▼' : '▶') : '·'}
</span>
<span class="folder-tree__icon">${isActive ? '📂' : '📁'}</span>
${editing
? html`<input class="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="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="folder-tree">
<div class="folder-tree__item ${!activeFolderId && !showUnfiled ? 'folder-tree__item--active' : ''} ${dropAll ? 'folder-tree__item--drop-target' : ''}"
onClick=${onSelectAll}
onDragOver=${allDrop.onDragOver} onDragLeave=${allDrop.onDragLeave} onDrop=${allDrop.onDrop}>
<span class="folder-tree__icon">📋</span>
<span class="folder-tree__name">All Notes</span>
</div>
<div class="folder-tree__item ${showUnfiled ? 'folder-tree__item--active' : ''} ${dropUnfiled ? 'folder-tree__item--drop-target' : ''}"
onClick=${onSelectUnfiled}
onDragOver=${unfiledDrop.onDragOver} onDragLeave=${unfiledDrop.onDragLeave} onDrop=${unfiledDrop.onDrop}>
<span class="folder-tree__icon">📄</span>
<span class="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="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="backlinks-panel">
<div class="backlinks-panel__header" onClick=${function() { setCollapsed(!collapsed); }}>
<span class="backlinks-panel__toggle">${collapsed ? '▶' : '▼'}</span>
<span>Backlinks</span>
<span class="backlinks-panel__count">${backlinks.length}</span>
</div>
${!collapsed && backlinks.map(function(bl) {
return html`
<div key=${bl.source_id} class="backlinks-panel__item"
onClick=${function() { if (onNavigate) onNavigate(bl.source_id); }}>
<span class="backlinks-panel__title">${bl.source_title}</span>
<span class="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="sidebar-tabs">
<button class="sidebar-tabs__tab ${activeTab === 'notes' ? 'sidebar-tabs__tab--active' : ''}"
onClick=${function() { onTabChange('notes'); }}>Notes</button>
<button class="sidebar-tabs__tab ${activeTab === 'outline' ? 'sidebar-tabs__tab--active' : ''} ${!noteSelected ? '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="sidebar-outline__empty">No headings found</div>`;
}
return html`
<div class="sidebar-outline">
${headings.map(function(h, i) {
return html`
<div key=${i} class="sidebar-outline__item sidebar-outline__item--h${h.level} ${i === activeHeadingIdx ? '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 = renderMarkdown(body);
// mark unresolved wikilinks
for (var i = 0; i < outgoingLinks.length; i++) {
var link = outgoingLinks[i];
if (!link.target_id) {
var search = 'class="wikilink" data-wikilink="' + escHtml(link.link_text) + '"';
var replace = 'class="wikilink wikilink--unresolved" data-wikilink="' + escHtml(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('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]);
// ── 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="notes-editor__empty">Select a note or create a new one</div>`;
}
// ── Render editor body ─────────────────────
function renderEditorBody() {
if (viewMode === 'rendered') {
return html`<div class="notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`;
}
if (viewMode === 'split') {
var editor = hasCM
? html`<div class="notes-editor__cm" ref=${cmContainerRef} />`
: html`<textarea class="notes-editor__textarea" ref=${textareaRef}
value=${body} onInput=${handleBodyChange}
onKeyDown=${handleKeyDown}
placeholder="Start writing in Markdown…" />`;
return [
editor,
html`<div class="notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
];
}
// edit mode
if (hasCM) {
return html`<div class="notes-editor__cm" ref=${cmContainerRef} />`;
}
return html`<textarea class="notes-editor__textarea" ref=${textareaRef}
value=${body} onInput=${handleBodyChange}
onKeyDown=${handleKeyDown}
placeholder="Start writing in Markdown…" />`;
}
return html`
<div class="notes-editor">
<div class="notes-editor__header">
<input class="notes-editor__title-input" type="text"
value=${title} onInput=${handleTitleChange}
placeholder="Note title" />
<div class="notes-editor__actions">
<select class="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 ? 'notes-saved notes-saved--dirty' : 'notes-saved'}>
${saving ? 'Saving…' : (dirty ? 'Unsaved' : 'Saved')}
</span>
<button class="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="notes-btn" onClick=${handleExport} title="Export as .md">Export</button>
<button class="notes-btn" onClick=${handlePin}
title=${note.pinned ? 'Unpin' : 'Pin'}>
${note.pinned ? '📌' : '📍'}
</button>
<button class="notes-btn notes-btn--danger" onClick=${handleArchive}
title="Archive">🗑</button>
</div>
</div>
<${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} />
<div class="notes-editor__content">
<div class="notes-editor__body ${viewMode === 'split' ? '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="graph-pane"><div class="graph-empty"><${Spinner} size="md" /></div></div>`;
}
if (!graphData || graphData.nodes.length === 0) {
return html`<div class="graph-pane"><div class="graph-empty">No notes to graph. Create notes with [[wikilinks]] to see connections.</div></div>`;
}
var adj = graphData.adj;
return html`
<div class="graph-pane">
<div class="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="graph-tooltip" style=${{ left: (mousePos.x + 14) + 'px', top: (mousePos.y - 10) + 'px' }}>
<div class="graph-tooltip__title">${hoveredNode.title}</div>
${hoveredNode.tags && hoveredNode.tags.length > 0 && html`
<div class="graph-tooltip__tags">
${hoveredNode.tags.map(function(t) { return html`<span class="tag-pill" key=${t}>${t}</span>`; })}
</div>
`}
<div class="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 = 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);
}
return html`
<${Topbar} title="Notes">
<${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<//>
<//>
<div class="notes-app">
<div class="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="notes-search">
<input type="text" placeholder="Search notes…"
value=${searchTerm} onInput=${handleSearch} />
</div>
<div class="notes-list">
${loading && html`<div class="notes-list__loading"><${Spinner} size="md" /></div>`}
${!loading && notes.length === 0 && html`
<div class="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);
})();