This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/sw/components/notes-pane/use-notes.js
2026-03-21 20:16:19 +00:00

476 lines
18 KiB
JavaScript

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