rebranding to n-brace

This commit is contained in:
erhazan 2025-05-06 22:18:55 +02:00
parent 1a72f110de
commit 175761805a
57 changed files with 1448 additions and 2829 deletions

4
.github/FUNDING.yml vendored
View file

@ -1,2 +1,2 @@
github: [hananoshikayomaru]
custom: https://www.buymeacoffee.com/yomaru
github: [nn-ninja]
custom: https://buymeacoffee.com/nbrace

5
.gitignore vendored
View file

@ -15,13 +15,8 @@ main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
.obsidian
obsidian-dataview.d.ts
d3-force-3d.d.ts
dist

View file

@ -4,3 +4,5 @@
bun run typecheck
bun run lint
bun run build
npm run lint
npm run lint

120
README.md
View file

@ -1,131 +1,41 @@
# Obsidian BraiNavigator Plugin
# Obsidian N-brace Plugin
![Obsidian 3D graph](https://github.com/HananoshikaYomaru/obsidian-brainavigator/assets/43137033/c8a501e8-c5b6-4622-b5df-2a2335609cae)
![Obsidian N-brace Plugin](https://github.com/HananoshikaYomaru/obsidian-brainavigator/assets/43137033/c8a501e8-c5b6-4622-b5df-2a2335609cae)
A 3D Graph for Obsidian with dozen of features!
A Graph navigation and visualization for Obsidian.
see the demo: <https://www.youtube.com/watch?v=w1H-pcM8nOU>
see the demo: <https://www.youtube.com/ not yet published!>
<a href="https://www.buymeacoffee.com/yomaru" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 30px !important;width: 105px !important;" ></a> [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/hananoshikayomaru)
linki do sponsorowania
## Installation
### Through community plugin store
You can install this on community plugin store 😎
You can install this on community plugin store
### Manual installation
1. cd to `.obsidian/plugins`
2. git clone this repo
3. `cd obsidian-brainavigator && bun install && bun run build`
3. `cd obsidian-n-brace && bun install && bun run build`
4. there you go 🎉
## Features
### Local Graph
In a note, you can run command `Open local 3D graph` to open a local graph. A local graph will only show nodes that connect to this nodes.
In local graph you will have all the features of global graph, plus:
1. depth: you can control the depth of maximum distance of the nodes away from the center node
2. link type: show only inlinks, outlinks or both.
> ✨ Tips: I set the `cmd + L` to open local 3D graph.
### Not present Global Graph
(as I feel it to be useless)
### Filter Node on global graph
1. filter by query
2. filter attachment
1. definition of attachment: any files which are not markdown files
3. filter orphans
1. definition of orphans: any node that has no links IN THE CURRENT GRAPH
### Group and color nodes on global graph
you can use query to create groups and color nodes on a global graph.
### Label fade
When you are closer to the node, the label will appear. When you move away from the node, the node will fade away.
### Changing display setting
You can change the following:
1. Node size
1. Node size also scale with degree of connection. The more links a node has, the bigger it is.
2. Link thickness
3. Link distance
4. Node repulsion
5. Distance from Focal. This will affect the label fade. When distance from focal is smaller, node further away from what you are look at will have label with lower opacity.
6. node hover color, node hover neighbour color, link hover color
7. show file extension, show full path on label
8. show center coordination
9. show link arrow
10. don't node move on drag
11. [dag](https://en.wikipedia.org/wiki/Directed_acyclic_graph) mode. See more on [DAG Mode](https://github.com/HananoshikaYomaru/obsidian-3d--graph#dag-mode).
12. embedding graph into note through `3d--graph` code block post processor (working in progress)
> [!CAUTION] The `force-graph` code block post processor is still in working progress.
> It is definitely not stable yet, use it in your own risk.
### Focus on node
hold `Ctrl`/`cmd` and click on a node will fly and focus on a node. It is the perfect way to navigate on large graph.
### Search and focus
You can search and focus on a node in the graph
### Multiple selection and batch commands
hold `shift` and click on nodes to select multiple nodes. Then right click on one of the selected nodes to open commands. You can run batched commands on the selected nodes.
### DAG mode
You can see DAG(Directed acyclic graph) orientation on a graph. This only has effect when the graph is acyclic.
In a note tab context menu, you can click on `N-brace` to open a local graph of viewed note.
### Save Setting
You can save, update and restore previous settings.
You can update and save your general settings.
### Other customization
## Acknowledgement
You can change the style of the graphview by css snippet.
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.
1. open your setting > appearance
2. create a css snippet
3. add the following
```css
body {
/* change the default node color */
--graph-node: #00ff00;
/* change the default link color */
--graph-line: #ff0000;
}
.force-graph-view canvas {
/* change the background color */
background: white;
}
.force-graph-view .node-label {
/* change the default node label color */
color: #00ff00;
}
```
![](https://share.cleanshot.com/Ld9xzBJ4+)
### Feature roadmap
1. Use dataview as search engine.
2. expose API to make it even more easier for other plugin to integrate.
some other uncertain features are will sit in the github issues but I work on them base on ICE (Impact, confidence, effort)

BIN
bun.lockb

Binary file not shown.

39
data.json Normal file
View file

@ -0,0 +1,39 @@
{
"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,
"dagOrientation": "null"
}
},
"pluginSetting": {
"baseFolder": "/",
"titleFontSize": 16,
"defaultGraphSpan": 5,
"linkColorTheme": "dark",
"linkColorIn": "122, 41, 143",
"linkColorOut": "13, 91, 130",
"linkColorOther": "71, 30, 143",
"maxNodeNumber": 1000,
"searchEngine": "default"
}
}

View file

@ -1,6 +1,7 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import * as fs from "node:fs";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
@ -10,6 +11,15 @@ if you want to view the source, please visit the github repository of this plugi
const prod = process.argv[2] === "production";
const rebuildPlugin = {
name: "rebuild-handler",
setup(build) {
build.onEnd(async () => {
await fs.promises.rename("main.css", "styles.css");
});
},
};
const context = await esbuild.context({
banner: {
js: banner,
@ -38,6 +48,7 @@ const context = await esbuild.context({
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
plugins: [rebuildPlugin],
});
if (prod) {

View file

@ -1,11 +1,11 @@
{
"id": "brainavigator-plugin",
"name": "Brainavigator",
"id": "n-brace-plugin",
"name": "N-brace",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Brainavigator plugin helping with exploring notes and better orientate in node view.",
"description": "The N-brace plugin enhances navigation and clarity in the local graph view.",
"author": "Rafael Hazan",
"authorUrl": "https://hayio.github.io/brainavigator/",
"fundingUrl": "https://hayio.github.io/brainavigator/buymeacoffeetodo",
"authorUrl": "https://hayio.github.io/n-brace/",
"fundingUrl": "https://buymeacoffee.com/nbrace",
"isDesktopOnly": false
}
}

View file

@ -1,7 +1,7 @@
{
"name": "brainavigator",
"name": "n-brace",
"version": "1.1.11",
"description": "A 3D graph for Obsidian",
"description": "A graph navigation and visualization for Obsidian",
"main": "main.js",
"scripts": {
"dev": "bun esbuild.config.mjs",
@ -10,14 +10,14 @@
"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 --fix"
"lint": "eslint . --ext .ts,.tsx --fix"
},
"keywords": [
"obsidian",
"plugin",
"3D",
"n-brace",
"navigation",
"D3",
"3D graph",
"graph"
],
"author": "Rafael Hazan",
@ -53,6 +53,7 @@
"dependencies": {
"react": "^18.3.1",
"react-dom": "18.3.1",
"lucide-react": "^0.511.0",
"jotai": "^2.9.1",
"force-graph": "^1.43.5",
"@tweenjs/tween.js": "^21.0.0",

View file

@ -1,22 +0,0 @@
import type { SearchConfig } from "@/Interfaces";
import { ActiveSearchEngine } from "@/Interfaces";
import type ForceGraphPlugin from "@/main";
import type { TAbstractFile } from "obsidian";
export class BasicSearchEngine extends ActiveSearchEngine {
private plugin: ForceGraphPlugin;
constructor(plugin: ForceGraphPlugin) {
super(false);
this.plugin = plugin;
}
parseQueryToConfig(query: string): SearchConfig {
throw new Error("Method not implemented.");
}
searchFiles(config: SearchConfig): TAbstractFile[] {
// base on the json, filter the files
throw new Error("Method not implemented.");
}
}

View file

@ -1,34 +0,0 @@
import type { SearchConfig } from "@/Interfaces";
import { ActiveSearchEngine } from "@/Interfaces";
import type ForceGraphPlugin from "@/main";
import { createNotice } from "@/util/createNotice";
import type { TAbstractFile } from "obsidian";
import { getAPI } from "obsidian-dataview";
export class DvSearchEngine extends ActiveSearchEngine {
private plugin: ForceGraphPlugin;
constructor(plugin: ForceGraphPlugin) {
super(false);
this.plugin = plugin;
}
checkDvEnabled() {
const dv = getAPI(this.plugin.app);
if (!dv) {
createNotice(
"Dataview is not enabled but you are using dataview search engine. Please update it in your plugin setting."
);
throw new Error("Dataview is not enabled");
}
}
parseQueryToConfig(query: string): SearchConfig {
this.checkDvEnabled();
throw new Error("Method not implemented.");
}
searchFiles(config: SearchConfig): TAbstractFile[] {
this.checkDvEnabled();
throw new Error("Method not implemented.");
}
}

View file

@ -1,61 +0,0 @@
import { BasicSearchEngine } from "@/BasicSearchEngine";
import { DvSearchEngine } from "@/DvSearchEngine";
import type { IFileManager, PassiveSearchEngine } from "@/Interfaces";
import { ActiveSearchEngine } from "@/Interfaces";
import { DefaultSearchEngine as DefaultSearchEngine } from "@/PassiveSearchEngine";
import { SearchEngineType } from "@/SettingsSchemas";
import type ForceGraphPlugin from "@/main";
/**
* this class will handle the active searching of a graph view.
*/
export class MyFileManager implements IFileManager {
private plugin: ForceGraphPlugin;
private _searchEngine: ActiveSearchEngine | PassiveSearchEngine;
constructor(plugin: ForceGraphPlugin) {
this.plugin = plugin;
this._searchEngine = this._setSearchEngine();
}
/**
* @internal
*/
private _setSearchEngine() {
const getSearchEngine = (searchEngine: SearchEngineType, plugin: ForceGraphPlugin) => {
if (searchEngine === SearchEngineType.default) return new DefaultSearchEngine(plugin);
else if (searchEngine === SearchEngineType.dataview) return new DvSearchEngine(plugin);
else return new BasicSearchEngine(plugin);
};
return getSearchEngine(
this.plugin.settingManager.getSettings().pluginSetting.searchEngine,
this.plugin
);
}
get searchEngine() {
return this._searchEngine;
}
setSearchEngine() {
this._searchEngine = this._setSearchEngine();
}
getFiles() {
return this.plugin.app.vault.getFiles();
}
getMarkdownFiles() {
return this.plugin.app.vault.getMarkdownFiles();
}
getAllFilesAndFolders() {
return this.plugin.app.vault.getAllLoadedFiles();
}
searchFiles(query: string) {
// check whether it is active search engine
if (this._searchEngine instanceof ActiveSearchEngine) {
return this._searchEngine.searchFiles(this._searchEngine.parseQueryToConfig(query));
}
throw new Error("passive search engine cannot search files");
}
}

View file

@ -1,18 +1,4 @@
import type { State } from "@/util/State";
import type { SearchView, TAbstractFile } from "obsidian";
/**
* the config object of the search engine
*/
export type SearchConfig = {
/**
* the file name
*/
file: string;
path: string;
extension: string;
tag: string;
};
export interface ISettingManager<SettingType = unknown> {
/**
@ -36,57 +22,3 @@ export interface ISettingManager<SettingType = unknown> {
*/
loadSettings(): Promise<SettingType>;
}
abstract class SearchEngine {
readonly useBuiltInSearchInput: boolean;
constructor(useBuiltInSearchInput: boolean) {
this.useBuiltInSearchInput = useBuiltInSearchInput;
}
}
export abstract class ActiveSearchEngine extends SearchEngine {
/**
* parse the query to config
*/
abstract parseQueryToConfig(query: string): SearchConfig;
/**
* search for files
*/
abstract searchFiles(config: SearchConfig): TAbstractFile[];
}
/**
* a passive search engine is not responsible for searching files, but it will listen to the change of the search result container and return the files
*/
export abstract class PassiveSearchEngine extends SearchEngine {
abstract addMutationObserver(
searchResultContainerEl: HTMLDivElement,
view: SearchView,
mutationCallback: (files: TAbstractFile[]) => void,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data?: any
): void;
}
export interface IFileManager {
/**
* get all the files in the vault
*/
getFiles(): void;
/**
* get all the markdown files in the vault
*/
getMarkdownFiles(): void;
/**
* get all files and folders in the vault
*/
getAllFilesAndFolders(): void;
/**
* given a query, search for files
*/
searchFiles(query: string): TAbstractFile[];
}

View file

@ -1,78 +0,0 @@
import type { PassiveSearchEngine } from "@/Interfaces";
import type ForceGraphPlugin from "@/main";
import { AsyncQueue } from "@/util/AsyncQueue";
import { waitForStable } from "@/util/waitFor";
import type { SearchView, TAbstractFile, TFile } from "obsidian";
export type SearchResultFile = ReturnType<typeof getFilesFromSearchResult>[0];
/**
* given the result from `getResultFromSearchView`, return the files
*/
export const getFilesFromSearchResult = (rawSearchResult: unknown) => {
// @ts-ignore
return Array.from(rawSearchResult.keys()) as TFile[];
};
export const getResultFromSearchView = async (searchView: SearchView) => {
await waitForStable(() => {
return searchView.dom.resultDomLookup.size;
}, {});
return searchView.dom.resultDomLookup;
};
/**
* this is the built in search engine that uses the obsidian search engine
*/
export class DefaultSearchEngine implements PassiveSearchEngine {
useBuiltInSearchInput = true;
plugin: ForceGraphPlugin;
constructor(plugin: ForceGraphPlugin) {
this.plugin = plugin;
}
/**
* given a search result container element, add a mutation observer to it
*/
addMutationObserver(
searchResultContainerEl: HTMLDivElement,
view: SearchView,
mutationCallback: (files: TAbstractFile[]) => void,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data?: any
) {
let files: TAbstractFile[] = [];
const asyncQueue = new AsyncQueue();
const observer = new MutationObserver(async (mutations) => {
// if the search result is loading or the cache is not ready, then we know that the search must not be ready yet
if (!this.plugin.cacheIsReady) return;
// disable this because seem like this will cause a bug of some group not updating color
// if (searchResultContainerEl.classList.contains("is-loading")) return;
files = getFilesFromSearchResult(await getResultFromSearchView(view));
// if the async queue is empty, add a task to it
if (asyncQueue.queue.length === 0)
asyncQueue.push(async () => {
await waitForStable(
() => {
return files.length;
},
{
timeout: 3000,
minDelay: 200,
interval: 100,
}
);
mutationCallback(files);
});
});
observer.observe(searchResultContainerEl, {
childList: true,
subtree: true,
});
}
}

View file

@ -1,19 +1,7 @@
import type { ISettingManager } from "@/Interfaces";
import { AsyncQueue } from "@/util/AsyncQueue";
import type {
Setting,
LocalGraphSettings,
MarkdownPostProcessorGraphSettings,
GraphSetting,
} from "@/SettingsSchemas";
import {
SettingSchema,
GraphType,
SearchEngineType,
CommandClickNodeAction,
defaultLocalGraphSetting,
defaultMarkdownPostProcessorGraphSetting,
} from "@/SettingsSchemas";
import type { GraphSetting, LocalGraphSettings, MarkdownPostProcessorGraphSettings, Setting } from "@/SettingsSchemas";
import { defaultLocalGraphSetting, defaultMarkdownPostProcessorGraphSetting, GraphType, SearchEngineType, SettingSchema } from "@/SettingsSchemas";
import { createNotice } from "@/util/createNotice";
import { State } from "@/util/State";
import type { Plugin } from "obsidian";
@ -66,8 +54,6 @@ export class PluginSettingManager implements ISettingManager<Setting> {
// load the data, this can be null if the plugin is used for the first time
const loadedData = (await this.plugin.loadData()) as unknown | null;
// console.log("loaded: ", loadedData);
// if the data is null, then we need to initialize the data
if (!loadedData) {
this.setting.value = DEFAULT_SETTING;
@ -86,8 +72,6 @@ export class PluginSettingManager implements ISettingManager<Setting> {
return this.setting.value;
}
// console.log("parsed loaded data successfully");
this.setting.value = result.data;
return this.setting.value;
}
@ -107,12 +91,8 @@ export class PluginSettingManager implements ISettingManager<Setting> {
}
this.isLoaded = true;
// console.log("parsed loaded data successfully");
}
await this.plugin.saveData(this.setting.value);
// debug
// console.log("saved: ", this.setting.value);
}
static getNewSetting(type: GraphType.local): LocalGraphSettings;
@ -132,7 +112,12 @@ export const DEFAULT_SETTING: Setting = {
temporaryLocalGraphSetting: defaultLocalGraphSetting,
pluginSetting: {
baseFolder: "/",
titleFontSize: 12,
titleFontSize: 16,
defaultGraphSpan: 5,
linkColorTheme: "dark",
linkColorIn: "122, 41, 143",
linkColorOut: "13, 91, 130",
linkColorOther: "71, 30, 143",
maxNodeNumber: 1000,
searchEngine: SearchEngineType.default,
},

View file

@ -179,6 +179,11 @@ export const SettingSchema = z.object({
pluginSetting: z.object({
baseFolder: z.string(),
titleFontSize: z.number(),
defaultGraphSpan: z.number(),
linkColorTheme: z.string(),
linkColorIn: z.string(),
linkColorOut: z.string(),
linkColorOther: z.string(),
maxNodeNumber: z.number(),
searchEngine: z.nativeEnum(SearchEngineType),
}),

View file

@ -3,14 +3,50 @@ import type { Link } from "@/graph/Link";
import type { Node } from "@/graph/Node";
import { atomWithReset } from "jotai/utils";
import type { Graph } from "@/graph/Graph";
import { DEFAULT_SETTING } from "@/SettingManager";
export const dimensionsAtom = atom({ width: 0, height: 0 });
interface GraphNavState {
selectedPath?: string;
selectedIndex?: number;
}
export const graphNavAtom = atomWithReset<GraphNavState>({ selectedPath: undefined });
export type GraphSettings = {
graphSpan: number;
linkColorIn: string;
linkColorOut: string;
linkColorOther: string;
};
export type NavHistory = {
backward: Record<string, string>;
forward: Record<string, string>;
};
export type NavIndexHistory = {
backward: Record<number, number>;
forward: Record<number, number>;
};
export const graphNavAtom = atomWithReset<GraphNavState>(
{ selectedPath: undefined, selectedIndex: undefined });
export const nodeIdxMaxAtom = atom(0);
export const graphSettingsAtom = atom<GraphSettings>({
graphSpan: DEFAULT_SETTING.pluginSetting.defaultGraphSpan,
linkColorIn: DEFAULT_SETTING.pluginSetting.linkColorIn,
linkColorOut: DEFAULT_SETTING.pluginSetting.linkColorOut,
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. */
export const expandNodePathAtom = atom<string | undefined>(undefined);
export const nodesAtom = atom<Node[]>([]);
export const linksAtom = atom<Link[]>([]);

View file

@ -1 +1,125 @@
import type { Node } from "@/graph/Node";
import type { NavHistory, NavIndexHistory } from "@/atoms/graphAtoms";
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, newSelected: Node) => {
if (!soFarSelected) {
return navHistory;
}
const shortPaths = getShortestPath(soFarSelected, newSelected);
if (!shortPaths || shortPaths.length <= 1) {
return navHistory;
}
for (const nodePair of consecutivePairs(shortPaths)) {
if (nodePair[0].isChildOf(nodePair[1].path)) {
navHistory.backward[nodePair[0].idx] = nodePair[1].idx;
navHistory.forward[nodePair[1].idx] = nodePair[0].idx;
}
}
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): number | undefined => {
function getFirstParent() {
const firstParent = node.links.find((l) => l.target.idx === node.idx);
if (firstParent) {
return firstParent.source.idx;
}
return undefined;
}
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);
if (firstChild) {
return firstChild.target.idx;
}
return undefined;
}
return navHistory.forward[node.idx] ?? getFirstChild();
};
const consecutivePairs = <T>(arr: T[]): [T, T][] =>
arr.slice(0, -1).map((item, index) => [item, arr[index + 1]]);
const getShortestPath = (node1: Node, node2: Node) => {
const visited = new Set<Node>();
const queue = [{ node: node1, path: [node1] }];
while (queue.length > 0) {
const { node, path } = queue.shift()!;
// If this node is the destination, return the path we took to get here
if (node === node2) {
return path;
}
visited.add(node);
// Go through all neighbors of the current node
for (const neighbor of node.links.map((l) =>
l.source.path === node.path ? l.target : l.source
)) {
if (!visited.has(neighbor)) {
queue.push({ node: neighbor, path: [...path, neighbor] });
}
}
}
// no path
return null;
};
export const calcNodeAngle = (parent: Node & Coords, node: Node & Coords) => {
return ((Math.PI + Math.atan2(-node.x + parent.x, node.y - parent.y)) * 180) / Math.PI;
};

View file

@ -1,31 +0,0 @@
import { deleteNote } from "@/commands/deleteNote";
import { createNotice } from "@/util/createNotice";
import type { BaseForceGraphView } from "@/views/graph/forceview/ForceGraphView";
import type { Node } from "@/graph/Node";
import { showShortestPath } from "@/commands/showShortestPath";
export interface Command {
title: string;
showConditon?: (view: BaseForceGraphView, nodes: Set<Node>) => boolean;
function: (view: BaseForceGraphView, nodes: Set<Node>) => void;
}
export const commands: Command[] = [
{
title: "Delete Note",
function: deleteNote,
},
{
title: "Test Command",
function: (view, nodes) => {
for (const node of nodes) {
const file = view.plugin.app.vault.getAbstractFileByPath(node.path);
if (file) createNotice(`run on ${file.name}`);
}
},
},
{
title: "Shortest Path",
showConditon: (view, nodes) => nodes.size === 2,
function: showShortestPath,
},
];

View file

@ -1,28 +0,0 @@
import { FuzzySuggestModal } from "obsidian";
import type { Node } from "@/graph/Node";
import type { BaseForceGraphView } from "@/views/graph/forceview/ForceGraphView";
import type { Command } from "@/commands/Command";
import { commands } from "@/commands/Command";
export class CommandModal extends FuzzySuggestModal<Command> {
private nodes: Set<Node>;
private view: BaseForceGraphView;
constructor(view: BaseForceGraphView, selectedNodes: Set<Node>) {
super(view.plugin.app);
this.nodes = selectedNodes;
this.view = view;
}
getItems() {
return commands.filter((command) => command.showConditon?.(this.view, this.nodes) ?? true);
}
getItemText(command: Command): string {
return command.title;
}
onChooseItem(command: Command, evt: MouseEvent | KeyboardEvent) {
command.function(this.view, this.nodes);
}
}

View file

@ -1,12 +0,0 @@
import type { BaseForceGraphView } from "@/views/graph/forceview/ForceGraphView";
import type { Node } from "@/graph/Node";
export const deleteNote = (view: BaseForceGraphView, nodes: Set<Node>) => {
const vault = view.plugin.app.vault;
for (const node of nodes) {
const file = vault.getAbstractFileByPath(node.path);
if (file) {
vault.trash(file, vault.config.trashOption === "system");
}
}
};

View file

@ -1,59 +0,0 @@
import type { BaseForceGraphView } from "@/views/graph/forceview/ForceGraphView";
import type { Node } from "@/graph/Node";
import { createNotice } from "@/util/createNotice";
const getShortestPath = (node1: Node, node2: Node) => {
const visited = new Set<Node>();
const queue = [{ node: node1, path: [node1] }];
while (queue.length > 0) {
const { node, path } = queue.shift()!;
// If this node is the destination, return the path we took to get here
if (node === node2) {
return path;
}
visited.add(node);
// Go through all neighbors of the current node
for (const neighbor of node.neighbors) {
if (!visited.has(neighbor)) {
queue.push({ node: neighbor, path: [...path, neighbor] });
}
}
}
// If we exhausted all possibilities and didn't find a path
console.error("No path found between the two nodes");
return null;
};
export const showShortestPath = (view: BaseForceGraphView, nodes: Set<Node>) => {
const [node1, node2] = [...nodes].filter(Boolean);
// figure the shortest path between the two nodes
if (!node1 || !node2) {
console.error("Invalid nodes provided");
return null;
}
const path = getShortestPath(node1, node2);
if (!path) {
createNotice("Shortest path not found");
return;
}
// set interval to highlight the path
// view.getForceGraph().interactionManager.focusOnNodeByPath(node.path)
const interval = setInterval(() => {
if (path.length > 0) {
const node = path.shift()!;
view.getForceGraph().interactionManager.focusOnNodeByPath(node.path);
} else {
clearInterval(interval);
}
}, 5000);
};

View file

@ -1,9 +1,9 @@
export const config = {
icon: "orbit",
viewType: {
local: "force-graph-view-local",
local: "n-brace-view",
},
displayText: {
local: "Brainavigator Graph",
local: "N-brace your brain",
},
};

View file

@ -3,9 +3,9 @@ import { Link } from "@/graph/Link";
import { Node } from "@/graph/Node";
import { copy } from "copy-anything";
import type { App, TAbstractFile } from "obsidian";
import { node } from "prop-types";
export class Graph {
public rootPath: string | undefined;
public readonly nodes: Node[];
public readonly links: Link[];
@ -14,11 +14,13 @@ export class Graph {
private readonly linkIndex: Map<string, Map<string, number>>;
constructor(
rootPath: string | undefined,
nodes: Node[],
links: Link[],
nodeIndex: Map<string, number>,
linkIndex: Map<string, Map<string, number>>
) {
this.rootPath = rootPath;
this.nodes = nodes;
this.links = links;
this.nodeIndex = nodeIndex || new Map<string, number>();
@ -95,6 +97,7 @@ export class Graph {
// Clones the graph
public clone = (): Graph => {
return new Graph(
this.rootPath,
copy(this.nodes),
copy(this.links),
copy(this.nodeIndex),
@ -103,12 +106,12 @@ export class Graph {
};
public static createEmpty = (): Graph => {
return new Graph([], [], new Map(), new Map());
return new Graph(undefined, [], [], new Map(), new Map());
};
// Creates a graph using the Obsidian API
public static createFromApp = (app: App, baseFolder: string): Graph => {
console.info(`Create mind map from path ${baseFolder}`);
console.debug(`Create mind map from path ${baseFolder}`);
const map = getMapFromMetaCache(
baseFolder,
app.metadataCache.resolvedLinks,
@ -168,7 +171,7 @@ export class Graph {
});
});
return new Graph(newNodes, links, Node.createNodeIndex(newNodes), Link.createLinkIndex(links));
return new Graph(undefined, newNodes, links, Node.createNodeIndex(newNodes), Link.createLinkIndex(links));
}
/**

View file

@ -7,6 +7,7 @@ export class Link {
public target: Node;
public color: string = "green";
public label?: string;
public distance: number = 0;
constructor(source: Node, target: Node) {
this.source = source;

View file

@ -8,6 +8,7 @@ export class Node {
public readonly val: number; // = weight, currently = 1 because scaling doesn't work well
public readonly unresolved: boolean;
public idx: number;
public readonly neighbors: Node[];
// public readonly parents: Node[];
public inlinkCount: number;
@ -17,8 +18,8 @@ export class Node {
public imagePath?: string;
public image?: ImageBitmap;
public zIndex: number = 10;
public topped: boolean = false;
public label?: string | null = null;
public expanded: boolean = false;
constructor(
name: string,
@ -42,8 +43,10 @@ export class Node {
// this.parents = parents;
this.links = links;
this.unresolved = val == 0;
this.idx = -1;
this.imagePath = imagePath;
this.image = image;
this.expanded = outlinkCount == 0;
}
// Creates an array of nodes from an array of files (from the Obsidian API)
@ -54,10 +57,7 @@ export class Node {
}
toggle(currMin: number, currMax: number) {
// this.topped = !this.topped;
// if (this.topped) {
this.zIndex = currMax + 1;
// }
}
/**
@ -106,6 +106,10 @@ export class Node {
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);
}
public static compare = (a: Node, b: Node) => {
return a.path === b.path;
};

View file

@ -1,26 +0,0 @@
import type { Node } from "@/graph/Node";
export class NodeGroup {
query: string;
color: string;
constructor(query: string, color: string) {
this.query = query;
this.color = color;
}
// TODO: this should match the Obsidian API result
static getRegex(query: string): RegExp {
return new RegExp(query);
}
static matches(query: string, node: Node): boolean {
return node.path.startsWith(this.sanitizeQuery(query));
}
static sanitizeQuery(query: string): string {
const trimmedQuery = query.trim();
if (trimmedQuery.startsWith("./")) return trimmedQuery.slice(1);
else return trimmedQuery;
}
}

View file

@ -1,5 +1,5 @@
import type { App, HoverParent, HoverPopover, PluginManifest } from "obsidian";
import { MarkdownView, Plugin } from "obsidian";
import type { App, HoverParent, HoverPopover, PluginManifest, WorkspaceLeaf } from "obsidian";
import { Plugin, TFile } from "obsidian";
import { State } from "@/util/State";
import { Graph } from "@/graph/Graph";
import type { LinkCache } from "@/graph/Link";
@ -9,12 +9,11 @@ import "@total-typescript/ts-reset/dom";
import { eventBus } from "@/util/EventBus";
import { SettingTab } from "@/views/SettingTab";
import { config } from "@/config";
import { MyFileManager } from "@/FileManager";
import { PluginSettingManager } from "@/SettingManager";
import { GraphType } from "@/SettingsSchemas";
import { ReactForceGraphView, VIEW_TYPE_REACT_FORCE_GRAPH } from "@/views/ReactForceGraphView";
import { getDefaultStore } from "jotai/index";
import { graphDataAtom, graphNavAtom } from "@/atoms/graphAtoms";
import type { GraphSettings} from "@/atoms/graphAtoms";
import { expandNodePathAtom, graphDataAtom, graphNavAtom, graphSettingsAtom, navIndexHistoryAtom, nodeIdxMaxAtom } from "@/atoms/graphAtoms";
import { RESET } from "jotai/utils";
export default class ForceGraphPlugin extends Plugin implements HoverParent {
@ -29,7 +28,6 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
*/
public globalGraph: Graph;
public fileManager: MyFileManager;
public settingManager: PluginSettingManager;
public baseFolder: string = "";
@ -48,9 +46,6 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
this.onGraphCacheChanged();
this.settingManager = new PluginSettingManager(this);
// this will be initialized in the onload function because we need to wait for the setting manager to initialize
this.fileManager = undefined as unknown as MyFileManager;
}
/**
@ -63,9 +58,6 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
// get the setting from setting manager
// const setting = this.settingManager.getSetting("test");
// initalise the file manager
this.fileManager = new MyFileManager(this);
// init the theme
this.cacheIsReady.value =
this.app.metadataCache.resolvedLinks !== undefined &&
@ -83,14 +75,31 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
this.addSettingTab(new SettingTab(this.app, this));
this.registerView(config.viewType.local, (leaf) => {
this.registerView(VIEW_TYPE_REACT_FORCE_GRAPH, (leaf) => {
return new ReactForceGraphView(leaf, this);
});
this.registerEvent(
this.app.workspace.on("active-leaf-change", (leaf) => {
if (leaf && leaf.view.getViewType() === "markdown") {
const file = leaf.view.file;
if (file) {
console.debug(`File navigated to: ${file.path}`);
eventBus.trigger("focus-node", file.path);
this.store.set(expandNodePathAtom, file.path);
}
}
})
);
// register hover link source TODO
this.registerHoverLinkSource("force-graph", {
defaultMod: true,
display: "Brainavigator Graph",
display: "N-brace",
});
eventBus.on("open-file", (filePath: string) => {
this.openFileInFirstTab(filePath);
});
}
@ -99,6 +108,7 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
// unregister the resolved cache listener
this.app.metadataCache.off("resolved", this.onGraphCacheReady);
this.app.metadataCache.off("resolve", this.onGraphCacheChanged);
this.app.workspace.detachLeavesOfType(VIEW_TYPE_REACT_FORCE_GRAPH);
}
private initListeners() {
@ -113,20 +123,109 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
if (!file) return;
menu.addItem((item) => {
item
.setTitle("Pocket graph")
.setTitle("N-brace")
.setIcon(config.icon)
.onClick(() => this.openGraph(GraphType.local));
.onClick(() => this.openGraph());
});
})
);
}
async openFileInFirstTab(filePath: string) {
const { workspace } = this.app;
// Find the first Markdown leaf (Tab 1)
// const markdownLeaves = workspace.getLeavesOfType("markdown");
const file = this.app.vault.getAbstractFileByPath(filePath);
if (!file || !(file instanceof TFile)) {
return;
}
// if (markdownLeaves.length === 0) {
// const rootLeaf = workspace.getMostRecentLeaf() || workspace.getLeaf();
// const newLeaf = workspace.createLeafBySplit(rootLeaf, "vertical", true); // Split left
// await newLeaf.openFile(file);
// workspace.revealLeaf(newLeaf);
// return;
// }
//
// // Use the first existing Markdown leaf
// const targetLeaf = markdownLeaves[markdownLeaves.length - 1];
// if (file) {
// await targetLeaf.openFile(file as any); // Open the file in the leaf
// workspace.revealLeaf(targetLeaf); // Ensure its visible
// } else {
// console.error(`File not found: ${filePath}`);
// }
const graphLeaf = workspace.getLeavesOfType(VIEW_TYPE_REACT_FORCE_GRAPH)[0];
if (!graphLeaf) {
console.error("Graph view not found");
return;
}
// Get Markdown leaves in the left split relative to the graph view
const leftMarkdownLeaves = this.getLeftMarkdownLeaves(graphLeaf);
const targetLeaf = this.getRightmostLeaf(leftMarkdownLeaves);
if (targetLeaf !== undefined) {
if (filePath !== targetLeaf.view.file?.path) {
await targetLeaf.openFile(file);
workspace.revealLeaf(targetLeaf);
}
} else {
// No Markdown leaves in left split, create a new left tab
const newLeaf = workspace.createLeafBySplit(graphLeaf, "vertical", true); // Split left
await newLeaf.openFile(file);
workspace.revealLeaf(newLeaf);
}
}
private getLeftMarkdownLeaves(graphLeaf: WorkspaceLeaf): WorkspaceLeaf[] {
const { workspace } = this.app;
const markdownLeaves = workspace.getLeavesOfType("markdown");
const leftLeaves: WorkspaceLeaf[] = [];
// Get bounding rect of the graph leaf
const graphRect = graphLeaf.view.containerEl.getBoundingClientRect();
// Filter Markdown leaves that are to the left of the graph leaf
markdownLeaves.forEach((leaf) => {
const leafRect = leaf.view.containerEl.getBoundingClientRect();
if (leafRect.width > 0 && leafRect.right <= graphRect.left) {
leftLeaves.push(leaf);
}
});
return leftLeaves;
}
private getRightmostLeaf(leaves: WorkspaceLeaf[]): WorkspaceLeaf | undefined {
if (leaves.length === 0) {
return undefined;
}
if (leaves.length === 1) {
return leaves[0];
}
let rightmostLeaf = leaves[0];
let maxRight = -Infinity;
leaves.forEach((leaf) => {
const rect = leaf.view.containerEl.getBoundingClientRect();
if (rect.right > maxRight) {
maxRight = rect.right;
rightmostLeaf = leaf;
}
});
return rightmostLeaf;
}
/**
* this will be called the when the cache is ready.
* And this will hit the else clause of the `onGraphCacheChanged` function
*/
private onGraphCacheReady = () => {
// console.log("Graph cache is ready");
this.cacheIsReady.value = true;
this.onGraphCacheChanged();
};
@ -147,10 +246,7 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
) {
this._resolvedCache = structuredClone(this.app.metadataCache.resolvedLinks);
const pluginSetting = this.settingManager.getSettings().pluginSetting;
this.baseFolder = pluginSetting.baseFolder;
this.baseFolder = (this.baseFolder.endsWith("/") ? this.baseFolder : this.baseFolder + "/").substring(1);
this.globalGraph = Graph.createFromApp(this.app, this.baseFolder);
this.resetGlobalGraph(pluginSetting.baseFolder);
} else {
this.isCacheReadyOnce = true;
}
@ -158,7 +254,7 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
public resetGlobalGraph = async (baseFolder: string) => {
this.baseFolder = (baseFolder.endsWith("/") ? baseFolder : baseFolder + "/").substring(1);
console.info(`resetGlobalGraph ${this.baseFolder}`);
console.debug(`resetGlobalGraph ${this.baseFolder}`);
// currentFile = this.app.workspace.getActiveFile();
this.globalGraph = Graph.createFromApp(this.app, this.baseFolder);
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_REACT_FORCE_GRAPH);
@ -167,25 +263,45 @@ export default class ForceGraphPlugin extends Plugin implements HoverParent {
}
const view: ReactForceGraphView = leaves[0].view;
const graph = await view.getNewGraphData();
let maxIdx = 0;
graph.nodes.forEach((n) => (n.idx = maxIdx++));
this.store.set(nodeIdxMaxAtom, maxIdx);
this.onGraphSettingAtomChanged({
graphSpan: this.settingManager.getSettings().pluginSetting.defaultGraphSpan,
linkColorIn: this.settingManager.getSettings().pluginSetting.linkColorIn,
linkColorOut: this.settingManager.getSettings().pluginSetting.linkColorOut,
linkColorOther: this.settingManager.getSettings().pluginSetting.linkColorOther,
});
console.debug(`Reset graph with max idx ${maxIdx - 1}`);
this.store.set(graphDataAtom, graph);
this.store.set(graphNavAtom, RESET);
if (graph.rootPath) {
this.store.set(graphNavAtom, { selectedPath: graph.rootPath });
} else {
this.store.set(graphNavAtom, RESET);
}
this.store.set(navIndexHistoryAtom, RESET);
};
/**
/**r
* this function will open a graph view in the current leaf
*/
private openGraph = async (graphType: GraphType) => {
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.`);
return;
}
eventBus.trigger("open-graph");
const leaf = this.app.workspace.getLeaf("split");
await leaf.setViewState({
type: config.viewType.local,
type: VIEW_TYPE_REACT_FORCE_GRAPH,
active: true,
});
};
onGraphSettingAtomChanged(settings: GraphSettings) {
this.store.set(graphSettingsAtom, settings);
}
}

View file

@ -7,5 +7,6 @@ type Coords = {
};
type NodeData = {
index: number;
nodeDims: number[];
}

View file

@ -1,12 +1,10 @@
// copied from https://github.com/Fevol/obsidian-typings/blob/e1ff0b68a2d4e6d6ff42b23b5eb18f1e30a4054c/obsidian-ex.d.ts
// import "obsidian";
import type {
import {
App,
CachedMetadata,
Command,
Constructor,
DataWriteOptions,
EditorPosition,
DataWriteOptions, EditorPosition,
EditorRange,
EditorSuggest,
EventRef,
@ -35,9 +33,9 @@ import type {
WorkspaceTabs,
WorkspaceWindow,
WorkspaceWindowInitData,
} from "obsidian";
import type { EditorView } from "@codemirror/view";
import type { EditorState, Extension } from "@codemirror/state";
} from 'obsidian';
import { EditorView } from '@codemirror/view';
import { EditorState, Extension } from '@codemirror/state';
/* eslint-disable @typescript-eslint/no-explicit-any */
@ -65,7 +63,7 @@ interface Account {
/**
* The license available to the account
*/
license: "" | "insider";
license: '' | 'insider';
/**
* Profile name
*/
@ -206,7 +204,7 @@ interface ThemeManifest {
* Version of the theme
* @remark Defaults to "0.0.0" if no theme manifest was provided in the repository
*/
version: "0.0.0" | string;
version: '0.0.0' | string;
}
interface CustomCSS extends Component {
@ -259,7 +257,7 @@ interface CustomCSS extends Component {
* Currently active theme, given by its name
* @remark "" is the default Obsidian theme
*/
theme: "" | string;
theme: '' | string;
/**
* Mapping of theme names to their manifest
*/
@ -368,6 +366,7 @@ interface CustomCSS extends Component {
setTranslucency: (translucency: boolean) => void;
}
interface ObsidianDOM {
/**
* Root element of the application
@ -387,6 +386,7 @@ interface ObsidianDOM {
workspaceEl: HTMLElement;
}
// interface EmbedRegistry {
// embedByExtension: Map<string, (e) => any>;
// }
@ -398,7 +398,7 @@ interface PositionedReference extends Reference {
position: {
start: Loc;
end: Loc;
};
}
}
interface LinkUpdate {
@ -507,36 +507,35 @@ interface HotkeyManager {
save: () => void;
}
type InternalPlugin =
| "audio-recorder"
| "backlink"
| "bookmarks"
| "canvas"
| "command-palette"
| "daily-notes"
| "editor-status"
| "file-explorer"
| "file-recovery"
| "global-search"
| "graph"
| "markdown-importer"
| "note-composer"
| "outgoing-link"
| "outline"
| "page-preview"
| "properties"
| "publish"
| "random-note"
| "slash-command"
| "slides"
| "starred"
| "switcher"
| "sync"
| "tag-pane"
| "templates"
| "word-count"
| "workspaces"
| "zk-prefixer";
type InternalPlugin = "audio-recorder" |
"backlink" |
"bookmarks" |
"canvas" |
"command-palette" |
"daily-notes" |
"editor-status" |
"file-explorer" |
"file-recovery" |
"global-search" |
"graph" |
"markdown-importer" |
"note-composer" |
"outgoing-link" |
"outline" |
"page-preview" |
"properties" |
"publish" |
"random-note" |
"slash-command" |
"slides" |
"starred" |
"switcher" |
"sync" |
"tag-pane" |
"templates" |
"word-count" |
"workspaces" |
"zk-prefixer"
interface InternalPlugins extends Events {
/**
@ -582,7 +581,7 @@ interface InternalPlugins extends Events {
/**
* @internal
*/
loadPlugin: ({ id: string, name: string }) => string;
loadPlugin: ({id: string, name: string}) => string;
/**
* @internal
*/
@ -620,6 +619,7 @@ interface KeyScope {
scope: EScope;
}
// interface KeymapManager {
// /**
// * Modifiers pressed within keyscope
@ -779,15 +779,14 @@ interface PropertyInfo {
count: number;
}
type PropertyWidgetType =
| "aliases"
type PropertyWidgetType = "aliases"
| "checkbox"
| "date"
| "datetime"
| "multitext"
| "number"
| "tags"
| "text";
| "text"
interface PropertyWidget {
/**
@ -853,10 +852,7 @@ interface MetadataTypeManager extends Events {
/**
* @internal Get expected widget type for property and the one inferred from the property value
*/
getTypeInfo: ({ key: string, type: string, value: any }) => {
inferred: PropertyWidget;
expected: PropertyWidget;
};
getTypeInfo: ({key: string, type: string, value: any}) => { inferred: PropertyWidget, expected: PropertyWidget }
/**
* Get all properties with an assigned widget type
*/
@ -983,6 +979,7 @@ interface MetadataTypeManager extends Events {
// show: () => void;
// }
interface PluginManifest {
/**
* Name of the author of the plugin
@ -1155,8 +1152,7 @@ interface WindowSelection {
win: Window;
}
type ConfigItem =
| "accentColor"
type ConfigItem = "accentColor"
| "alwaysUpdateLinks"
| "attachmentFolderPath"
| "autoConvertHtml"
@ -1207,13 +1203,13 @@ type ConfigItem =
| "useMarkdownLinks"
| "useTab"
| "userIgnoreFilters"
| "vimMode";
| "vimMode"
interface AppVaultConfig {
/**
* Appearance > Accent color
*/
accentColor: "" | string;
accentColor: '' | string;
/**
* Files & Links > Automatically update internal links
*/
@ -1221,7 +1217,7 @@ interface AppVaultConfig {
/**
* Files & Links > Attachment folder path
*/
attachmentFolderPath?: "/" | string;
attachmentFolderPath?: '/' | string;
/**
* Editor > Auto convert HTML
*/
@ -1245,20 +1241,20 @@ interface AppVaultConfig {
/**
* Community Plugins > Browse > Sort order
*/
communityPluginSortOrder: "download" | "update" | "release" | "alphabetical";
communityPluginSortOrder: 'download' | 'update' | 'release' | 'alphabetical';
/**
* Themes > Browse > Sort order
*/
communityThemeSortOrder: "download" | "update" | "release" | "alphabetical";
communityThemeSortOrder: 'download' | 'update' | 'release' | 'alphabetical';
/**
* Appearance > Theme
* @remark "" is the default Obsidian theme
*/
cssTheme?: "" | string;
cssTheme?: '' | string;
/**
* Editor > Default view for new tabs
*/
defaultViewMode?: "source" | "preview";
defaultViewMode?: 'source' | 'preview';
/**
*
*/
@ -1270,7 +1266,7 @@ interface AppVaultConfig {
/**
*
*/
fileSortOrder?: "alphabetical";
fileSortOrder?: 'alphabetical';
/**
* Editor > Always focus new tabs
*/
@ -1291,7 +1287,7 @@ interface AppVaultConfig {
/**
* Appearance > Interface font
*/
interfaceFontFamily?: "" | string;
interfaceFontFamily?: '' | string;
/**
* Editor > Use legacy editor
*/
@ -1303,7 +1299,7 @@ interface AppVaultConfig {
/**
* Mobile > Configure mobile Quick Action
*/
mobilePullAction?: "command-palette:open" | string;
mobilePullAction?: 'command-palette:open' | string;
/**
*
*/
@ -1315,7 +1311,7 @@ interface AppVaultConfig {
/**
*
*/
monospaceFontFamily?: "" | string;
monospaceFontFamily?: '' | string;
/**
* Appearance > Native menus
*/
@ -1323,22 +1319,22 @@ interface AppVaultConfig {
/**
* Files & Links > Default location for new notes | 'folder' > Folder to create new notes in
*/
newFileFolderPath?: "/" | string;
newFileFolderPath?: '/' | string;
/**
* Files & Links > Default location for new notes
*/
newFileLocation?: "root" | "current" | "folder";
newFileLocation?: 'root' | 'current' | 'folder';
/**
* Files & Links > New link format
*/
newLinkFormat?: "shortest" | "relative" | "absolute";
newLinkFormat?: 'shortest' | 'relative' | 'absolute';
/**
* Saved on executing 'Export to PDF' command
*/
pdfExportSettings?: {
pageSize: "letter" | string;
pageSize: 'letter' | string;
landscape: false | boolean;
margin: "0" | string;
margin: '0' | string;
downscalePercent: 100 | number;
};
/**
@ -1348,7 +1344,7 @@ interface AppVaultConfig {
/**
* Editor > Properties in document
*/
propertiesInDocument?: "visible" | "hidden" | "source";
propertiesInDocument?: 'visible' | 'hidden' | 'source'
/**
* Editor > Readable line length
*/
@ -1408,13 +1404,13 @@ interface AppVaultConfig {
/**
* Appearance > Text font
*/
textFontFamily?: "" | string;
textFontFamily?: '' | string;
/**
* Appearance > Base color scheme
* @remark Not be confused with cssTheme, this setting is for the light/dark mode
* @remark "moonstone" is light theme, "obsidian" is dark theme
*/
theme?: "moonstone" | "obsidian";
theme?: 'moonstone' | 'obsidian';
/**
* Appearance > Translucent window
*/
@ -1422,7 +1418,7 @@ interface AppVaultConfig {
/**
* Files & Links > Deleted files
*/
trashOption?: "system" | "local" | "none";
trashOption?: 'system' | 'local' | 'none';
/**
* @deprecated Probably left-over code from old properties type storage
*/
@ -1466,7 +1462,7 @@ interface FileEntry {
/**
* Type of entry
*/
type: "file" | "folder";
type: 'file' | 'folder';
}
interface ViewRegistry extends Events {
@ -1510,11 +1506,7 @@ interface ViewRegistry extends Events {
/**
* Register a view and its associated file extensions
*/
registerViewWithExtensions: (
extensions: string[],
type: string,
viewCreator: (leaf: WorkspaceLeaf) => View
) => void;
registerViewWithExtensions: (extensions: string[], type: string, viewCreator: (leaf: WorkspaceLeaf) => View) => void;
/**
* @internal
*/
@ -1559,13 +1551,7 @@ interface RecentFileTracker {
/**
* Get last n files of type (defaults to 10)
*/
getRecentFiles: ({
showMarkdown: boolean,
showCanvas: boolean,
showNonImageAttachments: boolean,
showImages: boolean,
maxCount: number,
}?) => string[];
getRecentFiles: ({showMarkdown: boolean, showCanvas: boolean, showNonImageAttachments: boolean, showImages: boolean, maxCount: number}?) => string[];
/**
* Set the last opened files
*/
@ -1650,7 +1636,7 @@ interface SerializedWorkspace {
/**
* Left ribbon
*/
leftRibbon: { hiddenItems: Record<string, boolean> };
leftRibbon: {hiddenItems: Record<string, boolean>};
/**
* Main (center) workspace leaf
*/
@ -1668,10 +1654,7 @@ interface ImportedAttachments {
name: string;
}
declare module "obsidian" {
interface MarkdownView {
file: TFile;
}
declare module 'obsidian' {
interface App {
/**
* The account signed in to Obsidian
@ -2034,11 +2017,7 @@ declare module "obsidian" {
* @internal
* @deprecated - Executes same functionality as `Scope.register`
*/
registerKey: (
modifiers: Modifier[],
key: string | null,
func: KeymapEventListener
) => KeymapEventHandler;
registerKey: (modifiers: Modifier[], key: string | null, func: KeymapEventListener) => KeymapEventHandler;
/**
* @internal
*/
@ -2057,7 +2036,7 @@ declare module "obsidian" {
/**
* @internal IndexedDB database
*/
db: IDBDatabase;
db: IDBDatabase
/**
* @internal File contents cache
*/
@ -2118,11 +2097,11 @@ declare module "obsidian" {
/**
* Get all property infos of the vault
*/
getAllPropertyInfos: () => Record<string, PropertyInfo>;
getAllPropertyInfos: () => Record<string, PropertyInfo>
/**
* Get all backlink information for a file
*/
getBacklinksForFile: (file?: TFile) => CustomArrayDict<string, Reference>;
getBacklinksForFile: (file?: TFile) => CustomArrayDict<string, Reference>
/**
* Get paths of all files cached in the vault
*/
@ -2138,7 +2117,7 @@ declare module "obsidian" {
/**
* Get all links (resolved or unresolved) in the vault
*/
getLinkSuggestions: () => { file: TFile | null; path: string }[];
getLinkSuggestions: () => { file: TFile | null, path: string }[];
/**
* Get destination of link path
*/
@ -2303,12 +2282,7 @@ declare module "obsidian" {
* @param extension - Extension of the file to create, defaults to "md"
* @param contents - Contents of the file to create, defaults to empty string
*/
createNewFile: (
location: TFolder = null,
filename: string = null,
extension: string = "md",
contents: string = ""
) => Promise<TFile>;
createNewFile: (location: TFolder = null, filename: string = null, extension: string = "md", contents: string = "") => Promise<TFile>;
/**
* Creates a new untitled folder in the vault at specified location
* @param location - Location to create the folder in, defaults to root
@ -2317,11 +2291,7 @@ declare module "obsidian" {
/**
* Creates a new Markdown file in the vault at specified location
*/
createNewMarkdownFile: (
location: TFolder = null,
filename: string = null,
contents: string = ""
) => Promise<TFile>;
createNewMarkdownFile: (location: TFolder = null, filename: string = null, contents: string = "") => Promise<TFile>;
/**
* Creates a new Markdown file based on linktext and path
* @param filename - Name of the file to create
@ -2346,13 +2316,7 @@ declare module "obsidian" {
* @param secondary_text - Text to insert (always inserted)
* @param atStart - Whether to insert text at the start or end of the file
*/
insertTextIntoFile: (
file: TFile,
primary_text: string,
basename: string,
secondary_text: string,
atStart: boolean = true
) => Promise<void>;
insertTextIntoFile: (file: TFile, primary_text: string, basename: string, secondary_text: string, atStart: boolean = true) => Promise<void>;
/**
* Iterate over all links in the vault with callback
* @param callback - Callback to execute for each link
@ -2485,6 +2449,7 @@ declare module "obsidian" {
setTitle: (title: string) => this;
}
interface Setting extends Modal {
/**
* Current active tab of the settings modal
@ -2571,7 +2536,7 @@ declare module "obsidian" {
/**
* @internal
*/
btime: { btime: (path: string, btime: number) => void };
btime: {btime: (path: string, btime: number) => void};
/**
* Mapping of file/folder path to vault entry, includes non-MD files
*/
@ -2611,7 +2576,7 @@ declare module "obsidian" {
/**
* @internal
*/
watchers: Record<string, { resolvedPath: string; watcher: any }>;
watchers: Record<string, {resolvedPath: string, watcher: any}>;
/**
* @internal Apply data write options to file
@ -2692,11 +2657,7 @@ declare module "obsidian" {
/**
* @internal
*/
reconcileFileCreation: (
normalizedPath: string,
normalizedNewPath: string,
option: boolean
) => void;
reconcileFileCreation: (normalizedPath: string, normalizedNewPath: string, option: boolean) => void;
/**
* @internal
*/
@ -2756,26 +2717,6 @@ declare module "obsidian" {
}
interface Workspace {
trigger(
name: "hover-link",
data: {
event: MouseEvent;
/**
* you need to register the link source in the `registerHoverLinkSource` function
*/
source: string;
targetEl: HTMLElement;
/**
* it can be any object, but you need to implements this interface
*/
hoverParent: HoverParent;
/**
* it is the path of the file
*/
linktext: string;
state?: EphemeralState;
}
): void;
/**
* Currently active tab group
*/
@ -2795,10 +2736,7 @@ declare module "obsidian" {
/**
* @internal
*/
editorSuggest: {
currentSuggest?: EditorSuggest<any>;
suggests: EditorSuggest<any>[];
};
editorSuggest: {currentSuggest?: EditorSuggest<any>, suggests: EditorSuggest<any>[]};
/**
* @internal
*/
@ -2898,19 +2836,11 @@ declare module "obsidian" {
* Get adjacent leaf in specified direction
* @remark Potentially does not work
*/
getAdjacentLeafInDirection: (
leaf: WorkspaceLeaf,
direction: "top" | "bottom" | "left" | "right"
) => WorkspaceLeaf | null;
getAdjacentLeafInDirection: (leaf: WorkspaceLeaf, direction: "top" | "bottom" | "left" | "right") => WorkspaceLeaf | null;
/**
* @internal Get the direction where the leaf should be dropped on dragevent
*/
getDropDirection: (
e: DragEvent,
rect: DOMRect,
directions: ["left", "right"],
leaf: WorkspaceLeaf
) => "left" | "right" | "top" | "bottom" | "center";
getDropDirection: (e: DragEvent, rect: DOMRect, directions: ["left", "right"], leaf: WorkspaceLeaf) => "left" | "right" | "top" | "bottom" | "center";
/**
* @internal Get the leaf where the leaf should be dropped on dragevent
* @param e Drag event
@ -2923,22 +2853,13 @@ declare module "obsidian" {
/**
* Get n last opened files of type (defaults to 10)
*/
getRecentFiles: ({
showMarkdown: boolean,
showCanvas: boolean,
showNonImageAttachments: boolean,
showImages: boolean,
maxCount: number,
}?) => string[];
getRecentFiles: ({showMarkdown: boolean, showCanvas: boolean, showNonImageAttachments: boolean, showImages: boolean, maxCount: number}?) => string[];
/**
* Get leaf in the side ribbon/dock and split if necessary
* @param sideRibbon Side ribbon to get leaf from
* @param split Whether to split the leaf if it does not exist
*/
getSideLeaf: (
sideRibbon: WorkspaceSidedock | WorkspaceMobileDrawer,
split: boolean
) => WorkspaceLeaf;
getSideLeaf: (sideRibbon: WorkspaceSidedock | WorkspaceMobileDrawer, split: boolean) => WorkspaceLeaf;
/**
* @internal
*/
@ -3048,16 +2969,15 @@ declare module "obsidian" {
/**
* @internal Split leaves in specified direction
*/
splitLeaf: (
leaf: WorkspaceLeaf,
newleaf: WorkspaceLeaf,
direction?: SplitDirection,
before?: boolean
) => void;
splitLeaf: (leaf: WorkspaceLeaf, newleaf: WorkspaceLeaf, direction?: SplitDirection, before?: boolean) => void;
/**
* Split provided leaf, or active leaf if none provided
*/
splitLeafOrActive: (leaf?: WorkspaceLeaf, direction?: SplitDirection) => void;
/**
* @internal
*/
trigger: (e: any) => void;
/**
* @internal Unregister a CodeMirror editor extension
*/
@ -3089,6 +3009,7 @@ declare module "obsidian" {
updateTitle: () => void;
}
interface Vault {
/**
* Low-level file system adapter for read and write operations
@ -3114,7 +3035,8 @@ declare module "obsidian" {
*/
fileMap: Record<string, TAbstractFile>;
on(name: "config-changed", callback: () => void, ctx?: any): EventRef;
on(name: 'config-changed', callback: () => void, ctx?: any): EventRef;
/**
* @internal Add file as child/parent to respective folders
@ -3151,11 +3073,7 @@ declare module "obsidian" {
/**
* @internal Get path for attachment that does not conflict with other existing files
*/
getAvailablePathForAttachments: (
filename: string,
file: TAbstractFile,
extension: string
) => string;
getAvailablePathForAttachments: (filename: string, file: TAbstractFile, extension: string) => string;
/**
* Get value from config by key
* @remark Default value will be selected if config value was not manually changed
@ -3275,6 +3193,8 @@ declare module "obsidian" {
writePluginData: (path: string, data: object) => Promise<void>;
}
// TODO: Add missing elements to other Obsidian interfaces and classes
interface Editor {
@ -3290,21 +3210,13 @@ declare module "obsidian" {
/**
* Make ranges of text highlighted within the editor given specified class (style)
*/
addHighlights: (
ranges: { from: EditorPosition; to: EditorPosition }[],
style: "is-flashing" | "obsidian-search-match-highlight",
remove_previous: boolean,
x: boolean
) => void;
addHighlights: (ranges: {from: EditorPosition, to: EditorPosition}[], style: "is-flashing" | "obsidian-search-match-highlight", remove_previous: boolean, x: boolean) => void;
/**
* Convert editor position to screen position
* @param pos Editor position
* @param mode Relative to the editor or the application window
*/
coordsAtPos: (
pos: EditorPosition,
relative_to_editor = false
) => { left: number; top: number; bottom: number; right: number };
coordsAtPos: (pos: EditorPosition, relative_to_editor = false) => {left: number, top: number, bottom: number, right: number};
/**
* Unfolds all folded lines one level up
* @remark If level 1 and 2 headings are folded, level 2 headings will be unfolded
@ -3318,16 +3230,11 @@ declare module "obsidian" {
/**
* Get all ranges that can be folded away in the editor
*/
getAllFoldableLines: () => { from: number; to: number }[];
getAllFoldableLines: () => { from:number, to:number }[];
/**
* Get a clickable link - if it exists - at specified position
*/
getClickableTokenAt: (pos: EditorPosition) => {
start: EditorPosition;
end: EditorPosition;
text: string;
type: string;
} | null;
getClickableTokenAt: (pos: EditorPosition) => { start: EditorPosition, end: EditorPosition, text: string, type: string } | null;
/**
* Get all blocks that were folded by their starting character position
*/
@ -3345,7 +3252,7 @@ declare module "obsidian" {
/**
* Get the closest character position to the specified coordinates
*/
posAtCoords: (coords: { left: number; top: number }) => EditorPosition;
posAtCoords: (coords: {left: number, top: number}) => EditorPosition;
/**
* Removes all highlights of specified class
*/
@ -3354,19 +3261,17 @@ declare module "obsidian" {
* Adds a search cursor to the editor
*/
searchCursor: (searchString: string) => {
current: () => { from: EditorPosition; to: EditorPosition };
findAll: () => { from: EditorPosition; to: EditorPosition }[];
findNext: () => { from: EditorPosition; to: EditorPosition };
findPrevious: () => { from: EditorPosition; to: EditorPosition };
current: () => {from: EditorPosition, to: EditorPosition};
findAll: () => {from: EditorPosition, to: EditorPosition}[];
findNext: () => {from: EditorPosition, to: EditorPosition};
findPrevious: () => {from: EditorPosition, to: EditorPosition};
replace: (replacement?: string, origin: string) => void;
replaceAll: (replacement?: string, origin: string) => void;
};
}
/**
* Applies specified markdown syntax to selected text or word under cursor
*/
toggleMarkdownFormatting: (
syntax: "bold" | "italic" | "strikethrough" | "highlight" | "code" | "math" | "comment"
) => void;
toggleMarkdownFormatting: (syntax: "bold" | "italic" | "strikethrough" | "highlight" | "code" | "math" | "comment") => void;
/**
* Clean-up function executed after indenting lists
@ -3473,6 +3378,7 @@ declare module "obsidian" {
renderer: ReadViewRenderer;
}
interface EventRef {
/**
* Context applied to the event callback
@ -3496,6 +3402,9 @@ declare module "obsidian" {
}
}
interface RendererSection {
el: HTMLElement;
html: string;
@ -3519,46 +3428,44 @@ interface ReadViewRenderer {
asyncSections: any[];
recycledSections: any[];
rendered: any[];
}
interface CMState extends EditorState {
vim: {
inputState: {
changeQueue: null;
keyBuffer: [];
motion: null;
motionArgs: null;
motionRepeat: [];
operator: null;
operatorArgs: null;
prefixRepeat: [];
registerName: null;
};
insertMode: false;
insertModeRepeat: undefined;
lastEditActionCommand: undefined;
lastEditInputState: undefined;
lastHPos: number;
lastHSPos: number;
changeQueue: null,
keyBuffer: [],
motion: null,
motionArgs: null,
motionRepeat: [],
operator: null,
operatorArgs: null,
prefixRepeat: [],
registerName: null,
},
insertMode: false,
insertModeRepeat: undefined,
lastEditActionCommand: undefined,
lastEditInputState: undefined,
lastHPos: number,
lastHSPos: number,
lastMotion: {
name?: string;
};
lastPastedText: null;
lastSelection: null;
};
name?: string,
},
lastPastedText: null,
lastSelection: null,
},
vimPlugin: {
lastKeydown: string;
};
lastKeydown: string,
}
}
// interface MarkdownView {
// file: TFile;
// }
interface CMView extends EditorView {
state: CMState;
}
interface EditorViewI extends EditorView {
cm?: CMView;
}

View file

@ -1,11 +0,0 @@
/**
* the reason why this is abstract class instead of interface is that we need the protected method.
*
* To use this class, you should create a fake static new constructor
*/
export abstract class LifeCycle {
/**
* create a static
*/
protected abstract onReady(): void;
}

View file

@ -1,15 +1,13 @@
import { createContext } from "react";
import type { App, TAbstractFile, WorkspaceLeaf } from "obsidian";
import type { App, WorkspaceLeaf } from "obsidian";
import { ItemView } from "obsidian";
import type { Root } from "react-dom/client";
import { createRoot } from "react-dom/client";
import ReactForceGraph from "@/views/graph/ReactForceGraph";
import type { Graph } from "@/graph/Graph";
import { Graph } from "@/graph/Graph";
import type ForceGraphPlugin from "@/main";
import { dimensionsAtom } from "@/atoms/graphAtoms";
import { getDefaultStore } from "jotai";
import { Link } from "@/graph/Link";
import type { Node } from "@/graph/Node";
import { getNewLocalGraph, loadImagesForGraph } from "@/views/graph/fileGraphMethods";
import { eventBus } from "@/util/EventBus";
import { PluginSettingManager } from "@/SettingManager";
@ -22,10 +20,6 @@ export const ViewContext = createContext<ReactForceGraphView | undefined>(undefi
export class ReactForceGraphView extends ItemView {
readonly plugin: ForceGraphPlugin;
readonly store = getDefaultStore();
/**
* when the app is just open, this can be null
*/
currentFile: TAbstractFile | null;
root: Root | null = null;
private width: number = this.contentEl.offsetWidth;
private settingManager: PluginSettingManager;
@ -33,7 +27,6 @@ export class ReactForceGraphView extends ItemView {
constructor(leaf: WorkspaceLeaf, plugin: ForceGraphPlugin) {
super(leaf);
this.plugin = plugin;
this.currentFile = this.plugin.app.workspace.getActiveFile();
this.settingManager = this.plugin.settingManager;
eventBus.on("settings-updated", this.handleSettingsUpdate.bind(this));
@ -44,7 +37,7 @@ export class ReactForceGraphView extends ItemView {
}
getDisplayText() {
return "React Brainavigator";
return "N-brace your brain";
}
private handleSettingsUpdate() {
@ -83,13 +76,16 @@ export class ReactForceGraphView extends ItemView {
width: this.contentEl.offsetWidth,
height: this.contentEl.offsetHeight,
});
// useSetAtom(dimensionsAtom)({width: this.contentEl.offsetWidth, height: this.contentEl.offsetHeight});
}
protected async expandNode(node): Promise<Graph> {
console.info("node to expand ", node);
protected async expandNode(nodePath: string | undefined): Promise<Graph> {
console.debug("node to expand ", nodePath);
if (!nodePath || !this.plugin.globalGraph) {
console.debug("Returning empty graph.");
return Promise.resolve(Graph.createEmpty());
}
const graph = getNewLocalGraph(this.plugin, {
centerFilePath: node?.path ?? undefined,
centerFilePath: nodePath,
searchResults: [],
filterSetting: {
searchQuery: "",
@ -100,11 +96,18 @@ export class ReactForceGraphView extends ItemView {
},
});
await loadImagesForGraph(this.plugin, graph);
graph.rootPath = nodePath;
graph.nodes.forEach((n) => {
if (n.path === nodePath) {
n.expanded = true;
}
});
console.debug(`Graph loaded ${nodePath}`);
return graph;
}
public async getNewGraphData(): Promise<Graph> {
return this.expandNode(this.currentFile);
return this.expandNode(this.plugin.app.workspace.getActiveFile()?.path ?? undefined);
}
async onClose() {

View file

@ -1,13 +1,25 @@
import type { App } from "obsidian";
import { PluginSettingTab, Setting } from "obsidian";
import type ForceGraphPlugin from "@/main";
import { CommandClickNodeAction, SearchEngineType } from "@/SettingsSchemas";
import { DEFAULT_SETTING } from "@/SettingManager";
import { eventBus } from "@/util/EventBus";
import type { GraphSettings } from "@/atoms/graphAtoms";
import { rgb } from "d3";
const DEFAULT_NUMBER = DEFAULT_SETTING.pluginSetting.maxNodeNumber;
const DEFAULT_BASE_FOLDER = DEFAULT_SETTING.pluginSetting.baseFolder;
const DEFAULT_TITLE_FONT_SIZE = DEFAULT_SETTING.pluginSetting.titleFontSize;
const DEFAULT_GRAPH_SPAN = DEFAULT_SETTING.pluginSetting.defaultGraphSpan;
export const LINK_IN_DARK_COLOR = DEFAULT_SETTING.pluginSetting.linkColorIn;
export const LINK_OUT_DARK_COLOR = DEFAULT_SETTING.pluginSetting.linkColorOut;
export const LINK_OTHER_DARK_COLOR = DEFAULT_SETTING.pluginSetting.linkColorOther;
export const LINK_IN_LIGHT_COLOR = "84, 168, 214";
export const LINK_OUT_LIGHT_COLOR = "220, 220, 0";
export const LINK_OTHER_LIGHT_COLOR = "119, 84, 168";
// let colorMode: "light" | "dark" | "custom" = "dark";
export class SettingTab extends PluginSettingTab {
plugin: ForceGraphPlugin;
@ -21,7 +33,7 @@ export class SettingTab extends PluginSettingTab {
const { containerEl } = this;
containerEl.empty();
containerEl.addClasses(["graph-3d-setting-tab"]);
containerEl.addClasses(["n-brace-setting-tab"]);
containerEl.createEl("h2", { text: "Settings" });
@ -115,7 +127,7 @@ export class SettingTab extends PluginSettingTab {
.setValue(String(pluginSetting.titleFontSize ?? DEFAULT_TITLE_FONT_SIZE))
.onChange(async (value) => {
// check if value is a number
if (isNaN(Number(value)) || Number(value) <= 0) {
if (isNaN(Number(value)) || Number(value) <= 3) {
// set the error to the input
text.inputEl.setCustomValidity("Please enter a number higher than 3");
this.plugin.settingManager.updateSettings((setting) => {
@ -135,7 +147,122 @@ export class SettingTab extends PluginSettingTab {
return text;
});
new Setting(containerEl)
.setName("Default display graph span")
.setDesc("Size of the graph that is displayed at once.")
.addText((text) => {
text
.setPlaceholder(`${DEFAULT_GRAPH_SPAN}`)
.setValue(String(pluginSetting.defaultGraphSpan ?? DEFAULT_GRAPH_SPAN))
.onChange(async (value) => {
// check if value is a number
if (isNaN(Number(value)) || Number(value) <= 0) {
// set the error to the input
text.inputEl.setCustomValidity("Please enter a number higher than 0");
this.plugin.settingManager.updateSettings((setting) => {
setting.value.pluginSetting.defaultGraphSpan = DEFAULT_GRAPH_SPAN;
});
} else {
text.inputEl.setCustomValidity("");
this.plugin.settingManager.updateSettings((setting) => {
setting.value.pluginSetting.defaultGraphSpan = Number(value);
this.plugin.onGraphSettingAtomChanged(this.buildGraphSettings(setting));
});
}
text.inputEl.reportValidity();
});
text.inputEl.setAttribute("type", "number");
text.inputEl.setAttribute("min", "1");
return text;
});
new Setting(containerEl)
.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")
.setValue(pluginSetting.linkColorTheme)
.onChange(async (value: "custom" | "light" | "dark") => {
if (value === "dark") {
this.plugin.settingManager.updateSettings((setting) => {
setting.value.pluginSetting.linkColorTheme = value;
setting.value.pluginSetting.linkColorIn = LINK_IN_DARK_COLOR;
setting.value.pluginSetting.linkColorOut = LINK_OUT_DARK_COLOR;
setting.value.pluginSetting.linkColorOther = LINK_OTHER_DARK_COLOR;
this.plugin.onGraphSettingAtomChanged(this.buildGraphSettings(setting));
});
} else if (value === "light") {
this.plugin.settingManager.updateSettings((setting) => {
setting.value.pluginSetting.linkColorTheme = value;
setting.value.pluginSetting.linkColorIn = LINK_IN_LIGHT_COLOR;
setting.value.pluginSetting.linkColorOut = LINK_OUT_LIGHT_COLOR;
setting.value.pluginSetting.linkColorOther = LINK_OTHER_LIGHT_COLOR;
this.plugin.onGraphSettingAtomChanged(this.buildGraphSettings(setting));
});
} else {
this.plugin.settingManager.updateSettings((setting) => {
setting.value.pluginSetting.linkColorTheme = value;
});
}
this.display();
})
)
.addColorPicker((color) => {
if (pluginSetting.linkColorTheme !== "custom") {
color.setDisabled(true);
}
color
.setValue(rgb(`rgb(${pluginSetting.linkColorIn})`).formatHex())
.onChange(async (value) => {
this.plugin.settingManager.updateSettings((setting) => {
const rgbObj = rgb(value);
setting.value.pluginSetting.linkColorIn = `${rgbObj.r}, ${rgbObj.g}, ${rgbObj.b}`;
this.plugin.onGraphSettingAtomChanged(this.buildGraphSettings(setting));
});
});
})
.addColorPicker((color) => {
if (pluginSetting.linkColorTheme !== "custom") {
color.setDisabled(true);
}
color
.setValue(rgb(`rgb(${pluginSetting.linkColorOut})`).formatHex())
.onChange(async (value) => {
this.plugin.settingManager.updateSettings((setting) => {
const rgbObj = rgb(value);
setting.value.pluginSetting.linkColorOut = `${rgbObj.r}, ${rgbObj.g}, ${rgbObj.b}`;
this.plugin.onGraphSettingAtomChanged(this.buildGraphSettings(setting));
});
});
})
.addColorPicker((color) => {
if (pluginSetting.linkColorTheme !== "custom") {
color.setDisabled(true);
}
color
.setValue(rgb(`rgb(${pluginSetting.linkColorOther})`).formatHex())
.onChange(async (value) => {
this.plugin.settingManager.updateSettings((setting) => {
const rgbObj = rgb(value);
setting.value.pluginSetting.linkColorOther = `${rgbObj.r}, ${rgbObj.g}, ${rgbObj.b}`;
this.plugin.onGraphSettingAtomChanged(this.buildGraphSettings(setting));
});
});
});
// create an H2 element called "Controls"
// containerEl.createEl("h2", { text: "Controls" });
}
private buildGraphSettings(setting): GraphSettings {
return {
graphSpan: setting.value.pluginSetting.defaultGraphSpan,
linkColorIn: setting.value.pluginSetting.linkColorIn,
linkColorOut: setting.value.pluginSetting.linkColorOut,
linkColorOther: setting.value.pluginSetting.linkColorOther,
};
}
}

View file

@ -1,51 +0,0 @@
export type HtmlBuilder = (containerEl: HTMLElement) => void | PromiseLike<void>;
export class TreeItem {
private readonly $inner: HTMLElement;
private readonly childrenBuilders: HtmlBuilder[];
private $treeItem: HTMLDivElement;
private $self: HTMLDivElement;
private $innerWrapper: HTMLDivElement;
constructor($inner: HTMLElement, children: HtmlBuilder[]) {
this.$inner = $inner;
this.childrenBuilders = children;
this.$treeItem = document.createElement("div");
this.toggleCollapse(true);
this.$self = document.createElement("div");
this.$innerWrapper = document.createElement("div");
}
async render(containerEl: HTMLElement) {
this.$treeItem.classList.add("graph-control-section", "tree-item");
this.$self.classList.add("tree-item-self");
this.$self.addEventListener("click", () => {
this.toggleCollapse();
});
this.$innerWrapper.classList.add("tree-item-inner");
this.$innerWrapper.appendChild(this.$inner);
this.$self.appendChild(this.$innerWrapper);
this.$treeItem.appendChild(this.$self);
containerEl.appendChild(this.$treeItem);
await this.renderChildren();
}
private async renderChildren() {
const $children = document.createElement("div");
$children.classList.add("tree-item-children");
this.$treeItem.appendChild($children);
const promises = this.childrenBuilders.map((build: HtmlBuilder) => build($children));
await Promise.all(promises);
}
private toggleCollapse(doCollapse?: boolean) {
if (doCollapse === undefined) {
doCollapse = !this.$treeItem.classList.contains("is-collapsed");
}
this.$treeItem.classList.toggle("is-collapsed", doCollapse);
}
}

View file

@ -1,22 +0,0 @@
/**
* append a color picker to the container element
*/
export const addColorPicker = (
containerEl: HTMLElement,
/**
* the current color, must be hex format
*/
value: string,
/**
* callback for when the color is changed
*/
onChange: (value: string) => void
) => {
const input = document.createElement("input");
input.type = "color";
input.value = value;
input.addEventListener("input", () => {
onChange(input.value);
});
containerEl.appendChild(input);
};

View file

@ -1,20 +0,0 @@
import { addColorPicker } from "@/views/atomics/addColorPicker";
import { Setting } from "obsidian";
export const addColorPickerSetting = (
containerEl: HTMLElement,
options: {
name: string;
/**
* the current color
*/
value: string;
},
/**
* callback for when the color is changed
*/
onChange: (value: string) => void
) => {
const setting = new Setting(containerEl).setName(options.name).setClass("mod-color-picker");
addColorPicker(setting.settingEl, options.value, (value) => onChange(value));
};

View file

@ -1,18 +0,0 @@
export const addPicker = (
containerEl: HTMLElement,
/**
* the current value
*/
value: string,
/**
* callback for when the value is changed
*/
onChange: (value: string) => void
) => {
const input = document.createElement("input");
input.value = value;
input.addEventListener("change", () => {
onChange(input.value);
});
containerEl.appendChild(input);
};

View file

@ -1,149 +0,0 @@
import { ActiveSearchEngine } from "@/Interfaces";
import type { BaseForceGraphView } from "@/views/graph/forceview/ForceGraphView";
import { spawnLeafView } from "@/views/leafView";
import type { SearchView, TAbstractFile } from "obsidian";
import { TextComponent } from "obsidian";
/**
* depends on the search engine, this might be a normal text input or a built-in search input
*
* @remarks
* the search input doesn't perform the search, it only display the input
*/
export const addSearchInput = async (
containerEl: HTMLElement,
/**
* the current value
*/
value: string,
/**
* callback for when the value is changed.
*
* When the input is changed, the will use the search engine to parse and look for the files
*
* @param value the new value
* @param files the files that match the query
*/
onChange: (value: string) => void,
view: BaseForceGraphView
) => {
const searchEl = containerEl.createDiv({
// cls :
});
if (!view.plugin.fileManager.searchEngine.useBuiltInSearchInput) {
const text = new TextComponent(searchEl).setValue(value).onChange((value) => {
onChange(value);
});
text.inputEl.parentElement?.addClasses([
"search-input-container",
"global-search-input-container",
]);
return;
}
const [searchLeaf] = spawnLeafView(view.plugin, searchEl);
await searchLeaf.setViewState({
type: "search",
});
// add searchEl to containerEl
containerEl.appendChild(searchEl);
const searchElement = searchLeaf.containerEl.querySelector(
".workspace-leaf-content[data-type='search']"
) as HTMLDivElement;
// element.style.removeProperty("overflow");
const searchRowEl = searchElement.querySelector(".search-row") as HTMLDivElement;
const searchResultContainerEl = searchElement.querySelector(
".search-result-container"
) as HTMLDivElement;
const searchResultInfoEl = searchElement.querySelector(".search-results-info") as HTMLDivElement;
const extraSettingButtonEl = searchRowEl.querySelector(
".setting-editor-extra-setting-button"
) as HTMLDivElement;
extraSettingButtonEl?.remove();
searchResultContainerEl.style.visibility = "hidden";
searchResultContainerEl.style.height = "0px";
searchResultContainerEl.style.position = "absolute";
searchResultInfoEl.style.visibility = "hidden";
searchResultInfoEl.style.height = "0px";
searchResultInfoEl.style.position = "absolute";
searchRowEl.style.margin = "0px";
// move the element to the containerEl
containerEl.appendChild(searchRowEl);
const inputEl = searchRowEl.getElementsByTagName("input")[0]!;
const settingIconEl = searchRowEl.querySelector(
".clickable-icon[aria-label='Search settings']"
) as HTMLDivElement;
const matchCaseIconEl = searchRowEl.querySelector(
"div.search-input-container > div[aria-label='Match case'] "
) as HTMLDivElement;
const clearButtonEl = searchRowEl.querySelector(".search-input-clear-button") as HTMLDivElement;
settingIconEl?.remove();
matchCaseIconEl?.remove();
inputEl.value = value;
inputEl.oninput = async (e: Event<HTMLInputElement>) => {
// @ts-ignore
onChange(e.currentTarget?.value);
};
clearButtonEl.onclick = () => {
// if inputEl has is-loading, then do nothing
// if (inputEl.classList.contains("is-loading")) return;
onChange("");
};
// this make search that the search result container el is alaways visible
if (view.itemView) view.itemView.containerEl.appendChild(searchResultContainerEl);
// if it is a passive engine, we need to enable mutation observer
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const addMutationObserver = (callback: (files: TAbstractFile[]) => void, data?: any) => {
if (view.plugin.fileManager.searchEngine instanceof ActiveSearchEngine)
throw new Error(
"you don't need mutation observer for active search engine, this function should not be called"
);
view.plugin.fileManager.searchEngine.addMutationObserver(
searchResultContainerEl,
searchLeaf.view as SearchView,
callback,
data
);
};
// debug
// const observer = new MutationObserver(async (mutations) => {
// // if the search result is loading or the cache is not ready, then we know that the search must not be ready yet
// if (!view.plugin.cacheIsReady) return;
// // disable this because seem like this will cause a bug of some group not updating color
// // if (searchResultContainerEl.classList.contains("is-loading")) return;
// const files = getFilesFromSearchResult(
// await getResultFromSearchView(searchLeaf.view as SearchView)
// );
// console.log(files);
// });
// observer.observe(searchResultContainerEl, {
// childList: true,
// subtree: true,
// });
const triggerSearch = () => {
// if the input is empty, return
if (inputEl.value === "") return;
inputEl.dispatchEvent(
new KeyboardEvent("keypress", {
key: "Enter",
})
);
};
return { searchRowEl, addMutationObserver, triggerSearch };
};

View file

@ -1,46 +0,0 @@
import { Setting } from "obsidian";
/**
*
* @param containerEl
* @param options
* @param onChange
* @returns
*/
export const addSimpleSliderSetting = (
containerEl: HTMLElement,
options: SliderOptions,
onChange: (newValue: number) => void
) => {
const slider = new Setting(containerEl)
.setName(options.name)
.setClass("mod-slider")
.addSlider((slider) => {
return slider
.setLimits(options.stepOptions.min, options.stepOptions.max, options.stepOptions.step)
.setValue(options.value)
.setDynamicTooltip()
.onChange(async (value) => {
onChange(value);
});
});
return slider;
};
export interface SliderOptions {
name: string;
stepOptions: SliderStepOptions;
value: number;
}
export interface SliderStepOptions {
min: number;
max: number;
step: number;
}
export const DEFAULT_SLIDER_STEP_OPTIONS: SliderStepOptions = {
min: 1,
max: 20,
step: 1,
};

View file

@ -1,12 +0,0 @@
export const addTextInput = (
containerEl: HTMLElement,
value: string,
onChange: (value: string) => void
) => {
const inputEl = document.createElement("input");
inputEl.value = value;
inputEl.addEventListener("change", () => {
onChange(inputEl.value);
});
containerEl.appendChild(inputEl);
};

View file

@ -1,23 +0,0 @@
import { Setting } from "obsidian";
export const addToggle = (
containerEl: HTMLElement,
options: {
name: string;
/**
* the current value
*/
value: boolean;
},
/**
* callback for when the value is changed
*/
onChange: (value: boolean) => void
) => {
const settings = new Setting(containerEl).setName(options.name).addToggle((toggle) => {
return toggle.setValue(options.value).onChange(async (value) => {
onChange(value);
});
});
return settings;
};

View file

@ -1,19 +1,18 @@
import type { Node } from "@/graph/Node";
import type { Link } from "@/graph/Link";
import { TFile } from "obsidian";
import type { GraphSettings } from "@/atoms/graphAtoms";
export class Drawing {
private static readonly SELECTED_COLOR = "21, 0, 158";
private static readonly PARENT_COLOR = "39, 85, 138";
private static readonly CHILD_COLOR = "103, 26, 120";
static drawLink(link: Link, ctx: CanvasRenderingContext2D, globalScale: number) {
static drawLink(link: Link, ctx: CanvasRenderingContext2D, globalScale: number, navDescending: boolean,
graphSettings: GraphSettings) {
if (link.color === "parent") {
return;
}
// Destructure the source and target coordinates
let { x: x1, y: y1 } = link.source;
let { x: x2, y: y2 } = link.target;
let { x: x1, y: y1 } = navDescending ? link.source : link.target;
let { x: x2, y: y2 } = navDescending ? link.target : link.source;
if (!x1) {
x1 = 0;
y1 = 0;
@ -29,7 +28,6 @@ export class Drawing {
// Calculate the length of the link
const length = Math.hypot(x2 - x1, y2 - y1);
// console.info(`LENNNNN ${x1} ${x2} ${y1} ${y2}`);
// Save the current canvas state
ctx.save();
@ -40,18 +38,19 @@ export class Drawing {
// Rotate the canvas to align with the link direction
ctx.rotate(Math.atan2(y2 - y1, x2 - x1));
// Create a gradient for the line width
const gradient = ctx.createLinearGradient(0, 0, length, 0);
// const intensity = Math.pow(link.distance + 2, -0.6);
const color =
link.label === "parent"
? this.PARENT_COLOR
? graphSettings.linkColorIn
: link.label === "child"
? this.CHILD_COLOR
: "71, 30, 143";
gradient.addColorStop(0, `rgba(${color}, 1)`); // Start color
gradient.addColorStop(1, `rgba(${color}, 0.25)`); // End color
? graphSettings.linkColorOut
: graphSettings.linkColorOther;
// Create a gradient for the line width
const gradient = ctx.createLinearGradient(0, 0, length, 0);
gradient.addColorStop(0, `rgba(${color}, 1`); // Start color 1
gradient.addColorStop(1, `rgba(${color}, 0.5`); // End color 0.25
// Set the stroke style to the gradient
// ctx.strokeStyle = `rgba(${color}, 1)`; // gradient;
ctx.strokeStyle = gradient;
// Create a pattern for widening the link
@ -77,6 +76,7 @@ export class Drawing {
node: Node & Coords & NodeData,
ctx: CanvasRenderingContext2D,
globalScale: number, titleFontSize: number,
graphSettings: GraphSettings
) {
const label = node.name.contains(".") ? node.name.substring(0, node.name.length - 3) : node.name;
const fontSize = titleFontSize / globalScale; // Scale font size
@ -86,10 +86,18 @@ export class Drawing {
node.label === "selected"
? this.SELECTED_COLOR
: node.label === "parent"
? this.PARENT_COLOR
? graphSettings.linkColorIn
: node.label === "child"
? this.CHILD_COLOR
: "71, 30, 143";
? graphSettings.linkColorOut
: graphSettings.linkColorOther;
function setupExpandedStyle() {
if (node.expanded) {
ctx.setLineDash([]);
} else {
ctx.setLineDash([8, 4]);
}
}
if (node.image) {
const textWidth = ctx.measureText(label).width;
@ -106,6 +114,7 @@ export class Drawing {
ctx.fillStyle = "white";
ctx.strokeStyle = `rgb(${color})`;
ctx.lineWidth = 0.5; /// globalScale;
setupExpandedStyle();
ctx.fill();
ctx.stroke();
@ -134,6 +143,7 @@ export class Drawing {
ctx.fillStyle = "white";
ctx.strokeStyle = `rgb(${color})`;
ctx.lineWidth = 1;
setupExpandedStyle();
Drawing.drawRoundedRect(
ctx,
node.x - totalWidth / 2,

View file

@ -0,0 +1,57 @@
.pm-graph-controls {
position: absolute;
top: 50px;
right: 10px;
display: grid;
grid-template-columns: 1fr 1fr; /* 2 columns */
gap: 5px;
background: rgba(255, 255, 255, 0.8); /* Semi-transparent background */
padding: 5px;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.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 {
position: absolute;
top: 50px;
left: 10px;
display: grid;
grid-template-columns: 1fr;
gap: 5px;
background: rgba(255, 255, 255, 0.8); /* Semi-transparent background */
padding: 5px;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
color: var(--color-base-00);
}
.slider-control input[type="range"] {
width: 100px;
cursor: pointer;
}
.slider-value {
position: absolute;
top: -20px; /* Above the slider */
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);
}

View file

@ -0,0 +1,100 @@
import React, { useEffect, useState } from "react";
import { FaArrowDown, FaArrowLeft, FaArrowRight, FaArrowUp } from "react-icons/fa";
import "./GraphControls.css";
import { RiFlipVerticalFill, RiFlipVerticalLine } from "react-icons/ri";
import { PiSunHorizonBold } from "react-icons/pi";
import { IoSunny } from "react-icons/io5";
import { useAtomValue } from "jotai/react";
import { graphSettingsAtom } from "@/atoms/graphAtoms";
interface GraphControlsProps {
onPanLeft: () => void;
onPanRight: () => void;
onPanUp: () => void;
onPanDown: () => void;
onDirectionToggle: () => void;
isDescending: boolean;
onMaxPathLengthChange: (maxPathLength: number) => void;
}
export const GraphControls: React.FC<GraphControlsProps> = ({
onPanLeft,
onPanRight,
onPanUp,
onPanDown,
onDirectionToggle,
isDescending,
onMaxPathLengthChange,
}) => {
const [isHidden, setIsHidden] = useState(false);
const graphSettings = useAtomValue(graphSettingsAtom);
const [maxPathLength, setMaxPathLength] = useState(graphSettings.graphSpan);
const handleHidden = () => {
setIsHidden((prev) => !prev);
};
const handleToggle = () => {
if (onDirectionToggle) {
onDirectionToggle();
}
};
const handleSliderChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = Number(event.target.value);
setMaxPathLength(value);
};
useEffect(() => {
const timeout = setTimeout(() => {
onMaxPathLengthChange(maxPathLength);
}, 1000);
return () => clearTimeout(timeout); // Clear on value change or unmount
}, [maxPathLength, onMaxPathLengthChange]);
return (
<>
{!isHidden && (
<div className="slider-control">
<label htmlFor="max-path-length" title="Visisble graph span size">
G-span {maxPathLength}
</label>
<input
id="max-path-length"
type="range"
min="0"
max="99"
value={maxPathLength}
onChange={handleSliderChange}
aria-label="Visisble graph span size"
/>
</div>
)}
<div className="pm-graph-controls">
<button onClick={handleToggle} title="Flip direction (Ctrl)">
{isDescending ? <RiFlipVerticalLine /> : <RiFlipVerticalFill />}
</button>
<button onClick={handleHidden} title={isHidden ? "Show" : "Hide"}>
{isHidden ? <IoSunny /> : <PiSunHorizonBold />}
</button>
{!isHidden && (
<>
<button onClick={onPanUp} title="Descend">
<FaArrowUp />
</button>
<button onClick={onPanDown} title="Go Back">
<FaArrowDown />
</button>
<button onClick={onPanLeft} title="Anti Clockwise">
<FaArrowLeft />
</button>
<button onClick={onPanRight} title="Clockwise">
<FaArrowRight />
</button>
</>
)}
</div>
</>
);
};

View file

@ -1,4 +1,4 @@
import React, { useContext, useEffect, useRef } from "react";
import React, { useContext, useEffect, useRef, useState } from "react";
import type { ForceGraphMethods } from "react-force-graph-2d";
import ForceGraph2D from "react-force-graph-2d";
import type { Graph } from "@/graph/Graph";
@ -7,15 +7,26 @@ import { Node } from "@/graph/Node";
import type { ReactForceGraphView } from "@/views/ReactForceGraphView";
import { ViewContext } from "@/views/ReactForceGraphView";
import { useAtom, useAtomValue } from "jotai/react";
import { useResetAtom } from "jotai/utils";
import { dimensionsAtom, graphDataAtom, graphNavAtom } from "@/atoms/graphAtoms";
import {
dimensionsAtom,
expandNodePathAtom,
graphDataAtom,
graphNavAtom,
graphSettingsAtom,
navHistoryAtom,
navIndexHistoryAtom,
nodeIdxMaxAtom,
} from "@/atoms/graphAtoms";
import * as d3 from "d3";
import { Link } from "@/graph/Link";
import { eventBus } from "@/util/EventBus";
import { GraphControls } from "@/views/graph/GraphControls";
import { calcNodeAngle, getNavBackward, getNavForward, getNavIndexBackward, getNavIndexForward, stackOnHistory, stackOnIndexHistory } from "@/atoms/graphOps";
interface GraphComponentProps {
data: Graph;
getInitialGraph: () => Promise<Graph>;
getExpandNode: () => Promise<Graph>;
getExpandNode: (nodePath: string | undefined) => Promise<Graph>;
titleFontSize: number;
}
@ -28,20 +39,58 @@ const ReactForceGraph: React.FC<GraphComponentProps> = ({
getExpandNode,
titleFontSize,
}) => {
const isFirstRun = useRef(true);
const fgRef = useRef<ForceGraphMethods | undefined>(undefined);
const containerRef = useRef<HTMLDivElement>(undefined);
const [nodeIdxMax, setNodeIdxMax] = useAtom(nodeIdxMaxAtom);
const graphSettings = useAtomValue(graphSettingsAtom);
// whether the graph exploration parent->child directed
const [isDescending, setIsDescending] = useState(true);
const [maxPathLength, setMaxPathLength] = useState(9);
const changedDescendingDuringCtrl = useRef(false);
const dimensions = useAtomValue(dimensionsAtom);
const [selectedNode, setSelectedNode] = useAtom(graphNavAtom);
const resetSelectedNode = useResetAtom(graphNavAtom);
const [expandNodePath, setExpandNodePath] = useAtom(expandNodePathAtom);
const [navHistory, setNavHistory] = useAtom(navHistoryAtom);
const [navIndexHistory, setNavIndexHistory] = useAtom(navIndexHistoryAtom);
const [graphData, setGraphData] = useAtom(graphDataAtom);
const [filteredGraphData, setFilteredGraphData] = useState<{
nodes: Node[];
links: Link[];
}>({ nodes: [], links: [] });
function assignIdx(nodes: Node[]) {
let maxIdx = nodeIdxMax;
nodes.forEach((n) => {
n.idx = maxIdx++;
console.debug(`Node ${n.path} idx = ${n.idx}`);
});
setNodeIdxMax(maxIdx);
return nodes;
}
useEffect(() => {
const fg = fgRef.current;
const initGraph = async () => {
const graph = await getInitialGraph();
console.debug(`Init Graph data starting with ${graph.rootPath}`);
assignIdx(graph.nodes);
setGraphData(graph);
setSelectedNode({ selectedPath: graph.rootPath });
};
initGraph();
// navigation will be instantly ready
containerRef.current?.focus();
}, []);
// directional forces depending on if a screen is vertical or horizontal
useEffect(() => {
const fg = fgRef.current;
// TODO change forces only if dimensions changed a lot
if (fg) {
console.info("Setting Graph forces");
console.debug("Setting Graph forces");
// fg.d3Force("link", d3.forceLink()
// .strength((link) => {
// return 1 / Math.min(link.target.inlinkCount, link.source.outlinkCount);
@ -73,16 +122,51 @@ const ReactForceGraph: React.FC<GraphComponentProps> = ({
}, [dimensions]);
useEffect(() => {
const fg = fgRef.current;
if (!selectedNode.selectedIndex || maxPathLength >= 99) {
setFilteredGraphData(graphData); // Show all if no selection or max
return;
}
const initGraph = async () => {
const graph = await getInitialGraph();
console.info("Init Graph data");
setGraphData(graph);
resetSelectedNode();
};
initGraph();
}, [getInitialGraph, setGraphData]);
// BFS to find nodes within maxPathLength
const distances = new Map<number, number>();
const queue: number[] = [selectedNode.selectedIndex];
distances.set(selectedNode.selectedIndex, 0);
while (queue.length > 0) {
const currentId = queue.shift()!;
const currentDistance = distances.get(currentId)!;
if (currentDistance >= maxPathLength) continue;
// Find neighbors
graphData.links.forEach((link) => {
let neighborId: number | undefined;
if (link.source.idx === currentId && !distances.has(link.target.idx)) {
link.distance = currentDistance;
neighborId = link.target.idx;
} else if (link.target.idx === currentId && !distances.has(link.source.idx)) {
link.distance = currentDistance;
neighborId = link.source.idx;
}
if (neighborId !== undefined) {
distances.set(neighborId, currentDistance + 1);
queue.push(neighborId);
}
});
}
// Filter nodes within maxPathLength
const validNodeIds = new Set(distances.keys());
const filteredNodes = graphData.nodes.filter((node) =>
validNodeIds.has(node.idx)
);
const filteredLinks = graphData.links.filter(
(link) => validNodeIds.has(link.source.idx) && validNodeIds.has(link.target.idx)
);
setFilteredGraphData({ nodes: filteredNodes, links: filteredLinks });
}, [selectedNode, maxPathLength, graphData]);
useEffect(() => {
graphData.nodes.forEach((node) => {
@ -105,21 +189,184 @@ const ReactForceGraph: React.FC<GraphComponentProps> = ({
});
}, [selectedNode]);
function updateAttributeDeep<T extends object, K extends keyof T>(
original: T,
key: K,
newValue: T[K]
): T {
const deepCopy = structuredClone(original); // Deep copy
deepCopy[key] = newValue; // Update the attribute
return deepCopy;
}
useEffect(() => {
if (expandNodePath) {
expandNodeOp(expandNodePath, undefined).then(() => {
setExpandNodePath(undefined);
});
}
}, [expandNodePath]);
function partition<T>(array: T[], predicate: (item: T) => boolean): [T[], T[]] {
const matches = array.filter(predicate);
const nonMatches = array.filter(item => !predicate(item));
return [matches, nonMatches];
}
const expandNode = (node: Node) => {
if (!node.expanded) {
expandNodeOp(node.path, node.idx);
node.expanded = true;
} else {
signalSelectedNode(node);
}
};
const expandNodeOp = async (nodePath: string | undefined, nodeIndex: number | undefined) => {
const fullConns = false;
const { nodes, links } = await getExpandNode(nodePath);
const expandedNode: Node & NodeData = graphData.nodes.find((n) =>
nodeIndex ? n.idx === nodeIndex : n.path === nodePath
);
if (!expandedNode) {
// when opening file totally ouf of visible graph scope
setGraphData({ nodes: assignIdx(nodes), links: links });
signalSelectedNode(nodes.find((n) =>
nodeIndex ? n.idx === nodeIndex : n.path === nodePath
));
return;
}
let newNodes;
let newLinks;
if (fullConns) {
newNodes = nodes.filter((n) => {
return !graphData.nodes.find((existingNode) => {
const exists = Node.compare(n, existingNode);
if (exists) {
links.forEach((l) => {
if (l.source.path === existingNode.path) {
l.source = existingNode;
} else if (l.target.path === existingNode.path) {
l.target = existingNode;
}
});
existingNode.links = [
...existingNode.links,
...n.links.filter(
(l) =>
!existingNode.links.find(
(existingLink) =>
l.source.path === existingLink.source.path &&
l.target.path === existingLink.target.path
)
),
];
}
return exists;
});
});
newLinks = links.filter((l) => {
return !graphData.links.find((existingLink) => {
return Link.compare(l, existingLink);
});
});
} else {
const nodeDuplicates = new Set<string>();
newLinks = links.filter((l) => {
// const exists = graphData.links.find((existingLink) => {
// return Link.compare(l, existingLink);
// });
const exists = graphData.links.find((existingLink) => {
return (existingLink.source.idx === expandedNode.idx || existingLink.target.idx === expandedNode.idx) &&
Link.compare(l, existingLink);
});
if (exists) {
nodeDuplicates.add(l.source.path);
nodeDuplicates.add(l.target.path);
}
return !exists;
});
newNodes = nodes.filter((n) => {
const isItExpanded = n.path === expandedNode.path;
if (isItExpanded) {
const existingNode = expandedNode;
links.forEach((l) => {
if (l.source.path === existingNode.path) {
l.source = existingNode;
} else if (l.target.path === existingNode.path) {
l.target = existingNode;
}
});
existingNode.links = [
...existingNode.links,
...n.links.filter(
(l) =>
!existingNode.links.find(
(existingLink) =>
l.source.path === existingLink.source.path &&
l.target.path === existingLink.target.path
)
),
];
return false;
}
return !nodeDuplicates.has(n.path);
// const isItParentOfExpanded = n.links.find((l) => l.target.path === expandedNode.path);
// if (isItParentOfExpanded) {
// return !graphData.nodes.find((existingNode) => {
// const exists = Node.compare(n, existingNode);
// // const hasLink = exists && existingNode.links.find((l) => l.tar)
// if (exists) {
// links.forEach((l) => {
// if (l.source.path === existingNode.path) {
// l.source = existingNode;
// } else if (l.target.path === existingNode.path) {
// l.target = existingNode;
// }
// });
// existingNode.links = [
// ...existingNode.links,
// ...n.links.filter(
// (l) =>
// !existingNode.links.find(
// (existingLink) =>
// l.source.path === existingLink.source.path &&
// l.target.path === existingLink.target.path
// )
// ),
// ];
// }
// return exists;
// });
// }
// return true;
});
}
graphData.nodes.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
// const simplifiedNodes = [...graphData.nodes, ...newNodes].map(node => ({
// path: node.path,
// links: node.links.map((link) => link.source.path + "->" + link.target.path),
// }));
// console.debug(`Nodes after expand: ${JSON.stringify(simplifiedNodes)}`);
setGraphData({
nodes: [...graphData.nodes, ...assignIdx(newNodes)],
links: [...graphData.links, ...newLinks],
});
signalSelectedNode(expandedNode);
console.debug(`New graph data after expansion: ${graphData.nodes.length + newNodes.length}
nodes and ${graphData.links.length + newLinks.length} links`);
};
const signalSelectedNode = (node: Node) => {
if (!node) {
console.debug("Signal undefined node selected!!!");
return;
}
setSelectedNode({ selectedPath: node.path, selectedIndex: node.idx });
const newHistory = stackOnHistory(navHistory,
graphData.nodes.find((n) => n.path === selectedNode.selectedPath), node);
// console.info(`HISTORY: ${JSON.stringify(newHistory)}`);
setNavHistory(newHistory);
if (selectedNode.selectedIndex !== undefined) {
const newIndexHistory = stackOnIndexHistory(navIndexHistory,
graphData.nodes.find((n) => n.idx === selectedNode.selectedIndex), node);
setNavIndexHistory(newIndexHistory);
}
zoomToFitNodes(node.path, node.idx);
eventBus.trigger("open-file", node.path);
};
const hoverNode = (node: Node) => {
if (!node) {
@ -139,62 +386,16 @@ const ReactForceGraph: React.FC<GraphComponentProps> = ({
graphData.nodes.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
};
const expandNode = async (node: Node) => {
const { nodes, links } = await getExpandNode(node);
const expandedNode = graphData.nodes.find((n) => n.path === node.path);
if (!expandedNode) {
return;
}
// const [newNodes, existingNodes] = partition(nodes, (n) => {
// return !graphData.nodes.find((existingNode) => {
// return Node.compare(n, existingNode);
// });
// });
const newNodes = nodes.filter((n) => {
return !graphData.nodes.find((existingNode) => {
const exists = Node.compare(n, existingNode);
if (exists) {
links.forEach((l) => {
if (l.source.path === existingNode.path) {
l.source = existingNode;
} else if (l.target.path === existingNode.path) {
l.target = existingNode;
}
});
existingNode.links = n.links;
}
return exists;
});
});
const newLinks = links.filter((l) => {
return !graphData.links.find((existingLink) => {
return Link.compare(l, existingLink);
});
});
// newLinks
// .filter((l) => l.source.path == expandedNode.path)
// .forEach((l) => {
// if (l.source.)
// l.source = expandedNode;
// });
setSelectedNode({ selectedPath: node.path });
graphData.nodes.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
console.info("Expand Setting Graph data");
setGraphData({
nodes: [...graphData.nodes, ...newNodes],
links: [...graphData.links, ...newLinks],
});
zoomToFitNodes(node.path);
console.log(`New graph data after expansion: ${graphData.nodes.length} nodes and ${graphData.links.length} links`);
};
const zoomToFitNodes = (selectedPath: string | undefined) => {
const zoomToFitNodes = (selectedPath: string | undefined, selectedIndex: number | undefined) => {
if (fgRef.current) {
if (selectedIndex !== undefined) {
return fgRef.current.zoomToFit(200, 50, (node: Node) => {
const res = !!node.links.find(
(l) => l.source.idx === selectedIndex || l.target.idx === selectedIndex
);
return res;
});
}
if (!selectedPath) {
return fgRef.current.zoomToFit(200, 50);
}
@ -207,39 +408,224 @@ const ReactForceGraph: React.FC<GraphComponentProps> = ({
}
};
return (
<ForceGraph2D
ref={fgRef}
graphData={graphData}
width={dimensions.width}
height={dimensions.height}
cooldownTicks={50}
onEngineStop={() => zoomToFitNodes(selectedNode.selectedPath)}
nodeCanvasObject={(node: Node & Coords & NodeData, ctx, globalScale) =>
Drawing.drawNode(node, ctx, globalScale, titleFontSize)
const handlePanRight = () => handlePanRightLeftByIndex(true, isDescending);
const handlePanLeft = () => handlePanRightLeftByIndex(false, isDescending);
const handlePanRightLeft = (clockwise: boolean, lookChildSiblings: boolean) => {
const selected = selectedNode.selectedPath;
if (!selected) {
return;
}
const node = graphData.nodes.find((n) => n.path === selected);
const relPath = lookChildSiblings ? getNavBackward(navHistory, node) : getNavForward(navHistory, node);
if (!relPath) {
return;
}
const rel: Node = graphData.nodes.find((n) => n.path === relPath);
const siblings = lookChildSiblings ?
rel.links.filter((l) => l.source.path === relPath && l.target.path !== node.path).map((l) => l.target):
rel.links.filter((l) => l.target.path === relPath && l.source.path !== node.path).map((l) => l.source);
if (!siblings.length) {
return;
}
const nodeAngle = calcNodeAngle(rel, node);
const angles = siblings.map((sibling) => {
const angle = calcNodeAngle(rel, sibling);
const angleDiff = (360 - nodeAngle + angle) % 360;
return { n: sibling, angle: angleDiff };
});
const closest = angles.reduce((best, angle) => {
return (best.angle < angle.angle && clockwise) || (best.angle > angle.angle && !clockwise) ? best : angle;
});
signalSelectedNode(closest.n);
};
const handlePanRightLeftByIndex = (clockwise: boolean, lookChildSiblings: boolean) => {
let node: Node;
if (selectedNode.selectedIndex !== undefined) {
node = graphData.nodes.find((n) => n.idx === selectedNode.selectedIndex);
} else {
if (!selectedNode.selectedPath) {
return;
}
nodePointerAreaPaint={(node: Node & Coords & NodeData, color, ctx) => {
ctx.fillStyle = color;
const bckgDimensions = node.nodeDims;
bckgDimensions &&
bckgDimensions[0] &&
bckgDimensions[1] &&
ctx.fillRect(
node.x - bckgDimensions[0] / 2,
node.y - bckgDimensions[1] / 2,
bckgDimensions[0],
bckgDimensions[1]
);
}}
linkCanvasObject={(link: Link, ctx, globalScale) => Drawing.drawLink(link, ctx, globalScale)}
nodeLabel="name"
nodeAutoColorBy="group"
linkDirectionalParticles={4}
linkDirectionalParticleColor={"rgba(71, 30, 143, 0.25)"}
onNodeClick={expandNode}
onNodeHover={hoverNode}
onLinkClick={(link) => alert(`Clicked link: ${link.source.name} -> ${link.target.name}`)}
/>
node = graphData.nodes.find((n) => n.path === selectedNode.selectedPath);
}
const relIndex = lookChildSiblings ? getNavIndexBackward(navIndexHistory, node) : getNavIndexForward(navIndexHistory, node);
if (relIndex === undefined) {
return;
}
const rel: Node = graphData.nodes.find((n) => n.idx === relIndex);
const siblings = lookChildSiblings ?
rel.links.filter((l) => l.source.idx === relIndex && l.target.idx !== node.idx).map((l) => l.target):
rel.links.filter((l) => l.target.idx === relIndex && l.source.idx !== node.idx).map((l) => l.source);
if (!siblings.length) {
return;
}
const nodeAngle = calcNodeAngle(rel, node);
const angles = siblings.map((sibling) => {
const angle = calcNodeAngle(rel, sibling);
const angleDiff = (360 - nodeAngle + angle) % 360;
return { n: sibling, angle: angleDiff };
});
const closest = angles.reduce((best, angle) => {
return (best.angle < angle.angle && clockwise) || (best.angle > angle.angle && !clockwise) ? best : angle;
});
signalSelectedNode(closest.n);
};
const handlePanUp = () => handlePanUpDownByIndex(isDescending);
const handlePanDown = () => handlePanUpDownByIndex(!isDescending);
const handlePanUpDown = (dirDescend: boolean) => {
const selected = selectedNode.selectedPath;
if (!selected) {
return;
}
const node = graphData.nodes.find((n) => n.path === selected);
const nextInLine = dirDescend ? getNavForward(navHistory, node) : getNavBackward(navHistory, node);
if (!nextInLine) {
if (!node?.expanded) {
expandNode(node);
}
return;
}
const parent: Node = graphData.nodes.find((n) => n.path === nextInLine);
signalSelectedNode(parent);
};
const handleDirectionToggle = () => {
setIsDescending(!isDescending);
};
const handlePanUpDownByIndex = (dirDescend: boolean) => {
let node: Node;
if (selectedNode.selectedIndex !== undefined) {
node = graphData.nodes.find((n) => n.idx === selectedNode.selectedIndex);
} else {
if (!selectedNode.selectedPath) {
return;
}
node = graphData.nodes.find((n) => n.path === selectedNode.selectedPath);
}
const nextInLine = dirDescend ? getNavIndexForward(navIndexHistory, node) : getNavIndexBackward(navIndexHistory, node);
if (nextInLine === undefined) {
if (!node?.expanded) {
expandNode(node);
}
return;
}
const parent: Node = graphData.nodes.find((n) => n.idx === nextInLine);
signalSelectedNode(parent);
};
const handleKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case "Control":
handleDirectionToggle();
event.preventDefault();
break;
case "ArrowUp":
handlePanUp();
event.preventDefault(); // Prevent scrolling
break;
case "ArrowDown":
handlePanDown();
event.preventDefault();
break;
case "ArrowLeft":
handlePanLeft();
event.preventDefault();
break;
case "ArrowRight":
handlePanRight();
event.preventDefault();
break;
default:
break;
}
if (event.ctrlKey) {
switch (event.key) {
case "ArrowUp":
case "ArrowDown":
case "ArrowLeft":
case "ArrowRight":
changedDescendingDuringCtrl.current = true;
break;
default:
break;
}
}
};
const handleKeyUp = (event: KeyboardEvent) => {
if (changedDescendingDuringCtrl.current) {
switch (event.key) {
case "Control":
changedDescendingDuringCtrl.current = false;
handleDirectionToggle();
event.preventDefault();
break;
default:
break;
}
}
};
return (
<div id="pocket-memory" ref={containerRef} onKeyUp={handleKeyUp} onKeyDown={handleKeyDown} tabIndex={0}>
<ForceGraph2D
ref={fgRef}
graphData={filteredGraphData}
width={dimensions.width}
height={dimensions.height}
cooldownTicks={50}
onEngineStop={() => zoomToFitNodes(selectedNode.selectedPath, selectedNode.selectedIndex)}
nodeCanvasObject={(node: Node & Coords & NodeData, ctx, globalScale) =>
Drawing.drawNode(node, ctx, globalScale, titleFontSize, graphSettings)
}
nodePointerAreaPaint={(node: Node & Coords & NodeData, color, ctx) => {
ctx.fillStyle = color;
const bckgDimensions = node.nodeDims;
bckgDimensions &&
bckgDimensions[0] &&
bckgDimensions[1] &&
ctx.fillRect(
node.x - bckgDimensions[0] / 2,
node.y - bckgDimensions[1] / 2,
bckgDimensions[0],
bckgDimensions[1]
);
}}
linkCanvasObject={(link: Link, ctx, globalScale) =>
Drawing.drawLink(link, ctx, globalScale, isDescending, graphSettings)
}
nodeLabel="name"
nodeAutoColorBy="group"
// linkDirectionalParticles={4}
// linkDirectionalParticleSpeed={0.01}
// linkDirectionalParticleColor={"rgba(r, g, b, 0.25)"}
onNodeClick={expandNode}
onNodeHover={hoverNode}
// onLinkClick={(link) => }
/>
<GraphControls
onPanLeft={handlePanLeft}
onPanRight={handlePanRight}
onPanUp={handlePanUp}
onPanDown={handlePanDown}
onDirectionToggle={handleDirectionToggle}
isDescending={isDescending}
onMaxPathLengthChange={setMaxPathLength}
/>
</div>
);
};

View file

@ -1,80 +0,0 @@
// import * as TWEEN from "@tweenjs/tween.js";
// import * as THREE from "three";
// import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
// import type { Node } from "@/graph/Node";
// import type { BaseForceGraph } from "@/views/graph/ForceGraph";
// import type { Link } from "@/graph/Link";
// import { CommandModal } from "@/commands/CommandModal";
// import { CommandClickNodeAction, GraphType } from "@/SettingsSchemas";
// import { createNotice } from "@/util/createNotice";
// import { hexToRGBA } from "@/util/hexToRGBA";
// import type { TFile } from "obsidian";
//
// const origin = new THREE.Vector3(0, 0, 0);
// const cameraLookAtCenterTransitionDuration = 1000;
// const LINK_PARTICLE_MULTIPLIER = 4;
// export const FOCAL_FROM_CAMERA = 400;
// const selectedColor = "#CCA700";
// const PARTICLE_FREQUECY = 4;
// const LINK_ARROW_WIDTH_MULTIPLIER = 5;
//
// /**
// * this instance handle all the interaction. In other words, the interaction manager
// */
// export class ReactForceGraphEngine {
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// private tween: { [tweenId: string]: TWEEN.Tween<any> | undefined } = {};
// private spaceDown = false;
// private commandDown = false;
// private selectedNodes = new Set<Node>();
// /**
// * the node connected to the hover node
// */
// public readonly highlightedNodes: Set<string> = new Set();
// /**
// * the links connected to the hover node
// */
// public readonly highlightedLinks: Set<Link> = new Set();
// hoveredNode: Node | null = null;
//
// // zooming
// private isZooming = false;
// private startZoomTimeout: Timer | undefined;
// private endZoomTimeout: Timer | undefined;
//
// public getNodeColor = (node: Node): string => {
// let color: string;
// const settings = this.forceGraph.view.settingManager.getCurrentSetting();
// const theme = this.forceGraph.view.theme;
// const searchResult = this.forceGraph.view.settingManager.searchResult;
// if (this.selectedNodes.has(node)) {
// color = selectedColor;
// } else if (this.isHighlightedNode(node)) {
// color =
// node === this.hoveredNode
// ? settings.display.nodeHoverColor
// : settings.display.nodeHoverNeighbourColor;
// } else {
// color = theme.graphNode;
// settings.groups.forEach((group, index) => {
// if (group.query.trim().length === 0) return;
// const searchStateGroup = searchResult.value.groups[index];
// if (searchStateGroup) {
// const searchGroupfilePaths = searchStateGroup.files.map((file) => file.path);
//
// // if the node path is in the searchGroupfiles, change the color to group.color
// if (searchGroupfilePaths.includes(node.path)) color = group.color;
// }
// });
// }
// const rgba = hexToRGBA(
// color,
// this.getIsAnyHighlighted() && !this.isHighlightedNode(node) ? 0.5 : 1
// );
// return rgba;
// };
//
// public isHighlightedNode = (node: Node): boolean => {
// return this.highlightedNodes.has(node.id);
// };
// }

View file

@ -84,11 +84,11 @@ const traverseNode = (
};
export const loadImagesForGraph = async (plugin: ForceGraphPlugin, graph: Graph) => {
console.info("Loading images...");
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.info(`Loading ${node.imagePath} image for ${node.path}`);
console.debug(`Loading ${node.imagePath} image for ${node.path}`);
const file = plugin.app.vault.getAbstractFileByPath(node.imagePath);
if (file instanceof TFile) {
const arrayBuffer = await plugin.app.vault.readBinary(file);
@ -123,7 +123,7 @@ export const loadImagesForGraph = async (plugin: ForceGraphPlugin, graph: Graph)
plugin.globalGraph.nodes.find((n) => n.path == node.path).image = node.image;
}
} else if (node.imagePath) {
console.info(`Image ${node.imagePath} for ${node.path} already loaded!`);
console.debug(`Image ${node.imagePath} for ${node.path} already loaded!`);
}
return node;
});
@ -193,16 +193,10 @@ export const getNewLocalGraph = (
return true;
});
const parentNodes = graph.filterNodes((node: Node) => {
return node.isParentOf(centerFilePath);
});
function applyToConsecutivePairs<T>(arr: T[], func: (a: T, b: T) => void): void {
for (let i = 0; i < arr.length - 1; i++) {
func(arr[i], arr[i + 1]);
}
}
// const parentNodes = graph.filterNodes((node: Node) => {
// return node.isParentOf(centerFilePath);
// });
//
// parents linked together
// if (parentNodes.length > 1) {
// applyToConsecutivePairs(parentNodes, (n1, n2) => {

View file

@ -1,603 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// Original code from https://github.com/nothingislost/obsidian-hover-editor/blob/9ec3449be9ab3433dc46c4c3acfde1da72ff0261/src/popover.ts
// You can use this file as a basic leaf view create method in anywhere
// Please rememeber if you want to use this file, you should patch the types-obsidian.d.ts file
// And also monkey around the Obsidian original method.
import type {
EphemeralState,
MarkdownEditView,
Plugin,
TFile,
View,
Workspace,
WorkspaceLeaf,
} from "obsidian";
import {
Component,
HoverPopover,
parseLinktext,
PopoverState,
requireApiVersion,
resolveSubpath,
WorkspaceSplit,
WorkspaceTabs,
} from "obsidian";
import type { OpenViewState } from "@/typings/types-obsidian";
const PREFIX = "graph-3d";
export interface EmbeddedViewParent {
hoverPopover: EmbeddedView | null;
containerEl?: HTMLElement;
view?: View;
dom?: HTMLElement;
}
const popovers = new WeakMap<Element, EmbeddedView>();
type ConstructableWorkspaceSplit = new (
ws: Workspace,
dir: "horizontal" | "vertical"
) => WorkspaceSplit;
export function isEmebeddedLeaf(leaf: WorkspaceLeaf) {
// Work around missing enhance.js API by checking match condition instead of looking up parent
return leaf.containerEl.matches(`.${PREFIX}-block.${PREFIX}-leaf-view .workspace-leaf`);
}
function genId(size: number): string {
const chars = [];
for (let n = 0; n < size; n++) chars.push(((16 * Math.random()) | 0).toString(16));
return chars.join("");
}
function nosuper<T>(base: new (...args: unknown[]) => T): new () => T {
const derived = function () {
return Object.setPrototypeOf(new Component(), new.target.prototype);
};
derived.prototype = base.prototype;
return Object.setPrototypeOf(derived, base);
}
export const spawnLeafView = (
plugin: Plugin,
initiatingEl?: HTMLElement,
leaf?: WorkspaceLeaf,
onShowCallback?: () => unknown,
props?: {
hoverElClasses?: string[];
containerElClasses?: string[];
}
): [WorkspaceLeaf, EmbeddedView] => {
// When Obsidian doesn't set any leaf active, use leaf instead.
let parent = app.workspace.activeLeaf as unknown as EmbeddedViewParent;
if (!parent) parent = leaf as unknown as EmbeddedViewParent;
if (!initiatingEl) initiatingEl = parent?.containerEl;
const hoverPopover = new EmbeddedView(
parent,
initiatingEl!,
plugin,
undefined,
onShowCallback,
props
);
return [hoverPopover.attachLeaf(), hoverPopover];
};
export class EmbeddedView extends nosuper(HoverPopover) {
onTarget: boolean;
setActive: (event: MouseEvent) => void;
// @ts-ignore
lockedOut: boolean;
abortController? = this.addChild(new Component());
detaching = false;
opening = false;
rootSplit: WorkspaceSplit = new (WorkspaceSplit as ConstructableWorkspaceSplit)(
window.app.workspace,
"vertical"
);
isPinned = true;
// @ts-ignore
titleEl: HTMLElement;
containerEl: HTMLElement;
// It is currently not useful.
// leafInHoverEl: WorkspaceLeaf;
oldPopover = this.parent?.hoverPopover;
document: Document = this.targetEl?.ownerDocument ?? window.activeDocument ?? window.document;
id = genId(8);
bounce?: Timer;
// @ts-ignore
boundOnZoomOut: () => void;
// @ts-ignore
originalPath: string; // these are kept to avoid adopting targets w/a different link
// @ts-ignore
originalLinkText: string;
static activePopover?: EmbeddedView;
static activeWindows() {
const windows: Window[] = [window];
const { floatingSplit } = app.workspace;
if (floatingSplit) {
for (const split of floatingSplit.children) {
if (split.win) windows.push(split.win);
}
}
return windows;
}
static containerForDocument(doc: Document) {
if (doc !== document && app.workspace.floatingSplit)
for (const container of app.workspace.floatingSplit.children) {
if (container.doc === doc) return container;
}
return app.workspace.rootSplit;
}
static activePopovers() {
return this.activeWindows().flatMap(this.popoversForWindow);
}
static popoversForWindow(win?: Window) {
return (
Array.prototype.slice.call(
win?.document?.body.querySelectorAll(`.${PREFIX}-leaf-view`) ?? []
) as HTMLElement[]
)
.map((el) => popovers.get(el)!)
.filter((he) => he);
}
static forLeaf(leaf: WorkspaceLeaf | undefined) {
// leaf can be null such as when right clicking on an internal link
const el = leaf && document.body.matchParent.call(leaf.containerEl, `.${PREFIX}-leaf-view`); // work around matchParent race condition
return el ? popovers.get(el) : undefined;
}
static iteratePopoverLeaves(ws: Workspace, cb: (leaf: WorkspaceLeaf) => boolean | void) {
for (const popover of this.activePopovers()) {
if (popover.rootSplit && ws.iterateLeaves(cb, popover.rootSplit)) return true;
}
return false;
}
hoverEl: HTMLElement = this.document.defaultView!.createDiv({
cls: `${PREFIX}-block ${PREFIX}-leaf-view`,
attr: { id: `${PREFIX}-` + this.id },
});
constructor(
parent: EmbeddedViewParent,
public targetEl: HTMLElement,
public plugin: Plugin,
waitTime?: number,
public onShowCallback?: () => unknown,
props?: {
hoverElClasses?: string[];
containerElClasses?: string[];
}
) {
//
super();
if (waitTime === undefined) {
waitTime = 300;
}
this.onTarget = true;
this.parent = parent;
this.waitTime = waitTime;
this.state = PopoverState.Showing;
this.abortController!.load();
this.show();
this.onShow();
this.setActive = this._setActive.bind(this);
// if (hoverEl) {
// hoverEl.addEventListener("mousedown", this.setActive);
// }
// custom logic begin
popovers.set(this.hoverEl, this);
if (props?.hoverElClasses) {
this.hoverEl.addClass(...props.hoverElClasses);
}
this.containerEl = this.hoverEl.createDiv({ cls: props?.containerElClasses });
this.buildWindowControls();
this.setInitialDimensions();
}
_setActive(evt: MouseEvent) {
evt.preventDefault();
evt.stopPropagation();
// @ts-ignore
this.plugin.app.workspace.setActiveLeaf(this.leaves()[0], {
focus: true,
});
}
getDefaultMode() {
// return this.parent?.view?.getMode ? this.parent.view.getMode() : "source";
return "source";
}
updateLeaves() {
if (this.onTarget && this.targetEl && !this.document.contains(this.targetEl)) {
this.onTarget = false;
this.transition();
}
let leafCount = 0;
this.plugin.app.workspace.iterateLeaves((leaf) => {
leafCount++;
}, this.rootSplit);
if (leafCount === 0) {
this.hide(); // close if we have no leaves
}
this.hoverEl.setAttribute("data-leaf-count", leafCount.toString());
}
leaves() {
const leaves: WorkspaceLeaf[] = [];
this.plugin.app.workspace.iterateLeaves((leaf) => {
leaves.push(leaf);
}, this.rootSplit);
return leaves;
}
setInitialDimensions() {
this.hoverEl.style.height = "auto";
this.hoverEl.style.width = "100%";
}
transition() {
if (this.shouldShow()) {
if (this.state === PopoverState.Hiding) {
this.state = PopoverState.Shown;
clearTimeout(this.timer);
}
} else {
if (this.state === PopoverState.Showing) {
this.hide();
} else {
if (this.state === PopoverState.Shown) {
this.state = PopoverState.Hiding;
this.timer = window.setTimeout(() => {
if (this.shouldShow()) {
this.transition();
} else {
this.hide();
}
}, this.waitTime);
}
}
}
}
buildWindowControls() {
this.titleEl = this.document.defaultView!.createDiv("popover-titlebar");
this.titleEl.createDiv("popover-title");
this.containerEl.prepend(this.titleEl);
}
attachLeaf(): WorkspaceLeaf {
this.rootSplit.getRoot = () =>
this.plugin.app.workspace[this.document === document ? "rootSplit" : "floatingSplit"]!;
this.rootSplit.getContainer = () => EmbeddedView.containerForDocument(this.document);
this.titleEl.insertAdjacentElement("afterend", this.rootSplit.containerEl);
const leaf = this.plugin.app.workspace.createLeafInParent(this.rootSplit, 0);
this.updateLeaves();
return leaf;
}
onload(): void {
super.onload();
this.registerEvent(this.plugin.app.workspace.on("layout-change", this.updateLeaves, this));
this.registerEvent(
app.workspace.on("layout-change", () => {
// Ensure that top-level items in a popover are not tabbed
// @ts-ignore
this.rootSplit.children.forEach((item: any, index: any) => {
if (item instanceof WorkspaceTabs) {
this.rootSplit.replaceChild(index, item.children[0]);
}
});
})
);
}
onShow() {
// Once we've been open for closeDelay, use the closeDelay as a hiding timeout
const closeDelay = 600;
setTimeout(() => (this.waitTime = closeDelay), closeDelay);
this.oldPopover?.hide();
this.oldPopover = null;
this.hoverEl.toggleClass("is-new", true);
this.document.body.addEventListener(
"click",
() => {
this.hoverEl.toggleClass("is-new", false);
},
{ once: true, capture: true }
);
if (this.parent) {
this.parent.hoverPopover = this;
}
// Remove original view header;
const viewHeaderEl = this.hoverEl.querySelector(".view-header");
viewHeaderEl?.remove();
const sizer = this.hoverEl.querySelector(".workspace-leaf");
if (sizer) this.hoverEl.appendChild(sizer);
// Remove original inline tilte;
const inlineTitle = this.hoverEl.querySelector(".inline-title");
if (inlineTitle) inlineTitle.remove();
this.onShowCallback?.();
this.onShowCallback = undefined; // only call it once
}
detect(el: HTMLElement) {
// TODO: may not be needed? the mouseover/out handers handle most detection use cases
const { targetEl } = this;
if (targetEl) {
this.onTarget = el === targetEl || targetEl.contains(el);
}
}
shouldShow() {
return this.shouldShowSelf() || this.shouldShowChild();
}
shouldShowChild(): boolean {
return EmbeddedView.activePopovers().some((popover) => {
if (popover !== this && popover.targetEl && this.hoverEl.contains(popover.targetEl)) {
return popover.shouldShow();
}
return false;
});
}
shouldShowSelf() {
// Don't let obsidian show() us if we've already started closing
// return !this.detaching && (this.onTarget || this.onHover);
return (
!this.detaching &&
!!(
this.onTarget ||
this.state == PopoverState.Shown ||
this.document.querySelector(
`body>.modal-container, body > #he${this.id} ~ .menu, body > #he${this.id} ~ .suggestion-container`
)
)
);
}
show() {
// native obsidian logic start
// if (!this.targetEl || this.document.body.contains(this.targetEl)) {
this.state = PopoverState.Shown;
this.timer = 0;
this.targetEl.appendChild(this.hoverEl);
this.onShow();
app.workspace.onLayoutChange();
// initializingHoverPopovers.remove(this);
// activeHoverPopovers.push(this);
// initializePopoverChecker();
this.load();
// }
// native obsidian logic end
// if this is an image view, set the dimensions to the natural dimensions of the image
// an interactjs reflow will be triggered to constrain the image to the viewport if it's
// too large
if (this.hoverEl.dataset.imgHeight && this.hoverEl.dataset.imgWidth) {
this.hoverEl.style.height =
parseFloat(this.hoverEl.dataset.imgHeight) + this.titleEl.offsetHeight + "px";
this.hoverEl.style.width = parseFloat(this.hoverEl.dataset.imgWidth) + "px";
}
}
onHide() {
this.oldPopover = null;
if (this.parent?.hoverPopover === this) {
this.parent.hoverPopover = null;
}
}
hide() {
this.onTarget = false;
this.detaching = true;
// Once we reach this point, we're committed to closing
// in case we didn't ever call show()
// A timer might be pending to call show() for the first time, make sure
// it doesn't bring us back up after we close
if (this.timer) {
clearTimeout(this.timer);
this.timer = 0;
}
// Hide our HTML element immediately, even if our leaves might not be
// detachable yet. This makes things more responsive and improves the
// odds of not showing an empty popup that's just going to disappear
// momentarily.
this.hoverEl.hide();
// If a file load is in progress, we need to wait until it's finished before
// detaching leaves. Because we set .detaching, The in-progress openFile()
// will call us again when it finishes.
if (this.opening) return;
// Leave this code here to observe the state of the leaves
const leaves = this.leaves();
if (leaves.length) {
// Detach all leaves before we unload the popover and remove it from the DOM.
// Each leaf.detach() will trigger layout-changed and the updateLeaves()
// method will then call hide() again when the last one is gone.
// leaves[0].detach();
// leaves[0].detach();
this.targetEl.empty();
} else {
this.parent = null;
this.abortController?.unload();
this.abortController = undefined;
return this.nativeHide();
}
}
nativeHide() {
const { hoverEl, targetEl } = this;
this.state = PopoverState.Hidden;
hoverEl.detach();
if (targetEl) {
const parent = targetEl.matchParent(`.${PREFIX}-leaf-view`);
if (parent) popovers.get(parent)?.transition();
}
this.onHide();
this.unload();
}
resolveLink(linkText: string, sourcePath: string): TFile | null {
const link = parseLinktext(linkText);
const tFile = link
? this.plugin.app.metadataCache.getFirstLinkpathDest(link.path, sourcePath)
: null;
return tFile;
}
async openLink(
linkText: string,
sourcePath: string,
eState?: EphemeralState,
createInLeaf?: WorkspaceLeaf
) {
let file = this.resolveLink(linkText, sourcePath);
const link = parseLinktext(linkText);
if (!file && createInLeaf) {
const folder = this.plugin.app.fileManager.getNewFileParent(sourcePath);
file = await this.plugin.app.fileManager.createNewMarkdownFile(folder, link.path);
}
if (!file) {
// this.displayCreateFileAction(linkText, sourcePath, eState);
return;
}
const { viewRegistry } = this.plugin.app;
const viewType = viewRegistry.typeByExtension[file.extension];
if (!viewType || !viewRegistry.viewByType[viewType]) {
// this.displayOpenFileAction(file);
return;
}
eState = Object.assign(this.buildEphemeralState(file, link), eState);
const parentMode = this.getDefaultMode();
const state = this.buildState(parentMode, eState);
const leaf = await this.openFile(file, state, createInLeaf);
const leafViewType = leaf?.view?.getViewType();
if (leafViewType === "image") {
// TODO: temporary workaround to prevent image popover from disappearing immediately when using live preview
if (
this.parent?.hasOwnProperty("editorEl") &&
(this.parent as unknown as MarkdownEditView).editorEl!.hasClass("is-live-preview")
) {
this.waitTime = 3000;
}
const img = leaf!.view.contentEl.querySelector("img")!;
this.hoverEl.dataset.imgHeight = String(img.naturalHeight);
this.hoverEl.dataset.imgWidth = String(img.naturalWidth);
this.hoverEl.dataset.imgRatio = String(img.naturalWidth / img.naturalHeight);
} else if (leafViewType === "pdf") {
this.hoverEl.style.height = "800px";
this.hoverEl.style.width = "600px";
}
if (state.state?.mode === "source") {
this.whenShown(() => {
// Not sure why this is needed, but without it we get issue #186
if (requireApiVersion("1.0")) (leaf?.view as any)?.editMode?.reinit?.();
leaf?.view?.setEphemeralState(state.eState);
});
}
}
whenShown(callback: () => any) {
// invoke callback once the popover is visible
if (this.detaching) return;
const existingCallback = this.onShowCallback;
this.onShowCallback = () => {
if (this.detaching) return;
callback();
if (typeof existingCallback === "function") existingCallback();
};
if (this.state === PopoverState.Shown) {
this.onShowCallback();
this.onShowCallback = undefined;
}
}
async openFile(file: TFile, openState?: OpenViewState, useLeaf?: WorkspaceLeaf) {
if (this.detaching) return;
const leaf = useLeaf ?? this.attachLeaf();
this.opening = true;
try {
await leaf.openFile(file, openState);
} catch (e) {
console.error(e);
} finally {
this.opening = false;
if (this.detaching) this.hide();
}
this.plugin.app.workspace.setActiveLeaf(leaf);
return leaf;
}
buildState(parentMode: string, eState?: EphemeralState) {
return {
active: false, // Don't let Obsidian force focus if we have autofocus off
state: { mode: "source" }, // Don't set any state for the view, because this leaf is stayed on another view.
eState: eState,
};
}
buildEphemeralState(
file: TFile,
link?: {
path: string;
subpath: string;
}
) {
const cache = this.plugin.app.metadataCache.getFileCache(file);
const subpath = cache ? resolveSubpath(cache, link?.subpath || "") : undefined;
const eState: EphemeralState = { subpath: link?.subpath };
if (subpath) {
eState.line = subpath.start.line;
eState.startLoc = subpath.start;
eState.endLoc = subpath.end || undefined;
}
return eState;
}
}

View file

@ -1,80 +0,0 @@
import { ExtraButtonComponent } from "obsidian";
import { addColorPicker } from "@/views/atomics/addColorPicker";
import { addSearchInput } from "@/views/atomics/addSearchInput";
import type { BaseForceGraphView } from "@/views/graph/forceview/ForceGraphView";
import { DefaultSearchEngine } from "@/PassiveSearchEngine";
import type { GroupSettings } from "@/SettingsSchemas";
/**
* given a group and a container element,
* create a group setting item inside the container element
*/
export const AddNodeGroupItem = async (
newGroup: GroupSettings[number],
containerEl: HTMLElement,
view: BaseForceGraphView,
/**
* the index of this group
*/
index: number,
searchInputs: Awaited<ReturnType<typeof addSearchInput>>[]
) => {
// This group must exist
const groupEl = containerEl.createDiv({ cls: "graph-color-group" });
const searchInput = await addSearchInput(
groupEl,
newGroup.query,
(value) => {
view.settingManager.updateCurrentSettings((setting) => {
setting.value.groups[index]!.query = value;
});
},
view
);
if (searchInput && view.plugin.fileManager.searchEngine instanceof DefaultSearchEngine) {
searchInput.addMutationObserver((files) => {
if (view.settingManager.searchResult.value.groups[index] === undefined)
view.settingManager.searchResult.value.groups[index] = { files: [] };
view.settingManager.searchResult.value.groups[index]!.files = files.map((file) => ({
name: file.name,
path: file.path,
}));
}, `Group ${index}`);
}
// add the search input to the searchInputs
searchInputs.push(searchInput);
addColorPicker(groupEl, newGroup.color, (value) => {
view.settingManager.updateCurrentSettings((setting) => {
// This group must exist
setting.value.groups[index]!.color = value;
});
});
new ExtraButtonComponent(groupEl)
.setIcon("cross")
.setTooltip("Delete Group")
.onClick(() => {
// remove itself from the UI
groupEl.remove();
// remove from setting
view.settingManager.updateCurrentSettings((setting) => {
setting.value.groups.splice(index, 1);
});
// remove from the search result
view.settingManager.searchResult.value.groups.splice(index, 1);
// remove the nodeGroupItem from the view
searchInputs.splice(index, 1);
});
return {
searchInput,
};
};

View file

@ -1,50 +0,0 @@
import { ButtonComponent } from "obsidian";
import { AddNodeGroupItem } from "@/views/settings/categories/AddGroupSettingItem";
import type { BaseForceGraphView } from "@/views/graph/forceview/ForceGraphView";
import type { addSearchInput } from "@/views/atomics/addSearchInput";
const getRandomColor = () => {
return "#" + Math.floor(Math.random() * 16777215).toString(16);
};
export const addNodeGroupButton = (
containerEl: HTMLElement,
view: BaseForceGraphView,
searchInputs: Awaited<ReturnType<typeof addSearchInput>>[]
) => {
// make sure there is only one button
containerEl.querySelector(".graph-color-button-container")?.remove();
const buttonContainerEl = containerEl.createDiv({
cls: "graph-color-button-container",
});
const nodeGroupButton: { groupItems: Awaited<ReturnType<typeof AddNodeGroupItem>>[] } = {
groupItems: [],
};
new ButtonComponent(buttonContainerEl)
.setClass("mod-cta")
.setButtonText("Add Group")
.onClick(async () => {
const newGroup = {
query: "",
color: getRandomColor(),
};
// add a group to group settings
view.settingManager.updateCurrentSettings((setting) => {
setting.value.groups.push(newGroup);
// add a group to UI as well, add it in the containerEl before the button container el
});
// we need to get the latest current setting so that index will be correct
const index = view.settingManager.getCurrentSetting().groups.length - 1;
// add a group to search result as well
view.settingManager.searchResult.value.groups[index] = {
files: [],
};
await AddNodeGroupItem(newGroup, containerEl, view, index, searchInputs);
containerEl.append(buttonContainerEl);
});
return nodeGroupButton;
};

View file

@ -1,248 +0,0 @@
import { addSimpleSliderSetting } from "@/views/atomics/addSimpleSliderSetting";
import { addColorPickerSetting } from "@/views/atomics/addColorPickerSetting";
import { addToggle } from "@/views/atomics/addToggle";
import { DropdownComponent, Setting } from "obsidian";
import type {
LocalDisplaySettings,
LocalGraphSettings,
} from "@/SettingsSchemas";
import {
DagOrientation,
distanceFromFocal,
linkDistance,
linkThickness,
nodeRepulsion,
nodeSize,
} from "@/SettingsSchemas";
import type { BaseGraphSettingManager } from "@/views/settings/graphSettingManagers/GraphSettingsManager";
import type { State } from "@/util/State";
import { createNotice } from "@/util/createNotice";
export const DisplaySettingsView = (
graphSetting: LocalGraphSettings,
containerEl: HTMLElement,
settingManager: BaseGraphSettingManager
) => {
const displaySettings = graphSetting.display;
// add the node size setting
addSimpleSliderSetting(
containerEl,
{
name: "Node size",
value: displaySettings.nodeSize,
stepOptions: nodeSize,
},
(value) => {
settingManager.updateCurrentSettings((setting) => {
setting.value.display.nodeSize = value;
});
}
);
// add link thinkness setting
addSimpleSliderSetting(
containerEl,
{
name: "Link thickness",
value: displaySettings.linkThickness,
stepOptions: linkThickness,
},
(value) => {
settingManager.updateCurrentSettings((setting) => {
setting.value.display.linkThickness = value;
});
}
);
// add link distance settings
addSimpleSliderSetting(
containerEl,
{
name: "Link distance",
value: displaySettings.linkDistance,
stepOptions: linkDistance,
},
(value) => {
settingManager.updateCurrentSettings((setting) => {
setting.value.display.linkDistance = value;
});
}
);
addSimpleSliderSetting(
containerEl,
{
name: "Node repulsion",
value: displaySettings.nodeRepulsion,
stepOptions: nodeRepulsion,
},
(value) => {
settingManager.updateCurrentSettings((setting) => {
setting.value.display.nodeRepulsion = value;
});
}
);
addSimpleSliderSetting(
containerEl,
{
name: "Distance from focal",
value: displaySettings.distanceFromFocal,
stepOptions: distanceFromFocal,
},
(value) => {
settingManager.updateCurrentSettings((setting) => {
setting.value.display.distanceFromFocal = value;
});
}
);
addColorPickerSetting(
containerEl,
{
name: "Node hover color",
value: displaySettings.nodeHoverColor,
},
(value) => {
settingManager.updateCurrentSettings((setting) => {
setting.value.display.nodeHoverColor = value;
});
}
);
// add node hover color setting
addColorPickerSetting(
containerEl,
{
name: "Node hover neighbour color",
value: displaySettings.nodeHoverNeighbourColor,
},
(value) => {
settingManager.updateCurrentSettings((setting) => {
setting.value.display.nodeHoverNeighbourColor = value;
});
}
);
// add link hover color setting
addColorPickerSetting(
containerEl,
{
name: "Link hover color",
value: displaySettings.linkHoverColor,
},
(value) => {
settingManager.updateCurrentSettings((setting) => {
setting.value.display.linkHoverColor = value;
});
}
);
// add show extension setting
addToggle(
containerEl,
{
name: "Show file extension",
value: displaySettings.showExtension,
},
(value) => {
settingManager.updateCurrentSettings((setting) => {
setting.value.display.showExtension = value;
});
}
);
// add show full path setting
addToggle(
containerEl,
{
name: "Show note full path",
value: displaySettings.showFullPath,
},
(value) => {
settingManager.updateCurrentSettings((setting) => {
setting.value.display.showFullPath = value;
});
}
);
addToggle(
containerEl,
{
name: "Show link arrow",
value: displaySettings.showLinkArrow,
},
(value) => {
settingManager.updateCurrentSettings((setting) => {
setting.value.display.showLinkArrow = value;
});
}
);
addToggle(
containerEl,
{
name: "Don't move when drag",
value: displaySettings.dontMoveWhenDrag,
},
(value) => {
settingManager.updateCurrentSettings((setting) => {
setting.value.display.dontMoveWhenDrag = value;
});
}
);
const localDisplaySettings = displaySettings as LocalDisplaySettings;
const dagDropDown = new Setting(containerEl).setName("Dag orientation");
const dropdown = new DropdownComponent(dagDropDown.settingEl)
.addOptions(DagOrientation)
// the default value will be null
.setValue(localDisplaySettings.dagOrientation ?? DagOrientation.null)
.onChange(async (value) => {
settingManager.updateCurrentSettings((setting: State<LocalGraphSettings>) => {
if (
!settingManager.getGraphView().getForceGraph().instance.graphData().isAcyclic() &&
value !== DagOrientation.null
) {
createNotice("The graph is cyclic, dag orientation will be ignored");
} else {
setting.value.display.dagOrientation = value as LocalDisplaySettings["dagOrientation"];
}
});
});
const hideDagOrientationSetting = () => {
// if the link type is both, then we need to hide the dag orientation setting
dagDropDown.settingEl.hide();
// set the dag orientation to null
settingManager.updateCurrentSettings((setting: State<LocalGraphSettings>) => {
setting.value.display.dagOrientation = DagOrientation.null;
});
// set the UI as well
dropdown.setValue(DagOrientation.null);
};
const showDagOrientationSetting = () => {
// if the link type is either inlink or outlink, then we need to add the dag orientation setting
dagDropDown.settingEl.show();
// set the dag orientation to null
settingManager.updateCurrentSettings((setting: State<LocalGraphSettings>) => {
setting.value.display.dagOrientation = DagOrientation.null;
});
// set the UI as well
dropdown.setValue(DagOrientation.null);
};
const isDropdownHidden = () => {
return dagDropDown.settingEl.style.display === "none";
};
return {
hideDagOrientationSetting,
showDagOrientationSetting,
isDropdownHidden,
};
};

View file

@ -1,116 +0,0 @@
import { Setting } from "obsidian";
import { addSearchInput } from "@/views/atomics/addSearchInput";
import type { BaseGraphSettingManager } from "@/views/settings/graphSettingManagers/GraphSettingsManager";
import type { State } from "@/util/State";
import { waitForStable } from "@/util/waitFor";
import { DefaultSearchEngine } from "@/PassiveSearchEngine";
import type { BaseFilterSettings, LocalFilterSetting, LocalGraphSettings } from "@/SettingsSchemas";
import { GraphType } from "@/SettingsSchemas";
export const FilterSettingsView = async (
filterSettings: BaseFilterSettings | LocalFilterSetting,
containerEl: HTMLElement,
settingManager: BaseGraphSettingManager
) => {
const graphView = settingManager.getGraphView();
const searchInput = await addSearchInput(
containerEl,
filterSettings.searchQuery,
(value) => {
//update the current setting of the plugin
settingManager.updateCurrentSettings((setting) => {
setting.value.filter.searchQuery = value;
});
},
graphView
);
// if this is a built-in search input, then we need to add a mutation observer
if (
searchInput &&
settingManager.getGraphView().plugin.fileManager.searchEngine instanceof DefaultSearchEngine
)
searchInput.addMutationObserver((files) => {
// the files is empty, by default, we will show all files
settingManager.searchResult.value.filter.files = files.map((file) => ({
name: file.name,
path: file.path,
}));
});
// add show attachments setting
new Setting(containerEl).setName("Show attachments").addToggle((toggle) => {
toggle.setValue(filterSettings.showAttachments || false).onChange(async (value) => {
settingManager.updateCurrentSettings((setting) => {
setting.value.filter.showAttachments = value;
});
});
});
// add show orphans setting
new Setting(containerEl).setName("Show orphans").addToggle((toggle) => {
toggle.setValue(filterSettings.showOrphans || false).onChange(async (value) => {
settingManager.updateCurrentSettings((setting) => {
setting.value.filter.showOrphans = value;
});
});
});
if (graphView.graphType === GraphType.local) {
const localFilterSettings = filterSettings as LocalFilterSetting;
let latestValue = localFilterSettings.depth;
let activeCallId = 0; // Unique identifier for each call
// add a slider for the depth
new Setting(containerEl).setName("Depth").addSlider((slider) => {
slider
.setLimits(1, 5, 1)
.setValue(localFilterSettings.depth)
.setDynamicTooltip()
.onChange((value) => {
latestValue = value;
const currentCallId = ++activeCallId; // Increment and store the unique ID for this call
waitForStable(() => latestValue, {
timeout: 3000, // wait for a max of 3 seconds for stability
minDelay: 300, // start checking after 300ms
interval: 100, // check every 100ms
rehitCount: 3, // require 3 consecutive checks with the same value
}).then((stableValue) => {
if (stableValue !== undefined && currentCallId === activeCallId) {
// Only proceed if this is the latest call
settingManager.updateCurrentSettings((setting: State<LocalGraphSettings>) => {
setting.value.filter.depth = stableValue;
});
}
});
});
});
// add dropdown show incoming links setting
new Setting(containerEl).setName("Show Incoming Links").addDropdown((dropdown) => {
dropdown
.addOptions({
both: "Both",
inlinks: "Inlinks",
outlinks: "Outlinks",
})
.setValue(localFilterSettings.linkType)
.onChange(async (value: "both" | "inlinks" | "outlinks") => {
// update the setting
settingManager.updateCurrentSettings((setting: State<LocalGraphSettings>) => {
setting.value.filter.linkType = value;
// we are putting false here because we know there are still some more to update
});
});
});
}
const triggerSearch = async () => {
searchInput?.triggerSearch();
};
return {
searchInput,
triggerSearch,
};
};

View file

@ -1,82 +0,0 @@
import type { SavedSetting } from "@/SettingsSchemas";
import { createNotice } from "@/util/createNotice";
import type { BaseForceGraphView } from "@/views/graph/forceview/ForceGraphView";
import { ExtraButtonComponent, TextComponent } from "obsidian";
/**
* given a container element, add a new setting group item to it
*/
export const addSaveSettingGroupItem = (
containerEl: HTMLDivElement,
savedSetting: SavedSetting,
view: BaseForceGraphView
) => {
// create a new div element
const innerEl = containerEl.createDiv({
attr: {
style:
"display: flex; flex-direction: row; justify-content: space-between; align-items: center;",
},
});
// create a new input element in the div element
const nameSetting = new TextComponent(innerEl);
nameSetting.setValue(savedSetting.title).onChange(async (value) => {
// TODO: change the setting group name
view.plugin.settingManager.updateSettings((settings) => {
const setting = settings.value.savedSettings.find(
(setting) => setting.id === savedSetting.id
);
if (setting) {
setting.title = value;
}
return settings;
});
});
const checkButton = new ExtraButtonComponent(innerEl);
checkButton
.setIcon("undo-2")
.setTooltip("Apply")
.onClick(async () => {
view.settingManager.applySettings(savedSetting.setting);
});
const updateButton = new ExtraButtonComponent(innerEl);
updateButton
.setIcon("pencil")
.setTooltip("Update")
.onClick(async () => {
if (confirm(`Are you sure you want to update: ${savedSetting.title}?`)) {
createNotice(`Updating saved settings ${savedSetting.title}`);
// update the setting
view.plugin.settingManager.updateSettings((settings) => {
const targetSavedSetting = settings.value.savedSettings.find(
(setting) => setting.id === savedSetting.id
);
if (targetSavedSetting)
targetSavedSetting.setting = view.settingManager.getCurrentSetting();
});
}
});
const trashButton = new ExtraButtonComponent(innerEl);
trashButton
.setIcon("trash")
.setTooltip("Delete")
.onClick(async () => {
if (confirm(`Are you sure you want to delete: ${savedSetting.title}?`)) {
// remove from UI
innerEl.remove();
// remove from settings
view.plugin.settingManager.updateSettings((settings) => {
settings.value.savedSettings = settings.value.savedSettings.filter(
(setting) => setting.id !== savedSetting.id
);
});
}
});
trashButton.extraSettingsEl.style.color = "red";
};

View file

@ -1,31 +0,0 @@
import { ButtonComponent } from "obsidian";
import { getMySwitcher } from "@/views/settings/categories/getMySwitcher";
import type { BaseForceGraphView } from "@/views/graph/forceview/ForceGraphView";
export const UtilitySettingsView = async (containerEl: HTMLElement, view: BaseForceGraphView) => {
const plugin = view.plugin;
const div = containerEl.createDiv();
// set the containerEl to have flex col space 4px between items
div.style.display = "flex";
div.style.flexDirection = "column";
div.style.gap = "4px";
new ButtonComponent(div).setButtonText("Search").onClick(() => {
const MySwitcher = getMySwitcher(view);
if (MySwitcher === undefined) return;
const modal = new MySwitcher(plugin.app, plugin);
modal.open();
});
new ButtonComponent(div).setButtonText("Look at center").onClick(() => {
// TODO: change all event to enum
view.getForceGraph()?.interactionManager.cameraLookAtCenter();
});
new ButtonComponent(div).setButtonText("Remove selection").onClick(() => {
view.getForceGraph()?.interactionManager.removeSelection();
});
};

View file

@ -1,19 +0,0 @@
import { AddNodeGroupItem } from "@/views/settings/categories/AddGroupSettingItem";
import type { BaseForceGraphView } from "@/views/graph/forceview/ForceGraphView";
import type { addSearchInput } from "@/views/atomics/addSearchInput";
import type { GroupSettings } from "@/SettingsSchemas";
export const addNodeGroups = async (
groupSettings: GroupSettings,
containerEl: HTMLElement,
view: BaseForceGraphView,
nodeGroups: Awaited<ReturnType<typeof addSearchInput>>[]
) => {
containerEl.querySelector(".node-group-container")?.remove();
const nodeGroupContainerEl = containerEl.createDiv({
cls: "graph-color-groups-container",
});
groupSettings.forEach(async (group, index) => {
await AddNodeGroupItem(group, nodeGroupContainerEl, view, index, nodeGroups);
});
};

View file

@ -1,36 +0,0 @@
import type { App, TFile } from "obsidian";
import type ForceGraphPlugin from "@/main";
import type { QuickSwitcherPlugin } from "@/typings/types-obsidian";
import type { BaseForceGraphView } from "@/views/graph/forceview/ForceGraphView";
export const getMySwitcher = (view: BaseForceGraphView) => {
const switcherInstance = (view.plugin.app.internalPlugins.plugins.switcher as QuickSwitcherPlugin)
.instance;
const QuickSwitcherModal = switcherInstance?.QuickSwitcherModal;
if (!QuickSwitcherModal) return;
// you need the options to open quick switcher, https://github.com/darlal/obsidian-switcher-plus/blob/2a1a8ccb0ca955397aa7516b746853427f5483ec/src/settings/switcherPlusSettings.ts#L132-L134
const MySwitcher = class extends QuickSwitcherModal {
constructor(app: App, public plugin: ForceGraphPlugin) {
// @ts-ignore
super(app, switcherInstance.options);
}
async getSuggestions(query: string) {
const suggestions = await super.getSuggestions(query);
const allFilePaths = view
.getForceGraph()
?.instance.graphData()
.nodes.map((n) => n.path);
return suggestions.filter(Boolean).filter((s) => {
// only show files in this view
return s.file ? allFilePaths?.includes(s.file.path) : false;
});
}
// @ts-ignore
onChooseSuggestion(item: { file: TFile }, evt: MouseEvent | KeyboardEvent) {
view.getForceGraph()?.interactionManager.searchNode(item.file.path);
}
};
return MySwitcher;
};

View file

@ -1,136 +1,54 @@
.force-graph-view .tree-item.is-collapsed > .tree-item-children {
display: none;
visibility: hidden;
}
.force-graph-view {
padding: 0 !important;
position: relative;
overflow: hidden !important;
}
.force-graph-view .graph-controls.is-collapsed > .graph-control-section {
display: none;
visibility: hidden;
}
.graph-control-section .tree-item-inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.force-graph-view .graph-control-section.is-collapsed .tree-item-inner::before {
content: "▶";
margin-right: 0.5em;
}
.force-graph-view .graph-control-section:not(.is-collapsed) .tree-item-inner::before {
content: "▼";
margin-right: 0.5em;
}
.force-graph-view .graph-controls:hover > .control-buttons {
opacity: 0.5;
}
.force-graph-view .graph-controls > .control-buttons:hover {
opacity: 1;
}
.force-graph-view .graph-controls > .control-buttons {
float: right;
margin-right: 0;
opacity: 0;
}
.force-graph-view .hidden {
display: none;
visibility: hidden;
}
.force-graph-view .control-buttons {
display: block;
}
.force-graph-view .control-buttons > * {
display: inline-block;
margin: 0;
}
.force-graph-view .graph-settings-view > .clickable-icon {
/* src/views/graph/GraphControls.css */
.pm-graph-controls {
position: absolute;
top: 8px;
right: 8px;
top: 50px;
right: 10px;
display: grid;
grid-template-columns: 1fr 1fr;
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);
}
.force-graph-view .node-label {
color: var(--text-normal);
.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;
}
.force-graph-view .scene-nav-info {
display: block;
visibility: visible;
.pm-graph-controls button:hover {
background: var(--color-base-60);
}
.mod-search-input {
width: 100%;
padding: 0.5em;
border: 1px solid var(--border-color);
border-radius: 0.25em;
background-color: var(--background-color);
color: var(--text-normal);
.slider-control {
position: absolute;
top: 50px;
left: 10px;
display: grid;
grid-template-columns: 1fr;
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);
color: var(--color-base-00);
}
.force-graph-view .graph-controls:not(.is-close) {
max-height: calc(90% - var(--size-4-4));
.slider-control input[type=range] {
width: 100px;
cursor: pointer;
}
.workspace-leaf-content:has(.search-result-container.is-loading) .graph-controls {
/* border color purple */
border-color: var(--color-purple);
/* create the animation */
animation: color-oscillation 2s ease infinite;
}
@keyframes color-oscillation {
0% {
border: 1px solid var(--color-purple);
}
50% {
border: 1px solid var(--background-modifier-border);
}
100% {
border: 1px solid var(--color-purple);
}
}
.workspace-leaf-content:has(.search-result-container.is-loading) .search-input-container input {
animation: outline-gradient-animation 2s linear infinite;
}
@keyframes outline-gradient-animation {
0% {
box-shadow: 0 2px 0 var(--color-purple);
}
50% {
box-shadow: 0 2px 0 var(--background-modifier-border);
}
100% {
box-shadow: 0 2px 0 var(--color-purple);
}
}
/* section: setting tab */
.graph-3d-setting-tab {
> h2 {
color: var(--color-purple);
font-size: var(--h2-size);
}
> h2 + .setting-item {
border-top: 0px;
}
.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);
}
/*# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsic3JjL3ZpZXdzL2dyYXBoL0dyYXBoQ29udHJvbHMuY3NzIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyIucG0tZ3JhcGgtY29udHJvbHMge1xuICBwb3NpdGlvbjogYWJzb2x1dGU7XG4gIHRvcDogNTBweDtcbiAgcmlnaHQ6IDEwcHg7XG4gIGRpc3BsYXk6IGdyaWQ7XG4gIGdyaWQtdGVtcGxhdGUtY29sdW1uczogMWZyIDFmcjsgLyogMiBjb2x1bW5zICovXG4gIGdhcDogNXB4O1xuICBiYWNrZ3JvdW5kOiByZ2JhKDI1NSwgMjU1LCAyNTUsIDAuOCk7IC8qIFNlbWktdHJhbnNwYXJlbnQgYmFja2dyb3VuZCAqL1xuICBwYWRkaW5nOiA1cHg7XG4gIGJvcmRlci1yYWRpdXM6IDVweDtcbiAgYm94LXNoYWRvdzogMCAycHggNHB4IHJnYmEoMCwgMCwgMCwgMC4yKTtcbn1cblxuLnBtLWdyYXBoLWNvbnRyb2xzIGJ1dHRvbiB7XG4gIGRpc3BsYXk6IGZsZXg7XG4gIGFsaWduLWl0ZW1zOiBjZW50ZXI7XG4gIGp1c3RpZnktY29udGVudDogY2VudGVyO1xuICBiYWNrZ3JvdW5kOiB2YXIoLS1jb2xvci1iYXNlLTQwKTtcbiAgYm9yZGVyOiBub25lO1xuICBib3JkZXItcmFkaXVzOiA0cHg7XG4gIGN1cnNvcjogcG9pbnRlcjtcbiAgZm9udC1zaXplOiAxNnB4O1xufVxuXG4ucG0tZ3JhcGgtY29udHJvbHMgYnV0dG9uOmhvdmVyIHtcbiAgYmFja2dyb3VuZDogdmFyKC0tY29sb3ItYmFzZS02MCk7XG59XG5cbi5zbGlkZXItY29udHJvbCB7XG4gIHBvc2l0aW9uOiBhYnNvbHV0ZTtcbiAgdG9wOiA1MHB4O1xuICBsZWZ0OiAxMHB4O1xuICBkaXNwbGF5OiBncmlkO1xuICBncmlkLXRlbXBsYXRlLWNvbHVtbnM6IDFmcjtcbiAgZ2FwOiA1cHg7XG4gIGJhY2tncm91bmQ6IHJnYmEoMjU1LCAyNTUsIDI1NSwgMC44KTsgLyogU2VtaS10cmFuc3BhcmVudCBiYWNrZ3JvdW5kICovXG4gIHBhZGRpbmc6IDVweDtcbiAgYm9yZGVyLXJhZGl1czogNXB4O1xuICBib3gtc2hhZG93OiAwIDJweCA0cHggcmdiYSgwLCAwLCAwLCAwLjIpO1xuICBjb2xvcjogdmFyKC0tY29sb3ItYmFzZS0wMCk7XG59XG5cbi5zbGlkZXItY29udHJvbCBpbnB1dFt0eXBlPVwicmFuZ2VcIl0ge1xuICB3aWR0aDogMTAwcHg7XG4gIGN1cnNvcjogcG9pbnRlcjtcbn1cblxuLnNsaWRlci12YWx1ZSB7XG4gIHBvc2l0aW9uOiBhYnNvbHV0ZTtcbiAgdG9wOiAtMjBweDsgLyogQWJvdmUgdGhlIHNsaWRlciAqL1xuICBsZWZ0OiA1MCU7XG4gIHRyYW5zZm9ybTogdHJhbnNsYXRlWCgtNTAlKTtcbiAgZm9udC1zaXplOiAxMnB4O1xuICBwYWRkaW5nOiAycHggNXB4O1xuICBib3JkZXItcmFkaXVzOiAzcHg7XG4gIGJveC1zaGFkb3c6IDAgMXB4IDJweCByZ2JhKDAsIDAsIDAsIDAuMSk7XG59XG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQUE7QUFDRTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUFBO0FBR0Y7QUFDRTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQUE7QUFHRjtBQUNFO0FBQUE7QUFHRjtBQUNFO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFBQTtBQUdGO0FBQ0U7QUFDQTtBQUFBO0FBR0Y7QUFDRTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQUE7IiwKICAibmFtZXMiOiBbXQp9Cg== */