Files

134 lines
4.3 KiB
JavaScript

const { Plugin, debounce } = require('obsidian');
module.exports = class RibbonClasp extends Plugin {
async onload() {
this.settings = Object.assign({ order: [] }, await this.loadData());
this.enforcing = false;
this.alive = true;
this.register(() => { this.alive = 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);
// disconnecting the observer doesn't stop an already-scheduled call
this.register(() => this.debouncedEnforce.cancel());
this.app.workspace.onLayoutReady(() => {
// not tied to our lifecycle; still fires if we were disabled during boot
if (!this.alive) return;
this.patchRibbon();
this.enforceOrder();
this.observeRibbon();
});
}
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;
const observer = new MutationObserver(() => this.debouncedEnforce());
observer.observe(containerEl, { childList: true });
this.register(() => observer.disconnect());
}
// 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;
const wrapper = (save) => {
original(save);
if (save && !this.enforcing && this.alive) {
this.recordOrder(ribbon);
}
};
ribbon.onChange = wrapper;
this.register(() => {
// don't restore over someone else's patch, just go inert instead
if (ribbon.onChange === wrapper) ribbon.onChange = original;
});
}
enforceOrder() {
const ribbon = this.getRibbon();
if (!ribbon) return;
const order = this.settings.order;
if (order.length === 0) {
this.recordOrder(ribbon);
return;
}
// anything we haven't seen before sorts to the bottom, in the order it came in
const rank = new Map(order.map((id, i) => [id, i]));
const sorted = [...ribbon.items].sort((a, b) => {
const ia = rank.get(a.id) ?? Infinity;
const ib = rank.get(b.id) ?? Infinity;
return ia === ib ? 0 : 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);
}
// a drag saves, and the dom change it makes wakes the observer up, which can
// save again before the first one has finished writing. queue them instead.
save() {
this.writing = Promise.resolve(this.writing)
.catch(() => {})
.then(() => this.saveData(this.settings));
return this.writing;
}
recordOrder(ribbon) {
const ids = ribbon.items.map((item) => item.id);
if (this.sameOrder(ids, this.settings.order)) return;
this.settings.order = ids;
this.save();
}
// ids of plugins that are gone stay in the list on purpose -- nothing prunes
// them, so reinstalling one puts its icon back where it used to be
//
// 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.save();
}
sameOrder(a, b) {
return a.length === b.length && a.every((id, i) => id === b[i]);
}
};