476 lines
18 KiB
JavaScript
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), []),
|
|
};
|
|
}
|