cleaning up the code

This commit is contained in:
erhazan 2026-02-26 04:21:31 +01:00
parent 1bf88110d5
commit 9975ab4e07
32 changed files with 269 additions and 3022 deletions

6
.gitignore vendored
View file

@ -8,9 +8,13 @@
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# Don't include the compiled files in the repo.
# They should be uploaded to GitHub releases instead.
main.js
styles.css
# Obsidian plugin settings (generated at runtime)
data.json
# Exclude sourcemaps
*.map

View file

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
N-brace (also called Brainavigator) is an Obsidian plugin that provides interactive 2D graph navigation and visualization using react-force-graph-2d. It displays local graphs centered on the current note, allowing keyboard-based navigation through note connections.
N-brace is an Obsidian plugin that provides interactive 2D graph navigation and visualization using react-force-graph-2d. It displays local graphs centered on the current note, allowing keyboard-based navigation through note connections.
## Build Commands

View file

@ -2,6 +2,7 @@ MIT License
Copyright (c) 2022 Alexander Weichart
Copyright (c) 2023 Hananoshika Yomaru
Copyright (c) 2026 Rafael Hazan
Copyright for portions util commit 7bfbc6ccbcadb350e451d15becfb375d6d8a6a6e are held by Alexander Weichart, 2022, up to caffbbb5c1bbb51a314fa2f29084b3c17c49cf55 are held by Hananoshika Yomaru, 2023. The rest is held by Rafael Hazan.

View file

