Changeset 0.17.3 (#78)
This commit is contained in:
@@ -489,10 +489,12 @@ const API = {
|
||||
if (sort) url += `&sort=${encodeURIComponent(sort)}`;
|
||||
return this._get(url);
|
||||
},
|
||||
createNote(title, content, folderPath, tags) {
|
||||
createNote(title, content, folderPath, tags, sourceChannelId, sourceMessageId) {
|
||||
const body = { title, content };
|
||||
if (folderPath) body.folder_path = folderPath;
|
||||
if (tags?.length) body.tags = tags;
|
||||
if (sourceChannelId) body.source_channel_id = sourceChannelId;
|
||||
if (sourceMessageId) body.source_message_id = sourceMessageId;
|
||||
return this._post('/api/v1/notes', body);
|
||||
},
|
||||
getNote(id) { return this._get(`/api/v1/notes/${id}`); },
|
||||
@@ -500,7 +502,10 @@ const API = {
|
||||
deleteNote(id) { return this._del(`/api/v1/notes/${id}`); },
|
||||
bulkDeleteNotes(ids) { return this._post('/api/v1/notes/bulk-delete', { ids }); },
|
||||
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
|
||||
searchNoteTitles(query, limit = 10) { return this._get(`/api/v1/notes/search-titles?q=${encodeURIComponent(query)}&limit=${limit}`); },
|
||||
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
|
||||
getNoteBacklinks(id) { return this._get(`/api/v1/notes/${id}/backlinks`); },
|
||||
getNoteGraph() { return this._get('/api/v1/notes/graph'); },
|
||||
|
||||
// ── Attachments ─────────────────────────
|
||||
|
||||
|
||||
490
src/js/note-graph.js
Normal file
490
src/js/note-graph.js
Normal file
@@ -0,0 +1,490 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Note Graph (Canvas)
|
||||
// ==========================================
|
||||
// Force-directed graph visualization of note
|
||||
// connections. Lazy-loaded — initializes only
|
||||
// when the graph view is opened.
|
||||
// ==========================================
|
||||
|
||||
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 = {};
|
||||
}
|
||||
438
src/js/notes.js
438
src/js/notes.js
@@ -7,6 +7,7 @@ var _editingNoteId = null;
|
||||
var _notesSelectMode = false;
|
||||
var _selectedNoteIds = new Set();
|
||||
var _notesSort = 'updated_desc';
|
||||
var _noteEditor = null; // CM6 noteEditor instance
|
||||
|
||||
// ── Notes ─────────────────────────────────────
|
||||
|
||||
@@ -172,7 +173,13 @@ async function loadNoteFolders() {
|
||||
function showNotesList() {
|
||||
document.getElementById('notesListView').style.display = '';
|
||||
document.getElementById('notesEditorView').style.display = 'none';
|
||||
const graphView = document.getElementById('notesGraphView');
|
||||
if (graphView) graphView.style.display = 'none';
|
||||
_editingNoteId = null;
|
||||
_destroyNoteEditor();
|
||||
// Reset list to loading state to prevent stale content flash
|
||||
const list = document.getElementById('notesList');
|
||||
if (list) list.innerHTML = '<div class="notes-loading">Loading…</div>';
|
||||
}
|
||||
|
||||
var _currentNote = null; // cached note for read mode
|
||||
@@ -211,14 +218,83 @@ function _populateEditFields(note) {
|
||||
document.getElementById('noteEditorTitle').value = note.title || '';
|
||||
document.getElementById('noteEditorFolder').value = note.folder_path || '';
|
||||
document.getElementById('noteEditorTags').value = (note.tags || []).join(', ');
|
||||
document.getElementById('noteEditorContent').value = note.content || '';
|
||||
_setNoteEditorContent(note.content || '');
|
||||
}
|
||||
|
||||
function _clearEditFields() {
|
||||
document.getElementById('noteEditorTitle').value = '';
|
||||
document.getElementById('noteEditorFolder').value = '';
|
||||
document.getElementById('noteEditorTags').value = '';
|
||||
document.getElementById('noteEditorContent').value = '';
|
||||
_setNoteEditorContent('');
|
||||
}
|
||||
|
||||
/** Get content from CM6 editor or fallback textarea */
|
||||
function _getNoteEditorContent() {
|
||||
if (_noteEditor) return _noteEditor.getValue();
|
||||
const ta = document.querySelector('#noteEditorContentContainer textarea');
|
||||
return ta ? ta.value : '';
|
||||
}
|
||||
|
||||
/** Set content in CM6 editor or fallback textarea */
|
||||
function _setNoteEditorContent(text) {
|
||||
if (_noteEditor) {
|
||||
_noteEditor.setValue(text);
|
||||
return;
|
||||
}
|
||||
// Lazy-init CM6 note editor
|
||||
const container = document.getElementById('noteEditorContentContainer');
|
||||
if (!container) return;
|
||||
|
||||
if (window.CM?.noteEditor) {
|
||||
container.innerHTML = ''; // clear any previous
|
||||
_noteEditor = CM.noteEditor(container, {
|
||||
value: text,
|
||||
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
|
||||
onChange: null, // could wire auto-save later
|
||||
onLink: (title) => {
|
||||
_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 {
|
||||
// Fallback: plain textarea
|
||||
if (!container.querySelector('textarea')) {
|
||||
container.innerHTML = '<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown..."></textarea>';
|
||||
}
|
||||
const ta = container.querySelector('textarea');
|
||||
if (ta) ta.value = text;
|
||||
}
|
||||
}
|
||||
|
||||
/** Navigate to a note by title (from wikilink click) */
|
||||
async function _navigateToLinkedNote(title) {
|
||||
try {
|
||||
const resp = await API.searchNoteTitles(title, 1);
|
||||
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
|
||||
if (match) {
|
||||
await openNoteEditor(match.id);
|
||||
} else {
|
||||
// Offer to create
|
||||
if (await showConfirm(`Note "${title}" not found. Create it?`)) {
|
||||
const created = await API.createNote(title, `# ${title}\n\n`, '/', []);
|
||||
await openNoteEditor(created.id);
|
||||
}
|
||||
}
|
||||
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
/** Destroy CM6 editor on panel close */
|
||||
function _destroyNoteEditor() {
|
||||
if (_noteEditor) {
|
||||
_noteEditor.destroy();
|
||||
_noteEditor = null;
|
||||
}
|
||||
}
|
||||
|
||||
function _showNoteReadMode() {
|
||||
@@ -239,6 +315,9 @@ function _showNoteReadMode() {
|
||||
noteReadEl.innerHTML = formatMessage(n.content || '');
|
||||
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(noteReadEl);
|
||||
|
||||
// Render [[wikilinks]] as clickable chips in read mode
|
||||
_renderWikilinksInReadMode(noteReadEl);
|
||||
|
||||
// Show/hide delete
|
||||
document.getElementById('noteDeleteBtn2').style.display = _editingNoteId ? '' : 'none';
|
||||
|
||||
@@ -247,6 +326,12 @@ function _showNoteReadMode() {
|
||||
document.getElementById('noteEditMode').style.display = 'none';
|
||||
document.getElementById('noteEditBtn').style.display = '';
|
||||
document.getElementById('notePreviewBtn').style.display = 'none';
|
||||
|
||||
// Load backlinks
|
||||
if (_editingNoteId) _loadBacklinks(_editingNoteId);
|
||||
|
||||
// Show daily note navigation if applicable
|
||||
_updateDailyNav(_currentNote);
|
||||
}
|
||||
|
||||
function _showNoteEditMode() {
|
||||
@@ -259,9 +344,9 @@ function _showNoteEditMode() {
|
||||
}
|
||||
|
||||
function _showNotePreview() {
|
||||
// Live preview from textarea without saving
|
||||
// Live preview from editor without saving
|
||||
const title = document.getElementById('noteEditorTitle').value.trim();
|
||||
const content = document.getElementById('noteEditorContent').value;
|
||||
const content = _getNoteEditorContent();
|
||||
const folderVal = document.getElementById('noteEditorFolder').value.trim();
|
||||
const tagsStr = document.getElementById('noteEditorTags').value.trim();
|
||||
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||
@@ -273,6 +358,7 @@ function _showNotePreview() {
|
||||
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
|
||||
document.getElementById('noteReadContent').innerHTML = formatMessage(content || '');
|
||||
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(document.getElementById('noteReadContent'));
|
||||
_renderWikilinksInReadMode(document.getElementById('noteReadContent'));
|
||||
|
||||
document.getElementById('noteReadMode').style.display = '';
|
||||
document.getElementById('noteEditMode').style.display = 'none';
|
||||
@@ -284,7 +370,7 @@ function _showNotePreview() {
|
||||
|
||||
async function saveNote() {
|
||||
const title = document.getElementById('noteEditorTitle').value.trim();
|
||||
const content = document.getElementById('noteEditorContent').value;
|
||||
const content = _getNoteEditorContent();
|
||||
const folder = document.getElementById('noteEditorFolder').value.trim();
|
||||
const tagsStr = document.getElementById('noteEditorTags').value.trim();
|
||||
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||
@@ -305,6 +391,8 @@ async function saveNote() {
|
||||
UI.toast('Note created', 'success');
|
||||
_showNoteReadMode();
|
||||
}
|
||||
// Invalidate graph cache
|
||||
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
@@ -314,17 +402,356 @@ async function deleteNote() {
|
||||
try {
|
||||
await API.deleteNote(_editingNoteId);
|
||||
UI.toast('Note deleted', 'success');
|
||||
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
|
||||
showNotesList();
|
||||
await loadNotesList();
|
||||
await loadNoteFolders();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Backlinks ───────────────────
|
||||
|
||||
async function _loadBacklinks(noteId) {
|
||||
const container = document.getElementById('noteBacklinks');
|
||||
const listEl = document.getElementById('noteBacklinksList');
|
||||
const countEl = document.getElementById('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" onclick="openNoteEditor('${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';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBacklinks() {
|
||||
const list = document.getElementById('noteBacklinksList');
|
||||
if (list) list.style.display = list.style.display === 'none' ? '' : 'none';
|
||||
}
|
||||
|
||||
// ── Wikilink Read-Mode Rendering ──
|
||||
|
||||
function _renderWikilinksInReadMode(containerEl) {
|
||||
if (!containerEl) return;
|
||||
const html = containerEl.innerHTML;
|
||||
let transclusionId = 0;
|
||||
// Replace [[Title|display]] and [[Title]] with clickable spans
|
||||
// For ![[transclusion]], render a placeholder that gets filled async
|
||||
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" onclick="_navigateToLinkedNote('${esc(titleTrimmed)}')" title="Open: ${esc(titleTrimmed)}">↗ ${esc(displayText)}</span>
|
||||
</div>
|
||||
<div class="transclusion-content"><span class="text-muted">Loading…</span></div>
|
||||
</div>`;
|
||||
}
|
||||
return `<span class="wikilink-chip" onclick="_navigateToLinkedNote('${esc(titleTrimmed)}')" title="Link: ${esc(titleTrimmed)}">${esc(displayText)}</span>`;
|
||||
}
|
||||
);
|
||||
containerEl.innerHTML = rendered;
|
||||
|
||||
// Async: resolve and embed transclusion content
|
||||
_resolveTransclusions(containerEl);
|
||||
}
|
||||
|
||||
/** Fetch content for each ![[embed]] placeholder — max depth 1 (no recursive transclusion) */
|
||||
async function _resolveTransclusions(containerEl) {
|
||||
const embeds = containerEl.querySelectorAll('.transclusion-embed');
|
||||
if (!embeds.length) return;
|
||||
|
||||
// Cache to avoid duplicate fetches in same note
|
||||
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;
|
||||
}
|
||||
|
||||
// Render embedded content (no recursive transclusion — strip ![[]] )
|
||||
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 function openDailyNote() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const title = `Daily — ${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 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 openNoteEditor(created.id);
|
||||
}
|
||||
} catch (e) { UI.toast('Failed to open daily note: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
/** Detect if current note is a daily note */
|
||||
function _isDailyNote(note) {
|
||||
return note && /^Daily — \d{4}-\d{2}-\d{2}$/.test(note.title);
|
||||
}
|
||||
|
||||
/** Show prev/next nav for daily notes */
|
||||
function _updateDailyNav(note) {
|
||||
let nav = document.getElementById('noteDailyNav');
|
||||
if (!_isDailyNote(note)) {
|
||||
if (nav) nav.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure nav element exists
|
||||
if (!nav) {
|
||||
const toolbar = document.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">‹ Prev</button>
|
||||
<span class="note-daily-date" id="dailyDateLabel"></span>
|
||||
<button class="btn-small" id="dailyNextBtn" title="Next day">Next ›</button>
|
||||
`;
|
||||
toolbar.after(nav);
|
||||
document.getElementById('dailyPrevBtn')?.addEventListener('click', () => _navigateDaily(-1));
|
||||
document.getElementById('dailyNextBtn')?.addEventListener('click', () => _navigateDaily(1));
|
||||
}
|
||||
|
||||
nav.style.display = '';
|
||||
const dateStr = note.title.replace('Daily — ', '');
|
||||
document.getElementById('dailyDateLabel').textContent = dateStr;
|
||||
|
||||
// Disable "Next" if it's today or future
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
document.getElementById('dailyNextBtn').disabled = dateStr >= today;
|
||||
}
|
||||
|
||||
/** Navigate to prev/next daily note */
|
||||
async function _navigateDaily(offset) {
|
||||
if (!_currentNote || !_isDailyNote(_currentNote)) return;
|
||||
const dateStr = _currentNote.title.replace('Daily — ', '');
|
||||
const d = new Date(dateStr + 'T12:00:00'); // noon to avoid DST edge
|
||||
d.setDate(d.getDate() + offset);
|
||||
const newDateStr = d.toISOString().slice(0, 10);
|
||||
const title = `Daily — ${newDateStr}`;
|
||||
|
||||
try {
|
||||
const resp = await API.searchNoteTitles(title, 1);
|
||||
const existing = (resp.data || []).find(
|
||||
n => n.title.toLowerCase() === title.toLowerCase()
|
||||
);
|
||||
if (existing) {
|
||||
await openNoteEditor(existing.id);
|
||||
} else {
|
||||
// Create the daily note for that day
|
||||
const content = `# ${newDateStr}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
|
||||
const created = await API.createNote(title, content, '/daily/', ['daily']);
|
||||
await openNoteEditor(created.id);
|
||||
}
|
||||
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Save-to-Note (from chat messages) ──
|
||||
|
||||
async function saveMessageToNote(msgIndex) {
|
||||
const chat = App.chats?.find(c => c.id === App.currentChatId);
|
||||
const msg = chat?.messages?.[msgIndex];
|
||||
if (!msg) return;
|
||||
|
||||
// Check for text selection within the message element
|
||||
const sel = window.getSelection();
|
||||
let content = msg.content;
|
||||
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;
|
||||
}
|
||||
|
||||
// Extract title from first line
|
||||
const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
|
||||
const defaultTitle = firstLine.slice(0, 60) || 'Chat excerpt';
|
||||
|
||||
// Show save-to-note modal
|
||||
_showSaveToNoteModal({
|
||||
content,
|
||||
sourceChannelId: App.currentChatId || '',
|
||||
sourceMessageId: msg.id || '',
|
||||
defaultTitle,
|
||||
});
|
||||
}
|
||||
|
||||
/** Modal for save-to-note with create/append options */
|
||||
function _showSaveToNoteModal(opts) {
|
||||
// Remove existing modal if any
|
||||
document.getElementById('saveToNoteModal')?.remove();
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'saveToNoteModal';
|
||||
modal.className = 'modal-overlay';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content 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 ? '…' : ''}</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;
|
||||
|
||||
// Toggle create vs append
|
||||
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 = '';
|
||||
});
|
||||
});
|
||||
|
||||
// Append search with debounce
|
||||
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);
|
||||
});
|
||||
|
||||
// Cancel
|
||||
document.getElementById('saveNoteCancelBtn').addEventListener('click', () => modal.remove());
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
||||
|
||||
// Confirm
|
||||
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'); }
|
||||
});
|
||||
|
||||
// Focus title
|
||||
document.getElementById('saveNoteTitle')?.focus();
|
||||
document.getElementById('saveNoteTitle')?.select();
|
||||
}
|
||||
|
||||
// ── Notes Listeners (extracted from initListeners) ──
|
||||
|
||||
function _initNotesListeners() {
|
||||
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
|
||||
document.getElementById('notesNewBtn')?.addEventListener('click', () => openNoteEditor(null));
|
||||
document.getElementById('notesGraphBtn')?.addEventListener('click', () => {
|
||||
if (typeof openNoteGraph === 'function') openNoteGraph();
|
||||
});
|
||||
document.getElementById('notesTodayBtn')?.addEventListener('click', openDailyNote);
|
||||
document.getElementById('notesSelectModeBtn')?.addEventListener('click', () => {
|
||||
if (_notesSelectMode) _exitSelectMode();
|
||||
else _enterSelectMode();
|
||||
@@ -334,7 +761,6 @@ function _initNotesListeners() {
|
||||
document.getElementById('noteDeleteBtn')?.addEventListener('click', deleteNote);
|
||||
document.getElementById('noteDeleteBtn2')?.addEventListener('click', deleteNote);
|
||||
document.getElementById('noteEditBtn')?.addEventListener('click', _showNoteEditMode);
|
||||
document.getElementById('noteEditBtn2')?.addEventListener('click', _showNoteEditMode);
|
||||
document.getElementById('notePreviewBtn')?.addEventListener('click', _showNotePreview);
|
||||
document.getElementById('noteCancelEditBtn')?.addEventListener('click', () => {
|
||||
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }
|
||||
|
||||
@@ -418,6 +418,7 @@ const UI = {
|
||||
${editBtn}
|
||||
${regenBtn}
|
||||
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
|
||||
<button class="msg-action-btn" onclick="saveMessageToNote(${index})" title="Save to note">Note</button>
|
||||
</div>
|
||||
</div>
|
||||
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
|
||||
|
||||
Reference in New Issue
Block a user