// ========================================== // 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), []), }; }