feat: Graph Notes app with semantic zoom graph, wikilink tokens, and shadcn-inspired UI

- Tauri v2 desktop app with React, TypeScript, Tailwind CSS v4
- Contenteditable editor with [[wikilink]] token chips (compact pills, unwrap on backspace/delete)
- Wikilink autocomplete: type [[ to search and link notes
- Force-directed graph view with semantic zoom (circle→card morph)
- Single-click zooms into node, double-click opens note
- shadcn-inspired design system: zinc neutrals, purple accents, gradient buttons
- Sidebar with search, file tree, active indicators, daily note shortcut
- Backlinks panel showing linked mentions with context
- File-based vault stored in local filesystem via Tauri FS plugin
This commit is contained in:
enzotar 2026-03-07 00:21:49 -08:00
commit 706c7ac5ad
56 changed files with 11824 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
}

7
README.md Normal file
View file

@ -0,0 +1,7 @@
# Tauri + React + Typescript
This template should help get you started developing with Tauri, React and Typescript in Vite.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Graph Notes</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2757
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

32
package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "graph-notes",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"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"
},
"devDependencies": {
"@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"
}
}

6
public/tauri.svg Normal file
View file

@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

1
public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

7
src-tauri/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

5289
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

24
src-tauri/Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "graph-notes"
version = "0.1.0"
description = "A graph-based note-taking app"
authors = ["you"]
edition = "2021"
[lib]
name = "graph_notes_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
walkdir = "2"
regex = "1"
chrono = "0.4"

3
src-tauri/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,17 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": [
"main"
],
"permissions": [
"core:default",
"opener:default",
"fs:default",
"fs:read-all",
"fs:write-all",
"fs:scope",
"dialog:default"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

260
src-tauri/src/lib.rs Normal file
View file

@ -0,0 +1,260 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use regex::Regex;
use chrono::Local;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NoteEntry {
pub path: String,
pub name: String,
pub is_dir: bool,
pub children: Option<Vec<NoteEntry>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GraphData {
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GraphNode {
pub id: String,
pub label: String,
pub path: String,
pub link_count: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GraphEdge {
pub source: String,
pub target: String,
}
fn normalize_note_name(name: &str) -> String {
name.trim().to_lowercase()
}
fn extract_wikilinks(content: &str) -> Vec<String> {
let re = Regex::new(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]").unwrap();
re.captures_iter(content)
.map(|cap| cap[1].trim().to_string())
.collect()
}
#[tauri::command]
fn list_notes(vault_path: String) -> Result<Vec<NoteEntry>, String> {
let vault = Path::new(&vault_path);
if !vault.exists() {
return Err("Vault path does not exist".to_string());
}
fn build_tree(dir: &Path, base: &Path) -> Vec<NoteEntry> {
let mut entries: Vec<NoteEntry> = Vec::new();
if let Ok(read_dir) = fs::read_dir(dir) {
let mut items: Vec<_> = read_dir.filter_map(|e| e.ok()).collect();
items.sort_by_key(|e| e.file_name());
for entry in items {
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
// Skip hidden files/dirs
if file_name.starts_with('.') {
continue;
}
if path.is_dir() {
let children = build_tree(&path, base);
let rel = path.strip_prefix(base).unwrap_or(&path);
entries.push(NoteEntry {
path: rel.to_string_lossy().to_string(),
name: file_name,
is_dir: true,
children: Some(children),
});
} else if path.extension().map_or(false, |ext| ext == "md") {
let rel = path.strip_prefix(base).unwrap_or(&path);
entries.push(NoteEntry {
path: rel.to_string_lossy().to_string(),
name: file_name.trim_end_matches(".md").to_string(),
is_dir: false,
children: None,
});
}
}
}
entries
}
Ok(build_tree(vault, vault))
}
#[tauri::command]
fn read_note(vault_path: String, relative_path: String) -> Result<String, String> {
let full_path = Path::new(&vault_path).join(&relative_path);
fs::read_to_string(&full_path).map_err(|e| format!("Failed to read note: {}", e))
}
#[tauri::command]
fn write_note(vault_path: String, relative_path: String, content: String) -> Result<(), String> {
let full_path = Path::new(&vault_path).join(&relative_path);
// Ensure parent directory exists
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?;
}
fs::write(&full_path, content).map_err(|e| format!("Failed to write note: {}", e))
}
#[tauri::command]
fn delete_note(vault_path: String, relative_path: String) -> Result<(), String> {
let full_path = Path::new(&vault_path).join(&relative_path);
if full_path.is_file() {
fs::remove_file(&full_path).map_err(|e| format!("Failed to delete note: {}", e))
} else {
Err("Note not found".to_string())
}
}
#[tauri::command]
fn build_graph(vault_path: String) -> Result<GraphData, String> {
let vault = Path::new(&vault_path);
if !vault.exists() {
return Err("Vault path does not exist".to_string());
}
let mut nodes: Vec<GraphNode> = Vec::new();
let mut edges: Vec<GraphEdge> = Vec::new();
// Collect all notes
let mut note_map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for entry in WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "md"))
{
let rel_path = entry.path().strip_prefix(vault).unwrap_or(entry.path());
let rel_str = rel_path.to_string_lossy().to_string();
let name = rel_path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
note_map.insert(normalize_note_name(&name), rel_str.clone());
nodes.push(GraphNode {
id: rel_str.clone(),
label: name,
path: rel_str,
link_count: 0,
});
}
// Parse links and build edges
for node in &mut nodes {
let full_path = vault.join(&node.path);
if let Ok(content) = fs::read_to_string(&full_path) {
let links = extract_wikilinks(&content);
node.link_count = links.len();
for link in &links {
let normalized = normalize_note_name(link);
if let Some(target_path) = note_map.get(&normalized) {
edges.push(GraphEdge {
source: node.id.clone(),
target: target_path.clone(),
});
}
}
}
}
Ok(GraphData { nodes, edges })
}
#[tauri::command]
fn get_or_create_daily(vault_path: String) -> Result<String, String> {
let today = Local::now().format("%Y-%m-%d").to_string();
let daily_dir = Path::new(&vault_path).join("daily");
let daily_path = daily_dir.join(format!("{}.md", today));
let relative_path = format!("daily/{}.md", today);
if !daily_dir.exists() {
fs::create_dir_all(&daily_dir).map_err(|e| format!("Failed to create daily dir: {}", e))?;
}
if !daily_path.exists() {
let content = format!("# {}\n\n", today);
fs::write(&daily_path, content).map_err(|e| format!("Failed to create daily note: {}", e))?;
}
Ok(relative_path)
}
#[tauri::command]
fn get_vault_path() -> Result<Option<String>, String> {
let config_dir = dirs_config_path();
if config_dir.exists() {
let content = fs::read_to_string(&config_dir).map_err(|e| e.to_string())?;
let trimmed = content.trim().to_string();
if trimmed.is_empty() {
Ok(None)
} else {
Ok(Some(trimmed))
}
} else {
Ok(None)
}
}
#[tauri::command]
fn set_vault_path(path: String) -> Result<(), String> {
let config_path = dirs_config_path();
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::write(&config_path, &path).map_err(|e| e.to_string())
}
fn dirs_config_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
Path::new(&home).join(".config").join("graph-notes").join("vault_path")
}
#[tauri::command]
fn ensure_vault(vault_path: String) -> Result<(), String> {
let vault = Path::new(&vault_path);
if !vault.exists() {
fs::create_dir_all(vault).map_err(|e| format!("Failed to create vault: {}", e))?;
}
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
list_notes,
read_note,
write_note,
delete_note,
build_graph,
get_or_create_daily,
get_vault_path,
set_vault_path,
ensure_vault,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View file

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
graph_notes_lib::run()
}

44
src-tauri/tauri.conf.json Normal file
View file

