some new features

This commit is contained in:
Hananoshika Yomaru 2023-10-16 20:25:09 -07:00
parent 71126d8f19
commit 42e2fed4e5
13 changed files with 1139 additions and 105 deletions

View file

@ -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.
<a href="https://www.buymeacoffee.com/yomaru" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>

BIN
bun.lockb

Binary file not shown.

View file

@ -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"
}
}

298
src/typings/types-obsidian.d.ts vendored Normal file
View file

@ -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<TFile> {
getSuggestions(query: string): TFile[] | Promise<TFile[]>;
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<string, Command>;
};
internalPlugins: {
plugins: InternalPlugins;
getPluginById<T extends keyof InternalPlugins>(id: T): InternalPlugins[T];
};
plugins: {
manifests: Record<string, PluginManifest>;
plugins: Record<string, Plugin> & {
["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<string, string>; // file extensions to view types
viewByType: Record<string, (leaf: WorkspaceLeaf) => 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<void>;
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<void>;
followLinkUnderCursor(newLeaf: boolean): void;
modes: Record<string, MarkdownSubView>;
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<TFile>;
}
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;
}
}

View file

@ -76,7 +76,8 @@ export class State<T> {
}, this as Record<string, unknown>) 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 {

View file

@ -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) => `<div class="node-label">${node.name}</div>`
// )
// .nodeLabel((node: Node) => `<div class="node-label">${node.name}</div>`)
// @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) {

View file

@ -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 {

598
src/views/leafView.ts Normal file
View file

@ -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<Element, EmbeddedView>();
type ConstructableWorkspaceSplit = new (
ws: Workspace,
dir: "horizontal" | "vertical"
) => WorkspaceSplit;
export function isEmebeddedLeaf(leaf: WorkspaceLeaf) {
// Work around missing enhance.js API by checking match condition instead of looking up parent
return leaf.containerEl.matches(`.${PREFIX}-block.${PREFIX}-leaf-view .workspace-leaf`);
}
function genId(size: number): string {
const chars = [];
for (let n = 0; n < size; n++) chars.push(((16 * Math.random()) | 0).toString(16));
return chars.join("");
}
function nosuper<T>(base: new (...args: unknown[]) => T): new () => T {
const derived = function () {
return Object.setPrototypeOf(new Component(), new.target.prototype);
};
derived.prototype = base.prototype;
return Object.setPrototypeOf(derived, base);
}
export const spawnLeafView = (
plugin: Plugin,
initiatingEl?: HTMLElement,
leaf?: WorkspaceLeaf,
onShowCallback?: () => unknown,
props?: {
hoverElClasses?: string[];
containerElClasses?: string[];
}
): [WorkspaceLeaf, EmbeddedView] => {
// When Obsidian doesn't set any leaf active, use leaf instead.
let parent = app.workspace.activeLeaf as unknown as EmbeddedViewParent;
if (!parent) parent = leaf as unknown as EmbeddedViewParent;
if (!initiatingEl) initiatingEl = parent?.containerEl;
const hoverPopover = new EmbeddedView(
parent,
initiatingEl!,
plugin,
undefined,
onShowCallback,
props
);
return [hoverPopover.attachLeaf(), hoverPopover];
};
export class EmbeddedView extends nosuper(HoverPopover) {
onTarget: boolean;
setActive: (event: MouseEvent) => void;
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;
}
}

View file

@ -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<GraphSettings>;
private readonly theme: ObsidianTheme;
private readonly plugin: Graph3dPlugin;
searchLeaf: WorkspaceLeaf;
constructor(settingsState: State<GraphSettings>, theme: ObsidianTheme, app: App) {
constructor(settingsState: State<GraphSettings>, 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<S>(
private appendSettingGroup<S>(
setting: S,
title: string,
view: (setting: S, containerEl: HTMLElement) => void

View file

@ -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<GroupSettings>,
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);
});

View file

@ -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<FilterSettings>,
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) => {

View file

@ -18,6 +18,7 @@ export const AddNodeGroupItem = (
});
addColorPicker(groupEl, group.value.color, (value) => {
console.log("color changed", value);
group.value.color = value;
});

View file

@ -9,6 +9,7 @@ export const addNodeGroups = (groupSettings: State<GroupSettings>, 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);