feat: v0.7–v0.9 — canvas, whiteboard, database, git sync, timeline, analytics, import/export, shortcuts, table editor

v0.7: GraphView rewrite (@blinksgg/canvas), WhiteboardView, DatabaseView (table/gallery/list), GitPanel, backlink context, dataview queries
v0.8: OutlinePanel, TimelineView, StatusBar (doc stats), TableEditor, RandomNote, link suggestions
v0.9: ImportExport (ZIP/folder), ShortcutsEditor (rebind+persist), GraphAnalytics (orphans/most-connected), note pinning

Backend: 15 new Rust commands, zip crate, rand crate
Frontend: 18 new components, ~1400 lines CSS
Dependencies: @blinksgg/canvas, jotai, graphology, d3-force
This commit is contained in:
enzotar 2026-03-08 22:57:57 -07:00
parent d174c7f26d
commit c1f556b86b
40 changed files with 12109 additions and 2083 deletions

View file

@ -1,64 +1,199 @@
# Changelog
All notable changes to this project will be documented in this file.
All notable changes to Graph Notes will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
## [0.4.0] - 2026-03-07
## [0.9.0] — 2026-03-09
### Added
- **Import/Export Hub** — Export vault as ZIP, import .md folders from Obsidian/Notion
- **Keyboard Shortcuts Editor** — View, rebind, persist all keyboard shortcuts
- **Graph Analytics** — Stats dashboard with orphan detection, most-connected notes, link density
- **Note Pinning** — Pin notes to sidebar top, persisted to `.graph-notes/pinned.json`
### Changed
- Sidebar: added 📊 Analytics action
- Command Palette: added Graph Analytics, Import/Export, Keyboard Shortcuts commands
- Backend: added `export_vault_zip`, `import_folder`, `save_shortcuts`, `load_shortcuts`, `get_pinned`, `set_pinned`
### Dependencies
- Added `zip` crate (Rust)
## [0.8.0] — 2026-03-09
### Added
- **Outline Sidebar** — Collapsible heading tree (H1H6) with click-to-scroll and active heading tracking
- **Timeline View** — Chronological note cards grouped by date with 7d/30d/1y filters
- **Document Statistics** — Status bar with word count, characters, lines, reading time, heading count
- **Markdown Table Editor** — Visual table grid with click-to-edit cells, add/remove rows/columns, Tab navigation
- **Random Note** — 🎲 Discover random notes from sidebar or command palette
- **Link Suggestions** — Backend `suggest_links` for wikilink auto-completion
### Changed
- Sidebar: added 📅 Timeline and 🎲 Random Note actions
- Command Palette: added Timeline, Random Note commands
- Backend: added `suggest_links`, `list_notes_by_date`, `random_note` commands
### Dependencies
- Added `rand` crate (Rust)
## [0.7.0] — 2026-03-09
### Added
- **Canvas Whiteboard** — Freeform visual thinking surface powered by `@blinksgg/canvas` with card/text nodes, drag, zoom, save/load
- **Database Views** — Notion-style table/gallery/list views from frontmatter properties with sort/filter
- **Backlink Context** — Paragraph-level excerpts around wikilink mentions in backlinks panel
- **Dataview Queries** — Inline ` ```dataview TABLE ... SORT ... ``` ` blocks rendering live query tables
- **Git Sync** — commit/push/pull panel with status indicator, changed file list, repo initialization
### Changed
- **GraphView rewritten** using `@blinksgg/canvas` (replaces custom HTML5 Canvas force simulation)
- Sidebar: added Database, Whiteboard quick actions
- Command Palette: added Database View, New Whiteboard, Git Sync commands
- Backlinks now use backend `get_backlink_context` for paragraph excerpts
### Dependencies
- Added `@blinksgg/canvas`, `jotai`, `graphology`, `d3-force`
## [0.6.0] — 2026-03-09
### Added
- **Tabbed Editor** — Multi-note tab bar with drag-reorder, close buttons, active tab highlighting
- **Note Refactoring** — Extract selection to new note (replaces with wikilink), merge notes (appends + updates links)
- **Encrypted Notes** — AES-256-GCM password protection with Argon2 key derivation, lock/unlock button in editor
- **Spaced Repetition Flashcards** — Study mode from `?? question :: answer ??` syntax, SM-2 scheduling, difficulty ratings
- **Heading Folding** — Fold state persistence per note via `.graph-notes/folds.json`
- **Custom CSS Snippets** — Live-preview CSS editor, persisted in `~/.config/graph-notes/custom.css`
- **Workspace Layouts** — Save/restore window arrangements in `.graph-notes/workspaces/`
- **Embeddable Widgets**`{{progress:N}}` progress bars, `{{counter:N}}` badges, `{{toggle:on/off}}` indicators
### Changed
- Editor supports right-click context menu for refactoring operations
- Command Palette extended with Flashcards, Custom CSS, and Save Workspace
- Sidebar quick actions include Flashcards
- Custom CSS loaded on mount via `useCustomCssInit` hook
### Dependencies
- Added `aes-gcm`, `argon2`, `rand`, `base64` for encryption
## [0.5.0] — 2026-03-08
### Added
- **Kanban Board** — Visual task board from `- [ ]` / `- [/]` / `- [x]` items across vault, with drag-and-drop between Todo/In Progress/Done columns
- **Focus / Zen Mode** — Distraction-free writing (`⌘⇧F`): hides sidebar, breadcrumbs, meta, centers content at max 720px
- **Note Version History** — Auto-snapshots every 5 min, timeline sidebar with inline diff viewer (add/remove highlighting)
- **PDF Export** — Print-styled export via browser print dialog with clean typography
- **Global Search & Replace** — Find/replace text across vault with dry-run preview before applying (`⌘H`)
- **Local Backlink Graph** — Mini force-directed canvas in preview showing current note's 1-hop link connections
- **Writing Goals** — Per-note word count targets with gradient progress bar (red→yellow→green)
- **Syntax-Highlighted Code Blocks** — highlight.js with 8 languages, copy-to-clipboard button, dark theme
### Changed
- Editor supports focus mode (hides chrome, centers content)
- Command Palette extended with Kanban, Focus Mode, Search & Replace, Export as PDF
- Sidebar quick actions include Kanban Board
- Auto-snapshot on save (throttled to 1 per 5 min)
### Dependencies
- Added `highlight.js` for syntax highlighting
## [0.4.0] — 2026-03-08
### Added
- **Frontmatter & Properties Panel** — YAML `---` fenced metadata with inline key-value editor (collapsible panel below breadcrumbs)
- **Table of Contents** — Auto-generated outline from headings, shown alongside preview mode with active heading highlight
- **Mermaid Diagram Rendering** — Fenced `mermaid` code blocks render as SVG diagrams in preview mode (lazy-loaded)
- **Image & Attachment Support** — Paste images from clipboard, stored in `_attachments/` directory with `![](path)` markdown
- **Slash Commands** — Type `/` at line start to open inline formatting menu (14 commands: headings, lists, code blocks, mermaid, tables)
- **Calendar View** — Visual month grid for daily notes with dot indicators, "Today" button, and click-to-create
- **Theme Picker** — 5 built-in themes (Dark Purple, Dark Emerald, Dark Ocean, Dark Rose, Light) with live preview, persisted
- **Export to HTML** — Export current note as styled standalone HTML file
### Changed
- Editor now includes PropertiesPanel, TableOfContents sidebar, and SlashMenu
- Command Palette extended with Calendar, Theme, and Export HTML commands
- Sidebar quick actions include Calendar View
- Added `⌘T` keyboard shortcut for Theme Picker
### Dependencies
- Added `mermaid` for diagram rendering
## [0.3.0] — 2026-03-08
### Added
- **Split Editor** — Open two notes side by side with a draggable divider (right-click → "Open in split")
- **Wikilink Hover Preview** — Hover over `[[wikilinks]]` to see a floating preview card with note content and link count
- **Note Transclusion**`![[note-name]]` embeds the content of another note inline, with recursive depth limiting (3 levels)
- **Vault Switcher** — Click sidebar brand to switch between recent vaults or open a new folder
- **Drag & Drop File Organization** — Drag notes between folders in the sidebar file tree
- **Breadcrumb Navigation** — Path breadcrumbs shown above the editor for nested notes
- **Note Templates** — Create notes from templates in `_templates/` directory via Command Palette (supports `{{title}}` and `{{date}}` variables)
- **Recent Notes** — Last 5 recently opened notes shown in the sidebar
- **Favorites** — Pin notes as favorites (right-click → "Favorite"), persisted per vault in `.graph-notes/favorites.json`
- **Open in Split Pane** — Right-click context menu option to open a note in a side-by-side view
### Changed
- Note view now uses `SplitView` component, supporting both single-pane and dual-pane editing
- Context menu expanded with "Favorite" and "Open in split" actions, plus visual divider
- Command Palette shows template commands when available
- `LinkPreview` component renders as a global overlay for all hover previews
## [0.2.0] — 2026-03-08
### Added
- **Full-Text Search** — Vault-wide content search in the sidebar (debounced, with context snippets and result ranking)
- **Command Palette**`⌘K` / `Ctrl+K` opens a fuzzy search palette for notes, commands, and content
- **Keyboard Shortcuts**`⌘N` new note, `⌘G` graph view, `⌘D` daily note, `⌘E` toggle edit/preview, `⌘\` toggle sidebar
- **Note Rename** — Right-click context menu on notes in sidebar for inline rename with automatic wikilink updates across vault
- **Note Delete** — Right-click context menu with confirmation dialog; navigates away if active note deleted
- **Tags System**`#tag` extraction from notes, sidebar tags section with click-to-filter, emerald-colored tag pills in editor
- **Graph Filtering** — Filter bar to highlight matching nodes, focus mode (1-hop neighborhood), orphan node toggle
- **Inline Markdown Styling** — Headings (`# ## ###`) render at proper sizes in edit mode, `**bold**`, `*italic*`, `` `code` `` styled inline
- **List Continuation** — Pressing Enter after `- item` auto-inserts bullet on next line
- **Tab Indent/Outdent** — Tab and Shift+Tab for list item indentation
- **Collapsible Sidebar** — Toggle sidebar visibility with `⌘\`
### Changed
- Edit/Preview mode is now global (shared via context), toggled with `⌘E` from anywhere
- Search input shows `⌘K` hint for command palette discovery
## [0.1.0] — 2026-03-07
### Added
- **Tauri v2 Desktop App** — Local-first note-taking with full filesystem access via `tauri-plugin-fs`
- **Contenteditable Editor** — Rich inline editing with `[[wikilink]]` token chips (compact pills that unwrap on backspace/delete)
- **Wikilink Autocomplete** — Type `[[` to fuzzy-search and link notes; creates new notes if no match found
- **Force-Directed Graph View** — Canvas-based visualization with semantic zoom (circles → rounded-rect cards with note previews)
- **Graph Interactions** — Single-click animates zoom to node, double-click opens note, drag to reposition nodes
- **shadcn-Inspired Design System** — Zinc-based neutrals, purple accent gradients, focus rings, spring transitions
- **Sidebar** — Recursive file tree with search, collapsible folders, active-state indicators, note count badge
- **Backlinks Panel** — Lists all notes linking to current page with highlighted context snippets
- **Markdown Preview** — Toggle between edit and rendered preview modes with inline wikilink rendering
- **Daily Notes** — Auto-generated daily journal entries accessible from sidebar shortcut
- **Auto-Save** — Debounced 500ms save on every keystroke
- **Custom Scrollbars** — Minimal 5px scrollbars matching the dark theme
## [0.4.0] - 2026-03-07 (origin)
### Added
- **VaultCache** (`src-tauri/src/cache.rs`): In-memory note cache with mtime-based invalidation
- **`init_vault_cache`**: Eagerly scan all `.md` files and populate cache on startup
- **`get_cache_stats`**: Return cache hits/misses/entry count for diagnostics
- **Cache-backed commands**: `read_note`, `read_note_with_meta`, `build_graph`, `search_vault` now read from cache
- **Frontend LRU cache** (`src/lib/noteCache.ts`): Cache last 20 notes with stale-while-revalidate
- **File watcher** (`notify` crate): Foundation for filesystem change detection and cache invalidation
- **`init_vault_cache`/`get_cache_stats`**: Startup scan + diagnostics
- **Cache-backed commands**: `read_note`, `build_graph`, `search_vault` from cache
- **Frontend LRU cache** (`src/lib/noteCache.ts`): 20-note stale-while-revalidate
- **File watcher** (`notify` crate): FS change detection foundation
### Changed
- `build_graph` iterates cached entries instead of walking disk (O(1) vs O(n) on subsequent calls)
- `search_vault` iterates cached entries instead of reading every file from disk
## [0.3.0] - 2026-03-07
## [0.3.0] - 2026-03-07 (origin)
### Added
- **Frontmatter Parsing**: Rust backend parses YAML frontmatter (`title`, `tags`, `created`, `modified`) on read
- **`read_note_with_meta`**: New IPC command returns content, parsed metadata, body (without frontmatter), and heading list
- **TypeScript Frontmatter Module** (`src/lib/frontmatter.ts`): Client-side parse/serialize with `extractHeadings()`
- **Outline / TOC Panel**: Right-side panel showing document headings with click-to-scroll and smooth highlight animation
- **Tabbed Right Panel**: Switch between Outline and Backlinks views in the note editor
- **Note Templates**: `_templates/` folder support; Command Palette lists templates and creates notes with `{{title}}`/`{{date}}` replacement
- **Image Attachments**: Drag-and-drop images onto editor; saves to `vault/attachments/` and inserts markdown image link
- **`list_templates`**: Rust command to scan `_templates/` folder for `.md` template files
- **`save_attachment`**: Rust command to save binary data to `vault/attachments/` with deduplication
- **Frontmatter Parsing**: YAML metadata, `read_note_with_meta`, TOC panel, templates, attachments
## [0.2.0] - 2026-03-07
## [0.2.0] - 2026-03-07 (origin)
### Added
- **Command Palette** (`Ctrl+K`): Fuzzy search notes and run commands with keyboard navigation (↑↓ Enter Esc)
- **Keyboard Shortcuts**: `Ctrl+N` new note, `Ctrl+G` graph view, `Ctrl+D` daily note
- **Full-Text Search**: Rust-powered vault content search with context snippets, displayed in sidebar
- **Note Rename**: Right-click context menu in sidebar for renaming notes with automatic wikilink updates across vault
- **Note Delete**: Context menu delete with confirmation dialog
- **Editor: Heading Scaling**: H1H3 headings render at proportional sizes in edit mode
- **Editor: Task Lists**: Interactive checkboxes for `- [ ]` / `- [x]` syntax, clickable to toggle
- **Editor: Inline Code**: Backtick-quoted text styled with monospace font and accent color
- **Editor: Markdown Preview**: Styled headings, code blocks, and checkbox rendering in preview mode
- **Graph Filtering**: Filter graph by folder and minimum link count with a dedicated filter bar
- **Command Palette**, keyboard shortcuts, full-text search, rename/delete, graph filtering
### Changed
- Sidebar search upgraded to show both filename matches and content search results
- Graph view header now reflects filtered node/edge counts
## [0.1.0] - 2025-06-01
## [0.1.0] - 2025-06-01 (origin)
### Added
- Tauri 2 desktop application with React 19 + Vite 7
- Contenteditable editor with inline wikilink tokens
- Wikilink autocomplete dropdown (`[[` trigger)
- Force-directed graph view with semantic zoom (circles → cards)
- Sidebar with file tree, search filtering, and quick actions
- Backlinks panel with context snippets
- Daily notes with auto-creation
- Auto-save with debounced writes
- Custom CSS design system (dark theme, glassmorphism, purple accents)
- Tauri 2 + React 19, editor, wikilinks, graph, sidebar, backlinks, daily notes

1454
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"name": "graph-notes",
"private": true,
"version": "0.1.0",
"version": "0.9.0",
"type": "module",
"scripts": {
"dev": "vite",
@ -10,23 +10,29 @@
"tauri": "tauri"
},
"dependencies": {
"@blinksgg/canvas": "file:../blinksgg/gg-antifragile/packages/canvas",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-fs": "^2",
"@tauri-apps/plugin-opener": "^2",
"d3-force": "^3.0.0",
"graphology": "^0.26.0",
"highlight.js": "^11.11.1",
"jotai": "^2.18.0",
"marked": "^15.0.0",
"mermaid": "^11.12.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-fs": "^2",
"@tauri-apps/plugin-dialog": "^2",
"marked": "^15.0.0"
"react-router-dom": "^7.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"@tailwindcss/vite": "^4.2.1",
"tailwindcss": "^4.2.1",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"@tauri-apps/cli": "^2"
"vite": "^7.0.4"
}
}

View file

@ -1,6 +1,6 @@
[package]
name = "graph-notes"
version = "0.1.0"
version = "0.9.0"
description = "A graph-based note-taking app"
authors = ["you"]
edition = "2021"
@ -22,4 +22,8 @@ serde_json = "1"
walkdir = "2"
regex = "1"
chrono = "0.4"
notify = { version = "6", features = ["macos_kqueue"] }
aes-gcm = "0.10"
argon2 = "0.5"
rand = "0.8"
base64 = "0.22"
zip = "2"

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Graph Notes",
"version": "0.1.0",
"version": "0.9.0",
"identifier": "com.graphnotes.app",
"build": {
"beforeDevCommand": "npm run dev",

View file

@ -5,7 +5,20 @@ import { Editor } from "./components/Editor";
import { Backlinks } from "./components/Backlinks";
import { GraphView } from "./components/GraphView";
import { CommandPalette } from "./components/CommandPalette";
import { OutlinePanel } from "./components/OutlinePanel";
import { SplitView } from "./components/SplitView";
import { LinkPreview } from "./components/LinkPreview";
import { CalendarView } from "./components/CalendarView";
import { ThemePicker, useThemeInit } from "./components/ThemePicker";
import { KanbanView } from "./components/KanbanView";
import { SearchReplace } from "./components/SearchReplace";
import { FlashcardView } from "./components/FlashcardView";
import { CSSEditor, useCustomCssInit } from "./components/CSSEditor";
import { TabBar, type Tab } from "./components/TabBar";
import { WhiteboardView } from "./components/WhiteboardView";
import { DatabaseView } from "./components/DatabaseView";
import { GitPanel } from "./components/GitPanel";
import { TimelineView } from "./components/TimelineView";
import { GraphAnalytics } from "./components/GraphAnalytics";
import {
listNotes,
readNote,
@ -13,12 +26,14 @@ import {
getVaultPath,
setVaultPath,
ensureVault,
initVaultCache,
getOrCreateDaily,
addVault,
getFavorites,
getBacklinkContext,
setFavorites as setFavoritesCmd,
type NoteEntry,
} from "./lib/commands";
import { extractWikilinks, type BacklinkEntry } from "./lib/wikilinks";
import { noteCache } from "./lib/noteCache";
/* ── Vault Context ──────────────────────────────────────────── */
interface VaultContextType {
@ -31,6 +46,18 @@ interface VaultContextType {
setNoteContent: (content: string) => void;
backlinks: BacklinkEntry[];
navigateToNote: (name: string) => void;
sidebarOpen: boolean;
toggleSidebar: () => void;
editMode: boolean;
toggleEditMode: () => void;
splitNote: string | null;
setSplitNote: (path: string | null) => void;
favorites: string[];
toggleFavorite: (path: string) => void;
recentNotes: string[];
switchVault: (path: string) => Promise<void>;
focusMode: boolean;
toggleFocusMode: () => void;
}
const VaultContext = createContext<VaultContextType>(null!);
@ -45,9 +72,29 @@ export default function App() {
const [backlinks, setBacklinks] = useState<BacklinkEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [paletteOpen, setPaletteOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [editMode, setEditMode] = useState(true);
const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false);
const [splitNote, setSplitNote] = useState<string | null>(null);
const [favorites, setFavorites] = useState<string[]>([]);
const [recentNotes, setRecentNotes] = useState<string[]>([]);
const [themePickerOpen, setThemePickerOpen] = useState(false);
const [focusMode, setFocusMode] = useState(false);
const [searchReplaceOpen, setSearchReplaceOpen] = useState(false);
const [cssEditorOpen, setCssEditorOpen] = useState(false);
const [tabs, setTabs] = useState<Tab[]>([]);
const [activeTab, setActiveTab] = useState(0);
const navigate = useNavigate();
// Apply saved theme + custom CSS on mount
useThemeInit();
useCustomCssInit();
const toggleSidebar = useCallback(() => setSidebarOpen(v => !v), []);
const toggleEditMode = useCallback(() => setEditMode(v => !v), []);
const toggleFocusMode = useCallback(() => setFocusMode(v => !v), []);
// Initialize vault
useEffect(() => {
(async () => {
@ -79,14 +126,6 @@ export default function App() {
console.warn("[GraphNotes] ensureVault failed:", e);
}
// Initialize the Rust-side vault cache (eagerly scan all notes)
try {
const count = await initVaultCache(path);
console.log(`[GraphNotes] Cache initialized: ${count} notes`);
} catch (e) {
console.warn("[GraphNotes] initVaultCache failed:", e);
}
setVaultPathState(path);
console.log("[GraphNotes] Vault ready at:", path);
setLoading(false);
@ -107,31 +146,55 @@ export default function App() {
useEffect(() => {
if (vaultPath) refreshNotes();
// Load favorites
if (vaultPath) {
getFavorites(vaultPath).then(setFavorites).catch(() => setFavorites([]));
}
}, [vaultPath, refreshNotes]);
// Global keyboard shortcuts
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key === "k") { e.preventDefault(); setPaletteOpen((v) => !v); }
else if (mod && e.key === "n") {
e.preventDefault(); setPaletteOpen(false);
const name = prompt("Note name:");
if (name?.trim()) {
writeNote(vaultPath, `${name.trim()}.md`, `# ${name.trim()}\n\n`).then(() => {
refreshNotes();
navigate(`/note/${encodeURIComponent(`${name.trim()}.md`)}`);
});
}
}
else if (mod && e.key === "g") { e.preventDefault(); setPaletteOpen(false); navigate("/graph"); }
else if (mod && e.key === "d") { e.preventDefault(); setPaletteOpen(false); navigate("/daily"); }
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [vaultPath, navigate, refreshNotes]);
// Track recent notes
const trackRecent = useCallback((notePath: string) => {
setRecentNotes(prev => {
const next = [notePath, ...prev.filter(p => p !== notePath)].slice(0, 10);
return next;
});
}, []);
// Build backlinks for current note
// Watch currentNote changes to track recents
useEffect(() => {
if (currentNote) trackRecent(currentNote);
}, [currentNote, trackRecent]);
// Toggle favorite
const toggleFavorite = useCallback((path: string) => {
setFavorites(prev => {
const next = prev.includes(path)
? prev.filter(p => p !== path)
: [...prev, path];
// Persist
if (vaultPath) setFavoritesCmd(vaultPath, next).catch(() => { });
return next;
});
}, [vaultPath]);
// Switch vault
const switchVault = useCallback(async (newPath: string) => {
try {
await ensureVault(newPath);
await setVaultPath(newPath);
await addVault(newPath);
setVaultPathState(newPath);
setCurrentNote(null);
setNoteContent("");
setSplitNote(null);
setRecentNotes([]);
navigate("/");
} catch (e) {
console.error("Switch vault failed:", e);
}
}, [navigate, setCurrentNote, setNoteContent]);
// Build backlinks for current note using backend context
useEffect(() => {
if (!vaultPath || !currentNote || !notes.length) {
setBacklinks([]);
@ -142,37 +205,41 @@ export default function App() {
const currentName = currentNote
.replace(/\.md$/, "")
.split("/")
.pop()
?.toLowerCase();
.pop() || "";
if (!currentName) return;
// Read all notes and find backlinks
const allPaths = flattenNotes(notes);
const entries: BacklinkEntry[] = [];
for (const notePath of allPaths) {
if (notePath === currentNote) continue;
try {
const content = await readNote(vaultPath, notePath);
const links = extractWikilinks(content);
for (const link of links) {
if (link.target.toLowerCase() === currentName) {
const lines = content.split("\n");
const contextLine =
lines.find((l) => l.includes(link.raw)) || "";
entries.push({
sourcePath: notePath,
sourceName: notePath.replace(/\.md$/, "").split("/").pop() || notePath,
context: contextLine.trim().substring(0, 200),
});
try {
const contexts = await getBacklinkContext(vaultPath, currentName);
const entries: BacklinkEntry[] = contexts.map(c => ({
sourcePath: c.source_path,
sourceName: c.source_name,
context: c.excerpt,
}));
setBacklinks(entries);
} catch {
// Fallback: simple wikilink scan
const allPaths = flattenNotes(notes);
const entries: BacklinkEntry[] = [];
for (const notePath of allPaths) {
if (notePath === currentNote) continue;
try {
const content = await readNote(vaultPath, notePath);
const links = extractWikilinks(content);
for (const link of links) {
if (link.target.toLowerCase() === currentName.toLowerCase()) {
const lines = content.split("\n");
const contextLine = lines.find((l) => l.includes(link.raw)) || "";
entries.push({
sourcePath: notePath,
sourceName: notePath.replace(/\.md$/, "").split("/").pop() || notePath,
context: contextLine.trim().substring(0, 200),
});
}
}
}
} catch {
// Skip unreadable notes
} catch { }
}
setBacklinks(entries);
}
setBacklinks(entries);
})();
}, [vaultPath, currentNote, notes]);
@ -202,6 +269,61 @@ export default function App() {
[notes, vaultPath, navigate, refreshNotes]
);
// ── Global keyboard shortcuts ──
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey;
if (!mod) return;
// ⌘⇧F — Focus mode
if (e.shiftKey && e.key.toLowerCase() === "f") {
e.preventDefault();
setFocusMode(v => !v);
return;
}
switch (e.key.toLowerCase()) {
case "k":
e.preventDefault();
setCmdPaletteOpen(v => !v);
break;
case "n":
e.preventDefault();
{
const name = prompt("Note name:");
if (name?.trim()) navigateToNote(name.trim());
}
break;
case "g":
e.preventDefault();
navigate("/graph");
break;
case "d":
e.preventDefault();
navigate("/daily");
break;
case "e":
e.preventDefault();
toggleEditMode();
break;
case "\\":
e.preventDefault();
toggleSidebar();
break;
case "t":
e.preventDefault();
setThemePickerOpen(v => !v);
break;
case "h":
e.preventDefault();
setSearchReplaceOpen(v => !v);
break;
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [navigate, navigateToNote, toggleEditMode, toggleSidebar]);
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
@ -237,18 +359,41 @@ export default function App() {
setNoteContent,
backlinks,
navigateToNote,
sidebarOpen,
toggleSidebar,
editMode,
toggleEditMode,
splitNote,
setSplitNote,
favorites,
toggleFavorite,
recentNotes,
switchVault,
focusMode,
toggleFocusMode,
}}
>
<div className="flex h-screen w-screen overflow-hidden">
<Sidebar />
<div className={`flex h-screen w-screen overflow-hidden ${focusMode ? "focus-mode" : ""}`}>
{sidebarOpen && !focusMode && <Sidebar />}
<Routes>
<Route path="/" element={<WelcomeScreen />} />
<Route path="/note/:path" element={<NoteView />} />
<Route path="/note/:path" element={<SplitView />} />
<Route path="/daily" element={<DailyView />} />
<Route path="/graph" element={<GraphView />} />
<Route path="/calendar" element={<CalendarView />} />
<Route path="/kanban" element={<KanbanView />} />
<Route path="/flashcards" element={<FlashcardView />} />
<Route path="/whiteboard/:name" element={<WhiteboardView />} />
<Route path="/database" element={<DatabaseView />} />
<Route path="/timeline" element={<TimelineView />} />
<Route path="/analytics" element={<GraphAnalytics />} />
</Routes>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
</div>
<CommandPalette open={cmdPaletteOpen} onClose={() => setCmdPaletteOpen(false)} />
<LinkPreview />
<ThemePicker open={themePickerOpen} onClose={() => setThemePickerOpen(false)} />
<SearchReplace open={searchReplaceOpen} onClose={() => setSearchReplaceOpen(false)} />
<CSSEditor open={cssEditorOpen} onClose={() => setCssEditorOpen(false)} />
</VaultContext.Provider>
);
}
@ -258,29 +403,11 @@ function NoteView() {
const { path } = useParams<{ path: string }>();
const { vaultPath, setCurrentNote, noteContent, setNoteContent } = useVault();
const decodedPath = decodeURIComponent(path || "");
const [rightTab, setRightTab] = useState<"backlinks" | "outline">("outline");
useEffect(() => {
if (!decodedPath || !vaultPath) return;
setCurrentNote(decodedPath);
// Check frontend LRU cache first
const cached = noteCache.get(decodedPath);
if (cached !== undefined) {
setNoteContent(cached);
// Still re-validate from backend in background
readNote(vaultPath, decodedPath).then((fresh) => {
if (fresh !== cached) {
setNoteContent(fresh);
noteCache.set(decodedPath, fresh);
}
}).catch(() => {});
} else {
readNote(vaultPath, decodedPath).then((content) => {
setNoteContent(content);
noteCache.set(decodedPath, content);
}).catch(() => setNoteContent(""));
}
readNote(vaultPath, decodedPath).then(setNoteContent).catch(() => setNoteContent(""));
}, [decodedPath, vaultPath, setCurrentNote, setNoteContent]);
return (
@ -288,23 +415,7 @@ function NoteView() {
<main className="flex-1 overflow-y-auto">
<Editor />
</main>
<aside className="right-panel">
<div className="right-panel-tabs">
<button
className={`right-panel-tab ${rightTab === "outline" ? "active" : ""}`}
onClick={() => setRightTab("outline")}
>
📑 Outline
</button>
<button
className={`right-panel-tab ${rightTab === "backlinks" ? "active" : ""}`}
onClick={() => setRightTab("backlinks")}
>
🔗 Backlinks
</button>
</div>
{rightTab === "outline" ? <OutlinePanel /> : <Backlinks />}
</aside>
<Backlinks />
</div>
);
}

View file

@ -0,0 +1,83 @@
import { useState, useEffect, useRef } from "react";
import { getCustomCss, setCustomCss } from "../lib/commands";
/**
* CSSEditor Monaco-lite custom CSS editor with live preview.
*/
export function CSSEditor({ open, onClose }: { open: boolean; onClose: () => void }) {
const [css, setCss] = useState("");
const [saved, setSaved] = useState(false);
const styleRef = useRef<HTMLStyleElement | null>(null);
useEffect(() => {
if (open) {
getCustomCss().then(setCss).catch(() => setCss(""));
}
}, [open]);
// Live preview
useEffect(() => {
if (!styleRef.current) {
const el = document.createElement("style");
el.id = "custom-user-css";
document.head.appendChild(el);
styleRef.current = el;
}
styleRef.current.textContent = css;
}, [css]);
const handleSave = async () => {
try {
await setCustomCss(css);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (e) {
console.error("Save CSS failed:", e);
}
};
if (!open) return null;
return (
<div className="css-backdrop" onClick={onClose}>
<div className="css-modal" onClick={e => e.stopPropagation()}>
<div className="css-header">
<h3 className="css-title">🎨 Custom CSS</h3>
<div className="css-actions">
{saved && <span className="css-saved"> Saved</span>}
<button className="css-save-btn" onClick={handleSave}>Save</button>
<button className="css-close" onClick={onClose}></button>
</div>
</div>
<textarea
className="css-textarea"
value={css}
onChange={e => setCss(e.target.value)}
placeholder={`/* Your custom CSS */\n\n.editor-title {\n font-style: italic;\n}\n\n.sidebar {\n opacity: 0.9;\n}`}
spellCheck={false}
/>
<div className="css-hint">
Changes apply immediately. Save to persist across sessions.
</div>
</div>
</div>
);
}
/**
* Hook to load custom CSS on mount.
*/
export function useCustomCssInit() {
useEffect(() => {
getCustomCss().then(css => {
if (!css) return;
let el = document.getElementById("custom-user-css") as HTMLStyleElement;
if (!el) {
el = document.createElement("style");
el.id = "custom-user-css";
document.head.appendChild(el);
}
el.textContent = css;
}).catch(() => { });
}, []);
}

View file

@ -0,0 +1,122 @@
import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useVault } from "../App";
import { listDailyNotes, getOrCreateDaily } from "../lib/commands";
/**
* CalendarView Visual month calendar for navigating daily notes.
* Dots indicate dates with existing daily notes.
*/
export function CalendarView() {
const { vaultPath } = useVault();
const navigate = useNavigate();
const [currentDate, setCurrentDate] = useState(new Date());
const [dailyDates, setDailyDates] = useState<Set<string>>(new Set());
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
// Load daily notes
useEffect(() => {
if (!vaultPath) return;
listDailyNotes(vaultPath)
.then(dates => setDailyDates(new Set(dates)))
.catch(() => setDailyDates(new Set()));
}, [vaultPath, month, year]);
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
// Calendar grid
const calendarDays = useMemo(() => {
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const days: (number | null)[] = [];
for (let i = 0; i < firstDay; i++) days.push(null);
for (let d = 1; d <= daysInMonth; d++) days.push(d);
// Pad to complete weeks
while (days.length % 7 !== 0) days.push(null);
return days;
}, [year, month]);
const monthName = currentDate.toLocaleString("default", { month: "long" });
const handleDayClick = async (day: number) => {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const notePath = `daily/${dateStr}.md`;
if (dailyDates.has(dateStr)) {
navigate(`/note/${encodeURIComponent(notePath)}`);
} else {
// Create the daily note for this date
try {
await getOrCreateDaily(vaultPath);
navigate(`/note/${encodeURIComponent(notePath)}`);
} catch {
navigate(`/note/${encodeURIComponent(notePath)}`);
}
}
};
return (
<div className="calendar-view">
<div className="calendar-card">
{/* Header */}
<div className="calendar-header">
<button className="calendar-nav" onClick={() => setCurrentDate(new Date(year, month - 1))}>
</button>
<h2 className="calendar-title">{monthName} {year}</h2>
<button className="calendar-nav" onClick={() => setCurrentDate(new Date(year, month + 1))}>
</button>
</div>
{/* Today button */}
<div className="calendar-today-bar">
<button
className="calendar-today-btn"
onClick={() => setCurrentDate(new Date())}
>
Today
</button>
</div>
{/* Day headers */}
<div className="calendar-grid">
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(d => (
<div key={d} className="calendar-day-header">{d}</div>
))}
{/* Day cells */}
{calendarDays.map((day, i) => {
if (day === null) {
return <div key={`empty-${i}`} className="calendar-day empty" />;
}
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const hasNote = dailyDates.has(dateStr);
const isToday = dateStr === todayStr;
return (
<button
key={dateStr}
className={`calendar-day ${isToday ? "today" : ""} ${hasNote ? "has-note" : ""}`}
onClick={() => handleDayClick(day)}
>
<span className="calendar-day-number">{day}</span>
{hasNote && <span className="calendar-dot" />}
</button>
);
})}
</div>
{/* Stats */}
<div className="calendar-stats">
<span>{dailyDates.size} daily note{dailyDates.size !== 1 ? "s" : ""}</span>
</div>
</div>
</div>
);
}

