From 175761805ab08b6090b24cf3815cb4dce61e44fb Mon Sep 17 00:00:00 2001 From: erhazan Date: Tue, 6 May 2025 22:18:55 +0200 Subject: [PATCH] rebranding to n-brace --- .github/FUNDING.yml | 4 +- .gitignore | 5 - .husky/pre-push | 2 + README.md | 120 +--- bun.lockb | Bin 219048 -> 219831 bytes data.json | 39 ++ esbuild.config.mjs | 11 + manifest.json | 12 +- package.json | 11 +- src/BasicSearchEngine.ts | 22 - src/DvSearchEngine.ts | 34 - src/FileManager.ts | 61 -- src/Interfaces.ts | 68 -- src/PassiveSearchEngine.ts | 78 --- src/SettingManager.ts | 31 +- src/SettingsSchemas.ts | 5 + src/atoms/graphAtoms.ts | 38 +- src/atoms/graphOps.ts | 124 ++++ src/commands/Command.ts | 31 - src/commands/CommandModal.ts | 28 - src/commands/deleteNote.ts | 12 - src/commands/showShortestPath.ts | 59 -- src/config.ts | 4 +- src/graph/Graph.ts | 11 +- src/graph/Link.ts | 1 + src/graph/Node.ts | 12 +- src/graph/NodeGroup.ts | 26 - src/main.ts | 172 ++++- src/typings/force-graph.d.ts | 1 + src/typings/obsidian-ex.d.ts | 373 ++++------- src/util/LifeCycle.ts | 11 - src/views/ReactForceGraphView.tsx | 33 +- src/views/SettingTab.ts | 133 +++- src/views/atomics/TreeItem.ts | 51 -- src/views/atomics/addColorPicker.ts | 22 - src/views/atomics/addColorPickerSetting.ts | 20 - src/views/atomics/addPicker.ts | 18 - src/views/atomics/addSearchInput.ts | 149 ----- src/views/atomics/addSimpleSliderSetting.ts | 46 -- src/views/atomics/addTextInput.ts | 12 - src/views/atomics/addToggle.ts | 23 - src/views/graph/Drawing.ts | 46 +- src/views/graph/GraphControls.css | 57 ++ src/views/graph/GraphControls.tsx | 100 +++ src/views/graph/ReactForceGraph.tsx | 620 ++++++++++++++---- src/views/graph/ReactForceGraphEngine.ts | 80 --- src/views/graph/fileGraphMethods.ts | 20 +- src/views/leafView.ts | 603 ----------------- .../categories/AddGroupSettingItem.ts | 80 --- .../settings/categories/AddNodeGroupButton.ts | 50 -- .../categories/DisplaySettingsView.ts | 248 ------- .../settings/categories/FilterSettingsView.ts | 116 ---- .../categories/SaveSettingGroupItem.ts | 82 --- .../categories/UtilitySettingsView.ts | 31 - .../settings/categories/addNodeGroups.ts | 19 - .../settings/categories/getMySwitcher.ts | 36 - styles.css | 176 ++--- 57 files changed, 1448 insertions(+), 2829 deletions(-) create mode 100644 data.json delete mode 100644 src/BasicSearchEngine.ts delete mode 100644 src/DvSearchEngine.ts delete mode 100644 src/FileManager.ts delete mode 100644 src/PassiveSearchEngine.ts delete mode 100644 src/commands/Command.ts delete mode 100644 src/commands/CommandModal.ts delete mode 100644 src/commands/deleteNote.ts delete mode 100644 src/commands/showShortestPath.ts delete mode 100644 src/graph/NodeGroup.ts delete mode 100644 src/util/LifeCycle.ts delete mode 100644 src/views/atomics/TreeItem.ts delete mode 100644 src/views/atomics/addColorPicker.ts delete mode 100644 src/views/atomics/addColorPickerSetting.ts delete mode 100644 src/views/atomics/addPicker.ts delete mode 100644 src/views/atomics/addSearchInput.ts delete mode 100644 src/views/atomics/addSimpleSliderSetting.ts delete mode 100644 src/views/atomics/addTextInput.ts delete mode 100644 src/views/atomics/addToggle.ts create mode 100644 src/views/graph/GraphControls.css create mode 100644 src/views/graph/GraphControls.tsx delete mode 100644 src/views/graph/ReactForceGraphEngine.ts delete mode 100644 src/views/leafView.ts delete mode 100644 src/views/settings/categories/AddGroupSettingItem.ts delete mode 100644 src/views/settings/categories/AddNodeGroupButton.ts delete mode 100644 src/views/settings/categories/DisplaySettingsView.ts delete mode 100644 src/views/settings/categories/FilterSettingsView.ts delete mode 100644 src/views/settings/categories/SaveSettingGroupItem.ts delete mode 100644 src/views/settings/categories/UtilitySettingsView.ts delete mode 100644 src/views/settings/categories/addNodeGroups.ts delete mode 100644 src/views/settings/categories/getMySwitcher.ts diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index e5ec5f6..11690b5 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,2 @@ -github: [hananoshikayomaru] -custom: https://www.buymeacoffee.com/yomaru +github: [nn-ninja] +custom: https://buymeacoffee.com/nbrace diff --git a/.gitignore b/.gitignore index d99fdb7..f4c68df 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.husky/pre-push b/.husky/pre-push index ca191e4..a39671c 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -4,3 +4,5 @@ bun run typecheck bun run lint bun run build +npm run lint +npm run lint diff --git a/README.md b/README.md index 729c664..cccc2c1 100644 --- a/README.md +++ b/README.md @@ -1,131 +1,41 @@ -# Obsidian BraiNavigator Plugin +# Obsidian N-brace Plugin -![Obsidian 3D graph](https://github.com/HananoshikaYomaru/obsidian-brainavigator/assets/43137033/c8a501e8-c5b6-4622-b5df-2a2335609cae) +![Obsidian N-brace Plugin](https://github.com/HananoshikaYomaru/obsidian-brainavigator/assets/43137033/c8a501e8-c5b6-4622-b5df-2a2335609cae) -A 3D Graph for Obsidian with dozen of features! +A Graph navigation and visualization for Obsidian. -see the demo: +see the demo: -Buy Me A Coffee [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/hananoshikayomaru) +linki do sponsorowania ## Installation ### Through community plugin store -You can install this on community plugin store 😎 +You can install this on community plugin store ### Manual installation 1. cd to `.obsidian/plugins` 2. git clone this repo -3. `cd obsidian-brainavigator && bun install && bun run build` +3. `cd obsidian-n-brace && bun install && bun run build` 4. there you go 🎉 ## Features ### Local Graph -In a note, you can run command `Open local 3D graph` to open a local graph. A local graph will only show nodes that connect to this nodes. - -In local graph you will have all the features of global graph, plus: - -1. depth: you can control the depth of maximum distance of the nodes away from the center node -2. link type: show only inlinks, outlinks or both. - -> ✨ Tips: I set the `cmd + L` to open local 3D graph. - -### Not present Global Graph -(as I feel it to be useless) - -### Filter Node on global graph - -1. filter by query -2. filter attachment - 1. definition of attachment: any files which are not markdown files -3. filter orphans - 1. definition of orphans: any node that has no links IN THE CURRENT GRAPH - -### Group and color nodes on global graph - -you can use query to create groups and color nodes on a global graph. - -### Label fade - -When you are closer to the node, the label will appear. When you move away from the node, the node will fade away. - -### Changing display setting - -You can change the following: - -1. Node size - 1. Node size also scale with degree of connection. The more links a node has, the bigger it is. -2. Link thickness -3. Link distance -4. Node repulsion -5. Distance from Focal. This will affect the label fade. When distance from focal is smaller, node further away from what you are look at will have label with lower opacity. -6. node hover color, node hover neighbour color, link hover color -7. show file extension, show full path on label -8. show center coordination -9. show link arrow -10. don't node move on drag -11. [dag](https://en.wikipedia.org/wiki/Directed_acyclic_graph) mode. See more on [DAG Mode](https://github.com/HananoshikaYomaru/obsidian-3d--graph#dag-mode). -12. embedding graph into note through `3d--graph` code block post processor (working in progress) - -> [!CAUTION] The `force-graph` code block post processor is still in working progress. -> It is definitely not stable yet, use it in your own risk. - -### Focus on node - -hold `Ctrl`/`cmd` and click on a node will fly and focus on a node. It is the perfect way to navigate on large graph. - -### Search and focus - -You can search and focus on a node in the graph - -### Multiple selection and batch commands - -hold `shift` and click on nodes to select multiple nodes. Then right click on one of the selected nodes to open commands. You can run batched commands on the selected nodes. - -### DAG mode - -You can see DAG(Directed acyclic graph) orientation on a graph. This only has effect when the graph is acyclic. +In a note tab context menu, you can click on `N-brace` to open a local graph of viewed note. ### Save Setting -You can save, update and restore previous settings. +You can update and save your general settings. -### Other customization +## Acknowledgement -You can change the style of the graphview by css snippet. +I want to especially thank to the creators of this repos: +1. Force Graph repo: +2. The original 3D graph plugin: +3. The improved version: +Their work was very helpful in getting my project off the ground. -1. open your setting > appearance -2. create a css snippet -3. add the following - -```css -body { - /* change the default node color */ - --graph-node: #00ff00; - /* change the default link color */ - --graph-line: #ff0000; -} - -.force-graph-view canvas { - /* change the background color */ - background: white; -} - -.force-graph-view .node-label { - /* change the default node label color */ - color: #00ff00; -} -``` - -![](https://share.cleanshot.com/Ld9xzBJ4+) - -### Feature roadmap - -1. Use dataview as search engine. -2. expose API to make it even more easier for other plugin to integrate. - -some other uncertain features are will sit in the github issues but I work on them base on ICE (Impact, confidence, effort) diff --git a/bun.lockb b/bun.lockb index 6fbee2b34fdad939c77d39c0fe47572c5d8ca4cc..a74fdd942731cabd844a5bd160d5102614938e93 100755 GIT binary patch delta 43608 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%0A8V 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 YkRr9s%#lTsitF$+XW}LLKP}e$KOovA)Bpeg diff --git a/data.json b/data.json new file mode 100644 index 0000000..cd341a3 --- /dev/null +++ b/data.json @@ -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" + } +} \ No newline at end of file diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 18ee13f..aa165aa 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -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) { diff --git a/manifest.json b/manifest.json index fd8f0af..2f4d95e 100644 --- a/manifest.json +++ b/manifest.json @@ -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 -} \ No newline at end of file +} diff --git a/package.json b/package.json index 15b480e..0e7285a 100644 --- a/package.json +++ b/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", diff --git a/src/BasicSearchEngine.ts b/src/BasicSearchEngine.ts deleted file mode 100644 index 1620641..0000000 --- a/src/BasicSearchEngine.ts +++ /dev/null @@ -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."); - } -} diff --git a/src/DvSearchEngine.ts b/src/DvSearchEngine.ts deleted file mode 100644 index e729124..0000000 --- a/src/DvSearchEngine.ts +++ /dev/null @@ -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."); - } -} diff --git a/src/FileManager.ts b/src/FileManager.ts deleted file mode 100644 index 1856d5d..0000000 --- a/src/FileManager.ts +++ /dev/null @@ -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"); - } -} diff --git a/src/Interfaces.ts b/src/Interfaces.ts index a45e87e..769f053 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -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 { /** @@ -36,57 +22,3 @@ export interface ISettingManager { */ loadSettings(): Promise; } - -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[]; -} diff --git a/src/PassiveSearchEngine.ts b/src/PassiveSearchEngine.ts deleted file mode 100644 index 8e3fc29..0000000 --- a/src/PassiveSearchEngine.ts +++ /dev/null @@ -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[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, - }); - } -} diff --git a/src/SettingManager.ts b/src/SettingManager.ts index b38c77c..b3e07ff 100644 --- a/src/SettingManager.ts +++ b/src/SettingManager.ts @@ -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 { // 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 { 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 { } 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, }, diff --git a/src/SettingsSchemas.ts b/src/SettingsSchemas.ts index a0e6a58..d277076 100644 --- a/src/SettingsSchemas.ts +++ b/src/SettingsSchemas.ts @@ -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), }), diff --git a/src/atoms/graphAtoms.ts b/src/atoms/graphAtoms.ts index 758b94a..5bc31a6 100644 --- a/src/atoms/graphAtoms.ts +++ b/src/atoms/graphAtoms.ts @@ -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({ selectedPath: undefined }); +export type GraphSettings = { + graphSpan: number; + linkColorIn: string; + linkColorOut: string; + linkColorOther: string; +}; + +export type NavHistory = { + backward: Record; + forward: Record; +}; + +export type NavIndexHistory = { + backward: Record; + forward: Record; +}; + +export const graphNavAtom = atomWithReset( + { selectedPath: undefined, selectedIndex: undefined }); + +export const nodeIdxMaxAtom = atom(0); + +export const graphSettingsAtom = atom({ + graphSpan: DEFAULT_SETTING.pluginSetting.defaultGraphSpan, + linkColorIn: DEFAULT_SETTING.pluginSetting.linkColorIn, + linkColorOut: DEFAULT_SETTING.pluginSetting.linkColorOut, + linkColorOther: DEFAULT_SETTING.pluginSetting.linkColorOther, +}); + +export const navHistoryAtom = atom({ backward: {}, forward: {} }); + +export const navIndexHistoryAtom = atomWithReset({ backward: {}, forward: {} }); + +/** Temporal state turning into selected node. */ +export const expandNodePathAtom = atom(undefined); export const nodesAtom = atom([]); export const linksAtom = atom([]); diff --git a/src/atoms/graphOps.ts b/src/atoms/graphOps.ts index 8b13789..d653c4f 100644 --- a/src/atoms/graphOps.ts +++ b/src/atoms/graphOps.ts @@ -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 = (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(); + 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; +}; diff --git a/src/commands/Command.ts b/src/commands/Command.ts deleted file mode 100644 index 64c23a4..0000000 --- a/src/commands/Command.ts +++ /dev/null @@ -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) => boolean; - function: (view: BaseForceGraphView, nodes: Set) => 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, - }, -]; diff --git a/src/commands/CommandModal.ts b/src/commands/CommandModal.ts deleted file mode 100644 index 78bda9a..0000000 --- a/src/commands/CommandModal.ts +++ /dev/null @@ -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 { - private nodes: Set; - private view: BaseForceGraphView; - - constructor(view: BaseForceGraphView, selectedNodes: Set) { - 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); - } -} diff --git a/src/commands/deleteNote.ts b/src/commands/deleteNote.ts deleted file mode 100644 index d463f24..0000000 --- a/src/commands/deleteNote.ts +++ /dev/null @@ -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) => { - 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"); - } - } -}; diff --git a/src/commands/showShortestPath.ts b/src/commands/showShortestPath.ts deleted file mode 100644 index b5c5d53..0000000 --- a/src/commands/showShortestPath.ts +++ /dev/null @@ -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(); - 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) => { - 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); -}; diff --git a/src/config.ts b/src/config.ts index 9bf07a6..4533dba 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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", }, }; diff --git a/src/graph/Graph.ts b/src/graph/Graph.ts index 8cb7c49..8f4bdaf 100644 --- a/src/graph/Graph.ts +++ b/src/graph/Graph.ts @@ -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>; constructor( + rootPath: string | undefined, nodes: Node[], links: Link[], nodeIndex: Map, linkIndex: Map> ) { + this.rootPath = rootPath; this.nodes = nodes; this.links = links; this.nodeIndex = nodeIndex || new Map(); @@ -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)); } /** diff --git a/src/graph/Link.ts b/src/graph/Link.ts index 246b201..e61d8d2 100644 --- a/src/graph/Link.ts +++ b/src/graph/Link.ts @@ -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; diff --git a/src/graph/Node.ts b/src/graph/Node.ts index fd31518..ebcb1e8 100644 --- a/src/graph/Node.ts +++ b/src/graph/Node.ts @@ -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; }; diff --git a/src/graph/NodeGroup.ts b/src/graph/NodeGroup.ts deleted file mode 100644 index 3ccc332..0000000 --- a/src/graph/NodeGroup.ts +++ /dev/null @@ -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; - } -} diff --git a/src/main.ts b/src/main.ts index da06b1d..6a31987 100644 --- a/src/main.ts +++ b/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); + } } diff --git a/src/typings/force-graph.d.ts b/src/typings/force-graph.d.ts index e946cf2..8f8fab7 100644 --- a/src/typings/force-graph.d.ts +++ b/src/typings/force-graph.d.ts @@ -7,5 +7,6 @@ type Coords = { }; type NodeData = { + index: number; nodeDims: number[]; } diff --git a/src/typings/obsidian-ex.d.ts b/src/typings/obsidian-ex.d.ts index 7a5e754..a2ebcb7 100644 --- a/src/typings/obsidian-ex.d.ts +++ b/src/typings/obsidian-ex.d.ts @@ -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 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 }; + leftRibbon: {hiddenItems: Record}; /** * 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; + getAllPropertyInfos: () => Record /** * Get all backlink information for a file */ - getBacklinksForFile: (file?: TFile) => CustomArrayDict; + getBacklinksForFile: (file?: TFile) => CustomArrayDict /** * 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; + createNewFile: (location: TFolder = null, filename: string = null, extension: string = "md", contents: string = "") => Promise; /** * 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; + createNewMarkdownFile: (location: TFolder = null, filename: string = null, contents: string = "") => Promise; /** * 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; + insertTextIntoFile: (file: TFile, primary_text: string, basename: string, secondary_text: string, atStart: boolean = true) => Promise; /** * 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; + watchers: Record; /** * @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; - suggests: EditorSuggest[]; - }; + editorSuggest: {currentSuggest?: EditorSuggest, suggests: EditorSuggest[]}; /** * @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; - 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; } + + // 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; } diff --git a/src/util/LifeCycle.ts b/src/util/LifeCycle.ts deleted file mode 100644 index 1352f44..0000000 --- a/src/util/LifeCycle.ts +++ /dev/null @@ -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; -} diff --git a/src/views/ReactForceGraphView.tsx b/src/views/ReactForceGraphView.tsx index c9400ef..221e31b 100644 --- a/src/views/ReactForceGraphView.tsx +++ b/src/views/ReactForceGraphView.tsx @@ -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(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 { - console.info("node to expand ", node); + protected async expandNode(nodePath: string | undefined): Promise { + 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 { - return this.expandNode(this.currentFile); + return this.expandNode(this.plugin.app.workspace.getActiveFile()?.path ?? undefined); } async onClose() { diff --git a/src/views/SettingTab.ts b/src/views/SettingTab.ts index aea5de3..5c9ce65 100644 --- a/src/views/SettingTab.ts +++ b/src/views/SettingTab.ts @@ -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, + }; + } } diff --git a/src/views/atomics/TreeItem.ts b/src/views/atomics/TreeItem.ts deleted file mode 100644 index 8a804e3..0000000 --- a/src/views/atomics/TreeItem.ts +++ /dev/null @@ -1,51 +0,0 @@ -export type HtmlBuilder = (containerEl: HTMLElement) => void | PromiseLike; - -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); - } -} diff --git a/src/views/atomics/addColorPicker.ts b/src/views/atomics/addColorPicker.ts deleted file mode 100644 index 53e7401..0000000 --- a/src/views/atomics/addColorPicker.ts +++ /dev/null @@ -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); -}; diff --git a/src/views/atomics/addColorPickerSetting.ts b/src/views/atomics/addColorPickerSetting.ts deleted file mode 100644 index f5f879b..0000000 --- a/src/views/atomics/addColorPickerSetting.ts +++ /dev/null @@ -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)); -}; diff --git a/src/views/atomics/addPicker.ts b/src/views/atomics/addPicker.ts deleted file mode 100644 index 26f6528..0000000 --- a/src/views/atomics/addPicker.ts +++ /dev/null @@ -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); -}; diff --git a/src/views/atomics/addSearchInput.ts b/src/views/atomics/addSearchInput.ts deleted file mode 100644 index ebcdb95..0000000 --- a/src/views/atomics/addSearchInput.ts +++ /dev/null @@ -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) => { - // @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 }; -}; diff --git a/src/views/atomics/addSimpleSliderSetting.ts b/src/views/atomics/addSimpleSliderSetting.ts deleted file mode 100644 index 83c730f..0000000 --- a/src/views/atomics/addSimpleSliderSetting.ts +++ /dev/null @@ -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, -}; diff --git a/src/views/atomics/addTextInput.ts b/src/views/atomics/addTextInput.ts deleted file mode 100644 index 6e7f205..0000000 --- a/src/views/atomics/addTextInput.ts +++ /dev/null @@ -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); -}; diff --git a/src/views/atomics/addToggle.ts b/src/views/atomics/addToggle.ts deleted file mode 100644 index 59bea5b..0000000 --- a/src/views/atomics/addToggle.ts +++ /dev/null @@ -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; -}; diff --git a/src/views/graph/Drawing.ts b/src/views/graph/Drawing.ts index 5bdf92a..de7097c 100644 --- a/src/views/graph/Drawing.ts +++ b/src/views/graph/Drawing.ts @@ -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, diff --git a/src/views/graph/GraphControls.css b/src/views/graph/GraphControls.css new file mode 100644 index 0000000..ea0305f --- /dev/null +++ b/src/views/graph/GraphControls.css @@ -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); +} diff --git a/src/views/graph/GraphControls.tsx b/src/views/graph/GraphControls.tsx new file mode 100644 index 0000000..0696685 --- /dev/null +++ b/src/views/graph/GraphControls.tsx @@ -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 = ({ + 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) => { + 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 && ( +

