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
24
.gitignore
vendored
Normal 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
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
7
README.md
Normal 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
|
|
@ -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
32
package.json
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
24
src-tauri/Cargo.toml
Normal 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
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
17
src-tauri/capabilities/default.json
Normal 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
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
260
src-tauri/src/lib.rs
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 |
93
src/components/Backlinks.tsx
Normal 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
|
|
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
// Replace [[wikilinks]] with token spans
|
||||
escaped = escaped.replace(
|
||||
/\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g,
|
||||
(_m, target, display) => {
|
||||
const label = display?.trim() || target.trim();
|
||||
const rawAttr = _m.replace(/"/g, """);
|
||||
return `<span class="wikilink-token" contenteditable="false" data-raw="${rawAttr}" data-target="${target.trim()}">${label}</span>`;
|
||||
}
|
||||
);
|
||||
|
||||
// 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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
700
src/components/GraphView.tsx
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
40
tsconfig.json
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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: ``
|
||||
|
||||
See [[Welcome]] for an overview, or start writing in your [[Daily Notes]].
|
||||
2
vault/Project Name.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Project Name
|
||||
|
||||
22
vault/Welcome.md
Normal 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
|
|
@ -0,0 +1,2 @@
|
|||
# Wikilink
|
||||
|
||||
6
vault/daily/2026-03-06.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
wsaw
|
||||
|
||||
[[Daily Notes]]
|
||||
|
||||
|
||||
|
||||
2
vault/daily/2026-03-07.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# 2026-03-07
|
||||
|
||||
31
vite.config.ts
Normal 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/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||