@ -1,6 +1,6 @@
# Obsidian N-brace Plugin
# N-brace Plugin
[Obsidian N-brace Plugin](https://nn-ninja.github.io/n-brace/) Homepage
[N-brace Plugin](https://nn-ninja.github.io/n-brace/) Homepage
A slightly different Graph navigation and visualization for Obsidian.
@ -61,16 +61,18 @@ I have in my plans to optimize this process.
It is designed to rather operate on a local graph, not on huge and unclear global graph.
### Tag List
The tag panel appears on the left side of the graph whenever any visible node carries a tag. Tags are sorted by frequency and color-coded consistently across the view.
**Filtering:** Uncheck tags to hide nodes that don't share at least one checked tag. The graph contracts to show only nodes reachable from the selected note through the filtered set. A search box lets you filter the tag list itself by name (comma-separated terms supported).
**Color modes:** Two coloring modes are available via the toggle buttons:
- **Cloudy tags** — each node is surrounded by a translucent color cloud showing its tags. Double-clicking a cloud segment isolates that tag.
- **Edgy tags** — links are colored by tags shared between their source and target nodes.
**Node tags panel:** When a note is selected, a secondary panel shows the tags of that note alongside tags present on its direct neighbors (dimmed). You can add or remove tags from the selected note directly by clicking the checkboxes — changes are written back to the file immediately.
### Save Setting
You can update and save your general settings.
## Acknowledgement
I want to especially thank to the creators of this repos:
1. Force Graph repo: <https://github.com/vasturiano/force-graph>
2. The original 3D graph plugin: <https://github.com/AlexW00/obsidian-3d-graph>
3. The improved version: <https://github.com/HananoshikaYomaru/obsidian-3d-graph>
Their work was very helpful in getting my project off the ground.

BIN
bun.lockb

Binary file not shown.

View file

@ -1,38 +0,0 @@
{
"savedSettings": [],
"temporaryLocalGraphSetting": {
"filter": {
"searchQuery": "",
"showOrphans": true,
"showAttachments": false,
"depth": 1,
"linkType": "both"
},
"groups": [],
"display": {
"nodeSize": 3,
"linkThickness": 2,
"linkDistance": 100,
"nodeRepulsion": 2800,
"distanceFromFocal": 300,
"nodeHoverColor": "#ff0000",
"nodeHoverNeighbourColor": "#00ff00",
"linkHoverColor": "#0000ff",
"showExtension": false,
"showFullPath": false,
"showLinkArrow": true,
"dontMoveWhenDrag": false
}
},
"pluginSetting": {
"baseFolder": "/",
"titleFontSize": 16,
"defaultGraphSpan": 5,
"linkColorTheme": "custom",
"linkColorIn": "122, 41, 143",
"linkColorOut": "13, 91, 130",
"linkColorOther": "148, 148, 148",
"maxNodeNumber": 1000,
"searchEngine": "default"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

After

Width:  |  Height:  |  Size: 387 KiB

1943
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,8 +10,7 @@
"release": "bash ./release.sh",
"prepare": "husky install",
"typecheck": "tsc -noEmit -skipLibCheck && bunx dpdm -T --exit-code circular:1 --warning false --tree false src/main.ts",
"lint": "eslint . --ext .ts,.tsx --fix",
"devtools": "ELECTRON_DISABLE_SANDBOX=1 react-devtools"
"lint": "eslint . --ext .ts,.tsx --fix"
},
"keywords": [
"obsidian",
@ -44,7 +43,6 @@
"husky": "^8.0.3",
"obsidian": "latest",
"prettier": "^3.6.2",
"react-devtools": "^7.0.1",
"semver": "^7.5.4",
"tslib": "2.4.0",
"typescript": "^5.0.5",
@ -55,6 +53,7 @@
"copy-anything": "^3.0.5",
"d3": "^7.9.0",
"d3-force": "^3.0.0",
"graphology": "^0.26.0",
"jotai": "^2.9.1",
"lucide-react": "^0.511.0",
"observable-slim": "^0.1.6",

View file

@ -21,11 +21,6 @@ export type GraphSettings = {
linkColorOther: string;
};
export type NavHistory = {
backward: Record<string, string>;
forward: Record<string, string>;
};
export type NavIndexHistory = {
backward: Record<number, number>;
forward: Record<number, number>;
@ -45,8 +40,6 @@ export const graphSettingsAtom = atom<GraphSettings>({
linkColorOther: DEFAULT_SETTING.pluginSetting.linkColorOther,
});
export const navHistoryAtom = atom<NavHistory>({ backward: {}, forward: {} });
export const navIndexHistoryAtom = atomWithReset<NavIndexHistory>({ backward: {}, forward: {} });
/** Temporal state turning into selected node. */
@ -65,12 +58,3 @@ export const graphDataAtom = atom(
links: set(linksAtom, newGraph.links),
})
);
// const graphData = atom(async (get) => {
// const id = get(postId)
// const response = await fetch(
// `https://hacker-news.firebaseio.com/v0/item/${id}.json`
// )
// const data: PostData = await response.json()
// return data
// })

View file

@ -1,27 +1,6 @@
import type { NavHistory, NavIndexHistory } from "@/atoms/graphAtoms";
import type { NavIndexHistory } from "@/atoms/graphAtoms";
import type { Node } from "@/graph/Node";
export const stackOnHistory = (navHistory: NavHistory, soFarSelected: Node, newSelected: Node) => {
if (!soFarSelected) {
return navHistory;
}
const shortPaths = getShortestPath(soFarSelected, newSelected);
if (!shortPaths || shortPaths.length <= 1) {
return navHistory;
}
// console.log(`SHORTPATH: ${shortPaths.map((n) => n.path)}`);
for (const nodePair of consecutivePairs(shortPaths)) {
if (nodePair[0].isChildOf(nodePair[1].path)) {
navHistory.backward[nodePair[0].path] = nodePair[1].path;
navHistory.forward[nodePair[1].path] = nodePair[0].path;
}
}
return navHistory;
};
export const stackOnIndexHistory = (
navHistory: NavIndexHistory,
soFarSelected: Node,
@ -46,18 +25,6 @@ export const stackOnIndexHistory = (
return navHistory;
};
export const getNavBackward = (navHistory: NavHistory, node: Node): string | undefined => {
function getFirstParent() {
const firstParent = node.links.find((l) => l.target.path === node.path);
if (firstParent) {
return firstParent.source.path;
}
return undefined;
}
return navHistory.backward[node.path] ?? getFirstParent();
};
export const getNavIndexBackward = (
navHistory: NavIndexHistory,
node: Node
@ -73,18 +40,6 @@ export const getNavIndexBackward = (
return navHistory.backward[node.idx] ?? getFirstParent();
};
export const getNavForward = (navHistory: NavHistory, node: Node): string | undefined => {
function getFirstChild() {
const firstChild = node.links.find((l) => l.source.path === node.path);
if (firstChild) {
return firstChild.target.path;
}
return undefined;
}
return navHistory.forward[node.path] ?? getFirstChild();
};
export const getNavIndexForward = (navHistory: NavIndexHistory, node: Node): number | undefined => {
function getFirstChild() {
const firstChild = node.links.find((l) => l.source.idx === node.idx);

View file

@ -1,4 +1,5 @@
import { createContext } from "react";
import type { App } from "obsidian";
export const AppContext = createContext<App | undefined>(undefined);

View file

@ -219,7 +219,7 @@ const getMapFromMetaCache = (
)
.filter(
(nodePath) =>
nodePath.startsWith(baseFolder) && (nodePath.endsWith(".md") || isAvatarImg(nodePath))
(baseFolder === "" || nodePath.startsWith(baseFolder + "/")) && (nodePath.endsWith(".md") || isAvatarImg(nodePath))
);
});

View file

@ -1,4 +1,4 @@
import { type Node, type ElemType } from "@/graph/Node";
import { type Node } from "@/graph/Node";
export type LinkCache = Record<string, Record<string, number>>;
@ -6,7 +6,6 @@ export class Link {
public source: Node & Coords;
public target: Node & Coords;
public color: string = "green";
public type: ElemType = "note";
public label?: string;
public distance: number = 0;
@ -19,23 +18,6 @@ export class Link {
this.target = target;
}
/**
* Creates a link index for an array of links
* @param links
* @returns
*/
static createLinkIndex(links: Link[]): Map<string, Map<string, number>> {
const linkIndex = new Map<string, Map<string, number>>();
links.forEach((link, index) => {
if (!linkIndex.has(link.source.id)) {
linkIndex.set(link.source.id, new Map<string, number>());
}
linkIndex.get(link.source.id)?.set(link.target.id, index);
});
return linkIndex;
}
public static compare = (a: Link, b: Link) => {
return a.source.id === b.source.id && a.target.id === b.target.id;
};

View file

@ -1,10 +1,7 @@
import type { SectionData } from "@/views/graph/fileGraphMethods";
import type { TAbstractFile } from "obsidian";
import { type Link } from "@/graph/Link";
export type ElemType = "note" | "para";
export class Node {
public readonly id: string;
public readonly name: string;
@ -14,7 +11,6 @@ export class Node {
public idx: number;
public readonly neighbors: Node[];
// public readonly parents: Node[];
public inlinkCount: number;
public outlinkCount: number;
public links: Link[];
@ -22,15 +18,11 @@ export class Node {
public imagePath?: string;
public image?: ImageBitmap;
public zIndex: number = 10;
public type: ElemType = "note";
public label?: string | null = null;
public selected: boolean = false;
public expanded: boolean = false;
public imploded: boolean = false;
public tags: string[] = [];
public paras: Record<string, SectionData> = {};
constructor(
name: string,
path: string,
@ -82,10 +74,6 @@ export class Node {
}
}
// addParent(parent: Node) {
// if (!this.parents.includes(parent)) this.parents.push(parent);
// }
// Pushes a link to the node's links array if it doesn't already exist
addLink(link: Link, isGlobal: boolean = false) {
if (!this.links.some((l) => l.source === link.source && l.target === link.target)) {
@ -107,15 +95,6 @@ export class Node {
else return this.neighbors.some((neighbor) => neighbor.id === node);
}
/**
* Child links has target pointing to the child.
* @param nodePath
*/
public isParentOf(nodePath: string) {
// return this.parents.some((parent) => parent.path === nodePath);
return this.path !== nodePath && this.links.some((link) => link.target.path === nodePath);
}
public isChildOf(nodePath: string) {
return this.path !== nodePath && this.links.some((link) => link.source.path === nodePath);
}
@ -123,12 +102,4 @@ export class Node {
public static compare = (a: Node, b: Node) => {
return a.path === b.path;
};
public static createNodeIndex(nodes: Node[]) {
const nodeIndex = new Map<string, number>();
nodes.forEach((node, index) => {
nodeIndex.set(node.id, index);
});
return nodeIndex;
}
}

View file

@ -3,7 +3,6 @@ import { useEffect } from "react";
import type { GraphData } from "./useGraphFiltering";
import type { Graph } from "@/graph/Graph";
import type { Link } from "@/graph/Link";
import type { MutableRefObject } from "react";
import type { ForceGraphMethods } from "react-force-graph-2d";
@ -25,15 +24,6 @@ export function useGraphForces(
console.debug("Setting Graph forces");
fg.d3Force(
"link",
d3.forceLink().distance((link: Link) => {
if (link.label === "para") return 15;
if (link.source.imploded || link.target.imploded) return 50;
return 30;
})
);
fg.d3Force("collide", d3.forceCollide(12));
fg.d3Force("center", d3.forceCenter(dimensions.width / 2, dimensions.height / 2));

View file

@ -8,29 +8,25 @@ import {
expandNodePathAtom,
graphDataAtom,
graphNavAtom,
navHistoryAtom,
navIndexHistoryAtom,
nodeIdxMaxAtom,
} from "@/atoms/graphAtoms";
import { stackOnHistory, stackOnIndexHistory } from "@/atoms/graphOps";
import { stackOnIndexHistory } from "@/atoms/graphOps";
import { Link } from "@/graph/Link";
import { eventBus } from "@/util/EventBus";
interface UseGraphOperationsProps {
getExpandNode: (nodePath: string | undefined) => Promise<Graph>;
implodeNode: (nodePath: string | undefined) => Promise<Graph>;
zoomToFitNodes: (selectedPath: string | undefined, selectedIndex: number | undefined) => void;
}
export function useGraphOperations({
getExpandNode,
implodeNode,
zoomToFitNodes,
}: UseGraphOperationsProps) {
const [nodeIdxMax, setNodeIdxMax] = useAtom(nodeIdxMaxAtom);
const [selectedNode, setSelectedNode] = useAtom(graphNavAtom);
const [expandNodePath, setExpandNodePath] = useAtom(expandNodePathAtom);
const [navHistory, setNavHistory] = useAtom(navHistoryAtom);
const [navIndexHistory, setNavIndexHistory] = useAtom(navIndexHistoryAtom);
const [graphData, setGraphData] = useAtom(graphDataAtom);
@ -52,13 +48,6 @@ export function useGraphOperations({
setSelectedNode({ selectedPath: node.path, selectedIndex: node.idx });
const newHistory = stackOnHistory(
navHistory,
graphData.nodes.find((n) => n.path === selectedNode.selectedPath) as Node,
node
);
setNavHistory(newHistory);
if (selectedNode.selectedIndex !== undefined) {
const newIndexHistory = stackOnIndexHistory(
navIndexHistory,
@ -144,7 +133,7 @@ export function useGraphOperations({
};
const expandNode = (node: Node) => {
if (node.expanded || node.label === "para") {
if (node.expanded) {
signalSelectedNode(node);
return;
}
@ -152,94 +141,6 @@ export function useGraphOperations({
node.expanded = true;
};
const implodeNodeOp = async (implodedNode: Node) => {
if (!implodedNode) return;
implodedNode.imploded = true;
const nodePath = implodedNode.path;
const { nodes } = await implodeNode(nodePath);
const paraInLinks = nodes.map((node) => {
const link = new Link(implodedNode, node);
link.type = "para";
link.label = "para";
return link;
});
const graphLinks = graphData.links.map((link) => {
if (link.target.idx === implodedNode.idx && link.target.path.contains("#")) {
for (const paraNode of nodes) {
if (link.target.path === paraNode.path) {
link.target = paraNode as Node & Coords;
}
return link;
}
return link;
}
if (link.source.idx !== implodedNode.idx) {
return link;
}
for (const paraNode of nodes) {
const paraTargets = new Set(
paraNode.links.map((l) =>
l.target.path.contains("#")
? l.target.path.substring(0, l.target.path.indexOf("#"))
: l.target.path
)
);
if (paraTargets.has(link.target.path.substring(0, link.target.path.length - 3))) {
link.source = paraNode as Node & Coords;
return link;
}
}
return link;
});
setGraphData({
nodes: [...graphData.nodes, ...assignIdx(nodes)],
links: [...graphLinks, ...paraInLinks],
} as Graph);
signalSelectedNode(implodedNode);
};
const unimplodeNodeOp = async (implodedNode: Node) => {
if (!implodedNode) return;
implodedNode.imploded = false;
const implodedLinks = graphData.links.filter(
(link) => link.label !== "para" || link.source.idx !== implodedNode.idx
);
implodedLinks.forEach((link) => {
if (link.source.path.startsWith(implodedNode.path)) {
link.source = implodedNode as Node & Coords;
}
});
setGraphData({
nodes: graphData.nodes.filter(
(node) => node.label !== "para" || !node.isChildOf(implodedNode.path)
),
links: implodedLinks,
} as Graph);
signalSelectedNode(implodedNode);
};
const handleImplode = () => {
const node = graphData.nodes.find((n) =>
selectedNode.selectedIndex
? n.idx === selectedNode.selectedIndex
: n.path === selectedNode.selectedPath
) as Node;
if (node.imploded) {
unimplodeNodeOp(node);
} else {
implodeNodeOp(node);
}
};
const hoverNode = (node: Node) => {
if (!node) return;
@ -263,13 +164,9 @@ export function useGraphOperations({
node.selected = node.path === selectedNode.selectedPath;
});
graphData.links.forEach((link) => {
if (link.label === "para") {
// skip
} else if (link.target.path === selectedNode.selectedPath) {
if (link.target.path === selectedNode.selectedPath) {
link.label = "parent";
if (link.source.label !== "para") {
link.source.label = "parent";
}
link.source.label = "parent";
} else if (link.source.path === selectedNode.selectedPath) {
link.label = "child";
link.target.label = "child";
@ -296,7 +193,6 @@ export function useGraphOperations({
assignIdx,
expandNode,
signalSelectedNode,
handleImplode,
hoverNode,
};
}

View file

@ -1,43 +1,30 @@
import "@total-typescript/ts-reset";
import "@total-typescript/ts-reset/dom";
import { getDefaultStore } from "jotai/index";
import { RESET } from "jotai/utils";
import { Plugin, TFile } from "obsidian";
import type { GraphSettings } from "@/atoms/graphAtoms";
import type { LinkCache } from "@/graph/Link";
import type { App, HoverParent, HoverPopover, PluginManifest, WorkspaceLeaf } from "obsidian";
import type { App, PluginManifest, WorkspaceLeaf } from "obsidian";
import {
expandNodePathAtom,
graphDataAtom,
graphNavAtom,
graphSettingsAtom,
navIndexHistoryAtom,
nodeIdxMaxAtom,
} from "@/atoms/graphAtoms";
import { expandNodePathAtom, graphDataAtom, graphNavAtom, graphSettingsAtom, navIndexHistoryAtom, nodeIdxMaxAtom } from "@/atoms/graphAtoms";
import { config } from "@/config";
import { Graph } from "@/graph/Graph";
import { PluginSettingManager } from "@/SettingManager";
import { deepCompare } from "@/util/deepCompare";
import { eventBus } from "@/util/EventBus";
import { State } from "@/util/State";
import "@total-typescript/ts-reset";
import "@total-typescript/ts-reset/dom";
import { ReactForceGraphView, VIEW_TYPE_REACT_FORCE_GRAPH } from "@/views/ReactForceGraphView";
import { SettingTab } from "@/views/SettingTab";
import { getDefaultStore } from "jotai/index";
import type { GraphSettings } from "@/atoms/graphAtoms";
import { RESET } from "jotai/utils";
export default class ForceGraphPlugin extends Plugin implements HoverParent {
export default class ForceGraphPlugin extends Plugin {
_resolvedCache: LinkCache;
public readonly cacheIsReady: State<boolean> = new State(
this.app.metadataCache.resolvedLinks !== undefined
this.app.metadataCache.resolvedLinks !== undefined,
);
readonly store = getDefaultStore();
private isCacheReadyOnce = false;
/**
* we keep a graph here because we dont want to create a new graph every time we open a graph view
*/
@ -45,14 +32,11 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
public settingManager: PluginSettingManager;
public baseFolder: string = "";
public mousePosition = { x: 0, y: 0 };
public hoverPopover: HoverPopover | null = null;
constructor(app: App, manifest: PluginManifest) {
super(app, manifest);
console.debug('Main construct start.');
console.debug("Main construct start.");
// this will be initialized in the on cache changed function
this._resolvedCache = undefined as unknown as LinkCache;
@ -69,7 +53,7 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
*/
async onload() {
console.debug('Main onload start.');
console.debug("Main onload start.");
await this.settingManager.loadSettings();
// get the setting from setting manager
@ -84,12 +68,6 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
// init listeners
this.initListeners();
this.registerDomEvent(window, "mousemove", (event) => {
// set the mouse position
this.mousePosition.x = event.clientX;
this.mousePosition.y = event.clientY;
});
this.addSettingTab(new SettingTab(this.app, this));
this.registerView(VIEW_TYPE_REACT_FORCE_GRAPH, (leaf) => {
@ -106,7 +84,7 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
this.store.set(expandNodePathAtom, file.path);
}
}
})
}),
);
this.registerHoverLinkSource("force-graph", {
@ -125,16 +103,13 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
onunload(): void {
super.unload();
// unregister the resolved cache listener
this.app.metadataCache.off("resolved", this.onGraphCacheReady);
this.app.metadataCache.off("resolve", this.onGraphCacheChanged);
}
private initListeners() {
// all files are resolved, so the cache is ready:
this.app.metadataCache.on("resolved", this.onGraphCacheReady);
this.registerEvent(this.app.metadataCache.on("resolved", this.onGraphCacheReady));
// the cache changed:
this.app.metadataCache.on("resolve", this.onGraphCacheChanged);
this.registerEvent(this.app.metadataCache.on("resolve", this.onGraphCacheChanged));
// show open local graph button in file menu
this.registerEvent(
@ -142,11 +117,11 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
if (!file) return;
menu.addItem((item) => {
item
.setTitle("use N-brace")
.setTitle("Use N-brace")
.setIcon(config.icon)
.onClick(() => this.openGraph());
});
})
}),
);
}
@ -213,8 +188,6 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
let leafToUse: WorkspaceLeaf;
if (targetLeaf !== undefined) {
leafToUse = targetLeaf;
if (filePath !== targetLeaf.view.file?.path) {
await targetLeaf.openFile(file);
}
@ -297,8 +270,6 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
this._resolvedCache = structuredClone(this.app.metadataCache.resolvedLinks);
const pluginSetting = this.settingManager.getSettings().pluginSetting;
this.resetGlobalGraph(pluginSetting.baseFolder);
} else {
this.isCacheReadyOnce = true;
}
};
@ -314,7 +285,10 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
if (!leaves || !leaves.length) {
return;
}
const view = leaves[0]!.view as ReactForceGraphView;
const view = leaves[0]!.view;
if (!(view instanceof ReactForceGraphView)) {
return;
}
const graph = await view.getNewGraphData();
let maxIdx = 0;
@ -344,7 +318,7 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
private openGraph = async () => {
if (!this.app.workspace.lastActiveFile.path.startsWith(this.baseFolder)) {
alert(
`Your file isn't under mind map base path ${this.baseFolder}. You can set it up in settings.`
`Your file isn't under base path ${this.baseFolder}. You can set it up in settings.`,
);
return;
}
@ -365,19 +339,11 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
let loadingOverlay: HTMLDivElement | undefined = undefined;
if (graphView.length > 0) {
loadingOverlay = graphView[0]?.view.containerEl.createDiv({
cls: "loading-overlay",
attr: {
style:
"position: absolute; top: 0; left: 0; width: 100%; height: 100%; " +
"background: rgba(0, 0, 0, 0.5); z-index: 9999; display: flex; align-items: center;",
},
cls: "nbrace-loading-overlay",
});
loadingOverlay?.createEl("h2", {
text: "...loading...",
attr: {
style:
"color: white; font-family: Arial, sans-serif; font-size: 24px; text-align: center;",
},
cls: "nbrace-loading-text",
text: "Loading…",
});
}
return loadingOverlay;

View file

@ -1,79 +0,0 @@
// function getColorFromStylesheet(selector: string, property: string): string | null {
// try {
// for (const sheet of Array.from(document.styleSheets)) {
// const rules = sheet.cssRules ? Array.from(sheet.cssRules) : [];
// for (const rule of rules) {
// if (rule instanceof CSSStyleRule && rule.selectorText === selector) {
// return rule.style.getPropertyValue(property);
// }
// }
// }
// } catch (e) {
// console.error("Error accessing stylesheets: ", e);
// }
// return null; // Return null if not found
// }
// Helper to access the current theme in TS
export class ObsidianTheme {
backgroundPrimary: string;
backgroundPrimaryAlt: string;
backgroundSecondary: string;
backgroundSecondaryAlt: string;
backgroundModifierBorder: string;
backgroundModifierSuccess: string;
backgroundModifierError: string;
colorAccent: string;
interactiveAccentHover: string;
textNormal: string;
textMuted: string;
textFaint: string;
textAccent: string;
graphNode: string;
graphLine: string;
// some others missing, but not needed currently
constructor(root: HTMLElement) {
this.backgroundPrimary = getComputedStyle(root).getPropertyValue("--background-primary").trim();
this.backgroundPrimaryAlt = getComputedStyle(root)
.getPropertyValue("--background-primary-alt")
.trim();
this.backgroundSecondary = getComputedStyle(root)
.getPropertyValue("--background-secondary")
.trim();
this.backgroundSecondaryAlt = getComputedStyle(root)
.getPropertyValue("--background-secondary-alt")
.trim();
this.backgroundModifierBorder = getComputedStyle(root)
.getPropertyValue("--background-modifier-border")
.trim();
this.backgroundModifierSuccess = getComputedStyle(root)
.getPropertyValue("--background-modifier-success")
.trim();
this.backgroundModifierError = getComputedStyle(root)
.getPropertyValue("--background-modifier-error")
.trim();
this.colorAccent = getComputedStyle(root).getPropertyValue("--color-accent").trim();
this.textNormal = getComputedStyle(root).getPropertyValue("--text-normal").trim();
this.textMuted = getComputedStyle(root).getPropertyValue("--text-muted").trim();
this.textFaint = getComputedStyle(root).getPropertyValue("--text-faint").trim();
this.textAccent = getComputedStyle(root).getPropertyValue("--text-accent").trim();
this.interactiveAccentHover = getComputedStyle(root)
.getPropertyValue("--interactive-accent-hover")
.trim();
this.graphNode = getComputedStyle(root).getPropertyValue("--graph-node").trim();
this.graphLine = getComputedStyle(root).getPropertyValue("--graph-line").trim();
}
}

View file

@ -1,46 +0,0 @@
type OfReturnType<T> = Readonly<
| {
data: T;
error: undefined;
}
| {
data: undefined;
error: Error;
}
>;
export async function of<T = unknown>(promise: Promise<T>): Promise<OfReturnType<T>> {
return Promise.resolve(promise)
.then(
(result): Readonly<{ data: T; error: undefined }> => ({
data: result,
error: undefined,
})
)
.catch((err): Readonly<{ data: undefined; error: Error }> => {
if (typeof err === "undefined") {
err = new Error("Rejection with empty value");
}
return {
data: undefined,
error: err as Error,
};
});
}
export function syncOf<T = unknown>(operation: () => T): OfReturnType<T> {
try {
const result = operation(); // Execute the synchronous operation
return {
data: result,
error: undefined,
};
} catch (error) {
// Handle any errors thrown by the operation
return {
data: undefined,
error: error instanceof Error ? error : new Error(String(error)),
};
}
}

View file

@ -1,12 +0,0 @@
export function generateUUID() {
let d = new Date().getTime();
if (typeof performance !== "undefined" && typeof performance.now === "function") {
d += performance.now(); //use high-precision timer if available
}
const uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (d + Math.random() * 16) % 16 | 0;
d = Math.floor(d / 16);
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
});
return uuid;
}

View file

@ -1,13 +0,0 @@
export function hexToRGBA(hex: string, alpha: number): string {
// Remove the hash symbol if present
hex = hex.replace("#", "");
// Split the hex value into RGB components
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
// Create the RGBA color value
const rgba = `rgba(${r}, ${g}, ${b}, ${alpha})`;
return rgba;
}

View file

@ -1,90 +0,0 @@
export function wait(seconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}
/**
* wait for a condition to be true
*/
export function waitFor(
callback: () => boolean,
{
timeout = 30000,
interval = 100,
}: {
timeout?: number;
interval?: number;
}
): Promise<void> {
const startTime = Date.now();
return new Promise((resolve) => {
const intervalId = setInterval(() => {
if (callback()) {
clearInterval(intervalId);
resolve();
}
if (Date.now() - startTime >= timeout) {
// timeout
clearInterval(intervalId);
resolve();
}
}, interval);
});
}
export function waitForStable<T = unknown>(
accessor: () => T,
{
timeout = 30000,
minDelay = 100,
interval = 100,
rehitCount = 3,
}: {
/**
* at most wait for timeout milliseconds
*/
timeout?: number;
/**
* start after reaching minDelay
*/
minDelay?: number;
/**
* check every interval milliseconds
*/
interval?: number;
/**
* number of time the value need to be the same before resolving
*/
rehitCount?: number;
}
) {
let previousValue = accessor();
let hitCount = 0;
const startTime = Date.now();
return new Promise<T | undefined>((resolve) => {
const intervalId = setInterval(() => {
const currentValue = accessor();
if (Date.now() - startTime >= timeout) {
// timeout
clearInterval(intervalId);
resolve(undefined);
}
if (currentValue === previousValue) {
hitCount += 1;
} else {
hitCount = 0;
previousValue = currentValue;
}
if (
hitCount >= rehitCount &&
currentValue === previousValue &&
Date.now() - startTime >= minDelay
) {
clearInterval(intervalId);
resolve(currentValue);
}
}, interval);
});
}

View file

@ -1,4 +1,5 @@
import { getDefaultStore } from "jotai";
import { RESET } from "jotai/utils";
import { ItemView } from "obsidian";
import { StrictMode, createContext } from "react";
import { createRoot } from "react-dom/client";
@ -9,13 +10,13 @@ import type { IconName, WorkspaceLeaf } from "obsidian";
import type { Root } from "react-dom/client";
import { dimensionsAtom } from "@/atoms/graphAtoms";
import { dimensionsAtom, expandNodePathAtom, graphDataAtom, graphNavAtom, navIndexHistoryAtom, nodeIdxMaxAtom } from "@/atoms/graphAtoms";
import { config } from "@/config";
import { AppContext } from "@/context";
import { Graph } from "@/graph/Graph";
import { tagIndex } from "@/graph/TagIndex";
import { eventBus } from "@/util/EventBus";
import { getNewLocalGraph, implodeGraph, loadImagesForGraph, loadTagsForGraph } from "@/views/graph/fileGraphMethods";
import { getNewLocalGraph, loadImagesForGraph, loadTagsForGraph } from "@/views/graph/fileGraphMethods";
import { ReactForceGraph } from "@/views/graph/ReactForceGraph";
@ -59,9 +60,7 @@ export class ReactForceGraphView extends ItemView {
}
private renderComponent() {
const initGraph = this.getNewGraphData.bind(this);
const expandNodeFun = this.expandNode.bind(this);
const implodeFun = this.implodeNode.bind(this);
if (this.root) {
this.root.render(
@ -69,9 +68,7 @@ export class ReactForceGraphView extends ItemView {
<AppContext.Provider value={this.app}>
<ViewContext.Provider value={this}>
<ReactForceGraph
getInitialGraph={initGraph}
getExpandNode={expandNodeFun}
implodeNode={implodeFun}
titleFontSize={this.settingManager.getSettings().pluginSetting.titleFontSize}
/>
</ViewContext.Provider>
@ -82,8 +79,20 @@ export class ReactForceGraphView extends ItemView {
}
async onOpen() {
// Reset all graph state so reopening always shows a fresh graph
this.store.set(graphDataAtom, Graph.createEmpty());
this.store.set(graphNavAtom, RESET);
this.store.set(navIndexHistoryAtom, RESET);
this.store.set(nodeIdxMaxAtom, 0);
this.root = createRoot(this.containerEl.children[1]!);
this.renderComponent();
const path = (this.plugin.app.workspace.getActiveFile()
?? this.plugin.app.workspace.lastActiveFile)?.path;
if (path) {
this.store.set(expandNodePathAtom, path);
}
}
onResize() {
@ -122,10 +131,6 @@ export class ReactForceGraphView extends ItemView {
return graph;
}
private async implodeNode(nodePath: string): Promise<Graph> {
return implodeGraph(this.app, this.plugin, nodePath);
};
public async getNewGraphData(): Promise<Graph> {
return this.expandNode(this.plugin.app.workspace.getActiveFile()?.path ?? undefined);
}

View file

@ -1,11 +1,24 @@
import { rgb } from "d3";
import { PluginSettingTab, Setting } from "obsidian";
import { AbstractInputSuggest, PluginSettingTab, Setting, normalizePath } from "obsidian";
import type { GraphSettings } from "@/atoms/graphAtoms";
import type ForceGraphPlugin from "@/main";
import type { Setting as SettingPlugin } from "@/SettingsSchemas";
import type { State } from "@/util/State";
import type { App } from "obsidian";
import type { App , TFolder} from "obsidian";
class FolderSuggest extends AbstractInputSuggest<TFolder> {
getSuggestions(query: string): TFolder[] {
const lower = query.toLowerCase();
return this.app.vault.getAllFolders(true).filter((f) =>
f.path.toLowerCase().includes(lower)
);
}
renderSuggestion(folder: TFolder, el: HTMLElement): void {
el.setText(normalizePath(folder.path));
}
}
import { DEFAULT_SETTING } from "@/SettingManager";
import { eventBus } from "@/util/EventBus";
@ -41,34 +54,36 @@ export class SettingTab extends PluginSettingTab {
containerEl.addClasses(["n-brace-setting-tab"]);
new Setting(containerEl)
.setName("Base Folder")
.setName("Base folder")
.setDesc(
"The base directory where your notes for exploration live — navigation builds from here."
"Only notes inside this folder will appear in the graph."
)
.addText((text) => {
text
.setPlaceholder(`${DEFAULT_BASE_FOLDER}`)
.setValue(String(pluginSetting.baseFolder ?? DEFAULT_BASE_FOLDER))
.onChange(async (value) => {
// check if value is a number
if (!value.startsWith("/")) {
// set the error to the input
text.inputEl.setCustomValidity("Please enter absolute path (beginning with /).");
this.plugin.settingManager.updateSettings((setting) => {
setting.value.pluginSetting.baseFolder = DEFAULT_BASE_FOLDER;
});
} else {
// remove the error
text.inputEl.setCustomValidity("");
const clean = ("/" + normalizePath(value)).replace("//", "/");
this.plugin.settingManager.updateSettings((setting) => {
setting.value.pluginSetting.baseFolder = value;
setting.value.pluginSetting.baseFolder = clean;
});
this.plugin.resetGlobalGraph(value);
this.plugin.resetGlobalGraph(clean);
}
text.inputEl.reportValidity();
});
text.inputEl.setAttribute("type", "string");
const folderSuggest = new FolderSuggest(this.app, text.inputEl);
folderSuggest.onSelect((folder) => {
folderSuggest.setValue(("/" + normalizePath(folder.path)).replace("//", "/"));
text.inputEl.dispatchEvent(new Event("input"));
});
});
new Setting(containerEl)
@ -132,13 +147,13 @@ export class SettingTab extends PluginSettingTab {
});
new Setting(containerEl)
.setName("Link Color")
.setName("Link color")
.setDesc("Choose predefined colors or a custom color for graph links")
.addDropdown((dropdown) =>
dropdown
.addOption("light", "Light")
.addOption("dark", "Dark")
.addOption("custom", "Custom Color In & Out & Other")
.addOption("custom", "Custom color in & out & other")
.setValue(pluginSetting.linkColorTheme)
.onChange(async (value: "custom" | "light" | "dark") => {
if (value === "dark") {

View file

@ -17,9 +17,6 @@ export class Drawing {
if (link.color === "parent") {
return;
}
if (link.label === "para") {
return Drawing.drawPara(link, ctx, navDescending);
}
// Destructure the source and target coordinates
let { x: x1, y: y1 } = navDescending ? link.source : link.target;
let { x: x2, y: y2 } = navDescending ? link.target : link.source;
@ -54,9 +51,7 @@ export class Drawing {
? graphSettings.linkColorIn
: link.label === "child"
? graphSettings.linkColorOut
: link.label === "para"
? this.SELECTED_COLOR
: graphSettings.linkColorOther;
: graphSettings.linkColorOther;
// Color edge only by tags shared between source and target
const sourceTags = link.source.tags ?? [];
const targetTags = link.target.tags ?? [];
@ -151,33 +146,6 @@ export class Drawing {
ctx.restore();
}
static drawPara(
link: Link,
ctx: CanvasRenderingContext2D,
navDescending: boolean,
) {
// Destructure the source and target coordinates
const { x: x1, y: y1 } = navDescending ? link.source : link.target;
const { x: x2, y: y2 } = navDescending ? link.target : link.source;
if (!x1) {
return;
}
if (!x2) {
return;
}
ctx.save();
ctx.strokeStyle = "rgba(150, 150, 150, 0.6)";
ctx.lineWidth = 2;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
// Draw a glowing rectangle between two nodes sharing a tag.
// Inset at both ends so the rect is fully hidden under the node clouds.
static drawTagEdge(
@ -268,10 +236,6 @@ export class Drawing {
graphSettings: GraphSettings
) {
if (node.label === 'para') {
return Drawing.drawParagraphNode(node, ctx, globalScale, titleFontSize, graphSettings);
}
const label = node.name.contains(".")
? node.name.substring(0, node.name.length - 3)
: node.name;
@ -372,119 +336,6 @@ export class Drawing {
}
}
static drawParagraphNode(
node: Node & Coords & NodeData,
ctx: CanvasRenderingContext2D,
globalScale: number,
bodyFontSize: number,
graphSettings: GraphSettings
) {
const text = node.name.contains(".")
? node.name.substring(0, node.name.length - 3)
: node.name;
const fontSize = bodyFontSize / globalScale;
const lineHeight = fontSize * 1.3;
ctx.font = `${fontSize}px Sans-Serif`;
function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
const words = text.split(" ");
const lines: string[] = [];
let currentLine = "";
for (const word of words) {
const testLine = currentLine ? currentLine + " " + word : word;
if (ctx.measureText(testLine).width > maxWidth) {
lines.push(currentLine);
currentLine = word;
} else {
currentLine = testLine;
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines;
}
function getWidestString(
ctx: CanvasRenderingContext2D,
strings: string[]
): number {
let maxWidth = 0;
for (const s of strings) {
const w = ctx.measureText(s).width;
if (w > maxWidth) {
maxWidth = w;
}
}
return maxWidth;
}
const maxWidth = 180 / globalScale; // you can modify this
const lines = wrapText(ctx, text, maxWidth);
const padding = 6 / globalScale;
const foldSize = 12 / globalScale;
const width = getWidestString(ctx, lines) + padding * 2;
const height = lines.length * lineHeight + padding * 2;
const x = node.x - width / 2;
const y = node.y - height / 2;
const color =
node.selected
? this.SELECTED_COLOR
: node.label === "parent"
? graphSettings.linkColorIn
: node.label === "child"
? graphSettings.linkColorOut
: graphSettings.linkColorOther;
// --- DRAW PARAGRAPH BOX WITH FOLDED CORNER ---
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + width - foldSize, y);
ctx.lineTo(x + width, y + foldSize);
ctx.lineTo(x + width, y + height);
ctx.lineTo(x, y + height);
ctx.closePath();
ctx.fillStyle = "white";
ctx.strokeStyle = `rgb(${color})`;
ctx.lineWidth = 2 / globalScale;
ctx.fill();
ctx.stroke();
// --- DRAW THE FOLD ---
ctx.beginPath();
ctx.moveTo(x + width - foldSize, y);
ctx.lineTo(x + width - foldSize, y + foldSize);
ctx.lineTo(x + width, y + foldSize);
ctx.closePath();
ctx.fillStyle = "#f0f0f0"; // folded corner color
ctx.fill();
ctx.stroke();
// --- DRAW TEXT ---
ctx.fillStyle = "black";
ctx.textAlign = "left";
ctx.textBaseline = "top";
let textY = y + 2 * padding;
for (const line of lines) {
ctx.fillText(line, x + padding, textY);
textY += lineHeight;
}
node.nodeDims = [width, height];
}
private static hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);

View file

@ -1,4 +1,4 @@
.pm-graph-controls {
.nbrace-graph-controls {
position: absolute;
display: grid;
gap: 5px;
@ -8,18 +8,18 @@
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.right-grid {
.nbrace-right-grid {
top: 50px;
right: 10px;
grid-template-columns: 1fr 1fr; /* 2 columns */
}
.first-row {
.nbrace-first-row {
top: 50px;
left: 130px;
}
.pm-graph-controls button {
.nbrace-graph-controls button {
display: flex;
align-items: center;
justify-content: center;
@ -30,11 +30,11 @@
font-size: 16px;
}
.pm-graph-controls button:hover {
.nbrace-graph-controls button:hover {
background: var(--color-base-60);
}
.slider-control {
.nbrace-slider-control {
grid-column: 1 / -1;
display: grid;
grid-template-columns: 1fr;
@ -42,12 +42,12 @@
color: var(--color-base-00);
}
.slider-control input[type="range"] {
.nbrace-slider-control input[type="range"] {
width: 100px;
cursor: pointer;
}
.slider-value {
.nbrace-slider-value {
position: absolute;
top: -20px; /* Above the slider */
left: 50%;
@ -58,7 +58,7 @@
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.pm-tag-list {
.nbrace-tag-list {
position: absolute;
top: 50px;
left: 10px;
@ -74,13 +74,13 @@
pointer-events: auto;
}
.pm-tag-left {
.nbrace-tag-left {
display: flex;
flex-direction: column;
gap: 2px;
}
.pm-tag-minimize-btn {
.nbrace-tag-minimize-btn {
display: flex;
align-items: center;
justify-content: center;
@ -93,18 +93,18 @@
align-self: flex-start;
}
.pm-tag-minimize-btn:hover {
.nbrace-tag-minimize-btn:hover {
color: rgba(0, 0, 0, 0.7);
}
.pm-tag-filter-panel {
.nbrace-tag-filter-panel {
display: flex;
flex-direction: column;
gap: 2px;
}
.pm-tag-node-panel {
.nbrace-tag-node-panel {
display: flex;
flex-direction: column;
gap: 2px;
@ -113,7 +113,7 @@
min-width: 0;
}
.pm-tag-node-panel-header {
.nbrace-tag-node-panel-header {
font-size: 11px;
font-weight: 600;
color: gray;
@ -121,52 +121,52 @@
margin-bottom: 16px;
}
.pm-tag-node-items {
.nbrace-tag-node-items {
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.pm-tag-node-items::-webkit-scrollbar {
.nbrace-tag-node-items::-webkit-scrollbar {
width: 4px;
}
.pm-tag-node-items::-webkit-scrollbar-thumb {
.nbrace-tag-node-items::-webkit-scrollbar-thumb {
background: #2563eb;
border-radius: 2px;
}
.pm-tag-pill--neighbor {
.nbrace-tag-pill--neighbor {
opacity: 0.55;
}
.pm-tag-items {
.nbrace-tag-items {
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.pm-tag-items::-webkit-scrollbar {
.nbrace-tag-items::-webkit-scrollbar {
width: 4px;
}
.pm-tag-items::-webkit-scrollbar-track {
.nbrace-tag-items::-webkit-scrollbar-track {
background: #2563eb;
}
.pm-tag-items::-webkit-scrollbar-thumb {
.nbrace-tag-items::-webkit-scrollbar-thumb {
background: #2563eb;
border-radius: 2px;
}
.pm-tag-search-wrap {
.nbrace-tag-search-wrap {
position: relative;
margin-bottom: 2px;
}
.pm-tag-search {
.nbrace-tag-search {
width: 100%;
box-sizing: border-box;
padding: 2px 20px 2px 5px;
@ -176,7 +176,7 @@
background: rgba(255, 255, 255, 0.9);
}
.pm-tag-search-clear {
.nbrace-tag-search-clear {
position: absolute;
right: 3px;
top: 50%;
@ -190,7 +190,7 @@
cursor: pointer;
}
.pm-tag-item {
.nbrace-tag-item {
display: flex;
align-items: center;
gap: 4px;
@ -198,18 +198,18 @@
cursor: pointer;
}
.pm-tag-item input[type="checkbox"] {
.nbrace-tag-item input[type="checkbox"] {
margin: 0;
cursor: pointer;
flex-shrink: 0;
}
.pm-tag-item input[type="checkbox"]:disabled {
.nbrace-tag-item input[type="checkbox"]:disabled {
cursor: default;
opacity: 0.5;
}
.pm-tag-pill {
.nbrace-tag-pill {
border-radius: 8px;
padding: 1px 6px;
color: white;
@ -217,27 +217,27 @@
flex: 1;
}
.pm-tag-select-all-label {
.nbrace-tag-select-all-label {
color: gray;
}
.pm-tag-name {
.nbrace-tag-name {
color: var(--color-base-00);
flex: 1;
}
.pm-tag-count {
.nbrace-tag-count {
color: var(--color-base-40);
font-size: 11px;
}
.pm-tag-color-toggle {
.nbrace-tag-color-toggle {
display: flex;
align-items: center;
gap: 4px;
}
.pm-tag-color-btn {
.nbrace-tag-color-btn {
flex: 1;
padding: 2px 6px;
font-size: 11px;
@ -248,11 +248,30 @@
color: var(--color-base-00);
}
.pm-tag-color-btn.active {
.nbrace-tag-color-btn.active {
background: var(--color-base-40);
color: white;
}
.pm-tag-color-btn:hover:not(.active) {
.nbrace-tag-color-btn:hover:not(.active) {
background: rgba(0, 0, 0, 0.06);
}
.nbrace-loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.nbrace-loading-text {
color: white;
font-size: 24px;
text-align: center;
}

View file

@ -1,3 +1,4 @@
import { useAtomValue } from "jotai/react";
import {
ArrowDown,
ArrowLeft,
@ -9,8 +10,8 @@ import {
Sunrise,
} from "lucide-react";
import React, { useEffect, useState } from "react";
import "@/views/graph/GraphControls.css";
import { useAtomValue } from "jotai/react";
import { graphSettingsAtom } from "@/atoms/graphAtoms";
@ -22,7 +23,6 @@ interface GraphControlsProps {
onDirectionToggle: () => void;
isDescending: boolean;
onMaxPathLengthChange: (maxPathLength: number) => void;
onImplode: () => void;
}
export const GraphControls: React.FC<GraphControlsProps> = ({
@ -33,7 +33,6 @@ export const GraphControls: React.FC<GraphControlsProps> = ({
onDirectionToggle,
isDescending,
onMaxPathLengthChange,
onImplode,
}) => {
const [isHidden, setIsHidden] = useState(false);
const graphSettings = useAtomValue(graphSettingsAtom);
@ -63,7 +62,7 @@ export const GraphControls: React.FC<GraphControlsProps> = ({
}, [maxPathLength, onMaxPathLengthChange]);
return (
<div className="pm-graph-controls right-grid">
<div className="nbrace-graph-controls nbrace-right-grid">
<button onClick={handleToggle} title="Flip direction (Ctrl)">
{isDescending ? <FlipVertical /> : <FlipVertical2 />}
</button>
@ -84,7 +83,7 @@ export const GraphControls: React.FC<GraphControlsProps> = ({
<button onClick={onPanRight} title="Clockwise">
<ArrowRight />
</button>
<div className="slider-control">
<div className="nbrace-slider-control">
<label htmlFor="max-path-length" title="Visible graph span size">
G-span {maxPathLength}
</label>

View file

@ -23,16 +23,12 @@ import { TagList, UNTAGGED } from "@/views/graph/TagList";
interface GraphComponentProps {
data?: Graph;
getInitialGraph: () => Promise<Graph>;
getExpandNode: (nodePath: string | undefined) => Promise<Graph>;
implodeNode: (nodePath: string | undefined) => Promise<Graph>;
titleFontSize: number;
}
export const ReactForceGraph: React.FC<GraphComponentProps> = ({
getInitialGraph,
getExpandNode,
implodeNode,
titleFontSize,
}) => {
const fgRef = useRef<ForceGraphMethods | undefined>(undefined);
@ -52,14 +48,11 @@ export const ReactForceGraph: React.FC<GraphComponentProps> = ({
setGraphData,
selectedNode,
navIndexHistory,
assignIdx,
expandNode,
signalSelectedNode,
handleImplode,
hoverNode,
} = useGraphOperations({
getExpandNode,
implodeNode,
zoomToFitNodes,
});
@ -100,13 +93,6 @@ export const ReactForceGraph: React.FC<GraphComponentProps> = ({
setGraphData({ nodes: graphData.nodes, links: graphData.links } as Graph);
}, [graphData, setGraphData]);
// After tag mutations are committed, force a canvas redraw (simulation may be idle)
useEffect(() => {
if (nodeTagVersion > 0) {
(fgRef.current as any)?.refresh?.();
}
}, [nodeTagVersion]);
const handleToggleTag = useCallback((tag: string) => {
setUncheckedTags((prev) => {
const next = new Set(prev);
@ -191,7 +177,7 @@ export const ReactForceGraph: React.FC<GraphComponentProps> = ({
const nodes = tagFilteredData.nodes as (Node & Coords & NodeData)[];
for (const node of nodes) {
if (node.label === "para" || node.x === undefined || node.y === undefined) continue;
if (node.x === undefined || node.y === undefined) continue;
const nodeTags = (node.tags ?? []).filter((t) => !uncheckedTags.has(t));
if (nodeTags.length === 0) continue;
@ -242,7 +228,7 @@ export const ReactForceGraph: React.FC<GraphComponentProps> = ({
const allNodes = tagFilteredData.nodes as (Node & Coords & NodeData)[];
const visibleNodes = allNodes.filter(
(n) => n.label !== "para" && n.x !== undefined && n.y !== undefined
(n) => n.x !== undefined && n.y !== undefined
);
const getHalfDiag = (n: Node & Coords & NodeData) =>
@ -306,17 +292,8 @@ export const ReactForceGraph: React.FC<GraphComponentProps> = ({
// D3 force configuration
useGraphForces(fgRef, dimensions, graphData, setGraphData);
// Initialize graph on mount
// Focus container for keyboard navigation on mount
useEffect(() => {
const initGraph = async () => {
const graph = await getInitialGraph();
console.debug(`Init Graph data starting with ${graph.rootPath}`);
assignIdx(graph.nodes);
setGraphData(graph);
};
initGraph();
// Focus container for keyboard navigation
containerRef.current?.focus();
}, []);
@ -385,7 +362,6 @@ export const ReactForceGraph: React.FC<GraphComponentProps> = ({
onDirectionToggle={navigation.handleDirectionToggle}
isDescending={navigation.isDescending}
onMaxPathLengthChange={setMaxPathLength}
onImplode={handleImplode}
/>
</div>
);

View file

@ -204,32 +204,32 @@ export const TagList: React.FC<TagListProps> = ({ nodes, links, selectedNodePath
const showNodePanel = selectedNodePath && (ownTags.length > 0 || neighborOnlyTags.length > 0);
return (
<div className="pm-tag-list">
<div className="pm-tag-left">
<div className="pm-tag-color-toggle">
<div className="nbrace-tag-list">
<div className="nbrace-tag-left">
<div className="nbrace-tag-color-toggle">
<button
className="pm-tag-minimize-btn"
className="nbrace-tag-minimize-btn"
onClick={() => setIsMinimized((v) => !v)}
title={isMinimized ? "Expand tag list" : "Minimize tag list"}
>
{isMinimized ? <PanelLeftOpen size={14} /> : <PanelLeftClose size={14} />}
</button>
<button
className={`pm-tag-color-btn${tagColorMode === "nodes" ? " active" : ""}`}
className={`nbrace-tag-color-btn${tagColorMode === "nodes" ? " active" : ""}`}
onClick={() => onSetTagColorMode("nodes")}
title="Color nodes by tag"
>Nodes view</button>
>Cloudy tags</button>
<button
className={`pm-tag-color-btn${tagColorMode === "edges" ? " active" : ""}`}
className={`nbrace-tag-color-btn${tagColorMode === "edges" ? " active" : ""}`}
onClick={() => onSetTagColorMode("edges")}
title="Color edges by tag"
>Edges view</button>
>Edgy tags</button>
</div>
{!isMinimized && (
<div className="pm-tag-filter-panel">
<div className="pm-tag-search-wrap">
<div className="nbrace-tag-filter-panel">
<div className="nbrace-tag-search-wrap">
<input
className="pm-tag-search"
className="nbrace-tag-search"
type="text"
placeholder="Filter tags…"
value={search}
@ -237,21 +237,21 @@ export const TagList: React.FC<TagListProps> = ({ nodes, links, selectedNodePath
onKeyDown={(e) => { if (e.key === "Enter") handleSelectAll(); }}
/>
{search && (
<button className="pm-tag-search-clear" onClick={() => onSearchChange("")} title="Clear">×</button>
<button className="nbrace-tag-search-clear" onClick={() => onSearchChange("")} title="Clear">×</button>
)}
</div>
<label className="pm-tag-item pm-tag-select-all">
<label className="nbrace-tag-item nbrace-tag-select-all">
<input
ref={selectAllRef}
type="checkbox"
checked={allChecked}
onChange={handleSelectAll}
/>
<span className="pm-tag-select-all-label">All</span>
<span className="nbrace-tag-select-all-label">All</span>
</label>
<div
ref={itemsRef}
className="pm-tag-items"
className="nbrace-tag-items"
style={itemsMaxHeight !== undefined ? { maxHeight: itemsMaxHeight } : undefined}
>
{filteredTags.map(([tag, count], i) => {
@ -259,7 +259,7 @@ export const TagList: React.FC<TagListProps> = ({ nodes, links, selectedNodePath
const isChecked = !uncheckedTags.has(tag);
const isDisabled = isOnSelected && isChecked && checkedCurrentTagCount <= 1;
return (
<label key={tag} ref={i === 0 ? firstItemRef : undefined} className="pm-tag-item">
<label key={tag} ref={i === 0 ? firstItemRef : undefined} className="nbrace-tag-item">
<input
type="checkbox"
checked={isChecked}
@ -267,12 +267,12 @@ export const TagList: React.FC<TagListProps> = ({ nodes, links, selectedNodePath
onChange={() => onToggleTag(tag)}
/>
<span
className="pm-tag-pill"
className="nbrace-tag-pill"
style={{ backgroundColor: tagColorMap.get(tag) ?? "#9e9e9e" }}
>
{tag === UNTAGGED ? "UNSPECIFIED" : tag}
</span>
<span className="pm-tag-count">{count}</span>
<span className="nbrace-tag-count">{count}</span>
</label>
);
})}
@ -282,18 +282,18 @@ export const TagList: React.FC<TagListProps> = ({ nodes, links, selectedNodePath
</div>
{!isMinimized && showNodePanel && (
<div className="pm-tag-node-panel">
<div className="pm-tag-node-panel-header">Node tags</div>
<div className="pm-tag-node-items" style={itemsMaxHeight !== undefined ? { maxHeight: itemsMaxHeight } : undefined}>
<div className="nbrace-tag-node-panel">
<div className="nbrace-tag-node-panel-header">Node tags</div>
<div className="nbrace-tag-node-items" style={itemsMaxHeight !== undefined ? { maxHeight: itemsMaxHeight } : undefined}>
{ownTags.map((tag) => (
<label key={tag} className="pm-tag-item">
<label key={tag} className="nbrace-tag-item">
<input
type="checkbox"
checked={pendingToggled.has(tag) ? pendingToggled.get(tag)! : selectedNodeTags.has(tag)}
onChange={() => handleFileTagToggle(tag)}
/>
<span
className="pm-tag-pill"
className="nbrace-tag-pill"
style={{ backgroundColor: tagColorMap.get(tag) ?? "#9e9e9e" }}
>
{tag}
@ -301,19 +301,19 @@ export const TagList: React.FC<TagListProps> = ({ nodes, links, selectedNodePath
</label>
))}
{neighborOnlyTags.map(([tag, count]) => (
<label key={tag} className="pm-tag-item">
<label key={tag} className="nbrace-tag-item">
<input
type="checkbox"
checked={pendingToggled.has(tag) ? pendingToggled.get(tag)! : selectedNodeTags.has(tag)}
onChange={() => handleFileTagToggle(tag)}
/>
<span
className="pm-tag-pill pm-tag-pill--neighbor"
className="nbrace-tag-pill nbrace-tag-pill--neighbor"
style={{ backgroundColor: tagColorMap.get(tag) ?? "#9e9e9e" }}
>
{tag}
</span>
<span className="pm-tag-count">{count}</span>
<span className="nbrace-tag-count">{count}</span>
</label>
))}
</div>

View file

@ -1,13 +1,12 @@
import { TFile } from "obsidian";
import type { Link } from "@/graph/Link";
import type { Node } from "@/graph/Node";
import type ForceGraphPlugin from "@/main";
import type { LocalGraphSettings } from "@/SettingsSchemas";
import type { App, Vault } from "obsidian";
import type { App } from "obsidian";
import { Graph } from "@/graph/Graph";
import { Link } from "@/graph/Link";
import { Node } from "@/graph/Node";
import { type TagIndex } from "@/graph/TagIndex";
/**
@ -91,7 +90,6 @@ const traverseNode = (
export const loadImagesForGraph = async (plugin: ForceGraphPlugin, graph: Graph) => {
console.debug("Loading images...");
const imageLoad = graph.nodes.map(async (node) => {
// console.info(`try image ${node.path}... ${node.imagePath}`);
if (node.imagePath && !node.image) {
console.debug(`Loading ${node.imagePath} image for ${node.path}`);
const file = plugin.app.vault.getAbstractFileByPath(node.imagePath);
@ -197,47 +195,6 @@ export const getNewLocalGraph = (
return graph;
};
export const implodeGraph = async (app: App, plugin: ForceGraphPlugin, nodePath: string): Promise<Graph> => {
const node = plugin.globalGraph.getNodeById(nodePath)
if (node === null) {
return Graph.createEmpty();
}
if (!node.imploded) {
const file = app.vault.getAbstractFileByPath(nodePath);
if (!file || !(file instanceof TFile)) {
return Graph.createEmpty();
}
const sections = await parseNoteSections(file, app.vault);
node.paras = sections.paras;
}
const paraLinks: Link[] = [];
const paraNodes = Object.entries(node.paras).filter(([key]) => { return key !== ''})
.map(([paraTitle, sections]) => {
const paraName = paraTitle.contains("#") ? paraTitle.substring(0, paraTitle.indexOf('#')) : paraTitle;
const paraPath = paraTitle.contains("#") ? paraTitle : `${nodePath}#${paraTitle}`;
// 3 stands for .md
// const paraPath = `${node.path.substring(0, node.path.length - 3)}#${paraName}`;
const paraNode = new Node(paraName, paraPath, 0, sections.links.length);
paraNode.type = "para";
paraNode.label = "para";
paraNode.expanded = true;
paraNode.links = [];
for (const target of sections.links) {
const targetNode = new Node(target, target, 0, 0);
paraNode.links.push(new Link(paraNode, targetNode));
}
paraLinks.push(...paraNode.links);
return paraNode;
});
return {
nodes: paraNodes, links: paraLinks
} as unknown as Graph;
}
export const loadTagsForGraph = (app: App, graph: Graph, tagIdx: TagIndex) => {
for (const node of graph.nodes) {
if (tagIdx.has(node.path)) {
@ -282,44 +239,3 @@ export const loadTagsForGraph = (app: App, graph: Graph, tagIdx: TagIndex) => {
}
};
export interface SectionData {
links: string[]; // Resolved note names or paths
level: number; // Heading level (1 for #, 2 for ##, etc.)
}
interface NoteGraphData {
paras: Record<string, SectionData>; // e.g., {'#animal': {links: ['mustang', 'arab'], level: 1}}
// Add other note metadata as needed
}
async function parseNoteSections(file: TFile, vault: Vault): Promise<NoteGraphData> {
const content = await vault.read(file);
const lines = content.split('\n');
const sections: Record<string, SectionData> = {};
let currentSection: string = '';
let currentLevel = 0;
sections[currentSection] = { links: [], level: 0 };
for (const line of lines) {
const headingMatch = line.match(/^(#{1,1})\s*(.+)/);
if (headingMatch) {
const level = headingMatch[1].length;
currentSection = headingMatch[2].trim(); // e.g., 'animal' or 'wooden horse'
currentLevel = level;
if (!sections[currentSection]) {
sections[currentSection] = { links: [], level };
}
continue; // Move to next line after setting section
}
// Extract wiki-style links [[Note|Alias]] or [[Note]], ignore embeds ![[ ]]
const linkRegex = /\[\[([^!].+)]]/g; // Excludes embeds starting with !
let match;
while ((match = linkRegex.exec(line)) !== null) {
const linkText = match[1].replace('|', '#').trim();
sections[currentSection].links.push(linkText);
}
}
return { paras: sections };
}

View file

@ -1,222 +0,0 @@
/* src/views/graph/GraphControls.css */
.pm-graph-controls {
position: absolute;
display: grid;
gap: 5px;
background: rgba(255, 255, 255, 0.8);
padding: 5px;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.right-grid {
top: 50px;
right: 10px;
grid-template-columns: 1fr 1fr;
}
.first-row {
top: 50px;
left: 130px;
}
.pm-graph-controls button {
display: flex;
align-items: center;
justify-content: center;
background: var(--color-base-40);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.pm-graph-controls button:hover {
background: var(--color-base-60);
}
.slider-control {
grid-column: 1 / -1;
display: grid;
grid-template-columns: 1fr;
gap: 5px;
color: var(--color-base-00);
}
.slider-control input[type=range] {
width: 100px;
cursor: pointer;
}
.slider-value {
position: absolute;
top: -20px;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
padding: 2px 5px;
border-radius: 3px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.pm-tag-list {
position: absolute;
top: 50px;
left: 10px;
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
background: rgba(255, 255, 255, 0.8);
padding: 6px 8px;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
font-size: 12px;
pointer-events: auto;
}
.pm-tag-left {
display: flex;
flex-direction: column;
gap: 2px;
}
.pm-tag-minimize-btn {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: rgba(0, 0, 0, 0.4);
flex-shrink: 0;
align-self: flex-start;
}
.pm-tag-minimize-btn:hover {
color: rgba(0, 0, 0, 0.7);
}
.pm-tag-filter-panel {
display: flex;
flex-direction: column;
gap: 2px;
}
.pm-tag-node-panel {
display: flex;
flex-direction: column;
gap: 2px;
border-left: 1px solid rgba(0, 0, 0, 0.12);
padding-left: 8px;
min-width: 0;
}
.pm-tag-node-panel-header {
font-size: 11px;
font-weight: 600;
color: gray;
white-space: nowrap;
margin-bottom: 16px;
}
.pm-tag-node-items {
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.pm-tag-node-items::-webkit-scrollbar {
width: 4px;
}
.pm-tag-node-items::-webkit-scrollbar-thumb {
background: #2563eb;
border-radius: 2px;
}
.pm-tag-pill--neighbor {
opacity: 0.55;
}
.pm-tag-items {
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.pm-tag-items::-webkit-scrollbar {
width: 4px;
}
.pm-tag-items::-webkit-scrollbar-track {
background: #2563eb;
}
.pm-tag-items::-webkit-scrollbar-thumb {
background: #2563eb;
border-radius: 2px;
}
.pm-tag-search-wrap {
position: relative;
margin-bottom: 2px;
}
.pm-tag-search {
width: 100%;
box-sizing: border-box;
padding: 2px 20px 2px 5px;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 3px;
font-size: 11px;
background: rgba(255, 255, 255, 0.9);
}
.pm-tag-search-clear {
position: absolute;
right: 3px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 0;
line-height: 1;
font-size: 13px;
color: rgba(0, 0, 0, 0.4);
cursor: pointer;
}
.pm-tag-item {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
cursor: pointer;
}
.pm-tag-item input[type=checkbox] {
margin: 0;
cursor: pointer;
flex-shrink: 0;
}
.pm-tag-item input[type=checkbox]:disabled {
cursor: default;
opacity: 0.5;
}
.pm-tag-pill {
border-radius: 8px;
padding: 1px 6px;
color: white;
font-size: 11px;
flex: 1;
}
.pm-tag-select-all-label {
color: gray;
}
.pm-tag-name {
color: var(--color-base-00);
flex: 1;
}
.pm-tag-count {
color: var(--color-base-40);
font-size: 11px;
}
.pm-tag-color-toggle {
display: flex;
align-items: center;
gap: 4px;
}
.pm-tag-color-btn {
flex: 1;
padding: 2px 6px;
font-size: 11px;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 3px;
background: rgba(255, 255, 255, 0.5);
cursor: pointer;
color: var(--color-base-00);
}
.pm-tag-color-btn.active {
background: var(--color-base-40);
color: white;
}
.pm-tag-color-btn:hover:not(.active) {
background: rgba(0, 0, 0, 0.06);
}