/** * The global action bar displayed at the bottom of the game view. * The Hotbar is a UI element at the bottom of the screen which contains Macros as interactive buttons. * The Hotbar supports 5 pages of global macros which can be dragged and dropped to organize as you wish. * * Left-clicking a Macro button triggers its effect. * Right-clicking the button displays a context menu of Macro options. * The number keys 1 through 0 activate numbered hotbar slots. * Pressing the delete key while hovering over a Macro will remove it from the bar. * * @see {@link Macros} * @see {@link Macro} */ class Hotbar extends Application { constructor(options) { super(options); game.macros.apps.push(this); /** * The currently viewed macro page * @type {number} */ this.page = 1; /** * The currently displayed set of macros * @type {Macro[]} */ this.macros = []; /** * Track collapsed state * @type {boolean} */ this._collapsed = false; /** * Track which hotbar slot is the current hover target, if any * @type {number|null} */ this._hover = null; } /* -------------------------------------------- */ /** @override */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "hotbar", template: "templates/hud/hotbar.html", popOut: false, dragDrop: [{ dragSelector: ".macro-icon", dropSelector: "#macro-list" }] }); } /* -------------------------------------------- */ /** * Whether the hotbar is locked. * @returns {boolean} */ get locked() { return game.settings.get("core", "hotbarLock"); } /* -------------------------------------------- */ /** @override */ getData(options={}) { this.macros = this._getMacrosByPage(this.page); return { page: this.page, macros: this.macros, barClass: this._collapsed ? "collapsed" : "", locked: this.locked }; } /* -------------------------------------------- */ /** * Get the Array of Macro (or null) values that should be displayed on a numbered page of the bar * @param {number} page * @returns {Macro[]} * @private */ _getMacrosByPage(page) { const macros = game.user.getHotbarMacros(page); for ( let [i, slot] of macros.entries() ) { slot.key = i<9 ? i+1 : 0; slot.icon = slot.macro ? slot.macro.img : null; slot.cssClass = slot.macro ? "active" : "inactive"; slot.tooltip = slot.macro ? slot.macro.name : null; } return macros; } /* -------------------------------------------- */ /** * Collapse the Hotbar, minimizing its display. * @returns {Promise} A promise which resolves once the collapse animation completes */ async collapse() { if ( this._collapsed ) return true; const toggle = this.element.find("#bar-toggle"); const icon = toggle.children("i"); const bar = this.element.find("#action-bar"); return new Promise(resolve => { bar.slideUp(200, () => { bar.addClass("collapsed"); icon.removeClass("fa-caret-down").addClass("fa-caret-up"); this._collapsed = true; resolve(true); }); }); } /* -------------------------------------------- */ /** * Expand the Hotbar, displaying it normally. * @returns {Promise} A promise which resolves once the expand animation completes */ async expand() { if ( !this._collapsed ) return true; const toggle = this.element.find("#bar-toggle"); const icon = toggle.children("i"); const bar = this.element.find("#action-bar"); return new Promise(resolve => { bar.slideDown(200, () => { bar.css("display", ""); bar.removeClass("collapsed"); icon.removeClass("fa-caret-up").addClass("fa-caret-down"); this._collapsed = false; resolve(true); }); }); } /* -------------------------------------------- */ /** * Change to a specific numbered page from 1 to 5 * @param {number} page The page number to change to. */ changePage(page) { this.page = Math.clamp(page ?? 1, 1, 5); this.render(); } /* -------------------------------------------- */ /** * Change the page of the hotbar by cycling up (positive) or down (negative) * @param {number} direction The direction to cycle */ cyclePage(direction) { direction = Number.isNumeric(direction) ? Math.sign(direction) : 1; if ( direction > 0 ) { this.page = this.page < 5 ? this.page+1 : 1; } else { this.page = this.page > 1 ? this.page-1 : 5; } this.render(); } /* -------------------------------------------- */ /* Event Listeners and Handlers /* -------------------------------------------- */ /** @override */ activateListeners(html) { super.activateListeners(html); // Macro actions html.find("#bar-toggle").click(this._onToggleBar.bind(this)); html.find("#macro-directory").click(ev => ui.macros.renderPopout(true)); html.find(".macro").click(this._onClickMacro.bind(this)); html.find(".page-control").click(this._onClickPageControl.bind(this)); // Activate context menu this._contextMenu(html); } /* -------------------------------------------- */ /** @inheritdoc */ _contextMenu(html) { ContextMenu.create(this, html, ".macro", this._getEntryContextOptions()); } /* -------------------------------------------- */ /** * Get the Macro entry context options * @returns {object[]} The Macro entry context options * @private */ _getEntryContextOptions() { return [ { name: "MACRO.Edit", icon: '', condition: li => { const macro = game.macros.get(li.data("macro-id")); return macro ? macro.isOwner : false; }, callback: li => { const macro = game.macros.get(li.data("macro-id")); macro.sheet.render(true); } }, { name: "MACRO.Remove", icon: '', condition: li => !!li.data("macro-id"), callback: li => game.user.assignHotbarMacro(null, Number(li.data("slot"))) }, { name: "MACRO.Delete", icon: '', condition: li => { const macro = game.macros.get(li.data("macro-id")); return macro ? macro.isOwner : false; }, callback: li => { const macro = game.macros.get(li.data("macro-id")); return Dialog.confirm({ title: `${game.i18n.localize("MACRO.Delete")} ${macro.name}`, content: `

${game.i18n.localize("AreYouSure")}

${game.i18n.localize("MACRO.DeleteWarning")}

`, yes: macro.delete.bind(macro) }); } } ]; } /* -------------------------------------------- */ /** * Handle left-click events to * @param {MouseEvent} event The originating click event * @protected */ async _onClickMacro(event) { event.preventDefault(); const li = event.currentTarget; // Case 1 - create a temporary Macro if ( li.classList.contains("inactive") ) { const cls = getDocumentClass("Macro"); const macro = new cls({name: cls.defaultName({type: "chat"}), type: "chat", scope: "global"}); macro.sheet._hotbarSlot = li.dataset.slot; macro.sheet.render(true); } // Case 2 - trigger a Macro else { const macro = game.macros.get(li.dataset.macroId); return macro.execute(); } } /* -------------------------------------------- */ /** * Handle pagination controls * @param {Event} event The originating click event * @private */ _onClickPageControl(event) { const action = event.currentTarget.dataset.action; switch ( action ) { case "page-up": this.cyclePage(1); break; case "page-down": this.cyclePage(-1); break; case "lock": this._toggleHotbarLock(); break; } } /* -------------------------------------------- */ /** @override */ _canDragStart(selector) { return !this.locked; } /* -------------------------------------------- */ /** @override */ _onDragStart(event) { const li = event.currentTarget.closest(".macro"); const macro = game.macros.get(li.dataset.macroId); if ( !macro ) return false; const dragData = foundry.utils.mergeObject(macro.toDragData(), {slot: li.dataset.slot}); event.dataTransfer.setData("text/plain", JSON.stringify(dragData)); } /* -------------------------------------------- */ /** @override */ _canDragDrop(selector) { return true; } /* -------------------------------------------- */ /** @override */ async _onDrop(event) { event.preventDefault(); const li = event.target.closest(".macro"); const slot = Number(li.dataset.slot); const data = TextEditor.getDragEventData(event); if ( Hooks.call("hotbarDrop", this, data, slot) === false ) return; // Forbid overwriting macros if the hotbar is locked. const existingMacro = game.macros.get(game.user.hotbar[slot]); if ( existingMacro && this.locked ) return ui.notifications.warn("MACRO.CannotOverwrite", { localize: true }); // Get the dropped document const cls = getDocumentClass(data.type); const doc = await cls?.fromDropData(data); if ( !doc ) return; // Get the Macro to add to the bar let macro; if ( data.type === "Macro" ) macro = game.macros.has(doc.id) ? doc : await cls.create(doc.toObject()); else if ( data.type === "RollTable" ) macro = await this._createRollTableRollMacro(doc); else macro = await this._createDocumentSheetToggle(doc); // Assign the macro to the hotbar if ( !macro ) return; return game.user.assignHotbarMacro(macro, slot, {fromSlot: data.slot}); } /* -------------------------------------------- */ /** * Create a Macro which rolls a RollTable when executed * @param {Document} table The RollTable document * @returns {Promise} A created Macro document to add to the bar * @private */ async _createRollTableRollMacro(table) { const command = `const table = await fromUuid("${table.uuid}");\nawait table.draw();`; return Macro.implementation.create({ name: `${game.i18n.localize("TABLE.Roll")} ${table.name}`, type: "script", img: table.img, command }); } /* -------------------------------------------- */ /** * Create a Macro document which can be used to toggle display of a Journal Entry. * @param {Document} doc A Document which should be toggled * @returns {Promise} A created Macro document to add to the bar * @protected */ async _createDocumentSheetToggle(doc) { const name = doc.name || `${game.i18n.localize(doc.constructor.metadata.label)} ${doc.id}`; return Macro.implementation.create({ name: `${game.i18n.localize("Display")} ${name}`, type: CONST.MACRO_TYPES.SCRIPT, img: "icons/svg/book.svg", command: `await Hotbar.toggleDocumentSheet("${doc.uuid}");` }); } /* -------------------------------------------- */ /** * Handle click events to toggle display of the macro bar * @param {Event} event * @private */ _onToggleBar(event) { event.preventDefault(); if ( this._collapsed ) return this.expand(); else return this.collapse(); } /* -------------------------------------------- */ /** * Toggle the hotbar's lock state. * @returns {Promise} * @protected */ async _toggleHotbarLock() { await game.settings.set("core", "hotbarLock", !this.locked); return this.render(); } /* -------------------------------------------- */ /** * Handle toggling a document sheet. * @param {string} uuid The Document UUID to display * @returns {Promise|Application|*} */ static async toggleDocumentSheet(uuid) { const doc = await fromUuid(uuid); if ( !doc ) { return ui.notifications.warn(game.i18n.format("WARNING.ObjectDoesNotExist", { name: game.i18n.localize("Document"), identifier: uuid })); } const sheet = doc.sheet; return sheet.rendered ? sheet.close() : sheet.render(true); } }