diff --git a/README.md b/README.md index bda7f56..cfdecd9 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,41 @@ ## Obsidian 3D Graph (Yomaru) > This is a fork from the original https://github.com/AlexW00/obsidian-3d-graph -> -> ⚠️ This plugin is still under early development. Please open github issue if you have problems. +> +> ⚠️ This plugin is still under early development. Please open github issue if you have problems. A 3D Graph for Obsidian! -## Installation +see the demo: https://www.youtube.com/watch?v=HnqXH6z4WrY -### through BRAT +## Installation -### Manual installation +### through BRAT -## Showcase: +1. install the BRAT plugin +2. go to the plugin option, add beta plugin, copy and paste the link of this repo +3. the plugin will automatically appear in the list of installed community plugins, enabled this plugin -https://www.youtube.com/watch?v=HnqXH6z4WrY +### Manual installation +1. cd to `.obsidian/plugins` +2. git clone this repo +3. `cd obsidian-3d-graph && bun install && bun run build` +4. there you go 🎉 +## Development + +1. cd to `.obsidian/plugins` +2. git clone this repo +3. `cd obsidian-3d-graph && bun install && bun run dev` +4. there you go 🎉 + +for release, just run `bun release` + +## Note + +## Say thank you + +If you are enjoying this plugin then please support my work and enthusiasm by buying me a coffee on https://www.buymeacoffee.com/yomaru. + +Buy Me A Coffee diff --git a/bun.lockb b/bun.lockb index a32d936..d8ed427 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 5b5ed7d..bad091a 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "3d-force-graph": "^1.72.3", "d3": "^7.8.5", "observable-slim": "^0.1.6", - "three": "^0.157.0" + "three": "^0.157.0", + "three-spritetext": "^1.8.1" } } diff --git a/src/typings/types-obsidian.d.ts b/src/typings/types-obsidian.d.ts new file mode 100644 index 0000000..b899bc0 --- /dev/null +++ b/src/typings/types-obsidian.d.ts @@ -0,0 +1,298 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import "obsidian"; +import { EphemeralState, SuggestModal, TFile, View, WorkspaceLeaf } from "obsidian"; +import { EmbeddedViewParent } from "@/views/leafView"; + +interface InternalPlugins { + switcher: QuickSwitcherPlugin; + "page-preview": InternalPlugin; + graph: GraphPlugin; + "global-search": GlobalSearchPlugin; +} + +interface GlobalSearchPlugin extends Plugin { + instance: { + getGlobalSearchQuery: () => string; + openGlobalSearch: (query: string) => void; + }; +} + +interface OpenViewState { + eState?: EphemeralState; + state?: { mode: string }; + active?: boolean; +} + +declare class QuickSwitcherModal extends SuggestModal { + getSuggestions(query: string): TFile[] | Promise; + + renderSuggestion(value: TFile, el: HTMLElement): unknown; + + onChooseSuggestion(item: TFile, evt: MouseEvent | KeyboardEvent): unknown; +} + +interface InternalPlugin { + disable(): void; + + enable(): void; + + enabled: boolean; + _loaded: boolean; + instance: { name: string; id: string }; +} + +interface GraphPlugin extends InternalPlugin { + views: { localgraph: (leaf: WorkspaceLeaf) => GraphView }; +} + +interface GraphView extends View { + engine: typeof Object; + renderer: { worker: { terminate(): void } }; +} + +interface QuickSwitcherPlugin extends InternalPlugin { + instance: { + name: string; + id: string; + QuickSwitcherModal: typeof QuickSwitcherModal; + }; +} + +declare global { + const i18next: { + t(id: string): string; + }; + + interface Window { + activeWindow: Window; + activeDocument: Document; + } +} + +declare module "obsidian" { + interface App { + commands: { + listCommands(): Command[]; + findCommand(id: string): Command; + removeCommand(id: string): void; + executeCommandById(id: string): void; + commands: Record; + }; + internalPlugins: { + plugins: InternalPlugins; + getPluginById(id: T): InternalPlugins[T]; + }; + plugins: { + manifests: Record; + plugins: Record & { + ["recent-files-obsidian"]: Plugin & { + shouldAddFile(file: TFile): boolean; + }; + }; + getPlugin(id: string): Plugin; + getPlugin(id: "calendar"): CalendarPlugin; + }; + dom: { appContainerEl: HTMLElement }; + viewRegistry: ViewRegistry; + + openWithDefaultApp(path: string): void; + } + + interface ViewRegistry { + typeByExtension: Record; // file extensions to view types + viewByType: Record View>; // file extensions to view types + } + + interface CalendarPlugin { + view: View; + } + + interface WorkspaceParent { + insertChild(index: number, child: WorkspaceItem, resize?: boolean): void; + + replaceChild(index: number, child: WorkspaceItem, resize?: boolean): void; + + removeChild(leaf: WorkspaceLeaf, resize?: boolean): void; + + containerEl: HTMLElement; + children: any; + } + + interface MarkdownEditView { + editorEl: HTMLElement; + } + + class MarkdownPreviewRendererStatic extends MarkdownPreviewRenderer { + static registerDomEvents( + el: HTMLElement, + handlerInstance: unknown, + cb: (el: HTMLElement) => unknown + ): void; + } + + interface WorkspaceLeaf { + openLinkText(linkText: string, path: string, state?: unknown): Promise; + + updateHeader(): void; + + containerEl: HTMLDivElement; + working: boolean; + parentSplit: WorkspaceParent; + activeTime: number; + } + + interface Workspace { + recordHistory(leaf: WorkspaceLeaf, pushHistory: boolean): void; + + iterateLeaves( + callback: (item: WorkspaceLeaf) => boolean | void, + item: WorkspaceItem | WorkspaceItem[] + ): boolean; + + iterateLeaves( + item: WorkspaceItem | WorkspaceItem[], + callback: (item: WorkspaceLeaf) => boolean | void + ): boolean; + + getDropLocation(event: MouseEvent): { + target: WorkspaceItem; + sidedock: boolean; + }; + + recursiveGetTarget(event: MouseEvent, parent: WorkspaceParent): WorkspaceItem; + + recordMostRecentOpenedFile(file: TFile): void; + + onDragLeaf(event: MouseEvent, leaf: WorkspaceLeaf): void; + + onLayoutChange(): void; // tell Obsidian leaves have been added/removed/etc. + activeLeafEvents(): void; + + floatingSplit: any; + + pushUndoHistory(leaf: WorkspaceLeaf, id: string, e: any): void; + } + + interface Editor { + getClickableTokenAt(pos: EditorPosition): { + text: string; + type: string; + start: EditorPosition; + end: EditorPosition; + }; + } + + interface WorkspaceItem { + side?: "left" | "right"; + } + + interface View { + iconEl: HTMLElement; + file: TFile; + + setMode(mode: MarkdownSubView): Promise; + + followLinkUnderCursor(newLeaf: boolean): void; + + modes: Record; + + getMode(): string; + + headerEl: HTMLElement; + contentEl: HTMLElement; + } + + interface SearchView extends View { + onKeyArrowRightInFocus(e: KeyboardEvent): void; + + onKeyArrowLeftInFocus(e: KeyboardEvent): void; + + onKeyArrowUpInFocus(e: KeyboardEvent): void; + + onKeyArrowDownInFocus(e: KeyboardEvent): void; + + onKeyEnterInFocus(e: KeyboardEvent): void; + + onKeyShowMoreBefore(e: KeyboardEvent): void; + + onKeyShowMoreAfter(e: KeyboardEvent): void; + + dom: any; + searchComponent: SearchComponent; + headerDom: any; + } + + interface EmptyView extends View { + actionListEl: HTMLElement; + emptyTitleEl: HTMLElement; + } + + interface FileManager { + createNewMarkdownFile(folder: TFolder, fileName: string): Promise; + } + + enum PopoverState { + Showing, + Shown, + Hiding, + Hidden, + } + + interface Menu { + items: MenuItem[]; + dom: HTMLElement; + hideCallback: () => unknown; + } + + interface MenuItem { + iconEl: HTMLElement; + dom: HTMLElement; + } + + interface EphemeralState { + focus?: boolean; + subpath?: string; + line?: number; + startLoc?: Loc; + endLoc?: Loc; + scroll?: number; + } + + interface HoverParent { + type?: string; + } + + interface WorkspaceContainer { + type: "window" | "split" | "tab"; + } + + interface HoverPopover { + parent: EmbeddedViewParent | null; + targetEl: HTMLElement; + hoverEl: HTMLElement; + + position(pos?: MousePos): void; + + hide(): void; + + show(): void; + + shouldShowSelf(): boolean; + + timer: number; + waitTime: number; + + shouldShow(): boolean; + + transition(): void; + } + + interface MousePos { + x: number; + y: number; + } + + interface Plugin { + registerGlobalCommand(command: Command): void; + } +} diff --git a/src/util/State.ts b/src/util/State.ts index f74e675..c5844fb 100644 --- a/src/util/State.ts +++ b/src/util/State.ts @@ -76,7 +76,8 @@ export class State { }, this as Record) as S; if (typeof subStateValue === "object") { // check if is like generic type S - if (subStateValue instanceof type) { + // @ts-ignore + if (subStateValue instanceof type || Boolean(subStateValue?.__getTarget)) { // @ts-ignore return new State(subStateValue.__getTarget); } else { diff --git a/src/views/graph/ForceGraph.ts b/src/views/graph/ForceGraph.ts index 735beeb..3f42b74 100644 --- a/src/views/graph/ForceGraph.ts +++ b/src/views/graph/ForceGraph.ts @@ -11,11 +11,15 @@ import { eventBus } from "@/util/EventBus"; import { GraphSettings } from "@/settings/GraphSettings"; import { DagOrientation } from "@/settings/categories/DisplaySettings"; import * as d3 from "d3"; +import * as THREE from "three"; const LINK_PARTICLE_MULTIPLIER = 2; const LINK_ARROW_WIDTH_MULTIPLIER = 5; const PARTICLE_FREQUECY = 4; +const FOCAL_FROM_CAMERA = 400; +const DISTANCE_FROM_FOCAL = 300; + // Adapted from https://github.com/vasturiano/3d-force-graph/blob/master/example/highlight/index.html // D3.js 3D Force Graph @@ -36,13 +40,15 @@ export class ForceGraph { private readonly isLocalGraph: boolean; private graph: Graph; private readonly plugin: Graph3dPlugin; + private myCube: THREE.Mesh; + private nodeLabelEl: HTMLDivElement; constructor(plugin: Graph3dPlugin, rootHtmlElement: HTMLElement, isLocalGraph: boolean) { this.rootHtmlElement = rootHtmlElement; this.isLocalGraph = isLocalGraph; this.plugin = plugin; - console.log("ForceGraph constructor", rootHtmlElement); + // console.log("ForceGraph constructor", rootHtmlElement); this.createGraph(); this.initListeners(); @@ -67,15 +73,68 @@ export class ForceGraph { } private createGraph() { + const xDir = new THREE.Vector3(1, 0, 0); + const yDir = new THREE.Vector3(0, 1, 0); + const zDir = new THREE.Vector3(0, 0, 1); + + const myCube = new THREE.Mesh( + new THREE.BoxGeometry(30, 30, 30), + new THREE.MeshBasicMaterial({ color: 0xff0000 }) + ); + this.myCube = myCube; + myCube.position.set(0, 0, -300); + myCube.visible = false; + + xDir.normalize(); + yDir.normalize(); + zDir.normalize(); + + const origin = new THREE.Vector3(0, 0, 0); + const length = 100; + + const xArrow = new THREE.ArrowHelper(xDir, origin, length, 0xff0000); + const yArrow = new THREE.ArrowHelper(yDir, origin, length, 0x00ff00); + const zArrow = new THREE.ArrowHelper(zDir, origin, length, 0x0000ff); + this.createInstance(); this.createNodes(); this.createLinks(); - // this.updateForce(settings.display.dagOrientation, settings.display.nodeRepulsion); + this.instance.scene().add(xArrow).add(yArrow).add(zArrow).add(myCube); + const camera = this.instance.camera() as THREE.PerspectiveCamera; + const renderer = this.instance.renderer(); + function onZoom(event: WheelEvent) { + // const delta = event.deltaY; + // const zoomSpeed = 0.1; + const distanceToCenter = camera.position.distanceTo(origin); + // console.log(camera.position, distanceToCenter); + camera.updateProjectionMatrix(); + xArrow.setLength(distanceToCenter / 10); + yArrow.setLength(distanceToCenter / 10); + zArrow.setLength(distanceToCenter / 10); + } + + renderer.domElement.addEventListener("wheel", onZoom); + + this.instance.scene().onBeforeRender = (renderer, scene, camera, geometry, material, group) => { + const cwd = new THREE.Vector3(); + camera.getWorldDirection(cwd); + cwd.multiplyScalar(FOCAL_FROM_CAMERA); + cwd.add(camera.position); + myCube.position.set(cwd.x, cwd.y, cwd.z); + myCube.setRotationFromQuaternion(camera.quaternion); + }; } private createInstance() { const [width, height] = [this.rootHtmlElement.innerWidth, this.rootHtmlElement.innerHeight]; const divEl = document.createElement("div"); + const nodeLabelEl = divEl.createDiv({ + cls: "node-label", + text: "test", + }); + nodeLabelEl.style.opacity = "0"; + this.nodeLabelEl = nodeLabelEl; + // set the divEl to have z-index 0 divEl.style.zIndex = "0"; const settings = this.plugin.getSettings(); @@ -88,9 +147,7 @@ export class ForceGraph { ], })(this.rootHtmlElement) .graphData(this.getGraphData()) - // .nodeLabel( - // (node: Node) => `
${node.name}
` - // ) + // .nodeLabel((node: Node) => `
${node.name}
`) // @ts-ignore we need to return null or empty string because by default it will access the name of node, see https://github.com/vasturiano/3d-force-graph#node-styling .nodeLabel((node: Node) => null) .nodeRelSize(settings.display.nodeSize) @@ -106,28 +163,35 @@ export class ForceGraph { // .d3Force("y", d3.forceY().strength(0.1)); } - private createNodes = () => { + private getNodeLabelText = (node: Node) => { const settings = this.plugin.getSettings(); + const fullPath = node.path; + const fileNameWithExtension = node.name; + const fullPathWithoutExtension = fullPath.substring(0, fullPath.lastIndexOf(".")); + const fileNameWithoutExtension = fileNameWithExtension.substring( + 0, + fileNameWithExtension.lastIndexOf(".") + ); + const text = !settings.display.showExtension + ? settings.display.showFullPath + ? fullPathWithoutExtension + : fileNameWithoutExtension + : settings.display.showFullPath + ? fullPath + : fileNameWithExtension; + return text; + }; + + private createNodes = () => { this.instance .nodeColor((node: Node) => this.getNodeColor(node)) // .nodeVisibility(this.doShowNode) .onNodeHover(this.onNodeHover) .nodeThreeObject((node: Node) => { const nodeEl = document.createElement("div"); - const fullPath = node.path; - const fileNameWithExtension = node.name; - const fullPathWithoutExtension = fullPath.substring(0, fullPath.lastIndexOf(".")); - const fileNameWithoutExtension = fileNameWithExtension.substring( - 0, - fileNameWithExtension.lastIndexOf(".") - ); - nodeEl.textContent = !settings.display.showExtension - ? settings.display.showFullPath - ? fullPathWithoutExtension - : fileNameWithoutExtension - : settings.display.showFullPath - ? fullPath - : fileNameWithExtension; + + const text = this.getNodeLabelText(node); + nodeEl.textContent = text; // @ts-ignore nodeEl.style.color = node.color; nodeEl.className = "node-label"; @@ -137,7 +201,34 @@ export class ForceGraph { nodeEl.style.borderRadius = "4px"; nodeEl.style.backgroundColor = rgba(0, 0, 0, 0.5); nodeEl.style.userSelect = "none"; - return new CSS2DObject(nodeEl); + + // const sprite = new SpriteText(text); + // sprite.color = "#8898aa"; + // sprite.textHeight = 12; + // sprite.position.y = 8; + // sprite.material.depthWrite = false; + // sprite.visible + + // return sprite; + + const cssObject = new CSS2DObject(nodeEl); + cssObject.onAfterRender = (renderer, scene, camera) => { + // get the position of the node + // @ts-ignore + const obj = node.__threeObj as THREE.Object3D | undefined; + const nodePosition = obj?.position as THREE.Vector3; + // then get the distance between the node and this.myCube , console.log it + const distance = nodePosition.distanceTo(this.myCube.position); + // change the opacity of the nodeEl base on the distance + // the higher the distance, the lower the opacity + // when the distance is 300, the opacity is 0 + // console.log(distance); + const normalizedDistance = Math.min(distance, DISTANCE_FROM_FOCAL) / DISTANCE_FROM_FOCAL; + const easedValue = 0.5 - 0.5 * Math.cos(normalizedDistance * Math.PI); + nodeEl.style.opacity = `${1 - easedValue}`; + }; + + return cssObject; }) .nodeThreeObjectExtend(true); }; @@ -150,7 +241,6 @@ export class ForceGraph { ? settings.display.linkThickness * 1.5 : settings.display.linkThickness ) - // .linkVisibility(this.doShowLink) .linkDirectionalParticles((link: Link) => this.isHighlightedLink(link) ? PARTICLE_FREQUECY : 0 ) @@ -205,9 +295,9 @@ export class ForceGraph { const settings = this.plugin.getSettings(); if (this.isLocalGraph && this.plugin.openFileState.value) { this.graph = this.plugin.globalGraph.clone().getLocalGraph(this.plugin.openFileState.value); - console.log(this.graph); + // console.log(this.graph); } else { - console.log(settings.filters.searchResult); + // console.log(settings.filters.searchResult); this.graph = this.plugin.globalGraph.filter((node) => { return ( (settings.filters.searchResult.length === 0 || @@ -224,7 +314,7 @@ export class ForceGraph { }; private refreshGraphData = () => { - console.log("refresh graph data"); + // console.log("refresh graph data"); this.instance.graphData(this.getGraphData()); }; @@ -288,6 +378,17 @@ export class ForceGraph { private onNodeHover = (node: Node | null) => { if ((!node && !this.highlightedNodes.size) || (node && this.hoveredNode === node)) return; + // set node label text + if (node) { + const text = this.getNodeLabelText(node); + this.nodeLabelEl.textContent = text; + // @ts-ignore + this.nodeLabelEl.style.color = node.color; + this.nodeLabelEl.style.opacity = "1"; + } else { + this.nodeLabelEl.style.opacity = "0"; + } + this.clearHighlights(); if (node) { diff --git a/src/views/graph/Graph3dView.ts b/src/views/graph/Graph3dView.ts index cdd6c8c..92593b0 100644 --- a/src/views/graph/Graph3dView.ts +++ b/src/views/graph/Graph3dView.ts @@ -29,7 +29,7 @@ export class Graph3dView extends ItemView { const settings = new GraphSettingsView( this.plugin.settingsState, this.plugin.theme, - this.plugin.app + this.plugin ); viewContent.appendChild(settings); } else { diff --git a/src/views/leafView.ts b/src/views/leafView.ts new file mode 100644 index 0000000..2550f63 --- /dev/null +++ b/src/views/leafView.ts @@ -0,0 +1,598 @@ +/* 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 { + Component, + EphemeralState, + HoverPopover, + MarkdownEditView, + parseLinktext, + Plugin, + PopoverState, + requireApiVersion, + resolveSubpath, + TFile, + View, + Workspace, + WorkspaceLeaf, + 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; + + lockedOut: boolean; + abortController? = this.addChild(new Component()); + detaching = false; + opening = false; + + rootSplit: WorkspaceSplit = new (WorkspaceSplit as ConstructableWorkspaceSplit)( + window.app.workspace, + "vertical" + ); + + isPinned = true; + + 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); + // @ts-ignore + bounce?: NodeJS.Timeout; + boundOnZoomOut: () => void; + + originalPath: string; // these are kept to avoid adopting targets w/a different link + 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(); + // console.log(leaf); + 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/GraphSettingsView.ts b/src/views/settings/GraphSettingsView.ts index 2b9b05c..6ef6fbb 100644 --- a/src/views/settings/GraphSettingsView.ts +++ b/src/views/settings/GraphSettingsView.ts @@ -3,26 +3,28 @@ import { DisplaySettingsView } from "@/views/settings/categories/DisplaySettings import { FilterSettings } from "@/settings/categories/FilterSettings"; import { GroupSettings } from "@/settings/categories/GroupSettings"; import { DisplaySettings } from "@/settings/categories/DisplaySettings"; -import { App, ExtraButtonComponent } from "obsidian"; +import { ExtraButtonComponent, WorkspaceLeaf } from "obsidian"; import { State, StateChange } from "@/util/State"; import { GroupSettingsView } from "@/views/settings/categories/GroupSettingsView"; import { FilterSettingsView } from "@/views/settings/categories/FilterSettingsView"; import { GraphSettings } from "@/settings/GraphSettings"; import { ObsidianTheme } from "@/util/ObsidianTheme"; import { eventBus } from "@/util/EventBus"; +import Graph3dPlugin from "@/main"; export class GraphSettingsView extends HTMLDivElement { - private app: App; private settingsButton: ExtraButtonComponent; private graphControls: HTMLDivElement; private readonly settingsState: State; private readonly theme: ObsidianTheme; + private readonly plugin: Graph3dPlugin; + searchLeaf: WorkspaceLeaf; - constructor(settingsState: State, theme: ObsidianTheme, app: App) { + constructor(settingsState: State, theme: ObsidianTheme, plugin: Graph3dPlugin) { super(); this.settingsState = settingsState; this.theme = theme; - this.app = app; + this.plugin = plugin; } private isCollapsedState = new State(true); @@ -40,23 +42,25 @@ export class GraphSettingsView extends HTMLDivElement { this.graphControls = this.createDiv({ cls: "graph-controls" }); this.appendGraphControlsItems(this.graphControls.createDiv({ cls: "control-buttons" })); - this.appendSetting( + this.appendSettingGroup( this.settingsState.createSubState("value.filters", FilterSettings), "Filters", - (...args) => FilterSettingsView(...args, this.app) + (...args) => FilterSettingsView(...args, this.plugin) ); - this.appendSetting( + this.appendSettingGroup( this.settingsState.createSubState("value.groups", GroupSettings), "Groups", (...args) => GroupSettingsView(...args, this.theme) ); - this.appendSetting( + this.appendSettingGroup( this.settingsState.createSubState("value.display", DisplaySettings), "Display", DisplaySettingsView ); this.initListeners(); this.toggleCollapsed(this.isCollapsedState.value); + + // init search view } private initListeners() { @@ -111,14 +115,18 @@ export class GraphSettingsView extends HTMLDivElement { } private appendMinimizeButton(containerEl: HTMLElement) { + // on close new ExtraButtonComponent(containerEl) .setIcon("x") .setTooltip("Close") - .onClick(() => (this.isCollapsedState.value = true)); + .onClick(() => { + this.isCollapsedState.value = true; + console.log("graph setting is closed"); + }); } // utility function to append a setting - private appendSetting( + private appendSettingGroup( setting: S, title: string, view: (setting: S, containerEl: HTMLElement) => void diff --git a/src/views/settings/categories/AddNodeGroupButton.ts b/src/views/settings/categories/AddNodeGroupButton.ts index 8421f27..84db330 100644 --- a/src/views/settings/categories/AddNodeGroupButton.ts +++ b/src/views/settings/categories/AddNodeGroupButton.ts @@ -5,6 +5,10 @@ import { ObsidianTheme } from "@/util/ObsidianTheme"; import { State } from "@/util/State"; import { GroupSettingsView } from "@/views/settings/categories/GroupSettingsView"; +const getRandomColor = () => { + return "#" + Math.floor(Math.random() * 16777215).toString(16); +}; + export const addNodeGroupButton = ( groupSettings: State, containerEl: HTMLElement, @@ -20,7 +24,7 @@ export const addNodeGroupButton = ( .setClass("mod-cta") .setButtonText("Add Group") .onClick(() => { - groupSettings.value.groups.push(new NodeGroup("", theme.textMuted)); + groupSettings.value.groups.push(new NodeGroup("", getRandomColor())); containerEl.empty(); GroupSettingsView(groupSettings, containerEl, theme); }); diff --git a/src/views/settings/categories/FilterSettingsView.ts b/src/views/settings/categories/FilterSettingsView.ts index 823cf31..2748c26 100644 --- a/src/views/settings/categories/FilterSettingsView.ts +++ b/src/views/settings/categories/FilterSettingsView.ts @@ -1,69 +1,68 @@ -import { App, Notice, Setting, Plugin } from "obsidian"; +import { SearchView, Setting, TFile } from "obsidian"; import { FilterSettings } from "@/settings/categories/FilterSettings"; import { State } from "@/util/State"; +import { spawnLeafView } from "@/views/leafView"; +import Graph3dPlugin from "@/main"; -interface GlobalSearchPlugin extends Plugin { - instance: { - getGlobalSearchQuery: () => string; - openGlobalSearch: (query: string) => void; - }; -} +const DomLookUpTime = 300; -export const FilterSettingsView = ( +export const FilterSettingsView = async ( filterSettings: State, containerEl: HTMLElement, - app: App + plugin: Graph3dPlugin ) => { - const instance = (app.internalPlugins.plugins["global-search"] as GlobalSearchPlugin).instance; - // add search setting - new Setting(containerEl) - .setClass("mod-search-setting") - .addSearch((search) => { - // initialize search with current search query - search.inputEl.value = filterSettings.value.searchQuery; - filterSettings.onChange((change) => { - if (change.currentPath === "searchQuery") { - search.inputEl.value = change.newValue as string; - } - }); - search.setPlaceholder("Read only"); - search.inputEl.onclick = (e) => { - new Notice("This search is delegated to the global search plugin"); - const currentSearch = instance.getGlobalSearchQuery(); - instance.openGlobalSearch(currentSearch); - }; - search.clearButtonEl.onclick = (e) => { - filterSettings.value.searchQuery = ""; - filterSettings.value.searchResult = []; - }; - search.inputEl.readOnly = true; - return search; - }) - .addButton((button) => { - button.setButtonText("Search").onClick(async (e) => { - const currentSearch = instance.getGlobalSearchQuery(); - instance.openGlobalSearch(currentSearch); - const searchLeaf = app.workspace.getLeavesOfType("search")[0]; - if (searchLeaf) { - filterSettings.value.searchQuery = instance.getGlobalSearchQuery(); - const search = await searchLeaf.open(searchLeaf.view); - const rawSearchResult = await new Promise((resolve) => - setTimeout(() => { - // @ts-ignore - resolve(search.dom.resultDomLookup); - }, 300) - ); - // @ts-ignore - const files = Array.from(rawSearchResult.keys()); - console.log(files); - // @ts-ignore - filterSettings.value.searchResult = files.map((f) => f.path as string); - console.log(filterSettings.value.searchResult); - } else { - new Notice("Please open your search leaf first"); - } - }); - }); + const searchEl = containerEl.createDiv({ + // cls : + }); + const [searchLeaf] = spawnLeafView(plugin, searchEl); + + await searchLeaf.setViewState({ + type: "search", + }); + + // add searchEl to containerEl + containerEl.appendChild(searchEl); + + const element = searchLeaf.containerEl.querySelector( + ".workspace-leaf-content[data-type='search']" + ) as HTMLDivElement; + // element.style.removeProperty("overflow"); + const searchRowEl = element.querySelector(".search-row") as HTMLDivElement; + searchRowEl.style.margin = "0px"; + console.log(element); + // 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(); + console.log(settingIconEl); + inputEl.value = filterSettings.value.searchQuery; + inputEl.onkeydown = async (e) => { + const currentView = searchLeaf.view as SearchView; + const rawSearchResult = await new Promise((resolve) => + setTimeout(() => { + // @ts-ignore + resolve(currentView.dom.resultDomLookup); + }, DomLookUpTime) + ); + // @ts-ignore + const files = Array.from(rawSearchResult.keys()) as TFile[]; + + filterSettings.value.searchResult = files.map((f) => f.path); + filterSettings.value.searchQuery = inputEl.value; + }; + + clearButtonEl.onclick = async () => { + filterSettings.value.searchQuery = ""; + filterSettings.value.searchResult = []; + }; // add show attachments setting new Setting(containerEl).setName("Show Attachments").addToggle((toggle) => { diff --git a/src/views/settings/categories/GroupSettingItem.ts b/src/views/settings/categories/GroupSettingItem.ts index 1bbf7d3..0c3648e 100644 --- a/src/views/settings/categories/GroupSettingItem.ts +++ b/src/views/settings/categories/GroupSettingItem.ts @@ -18,6 +18,7 @@ export const AddNodeGroupItem = ( }); addColorPicker(groupEl, group.value.color, (value) => { + console.log("color changed", value); group.value.color = value; }); diff --git a/src/views/settings/categories/addNodeGroups.ts b/src/views/settings/categories/addNodeGroups.ts index 3b2bb37..b79ec60 100644 --- a/src/views/settings/categories/addNodeGroups.ts +++ b/src/views/settings/categories/addNodeGroups.ts @@ -9,6 +9,7 @@ export const addNodeGroups = (groupSettings: State, containerEl: cls: "graph-color-groups-container", }); groupSettings.value.groups.forEach((group, index) => { + console.log(groupSettings.value.groups[0]); const groupState = groupSettings.createSubState(`value.groups.${index}`, NodeGroup); AddNodeGroupItem(groupState, nodeGroupContainerEl, () => { groupSettings.value.groups.splice(index, 1);