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..8bd6b3f 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,11 @@ "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" } }, "notifs": { diff --git a/module/apps/SidebarTabRearranger.mjs b/module/apps/SidebarTabRearranger.mjs new file mode 100644 index 0000000..53ee0fa --- /dev/null +++ b/module/apps/SidebarTabRearranger.mjs @@ -0,0 +1,164 @@ +import { __ID__, filePath } from "../consts.mjs"; +import { performArraySort } from "../utils/performArraySort.mjs"; + +const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; +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: { + }, + }; + + 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); + + // TODO: define this using the game settings + this.#order = Object.keys(ui.sidebar.constructor.TABS); + }; + // #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() {}; + // #endregion Lifecycle + + // #region Data Prep + async _prepareContext() { + const ctx = { + meta: { + idp: this.id, + }, + }; + + 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 + // #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..9d7e5a7 --- /dev/null +++ b/module/tweaks/rearrangeSidebarTabs.mjs @@ -0,0 +1,39 @@ +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 + // TODO: do this + // #endregion Implementation + + status[key] = SettingStatusEnum.Registered; +}; 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..efd1f19 --- /dev/null +++ b/styles/apps/SidebarTabRearranger.css @@ -0,0 +1,69 @@ +.oft.SidebarTabRearranger { + > .window-content { + gap: 16px; + } + + footer { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; + } + + .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; + } +} 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 @@ diff --git a/templates/SidebarTabRearranger/footer.hbs b/templates/SidebarTabRearranger/footer.hbs new file mode 100644 index 0000000..f159af4 --- /dev/null +++ b/templates/SidebarTabRearranger/footer.hbs @@ -0,0 +1,14 @@ + diff --git a/templates/SidebarTabRearranger/list.hbs b/templates/SidebarTabRearranger/list.hbs new file mode 100644 index 0000000..63dd785 --- /dev/null +++ b/templates/SidebarTabRearranger/list.hbs @@ -0,0 +1,33 @@ +
+
+ {{ localize "OFT.apps.SidebarTabRearranger.top" }} +
+
+
+ {{#each tabs as | tab |}} +
+ + +
+
+ {{/each}} +
+
+ {{ localize "OFT.apps.SidebarTabRearranger.bottom" }} +
+