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 6fbee2b..a74fdd9 100755 Binary files a/bun.lockb and b/bun.lockb differ 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== */