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:
parent
d174c7f26d
commit
c1f556b86b
40 changed files with 12109 additions and 2083 deletions
231
CHANGELOG.md
231
CHANGELOG.md
|
|
@ -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 (H1–H6) 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 `` 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**: H1–H3 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
1454
package-lock.json
generated
File diff suppressed because it is too large
Load diff
26
package.json
26
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
2007
src-tauri/src/lib.rs
2007
src-tauri/src/lib.rs
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
311
src/App.tsx
311
src/App.tsx
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
83
src/components/CSSEditor.tsx
Normal file
83
src/components/CSSEditor.tsx
Normal 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(() => { });
|
||||
}, []);
|
||||
}
|
||||
122
src/components/CalendarView.tsx
Normal file
122
src/components/CalendarView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
171
src/components/DatabaseView.tsx
Normal file
171
src/components/DatabaseView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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\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 = ``;
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
// Process line by line for heading detection
|
||||
const lines = raw.split("\n");
|
||||
const htmlLines = lines.map(line => {
|
||||
// Escape HTML
|
||||
let escaped = line
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
// Replace [[wikilinks]] with token spans
|
||||
escaped = escaped.replace(
|
||||
/\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g,
|
||||
(_m, target, display) => {
|
||||
const label = display?.trim() || target.trim();
|
||||
const rawAttr = _m.replace(/"/g, """);
|
||||
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, """);
|
||||
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 */
|
||||
|
|
|
|||
105
src/components/FlashcardView.tsx
Normal file
105
src/components/FlashcardView.tsx
Normal 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
160
src/components/GitPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
src/components/GraphAnalytics.tsx
Normal file
126
src/components/GraphAnalytics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
138
src/components/HistoryPanel.tsx
Normal file
138
src/components/HistoryPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/components/ImportExport.tsx
Normal file
76
src/components/ImportExport.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
src/components/KanbanView.tsx
Normal file
115
src/components/KanbanView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
src/components/LinkPreview.tsx
Normal file
147
src/components/LinkPreview.tsx
Normal 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;
|
||||
}
|
||||
69
src/components/LockScreen.tsx
Normal file
69
src/components/LockScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
src/components/MiniGraph.tsx
Normal file
133
src/components/MiniGraph.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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(" ");
|
||||
}
|
||||
|
|
|
|||
127
src/components/PropertiesPanel.tsx
Normal file
127
src/components/PropertiesPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/components/RefactorMenu.tsx
Normal file
104
src/components/RefactorMenu.tsx
Normal 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;
|
||||
}
|
||||
101
src/components/SearchReplace.tsx
Normal file
101
src/components/SearchReplace.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
src/components/ShortcutsEditor.tsx
Normal file
122
src/components/ShortcutsEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
104
src/components/SlashMenu.tsx
Normal file
104
src/components/SlashMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
src/components/SplitView.tsx
Normal file
122
src/components/SplitView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/components/StatusBar.tsx
Normal file
60
src/components/StatusBar.tsx
Normal 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
67
src/components/TabBar.tsx
Normal 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 };
|
||||
192
src/components/TableEditor.tsx
Normal file
192
src/components/TableEditor.tsx
Normal 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");
|
||||
}
|
||||
91
src/components/TableOfContents.tsx
Normal file
91
src/components/TableOfContents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
src/components/ThemePicker.tsx
Normal file
186
src/components/ThemePicker.tsx
Normal 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(() => { });
|
||||
}, []);
|
||||
}
|
||||
98
src/components/TimelineView.tsx
Normal file
98
src/components/TimelineView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
src/components/TransclusionBlock.tsx
Normal file
122
src/components/TransclusionBlock.tsx
Normal 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;
|
||||
}
|
||||
72
src/components/WhiteboardView.tsx
Normal file
72
src/components/WhiteboardView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4303
src/index.css
4303
src/index.css
File diff suppressed because it is too large
Load diff
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue