Changeset 0.37.9 (#221)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
155
src/js/notes.js
155
src/js/notes.js
@@ -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);
|
||||
194
src/js/sw/components/notes-pane/index.js
Normal file
194
src/js/sw/components/notes-pane/index.js
Normal 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';
|
||||
157
src/js/sw/components/notes-pane/markdown.js
Normal file
157
src/js/sw/components/notes-pane/markdown.js
Normal 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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
172
src/js/sw/components/notes-pane/note-editor.js
Normal file
172
src/js/sw/components/notes-pane/note-editor.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
561
src/js/sw/components/notes-pane/note-graph.js
Normal file
561
src/js/sw/components/notes-pane/note-graph.js
Normal 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);
|
||||
};
|
||||
}
|
||||
103
src/js/sw/components/notes-pane/note-list-item.js
Normal file
103
src/js/sw/components/notes-pane/note-list-item.js
Normal 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>`;
|
||||
}
|
||||
163
src/js/sw/components/notes-pane/note-list.js
Normal file
163
src/js/sw/components/notes-pane/note-list.js
Normal 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>`}
|
||||
`;
|
||||
}
|
||||
184
src/js/sw/components/notes-pane/note-reader.js
Normal file
184
src/js/sw/components/notes-pane/note-reader.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
159
src/js/sw/components/notes-pane/quick-switcher.js
Normal file
159
src/js/sw/components/notes-pane/quick-switcher.js
Normal 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 };
|
||||
188
src/js/sw/components/notes-pane/save-to-note.js
Normal file
188
src/js/sw/components/notes-pane/save-to-note.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
475
src/js/sw/components/notes-pane/use-notes.js
Normal file
475
src/js/sw/components/notes-pane/use-notes.js
Normal 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), []),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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 ─────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user