Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
node_modules/
dist/
.astro/
packages/*/dist/

# Generated public assets (rebuilt on every deploy β€” don't commit)
public/graph.json
Expand All @@ -12,6 +13,7 @@ public/blocks.json
public/vault-assets/

# Obsidian workspace state (machine-local, not useful in git)
vault/.obsidian/plugins/
vault/.obsidian/workspace.json
vault/.obsidian/workspace-mobile.json

Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
"build": "node scripts/sync-titles.mjs && astro build",
"preview": "astro preview",
"astro": "astro",
"sync-titles": "node scripts/sync-titles.mjs"
"sync-titles": "node scripts/sync-titles.mjs",
"plugin:build": "pnpm --filter galaxybrain-obsidian-plugin build",
"plugin:dev": "pnpm --filter galaxybrain-obsidian-plugin dev",
"plugin:check": "pnpm --filter galaxybrain-obsidian-plugin check"
},
"pnpm": {
"ignoredBuiltDependencies": [
Expand Down Expand Up @@ -40,4 +43,4 @@
"typescript": "^5.9.3",
"unified": "^11.0.5"
}
}
}
69 changes: 69 additions & 0 deletions packages/obsidian-plugin/esbuild.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import esbuild from 'esbuild';
import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const watch = process.argv.includes('--watch');
const packageDir = path.dirname(fileURLToPath(import.meta.url));
const distDir = path.join(packageDir, 'dist');
const vaultPluginDir = path.resolve(
packageDir,
'../../vault/.obsidian/plugins/galaxybrain-preview',
);
const staticFiles = ['manifest.json', 'styles.css', 'versions.json'];

function ensureDir(dir) {
mkdirSync(dir, { recursive: true });
}

function copyStaticFiles(targetDir) {
ensureDir(targetDir);
for (const file of staticFiles) {
copyFileSync(path.join(packageDir, file), path.join(targetDir, file));
}
}

function copyBuildOutputs() {
const builtMain = path.join(distDir, 'main.js');
if (!existsSync(builtMain)) return;

copyStaticFiles(distDir);
copyStaticFiles(vaultPluginDir);
copyFileSync(builtMain, path.join(vaultPluginDir, 'main.js'));
}

const copyPluginFiles = {
name: 'copy-plugin-files',
setup(build) {
build.onEnd((result) => {
if (result.errors.length > 0) return;
copyBuildOutputs();
console.log(`[galaxybrain-preview] Copied build to ${vaultPluginDir}`);
});
},
};

const buildOptions = {
entryPoints: [path.join(packageDir, 'main.ts')],
outfile: path.join(distDir, 'main.js'),
bundle: true,
format: 'cjs',
platform: 'node',
target: 'es2022',
jsx: 'automatic',
jsxImportSource: 'react',
sourcemap: watch ? 'inline' : false,
external: ['obsidian', 'electron'],
logLevel: 'info',
plugins: [copyPluginFiles],
};

ensureDir(distDir);

if (watch) {
const context = await esbuild.context(buildOptions);
await context.watch();
console.log('[galaxybrain-preview] Watching for changes...');
} else {
await esbuild.build(buildOptions);
}
93 changes: 93 additions & 0 deletions packages/obsidian-plugin/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { ItemView, Plugin, WorkspaceLeaf } from 'obsidian';
import { GalaxyBrainPanel } from './src/galaxybrain-panel';
import { GalaxyBrainGraphStore } from './src/graph-store';

export const VIEW_TYPE_GALAXYBRAIN = 'galaxybrain-view';
const ICON_NAME = 'git-fork';

class GalaxyBrainView extends ItemView {
private readonly plugin: GalaxyBrainPlugin;
private reactRoot: Root | null = null;

constructor(leaf: WorkspaceLeaf, plugin: GalaxyBrainPlugin) {
super(leaf);
this.plugin = plugin;
}

getViewType(): string {
return VIEW_TYPE_GALAXYBRAIN;
}

getDisplayText(): string {
return 'GalaxyBrain';
}

getIcon(): string {
return ICON_NAME;
}

async onOpen(): Promise<void> {
this.contentEl.empty();
this.contentEl.addClass('galaxybrain-view__content');

const mount = this.contentEl.createDiv({ cls: 'galaxybrain-view__mount' });
this.reactRoot = createRoot(mount);
this.reactRoot.render(createElement(GalaxyBrainPanel, {
app: this.app,
store: this.plugin.store,
}));
}

async onClose(): Promise<void> {
this.reactRoot?.unmount();
this.reactRoot = null;
this.contentEl.empty();
}
}

export default class GalaxyBrainPlugin extends Plugin {
readonly store = new GalaxyBrainGraphStore(this.app);

async onload(): Promise<void> {
this.registerView(
VIEW_TYPE_GALAXYBRAIN,
(leaf) => new GalaxyBrainView(leaf, this),
);

this.addRibbonIcon(ICON_NAME, 'Open GalaxyBrain', () => {
void this.activateView();
});

this.addCommand({
id: 'open-galaxybrain',
name: 'Open GalaxyBrain',
callback: () => {
void this.activateView();
},
});

void this.store.start();
}

onunload(): void {
this.store.stop();
this.app.workspace.detachLeavesOfType(VIEW_TYPE_GALAXYBRAIN);
}

async activateView(): Promise<void> {
const { workspace } = this.app;
let leaf = workspace.getLeavesOfType(VIEW_TYPE_GALAXYBRAIN)[0];

if (!leaf) {
leaf = workspace.getLeaf(true);
await leaf.setViewState({
type: VIEW_TYPE_GALAXYBRAIN,
active: true,
});
}

workspace.revealLeaf(leaf);
}
}
10 changes: 10 additions & 0 deletions packages/obsidian-plugin/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"id": "galaxybrain-preview",
"name": "GalaxyBrain Preview",
"version": "0.1.0",
"minAppVersion": "1.6.0",
"description": "Native 3D GalaxyBrain graph view for Obsidian vaults.",
"author": "GalaxyBrain contributors",
"authorUrl": "https://github.com/trungnguyenarts/GalaxyBrain",
"isDesktopOnly": true
}
28 changes: 28 additions & 0 deletions packages/obsidian-plugin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "galaxybrain-obsidian-plugin",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "node esbuild.config.mjs",
"dev": "node esbuild.config.mjs --watch",
"check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"gray-matter": "^4.0.3",
"lucide-react": "^0.575.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-force-graph-3d": "^1.29.1",
"three": "^0.183.2"
},
"devDependencies": {
"@types/node": "^24.9.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.183.1",
"esbuild": "^0.25.3",
"obsidian": "^1.8.10",
"typescript": "^5.9.3"
}
}
109 changes: 109 additions & 0 deletions packages/obsidian-plugin/src/color-presets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { GraphData, GraphNode } from '../../../src/lib/types';
import type { GraphColorPreset } from './plugin-types';

interface ColorPresetDefinition {
filePalette: string[];
tagPalette: string[];
}

const COLOR_PRESETS: Record<GraphColorPreset, ColorPresetDefinition> = {
default: {
filePalette: [
'#3498db',
],
tagPalette: [
'#FF6B6B', '#FFA726', '#FFEE58', '#66BB6A', '#26C6DA',
'#42A5F5', '#7E57C2', '#AB47BC', '#EC407A',
'#8D6E63', '#78909C', '#D4E157',
],
},
ocean: {
filePalette: [
'#3b82f6', '#0ea5e9', '#06b6d4', '#2563eb',
'#14b8a6', '#60a5fa', '#2dd4bf', '#818cf8',
],
tagPalette: [
'#38bdf8', '#0ea5e9', '#0284c7', '#2563eb', '#1d4ed8',
'#14b8a6', '#06b6d4', '#22d3ee', '#60a5fa',
'#818cf8', '#2dd4bf', '#67e8f9',
],
},
ember: {
filePalette: [
'#e76f51', '#ef4444', '#f97316', '#fb7185',
'#f59e0b', '#f43f5e', '#dc2626', '#fdba74',
],
tagPalette: [
'#ef4444', '#f97316', '#fb7185', '#f59e0b', '#f43f5e',
'#dc2626', '#ea580c', '#facc15', '#b45309',
'#c2410c', '#fda4af', '#fdba74',
],
},
forest: {
filePalette: [
'#2a9d8f', '#22c55e', '#16a34a', '#84cc16',
'#10b981', '#4ade80', '#15803d', '#34d399',
],
tagPalette: [
'#2a9d8f', '#22c55e', '#16a34a', '#84cc16', '#65a30d',
'#10b981', '#4ade80', '#15803d', '#86efac',
'#4d7c0f', '#34d399', '#a3e635',
],
},
graphite: {
filePalette: [
'#94a3b8', '#cbd5e1', '#64748b', '#d6d3d1',
'#9ca3af', '#bdb2ff', '#71717a', '#e5e7eb',
],
tagPalette: [
'#cbd5e1', '#94a3b8', '#e2e8f0', '#64748b', '#a8a29e',
'#d6d3d1', '#9ca3af', '#bdb2ff', '#f5f5f4',
'#71717a', '#d4d4d8', '#e5e7eb',
],
},
};

function hashString(input: string): number {
let hash = 5381;
for (let index = 0; index < input.length; index += 1) {
hash = (((hash << 5) + hash) ^ input.charCodeAt(index)) >>> 0;
}
return hash;
}

function recolorNode(node: GraphNode, preset: ColorPresetDefinition): GraphNode {
if (node.type === 'ghost' || node.colorSource === 'ghost') {
return node;
}

if (node.type === 'file') {
return {
...node,
color: preset.filePalette[hashString(node.id) % preset.filePalette.length],
};
}

if (node.type === 'tag' || node.colorSource === 'tag') {
const family = node.id.replace(/^tag:/, '').split('/')[0];
const nextColor = preset.tagPalette[hashString(family) % preset.tagPalette.length];
return {
...node,
color: nextColor,
};
}

return node;
}

export function applyColorPreset(
graphData: GraphData | null,
presetId: GraphColorPreset,
): GraphData | null {
if (!graphData || presetId === 'default') return graphData;

const preset = COLOR_PRESETS[presetId];
return {
nodes: graphData.nodes.map((node) => recolorNode(node, preset)),
links: graphData.links,
};
}
Loading