+ + +
+ )} +
+ + + {!isHidden && ( + <> + + + + + + )} +
+ + ); +}; diff --git a/src/views/graph/ReactForceGraph.tsx b/src/views/graph/ReactForceGraph.tsx index 3b15eca..e9205f0 100644 --- a/src/views/graph/ReactForceGraph.tsx +++ b/src/views/graph/ReactForceGraph.tsx @@ -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; - getExpandNode: () => Promise; + getExpandNode: (nodePath: string | undefined) => Promise; titleFontSize: number; } @@ -28,20 +39,58 @@ const ReactForceGraph: React.FC = ({ getExpandNode, titleFontSize, }) => { - const isFirstRun = useRef(true); const fgRef = useRef(undefined); + const containerRef = useRef(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 = ({ }, [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(); + 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 = ({ }); }, [selectedNode]); - function updateAttributeDeep( - 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(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(); + 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 = ({ 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 = ({ } }; - return ( - 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 ( +
+ 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) => } + /> + +
); }; diff --git a/src/views/graph/ReactForceGraphEngine.ts b/src/views/graph/ReactForceGraphEngine.ts deleted file mode 100644 index 14f7ed2..0000000 --- a/src/views/graph/ReactForceGraphEngine.ts +++ /dev/null @@ -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 | undefined } = {}; -// private spaceDown = false; -// private commandDown = false; -// private selectedNodes = new Set(); -// /** -// * the node connected to the hover node -// */ -// public readonly highlightedNodes: Set = new Set(); -// /** -// * the links connected to the hover node -// */ -// public readonly highlightedLinks: Set = 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); -// }; -// } diff --git a/src/views/graph/fileGraphMethods.ts b/src/views/graph/fileGraphMethods.ts index 95d0c4a..2ff69e1 100644 --- a/src/views/graph/fileGraphMethods.ts +++ b/src/views/graph/fileGraphMethods.ts @@ -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(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) => { diff --git a/src/views/leafView.ts b/src/views/leafView.ts deleted file mode 100644 index 7bfc96f..0000000 --- a/src/views/leafView.ts +++ /dev/null @@ -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(); -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(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; - } -} diff --git a/src/views/settings/categories/AddGroupSettingItem.ts b/src/views/settings/categories/AddGroupSettingItem.ts deleted file mode 100644 index 82e22ed..0000000 --- a/src/views/settings/categories/AddGroupSettingItem.ts +++ /dev/null @@ -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>[] -) => { - // 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, - }; -}; diff --git a/src/views/settings/categories/AddNodeGroupButton.ts b/src/views/settings/categories/AddNodeGroupButton.ts deleted file mode 100644 index a442e93..0000000 --- a/src/views/settings/categories/AddNodeGroupButton.ts +++ /dev/null @@ -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>[] -) => { - // 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>[] } = { - 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; -}; diff --git a/src/views/settings/categories/DisplaySettingsView.ts b/src/views/settings/categories/DisplaySettingsView.ts deleted file mode 100644 index a4e0439..0000000 --- a/src/views/settings/categories/DisplaySettingsView.ts +++ /dev/null @@ -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) => { - 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) => { - 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) => { - 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, - }; -}; diff --git a/src/views/settings/categories/FilterSettingsView.ts b/src/views/settings/categories/FilterSettingsView.ts deleted file mode 100644 index d7e62ea..0000000 --- a/src/views/settings/categories/FilterSettingsView.ts +++ /dev/null @@ -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) => { - 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) => { - 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, - }; -}; diff --git a/src/views/settings/categories/SaveSettingGroupItem.ts b/src/views/settings/categories/SaveSettingGroupItem.ts deleted file mode 100644 index c364e2a..0000000 --- a/src/views/settings/categories/SaveSettingGroupItem.ts +++ /dev/null @@ -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"; -}; diff --git a/src/views/settings/categories/UtilitySettingsView.ts b/src/views/settings/categories/UtilitySettingsView.ts deleted file mode 100644 index 7e45652..0000000 --- a/src/views/settings/categories/UtilitySettingsView.ts +++ /dev/null @@ -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(); - }); -}; diff --git a/src/views/settings/categories/addNodeGroups.ts b/src/views/settings/categories/addNodeGroups.ts deleted file mode 100644 index a9d4d75..0000000 --- a/src/views/settings/categories/addNodeGroups.ts +++ /dev/null @@ -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>[] -) => { - 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); - }); -}; diff --git a/src/views/settings/categories/getMySwitcher.ts b/src/views/settings/categories/getMySwitcher.ts deleted file mode 100644 index 34e0519..0000000 --- a/src/views/settings/categories/getMySwitcher.ts +++ /dev/null @@ -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; -}; diff --git a/styles.css b/styles.css index 9c936e2..10f7e20 100644 --- a/styles.css +++ b/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== */