Changeset 0.37.9 (#221)
This commit is contained in:
475
src/js/sw/components/notes-pane/use-notes.js
Normal file
475
src/js/sw/components/notes-pane/use-notes.js
Normal 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), []),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user