Changeset 0.37.9 (#221)

This commit is contained in:
2026-03-21 20:16:19 +00:00
parent 2695bb3bdc
commit 37b639c9c8
27 changed files with 3435 additions and 2018 deletions

View File

@@ -322,10 +322,9 @@ function initListeners() {
_initChatListeners(); // from chat.js
// _initSettingsListeners and _initAdminListeners removed in v0.37.7
_initNotesListeners(); // from notes.js
// _initNotesListeners + _registerNotesPanel removed in v0.37.9 — see sw/components/notes-pane/
_initFileListeners(); // from files.js
_registerPreviewPanel(); // from ui-format.js — register preview with PanelRegistry
_registerNotesPanel(); // from notes.js — register notes with PanelRegistry
_registerProjectPanel(); // from projects-ui.js — register project detail panel
if (typeof Notifications !== 'undefined') Notifications.init(); // v0.20.0
_initGlobalKeyboard(); // local: Escape, Ctrl+K, Ctrl+\, resize

View File

@@ -1,501 +0,0 @@
// ==========================================
// Chat Switchboard — Note Graph (Canvas)
// ==========================================
// Force-directed graph visualization of note
// connections. Lazy-loaded — initializes only
// when the graph view is opened.
// ==========================================
//
// Exports: window.openNoteGraph,window.closeNoteGraph,window._render,window._graphResetZoom,window._graphToggleOrphans,window.invalidateNoteGraph
var _graphData = null; // cached { nodes, edges, unresolved }
var _graphState = null; // { panX, panY, zoom, animId, canvas, ctx }
var _graphDirty = true; // invalidation flag
// ── Force Simulation Parameters ─────────────
const SIM = {
repulsion: 800,
attraction: 0.006,
edgeLength: 130,
damping: 0.88,
centerGravity: 0.012,
maxVelocity: 8,
ticksPerFrame: 3,
coolThreshold: 0.5, // total KE below this → stop sim
};
const BASE_RADIUS = 6;
// ── Color Palette for Folders ───────────────
const FOLDER_COLORS = [
'#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6',
'#8b5cf6', '#ef4444', '#06b6d4', '#84cc16', '#f97316',
];
var _folderColorMap = {};
function folderColor(path) {
if (!path || path === '/') return '#6b7280';
if (!_folderColorMap[path]) {
const idx = Object.keys(_folderColorMap).length % FOLDER_COLORS.length;
_folderColorMap[path] = FOLDER_COLORS[idx];
}
return _folderColorMap[path];
}
// ── Graph Initialization ────────────────────
async function openNoteGraph() {
const listView = document.getElementById('notesListView');
const editorView = document.getElementById('notesEditorView');
const graphView = document.getElementById('notesGraphView');
if (!graphView) return;
listView.style.display = 'none';
editorView.style.display = 'none';
graphView.style.display = '';
const canvas = document.getElementById('noteGraphCanvas');
if (!canvas) return;
// Size canvas to panel
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height - 40; // toolbar height
const ctx = canvas.getContext('2d');
_graphState = {
panX: 0, panY: 0, zoom: 1,
animId: null, canvas, ctx,
hoveredNode: null, dragNode: null,
isDragging: false, lastMouse: null,
showOrphans: true,
};
// Load data
try {
_graphData = await API.getNoteGraph();
_prepareGraphNodes(canvas);
_updateGraphStats();
_startSimulation();
_attachGraphListeners(canvas);
} catch (e) {
ctx.fillStyle = 'var(--text, #ccc)';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Failed to load graph: ' + e.message, canvas.width / 2, canvas.height / 2);
}
}
function closeNoteGraph() {
if (_graphState?.animId) {
cancelAnimationFrame(_graphState.animId);
_graphState.animId = null;
}
const graphView = document.getElementById('notesGraphView');
if (graphView) graphView.style.display = 'none';
document.getElementById('notesListView').style.display = '';
}
// ── Prepare Nodes with Physics State ────────
function _prepareGraphNodes(canvas) {
if (!_graphData) return;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
// Build lookup for edge source/target
const nodeMap = {};
for (const n of _graphData.nodes) {
n.x = cx + (Math.random() - 0.5) * canvas.width * 0.5;
n.y = cy + (Math.random() - 0.5) * canvas.height * 0.5;
n.vx = 0; n.vy = 0;
n.ax = 0; n.ay = 0;
n.pinned = false;
n.hovered = false;
nodeMap[n.id] = n;
}
// Resolve edge references
for (const e of _graphData.edges) {
e.sourceNode = nodeMap[e.source];
e.targetNode = nodeMap[e.target];
}
// Optionally include ghost nodes for unresolved links
_graphData._ghostNodes = [];
if (_graphData.unresolved) {
const ghostMap = {};
for (const d of _graphData.unresolved) {
const key = d.title.toLowerCase();
if (!ghostMap[key]) {
const ghost = {
id: '__ghost_' + key, title: d.title,
folder_path: '', tags: [], link_count: 0,
x: cx + (Math.random() - 0.5) * canvas.width * 0.4,
y: cy + (Math.random() - 0.5) * canvas.height * 0.4,
vx: 0, vy: 0, ax: 0, ay: 0,
pinned: false, hovered: false, isGhost: true,
};
ghostMap[key] = ghost;
_graphData._ghostNodes.push(ghost);
}
}
}
}
function _updateGraphStats() {
const nc = document.getElementById('graphNodeCount');
const ec = document.getElementById('graphEdgeCount');
if (nc) nc.textContent = _graphData?.nodes?.length || 0;
if (ec) ec.textContent = _graphData?.edges?.length || 0;
}
// ── Force Simulation Loop ───────────────────
function _startSimulation() {
if (!_graphData || !_graphState) return;
function frame() {
for (let t = 0; t < SIM.ticksPerFrame; t++) {
_tick();
}
_render();
// Check convergence
let totalKE = 0;
for (const n of _allNodes()) {
totalKE += n.vx * n.vx + n.vy * n.vy;
}
if (totalKE > SIM.coolThreshold || _graphState.dragNode) {
_graphState.animId = requestAnimationFrame(frame);
} else {
_graphState.animId = null;
// One final render
_render();
}
}
if (_graphState.animId) cancelAnimationFrame(_graphState.animId);
_graphState.animId = requestAnimationFrame(frame);
}
function _allNodes() {
if (!_graphData) return [];
const nodes = [..._graphData.nodes];
if (_graphState?.showOrphans !== false && _graphData._ghostNodes) {
nodes.push(..._graphData._ghostNodes);
}
return nodes;
}
function _tick() {
const nodes = _allNodes();
const edges = _graphData?.edges || [];
const canvas = _graphState?.canvas;
if (!canvas || nodes.length === 0) return;
// Reset accelerations
for (const n of nodes) { n.ax = 0; n.ay = 0; }
// 1. Repulsion (all pairs)
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x;
const dy = nodes[j].y - nodes[i].y;
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const f = SIM.repulsion / (dist * dist);
const fx = (dx / dist) * f;
const fy = (dy / dist) * f;
nodes[i].ax -= fx; nodes[i].ay -= fy;
nodes[j].ax += fx; nodes[j].ay += fy;
}
}
// 2. Attraction (edges)
for (const e of edges) {
const s = e.sourceNode, t = e.targetNode;
if (!s || !t) continue;
const dx = t.x - s.x, dy = t.y - s.y;
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const f = SIM.attraction * (dist - SIM.edgeLength);
const fx = (dx / dist) * f;
const fy = (dy / dist) * f;
s.ax += fx; s.ay += fy;
t.ax -= fx; t.ay -= fy;
}
// 3. Center gravity + integration
const cx = canvas.width / 2, cy = canvas.height / 2;
for (const n of nodes) {
if (n.pinned) continue;
n.ax += (cx - n.x) * SIM.centerGravity;
n.ay += (cy - n.y) * SIM.centerGravity;
n.vx = _clamp((n.vx + n.ax) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity);
n.vy = _clamp((n.vy + n.ay) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity);
n.x += n.vx;
n.y += n.vy;
}
}
function _clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
// ── Canvas Rendering ────────────────────────
function _render() {
const { ctx, canvas, panX, panY, zoom, hoveredNode, showOrphans } = _graphState;
if (!ctx || !_graphData) return;
const nodes = _allNodes();
const edges = _graphData.edges || [];
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(panX, panY);
ctx.scale(zoom, zoom);
// Edges
for (const e of edges) {
const s = e.sourceNode, t = e.targetNode;
if (!s || !t) continue;
const isHighlighted = hoveredNode &&
(s.id === hoveredNode.id || t.id === hoveredNode.id);
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(t.x, t.y);
if (e.is_transclusion) {
ctx.setLineDash([4, 4]);
ctx.strokeStyle = isHighlighted
? 'rgba(99, 102, 241, 0.8)' : 'rgba(99, 102, 241, 0.3)';
} else {
ctx.setLineDash([]);
ctx.strokeStyle = isHighlighted
? 'rgba(160, 160, 160, 0.8)' : 'rgba(120, 120, 120, 0.25)';
}
ctx.lineWidth = isHighlighted ? 2 : 1;
ctx.stroke();
}
ctx.setLineDash([]);
// Nodes
for (const n of nodes) {
if (n.isGhost && !showOrphans) continue;
const r = n.isGhost
? BASE_RADIUS * 0.6
: BASE_RADIUS + Math.sqrt(n.link_count || 0) * 2;
const isHovered = hoveredNode && n.id === hoveredNode.id;
const isNeighbor = hoveredNode && _isNeighbor(hoveredNode, n);
const isDimmed = hoveredNode && !isHovered && !isNeighbor;
ctx.beginPath();
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
if (n.isGhost) {
ctx.fillStyle = isDimmed ? 'rgba(200, 50, 50, 0.1)' : 'rgba(200, 50, 50, 0.4)';
ctx.setLineDash([2, 2]);
ctx.strokeStyle = 'rgba(200, 50, 50, 0.5)';
} else if (isHovered) {
ctx.fillStyle = '#6366f1';
} else {
const color = folderColor(n.folder_path);
ctx.fillStyle = isDimmed ? _withAlpha(color, 0.15) : _withAlpha(color, 0.7);
ctx.strokeStyle = isDimmed ? 'rgba(255,255,255,0.05)' : 'rgba(255,255,255,0.15)';
}
ctx.fill();
if (!n.isGhost) {
ctx.setLineDash([]);
ctx.lineWidth = isHovered ? 2 : 1;
ctx.stroke();
} else {
ctx.stroke();
ctx.setLineDash([]);
}
// Labels
if (zoom > 0.5 && (isHovered || isNeighbor || !hoveredNode)) {
const fontSize = Math.max(10, 11 / zoom);
ctx.font = `${n.isGhost ? 'italic ' : ''}${fontSize}px var(--font, system-ui, sans-serif)`;
ctx.fillStyle = isDimmed ? 'rgba(200,200,200,0.2)' : 'rgba(220,220,220,0.9)';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const label = n.title.length > 30 ? n.title.slice(0, 28) + '…' : n.title;
ctx.fillText(label, n.x, n.y + r + 4);
}
}
ctx.restore();
}
function _isNeighbor(hovered, candidate) {
if (!_graphData) return false;
for (const e of _graphData.edges) {
if ((e.source === hovered.id && e.target === candidate.id) ||
(e.target === hovered.id && e.source === candidate.id)) {
return true;
}
}
return false;
}
function _withAlpha(hex, alpha) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
// ── Interaction: Pan, Zoom, Drag, Click ─────
function _attachGraphListeners(canvas) {
// Mouse → canvas coordinates (accounting for pan/zoom)
function toGraph(e) {
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - _graphState.panX) / _graphState.zoom;
const y = (e.clientY - rect.top - _graphState.panY) / _graphState.zoom;
return { x, y };
}
function hitTest(gx, gy) {
const nodes = _allNodes();
for (let i = nodes.length - 1; i >= 0; i--) {
const n = nodes[i];
const r = n.isGhost ? BASE_RADIUS * 0.6 : BASE_RADIUS + Math.sqrt(n.link_count || 0) * 2;
const dx = n.x - gx, dy = n.y - gy;
if (dx * dx + dy * dy <= (r + 4) * (r + 4)) return n;
}
return null;
}
// Mousedown
canvas.addEventListener('mousedown', (e) => {
const g = toGraph(e);
const node = hitTest(g.x, g.y);
if (node && !node.isGhost) {
_graphState.dragNode = node;
node.pinned = true;
_graphState.isDragging = false;
}
_graphState.lastMouse = { x: e.clientX, y: e.clientY };
});
// Mousemove
canvas.addEventListener('mousemove', (e) => {
const g = toGraph(e);
if (_graphState.dragNode) {
_graphState.isDragging = true;
_graphState.dragNode.x = g.x;
_graphState.dragNode.y = g.y;
_graphState.dragNode.vx = 0;
_graphState.dragNode.vy = 0;
if (!_graphState.animId) _startSimulation();
} else if (_graphState.lastMouse && e.buttons === 1) {
// Pan
_graphState.panX += e.clientX - _graphState.lastMouse.x;
_graphState.panY += e.clientY - _graphState.lastMouse.y;
_graphState.lastMouse = { x: e.clientX, y: e.clientY };
_render();
} else {
// Hover
const node = hitTest(g.x, g.y);
if (node !== _graphState.hoveredNode) {
_graphState.hoveredNode = node;
canvas.style.cursor = node ? 'pointer' : 'default';
_render();
}
}
});
// Mouseup
canvas.addEventListener('mouseup', (e) => {
if (_graphState.dragNode) {
_graphState.dragNode.pinned = false;
// Click (not drag) → open note
if (!_graphState.isDragging && !_graphState.dragNode.isGhost) {
openNoteEditor(_graphState.dragNode.id);
}
_graphState.dragNode = null;
}
_graphState.lastMouse = null;
_graphState.isDragging = false;
});
// Wheel → zoom
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.92 : 1.08;
const newZoom = _clamp(_graphState.zoom * factor, 0.15, 4.0);
// Zoom toward cursor
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
_graphState.panX = mx - (mx - _graphState.panX) * (newZoom / _graphState.zoom);
_graphState.panY = my - (my - _graphState.panY) * (newZoom / _graphState.zoom);
_graphState.zoom = newZoom;
_render();
}, { passive: false });
// Resize observer — debounced to prevent notification loop
let _roFrame = null;
const ro = new ResizeObserver(() => {
if (_roFrame) return; // already scheduled
_roFrame = requestAnimationFrame(() => {
_roFrame = null;
const rect = canvas.parentElement.getBoundingClientRect();
const w = Math.round(rect.width);
const h = Math.round(rect.height - 40);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
_render();
}
});
});
ro.observe(canvas.parentElement);
}
function _graphResetZoom() {
if (!_graphState) return;
_graphState.panX = 0;
_graphState.panY = 0;
_graphState.zoom = 1;
_render();
}
function _graphToggleOrphans() {
if (!_graphState) return;
_graphState.showOrphans = !_graphState.showOrphans;
_render();
}
// ── Invalidation ────────────────────────────
function invalidateNoteGraph() {
_graphData = null;
_graphDirty = true;
_folderColorMap = {};
}
// ── Exports ─────────────────────────────────
sb.register('openNoteGraph', openNoteGraph);
sb.register('closeNoteGraph', closeNoteGraph);
sb.register('_render', _render);
sb.register('_graphResetZoom', _graphResetZoom);
sb.register('_graphToggleOrphans', _graphToggleOrphans);
sb.register('invalidateNoteGraph', invalidateNoteGraph);

View File

