mirror of
https://github.com/nn-ninja/n-brace.git
synced 2026-07-22 06:42:36 +00:00
rebranding to n-brace
This commit is contained in:
parent
1a72f110de
commit
175761805a
57 changed files with 1448 additions and 2829 deletions
4
.github/FUNDING.yml
vendored
4
.github/FUNDING.yml
vendored
|
|
@ -1,2 +1,2 @@
|
|||
github: [hananoshikayomaru]
|
||||
custom: https://www.buymeacoffee.com/yomaru
|
||||
github: [nn-ninja]
|
||||
custom: https://buymeacoffee.com/nbrace
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,3 +4,5 @@
|
|||
bun run typecheck
|
||||
bun run lint
|
||||
bun run build
|
||||
npm run lint
|
||||
npm run lint
|
||||
|
|
|
|||
120
README.md
120
README.md
|
|
@ -1,131 +1,41 @@
|
|||
# Obsidian BraiNavigator Plugin
|
||||
# Obsidian N-brace Plugin
|
||||
|
||||

|
||||

|
||||
|
||||
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://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;
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
### 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
BIN
bun.lockb
Binary file not shown.
39
data.json
Normal file
39
data.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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[]>([]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
172
src/main.ts
172
src/main.ts
|
|
@ -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 it’s 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
src/typings/force-graph.d.ts
vendored
1
src/typings/force-graph.d.ts
vendored
|
|
@ -7,5 +7,6 @@ type Coords = {
|
|||
};
|
||||
|
||||
type NodeData = {
|
||||
index: number;
|
||||
nodeDims: number[];
|
||||
}
|
||||
|
|
|
|||
373
src/typings/obsidian-ex.d.ts
vendored
373
src/typings/obsidian-ex.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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));
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
57
src/views/graph/GraphControls.css
Normal file
57
src/views/graph/GraphControls.css
Normal 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);
|
||||
}
|
||||
100
src/views/graph/GraphControls.tsx
Normal file
100
src/views/graph/GraphControls.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
// };
|
||||
// }
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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";
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
176
styles.css
176
styles.css
|
|
@ -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== */
|
||||
|
|
|
|||
Loading…
Reference in a new issue