const { Plugin } = require('obsidian'); // a bunch of ribbon icons can attach within milliseconds of each other on // startup, so debounce instead of re-checking the order on every single one function debounce(fn, wait) { let timer = null; return () => { clearTimeout(timer); timer = setTimeout(fn, wait); }; } module.exports = class RibbonOrderLock extends Plugin { async onload() { this.settings = Object.assign({ order: [] }, await this.loadData()); this.enforcing = false; this.debouncedEnforce = debounce(() => this.enforceOrder(), 200); this.app.workspace.onLayoutReady(() => { this.patchRibbon(); this.enforceOrder(); this.observeRibbon(); }); } onunload() { this.observer?.disconnect(); const ribbon = this.getRibbon(); if (ribbon && this.originalOnChange) { ribbon.onChange = this.originalOnChange; } } getRibbon() { return this.app.workspace.leftRibbon ?? null; } getRibbonContainerEl(ribbon) { return ribbon.ribbonItemsEl ?? document.querySelector('.side-dock-ribbon.mod-left .side-dock-actions'); } observeRibbon() { const ribbon = this.getRibbon(); if (!ribbon) return; const containerEl = this.getRibbonContainerEl(ribbon); if (!containerEl) return; this.observer = new MutationObserver(() => this.debouncedEnforce()); this.observer.observe(containerEl, { childList: true }); } // hook the ribbon's own onChange so drags and hide/show toggles update // our saved order automatically, same as Obsidian's native reorder does patchRibbon() { const ribbon = this.getRibbon(); if (!ribbon || this.originalOnChange) return; const original = ribbon.onChange.bind(ribbon); this.originalOnChange = original; ribbon.onChange = (save) => { original(save); if (save && !this.enforcing) { this.recordOrder(ribbon); } }; } enforceOrder() { const ribbon = this.getRibbon(); if (!ribbon) return; const order = this.settings.order; if (order.length === 0) { this.recordOrder(ribbon); return; } const sorted = [...ribbon.items].sort((a, b) => { const ia = order.indexOf(a.id); const ib = order.indexOf(b.id); if (ia === -1 && ib === -1) return 0; if (ia === -1) return 1; if (ib === -1) return -1; return ia - ib; }); const changed = sorted.some((item, i) => ribbon.items[i] !== item); if (changed) { ribbon.items.length = 0; ribbon.items.push(...sorted); this.enforcing = true; try { ribbon.onChange(true); } finally { this.enforcing = false; } } this.mergeNewItems(ribbon); } recordOrder(ribbon) { const ids = ribbon.items.map((item) => item.id); if (this.sameOrder(ids, this.settings.order)) return; this.settings.order = ids; this.saveData(this.settings); } // only append icons we haven't seen before instead of overwriting the // whole order -- enforceOrder() runs during boot too, before every // plugin has attached its icon yet, and a full overwrite there was // wiping out the remembered position for anything still loading mergeNewItems(ribbon) { const known = new Set(this.settings.order); const newIds = ribbon.items.map((item) => item.id).filter((id) => !known.has(id)); if (newIds.length === 0) return; this.settings.order = [...this.settings.order, ...newIds]; this.saveData(this.settings); } sameOrder(a, b) { return a.length === b.length && a.every((id, i) => id === b[i]); } };