diff --git a/manifest.json b/manifest.json index c4e0825..3e0e64e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { - "id": "obsidian-plugin-template", - "name": "Obsidian plugin template", + "id": "open-editors", + "name": "Open Editors", "minAppVersion": "0.15.0", - "description": "", + "description": "Adds a view which let you manage your opened tabs.", "author": "4Source", "authorUrl": "https://github.com/4Source", "isDesktopOnly": false, diff --git a/package-lock.json b/package-lock.json index 7cd98dc..33b4f1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "obsidian-plugin-template", + "name": "open-editors", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "obsidian-plugin-template", + "name": "open-editors", "version": "0.0.1", "license": "MIT", "devDependencies": { diff --git a/package.json b/package.json index 553fa73..03294f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "obsidian-plugin-template", - "description": "", + "name": "open-editors", + "description": "Obsidian plugin", "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs", diff --git a/src/OpenEditorsView.ts b/src/OpenEditorsView.ts new file mode 100644 index 0000000..e8ce137 --- /dev/null +++ b/src/OpenEditorsView.ts @@ -0,0 +1,137 @@ +import { View, WorkspaceLeaf } from 'obsidian'; +import { ICON_CLOSE, ICON_CLOSE_GROUP, ICON_CLOSE_WINDOW, ICON_OPEN_EDITORS, VIEW_TYPE_OPEN_EDITORS } from './constants'; +import { TreeItem } from './TreeItem'; + +export class OpenEditorsView extends View { + windows: TreeItem[]; + treeEl: HTMLDivElement; + + constructor (leaf: WorkspaceLeaf) { + super(leaf); + + this.windows = []; + this.icon = ICON_OPEN_EDITORS; + } + + getViewType (): string { + return VIEW_TYPE_OPEN_EDITORS; + } + getDisplayText (): string { + return 'Open Editors'; + } + + async onOpen () { + // Create the HTML container + this.containerEl.empty(); + const container = this.containerEl.createEl('div', { cls: (VIEW_TYPE_OPEN_EDITORS + '-container') }); + this.treeEl = container.createEl('div', { attr: { 'style': '' } }); + + // If layout changes clear the current tree and recreate it from layout + this.registerEvent(this.app.workspace.on('layout-change', async () => { + this.clearTree(); + this.createTree(); + })); + } + + createTree () { + const layout = this.app.workspace.getLayout(); + + // Expand the tree with the groups and leafs from the main window + const main = layout['main']; + if (main) { + const tree = new TreeItem(this.treeEl, 'Main window', main.id, undefined, [{ + iconId: ICON_CLOSE_WINDOW, + onClickCallback: () => { + tree.rekursiveCall((tree) => { + this.app.workspace.getLeafById(tree.id)?.detach(); + }); + }, + ariaLabel: 'Close all', + }]); + this.windows.push(tree); + main.children.forEach((element: { id: string, type: string, children: object[], state: { title: string } }) => { + TreeWalker(element, tree); + }); + } + + // Expand the tree with the groups and leafs from popout windows + const floatingWindow = layout['floating']; + if (floatingWindow) { + let count = 1; + floatingWindow.children.forEach((element: { id: string, type: string, children: object[] }) => { + const tree = new TreeItem(this.treeEl, `Window ${count}`, element.id, undefined, [{ + iconId: ICON_CLOSE_WINDOW, + onClickCallback: () => { + tree.rekursiveCall((tree) => { + this.app.workspace.getLeafById(tree.id)?.detach(); + }); + }, + ariaLabel: 'Close all', + }]); + this.windows.push(tree); + element.children.forEach((element_: { id: string, type: string, children: object[], state: { title: string } }) => { + TreeWalker(element_, tree); + }); + count++; + }); + } + } + + clearTree () { + this.windows.forEach(window => window.deleteTree()); + this.windows = []; + + this.treeEl.empty(); + } +} + +function TreeWalker (layout: { id: string, type: string, children: object[], state: { title: string } }, parent: TreeItem) { + let count = 0; + switch (layout.type) { + case 'split': + count = 1; + layout.children.forEach((element: { id: string, type: string, children: object[], state: { title: string } }) => { + const tree = parent.addChild((container) => new TreeItem(container, `Group ${count}`, element.id, undefined, [{ + iconId: ICON_CLOSE_GROUP, + onClickCallback: () => { + tree.rekursiveCall((tree) => { + this.app.workspace.getLeafById(tree.id)?.detach(); + }); + }, + ariaLabel: 'Close all', + }])); + TreeWalker(element, tree); + count++; + }); + break; + case 'tabs': + count = 1; + layout.children.forEach((element: { id: string, type: string, children: object[], state: { title: string } }) => { + TreeWalker(element, parent); + count++; + }); + break; + case 'leaf': + parent.addChild((container) => new TreeItem(container, layout.state.title, layout.id, { + onClickCallback: () => { + const leaf = this.app.workspace.getLeafById(layout.id); + if (!leaf) { + console.warn('Leaf not found with the id ', layout.id); + return; + } + this.app.workspace.setActiveLeaf(leaf); + // TODO: Leaf is in popout Window bringt to forground + }, + }, [{ + iconId: ICON_CLOSE, + onClickCallback: () => { + this.app.workspace.getLeafById(layout.id)?.detach(); + }, + ariaLabel: 'Close', + }])); + break; + default: + console.warn('Unknown layout component', layout); + break; + } +} \ No newline at end of file diff --git a/src/TreeItem.ts b/src/TreeItem.ts new file mode 100644 index 0000000..2e40863 --- /dev/null +++ b/src/TreeItem.ts @@ -0,0 +1,123 @@ +import { IconName, setIcon } from 'obsidian'; + +export class TreeItem { + id: string; + parent: TreeItem | undefined; + children: TreeItem[]; + treeItemEl: HTMLDivElement; + selfEl: HTMLDivElement; + childrenEl: HTMLDivElement; + iconEl: HTMLDivElement; + + constructor (conteiner: HTMLDivElement, title: string, id: string, handler?: { onClickCallback?: (ev: MouseEvent) => void, onDragCallback?: (ev: DragEvent, delegateTarget: HTMLElement) => void }, actions?: { iconId: IconName, onClickCallback: (ev: MouseEvent) => void, ariaLabel?: string }[]) { + this.children = []; + this.id = id; + this.treeItemEl = conteiner.createEl('div', { cls: 'tree-item' }); + this.selfEl = this.treeItemEl.createEl('div', { cls: 'open-editors-tree-item-self tree-item-self is-clickable' }); + this.childrenEl = this.treeItemEl.createEl('div', { cls: 'tree-item-children' }); + + this.selfEl.onClickEvent((ev) => { + if (this.selfEl.hasClass('mod-collapsible')) { + if (this.iconEl.hasClass('is-collapsed')) { + this.selfEl.removeClass('is-collapsed'); + this.iconEl.removeClass('is-collapsed'); + this.childrenEl.setCssProps({ 'display': 'block' }); + } + else { + this.selfEl.addClass('is-collapsed'); + this.iconEl.addClass('is-collapsed'); + this.childrenEl.setCssProps({ 'display': 'none' }); + } + } + if (handler?.onClickCallback) { + handler.onClickCallback(ev); + } + }); + + if (handler?.onDragCallback) { + this.selfEl.on('drag', 'selector', (ev, delegateTarget) => { + console.log(ev, delegateTarget); + if (handler?.onDragCallback) { + handler.onDragCallback(ev, delegateTarget); + } + }); + } + + this.selfEl.createEl('div', { cls: 'open-editors-tree-item-inner tree-item-inner' }) + .createEl('div', { cls: 'tree-item-inner-text' }) + .createEl('span', { cls: 'tree-item-inner-text', text: title }); + const flairOuter = this.selfEl.createEl('div', { cls: 'open-editors-tree-item-flair-outer tree-item-flair-outer' }); + + if (actions) { + actions.forEach(action => { + const button = flairOuter.createEl('div', { cls: 'tree-item-flair mod-clickable tree-iten-action-icon' }); + setIcon(button.createEl('span', { cls: '' }), action.iconId); + if (action.ariaLabel) { + button.ariaLabel = action.ariaLabel; + // button.setAttr('data-tooltip-position', 'top'); + } + button.onClickEvent((ev) => { + ev.stopPropagation(); + action.onClickCallback(ev); + }); + }); + } + } + + rekursiveCall (callback: (tree: TreeItem) => void) { + this.children.forEach((child) => { + child.rekursiveCall(callback); + }); + callback(this); + } + + addChild (treeCreateCallback: (container: HTMLDivElement) => TreeItem): TreeItem { + if (!this.selfEl.hasClass('mod-collapsible')) { + this.selfEl.addClass('mod-collapsible'); + this.iconEl = this.selfEl.createEl('div', { cls: 'open-editors-tree-item-icon tree-item-icon collapse-icon' }); + setIcon(this.iconEl, 'right-triangle'); + } + + const child = treeCreateCallback(this.childrenEl); + child.parent = this; + this.children.push(child); + return child; + } + + removeChild (id: string) { + const child = this.children.find((value) => value.id == id); + if (child) { + this.children.remove(child); + child.removeAllChildren(); + child.removeDom(); + } + if (this.children.length <= 0 && this.selfEl.hasClass('mod-collapsible')) { + this.selfEl.removeClass('mod-collapsible'); + this.iconEl?.remove(); + } + } + + removeAllChildren () { + while (this.children.length > 0) { + const child = this.children.pop(); + if (child) { + child.removeAllChildren(); + child.removeDom(); + } + } + this.selfEl.removeClass('mod-collapsible'); + this.iconEl?.remove(); + } + + deleteTree () { + this.removeAllChildren(); + this.removeDom(); + } + + private removeDom () { + this.treeItemEl?.remove(); + this.selfEl?.remove(); + this.childrenEl?.remove(); + this.iconEl?.remove(); + } +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..0fafdcd --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,11 @@ +export const VIEW_TYPE_OPEN_EDITORS = 'open-editors'; +export const ICON_OPEN_EDITORS = 'layers'; + +export const ICON_CLOSE = 'x-square'; // x +export const ICON_CLOSE_GROUP = 'copy-x'; // folder-x +export const ICON_CLOSE_WINDOW = 'copy-x'; // monitor-x +export const ICON_MOVE_TO_NEW_WINDOW = 'picture-in-picture'; +export const ICON_OPEN_IN_NEW_WINDOW = 'picture-in-picture-2'; +export const ICON_SPLIT_RIGHT = 'separator-vertical'; +export const ICON_SPLIT_DOWN = 'separator-horizontal'; +export const ICON_REVEAL_IN_NAVIGATION = 'folder-open'; \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index b726a21..8e6ef6c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,15 +1,22 @@ -import { Plugin } from 'obsidian'; +import { Plugin, WorkspaceLeaf } from 'obsidian'; import { DEFAULT_SETTINGS, Settings } from './settings/SettingsInterface'; -import { MyPluginSettingTab } from './settings/SettingsTab'; +import { ICON_OPEN_EDITORS, VIEW_TYPE_OPEN_EDITORS } from './constants'; +import { OpenEditorsView } from './OpenEditorsView'; -export default class MyPlugin extends Plugin { +export default class OpenEditorsPlugin extends Plugin { settings: Settings; async onload () { await this.loadSettings(); // This adds a settings tab so the user can configure various aspects of the plugin - this.addSettingTab(new MyPluginSettingTab(this.app, this)); + // this.addSettingTab(new OpenEditorsSettingTab(this.app, this)); + + this.registerView(VIEW_TYPE_OPEN_EDITORS, (leaf) => new OpenEditorsView(leaf)); + + this.addRibbonIcon(ICON_OPEN_EDITORS, 'Open Open Editors', () => { + this.activateView(VIEW_TYPE_OPEN_EDITORS); + }); } onunload () { @@ -23,4 +30,29 @@ export default class MyPlugin extends Plugin { async saveSettings () { await this.saveData(this.settings); } + + async activateView (viewType: string) { + const { workspace } = this.app; + + let leaf: WorkspaceLeaf | null = null; + const leaves = workspace.getLeavesOfType(viewType); + + if (leaves.length > 0) { + // A leaf with our view already exists, use that + leaf = leaves[0]; + } + else { + // Our view could not be found in the workspace, create a new leaf + // in the right sidebar for it + leaf = workspace.getRightLeaf(false); + if (leaf) { + await leaf.setViewState({ type: viewType, active: true }); + } + } + + // "Reveal" the leaf in case it is in a collapsed sidebar + if (leaf) { + workspace.revealLeaf(leaf); + } + } } diff --git a/src/settings/SettingsTab.ts b/src/settings/SettingsTab.ts index 4e9ab55..7efa3bc 100644 --- a/src/settings/SettingsTab.ts +++ b/src/settings/SettingsTab.ts @@ -1,12 +1,12 @@ import { App, PluginSettingTab, Setting } from 'obsidian'; -import MyPlugin from 'src/main'; +import OpenEditorsPlugin from 'src/main'; -export class MyPluginSettingTab extends PluginSettingTab { - plugin: MyPlugin; +export class OpenEditorsSettingTab extends PluginSettingTab { + plugin: OpenEditorsPlugin; constructor ( app: App, - plugin: MyPlugin + plugin: OpenEditorsPlugin ) { super(app, plugin); this.plugin = plugin; diff --git a/styles.css b/styles.css index 7bed3db..212b4bd 100644 --- a/styles.css +++ b/styles.css @@ -1,8 +1,47 @@ -/* +.open-editors-container { + font-size: var(--font-ui-small); + padding: var(--size-4-3) var(--size-4-3) var(--size-4-8); + overflow: auto; +} -This CSS file will be included with your plugin, and -available in the app when your plugin is enabled. +.tree-iten-action-icon { + vertical-align: middle; + display: flex; + align-items: center; + margin-inline-end: var(--size-4-2); +} -If your plugin does not need CSS, delete this file. +.tree-iten-action-icon:last-child { + vertical-align: middle; + display: flex; + align-items: center; + margin-inline-end: 0; +} -*/ \ No newline at end of file +.tree-iten-action-icon>span>svg { + height: var(--icon-s) !important; + width: var(--icon-s) !important; +} + +.tree-iten-action-icon:hover { + background-color: var(--interactive-hover); +} + +.open-editors-tree-item-self { + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +.open-editors-tree-item-inner { + margin-top: var(--size-4-1) !important; + margin-bottom: var(--size-4-1) !important; +} + +.open-editors-tree-item-icon { + margin-top: var(--size-4-1) !important; + margin-bottom: var(--size-4-1) !important; +} + +.open-editors-tree-item-flair-outer { + /* padding-top: var(--size-4-1); */ +} \ No newline at end of file