├── .eslintignore ├── .eslintrc ├── .gitignore ├── README.md ├── esbuild.config.mjs ├── manifest-beta.json ├── manifest.json ├── package.json ├── src ├── file-explorer │ ├── custom-sort.ts │ └── fuzzy-filter.ts ├── main.ts ├── settings │ └── settings.ts ├── types │ ├── obsidian.d.ts │ └── types.d.ts └── utils.ts ├── styles.css ├── tsconfig.json └── versions.json /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "parserOptions": { 13 | "sourceType": "module" 14 | }, 15 | "rules": { 16 | "no-unused-vars": "off", 17 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 18 | "@typescript-eslint/ban-ts-comment": "off", 19 | "no-prototype-builtins": "off", 20 | "@typescript-eslint/no-empty-function": "off" 21 | } 22 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | package-lock.json 11 | 12 | # Don't include the compiled main.js file in the repo. 13 | # They should be uploaded to GitHub releases instead. 14 | main.js 15 | 16 | # Exclude sourcemaps 17 | *.map 18 | 19 | # obsidian 20 | data.json 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Obsidian Bartender 2 | 3 | Take control of your Obsidian workspace by organizing, rearranging, and filtering nav bars, ribbon bars, status bars, and the file explorer. 4 | 5 | ### File Explorer 6 | 7 | #### Filtering 8 | 9 | The file explorer can be filtered using fuse.js extended search syntax: 10 | 11 | White space acts as an **AND** operator, while a single pipe (`|`) character acts as an **OR** operator. To escape white space, use double quote ex. `="scheme language"` for exact match. 12 | 13 | | Token | Match type | Description | 14 | | ----------- | -------------------------- | -------------------------------------- | 15 | | `jscript` | fuzzy-match | Items that fuzzy match `jscript` | 16 | | `=scheme` | exact-match | Items that are `scheme` | 17 | | `'python` | include-match | Items that include `python` | 18 | | `!ruby` | inverse-exact-match | Items that do not include `ruby` | 19 | | `^java` | prefix-exact-match | Items that start with `java` | 20 | | `!^erlang` | inverse-prefix-exact-match | Items that do not start with `erlang` | 21 | | `.js$` | suffix-exact-match | Items that end with `.js` | 22 | | `!.go$` | inverse-suffix-exact-match | Items that do not end with `.go` | 23 | 24 | White space acts as an **AND** operator, while a single pipe (`|`) character acts as an **OR** operator. 25 | 26 | ### Installing the plugin using BRAT 27 | 28 | 1. Install the BRAT plugin 29 | 1. Open `Settings` -> `Community Plugins` 30 | 2. Disable safe mode, if enabled 31 | 3. *Browse*, and search for "BRAT" 32 | 4. Install the latest version of **Obsidian 42 - BRAT** 33 | 2. Open BRAT settings (`Settings` -> `Obsidian 42 - BRAT`) 34 | 1. Scroll to the `Beta Plugin List` section 35 | 2. `Add Beta Plugin` 36 | 3. Specify this repository: `nothingislost/obsidian-bartender` 37 | 3. Enable the `Bartender` plugin (`Settings` -> `Community Plugins`) 38 | 39 | ### Manually installing the plugin 40 | 41 | - Copy over `main.js`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/obsidian-bartender/`. 42 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = `/* 6 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 7 | if you want to view the source, please visit the github repository of this plugin 8 | */ 9 | `; 10 | 11 | const prod = process.argv[2] === "production"; 12 | 13 | esbuild 14 | .build({ 15 | banner: { 16 | js: banner, 17 | }, 18 | minify: prod ? true : false, 19 | entryPoints: ["src/main.ts"], 20 | bundle: true, 21 | external: [ 22 | "obsidian", 23 | "electron", 24 | "codemirror", 25 | "@codemirror/closebrackets", 26 | "@codemirror/commands", 27 | "@codemirror/fold", 28 | "@codemirror/gutter", 29 | "@codemirror/history", 30 | "@codemirror/language", 31 | "@codemirror/rangeset", 32 | "@codemirror/rectangular-selection", 33 | "@codemirror/search", 34 | "@codemirror/state", 35 | "@codemirror/stream-parser", 36 | "@codemirror/text", 37 | "@codemirror/view", 38 | ...builtins, 39 | ], 40 | format: "cjs", 41 | watch: !prod, 42 | target: "es2016", 43 | logLevel: "info", 44 | sourcemap: prod ? false : "inline", 45 | treeShaking: true, 46 | outfile: "dist/main.js", 47 | }) 48 | .catch(() => process.exit(1)); 49 | -------------------------------------------------------------------------------- /manifest-beta.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-bartender", 3 | "name": "Bartender", 4 | "version": "0.5.9", 5 | "minAppVersion": "1.2.0", 6 | "description": "Allows for rearranging the elements in the status bar and sidebar ribbon", 7 | "author": "NothingIsLost", 8 | "authorUrl": "https://github.com/nothingislost", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-bartender", 3 | "name": "Bartender", 4 | "version": "0.5.9", 5 | "minAppVersion": "1.2.0", 6 | "description": "Allows for rearranging the elements in the status bar and sidebar ribbon", 7 | "author": "NothingIsLost", 8 | "authorUrl": "https://github.com/nothingislost", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-bartender", 3 | "version": "0.5.9", 4 | "description": "Allows for rearranging the elements in the status bar and sidebar ribbon", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "node esbuild.config.mjs production" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@types/node": "^16.11.6", 15 | "@types/sortablejs": "^1.10.7", 16 | "@typescript-eslint/eslint-plugin": "^5.2.0", 17 | "@typescript-eslint/parser": "^5.2.0", 18 | "builtin-modules": "^3.2.0", 19 | "esbuild": "0.13.12", 20 | "i18next": "^21.6.10", 21 | "monkey-around": "^2.2.0", 22 | "obsidian": "^0.15.1", 23 | "sortablejs": "^1.15.0", 24 | "tslib": "2.3.1", 25 | "typescript": "4.4.4" 26 | }, 27 | "dependencies": { 28 | "fuse.js": "^6.5.3" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/file-explorer/custom-sort.ts: -------------------------------------------------------------------------------- 1 | import Fuse from "fuse.js"; 2 | import { Menu, TAbstractFile, TFile, TFolder, requireApiVersion } from "obsidian"; 3 | 4 | let Collator = new Intl.Collator(undefined, { 5 | usage: "sort", 6 | sensitivity: "base", 7 | numeric: true, 8 | }).compare; 9 | 10 | let Sorter = { 11 | alphabetical: function (first: TFile, second: TFile) { 12 | return Collator(first.basename, second.basename); 13 | }, 14 | alphabeticalReverse: function (first: TFile, second: TFile) { 15 | return -Sorter.alphabetical(first, second); 16 | }, 17 | byModifiedTime: function (first: TFile, second: TFile) { 18 | return second.stat.mtime - first.stat.mtime; 19 | }, 20 | byModifiedTimeReverse: function (first: TFile, second: TFile) { 21 | return -Sorter.byModifiedTime(first, second); 22 | }, 23 | byCreatedTime: function (first: TFile, second: TFile) { 24 | return second.stat.ctime - first.stat.ctime; 25 | }, 26 | byCreatedTimeReverse: function (first: TFile, second: TFile) { 27 | return -Sorter.byCreatedTime(first, second); 28 | }, 29 | }; 30 | 31 | const Translate = i18next.t.bind(i18next); 32 | 33 | const SortGlyph = "up-and-down-arrows"; 34 | 35 | const sortOptionStrings = { 36 | alphabetical: "plugins.file-explorer.label-sort-a-to-z", 37 | alphabeticalReverse: "plugins.file-explorer.label-sort-z-to-a", 38 | byModifiedTime: "plugins.file-explorer.label-sort-new-to-old", 39 | byModifiedTimeReverse: "plugins.file-explorer.label-sort-old-to-new", 40 | byCreatedTime: "plugins.file-explorer.label-sort-created-new-to-old", 41 | byCreatedTimeReverse: "plugins.file-explorer.label-sort-created-old-to-new", 42 | custom: "Custom", 43 | }; 44 | 45 | const sortOptionGroups = [ 46 | ["alphabetical", "alphabeticalReverse"], 47 | ["byModifiedTime", "byModifiedTimeReverse"], 48 | ["byCreatedTime", "byCreatedTimeReverse"], 49 | ["custom"], 50 | ]; 51 | 52 | export const folderSort = function (order: string[], foldersOnBottom?: boolean) { 53 | let fileExplorer = this.fileExplorer, 54 | folderContents = this.file.children.slice(); 55 | folderContents.sort(function (firstEl: TFile | TFolder, secondEl: TFile | TFolder) { 56 | let firstIsFolder, secondIsFolder; 57 | if ( 58 | foldersOnBottom && 59 | ((firstIsFolder = firstEl instanceof TFolder) || (secondIsFolder = secondEl instanceof TFolder)) 60 | ) { 61 | return firstIsFolder && !secondIsFolder 62 | ? 1 63 | : secondIsFolder && !firstIsFolder 64 | ? -1 65 | : Collator(firstEl.name, secondEl.name); 66 | } else { 67 | if (!order) return Collator(firstEl.name, secondEl.name); 68 | 69 | const index1 = order.indexOf(firstEl.path); 70 | const index2 = order.indexOf(secondEl.path); 71 | 72 | return (index1 > -1 ? index1 : Infinity) - (index2 > -1 ? index2 : Infinity); 73 | } 74 | }); 75 | const items = folderContents 76 | .map((child: TAbstractFile) => fileExplorer.fileItems[child.path]) 77 | .filter((f: TAbstractFile) => f); 78 | 79 | if (requireApiVersion && requireApiVersion("0.15.0")) { 80 | this.vChildren.setChildren(items); 81 | } else { 82 | this.children = items; 83 | } 84 | }; 85 | 86 | export const addSortButton = function (sorter: any, sortOption: any) { 87 | let plugin = this; 88 | let sortEl = this.addNavButton( 89 | SortGlyph, 90 | Translate("plugins.file-explorer.action-change-sort"), 91 | function (event: MouseEvent) { 92 | event.preventDefault(); 93 | let menu = new Menu(plugin.app); 94 | for ( 95 | let currentSortOption = sortOption(), groupIndex = 0, _sortOptionGroups = sortOptionGroups; 96 | groupIndex < _sortOptionGroups.length; 97 | groupIndex++ 98 | ) { 99 | for ( 100 | let addMenuItem = function (_sortOption: keyof typeof sortOptionStrings) { 101 | let label = Translate(sortOptionStrings[_sortOption]); 102 | menu.addItem(function (item) { 103 | return item 104 | .setTitle(label) 105 | .setActive(_sortOption === currentSortOption) 106 | .onClick(function () { 107 | if (_sortOption !== currentSortOption) { 108 | sortEl.setAttribute("data-sort-method", _sortOption); 109 | plugin.app.workspace.trigger("file-explorer-sort-change", _sortOption); 110 | } 111 | sorter(_sortOption); 112 | }); 113 | }); 114 | }, 115 | itemIndex = 0, 116 | sortOptionGroup = _sortOptionGroups[groupIndex]; 117 | itemIndex < sortOptionGroup.length; 118 | itemIndex++ 119 | ) { 120 | addMenuItem(sortOptionGroup[itemIndex] as keyof typeof sortOptionStrings); 121 | } 122 | menu.addSeparator(); 123 | } 124 | menu.showAtMouseEvent(event); 125 | } 126 | ); 127 | setTimeout(() => { 128 | sortEl.setAttribute("data-sort-method", sortOption()); 129 | }, 100); 130 | this.addNavButton("three-horizontal-bars", "Drag to rearrange", function (event: MouseEvent) { 131 | event.preventDefault(); 132 | let value = !this.hasClass("is-active"); 133 | this.toggleClass("is-active", value); 134 | plugin.app.workspace.trigger("file-explorer-draggable-change", value); 135 | }).addClass("drag-to-rearrange"); 136 | this.addNavButton("search", "Filter items", function (event: MouseEvent) { 137 | event.preventDefault(); 138 | let value = !this.hasClass("is-active"); 139 | this.toggleClass("is-active", value); 140 | let filterEl = document.body.querySelector( 141 | '.workspace-leaf-content[data-type="file-explorer"] .search-input-container > input' 142 | ) as HTMLInputElement; 143 | 144 | if (filterEl && !value) { 145 | filterEl.parentElement?.hide(); 146 | filterEl.value = ""; 147 | filterEl.dispatchEvent(new Event("input")); 148 | } else { 149 | filterEl?.parentElement?.show(); 150 | filterEl?.focus(); 151 | } 152 | plugin.app.workspace.trigger("file-explorer-draggable-change", value); 153 | }); 154 | return sortEl; 155 | }; 156 | -------------------------------------------------------------------------------- /src/file-explorer/fuzzy-filter.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nothingislost/obsidian-bartender/d624cae73ad029ed1253ea7cd3654685aa240780/src/file-explorer/fuzzy-filter.ts -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import Fuse from "fuse.js"; 2 | import { around } from "monkey-around"; 3 | import { 4 | ChildElement, 5 | Component, 6 | FileExplorerHeader, 7 | FileExplorerView, 8 | Platform, 9 | Plugin, 10 | RootElements, 11 | Scope, 12 | setIcon, 13 | SplitDirection, 14 | TFolder, 15 | Vault, 16 | View, 17 | ViewCreator, 18 | Workspace, 19 | WorkspaceItem, 20 | WorkspaceLeaf, 21 | WorkspaceSplit, 22 | WorkspaceTabs, 23 | requireApiVersion, 24 | } from "obsidian"; 25 | 26 | import Sortable, { MultiDrag } from "sortablejs"; 27 | import { addSortButton, folderSort } from "./file-explorer/custom-sort"; 28 | import { BartenderSettings, DEFAULT_SETTINGS, SettingTab } from "./settings/settings"; 29 | import { 30 | generateId, 31 | GenerateIdOptions, 32 | getFn, 33 | getItems, 34 | getNextSiblings, 35 | getPreviousSiblings, 36 | highlight, 37 | reorderArray, 38 | } from "./utils"; 39 | 40 | Sortable.mount(new MultiDrag()); 41 | 42 | const STATUS_BAR_SELECTOR = "body > div.app-container div.status-bar"; 43 | const RIBBON_BAR_SELECTOR = "body > div.app-container div.side-dock-actions"; 44 | const DRAG_DELAY = Platform.isMobile ? 200 : 200; 45 | const ANIMATION_DURATION = 500; 46 | 47 | export default class BartenderPlugin extends Plugin { 48 | statusBarSorter: Sortable; 49 | ribbonBarSorter: Sortable; 50 | fileSorter: Sortable; 51 | separator: HTMLElement; 52 | settings: BartenderSettings; 53 | settingsTab: SettingTab; 54 | 55 | async onload() { 56 | await this.loadSettings(); 57 | this.registerMonkeyPatches(); 58 | this.registerEventHandlers(); 59 | this.registerSettingsTab(); 60 | this.initialize(); 61 | } 62 | 63 | async loadSettings() { 64 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 65 | } 66 | 67 | async saveSettings() { 68 | await this.saveData(this.settings); 69 | } 70 | 71 | patchFileExplorerFolder() { 72 | let plugin = this; 73 | let leaf = this.app.workspace.getLeaf(true); 74 | let fileExplorer = this.app.viewRegistry.viewByType["file-explorer"](leaf) as FileExplorerView; 75 | // @ts-ignore 76 | let tmpFolder = new TFolder(Vault, ""); 77 | let Folder = fileExplorer.createFolderDom(tmpFolder).constructor; 78 | this.register( 79 | around(Folder.prototype, { 80 | sort(old: any) { 81 | return function (...args: any[]) { 82 | let order = plugin.settings.fileExplorerOrder[this.file.path]; 83 | if (this.fileExplorer.sortOrder === "custom") { 84 | return folderSort.call(this, order, ...args); 85 | } else { 86 | return old.call(this, ...args); 87 | } 88 | }; 89 | }, 90 | }) 91 | ); 92 | leaf.detach(); 93 | } 94 | 95 | initialize() { 96 | this.app.workspace.onLayoutReady(() => { 97 | this.patchFileExplorerFolder(); 98 | setTimeout( 99 | () => { 100 | if (Platform.isDesktop) { 101 | // add sorter to the status bar 102 | this.insertSeparator(STATUS_BAR_SELECTOR, "status-bar-item", true, 16); 103 | this.setStatusBarSorter(); 104 | 105 | // add sorter to the sidebar tabs 106 | if (requireApiVersion && !requireApiVersion("0.15.3")) { 107 | let left = (this.app.workspace.leftSplit as WorkspaceSplit).children; 108 | let right = (this.app.workspace.rightSplit as WorkspaceSplit).children; 109 | left.concat(right).forEach(child => { 110 | if (child.hasOwnProperty("tabsInnerEl") && !child.iconSorter) { 111 | child.iconSorter = this.setTabBarSorter(child.tabsInnerEl, child); 112 | } 113 | }); 114 | } 115 | } 116 | 117 | // add file explorer sorter 118 | this.setFileExplorerSorter(); 119 | 120 | // add sorter to the left sidebar ribbon 121 | this.insertSeparator(RIBBON_BAR_SELECTOR, "side-dock-ribbon-action", false, 18); 122 | this.setRibbonBarSorter(); 123 | 124 | // add sorter to all view actions icon groups 125 | this.app.workspace.iterateRootLeaves(leaf => { 126 | if (leaf?.view?.hasOwnProperty("actionsEl") && !leaf?.view?.hasOwnProperty("iconSorter")) { 127 | leaf.view.iconSorter = this.setViewActionSorter(leaf.view.actionsEl, leaf.view); 128 | } 129 | }); 130 | }, 131 | Platform.isMobile ? 3000 : 400 132 | ); // give time for plugins like Customizable Page Header to add their icons 133 | }); 134 | } 135 | 136 | registerSettingsTab() { 137 | this.settingsTab = new SettingTab(this.app, this); 138 | this.addSettingTab(this.settingsTab); 139 | } 140 | 141 | clearFileExplorerFilter() { 142 | const fileExplorer = this.getFileExplorer(); 143 | let fileExplorerFilterEl: HTMLInputElement | null = document.body.querySelector( 144 | '.workspace-leaf-content[data-type="file-explorer"] .search-input-container > input' 145 | ); 146 | fileExplorerFilterEl && (fileExplorerFilterEl.value = ""); 147 | fileExplorer.dom.infinityScroll.filter = ""; 148 | fileExplorer.dom.infinityScroll.compute(); 149 | } 150 | 151 | fileExplorerFilter = function () { 152 | const supportsVirtualChildren = requireApiVersion && requireApiVersion("0.15.0"); 153 | let fileExplorer = this?.rootEl?.fileExplorer; 154 | if (!fileExplorer) return; 155 | const _children = supportsVirtualChildren ? this.rootEl?.vChildren._children : this.rootEl?.children; 156 | if (!_children) return; 157 | if (this.filter?.length >= 1) { 158 | if (!this.filtered) { 159 | this.rootEl._children = _children; 160 | this.filtered = true; 161 | } 162 | const options = { 163 | includeScore: true, 164 | includeMatches: true, 165 | useExtendedSearch: true, 166 | getFn: getFn, 167 | threshold: 0.1, 168 | ignoreLocation: true, 169 | keys: ["file.path"], 170 | }; 171 | let flattenedItems = getItems(this.rootEl._children); 172 | const fuse = new Fuse(flattenedItems, options); 173 | const maxResults = 200; 174 | let results = fuse.search(this.filter).slice(0, maxResults); 175 | if (supportsVirtualChildren) { 176 | this.rootEl.vChildren._children = highlight(results); 177 | } else { 178 | this.rootEl.children = highlight(results); 179 | } 180 | } else if (this.filter?.length < 1 && this.filtered) { 181 | if (this.rootEl._children) { 182 | if (supportsVirtualChildren) { 183 | this.rootEl.vChildren._children = this.rootEl._children; 184 | } else { 185 | this.rootEl.children = this.rootEl._children; 186 | } 187 | } 188 | 189 | let flattenedItems = getItems(this.rootEl._children); 190 | flattenedItems.map((match: ChildElement) => { 191 | if ((match).innerEl.origContent) { 192 | match.innerEl.setText((match).innerEl.origContent); 193 | delete (match).innerEl.origContent; 194 | match.innerEl.removeClass("has-matches"); 195 | } 196 | }); 197 | 198 | this.filtered = false; 199 | } 200 | }; 201 | 202 | registerEventHandlers() { 203 | this.registerEvent( 204 | this.app.workspace.on("file-explorer-draggable-change", value => { 205 | this.toggleFileExplorerSorters(value); 206 | }) 207 | ); 208 | this.registerEvent( 209 | this.app.workspace.on("file-explorer-sort-change", (sortMethod: string) => { 210 | if (sortMethod === "custom") { 211 | setTimeout(() => { 212 | this.setFileExplorerSorter(); 213 | }, 10); 214 | } else { 215 | this.cleanupFileExplorerSorters(); 216 | } 217 | }) 218 | ); 219 | this.registerEvent( 220 | this.app.workspace.on("file-explorer-load", (fileExplorer: FileExplorerView) => { 221 | setTimeout(() => { 222 | this.setFileExplorerSorter(fileExplorer); 223 | }, 1000); 224 | }) 225 | ); 226 | this.registerEvent( 227 | this.app.workspace.on("bartender-leaf-split", (originLeaf: WorkspaceItem, newLeaf: WorkspaceItem) => { 228 | let element: HTMLElement = newLeaf.tabsInnerEl as HTMLElement; 229 | if (newLeaf.type === "tabs" && newLeaf instanceof WorkspaceTabs) { 230 | if (requireApiVersion && !requireApiVersion("0.15.3")) { 231 | this.setTabBarSorter(element, newLeaf); 232 | } 233 | } 234 | }) 235 | ); 236 | 237 | this.registerEvent( 238 | this.app.workspace.on("ribbon-bar-updated", () => { 239 | setTimeout(() => { 240 | if (this.settings.ribbonBarOrder && this.ribbonBarSorter) { 241 | this.setElementIDs(this.ribbonBarSorter.el, { useClass: true, useAria: true, useIcon: true }); 242 | this.ribbonBarSorter.sort(this.settings.ribbonBarOrder); 243 | } 244 | }, 0); 245 | }) 246 | ); 247 | this.registerEvent( 248 | this.app.workspace.on("status-bar-updated", () => { 249 | setTimeout(() => { 250 | if (this.settings.statusBarOrder && this.statusBarSorter) { 251 | this.setElementIDs(this.statusBarSorter.el, { useClass: true, useIcon: true }); 252 | this.statusBarSorter.sort(this.settings.statusBarOrder); 253 | } 254 | }, 0); 255 | }) 256 | ); 257 | } 258 | 259 | registerMonkeyPatches() { 260 | const plugin = this; 261 | this.register( 262 | around(this.app.viewRegistry.constructor.prototype, { 263 | registerView(old: any) { 264 | return function (type: string, viewCreator: ViewCreator, ...args: unknown[]) { 265 | plugin.app.workspace.trigger("view-registered", type, viewCreator); 266 | return old.call(this, type, viewCreator, ...args); 267 | }; 268 | }, 269 | }) 270 | ); 271 | // This catches the initial FE view registration so that we can patch the sort button creation logic before 272 | // the button is created 273 | // TODO: Add conditional logic to patch the sort button even if we don't catch the initial startup 274 | // 1) If layout ready, get the existing FE instance, create a patched button, and hide the existing button 275 | // 2) Patch `addSortButton` to emit an event 276 | // 3) On event, 277 | if (!this.app.workspace.layoutReady) { 278 | let eventRef = this.app.workspace.on("view-registered", (type: string, viewCreator: ViewCreator) => { 279 | if (type !== "file-explorer") return; 280 | this.app.workspace.offref(eventRef); 281 | // @ts-ignore we need a leaf before any leafs exists in the workspace, so we create one from scratch 282 | let leaf = new WorkspaceLeaf(plugin.app); 283 | let fileExplorer = viewCreator(leaf) as FileExplorerView; 284 | this.patchFileExplorer(fileExplorer); 285 | }); 286 | } else { 287 | let fileExplorer = this.getFileExplorer(); 288 | this.patchFileExplorer(fileExplorer); 289 | } 290 | this.register( 291 | around(View.prototype, { 292 | onunload(old: any) { 293 | return function (...args) { 294 | try { 295 | if (this.iconSorter) { 296 | this.iconSorter.destroy(); 297 | this.iconSorter = null; 298 | } 299 | } catch {} 300 | return old.call(this, ...args); 301 | }; 302 | }, 303 | onload(old: any) { 304 | return function (...args) { 305 | setTimeout(() => { 306 | if (this.app.workspace.layoutReady) { 307 | try { 308 | if (!(this.leaf.parentSplit instanceof WorkspaceTabs)) { 309 | if (this.hasOwnProperty("actionsEl") && !this.iconSorter) { 310 | this.iconSorter = plugin.setViewActionSorter(this.actionsEl, this); 311 | } 312 | } 313 | } catch {} 314 | } 315 | }, 200); 316 | 317 | return old.call(this, ...args); 318 | }; 319 | }, 320 | }) 321 | ); 322 | if (Platform.isDesktop) { 323 | this.register( 324 | around(HTMLDivElement.prototype, { 325 | addEventListener(old: any) { 326 | return function ( 327 | type: string, 328 | listener: EventListenerOrEventListenerObject, 329 | options?: boolean | AddEventListenerOptions 330 | ) { 331 | if (type === "mousedown" && listener instanceof Function && this.hasClass("workspace-tab-header")) { 332 | let origListener = listener; 333 | listener = event => { 334 | if (event instanceof MouseEvent && (event?.altKey || event?.metaKey)) return; 335 | else origListener(event); 336 | }; 337 | } 338 | const result = old.call(this, type, listener, options); 339 | return result; 340 | }; 341 | }, 342 | }) 343 | ); 344 | } 345 | this.register( 346 | around(Workspace.prototype, { 347 | splitLeaf(old: any) { 348 | return function ( 349 | source: WorkspaceItem, 350 | newLeaf: WorkspaceItem, 351 | direction?: SplitDirection, 352 | before?: boolean, 353 | ...args 354 | ) { 355 | let result = old.call(this, source, newLeaf, direction, before, ...args); 356 | this.trigger("bartender-leaf-split", source, newLeaf); 357 | return result; 358 | }; 359 | }, 360 | changeLayout(old: any) { 361 | return async function (workspace: any, ...args): Promise { 362 | let result = await old.call(this, workspace, ...args); 363 | this.trigger("bartender-workspace-change"); 364 | return result; 365 | }; 366 | }, 367 | }) 368 | ); 369 | this.register( 370 | around(Plugin.prototype, { 371 | addStatusBarItem(old: any) { 372 | return function (...args): HTMLElement { 373 | const result = old.call(this, ...args); 374 | this.app.workspace.trigger("status-bar-updated"); 375 | return result; 376 | }; 377 | }, 378 | addRibbonIcon(old: any) { 379 | return function (...args): HTMLElement { 380 | const result = old.call(this, ...args); 381 | this.app.workspace.trigger("ribbon-bar-updated"); 382 | return result; 383 | }; 384 | }, 385 | }) 386 | ); 387 | } 388 | 389 | patchFileExplorer(fileExplorer: FileExplorerView) { 390 | let plugin = this; 391 | if (fileExplorer) { 392 | let InfinityScroll = fileExplorer.dom.infinityScroll.constructor; 393 | // register clear first so that it gets called first onunload 394 | this.register(() => this.clearFileExplorerFilter()); 395 | this.register( 396 | around(InfinityScroll.prototype, { 397 | compute(old: any) { 398 | return function (...args: any[]) { 399 | try { 400 | if (this.scrollEl.hasClass("nav-files-container")) { 401 | plugin.fileExplorerFilter.call(this); 402 | } 403 | } catch (err) { 404 | console.log(err) 405 | } 406 | const result = old.call(this, ...args); 407 | return result; 408 | }; 409 | }, 410 | }) 411 | ); 412 | this.register( 413 | around(fileExplorer.headerDom.constructor.prototype, { 414 | addSortButton(old: any) { 415 | return function (...args: any[]) { 416 | if (this.navHeaderEl?.parentElement?.dataset?.type === "file-explorer") { 417 | plugin.setFileExplorerFilter(this); 418 | return addSortButton.call(this, ...args); 419 | } else { 420 | return old.call(this, ...args); 421 | } 422 | }; 423 | }, 424 | }) 425 | ); 426 | } 427 | } 428 | 429 | insertSeparator(selector: string, className: string, rtl: Boolean, glyphSize: number = 16) { 430 | let elements = document.body.querySelectorAll(selector); 431 | elements.forEach((el: HTMLElement) => { 432 | let getSiblings = rtl ? getPreviousSiblings : getNextSiblings; 433 | if (el) { 434 | let separator = el.createDiv(`${className} separator`); 435 | rtl && el.prepend(separator); 436 | let glyphEl = separator.createDiv("glyph"); 437 | let glyphName = "plus-with-circle"; // this gets replaced using CSS 438 | // TODO: Handle mobile icon size differences? 439 | setIcon(glyphEl, glyphName, glyphSize); 440 | separator.addClass("is-collapsed"); 441 | this.register(() => separator.detach()); 442 | let hideTimeout: NodeJS.Timeout; 443 | separator.onClickEvent((event: MouseEvent) => { 444 | if (separator.hasClass("is-collapsed")) { 445 | Array.from(el.children).forEach(el => el.removeClass("is-hidden")); 446 | separator.removeClass("is-collapsed"); 447 | } else { 448 | getSiblings(separator).forEach(el => el.addClass("is-hidden")); 449 | separator.addClass("is-collapsed"); 450 | } 451 | }); 452 | el.onmouseenter = ev => { 453 | hideTimeout && clearTimeout(hideTimeout); 454 | }; 455 | el.onmouseleave = ev => { 456 | if (this.settings.autoHide) { 457 | hideTimeout = setTimeout(() => { 458 | getSiblings(separator).forEach(el => el.addClass("is-hidden")); 459 | separator.addClass("is-collapsed"); 460 | }, this.settings.autoHideDelay); 461 | } 462 | }; 463 | setTimeout(() => { 464 | getSiblings(separator).forEach(el => el.addClass("is-hidden")); 465 | separator.addClass("is-collapsed"); 466 | }, 0); 467 | } 468 | }); 469 | } 470 | 471 | setElementIDs(parentEl: HTMLElement, options: GenerateIdOptions) { 472 | Array.from(parentEl.children).forEach(child => { 473 | if (child instanceof HTMLElement) { 474 | if (!child.getAttribute("data-id")) { 475 | child.setAttribute("data-id", generateId(child, options)); 476 | } 477 | } 478 | }); 479 | } 480 | 481 | setTabBarSorter(element: HTMLElement, leaf: WorkspaceTabs) { 482 | this.setElementIDs(element, { useClass: true, useIcon: true }); 483 | let sorter = Sortable.create(element, { 484 | group: "leftTabBar", 485 | dataIdAttr: "data-id", 486 | chosenClass: "bt-sortable-chosen", 487 | delay: Platform.isMobile ? 200 : this.settings.dragDelay, 488 | dropBubble: false, 489 | dragoverBubble: false, 490 | animation: ANIMATION_DURATION, 491 | onChoose: () => element.parentElement?.addClass("is-dragging"), 492 | onUnchoose: () => element.parentElement?.removeClass("is-dragging"), 493 | onStart: () => { 494 | document.body.addClass("is-dragging"); 495 | element.querySelector(".separator")?.removeClass("is-collapsed"); 496 | Array.from(element.children).forEach(el => el.removeClass("is-hidden")); 497 | }, 498 | onEnd: event => { 499 | document.body.removeClass("is-dragging"); 500 | if (event.oldIndex !== undefined && event.newIndex !== undefined) { 501 | reorderArray(leaf.children, event.oldIndex, event.newIndex); 502 | leaf.currentTab = event.newIndex; 503 | leaf.recomputeChildrenDimensions(); 504 | } 505 | this.app.workspace.requestSaveLayout(); 506 | }, 507 | }); 508 | return sorter; 509 | } 510 | 511 | setStatusBarSorter() { 512 | let el = document.body.querySelector("body > div.app-container > div.status-bar") as HTMLElement; 513 | if (el) { 514 | this.setElementIDs(el, { useClass: true, useAria: true, useIcon: true }); 515 | this.statusBarSorter = Sortable.create(el, { 516 | group: "statusBar", 517 | dataIdAttr: "data-id", 518 | chosenClass: "bt-sortable-chosen", 519 | delay: Platform.isMobile ? 200 : this.settings.dragDelay, 520 | animation: ANIMATION_DURATION, 521 | onChoose: () => { 522 | Array.from(el.children).forEach(el => el.removeClass("is-hidden")); 523 | }, 524 | onStart: () => { 525 | el.querySelector(".separator")?.removeClass("is-collapsed"); 526 | Array.from(el.children).forEach(el => el.removeClass("is-hidden")); 527 | }, 528 | store: { 529 | get: sortable => { 530 | return this.settings.statusBarOrder; 531 | }, 532 | set: s => { 533 | this.settings.statusBarOrder = s.toArray(); 534 | this.saveSettings(); 535 | }, 536 | }, 537 | }); 538 | } 539 | } 540 | 541 | setViewActionSorter(el: HTMLElement, view: View): Sortable | undefined { 542 | this.setElementIDs(el, { useClass: true, useIcon: true }); 543 | let hasSorter = Object.values(el).find(value => value?.hasOwnProperty("nativeDraggable")); 544 | if (hasSorter) return undefined; 545 | let viewType = view?.getViewType() || "unknown"; 546 | let sortable = new Sortable(el, { 547 | group: "actionBar", 548 | dataIdAttr: "data-id", 549 | chosenClass: "bt-sortable-chosen", 550 | delay: Platform.isMobile ? 200 : this.settings.dragDelay, 551 | sort: true, 552 | animation: ANIMATION_DURATION, 553 | onStart: () => { 554 | el.querySelector(".separator")?.removeClass("is-collapsed"); 555 | Array.from(el.children).forEach(el => el.removeClass("is-hidden")); 556 | }, 557 | store: { 558 | get: () => { 559 | return this.settings.actionBarOrder[viewType]; 560 | }, 561 | set: s => { 562 | this.settings.actionBarOrder[viewType] = s.toArray(); 563 | this.saveSettings(); 564 | }, 565 | }, 566 | }); 567 | return sortable; 568 | } 569 | 570 | setRibbonBarSorter() { 571 | let el = document.body.querySelector("body > div.app-container div.side-dock-actions") as HTMLElement; 572 | if (el) { 573 | this.setElementIDs(el, { useClass: true, useAria: true, useIcon: true }); 574 | this.ribbonBarSorter = Sortable.create(el, { 575 | group: "ribbonBar", 576 | dataIdAttr: "data-id", 577 | delay: Platform.isMobile ? 200 : this.settings.dragDelay, 578 | chosenClass: "bt-sortable-chosen", 579 | animation: ANIMATION_DURATION, 580 | onChoose: () => { 581 | Array.from(el.children).forEach(el => el.removeClass("is-hidden")); 582 | }, 583 | onStart: () => { 584 | el.querySelector(".separator")?.removeClass("is-collapsed"); 585 | Array.from(el.children).forEach(el => el.removeClass("is-hidden")); 586 | }, 587 | store: { 588 | get: sortable => { 589 | return this.settings.ribbonBarOrder; 590 | }, 591 | set: s => { 592 | this.settings.ribbonBarOrder = s.toArray(); 593 | this.saveSettings(); 594 | }, 595 | }, 596 | }); 597 | } 598 | } 599 | 600 | setFileExplorerFilter(headerDom: FileExplorerHeader) { 601 | let fileExplorerNav = headerDom.navHeaderEl; 602 | if (fileExplorerNav) { 603 | let fileExplorerFilter = fileExplorerNav.createDiv("search-input-container"); 604 | fileExplorerNav.insertAdjacentElement("afterend", fileExplorerFilter); 605 | let fileExplorerFilterInput = fileExplorerFilter.createEl("input"); 606 | fileExplorerFilterInput.placeholder = "Type to filter..."; 607 | fileExplorerFilterInput.type = "text"; 608 | fileExplorerFilter.hide(); 609 | let filterScope = new Scope(this.app.scope); 610 | fileExplorerFilterInput.onfocus = () => { 611 | this.app.keymap.pushScope(filterScope); 612 | } 613 | fileExplorerFilterInput.onblur = () => { 614 | this.app.keymap.popScope(filterScope); 615 | } 616 | fileExplorerFilterInput.oninput = (ev: InputEvent) => { 617 | let fileExplorer = this.getFileExplorer(); 618 | if (ev.target instanceof HTMLInputElement) { 619 | if (ev.target.value.length) { 620 | clearButtonEl.show(); 621 | } else { 622 | clearButtonEl.hide(); 623 | } 624 | fileExplorer.dom.infinityScroll.filter = ev.target.value; 625 | } 626 | fileExplorer.dom.infinityScroll.compute(); 627 | }; 628 | let clearButtonEl = fileExplorerFilter.createDiv("search-input-clear-button", function (el) { 629 | el.addEventListener("click", function () { 630 | (fileExplorerFilterInput.value = ""), clearButtonEl.hide(); 631 | fileExplorerFilterInput.focus(); 632 | fileExplorerFilterInput.dispatchEvent(new Event("input")); 633 | }), 634 | el.hide(); 635 | }); 636 | } 637 | } 638 | 639 | setFileExplorerSorter(fileExplorer?: FileExplorerView) { 640 | // TODO: Register sorter on new folder creation 641 | // TODO: Unregister sorter on folder deletion 642 | if (!fileExplorer) fileExplorer = this.getFileExplorer(); 643 | if (!fileExplorer || fileExplorer.sortOrder !== "custom" || fileExplorer.hasCustomSorter) return; 644 | let roots = this.getRootFolders(fileExplorer); 645 | if (!roots || !roots.length) return; 646 | for (let root of roots) { 647 | let el = root?.childrenEl; 648 | if (!el) continue; 649 | let draggedItems: HTMLElement[]; 650 | fileExplorer.hasCustomSorter = true; 651 | let dragEnabled = document.body.querySelector("div.nav-action-button.drag-to-rearrange")?.hasClass("is-active") 652 | ? true 653 | : false; 654 | root.sorter = Sortable.create(el!, { 655 | group: "fileExplorer" + root.file.path, 656 | forceFallback: true, 657 | multiDrag: true, 658 | // @ts-ignore 659 | multiDragKey: "alt", 660 | // selectedClass: "is-selected", 661 | chosenClass: "bt-sortable-chosen", 662 | delay: 0, 663 | disabled: !dragEnabled, 664 | sort: dragEnabled, // init with dragging disabled. the nav bar button will toggle on/off 665 | animation: ANIMATION_DURATION, 666 | onStart: evt => { 667 | if (evt.items.length) { 668 | draggedItems = evt.items; 669 | } else { 670 | draggedItems = [evt.item]; 671 | } 672 | }, 673 | onMove: evt => { 674 | // TODO: Refactor this 675 | // Responsible for updating the internal Obsidian array that contains the file item order 676 | // Without this logic, reordering is ephemeral and will be undone by Obisidian's native processes 677 | const supportsVirtualChildren = requireApiVersion && requireApiVersion("0.15.0"); 678 | let _children = supportsVirtualChildren ? root.vChildren?._children : root.children; 679 | if (!_children || !draggedItems?.length) return; 680 | let children = _children.map(child => child.el); 681 | let adjacentEl = evt.related; 682 | let targetIndex = children.indexOf(adjacentEl); 683 | let firstItem = draggedItems.first(); 684 | let firstItemIndex = children.indexOf(firstItem!); 685 | let _draggedItems = draggedItems.slice(); 686 | if (firstItemIndex > targetIndex) _draggedItems.reverse(); 687 | for (let item of _draggedItems) { 688 | let itemIndex = children.indexOf(item); 689 | _children = reorderArray(_children, itemIndex, targetIndex); 690 | children = reorderArray(children, itemIndex, targetIndex); 691 | } 692 | this.settings.fileExplorerOrder[root.file.path] = _children.map(child => child.file.path); 693 | this.saveSettings(); 694 | // return !adjacentEl.hasClass("nav-folder"); 695 | }, 696 | onEnd: evt => { 697 | draggedItems = []; 698 | document.querySelector("body>div.drag-ghost")?.detach(); 699 | }, 700 | }); 701 | } 702 | } 703 | 704 | getFileExplorer() { 705 | let fileExplorer: FileExplorerView | undefined = this.app.workspace.getLeavesOfType("file-explorer")?.first() 706 | ?.view as unknown as FileExplorerView; 707 | return fileExplorer; 708 | } 709 | 710 | getRootFolders(fileExplorer?: FileExplorerView): [RootElements | ChildElement] | undefined { 711 | if (!fileExplorer) fileExplorer = this.getFileExplorer(); 712 | if (!fileExplorer) return; 713 | let root = fileExplorer.dom?.infinityScroll?.rootEl; 714 | let roots = root && this.traverseRoots(root); 715 | return roots; 716 | } 717 | 718 | traverseRoots(root: RootElements | ChildElement, items?: [RootElements | ChildElement]) { 719 | if (!items) items = [root]; 720 | const supportsVirtualChildren = requireApiVersion && requireApiVersion("0.15.0"); 721 | const _children = supportsVirtualChildren ? root.vChildren?._children : root.children; 722 | for (let child of _children || []) { 723 | if (child.children || child.vChildren?._children) { 724 | items.push(child); 725 | } 726 | this.traverseRoots(child, items); 727 | } 728 | return items; 729 | } 730 | 731 | toggleFileExplorerSorters(enable: boolean) { 732 | let fileExplorer = this.getFileExplorer(); 733 | let roots = this.getRootFolders(fileExplorer); 734 | if (roots?.length) { 735 | for (let root of roots) { 736 | if (root.sorter) { 737 | root.sorter.option("sort", enable); 738 | root.sorter.option("disabled", !enable); 739 | } 740 | } 741 | } 742 | } 743 | 744 | cleanupFileExplorerSorters() { 745 | let fileExplorer = this.getFileExplorer(); 746 | let roots = this.getRootFolders(fileExplorer); 747 | if (roots?.length) { 748 | for (let root of roots) { 749 | if (root.sorter) { 750 | root.sorter.destroy(); 751 | delete root.sorter; 752 | Object.keys(root.childrenEl!).forEach( 753 | key => key.startsWith("Sortable") && delete (root.childrenEl as any)[key] 754 | ); 755 | // sortable.destroy removes all of the draggble attributes :( so we put them back 756 | root 757 | .childrenEl!.querySelectorAll("div.nav-file-title") 758 | .forEach((el: HTMLDivElement) => (el.draggable = true)); 759 | root 760 | .childrenEl!.querySelectorAll("div.nav-folder-title") 761 | .forEach((el: HTMLDivElement) => (el.draggable = true)); 762 | } 763 | } 764 | } 765 | delete fileExplorer.hasCustomSorter; 766 | 767 | // unset "custom" file explorer sort 768 | if (this.app.vault.getConfig("fileSortOrder") === "custom") { 769 | fileExplorer.setSortOrder("alphabetical"); 770 | } 771 | } 772 | 773 | onunload(): void { 774 | this.statusBarSorter?.destroy(); 775 | this.ribbonBarSorter?.destroy(); 776 | this.app.workspace.iterateAllLeaves(leaf => { 777 | let sorterParent: View | WorkspaceTabs | WorkspaceLeaf | boolean; 778 | if ( 779 | (sorterParent = leaf?.iconSorter ? leaf : false) || 780 | (sorterParent = leaf?.view?.iconSorter ? leaf.view : false) || 781 | (sorterParent = 782 | leaf?.parentSplit instanceof WorkspaceTabs && leaf?.parentSplit?.iconSorter ? leaf?.parentSplit : false) 783 | ) { 784 | try { 785 | sorterParent.iconSorter?.destroy(); 786 | } catch (err) { 787 | } finally { 788 | delete sorterParent.iconSorter; 789 | } 790 | } 791 | }); 792 | 793 | // clean up file explorer sorters 794 | this.cleanupFileExplorerSorters(); 795 | } 796 | } 797 | -------------------------------------------------------------------------------- /src/settings/settings.ts: -------------------------------------------------------------------------------- 1 | import { App, PluginSettingTab, Setting } from "obsidian"; 2 | import BartenderPlugin from "../main"; 3 | 4 | export interface BartenderSettings { 5 | statusBarOrder: string[]; 6 | ribbonBarOrder: string[]; 7 | fileExplorerOrder: Record; 8 | actionBarOrder: Record; 9 | autoHide: boolean; 10 | autoHideDelay: number; 11 | dragDelay: number; 12 | } 13 | 14 | export const DEFAULT_SETTINGS: BartenderSettings = { 15 | statusBarOrder: [], 16 | ribbonBarOrder: [], 17 | fileExplorerOrder: {}, 18 | actionBarOrder: {}, 19 | autoHide: false, 20 | autoHideDelay: 2000, 21 | dragDelay: 200, 22 | }; 23 | 24 | export class SettingTab extends PluginSettingTab { 25 | plugin: BartenderPlugin; 26 | 27 | constructor(app: App, plugin: BartenderPlugin) { 28 | super(app, plugin); 29 | this.plugin = plugin; 30 | } 31 | 32 | hide() {} 33 | 34 | display(): void { 35 | const { containerEl } = this; 36 | 37 | containerEl.empty(); 38 | 39 | new Setting(containerEl) 40 | .setName("Auto Collapse") 41 | .setDesc("Automatically hide ribbon and status bar items once your mouse leaves the icon container") 42 | .addToggle(toggle => 43 | toggle.setValue(this.plugin.settings.autoHide).onChange(value => { 44 | this.plugin.settings.autoHide = value; 45 | this.plugin.saveSettings(); 46 | }) 47 | ); 48 | 49 | new Setting(containerEl) 50 | .setName("Auto Collapse Delay") 51 | .setDesc("How long to wait before auto collapsing hidden icons on the ribbon and status bar") 52 | .addText(textfield => { 53 | textfield.setPlaceholder(String(2000)); 54 | textfield.inputEl.type = "number"; 55 | textfield.setValue(String(this.plugin.settings.autoHideDelay)); 56 | textfield.onChange(async value => { 57 | this.plugin.settings.autoHideDelay = Number(value); 58 | this.plugin.saveSettings(); 59 | }); 60 | }); 61 | 62 | new Setting(containerEl) 63 | .setName("Drag Start Delay (ms)") 64 | .setDesc("How long to wait before triggering the drag behavior after clicking. ⚠️ Requires an app restart.") 65 | .addText(textfield => { 66 | textfield.setPlaceholder(String(200)); 67 | textfield.inputEl.type = "number"; 68 | textfield.setValue(String(this.plugin.settings.dragDelay)); 69 | textfield.onChange(async value => { 70 | this.plugin.settings.dragDelay = Number(value); 71 | this.plugin.saveSettings(); 72 | }); 73 | }); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/types/obsidian.d.ts: -------------------------------------------------------------------------------- 1 | import "obsidian"; 2 | import Sortable from "sortablejs"; 3 | 4 | declare module "obsidian" { 5 | export interface Workspace extends Events { 6 | on(name: "status-bar-updated", callback: () => any, ctx?: any): EventRef; 7 | on(name: "ribbon-bar-updated", callback: () => any, ctx?: any): EventRef; 8 | on(name: "bartender-workspace-change", callback: () => any, ctx?: any): EventRef; 9 | on( 10 | name: "bartender-leaf-split", 11 | callback: (originLeaf: WorkspaceItem, newLeaf: WorkspaceItem) => any, 12 | ctx?: any 13 | ): EventRef; 14 | } 15 | interface Vault { 16 | getConfig(config: String): unknown; 17 | setConfig(config: String, value: any): void; 18 | } 19 | interface View { 20 | actionsEl: HTMLElement; 21 | iconSorter?: Sortable; 22 | } 23 | interface WorkspaceLeaf { 24 | tabHeaderEl: HTMLElement; 25 | parentSplit: WorkspaceSplit; 26 | iconSorter?: Sortable; 27 | } 28 | interface WorkspaceSplit { 29 | children: WorkspaceTabs[]; 30 | } 31 | interface WorkspaceItem { 32 | tabsInnerEl: HTMLElement; 33 | view: View; 34 | type: string; 35 | } 36 | interface WorkspaceTabs { 37 | children: WorkspaceLeaf[]; 38 | component: Component; 39 | currentTab: number; 40 | iconSorter?: Sortable; 41 | recomputeChildrenDimensions(): void; 42 | updateDecorativeCurves(): void; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/types/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { i18n } from "i18next"; 2 | import "obsidian"; 3 | import "sortablejs"; 4 | import Sortable from "sortablejs"; 5 | 6 | declare global { 7 | const i18next: i18n; 8 | } 9 | 10 | declare module "sortablejs" { 11 | interface SortableEvent extends Event { 12 | items: HTMLElement[]; 13 | } 14 | } 15 | declare module "obsidian" { 16 | export interface Workspace extends Events { 17 | on(name: "view-registered", callback: (type: string, viewCreator: ViewCreator) => any, ctx?: any): EventRef; 18 | on(name: "file-explorer-load", callback: (fileExplorer: FileExplorerView) => any, ctx?: any): EventRef; 19 | on(name: "file-explorer-sort-change", callback: (sortMethod: string) => any, ctx?: any): EventRef; 20 | on(name: "infinity-scroll-compute", callback: (infinityScroll: InfinityScroll) => any, ctx?: any): EventRef; 21 | on(name: "file-explorer-draggable-change", callback: (dragEnabled: boolean) => any, ctx?: any): EventRef; 22 | on(name: "file-explorer-filter-change", callback: (filterEnabled: boolean) => any, ctx?: any): EventRef; 23 | } 24 | export interface PluginInstance { 25 | id: string; 26 | } 27 | export interface ViewRegistry { 28 | viewByType: Record unknown>; 29 | isExtensionRegistered(extension: string): boolean; 30 | } 31 | 32 | export interface App { 33 | internalPlugins: InternalPlugins; 34 | viewRegistry: ViewRegistry; 35 | } 36 | export interface InstalledPlugin { 37 | enabled: boolean; 38 | instance: PluginInstance; 39 | } 40 | 41 | export interface InternalPlugins { 42 | plugins: Record; 43 | getPluginById(id: string): InstalledPlugin; 44 | } 45 | export interface FileExplorerView extends View { 46 | dom: FileExplorerViewDom; 47 | createFolderDom(folder: TFolder): FileExplorerFolder; 48 | headerDom: FileExplorerHeader; 49 | sortOrder: string; 50 | hasCustomSorter?: boolean; 51 | dragEnabled: boolean; 52 | setSortOrder(order: String): void; 53 | } 54 | interface FileExplorerHeader { 55 | addSortButton(sorter: (sortType: string) => void, sortOrder: () => string): void; 56 | navHeaderEl: HTMLElement; 57 | } 58 | interface FileExplorerFolder {} 59 | export interface FileExplorerViewDom { 60 | infinityScroll: InfinityScroll; 61 | navFileContainerEl: HTMLElement; 62 | } 63 | export interface InfinityScroll { 64 | rootEl: RootElements; 65 | scrollEl: HTMLElement; 66 | filtered: boolean; 67 | filter: string; 68 | compute(): void; 69 | } 70 | export interface VirtualChildren { 71 | children: ChildElement[]; 72 | _children: ChildElement[]; 73 | owner: ChildElement 74 | } 75 | export interface RootElements { 76 | childrenEl: HTMLElement; 77 | children: ChildElement[]; 78 | _children: ChildElement[]; 79 | vChildren: VirtualChildren; 80 | file: TAbstractFile; 81 | sorter: Sortable; 82 | fileExplorer: FileExplorerView; 83 | } 84 | export interface ChildElement { 85 | el: HTMLElement; 86 | file: TAbstractFile; 87 | fileExplorer: FileExplorerView; 88 | titleEl: HTMLElement; 89 | titleInnerEl: HTMLElement; 90 | children?: ChildElement[]; 91 | vChildren: VirtualChildren; 92 | childrenEl?: HTMLElement; 93 | sorter?: Sortable; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import Fuse from "fuse.js"; 2 | import { ChildElement, requireApiVersion } from "obsidian"; 3 | 4 | export function getPreviousSiblings(el: HTMLElement, filter?: (el: HTMLElement) => boolean): HTMLElement[] { 5 | var sibs = []; 6 | while ((el = el.previousSibling as HTMLElement)) { 7 | if (el.nodeType === 3) continue; // text node 8 | if (!filter || filter(el)) sibs.push(el); 9 | } 10 | return sibs; 11 | } 12 | export function getNextSiblings(el: HTMLElement, filter?: (el: HTMLElement) => boolean): HTMLElement[] { 13 | var sibs = []; 14 | while ((el = el.nextSibling as HTMLElement)) { 15 | if (el.nodeType === 3) continue; // text node 16 | if (!filter || filter(el)) sibs.push(el); 17 | } 18 | return sibs; 19 | } 20 | 21 | export interface GenerateIdOptions { 22 | useTag?: boolean; 23 | useAria?: boolean; 24 | useClass?: boolean; 25 | useIcon?: boolean; 26 | useText?: boolean; 27 | } 28 | 29 | export function generateId(el: HTMLElement, options?: GenerateIdOptions) { 30 | let classes = options?.useClass 31 | ? Array.from(el.classList) 32 | .filter(c => !c.startsWith("is-")) 33 | .sort() 34 | .join(" ") 35 | : ""; 36 | let str = 37 | (options?.useTag ? el.tagName : "") + 38 | (options?.useClass ? classes : "") + 39 | (options?.useText ? el.textContent : "") + 40 | (options?.useAria ? el.getAttr("aria-label") : "") + 41 | (options?.useIcon ? el.querySelector("svg")?.className?.baseVal : ""); 42 | return cyrb53(str); 43 | } 44 | 45 | export function base36(str: string) { 46 | let i = str.length; 47 | let sum = 0; 48 | 49 | while (i--) { 50 | sum += str.charCodeAt(i); 51 | } 52 | return sum.toString(36); 53 | } 54 | 55 | export const cyrb53 = function (str: string, seed = 0) { 56 | let h1 = 0xdeadbeef ^ seed, 57 | h2 = 0x41c6ce57 ^ seed; 58 | for (let i = 0, ch; i < str.length; i++) { 59 | ch = str.charCodeAt(i); 60 | h1 = Math.imul(h1 ^ ch, 2654435761); 61 | h2 = Math.imul(h2 ^ ch, 1597334677); 62 | } 63 | h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); 64 | h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); 65 | return 4294967296 * (2097151 & h2) + (h1 >>> 0).toString(); 66 | }; 67 | 68 | export function reorderArray(array: any[], from: number, to: number, on = 1) { 69 | return array.splice(to, 0, ...array.splice(from, on)), array; 70 | } 71 | 72 | // flatten infinity scroll root elements 73 | 74 | export const getItems = (items: ChildElement[]): ChildElement[] => { 75 | let children: any[] = []; 76 | const supportsVirtualChildren = requireApiVersion && requireApiVersion("0.15.0"); 77 | let _items; 78 | if (supportsVirtualChildren) { 79 | _items = items 80 | .reduce((res, item) => { 81 | if (item.vChildren?._children) { 82 | children = [...children, ...item.vChildren._children]; 83 | } else { 84 | res.push(item); 85 | } 86 | return res; 87 | }, [] as ChildElement[]) 88 | .concat(children.length ? getItems(children) : children); 89 | } else { 90 | _items = items 91 | .reduce((res, item) => { 92 | if (item.children) { 93 | children = [...children, ...item.children]; 94 | } else { 95 | res.push(item); 96 | } 97 | return res; 98 | }, [] as ChildElement[]) 99 | .concat(children.length ? getItems(children) : children); 100 | } 101 | return _items; 102 | }; 103 | 104 | // highlight fuzzy filter matches 105 | 106 | type NestedObject = { [key: string]: string | NestedObject }; 107 | 108 | export const highlight = (fuseSearchResult: any, highlightClassName: string = "suggestion-highlight") => { 109 | const set = (obj: NestedObject, path: string, value: any) => { 110 | const pathValue = path.split("."); 111 | let i; 112 | 113 | for (i = 0; i < pathValue.length - 1; i++) { 114 | obj = obj[pathValue[i]] as NestedObject; 115 | } 116 | 117 | obj[pathValue[i]] = value; 118 | }; 119 | 120 | const generateHighlightedText = (inputText: string, regions: number[][] = []) => { 121 | let result = regions 122 | .reduce((str, [start, end]) => { 123 | str[start] = `${str[start]}`; 124 | str[end] = `${str[end]}`; 125 | return str; 126 | }, inputText.split("")) 127 | .join(""); // .replace(/.md$/, ""); 128 | 129 | return result; 130 | }; 131 | 132 | return fuseSearchResult 133 | .filter(({ matches }: any) => matches && matches.length) 134 | .map(({ item, matches }: any) => { 135 | const highlightedItem = { ...item }; 136 | matches.forEach((match: any) => { 137 | if (!highlightedItem.innerEl.origContent) 138 | highlightedItem.innerEl.origContent = highlightedItem.innerEl.textContent; 139 | set(highlightedItem, "innerEl.innerHTML", generateHighlightedText(match.value, match.indices)); 140 | highlightedItem.innerEl?.addClass("has-matches"); 141 | }); 142 | 143 | return highlightedItem; 144 | }); 145 | }; 146 | 147 | export function removeExt(obj: any) { 148 | if (typeof obj === "string" || obj instanceof String) { 149 | return obj.replace(/.md$/, ""); 150 | } 151 | return obj; 152 | } 153 | 154 | export function getFn(obj: any, path: string[]) { 155 | var value = Fuse.config.getFn(obj, path); 156 | if (Array.isArray(value)) { 157 | return value.map(el => removeExt(el)); 158 | } 159 | return removeExt(value); 160 | } 161 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --left-indicator: url('data:image/svg+xml;utf8,'); 3 | --right-indicator: url('data:image/svg+xml;utf8,'); 4 | --up-indicator: url('data:image/svg+xml;utf8,'); 5 | --down-indicator: url('data:image/svg+xml;utf8,'); 6 | } 7 | 8 | .side-dock-actions .sortable-ghost, 9 | .status-bar .sortable-ghost, 10 | .view-actions .sortable-ghost, 11 | .workspace-tab-header-container .sortable-ghost { 12 | visibility: hidden; 13 | } 14 | 15 | .side-dock-ribbon div.separator { 16 | cursor: grab; 17 | padding: 0px 4px; 18 | display: flex; 19 | place-items: center; 20 | justify-content: center; 21 | height: 1em; 22 | } 23 | 24 | div.separator svg { 25 | background-color: currentColor; 26 | } 27 | 28 | .side-dock-ribbon div.separator .glyph { 29 | /* margin-top: -4px; */ 30 | display: flex; 31 | align-self: flex-end; 32 | } 33 | 34 | .side-dock-ribbon div.separator:hover { 35 | /* some themes highlight the hovered icon and it looks bad on the separator */ 36 | background: none; 37 | } 38 | 39 | .side-dock-ribbon div.separator svg { 40 | -webkit-mask-image: var(--up-indicator); 41 | } 42 | .side-dock-ribbon div.separator.is-collapsed.bt-sortable-chosen svg { 43 | /* so that the icon updates to show the expand icon on drag start */ 44 | -webkit-mask-image: var(--up-indicator); 45 | } 46 | .side-dock-ribbon div.separator.is-collapsed svg { 47 | -webkit-mask-image: var(--down-indicator); 48 | } 49 | 50 | /* for minimal floating ribbon support */ 51 | 52 | .hider-ribbon .side-dock-ribbon div.separator { 53 | height: 26px; 54 | } 55 | 56 | .hider-ribbon .side-dock-ribbon div.separator .glyph { 57 | display: flex; 58 | align-self: flex-end; 59 | } 60 | .hider-ribbon .side-dock-ribbon div.separator svg { 61 | -webkit-mask-image: var(--left-indicator); 62 | } 63 | .hider-ribbon .side-dock-ribbon div.separator.is-collapsed.bt-sortable-chosen svg { 64 | /* so that the icon updates to show the expand icon on drag start */ 65 | -webkit-mask-image: var(--left-indicator); 66 | } 67 | .hider-ribbon .side-dock-ribbon div.separator.is-collapsed svg { 68 | -webkit-mask-image: var(--right-indicator); 69 | } 70 | 71 | .view-actions div.separator.is-collapsed { 72 | transform: rotateY(180deg); 73 | } 74 | 75 | .status-bar .is-hidden, 76 | .side-dock-ribbon .is-hidden, 77 | .view-actions .is-hidden { 78 | /* if you're mad about this !important 79 | set the --is-hidden-display variable to override it */ 80 | --is-hidden-display: none; 81 | display: var(--is-hidden-display) !important; 82 | } 83 | 84 | .status-bar div.separator { 85 | --cursor: grab; /* to deal with minimal theme */ 86 | cursor: grab; 87 | padding: 0px 4px; 88 | display: flex; 89 | align-items: center; 90 | /* line-height: 1; */ 91 | } 92 | 93 | .status-bar div.separator .glyph { 94 | display: flex; 95 | } 96 | 97 | .status-bar div.separator svg, 98 | .status-bar div.separator.is-collapsed.bt-sortable-chosen svg { 99 | -webkit-mask-image: var(--right-indicator); 100 | } 101 | 102 | .status-bar div.separator.is-collapsed svg { 103 | -webkit-mask-image: var(--left-indicator); 104 | } 105 | 106 | .side-dock-ribbon div.side-dock-ribbon-action.bt-sortable-chosen, 107 | .side-dock-ribbon div.separator.bt-sortable-chosen, 108 | .status-bar div.separator.bt-sortable-chosen { 109 | --cursor: grabbing; 110 | cursor: grabbing; 111 | } 112 | 113 | body.is-dragging .tooltip { 114 | display: none; 115 | } 116 | 117 | .workspace-tab-header-container.is-dragging .workspace-tab-header, 118 | .workspace-tab-header-container.is-dragging .workspace-tab-container-before, 119 | .workspace-tab-header-container.is-dragging .workspace-tab-container-after { 120 | background: none; 121 | } 122 | .workspace-tab-header-container.is-dragging .workspace-tab-header-inner-icon:hover { 123 | background: none; 124 | } 125 | 126 | .workspace-leaf-content[data-type="file-explorer"] .nav-header .search-input-container { 127 | display: none; 128 | } 129 | 130 | .workspace-leaf-content[data-type="file-explorer"] .nav-header .search-input-container.is-active { 131 | display: block; 132 | } 133 | 134 | div.nav-action-button[data-sort-method] + div.nav-action-button.drag-to-rearrange { 135 | display: none; 136 | } 137 | 138 | div.nav-action-button[data-sort-method="custom"] + div.nav-action-button.drag-to-rearrange { 139 | display: block; 140 | } 141 | 142 | .nav-files-container .sortable-fallback { 143 | display: none; 144 | } 145 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "allowSyntheticDefaultImports": true, 7 | "module": "ESNext", 8 | "target": "ES6", 9 | "allowJs": true, 10 | "strictNullChecks": true, 11 | "noImplicitAny": true, 12 | "moduleResolution": "node", 13 | "importHelpers": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.0.1": "0.12.5" 3 | } 4 | --------------------------------------------------------------------------------