const { Plugin, debounce } = require('obsidian'); module.exports = class RibbonClasp extends Plugin { async onload() { this.settings = Object.assign({ order: [] }, await this.loadData()); this.enforcing = false; // icons can attach within milliseconds of each other on startup // (resetTimer, or it fires 200ms after the first one instead of the last) this.debouncedEnforce = debounce(() => this.enforceOrder(), 200, true); this.app.workspace.onLayoutReady(() => { // not tied to our lifecycle; still fires if we were disabled during boot if (this.unloaded) return; this.patchRibbon(); this.enforceOrder(); this.observeRibbon(); }); } onunload() { this.unloaded = true; this.observer?.disconnect(); // disconnect doesn't stop an already-scheduled call this.debouncedEnforce?.cancel(); const ribbon = this.getRibbon(); // don't restore over someone else's patch, just go inert instead if (ribbon && ribbon.onChange === this.wrapper) { 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; this.wrapper = (save) => { original(save); if (save && !this.enforcing && !this.unloaded) { this.recordOrder(ribbon); } }; ribbon.onChange = this.wrapper; } 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]); } };