Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,9 @@ node_modules/
tsconfig.tsbuildinfo
next-env.d.ts

# Plugin build output
plugin/dist/
plugin/node_modules/

# Internal docs
CLAUDE.md
4 changes: 3 additions & 1 deletion components/about-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useModKey } from "@/lib/utils"
interface AboutPanelProps {
open: boolean
onClose: () => void
container?: HTMLElement
}

function CopyEmailButton() {
Expand Down Expand Up @@ -94,12 +95,13 @@ const CONTENT_TYPE_HIGHLIGHTS = [
"claim", "question", "idea", "task", "thesis", "quote", "entity", "reference"
] as const

export function AboutPanel({ open, onClose }: AboutPanelProps) {
export function AboutPanel({ open, onClose, container }: AboutPanelProps) {
const mod = useModKey()
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent
side="right"
container={container}
className="w-full sm:max-w-2xl flex flex-col gap-0 p-0 bg-card border-l border-border z-[200] overflow-hidden"
>
<SheetTitle className="sr-only">About nodepad</SheetTitle>
Expand Down
10 changes: 10 additions & 0 deletions components/graph-area.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,16 @@ export function GraphArea({
onMouseLeave={handleSvgMouseUp}
onClick={() => { if (!didPan.current) setSelectedId(null) }}
>
{/* Full-area transparent rect — ensures wheel/pan events fire on empty canvas,
not just over painted nodes. Needed because Obsidian forces SVG display:inline-block
which limits hit-testing to painted regions only. */}
<rect
width={dims.w}
height={dims.h}
fill="transparent"
style={{ pointerEvents: "all" }}
/>

<defs>
<filter id="glow-synth" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="7" result="blur" />
Expand Down
4 changes: 3 additions & 1 deletion components/status-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface StatusBarProps {
modelLabel?: string
showHelpTooltip?: boolean
onHelpTooltipDismiss?: () => void
portalContainer?: HTMLElement
}

export function StatusBar({
Expand All @@ -38,6 +39,7 @@ export function StatusBar({
modelLabel,
showHelpTooltip,
onHelpTooltipDismiss,
portalContainer,
}: StatusBarProps) {
const [time, setTime] = useState("")
const [isAboutOpen, setIsAboutOpen] = useState(false)
Expand Down Expand Up @@ -230,7 +232,7 @@ export function StatusBar({
</div>
</div>

<AboutPanel open={isAboutOpen} onClose={() => setIsAboutOpen(false)} />
<AboutPanel open={isAboutOpen} onClose={() => setIsAboutOpen(false)} container={portalContainer} />
</header>
)
}
Expand Down
3 changes: 2 additions & 1 deletion components/tiling-minimap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ export function TilingMinimap({ pages, activePageIdx, onPageClick }: TilingMinim
onClick={() => onPageClick(idx)}
onMouseEnter={() => setHoveredIdx(idx)}
onMouseLeave={() => setHoveredIdx(null)}
className={`group relative flex flex-col items-center gap-[4px] p-1.5 rounded-md transition-all duration-150 outline-none ${
style={{ paddingTop: "1.5rem", paddingBottom: "1.5rem", paddingLeft: "0.375rem", paddingRight: "0.375rem" }}
className={`group relative flex flex-col items-center gap-[4px] rounded-md transition-all duration-150 outline-none ${
isActive
? "bg-primary/15 border border-primary/40 shadow-[0_0_0_1px_var(--primary)]"
: "border border-white/10 bg-white/[0.04] hover:bg-white/[0.09] hover:border-white/25"
Expand Down
9 changes: 6 additions & 3 deletions components/ui/sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ function SheetClose({
}

function SheetPortal({
container,
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}: React.ComponentProps<typeof SheetPrimitive.Portal> & { container?: HTMLElement }) {
return <SheetPrimitive.Portal data-slot="sheet-portal" container={container} {...props} />
}

function SheetOverlay({
Expand All @@ -48,12 +49,14 @@ function SheetContent({
className,
children,
side = 'right',
container,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left'
container?: HTMLElement
}) {
return (
<SheetPortal>
<SheetPortal container={container}>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
Expand Down
43 changes: 24 additions & 19 deletions components/vim-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import {
import { Command } from "cmdk"
import { useModKey } from "@/lib/utils"

const ACTION_ITEMS = [
{ id: "export-nodepad", icon: FolderDown, label: "Export", sub: ".nodepad" },
{ id: "import-nodepad", icon: FolderInput, label: "Import", sub: ".nodepad" },
{ id: "export-md", icon: Download, label: "Export", sub: "markdown" },
{ id: "copy-md", icon: Clipboard, label: "Copy", sub: "markdown" },
{ id: "clear", icon: Trash2, label: "Clear", sub: "canvas" },
const ALL_ACTION_ITEMS = [
{ id: "export-nodepad", icon: FolderDown, label: "Export", sub: ".nodepad", pluginOnly: false },
{ id: "import-nodepad", icon: FolderInput, label: "Import", sub: ".nodepad", pluginOnly: false },
{ id: "export-md", icon: Download, label: "Export", sub: "markdown", pluginOnly: true },
{ id: "copy-md", icon: Clipboard, label: "Copy", sub: "markdown", pluginOnly: true },
{ id: "clear", icon: Trash2, label: "Clear", sub: "canvas", pluginOnly: true },
]

// ─── Props ───────────────────────────────────────────────────────────────────
Expand All @@ -25,11 +25,12 @@ interface VimInputProps {
onCommand: (cmd: string, text?: string) => void
isCommandKOpen: boolean
setIsCommandKOpen: (open: boolean) => void
isPlugin?: boolean
}

// ─── Component ───────────────────────────────────────────────────────────────

export function VimInput({ onSubmit, onCommand, isCommandKOpen, setIsCommandKOpen }: VimInputProps) {
export function VimInput({ onSubmit, onCommand, isCommandKOpen, setIsCommandKOpen, isPlugin }: VimInputProps) {
const [value, setValue] = React.useState("")
const [search, setSearch] = React.useState("")
const [focusedIdx, setFocusedIdx] = React.useState(0)
Expand All @@ -48,11 +49,18 @@ export function VimInput({ onSubmit, onCommand, isCommandKOpen, setIsCommandKOpe
], [])

const NAV_ITEMS = React.useMemo(() => [
{ id: "open-projects", icon: FolderOpen, label: "Projects", sub: "" },
{ id: "new-project", icon: FolderPlus, label: "New Project", sub: "" },
{ id: "open-index", icon: BookOpen, label: "Index", sub: "" },
{ id: "open-synthesis", icon: Sparkles, label: "Synthesis", sub: "" },
], [])
...(!isPlugin ? [
{ id: "open-projects", icon: FolderOpen, label: "Projects", sub: "" },
{ id: "new-project", icon: FolderPlus, label: "New Project", sub: "" },
] : []),
{ id: "open-index", icon: BookOpen, label: "Index", sub: "" },
{ id: "open-synthesis", icon: Sparkles, label: "Synthesis", sub: "" },
], [isPlugin])

const ACTION_ITEMS = React.useMemo(
() => ALL_ACTION_ITEMS.filter(i => isPlugin ? i.pluginOnly : true),
[isPlugin]
)

// ── Filtered items ──────────────────────────────────────────────────────

Expand All @@ -67,13 +75,10 @@ export function VimInput({ onSubmit, onCommand, isCommandKOpen, setIsCommandKOpe
const totalItems = viewCount + navCount + actionCount

// Section boundaries for keyboard nav
// Section 0: views [0 .. viewCount)
// Section 1: nav [viewCount .. viewCount+navCount)
// Section 2: actions [viewCount+navCount .. total)
const sections = React.useMemo(() => [
{ start: 0, count: viewCount, cols: 3 },
{ start: viewCount, count: navCount, cols: 4 },
{ start: viewCount + navCount, count: actionCount, cols: 5 },
{ start: viewCount, count: navCount, cols: navCount },
{ start: viewCount + navCount, count: actionCount, cols: actionCount },
], [viewCount, navCount, actionCount])

const getSectionForIdx = React.useCallback((idx: number) => {
Expand Down Expand Up @@ -262,7 +267,7 @@ export function VimInput({ onSubmit, onCommand, isCommandKOpen, setIsCommandKOpe
{navItems.length > 0 && (
<div className="border-t border-white/10 pt-3">
<p className="px-1 pb-2 font-mono text-[8px] font-bold uppercase tracking-[0.2em] text-white/45">Navigate</p>
<div className="grid grid-cols-4 gap-1.5">
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${navItems.length}, minmax(0, 1fr))` }}>
{navItems.map((item, i) => {
const idx = viewCount + i
const focused = focusedIdx === idx
Expand Down Expand Up @@ -290,7 +295,7 @@ export function VimInput({ onSubmit, onCommand, isCommandKOpen, setIsCommandKOpe
{actionItems.length > 0 && (
<div className="border-t border-white/10 pt-3">
<p className="px-1 pb-2 font-mono text-[8px] font-bold uppercase tracking-[0.2em] text-white/45">Actions</p>
<div className="grid grid-cols-5 gap-1.5">
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${actionItems.length}, minmax(0, 1fr))` }}>
{actionItems.map((item, i) => {
const idx = viewCount + navCount + i
const focused = focusedIdx === idx
Expand Down
85 changes: 85 additions & 0 deletions plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Nodepad for Obsidian

A native renderer for `.nodepad` files inside Obsidian. Opens spatial canvas notes — tiling, kanban, and graph views — directly in a vault tab, with the same AI-assisted enrichment and ghost-synthesis behaviour as the standalone web app. Each `.nodepad` file is an independent space stored as versioned JSON inside your vault.

---

## Status

Version 0.1.0. Desktop-only (the plugin manifest sets `isDesktopOnly: true` because it shells out to local binaries and reaches `localhost` for some providers). Not yet listed in the Obsidian Community Plugins directory — install by sideloading the release build, as described below.

---

## Install

Manual sideload:

1. Grab `main.js`, `manifest.json`, and `styles.css` from a release build (see *Build from source* below if you don't have a prebuilt release).
2. Create the folder `<vault>/.obsidian/plugins/nodepad/` and drop the three files inside.
3. In Obsidian, open **Settings → Community plugins**, make sure Restricted mode is off, click **Reload plugins**, then enable **Nodepad**.

If you use [BRAT](https://github.com/TfTHacker/obsidian42-brat), you can also point it at the GitHub repo (`Dev-020/nodepad_Dev`) to track builds automatically — provided a release is published there.

---

## Usage

Once enabled, the plugin registers itself as the handler for the `.nodepad` file extension. Open any `.nodepad` file in your vault and it will render as a Nodepad space rather than as raw JSON.

To create a new space:

- Click the **layout-dashboard** ribbon icon on the left rail, or
- Open the command palette and run **New Nodepad Space**, or
- Right-click any folder in the file explorer and choose **New Nodepad Space** to create the file inside that folder.

New files are named `Untitled Space <timestamp>.nodepad` and opened in a new tab.

Inside a space, the input bar at the bottom adds notes, the menu icon (☰) at the top-left opens **Settings → Nodepad**, and `⌘K` / `Ctrl+K` opens the command palette for switching views (tiling / kanban / graph), opening the synthesis and index panels, exporting to markdown, and clearing the canvas. `⌘Z` / `Ctrl+Z` undoes the last block change (up to 20 steps). `Escape` closes open panels.

---

## AI providers

Configure under **Settings → Nodepad**. The provider you pick is used for both note enrichment and ghost-synthesis generation.

| Provider | Key required | Notes |
|---|---|---|
| OpenRouter *(default)* | Yes | Single key for Claude, GPT-4o, Gemini, DeepSeek, Mistral, and the free Nemotron tier. |
| OpenAI | Yes | Direct OpenAI key. GPT-4o, GPT-4.1, o4-mini. |
| Z.ai | Yes | GLM-4.5 / 4.7 / 5 / 5-turbo from Zhipu AI. |
| Ollama | Optional | Toggle **Use local Ollama** to route to `http://localhost:11434` with no key. Disable the toggle to use Ollama Cloud with a key. |
| Gemini CLI | No | Uses local Google account auth via the `gemini` CLI binary. Click **Check binary** in settings to verify it's on `PATH`. |

For local Ollama, the **Discover models** button in settings fetches the model list from `localhost:11434/api/tags`. For OpenRouter and OpenAI, a **Custom base URL** field lets you override the endpoint (useful for proxies).

---

## Build from source

```
cd plugin
npm install
npm run build
```

`npm run build` runs esbuild in production mode and writes `main.js`, `manifest.json`, and `styles.css` into `plugin/dist/`. That folder is gitignored and is what you sideload into your vault's `plugins/nodepad/` directory. Use `npm run dev` instead for an inline-sourcemap watch build during development.

The build pulls source from `plugin/src/` and aliases `@/lib`, `@/components`, and `@/app` to the corresponding folders in the repo root, so the Obsidian view shares the same React components as the web app.

---

## Troubleshooting

**"gemini not found on PATH"** when using the Gemini CLI provider — install the Gemini CLI from `g.co/gemini-cli` and make sure the `gemini` binary is reachable from the shell Obsidian was launched from. On macOS, launching Obsidian from Spotlight may not inherit the same `PATH` as your terminal; launching from a shell or adjusting your login shell config can help.

**"Local Ollama not running or unreachable"** when clicking **Discover models** — confirm Ollama is running and listening on the default `http://localhost:11434`.

**Plugin doesn't appear on mobile** — this is intentional. The manifest sets `isDesktopOnly: true` because the Gemini CLI provider spawns a child process and the local-Ollama path requires `localhost` networking.

**"No API key" banner across the top of a space** — open **Settings → Nodepad** and either paste a key for your selected provider, switch to Gemini CLI, or toggle on local Ollama.

---

## Roadmap

A custom base-URL setting for Ollama is a likely future addition. The OpenRouter and OpenAI providers already expose a **Custom base URL** field; Ollama doesn't, which means users who run Ollama on a non-default endpoint — a custom `OLLAMA_HOST`, a WSL or remote box reached over the network, or Docker with a remapped port — currently can't point the plugin at it. Bringing Ollama in line with the other providers would close that gap.
58 changes: 58 additions & 0 deletions plugin/esbuild.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import esbuild from "esbuild"
import process from "process"
import builtins from "builtin-modules"
import path from "path"
import fs from "fs"
import { fileURLToPath } from "url"

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const prod = process.argv[2] === "production"

fs.mkdirSync("dist", { recursive: true })

const context = await esbuild.context({
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins,
],
alias: {
"@/lib": path.resolve(__dirname, "../lib"),
"@/components": path.resolve(__dirname, "../components"),
"@/app": path.resolve(__dirname, "../app"),
"react": path.resolve(__dirname, "node_modules/react"),
"react-dom": path.resolve(__dirname, "node_modules/react-dom"),
"react/jsx-runtime": path.resolve(__dirname, "node_modules/react/jsx-runtime"),
"react/jsx-dev-runtime": path.resolve(__dirname, "node_modules/react/jsx-dev-runtime"),
"react-dom/client": path.resolve(__dirname, "node_modules/react-dom/client"),
},
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "dist/main.js",
jsx: "automatic",
})

if (prod) {
await context.rebuild()
fs.copyFileSync("manifest.json", "dist/manifest.json")
process.exit(0)
} else {
fs.copyFileSync("manifest.json", "dist/manifest.json")
await context.watch()
}
10 changes: 10 additions & 0 deletions plugin/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"id": "nodepad",
"name": "Nodepad",
"version": "0.1.0",
"minAppVersion": "1.4.0",
"description": "A spatial research tool where AI augments your thinking. Each .nodepad file is an independent canvas with tiling, kanban, and graph views.",
"author": "Dev-020",
"authorUrl": "https://github.com/Dev-020/nodepad_Dev",
"isDesktopOnly": true
}
Loading