@ -0,0 +1,44 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Graph Notes",
"version": "0.1.0",
"identifier": "com.graphnotes.app",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Graph Notes",
"width": 1400,
"height": 900,
"minWidth": 900,
"minHeight": 600,
"decorations": true,
"resizable": true
}
],
"security": {
"csp": null
}
},
"plugins": {
"fs": {
"requireLiteralLeadingDot": false
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

116
src/App.css Normal file
View file

@ -0,0 +1,116 @@
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafb);
}
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
.container {
margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
display: flex;
justify-content: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
text-align: center;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
outline: none;
}
#greet-input {
margin-right: 5px;
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
a:hover {
color: #24c8db;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
button:active {
background-color: #0f0f0f69;
}
}

299
src/App.tsx Normal file
View file

@ -0,0 +1,299 @@
import { useState, useEffect, useCallback, createContext, useContext } from "react";
import { Routes, Route, useNavigate, useParams } from "react-router-dom";
import { Sidebar } from "./components/Sidebar";
import { Editor } from "./components/Editor";
import { Backlinks } from "./components/Backlinks";
import { GraphView } from "./components/GraphView";
import {
listNotes,
readNote,
writeNote,
getVaultPath,
setVaultPath,
ensureVault,
getOrCreateDaily,
type NoteEntry,
} from "./lib/commands";
import { extractWikilinks, type BacklinkEntry } from "./lib/wikilinks";
/* ── Vault Context ──────────────────────────────────────────── */
interface VaultContextType {
vaultPath: string;
notes: NoteEntry[];
refreshNotes: () => Promise<void>;
currentNote: string | null;
setCurrentNote: (path: string | null) => void;
noteContent: string;
setNoteContent: (content: string) => void;
backlinks: BacklinkEntry[];
navigateToNote: (name: string) => void;
}
const VaultContext = createContext<VaultContextType>(null!);
export const useVault = () => useContext(VaultContext);
/* ── Main App ───────────────────────────────────────────────── */
export default function App() {
const [vaultPath, setVaultPathState] = useState<string>("");
const [notes, setNotes] = useState<NoteEntry[]>([]);
const [currentNote, setCurrentNote] = useState<string | null>(null);
const [noteContent, setNoteContent] = useState<string>("");
const [backlinks, setBacklinks] = useState<BacklinkEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
// Initialize vault
useEffect(() => {
(async () => {
try {
console.log("[GraphNotes] Initializing vault...");
let path: string | null = null;
try {
path = await getVaultPath();
console.log("[GraphNotes] Stored vault path:", path);
} catch (e) {
console.warn("[GraphNotes] getVaultPath failed:", e);
}
if (!path) {
// Default to the project's vault directory
path = "/home/amir/code/notes/vault";
console.log("[GraphNotes] Using default vault path:", path);
try {
await setVaultPath(path);
} catch (e) {
console.warn("[GraphNotes] setVaultPath failed:", e);
}
}
try {
await ensureVault(path);
} catch (e) {
console.warn("[GraphNotes] ensureVault failed:", e);
}
setVaultPathState(path);
console.log("[GraphNotes] Vault ready at:", path);
setLoading(false);
} catch (e) {
console.error("[GraphNotes] Init failed:", e);
setError(String(e));
setLoading(false);
}
})();
}, []);
// Load notes when vault is ready
const refreshNotes = useCallback(async () => {
if (!vaultPath) return;
const entries = await listNotes(vaultPath);
setNotes(entries);
}, [vaultPath]);
useEffect(() => {
if (vaultPath) refreshNotes();
}, [vaultPath, refreshNotes]);
// Build backlinks for current note
useEffect(() => {
if (!vaultPath || !currentNote || !notes.length) {
setBacklinks([]);
return;
}
(async () => {
const currentName = currentNote
.replace(/\.md$/, "")
.split("/")
.pop()
?.toLowerCase();
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),
});
}
}
} catch {
// Skip unreadable notes
}
}
setBacklinks(entries);
})();
}, [vaultPath, currentNote, notes]);
const navigateToNote = useCallback(
(name: string) => {
// Find the note by name (case-insensitive)
const allPaths = flattenNotes(notes);
const match = allPaths.find(
(p) =>
p
.replace(/\.md$/, "")
.split("/")
.pop()
?.toLowerCase() === name.toLowerCase()
);
if (match) {
navigate(`/note/${encodeURIComponent(match)}`);
} else {
// Create new note
const newPath = `${name}.md`;
writeNote(vaultPath, newPath, `# ${name}\n\n`).then(() => {
refreshNotes();
navigate(`/note/${encodeURIComponent(newPath)}`);
});
}
},
[notes, vaultPath, navigate, refreshNotes]
);
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center animate-fade-in">
<div className="text-4xl mb-4">📝</div>
<p className="text-[var(--text-secondary)]">Loading vault...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center animate-fade-in max-w-md">
<div className="text-4xl mb-4"></div>
<p className="text-[var(--text-primary)] font-semibold mb-2">Failed to load vault</p>
<p className="text-[var(--text-muted)] text-sm">{error}</p>
</div>
</div>
);
}
return (
<VaultContext.Provider
value={{
vaultPath,
notes,
refreshNotes,
currentNote,
setCurrentNote,
noteContent,
setNoteContent,
backlinks,
navigateToNote,
}}
>
<div className="flex h-screen w-screen overflow-hidden">
<Sidebar />
<Routes>
<Route path="/" element={<WelcomeScreen />} />
<Route path="/note/:path" element={<NoteView />} />
<Route path="/daily" element={<DailyView />} />
<Route path="/graph" element={<GraphView />} />
</Routes>
</div>
</VaultContext.Provider>
);
}
/* ── Note View ──────────────────────────────────────────────── */
function NoteView() {
const { path } = useParams<{ path: string }>();
const { vaultPath, setCurrentNote, noteContent, setNoteContent } = useVault();
const decodedPath = decodeURIComponent(path || "");
useEffect(() => {
if (!decodedPath || !vaultPath) return;
setCurrentNote(decodedPath);
readNote(vaultPath, decodedPath).then(setNoteContent).catch(() => setNoteContent(""));
}, [decodedPath, vaultPath, setCurrentNote, setNoteContent]);
return (
<div className="flex flex-1 overflow-hidden">
<main className="flex-1 overflow-y-auto">
<Editor />
</main>
<Backlinks />
</div>
);
}
/* ── Daily View ─────────────────────────────────────────────── */
function DailyView() {
const { vaultPath, refreshNotes } = useVault();
const navigate = useNavigate();
useEffect(() => {
if (!vaultPath) return;
getOrCreateDaily(vaultPath).then((dailyPath) => {
refreshNotes();
navigate(`/note/${encodeURIComponent(dailyPath)}`, { replace: true });
});
}, [vaultPath, navigate, refreshNotes]);
return (
<div className="flex-1 flex items-center justify-center">
<p className="text-[var(--text-muted)]">Creating daily note...</p>
</div>
);
}
/* ── Welcome Screen ─────────────────────────────────────────── */
function WelcomeScreen() {
const navigate = useNavigate();
return (
<div className="welcome">
<div className="welcome-card">
<div className="welcome-icon">📝</div>
<h1 className="welcome-title">Graph Notes</h1>
<p className="welcome-subtitle">
A local-first knowledge base with bidirectional linking
and graph visualization. Your notes, your data.
</p>
<div className="welcome-actions">
<button className="btn btn-primary" onClick={() => navigate("/daily")}>
📅 Today's Note
</button>
<button className="btn" onClick={() => navigate("/graph")}>
🔮 Graph View
</button>
</div>
</div>
</div>
);
}
/* ── Helpers ────────────────────────────────────────────────── */
function flattenNotes(entries: NoteEntry[]): 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;
}

1
src/assets/react.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1,93 @@
import { useState } from "react";
import { useVault } from "../App";
export function Backlinks() {
const { backlinks, navigateToNote, currentNote } = useVault();
const [isExpanded, setIsExpanded] = useState(true);
const noteName = currentNote
?.replace(/\.md$/, "")
.split("/")
.pop() || "";
if (!currentNote) return null;
return (
<aside className="backlinks-panel">
{/* ── Header ── */}
<div className="backlinks-header">
<button
className="flex items-center justify-between w-full cursor-pointer"
style={{ background: "none", border: "none", color: "inherit", padding: 0 }}
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<span className="backlinks-title">Backlinks</span>
<span className="badge badge-purple">{backlinks.length}</span>
</div>
<span
style={{
fontSize: 10,
color: "var(--text-muted)",
transition: "transform 150ms ease",
transform: isExpanded ? "rotate(0)" : "rotate(-90deg)",
}}
>
</span>
</button>
</div>
{/* ── Content ── */}
{isExpanded && (
<div className="flex-1 overflow-y-auto" style={{ padding: "8px 0" }}>
{backlinks.length === 0 ? (
<div style={{ padding: "32px 16px", textAlign: "center" }}>
<p style={{ fontSize: 28, marginBottom: 8, opacity: 0.3 }}>🔗</p>
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
No notes link to <strong>{noteName}</strong>
</p>
<p style={{ fontSize: 10, color: "var(--text-muted)", marginTop: 4 }}>
Use <code className="wikilink" style={{ fontSize: 10 }}>[[{noteName}]]</code> in another note
</p>
</div>
) : (
<>
{backlinks.map((bl, i) => (
<div
key={`${bl.sourcePath}-${i}`}
className="backlink-item"
onClick={() => navigateToNote(bl.sourceName)}
>
<span className="backlink-name">📄 {bl.sourceName}</span>
{bl.context && (
<span className="backlink-context">
{highlightMention(bl.context, noteName)}
</span>
)}
</div>
))}
</>
)}
</div>
)}
</aside>
);
}
function highlightMention(text: string, noteName: string) {
const regex = new RegExp(`(\\[\\[${escapeRegex(noteName)}\\]\\])`, "gi");
const parts = text.split(regex);
return parts.map((part, i) =>
regex.test(part) ? (
<span key={i} className="wikilink" style={{ fontSize: 10 }}>{noteName}</span>
) : (
<span key={i}>{part}</span>
)
);
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

586
src/components/Editor.tsx Normal file
View file

@ -0,0 +1,586 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { useVault } from "../App";
import { writeNote } from "../lib/commands";
import { extractWikilinks } from "../lib/wikilinks";
import { marked } from "marked";
interface AutocompleteState {
active: boolean;
query: string;
range: Range | null; // Range from [[ to cursor
selectedIndex: number;
position: { top: number; left: number };
}
export function Editor() {
const { vaultPath, currentNote, noteContent, setNoteContent, navigateToNote, notes } = 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 [isSaving, setIsSaving] = useState(false);
const [autocomplete, setAutocomplete] = useState<AutocompleteState>({
active: false, query: "", range: null, selectedIndex: 0,
position: { top: 0, left: 0 },
});
const noteName = currentNote
?.replace(/\.md$/, "")
.split("/")
.pop() || "Untitled";
const links = extractWikilinks(noteContent);
const wordCount = noteContent.trim().split(/\s+/).filter(Boolean).length;
const allNoteNames = flattenNoteNames(notes);
const filteredNotes = autocomplete.active
? allNoteNames.filter((n) =>
n.toLowerCase().includes(autocomplete.query.toLowerCase())
)
: [];
// ── Save with debounce ──
const saveContent = useCallback(
(value: string) => {
setNoteContent(value);
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(async () => {
if (vaultPath && currentNote) {
setIsSaving(true);
await writeNote(vaultPath, currentNote, value);
setIsSaving(false);
}
}, 500);
},
[vaultPath, currentNote, setNoteContent]
);
// ── Extract raw markdown from contenteditable DOM ──
const extractRaw = useCallback((): string => {
if (!ceRef.current) return "";
return domToMarkdown(ceRef.current);
}, []);
// ── Render markdown into contenteditable HTML ──
const renderToDOM = useCallback((raw: string) => {
if (!ceRef.current) return;
const html = markdownToTokenHTML(raw);
ceRef.current.innerHTML = html || '<br>';
}, []);
// ── Initialize / switch note ──
useEffect(() => {
if (currentNote !== lastNoteRef.current) {
lastNoteRef.current = currentNote;
renderToDOM(noteContent);
}
}, [currentNote, noteContent, renderToDOM]);
// ── Handle input events ──
const handleInput = useCallback(() => {
if (isComposingRef.current) return;
const raw = extractRaw();
saveContent(raw);
checkAutocomplete();
}, [extractRaw, saveContent]);
// ── Check for [[ autocomplete trigger ──
const checkAutocomplete = useCallback(() => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
if (!range.collapsed) {
setAutocomplete(prev => ({ ...prev, active: false }));
return;
}
// Get text before cursor in the current text node
const node = range.startContainer;
if (node.nodeType !== Node.TEXT_NODE) {
setAutocomplete(prev => ({ ...prev, active: false }));
return;
}
const text = node.textContent || "";
const offset = range.startOffset;
const textBefore = text.substring(0, offset);
const lastOpen = textBefore.lastIndexOf("[[");
const lastClose = textBefore.lastIndexOf("]]");
if (lastOpen !== -1 && lastOpen > lastClose) {
const query = textBefore.substring(lastOpen + 2);
if (!query.includes("\n")) {
// Get position for dropdown
const rect = range.getBoundingClientRect();
const containerRect = ceRef.current?.parentElement?.getBoundingClientRect() || rect;
setAutocomplete({
active: true,
query,
range: range.cloneRange(),
selectedIndex: 0,
position: {
top: rect.bottom - containerRect.top + 4,
left: rect.left - containerRect.left,
},
});
return;
}
}
setAutocomplete(prev => ({ ...prev, active: false }));
}, []);
// ── Insert wikilink token ──
const insertToken = useCallback(
(name: string) => {
const sel = window.getSelection();
if (!sel || !ceRef.current) return;
// Find the [[ in the current text node and replace through cursor
const node = sel.focusNode;
if (!node || node.nodeType !== Node.TEXT_NODE) return;
const text = node.textContent || "";
const offset = sel.focusOffset;
const textBefore = text.substring(0, offset);
const lastOpen = textBefore.lastIndexOf("[[");
if (lastOpen === -1) return;
const before = text.substring(0, lastOpen);
const after = text.substring(offset);
// Create token element
const token = createTokenElement(name, `[[${name}]]`);
const beforeNode = document.createTextNode(before);
const afterNode = document.createTextNode(after || "\u200B"); // zero-width space if empty
const parent = node.parentNode!;
parent.insertBefore(beforeNode, node);
parent.insertBefore(token, node);
parent.insertBefore(afterNode, node);
parent.removeChild(node);
// Place cursor after the token
const newRange = document.createRange();
newRange.setStart(afterNode, after ? 0 : 1);
newRange.collapse(true);
sel.removeAllRanges();
sel.addRange(newRange);
setAutocomplete(prev => ({ ...prev, active: false }));
// Save
const raw = extractRaw();
saveContent(raw);
},
[extractRaw, saveContent]
);
// ── Handle keydown ──
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
// Autocomplete navigation
if (autocomplete.active) {
if (filteredNotes.length > 0) {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setAutocomplete(prev => ({
...prev,
selectedIndex: Math.min(prev.selectedIndex + 1, filteredNotes.length - 1),
}));
return;
case "ArrowUp":
e.preventDefault();
setAutocomplete(prev => ({
...prev,
selectedIndex: Math.max(prev.selectedIndex - 1, 0),
}));
return;
case "Enter":
case "Tab":
e.preventDefault();
insertToken(filteredNotes[autocomplete.selectedIndex]);
return;
case "Escape":
e.preventDefault();
setAutocomplete(prev => ({ ...prev, active: false }));
return;
}
} else if (autocomplete.query.length > 0) {
if (e.key === "Enter") {
e.preventDefault();
insertToken(autocomplete.query);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setAutocomplete(prev => ({ ...prev, active: false }));
return;
}
}
}
// Token unwrap on Backspace/Delete
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
if (e.key === "Backspace") {
const node = sel.focusNode;
const offset = sel.focusOffset;
if (node?.nodeType === Node.TEXT_NODE && offset === 0) {
// Cursor at start of text node — check previous sibling
const prev = node.previousSibling as HTMLElement | null;
if (prev?.classList?.contains("wikilink-token")) {
e.preventDefault();
unwrapToken(prev, sel, "end");
const raw = extractRaw();
saveContent(raw);
return;
}
}
// Check if cursor is right after a token (in parent, between nodes)
if (node?.nodeType === Node.ELEMENT_NODE && offset > 0) {
const prevChild = node.childNodes[offset - 1] as HTMLElement | null;
if (prevChild?.classList?.contains("wikilink-token")) {
e.preventDefault();
unwrapToken(prevChild, sel, "end");
const raw = extractRaw();
saveContent(raw);
return;
}
}
}
if (e.key === "Delete") {
const node = sel.focusNode;
const offset = sel.focusOffset;
if (node?.nodeType === Node.TEXT_NODE && offset === (node.textContent?.length || 0)) {
const next = node.nextSibling as HTMLElement | null;
if (next?.classList?.contains("wikilink-token")) {
e.preventDefault();
unwrapToken(next, sel, "start");
const raw = extractRaw();
saveContent(raw);
return;
}
}
if (node?.nodeType === Node.ELEMENT_NODE && offset < node.childNodes.length) {
const nextChild = node.childNodes[offset] as HTMLElement | null;
if (nextChild?.classList?.contains("wikilink-token")) {
e.preventDefault();
unwrapToken(nextChild, sel, "start");
const raw = extractRaw();
saveContent(raw);
return;
}
}
}
},
[autocomplete, filteredNotes, insertToken, extractRaw, saveContent]
);
// ── Handle click on token → navigate ──
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
if (target.classList.contains("wikilink-token")) {
e.preventDefault();
const linkTarget = target.dataset.target;
if (linkTarget) navigateToNote(linkTarget);
}
},
[navigateToNote]
);
// ── Preview click handler ──
useEffect(() => {
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.classList.contains("wikilink")) {
e.preventDefault();
const linkTarget = target.getAttribute("data-target");
if (linkTarget) navigateToNote(linkTarget);
}
};
const el = editorRef.current;
el?.addEventListener("click", handler);
return () => el?.removeEventListener("click", handler);
}, [navigateToNote]);
// ── Close autocomplete on outside click ──
useEffect(() => {
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest(".autocomplete-dropdown") && !target.closest("[contenteditable]")) {
setAutocomplete(prev => ({ ...prev, active: false }));
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
if (!currentNote) {
return (
<div className="flex-1 flex items-center justify-center">
<p className="text-[var(--text-muted)]">Select a note to begin editing</p>
</div>
);
}
const renderedMarkdown = (() => {
let html = marked(noteContent, { async: false }) as string;
html = html.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_m, target, display) => {
const label = display?.trim() || target.trim();
return `<span class="wikilink" data-target="${target.trim()}">${label}</span>`;
}
);
return html;
})();
return (
<div className="flex flex-col h-full" ref={editorRef}>
{/* ── Header ── */}
<div className="editor-header">
<div className="flex items-center gap-3">
<h2 className="editor-title">{noteName}</h2>
{isSaving && (
<div className="save-indicator">
<span className="save-dot" />
Saving
</div>
)}
</div>
<div className="editor-meta">
<span className="editor-meta-item">{wordCount} words</span>
<span className="editor-meta-divider" />
<span className="editor-meta-item">{links.length} links</span>
<div className="toggle-group">
<button
className={`toggle-item ${!isPreview ? "active" : ""}`}
onClick={() => setIsPreview(false)}
>
Edit
</button>
<button
className={`toggle-item ${isPreview ? "active" : ""}`}
onClick={() => setIsPreview(true)}
>
Preview
</button>
</div>
</div>
</div>
{/* ── Content ── */}
<div className="flex-1 overflow-y-auto relative">
{isPreview ? (
<div
className="prose prose-invert max-w-none px-8 py-6"
style={{
color: "var(--text-primary)",
lineHeight: 1.8,
fontSize: "15px",
}}
dangerouslySetInnerHTML={{ __html: renderedMarkdown }}
/>
) : (
<>
{/* Contenteditable editor */}
<div
ref={ceRef}
contentEditable
suppressContentEditableWarning
className="editor-ce"
onInput={handleInput}
onKeyDown={handleKeyDown}
onClick={handleClick}
onCompositionStart={() => { isComposingRef.current = true; }}
onCompositionEnd={() => {
isComposingRef.current = false;
handleInput();
}}
data-placeholder="Start writing... Type [[ to link to another note"
/>
{/* ── Autocomplete Dropdown ── */}
{autocomplete.active && filteredNotes.length > 0 && (
<div
className="autocomplete-dropdown"
style={{
position: "absolute",
top: autocomplete.position.top,
left: autocomplete.position.left,
zIndex: 50,
}}
>
<div className="autocomplete-header">🔗 Link to note</div>
{filteredNotes.slice(0, 8).map((name, i) => (
<div
key={name}
className={`autocomplete-item ${i === autocomplete.selectedIndex ? "selected" : ""}`}
onMouseDown={(e) => { e.preventDefault(); insertToken(name); }}
onMouseEnter={() => setAutocomplete(prev => ({ ...prev, selectedIndex: i }))}
>
<span className="autocomplete-icon">📄</span>
<span className="autocomplete-name">
{highlightMatch(name, autocomplete.query)}
</span>
</div>
))}
<div className="autocomplete-hint"> navigate Enter select Esc close</div>
</div>
)}
{autocomplete.active && filteredNotes.length === 0 && autocomplete.query.length > 0 && (
<div
className="autocomplete-dropdown"
style={{
position: "absolute",
top: autocomplete.position.top,
left: autocomplete.position.left,
zIndex: 50,
}}
>
<div className="autocomplete-header"> Create new note</div>
<div
className="autocomplete-item selected"
onMouseDown={(e) => { e.preventDefault(); insertToken(autocomplete.query); }}
>
<span className="autocomplete-icon"></span>
<span className="autocomplete-name">
Create "<strong>{autocomplete.query}</strong>"
</span>
</div>
<div className="autocomplete-hint">Enter to create Esc close</div>
</div>
)}
</>
)}
</div>
</div>
);
}
/* ── DOM Helpers ───────────────────────────────────────────── */
/** Create a non-editable wikilink token element */
function createTokenElement(label: string, raw: string): HTMLSpanElement {
const span = document.createElement("span");
span.className = "wikilink-token";
span.contentEditable = "false";
span.dataset.raw = raw;
span.dataset.target = label;
span.textContent = label;
return span;
}
/** Unwrap a token element back to raw text */
function unwrapToken(token: HTMLElement, sel: Selection, cursorAt: "start" | "end") {
const raw = token.dataset.raw || "";
const textNode = document.createTextNode(raw);
token.replaceWith(textNode);
const range = document.createRange();
range.setStart(textNode, cursorAt === "end" ? raw.length : 0);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
/** Convert contenteditable DOM to raw markdown string */
function domToMarkdown(el: HTMLDivElement): string {
let text = "";
function walk(node: Node, isTopLevel: boolean) {
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent || "";
} else if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement;
if (element.classList.contains("wikilink-token")) {
text += element.dataset.raw || "";
return;
}
if (element.tagName === "BR") {
text += "\n";
return;
}
// DIV = line break (browser wraps lines in divs)
if (element.tagName === "DIV" && isTopLevel && text.length > 0 && !text.endsWith("\n")) {
text += "\n";
}
for (const child of Array.from(element.childNodes)) {
walk(child, false);
}
}
}
for (const child of Array.from(el.childNodes)) {
walk(child, true);
}
return text;
}
/** Convert raw markdown to tokenized HTML for contenteditable */
function markdownToTokenHTML(raw: string): string {
// Escape HTML
let escaped = raw
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
// Replace [[wikilinks]] with token spans
escaped = escaped.replace(
/\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g,
(_m, target, display) => {
const label = display?.trim() || target.trim();
const rawAttr = _m.replace(/"/g, "&quot;");
return `<span class="wikilink-token" contenteditable="false" data-raw="${rawAttr}" data-target="${target.trim()}">${label}</span>`;
}
);
// Convert newlines to <br>
escaped = escaped.replace(/\n/g, "<br>");
return escaped;
}
/** Flatten note entries to a list of display names */
function flattenNoteNames(entries: { name: string; is_dir: boolean; children?: any[] }[]): string[] {
const names: string[] = [];
for (const entry of entries) {
if (entry.is_dir && entry.children) {
names.push(...flattenNoteNames(entry.children));
} else if (!entry.is_dir) {
names.push(entry.name.replace(/\.md$/, ""));
}
}
return names;
}
/** Highlight matching portion of a note name */
function highlightMatch(name: string, query: string): React.ReactNode {
if (!query) return name;
const idx = name.toLowerCase().indexOf(query.toLowerCase());
if (idx === -1) return name;
return (
<>
{name.substring(0, idx)}
<strong style={{ color: "var(--accent-purple)" }}>
{name.substring(idx, idx + query.length)}
</strong>
{name.substring(idx + query.length)}
</>
);
}

View file

@ -0,0 +1,700 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useVault } from "../App";
import { buildGraph, readNote, 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;
}
const NODE_COLORS = [
"#8b5cf6", "#3b82f6", "#10b981", "#f59e0b", "#f43f5e",
"#06b6d4", "#a855f7", "#ec4899", "#14b8a6", "#ef4444",
];
export function GraphView() {
const { vaultPath } = useVault();
const navigate = useNavigate();
const [graphData, setGraphData] = useState<GraphData | null>(null);
useEffect(() => {
if (!vaultPath) return;
buildGraph(vaultPath).then(setGraphData);
}, [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>
);
}
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">{graphData.nodes.length} notes</span>
<span className="badge badge-muted">{graphData.edges.length} links</span>
</div>
<div style={{ fontSize: 10, color: "var(--text-muted)" }}>
Scroll to zoom · Click to focus · Double-click to open
</div>
</div>
<div className="flex-1 relative">
<ForceGraph
graphData={graphData}
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"
style={{
background: "radial-gradient(ellipse at center, #1a1a2e 0%, #0a0a0f 70%)",
}}
/>
);
}
/* ── 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;
}

188
src/components/Sidebar.tsx Normal file
View file

@ -0,0 +1,188 @@
import { useState, useMemo } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useVault } from "../App";
import { writeNote } from "../lib/commands";
import type { NoteEntry } from "../lib/commands";
export function Sidebar() {
const { notes, vaultPath, refreshNotes } = useVault();
const [search, setSearch] = useState("");
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const navigate = useNavigate();
const location = useLocation();
const filteredNotes = useMemo(() => {
if (!search.trim()) return notes;
return filterNotes(notes, search.toLowerCase());
}, [notes, search]);
const handleCreateNote = async () => {
const name = prompt("Note name:");
if (!name?.trim()) return;
const path = `${name.trim()}.md`;
await writeNote(vaultPath, path, `# ${name.trim()}\n\n`);
await refreshNotes();
navigate(`/note/${encodeURIComponent(path)}`);
};
const toggleFolder = (path: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
};
return (
<aside className="sidebar">
{/* ── Brand + Search ── */}
<div className="sidebar-header">
<div className="sidebar-brand">
<div className="sidebar-brand-icon">📝</div>
<span className="sidebar-brand-text">GRAPH NOTES</span>
<div style={{ flex: 1 }} />
<button
className="sidebar-new-btn"
onClick={handleCreateNote}
title="New note"
>
+
</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" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="Search notes..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{/* ── Quick Actions ── */}
<div className="sidebar-actions">
<button
className={`sidebar-action ${location.pathname === "/daily" ? "active" : ""}`}
onClick={() => navigate("/daily")}
>
<span className="sidebar-action-icon">📅</span>
Daily Note
</button>
<button
className={`sidebar-action ${location.pathname === "/graph" ? "active" : ""}`}
onClick={() => navigate("/graph")}
>
<span className="sidebar-action-icon">🔮</span>
Graph View
</button>
</div>
{/* ── File Tree ── */}
<div className="sidebar-tree">
<div className="sidebar-section-label">
<span>Notes</span>
<span className="badge badge-muted">{countFiles(notes)}</span>
</div>
<NoteTree
entries={filteredNotes}
collapsed={collapsed}
onToggle={toggleFolder}
depth={0}
/>
{filteredNotes.length === 0 && (
<p style={{ padding: "24px 16px", fontSize: 11, color: "var(--text-muted)", textAlign: "center" }}>
{search ? "No matching notes" : "No notes yet"}
</p>
)}
</div>
</aside>
);
}
/* ── Recursive File Tree ────────────────────────────────────── */
function NoteTree({
entries, collapsed, onToggle, depth,
}: {
entries: NoteEntry[];
collapsed: Set<string>;
onToggle: (path: string) => void;
depth: number;
}) {
const navigate = useNavigate();
const location = useLocation();
return (
<ul style={{ listStyle: "none" }}>
{entries.map((entry) => {
const isActive = location.pathname === `/note/${encodeURIComponent(entry.path)}`;
const isCollapsed = collapsed.has(entry.path);
if (entry.is_dir) {
return (
<li key={entry.path}>
<button
className="tree-item"
style={{ paddingLeft: `${14 + depth * 16}px` }}
onClick={() => onToggle(entry.path)}
>
<span
className="tree-item-chevron"
style={{ transform: isCollapsed ? "rotate(-90deg)" : "rotate(0)" }}
>
</span>
<span className="tree-item-icon">📁</span>
<span className="tree-item-label" style={{ fontWeight: 500 }}>{entry.name}</span>
</button>
{!isCollapsed && entry.children && (
<NoteTree entries={entry.children} collapsed={collapsed} onToggle={onToggle} depth={depth + 1} />
)}
</li>
);
}
return (
<li key={entry.path}>
<button
className={`tree-item ${isActive ? "active" : ""}`}
style={{ paddingLeft: `${14 + depth * 16}px` }}
onClick={() => navigate(`/note/${encodeURIComponent(entry.path)}`)}
>
<span className="tree-item-icon">📄</span>
<span className="tree-item-label">{entry.name.replace(/\.md$/, "")}</span>
</button>
</li>
);
})}
</ul>
);
}
/* ── Helpers ────────────────────────────────────────────────── */
function filterNotes(entries: NoteEntry[], query: string): NoteEntry[] {
const result: NoteEntry[] = [];
for (const entry of entries) {
if (entry.is_dir && entry.children) {
const filtered = filterNotes(entry.children, query);
if (filtered.length > 0) result.push({ ...entry, children: filtered });
} else if (entry.name.toLowerCase().includes(query)) {
result.push(entry);
}
}
return result;
}
function countFiles(entries: NoteEntry[]): number {
let count = 0;
for (const e of entries) {
if (e.is_dir && e.children) count += countFiles(e.children);
else if (!e.is_dir) count++;
}
return count;
}