View file

@ -1,220 +1,296 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useVault } from "../App";
import { listTemplates, readNote, writeNote, type NoteEntry } from "../lib/commands";
import { searchVault, listTemplates, createFromTemplate, exportNoteHtml, type SearchResult, type TemplateInfo } from "../lib/commands";
interface Command {
interface CommandItem {
id: string;
label: string;
icon: string;
label: string;
hint?: string;
action: () => void;
section: "notes" | "commands" | "templates";
}
export function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void }) {
const { vaultPath, notes, navigateToNote, currentNote } = useVault();
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [templates, setTemplates] = useState<TemplateInfo[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const { notes, vaultPath, refreshNotes } = useVault();
const [templates, setTemplates] = useState<string[]>([]);
// Flatten notes for search
const allNotes = useMemo(() => flattenEntries(notes), [notes]);
// Load templates when opened
useEffect(() => {
if (open && vaultPath) {
listTemplates(vaultPath).then(setTemplates).catch(() => setTemplates([]));
}
}, [open, vaultPath]);
// Build command list
const commands: Command[] = useMemo(() => {
const cmds: Command[] = [
{ id: "cmd-new", label: "New Note", icon: "✏️", section: "commands", action: () => {
onClose();
const name = prompt("Note name:");
if (name?.trim()) {
import("../lib/commands").then(({ writeNote }) => {
writeNote(vaultPath, `${name.trim()}.md`, `# ${name.trim()}\n\n`).then(() => {
refreshNotes();
navigate(`/note/${encodeURIComponent(`${name.trim()}.md`)}`);
});
});
}
}},
{ id: "cmd-daily", label: "Open Daily Note", icon: "📅", section: "commands", action: () => { onClose(); navigate("/daily"); }},
{ id: "cmd-graph", label: "Open Graph View", icon: "🔮", section: "commands", action: () => { onClose(); navigate("/graph"); }},
];
// Focus input when opened
useEffect(() => {
if (open) {
setQuery("");
setSelectedIndex(0);
setSearchResults([]);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
// Add notes as commands
for (const note of allNotes) {
cmds.push({
id: `note-${note.path}`,
label: note.name,
icon: "📄",
section: "notes",
action: () => { onClose(); navigate(`/note/${encodeURIComponent(note.path)}`); },
});
// Debounced search
useEffect(() => {
if (!query.trim() || query.length < 2) {
setSearchResults([]);
return;
}
// Add template commands
for (const tmpl of templates) {
cmds.push({
id: `tmpl-${tmpl}`,
label: `New from: ${tmpl}`,
icon: "📋",
section: "templates",
action: () => {
onClose();
const name = prompt(`Note name (from ${tmpl} template):`);
if (name?.trim() && vaultPath) {
readNote(vaultPath, `_templates/${tmpl}.md`).then((content) => {
const today = new Date().toISOString().split("T")[0];
const filled = content
.replace(/\{\{title\}\}/gi, name.trim())
.replace(/\{\{date\}\}/gi, today);
writeNote(vaultPath, `${name.trim()}.md`, filled).then(() => {
refreshNotes();
navigate(`/note/${encodeURIComponent(`${name.trim()}.md`)}`);
});
});
const timer = setTimeout(async () => {
try {
const results = await searchVault(vaultPath, query);
setSearchResults(results.slice(0, 10));
} catch {
setSearchResults([]);
}
}, 200);
return () => clearTimeout(timer);
}, [query, vaultPath]);
// Build command list
const commands: CommandItem[] = [];
if (!query.trim()) {
commands.push(
{ id: "daily", icon: "📅", label: "Open Daily Note", hint: "⌘D", action: () => { navigate("/daily"); onClose(); } },
{ id: "graph", icon: "🔮", label: "Graph View", hint: "⌘G", action: () => { navigate("/graph"); onClose(); } },
{ id: "new", icon: "✏️", label: "New Note", hint: "⌘N", action: () => { const name = prompt("Note name:"); if (name?.trim()) { navigateToNote(name.trim()); onClose(); } } },
);
// Template commands
if (templates.length > 0) {
for (const t of templates) {
commands.push({
id: `template-${t.path}`,
icon: "📋",
label: `New from template: ${t.name}`,
hint: "_templates",
action: async () => {
const name = prompt(`Note name (from "${t.name}" template):`);
if (name?.trim()) {
try {
const path = await createFromTemplate(vaultPath, t.path, name.trim());
navigate(`/note/${encodeURIComponent(path)}`);
onClose();
} catch (e) {
console.error("Template creation failed:", e);
}
}
},
});
}
}
// v0.4 commands
commands.push(
{ id: "calendar", icon: "📆", label: "Calendar View", action: () => { navigate("/calendar"); onClose(); } },
{ id: "theme", icon: "🎨", label: "Change Theme", hint: "⌘T", action: () => { onClose(); } },
);
if (currentNote) {
commands.push({
id: "export-html",
icon: "📤",
label: "Export as HTML",
action: async () => {
try {
const html = await exportNoteHtml(vaultPath, currentNote);
const blob = new Blob([html], { type: "text/html" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = currentNote.replace(/\.md$/, ".html");
a.click();
URL.revokeObjectURL(url);
onClose();
} catch (e) {
console.error("Export failed:", e);
}
},
});
}
return cmds;
}, [allNotes, templates, navigate, onClose, vaultPath, refreshNotes]);
// v0.5 commands
commands.push(
{ id: "kanban", icon: "📋", label: "Kanban Board", action: () => { navigate("/kanban"); onClose(); } },
{ id: "focus", icon: "🧘", label: "Focus Mode", hint: "⌘⇧F", action: () => { onClose(); } },
{ id: "search-replace", icon: "🔍", label: "Search & Replace", hint: "⌘H", action: () => { onClose(); } },
);
// Filter by query
const filtered = useMemo(() => {
if (!query.trim()) return commands;
const q = query.toLowerCase();
return commands.filter((c) => c.label.toLowerCase().includes(q));
}, [commands, query]);
// Reset selection on filter change
useEffect(() => { setSelectedIndex(0); }, [filtered]);
// Focus input on open
useEffect(() => {
if (open) {
setQuery("");
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 50);
// Load templates
if (vaultPath) {
listTemplates(vaultPath).then(setTemplates).catch(() => setTemplates([]));
}
if (currentNote) {
commands.push({
id: "export-pdf",
icon: "📄",
label: "Export as PDF",
action: () => {
onClose();
setTimeout(() => window.print(), 200);
},
});
}
}, [open, vaultPath]);
// v0.6 commands
commands.push(
{ id: "flashcards", icon: "🎴", label: "Flashcards", action: () => { navigate("/flashcards"); onClose(); } },
{ id: "custom-css", icon: "🎨", label: "Custom CSS", action: () => { onClose(); } },
{
id: "save-workspace", icon: "💾", label: "Save Workspace", action: () => {
const name = prompt("Workspace name:");
if (name?.trim()) { onClose(); }
}
},
);
// v0.7 commands
commands.push(
{ id: "database", icon: "📊", label: "Database View", action: () => { navigate("/database"); onClose(); } },
{
id: "whiteboard", icon: "🎨", label: "New Whiteboard", action: () => {
const name = prompt("Canvas name:");
if (name?.trim()) { navigate(`/whiteboard/${encodeURIComponent(name.trim())}`); onClose(); }
}
},
{ id: "git-sync", icon: "🔀", label: "Git Sync", action: () => { onClose(); } },
);
// v0.8 commands
commands.push(
{ id: "timeline", icon: "📅", label: "Timeline", action: () => { navigate("/timeline"); onClose(); } },
{
id: "random-note", icon: "🎲", label: "Random Note", action: async () => {
try {
const { randomNote } = await import("../lib/commands");
const name = await randomNote(vaultPath);
navigate(`/note/${encodeURIComponent(name)}`);
} catch { }
onClose();
}
},
);
// v0.9 commands
commands.push(
{ id: "analytics", icon: "📊", label: "Graph Analytics", action: () => { navigate("/analytics"); onClose(); } },
{ id: "import-export", icon: "📦", label: "Import / Export", action: () => { onClose(); } },
{ id: "shortcuts", icon: "⌨️", label: "Keyboard Shortcuts", action: () => { onClose(); } },
);
}
// Note matches
const flatNotes = flattenNoteNames(notes);
const queryLower = query.toLowerCase();
if (query.trim()) {
const nameMatches = flatNotes
.filter(n => n.name.toLowerCase().includes(queryLower))
.slice(0, 5);
for (const note of nameMatches) {
commands.push({
id: `note-${note.path}`,
icon: "📄",
label: note.name,
hint: note.path,
action: () => { navigate(`/note/${encodeURIComponent(note.path)}`); onClose(); },
});
}
// Content search results
for (const result of searchResults) {
// Don't duplicate filename matches
if (commands.some(c => c.id === `note-${result.path}`)) continue;
commands.push({
id: `search-${result.path}`,
icon: "🔍",
label: result.name,
hint: result.context.substring(0, 80),
action: () => { navigate(`/note/${encodeURIComponent(result.path)}`); onClose(); },
});
}
}
// Clamp selection
const maxIndex = Math.max(0, commands.length - 1);
const safeIndex = Math.min(selectedIndex, maxIndex);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, maxIndex));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
commands[safeIndex]?.action();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}, [commands, safeIndex, maxIndex, onClose]);
// Scroll selected item into view
useEffect(() => {
if (listRef.current) {
const item = listRef.current.children[selectedIndex] as HTMLElement;
item?.scrollIntoView({ block: "nearest" });
}
}, [selectedIndex]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
filtered[selectedIndex]?.action();
} else if (e.key === "Escape") {
onClose();
}
};
const list = listRef.current;
if (!list) return;
const item = list.children[safeIndex] as HTMLElement | undefined;
item?.scrollIntoView({ block: "nearest" });
}, [safeIndex]);
if (!open) return null;
// Group by section
const noteResults = filtered.filter((c) => c.section === "notes");
const cmdResults = filtered.filter((c) => c.section === "commands");
const tmplResults = filtered.filter((c) => c.section === "templates");
const ordered = [...cmdResults, ...tmplResults, ...noteResults];
return (
<div className="palette-overlay" onClick={onClose}>
<div className="palette" onClick={(e) => e.stopPropagation()} onKeyDown={handleKeyDown}>
<div className="palette-input-wrap">
<svg className="palette-search-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<div className="cmd-backdrop" onClick={onClose}>
<div className="cmd-palette" onClick={e => e.stopPropagation()} onKeyDown={handleKeyDown}>
{/* Search input */}
<div className="cmd-input-wrap">
<svg className="cmd-input-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
ref={inputRef}
className="cmd-input"
type="text"
className="palette-input"
placeholder="Search notes & commands..."
placeholder="Search notes, commands..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onChange={e => { setQuery(e.target.value); setSelectedIndex(0); }}
/>
<kbd className="palette-kbd">esc</kbd>
<kbd className="cmd-kbd">ESC</kbd>
</div>
<div className="palette-list" ref={listRef}>
{ordered.length === 0 && (
<div className="palette-empty">No results found</div>
{/* Results */}
<div className="cmd-list" ref={listRef}>
{commands.length === 0 && query.trim() && (
<div className="cmd-empty">No results for "{query}"</div>
)}
{cmdResults.length > 0 && (
<div className="palette-section-label">Commands</div>
)}
{cmdResults.map((cmd) => {
const globalIdx = ordered.indexOf(cmd);
return (
<button
key={cmd.id}
className={`palette-item ${globalIdx === selectedIndex ? "selected" : ""}`}
onClick={cmd.action}
onMouseEnter={() => setSelectedIndex(globalIdx)}
>
<span className="palette-item-icon">{cmd.icon}</span>
<span className="palette-item-label">{cmd.label}</span>
</button>
);
})}
{noteResults.length > 0 && (
<div className="palette-section-label">Notes</div>
)}
{tmplResults.length > 0 && (
<div className="palette-section-label">Templates</div>
)}
{tmplResults.map((cmd) => {
const globalIdx = ordered.indexOf(cmd);
return (
<button
key={cmd.id}
className={`palette-item ${globalIdx === selectedIndex ? "selected" : ""}`}
onClick={cmd.action}
onMouseEnter={() => setSelectedIndex(globalIdx)}
>
<span className="palette-item-icon">{cmd.icon}</span>
<span className="palette-item-label">{cmd.label}</span>
</button>
);
})}
{noteResults.map((cmd) => {
const globalIdx = ordered.indexOf(cmd);
return (
<button
key={cmd.id}
className={`palette-item ${globalIdx === selectedIndex ? "selected" : ""}`}
onClick={cmd.action}
onMouseEnter={() => setSelectedIndex(globalIdx)}
>
<span className="palette-item-icon">{cmd.icon}</span>
<span className="palette-item-label">{cmd.label}</span>
</button>
);
})}
{commands.map((cmd, i) => (
<button
key={cmd.id}
className={`cmd-item ${i === safeIndex ? "selected" : ""}`}
onClick={cmd.action}
onMouseEnter={() => setSelectedIndex(i)}
>
<span className="cmd-item-icon">{cmd.icon}</span>
<span className="cmd-item-label">{cmd.label}</span>
{cmd.hint && <span className="cmd-item-hint">{cmd.hint}</span>}
</button>
))}
</div>
<div className="palette-footer">
{/* Footer */}
<div className="cmd-footer">
<span> navigate</span>
<span> open</span>
<span> select</span>
<span>esc close</span>
</div>
</div>
@ -222,13 +298,16 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
);
}
function flattenEntries(entries: NoteEntry[]): { name: string; path: string }[] {
/* ── Helpers ────────────────────────────────────────────────── */
function flattenNoteNames(
entries: { name: string; path: string; is_dir: boolean; children?: any[] }[]
): { name: string; path: string }[] {
const result: { name: string; path: string }[] = [];
for (const e of entries) {
if (e.is_dir && e.children) {
result.push(...flattenEntries(e.children));
} else if (!e.is_dir) {
result.push({ name: e.name, path: e.path });
for (const entry of entries) {
if (entry.is_dir && entry.children) {
result.push(...flattenNoteNames(entry.children));
} else if (!entry.is_dir) {
result.push({ name: entry.name, path: entry.path });
}
}
return result;

View file

@ -0,0 +1,171 @@
import { useEffect, useState, useMemo } from "react";
import { useVault } from "../App";
import { queryFrontmatter, type FrontmatterRow } from "../lib/commands";
type ViewMode = "table" | "gallery" | "list";
/**
* DatabaseView Notion-style table/gallery/list views from frontmatter.
*/
export function DatabaseView() {
const { vaultPath, navigateToNote } = useVault();
const [rows, setRows] = useState<FrontmatterRow[]>([]);
const [viewMode, setViewMode] = useState<ViewMode>("table");
const [sortField, setSortField] = useState<string>("title");
const [sortAsc, setSortAsc] = useState(true);
const [filterField, setFilterField] = useState("");
const [filterValue, setFilterValue] = useState("");
useEffect(() => {
if (!vaultPath) return;
queryFrontmatter(vaultPath).then(setRows).catch(() => setRows([]));
}, [vaultPath]);
// All unique field keys
const allFields = useMemo(() => {
const keys = new Set<string>();
rows.forEach(r => Object.keys(r.fields).forEach(k => keys.add(k)));
return ["title", ...Array.from(keys)];
}, [rows]);
// Sort + Filter
const processed = useMemo(() => {
let data = [...rows];
if (filterField && filterValue) {
data = data.filter(r => {
const val = filterField === "title"
? r.title
: (r.fields[filterField] || "");
return val.toLowerCase().includes(filterValue.toLowerCase());
});
}
data.sort((a, b) => {
const va = sortField === "title" ? a.title : (a.fields[sortField] || "");
const vb = sortField === "title" ? b.title : (b.fields[sortField] || "");
return sortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
});
return data;
}, [rows, sortField, sortAsc, filterField, filterValue]);
const handleSort = (field: string) => {
if (sortField === field) setSortAsc(!sortAsc);
else { setSortField(field); setSortAsc(true); }
};
return (
<div className="database-view">
<div className="database-header">
<h2 className="database-title">📊 Database</h2>
<div className="database-controls">
<div className="database-filter">
<select
className="database-select"
value={filterField}
onChange={e => setFilterField(e.target.value)}
>
<option value="">Filter by</option>
{allFields.map(f => <option key={f} value={f}>{f}</option>)}
</select>
{filterField && (
<input
className="database-filter-input"
value={filterValue}
onChange={e => setFilterValue(e.target.value)}
placeholder="Contains…"
/>
)}
</div>
<div className="database-mode-group">
{(["table", "gallery", "list"] as ViewMode[]).map(m => (
<button
key={m}
className={`database-mode-btn ${viewMode === m ? "active" : ""}`}
onClick={() => setViewMode(m)}
>
{m === "table" ? "📋" : m === "gallery" ? "🖼️" : "📝"} {m}
</button>
))}
</div>
</div>
</div>
{viewMode === "table" && (
<div className="database-table-wrapper">
<table className="database-table">
<thead>
<tr>
{allFields.map(f => (
<th key={f} onClick={() => handleSort(f)} className="database-th">
{f}
{sortField === f && <span>{sortAsc ? " ↑" : " ↓"}</span>}
</th>
))}
</tr>
</thead>
<tbody>
{processed.map(row => (
<tr
key={row.path}
className="database-row"
onClick={() => navigateToNote(row.title)}
>
{allFields.map(f => (
<td key={f} className="database-td">
{f === "title" ? row.title : (row.fields[f] || "—")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
{viewMode === "gallery" && (
<div className="database-gallery">
{processed.map(row => (
<div
key={row.path}
className="database-gallery-card"
onClick={() => navigateToNote(row.title)}
>
<div className="gallery-card-title">{row.title}</div>
<div className="gallery-card-fields">
{Object.entries(row.fields).slice(0, 3).map(([k, v]) => (
<div key={k} className="gallery-card-field">
<span className="gallery-field-key">{k}:</span>
<span className="gallery-field-val">{v}</span>
</div>
))}
</div>
</div>
))}
</div>
)}
{viewMode === "list" && (
<div className="database-list">
{processed.map(row => (
<div
key={row.path}
className="database-list-item"
onClick={() => navigateToNote(row.title)}
>
<span className="database-list-name">{row.title}</span>
<span className="database-list-meta">
{Object.entries(row.fields).slice(0, 2).map(([k, v]) => `${k}: ${v}`).join(" • ")}
</span>
</div>
))}
</div>
)}
{processed.length === 0 && (
<div className="database-empty">
<p>No notes with frontmatter found.</p>
<p>Add YAML frontmatter between <code>---</code> markers to your notes.</p>
</div>
)}
</div>
);
}

View file

@ -1,8 +1,15 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { useVault } from "../App";
import { writeNote, saveAttachment } from "../lib/commands";
import { writeNote, saveAttachment, saveSnapshot, getWritingGoal, setWritingGoal as setWritingGoalCmd, isEncrypted, encryptNote, decryptNote } from "../lib/commands";
import { extractWikilinks } from "../lib/wikilinks";
import { marked } from "marked";
import { PropertiesPanel } from "./PropertiesPanel";
import { TableOfContents } from "./TableOfContents";
import { SlashMenu } from "./SlashMenu";
import { HistoryPanel } from "./HistoryPanel";
import { MiniGraph } from "./MiniGraph";
import { LockButton, LockScreen } from "./LockScreen";
import { RefactorMenu } from "./RefactorMenu";
interface AutocompleteState {
active: boolean;
@ -13,18 +20,29 @@ interface AutocompleteState {
}
export function Editor() {
const { vaultPath, currentNote, noteContent, setNoteContent, navigateToNote, notes } = useVault();
const { vaultPath, currentNote, noteContent, setNoteContent, navigateToNote, notes, editMode, toggleEditMode, focusMode } = useVault();
const editorRef = useRef<HTMLDivElement>(null);
const ceRef = useRef<HTMLDivElement>(null); // contenteditable
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const lastNoteRef = useRef<string | null>(null);
const isComposingRef = useRef(false);
const [isPreview, setIsPreview] = useState(false);
const isPreview = !editMode;
const [isSaving, setIsSaving] = useState(false);
const [autocomplete, setAutocomplete] = useState<AutocompleteState>({
active: false, query: "", range: null, selectedIndex: 0,
position: { top: 0, left: 0 },
});
const [slashMenu, setSlashMenu] = useState<{ visible: boolean; query: string; position: { top: number; left: number } }>({
visible: false, query: "", position: { top: 0, left: 0 },
});
const [historyOpen, setHistoryOpen] = useState(false);
const [writingGoal, setWritingGoal] = useState(0);
const [goalEditing, setGoalEditing] = useState(false);
const lastSnapshotRef = useRef(0);
const [noteEncrypted, setNoteEncrypted] = useState(false);
const [lockScreenOpen, setLockScreenOpen] = useState(false);
const [lockError, setLockError] = useState<string | null>(null);
const [refactorMenu, setRefactorMenu] = useState({ visible: false, position: { top: 0, left: 0 }, text: "" });
const noteName = currentNote
?.replace(/\.md$/, "")
@ -226,6 +244,64 @@ export function Editor() {
}
}
// ── List continuation: Enter after "- item" inserts "- " on next line ──
if (e.key === "Enter" && !e.shiftKey) {
const raw = extractRaw();
const lines = raw.split("\n");
// Find current line (approximate: count \n before cursor)
const sel2 = window.getSelection();
if (sel2 && sel2.focusNode) {
const fullText = ceRef.current?.textContent || "";
// crude position estimate
let pos = 0;
const walk = (node: Node): boolean => {
if (node === sel2.focusNode) {
pos += sel2.focusOffset;
return true;
}
if (node.nodeType === Node.TEXT_NODE) {
pos += (node.textContent || "").length;
} else {
for (const child of Array.from(node.childNodes)) {
if (walk(child)) return true;
}
}
return false;
};
if (ceRef.current) walk(ceRef.current);
const textUpToCursor = fullText.substring(0, pos);
const lastNewline = textUpToCursor.lastIndexOf("\n");
const currentLine = textUpToCursor.substring(lastNewline + 1);
const listMatch = currentLine.match(/^(\s*)([-*+]|\d+\.)\s/);
if (listMatch) {
// If line is ONLY the bullet (empty item), remove it
if (currentLine.trim() === listMatch[2]) {
// Don't continue, let default handle
} else {
e.preventDefault();
document.execCommand("insertText", false, "\n" + listMatch[1] + listMatch[2] + " ");
const newRaw = extractRaw();
saveContent(newRaw);
return;
}
}
}
}
// ── Tab indent/outdent for list items ──
if (e.key === "Tab") {
e.preventDefault();
if (e.shiftKey) {
document.execCommand("outdent");
} else {
document.execCommand("insertText", false, " ");
}
const raw = extractRaw();
saveContent(raw);
return;
}
// Token unwrap on Backspace/Delete
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
@ -294,36 +370,8 @@ export function Editor() {
const linkTarget = target.dataset.target;
if (linkTarget) navigateToNote(linkTarget);
}
// Task checkbox toggle
if (target.dataset.task === "true") {
e.preventDefault();
const raw = extractRaw();
// Find the line with this checkbox and toggle it
const lines = raw.split("\n");
// Find button's parent div to locate which task line this is
const parentDiv = target.closest(".task-line");
if (parentDiv) {
const allTaskDivs = Array.from(ceRef.current?.querySelectorAll(".task-line") || []);
const taskIdx = allTaskDivs.indexOf(parentDiv);
let taskCount = 0;
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/^(\s*-\s+)\[([ xX])\](\s+.*)$/);
if (match) {
if (taskCount === taskIdx) {
const checked = match[2].toLowerCase() === "x";
lines[i] = `${match[1]}[${checked ? " " : "x"}]${match[3]}`;
break;
}
taskCount++;
}
}
const newRaw = lines.join("\n");
saveContent(newRaw);
renderToDOM(newRaw);
}
}
},
[navigateToNote, extractRaw, saveContent, renderToDOM]
[navigateToNote]
);
// ── Preview click handler ──
@ -353,29 +401,6 @@ export function Editor() {
return () => document.removeEventListener("mousedown", handler);
}, []);
// ── Handle image drop ──
const handleDrop = useCallback(
async (e: React.DragEvent<HTMLDivElement>) => {
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
if (files.length === 0) return;
e.preventDefault();
e.stopPropagation();
for (const file of files) {
const buffer = await file.arrayBuffer();
const data = Array.from(new Uint8Array(buffer));
const relPath = await saveAttachment(vaultPath, file.name, data);
// Insert markdown image at cursor
const imageMarkdown = `\n![${file.name}](${relPath})\n`;
const raw = extractRaw();
const newRaw = raw + imageMarkdown;
saveContent(newRaw);
renderToDOM(newRaw);
}
},
[vaultPath, extractRaw, saveContent, renderToDOM]
);
if (!currentNote) {
return (
<div className="flex-1 flex items-center justify-center">
@ -396,10 +421,250 @@ export function Editor() {
return html;
})();
// Mermaid post-processing (render in a separate effect)
const mermaidRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isPreview || !mermaidRef.current) return;
const mermaidBlocks = mermaidRef.current.querySelectorAll<HTMLElement>("code.language-mermaid");
if (mermaidBlocks.length === 0) return;
// Lazy load mermaid
import("mermaid").then(mod => {
const mermaid = mod.default;
mermaid.initialize({ startOnLoad: false, theme: "dark" });
mermaidBlocks.forEach(async (block, i) => {
const pre = block.parentElement;
if (!pre) return;
const code = block.textContent || "";
try {
const { svg } = await mermaid.render(`mermaid-${i}`, code);
pre.outerHTML = `<div class="mermaid-diagram">${svg}</div>`;
} catch {
// leave as code block if mermaid fails
}
});
}).catch(() => { });
}, [isPreview, renderedMarkdown]);
// highlight.js for code blocks
useEffect(() => {
if (!isPreview || !mermaidRef.current) return;
import("highlight.js/lib/core").then(async (hljs) => {
const hljsCore = hljs.default;
// Lazy-load common languages
const [js, ts, css, json, py, rust, bash, md] = await Promise.all([
import("highlight.js/lib/languages/javascript"),
import("highlight.js/lib/languages/typescript"),
import("highlight.js/lib/languages/css"),
import("highlight.js/lib/languages/json"),
import("highlight.js/lib/languages/python"),
import("highlight.js/lib/languages/rust"),
import("highlight.js/lib/languages/bash"),
import("highlight.js/lib/languages/markdown"),
]);
hljsCore.registerLanguage("javascript", js.default);
hljsCore.registerLanguage("typescript", ts.default);
hljsCore.registerLanguage("css", css.default);
hljsCore.registerLanguage("json", json.default);
hljsCore.registerLanguage("python", py.default);
hljsCore.registerLanguage("rust", rust.default);
hljsCore.registerLanguage("bash", bash.default);
hljsCore.registerLanguage("markdown", md.default);
const blocks = mermaidRef.current?.querySelectorAll<HTMLElement>("pre code:not(.language-mermaid)") || [];
blocks.forEach(block => {
hljsCore.highlightElement(block);
// Add copy button
const pre = block.parentElement;
if (pre && !pre.querySelector(".code-copy-btn")) {
const btn = document.createElement("button");
btn.className = "code-copy-btn";
btn.textContent = "Copy";
btn.onclick = () => {
navigator.clipboard.writeText(block.textContent || "");
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = "Copy"; }, 1500);
};
pre.style.position = "relative";
pre.appendChild(btn);
}
});
}).catch(() => { });
}, [isPreview, renderedMarkdown]);
// Auto-snapshot on save (max 1 per 5 min)
useEffect(() => {
if (!vaultPath || !currentNote || !noteContent) return;
const now = Date.now();
if (now - lastSnapshotRef.current > 5 * 60 * 1000 && noteContent.length > 50) {
lastSnapshotRef.current = now;
saveSnapshot(vaultPath, currentNote).catch(() => { });
}
}, [vaultPath, currentNote, noteContent]);
// Load writing goal
useEffect(() => {
if (!vaultPath || !currentNote) { setWritingGoal(0); return; }
getWritingGoal(vaultPath, currentNote).then(setWritingGoal).catch(() => setWritingGoal(0));
}, [vaultPath, currentNote]);
// Check if note is encrypted
useEffect(() => {
if (!vaultPath || !currentNote) { setNoteEncrypted(false); return; }
isEncrypted(vaultPath, currentNote).then(setNoteEncrypted).catch(() => setNoteEncrypted(false));
}, [vaultPath, currentNote]);
// Widget rendering in preview
useEffect(() => {
if (!isPreview || !mermaidRef.current) return;
const container = mermaidRef.current;
// {{progress:N}} → progress bar
container.innerHTML = container.innerHTML.replace(
/\{\{progress:(\d+)\}\}/g,
(_, n) => `<div class="widget-progress"><div class="widget-progress-fill" style="width:${Math.min(100, +n)}%"></div><span class="widget-progress-label">${n}%</span></div>`
);
// {{counter:N}} → counter badge
container.innerHTML = container.innerHTML.replace(
/\{\{counter:(\d+)\}\}/g,
(_, n) => `<span class="widget-counter">${n}</span>`
);
// {{toggle:on/off}} → toggle indicator
container.innerHTML = container.innerHTML.replace(
/\{\{toggle:(on|off)\}\}/g,
(_, state) => `<span class="widget-toggle ${state === 'on' ? 'on' : ''}">${state === 'on' ? '●' : '○'}</span>`
);
}, [isPreview, renderedMarkdown]);
// Right-click for refactoring
const handleContextMenu = useCallback((e: React.MouseEvent) => {
const sel = window.getSelection();
const text = sel?.toString() || "";
if (text.trim() || currentNote) {
e.preventDefault();
setRefactorMenu({ visible: true, position: { top: e.clientY, left: e.clientX }, text });
}
}, [currentNote]);
// Image paste handler
const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
const items = e.clipboardData.items;
for (const item of items) {
if (item.type.startsWith("image/")) {
e.preventDefault();
const file = item.getAsFile();
if (!file || !vaultPath) return;
const buffer = await file.arrayBuffer();
const data = Array.from(new Uint8Array(buffer));
const ext = file.type.split("/")[1] || "png";
const fileName = `paste-${Date.now()}.${ext}`;
try {
const relPath = await saveAttachment(vaultPath, fileName, data);
// Insert markdown image at cursor
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
const imgText = `![${fileName}](${relPath})`;
const textNode = document.createTextNode(imgText);
range.insertNode(textNode);
range.collapse(false);
}
// Trigger save
const raw = ceRef.current?.innerText || "";
setNoteContent(raw);
if (currentNote) {
await writeNote(vaultPath, currentNote, raw);
}
} catch (err) {
console.error("Image paste failed:", err);
}
return;
}
}
}, [vaultPath, currentNote, setNoteContent]);
// Slash command trigger
const checkSlashCommand = useCallback(() => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
const node = range.startContainer;
if (node.nodeType !== Node.TEXT_NODE) return;
const text = node.textContent || "";
const offset = range.startOffset;
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
const lineText = text.substring(lineStart, offset);
if (lineText.startsWith("/")) {
const query = lineText.substring(1);
const rect = range.getBoundingClientRect();
setSlashMenu({
visible: true,
query,
position: { top: rect.bottom + 4, left: rect.left },
});
} else {
setSlashMenu(prev => ({ ...prev, visible: false }));
}
}, []);
const handleSlashSelect = useCallback((insert: string) => {
setSlashMenu(prev => ({ ...prev, visible: false }));
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
const node = range.startContainer;
if (node.nodeType !== Node.TEXT_NODE) return;
const text = node.textContent || "";
const offset = range.startOffset;
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
// Replace the /query with the insert text
node.textContent = text.substring(0, lineStart) + insert + text.substring(offset);
// Move cursor after insert
const newOffset = lineStart + insert.length;
range.setStart(node, Math.min(newOffset, node.textContent.length));
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
// Save
const raw = ceRef.current?.innerText || "";
setNoteContent(raw);
if (currentNote && vaultPath) {
writeNote(vaultPath, currentNote, raw).catch(() => { });
}
}, [vaultPath, currentNote, setNoteContent]);
// Breadcrumb segments
const breadcrumbs = currentNote
? currentNote.replace(/\.md$/, "").split("/")
: [];
return (
<div className="flex flex-col h-full" ref={editorRef}>
<div className={`flex flex-col h-full ${focusMode ? "editor-focus" : ""}`} ref={editorRef}>
{/* ── Breadcrumbs ── */}
{!focusMode && breadcrumbs.length > 1 && (
<div className="breadcrumb-bar">
{breadcrumbs.map((seg, i) => (
<span key={i}>
{i > 0 && <span className="breadcrumb-separator">/</span>}
<span className={`breadcrumb-item ${i === breadcrumbs.length - 1 ? "active" : ""}`}>
{seg}
</span>
</span>
))}
</div>
)}
{/* ── Properties Panel ── */}
{!focusMode && <PropertiesPanel />}
{/* ── Header ── */}
<div className="editor-header">
<div className={`editor-header ${focusMode ? "editor-header-focus" : ""}`}>
<div className="flex items-center gap-3">
<h2 className="editor-title">{noteName}</h2>
{isSaving && (
@ -416,32 +681,66 @@ export function Editor() {
<div className="toggle-group">
<button
className={`toggle-item ${!isPreview ? "active" : ""}`}
onClick={() => setIsPreview(false)}
onClick={() => { if (isPreview) toggleEditMode(); }}
>
Edit
</button>
<button
className={`toggle-item ${isPreview ? "active" : ""}`}
onClick={() => setIsPreview(true)}
onClick={() => { if (!isPreview) toggleEditMode(); }}
>
Preview
</button>
</div>
{!focusMode && (
<button className={`history-toggle-btn ${historyOpen ? "active" : ""}`} onClick={() => setHistoryOpen(v => !v)} title="Version history">
🕐
</button>
)}
{!focusMode && (
<LockButton
isLocked={noteEncrypted}
onLock={async () => {
const pw = prompt("Set password:");
if (pw && currentNote) {
await encryptNote(vaultPath, currentNote, pw).catch(() => { });
setNoteEncrypted(true);
}
}}
onUnlock={() => setLockScreenOpen(true)}
/>
)}
</div>
</div>
{/* ── Writing Goal Progress ── */}
{writingGoal > 0 && (
<div className="writing-goal-bar">
<div className="writing-goal-fill" style={{ width: `${Math.min(100, (wordCount / writingGoal) * 100)}%` }} />
<span className="writing-goal-text">{wordCount} / {writingGoal} words</span>
</div>
)}
{/* ── Content ── */}
<div className="flex-1 overflow-y-auto relative">
{isPreview ? (
<div
className="markdown-preview prose prose-invert max-w-none px-8 py-6"
style={{
color: "var(--text-primary)",
lineHeight: 1.8,
fontSize: "15px",
}}
dangerouslySetInnerHTML={{ __html: renderedMarkdown }}
/>
<div className="flex h-full">
<div className="flex flex-1 overflow-y-auto">
<div
ref={mermaidRef}
className="prose prose-invert max-w-none px-8 py-6 flex-1"
style={{
color: "var(--text-primary)",
lineHeight: 1.8,
fontSize: "15px",
}}
dangerouslySetInnerHTML={{ __html: renderedMarkdown }}
/>
{!focusMode && <TableOfContents />}
{!focusMode && <MiniGraph />}
</div>
{historyOpen && !focusMode && <HistoryPanel onClose={() => setHistoryOpen(false)} />}
</div>
) : (
<>
{/* Contenteditable editor */}
@ -450,11 +749,10 @@ export function Editor() {
contentEditable
suppressContentEditableWarning
className="editor-ce"
onInput={handleInput}
onInput={() => { handleInput(); checkSlashCommand(); }}
onKeyDown={handleKeyDown}
onClick={handleClick}
onDrop={handleDrop}
onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) e.preventDefault(); }}
onPaste={handlePaste}
onCompositionStart={() => { isComposingRef.current = true; }}
onCompositionEnd={() => {
isComposingRef.current = false;
@ -515,9 +813,48 @@ export function Editor() {
<div className="autocomplete-hint">Enter to create Esc close</div>
</div>
)}
{/* ── Slash Command Menu ── */}
<SlashMenu
visible={slashMenu.visible}
position={slashMenu.position}
query={slashMenu.query}
onSelect={handleSlashSelect}
onClose={() => setSlashMenu(prev => ({ ...prev, visible: false }))}
/>
</>
)}
</div>
{/* ── Refactor Menu ── */}
<RefactorMenu
visible={refactorMenu.visible}
position={refactorMenu.position}
selectedText={refactorMenu.text}
onClose={() => setRefactorMenu({ visible: false, position: { top: 0, left: 0 }, text: "" })}
/>
{/* ── Lock Screen ── */}
{lockScreenOpen && (
<LockScreen
error={lockError}
onCancel={() => { setLockScreenOpen(false); setLockError(null); }}
onUnlock={async (pw) => {
if (!currentNote) return;
try {
const content = await decryptNote(vaultPath, currentNote, pw);
setNoteContent(content);
setNoteEncrypted(false);
setLockScreenOpen(false);
setLockError(null);
// Save decrypted
await writeNote(vaultPath, currentNote, content);
} catch {
setLockError("Wrong password");
}
}}
/>
)}
</div>
);
}
@ -587,59 +924,60 @@ function domToMarkdown(el: HTMLDivElement): string {
/** Convert raw markdown to tokenized HTML for contenteditable */
function markdownToTokenHTML(raw: string): string {
// Escape HTML
let escaped = raw
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
// Process line by line for heading detection
const lines = raw.split("\n");
const htmlLines = lines.map(line => {
// Escape HTML
let escaped = line
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
// Replace [[wikilinks]] with token spans
escaped = escaped.replace(
/\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g,
(_m, target, display) => {
const label = display?.trim() || target.trim();
const rawAttr = _m.replace(/"/g, "&quot;");
return `<span class="wikilink-token" contenteditable="false" data-raw="${rawAttr}" data-target="${target.trim()}">${label}</span>`;
}
);
// Replace [[wikilinks]] with token spans
escaped = escaped.replace(
/\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g,
(_m, target, display) => {
const label = display?.trim() || target.trim();
const rawAttr = _m.replace(/"/g, "&quot;");
return `<span class="wikilink-token" contenteditable="false" data-raw="${rawAttr}" data-target="${target.trim()}">${label}</span>`;
}
);
// Process lines for headings, task lists, and code blocks
const lines = escaped.split("\n");
const processed = lines.map((line) => {
// Heading scaling (h1-h3)
const headingMatch = line.match(/^(#{1,3})\s+(.*)$/);
// Replace #tags with tag tokens (but not inside wikilinks or at start of heading)
escaped = escaped.replace(
/(?:^|(?<=\s))(#[a-zA-Z][a-zA-Z0-9_/-]*)/g,
'<span class="tag-token">$1</span>'
);
// Inline code: `code`
escaped = escaped.replace(
/`([^`]+)`/g,
'<span class="inline-code">$1</span>'
);
// Bold: **text**
escaped = escaped.replace(
/\*\*([^*]+)\*\*/g,
'<span class="inline-bold">$1</span>'
);
// Italic: *text* (but not **)
escaped = escaped.replace(
/(?<!\*)\*([^*]+)\*(?!\*)/g,
'<span class="inline-italic">$1</span>'
);
// Detect heading level
const headingMatch = escaped.match(/^(#{1,3})\s/);
if (headingMatch) {
const level = headingMatch[1].length;
return `<div data-heading="${level}">${line}</div>`;
return `<div data-heading="${level}">${escaped}</div>`;
}
// Task list items: - [ ] or - [x]
const taskMatch = line.match(/^(\s*)-\s+\[([ xX])\]\s+(.*)$/);
if (taskMatch) {
const indent = taskMatch[1];
const checked = taskMatch[2].toLowerCase() === "x";
const text = taskMatch[3];
const checkboxClass = checked ? "editor-checkbox checked" : "editor-checkbox";
const checkMark = checked ? "✓" : "";
const lineClass = checked ? "task-line completed" : "task-line";
return `<div class="${lineClass}">${indent}<button class="${checkboxClass}" contenteditable="false" data-task="true">${checkMark}</button>${text}</div>`;
}
return line;
return escaped;
});
escaped = processed.join("\n");
// Inline code: `code`
escaped = escaped.replace(
/`([^`\n]+?)`/g,
'<span class="editor-code-inline">$1</span>'
);
// Convert newlines to <br>
escaped = escaped.replace(/\n/g, "<br>");
return escaped;
return htmlLines.join("<br>");
}
/** Flatten note entries to a list of display names */

View file

@ -0,0 +1,105 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useVault } from "../App";
import { listFlashcards, updateCardSchedule, type Flashcard } from "../lib/commands";
/**
* FlashcardView Spaced repetition study mode.
*/
export function FlashcardView() {
const { vaultPath } = useVault();
const [cards, setCards] = useState<Flashcard[]>([]);
const [index, setIndex] = useState(0);
const [flipped, setFlipped] = useState(false);
const [loading, setLoading] = useState(true);
const [sessionDone, setSessionDone] = useState(0);
const loadCards = useCallback(async () => {
if (!vaultPath) return;
setLoading(true);
try {
const all = await listFlashcards(vaultPath);
const today = new Date().toISOString().slice(0, 10);
// Show cards due today or with no due date
const due = all.filter(c => !c.due || c.due <= today);
setCards(due.length > 0 ? due : all);
} catch {
setCards([]);
}
setLoading(false);
}, [vaultPath]);
useEffect(() => { loadCards(); }, [loadCards]);
const current = cards[index];
const handleRate = useCallback(async (quality: number) => {
if (!current || !vaultPath) return;
const cardId = `${current.source_path}:${current.line_number}`;
await updateCardSchedule(vaultPath, cardId, quality).catch(() => { });
setSessionDone(n => n + 1);
setFlipped(false);
if (index + 1 < cards.length) {
setIndex(i => i + 1);
} else {
setIndex(0);
loadCards(); // Refresh for next round
}
}, [current, vaultPath, index, cards.length, loadCards]);
if (loading) {
return <div className="flashcard-view"><div className="flashcard-loading">Loading flashcards</div></div>;
}
if (cards.length === 0) {
return (
<div className="flashcard-view">
<div className="flashcard-empty">
<div className="flashcard-empty-icon">🎴</div>
<h3>No flashcards found</h3>
<p>Add <code>?? question :: answer ??</code> to your notes</p>
</div>
</div>
);
}
return (
<div className="flashcard-view">
<div className="flashcard-header">
<span className="flashcard-progress">{index + 1} / {cards.length}</span>
<span className="flashcard-done">{sessionDone} reviewed</span>
</div>
<div
className={`flashcard-card ${flipped ? "flipped" : ""}`}
onClick={() => setFlipped(!flipped)}
>
<div className="flashcard-front">
<div className="flashcard-label">Question</div>
<div className="flashcard-text">{current.question}</div>
<div className="flashcard-hint">Click to reveal</div>
</div>
<div className="flashcard-back">
<div className="flashcard-label">Answer</div>
<div className="flashcard-text">{current.answer}</div>
</div>
</div>
{flipped && (
<div className="flashcard-ratings">
<span className="flashcard-ratings-label">How well did you know this?</span>
<div className="flashcard-buttons">
<button className="flashcard-btn rate-1" onClick={() => handleRate(1)}>Again</button>
<button className="flashcard-btn rate-2" onClick={() => handleRate(2)}>Hard</button>
<button className="flashcard-btn rate-3" onClick={() => handleRate(3)}>Good</button>
<button className="flashcard-btn rate-4" onClick={() => handleRate(4)}>Easy</button>
<button className="flashcard-btn rate-5" onClick={() => handleRate(5)}>Perfect</button>
</div>
</div>
)}
<div className="flashcard-source">
From: {current.source_path.replace(".md", "")}
</div>
</div>
);
}

160
src/components/GitPanel.tsx Normal file
View file

@ -0,0 +1,160 @@
import { useState, useEffect, useCallback } from "react";
import { useVault } from "../App";
import { gitStatus, gitCommit, gitPull, gitPush, gitInit } from "../lib/commands";
interface GitFile {
status: string;
path: string;
}
/**
* GitPanel Git sync sidebar panel.
*/
export function GitPanel({ open, onClose }: { open: boolean; onClose: () => void }) {
const { vaultPath } = useVault();
const [files, setFiles] = useState<GitFile[]>([]);
const [message, setMessage] = useState("");
const [output, setOutput] = useState("");
const [isRepo, setIsRepo] = useState(true);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
if (!vaultPath) return;
try {
const status = await gitStatus(vaultPath);
const parsed: GitFile[] = status
.split("\n")
.filter(l => l.trim())
.map(l => ({
status: l.substring(0, 2).trim(),
path: l.substring(3).trim(),
}));
setFiles(parsed);
setIsRepo(true);
} catch {
setIsRepo(false);
setFiles([]);
}
}, [vaultPath]);
useEffect(() => { if (open) refresh(); }, [open, refresh]);
const handleCommit = async () => {
if (!vaultPath || !message.trim()) return;
setLoading(true);
try {
const result = await gitCommit(vaultPath, message.trim());
setOutput(result);
setMessage("");
await refresh();
} catch (e) {
setOutput(`Error: ${e}`);
}
setLoading(false);
};
const handlePull = async () => {
if (!vaultPath) return;
setLoading(true);
try {
const result = await gitPull(vaultPath);
setOutput(result);
await refresh();
} catch (e) {
setOutput(`Error: ${e}`);
}
setLoading(false);
};
const handlePush = async () => {
if (!vaultPath) return;
setLoading(true);
try {
const result = await gitPush(vaultPath);
setOutput(result);
} catch (e) {
setOutput(`Error: ${e}`);
}
setLoading(false);
};
const handleInit = async () => {
if (!vaultPath) return;
try {
await gitInit(vaultPath);
setIsRepo(true);
await refresh();
} catch (e) {
setOutput(`Error: ${e}`);
}
};
if (!open) return null;
return (
<div className="git-panel">
<div className="git-header">
<h3 className="git-title">🔀 Git Sync</h3>
<button className="git-close" onClick={onClose}></button>
</div>
{!isRepo ? (
<div className="git-init-prompt">
<p>This vault is not a git repository.</p>
<button className="git-init-btn" onClick={handleInit}>Initialize Git</button>
</div>
) : (
<>
<div className="git-status">
<div className="git-status-label">
{files.length === 0 ? "✅ Clean" : `${files.length} changed`}
</div>
<div className="git-file-list">
{files.map((f, i) => (
<div key={i} className="git-file">
<span className={`git-file-status status-${f.status.charAt(0).toLowerCase()}`}>
{f.status}
</span>
<span className="git-file-path">{f.path}</span>
</div>
))}
</div>
</div>
<div className="git-commit-area">
<input
className="git-message"
value={message}
onChange={e => setMessage(e.target.value)}
placeholder="Commit message…"
onKeyDown={e => e.key === "Enter" && handleCommit()}
/>
<button
className="git-commit-btn"
onClick={handleCommit}
disabled={!message.trim() || loading}
>
Commit
</button>
</div>
<div className="git-sync-actions">
<button className="git-pull-btn" onClick={handlePull} disabled={loading}>
Pull
</button>
<button className="git-push-btn" onClick={handlePush} disabled={loading}>
Push
</button>
<button className="git-refresh-btn" onClick={refresh}>
🔄 Refresh
</button>
</div>
{output && (
<pre className="git-output">{output}</pre>
)}
</>
)}
</div>
);
}

View file

@ -0,0 +1,126 @@
import { useEffect, useState, useMemo } from "react";
import { useVault } from "../App";
import { buildGraph } from "../lib/commands";
interface AnalyticsData {
totalNotes: number;
totalLinks: number;
avgLinks: number;
orphans: { name: string }[];
mostConnected: { name: string; count: number }[];
}
/**
* GraphAnalytics Orphan detection, most-connected, graph stats.
*/
export function GraphAnalytics() {
const { vaultPath, navigateToNote } = useVault();
const [data, setData] = useState<AnalyticsData | null>(null);
useEffect(() => {
if (!vaultPath) return;
buildGraph(vaultPath).then(graph => {
const linkCount = new Map<string, number>();
// Count connections per node
graph.nodes.forEach(n => linkCount.set(n.id, 0));
graph.edges.forEach(e => {
linkCount.set(e.source, (linkCount.get(e.source) || 0) + 1);
linkCount.set(e.target, (linkCount.get(e.target) || 0) + 1);
});
const orphans = graph.nodes
.filter(n => (linkCount.get(n.id) || 0) === 0)
.map(n => ({ name: n.label }));
const sorted = Array.from(linkCount.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
const mostConnected = sorted.map(([id, count]) => ({
name: graph.nodes.find(n => n.id === id)?.label || id.replace(".md", ""),
count,
}));
const totalLinks = graph.edges.length;
const avgLinks = graph.nodes.length > 0 ? totalLinks / graph.nodes.length : 0;
setData({
totalNotes: graph.nodes.length,
totalLinks,
avgLinks,
orphans,
mostConnected,
});
}).catch(() => { });
}, [vaultPath]);
if (!data) {
return <div className="analytics-view"><div className="analytics-loading">Loading analytics</div></div>;
}
return (
<div className="analytics-view">
<h2 className="analytics-title">📊 Graph Analytics</h2>
{/* Stats */}
<div className="analytics-stats">
<div className="analytics-stat">
<span className="stat-value">{data.totalNotes}</span>
<span className="stat-label">Notes</span>
</div>
<div className="analytics-stat">
<span className="stat-value">{data.totalLinks}</span>
<span className="stat-label">Links</span>
</div>
<div className="analytics-stat">
<span className="stat-value">{data.avgLinks.toFixed(1)}</span>
<span className="stat-label">Avg Links</span>
</div>
<div className="analytics-stat">
<span className="stat-value">{data.orphans.length}</span>
<span className="stat-label">Orphans</span>
</div>
</div>
{/* Most Connected */}
<div className="analytics-section">
<h3 className="analytics-section-title">🔗 Most Connected</h3>
<div className="analytics-list">
{data.mostConnected.map((item, i) => (
<div
key={item.name}
className="analytics-list-item"
onClick={() => navigateToNote(item.name)}
>
<span className="analytics-rank">#{i + 1}</span>
<span className="analytics-name">{item.name}</span>
<span className="analytics-count">{item.count} links</span>
<div className="analytics-bar" style={{ width: `${(item.count / (data.mostConnected[0]?.count || 1)) * 100}%` }} />
</div>
))}
</div>
</div>
{/* Orphans */}
<div className="analytics-section">
<h3 className="analytics-section-title">🏝 Orphan Notes ({data.orphans.length})</h3>
{data.orphans.length === 0 ? (
<p className="analytics-empty">No orphan notes every note is linked!</p>
) : (
<div className="analytics-orphan-grid">
{data.orphans.map(o => (
<button
key={o.name}
className="analytics-orphan-chip"
onClick={() => navigateToNote(o.name)}
>
{o.name}
</button>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -1,747 +1,81 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { useEffect, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { Provider as JotaiProvider } from "jotai";
import { Canvas, CanvasStyleProvider, registerBuiltinCommands } from "@blinksgg/canvas";
import { useVault } from "../App";
import { buildGraph, readNote, type GraphData } from "../lib/commands";
import { buildGraph, type GraphData } from "../lib/commands";
/**
* GraphView Force-directed graph with semantic zoom.
* At low zoom: compact circles. At high zoom: morphs into
* rounded-rectangle cards showing note previews.
*/
interface SimNode {
id: string;
label: string;
path: string;
linkCount: number;
x: number;
y: number;
vx: number;
vy: number;
baseRadius: number;
color: string;
pinned: boolean;
preview: string; // First few lines of content
}
interface SimEdge {
source: string;
target: string;
}
registerBuiltinCommands();
const NODE_COLORS = [
"#8b5cf6", "#3b82f6", "#10b981", "#f59e0b", "#f43f5e",
"#06b6d4", "#a855f7", "#ec4899", "#14b8a6", "#ef4444",
];
/**
* GraphView Force-directed graph powered by @blinksgg/canvas.
*/
export function GraphView() {
const { vaultPath } = useVault();
const navigate = useNavigate();
const [graphData, setGraphData] = useState<GraphData | null>(null);
const [folderFilter, setFolderFilter] = useState<string>("all");
const [minLinks, setMinLinks] = useState<number>(0);
const [initialNodes, setInitialNodes] = useState<any[]>([]);
// Load graph data
useEffect(() => {
if (!vaultPath) return;
buildGraph(vaultPath).then(setGraphData);
buildGraph(vaultPath).then(data => {
setGraphData(data);
// Create initial nodes with random positions
const nodes = data.nodes.map((node, i) => ({
id: node.id,
graph_id: "vault-graph",
label: node.label,
node_type: "note",
configuration: null,
ui_properties: {
x: (Math.random() - 0.5) * 800,
y: (Math.random() - 0.5) * 600,
width: 160,
height: 60,
color: NODE_COLORS[i % NODE_COLORS.length],
},
data: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}));
setInitialNodes(nodes);
}).catch(() => { });
}, [vaultPath]);
if (!graphData) {
return (
<div className="flex-1 flex items-center justify-center">
<p className="text-[var(--text-muted)] animate-pulse">
Building graph...
</p>
</div>
);
}
// Extract unique folders
const folders = Array.from(
new Set(graphData.nodes.map((n) => {
const parts = n.path.split("/");
return parts.length > 1 ? parts.slice(0, -1).join("/") : "(root)";
}))
).sort();
// Filter nodes
const filteredNodes = graphData.nodes.filter((node) => {
const folder = node.path.includes("/")
? node.path.split("/").slice(0, -1).join("/")
: "(root)";
if (folderFilter !== "all" && folder !== folderFilter) return false;
if (node.link_count < minLinks) return false;
return true;
});
const nodeIds = new Set(filteredNodes.map((n) => n.id));
const filteredEdges = graphData.edges.filter(
(e) => nodeIds.has(e.source) && nodeIds.has(e.target)
);
const filteredData: GraphData = { nodes: filteredNodes, edges: filteredEdges };
return (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="graph-header">
<div className="flex items-center gap-3">
<h2 style={{ fontSize: 14, fontWeight: 600, color: "var(--text-primary)" }}>
🔮 Graph View
</h2>
<span className="badge badge-purple">{filteredNodes.length} notes</span>
<span className="badge badge-muted">{filteredEdges.length} links</span>
</div>
<div style={{ fontSize: 10, color: "var(--text-muted)" }}>
Scroll to zoom · Click to focus · Double-click to open
</div>
</div>
{/* ── Filter Bar ── */}
<div className="graph-filter-bar">
<label>
Folder
<select value={folderFilter} onChange={(e) => setFolderFilter(e.target.value)}>
<option value="all">All</option>
{folders.map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
</label>
<label>
Min links: {minLinks}
<input
type="range"
min={0}
max={Math.max(5, ...graphData.nodes.map((n) => n.link_count))}
value={minLinks}
onChange={(e) => setMinLinks(Number(e.target.value))}
/>
</label>
</div>
<div className="flex-1 relative">
<ForceGraph
graphData={filteredData}
onNodeClick={(path) => navigate(`/note/${encodeURIComponent(path)}`)}
/>
</div>
</div>
);
}
/* ── Force-Directed Graph with Semantic Zoom ──────────────── */
function ForceGraph({
graphData,
onNodeClick,
}: {
graphData: GraphData;
onNodeClick: (path: string) => void;
}) {
const { vaultPath } = useVault();
const canvasRef = useRef<HTMLCanvasElement>(null);
const nodesRef = useRef<SimNode[]>([]);
const edgesRef = useRef<SimEdge[]>([]);
const nodeMapRef = useRef<Map<string, SimNode>>(new Map());
const animRef = useRef<number>(0);
const stateRef = useRef({
zoom: 1,
panX: 0,
panY: 0,
W: 800,
H: 600,
isDragging: false,
dragNode: null as SimNode | null,
lastMouse: { x: 0, y: 0 },
mouseDownPos: { x: 0, y: 0 },
hasDragged: false,
hoveredNode: null as SimNode | null,
alpha: 1.0,
});
// Initialize nodes
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const parent = canvas.parentElement!;
const W = parent.clientWidth;
const H = parent.clientHeight;
const s = stateRef.current;
s.W = W;
s.H = H;
const cx = W / 2;
const cy = H / 2;
const circleR = Math.min(W, H) * 0.3;
const n = graphData.nodes.length;
const nodes: SimNode[] = graphData.nodes.map((node, i) => ({
id: node.id,
label: node.label,
path: node.path,
linkCount: node.link_count,
x: cx + circleR * Math.cos((2 * Math.PI * i) / Math.max(n, 1)),
y: cy + circleR * Math.sin((2 * Math.PI * i) / Math.max(n, 1)),
vx: 0,
vy: 0,
baseRadius: Math.max(10, Math.min(28, 10 + node.link_count * 4)),
color: NODE_COLORS[i % NODE_COLORS.length],
pinned: false,
preview: "",
}));
nodesRef.current = nodes;
edgesRef.current = graphData.edges;
nodeMapRef.current = new Map(nodes.map((n) => [n.id, n]));
s.alpha = 1.0;
// HiDPI
const dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = W + "px";
canvas.style.height = H + "px";
// Load note previews
if (vaultPath) {
for (const node of nodes) {
readNote(vaultPath, node.path).then((content) => {
// Strip markdown, take first 120 chars
const cleaned = content
.replace(/^#+ .*/gm, "") // remove headings
.replace(/\[\[([^\]]+)\]\]/g, "$1") // unwrap wikilinks
.replace(/[*_~`]/g, "") // strip formatting
.trim();
const lines = cleaned.split("\n").filter(l => l.trim()).slice(0, 4);
node.preview = lines.join("\n").substring(0, 150);
}).catch(() => { /* ignore missing files */ });
}
}
}, [graphData, vaultPath]);
// Animation loop
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d")!;
function loop() {
simulate();
draw(ctx);
animRef.current = requestAnimationFrame(loop);
}
animRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(animRef.current);
}, [graphData]);
// ── Force simulation ──
function simulate() {
const s = stateRef.current;
const nodes = nodesRef.current;
const edges = edgesRef.current;
const nodeMap = nodeMapRef.current;
if (s.alpha < 0.001) return;
const cx = s.W / 2;
const cy = s.H / 2;
// Center gravity
for (const node of nodes) {
if (node.pinned) continue;
node.vx += (cx - node.x) * 0.002 * s.alpha;
node.vy += (cy - node.y) * 0.002 * s.alpha;
}
// Repulsion (charge)
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i], b = nodes[j];
let dx = b.x - a.x;
let dy = b.y - a.y;
let dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1) {
dx = (Math.random() - 0.5) * 4;
dy = (Math.random() - 0.5) * 4;
dist = Math.sqrt(dx * dx + dy * dy);
}
const strength = -1600 / (dist * dist + 200);
const fx = (dx / dist) * strength * s.alpha;
const fy = (dy / dist) * strength * s.alpha;
if (!a.pinned) { a.vx -= fx; a.vy -= fy; }
if (!b.pinned) { b.vx += fx; b.vy += fy; }
// Collision — generous spacing so cards don't overlap
const minD = a.baseRadius + b.baseRadius + 100;
if (dist < minD) {
const push = (minD - dist) * 0.3;
const px = (dx / dist) * push;
const py = (dy / dist) * push;
if (!a.pinned) { a.x -= px; a.y -= py; }
if (!b.pinned) { b.x += px; b.y += py; }
}
}
}
// Link springs
for (const edge of edges) {
const src = nodeMap.get(edge.source);
const tgt = nodeMap.get(edge.target);
if (!src || !tgt) continue;
let dx = tgt.x - src.x, dy = tgt.y - src.y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = (dist - 280) * 0.006 * s.alpha;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
if (!src.pinned) { src.vx += fx; src.vy += fy; }
if (!tgt.pinned) { tgt.vx -= fx; tgt.vy -= fy; }
}
// Velocity + boundary
for (const node of nodes) {
if (node.pinned) { node.vx = 0; node.vy = 0; continue; }
node.vx *= 0.82;
node.vy *= 0.82;
node.x += node.vx;
node.y += node.vy;
const m = 50;
if (node.x < m) node.vx += (m - node.x) * 0.08;
if (node.x > s.W - m) node.vx += (s.W - m - node.x) * 0.08;
if (node.y < m) node.vy += (m - node.y) * 0.08;
if (node.y > s.H - m) node.vy += (s.H - m - node.y) * 0.08;
}
s.alpha *= 0.995;
}
// ── Render with semantic zoom ──
function draw(ctx: CanvasRenderingContext2D) {
const s = stateRef.current;
const nodes = nodesRef.current;
const edges = edgesRef.current;
const nodeMap = nodeMapRef.current;
const { W, H, zoom, panX, panY, hoveredNode } = s;
const dpr = window.devicePixelRatio || 1;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
ctx.save();
ctx.translate(panX, panY);
ctx.scale(zoom, zoom);
// Semantic zoom factor: 0 = circle mode, 1 = full card mode
// Transition: starts at 1.2x, full card at 2.5x
const cardT = clamp01((zoom - 1.2) / 1.3);
const CARD_W = 160;
const CARD_H = lerp(36, 90, cardT);
// ── Edges ──
for (const edge of edges) {
const src = nodeMap.get(edge.source);
const tgt = nodeMap.get(edge.target);
if (!src || !tgt) continue;
const lit = hoveredNode === src || hoveredNode === tgt;
ctx.beginPath();
ctx.moveTo(src.x, src.y);
ctx.lineTo(tgt.x, tgt.y);
ctx.strokeStyle = lit ? "rgba(139,92,246,0.5)" : "rgba(255,255,255,0.06)";
ctx.lineWidth = lit ? 2 : 1;
ctx.stroke();
}
// ── Nodes (circle→card morph) ──
for (const node of nodes) {
const hovered = hoveredNode === node;
const connected = hoveredNode && edges.some(
e => (nodeMap.get(e.source) === hoveredNode && nodeMap.get(e.target) === node) ||
(nodeMap.get(e.target) === hoveredNode && nodeMap.get(e.source) === node)
);
const dimmed = hoveredNode != null && !hovered && !connected;
const r = node.baseRadius;
if (cardT < 0.05) {
// ── Pure circle mode ──
drawCircleNode(ctx, node, r, hovered, dimmed);
} else if (cardT > 0.95) {
// ── Pure card mode ──
drawCardNode(ctx, node, CARD_W, CARD_H, hovered, dimmed);
} else {
// ── Morphing transition ──
drawMorphNode(ctx, node, r, CARD_W, CARD_H, cardT, hovered, dimmed);
}
}
ctx.restore();
}
// ── Circle rendering (low zoom) ──
function drawCircleNode(
ctx: CanvasRenderingContext2D, node: SimNode, r: number,
hovered: boolean, dimmed: boolean
) {
// Glow
if (hovered) {
const g = ctx.createRadialGradient(node.x, node.y, r, node.x, node.y, r + 14);
g.addColorStop(0, node.color + "40");
g.addColorStop(1, node.color + "00");
ctx.beginPath();
ctx.arc(node.x, node.y, r + 14, 0, Math.PI * 2);
ctx.fillStyle = g;
ctx.fill();
}
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
ctx.fillStyle = dimmed ? node.color + "30" : hovered ? node.color : node.color + "BB";
ctx.fill();
ctx.strokeStyle = hovered ? "#fff" : dimmed ? node.color + "20" : node.color + "60";
ctx.lineWidth = hovered ? 2.5 : 1.5;
ctx.stroke();
// Label
const fs = hovered ? 12 : 10;
ctx.font = `${hovered ? "600" : "400"} ${fs}px Inter, sans-serif`;
ctx.fillStyle = dimmed ? "rgba(232,232,240,0.15)" : hovered ? "#fff" : "rgba(232,232,240,0.7)";
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillText(node.label, node.x, node.y + r + 6, 120);
// Badge
if (node.linkCount > 0 && !dimmed) {
const bx = node.x + r * 0.7, by = node.y - r * 0.7;
ctx.beginPath();
ctx.arc(bx, by, 7, 0, Math.PI * 2);
ctx.fillStyle = "#8b5cf6";
ctx.fill();
ctx.font = "600 7px Inter";
ctx.fillStyle = "#fff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(String(node.linkCount), bx, by);
}
}
// ── Card rendering (high zoom) ──
function drawCardNode(
ctx: CanvasRenderingContext2D, node: SimNode,
w: number, h: number, hovered: boolean, dimmed: boolean
) {
const x = node.x - w / 2;
const y = node.y - h / 2;
const borderR = 12;
// Shadow
if (hovered) {
ctx.shadowColor = node.color + "60";
ctx.shadowBlur = 20;
ctx.shadowOffsetY = 4;
}
// Card background
ctx.beginPath();
ctx.roundRect(x, y, w, h, borderR);
ctx.fillStyle = dimmed ? "rgba(18,18,26,0.4)" : "rgba(18,18,26,0.92)";
ctx.fill();
ctx.strokeStyle = hovered ? node.color : dimmed ? "rgba(42,42,62,0.3)" : "rgba(42,42,62,0.8)";
ctx.lineWidth = hovered ? 2 : 1;
ctx.stroke();
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
ctx.shadowOffsetY = 0;
// Color accent bar at top
ctx.beginPath();
ctx.roundRect(x, y, w, 4, [borderR, borderR, 0, 0]);
ctx.fillStyle = dimmed ? node.color + "30" : node.color;
ctx.fill();
// Title
ctx.font = `600 9px Inter, sans-serif`;
ctx.fillStyle = dimmed ? "rgba(232,232,240,0.2)" : hovered ? "#fff" : "rgba(232,232,240,0.9)";
ctx.textAlign = "left";
ctx.textBaseline = "top";
const title = truncate(node.label, 22);
ctx.fillText(title, x + 8, y + 10);
// Link badge in title area
if (node.linkCount > 0) {
const badgeText = `${node.linkCount} link${node.linkCount > 1 ? "s" : ""}`;
ctx.font = "500 7px Inter, sans-serif";
ctx.fillStyle = dimmed ? node.color + "30" : node.color + "AA";
ctx.textAlign = "right";
ctx.fillText(badgeText, x + w - 8, y + 12);
}
// Preview text
if (node.preview && h > 50) {
ctx.font = "400 7px Inter, sans-serif";
ctx.fillStyle = dimmed ? "rgba(160,160,184,0.15)" : "rgba(160,160,184,0.8)";
ctx.textAlign = "left";
const lines = wrapText(ctx, node.preview, w - 20);
const maxLines = Math.floor((h - 30) / 12);
for (let i = 0; i < Math.min(lines.length, maxLines); i++) {
ctx.fillText(lines[i], x + 10, y + 26 + i * 12, w - 20);
}
}
}
// ── Morphing transition (circle→card) ──
function drawMorphNode(
ctx: CanvasRenderingContext2D, node: SimNode, r: number,
cardW: number, cardH: number, t: number,
hovered: boolean, dimmed: boolean
) {
// Interpolate dimensions
const circleSize = r * 2;
const w = lerp(circleSize, cardW, t);
const h = lerp(circleSize, cardH, t);
const borderR = lerp(r, 12, t);
const x = node.x - w / 2;
const y = node.y - h / 2;
// Background
ctx.beginPath();
ctx.roundRect(x, y, w, h, borderR);
const bgAlpha = lerp(0, 0.92, t);
ctx.fillStyle = dimmed
? `rgba(18,18,26,${bgAlpha * 0.4})`
: `rgba(18,18,26,${bgAlpha})`;
ctx.fill();
// Border / node fill blend
ctx.strokeStyle = hovered ? node.color : dimmed ? node.color + "20" : node.color + "60";
ctx.lineWidth = hovered ? 2 : 1.5;
ctx.stroke();
// Colored fill (fades as card grows)
const fillAlpha = lerp(0.8, 0, t);
if (fillAlpha > 0.01) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, borderR);
ctx.fillStyle = dimmed
? node.color + hex2(fillAlpha * 0.3)
: node.color + hex2(fillAlpha);
ctx.fill();
}
// Color accent bar (fades in)
if (t > 0.3) {
const barAlpha = clamp01((t - 0.3) / 0.3);
ctx.beginPath();
ctx.roundRect(x, y, w, 3 + t, [borderR, borderR, 0, 0]);
ctx.fillStyle = dimmed ? node.color + hex2(barAlpha * 0.3) : node.color + hex2(barAlpha);
ctx.fill();
}
// Title (always visible)
const titleAlpha = dimmed ? 0.2 : hovered ? 1 : 0.85;
const titleSize = lerp(10, 12, t);
ctx.font = `${hovered ? "600" : "500"} ${titleSize}px Inter, sans-serif`;
ctx.fillStyle = `rgba(232,232,240,${titleAlpha})`;
ctx.textAlign = t > 0.5 ? "left" : "center";
ctx.textBaseline = "top";
const titleX = t > 0.5 ? x + 10 : node.x;
const titleY = t > 0.5 ? y + 10 : node.y + h / 2 + 6;
ctx.fillText(truncate(node.label, 22), titleX, titleY, w - 20);
// Preview text (fades in)
if (t > 0.6 && node.preview && h > 40) {
const previewAlpha = clamp01((t - 0.6) / 0.3);
ctx.font = "400 7px Inter, sans-serif";
ctx.fillStyle = `rgba(160,160,184,${previewAlpha * (dimmed ? 0.15 : 0.7)})`;
ctx.textAlign = "left";
const lines = wrapText(ctx, node.preview, w - 24);
const maxLines = Math.floor((h - 32) / 14);
for (let i = 0; i < Math.min(lines.length, maxLines); i++) {
ctx.fillText(lines[i], x + 12, y + 28 + i * 14, w - 24);
}
}
}
// ── Mouse helpers ──
function screenToWorld(sx: number, sy: number) {
const s = stateRef.current;
return { x: (sx - s.panX) / s.zoom, y: (sy - s.panY) / s.zoom };
}
function findNodeAt(wx: number, wy: number): SimNode | null {
const s = stateRef.current;
const nodes = nodesRef.current;
const cardT = clamp01((s.zoom - 1.2) / 1.3);
const CARD_W = 180;
const CARD_H = lerp(36, 90, cardT);
for (let i = nodes.length - 1; i >= 0; i--) {
const n = nodes[i];
if (cardT > 0.3) {
// Card hit test (rectangular)
const w = lerp(n.baseRadius * 2, CARD_W, cardT);
const h = lerp(n.baseRadius * 2, CARD_H, cardT);
const pad = 8;
if (
wx >= n.x - w / 2 - pad && wx <= n.x + w / 2 + pad &&
wy >= n.y - h / 2 - pad && wy <= n.y + h / 2 + pad
) return n;
} else {
// Circle hit test
const dx = wx - n.x, dy = wy - n.y;
const hitR = n.baseRadius + 20;
if (dx * dx + dy * dy <= hitR * hitR) return n;
// Label area
const labelW = n.label.length * 4 + 10;
if (Math.abs(dx) <= labelW && dy >= n.baseRadius && dy <= n.baseRadius + 22) return n;
}
}
return null;
}
// ── Event handlers ──
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const s = stateRef.current;
function onDown(e: MouseEvent) {
const wp = screenToWorld(e.offsetX, e.offsetY);
const node = findNodeAt(wp.x, wp.y);
s.mouseDownPos = { x: e.offsetX, y: e.offsetY };
s.hasDragged = false;
if (node) {
s.dragNode = node;
node.pinned = true;
s.alpha = Math.max(s.alpha, 0.3);
} else {
s.isDragging = true;
}
s.lastMouse = { x: e.offsetX, y: e.offsetY };
}
function onMove(e: MouseEvent) {
const dx = e.offsetX - s.mouseDownPos.x;
const dy = e.offsetY - s.mouseDownPos.y;
if (dx * dx + dy * dy > 16) s.hasDragged = true;
const wp = screenToWorld(e.offsetX, e.offsetY);
if (s.dragNode) {
s.dragNode.x = wp.x;
s.dragNode.y = wp.y;
s.dragNode.vx = 0;
s.dragNode.vy = 0;
s.alpha = Math.max(s.alpha, 0.08);
} else if (s.isDragging) {
s.panX += e.offsetX - s.lastMouse.x;
s.panY += e.offsetY - s.lastMouse.y;
}
s.lastMouse = { x: e.offsetX, y: e.offsetY };
s.hoveredNode = findNodeAt(wp.x, wp.y);
canvas!.style.cursor = s.hoveredNode ? "pointer" : s.isDragging ? "grabbing" : "grab";
}
function onUp() {
if (s.dragNode) {
if (!s.hasDragged) {
// Single click → zoom into the node
const node = s.dragNode;
const targetZoom = Math.min(s.zoom * 2, 12);
const cx = s.W / 2;
const cy = s.H / 2;
// Animate zoom to center on this node
const startZoom = s.zoom;
const startPanX = s.panX;
const startPanY = s.panY;
const endPanX = cx - node.x * targetZoom;
const endPanY = cy - node.y * targetZoom;
const duration = 400;
const t0 = performance.now();
function animateZoom(now: number) {
const elapsed = now - t0;
const p = Math.min(elapsed / duration, 1);
// Ease out cubic
const ease = 1 - Math.pow(1 - p, 3);
s.zoom = startZoom + (targetZoom - startZoom) * ease;
s.panX = startPanX + (endPanX - startPanX) * ease;
s.panY = startPanY + (endPanY - startPanY) * ease;
if (p < 1) requestAnimationFrame(animateZoom);
}
requestAnimationFrame(animateZoom);
}
s.dragNode.pinned = false;
s.dragNode = null;
}
s.isDragging = false;
}
function onDblClick(e: MouseEvent) {
const wp = screenToWorld(e.offsetX, e.offsetY);
const node = findNodeAt(wp.x, wp.y);
if (node) onNodeClick(node.path);
}
function onWheel(e: WheelEvent) {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.96 : 1.04;
s.panX = e.offsetX - (e.offsetX - s.panX) * factor;
s.panY = e.offsetY - (e.offsetY - s.panY) * factor;
s.zoom = Math.max(0.3, Math.min(12, s.zoom * factor));
}
canvas.addEventListener("mousedown", onDown);
canvas.addEventListener("mousemove", onMove);
canvas.addEventListener("mouseup", onUp);
canvas.addEventListener("mouseleave", onUp);
canvas.addEventListener("dblclick", onDblClick);
canvas.addEventListener("wheel", onWheel, { passive: false });
return () => {
canvas.removeEventListener("mousedown", onDown);
canvas.removeEventListener("mousemove", onMove);
canvas.removeEventListener("mouseup", onUp);
canvas.removeEventListener("mouseleave", onUp);
canvas.removeEventListener("dblclick", onDblClick);
canvas.removeEventListener("wheel", onWheel);
};
}, [graphData, onNodeClick]);
return (
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full"
const renderNode = useCallback(({ node, isSelected }: any) => (
<div
className={`graph-canvas-node ${isSelected ? "selected" : ""}`}
style={{
background: "radial-gradient(ellipse at center, #1a1a2e 0%, #0a0a0f 70%)",
background: node.color || "#8b5cf6",
borderColor: isSelected ? "#fff" : "transparent",
}}
/>
>
<span className="graph-canvas-label">{node.label || node.dbData?.label}</span>
</div>
), []);
const handleNodeClick = useCallback((nodeId: string) => {
navigate(`/note/${encodeURIComponent(nodeId)}`);
}, [navigate]);
return (
<JotaiProvider>
<CanvasStyleProvider isDark={true}>
<div className="graph-canvas-wrapper">
<Canvas
renderNode={renderNode}
onNodeClick={handleNodeClick}
minZoom={0.1}
maxZoom={5}
/>
</div>
</CanvasStyleProvider>
</JotaiProvider>
);
}
/* ── Utility functions ──────────────────────────────────────── */
function clamp01(v: number) { return Math.max(0, Math.min(1, v)); }
function lerp(a: number, b: number, t: number) { return a + (b - a) * t; }
function hex2(alpha: number): string {
return Math.round(clamp01(alpha) * 255).toString(16).padStart(2, "0");
}
function truncate(s: string, max: number): string {
return s.length > max ? s.substring(0, max - 1) + "…" : s;
}
function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
const words = text.split(/\s+/);
const lines: string[] = [];
let current = "";
for (const word of words) {
const test = current ? current + " " + word : word;
if (ctx.measureText(test).width > maxWidth && current) {
lines.push(current);
current = word;
} else {
current = test;
}
}
if (current) lines.push(current);
return lines;
}

View file

@ -0,0 +1,138 @@
import { useState, useEffect, useCallback } from "react";
import { useVault } from "../App";
import { listSnapshots, readSnapshot, saveSnapshot, type SnapshotInfo } from "../lib/commands";
/**
* HistoryPanel Timeline of note snapshots with inline diff viewer.
*/
export function HistoryPanel({ onClose }: { onClose: () => void }) {
const { vaultPath, currentNote, noteContent } = useVault();
const [snapshots, setSnapshots] = useState<SnapshotInfo[]>([]);
const [selectedContent, setSelectedContent] = useState<string | null>(null);
const [selectedName, setSelectedName] = useState<string>("");
const [showDiff, setShowDiff] = useState(false);
const loadSnapshots = useCallback(async () => {
if (!vaultPath || !currentNote) return;
try {
const snaps = await listSnapshots(vaultPath, currentNote);
setSnapshots(snaps);
} catch {
setSnapshots([]);
}
}, [vaultPath, currentNote]);
useEffect(() => { loadSnapshots(); }, [loadSnapshots]);
const handleView = async (snap: SnapshotInfo) => {
if (!vaultPath || !currentNote) return;
try {
const content = await readSnapshot(vaultPath, currentNote, snap.filename);
setSelectedContent(content);
setSelectedName(snap.timestamp);
setShowDiff(false);
} catch (e) {
console.error("Failed to read snapshot:", e);
}
};
const handleSnapshot = async () => {
if (!vaultPath || !currentNote) return;
await saveSnapshot(vaultPath, currentNote);
loadSnapshots();
};
const formatTimestamp = (ts: string) => {
// 20260308_203015 → Mar 8, 20:30
const y = ts.slice(0, 4), mo = ts.slice(4, 6), d = ts.slice(6, 8);
const h = ts.slice(9, 11), mi = ts.slice(11, 13);
const date = new Date(+y, +mo - 1, +d, +h, +mi);
return date.toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
};
// Simple diff: lines added/removed
const diffLines = () => {
if (!selectedContent) return [];
const oldLines = selectedContent.split("\n");
const newLines = noteContent.split("\n");
const result: { type: "same" | "add" | "remove"; text: string }[] = [];
const maxLen = Math.max(oldLines.length, newLines.length);
for (let i = 0; i < maxLen; i++) {
const old = oldLines[i];
const cur = newLines[i];
if (old === cur) {
result.push({ type: "same", text: old || "" });
} else {
if (old !== undefined) result.push({ type: "remove", text: old });
if (cur !== undefined) result.push({ type: "add", text: cur });
}
}
return result;
};
return (
<div className="history-panel">
<div className="history-header">
<h3 className="history-title">🕐 History</h3>
<div className="history-actions">
<button className="history-snap-btn" onClick={handleSnapshot}>📸 Snapshot</button>
<button className="history-close" onClick={onClose}></button>
</div>
</div>
<div className="history-body">
{/* Timeline */}
<div className="history-timeline">
{snapshots.length === 0 && (
<div className="history-empty">No snapshots yet</div>
)}
{snapshots.map(snap => (
<button
key={snap.filename}
className={`history-item ${selectedName === snap.timestamp ? "active" : ""}`}
onClick={() => handleView(snap)}
>
<span className="history-dot" />
<div className="history-item-info">
<span className="history-item-time">{formatTimestamp(snap.timestamp)}</span>
<span className="history-item-size">{(snap.size / 1024).toFixed(1)} KB</span>
</div>
</button>
))}
</div>
{/* Viewer */}
{selectedContent !== null && (
<div className="history-viewer">
<div className="history-viewer-header">
<span>{formatTimestamp(selectedName)}</span>
<button
className={`history-diff-toggle ${showDiff ? "active" : ""}`}
onClick={() => setShowDiff(!showDiff)}
>
{showDiff ? "View snapshot" : "Show diff"}
</button>
</div>
<div className="history-viewer-content">
{showDiff ? (
<div className="history-diff">
{diffLines().map((line, i) => (
<div key={i} className={`diff-line diff-${line.type}`}>
<span className="diff-marker">
{line.type === "add" ? "+" : line.type === "remove" ? "" : " "}
</span>
{line.text}
</div>
))}
</div>
) : (
<pre className="history-snapshot-text">{selectedContent}</pre>
)}
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,76 @@
import { useState, useCallback } from "react";
import { useVault } from "../App";
import { exportVaultZip, importFolder } from "../lib/commands";
/**
* ImportExport Import notes from folders, export vault as ZIP.
*/
export function ImportExport({ onClose }: { onClose: () => void }) {
const { vaultPath, refreshNotes } = useVault();
const [status, setStatus] = useState("");
const [loading, setLoading] = useState(false);
const handleExport = useCallback(async () => {
if (!vaultPath) return;
setLoading(true);
try {
const ts = new Date().toISOString().slice(0, 10);
const outputPath = `${vaultPath}/../vault-export-${ts}.zip`;
const result = await exportVaultZip(vaultPath, outputPath);
setStatus(`${result}`);
} catch (e: any) {
setStatus(`❌ Export failed: ${e}`);
}
setLoading(false);
}, [vaultPath]);
const handleImport = useCallback(async () => {
if (!vaultPath) return;
const sourcePath = prompt("Path to folder containing .md files:");
if (!sourcePath?.trim()) return;
setLoading(true);
try {
const count = await importFolder(vaultPath, sourcePath.trim());
setStatus(`✅ Imported ${count} note${count !== 1 ? "s" : ""}`);
refreshNotes();
} catch (e: any) {
setStatus(`❌ Import failed: ${e}`);
}
setLoading(false);
}, [vaultPath, refreshNotes]);
return (
<div className="ie-overlay" onClick={onClose}>
<div className="ie-modal" onClick={e => e.stopPropagation()}>
<div className="ie-header">
<h3 className="ie-title">📦 Import / Export</h3>
<button className="ie-close" onClick={onClose}></button>
</div>
<div className="ie-body">
<div className="ie-section">
<h4 className="ie-section-title">Export Vault</h4>
<p className="ie-desc">Create a ZIP archive of all notes and attachments.</p>
<button className="ie-action-btn" onClick={handleExport} disabled={loading}>
📤 Export as ZIP
</button>
</div>
<div className="ie-divider" />
<div className="ie-section">
<h4 className="ie-section-title">Import Notes</h4>
<p className="ie-desc">
Import <code>.md</code> files from an Obsidian vault, Notion export, or any folder.
</p>
<button className="ie-action-btn" onClick={handleImport} disabled={loading}>
📥 Import from Folder
</button>
</div>
{status && <div className="ie-status">{status}</div>}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,115 @@
import { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useVault } from "../App";
import { listTasks, toggleTask, type TaskItem } from "../lib/commands";
type Column = "todo" | "in-progress" | "done";
const COLUMNS: { id: Column; label: string; icon: string }[] = [
{ id: "todo", label: "To Do", icon: "☐" },
{ id: "in-progress", label: "In Progress", icon: "◐" },
{ id: "done", label: "Done", icon: "✓" },
];
/**
* KanbanView Visual board extracted from `- [ ]` / `- [/]` / `- [x]` items across vault.
*/
export function KanbanView() {
const { vaultPath, refreshNotes } = useVault();
const navigate = useNavigate();
const [tasks, setTasks] = useState<TaskItem[]>([]);
const [loading, setLoading] = useState(true);
const [dragItem, setDragItem] = useState<TaskItem | null>(null);
const loadTasks = useCallback(async () => {
if (!vaultPath) return;
setLoading(true);
try {
const items = await listTasks(vaultPath);
setTasks(items);
} catch {
setTasks([]);
}
setLoading(false);
}, [vaultPath]);
useEffect(() => { loadTasks(); }, [loadTasks]);
const handleDrop = async (column: Column) => {
if (!dragItem || dragItem.state === column) return;
try {
await toggleTask(vaultPath, dragItem.source_path, dragItem.line_number, column);
setTasks(prev => prev.map(t =>
t.source_path === dragItem.source_path && t.line_number === dragItem.line_number
? { ...t, state: column }
: t
));
refreshNotes();
} catch (e) {
console.error("Toggle failed:", e);
}
setDragItem(null);
};
const openSource = (task: TaskItem) => {
navigate(`/note/${encodeURIComponent(task.source_path)}`);
};
if (loading) {
return (
<div className="kanban-view">
<div className="kanban-loading">Loading tasks</div>
</div>
);
}
return (
<div className="kanban-view">
<div className="kanban-header">
<h2 className="kanban-title">📋 Task Board</h2>
<span className="kanban-count">{tasks.length} tasks</span>
<button className="kanban-refresh" onClick={loadTasks}></button>
</div>
<div className="kanban-columns">
{COLUMNS.map(col => {
const colTasks = tasks.filter(t => t.state === col.id);
return (
<div
key={col.id}
className={`kanban-column ${dragItem ? "drop-ready" : ""}`}
onDragOver={e => e.preventDefault()}
onDrop={() => handleDrop(col.id)}
>
<div className="kanban-column-header">
<span className="kanban-column-icon">{col.icon}</span>
<span className="kanban-column-label">{col.label}</span>
<span className="badge badge-muted">{colTasks.length}</span>
</div>
<div className="kanban-column-body">
{colTasks.map((task, i) => (
<div
key={`${task.source_path}-${task.line_number}-${i}`}
className="kanban-card"
draggable
onDragStart={() => setDragItem(task)}
onDragEnd={() => setDragItem(null)}
>
<div className="kanban-card-text">{task.text}</div>
<button
className="kanban-card-source"
onClick={() => openSource(task)}
>
{task.source_path.replace(".md", "")}
</button>
</div>
))}
{colTasks.length === 0 && (
<div className="kanban-empty">No tasks</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,147 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useVault } from "../App";
import { readNotePreview, buildGraph, type GraphData } from "../lib/commands";
interface PreviewState {
visible: boolean;
noteName: string;
notePath: string | null;
content: string;
linkCount: number;
x: number;
y: number;
}
/**
* LinkPreview Floating card that appears when hovering over wikilinks.
* Shows the linked note's title, preview text, and link count.
* Must be rendered inside VaultContext.
*/
export function LinkPreview() {
const { vaultPath, notes } = useVault();
const [preview, setPreview] = useState<PreviewState>({
visible: false, noteName: "", notePath: null, content: "", linkCount: 0, x: 0, y: 0,
});
const hoverTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const graphCacheRef = useRef<GraphData | null>(null);
const cardRef = useRef<HTMLDivElement>(null);
// Cache graph data for link counts
useEffect(() => {
if (!vaultPath) return;
buildGraph(vaultPath).then(data => { graphCacheRef.current = data; }).catch(() => { });
}, [vaultPath, notes]);
const showPreview = useCallback(async (target: string, rect: DOMRect) => {
// Find the note path from name
const allPaths = flattenNotes(notes);
const match = allPaths.find(
p => p.replace(/\.md$/, "").split("/").pop()?.toLowerCase() === target.toLowerCase()
);
if (!match) {
setPreview(p => ({ ...p, visible: false }));
return;
}
try {
const content = await readNotePreview(vaultPath, match, 200);
const graph = graphCacheRef.current;
const nodeData = graph?.nodes.find(n => n.path === match);
const linkCount = nodeData?.link_count || 0;
setPreview({
visible: true,
noteName: target,
notePath: match,
content,
linkCount,
x: rect.left + rect.width / 2,
y: rect.bottom + 8,
});
} catch {
setPreview(p => ({ ...p, visible: false }));
}
}, [vaultPath, notes]);
// Global hover listeners for wikilink tokens
useEffect(() => {
const handleMouseEnter = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.classList.contains("wikilink-token") && !target.classList.contains("wikilink")) return;
const linkTarget = target.dataset.target;
if (!linkTarget) return;
clearTimeout(hideTimerRef.current);
hoverTimerRef.current = setTimeout(() => {
const rect = target.getBoundingClientRect();
showPreview(linkTarget, rect);
}, 350);
};
const handleMouseLeave = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.classList.contains("wikilink-token") && !target.classList.contains("wikilink")) return;
clearTimeout(hoverTimerRef.current);
hideTimerRef.current = setTimeout(() => {
setPreview(p => ({ ...p, visible: false }));
}, 200);
};
document.addEventListener("mouseenter", handleMouseEnter, true);
document.addEventListener("mouseleave", handleMouseLeave, true);
return () => {
document.removeEventListener("mouseenter", handleMouseEnter, true);
document.removeEventListener("mouseleave", handleMouseLeave, true);
clearTimeout(hoverTimerRef.current);
clearTimeout(hideTimerRef.current);
};
}, [showPreview]);
if (!preview.visible) return null;
return (
<div
ref={cardRef}
className="link-preview-card"
style={{
left: Math.max(16, Math.min(preview.x - 140, window.innerWidth - 296)),
top: Math.min(preview.y, window.innerHeight - 160),
}}
onMouseEnter={() => clearTimeout(hideTimerRef.current)}
onMouseLeave={() => {
hideTimerRef.current = setTimeout(() => {
setPreview(p => ({ ...p, visible: false }));
}, 150);
}}
>
<div className="link-preview-header">
<span className="link-preview-icon">📄</span>
<span className="link-preview-title">{preview.noteName}</span>
{preview.linkCount > 0 && (
<span className="badge badge-purple" style={{ fontSize: 9, padding: "1px 5px" }}>
{preview.linkCount} link{preview.linkCount > 1 ? "s" : ""}
</span>
)}
</div>
<div className="link-preview-content">
{preview.content || "Empty note"}
</div>
</div>
);
}
function flattenNotes(entries: { path: string; is_dir: boolean; children?: any[] }[]): string[] {
const paths: string[] = [];
for (const entry of entries) {
if (entry.is_dir && entry.children) {
paths.push(...flattenNotes(entry.children));
} else if (!entry.is_dir) {
paths.push(entry.path);
}
}
return paths;
}

View file

@ -0,0 +1,69 @@
import { useState } from "react";
/**
* LockScreen Password prompt overlay for encrypted notes.
*/
export function LockScreen({
onUnlock,
onCancel,
error,
}: {
onUnlock: (password: string) => void;
onCancel: () => void;
error: string | null;
}) {
const [password, setPassword] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (password.trim()) onUnlock(password);
};
return (
<div className="lock-screen">
<div className="lock-card">
<div className="lock-icon">🔒</div>
<h3 className="lock-title">Encrypted Note</h3>
<p className="lock-desc">Enter password to decrypt</p>
<form onSubmit={handleSubmit} className="lock-form">
<input
type="password"
className="lock-input"
placeholder="Password…"
value={password}
onChange={e => setPassword(e.target.value)}
autoFocus
/>
{error && <div className="lock-error">{error}</div>}
<div className="lock-actions">
<button type="button" className="lock-cancel" onClick={onCancel}>Cancel</button>
<button type="submit" className="lock-submit" disabled={!password.trim()}>Decrypt</button>
</div>
</form>
</div>
</div>
);
}
/**
* LockButton Toggle encryption in editor header.
*/
export function LockButton({
isLocked,
onLock,
onUnlock,
}: {
isLocked: boolean;
onLock: () => void;
onUnlock: () => void;
}) {
return (
<button
className={`lock-btn ${isLocked ? "locked" : ""}`}
onClick={isLocked ? onUnlock : onLock}
title={isLocked ? "Decrypt note" : "Encrypt note"}
>
{isLocked ? "🔒" : "🔓"}
</button>
);
}

View file

@ -0,0 +1,133 @@
import { useEffect, useRef, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useVault } from "../App";
import { extractWikilinks } from "../lib/wikilinks";
/**
* MiniGraph Small canvas showing current note's 1-hop link neighborhood.
*/
export function MiniGraph() {
const { currentNote, noteContent, notes } = useVault();
const navigate = useNavigate();
const canvasRef = useRef<HTMLCanvasElement>(null);
// Build local graph data
const graphData = useMemo(() => {
if (!currentNote || !noteContent) return { nodes: [], edges: [] };
const noteName = currentNote.replace(/\.md$/, "").split("/").pop() || "";
const links = extractWikilinks(noteContent);
const nodeSet = new Set<string>([noteName]);
const edges: { from: string; to: string }[] = [];
// Outgoing links
for (const link of links) {
nodeSet.add(link.target);
edges.push({ from: noteName, to: link.target });
}
// Backlinks: scan all notes for links to current
const flatNotes = flattenNotes(notes);
for (const n of flatNotes) {
const nName = n.replace(/\.md$/, "").split("/").pop() || "";
if (nName !== noteName && !nodeSet.has(nName)) {
// Would need content to check — skip for now, just show outgoing
}
}
const nodesArr = Array.from(nodeSet).map((name, i) => {
const angle = (2 * Math.PI * i) / nodeSet.size;
const r = nodeSet.size > 1 ? 55 : 0;
return {
name,
x: 100 + r * Math.cos(angle),
y: 80 + r * Math.sin(angle),
isCurrent: name === noteName,
};
});
return { nodes: nodesArr, edges };
}, [currentNote, noteContent, notes]);
// Render canvas
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
// Edges
ctx.strokeStyle = "rgba(139, 92, 246, 0.3)";
ctx.lineWidth = 1;
for (const edge of graphData.edges) {
const from = graphData.nodes.find(n => n.name === edge.from);
const to = graphData.nodes.find(n => n.name === edge.to);
if (from && to) {
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
}
}
// Nodes
for (const node of graphData.nodes) {
ctx.beginPath();
ctx.arc(node.x, node.y, node.isCurrent ? 6 : 4, 0, 2 * Math.PI);
ctx.fillStyle = node.isCurrent ? "#a78bfa" : "#52525b";
ctx.fill();
// Label
ctx.fillStyle = node.isCurrent ? "#fafafa" : "#a1a1aa";
ctx.font = "9px Inter, sans-serif";
ctx.textAlign = "center";
const label = node.name.length > 12 ? node.name.slice(0, 11) + "…" : node.name;
ctx.fillText(label, node.x, node.y + 14);
}
}, [graphData]);
const handleClick = (e: React.MouseEvent) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
for (const node of graphData.nodes) {
const dx = node.x - x;
const dy = node.y - y;
if (dx * dx + dy * dy < 100) {
navigate(`/note/${encodeURIComponent(node.name + ".md")}`);
break;
}
}
};
if (graphData.nodes.length <= 1) return null;
return (
<div className="mini-graph-panel">
<div className="mini-graph-label">Local Graph</div>
<canvas
ref={canvasRef}
width={200}
height={160}
className="mini-graph-canvas"
onClick={handleClick}
/>
</div>
);
}
function flattenNotes(entries: any[]): string[] {
const result: string[] = [];
for (const e of entries) {
if (e.is_dir && e.children) result.push(...flattenNotes(e.children));
else if (!e.is_dir) result.push(e.path);
}
return result;
}

View file

@ -1,97 +1,120 @@
import { useMemo } from "react";
import { useState, useEffect, useMemo } from "react";
import { useVault } from "../App";
import { extractHeadings, type HeadingEntry } from "../lib/frontmatter";
interface OutlinePanelProps {
onScrollToLine?: (line: number) => void;
interface Heading {
level: number;
text: string;
line: number;
}
export function OutlinePanel({ onScrollToLine }: OutlinePanelProps) {
const { noteContent, currentNote } = useVault();
/**
* OutlinePanel Collapsible heading tree from current note.
*/
export function OutlinePanel({ content, onScrollTo }: {
content: string;
onScrollTo: (line: number) => void;
}) {
const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
const [activeIdx, setActiveIdx] = useState(0);
const headings = useMemo(() => extractHeadings(noteContent), [noteContent]);
const noteName = currentNote
?.replace(/\.md$/, "")
.split("/")
.pop() || "Untitled";
const headings = useMemo(() => {
const result: Heading[] = [];
content.split("\n").forEach((line, i) => {
const match = line.match(/^(#{1,6})\s+(.+)/);
if (match) {
result.push({
level: match[1].length,
text: match[2].replace(/[*_`\[\]]/g, ""),
line: i + 1,
});
}
});
return result;
}, [content]);
if (!currentNote) return null;
const toggleCollapse = (idx: number) => {
setCollapsed(prev => {
const next = new Set(prev);
next.has(idx) ? next.delete(idx) : next.add(idx);
return next;
});
};
// Determine which headings are visible (not collapsed by parent)
const visibleHeadings = useMemo(() => {
const visible: { heading: Heading; idx: number; hidden: boolean }[] = [];
const collapseStack: number[] = [];
headings.forEach((h, i) => {
// Pop from stack if this heading is at same or higher level
while (collapseStack.length > 0 &&
headings[collapseStack[collapseStack.length - 1]].level >= h.level) {
collapseStack.pop();
}
const hidden = collapseStack.some(ci => collapsed.has(ci));
visible.push({ heading: h, idx: i, hidden });
if (collapsed.has(i)) {
collapseStack.push(i);
}
});
return visible;
}, [headings, collapsed]);
const hasChildren = (idx: number): boolean => {
if (idx >= headings.length - 1) return false;
return headings[idx + 1].level > headings[idx].level;
};
if (headings.length === 0) {
return (
<div className="outline-panel">
<div className="outline-empty">
<p>No headings found</p>
<p className="outline-hint">Add # headings to your note</p>
</div>
</div>
);
}
return (
<div className="outline-panel">
<div className="outline-header">
<button className="outline-title">📑 Outline</button>
<span className="outline-title">Outline</span>
<span className="badge badge-purple">{headings.length}</span>
</div>
<div className="outline-tree">
{visibleHeadings.map(({ heading, idx, hidden }) => {
if (hidden) return null;
const indent = (heading.level - 1) * 14;
const canCollapse = hasChildren(idx);
const isCollapsed = collapsed.has(idx);
{headings.length === 0 ? (
<div className="outline-empty">
No headings found in this note
</div>
) : (
<nav className="outline-list">
{headings.map((h, i) => (
<button
key={`${h.line}-${i}`}
className={`outline-item outline-h${Math.min(h.level, 3)}`}
onClick={() => scrollToHeading(h, onScrollToLine)}
title={`Line ${h.line}`}
return (
<div
key={idx}
className={`outline-item ${activeIdx === idx ? "active" : ""}`}
style={{ paddingLeft: 8 + indent }}
onClick={() => { setActiveIdx(idx); onScrollTo(heading.line); }}
>
<span className="outline-marker">
{"#".repeat(h.level)}
{canCollapse && (
<button
className="outline-collapse-btn"
onClick={(e) => { e.stopPropagation(); toggleCollapse(idx); }}
>
{isCollapsed ? "▸" : "▾"}
</button>
)}
<span className="outline-heading-marker">
{"#".repeat(heading.level)}
</span>
<span className="outline-text">{h.text}</span>
</button>
))}
</nav>
)}
<div className="outline-meta">
<span>{headings.length} heading{headings.length !== 1 ? "s" : ""}</span>
<span>·</span>
<span>{countByLevel(headings)}</span>
<span className="outline-heading-text">{heading.text}</span>
</div>
);
})}
</div>
</div>
);
}
function scrollToHeading(heading: HeadingEntry, onScrollToLine?: (line: number) => void) {
if (onScrollToLine) {
onScrollToLine(heading.line);
return;
}
// Fallback: find the heading text in the contenteditable or preview
const target = heading.text;
const editor = document.querySelector(".editor-ce") || document.querySelector(".markdown-preview");
if (!editor) return;
// Search through DOM text for the heading text
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) {
if (node.textContent?.includes(target)) {
const parent = node.parentElement;
if (parent) {
parent.scrollIntoView({ behavior: "smooth", block: "center" });
// Brief highlight
parent.style.transition = "background 300ms";
parent.style.background = "rgba(139, 92, 246, 0.15)";
setTimeout(() => {
parent.style.background = "";
}, 1500);
}
break;
}
}
}
function countByLevel(headings: HeadingEntry[]): string {
const counts = new Map<number, number>();
for (const h of headings) {
counts.set(h.level, (counts.get(h.level) || 0) + 1);
}
return Array.from(counts.entries())
.sort((a, b) => a[0] - b[0])
.map(([level, count]) => `H${level}:${count}`)
.join(" ");
}

View file

@ -0,0 +1,127 @@
import { useState, useEffect, useCallback } from "react";
import { useVault } from "../App";
import { parseFrontmatter, writeFrontmatter } from "../lib/commands";
/**
* PropertiesPanel Collapsible YAML frontmatter metadata editor.
* Parses `---` fenced YAML, displays key-value pairs, supports inline editing.
*/
export function PropertiesPanel() {
const { vaultPath, currentNote } = useVault();
const [properties, setProperties] = useState<Record<string, string>>({});
const [isOpen, setIsOpen] = useState(false);
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [newKey, setNewKey] = useState("");
const [newValue, setNewValue] = useState("");
// Load frontmatter when note changes
useEffect(() => {
if (!vaultPath || !currentNote) {
setProperties({});
return;
}
parseFrontmatter(vaultPath, currentNote)
.then(data => setProperties(data as Record<string, string>))
.catch(() => setProperties({}));
}, [vaultPath, currentNote]);
const save = useCallback(async (updated: Record<string, string>) => {
if (!vaultPath || !currentNote) return;
setProperties(updated);
try {
await writeFrontmatter(vaultPath, currentNote, updated);
} catch (e) {
console.error("Failed to save frontmatter:", e);
}
}, [vaultPath, currentNote]);
const handleEditStart = (key: string) => {
setEditingKey(key);
setEditValue(properties[key] || "");
};
const handleEditSave = () => {
if (!editingKey) return;
const updated = { ...properties, [editingKey]: editValue };
save(updated);
setEditingKey(null);
};
const handleDelete = (key: string) => {
const updated = { ...properties };
delete updated[key];
save(updated);
};
const handleAddProperty = () => {
if (!newKey.trim()) return;
const updated = { ...properties, [newKey.trim()]: newValue.trim() };
save(updated);
setNewKey("");
setNewValue("");
};
const hasProperties = Object.keys(properties).length > 0;
if (!currentNote) return null;
return (
<div className="properties-panel">
<button className="properties-toggle" onClick={() => setIsOpen(!isOpen)}>
<span className="properties-chevron" style={{ transform: isOpen ? "rotate(0)" : "rotate(-90deg)" }}></span>
<span className="properties-label">
Properties
{hasProperties && <span className="badge badge-purple" style={{ fontSize: 9, marginLeft: 6 }}>{Object.keys(properties).length}</span>}
</span>
</button>
{isOpen && (
<div className="properties-body">
{Object.entries(properties).map(([key, value]) => (
<div key={key} className="property-row">
<span className="property-key">{key}</span>
{editingKey === key ? (
<input
className="property-input"
value={editValue}
autoFocus
onChange={e => setEditValue(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") handleEditSave();
if (e.key === "Escape") setEditingKey(null);
}}
onBlur={handleEditSave}
/>
) : (
<span className="property-value" onClick={() => handleEditStart(key)}>
{value || <em className="text-muted">empty</em>}
</span>
)}
<button className="property-delete" onClick={() => handleDelete(key)} title="Remove"></button>
</div>
))}
{/* Add new property */}
<div className="property-add">
<input
className="property-input"
placeholder="key"
value={newKey}
onChange={e => setNewKey(e.target.value)}
onKeyDown={e => { if (e.key === "Enter") handleAddProperty(); }}
/>
<input
className="property-input"
placeholder="value"
value={newValue}
onChange={e => setNewValue(e.target.value)}
onKeyDown={e => { if (e.key === "Enter") handleAddProperty(); }}
/>
<button className="property-add-btn" onClick={handleAddProperty}>+</button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,104 @@
import { useState } from "react";
import { useVault } from "../App";
import { extractToNote, mergeNotes } from "../lib/commands";
/**
* RefactorMenu Context menu for note refactoring operations.
*/
export function RefactorMenu({
visible,
position,
selectedText,
onClose,
}: {
visible: boolean;
position: { top: number; left: number };
selectedText: string;
onClose: () => void;
}) {
const { vaultPath, currentNote, notes, navigateToNote, refreshNotes } = useVault();
const [showMerge, setShowMerge] = useState(false);
const [mergeSearch, setMergeSearch] = useState("");
if (!visible) return null;
const handleExtract = async () => {
const name = prompt("New note name:");
if (!name?.trim() || !currentNote) return;
try {
const newPath = await extractToNote(vaultPath, currentNote, selectedText, name.trim());
refreshNotes();
navigateToNote(name.trim());
onClose();
} catch (e) {
alert(`Extract failed: ${e}`);
}
};
const handleMerge = async (targetPath: string) => {
if (!currentNote) return;
const confirm = window.confirm(`Merge "${currentNote.replace(".md", "")}" INTO "${targetPath.replace(".md", "")}"? Source will be deleted.`);
if (!confirm) return;
try {
await mergeNotes(vaultPath, currentNote, targetPath);
refreshNotes();
navigateToNote(targetPath.replace(".md", ""));
onClose();
} catch (e) {
alert(`Merge failed: ${e}`);
}
};
const flatNotes = flattenPaths(notes).filter(p => p !== currentNote);
const filtered = mergeSearch
? flatNotes.filter(p => p.toLowerCase().includes(mergeSearch.toLowerCase()))
: flatNotes.slice(0, 10);
return (
<div className="refactor-menu" style={{ top: position.top, left: position.left }}>
{!showMerge ? (
<>
{selectedText && (
<button className="refactor-item" onClick={handleExtract}>
<span className="refactor-icon"></span>
Extract to new note
</button>
)}
<button className="refactor-item" onClick={() => setShowMerge(true)}>
<span className="refactor-icon">🔗</span>
Merge into
</button>
</>
) : (
<div className="refactor-merge">
<input
className="refactor-search"
placeholder="Search target note…"
value={mergeSearch}
onChange={e => setMergeSearch(e.target.value)}
autoFocus
/>
<div className="refactor-merge-list">
{filtered.map(p => (
<button key={p} className="refactor-merge-item" onClick={() => handleMerge(p)}>
{p.replace(".md", "")}
</button>
))}
</div>
<button className="refactor-back" onClick={() => { setShowMerge(false); setMergeSearch(""); }}>
Back
</button>
</div>
)}
</div>
);
}
function flattenPaths(entries: any[]): string[] {
const result: string[] = [];
for (const e of entries) {
if (e.is_dir && e.children) result.push(...flattenPaths(e.children));
else if (!e.is_dir) result.push(e.path);
}
return result;
}

View file

@ -0,0 +1,101 @@
import { useState, useCallback } from "react";
import { useVault } from "../App";
import { searchReplaceVault, type ReplaceResult } from "../lib/commands";
/**
* SearchReplace Global find & replace across the vault with preview.
*/
export function SearchReplace({ open, onClose }: { open: boolean; onClose: () => void }) {
const { vaultPath, refreshNotes } = useVault();
const [search, setSearch] = useState("");
const [replace, setReplace] = useState("");
const [results, setResults] = useState<ReplaceResult[]>([]);
const [previewed, setPreviewed] = useState(false);
const [applied, setApplied] = useState(false);
const handlePreview = useCallback(async () => {
if (!search.trim() || !vaultPath) return;
try {
const res = await searchReplaceVault(vaultPath, search, replace, true);
setResults(res);
setPreviewed(true);
setApplied(false);
} catch (e) {
console.error("Search failed:", e);
}
}, [vaultPath, search, replace]);
const handleApply = useCallback(async () => {
if (!search.trim() || !vaultPath) return;
try {
await searchReplaceVault(vaultPath, search, replace, false);
setApplied(true);
refreshNotes();
} catch (e) {
console.error("Replace failed:", e);
}
}, [vaultPath, search, replace, refreshNotes]);
const totalMatches = results.reduce((sum, r) => sum + r.count, 0);
if (!open) return null;
return (
<div className="sr-backdrop" onClick={onClose}>
<div className="sr-modal" onClick={e => e.stopPropagation()}>
<h3 className="sr-title">🔍 Search & Replace</h3>
<div className="sr-inputs">
<div className="sr-field">
<label className="sr-label">Find</label>
<input
className="sr-input"
value={search}
onChange={e => { setSearch(e.target.value); setPreviewed(false); setApplied(false); }}
placeholder="Search text…"
autoFocus
/>
</div>
<div className="sr-field">
<label className="sr-label">Replace</label>
<input
className="sr-input"
value={replace}
onChange={e => { setReplace(e.target.value); setPreviewed(false); setApplied(false); }}
placeholder="Replacement…"
/>
</div>
</div>
<div className="sr-actions">
<button className="sr-btn sr-preview-btn" onClick={handlePreview} disabled={!search.trim()}>
Preview
</button>
{previewed && !applied && (
<button className="sr-btn sr-apply-btn" onClick={handleApply}>
Replace All ({totalMatches} in {results.length} files)
</button>
)}
</div>
{previewed && (
<div className="sr-results">
{results.length === 0 ? (
<div className="sr-empty">No matches found</div>
) : (
<>
{applied && <div className="sr-success"> Replaced {totalMatches} occurrences</div>}
{results.map(r => (
<div key={r.path} className="sr-result-row">
<span className="sr-result-path">{r.path}</span>
<span className="sr-result-count">{r.count} match{r.count !== 1 ? "es" : ""}</span>
</div>
))}
</>
)}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,122 @@
import { useState, useEffect, useCallback } from "react";
import { useVault } from "../App";
import { saveShortcuts, loadShortcuts } from "../lib/commands";
interface Shortcut {
id: string;
label: string;
keys: string;
defaultKeys: string;
}
const DEFAULT_SHORTCUTS: Shortcut[] = [
{ id: "cmd-palette", label: "Command Palette", keys: "Ctrl+K", defaultKeys: "Ctrl+K" },
{ id: "new-note", label: "New Note", keys: "Ctrl+N", defaultKeys: "Ctrl+N" },
{ id: "save", label: "Save", keys: "Ctrl+S", defaultKeys: "Ctrl+S" },
{ id: "search", label: "Search", keys: "Ctrl+F", defaultKeys: "Ctrl+F" },
{ id: "graph", label: "Graph View", keys: "Ctrl+G", defaultKeys: "Ctrl+G" },
{ id: "daily", label: "Daily Note", keys: "Ctrl+D", defaultKeys: "Ctrl+D" },
{ id: "sidebar", label: "Toggle Sidebar", keys: "Ctrl+B", defaultKeys: "Ctrl+B" },
{ id: "focus", label: "Focus Mode", keys: "Ctrl+Shift+F", defaultKeys: "Ctrl+Shift+F" },
{ id: "split", label: "Split View", keys: "Ctrl+\\", defaultKeys: "Ctrl+\\" },
{ id: "close-tab", label: "Close Tab", keys: "Ctrl+W", defaultKeys: "Ctrl+W" },
{ id: "search-replace", label: "Search & Replace", keys: "Ctrl+H", defaultKeys: "Ctrl+H" },
{ id: "export-pdf", label: "Export PDF", keys: "Ctrl+Shift+E", defaultKeys: "Ctrl+Shift+E" },
];
/**
* ShortcutsEditor View and customize keyboard shortcuts.
*/
export function ShortcutsEditor({ onClose }: { onClose: () => void }) {
const { vaultPath } = useVault();
const [shortcuts, setShortcuts] = useState<Shortcut[]>(DEFAULT_SHORTCUTS);
const [recording, setRecording] = useState<string | null>(null);
useEffect(() => {
if (!vaultPath) return;
loadShortcuts(vaultPath).then(json => {
try {
const overrides = JSON.parse(json);
setShortcuts(prev => prev.map(s => ({
...s,
keys: overrides[s.id] || s.defaultKeys,
})));
} catch { }
});
}, [vaultPath]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (!recording) return;
e.preventDefault();
e.stopPropagation();
const parts: string[] = [];
if (e.ctrlKey || e.metaKey) parts.push("Ctrl");
if (e.shiftKey) parts.push("Shift");
if (e.altKey) parts.push("Alt");
if (!["Control", "Shift", "Alt", "Meta"].includes(e.key)) {
parts.push(e.key.length === 1 ? e.key.toUpperCase() : e.key);
}
if (parts.length > 1 || (!e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey)) {
const combo = parts.join("+");
setShortcuts(prev => prev.map(s =>
s.id === recording ? { ...s, keys: combo } : s
));
setRecording(null);
}
}, [recording]);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [handleKeyDown]);
const save = useCallback(async () => {
if (!vaultPath) return;
const overrides: Record<string, string> = {};
shortcuts.forEach(s => {
if (s.keys !== s.defaultKeys) overrides[s.id] = s.keys;
});
await saveShortcuts(vaultPath, JSON.stringify(overrides, null, 2));
}, [vaultPath, shortcuts]);
const reset = (id: string) => {
setShortcuts(prev => prev.map(s =>
s.id === id ? { ...s, keys: s.defaultKeys } : s
));
};
return (
<div className="shortcuts-overlay" onClick={onClose}>
<div className="shortcuts-modal" onClick={e => e.stopPropagation()}>
<div className="shortcuts-header">
<h3 className="shortcuts-title"> Keyboard Shortcuts</h3>
<div className="shortcuts-header-actions">
<button className="shortcuts-save" onClick={save}>Save</button>
<button className="shortcuts-close" onClick={onClose}></button>
</div>
</div>
<div className="shortcuts-list">
{shortcuts.map(s => (
<div key={s.id} className={`shortcut-row ${recording === s.id ? "recording" : ""}`}>
<span className="shortcut-label">{s.label}</span>
<div className="shortcut-keys-area">
<button
className={`shortcut-keys ${recording === s.id ? "listening" : ""}`}
onClick={() => setRecording(recording === s.id ? null : s.id)}
>
{recording === s.id ? "Press keys…" : s.keys}
</button>
{s.keys !== s.defaultKeys && (
<button className="shortcut-reset" onClick={() => reset(s.id)}></button>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}

View file

@ -1,47 +1,68 @@
import { useState, useMemo, useEffect, useCallback } from "react";
import { useState, useMemo, useEffect, useRef } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useVault } from "../App";
import { writeNote, searchVault, renameNote, deleteNote } from "../lib/commands";
import type { NoteEntry, SearchResult } from "../lib/commands";
import { ContextMenu, useContextMenu } from "./ContextMenu";
import { writeNote, deleteNote, renameNote, updateWikilinks, searchVault, listTags, listRecentVaults } from "../lib/commands";
import type { NoteEntry, SearchResult, TagInfo } from "../lib/commands";
export function Sidebar() {
const { notes, vaultPath, refreshNotes } = useVault();
const { notes, vaultPath, refreshNotes, favorites, toggleFavorite, recentNotes, setSplitNote, switchVault } = useVault();
const [search, setSearch] = useState("");
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [tags, setTags] = useState<TagInfo[]>([]);
const [activeTag, setActiveTag] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; entry: NoteEntry } | null>(null);
const [renamingPath, setRenamingPath] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [vaultDropdownOpen, setVaultDropdownOpen] = useState(false);
const [recentVaults, setRecentVaults] = useState<string[]>([]);
const [dragOverPath, setDragOverPath] = useState<string | null>(null);
const navigate = useNavigate();
const location = useLocation();
const { menuPos, menuTarget, openMenu, closeMenu } = useContextMenu();
const filteredNotes = useMemo(() => {
if (!search.trim()) return notes;
return filterNotes(notes, search.toLowerCase());
}, [notes, search]);
// Full-text search with debounce
// Load recent vaults
useEffect(() => {
if (!search.trim() || search.trim().length < 2) {
listRecentVaults().then(setRecentVaults).catch(() => setRecentVaults([]));
}, [vaultPath]);
// Debounced full-text search
useEffect(() => {
if (!search.trim() || search.length < 2 || !vaultPath) {
setSearchResults([]);
return;
}
setIsSearching(true);
const timer = setTimeout(async () => {
try {
const results = await searchVault(vaultPath, search.trim());
setSearchResults(results);
const results = await searchVault(vaultPath, search);
setSearchResults(results.slice(0, 15));
} catch {
setSearchResults([]);
} finally {
setIsSearching(false);
}
}, 300);
return () => clearTimeout(timer);
}, [search, vaultPath]);
// Load tags
useEffect(() => {
if (!vaultPath) return;
listTags(vaultPath).then(setTags).catch(() => setTags([]));
}, [vaultPath, notes]);
const filteredNotes = useMemo(() => {
let filtered = notes;
if (search.trim()) {
filtered = filterNotes(filtered, search.toLowerCase());
}
if (activeTag) {
// Find which notes have this tag
const tagInfo = tags.find(t => t.tag === activeTag);
if (tagInfo) {
filtered = filterByPaths(filtered, new Set(tagInfo.notes));
}
}
return filtered;
}, [notes, search, activeTag, tags]);
const handleCreateNote = async () => {
const name = prompt("Note name:");
if (!name?.trim()) return;
@ -60,59 +81,164 @@ export function Sidebar() {
});
};
const handleRename = useCallback(async (notePath: string) => {
const oldName = notePath.replace(/\.md$/, "").split("/").pop() || "";
const newName = prompt("Rename note:", oldName);
if (!newName?.trim() || newName.trim() === oldName) return;
try {
const newPath = await renameNote(vaultPath, notePath, newName.trim());
await refreshNotes();
// Navigate to renamed note
navigate(`/note/${encodeURIComponent(newPath)}`);
} catch (e) {
alert(`Rename failed: ${e}`);
}
}, [vaultPath, refreshNotes, navigate]);
// Context menu handlers
const handleContextMenu = (e: React.MouseEvent, entry: NoteEntry) => {
if (entry.is_dir) return;
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, entry });
};
const handleDelete = async () => {
if (!contextMenu) return;
const { entry } = contextMenu;
setContextMenu(null);
const confirm = window.confirm(`Delete "${entry.name}"? This cannot be undone.`);
if (!confirm) return;
const handleDelete = useCallback(async (notePath: string) => {
const name = notePath.replace(/\.md$/, "").split("/").pop() || notePath;
if (!confirm(`Delete "${name}"? This cannot be undone.`)) return;
try {
await deleteNote(vaultPath, notePath);
await deleteNote(vaultPath, entry.path);
await refreshNotes();
// Navigate away if we deleted the current note
if (location.pathname === `/note/${encodeURIComponent(notePath)}`) {
// If deleted note is current, go home
if (location.pathname === `/note/${encodeURIComponent(entry.path)}`) {
navigate("/");
}
} catch (e) {
alert(`Delete failed: ${e}`);
console.error("Delete failed:", e);
}
}, [vaultPath, refreshNotes, navigate, location]);
};
const contextMenuItems = menuTarget ? [
{ label: "Rename", icon: "✏️", action: () => handleRename(menuTarget) },
{ label: "Delete", icon: "🗑️", action: () => handleDelete(menuTarget), danger: true },
] : [];
const handleRename = () => {
if (!contextMenu) return;
const { entry } = contextMenu;
setContextMenu(null);
setRenamingPath(entry.path);
setRenameValue(entry.name);
};
const hasContentResults = search.trim().length >= 2 && searchResults.length > 0;
const submitRename = async () => {
if (!renamingPath || !renameValue.trim()) {
setRenamingPath(null);
return;
}
const oldName = renamingPath.replace(/\.md$/, "").split("/").pop() || "";
const newName = renameValue.trim();
if (oldName === newName) {
setRenamingPath(null);
return;
}
const dir = renamingPath.includes("/")
? renamingPath.substring(0, renamingPath.lastIndexOf("/") + 1)
: "";
const newPath = `${dir}${newName}.md`;
try {
await renameNote(vaultPath, renamingPath, newPath);
await updateWikilinks(vaultPath, oldName, newName);
await refreshNotes();
// If renamed note is current, navigate to new path
if (location.pathname === `/note/${encodeURIComponent(renamingPath)}`) {
navigate(`/note/${encodeURIComponent(newPath)}`, { replace: true });
}
} catch (e) {
console.error("Rename failed:", e);
alert(`Rename failed: ${e}`);
}
setRenamingPath(null);
};
// Close context menu on click anywhere
useEffect(() => {
if (!contextMenu) return;
const close = () => setContextMenu(null);
window.addEventListener("click", close);
return () => window.removeEventListener("click", close);
}, [contextMenu]);
// Drag & drop handler
const handleDrop = async (targetDir: string, sourcePath: string) => {
setDragOverPath(null);
if (!sourcePath || sourcePath === targetDir) return;
const fileName = sourcePath.split("/").pop() || "";
const newPath = targetDir ? `${targetDir}/${fileName}` : fileName;
if (newPath === sourcePath) return;
try {
const oldName = sourcePath.replace(/\.md$/, "").split("/").pop() || "";
await renameNote(vaultPath, sourcePath, newPath);
await updateWikilinks(vaultPath, oldName, oldName);
await refreshNotes();
if (location.pathname === `/note/${encodeURIComponent(sourcePath)}`) {
navigate(`/note/${encodeURIComponent(newPath)}`, { replace: true });
}
} catch (e) {
console.error("Move failed:", e);
}
};
const vaultName = vaultPath.split("/").pop() || "vault";
return (
<aside className="sidebar">
{/* ── Brand + Search ── */}
{/* ── Brand + Vault Switcher ── */}
<div className="sidebar-header">
<div className="sidebar-brand">
<div className="sidebar-brand-icon">📝</div>
<span className="sidebar-brand-text">GRAPH NOTES</span>
<div
className="sidebar-brand-clickable"
onClick={() => setVaultDropdownOpen(v => !v)}
>
<div className="sidebar-brand-icon">📝</div>
<div>
<span className="sidebar-brand-text">GRAPH NOTES</span>
<span className="sidebar-vault-name">{vaultName}</span>
</div>
</div>
<div style={{ flex: 1 }} />
<button
className="sidebar-new-btn"
onClick={handleCreateNote}
title="New note (Ctrl+N)"
title="New note (N)"
>
+
</button>
</div>
{/* Vault Dropdown */}
{vaultDropdownOpen && (
<div className="vault-dropdown">
<div className="vault-dropdown-label">Switch vault</div>
{recentVaults.filter(v => v !== vaultPath).map(v => (
<button
key={v}
className="vault-dropdown-item"
onClick={() => { switchVault(v); setVaultDropdownOpen(false); }}
>
📁 {v.split("/").pop()}
<span className="vault-dropdown-path">{v}</span>
</button>
))}
{recentVaults.filter(v => v !== vaultPath).length === 0 && (
<div className="vault-dropdown-empty">No other vaults</div>
)}
<button
className="vault-dropdown-item vault-dropdown-add"
onClick={async () => {
const path = prompt("Enter vault directory path:");
if (path?.trim()) {
await switchVault(path.trim());
setVaultDropdownOpen(false);
}
}}
>
Open folder...
</button>
</div>
)}
<div className="sidebar-search">
<svg className="sidebar-search-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="11" cy="11" r="8" />
@ -120,7 +246,7 @@ export function Sidebar() {
</svg>
<input
type="text"
placeholder="Search notes & content..."
placeholder="Search notes... (⌘K)"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
@ -135,6 +261,7 @@ export function Sidebar() {
>
<span className="sidebar-action-icon">📅</span>
Daily Note
<span className="shortcut-hint">D</span>
</button>
<button
className={`sidebar-action ${location.pathname === "/graph" ? "active" : ""}`}
@ -142,19 +269,85 @@ export function Sidebar() {
>
<span className="sidebar-action-icon">🔮</span>
Graph View
<span className="shortcut-hint">G</span>
</button>
<button
className={`sidebar-action ${location.pathname === "/calendar" ? "active" : ""}`}
onClick={() => navigate("/calendar")}
>
<span className="sidebar-action-icon">📅</span>
Calendar
</button>
<button
className={`sidebar-action ${location.pathname === "/kanban" ? "active" : ""}`}
onClick={() => navigate("/kanban")}
>
<span className="sidebar-action-icon">📋</span>
Kanban Board
</button>
<button
className={`sidebar-action ${location.pathname === "/flashcards" ? "active" : ""}`}
onClick={() => navigate("/flashcards")}
>
<span className="sidebar-action-icon">🎴</span>
Flashcards
</button>
<button
className={`sidebar-action ${location.pathname === "/database" ? "active" : ""}`}
onClick={() => navigate("/database")}
>
<span className="sidebar-action-icon">📊</span>
Database
</button>
<button
className="sidebar-action"
onClick={() => {
const name = prompt("Canvas name:");
if (name?.trim()) navigate(`/whiteboard/${encodeURIComponent(name.trim())}`);
}}
>
<span className="sidebar-action-icon">🎨</span>
Whiteboard
</button>
<button
className={`sidebar-action ${location.pathname === "/timeline" ? "active" : ""}`}
onClick={() => navigate("/timeline")}
>
<span className="sidebar-action-icon">📅</span>
Timeline
</button>
<button
className="sidebar-action"
onClick={async () => {
try {
const { randomNote } = await import("../lib/commands");
const name = await randomNote(vaultPath);
navigate(`/note/${encodeURIComponent(name)}`);
} catch { }
}}
>
<span className="sidebar-action-icon">🎲</span>
Random Note
</button>
<button
className={`sidebar-action ${location.pathname === "/analytics" ? "active" : ""}`}
onClick={() => navigate("/analytics")}
>
<span className="sidebar-action-icon">📊</span>
Analytics
</button>
</div>
{/* ── Content Search Results ── */}
{hasContentResults && (
{/* ── Search Results ── */}
{search.trim().length >= 2 && searchResults.length > 0 && (
<div className="sidebar-search-results">
<div className="sidebar-section-label">
<span>Content Matches</span>
<span>Search Results</span>
<span className="badge badge-purple">{searchResults.length}</span>
</div>
{searchResults.slice(0, 10).map((result, i) => (
{searchResults.map((result, i) => (
<button
key={`${result.path}-${result.line_number}-${i}`}
key={`${result.path}-${i}`}
className="search-result-item"
onClick={() => navigate(`/note/${encodeURIComponent(result.path)}`)}
>
@ -164,9 +357,74 @@ export function Sidebar() {
))}
</div>
)}
{isSearching && search.trim().length >= 2 && (
<div style={{ padding: "8px 16px", fontSize: 11, color: "var(--text-muted)" }}>
Searching...
{/* ── Tags ── */}
{tags.length > 0 && (
<div className="sidebar-tags">
<div className="sidebar-section-label">
<span>Tags</span>
<span className="badge badge-muted">{tags.length}</span>
</div>
<div className="tag-list">
{activeTag && (
<button
className="tag-chip tag-chip-clear"
onClick={() => setActiveTag(null)}
>
Clear
</button>
)}
{tags.slice(0, 20).map(tag => (
<button
key={tag.tag}
className={`tag-chip ${activeTag === tag.tag ? "active" : ""}`}
onClick={() => setActiveTag(activeTag === tag.tag ? null : tag.tag)}
>
{tag.tag}
<span className="tag-count">{tag.count}</span>
</button>
))}
</div>
</div>
)}
{/* ── Favorites ── */}
{favorites.length > 0 && (
<div className="sidebar-favorites">
<div className="sidebar-section-label">
<span> Favorites</span>
</div>
{favorites.slice(0, 10).map(fav => (
<button
key={fav}
className={`tree-item ${location.pathname === `/note/${encodeURIComponent(fav)}` ? "active" : ""}`}
style={{ paddingLeft: "16px" }}
onClick={() => navigate(`/note/${encodeURIComponent(fav)}`)}
>
<span className="tree-item-icon">📌</span>
<span className="tree-item-label">{fav.replace(/\.md$/, "").split("/").pop()}</span>
</button>
))}
</div>
)}
{/* ── Recent Notes ── */}
{recentNotes.length > 0 && (
<div className="sidebar-recents">
<div className="sidebar-section-label">
<span>🕓 Recent</span>
</div>
{recentNotes.slice(0, 5).map(note => (
<button
key={note}
className={`tree-item ${location.pathname === `/note/${encodeURIComponent(note)}` ? "active" : ""}`}
style={{ paddingLeft: "16px" }}
onClick={() => navigate(`/note/${encodeURIComponent(note)}`)}
>
<span className="tree-item-icon">📄</span>
<span className="tree-item-label">{note.replace(/\.md$/, "").split("/").pop()}</span>
</button>
))}
</div>
)}
@ -180,47 +438,112 @@ export function Sidebar() {
entries={filteredNotes}
collapsed={collapsed}
onToggle={toggleFolder}
onContextMenu={openMenu}
onContextMenu={handleContextMenu}
renamingPath={renamingPath}
renameValue={renameValue}
setRenameValue={setRenameValue}
submitRename={submitRename}
onDrop={handleDrop}
dragOverPath={dragOverPath}
setDragOverPath={setDragOverPath}
depth={0}
/>
{filteredNotes.length === 0 && (
<p style={{ padding: "24px 16px", fontSize: 11, color: "var(--text-muted)", textAlign: "center" }}>
<p
style={{
padding: "24px 16px",
fontSize: 11,
color: "var(--text-muted)",
textAlign: "center",
}}
>
{search ? "No matching notes" : "No notes yet"}
</p>
)}
</div>
<ContextMenu items={contextMenuItems} position={menuPos} onClose={closeMenu} />
{/* ── Context Menu ── */}
{contextMenu && (
<div
className="context-menu"
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={e => e.stopPropagation()}
>
<button className="context-menu-item" onClick={handleRename}>
Rename
</button>
<button className="context-menu-item" onClick={() => {
if (contextMenu) {
toggleFavorite(contextMenu.entry.path);
setContextMenu(null);
}
}}>
{contextMenu && favorites.includes(contextMenu.entry.path) ? "⭐ Unfavorite" : "☆ Favorite"}
</button>
<button className="context-menu-item" onClick={() => {
if (contextMenu) {
setSplitNote(contextMenu.entry.path);
setContextMenu(null);
}
}}>
Open in split
</button>
<div className="context-menu-divider" />
<button className="context-menu-item context-menu-danger" onClick={handleDelete}>
🗑 Delete
</button>
</div>
)}
</aside>
);
}
/* ── Recursive File Tree ────────────────────────────────────── */
function NoteTree({
entries, collapsed, onToggle, onContextMenu, depth,
entries, collapsed, onToggle, onContextMenu, renamingPath, renameValue, setRenameValue, submitRename, onDrop, dragOverPath, setDragOverPath, depth,
}: {
entries: NoteEntry[];
collapsed: Set<string>;
onToggle: (path: string) => void;
onContextMenu: (e: React.MouseEvent, target: string) => void;
onContextMenu: (e: React.MouseEvent, entry: NoteEntry) => void;
renamingPath: string | null;
renameValue: string;
setRenameValue: (v: string) => void;
submitRename: () => void;
onDrop: (targetDir: string, sourcePath: string) => void;
dragOverPath: string | null;
setDragOverPath: (path: string | null) => void;
depth: number;
}) {
const navigate = useNavigate();
const location = useLocation();
const renameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (renamingPath) renameInputRef.current?.focus();
}, [renamingPath]);
return (
<ul style={{ listStyle: "none" }}>
{entries.map((entry) => {
const isActive = location.pathname === `/note/${encodeURIComponent(entry.path)}`;
const isCollapsed = collapsed.has(entry.path);
const isRenaming = renamingPath === entry.path;
if (entry.is_dir) {
return (
<li key={entry.path}>
<button
className="tree-item"
className={`tree-item ${dragOverPath === entry.path ? "drag-over" : ""}`}
style={{ paddingLeft: `${14 + depth * 16}px` }}
onClick={() => onToggle(entry.path)}
onDragOver={e => { e.preventDefault(); setDragOverPath(entry.path); }}
onDragLeave={() => setDragOverPath(null)}
onDrop={e => {
e.preventDefault();
const source = e.dataTransfer.getData("text/plain");
onDrop(entry.path, source);
}}
>
<span
className="tree-item-chevron"
@ -232,7 +555,20 @@ function NoteTree({
<span className="tree-item-label" style={{ fontWeight: 500 }}>{entry.name}</span>
</button>
{!isCollapsed && entry.children && (
<NoteTree entries={entry.children} collapsed={collapsed} onToggle={onToggle} onContextMenu={onContextMenu} depth={depth + 1} />
<NoteTree
entries={entry.children}
collapsed={collapsed}
onToggle={onToggle}
onContextMenu={onContextMenu}
renamingPath={renamingPath}
renameValue={renameValue}
setRenameValue={setRenameValue}
submitRename={submitRename}
onDrop={onDrop}
dragOverPath={dragOverPath}
setDragOverPath={setDragOverPath}
depth={depth + 1}
/>
)}
</li>
);
@ -240,15 +576,37 @@ function NoteTree({
return (
<li key={entry.path}>
<button
className={`tree-item ${isActive ? "active" : ""}`}
style={{ paddingLeft: `${14 + depth * 16}px` }}
onClick={() => navigate(`/note/${encodeURIComponent(entry.path)}`)}
onContextMenu={(e) => onContextMenu(e, entry.path)}
>
<span className="tree-item-icon">📄</span>
<span className="tree-item-label">{entry.name.replace(/\.md$/, "")}</span>
</button>
{isRenaming ? (
<div
className="tree-item"
style={{ paddingLeft: `${14 + depth * 16}px` }}
>
<span className="tree-item-icon">📄</span>
<input
ref={renameInputRef}
className="rename-input"
value={renameValue}
onChange={e => setRenameValue(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") submitRename();
if (e.key === "Escape") submitRename();
}}
onBlur={submitRename}
/>
</div>
) : (
<button
className={`tree-item ${isActive ? "active" : ""}`}
style={{ paddingLeft: `${14 + depth * 16}px` }}
draggable
onDragStart={e => e.dataTransfer.setData("text/plain", entry.path)}
onClick={() => navigate(`/note/${encodeURIComponent(entry.path)}`)}
onContextMenu={e => onContextMenu(e, entry)}
>
<span className="tree-item-icon">📄</span>
<span className="tree-item-label">{entry.name.replace(/\.md$/, "")}</span>
</button>
)}
</li>
);
})}
@ -270,6 +628,19 @@ function filterNotes(entries: NoteEntry[], query: string): NoteEntry[] {
return result;
}
function filterByPaths(entries: NoteEntry[], paths: Set<string>): NoteEntry[] {
const result: NoteEntry[] = [];
for (const entry of entries) {
if (entry.is_dir && entry.children) {
const filtered = filterByPaths(entry.children, paths);
if (filtered.length > 0) result.push({ ...entry, children: filtered });
} else if (paths.has(entry.path)) {
result.push(entry);
}
}
return result;
}
function countFiles(entries: NoteEntry[]): number {
let count = 0;
for (const e of entries) {

View file

@ -0,0 +1,104 @@
import { useState, useEffect, useRef, useCallback } from "react";
interface SlashCommand {
id: string;
icon: string;
label: string;
insert: string;
}
const COMMANDS: SlashCommand[] = [
{ id: "h1", icon: "H₁", label: "Heading 1", insert: "# " },
{ id: "h2", icon: "H₂", label: "Heading 2", insert: "## " },
{ id: "h3", icon: "H₃", label: "Heading 3", insert: "### " },
{ id: "bullet", icon: "•", label: "Bullet List", insert: "- " },
{ id: "numbered", icon: "1.", label: "Numbered List", insert: "1. " },
{ id: "todo", icon: "☐", label: "Todo", insert: "- [ ] " },
{ id: "code", icon: "⟨⟩", label: "Code Block", insert: "```\n\n```" },
{ id: "divider", icon: "—", label: "Divider", insert: "---\n" },
{ id: "quote", icon: "❝", label: "Blockquote", insert: "> " },
{ id: "table", icon: "⊞", label: "Table", insert: "| Column 1 | Column 2 |\n| --- | --- |\n| cell | cell |" },
{ id: "mermaid", icon: "◇", label: "Mermaid Diagram", insert: "```mermaid\ngraph LR\n A --> B\n```" },
{ id: "link", icon: "🔗", label: "Wikilink", insert: "[[" },
{ id: "image", icon: "🖼", label: "Image", insert: "![alt text]()" },
{ id: "transclusion", icon: "📎", label: "Transclusion", insert: "![[" },
];
interface Props {
visible: boolean;
position: { top: number; left: number };
query: string;
onSelect: (insert: string) => void;
onClose: () => void;
}
/**
* SlashMenu Inline formatting command menu, triggered by `/`.
*/
export function SlashMenu({ visible, position, query, onSelect, onClose }: Props) {
const [selectedIndex, setSelectedIndex] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const filtered = COMMANDS.filter(c =>
c.label.toLowerCase().includes(query.toLowerCase())
);
// Reset selection when query changes
useEffect(() => {
setSelectedIndex(0);
}, [query]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (!visible) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (filtered[selectedIndex]) {
onSelect(filtered[selectedIndex].insert);
}
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}, [visible, filtered, selectedIndex, onSelect, onClose]);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [handleKeyDown]);
// Scroll active item into view
useEffect(() => {
const list = listRef.current;
if (!list) return;
const item = list.children[selectedIndex] as HTMLElement | undefined;
item?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
if (!visible || filtered.length === 0) return null;
return (
<div
className="slash-menu"
ref={listRef}
style={{ top: position.top, left: position.left }}
>
{filtered.map((cmd, i) => (
<button
key={cmd.id}
className={`slash-menu-item ${i === selectedIndex ? "selected" : ""}`}
onMouseEnter={() => setSelectedIndex(i)}
onClick={() => onSelect(cmd.insert)}
>
<span className="slash-menu-icon">{cmd.icon}</span>
<span className="slash-menu-label">{cmd.label}</span>
</button>
))}
</div>
);
}

View file

@ -0,0 +1,122 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { useParams } from "react-router-dom";
import { useVault } from "../App";
import { readNote } from "../lib/commands";
import { Editor } from "./Editor";
import { Backlinks } from "./Backlinks";
/**
* SplitView Renders two note editors side by side with a draggable divider.
* The left pane shows the current note, the right pane shows the split note.
*/
export function SplitView() {
const { path } = useParams<{ path: string }>();
const { vaultPath, setCurrentNote, setNoteContent, splitNote } = useVault();
const decodedPath = decodeURIComponent(path || "");
const [splitContent, setSplitContent] = useState("");
const [dividerPos, setDividerPos] = useState(50); // percentage
const containerRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
// Load primary note
useEffect(() => {
if (!decodedPath || !vaultPath) return;
setCurrentNote(decodedPath);
readNote(vaultPath, decodedPath).then(setNoteContent).catch(() => setNoteContent(""));
}, [decodedPath, vaultPath, setCurrentNote, setNoteContent]);
// Load split note
useEffect(() => {
if (!splitNote || !vaultPath) return;
readNote(vaultPath, splitNote).then(setSplitContent).catch(() => setSplitContent(""));
}, [splitNote, vaultPath]);
// Divider drag
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isDragging.current = true;
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const pct = ((e.clientX - rect.left) / rect.width) * 100;
setDividerPos(Math.max(25, Math.min(75, pct)));
};
const handleMouseUp = () => {
isDragging.current = false;
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}, []);
if (!splitNote) {
// No split — render single pane like NoteView
return (
<div className="flex flex-1 overflow-hidden">
<main className="flex-1 overflow-y-auto">
<Editor />
</main>
<Backlinks />
</div>
);
}
return (
<div className="split-container" ref={containerRef}>
{/* Left pane — primary note */}
<div className="split-pane" style={{ width: `${dividerPos}%` }}>
<main className="flex-1 overflow-y-auto">
<Editor />
</main>
</div>
{/* Divider */}
<div className="split-divider" onMouseDown={handleMouseDown}>
<div className="split-divider-handle" />
</div>
{/* Right pane — split note */}
<div className="split-pane" style={{ width: `${100 - dividerPos}%` }}>
<div className="split-pane-header">
<span className="split-pane-title">
{splitNote.replace(/\.md$/, "").split("/").pop()}
</span>
<SplitCloseButton />
</div>
<div className="flex-1 overflow-y-auto">
<div
className="prose prose-invert max-w-none px-8 py-6"
style={{ color: "var(--text-primary)", lineHeight: 1.8, fontSize: "14px" }}
>
<pre style={{
whiteSpace: "pre-wrap",
fontFamily: "inherit",
margin: 0,
fontSize: "inherit",
color: "var(--text-secondary)",
}}>
{splitContent}
</pre>
</div>
</div>
</div>
</div>
);
}
function SplitCloseButton() {
const { setSplitNote } = useVault();
return (
<button
className="split-close-btn"
onClick={() => setSplitNote(null)}
title="Close split"
>
</button>
);
}

View file

@ -0,0 +1,60 @@
import { useMemo } from "react";
/**
* StatusBar Document statistics at bottom of editor.
*/
export function StatusBar({ content }: { content: string }) {
const stats = useMemo(() => {
if (!content) return { words: 0, chars: 0, lines: 0, readTime: "0 min", headings: 0 };
const words = content.trim().split(/\s+/).filter(w => w.length > 0).length;
const chars = content.length;
const lines = content.split("\n").length;
const readTime = Math.max(1, Math.ceil(words / 200));
const headings = (content.match(/^#{1,6}\s/gm) || []).length;
return {
words,
chars,
lines,
readTime: readTime === 1 ? "1 min" : `${readTime} min`,
headings,
};
}, [content]);
return (
<div className="status-bar">
<div className="status-bar-left">
<span className="status-item">
<span className="status-label">Words</span>
<span className="status-value">{stats.words.toLocaleString()}</span>
</span>
<span className="status-separator">·</span>
<span className="status-item">
<span className="status-label">Chars</span>
<span className="status-value">{stats.chars.toLocaleString()}</span>
</span>
<span className="status-separator">·</span>
<span className="status-item">
<span className="status-label">Lines</span>
<span className="status-value">{stats.lines}</span>
</span>
</div>
<div className="status-bar-right">
<span className="status-item">
<span className="status-label">📖</span>
<span className="status-value">{stats.readTime}</span>
</span>
{stats.headings > 0 && (
<>
<span className="status-separator">·</span>
<span className="status-item">
<span className="status-label">#</span>
<span className="status-value">{stats.headings}</span>
</span>
</>
)}
</div>
</div>
);
}

67
src/components/TabBar.tsx Normal file
View file

@ -0,0 +1,67 @@
import { useVault } from "../App";
import { saveTabs } from "../lib/commands";
interface Tab {
path: string;
name: string;
}
/**
* TabBar Horizontal tab strip for multi-note editing.
*/
export function TabBar({
tabs,
activeTab,
onSelectTab,
onCloseTab,
onReorder,
}: {
tabs: Tab[];
activeTab: number;
onSelectTab: (index: number) => void;
onCloseTab: (index: number) => void;
onReorder: (from: number, to: number) => void;
}) {
const { vaultPath } = useVault();
let dragIndex: number | null = null;
const handleDragStart = (i: number) => { dragIndex = i; };
const handleDrop = (i: number) => {
if (dragIndex !== null && dragIndex !== i) {
onReorder(dragIndex, i);
}
dragIndex = null;
};
const persistTabs = async () => {
if (vaultPath) {
await saveTabs(vaultPath, JSON.stringify(tabs.map(t => t.path))).catch(() => { });
}
};
return (
<div className="tab-bar">
{tabs.map((tab, i) => (
<div
key={tab.path}
className={`tab-item ${i === activeTab ? "active" : ""}`}
onClick={() => onSelectTab(i)}
draggable
onDragStart={() => handleDragStart(i)}
onDragOver={e => e.preventDefault()}
onDrop={() => handleDrop(i)}
>
<span className="tab-name">{tab.name}</span>
<button
className="tab-close"
onClick={e => { e.stopPropagation(); onCloseTab(i); persistTabs(); }}
>
</button>
</div>
))}
</div>
);
}
export type { Tab };

View file

@ -0,0 +1,192 @@
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
interface TableData {
headers: string[];
rows: string[][];
}
/**
* TableEditor Visual markdown table editing.
*/
export function TableEditor({
markdown,
onChange,
}: {
markdown: string;
onChange: (newMarkdown: string) => void;
}) {
const [table, setTable] = useState<TableData>(() => parseTable(markdown));
const [editCell, setEditCell] = useState<{ row: number; col: number } | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editCell && inputRef.current) inputRef.current.focus();
}, [editCell]);
const updateCell = useCallback((row: number, col: number, value: string) => {
setTable(prev => {
const next = { ...prev, rows: prev.rows.map(r => [...r]) };
if (row === -1) {
next.headers = [...next.headers];
next.headers[col] = value;
} else {
next.rows[row][col] = value;
}
return next;
});
}, []);
const commitEdit = useCallback(() => {
setEditCell(null);
onChange(serializeTable(table));
}, [table, onChange]);
const addRow = () => {
setTable(prev => ({
...prev,
rows: [...prev.rows, prev.headers.map(() => "")],
}));
};
const addColumn = () => {
const name = prompt("Column name:");
if (!name?.trim()) return;
setTable(prev => ({
headers: [...prev.headers, name.trim()],
rows: prev.rows.map(r => [...r, ""]),
}));
};
const removeRow = (idx: number) => {
setTable(prev => ({
...prev,
rows: prev.rows.filter((_, i) => i !== idx),
}));
};
const removeColumn = (idx: number) => {
setTable(prev => ({
headers: prev.headers.filter((_, i) => i !== idx),
rows: prev.rows.map(r => r.filter((_, i) => i !== idx)),
}));
};
// Auto-sync to parent on table change
useEffect(() => {
onChange(serializeTable(table));
}, [table]);
return (
<div className="table-editor">
<div className="table-editor-toolbar">
<button className="te-btn" onClick={addRow}>+ Row</button>
<button className="te-btn" onClick={addColumn}>+ Column</button>
</div>
<div className="table-editor-scroll">
<table className="te-table">
<thead>
<tr>
{table.headers.map((h, ci) => (
<th key={ci} className="te-th">
{editCell?.row === -1 && editCell.col === ci ? (
<input
ref={inputRef}
className="te-input"
value={h}
onChange={e => updateCell(-1, ci, e.target.value)}
onBlur={commitEdit}
onKeyDown={e => {
if (e.key === "Enter") commitEdit();
if (e.key === "Tab") {
e.preventDefault();
const nextCol = ci + 1 < table.headers.length ? ci + 1 : 0;
setEditCell({ row: 0, col: nextCol });
}
}}
/>
) : (
<div className="te-header-cell" onClick={() => setEditCell({ row: -1, col: ci })}>
<span>{h || "—"}</span>
<button className="te-remove-col" onClick={(e) => { e.stopPropagation(); removeColumn(ci); }}></button>
</div>
)}
</th>
))}
</tr>
</thead>
<tbody>
{table.rows.map((row, ri) => (
<tr key={ri}>
{row.map((cell, ci) => (
<td key={ci} className="te-td">
{editCell?.row === ri && editCell.col === ci ? (
<input
ref={inputRef}
className="te-input"
value={cell}
onChange={e => updateCell(ri, ci, e.target.value)}
onBlur={commitEdit}
onKeyDown={e => {
if (e.key === "Enter") {
const nextRow = ri + 1 < table.rows.length ? ri + 1 : ri;
setEditCell({ row: nextRow, col: ci });
}
if (e.key === "Tab") {
e.preventDefault();
if (ci + 1 < row.length) {
setEditCell({ row: ri, col: ci + 1 });
} else if (ri + 1 < table.rows.length) {
setEditCell({ row: ri + 1, col: 0 });
}
}
}}
/>
) : (
<span className="te-cell" onClick={() => setEditCell({ row: ri, col: ci })}>
{cell || "\u00A0"}
</span>
)}
</td>
))}
<td className="te-remove-row-cell">
<button className="te-remove-row" onClick={() => removeRow(ri)}></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
/* ── Parse / Serialize ──────────────────────── */
function parseTable(md: string): TableData {
const lines = md.trim().split("\n").filter(l => l.trim());
if (lines.length < 2) return { headers: ["Column 1"], rows: [[""]] };
const splitRow = (line: string) =>
line.split("|").map(c => c.trim()).filter((_, i, a) => i > 0 && i < a.length);
const headers = splitRow(lines[0]);
const rows = lines.slice(2).map(splitRow);
return { headers, rows: rows.length ? rows : [[...headers.map(() => "")]] };
}
function serializeTable(table: TableData): string {
const maxWidths = table.headers.map((h, i) =>
Math.max(h.length, ...table.rows.map(r => (r[i] || "").length), 3)
);
const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - s.length));
const headerLine = "| " + table.headers.map((h, i) => pad(h, maxWidths[i])).join(" | ") + " |";
const sepLine = "| " + maxWidths.map(w => "-".repeat(w)).join(" | ") + " |";
const rowLines = table.rows.map(row =>
"| " + row.map((c, i) => pad(c, maxWidths[i])).join(" | ") + " |"
);
return [headerLine, sepLine, ...rowLines].join("\n");
}

View file

@ -0,0 +1,91 @@
import { useMemo, useEffect, useRef, useState } from "react";
import { useVault } from "../App";
interface TocEntry {
level: number;
text: string;
id: string;
}
/**
* TableOfContents Auto-generated outline from note headings.
* Click to scroll, highlights active heading on scroll.
*/
export function TableOfContents() {
const { noteContent, currentNote } = useVault();
const [activeId, setActiveId] = useState("");
const [isOpen, setIsOpen] = useState(true);
const observerRef = useRef<IntersectionObserver | null>(null);
const headings = useMemo(() => {
if (!noteContent) return [];
const entries: TocEntry[] = [];
for (const line of noteContent.split("\n")) {
const match = line.match(/^(#{1,4})\s+(.+)/);
if (match) {
const level = match[1].length;
const text = match[2].trim();
const id = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-$/, "");
entries.push({ level, text, id });
}
}
return entries;
}, [noteContent]);
// Scroll spy — observe headings
useEffect(() => {
if (!headings.length) return;
observerRef.current?.disconnect();
const observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
}
},
{ rootMargin: "-20% 0px -60% 0px" }
);
observerRef.current = observer;
// Observe heading elements
for (const h of headings) {
const el = document.getElementById(h.id);
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, [headings, currentNote]);
const scrollTo = (id: string) => {
const el = document.getElementById(id);
el?.scrollIntoView({ behavior: "smooth", block: "start" });
};
if (!headings.length) return null;
return (
<div className="toc-panel">
<button className="toc-toggle" onClick={() => setIsOpen(!isOpen)}>
<span className="toc-chevron" style={{ transform: isOpen ? "rotate(0)" : "rotate(-90deg)" }}></span>
<span className="toc-label">Contents</span>
<span className="badge badge-muted" style={{ fontSize: 9 }}>{headings.length}</span>
</button>
{isOpen && (
<nav className="toc-list">
{headings.map((h, i) => (
<button
key={`${h.id}-${i}`}
className={`toc-item ${activeId === h.id ? "active" : ""}`}
style={{ paddingLeft: `${8 + (h.level - 1) * 12}px` }}
onClick={() => scrollTo(h.id)}
>
{h.text}
</button>
))}
</nav>
)}
</div>
);
}

View file

@ -0,0 +1,186 @@
import { useState, useEffect } from "react";
import { getTheme, setTheme as setThemeCmd } from "../lib/commands";
interface ThemeConfig {
id: string;
name: string;
preview: { bg: string; accent: string; text: string };
vars: Record<string, string>;
}
const THEMES: ThemeConfig[] = [
{
id: "dark-purple",
name: "Dark Purple",
preview: { bg: "#09090b", accent: "#a78bfa", text: "#fafafa" },
vars: {
"--bg-primary": "#09090b",
"--bg-secondary": "#0f0f14",
"--bg-tertiary": "#18181f",
"--bg-elevated": "#1f1f2c",
"--bg-hover": "#27273a",
"--bg-active": "#2e2e45",
"--text-primary": "#fafafa",
"--text-secondary": "#a1a1aa",
"--text-muted": "#52525b",
"--text-accent": "#a78bfa",
"--accent-purple": "#a78bfa",
"--accent-purple-bright": "#8b5cf6",
"--accent-purple-dim": "rgba(139, 92, 246, 0.25)",
"--accent-purple-glow": "rgba(139, 92, 246, 0.12)",
},
},
{
id: "dark-green",
name: "Dark Emerald",
preview: { bg: "#0a0f0d", accent: "#34d399", text: "#f0fdf4" },
vars: {
"--bg-primary": "#0a0f0d",
"--bg-secondary": "#0d1512",
"--bg-tertiary": "#131f1a",
"--bg-elevated": "#1a2e26",
"--bg-hover": "#234035",
"--bg-active": "#2a5244",
"--text-primary": "#f0fdf4",
"--text-secondary": "#86efac",
"--text-muted": "#4b7a62",
"--text-accent": "#34d399",
"--accent-purple": "#34d399",
"--accent-purple-bright": "#10b981",
"--accent-purple-dim": "rgba(52, 211, 153, 0.25)",
"--accent-purple-glow": "rgba(52, 211, 153, 0.12)",
},
},
{
id: "dark-blue",
name: "Dark Ocean",
preview: { bg: "#0a0c14", accent: "#60a5fa", text: "#f0f9ff" },
vars: {
"--bg-primary": "#0a0c14",
"--bg-secondary": "#0e1220",
"--bg-tertiary": "#141a2e",
"--bg-elevated": "#1c243e",
"--bg-hover": "#263252",
"--bg-active": "#2e3e66",
"--text-primary": "#f0f9ff",
"--text-secondary": "#93c5fd",
"--text-muted": "#4b6a94",
"--text-accent": "#60a5fa",
"--accent-purple": "#60a5fa",
"--accent-purple-bright": "#3b82f6",
"--accent-purple-dim": "rgba(96, 165, 250, 0.25)",
"--accent-purple-glow": "rgba(96, 165, 250, 0.12)",
},
},
{
id: "dark-rose",
name: "Dark Rose",
preview: { bg: "#0f0a0b", accent: "#fb7185", text: "#fff1f2" },
vars: {
"--bg-primary": "#0f0a0b",
"--bg-secondary": "#160e10",
"--bg-tertiary": "#1f1418",
"--bg-elevated": "#2e1c22",
"--bg-hover": "#3e262e",
"--bg-active": "#4e303a",
"--text-primary": "#fff1f2",
"--text-secondary": "#fda4af",
"--text-muted": "#8a5060",
"--text-accent": "#fb7185",
"--accent-purple": "#fb7185",
"--accent-purple-bright": "#f43f5e",
"--accent-purple-dim": "rgba(251, 113, 133, 0.25)",
"--accent-purple-glow": "rgba(251, 113, 133, 0.12)",
},
},
{
id: "light",
name: "Light",
preview: { bg: "#fafafa", accent: "#7c3aed", text: "#18181b" },
vars: {
"--bg-primary": "#fafafa",
"--bg-secondary": "#f4f4f5",
"--bg-tertiary": "#e4e4e7",
"--bg-elevated": "#ffffff",
"--bg-hover": "#e4e4e7",
"--bg-active": "#d4d4d8",
"--text-primary": "#18181b",
"--text-secondary": "#52525b",
"--text-muted": "#a1a1aa",
"--text-accent": "#7c3aed",
"--accent-purple": "#7c3aed",
"--accent-purple-bright": "#6d28d9",
"--accent-purple-dim": "rgba(124, 58, 237, 0.15)",
"--accent-purple-glow": "rgba(124, 58, 237, 0.08)",
},
},
];
/**
* ThemePicker Modal with visual theme previews. Click to apply, persists selection.
*/
export function ThemePicker({ open, onClose }: { open: boolean; onClose: () => void }) {
const [activeTheme, setActiveTheme] = useState("dark-purple");
useEffect(() => {
getTheme().then(setActiveTheme).catch(() => { });
}, []);
const applyTheme = (theme: ThemeConfig) => {
setActiveTheme(theme.id);
const root = document.documentElement;
for (const [key, value] of Object.entries(theme.vars)) {
root.style.setProperty(key, value);
}
setThemeCmd(theme.id).catch(() => { });
onClose();
};
if (!open) return null;
return (
<div className="theme-backdrop" onClick={onClose}>
<div className="theme-modal" onClick={e => e.stopPropagation()}>
<h3 className="theme-modal-title">Choose Theme</h3>
<div className="theme-grid">
{THEMES.map(theme => (
<button
key={theme.id}
className={`theme-card ${activeTheme === theme.id ? "active" : ""}`}
onClick={() => applyTheme(theme)}
>
<div
className="theme-preview"
style={{ background: theme.preview.bg }}
>
<div className="theme-preview-bar" style={{ background: theme.preview.accent }} />
<div className="theme-preview-line" style={{ background: theme.preview.text, opacity: 0.7 }} />
<div className="theme-preview-line short" style={{ background: theme.preview.text, opacity: 0.4 }} />
<div className="theme-preview-dot" style={{ background: theme.preview.accent }} />
</div>
<span className="theme-card-name">{theme.name}</span>
{activeTheme === theme.id && <span className="theme-active-badge">Active</span>}
</button>
))}
</div>
</div>
</div>
);
}
/**
* Load and apply the saved theme on app start.
*/
export function useThemeInit() {
useEffect(() => {
getTheme().then(themeId => {
const theme = THEMES.find(t => t.id === themeId);
if (theme) {
const root = document.documentElement;
for (const [key, value] of Object.entries(theme.vars)) {
root.style.setProperty(key, value);
}
}
}).catch(() => { });
}, []);
}

View file

@ -0,0 +1,98 @@
import { useEffect, useState, useMemo } from "react";
import { useVault } from "../App";
import { listNotesByDate, type NoteByDate } from "../lib/commands";
/**
* TimelineView Chronological note view with visual timeline.
*/
export function TimelineView() {
const { vaultPath, navigateToNote } = useVault();
const [notes, setNotes] = useState<NoteByDate[]>([]);
const [rangeFilter, setRangeFilter] = useState<"all" | "week" | "month" | "year">("all");
useEffect(() => {
if (!vaultPath) return;
listNotesByDate(vaultPath).then(setNotes).catch(() => setNotes([]));
}, [vaultPath]);
// Group by date
const grouped = useMemo(() => {
const now = Date.now() / 1000;
let filtered = notes;
if (rangeFilter === "week") filtered = notes.filter(n => now - n.modified < 7 * 86400);
else if (rangeFilter === "month") filtered = notes.filter(n => now - n.modified < 30 * 86400);
else if (rangeFilter === "year") filtered = notes.filter(n => now - n.modified < 365 * 86400);
const groups: Map<string, NoteByDate[]> = new Map();
filtered.forEach(note => {
const date = new Date(note.modified * 1000);
const key = date.toLocaleDateString("en-US", {
year: "numeric", month: "long", day: "numeric",
});
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(note);
});
return Array.from(groups.entries());
}, [notes, rangeFilter]);
const formatTime = (ts: number) => {
return new Date(ts * 1000).toLocaleTimeString("en-US", {
hour: "2-digit", minute: "2-digit",
});
};
return (
<div className="timeline-view">
<div className="timeline-header">
<h2 className="timeline-title">📅 Timeline</h2>
<div className="timeline-filters">
{(["all", "week", "month", "year"] as const).map(r => (
<button
key={r}
className={`timeline-filter-btn ${rangeFilter === r ? "active" : ""}`}
onClick={() => setRangeFilter(r)}
>
{r === "all" ? "All" : r === "week" ? "7d" : r === "month" ? "30d" : "1y"}
</button>
))}
</div>
</div>
<div className="timeline-content">
{grouped.map(([date, items]) => (
<div key={date} className="timeline-group">
<div className="timeline-date-header">
<div className="timeline-date-dot" />
<span className="timeline-date-text">{date}</span>
<span className="timeline-date-count">{items.length}</span>
</div>
<div className="timeline-items">
{items.map(note => (
<div
key={note.path}
className="timeline-card"
onClick={() => navigateToNote(note.name)}
>
<div className="timeline-card-header">
<span className="timeline-card-name">{note.name}</span>
<span className="timeline-card-time">{formatTime(note.modified)}</span>
</div>
{note.preview && (
<p className="timeline-card-preview">{note.preview}</p>
)}
</div>
))}
</div>
</div>
))}
{grouped.length === 0 && (
<div className="timeline-empty">
<p>No notes found for this time range.</p>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,122 @@
import { useState, useEffect } from "react";
import { useVault } from "../App";
import { readNote } from "../lib/commands";
import { marked } from "marked";
/**
* TransclusionBlock Renders the content of another note inline.
* Used for ![[note-name]] transclusion syntax.
*/
export function TransclusionBlock({ noteName, depth = 0 }: { noteName: string; depth?: number }) {
const { vaultPath, notes, navigateToNote } = useVault();
const [content, setContent] = useState<string | null>(null);
const [collapsed, setCollapsed] = useState(false);
const [error, setError] = useState(false);
const MAX_DEPTH = 3;
useEffect(() => {
if (depth >= MAX_DEPTH) return;
if (!vaultPath) return;
// Find note path by name
const allPaths = flattenNotes(notes);
const match = allPaths.find(
p => p.replace(/\.md$/, "").split("/").pop()?.toLowerCase() === noteName.toLowerCase()
);
if (!match) {
setError(true);
return;
}
readNote(vaultPath, match)
.then(setContent)
.catch(() => setError(true));
}, [vaultPath, noteName, notes, depth]);
if (depth >= MAX_DEPTH) {
return (
<div className="transclusion-block transclusion-limit">
<span className="transclusion-icon"></span>
<span>Transclusion depth limit reached for <strong>{noteName}</strong></span>
</div>
);
}
if (error) {
return (
<div className="transclusion-block transclusion-error">
<span className="transclusion-icon"></span>
<span>Note not found: <strong>{noteName}</strong></span>
</div>
);
}
if (content === null) {
return (
<div className="transclusion-block transclusion-loading">
<span className="transclusion-icon"></span>
<span>Loading {noteName}...</span>
</div>
);
}
// Process transclusions recursively
const processedContent = content.replace(
/!\[\[([^\]]+)\]\]/g,
(_m, target) => `<div class="transclusion-nested" data-note="${target.trim()}"></div>`
);
// Render markdown
let html = marked(processedContent, { async: false }) as string;
// Process wikilinks
html = html.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_m, target, display) => {
const label = display?.trim() || target.trim();
return `<span class="wikilink" data-target="${target.trim()}">${label}</span>`;
}
);
return (
<div className={`transclusion-block ${collapsed ? "collapsed" : ""}`}>
<div className="transclusion-header">
<button
className="transclusion-toggle"
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? "▶" : "▾"}
</button>
<span className="transclusion-name" onClick={() => navigateToNote(noteName)}>
📄 {noteName}
</span>
<button
className="transclusion-open"
onClick={() => navigateToNote(noteName)}
title="Open note"
>
</button>
</div>
{!collapsed && (
<div
className="transclusion-content"
dangerouslySetInnerHTML={{ __html: html }}
/>
)}
</div>
);
}
function flattenNotes(entries: { path: string; is_dir: boolean; children?: any[] }[]): string[] {
const paths: string[] = [];
for (const entry of entries) {
if (entry.is_dir && entry.children) {
paths.push(...flattenNotes(entry.children));
} else if (!entry.is_dir) {
paths.push(entry.path);
}
}
return paths;
}

View file

@ -0,0 +1,72 @@
import { useEffect, useState, useCallback } from "react";
import { useParams } from "react-router-dom";
import { Provider as JotaiProvider } from "jotai";
import { Canvas, CanvasStyleProvider, registerBuiltinCommands, ViewportControls } from "@blinksgg/canvas";
import { useVault } from "../App";
import { saveCanvas, loadCanvas } from "../lib/commands";
registerBuiltinCommands();
const CARD_COLORS = ["#8b5cf6", "#3b82f6", "#10b981", "#f59e0b", "#f43f5e", "#ec4899"];
/**
* WhiteboardView Freeform visual thinking canvas.
*/
export function WhiteboardView() {
const { name } = useParams<{ name: string }>();
const { vaultPath, navigateToNote } = useVault();
const renderNode = useCallback(({ node, isSelected }: any) => {
const nodeType = node.dbData?.node_type || "card";
if (nodeType === "text") {
return (
<div className={`wb-text-node ${isSelected ? "selected" : ""}`}>
{node.label || node.dbData?.label}
</div>
);
}
return (
<div
className={`wb-card-node ${isSelected ? "selected" : ""}`}
style={{ borderColor: node.color || "#8b5cf6" }}
>
<div className="wb-card-color" style={{ background: node.color || "#8b5cf6" }} />
<span className="wb-card-label">{node.label || node.dbData?.label}</span>
</div>
);
}, []);
const handleSave = useCallback(async () => {
if (!vaultPath || !name) return;
await saveCanvas(vaultPath, name, JSON.stringify({ savedAt: new Date().toISOString() })).catch(() => { });
}, [vaultPath, name]);
return (
<JotaiProvider>
<CanvasStyleProvider isDark={true}>
<div className="whiteboard-wrapper">
<div className="whiteboard-toolbar">
<span className="whiteboard-title">📋 {name || "Untitled"}</span>
<div className="whiteboard-actions">
<button className="wb-btn wb-save" onClick={handleSave}>💾 Save</button>
</div>
</div>
<Canvas
renderNode={renderNode}
onNodeDoubleClick={(nodeId, nodeData) => {
const label = nodeData?.label || (nodeData as any)?.dbData?.label;
if (label) navigateToNote(label);
}}
onBackgroundDoubleClick={(worldPos) => {
// Could add node creation here
}}
minZoom={0.1}
maxZoom={5}
>
<ViewportControls />
</Canvas>
</div>
</CanvasStyleProvider>
</JotaiProvider>
);
}

File diff suppressed because it is too large Load diff

View file

@ -28,8 +28,15 @@ export interface GraphData {
export interface SearchResult {
path: string;
name: string;
context: string;
line_number: number;
context: string;
score: number;
}
export interface TagInfo {
tag: string;
count: number;
notes: string[];
}
/* ── Commands ───────────────────────────────────────────────── */
@ -53,14 +60,6 @@ export async function buildGraph(vaultPath: string): Promise<GraphData> {
return invoke<GraphData>("build_graph", { vaultPath });
}
export async function searchVault(vaultPath: string, query: string): Promise<SearchResult[]> {
return invoke<SearchResult[]>("search_vault", { vaultPath, query });
}
export async function renameNote(vaultPath: string, oldPath: string, newName: string): Promise<string> {
return invoke<string>("rename_note", { vaultPath, oldPath, newName });
}
export async function getOrCreateDaily(vaultPath: string): Promise<string> {
return invoke<string>("get_or_create_daily", { vaultPath });
}
@ -77,53 +76,318 @@ export async function ensureVault(vaultPath: string): Promise<void> {
return invoke("ensure_vault", { vaultPath });
}
/* ── v0.3 Commands ─────────────────────────────────────────── */
export interface NoteMeta {
title?: string;
tags: string[];
created?: string;
modified?: string;
extra: Record<string, string>;
export async function searchVault(vaultPath: string, query: string): Promise<SearchResult[]> {
return invoke<SearchResult[]>("search_vault", { vaultPath, query });
}
export interface HeadingEntry {
level: number;
export async function renameNote(vaultPath: string, oldPath: string, newPath: string): Promise<void> {
return invoke("rename_note", { vaultPath, oldPath, newPath });
}
export async function updateWikilinks(vaultPath: string, oldName: string, newName: string): Promise<number> {
return invoke<number>("update_wikilinks", { vaultPath, oldName, newName });
}
export async function listTags(vaultPath: string): Promise<TagInfo[]> {
return invoke<TagInfo[]>("list_tags", { vaultPath });
}
export interface TemplateInfo {
name: string;
path: string;
}
export async function readNotePreview(vaultPath: string, notePath: string, maxChars?: number): Promise<string> {
return invoke<string>("read_note_preview", { vaultPath, notePath, maxChars });
}
export async function listRecentVaults(): Promise<string[]> {
return invoke<string[]>("list_recent_vaults");
}
export async function addVault(vaultPath: string): Promise<void> {
return invoke("add_vault", { vaultPath });
}
export async function listTemplates(vaultPath: string): Promise<TemplateInfo[]> {
return invoke<TemplateInfo[]>("list_templates", { vaultPath });
}
export async function createFromTemplate(vaultPath: string, templatePath: string, noteName: string): Promise<string> {
return invoke<string>("create_from_template", { vaultPath, templatePath, noteName });
}
export async function getFavorites(vaultPath: string): Promise<string[]> {
return invoke<string[]>("get_favorites", { vaultPath });
}
export async function setFavorites(vaultPath: string, favorites: string[]): Promise<void> {
return invoke("set_favorites", { vaultPath, favorites });
}
/* ── v0.4 Commands ──────────────────────────────────────── */
export async function parseFrontmatter(vaultPath: string, notePath: string): Promise<Record<string, string>> {
return invoke<Record<string, string>>("parse_frontmatter", { vaultPath, notePath });
}
export async function writeFrontmatter(vaultPath: string, notePath: string, frontmatter: Record<string, string>): Promise<void> {
return invoke("write_frontmatter", { vaultPath, notePath, frontmatter });
}
export async function saveAttachment(vaultPath: string, fileName: string, data: number[]): Promise<string> {
return invoke<string>("save_attachment", { vaultPath, fileName, data });
}
export async function listAttachments(vaultPath: string): Promise<string[]> {
return invoke<string[]>("list_attachments", { vaultPath });
}
export async function listDailyNotes(vaultPath: string): Promise<string[]> {
return invoke<string[]>("list_daily_notes", { vaultPath });
}
export async function getTheme(): Promise<string> {
return invoke<string>("get_theme");
}
export async function setTheme(theme: string): Promise<void> {
return invoke("set_theme", { theme });
}
export async function exportNoteHtml(vaultPath: string, notePath: string): Promise<string> {
return invoke<string>("export_note_html", { vaultPath, notePath });
}
/* ── v0.5 Commands ──────────────────────────────────────── */
export interface TaskItem {
text: string;
line: number;
state: string;
source_path: string;
line_number: number;
}
export interface NoteWithMeta {
content: string;
meta: NoteMeta;
body: string;
headings: HeadingEntry[];
export interface SnapshotInfo {
timestamp: string;
filename: string;
size: number;
}
export async function readNoteWithMeta(vaultPath: string, relativePath: string): Promise<NoteWithMeta> {
return invoke<NoteWithMeta>("read_note_with_meta", { vaultPath, relativePath });
export interface ReplaceResult {
path: string;
count: number;
}
export async function listTemplates(vaultPath: string): Promise<string[]> {
return invoke<string[]>("list_templates", { vaultPath });
export async function listTasks(vaultPath: string): Promise<TaskItem[]> {
return invoke<TaskItem[]>("list_tasks", { vaultPath });
}
export async function saveAttachment(vaultPath: string, filename: string, data: number[]): Promise<string> {
return invoke<string>("save_attachment", { vaultPath, filename, data });
export async function toggleTask(vaultPath: string, notePath: string, lineNumber: number, newState: string): Promise<void> {
return invoke("toggle_task", { vaultPath, notePath, lineNumber, newState });
}
/* ── v0.4 Cache Commands ───────────────────────────────────── */
export interface CacheStats {
entry_count: number;
hits: number;
misses: number;
export async function saveSnapshot(vaultPath: string, notePath: string): Promise<string> {
return invoke<string>("save_snapshot", { vaultPath, notePath });
}
export async function initVaultCache(vaultPath: string): Promise<number> {
return invoke<number>("init_vault_cache", { vaultPath });
export async function listSnapshots(vaultPath: string, notePath: string): Promise<SnapshotInfo[]> {
return invoke<SnapshotInfo[]>("list_snapshots", { vaultPath, notePath });
}
export async function getCacheStats(): Promise<CacheStats> {
return invoke<CacheStats>("get_cache_stats");
export async function readSnapshot(vaultPath: string, notePath: string, snapshotName: string): Promise<string> {
return invoke<string>("read_snapshot", { vaultPath, notePath, snapshotName });
}
export async function searchReplaceVault(vaultPath: string, search: string, replace: string, dryRun: boolean): Promise<ReplaceResult[]> {
return invoke<ReplaceResult[]>("search_replace_vault", { vaultPath, search, replace, dryRun });
}
export async function getWritingGoal(vaultPath: string, notePath: string): Promise<number> {
return invoke<number>("get_writing_goal", { vaultPath, notePath });
}
export async function setWritingGoal(vaultPath: string, notePath: string, goal: number): Promise<void> {
return invoke("set_writing_goal", { vaultPath, notePath, goal });
}
/* ── v0.6 Commands ──────────────────────────────────────── */
export interface Flashcard {
question: string;
answer: string;
source_path: string;
line_number: number;
due: string | null;
interval: number;
ease: number;
}
export async function extractToNote(vaultPath: string, sourcePath: string, selectedText: string, newNoteName: string): Promise<string> {
return invoke<string>("extract_to_note", { vaultPath, sourcePath, selectedText, newNoteName });
}
export async function mergeNotes(vaultPath: string, sourcePath: string, targetPath: string): Promise<void> {
return invoke("merge_notes", { vaultPath, sourcePath, targetPath });
}
export async function encryptNote(vaultPath: string, notePath: string, password: string): Promise<void> {
return invoke("encrypt_note", { vaultPath, notePath, password });
}
export async function decryptNote(vaultPath: string, notePath: string, password: string): Promise<string> {
return invoke<string>("decrypt_note", { vaultPath, notePath, password });
}
export async function isEncrypted(vaultPath: string, notePath: string): Promise<boolean> {
return invoke<boolean>("is_encrypted", { vaultPath, notePath });
}
export async function listFlashcards(vaultPath: string): Promise<Flashcard[]> {
return invoke<Flashcard[]>("list_flashcards", { vaultPath });
}
export async function updateCardSchedule(vaultPath: string, cardId: string, quality: number): Promise<void> {
return invoke("update_card_schedule", { vaultPath, cardId, quality });
}
export async function saveFoldState(vaultPath: string, notePath: string, folds: number[]): Promise<void> {
return invoke("save_fold_state", { vaultPath, notePath, folds });
}
export async function loadFoldState(vaultPath: string, notePath: string): Promise<number[]> {
return invoke<number[]>("load_fold_state", { vaultPath, notePath });
}
export async function getCustomCss(): Promise<string> {
return invoke<string>("get_custom_css");
}
export async function setCustomCss(css: string): Promise<void> {
return invoke("set_custom_css", { css });
}
export async function saveWorkspace(vaultPath: string, name: string, state: string): Promise<void> {
return invoke("save_workspace", { vaultPath, name, state });
}
export async function loadWorkspace(vaultPath: string, name: string): Promise<string> {
return invoke<string>("load_workspace", { vaultPath, name });
}
export async function listWorkspaces(vaultPath: string): Promise<string[]> {
return invoke<string[]>("list_workspaces", { vaultPath });
}
export async function saveTabs(vaultPath: string, tabs: string): Promise<void> {
return invoke("save_tabs", { vaultPath, tabs });
}
export async function loadTabs(vaultPath: string): Promise<string> {
return invoke<string>("load_tabs", { vaultPath });
}
/* ── v0.7 Commands ─────────────────────────────────────── */
// Canvas
export async function saveCanvas(vaultPath: string, name: string, data: string): Promise<void> {
return invoke("save_canvas", { vaultPath, name, data });
}
export async function loadCanvas(vaultPath: string, name: string): Promise<string> {
return invoke<string>("load_canvas", { vaultPath, name });
}
export async function listCanvases(vaultPath: string): Promise<string[]> {
return invoke<string[]>("list_canvases", { vaultPath });
}
// Frontmatter Query
export interface FrontmatterRow {
path: string;
title: string;
fields: Record<string, string>;
}
export async function queryFrontmatter(vaultPath: string): Promise<FrontmatterRow[]> {
return invoke<FrontmatterRow[]>("query_frontmatter", { vaultPath });
}
// Backlink Context
export interface BacklinkContext {
source_path: string;
source_name: string;
excerpt: string;
}
export async function getBacklinkContext(vaultPath: string, noteName: string): Promise<BacklinkContext[]> {
return invoke<BacklinkContext[]>("get_backlink_context", { vaultPath, noteName });
}
// Dataview
export interface DataviewResult {
columns: string[];
rows: string[][];
}
export async function runDataviewQuery(vaultPath: string, query: string): Promise<DataviewResult> {
return invoke<DataviewResult>("run_dataview_query", { vaultPath, query });
}
// Git
export async function gitStatus(vaultPath: string): Promise<string> {
return invoke<string>("git_status", { vaultPath });
}
export async function gitCommit(vaultPath: string, message: string): Promise<string> {
return invoke<string>("git_commit", { vaultPath, message });
}
export async function gitPull(vaultPath: string): Promise<string> {
return invoke<string>("git_pull", { vaultPath });
}
export async function gitPush(vaultPath: string): Promise<string> {
return invoke<string>("git_push", { vaultPath });
}
export async function gitInit(vaultPath: string): Promise<string> {
return invoke<string>("git_init", { vaultPath });
}
/* ── v0.8 Commands ─────────────────────────────────────── */
export async function suggestLinks(vaultPath: string, partial: string): Promise<string[]> {
return invoke<string[]>("suggest_links", { vaultPath, partial });
}
export interface NoteByDate {
path: string;
name: string;
modified: number;
preview: string;
}
export async function listNotesByDate(vaultPath: string): Promise<NoteByDate[]> {
return invoke<NoteByDate[]>("list_notes_by_date", { vaultPath });
}
export async function randomNote(vaultPath: string): Promise<string> {
return invoke<string>("random_note", { vaultPath });
}
/* ── v0.9 Commands ─────────────────────────────────────── */
export async function exportVaultZip(vaultPath: string, outputPath: string): Promise<string> {
return invoke<string>("export_vault_zip", { vaultPath, outputPath });
}
export async function importFolder(vaultPath: string, sourcePath: string): Promise<number> {
return invoke<number>("import_folder", { vaultPath, sourcePath });
}
export async function saveShortcuts(vaultPath: string, shortcutsJson: string): Promise<void> {
return invoke<void>("save_shortcuts", { vaultPath, shortcutsJson });
}
export async function loadShortcuts(vaultPath: string): Promise<string> {
return invoke<string>("load_shortcuts", { vaultPath });
}
export async function getPinned(vaultPath: string): Promise<string[]> {
return invoke<string[]>("get_pinned", { vaultPath });
}
export async function setPinned(vaultPath: string, pinned: string[]): Promise<void> {
return invoke<void>("set_pinned", { vaultPath, pinned });
}