feat: v0.2.0 — command palette, full-text search, note management, editor improvements, graph filtering
- Add search_vault and rename_note Rust backend commands - Add CommandPalette component with fuzzy search and keyboard nav (Ctrl+K) - Add ContextMenu component with rename/delete actions - Add global keyboard shortcuts (Ctrl+N/G/D) - Rebuild Sidebar with debounced full-text search results - Add Editor heading scaling (h1-h3), interactive task checkboxes, inline code - Add GraphView filter bar (folder dropdown, min links slider) - Add 440+ lines of CSS for all new components - Update CHANGELOG.md with v0.2.0 entry
This commit is contained in:
parent
b03237f4c2
commit
2041798048
10 changed files with 1107 additions and 27 deletions
46
CHANGELOG.md
46
CHANGELOG.md
|
|
@ -1,21 +1,37 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to Graph Notes will be documented in this file.
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.0] — 2026-03-07
|
||||
## [0.2.0] - 2026-03-07
|
||||
|
||||
### Added
|
||||
- **Tauri v2 Desktop App** — Local-first note-taking with full filesystem access via `tauri-plugin-fs`
|
||||
- **Contenteditable Editor** — Rich inline editing with `[[wikilink]]` token chips (compact pills that unwrap on backspace/delete)
|
||||
- **Wikilink Autocomplete** — Type `[[` to fuzzy-search and link notes; creates new notes if no match found
|
||||
- **Force-Directed Graph View** — Canvas-based visualization with semantic zoom (circles → rounded-rect cards with note previews)
|
||||
- **Graph Interactions** — Single-click animates zoom to node, double-click opens note, drag to reposition nodes
|
||||
- **shadcn-Inspired Design System** — Zinc-based neutrals, purple accent gradients, focus rings, spring transitions
|
||||
- **Sidebar** — Recursive file tree with search, collapsible folders, active-state indicators, note count badge
|
||||
- **Backlinks Panel** — Lists all notes linking to current page with highlighted context snippets
|
||||
- **Markdown Preview** — Toggle between edit and rendered preview modes with inline wikilink rendering
|
||||
- **Daily Notes** — Auto-generated daily journal entries accessible from sidebar shortcut
|
||||
- **Auto-Save** — Debounced 500ms save on every keystroke
|
||||
- **Custom Scrollbars** — Minimal 5px scrollbars matching the dark theme
|
||||
- **Command Palette** (`Ctrl+K`): Fuzzy search notes and run commands with keyboard navigation (↑↓ Enter Esc)
|
||||
- **Keyboard Shortcuts**: `Ctrl+N` new note, `Ctrl+G` graph view, `Ctrl+D` daily note
|
||||
- **Full-Text Search**: Rust-powered vault content search with context snippets, displayed in sidebar
|
||||
- **Note Rename**: Right-click context menu in sidebar for renaming notes with automatic wikilink updates across vault
|
||||
- **Note Delete**: Context menu delete with confirmation dialog
|
||||
- **Editor: Heading Scaling**: H1–H3 headings render at proportional sizes in edit mode
|
||||
- **Editor: Task Lists**: Interactive checkboxes for `- [ ]` / `- [x]` syntax, clickable to toggle
|
||||
- **Editor: Inline Code**: Backtick-quoted text styled with monospace font and accent color
|
||||
- **Editor: Markdown Preview**: Styled headings, code blocks, and checkbox rendering in preview mode
|
||||
- **Graph Filtering**: Filter graph by folder and minimum link count with a dedicated filter bar
|
||||
|
||||
### Changed
|
||||
- Sidebar search upgraded to show both filename matches and content search results
|
||||
- Graph view header now reflects filtered node/edge counts
|
||||
|
||||
## [0.1.0] - 2025-06-01
|
||||
|
||||
### Added
|
||||
- Tauri 2 desktop application with React 19 + Vite 7
|
||||
- Contenteditable editor with inline wikilink tokens
|
||||
- Wikilink autocomplete dropdown (`[[` trigger)
|
||||
- Force-directed graph view with semantic zoom (circles → cards)
|
||||
- Sidebar with file tree, search filtering, and quick actions
|
||||
- Backlinks panel with context snippets
|
||||
- Daily notes with auto-creation
|
||||
- Auto-save with debounced writes
|
||||
- Custom CSS design system (dark theme, glassmorphism, purple accents)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,14 @@ pub struct GraphEdge {
|
|||
pub target: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SearchResult {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub context: String,
|
||||
pub line_number: usize,
|
||||
}
|
||||
|
||||
fn normalize_note_name(name: &str) -> String {
|
||||
name.trim().to_lowercase()
|
||||
}
|
||||
|
|
@ -180,6 +188,122 @@ fn build_graph(vault_path: String) -> Result<GraphData, String> {
|
|||
Ok(GraphData { nodes, edges })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn search_vault(vault_path: String, query: String) -> Result<Vec<SearchResult>, String> {
|
||||
let vault = Path::new(&vault_path);
|
||||
if !vault.exists() {
|
||||
return Err("Vault path does not exist".to_string());
|
||||
}
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
let mut results: Vec<SearchResult> = Vec::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"))
|
||||
{
|
||||
if let Ok(content) = fs::read_to_string(entry.path()) {
|
||||
for (i, line) in content.lines().enumerate() {
|
||||
if line.to_lowercase().contains(&query_lower) {
|
||||
let rel_path = entry.path().strip_prefix(vault).unwrap_or(entry.path());
|
||||
let name = rel_path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
// Build context: the matching line, trimmed
|
||||
let context = line.trim().to_string();
|
||||
let context_display = if context.len() > 120 {
|
||||
format!("{}…", &context[..120])
|
||||
} else {
|
||||
context
|
||||
};
|
||||
|
||||
results.push(SearchResult {
|
||||
path: rel_path.to_string_lossy().to_string(),
|
||||
name,
|
||||
context: context_display,
|
||||
line_number: i + 1,
|
||||
});
|
||||
|
||||
// Max 3 results per file
|
||||
if results.iter().filter(|r| r.path == rel_path.to_string_lossy().to_string()).count() >= 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cap total results
|
||||
results.truncate(50);
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn rename_note(vault_path: String, old_path: String, new_name: String) -> Result<String, String> {
|
||||
let vault = Path::new(&vault_path);
|
||||
let old_full = vault.join(&old_path);
|
||||
if !old_full.is_file() {
|
||||
return Err("Note not found".to_string());
|
||||
}
|
||||
|
||||
// Compute new path (same directory, new name)
|
||||
let parent = Path::new(&old_path).parent().unwrap_or(Path::new(""));
|
||||
let new_file = format!("{}.md", new_name.trim());
|
||||
let new_rel = if parent == Path::new("") {
|
||||
new_file.clone()
|
||||
} else {
|
||||
format!("{}/{}", parent.to_string_lossy(), new_file)
|
||||
};
|
||||
let new_full = vault.join(&new_rel);
|
||||
|
||||
if new_full.exists() {
|
||||
return Err("A note with that name already exists".to_string());
|
||||
}
|
||||
|
||||
// Rename the file
|
||||
fs::rename(&old_full, &new_full).map_err(|e| format!("Failed to rename: {}", e))?;
|
||||
|
||||
// Update wikilinks across vault: [[old_name]] → [[new_name]]
|
||||
let old_stem = Path::new(&old_path)
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let new_stem = new_name.trim().to_string();
|
||||
|
||||
if old_stem != new_stem {
|
||||
let link_re = Regex::new(&format!(
|
||||
r"\[\[{}(\|[^\]]+)?\]\]",
|
||||
regex::escape(&old_stem)
|
||||
)).map_err(|e| e.to_string())?;
|
||||
|
||||
for entry in WalkDir::new(vault)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map_or(false, |ext| ext == "md"))
|
||||
{
|
||||
if let Ok(content) = fs::read_to_string(entry.path()) {
|
||||
let updated = link_re.replace_all(&content, |caps: ®ex::Captures| {
|
||||
match caps.get(1) {
|
||||
Some(alias) => format!("[[{}{}", new_stem, alias.as_str()),
|
||||
None => format!("[[{}]]", new_stem),
|
||||
}
|
||||
}).to_string();
|
||||
|
||||
if updated != content {
|
||||
let _ = fs::write(entry.path(), &updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(new_rel)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_or_create_daily(vault_path: String) -> Result<String, String> {
|
||||
let today = Local::now().format("%Y-%m-%d").to_string();
|
||||
|
|
@ -250,6 +374,8 @@ pub fn run() {
|
|||
write_note,
|
||||
delete_note,
|
||||
build_graph,
|
||||
search_vault,
|
||||
rename_note,
|
||||
get_or_create_daily,
|
||||
get_vault_path,
|
||||
set_vault_path,
|
||||
|
|
|
|||
25
src/App.tsx
25
src/App.tsx
|
|
@ -4,6 +4,7 @@ import { Sidebar } from "./components/Sidebar";
|
|||
import { Editor } from "./components/Editor";
|
||||
import { Backlinks } from "./components/Backlinks";
|
||||
import { GraphView } from "./components/GraphView";
|
||||
import { CommandPalette } from "./components/CommandPalette";
|
||||
import {
|
||||
listNotes,
|
||||
readNote,
|
||||
|
|
@ -41,6 +42,7 @@ export default function App() {
|
|||
const [backlinks, setBacklinks] = useState<BacklinkEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Initialize vault
|
||||
|
|
@ -96,6 +98,28 @@ export default function App() {
|
|||
if (vaultPath) refreshNotes();
|
||||
}, [vaultPath, refreshNotes]);
|
||||
|
||||
// Global keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (mod && e.key === "k") { e.preventDefault(); setPaletteOpen((v) => !v); }
|
||||
else if (mod && e.key === "n") {
|
||||
e.preventDefault(); setPaletteOpen(false);
|
||||
const name = prompt("Note name:");
|
||||
if (name?.trim()) {
|
||||
writeNote(vaultPath, `${name.trim()}.md`, `# ${name.trim()}\n\n`).then(() => {
|
||||
refreshNotes();
|
||||
navigate(`/note/${encodeURIComponent(`${name.trim()}.md`)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (mod && e.key === "g") { e.preventDefault(); setPaletteOpen(false); navigate("/graph"); }
|
||||
else if (mod && e.key === "d") { e.preventDefault(); setPaletteOpen(false); navigate("/daily"); }
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [vaultPath, navigate, refreshNotes]);
|
||||
|
||||
// Build backlinks for current note
|
||||
useEffect(() => {
|
||||
if (!vaultPath || !currentNote || !notes.length) {
|
||||
|
|
@ -212,6 +236,7 @@ export default function App() {
|
|||
<Route path="/daily" element={<DailyView />} />
|
||||
<Route path="/graph" element={<GraphView />} />
|
||||
</Routes>
|
||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
|
||||
</div>
|
||||
</VaultContext.Provider>
|
||||
);
|
||||
|
|
|
|||
186
src/components/CommandPalette.tsx
Normal file
186
src/components/CommandPalette.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useVault } from "../App";
|
||||
import type { NoteEntry } from "../lib/commands";
|
||||
|
||||
interface Command {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
action: () => void;
|
||||
section: "notes" | "commands";
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const { notes, vaultPath, refreshNotes } = useVault();
|
||||
|
||||
// Flatten notes for search
|
||||
const allNotes = useMemo(() => flattenEntries(notes), [notes]);
|
||||
|
||||
// Build command list
|
||||
const commands: Command[] = useMemo(() => {
|
||||
const cmds: Command[] = [
|
||||
{ id: "cmd-new", label: "New Note", icon: "✏️", section: "commands", action: () => {
|
||||
onClose();
|
||||
const name = prompt("Note name:");
|
||||
if (name?.trim()) {
|
||||
import("../lib/commands").then(({ writeNote }) => {
|
||||
writeNote(vaultPath, `${name.trim()}.md`, `# ${name.trim()}\n\n`).then(() => {
|
||||
refreshNotes();
|
||||
navigate(`/note/${encodeURIComponent(`${name.trim()}.md`)}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
}},
|
||||
{ id: "cmd-daily", label: "Open Daily Note", icon: "📅", section: "commands", action: () => { onClose(); navigate("/daily"); }},
|
||||
{ id: "cmd-graph", label: "Open Graph View", icon: "🔮", section: "commands", action: () => { onClose(); navigate("/graph"); }},
|
||||
];
|
||||
|
||||
// Add notes as commands
|
||||
for (const note of allNotes) {
|
||||
cmds.push({
|
||||
id: `note-${note.path}`,
|
||||
label: note.name,
|
||||
icon: "📄",
|
||||
section: "notes",
|
||||
action: () => { onClose(); navigate(`/note/${encodeURIComponent(note.path)}`); },
|
||||
});
|
||||
}
|
||||
|
||||
return cmds;
|
||||
}, [allNotes, navigate, onClose, vaultPath, refreshNotes]);
|
||||
|
||||
// Filter by query
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return commands;
|
||||
const q = query.toLowerCase();
|
||||
return commands.filter((c) => c.label.toLowerCase().includes(q));
|
||||
}, [commands, query]);
|
||||
|
||||
// Reset selection on filter change
|
||||
useEffect(() => { setSelectedIndex(0); }, [filtered]);
|
||||
|
||||
// Focus input on open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setSelectedIndex(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
const item = listRef.current.children[selectedIndex] as HTMLElement;
|
||||
item?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
filtered[selectedIndex]?.action();
|
||||
} else if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Group by section
|
||||
const noteResults = filtered.filter((c) => c.section === "notes");
|
||||
const cmdResults = filtered.filter((c) => c.section === "commands");
|
||||
const ordered = [...cmdResults, ...noteResults];
|
||||
|
||||
return (
|
||||
<div className="palette-overlay" onClick={onClose}>
|
||||
<div className="palette" onClick={(e) => e.stopPropagation()} onKeyDown={handleKeyDown}>
|
||||
<div className="palette-input-wrap">
|
||||
<svg className="palette-search-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="palette-input"
|
||||
placeholder="Search notes & commands..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<kbd className="palette-kbd">esc</kbd>
|
||||
</div>
|
||||
|
||||
<div className="palette-list" ref={listRef}>
|
||||
{ordered.length === 0 && (
|
||||
<div className="palette-empty">No results found</div>
|
||||
)}
|
||||
{cmdResults.length > 0 && (
|
||||
<div className="palette-section-label">Commands</div>
|
||||
)}
|
||||
{cmdResults.map((cmd) => {
|
||||
const globalIdx = ordered.indexOf(cmd);
|
||||
return (
|
||||
<button
|
||||
key={cmd.id}
|
||||
className={`palette-item ${globalIdx === selectedIndex ? "selected" : ""}`}
|
||||
onClick={cmd.action}
|
||||
onMouseEnter={() => setSelectedIndex(globalIdx)}
|
||||
>
|
||||
<span className="palette-item-icon">{cmd.icon}</span>
|
||||
<span className="palette-item-label">{cmd.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{noteResults.length > 0 && (
|
||||
<div className="palette-section-label">Notes</div>
|
||||
)}
|
||||
{noteResults.map((cmd) => {
|
||||
const globalIdx = ordered.indexOf(cmd);
|
||||
return (
|
||||
<button
|
||||
key={cmd.id}
|
||||
className={`palette-item ${globalIdx === selectedIndex ? "selected" : ""}`}
|
||||
onClick={cmd.action}
|
||||
onMouseEnter={() => setSelectedIndex(globalIdx)}
|
||||
>
|
||||
<span className="palette-item-icon">{cmd.icon}</span>
|
||||
<span className="palette-item-label">{cmd.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="palette-footer">
|
||||
<span>↑↓ navigate</span>
|
||||
<span>↵ open</span>
|
||||
<span>esc close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function flattenEntries(entries: NoteEntry[]): { name: string; path: string }[] {
|
||||
const result: { name: string; path: string }[] = [];
|
||||
for (const e of entries) {
|
||||
if (e.is_dir && e.children) {
|
||||
result.push(...flattenEntries(e.children));
|
||||
} else if (!e.is_dir) {
|
||||
result.push({ name: e.name, path: e.path });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
75
src/components/ContextMenu.tsx
Normal file
75
src/components/ContextMenu.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
icon: string;
|
||||
action: () => void;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
items: MenuItem[];
|
||||
position: { x: number; y: number } | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!position) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const escHandler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
document.addEventListener("mousedown", handler);
|
||||
document.addEventListener("keydown", escHandler);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handler);
|
||||
document.removeEventListener("keydown", escHandler);
|
||||
};
|
||||
}, [position, onClose]);
|
||||
|
||||
if (!position) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="context-menu"
|
||||
style={{ top: position.y, left: position.x }}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`context-menu-item ${item.danger ? "danger" : ""}`}
|
||||
onClick={() => { item.action(); onClose(); }}
|
||||
>
|
||||
<span className="context-menu-icon">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Hook: useContextMenu */
|
||||
export function useContextMenu() {
|
||||
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [menuTarget, setMenuTarget] = useState<string | null>(null);
|
||||
|
||||
const openMenu = useCallback((e: React.MouseEvent, target: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setMenuPos({ x: e.clientX, y: e.clientY });
|
||||
setMenuTarget(target);
|
||||
}, []);
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setMenuPos(null);
|
||||
setMenuTarget(null);
|
||||
}, []);
|
||||
|
||||
return { menuPos, menuTarget, openMenu, closeMenu };
|
||||
}
|
||||
|
|
@ -294,8 +294,36 @@ export function Editor() {
|
|||
const linkTarget = target.dataset.target;
|
||||
if (linkTarget) navigateToNote(linkTarget);
|
||||
}
|
||||
// Task checkbox toggle
|
||||
if (target.dataset.task === "true") {
|
||||
e.preventDefault();
|
||||
const raw = extractRaw();
|
||||
// Find the line with this checkbox and toggle it
|
||||
const lines = raw.split("\n");
|
||||
// Find button's parent div to locate which task line this is
|
||||
const parentDiv = target.closest(".task-line");
|
||||
if (parentDiv) {
|
||||
const allTaskDivs = Array.from(ceRef.current?.querySelectorAll(".task-line") || []);
|
||||
const taskIdx = allTaskDivs.indexOf(parentDiv);
|
||||
let taskCount = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(/^(\s*-\s+)\[([ xX])\](\s+.*)$/);
|
||||
if (match) {
|
||||
if (taskCount === taskIdx) {
|
||||
const checked = match[2].toLowerCase() === "x";
|
||||
lines[i] = `${match[1]}[${checked ? " " : "x"}]${match[3]}`;
|
||||
break;
|
||||
}
|
||||
taskCount++;
|
||||
}
|
||||
}
|
||||
const newRaw = lines.join("\n");
|
||||
saveContent(newRaw);
|
||||
renderToDOM(newRaw);
|
||||
}
|
||||
}
|
||||
},
|
||||
[navigateToNote]
|
||||
[navigateToNote, extractRaw, saveContent, renderToDOM]
|
||||
);
|
||||
|
||||
// ── Preview click handler ──
|
||||
|
|
@ -383,7 +411,7 @@ export function Editor() {
|
|||
<div className="flex-1 overflow-y-auto relative">
|
||||
{isPreview ? (
|
||||
<div
|
||||
className="prose prose-invert max-w-none px-8 py-6"
|
||||
className="markdown-preview prose prose-invert max-w-none px-8 py-6"
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
lineHeight: 1.8,
|
||||
|
|
@ -550,6 +578,39 @@ function markdownToTokenHTML(raw: string): string {
|
|||
}
|
||||
);
|
||||
|
||||
// Process lines for headings, task lists, and code blocks
|
||||
const lines = escaped.split("\n");
|
||||
const processed = lines.map((line) => {
|
||||
// Heading scaling (h1-h3)
|
||||
const headingMatch = line.match(/^(#{1,3})\s+(.*)$/);
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length;
|
||||
return `<div data-heading="${level}">${line}</div>`;
|
||||
}
|
||||
|
||||
// Task list items: - [ ] or - [x]
|
||||
const taskMatch = line.match(/^(\s*)-\s+\[([ xX])\]\s+(.*)$/);
|
||||
if (taskMatch) {
|
||||
const indent = taskMatch[1];
|
||||
const checked = taskMatch[2].toLowerCase() === "x";
|
||||
const text = taskMatch[3];
|
||||
const checkboxClass = checked ? "editor-checkbox checked" : "editor-checkbox";
|
||||
const checkMark = checked ? "✓" : "";
|
||||
const lineClass = checked ? "task-line completed" : "task-line";
|
||||
return `<div class="${lineClass}">${indent}<button class="${checkboxClass}" contenteditable="false" data-task="true">${checkMark}</button>${text}</div>`;
|
||||
}
|
||||
|
||||
return line;
|
||||
});
|
||||
|
||||
escaped = processed.join("\n");
|
||||
|
||||
// Inline code: `code`
|
||||
escaped = escaped.replace(
|
||||
/`([^`\n]+?)`/g,
|
||||
'<span class="editor-code-inline">$1</span>'
|
||||
);
|
||||
|
||||
// Convert newlines to <br>
|
||||
escaped = escaped.replace(/\n/g, "<br>");
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ export function GraphView() {
|
|||
const { vaultPath } = useVault();
|
||||
const navigate = useNavigate();
|
||||
const [graphData, setGraphData] = useState<GraphData | null>(null);
|
||||
const [folderFilter, setFolderFilter] = useState<string>("all");
|
||||
const [minLinks, setMinLinks] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!vaultPath) return;
|
||||
|
|
@ -54,6 +56,29 @@ export function GraphView() {
|
|||
);
|
||||
}
|
||||
|
||||
// Extract unique folders
|
||||
const folders = Array.from(
|
||||
new Set(graphData.nodes.map((n) => {
|
||||
const parts = n.path.split("/");
|
||||
return parts.length > 1 ? parts.slice(0, -1).join("/") : "(root)";
|
||||
}))
|
||||
).sort();
|
||||
|
||||
// Filter nodes
|
||||
const filteredNodes = graphData.nodes.filter((node) => {
|
||||
const folder = node.path.includes("/")
|
||||
? node.path.split("/").slice(0, -1).join("/")
|
||||
: "(root)";
|
||||
if (folderFilter !== "all" && folder !== folderFilter) return false;
|
||||
if (node.link_count < minLinks) return false;
|
||||
return true;
|
||||
});
|
||||
const nodeIds = new Set(filteredNodes.map((n) => n.id));
|
||||
const filteredEdges = graphData.edges.filter(
|
||||
(e) => nodeIds.has(e.source) && nodeIds.has(e.target)
|
||||
);
|
||||
const filteredData: GraphData = { nodes: filteredNodes, edges: filteredEdges };
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="graph-header">
|
||||
|
|
@ -61,16 +86,38 @@ export function GraphView() {
|
|||
<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>
|
||||
<span className="badge badge-purple">{filteredNodes.length} notes</span>
|
||||
<span className="badge badge-muted">{filteredEdges.length} links</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: "var(--text-muted)" }}>
|
||||
Scroll to zoom · Click to focus · Double-click to open
|
||||
</div>
|
||||
</div>
|
||||
{/* ── Filter Bar ── */}
|
||||
<div className="graph-filter-bar">
|
||||
<label>
|
||||
Folder
|
||||
<select value={folderFilter} onChange={(e) => setFolderFilter(e.target.value)}>
|
||||
<option value="all">All</option>
|
||||
{folders.map((f) => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Min links: {minLinks}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(5, ...graphData.nodes.map((n) => n.link_count))}
|
||||
value={minLinks}
|
||||
onChange={(e) => setMinLinks(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex-1 relative">
|
||||
<ForceGraph
|
||||
graphData={graphData}
|
||||
graphData={filteredData}
|
||||
onNodeClick={(path) => navigate(`/note/${encodeURIComponent(path)}`)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,47 @@
|
|||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useVault } from "../App";
|
||||
import { writeNote } from "../lib/commands";
|
||||
import type { NoteEntry } from "../lib/commands";
|
||||
import { writeNote, searchVault, renameNote, deleteNote } from "../lib/commands";
|
||||
import type { NoteEntry, SearchResult } from "../lib/commands";
|
||||
import { ContextMenu, useContextMenu } from "./ContextMenu";
|
||||
|
||||
export function Sidebar() {
|
||||
const { notes, vaultPath, refreshNotes } = useVault();
|
||||
const [search, setSearch] = useState("");
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { menuPos, menuTarget, openMenu, closeMenu } = useContextMenu();
|
||||
|
||||
const filteredNotes = useMemo(() => {
|
||||
if (!search.trim()) return notes;
|
||||
return filterNotes(notes, search.toLowerCase());
|
||||
}, [notes, search]);
|
||||
|
||||
// Full-text search with debounce
|
||||
useEffect(() => {
|
||||
if (!search.trim() || search.trim().length < 2) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const results = await searchVault(vaultPath, search.trim());
|
||||
setSearchResults(results);
|
||||
} catch {
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, vaultPath]);
|
||||
|
||||
const handleCreateNote = async () => {
|
||||
const name = prompt("Note name:");
|
||||
if (!name?.trim()) return;
|
||||
|
|
@ -34,6 +60,42 @@ export function Sidebar() {
|
|||
});
|
||||
};
|
||||
|
||||
const handleRename = useCallback(async (notePath: string) => {
|
||||
const oldName = notePath.replace(/\.md$/, "").split("/").pop() || "";
|
||||
const newName = prompt("Rename note:", oldName);
|
||||
if (!newName?.trim() || newName.trim() === oldName) return;
|
||||
try {
|
||||
const newPath = await renameNote(vaultPath, notePath, newName.trim());
|
||||
await refreshNotes();
|
||||
// Navigate to renamed note
|
||||
navigate(`/note/${encodeURIComponent(newPath)}`);
|
||||
} catch (e) {
|
||||
alert(`Rename failed: ${e}`);
|
||||
}
|
||||
}, [vaultPath, refreshNotes, navigate]);
|
||||
|
||||
const handleDelete = useCallback(async (notePath: string) => {
|
||||
const name = notePath.replace(/\.md$/, "").split("/").pop() || notePath;
|
||||
if (!confirm(`Delete "${name}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await deleteNote(vaultPath, notePath);
|
||||
await refreshNotes();
|
||||
// Navigate away if we deleted the current note
|
||||
if (location.pathname === `/note/${encodeURIComponent(notePath)}`) {
|
||||
navigate("/");
|
||||
}
|
||||
} catch (e) {
|
||||
alert(`Delete failed: ${e}`);
|
||||
}
|
||||
}, [vaultPath, refreshNotes, navigate, location]);
|
||||
|
||||
const contextMenuItems = menuTarget ? [
|
||||
{ label: "Rename", icon: "✏️", action: () => handleRename(menuTarget) },
|
||||
{ label: "Delete", icon: "🗑️", action: () => handleDelete(menuTarget), danger: true },
|
||||
] : [];
|
||||
|
||||
const hasContentResults = search.trim().length >= 2 && searchResults.length > 0;
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
{/* ── Brand + Search ── */}
|
||||
|
|
@ -45,7 +107,7 @@ export function Sidebar() {
|
|||
<button
|
||||
className="sidebar-new-btn"
|
||||
onClick={handleCreateNote}
|
||||
title="New note"
|
||||
title="New note (Ctrl+N)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
|
|
@ -58,7 +120,7 @@ export function Sidebar() {
|
|||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search notes..."
|
||||
placeholder="Search notes & content..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
|
@ -83,6 +145,31 @@ export function Sidebar() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Content Search Results ── */}
|
||||
{hasContentResults && (
|
||||
<div className="sidebar-search-results">
|
||||
<div className="sidebar-section-label">
|
||||
<span>Content Matches</span>
|
||||
<span className="badge badge-purple">{searchResults.length}</span>
|
||||
</div>
|
||||
{searchResults.slice(0, 10).map((result, i) => (
|
||||
<button
|
||||
key={`${result.path}-${result.line_number}-${i}`}
|
||||
className="search-result-item"
|
||||
onClick={() => navigate(`/note/${encodeURIComponent(result.path)}`)}
|
||||
>
|
||||
<span className="search-result-name">📄 {result.name}</span>
|
||||
<span className="search-result-context">{result.context}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isSearching && search.trim().length >= 2 && (
|
||||
<div style={{ padding: "8px 16px", fontSize: 11, color: "var(--text-muted)" }}>
|
||||
Searching...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── File Tree ── */}
|
||||
<div className="sidebar-tree">
|
||||
<div className="sidebar-section-label">
|
||||
|
|
@ -93,6 +180,7 @@ export function Sidebar() {
|
|||
entries={filteredNotes}
|
||||
collapsed={collapsed}
|
||||
onToggle={toggleFolder}
|
||||
onContextMenu={openMenu}
|
||||
depth={0}
|
||||
/>
|
||||
{filteredNotes.length === 0 && (
|
||||
|
|
@ -101,17 +189,20 @@ export function Sidebar() {
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ContextMenu items={contextMenuItems} position={menuPos} onClose={closeMenu} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Recursive File Tree ────────────────────────────────────── */
|
||||
function NoteTree({
|
||||
entries, collapsed, onToggle, depth,
|
||||
entries, collapsed, onToggle, onContextMenu, depth,
|
||||
}: {
|
||||
entries: NoteEntry[];
|
||||
collapsed: Set<string>;
|
||||
onToggle: (path: string) => void;
|
||||
onContextMenu: (e: React.MouseEvent, target: string) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -141,7 +232,7 @@ function NoteTree({
|
|||
<span className="tree-item-label" style={{ fontWeight: 500 }}>{entry.name}</span>
|
||||
</button>
|
||||
{!isCollapsed && entry.children && (
|
||||
<NoteTree entries={entry.children} collapsed={collapsed} onToggle={onToggle} depth={depth + 1} />
|
||||
<NoteTree entries={entry.children} collapsed={collapsed} onToggle={onToggle} onContextMenu={onContextMenu} depth={depth + 1} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
|
|
@ -153,6 +244,7 @@ function NoteTree({
|
|||
className={`tree-item ${isActive ? "active" : ""}`}
|
||||
style={{ paddingLeft: `${14 + depth * 16}px` }}
|
||||
onClick={() => navigate(`/note/${encodeURIComponent(entry.path)}`)}
|
||||
onContextMenu={(e) => onContextMenu(e, entry.path)}
|
||||
>
|
||||
<span className="tree-item-icon">📄</span>
|
||||
<span className="tree-item-label">{entry.name.replace(/\.md$/, "")}</span>
|
||||
|
|
|
|||
437
src/index.css
437
src/index.css
|
|
@ -925,4 +925,441 @@ body,
|
|||
|
||||
.animate-slide-right {
|
||||
animation: slideInRight 200ms ease forwards;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
v0.2 Additions
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Command Palette ──────────────────────────────────────── */
|
||||
.palette-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 20vh;
|
||||
animation: fadeIn 100ms ease;
|
||||
}
|
||||
|
||||
.palette {
|
||||
width: 520px;
|
||||
max-height: 440px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg), 0 0 60px rgba(139, 92, 246, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: dropdownIn 150ms ease forwards;
|
||||
}
|
||||
|
||||
.palette-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.palette-search-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.palette-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.palette-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.palette-kbd {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xs);
|
||||
padding: 2px 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.palette-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.palette-section-label {
|
||||
padding: 8px 16px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.palette-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.palette-item:hover,
|
||||
.palette-item.selected {
|
||||
background: var(--accent-purple-glow);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.palette-item.selected {
|
||||
background: var(--accent-purple-dim);
|
||||
border-left: 2px solid var(--accent-purple);
|
||||
}
|
||||
|
||||
.palette-item-icon {
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.palette-item-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.palette-empty {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.palette-footer {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* ── Context Menu ─────────────────────────────────────────── */
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 1001;
|
||||
min-width: 160px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 4px;
|
||||
animation: dropdownIn 100ms ease forwards;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-xs);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.context-menu-item.danger:hover {
|
||||
background: rgba(244, 63, 94, 0.12);
|
||||
color: var(--accent-rose);
|
||||
}
|
||||
|
||||
.context-menu-icon {
|
||||
font-size: 13px;
|
||||
width: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Search Results in Sidebar ────────────────────────────── */
|
||||
.sidebar-search-results {
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
padding: 7px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.search-result-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.search-result-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.search-result-context {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Editor: Heading Scaling ──────────────────────────────── */
|
||||
.editor-ce [data-heading="1"] {
|
||||
font-size: 1.75em;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 0.2em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-ce [data-heading="2"] {
|
||||
font-size: 1.35em;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
margin-bottom: 0.15em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-ce [data-heading="3"] {
|
||||
font-size: 1.15em;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 0.1em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Editor: Checkbox Tasks ───────────────────────────────── */
|
||||
.editor-checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--border-secondary);
|
||||
border-radius: var(--radius-xs);
|
||||
margin-right: 6px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.editor-checkbox.checked {
|
||||
background: var(--accent-purple-bright);
|
||||
border-color: var(--accent-purple-bright);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.editor-checkbox:hover {
|
||||
border-color: var(--accent-purple);
|
||||
box-shadow: 0 0 0 3px var(--accent-purple-subtle);
|
||||
}
|
||||
|
||||
.task-line.completed {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── Editor: Code Blocks ──────────────────────────────────── */
|
||||
.editor-code-inline {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xs);
|
||||
padding: 1px 5px;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
font-size: 0.88em;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.editor-code-block {
|
||||
display: block;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 16px;
|
||||
margin: 8px 0;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* ── Markdown Preview: headings ───────────────────────────── */
|
||||
.markdown-preview h1 {
|
||||
font-size: 1.8em;
|
||||
font-weight: 700;
|
||||
margin: 1em 0 0.5em;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.markdown-preview h2 {
|
||||
font-size: 1.4em;
|
||||
font-weight: 600;
|
||||
margin: 0.8em 0 0.4em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.markdown-preview h3 {
|
||||
font-size: 1.15em;
|
||||
font-weight: 600;
|
||||
margin: 0.6em 0 0.3em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Markdown Preview: code ───────────────────────────────── */
|
||||
.markdown-preview code {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xs);
|
||||
padding: 1px 5px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.88em;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.markdown-preview pre {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 18px;
|
||||
margin: 10px 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.markdown-preview pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Markdown Preview: checkboxes ─────────────────────────── */
|
||||
.markdown-preview input[type="checkbox"] {
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--border-secondary);
|
||||
border-radius: 3px;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.markdown-preview input[type="checkbox"]:checked {
|
||||
background: var(--accent-purple-bright);
|
||||
border-color: var(--accent-purple-bright);
|
||||
}
|
||||
|
||||
.markdown-preview input[type="checkbox"]:checked::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: 1px;
|
||||
font-size: 10px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ── Graph Filter Bar ─────────────────────────────────────── */
|
||||
.graph-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 20px;
|
||||
height: 40px;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.graph-filter-bar label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.graph-filter-bar select,
|
||||
.graph-filter-bar input[type="range"] {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.graph-filter-bar select:focus,
|
||||
.graph-filter-bar input[type="range"]:focus {
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
.graph-filter-bar input[type="range"] {
|
||||
width: 80px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent-purple);
|
||||
}
|
||||
|
|
@ -25,6 +25,13 @@ export interface GraphData {
|
|||
edges: GraphEdge[];
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
path: string;
|
||||
name: string;
|
||||
context: string;
|
||||
line_number: number;
|
||||
}
|
||||
|
||||
/* ── Commands ───────────────────────────────────────────────── */
|
||||
export async function listNotes(vaultPath: string): Promise<NoteEntry[]> {
|
||||
return invoke<NoteEntry[]>("list_notes", { vaultPath });
|
||||
|
|
@ -46,6 +53,14 @@ export async function buildGraph(vaultPath: string): Promise<GraphData> {
|
|||
return invoke<GraphData>("build_graph", { vaultPath });
|
||||
}
|
||||
|
||||
export async function searchVault(vaultPath: string, query: string): Promise<SearchResult[]> {
|
||||
return invoke<SearchResult[]>("search_vault", { vaultPath, query });
|
||||
}
|
||||
|
||||
export async function renameNote(vaultPath: string, oldPath: string, newName: string): Promise<string> {
|
||||
return invoke<string>("rename_note", { vaultPath, oldPath, newName });
|
||||
}
|
||||
|
||||
export async function getOrCreateDaily(vaultPath: string): Promise<string> {
|
||||
return invoke<string>("get_or_create_daily", { vaultPath });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue