Changeset 0.37.9 (#221)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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