diff --git a/assets/icons/drag-handle.svg b/assets/icons/drag-handle.svg new file mode 100644 index 0000000..b7c2d73 --- /dev/null +++ b/assets/icons/drag-handle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/langs/en-ca.json b/langs/en-ca.json index a64ce8d..ec50645 100644 --- a/langs/en-ca.json +++ b/langs/en-ca.json @@ -76,6 +76,11 @@ "name": "Hotbar Settings", "hint": "Tweaks that modify Foundry's hotbar", "label": "Configure Hotbar" + }, + "rearrangeSidebarTabs": { + "name": "Rearrange Sidebar Tabs", + "hint": "(v13+) Allows you to customize the order the right-hand sidebar tabs appear in. Including module-added sidebar tabs.", + "label": "Change Tab Order" } }, "keybindings": { @@ -85,6 +90,7 @@ } }, "apps": { + "discard-changes": "Discard Changes", "no-settings-to-display": "No settings to display", "make-global-reference": "Make Global Reference", "StatusEffectIconConfig": { @@ -92,6 +98,16 @@ "no-status-effects": "No status effects detected, this is most likely due to your game system or other modules.", "remove-override": "Remove custom override", "select-using-image-tagger": "Select using Image Tagger" + }, + "SidebarTabRearranger": { + "title": "Rearrange Sidebar Tabs", + "top": "Top", + "bottom": "Bottom", + "save-user": "Save Changes for User", + "remove-user": "Remove User Order", + "save-world": "Save Changes for World", + "remove-world": "Remove World Order", + "user-override-warning": "You have a User-level tab order, saving any changes to the World-level tab order will not show up for you." } }, "notifs": { diff --git a/module/apps/SidebarTabRearranger.mjs b/module/apps/SidebarTabRearranger.mjs new file mode 100644 index 0000000..b5a4d97 --- /dev/null +++ b/module/apps/SidebarTabRearranger.mjs @@ -0,0 +1,197 @@ +import { __ID__, filePath } from "../consts.mjs"; +import { performArraySort } from "../utils/performArraySort.mjs"; +import { key as rearrangeSidebarTabsKey } from "../tweaks/rearrangeSidebarTabs.mjs"; + +const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; +const { SettingsConfig } = foundry.applications.settings; +const { DragDrop } = foundry.applications.ux; +const { getDocumentClass } = foundry.utils; + +export class SidebarTabRearranger extends HandlebarsApplicationMixin(ApplicationV2) { + // #region Options + static DEFAULT_OPTIONS = { + tag: `form`, + classes: [ + __ID__, + `SidebarTabRearranger`, + ], + window: { + title: `OFT.apps.SidebarTabRearranger.title`, + }, + position: {}, + form: { + handler: this.#onSubmit, + closeOnSubmit: true, + submitOnChange: false, + }, + actions: { + unsetSetting: this.#unsetSetting, + }, + }; + + static PARTS = { + list: { template: filePath(`templates/SidebarTabRearranger/list.hbs`) }, + footer: { template: filePath(`templates/SidebarTabRearranger/footer.hbs`) }, + }; + // #endregion Options + + // #region Instance Data + #order = []; + + constructor(...args) { + super(...args); + this.#order = Object.keys(ui.sidebar.constructor.TABS); + }; + + get worldSave() { + return game.settings.get(__ID__, `${rearrangeSidebarTabsKey}World`); + }; + + get userSave() { + return game.settings.get(__ID__, `${rearrangeSidebarTabsKey}User`); + }; + // #endregion Instance Data + + // #region Lifecycle + async _onRender(...args) { + await super._onRender(...args); + + new DragDrop.implementation({ + dragSelector: `.tab`, + dropSelector: `.drop-zone`, + callbacks: { + dragstart: this.#onDragStart.bind(this), + dragenter: this.#onDragEnter.bind(this), + dragleave: this.#onDragLeave.bind(this), + dragend: this.#onDragEnd.bind(this), + drop: this.#onDrop.bind(this), + }, + }).bind(this.element); + }; + + /** @this {SidebarTabRearranger} */ + static async #onSubmit(event) { + const { scope } = event.submitter.dataset; + if (!scope || ![`User`, `World`].includes(scope)) { return }; + + if (scope === `World` && this.userSave != null) { + return; + } + + game.settings.set( + __ID__, + `${rearrangeSidebarTabsKey}${scope}`, + this.#order, + ); + SettingsConfig.reloadConfirm({ world: scope === `World` }); + }; + // #endregion Lifecycle + + // #region Data Prep + async _prepareContext() { + const ctx = { + meta: { + idp: this.id, + isGM: game.user.isGM, + }, + showUserOverrideWarning: game.user.isGM && this.userSave != null, + }; + + const tabs = ui.sidebar.constructor.TABS; + ctx.tabs = []; + for (let i = 0; i < this.#order.length; i++) { + const id = this.#order[i]; + const tab = tabs[id]; + if (!tab) { continue }; + + let { documentName, gmOnly, tooltip, icon } = tab; + if (gmOnly && !game.user.isGM) { continue }; + + if (documentName) { + tooltip ??= getDocumentClass(documentName).metadata.labelPlural; + icon ??= CONFIG[documentName]?.sidebarIcon; + }; + + ctx.tabs.push({ + id, + name: game.i18n.localize(tooltip), + icon, + nextIndex: i + 1, + }); + }; + + return ctx; + }; + // #endregion Data Prep + + // #region Actions + /** @this {SidebarTabRearranger} */ + static async #unsetSetting(event, target) { + const { scope } = target.dataset; + if (!scope || ![`User`, `World`].includes(scope)) { return }; + + await game.settings.set(__ID__, `${rearrangeSidebarTabsKey}${scope}`, null); + SettingsConfig.reloadConfirm({ world: scope === `World` }); + }; + // #endregion Actions + + // #region Drag & Drop + #onDragStart(event) { + /** @type {HTMLLIElement|undefined} */ + const target = event.target.closest(`[data-tab-id]`); + if (!target) { return }; + + const tabID = target.dataset.tabId; + + target.classList.add(`no-hover-styles`); + event.dataTransfer.setDragImage(target, 0, 0); + event.dataTransfer.setData(`oft/tab`, tabID); + target.closest(`.tab-list`)?.classList.add(`dragging`); + + /* + This timeout is required to get the difference between the drag + image and the element in-DOM, because this puts the class removal + in a subsequent event cycle instead of being handled in the current + cycle. + */ + setTimeout(() => target.classList.remove(`no-hover-styles`), 0); + }; + + #onDragEnter(event) { + event.currentTarget.style.setProperty(`--colour`, `#00aa00`); + }; + + #onDragLeave(event) { + event.currentTarget.style.removeProperty(`--colour`); + }; + + #onDragEnd() { + this.element.querySelector(`.tab-list`)?.classList.remove(`dragging`); + this.element + .querySelectorAll(`[style="--colour: #00aa00"]`) + .forEach(el => el.style.removeProperty(`--colour`)); + }; + + #onDrop(event) { + const droppedID = event.dataTransfer.getData(`oft/tab`); + this.element.querySelector(`.tab-list`)?.classList.remove(`dragging`); + event.currentTarget?.style?.removeProperty(`--colour`); + if (!droppedID) { return }; + + const droppedIndex = this.#order.findIndex(t => t === droppedID); + const dropTarget = event.currentTarget; + const targetIndex = parseInt(dropTarget?.dataset.moveToIndex); + if ( + !dropTarget + || droppedIndex < 0 + || targetIndex === droppedIndex + ) { return }; + + this.#order = performArraySort( + droppedID, + { targetIndex, list: this.#order }, + ); + this.render({ parts: [`list`] }); + }; + // #endregion Drag & Drop +}; diff --git a/module/hooks/setup.mjs b/module/hooks/setup.mjs index b3d5fbf..12647b7 100644 --- a/module/hooks/setup.mjs +++ b/module/hooks/setup.mjs @@ -10,6 +10,7 @@ import { hotbarButtonGap } from "../tweaks/hotbarButtonGap.mjs"; import { hotbarButtonSize } from "../tweaks/hotbarButtonSize.mjs"; import { preventTokenRotation } from "../tweaks/preventTokenRotation.mjs"; import { preventUserConfigOpen } from "../tweaks/preventUserConfigOpen.mjs"; +import { rearrangeSidebarTabs } from "../tweaks/rearrangeSidebarTabs.mjs"; import { repositionHotbar } from "../tweaks/repositionHotbar.mjs"; import { startingSidebarTab } from "../tweaks/startingSidebarTab.mjs"; import { startSidebarExpanded } from "../tweaks/startSidebarExpanded.mjs"; @@ -50,6 +51,7 @@ Hooks.on(`setup`, () => { repositionHotbar(); customStatusIcons(); + rearrangeSidebarTabs(); chatImageLinks(); chatSidebarBackground(); startSidebarExpanded(); diff --git a/module/tweaks/rearrangeSidebarTabs.mjs b/module/tweaks/rearrangeSidebarTabs.mjs new file mode 100644 index 0000000..512f6bc --- /dev/null +++ b/module/tweaks/rearrangeSidebarTabs.mjs @@ -0,0 +1,63 @@ +import { SettingStatusEnum, status } from "../utils/SettingStatus.mjs"; +import { __ID__ } from "../consts.mjs"; +import { Logger } from "../utils/Logger.mjs"; +import { preventTweakRegistration } from "../utils/preRegisterTweak.mjs"; +import { SidebarTabRearranger } from "../apps/SidebarTabRearranger.mjs"; + +export const key = `rearrangeSidebarTabs`; + +export function rearrangeSidebarTabs() { + status[key] = SettingStatusEnum.Unknown; + if (preventTweakRegistration(key)) { return }; + + // #region Registration + Logger.log(`Registering tweak: ${key}`); + game.settings.registerMenu(__ID__, `${key}Menu`, { + name: `OFT.menu.${key}.name`, + hint: `OFT.menu.${key}.hint`, + label: `OFT.menu.${key}.label`, + restricted: false, + type: SidebarTabRearranger, + }); + game.settings.register(__ID__, `${key}World`, { + scope: `world`, + config: false, + type: Array, + }); + game.settings.register(__ID__, `${key}User`, { + scope: `user`, + config: false, + type: Array, + }); + // #endregion Registration + + // #region Implementation + updateTabOrder(); + // #endregion Implementation + + status[key] = SettingStatusEnum.Registered; +}; + +function updateTabOrder() { + const order = game.settings.get(__ID__, `${key}User`) + ?? game.settings.get(__ID__, `${key}World`) + ?? null; + if (!order) { return }; + + const tabs = CONFIG.ui.sidebar.TABS; + const replaced = {}; + + // Sort all of the tabs that are provided + for (const tabID of order) { + if (!tabs[tabID]) { continue }; + replaced[tabID] = tabs[tabID]; + }; + + // Add any tabs that are not in the ordering yet + for (const tabID in tabs) { + if (replaced[tabID] != null) { continue }; + replaced[tabID] = tabs[tabID]; + }; + + CONFIG.ui.sidebar.TABS = replaced; +}; diff --git a/module/utils/performArraySort.mjs b/module/utils/performArraySort.mjs new file mode 100644 index 0000000..04fa195 --- /dev/null +++ b/module/utils/performArraySort.mjs @@ -0,0 +1,31 @@ +export function performArraySort( + element, + { list, targetIndex }, +) { + + // Case: same position + if (list.indexOf(el => el === element) === targetIndex) { + return Array.from(list); + }; + + // Case: start of array + if (targetIndex === 0) { + list = list.filter(el => el !== element); + return [element, ...list]; + }; + + // Case: end of array + if (targetIndex === list.length - 1) { + list = list.filter(el => el !== element); + return [...list, element]; + }; + + // Case: middle of array + const front = list + .slice(0, targetIndex) + .filter(el => el !== element); + const back = list + .slice(targetIndex) + .filter(el => el !== element); + return [...front, element, ...back]; +}; diff --git a/styles/apps/SidebarTabRearranger.css b/styles/apps/SidebarTabRearranger.css new file mode 100644 index 0000000..e51f1f1 --- /dev/null +++ b/styles/apps/SidebarTabRearranger.css @@ -0,0 +1,84 @@ +.oft.SidebarTabRearranger { + > .window-content { + gap: 16px; + } + + main { + width: min-content; + } + + footer { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; + + .wide { + grid-column: span 2; + } + } + + .drop-zone { + --colour: transparent; + background: color-mix(in srgb, var(--colour) 30%, transparent 70%); + border: 1px solid var(--colour); + border-radius: 4px; + transition: all 150ms ease-in-out; + width: 12px; + } + + .tab-list { + display: flex; + flex-direction: row; + row-gap: 8px; + column-gap: 2px; + list-style-type: none; + margin: 0; + padding: 0; + + &.dragging > .drop-zone { + --colour: #c9593f; + } + } + + .tab { + margin: 0; + display: flex; + flex-direction: column; + align-items: center; + cursor: var(--cursor-grab); + + > .drag-handle-icon { + display: none; + } + &:hover:not(.no-hover-styles) { + > .sidebar-icon { + display: none; + } + > .drag-handle-icon { + display: initial; + } + } + } + + .emulate-button { + --size: 32px; + display: flex; + justify-content: center; + align-items: center; + height: var(--size); + aspect-ratio: 1; + border: 1px solid var(--color-light-5); + border-radius: 4px; + } + + .bottom-label { + text-align: right; + } + + .warning { + border: 1px solid var(--color-level-warning-border); + border-radius: 4px; + padding: 4px 6px; + font-weight: 600; + } +} diff --git a/styles/main.css b/styles/main.css index 63794a5..bcb2caf 100644 --- a/styles/main.css +++ b/styles/main.css @@ -6,6 +6,7 @@ @import url("./repositionHotbar.css") layer(tweaks); @import url("./apps/common.css") layer(apps); +@import url("./apps/SidebarTabRearranger.css") layer(apps); @import url("./apps/StatusEffectIconConfig.css") layer(apps); /* Make the chat sidebar the same width as all the other tabs */ diff --git a/templates/OFTSettingsMenu/footer.hbs b/templates/OFTSettingsMenu/footer.hbs index 41f2375..dd31400 100644 --- a/templates/OFTSettingsMenu/footer.hbs +++ b/templates/OFTSettingsMenu/footer.hbs @@ -2,7 +2,12 @@ - + {{localize "SETTINGS.Save"}} diff --git a/templates/SidebarTabRearranger/footer.hbs b/templates/SidebarTabRearranger/footer.hbs new file mode 100644 index 0000000..5f53dc2 --- /dev/null +++ b/templates/SidebarTabRearranger/footer.hbs @@ -0,0 +1,46 @@ + diff --git a/templates/SidebarTabRearranger/list.hbs b/templates/SidebarTabRearranger/list.hbs new file mode 100644 index 0000000..6de3416 --- /dev/null +++ b/templates/SidebarTabRearranger/list.hbs @@ -0,0 +1,38 @@ + + {{#if showUserOverrideWarning}} + + {{ localize "OFT.apps.SidebarTabRearranger.user-override-warning" }} + + {{/if}} + + {{ localize "OFT.apps.SidebarTabRearranger.top" }} + + + + {{#each tabs as | tab |}} + + + + + + {{/each}} + + + {{ localize "OFT.apps.SidebarTabRearranger.bottom" }} + +
+ {{ localize "OFT.apps.SidebarTabRearranger.user-override-warning" }} +