@@ -1,976 +0,0 @@
// ==========================================
// Chat Switchboard NotePanel Component
// ==========================================
// v0.30.1: Mountable, self-contained notes panel instances.
// Follows the ChatPane pattern: createComponentRegistry + componentMixin.
// NotePanel.primary is the backward-compat bridge (singleton used by notes.js shim).
//
// Exports: window.NotePanel
const NotePanel = {
...createComponentRegistry('NotePanel'),
create(opts) {
const id = opts.id || 'notes-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
const container = opts.container;
if (!container) {
console.error('[NotePanel] container is required');
return null;
}
const instance = componentMixin({
id,
container,
projectId: opts.projectId || null,
// ── State (was module-level in notes.js) ──
_editingNoteId: null,
_notesSelectMode: false,
_selectedNoteIds: new Set(),
_notesSort: 'updated_desc',
_noteEditor: null,
_currentNote: null,
_searchTimer: null,
_page: 1,
_pageSize: 50,
_hasMore: false,
// ── DOM query helper ──
$(sel) { return this.container.querySelector(sel); },
$$(sel) { return this.container.querySelectorAll(sel); },
// ── Notes List ──
async loadNotesList(folder, searchQuery, append) {
const list = this.$('#notesList');
if (!list) return;
if (!append) {
list.innerHTML = '<div class="loading">Loading\u2026</div>';
this._page = 1;
}
try {
let notes, isSearch = false;
if (searchQuery) {
isSearch = true;
const data = await API.searchNotes(searchQuery);
notes = data.data || [];
this._hasMore = false;
} else {
const folderVal = folder || this.$('#notesFolderFilter')?.value || '';
const offset = (this._page - 1) * this._pageSize;
const data = await API.listNotes(this._pageSize, offset, folderVal, '', this._notesSort);
notes = data.data || [];
this._hasMore = notes.length >= this._pageSize;
}
if (!append) list.innerHTML = '';
if (notes.length === 0 && !append) {
const folderVal = this.$('#notesFolderFilter')?.value || '';
list.innerHTML = `<div class="empty-hint">${isSearch ? 'No results found' : (folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.')}</div>`;
return;
}
// Remove old "Load more" button if present
list.querySelector('.notes-load-more')?.remove();
list.insertAdjacentHTML('beforeend', notes.map(n => this._noteListItem(n, isSearch)).join(''));
// Pagination: "Load more" button
if (this._hasMore) {
const btn = document.createElement('button');
btn.className = 'btn-small notes-load-more';
btn.textContent = 'Load more\u2026';
btn.style.cssText = 'display:block;margin:8px auto;';
btn.addEventListener('click', () => {
this._page++;
this.loadNotesList(null, null, true);
});
list.appendChild(btn);
}
} catch (e) {
if (!append) list.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
}
},
_noteListItem(note, isSearch) {
const tags = (note.tags || []).map(t => `<span class="note-tag">${esc(t)}</span>`).join('');
const folder = note.folder_path && note.folder_path !== '/' ? `<span class="note-folder">${esc(note.folder_path)}</span>` : '';
const preview = isSearch && note.headline
? `<div class="note-headline">${this._highlightHeadline(note.headline)}</div>`
: `<div class="note-preview">${esc((note.preview || note.content || '').slice(0, 120))}</div>`;
const time = _relativeTime(note.updated_at);
const checked = this._selectedNoteIds.has(note.id) ? 'checked' : '';
return `
<div class="note-item ${this._selectedNoteIds.has(note.id) ? 'selected' : ''}" data-note-id="${note.id}">
<div class="note-select-col" style="display:${this._notesSelectMode ? '' : 'none'}">
<input type="checkbox" class="note-checkbox" data-id="${note.id}" ${checked}
data-action="_toggleNoteSelectCb" data-args='${JSON.stringify([note.id])}' data-pass-el">
</div>
<div class="note-content-col" data-action="_noteItemClick" data-args='${JSON.stringify([note.id])}'">
<div class="note-item-header">
<span class="note-item-title">${esc(note.title)}</span>
<span class="note-item-time">${time}</span>
</div>
${preview}
<div class="note-item-meta">${folder}${tags}</div>
</div>
</div>`;
},
_highlightHeadline(headline) {
const escaped = esc(headline);
return escaped.replace(/\*\*(.+?)\*\*/g, '<mark>$1</mark>');
},
// ── Multi-select ──
_enterSelectMode() {
this._notesSelectMode = true;
this._selectedNoteIds.clear();
const bar = this.$('#notesSelectionBar');
if (bar) bar.style.display = '';
this.$$('.note-select-col').forEach(el => el.style.display = '');
this._updateSelectedCount();
},
_exitSelectMode() {
this._notesSelectMode = false;
this._selectedNoteIds.clear();
const bar = this.$('#notesSelectionBar');
if (bar) bar.style.display = 'none';
this.$$('.note-select-col').forEach(el => el.style.display = 'none');
this.$$('.note-item.selected').forEach(el => el.classList.remove('selected'));
this.$$('.note-checkbox').forEach(cb => cb.checked = false);
const sa = this.$('#notesSelectAll');
if (sa) sa.checked = false;
},
_toggleNoteSelect(noteId, forceState) {
if (!this._notesSelectMode) {
this._enterSelectMode();
}
const has = this._selectedNoteIds.has(noteId);
const newState = forceState !== undefined ? forceState : !has;
if (newState) this._selectedNoteIds.add(noteId);
else this._selectedNoteIds.delete(noteId);
const item = this.container.querySelector(`.note-item[data-note-id="${noteId}"]`);
if (item) {
item.classList.toggle('selected', newState);
const cb = item.querySelector('.note-checkbox');
if (cb) cb.checked = newState;
}
this._updateSelectedCount();
},
_toggleSelectAll(checked) {
this.$$('.note-checkbox').forEach(cb => {
const cbId = cb.dataset.id;
if (checked) this._selectedNoteIds.add(cbId);
else this._selectedNoteIds.delete(cbId);
cb.checked = checked;
cb.closest('.note-item')?.classList.toggle('selected', checked);
});
this._updateSelectedCount();
},
_updateSelectedCount() {
const el = this.$('#notesSelectedCount');
if (el) el.textContent = this._selectedNoteIds.size;
},
async _bulkDeleteSelected() {
if (this._selectedNoteIds.size === 0) return;
const count = this._selectedNoteIds.size;
if (!await showConfirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return;
try {
const resp = await API.bulkDeleteNotes([...this._selectedNoteIds]);
UI.toast(`Deleted ${resp.deleted} note${resp.deleted !== 1 ? 's' : ''}`, 'success');
this._exitSelectMode();
await this.loadNotesList();
await this.loadNoteFolders();
} catch (e) { UI.toast(e.message, 'error'); }
},
// ── Folders ──
async loadNoteFolders() {
const sel = this.$('#notesFolderFilter');
if (!sel) return;
try {
const data = await API.listNoteFolders();
const folders = data.folders || [];
sel.innerHTML = '<option value="">All folders</option>';
folders.forEach(f => {
sel.innerHTML += `<option value="${esc(f.path)}">${esc(f.path)} (${f.count})</option>`;
});
} catch (e) { /* non-critical */ }
},
// ── View switching ──
showNotesList() {
const lv = this.$('#notesListView');
const ev = this.$('#notesEditorView');
const gv = this.$('#notesGraphView');
if (lv) lv.style.display = '';
if (ev) ev.style.display = 'none';
if (gv) gv.style.display = 'none';
this._editingNoteId = null;
this._destroyNoteEditor();
const list = this.$('#notesList');
if (list) list.innerHTML = '<div class="loading">Loading\u2026</div>';
},
// ── Note Editor ──
copyNoteContent() {
if (!this._currentNote) return;
const text = `# ${this._currentNote.title || ''}\n\n${this._currentNote.content || ''}`;
navigator.clipboard.writeText(text).then(() => UI.toast('Copied', 'success'));
},
async openNoteEditor(noteId) {
const lv = this.$('#notesListView');
const ev = this.$('#notesEditorView');
if (lv) lv.style.display = 'none';
if (ev) ev.style.display = '';
if (noteId) {
this._editingNoteId = noteId;
try {
this._currentNote = await API.getNote(noteId);
this._populateEditFields(this._currentNote);
this._showNoteReadMode();
} catch (e) {
UI.toast('Failed to load note: ' + e.message, 'error');
this.showNotesList();
}
} else {
this._editingNoteId = null;
this._currentNote = null;
this._clearEditFields();
this._showNoteEditMode();
this.$('#noteEditorTitle')?.focus();
}
},
_populateEditFields(note) {
const t = this.$('#noteEditorTitle');
const f = this.$('#noteEditorFolder');
const g = this.$('#noteEditorTags');
if (t) t.value = note.title || '';
if (f) f.value = note.folder_path || '';
if (g) g.value = (note.tags || []).join(', ');
this._setNoteEditorContent(note.content || '');
},
_clearEditFields() {
const t = this.$('#noteEditorTitle');
const f = this.$('#noteEditorFolder');
const g = this.$('#noteEditorTags');
if (t) t.value = '';
if (f) f.value = '';
if (g) g.value = '';
this._setNoteEditorContent('');
},
_getNoteEditorContent() {
if (this._noteEditor) return this._noteEditor.getValue();
const ta = this.container.querySelector('#noteEditorContentContainer textarea');
return ta ? ta.value : '';
},
_setNoteEditorContent(text) {
if (this._noteEditor) {
this._noteEditor.setValue(text);
return;
}
const container = this.$('#noteEditorContentContainer');
if (!container) return;
if (window.CM?.noteEditor) {
container.innerHTML = '';
this._noteEditor = CM.noteEditor(container, {
value: text,
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
onChange: null,
onLink: (title) => {
this._navigateToLinkedNote(title);
},
linkCompleter: async (query) => {
if (!query || query.length < 1) return [];
try {
const resp = await API.searchNoteTitles(query, 8);
return (resp.data || []).map(n => ({ label: n.title, id: n.id }));
} catch { return []; }
},
});
} else {
if (!container.querySelector('textarea')) {
container.innerHTML = '<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown\u2026"></textarea>';
}
const ta = container.querySelector('textarea');
if (ta) ta.value = text;
}
},
async _navigateToLinkedNote(title) {
try {
const resp = await API.searchNoteTitles(title, 1);
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
if (match) {
await this.openNoteEditor(match.id);
} else {
if (await showConfirm(`Note "${title}" not found. Create it?`)) {
const created = await API.createNote(title, `# ${title}\n\n`, '/', []);
await this.openNoteEditor(created.id);
}
}
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
},
_destroyNoteEditor() {
if (this._noteEditor) {
this._noteEditor.destroy();
this._noteEditor = null;
}
},
_showNoteReadMode() {
if (!this._currentNote) return;
const n = this._currentNote;
const titleEl = this.$('#noteReadTitle');
if (titleEl) titleEl.textContent = n.title || '';
const parts = [];
if (n.folder_path && n.folder_path !== '/') parts.push(`<span class="note-folder">${esc(n.folder_path)}</span>`);
(n.tags || []).forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
const metaEl = this.$('#noteReadMeta');
if (metaEl) metaEl.innerHTML = parts.join(' ');
const noteReadEl = this.$('#noteReadContent');
if (noteReadEl) {
noteReadEl.innerHTML = formatMessage(n.content || '');
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(noteReadEl);
this._renderWikilinksInReadMode(noteReadEl);
}
const delBtn = this.$('#noteDeleteBtn2');
if (delBtn) delBtn.style.display = this._editingNoteId ? '' : 'none';
const rm = this.$('#noteReadMode');
const em = this.$('#noteEditMode');
const eb = this.$('#noteEditBtn');
const pb = this.$('#notePreviewBtn');
if (rm) rm.style.display = '';
if (em) em.style.display = 'none';
if (eb) eb.style.display = '';
if (pb) pb.style.display = 'none';
if (this._editingNoteId) this._loadBacklinks(this._editingNoteId);
this._updateDailyNav(this._currentNote);
},
_showNoteEditMode() {
const rm = this.$('#noteReadMode');
const em = this.$('#noteEditMode');
const db = this.$('#noteDeleteBtn');
const cb = this.$('#noteCancelEditBtn');
const eb = this.$('#noteEditBtn');
const pb = this.$('#notePreviewBtn');
if (rm) rm.style.display = 'none';
if (em) em.style.display = '';
if (db) db.style.display = this._editingNoteId ? '' : 'none';
if (cb) cb.style.display = this._editingNoteId ? '' : 'none';
if (eb) eb.style.display = 'none';
if (pb) pb.style.display = '';
},
_showNotePreview() {
const title = this.$('#noteEditorTitle')?.value.trim() || '';
const content = this._getNoteEditorContent();
const folderVal = this.$('#noteEditorFolder')?.value.trim() || '';
const tagsStr = this.$('#noteEditorTags')?.value.trim() || '';
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
const titleEl = this.$('#noteReadTitle');
if (titleEl) titleEl.textContent = title || 'Untitled';
const parts = [];
if (folderVal) parts.push(`<span class="note-folder">${esc(folderVal)}</span>`);
tags.forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
const metaEl = this.$('#noteReadMeta');
if (metaEl) metaEl.innerHTML = parts.join(' ');
const readEl = this.$('#noteReadContent');
if (readEl) {
readEl.innerHTML = formatMessage(content || '');
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(readEl);
this._renderWikilinksInReadMode(readEl);
}
const rm = this.$('#noteReadMode');
const em = this.$('#noteEditMode');
const eb = this.$('#noteEditBtn');
const pb = this.$('#notePreviewBtn');
if (rm) rm.style.display = '';
if (em) em.style.display = 'none';
if (eb) eb.style.display = '';
if (pb) pb.style.display = 'none';
const delBtn = this.$('#noteDeleteBtn2');
if (delBtn) delBtn.style.display = 'none';
},
// ── CRUD ──
async saveNote() {
const title = this.$('#noteEditorTitle')?.value.trim() || '';
const content = this._getNoteEditorContent();
const folder = this.$('#noteEditorFolder')?.value.trim() || '';
const tagsStr = this.$('#noteEditorTags')?.value.trim() || '';
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
if (!title) { UI.toast('Title is required', 'warning'); return; }
if (!content) { UI.toast('Content is required', 'warning'); return; }
try {
if (this._editingNoteId) {
const updated = await API.updateNote(this._editingNoteId, { title, content, folder_path: folder, tags, mode: 'replace' });
this._currentNote = updated;
UI.toast('Note updated', 'success');
this._showNoteReadMode();
} else {
const created = await API.createNote(title, content, folder, tags);
this._editingNoteId = created.id;
this._currentNote = created;
UI.toast('Note created', 'success');
this._showNoteReadMode();
}
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
} catch (e) { UI.toast(e.message, 'error'); }
},
async deleteNote() {
if (!this._editingNoteId) return;
if (!await showConfirm('Delete this note?')) return;
try {
await API.deleteNote(this._editingNoteId);
UI.toast('Note deleted', 'success');
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
this.showNotesList();
await this.loadNotesList();
await this.loadNoteFolders();
} catch (e) { UI.toast(e.message, 'error'); }
},
// ── Backlinks ──
async _loadBacklinks(noteId) {
const container = this.$('#noteBacklinks');
const listEl = this.$('#noteBacklinksList');
const countEl = this.$('#noteBacklinksCount');
if (!container || !listEl || !countEl) return;
try {
const resp = await API.getNoteBacklinks(noteId);
const links = resp.data || [];
countEl.textContent = links.length;
container.style.display = links.length > 0 ? '' : 'none';
listEl.innerHTML = links.map(n => `
<div class="note-backlink-item" data-action="openNoteEditor" data-args='${JSON.stringify([n.id])}'">
<span class="note-backlink-title">${esc(n.title)}</span>
<span class="note-backlink-folder text-muted">${esc(n.folder_path)}</span>
</div>
`).join('');
} catch {
container.style.display = 'none';
}
},
toggleBacklinks() {
const list = this.$('#noteBacklinksList');
if (list) list.style.display = list.style.display === 'none' ? '' : 'none';
},
// ── Wikilink Read-Mode Rendering ──
_renderWikilinksInReadMode(containerEl) {
if (!containerEl) return;
const html = containerEl.innerHTML;
let transclusionId = 0;
const rendered = html.replace(
/(!?)\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g,
(match, bang, title, display) => {
const titleTrimmed = title.trim();
const displayText = display ? display.trim() : titleTrimmed;
if (bang === '!') {
const tid = `transclusion-${++transclusionId}`;
return `<div class="transclusion-embed" id="${tid}" data-title="${esc(titleTrimmed)}">
<div class="transclusion-header">
<span class="wikilink-chip wikilink-transclusion" data-action="_navigateToLinkedNote" data-args='${JSON.stringify([esc(titleTrimmed)])}'" title="Open: ${esc(titleTrimmed)}">↗ ${esc(displayText)}</span>
</div>
<div class="transclusion-content"><span class="text-muted">Loading\u2026</span></div>
</div>`;
}
return `<span class="wikilink-chip" data-action="_navigateToLinkedNote" data-args='${JSON.stringify([esc(titleTrimmed)])}'" title="Link: ${esc(titleTrimmed)}">${esc(displayText)}</span>`;
}
);
containerEl.innerHTML = rendered;
this._resolveTransclusions(containerEl);
},
async _resolveTransclusions(containerEl) {
const embeds = containerEl.querySelectorAll('.transclusion-embed');
if (!embeds.length) return;
const cache = {};
for (const el of embeds) {
const title = el.dataset.title;
const contentEl = el.querySelector('.transclusion-content');
if (!title || !contentEl) continue;
try {
let note = cache[title.toLowerCase()];
if (!note) {
const resp = await API.searchNoteTitles(title, 1);
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
if (!match) {
contentEl.innerHTML = '<span class="text-muted">Note not found</span>';
continue;
}
note = await API.getNote(match.id);
cache[title.toLowerCase()] = note;
}
const safeContent = (note.content || '').replace(/!\[\[[^\]]+\]\]/g, (m) => {
const inner = m.match(/!\[\[([^\]|]+)/)?.[1]?.trim() || '';
return `[Embedded: ${inner}]`;
});
contentEl.innerHTML = formatMessage(safeContent);
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(contentEl);
} catch {
contentEl.innerHTML = '<span class="text-muted">Failed to load</span>';
}
}
},
// ── Daily Notes ──
async openDailyNote() {
const today = new Date().toISOString().slice(0, 10);
const title = `Daily \u2014 ${today}`;
const folder = '/daily/';
try {
const resp = await API.searchNoteTitles(title, 1);
const existing = (resp.data || []).find(
n => n.title.toLowerCase() === title.toLowerCase()
);
if (existing) {
await this.openNoteEditor(existing.id);
} else {
const content = `# ${today}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
const created = await API.createNote(title, content, folder, ['daily']);
await this.openNoteEditor(created.id);
}
} catch (e) { UI.toast('Failed to open daily note: ' + e.message, 'error'); }
},
_isDailyNote(note) {
return note && /^Daily \u2014 \d{4}-\d{2}-\d{2}$/.test(note.title);
},
_updateDailyNav(note) {
let nav = this.$('#noteDailyNav');
if (!this._isDailyNote(note)) {
if (nav) nav.style.display = 'none';
return;
}
if (!nav) {
const toolbar = this.container.querySelector('.notes-editor-toolbar');
if (!toolbar) return;
nav = document.createElement('div');
nav.id = 'noteDailyNav';
nav.className = 'note-daily-nav';
nav.innerHTML = `
<button class="btn-small" id="dailyPrevBtn" title="Previous day">\u2039 Prev</button>
<span class="note-daily-date" id="dailyDateLabel"></span>
<button class="btn-small" id="dailyNextBtn" title="Next day">Next \u203a</button>
`;
toolbar.after(nav);
nav.querySelector('#dailyPrevBtn')?.addEventListener('click', () => this._navigateDaily(-1));
nav.querySelector('#dailyNextBtn')?.addEventListener('click', () => this._navigateDaily(1));
}
nav.style.display = '';
const dateStr = note.title.replace('Daily \u2014 ', '');
const label = nav.querySelector('#dailyDateLabel');
if (label) label.textContent = dateStr;
const today = new Date().toISOString().slice(0, 10);
const nextBtn = nav.querySelector('#dailyNextBtn');
if (nextBtn) nextBtn.disabled = dateStr >= today;
},
async _navigateDaily(offset) {
if (!this._currentNote || !this._isDailyNote(this._currentNote)) return;
const dateStr = this._currentNote.title.replace('Daily \u2014 ', '');
const d = new Date(dateStr + 'T12:00:00');
d.setDate(d.getDate() + offset);
const newDateStr = d.toISOString().slice(0, 10);
const title = `Daily \u2014 ${newDateStr}`;
try {
const resp = await API.searchNoteTitles(title, 1);
const existing = (resp.data || []).find(
n => n.title.toLowerCase() === title.toLowerCase()
);
if (existing) {
await this.openNoteEditor(existing.id);
} else {
const content = `# ${newDateStr}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
const created = await API.createNote(title, content, '/daily/', ['daily']);
await this.openNoteEditor(created.id);
}
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
},
// ── Save-to-Note (from chat messages) ──
async saveMessageToNote(msgIndex) {
const chat = App.chats?.find(c => c.id === App.activeId);
const msg = chat?.messages?.[msgIndex];
if (!msg) return;
const sel = window.getSelection();
let content = msg.content;
content = content.replace(/<(?:thinking|think)>[\s\S]*?<\/(?:thinking|think)>/gi, '').trim();
const msgId = msg.id || '';
const msgEl = msgId ? document.querySelector(`.message[data-msg-id="${msgId}"] .msg-text`) : null;
if (sel && sel.rangeCount > 0 && msgEl?.contains(sel.anchorNode)) {
const selected = sel.toString().trim();
if (selected) content = selected;
}
const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
const defaultTitle = firstLine.slice(0, 60) || 'Chat excerpt';
this._showSaveToNoteModal({
content,
sourceChannelId: App.activeId || '',
sourceMessageId: msg.id || '',
defaultTitle,
});
},
_showSaveToNoteModal(opts) {
document.getElementById('saveToNoteModal')?.remove();
const modal = document.createElement('div');
modal.id = 'saveToNoteModal';
modal.className = 'modal-overlay active';
modal.innerHTML = `
<div class="modal save-to-note-modal">
<h3>Save to Note</h3>
<div class="save-note-mode-toggle">
<label><input type="radio" name="saveNoteMode" value="create" checked> Create new note</label>
<label><input type="radio" name="saveNoteMode" value="append"> Append to existing</label>
</div>
<div id="saveNoteCreateFields">
<div class="form-group">
<input type="text" id="saveNoteTitle" placeholder="Title" value="${esc(opts.defaultTitle)}" class="notes-title-input">
</div>
<div class="form-row">
<div class="form-group"><input type="text" id="saveNoteFolder" placeholder="Folder (e.g. /work)" class="notes-folder-input"></div>
<div class="form-group"><input type="text" id="saveNoteTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
</div>
</div>
<div id="saveNoteAppendFields" style="display:none">
<div class="form-group">
<input type="text" id="saveNoteSearch" placeholder="Search notes to append to..." class="notes-title-input" autocomplete="off">
<div id="saveNoteSearchResults" class="save-note-search-results"></div>
</div>
</div>
<div class="form-group">
<label class="text-muted" style="font-size:12px">Preview (${opts.content.length} chars)</label>
<div class="save-note-preview msg-text">${formatMessage(opts.content.slice(0, 300))}${opts.content.length > 300 ? '\u2026' : ''}</div>
</div>
<div class="modal-actions">
<button class="btn-small" id="saveNoteCancelBtn">Cancel</button>
<button class="btn-small btn-primary" id="saveNoteConfirmBtn">Create</button>
</div>
</div>
`;
document.body.appendChild(modal);
let selectedAppendNote = null;
modal.querySelectorAll('input[name="saveNoteMode"]').forEach(radio => {
radio.addEventListener('change', (e) => {
const isAppend = e.target.value === 'append';
document.getElementById('saveNoteCreateFields').style.display = isAppend ? 'none' : '';
document.getElementById('saveNoteAppendFields').style.display = isAppend ? '' : 'none';
document.getElementById('saveNoteConfirmBtn').textContent = isAppend ? 'Append' : 'Create';
selectedAppendNote = null;
document.getElementById('saveNoteSearchResults').innerHTML = '';
});
});
let _searchTimer;
document.getElementById('saveNoteSearch')?.addEventListener('input', (e) => {
clearTimeout(_searchTimer);
const q = e.target.value.trim();
selectedAppendNote = null;
_searchTimer = setTimeout(async () => {
const resultsEl = document.getElementById('saveNoteSearchResults');
if (q.length < 2) { resultsEl.innerHTML = ''; return; }
try {
const resp = await API.searchNoteTitles(q, 8);
const notes = resp.data || [];
resultsEl.innerHTML = notes.map(n =>
`<div class="save-note-result" data-id="${n.id}" data-title="${esc(n.title)}">${esc(n.title)}</div>`
).join('') || '<div class="text-muted" style="padding:6px">No results</div>';
resultsEl.querySelectorAll('.save-note-result').forEach(el => {
el.addEventListener('click', () => {
resultsEl.querySelectorAll('.save-note-result').forEach(r => r.classList.remove('selected'));
el.classList.add('selected');
selectedAppendNote = { id: el.dataset.id, title: el.dataset.title };
document.getElementById('saveNoteSearch').value = el.dataset.title;
});
});
} catch { resultsEl.innerHTML = '<div class="text-muted" style="padding:6px">Search failed</div>'; }
}, 250);
});
document.getElementById('saveNoteCancelBtn').addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
document.getElementById('saveNoteConfirmBtn').addEventListener('click', async () => {
const isAppend = modal.querySelector('input[name="saveNoteMode"]:checked')?.value === 'append';
try {
if (isAppend) {
if (!selectedAppendNote) { UI.toast('Select a note to append to', 'warning'); return; }
await API.updateNote(selectedAppendNote.id, {
content: '\n\n---\n\n' + opts.content,
mode: 'append',
});
UI.toast(`Appended to "${selectedAppendNote.title}"`, 'success');
} else {
const title = document.getElementById('saveNoteTitle').value.trim();
if (!title) { UI.toast('Title is required', 'warning'); return; }
const folder = document.getElementById('saveNoteFolder').value.trim();
const tagsStr = document.getElementById('saveNoteTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
await API.createNote(title, opts.content, folder, tags, opts.sourceChannelId, opts.sourceMessageId);
UI.toast('Note created', 'success');
}
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
modal.remove();
} catch (e) { UI.toast(e.message, 'error'); }
});
document.getElementById('saveNoteTitle')?.focus();
document.getElementById('saveNoteTitle')?.select();
},
// ── Compound onclick wrappers (for data-action dispatch) ──
_toggleNoteSelectCb(el, noteId) {
this._toggleNoteSelect(noteId, el.checked);
},
_noteItemClick(noteId) {
if (this._notesSelectMode) {
this._toggleNoteSelect(noteId);
} else {
this.openNoteEditor(noteId);
}
},
// ── Lifecycle ──
_cleanup() {
this._destroyNoteEditor();
clearTimeout(this._searchTimer);
},
}, NotePanel);
NotePanel._register(id, instance);
return instance;
},
};
// ── Self-mount: build DOM + create + wire in one call ──
// Canonical entry point for all consumers (notes.js sidebar, editor package, sw.notes()).
// Uses the same IDs that NotePanel.create() expects — $() is container-scoped so no collisions.
NotePanel.mount = function (container, opts) {
const _opts = opts || {};
const el = document.createElement('div');
el.className = 'note-panel-root';
el.innerHTML =
'<!-- List view -->' +
'<div id="notesListView">' +
'<div class="notes-toolbar">' +
'<button id="notesNewBtn" class="btn-small" title="New note">+ New</button>' +
'<button id="notesTodayBtn" class="btn-small" title="Open daily note">\ud83d\udcc5 Today</button>' +
'<button id="notesGraphBtn" class="btn-small" title="Note graph">\ud83d\udd78 Graph</button>' +
'<button id="notesSelectModeBtn" class="btn-small" title="Select mode">\u2611 Select</button>' +
'</div>' +
'<div class="notes-search-row">' +
'<input type="text" id="notesSearchInput" class="notes-search-input" placeholder="Search notes\u2026" autocomplete="off">' +
'</div>' +
'<div class="notes-filter-row">' +
'<select id="notesFolderFilter" class="notes-filter-select"><option value="">All folders</option></select>' +
'<select id="notesSortSelect" class="notes-filter-select">' +
'<option value="updated_desc">Last updated</option>' +
'<option value="created_desc">Created</option>' +
'<option value="alpha">Alphabetical</option>' +
'</select>' +
'</div>' +
'<div id="notesList" class="notes-list"></div>' +
'<div id="notesSelectionBar" class="notes-selection-bar" style="display:none;">' +
'<label class="notes-select-all"><input type="checkbox" id="notesSelectAll"> All</label>' +
'<span id="notesSelectedCount">0</span> selected' +
'<button id="notesDeleteSelectedBtn" class="btn-small btn-danger">Delete</button>' +
'<button id="notesCancelSelectBtn" class="btn-small">Cancel</button>' +
'</div>' +
'</div>' +
'<!-- Editor view -->' +
'<div id="notesEditorView" style="display:none;">' +
'<div class="notes-editor-header">' +
'<button id="notesBackBtn" class="btn-small" title="Back to list">\u2190 Back</button>' +
'<div style="flex:1"></div>' +
'<button id="noteEditBtn" class="btn-small" style="display:none;">Edit</button>' +
'<button id="notePreviewBtn" class="btn-small" style="display:none;">Preview</button>' +
'<button id="noteCancelEditBtn" class="btn-small" style="display:none;">Cancel</button>' +
'<button id="noteDeleteBtn" class="btn-small btn-danger" style="display:none;">Delete</button>' +
'<button id="noteSaveBtn" class="btn-small btn-primary">Save</button>' +
'</div>' +
'<div id="noteEditMode" class="notes-editor">' +
'<input type="text" id="noteEditorTitle" class="notes-title-input" placeholder="Note title">' +
'<div class="notes-meta-row">' +
'<input type="text" id="noteEditorFolder" class="notes-folder-input" placeholder="Folder (e.g. work/project)">' +
'<input type="text" id="noteEditorTags" class="notes-tags-input" placeholder="Tags (comma-separated)">' +
'</div>' +
'<div id="noteEditorContentContainer" class="notes-content-container">' +
'<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown\u2026"></textarea>' +
'</div>' +
'</div>' +
'<div id="noteReadMode" style="display:none;">' +
'<div class="notes-read-view">' +
'<h3 id="noteReadTitle" class="note-read-title"></h3>' +
'<div id="noteReadMeta" class="note-read-meta"></div>' +
'<div id="noteReadContent" class="note-read-content msg-content"></div>' +
'<button id="noteDeleteBtn2" class="btn-small btn-danger" style="display:none;margin-top:8px;">Delete</button>' +
'</div>' +
'</div>' +
'<div id="noteBacklinks" class="note-backlinks" style="display:none;">' +
'<div class="note-backlinks-header" data-action="toggleBacklinks">' +
'Backlinks <span id="noteBacklinksCount" class="badge">0</span>' +
'</div>' +
'<div id="noteBacklinksList" class="note-backlinks-list"></div>' +
'</div>' +
'</div>' +
'<!-- Graph view -->' +
'<div id="notesGraphView" class="notes-graph-view" style="display:none;">' +
'<div class="notes-graph-toolbar">' +
'<button id="notesGraphBackBtn" class="btn-small" title="Back to list">\u2190 Back</button>' +
'<span class="notes-graph-stats">' +
'<span id="graphNodeCount">0</span> notes \u00b7 <span id="graphEdgeCount">0</span> links' +
'</span>' +
'<div style="flex:1"></div>' +
'<button class="btn-small" data-action="_graphToggleOrphans" title="Toggle orphan nodes">Orphans</button>' +
'<button class="btn-small" data-action="_graphResetZoom" title="Reset zoom">Reset</button>' +
'</div>' +
'<canvas id="noteGraphCanvas" style="width:100%;display:block;"></canvas>' +
'</div>';
container.appendChild(el);
// Create NotePanel instance — $() is container-scoped, so the IDs above "just work"
const np = this.create({ container: el, projectId: _opts.projectId || null });
// ── Wire all events (same as notes.js _registerNotesPanel) ──
const q = (sel) => el.querySelector(sel);
q('#notesNewBtn').addEventListener('click', () => np.openNoteEditor(null));
q('#notesTodayBtn').addEventListener('click', () => np.openDailyNote());
q('#notesGraphBtn').addEventListener('click', () => {
np.showGraph();
if (typeof openNoteGraph === 'function') openNoteGraph();
if (_opts.onOpenGraph) _opts.onOpenGraph();
});
q('#notesGraphBackBtn').addEventListener('click', () => {
np.showNotesList();
if (typeof closeNoteGraph === 'function') closeNoteGraph();
});
q('#notesSelectModeBtn').addEventListener('click', () => {
if (np._notesSelectMode) np._exitSelectMode();
else np._enterSelectMode();
});
q('#notesBackBtn').addEventListener('click', () => { np.showNotesList(); np.loadNotesList(); np.loadNoteFolders(); });
q('#noteSaveBtn').addEventListener('click', () => np.saveNote());
q('#noteDeleteBtn').addEventListener('click', () => np.deleteNote());
q('#noteDeleteBtn2').addEventListener('click', () => np.deleteNote());
q('#noteEditBtn').addEventListener('click', () => np._showNoteEditMode());
q('#notePreviewBtn').addEventListener('click', () => np._showNotePreview());
q('#noteCancelEditBtn').addEventListener('click', () => {
if (np._currentNote) { np._populateEditFields(np._currentNote); np._showNoteReadMode(); }
else np.showNotesList();
});
q('#notesFolderFilter').addEventListener('change', (e) => {
np.$('#notesSearchInput').value = '';
np._exitSelectMode();
np.loadNotesList(e.target.value);
});
q('#notesSortSelect').addEventListener('change', (e) => {
np._notesSort = e.target.value;
np._exitSelectMode();
np.loadNotesList();
});
q('#notesSelectAll').addEventListener('change', (e) => np._toggleSelectAll(e.target.checked));
q('#notesDeleteSelectedBtn').addEventListener('click', () => np._bulkDeleteSelected());
q('#notesCancelSelectBtn').addEventListener('click', () => np._exitSelectMode());
let _searchTimer;
q('#notesSearchInput').addEventListener('input', (e) => {
clearTimeout(_searchTimer);
const query = e.target.value.trim();
_searchTimer = setTimeout(() => {
np._exitSelectMode();
if (query.length >= 2) {
np.$('#notesFolderFilter').value = '';
np.loadNotesList(null, query);
} else if (query.length === 0) {
np.loadNotesList();
}
}, 300);
});
// Add showGraph view switcher
np.showGraph = function () {
const lv = np.$('#notesListView');
const ev = np.$('#notesEditorView');
const gv = np.$('#notesGraphView');
if (lv) lv.style.display = 'none';
if (ev) ev.style.display = 'none';
if (gv) gv.style.display = '';
};
return np;
};
// ── Exports ─────────────────────────────────
sb.ns('NotePanel', NotePanel);

View File

@@ -1,155 +0,0 @@
// ==========================================
// Chat Switchboard Notes Panel (Shim)
// ==========================================
// v0.30.1: Thin backward-compat layer over NotePanel component.
// All state and logic now lives in note-panel.js.
// This file creates the singleton (NotePanel.primary) and wires
// sb.register() calls so data-action attributes continue to work.
// ── Singleton accessor ──
function _np() { return NotePanel.primary; }
// ── Public API (delegated to NotePanel.primary) ──
async function openNotes() {
if (PanelRegistry.isOpen('notes')) {
PanelRegistry.close('notes');
return;
}
PanelRegistry.open('notes');
_np()?._exitSelectMode();
}
async function loadNotesList(folder, searchQuery) {
return _np()?.loadNotesList(folder, searchQuery);
}
function showNotesList() {
return _np()?.showNotesList();
}
async function loadNoteFolders() {
return _np()?.loadNoteFolders();
}
async function openNoteEditor(noteId) {
return _np()?.openNoteEditor(noteId);
}
async function saveNote() {
return _np()?.saveNote();
}
async function deleteNote() {
return _np()?.deleteNote();
}
async function openDailyNote() {
return _np()?.openDailyNote();
}
async function saveMessageToNote(msgIndex) {
return _np()?.saveMessageToNote(msgIndex);
}
function _toggleNoteSelect(id, forceState) {
return _np()?._toggleNoteSelect(id, forceState);
}
function _navigateToLinkedNote(title) {
return _np()?._navigateToLinkedNote(title);
}
function toggleBacklinks() {
return _np()?.toggleBacklinks();
}
function _toggleNoteSelectCb(el, noteId) {
return _np()?._toggleNoteSelectCb(el, noteId);
}
function _noteItemClick(noteId) {
return _np()?._noteItemClick(noteId);
}
// Read-only accessors for backward compat (sb.register registered these as values)
function _currentNote() { return _np()?._currentNote; }
function _editingNoteId() { return _np()?._editingNoteId; }
// ── Panel Registration ──
// Creates the singleton NotePanel.primary and registers with PanelRegistry.
function _registerNotesPanel() {
const body = document.getElementById('sidePanelBody');
if (!body || typeof PanelRegistry === 'undefined' || typeof NotePanel === 'undefined') return;
// ── Create wrapper element ──
const wrapper = document.createElement('div');
wrapper.id = 'sidePanelNotes';
wrapper.style.display = 'none';
wrapper.style.height = '100%';
body.appendChild(wrapper);
// ── Mount NotePanel (DOM + create + wire — single source of truth) ──
const np = NotePanel.mount(wrapper, {});
NotePanel.primary = np;
// ── Wire sidebar button ──
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
// ── Register with PanelRegistry ──
PanelRegistry.register('notes', {
element: wrapper,
label: 'Notes',
onOpen() {
np.loadNotesList();
np.loadNoteFolders();
},
onClose() {
np._destroyNoteEditor();
},
saveState() {
const listView = np.$('#notesListView');
const editorView = np.$('#notesEditorView');
const graphView = np.$('#notesGraphView');
let viewMode = 'list';
if (editorView && editorView.style.display !== 'none') viewMode = 'editor';
if (graphView && graphView.style.display !== 'none') viewMode = 'graph';
return {
scrollTop: listView?.scrollTop || 0,
editingNoteId: np._editingNoteId,
viewMode,
};
},
restoreState(state) {
if (state.scrollTop) {
const listView = np.$('#notesListView');
if (listView) listView.scrollTop = state.scrollTop;
}
},
});
}
/** @deprecated — listeners are now wired in _registerNotesPanel() */
function _initNotesListeners() {
// No-op: kept for backward compatibility with app.js calling sequence.
}
// ── Exports ─────────────────────────────────
sb.register('_currentNote', _currentNote);
sb.register('_editingNoteId', _editingNoteId);
sb.register('_initNotesListeners', _initNotesListeners);
sb.register('_registerNotesPanel', _registerNotesPanel);
sb.register('deleteNote', deleteNote);
sb.register('openNoteEditor', openNoteEditor);
sb.register('openNotes', openNotes);
sb.register('saveMessageToNote', saveMessageToNote);
sb.register('saveNote', saveNote);
sb.register('_toggleNoteSelect', _toggleNoteSelect);
sb.register('_navigateToLinkedNote', _navigateToLinkedNote);
sb.register('toggleBacklinks', toggleBacklinks);
sb.register('_toggleNoteSelectCb', _toggleNoteSelectCb);
sb.register('_noteItemClick', _noteItemClick);

View File

@@ -0,0 +1,194 @@
// ==========================================
// NotesPane Kit — Default Assembly
// ==========================================
// Wires useNotes + all sub-components into the standard
// notes experience. Surfaces that need a different
// layout import individual pieces instead.
//
// Usage:
// import { NotesPane } from './components/notes-pane/index.js';
// html`<${NotesPane} handleRef=${ref} standalone=${true} />`
//
// Re-exports all pieces for kit consumers:
// import { useNotes, NoteList, NoteEditor, ... } from './components/notes-pane/index.js';
import { useNotes } from './use-notes.js';
import { NoteList } from './note-list.js';
import { NoteListItem } from './note-list-item.js';
import { NoteEditor } from './note-editor.js';
import { NoteReader } from './note-reader.js';
import { QuickSwitcher, saveRecentNote } from './quick-switcher.js';
import { SaveToNoteDialog } from './save-to-note.js';
import { renderNoteMarkdown, attachWikilinkHandlers, resolveTransclusions, extractHeadings } from './markdown.js';
const { useState, useRef, useEffect, useCallback } = window.hooks;
const html = window.html;
/**
* Default NotesPane assembly.
*
* @param {{
* initialFolder?: string,
* initialNoteId?: string,
* standalone?: boolean,
* onNoteChange?: (note: object|null) => void,
* handleRef?: { current: any },
* className?: string,
* }} props
*/
export function NotesPane(props) {
const {
initialFolder,
initialNoteId,
standalone = true,
onNoteChange,
handleRef,
className,
} = props;
const notes = useNotes({
initialFolder,
initialNoteId,
onNoteChange,
standalone,
enableGraph: true,
enableQuickSwitcher: true,
});
// ── Lazy-load graph component ───────────
const [GraphComp, setGraphComp] = useState(null);
useEffect(() => {
if (notes.view === 'graph' && !GraphComp) {
import('./note-graph.js').then(m => setGraphComp(() => m.NoteGraph));
}
}, [notes.view, GraphComp]);
const graphHandleRef = useRef(null);
// ── Graph data loading ──────────────────
useEffect(() => {
if (notes.view === 'graph' && !notes.graphData) {
notes.loadGraph();
}
}, [notes.view]);
// ── Expose imperative handle ────────────
useEffect(() => {
if (handleRef) {
handleRef.current = {
getView() { return notes.view; },
setView(v) { notes.setView(v); },
openNote(id) { saveRecentNote({ id, title: '' }); notes.openNote(id); },
newNote() { notes.newNote(); },
openDailyNote() { notes.openDailyNote(); },
refreshList() { notes.refreshList(); },
getEditingId() { return notes.editingId; },
openQuickSwitcher() { notes.setQuickSwitcherOpen(true); },
};
}
});
// ── Quick switcher create-new handler ───
const onQuickSwitcherCreate = useCallback((title) => {
notes.newNote();
// The editor will open blank — we can't pre-fill via state here
// but the title will be empty for the user to type
}, [notes.newNote]);
// ── View routing ────────────────────────
const renderView = () => {
switch (notes.view) {
case 'list':
return html`
<${NoteList}
notes=${notes.notes} loading=${notes.loading}
hasMore=${notes.hasMore} loadMore=${notes.loadMore}
folders=${notes.folders} folder=${notes.folder} setFolder=${notes.setFolder}
sort=${notes.sort} setSort=${notes.setSort}
searchQuery=${notes.searchQuery} setSearchQuery=${notes.setSearchQuery}
tagFilter=${notes.tagFilter} setTagFilter=${notes.setTagFilter}
selectMode=${notes.selectMode} selectedIds=${notes.selectedIds}
enterSelectMode=${notes.enterSelectMode} exitSelectMode=${notes.exitSelectMode}
toggleSelect=${notes.toggleSelect} toggleSelectAll=${notes.toggleSelectAll}
bulkDelete=${notes.bulkDelete}
onNoteClick=${(id) => { saveRecentNote({ id, title: '' }); notes.openNote(id); }}
onNewNote=${notes.newNote}
onDailyNote=${notes.openDailyNote}
onGraphClick=${() => notes.setView('graph')}
pinnedIds=${notes.pinnedIds} togglePin=${notes.togglePin} />`;
case 'editor':
return html`
<${NoteEditor}
currentNote=${notes.currentNote}
editingId=${notes.editingId}
folders=${notes.folders}
onSave=${notes.saveNote}
onCancel=${() => {
if (notes.currentNote) {
notes.setEditorMode('read');
notes.setView('reader');
} else {
notes.setView('list');
notes.refreshList();
}
}}
onDelete=${notes.deleteNote} />`;
case 'reader':
return html`
<${NoteReader}
note=${notes.currentNote}
backlinks=${notes.backlinks}
onEdit=${() => {
notes.setEditorMode('edit');
notes.setView('editor');
}}
onDelete=${notes.deleteNote}
onCopy=${notes.copyNote}
onNavigateLink=${notes.navigateToLink}
onBack=${() => { notes.setView('list'); notes.refreshList(); }}
isDailyNote=${notes.isDailyNote}
onDailyPrev=${() => notes.navigateDaily(-1)}
onDailyNext=${() => notes.navigateDaily(1)} />`;
case 'graph':
if (!GraphComp) {
return html`<div class="sw-notes-pane__loading">Loading graph\u2026</div>`;
}
return html`
<${GraphComp}
data=${notes.graphData}
onNodeClick=${(id) => { notes.openNote(id); }}
onGhostClick=${(title) => { notes.navigateToLink(title); }}
onBack=${() => notes.setView('list')}
handleRef=${graphHandleRef} />`;
default:
return null;
}
};
return html`
<div class=${'sw-notes-pane' + (className ? ' ' + className : '')}>
${renderView()}
<${QuickSwitcher}
open=${notes.quickSwitcherOpen}
onClose=${() => notes.setQuickSwitcherOpen(false)}
onSelect=${(id) => notes.openNote(id)}
onCreateNew=${onQuickSwitcherCreate} />
</div>
`;
}
// ── Kit re-exports ─────────────────────────
export { useNotes } from './use-notes.js';
export { NoteList } from './note-list.js';
export { NoteListItem } from './note-list-item.js';
export { NoteEditor } from './note-editor.js';
export { NoteReader } from './note-reader.js';
export { NoteGraph } from './note-graph.js';
export { QuickSwitcher, saveRecentNote } from './quick-switcher.js';
export { SaveToNoteDialog } from './save-to-note.js';
export { renderNoteMarkdown, attachWikilinkHandlers, resolveTransclusions, extractHeadings } from './markdown.js';

View File

@@ -0,0 +1,157 @@
// ==========================================
// NotesPane Kit — Markdown with Wikilinks
// ==========================================
// Renders markdown content via marked + DOMPurify,
// then post-processes wikilinks into clickable chips
// and transclusion blocks.
// Independently importable.
const WIKILINK_RE = /(!?)\[\[([^\[\]|]+?)(?:\|([^\]]+?))?\]\]/g;
/**
* Render markdown with wikilink support.
*
* @param {string} content — raw markdown
* @param {{ onLinkClick?: (title: string) => void }} opts
* @returns {{ html: string, wikilinks: Array<{title: string, display: string, isTransclusion: boolean}> }}
*/
export function renderNoteMarkdown(content, opts = {}) {
if (!content) return { html: '', wikilinks: [] };
// 1. Render markdown
let rendered = '';
if (window.marked) {
try {
rendered = window.marked.parse(content, { breaks: true, gfm: true });
} catch {
rendered = _escapeHtml(content).replace(/\n/g, '<br>');
}
} else {
rendered = _escapeHtml(content).replace(/\n/g, '<br>');
}
// 2. Sanitize
if (window.DOMPurify) {
rendered = window.DOMPurify.sanitize(rendered, {
ADD_ATTR: ['target', 'rel'],
});
}
// 3. Extract and replace wikilinks
const wikilinks = [];
rendered = rendered.replace(WIKILINK_RE, (_match, bang, title, display) => {
const titleTrimmed = title.trim();
const displayText = display ? display.trim() : titleTrimmed;
const isTransclusion = bang === '!';
wikilinks.push({ title: titleTrimmed, display: displayText, isTransclusion });
if (isTransclusion) {
return `<div class="sw-notes-pane__transclusion" data-title="${_escapeAttr(titleTrimmed)}">` +
`<div class="sw-notes-pane__transclusion-header">` +
`<span class="sw-notes-pane__wikilink sw-notes-pane__wikilink--transclusion" ` +
`data-link-title="${_escapeAttr(titleTrimmed)}">` +
`\u2197 ${_escapeHtml(displayText)}</span></div>` +
`<div class="sw-notes-pane__transclusion-content">Loading\u2026</div></div>`;
}
return `<span class="sw-notes-pane__wikilink" ` +
`data-link-title="${_escapeAttr(titleTrimmed)}">${_escapeHtml(displayText)}</span>`;
});
return { html: rendered, wikilinks };
}
/**
* Attach click handlers to wikilink elements inside a container.
* @param {HTMLElement} container
* @param {(title: string) => void} onLinkClick
*/
export function attachWikilinkHandlers(container, onLinkClick) {
if (!container || !onLinkClick) return;
container.querySelectorAll('[data-link-title]').forEach(el => {
el.style.cursor = 'pointer';
el.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
onLinkClick(el.dataset.linkTitle);
});
});
}
/**
* Resolve transclusion blocks — load content for ![[Title]] embeds.
* @param {HTMLElement} container
* @param {(title: string) => void} onLinkClick
*/
export async function resolveTransclusions(container, onLinkClick) {
const embeds = container?.querySelectorAll('.sw-notes-pane__transclusion');
if (!embeds?.length) return;
const api = window.sw?.api?.notes;
if (!api) return;
const cache = {};
for (const el of embeds) {
const title = el.dataset.title;
const contentEl = el.querySelector('.sw-notes-pane__transclusion-content');
if (!title || !contentEl) continue;
try {
let note = cache[title.toLowerCase()];
if (!note) {
const resp = await api.searchTitles(title, 1);
const match = (resp.data || resp || []).find(
n => n.title.toLowerCase() === title.toLowerCase()
);
if (!match) {
contentEl.innerHTML = '<span style="color:var(--text-3)">Note not found</span>';
continue;
}
note = await api.get(match.id);
cache[title.toLowerCase()] = note;
}
// Render content (strip nested transclusions to prevent recursion)
const safeContent = (note.content || '').replace(/!\[\[[^\]]+\]\]/g, (m) => {
const inner = m.match(/!\[\[([^\]|]+)/)?.[1]?.trim() || '';
return `[Embedded: ${inner}]`;
});
const { html } = renderNoteMarkdown(safeContent);
contentEl.innerHTML = html;
// Attach link handlers inside transclusion
if (onLinkClick) attachWikilinkHandlers(contentEl, onLinkClick);
} catch {
contentEl.innerHTML = '<span style="color:var(--text-3)">Failed to load</span>';
}
}
}
/**
* Extract headings from markdown content for outline/TOC.
* @param {string} content — raw markdown
* @returns {Array<{level: number, text: string, id: string}>}
*/
export function extractHeadings(content) {
if (!content) return [];
const headings = [];
const lines = content.split('\n');
for (const line of lines) {
const match = line.match(/^(#{1,3})\s+(.+)/);
if (match) {
const text = match[2].trim();
const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
headings.push({ level: match[1].length, text, id });
}
}
return headings;
}
function _escapeHtml(str) {
const el = document.createElement('div');
el.textContent = str;
return el.innerHTML;
}
function _escapeAttr(str) {
return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

View File

@@ -0,0 +1,172 @@
// ==========================================
// NotesPane Kit — NoteEditor
// ==========================================
// Editor view: title, folder, tags, content with
// CM6 integration (falls back to textarea).
// Word/char count, keyboard shortcuts.
// Independently importable.
const { useState, useRef, useCallback, useEffect } = window.hooks;
const html = window.html;
/**
* @param {{
* currentNote: object|null,
* editingId: string|null,
* folders: Array,
* onSave: (fields: object) => Promise<boolean>,
* onCancel: () => void,
* onDelete: () => void,
* }} props
*/
export function NoteEditor(props) {
const { currentNote, editingId, folders, onSave, onCancel, onDelete } = props;
const [title, setTitle] = useState(currentNote?.title || '');
const [folderPath, setFolderPath] = useState(currentNote?.folder_path || '');
const [tags, setTags] = useState((currentNote?.tags || []).join(', '));
const [content, setContent] = useState(currentNote?.content || '');
const [saving, setSaving] = useState(false);
const titleRef = useRef(null);
const contentRef = useRef(null);
const cmEditorRef = useRef(null);
const containerRef = useRef(null);
// ── Populate from note ──────────────────
useEffect(() => {
setTitle(currentNote?.title || '');
setFolderPath(currentNote?.folder_path || '');
setTags((currentNote?.tags || []).join(', '));
setContent(currentNote?.content || '');
// Destroy old CM editor if any
if (cmEditorRef.current) {
cmEditorRef.current.destroy();
cmEditorRef.current = null;
}
}, [currentNote?.id]);
// ── Auto-focus title on new note ────────
useEffect(() => {
if (!editingId && titleRef.current) {
titleRef.current.focus();
}
}, [editingId]);
// ── CM6 integration ─────────────────────
useEffect(() => {
const cmContainer = containerRef.current;
if (!cmContainer) return;
if (window.CM?.noteEditor && !cmEditorRef.current) {
cmContainer.innerHTML = '';
cmEditorRef.current = window.CM.noteEditor(cmContainer, {
value: content,
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
onChange: (val) => setContent(val),
onLink: null, // handled by reader, not editor
linkCompleter: async (query) => {
if (!query || query.length < 1) return [];
try {
const resp = await window.sw.api.notes.searchTitles(query, 8);
return (resp.data || resp || []).map(n => ({ label: n.title, id: n.id }));
} catch { return []; }
},
});
}
return () => {
if (cmEditorRef.current) {
cmEditorRef.current.destroy();
cmEditorRef.current = null;
}
};
}, []);
// ── Get content (CM or textarea) ────────
const getContent = useCallback(() => {
if (cmEditorRef.current) return cmEditorRef.current.getValue();
return content;
}, [content]);
// ── Word/char count ─────────────────────
const wordCount = content ? content.trim().split(/\s+/).filter(Boolean).length : 0;
const charCount = content ? content.length : 0;
// ── Save handler ────────────────────────
const handleSave = useCallback(async () => {
if (saving) return;
setSaving(true);
const tagsArr = tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [];
const ok = await onSave({
title: title.trim(),
content: getContent(),
folder_path: folderPath.trim(),
tags: tagsArr,
});
setSaving(false);
}, [title, folderPath, tags, getContent, onSave, saving]);
// ── Keyboard shortcuts ──────────────────
useEffect(() => {
const handler = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
handleSave();
}
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [handleSave, onCancel]);
return html`
<div class="sw-notes-pane__editor-header">
<button class="sw-notes-pane__toolbar-btn" onClick=${onCancel}>← Back</button>
<span class="sw-notes-pane__editor-header-spacer" />
${editingId && html`
<button class="sw-notes-pane__toolbar-btn"
style="color:var(--danger);border-color:var(--danger)"
onClick=${onDelete}>Delete</button>`}
<button class="sw-notes-pane__toolbar-btn"
style="color:var(--success);border-color:var(--success)"
onClick=${handleSave} disabled=${saving}>
${saving ? 'Saving\u2026' : 'Save'}
</button>
</div>
<div class="sw-notes-pane__editor">
<input class="sw-notes-pane__editor-title" type="text"
ref=${titleRef} placeholder="Note title"
value=${title} onInput=${e => setTitle(e.target.value)} />
<div class="sw-notes-pane__editor-meta">
<input type="text" placeholder="Folder (e.g. /work/project)"
value=${folderPath} onInput=${e => setFolderPath(e.target.value)}
list="sw-notes-folders" />
<datalist id="sw-notes-folders">
${folders.map(f => html`<option key=${f.path} value=${f.path} />`)}
</datalist>
<input type="text" placeholder="Tags (comma-separated)"
value=${tags} onInput=${e => setTags(e.target.value)} />
</div>
<div class="sw-notes-pane__editor-content" ref=${containerRef}>
${!window.CM?.noteEditor && html`
<textarea class="sw-notes-pane__editor-textarea"
ref=${contentRef}
placeholder="Write your note in markdown\u2026"
value=${content}
onInput=${e => setContent(e.target.value)} />`}
</div>
<div class="sw-notes-pane__editor-footer">
<span>${wordCount} words · ${charCount} chars</span>
<span style="color:var(--text-3)">Ctrl+S to save · Esc to cancel</span>
</div>
</div>
`;
}

View File

@@ -0,0 +1,561 @@
// ==========================================
// NotesPane Kit — NoteGraph (Canvas)
// ==========================================
// Force-directed graph visualization of note connections.
// Self-contained Preact component with physics sim,
// pan/zoom/drag, hover highlighting, folder colors.
// Lazy-loadable via dynamic import().
// Independently importable.
const { useRef, useEffect, useState, useCallback } = window.hooks;
const html = window.html;
// ── Simulation Parameters ───────────────────
const SIM = {
repulsion: 800,
attraction: 0.006,
edgeLength: 130,
damping: 0.88,
centerGravity: 0.012,
maxVelocity: 8,
ticksPerFrame: 3,
coolThreshold: 0.5,
};
const BASE_RADIUS = 6;
const FOLDER_COLORS = [
'#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6',
'#8b5cf6', '#ef4444', '#06b6d4', '#84cc16', '#f97316',
];
function _clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
function _withAlpha(hex, alpha) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
/**
* @param {{
* data: { nodes: Array, edges: Array, unresolved?: Array } | null,
* onNodeClick: (id: string) => void,
* onGhostClick: (title: string) => void,
* onBack: () => void,
* handleRef?: { current: any },
* }} props
*/
export function NoteGraph(props) {
const { data, onNodeClick, onGhostClick, onBack, handleRef } = props;
const canvasRef = useRef(null);
const wrapRef = useRef(null);
const stateRef = useRef(null);
const [showOrphans, setShowOrphans] = useState(true);
const [focusNodeId, setFocusNodeId] = useState(null);
const [stats, setStats] = useState({ nodes: 0, edges: 0 });
// ── Prepare graph data with physics state ──
useEffect(() => {
if (!data || !canvasRef.current) return;
const canvas = canvasRef.current;
const rect = wrapRef.current.getBoundingClientRect();
canvas.width = Math.round(rect.width);
canvas.height = Math.round(rect.height);
const cx = canvas.width / 2;
const cy = canvas.height / 2;
// Add physics state to nodes
const nodeMap = {};
const folderColorMap = {};
function folderColor(path) {
if (!path || path === '/') return '#6b7280';
if (!folderColorMap[path]) {
const idx = Object.keys(folderColorMap).length % FOLDER_COLORS.length;
folderColorMap[path] = FOLDER_COLORS[idx];
}
return folderColorMap[path];
}
const nodes = data.nodes.map(n => {
const node = {
...n,
x: cx + (Math.random() - 0.5) * canvas.width * 0.5,
y: cy + (Math.random() - 0.5) * canvas.height * 0.5,
vx: 0, vy: 0, ax: 0, ay: 0,
pinned: false, isGhost: false,
};
nodeMap[n.id] = node;
return node;
});
// Resolve edge references
const edges = data.edges.map(e => ({
...e,
sourceNode: nodeMap[e.source],
targetNode: nodeMap[e.target],
}));
// Ghost nodes for unresolved links
const ghostNodes = [];
if (data.unresolved) {
const ghostMap = {};
for (const d of data.unresolved) {
const key = d.title.toLowerCase();
if (!ghostMap[key]) {
const ghost = {
id: '__ghost_' + key, title: d.title,
folder_path: '', tags: [], link_count: 0,
x: cx + (Math.random() - 0.5) * canvas.width * 0.4,
y: cy + (Math.random() - 0.5) * canvas.height * 0.4,
vx: 0, vy: 0, ax: 0, ay: 0,
pinned: false, isGhost: true,
};
ghostMap[key] = ghost;
ghostNodes.push(ghost);
}
}
}
setStats({ nodes: nodes.length, edges: edges.length });
const state = {
nodes, edges, ghostNodes, nodeMap, folderColorMap, folderColor,
panX: 0, panY: 0, zoom: 1,
animId: null, canvas, ctx: canvas.getContext('2d'),
hoveredNode: null, dragNode: null,
isDragging: false, lastMouse: null,
showOrphans: true,
focusNodeId: null,
onNodeClick, onGhostClick,
onFocusNode: (id) => setFocusNodeId(id),
};
stateRef.current = state;
// Start simulation
_startSim(state, onNodeClick);
// Attach listeners
const cleanup = _attachListeners(state, onNodeClick);
// Resize observer
let roFrame = null;
const ro = new ResizeObserver(() => {
if (roFrame) return;
roFrame = requestAnimationFrame(() => {
roFrame = null;
const r = wrapRef.current.getBoundingClientRect();
const w = Math.round(r.width);
const h = Math.round(r.height);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
_render(state);
}
});
});
ro.observe(wrapRef.current);
return () => {
if (state.animId) cancelAnimationFrame(state.animId);
state.animId = null;
cleanup();
ro.disconnect();
};
}, [data, onNodeClick]);
// ── Sync showOrphans + focusNodeId into state ──
useEffect(() => {
if (stateRef.current) {
stateRef.current.showOrphans = showOrphans;
stateRef.current.focusNodeId = focusNodeId;
_render(stateRef.current);
}
}, [showOrphans, focusNodeId]);
// ── Expose imperative handle ────────────
useEffect(() => {
if (handleRef) {
handleRef.current = {
resetZoom() {
const s = stateRef.current;
if (!s) return;
s.panX = 0; s.panY = 0; s.zoom = 1;
_render(s);
},
toggleOrphans() {
setShowOrphans(p => !p);
},
focusNode(id) {
setFocusNodeId(id);
},
clearFocus() {
setFocusNodeId(null);
},
};
}
});
// ── Focus mode label ───────────────────
const focusLabel = focusNodeId && stateRef.current
? (stateRef.current.nodes.find(n => n.id === focusNodeId)?.title || 'Unknown')
: null;
return html`
<div class="sw-notes-pane__graph">
<div class="sw-notes-pane__graph-toolbar">
<button class="sw-notes-pane__toolbar-btn" onClick=${onBack}>← Back</button>
<span class="sw-notes-pane__graph-stats">
${stats.nodes} notes · ${stats.edges} links
</span>
<span class="sw-notes-pane__toolbar-spacer" />
${focusNodeId && html`
<span style="font-size:11px;color:var(--accent)">Focus: ${focusLabel}</span>
<button class="sw-notes-pane__toolbar-btn" onClick=${() => setFocusNodeId(null)}>
Show All
</button>`}
<button class="sw-notes-pane__toolbar-btn"
onClick=${() => setShowOrphans(p => !p)}>
Orphans ${showOrphans ? 'On' : 'Off'}
</button>
<button class="sw-notes-pane__toolbar-btn"
onClick=${() => handleRef?.current?.resetZoom()}>
Reset
</button>
</div>
<div class="sw-notes-pane__graph-canvas-wrap" ref=${wrapRef}>
<canvas ref=${canvasRef} />
</div>
</div>
`;
}
// ── Simulation ──────────────────────────────
function _allNodes(state) {
const nodes = [...state.nodes];
if (state.showOrphans !== false && state.ghostNodes) {
nodes.push(...state.ghostNodes);
}
return nodes;
}
function _startSim(state, onNodeClick) {
function frame() {
for (let t = 0; t < SIM.ticksPerFrame; t++) _tick(state);
_render(state);
let totalKE = 0;
for (const n of _allNodes(state)) {
totalKE += n.vx * n.vx + n.vy * n.vy;
}
if (totalKE > SIM.coolThreshold || state.dragNode) {
state.animId = requestAnimationFrame(frame);
} else {
state.animId = null;
_render(state);
}
}
if (state.animId) cancelAnimationFrame(state.animId);
state.animId = requestAnimationFrame(frame);
}
function _tick(state) {
const nodes = _allNodes(state);
const edges = state.edges;
if (nodes.length === 0) return;
for (const n of nodes) { n.ax = 0; n.ay = 0; }
// Repulsion
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x;
const dy = nodes[j].y - nodes[i].y;
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const f = SIM.repulsion / (dist * dist);
const fx = (dx / dist) * f;
const fy = (dy / dist) * f;
nodes[i].ax -= fx; nodes[i].ay -= fy;
nodes[j].ax += fx; nodes[j].ay += fy;
}
}
// Attraction (edges)
for (const e of edges) {
const s = e.sourceNode, t = e.targetNode;
if (!s || !t) continue;
const dx = t.x - s.x, dy = t.y - s.y;
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const f = SIM.attraction * (dist - SIM.edgeLength);
const fx = (dx / dist) * f;
const fy = (dy / dist) * f;
s.ax += fx; s.ay += fy;
t.ax -= fx; t.ay -= fy;
}
// Center gravity + integration
const cx = state.canvas.width / 2, cy = state.canvas.height / 2;
for (const n of nodes) {
if (n.pinned) continue;
n.ax += (cx - n.x) * SIM.centerGravity;
n.ay += (cy - n.y) * SIM.centerGravity;
n.vx = _clamp((n.vx + n.ax) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity);
n.vy = _clamp((n.vy + n.ay) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity);
n.x += n.vx;
n.y += n.vy;
}
}
// ── Rendering ───────────────────────────────
function _render(state) {
const { ctx, canvas, panX, panY, zoom, hoveredNode, focusNodeId } = state;
if (!ctx) return;
const nodes = _allNodes(state);
const edges = state.edges;
// Build focus neighbor set for focus mode
const focusNeighborIds = new Set();
if (focusNodeId) {
focusNeighborIds.add(focusNodeId);
for (const e of edges) {
if (e.source === focusNodeId) focusNeighborIds.add(e.target);
if (e.target === focusNodeId) focusNeighborIds.add(e.source);
}
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(panX, panY);
ctx.scale(zoom, zoom);
// Edges
for (const e of edges) {
const s = e.sourceNode, t = e.targetNode;
if (!s || !t) continue;
// Focus mode: hide edges not touching the focus neighborhood
if (focusNodeId && !focusNeighborIds.has(e.source) && !focusNeighborIds.has(e.target)) continue;
const isFocusDimmed = focusNodeId && !(focusNeighborIds.has(e.source) && focusNeighborIds.has(e.target));
const isHighlighted = hoveredNode && (s.id === hoveredNode.id || t.id === hoveredNode.id);
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(t.x, t.y);
if (e.is_transclusion) {
ctx.setLineDash([4, 4]);
ctx.strokeStyle = isHighlighted ? 'rgba(99,102,241,0.8)'
: isFocusDimmed ? 'rgba(99,102,241,0.08)' : 'rgba(99,102,241,0.3)';
} else {
ctx.setLineDash([]);
ctx.strokeStyle = isHighlighted ? 'rgba(160,160,160,0.8)'
: isFocusDimmed ? 'rgba(120,120,120,0.06)' : 'rgba(120,120,120,0.25)';
}
ctx.lineWidth = isHighlighted ? 2 : 1;
ctx.stroke();
}
ctx.setLineDash([]);
// Nodes
for (const n of nodes) {
if (n.isGhost && !state.showOrphans) continue;
const r = n.isGhost ? BASE_RADIUS * 0.6 : BASE_RADIUS + Math.sqrt(n.link_count || 0) * 2;
const isHovered = hoveredNode && n.id === hoveredNode.id;
const isNeighbor = hoveredNode && _isNeighbor(state, hoveredNode, n);
const isFocusVisible = !focusNodeId || focusNeighborIds.has(n.id);
const isDimmed = (hoveredNode && !isHovered && !isNeighbor) || !isFocusVisible;
ctx.beginPath();
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
if (n.isGhost) {
ctx.fillStyle = isDimmed ? 'rgba(200,50,50,0.1)' : 'rgba(200,50,50,0.4)';
ctx.setLineDash([2, 2]);
ctx.strokeStyle = 'rgba(200,50,50,0.5)';
} else if (isHovered) {
ctx.fillStyle = '#6366f1';
} else {
const color = state.folderColor(n.folder_path);
ctx.fillStyle = isDimmed ? _withAlpha(color, 0.15) : _withAlpha(color, 0.7);
ctx.strokeStyle = isDimmed ? 'rgba(255,255,255,0.05)' : 'rgba(255,255,255,0.15)';
}
ctx.fill();
if (!n.isGhost) {
ctx.setLineDash([]);
ctx.lineWidth = isHovered ? 2 : 1;
ctx.stroke();
} else {
ctx.stroke();
ctx.setLineDash([]);
}
// Labels
if (zoom > 0.5 && (isHovered || isNeighbor || !hoveredNode)) {
const fontSize = Math.max(10, 11 / zoom);
ctx.font = `${n.isGhost ? 'italic ' : ''}${fontSize}px var(--font, system-ui, sans-serif)`;
ctx.fillStyle = isDimmed ? 'rgba(200,200,200,0.2)' : 'rgba(220,220,220,0.9)';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const label = n.title.length > 30 ? n.title.slice(0, 28) + '\u2026' : n.title;
ctx.fillText(label, n.x, n.y + r + 4);
}
}
ctx.restore();
}
function _isNeighbor(state, hovered, candidate) {
for (const e of state.edges) {
if ((e.source === hovered.id && e.target === candidate.id) ||
(e.target === hovered.id && e.source === candidate.id)) {
return true;
}
}
return false;
}
// ── Interaction ─────────────────────────────
function _attachListeners(state, onNodeClick) {
const canvas = state.canvas;
function toGraph(e) {
const rect = canvas.getBoundingClientRect();
return {
x: (e.clientX - rect.left - state.panX) / state.zoom,
y: (e.clientY - rect.top - state.panY) / state.zoom,
};
}
function hitTest(gx, gy) {
const nodes = _allNodes(state);
for (let i = nodes.length - 1; i >= 0; i--) {
const n = nodes[i];
const r = n.isGhost ? BASE_RADIUS * 0.6 : BASE_RADIUS + Math.sqrt(n.link_count || 0) * 2;
const dx = n.x - gx, dy = n.y - gy;
if (dx * dx + dy * dy <= (r + 4) * (r + 4)) return n;
}
return null;
}
const DRAG_THRESHOLD = 5; // px — must move at least this far to count as drag
const onMouseDown = (e) => {
const g = toGraph(e);
const node = hitTest(g.x, g.y);
if (node) {
state.dragNode = node;
if (!node.isGhost) node.pinned = true;
state.isDragging = false;
}
state.lastMouse = { x: e.clientX, y: e.clientY };
state.mouseDownPos = { x: e.clientX, y: e.clientY };
};
const onMouseMove = (e) => {
const g = toGraph(e);
if (state.dragNode) {
// Only count as drag if moved past threshold
if (!state.isDragging && state.mouseDownPos) {
const dx = e.clientX - state.mouseDownPos.x;
const dy = e.clientY - state.mouseDownPos.y;
if (dx * dx + dy * dy < DRAG_THRESHOLD * DRAG_THRESHOLD) return;
}
state.isDragging = true;
state.dragNode.x = g.x;
state.dragNode.y = g.y;
state.dragNode.vx = 0;
state.dragNode.vy = 0;
if (!state.animId) _startSim(state, onNodeClick);
} else if (state.lastMouse && e.buttons === 1) {
state.panX += e.clientX - state.lastMouse.x;
state.panY += e.clientY - state.lastMouse.y;
state.lastMouse = { x: e.clientX, y: e.clientY };
_render(state);
} else {
const node = hitTest(g.x, g.y);
if (node !== state.hoveredNode) {
state.hoveredNode = node;
canvas.style.cursor = node ? 'pointer' : 'default';
_render(state);
}
}
};
const onMouseUp = (e) => {
if (state.dragNode) {
const clicked = state.dragNode;
clicked.pinned = false;
if (!state.isDragging) {
if (clicked.isGhost && state.onGhostClick) {
// Ghost node → create note with that title
state.onGhostClick(clicked.title);
} else if (!clicked.isGhost) {
// Single click → focus on this node (show neighbors)
if (state.onFocusNode) {
state.onFocusNode(clicked.id === state.focusNodeId ? null : clicked.id);
}
}
}
state.dragNode = null;
} else if (!state.isDragging) {
// Clicked empty space → clear focus
if (state.onFocusNode && state.focusNodeId) {
state.onFocusNode(null);
}
}
state.lastMouse = null;
state.isDragging = false;
};
// Double-click → open note
const onDblClick = (e) => {
const g = toGraph(e);
const node = hitTest(g.x, g.y);
if (node && !node.isGhost && onNodeClick) {
onNodeClick(node.id);
} else if (node && node.isGhost && state.onGhostClick) {
state.onGhostClick(node.title);
}
};
const onWheel = (e) => {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.92 : 1.08;
const newZoom = _clamp(state.zoom * factor, 0.15, 4.0);
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
state.panX = mx - (mx - state.panX) * (newZoom / state.zoom);
state.panY = my - (my - state.panY) * (newZoom / state.zoom);
state.zoom = newZoom;
_render(state);
};
canvas.addEventListener('mousedown', onMouseDown);
canvas.addEventListener('mousemove', onMouseMove);
canvas.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('dblclick', onDblClick);
canvas.addEventListener('wheel', onWheel, { passive: false });
return () => {
canvas.removeEventListener('mousedown', onMouseDown);
canvas.removeEventListener('mousemove', onMouseMove);
canvas.removeEventListener('mouseup', onMouseUp);
canvas.removeEventListener('dblclick', onDblClick);
canvas.removeEventListener('wheel', onWheel);
};
}

View File

@@ -0,0 +1,103 @@
// ==========================================
// NotesPane Kit — NoteListItem
// ==========================================
// Single note row in the list view.
// Independently importable.
const html = window.html;
function _relativeTime(iso) {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return 'just now';
const m = Math.floor(s / 60);
if (m < 60) return m + 'm ago';
const h = Math.floor(m / 60);
if (h < 24) return h + 'h ago';
const d = Math.floor(h / 24);
if (d < 30) return d + 'd ago';
return new Date(iso).toLocaleDateString();
}
function _esc(s) {
const el = document.createElement('span');
el.textContent = s;
return el.innerHTML;
}
function _highlightHeadline(headline) {
const escaped = _esc(headline);
return escaped.replace(/\*\*(.+?)\*\*/g, '<mark>$1</mark>');
}
/**
* @param {{
* note: object,
* isSearch?: boolean,
* onClick: (id: string) => void,
* selectMode?: boolean,
* selected?: boolean,
* onToggleSelect?: (id: string) => void,
* pinned?: boolean,
* onTogglePin?: (id: string) => void,
* }} props
*/
export function NoteListItem(props) {
const { note, isSearch, onClick, selectMode, selected, onToggleSelect, pinned, onTogglePin } = props;
const preview = isSearch && note.headline
? html`<div class="sw-notes-pane__item-preview"
dangerouslySetInnerHTML=${{ __html: _highlightHeadline(note.headline) }} />`
: html`<div class="sw-notes-pane__item-preview">
${(note.preview || note.content || '').slice(0, 120)}
</div>`;
const tags = (note.tags || []).map(t =>
html`<span class="sw-notes-pane__item-tag" key=${t}>${t}</span>`
);
const folderBadge = note.folder_path && note.folder_path !== '/'
? html`<span class="sw-notes-pane__item-folder">${note.folder_path}</span>`
: null;
const onItemClick = (e) => {
if (selectMode && onToggleSelect) {
onToggleSelect(note.id);
} else {
onClick(note.id);
}
};
const onCheckChange = (e) => {
e.stopPropagation();
if (onToggleSelect) onToggleSelect(note.id);
};
const onPinClick = (e) => {
e.stopPropagation();
if (onTogglePin) onTogglePin(note.id);
};
return html`
<div class=${'sw-notes-pane__item' + (selected ? ' sw-notes-pane__item--selected' : '')}
onClick=${onItemClick}>
${selectMode && html`
<input type="checkbox" class="sw-notes-pane__item-checkbox"
checked=${selected} onChange=${onCheckChange} />`}
<div class="sw-notes-pane__item-body">
<div class="sw-notes-pane__item-header">
<span class="sw-notes-pane__item-title">${note.title}</span>
<span class="sw-notes-pane__item-time">${_relativeTime(note.updated_at)}</span>
</div>
${preview}
${(folderBadge || tags.length > 0) && html`
<div class="sw-notes-pane__item-meta">${folderBadge}${tags}</div>`}
</div>
${onTogglePin && html`
<button class=${'sw-notes-pane__item-pin' + (pinned ? ' sw-notes-pane__item-pin--active' : '')}
onClick=${onPinClick} title=${pinned ? 'Unpin' : 'Pin'}>
${pinned ? '\u{1F4CC}' : '\u{1F4CC}'}
</button>`}
</div>`;
}

View File

@@ -0,0 +1,163 @@
// ==========================================
// NotesPane Kit — NoteList
// ==========================================
// List view: toolbar, search, filters, tag cloud,
// scrollable note list with pinned section,
// selection bar, and pagination.
// Independently importable.
import { NoteListItem } from './note-list-item.js';
const { useState, useRef, useCallback, useEffect } = window.hooks;
const html = window.html;
/**
* @param {{
* notes: Array, loading: boolean, hasMore: boolean, loadMore: () => void,
* folders: Array, folder: string, setFolder: (f: string) => void,
* sort: string, setSort: (s: string) => void,
* searchQuery: string, setSearchQuery: (q: string) => void,
* tagFilter: string, setTagFilter: (t: string) => void,
* selectMode: boolean, selectedIds: Set, enterSelectMode: () => void,
* exitSelectMode: () => void, toggleSelect: (id: string) => void,
* toggleSelectAll: (checked: boolean) => void, bulkDelete: () => void,
* onNoteClick: (id: string) => void, onNewNote: () => void,
* onDailyNote: () => void, onGraphClick: () => void,
* pinnedIds: Set, togglePin: (id: string) => void,
* }} props
*/
export function NoteList(props) {
const {
notes, loading, hasMore, loadMore,
folders, folder, setFolder,
sort, setSort,
searchQuery, setSearchQuery,
tagFilter, setTagFilter,
selectMode, selectedIds, enterSelectMode, exitSelectMode,
toggleSelect, toggleSelectAll, bulkDelete,
onNoteClick, onNewNote, onDailyNote, onGraphClick,
pinnedIds, togglePin,
} = props;
const searchTimerRef = useRef(null);
const searchInputRef = useRef(null);
const onSearchInput = useCallback((e) => {
const val = e.target.value;
clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => {
if (val.trim().length >= 2 || val.trim().length === 0) {
setSearchQuery(val.trim());
}
}, 300);
}, [setSearchQuery]);
// ── Tag cloud: collect tags from visible notes ──
const tagCounts = {};
for (const n of notes) {
for (const t of (n.tags || [])) {
tagCounts[t] = (tagCounts[t] || 0) + 1;
}
}
const topTags = Object.entries(tagCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 15);
// ── Split pinned vs unpinned ──
const isSearch = searchQuery && searchQuery.length >= 2;
const pinnedNotes = isSearch ? [] : notes.filter(n => pinnedIds.has(n.id));
const unpinnedNotes = isSearch ? notes : notes.filter(n => !pinnedIds.has(n.id));
return html`
<div class="sw-notes-pane__toolbar">
<button class="sw-notes-pane__toolbar-btn" onClick=${onNewNote} title="New note">+ New</button>
<button class="sw-notes-pane__toolbar-btn" onClick=${onDailyNote} title="Daily note">Today</button>
${onGraphClick && html`
<button class="sw-notes-pane__toolbar-btn" onClick=${onGraphClick} title="Graph view">Graph</button>`}
<span class="sw-notes-pane__toolbar-spacer" />
<button class="sw-notes-pane__toolbar-btn" onClick=${() => selectMode ? exitSelectMode() : enterSelectMode()}
title=${selectMode ? 'Cancel selection' : 'Select mode'}>
${selectMode ? 'Cancel' : 'Select'}
</button>
</div>
<div class="sw-notes-pane__search-row">
<input class="sw-notes-pane__search-input" type="text"
placeholder="Search notes\u2026" autocomplete="off"
ref=${searchInputRef} onInput=${onSearchInput}
value=${searchQuery} />
</div>
<div class="sw-notes-pane__filter-row">
<select class="sw-notes-pane__filter-select"
value=${folder} onChange=${e => { setFolder(e.target.value); }}>
<option value="">All folders</option>
${folders.map(f => html`
<option key=${f.path} value=${f.path}>${f.path} (${f.count})</option>`)}
</select>
<select class="sw-notes-pane__filter-select"
value=${sort} onChange=${e => setSort(e.target.value)}>
<option value="updated_desc">Last updated</option>
<option value="created_desc">Created</option>
<option value="alpha">Alphabetical</option>
</select>
</div>
${topTags.length > 0 && html`
<div class="sw-notes-pane__tag-cloud">
${topTags.map(([tag, count]) => html`
<span key=${tag}
class=${'sw-notes-pane__tag-chip' + (tagFilter === tag ? ' sw-notes-pane__tag-chip--active' : '')}
onClick=${() => setTagFilter(tagFilter === tag ? '' : tag)}>
${tag}
</span>`)}
</div>`}
<div class="sw-notes-pane__list">
${loading && notes.length === 0 && html`
<div class="sw-notes-pane__loading">Loading\u2026</div>`}
${!loading && notes.length === 0 && html`
<div class="sw-notes-pane__list-empty">
${isSearch ? 'No results found' : (folder ? 'No notes in this folder' : 'No notes yet. Create one!')}
</div>`}
${pinnedNotes.length > 0 && html`
<div class="sw-notes-pane__pinned-divider">Pinned</div>
${pinnedNotes.map(n => html`
<${NoteListItem} key=${n.id} note=${n} isSearch=${isSearch}
onClick=${onNoteClick} selectMode=${selectMode}
selected=${selectedIds.has(n.id)} onToggleSelect=${toggleSelect}
pinned=${true} onTogglePin=${togglePin} />`)}
${unpinnedNotes.length > 0 && html`
<div class="sw-notes-pane__pinned-divider">Notes</div>`}`}
${unpinnedNotes.map(n => html`
<${NoteListItem} key=${n.id} note=${n} isSearch=${isSearch}
onClick=${onNoteClick} selectMode=${selectMode}
selected=${selectedIds.has(n.id)} onToggleSelect=${toggleSelect}
pinned=${false} onTogglePin=${togglePin} />`)}
${hasMore && html`
<button class="sw-notes-pane__load-more" onClick=${loadMore}>
Load more\u2026
</button>`}
</div>
${selectMode && html`
<div class="sw-notes-pane__selection-bar">
<label>
<input type="checkbox"
checked=${selectedIds.size === notes.length && notes.length > 0}
onChange=${e => toggleSelectAll(e.target.checked)} />
All
</label>
<span>${selectedIds.size} selected</span>
<button class="sw-notes-pane__toolbar-btn" onClick=${bulkDelete}
style="color:var(--danger);border-color:var(--danger)">
Delete
</button>
<button class="sw-notes-pane__toolbar-btn" onClick=${exitSelectMode}>Cancel</button>
</div>`}
`;
}

View File

@@ -0,0 +1,184 @@
// ==========================================
// NotesPane Kit — NoteReader
// ==========================================
// Read-mode view: rendered markdown with wikilink chips,
// transclusion embeds, outline/TOC, backlinks panel,
// unlinked mentions, daily note navigation.
// Independently importable.
import { renderNoteMarkdown, attachWikilinkHandlers, resolveTransclusions, extractHeadings } from './markdown.js';
const { useState, useRef, useCallback, useEffect } = window.hooks;
const html = window.html;
/**
* @param {{
* note: object,
* backlinks: Array,
* onEdit: () => void,
* onDelete: () => void,
* onCopy: () => void,
* onNavigateLink: (title: string) => void,
* onBack: () => void,
* isDailyNote: boolean,
* onDailyPrev: () => void,
* onDailyNext: () => void,
* }} props
*/
export function NoteReader(props) {
const {
note, backlinks, onEdit, onDelete, onCopy, onNavigateLink,
onBack, isDailyNote, onDailyPrev, onDailyNext,
} = props;
const contentRef = useRef(null);
const [backlinksOpen, setBacklinksOpen] = useState(true);
const [unlinkedMentions, setUnlinkedMentions] = useState([]);
// ── Render content + wikilinks ──────────
useEffect(() => {
if (!contentRef.current || !note) return;
const { html: rendered } = renderNoteMarkdown(note.content || '');
contentRef.current.innerHTML = rendered;
// Attach wikilink click handlers
attachWikilinkHandlers(contentRef.current, onNavigateLink);
// Resolve transclusions
resolveTransclusions(contentRef.current, onNavigateLink);
// Add IDs to headings for outline scroll-to
contentRef.current.querySelectorAll('h1, h2, h3').forEach(el => {
const id = el.textContent.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
el.id = 'heading-' + id;
});
}, [note?.id, note?.content, onNavigateLink]);
// ── Extract headings for outline ────────
const headings = note ? extractHeadings(note.content || '') : [];
// ── Find unlinked mentions ──────────────
useEffect(() => {
if (!note?.content) { setUnlinkedMentions([]); return; }
// Debounce: fetch note titles and check for unlinked mentions
const timer = setTimeout(async () => {
try {
const api = window.sw?.api?.notes;
if (!api) return;
// Get all note titles (limited)
const resp = await api.list({ limit: 200, offset: 0 });
const allNotes = resp.data || resp || [];
const content = note.content.toLowerCase();
// Find titles mentioned in content but not wikilinked
const linked = new Set();
const linkRe = /\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]/g;
let m;
while ((m = linkRe.exec(note.content)) !== null) {
linked.add(m[1].trim().toLowerCase());
}
const mentions = allNotes
.filter(n => n.id !== note.id)
.filter(n => {
const t = n.title.toLowerCase();
return t.length > 2 && content.includes(t) && !linked.has(t);
})
.map(n => n.title)
.slice(0, 10);
setUnlinkedMentions(mentions);
} catch { setUnlinkedMentions([]); }
}, 500);
return () => clearTimeout(timer);
}, [note?.id, note?.content]);
if (!note) return null;
const folderBadge = note.folder_path && note.folder_path !== '/'
? html`<span class="sw-notes-pane__item-folder">${note.folder_path}</span>`
: null;
const tagChips = (note.tags || []).map(t =>
html`<span class="sw-notes-pane__item-tag" key=${t}>${t}</span>`
);
const today = new Date().toISOString().slice(0, 10);
const dailyDate = isDailyNote ? note.title.replace('Daily \u2014 ', '') : null;
const scrollToHeading = (id) => {
const el = contentRef.current?.querySelector('#heading-' + id);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return html`
<div class="sw-notes-pane__editor-header">
<button class="sw-notes-pane__toolbar-btn" onClick=${onBack}>← Back</button>
<span class="sw-notes-pane__editor-header-spacer" />
<button class="sw-notes-pane__toolbar-btn" onClick=${onCopy} title="Copy to clipboard">Copy</button>
<button class="sw-notes-pane__toolbar-btn" onClick=${onEdit}>Edit</button>
<button class="sw-notes-pane__toolbar-btn"
style="color:var(--danger);border-color:var(--danger)"
onClick=${onDelete}>Delete</button>
</div>
<div class=${'sw-notes-pane__reader' + (headings.length > 2 ? ' sw-notes-pane__reader--with-outline' : '')}>
${headings.length > 2 && html`
<div class="sw-notes-pane__outline-sidebar">
<div class="sw-notes-pane__outline-title">Outline</div>
${headings.map(h => html`
<a key=${h.id}
class=${'sw-notes-pane__outline-item sw-notes-pane__outline-item--h' + h.level}
onClick=${() => scrollToHeading(h.id)}>
${h.text}
</a>`)}
</div>`}
<div class="sw-notes-pane__reader-scroll">
<h1 class="sw-notes-pane__reader-title">${note.title}</h1>
${(folderBadge || tagChips.length > 0) && html`
<div class="sw-notes-pane__reader-meta">${folderBadge}${tagChips}</div>`}
${isDailyNote && html`
<div class="sw-notes-pane__daily-nav">
<button class="sw-notes-pane__toolbar-btn" onClick=${onDailyPrev}> Prev</button>
<span>${dailyDate}</span>
<button class="sw-notes-pane__toolbar-btn" onClick=${onDailyNext}
disabled=${dailyDate >= today}>Next </button>
</div>`}
<div class="sw-notes-pane__reader-content" ref=${contentRef} />
${backlinks.length > 0 && html`
<div class="sw-notes-pane__backlinks">
<div class="sw-notes-pane__backlinks-header"
onClick=${() => setBacklinksOpen(p => !p)}>
${backlinksOpen ? '▾' : '▸'} Backlinks
<span class="sw-notes-pane__backlinks-badge">${backlinks.length}</span>
</div>
${backlinksOpen && html`
<div class="sw-notes-pane__backlinks-list">
${backlinks.map(bl => html`
<div key=${bl.id} class="sw-notes-pane__backlink-item"
onClick=${() => onNavigateLink(bl.title)}>
<span>${bl.title}</span>
${bl.folder_path && bl.folder_path !== '/' && html`
<span class="sw-notes-pane__backlink-folder">${bl.folder_path}</span>`}
</div>`)}
</div>`}
</div>`}
${unlinkedMentions.length > 0 && html`
<div class="sw-notes-pane__unlinked">
<div class="sw-notes-pane__unlinked-title">Unlinked Mentions</div>
${unlinkedMentions.map(title => html`
<span key=${title} class="sw-notes-pane__unlinked-item"
onClick=${() => onNavigateLink(title)} title="Navigate to ${title}">
${title}
</span>`)}
</div>`}
</div>
</div>
`;
}

View File

@@ -0,0 +1,159 @@
// ==========================================
// NotesPane Kit — QuickSwitcher
// ==========================================
// Cmd+K / Ctrl+K style quick-open overlay.
// Fuzzy title search, keyboard nav, recent notes,
// create-on-enter. Obsidian's signature UX.
// Independently importable.
const { useState, useRef, useCallback, useEffect } = window.hooks;
const html = window.html;
const RECENTS_KEY = 'sw_notes_recent';
const MAX_RECENTS = 5;
function _loadRecents() {
try {
return JSON.parse(localStorage.getItem(RECENTS_KEY) || '[]').slice(0, MAX_RECENTS);
} catch { return []; }
}
function _saveRecent(note) {
try {
const recents = _loadRecents().filter(r => r.id !== note.id);
recents.unshift({ id: note.id, title: note.title, folder_path: note.folder_path });
localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, MAX_RECENTS)));
} catch {}
}
/**
* @param {{
* open: boolean,
* onClose: () => void,
* onSelect: (id: string) => void,
* onCreateNew: (title: string) => void,
* }} props
*/
export function QuickSwitcher(props) {
const { open, onClose, onSelect, onCreateNew } = props;
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [activeIndex, setActiveIndex] = useState(0);
const [recents, setRecents] = useState([]);
const inputRef = useRef(null);
const searchTimerRef = useRef(null);
// ── Load recents on open ────────────────
useEffect(() => {
if (open) {
setQuery('');
setResults([]);
setActiveIndex(0);
setRecents(_loadRecents());
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
// ── Search ──────────────────────────────
useEffect(() => {
clearTimeout(searchTimerRef.current);
if (!query || query.length < 1) {
setResults([]);
setActiveIndex(0);
return;
}
searchTimerRef.current = setTimeout(async () => {
try {
const resp = await window.sw.api.notes.searchTitles(query, 10);
setResults(resp.data || resp || []);
setActiveIndex(0);
} catch { setResults([]); }
}, 150);
}, [query]);
// ── Visible items ───────────────────────
const items = query ? results : recents;
const hasExactMatch = results.some(r => r.title.toLowerCase() === query.toLowerCase());
const showCreate = query && !hasExactMatch;
// ── Keyboard navigation ─────────────────
const onKeyDown = useCallback((e) => {
const totalItems = items.length + (showCreate ? 1 : 0);
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex(i => (i + 1) % Math.max(totalItems, 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex(i => (i - 1 + totalItems) % Math.max(totalItems, 1));
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex < items.length) {
_select(items[activeIndex]);
} else if (showCreate) {
onCreateNew(query);
onClose();
}
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}, [items, activeIndex, showCreate, query, onClose, onCreateNew]);
const _select = (note) => {
_saveRecent(note);
onSelect(note.id);
onClose();
};
if (!open) return null;
return html`
<div class="sw-notes-pane__switcher-overlay"
onClick=${(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="sw-notes-pane__switcher">
<input class="sw-notes-pane__switcher-input"
ref=${inputRef}
placeholder="Search notes\u2026"
value=${query}
onInput=${e => setQuery(e.target.value)}
onKeyDown=${onKeyDown} />
<div class="sw-notes-pane__switcher-results">
${!query && recents.length > 0 && html`
<div class="sw-notes-pane__switcher-section">Recent</div>`}
${query && results.length === 0 && html`
<div style="padding:12px 14px;color:var(--text-3);font-size:13px">
No notes found
</div>`}
${items.map((note, i) => html`
<div key=${note.id}
class=${'sw-notes-pane__switcher-item' + (i === activeIndex ? ' sw-notes-pane__switcher-item--active' : '')}
onClick=${() => _select(note)}
onMouseEnter=${() => setActiveIndex(i)}>
<span class="sw-notes-pane__switcher-item-title">${note.title}</span>
${note.folder_path && note.folder_path !== '/' && html`
<span class="sw-notes-pane__switcher-item-folder">${note.folder_path}</span>`}
</div>`)}
${showCreate && html`
<div class=${'sw-notes-pane__switcher-create' + (activeIndex === items.length ? ' sw-notes-pane__switcher-item--active' : '')}
onClick=${() => { onCreateNew(query); onClose(); }}
onMouseEnter=${() => setActiveIndex(items.length)}>
+ Create "${query}"
</div>`}
</div>
<div class="sw-notes-pane__switcher-hint">
↑↓ navigate · Enter select · Esc close
</div>
</div>
</div>
`;
}
// Re-export for external use (e.g., saving recents from openNote)
export { _saveRecent as saveRecentNote };

View File

@@ -0,0 +1,188 @@
// ==========================================
// NotesPane Kit — SaveToNoteDialog
// ==========================================
// "Save to note" dialog: create-new or append-to-existing.
// Independently importable by any surface (ChatPane, etc.).
import { renderNoteMarkdown } from './markdown.js';
const { useState, useRef, useCallback, useEffect } = window.hooks;
const html = window.html;
/**
* @param {{
* open: boolean,
* onClose: () => void,
* content: string,
* sourceChannelId?: string,
* sourceMessageId?: string,
* defaultTitle?: string,
* }} props
*/
export function SaveToNoteDialog(props) {
const { open, onClose, content, sourceChannelId, sourceMessageId, defaultTitle } = props;
const [mode, setMode] = useState('create'); // 'create' | 'append'
const [title, setTitle] = useState(defaultTitle || '');
const [folderPath, setFolderPath] = useState('');
const [tags, setTags] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [selectedNote, setSelectedNote] = useState(null);
const [saving, setSaving] = useState(false);
const searchTimerRef = useRef(null);
const titleInputRef = useRef(null);
// ── Reset on open ───────────────────────
useEffect(() => {
if (open) {
setMode('create');
setTitle(defaultTitle || '');
setFolderPath('');
setTags('');
setSearchQuery('');
setSearchResults([]);
setSelectedNote(null);
setTimeout(() => titleInputRef.current?.focus(), 50);
}
}, [open, defaultTitle]);
// ── Search for append target ────────────
useEffect(() => {
clearTimeout(searchTimerRef.current);
if (mode !== 'append' || searchQuery.length < 2) {
setSearchResults([]);
return;
}
searchTimerRef.current = setTimeout(async () => {
try {
const resp = await window.sw.api.notes.searchTitles(searchQuery, 8);
setSearchResults(resp.data || resp || []);
} catch { setSearchResults([]); }
}, 250);
}, [searchQuery, mode]);
// ── Save ────────────────────────────────
const handleSave = useCallback(async () => {
if (saving) return;
setSaving(true);
const api = window.sw.api.notes;
const toast = window.sw?.toast;
try {
if (mode === 'append') {
if (!selectedNote) {
toast?.('Select a note to append to', 'warning');
setSaving(false);
return;
}
await api.update(selectedNote.id, {
content: '\n\n---\n\n' + content,
mode: 'append',
});
toast?.(`Appended to "${selectedNote.title}"`, 'success');
} else {
if (!title.trim()) {
toast?.('Title is required', 'warning');
setSaving(false);
return;
}
const tagsArr = tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [];
await api.create({
title: title.trim(),
content,
folder_path: folderPath.trim(),
tags: tagsArr,
source_channel_id: sourceChannelId,
source_message_id: sourceMessageId,
});
toast?.('Note created', 'success');
}
onClose();
} catch (e) {
toast?.(e.message, 'error');
}
setSaving(false);
}, [mode, title, folderPath, tags, content, selectedNote, sourceChannelId, sourceMessageId, onClose, saving]);
if (!open) return null;
// Preview
const previewContent = content ? content.slice(0, 300) + (content.length > 300 ? '\u2026' : '') : '';
const { html: previewHtml } = renderNoteMarkdown(previewContent);
return html`
<div class="sw-notes-pane__switcher-overlay" onClick=${(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="sw-notes-pane__switcher" style="width:min(480px,90vw)">
<div style="padding:14px">
<h3 style="margin:0 0 10px;font-size:15px;color:var(--text)">Save to Note</h3>
<div class="sw-notes-pane__save-mode">
<label>
<input type="radio" name="saveMode" value="create"
checked=${mode === 'create'}
onChange=${() => setMode('create')} />
Create new
</label>
<label>
<input type="radio" name="saveMode" value="append"
checked=${mode === 'append'}
onChange=${() => setMode('append')} />
Append to existing
</label>
</div>
${mode === 'create' && html`
<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:10px">
<input class="sw-notes-pane__search-input" type="text"
ref=${titleInputRef} placeholder="Title"
value=${title} onInput=${e => setTitle(e.target.value)} />
<div style="display:flex;gap:6px">
<input class="sw-notes-pane__search-input" type="text"
placeholder="Folder (e.g. /work)" style="flex:1"
value=${folderPath} onInput=${e => setFolderPath(e.target.value)} />
<input class="sw-notes-pane__search-input" type="text"
placeholder="Tags (comma-separated)" style="flex:1"
value=${tags} onInput=${e => setTags(e.target.value)} />
</div>
</div>`}
${mode === 'append' && html`
<div style="margin-bottom:10px">
<input class="sw-notes-pane__search-input" type="text"
placeholder="Search notes to append to\u2026"
value=${searchQuery}
onInput=${e => setSearchQuery(e.target.value)} />
${searchResults.length > 0 && html`
<div class="sw-notes-pane__save-search-results">
${searchResults.map(n => html`
<div key=${n.id}
class=${'sw-notes-pane__save-search-item' + (selectedNote?.id === n.id ? ' sw-notes-pane__save-search-item--selected' : '')}
onClick=${() => { setSelectedNote(n); setSearchQuery(n.title); }}>
${n.title}
</div>`)}
</div>`}
</div>`}
<div style="margin-bottom:10px">
<div style="font-size:11px;color:var(--text-3);margin-bottom:4px">
Preview (${content?.length || 0} chars)
</div>
<div class="sw-notes-pane__save-preview"
dangerouslySetInnerHTML=${{ __html: previewHtml }} />
</div>
<div style="display:flex;justify-content:flex-end;gap:6px">
<button class="sw-notes-pane__toolbar-btn" onClick=${onClose}>Cancel</button>
<button class="sw-notes-pane__toolbar-btn"
style="color:var(--accent);border-color:var(--accent)"
onClick=${handleSave} disabled=${saving}>
${saving ? 'Saving\u2026' : (mode === 'append' ? 'Append' : 'Create')}
</button>
</div>
</div>
</div>
</div>
`;
}

View File

@@ -0,0 +1,475 @@
// ==========================================
// NotesPane Kit — useNotes Hook
// ==========================================
// Core state machine: views, CRUD, pagination, search,
// folders, sort, selection, backlinks, daily notes,
// wikilink navigation, pinning, quick switcher.
// Independently importable.
//
// Usage:
// const notes = useNotes({ onNoteChange });
// notes.openNote(id);
const { useState, useRef, useCallback, useEffect } = window.hooks;
const PAGE_SIZE = 50;
const PINS_KEY = 'sw_notes_pinned';
function _loadPins() {
try {
const raw = localStorage.getItem(PINS_KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch { return new Set(); }
}
function _savePins(set) {
try { localStorage.setItem(PINS_KEY, JSON.stringify([...set])); } catch {}
}
/**
* @param {{
* initialFolder?: string,
* initialNoteId?: string,
* onNoteChange?: (note: object|null) => void,
* standalone?: boolean,
* enableGraph?: boolean,
* enableQuickSwitcher?: boolean,
* }} opts
*/
export function useNotes(opts = {}) {
const {
initialFolder,
initialNoteId,
onNoteChange,
enableGraph = true,
enableQuickSwitcher = true,
} = opts;
// ── View state ──────────────────────────
const [view, _setView] = useState(initialNoteId ? 'reader' : 'list');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// ── List state ──────────────────────────
const [notes, setNotes] = useState([]);
const [folder, setFolder] = useState(initialFolder || '');
const [searchQuery, setSearchQuery] = useState('');
const [sort, setSort] = useState('updated_desc');
const [tagFilter, setTagFilter] = useState('');
const pageRef = useRef(1);
const [hasMore, setHasMore] = useState(false);
const [folders, setFolders] = useState([]);
// ── Selection state ─────────────────────
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState(new Set());
// ── Editor/Reader state ─────────────────
const [currentNote, setCurrentNote] = useState(null);
const [editingId, setEditingId] = useState(initialNoteId || null);
const [editorMode, setEditorMode] = useState('read'); // 'read' | 'edit' | 'preview'
const [backlinks, setBacklinks] = useState([]);
// ── Graph state ─────────────────────────
const [graphData, setGraphData] = useState(null);
const graphDirtyRef = useRef(true);
// ── Quick Switcher ──────────────────────
const [quickSwitcherOpen, setQuickSwitcherOpen] = useState(false);
// ── Pinning ─────────────────────────────
const [pinnedIds, setPinnedIds] = useState(_loadPins);
// ── API shortcut ────────────────────────
const api = () => window.sw.api.notes;
const toast = (msg, v) => window.sw?.toast?.(msg, v);
const confirm = (msg) => window.sw?.confirm?.(msg) ?? Promise.resolve(window.confirm(msg));
// ── View switching ──────────────────────
const setView = useCallback((v) => {
_setView(v);
if (v === 'list') {
setCurrentNote(null);
setEditingId(null);
setBacklinks([]);
if (onNoteChange) onNoteChange(null);
}
}, [onNoteChange]);
// ── Load notes list ─────────────────────
const _loadList = useCallback(async (append = false) => {
setLoading(true);
setError(null);
try {
let result, isSearch = false;
if (searchQuery && searchQuery.length >= 2) {
isSearch = true;
const data = await api().search(searchQuery);
result = data.data || data || [];
setHasMore(false);
} else {
const page = append ? pageRef.current : 1;
if (!append) pageRef.current = 1;
const offset = (page - 1) * PAGE_SIZE;
const data = await api().list({ limit: PAGE_SIZE, offset, folder, tag: tagFilter, sort });
result = data.data || data || [];
setHasMore(result.length >= PAGE_SIZE);
}
if (append) {
setNotes(prev => [...prev, ...result]);
} else {
setNotes(result);
}
} catch (e) {
setError(e.message);
}
setLoading(false);
}, [searchQuery, folder, tagFilter, sort]);
const refreshList = useCallback(() => {
pageRef.current = 1;
_loadList(false);
}, [_loadList]);
const loadMore = useCallback(() => {
pageRef.current += 1;
_loadList(true);
}, [_loadList]);
// ── Load folders ────────────────────────
const _loadFolders = useCallback(async () => {
try {
const data = await api().folders();
setFolders(data.folders || data || []);
} catch {}
}, []);
// ── Initial load ────────────────────────
const didMountRef = useRef(false);
useEffect(() => {
if (!didMountRef.current) {
didMountRef.current = true;
_loadList();
_loadFolders();
if (initialNoteId) {
_openNoteById(initialNoteId);
}
}
}, []);
// ── Reload on filter changes ────────────
const prevFiltersRef = useRef({ folder, sort, tagFilter, searchQuery });
useEffect(() => {
const prev = prevFiltersRef.current;
if (prev.folder !== folder || prev.sort !== sort ||
prev.tagFilter !== tagFilter || prev.searchQuery !== searchQuery) {
prevFiltersRef.current = { folder, sort, tagFilter, searchQuery };
if (didMountRef.current) {
pageRef.current = 1;
_loadList(false);
}
}
}, [folder, sort, tagFilter, searchQuery, _loadList]);
// ── Selection ───────────────────────────
const enterSelectMode = useCallback(() => {
setSelectMode(true);
setSelectedIds(new Set());
}, []);
const exitSelectMode = useCallback(() => {
setSelectMode(false);
setSelectedIds(new Set());
}, []);
const toggleSelect = useCallback((id) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const toggleSelectAll = useCallback((checked) => {
if (checked) {
setSelectedIds(new Set(notes.map(n => n.id)));
} else {
setSelectedIds(new Set());
}
}, [notes]);
const bulkDelete = useCallback(async () => {
if (selectedIds.size === 0) return;
const count = selectedIds.size;
if (!await confirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return;
try {
const resp = await api().bulkDelete([...selectedIds]);
toast(`Deleted ${resp.deleted || count} note${count !== 1 ? 's' : ''}`, 'success');
exitSelectMode();
graphDirtyRef.current = true;
refreshList();
_loadFolders();
} catch (e) { toast(e.message, 'error'); }
}, [selectedIds, exitSelectMode, refreshList, _loadFolders]);
// ── Open note ───────────────────────────
const _openNoteById = useCallback(async (id) => {
try {
const note = await api().get(id);
setCurrentNote(note);
setEditingId(id);
setEditorMode('read');
_setView('reader');
if (onNoteChange) onNoteChange(note);
// Load backlinks
try {
const bl = await api().backlinks(id);
setBacklinks(bl.data || bl || []);
} catch { setBacklinks([]); }
} catch (e) {
toast('Failed to load note: ' + e.message, 'error');
}
}, [onNoteChange]);
const openNote = useCallback((id) => _openNoteById(id), [_openNoteById]);
const newNote = useCallback(() => {
setCurrentNote(null);
setEditingId(null);
setEditorMode('edit');
setBacklinks([]);
_setView('editor');
if (onNoteChange) onNoteChange(null);
}, [onNoteChange]);
// ── Save note ───────────────────────────
const saveNote = useCallback(async (fields) => {
const { title, content, folder_path, tags } = fields;
if (!title) { toast('Title is required', 'warning'); return false; }
if (!content) { toast('Content is required', 'warning'); return false; }
try {
if (editingId) {
const updated = await api().update(editingId, {
title, content, folder_path, tags, mode: 'replace',
});
setCurrentNote(updated);
toast('Note updated', 'success');
setEditorMode('read');
_setView('reader');
// Reload backlinks
try {
const bl = await api().backlinks(editingId);
setBacklinks(bl.data || bl || []);
} catch {}
} else {
const created = await api().create({ title, content, folder_path, tags });
setCurrentNote(created);
setEditingId(created.id);
toast('Note created', 'success');
setEditorMode('read');
_setView('reader');
}
graphDirtyRef.current = true;
return true;
} catch (e) {
toast(e.message, 'error');
return false;
}
}, [editingId]);
// ── Delete note ─────────────────────────
const deleteNote = useCallback(async () => {
if (!editingId) return;
if (!await confirm('Delete this note?')) return;
try {
await api().del(editingId);
toast('Note deleted', 'success');
graphDirtyRef.current = true;
setView('list');
refreshList();
_loadFolders();
} catch (e) { toast(e.message, 'error'); }
}, [editingId, setView, refreshList, _loadFolders]);
// ── Copy note ───────────────────────────
const copyNote = useCallback(() => {
if (!currentNote) return;
const text = `# ${currentNote.title || ''}\n\n${currentNote.content || ''}`;
navigator.clipboard.writeText(text)
.then(() => toast('Copied to clipboard', 'success'))
.catch(() => toast('Copy failed', 'error'));
}, [currentNote]);
// ── Wikilink navigation ─────────────────
const navigateToLink = useCallback(async (title) => {
try {
const resp = await api().searchTitles(title, 1);
const match = (resp.data || resp || []).find(
n => n.title.toLowerCase() === title.toLowerCase()
);
if (match) {
await _openNoteById(match.id);
} else {
if (await confirm(`Note "${title}" not found. Create it?`)) {
const created = await api().create({
title,
content: `# ${title}\n\n`,
folder_path: '/',
tags: [],
});
await _openNoteById(created.id);
graphDirtyRef.current = true;
}
}
} catch (e) { toast('Failed to navigate: ' + e.message, 'error'); }
}, [_openNoteById]);
// ── Daily Notes ─────────────────────────
const _isDailyNote = useCallback((note) => {
return note && /^Daily \u2014 \d{4}-\d{2}-\d{2}$/.test(note.title);
}, []);
const isDailyNote = _isDailyNote(currentNote);
const openDailyNote = useCallback(async () => {
const today = new Date().toISOString().slice(0, 10);
const title = `Daily \u2014 ${today}`;
try {
const resp = await api().searchTitles(title, 1);
const existing = (resp.data || resp || []).find(
n => n.title.toLowerCase() === title.toLowerCase()
);
if (existing) {
await _openNoteById(existing.id);
} else {
const content = `# ${today}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
const created = await api().create({
title, content, folder_path: '/daily/', tags: ['daily'],
});
await _openNoteById(created.id);
graphDirtyRef.current = true;
}
} catch (e) { toast('Failed to open daily note: ' + e.message, 'error'); }
}, [_openNoteById]);
const navigateDaily = useCallback(async (offset) => {
if (!currentNote || !_isDailyNote(currentNote)) return;
const dateStr = currentNote.title.replace('Daily \u2014 ', '');
const d = new Date(dateStr + 'T12:00:00');
d.setDate(d.getDate() + offset);
const newDate = d.toISOString().slice(0, 10);
const title = `Daily \u2014 ${newDate}`;
try {
const resp = await api().searchTitles(title, 1);
const existing = (resp.data || resp || []).find(
n => n.title.toLowerCase() === title.toLowerCase()
);
if (existing) {
await _openNoteById(existing.id);
} else {
const content = `# ${newDate}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
const created = await api().create({
title, content, folder_path: '/daily/', tags: ['daily'],
});
await _openNoteById(created.id);
graphDirtyRef.current = true;
}
} catch (e) { toast('Failed to navigate: ' + e.message, 'error'); }
}, [currentNote, _isDailyNote, _openNoteById]);
// ── Graph ───────────────────────────────
const loadGraph = useCallback(async () => {
try {
const data = await api().graph();
setGraphData(data);
graphDirtyRef.current = false;
return data;
} catch (e) {
toast('Failed to load graph: ' + e.message, 'error');
return null;
}
}, []);
const invalidateGraph = useCallback(() => {
graphDirtyRef.current = true;
}, []);
// ── Pinning ─────────────────────────────
const togglePin = useCallback((id) => {
setPinnedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
_savePins(next);
return next;
});
}, []);
// ── Keyboard shortcuts ──────────────────
useEffect(() => {
const handler = (e) => {
// Quick switcher: Ctrl/Cmd + K
if (enableQuickSwitcher && (e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
setQuickSwitcherOpen(prev => !prev);
return;
}
// Ctrl/Cmd + N: new note
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
// Only prevent when notes pane is focused context
if (document.querySelector('.sw-notes-pane')) {
e.preventDefault();
newNote();
return;
}
}
// Ctrl/Cmd + D: daily note
if ((e.ctrlKey || e.metaKey) && e.key === 'd') {
if (document.querySelector('.sw-notes-pane')) {
e.preventDefault();
openDailyNote();
return;
}
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [enableQuickSwitcher, newNote, openDailyNote]);
return {
// View
view, setView,
// List
notes, loading, error,
folder, setFolder,
searchQuery, setSearchQuery,
sort, setSort,
hasMore, loadMore,
folders, refreshList,
tagFilter, setTagFilter,
// Selection
selectMode, selectedIds,
enterSelectMode, exitSelectMode,
toggleSelect, toggleSelectAll,
bulkDelete,
// Editor/Reader
currentNote, editingId,
editorMode, setEditorMode,
openNote, newNote,
saveNote, deleteNote, copyNote,
backlinks, navigateToLink,
// Daily
openDailyNote, isDailyNote, navigateDaily,
// Graph
graphData, loadGraph, invalidateGraph,
// Quick Switcher
quickSwitcherOpen, setQuickSwitcherOpen,
// Pinning
pinnedIds, togglePin,
// Misc
clearError: useCallback(() => setError(null), []),
};
}

View File

@@ -127,8 +127,19 @@ export async function boot() {
});
};
// NotesPane render helper — surfaces call sw.notesPane(container, opts)
// Returns Promise<imperative handle>.
sw.notesPane = function (container, opts = {}) {
return import('../components/notes-pane/index.js').then(({ NotesPane }) => {
const handleRef = { current: null };
const { render } = preact;
render(html`<${NotesPane} handleRef=${handleRef} ...${opts} />`, container);
return handleRef.current;
});
};
// Marker for idempotency
sw._sdk = '0.37.8';
sw._sdk = '0.37.9';
// 8. Expose globally
window.sw = sw;
@@ -150,7 +161,7 @@ export async function boot() {
// 10. Signal ready
events.emit('sdk.ready', {}, { localOnly: true });
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
console.log('[sw] SDK v0.37.8 ready');
console.log('[sw] SDK v0.37.9 ready');
return sw;
}

View File

@@ -479,11 +479,12 @@ const Switchboard = {
* @returns {object} NotePanel instance (loadNotesList, openNoteEditor, destroy, etc.)
*/
sw.notes = function (container, opts) {
if (typeof NotePanel === 'undefined') {
console.error('[Switchboard] NotePanel not available');
return null;
// v0.37.9: Prefer new Preact NotesPane via sw.notesPane() when SDK is loaded
if (window.sw?.notesPane) {
return window.sw.notesPane(container, opts || {});
}
return NotePanel.mount(container, opts || {});
console.warn('[Switchboard] sw.notes() — use sw.notesPane() instead (v0.37.9+)');
return null;
};
/**

View File

@@ -1271,7 +1271,7 @@ const UI = {
const link = document.createElement('button');
link.className = 'tool-note-link';
link.textContent = '📝 View note';
link.onclick = () => { openNotes(); setTimeout(() => openNoteEditor(parsed.id), 300); };
link.onclick = () => { /* v0.37.9: notes navigation moved to Preact NotesPane */ };
el.appendChild(link);
}
} catch (e) { /* not JSON or no useful summary */ }

View File

@@ -604,8 +604,10 @@ function _copyCodeBlock(codeId) {
if (el) navigator.clipboard.writeText(el.textContent).then(() => UI.toast('Copied', 'success'));
}
function _openNoteFromTool(noteId) {
openNotes();
setTimeout(() => openNoteEditor(noteId), 300);
// v0.37.9: old notes.js removed — navigate to notes surface instead
if (window.sw?.notesPane) {
console.log('[ui-format] Note tool link — navigate to /notes');
}
}
// ── Exports ─────────────────────────────────