928
src/index.css Normal file
View file

@ -0,0 +1,928 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
@import "tailwindcss";
/* ── Design Tokens ─────────────────────────────────────────── */
:root {
/* Surfaces */
--bg-primary: #09090b;
--bg-secondary: #0f0f14;
--bg-tertiary: #18181f;
--bg-elevated: #1f1f2c;
--bg-hover: #27273a;
--bg-active: #2e2e45;
/* Text */
--text-primary: #fafafa;
--text-secondary: #a1a1aa;
--text-muted: #52525b;
--text-accent: #a78bfa;
/* Borders */
--border-primary: rgba(255, 255, 255, 0.06);
--border-secondary: rgba(255, 255, 255, 0.10);
--border-accent: rgba(139, 92, 246, 0.4);
--border-focus: rgba(139, 92, 246, 0.6);
/* Accents */
--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);
--accent-purple-subtle: rgba(139, 92, 246, 0.06);
--accent-blue: #60a5fa;
--accent-emerald: #34d399;
--accent-amber: #fbbf24;
--accent-rose: #fb7185;
/* Effects */
--glass-bg: rgba(15, 15, 20, 0.80);
--glass-border: rgba(255, 255, 255, 0.05);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.50);
--shadow-glow: 0 0 20px rgba(139, 92, 246, 0.15);
/* Radii */
--radius-xs: 4px;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 20px;
--radius-full: 9999px;
/* Transitions */
--transition-fast: 100ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 180ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-spring: 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
/* Layout */
--sidebar-width: 260px;
--panel-width: 300px;
--header-height: 48px;
}
/* ── Base ───────────────────────────────────────────────────── */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
width: 100%;
overflow: hidden;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
font-feature-settings: 'cv11', 'ss01';
font-size: 14px;
line-height: 1.5;
}
/* ── Scrollbar ─────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.16);
}
::selection {
background: var(--accent-purple-dim);
color: var(--text-primary);
}
/* ── Sidebar ───────────────────────────────────────────────── */
.sidebar {
background: var(--bg-secondary);
border-right: 1px solid var(--border-primary);
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
width: var(--sidebar-width);
min-width: var(--sidebar-width);
animation: slideInLeft 200ms ease forwards;
}
.sidebar-header {
padding: 16px 16px 12px;
border-bottom: 1px solid var(--border-primary);
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.sidebar-brand-icon {
width: 28px;
height: 28px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--accent-purple-bright), var(--accent-blue));
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.3);
}
.sidebar-brand-text {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.03em;
background: linear-gradient(135deg, var(--accent-purple), var(--accent-blue));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.sidebar-search {
position: relative;
}
.sidebar-search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 14px;
color: var(--text-muted);
pointer-events: none;
}
.sidebar-search input {
width: 100%;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
padding: 7px 10px 7px 32px;
font-size: 12px;
outline: none;
transition: all var(--transition-normal);
}
.sidebar-search input:focus {
border-color: var(--border-focus);
background: var(--bg-elevated);
box-shadow: 0 0 0 3px var(--accent-purple-subtle);
}
.sidebar-search input::placeholder {
color: var(--text-muted);
}
/* ── Quick Actions ─────────────────────────────────────────── */
.sidebar-actions {
padding: 6px 8px;
border-bottom: 1px solid var(--border-primary);
display: flex;
flex-direction: column;
gap: 1px;
}
.sidebar-action {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
border: none;
background: transparent;
width: 100%;
text-align: left;
}
.sidebar-action:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sidebar-action.active {
background: var(--accent-purple-glow);
color: var(--accent-purple);
}
.sidebar-action-icon {
font-size: 14px;
width: 20px;
display: flex;
align-items: center;
justify-content: center;
}
/* ── File Tree ─────────────────────────────────────────────── */
.sidebar-tree {
flex: 1;
overflow-y: auto;
padding: 6px 0;
}
.sidebar-section-label {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 16px 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
.tree-item {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 5px 12px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
border: none;
background: transparent;
text-align: left;
border-radius: 0;
position: relative;
}
.tree-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.tree-item.active {
background: var(--accent-purple-glow);
color: var(--accent-purple);
font-weight: 500;
}
.tree-item.active::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 2px;
background: var(--accent-purple);
border-radius: 0 2px 2px 0;
}
.tree-item-icon {
font-size: 13px;
opacity: 0.5;
width: 18px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tree-item-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.tree-item-chevron {
font-size: 9px;
transition: transform var(--transition-fast);
opacity: 0.4;
width: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* ── New Note Button ───────────────────────────────────────── */
.sidebar-new-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-primary);
background: var(--bg-tertiary);
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
transition: all var(--transition-fast);
line-height: 1;
}
.sidebar-new-btn:hover {
background: var(--accent-purple-glow);
color: var(--accent-purple);
border-color: var(--border-accent);
}
/* ── Editor Header ─────────────────────────────────────────── */
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: var(--header-height);
border-bottom: 1px solid var(--border-primary);
background: var(--bg-secondary);
flex-shrink: 0;
}
.editor-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.editor-meta {
display: flex;
align-items: center;
gap: 8px;
}
.editor-meta-item {
font-size: 11px;
color: var(--text-muted);
}
.editor-meta-divider {
width: 1px;
height: 12px;
background: var(--border-primary);
}
/* ── Toggle Tabs (Edit/Preview) ────────────────────────────── */
.toggle-group {
display: flex;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
border: 1px solid var(--border-primary);
padding: 2px;
gap: 2px;
}
.toggle-item {
padding: 3px 12px;
border-radius: var(--radius-xs);
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
transition: all var(--transition-fast);
border: none;
background: transparent;
}
.toggle-item:hover {
color: var(--text-secondary);
}
.toggle-item.active {
background: var(--accent-purple-bright);
color: white;
box-shadow: var(--shadow-sm);
}
/* ── Saving Indicator ──────────────────────────────────────── */
.save-indicator {
font-size: 10px;
color: var(--accent-emerald);
display: flex;
align-items: center;
gap: 4px;
animation: fadeIn 150ms ease;
}
.save-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent-emerald);
animation: pulse-dot 1s ease infinite;
}
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* ── Contenteditable Editor ────────────────────────────────── */
.editor-ce {
min-height: 100%;
padding: 28px 36px;
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
font-size: 13.5px;
line-height: 1.8;
color: var(--text-primary);
outline: none;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
tab-size: 2;
caret-color: var(--accent-purple);
letter-spacing: -0.01em;
}
.editor-ce:empty::before {
content: attr(data-placeholder);
color: var(--text-muted);
pointer-events: none;
font-style: italic;
}
/* ── Wikilink Token ────────────────────────────────────────── */
.wikilink-token {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 0px 8px;
margin: 0 1px;
border-radius: var(--radius-full);
background: var(--accent-purple-glow);
color: var(--accent-purple);
font-family: 'Inter', sans-serif;
font-size: 12px;
font-weight: 500;
cursor: pointer;
user-select: none;
transition: all var(--transition-fast);
border: 1px solid var(--accent-purple-subtle);
vertical-align: baseline;
line-height: 1.6;
}
.wikilink-token::before {
content: '→';
font-size: 10px;
opacity: 0.6;
}
.wikilink-token:hover {
background: var(--accent-purple-dim);
border-color: var(--accent-purple);
color: #fff;
box-shadow: var(--shadow-glow);
transform: translateY(-1px);
}
/* ── Preview Wikilinks ─────────────────────────────────────── */
.wikilink {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 8px;
border-radius: var(--radius-xs);
background: var(--accent-purple-glow);
color: var(--accent-purple);
font-weight: 500;
font-size: 0.9em;
cursor: pointer;
transition: all var(--transition-fast);
text-decoration: none;
border: 1px solid transparent;
}
.wikilink:hover {
background: var(--accent-purple-dim);
border-color: var(--accent-purple);
transform: translateY(-1px);
box-shadow: var(--shadow-glow);
}
/* ── Autocomplete Dropdown ─────────────────────────────────── */
.autocomplete-dropdown {
min-width: 260px;
max-width: 360px;
background: var(--bg-elevated);
border: 1px solid var(--border-secondary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg), 0 0 0 1px var(--glass-border);
backdrop-filter: blur(24px);
overflow: hidden;
animation: dropdownIn 120ms ease forwards;
}
@keyframes dropdownIn {
from {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.autocomplete-header {
padding: 8px 12px 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
border-bottom: 1px solid var(--border-primary);
}
.autocomplete-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
cursor: pointer;
transition: all var(--transition-fast);
font-size: 12px;
color: var(--text-secondary);
}
.autocomplete-item:hover,
.autocomplete-item.selected {
background: var(--accent-purple-glow);
color: var(--text-primary);
}
.autocomplete-item.selected {
background: var(--accent-purple-dim);
border-left: 2px solid var(--accent-purple);
}
.autocomplete-icon {
font-size: 13px;
flex-shrink: 0;
}
.autocomplete-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.autocomplete-name strong {
color: var(--accent-purple);
font-weight: 600;
}
.autocomplete-empty {
padding: 12px;
text-align: center;
font-size: 11px;
color: var(--text-muted);
}
.autocomplete-hint {
padding: 5px 12px;
font-size: 10px;
color: var(--text-muted);
border-top: 1px solid var(--border-primary);
text-align: center;
}
/* ── Graph Header ──────────────────────────────────────────── */
.graph-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: var(--header-height);
border-bottom: 1px solid var(--border-primary);
background: var(--bg-secondary);
}
/* ── Backlinks Panel ───────────────────────────────────────── */
.backlinks-panel {
width: var(--panel-width);
min-width: var(--panel-width);
background: var(--bg-secondary);
border-left: 1px solid var(--border-primary);
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideInRight 200ms ease forwards;
}
.backlinks-header {
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
height: var(--header-height);
border-bottom: 1px solid var(--border-primary);
flex-shrink: 0;
}
.backlinks-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.backlink-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 16px;
cursor: pointer;
transition: background var(--transition-fast);
border-bottom: 1px solid var(--border-primary);
}
.backlink-item:hover {
background: var(--bg-hover);
}
.backlink-name {
font-size: 12px;
font-weight: 500;
color: var(--accent-purple);
}
.backlink-context {
font-size: 11px;
color: var(--text-muted);
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
/* ── Badge ─────────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: var(--radius-full);
font-size: 10px;
font-weight: 600;
}
.badge-purple {
background: var(--accent-purple-dim);
color: var(--accent-purple);
}
.badge-muted {
background: var(--bg-tertiary);
color: var(--text-muted);
}
/* keep old class name working */
.link-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: var(--radius-full);
background: var(--accent-purple-dim);
color: var(--accent-purple);
font-size: 10px;
font-weight: 600;
}
/* ── Buttons ───────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 7px 14px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
user-select: none;
}
.btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-secondary);
}
.btn:active {
transform: scale(0.97);
}
.btn-primary {
background: linear-gradient(135deg, var(--accent-purple-bright), #7c3aed);
color: white;
border-color: transparent;
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.3);
}
.btn-primary:hover {
background: linear-gradient(135deg, #9b7af7, var(--accent-purple-bright));
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.4);
transform: translateY(-1px);
}
.btn-primary:active {
transform: translateY(0) scale(0.97);
}
.btn-ghost {
background: transparent;
border-color: transparent;
}
.btn-ghost:hover {
background: var(--bg-hover);
}
.btn-icon {
padding: 6px;
width: 30px;
height: 30px;
}
/* ── Welcome Screen ────────────────────────────────────────── */
.welcome {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.welcome-card {
text-align: center;
animation: welcomeIn 500ms ease forwards;
max-width: 420px;
padding: 48px;
}
@keyframes welcomeIn {
from {
opacity: 0;
transform: translateY(16px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.welcome-icon {
width: 64px;
height: 64px;
border-radius: var(--radius-xl);
background: linear-gradient(135deg, var(--accent-purple-bright), var(--accent-blue));
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
margin: 0 auto 20px;
box-shadow: 0 8px 32px rgba(139, 92, 246, 0.3);
}
.welcome-title {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
background: linear-gradient(135deg, var(--accent-purple), var(--accent-blue));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 8px;
}
.welcome-subtitle {
font-size: 13px;
color: var(--text-muted);
line-height: 1.7;
margin-bottom: 28px;
}
.welcome-actions {
display: flex;
gap: 10px;
justify-content: center;
}
/* ── Glass Panel (compat) ──────────────────────────────────── */
.glass-panel {
background: var(--glass-bg);
backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
}
/* ── Animations ────────────────────────────────────────────── */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-8px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(8px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes pulse-glow {
0%,
100% {
box-shadow: 0 0 0 0 var(--accent-purple-dim);
}
50% {
box-shadow: 0 0 16px 4px var(--accent-purple-dim);
}
}
@keyframes shimmer {
0% {
background-position: -200px 0;
}
100% {
background-position: calc(200px + 100%) 0;
}
}
.animate-fade-in {
animation: fadeIn 300ms ease forwards;
}
.animate-slide-left {
animation: slideInLeft 200ms ease forwards;
}
.animate-slide-right {
animation: slideInRight 200ms ease forwards;
}

63
src/lib/commands.ts Normal file
View file

@ -0,0 +1,63 @@
import { invoke } from "@tauri-apps/api/core";
/* ── Types ──────────────────────────────────────────────────── */
export interface NoteEntry {
path: string;
name: string;
is_dir: boolean;
children?: NoteEntry[];
}
export interface GraphNode {
id: string;
label: string;
path: string;
link_count: number;
}
export interface GraphEdge {
source: string;
target: string;
}
export interface GraphData {
nodes: GraphNode[];
edges: GraphEdge[];
}
/* ── Commands ───────────────────────────────────────────────── */
export async function listNotes(vaultPath: string): Promise<NoteEntry[]> {
return invoke<NoteEntry[]>("list_notes", { vaultPath });
}
export async function readNote(vaultPath: string, relativePath: string): Promise<string> {
return invoke<string>("read_note", { vaultPath, relativePath });
}
export async function writeNote(vaultPath: string, relativePath: string, content: string): Promise<void> {
return invoke("write_note", { vaultPath, relativePath, content });
}
export async function deleteNote(vaultPath: string, relativePath: string): Promise<void> {
return invoke("delete_note", { vaultPath, relativePath });
}
export async function buildGraph(vaultPath: string): Promise<GraphData> {
return invoke<GraphData>("build_graph", { vaultPath });
}
export async function getOrCreateDaily(vaultPath: string): Promise<string> {
return invoke<string>("get_or_create_daily", { vaultPath });
}
export async function getVaultPath(): Promise<string | null> {
return invoke<string | null>("get_vault_path");
}
export async function setVaultPath(path: string): Promise<void> {
return invoke("set_vault_path", { path });
}
export async function ensureVault(vaultPath: string): Promise<void> {
return invoke("ensure_vault", { vaultPath });
}

123
src/lib/wikilinks.ts Normal file
View file

@ -0,0 +1,123 @@
/**
* Wikilink parser extract [[links]] and build backlink index
*/
export interface WikiLink {
target: string;
display: string;
raw: string;
}
export interface BacklinkEntry {
sourcePath: string;
sourceName: string;
context: string;
}
const WIKILINK_REGEX = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
/**
* Extract all [[wikilinks]] from markdown content
*/
export function extractWikilinks(content: string): WikiLink[] {
const links: WikiLink[] = [];
let match;
const regex = new RegExp(WIKILINK_REGEX.source, "g");
while ((match = regex.exec(content)) !== null) {
links.push({
target: match[1].trim(),
display: match[2]?.trim() || match[1].trim(),
raw: match[0],
});
}
return links;
}
/**
* Build a backlinks index from all notes
* Returns a map: noteName [{ sourcePath, sourceName, context }]
*/
export function buildBacklinksIndex(
notes: { path: string; name: string; content: string }[]
): Map<string, BacklinkEntry[]> {
const index = new Map<string, BacklinkEntry[]>();
for (const note of notes) {
const links = extractWikilinks(note.content);
for (const link of links) {
const targetKey = link.target.toLowerCase();
const existing = index.get(targetKey) || [];
// Extract context: the line containing the link
const lines = note.content.split("\n");
const contextLine = lines.find((line) => line.includes(link.raw)) || "";
const context = contextLine.trim().substring(0, 200);
existing.push({
sourcePath: note.path,
sourceName: note.name,
context,
});
index.set(targetKey, existing);
}
}
return index;
}
/**
* Replace [[wikilinks]] in text with HTML spans for rendering
*/
export function renderWikilinks(
content: string,
onClickLink?: (target: string) => void
): string {
return content.replace(WIKILINK_REGEX, (_match, target, display) => {
const label = display?.trim() || target.trim();
return `<span class="wikilink" data-target="${target.trim()}">${label}</span>`;
});
}
/**
* Find unlinked mentions of a note name in other notes
*/
export function findUnlinkedMentions(
noteName: string,
notes: { path: string; name: string; content: string }[],
currentPath: string
): BacklinkEntry[] {
const mentions: BacklinkEntry[] = [];
const nameRegex = new RegExp(`\\b${escapeRegex(noteName)}\\b`, "gi");
for (const note of notes) {
if (note.path === currentPath) continue;
// Skip if it's already a [[linked]] mention
const linkedTargets = extractWikilinks(note.content).map((l) =>
l.target.toLowerCase()
);
if (linkedTargets.includes(noteName.toLowerCase())) continue;
const lines = note.content.split("\n");
for (const line of lines) {
if (nameRegex.test(line)) {
mentions.push({
sourcePath: note.path,
sourceName: note.name,
context: line.trim().substring(0, 200),
});
break; // One mention per note
}
}
}
return mentions;
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

13
src/main.tsx Normal file
View file

@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

1
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

40
tsconfig.json Normal file
View file

@ -0,0 +1,40 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": [
"src"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

10
tsconfig.node.json Normal file
View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

27
vault/Daily Notes.md Normal file
View file

@ -0,0 +1,27 @@
# Daily Notes
**Daily Notes** are a powerful way to capture ideas, tasks, and thoughts as they come.
## How It Works
Click the 📅 **Daily Note** button in the sidebar to create or open today's note. Each day gets its own file in the `daily/` folder, named `YYYY-MM-DD.md`.
## Why Daily Notes?
- **Low friction**: No need to decide where a thought goes — just write
- **Build connections**: Link to relevant notes with `[[wikilinks]]`
- **Review later**: Browse past daily notes to rediscover ideas
## Best Practices
1. Start each day with a fresh daily note
2. Use `[[Project Name]]` to link thoughts to topics
3. Review your [[Graph View]] weekly to spot patterns
4. Use the [[Welcome]] guide if you're just getting started
Daily notes work great as an inbox — capture now, organize later using [[Markdown Syntax]] and wikilinks.
[[Daily Notes]] tis
[[Daily Notes]]

24
vault/Graph View.md Normal file
View file

@ -0,0 +1,24 @@
# Graph View
The **Graph View** is a visual representation of all your notes and their connections.
## How It Works
Every note in your vault becomes a **node** in the graph. When you create a [[Wikilink|wikilink]] between notes using `[[brackets]]`, an **edge** is drawn between them.
## Features
- **Force-directed layout**: Notes arrange themselves based on their connections
- **Zoom & Pan**: Scroll to zoom, drag the background to pan
- **Click to navigate**: Click any node to open that note
- **Node size**: Nodes with more links appear larger
- **Link count badges**: See how connected each note is
## Tips
- Notes with lots of connections will naturally cluster together
- Orphan notes (without links) will drift to the edges
- Use [[Markdown Syntax]] to learn about linking
- Check your [[Welcome]] note for an overview
The graph is rebuilt every time you open it, so new links appear instantly.

15
vault/Ideas.md Normal file
View file

@ -0,0 +1,15 @@
# Ideas
A place to collect random ideas and thoughts.
## Project Ideas
- Build a recipe manager with [[Wikilink|wikilinks]] between ingredients and recipes
- Create a reading list that links [[Daily Notes]] entries to book notes
- Map out learning paths using the [[Graph View]]
## Connections
The power of linked notes is that ideas find each other naturally. Start writing in [[Daily Notes]], link to topics, and watch your knowledge [[Graph View|graph]] grow.
See [[Welcome]] to get started with the basics, and [[Markdown Syntax]] for formatting tips.

39
vault/Markdown Syntax.md Normal file
View file

@ -0,0 +1,39 @@
# Markdown Syntax
Graph Notes uses standard **Markdown** for formatting your notes.
## Basic Formatting
- **Bold** with `**text**`
- *Italic* with `*text*`
- `Code` with backticks
- ~~Strikethrough~~ with `~~text~~`
## Wikilinks
The most important feature! Link to other notes with double brackets:
- `[[Welcome]]` → links to the [[Welcome]] note
- `[[Daily Notes]]` → links to [[Daily Notes]]
- `[[Note Name|display text]]` → shows custom text
## Headers
Use `#` for headers — from `#` (h1) to `######` (h6).
## Lists
- Unordered lists with `-` or `*`
- Numbered lists with `1.`
- Nested lists with indentation
## Code Blocks
Use triple backticks for code blocks with syntax highlighting.
## Links and Images
- Links: `[text](url)`
- Images: `![alt](url)`
See [[Welcome]] for an overview, or start writing in your [[Daily Notes]].

2
vault/Project Name.md Normal file
View file

@ -0,0 +1,2 @@
# Project Name

22
vault/Welcome.md Normal file
View file

@ -0,0 +1,22 @@
# Welcome to Graph Notes
Welcome to **Graph Notes** — your local-first knowledge base with bidirectional linking and graph visualization.
## Getting Started
This app stores all your notes as plain `.md` files on disk. You can:
- **Create notes** using the + button in the sidebar
- **Link between notes** using `[[wikilinks]]` — just type `[[Note Name]]`
- **See backlinks** in the right panel — all notes that link to the current one
- **Visualize connections** in the [[Graph View]]
## Key Features
- 📝 **Markdown editing** with live preview
- 🔗 **Bidirectional backlinks** — see who links to you
- 🔮 **Graph View** — visualize your knowledge network
- 📅 **Daily Notes** — capture thoughts day by day
- 💾 **Local-first** — your notes are just `.md` files
Check out [[Markdown Syntax]] to learn more about formatting, or explore the [[Graph View]] to see how everything connects.

2
vault/Wikilink.md Normal file
View file

@ -0,0 +1,2 @@
# Wikilink

View file

@ -0,0 +1,6 @@
wsaw
[[Daily Notes]]

View file

@ -0,0 +1,2 @@
# 2026-03-07

31
vite.config.ts Normal file
View file

@ -0,0 +1,31 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
const host = process.env.TAURI_DEV_HOST;
export default defineConfig(async () => ({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
ignored: ["**/src-tauri/**", "**/vault/**"],
},
},
}));