313 lines
9.0 KiB
JavaScript
313 lines
9.0 KiB
JavaScript
/**
|
|
* An abstract base class for displaying a heads-up-display interface bound to a Placeable Object on the canvas
|
|
* @interface
|
|
* @template {PlaceableObject} ActiveHUDObject
|
|
* @template {CanvasDocument} ActiveHUDDocument
|
|
* @template {PlaceablesLayer} ActiveHUDLayer
|
|
*/
|
|
class BasePlaceableHUD extends Application {
|
|
|
|
/**
|
|
* Reference a PlaceableObject this HUD is currently bound to.
|
|
* @type {ActiveHUDObject}
|
|
*/
|
|
object;
|
|
|
|
/**
|
|
* Track whether a control icon is hovered or not
|
|
* @type {boolean}
|
|
*/
|
|
#hoverControlIcon = false;
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
static get defaultOptions() {
|
|
return foundry.utils.mergeObject(super.defaultOptions, {
|
|
classes: ["placeable-hud"],
|
|
popOut: false
|
|
});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Convenience access to the Document which this HUD modifies.
|
|
* @returns {ActiveHUDDocument}
|
|
*/
|
|
get document() {
|
|
return this.object?.document;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Convenience access for the canvas layer which this HUD modifies
|
|
* @type {ActiveHUDLayer}
|
|
*/
|
|
get layer() {
|
|
return this.object?.layer;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* Methods
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Bind the HUD to a new PlaceableObject and display it
|
|
* @param {PlaceableObject} object A PlaceableObject instance to which the HUD should be bound
|
|
*/
|
|
bind(object) {
|
|
const states = this.constructor.RENDER_STATES;
|
|
if ( [states.CLOSING, states.RENDERING].includes(this._state) ) return;
|
|
if ( this.object ) this.clear();
|
|
|
|
// Record the new object
|
|
if ( !(object instanceof PlaceableObject) || (object.scene !== canvas.scene) ) {
|
|
throw new Error("You may only bind a HUD instance to a PlaceableObject in the currently viewed Scene.");
|
|
}
|
|
this.object = object;
|
|
|
|
// Render the HUD
|
|
this.render(true);
|
|
this.element.hide().fadeIn(200);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Clear the HUD by fading out it's active HTML and recording the new display state
|
|
*/
|
|
clear() {
|
|
let states = this.constructor.RENDER_STATES;
|
|
if ( this._state <= states.NONE ) return;
|
|
this._state = states.CLOSING;
|
|
|
|
// Unbind
|
|
this.object = null;
|
|
this.element.hide();
|
|
this._element = null;
|
|
this._state = states.NONE;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @override */
|
|
async _render(...args) {
|
|
await super._render(...args);
|
|
this.setPosition();
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @override */
|
|
getData(options = {}) {
|
|
const data = this.object.document.toObject();
|
|
return foundry.utils.mergeObject(data, {
|
|
id: this.id,
|
|
classes: this.options.classes.join(" "),
|
|
appId: this.appId,
|
|
isGM: game.user.isGM,
|
|
isGamePaused: game.paused,
|
|
icons: CONFIG.controlIcons
|
|
});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @override */
|
|
setPosition({left, top, width, height, scale} = {}) {
|
|
const position = {
|
|
width: width || this.object.width,
|
|
height: height || this.object.height,
|
|
left: left ?? this.object.x,
|
|
top: top ?? this.object.y
|
|
};
|
|
this.element.css(position);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* Event Listeners and Handlers */
|
|
/* -------------------------------------------- */
|
|
|
|
/** @override */
|
|
activateListeners(html) {
|
|
// Attribute Bars
|
|
html.find(".attribute input")
|
|
.click(this._onAttributeClick)
|
|
.keydown(this._onAttributeKeydown.bind(this))
|
|
.focusout(this._onAttributeUpdate.bind(this));
|
|
|
|
// Control icons hover detection
|
|
html.find(".control-icon")
|
|
.mouseleave(() => this.#hoverControlIcon = false)
|
|
.mouseenter(() => this.#hoverControlIcon = true)
|
|
.click(this._onClickControl.bind(this));
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Handle mouse clicks to control a HUD control button
|
|
* @param {PointerEvent} event The originating click event
|
|
* @protected
|
|
*/
|
|
_onClickControl(event) {
|
|
const button = event.currentTarget;
|
|
switch ( button.dataset.action ) {
|
|
case "visibility":
|
|
return this._onToggleVisibility(event);
|
|
case "locked":
|
|
return this._onToggleLocked(event);
|
|
case "sort-up":
|
|
return this._onSort(event, true);
|
|
case "sort-down":
|
|
return this._onSort(event, false);
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Handle initial click to focus an attribute update field
|
|
* @param {MouseEvent} event The mouse click event
|
|
* @protected
|
|
*/
|
|
_onAttributeClick(event) {
|
|
event.currentTarget.select();
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Force field handling on an Enter keypress even if the value of the field did not change.
|
|
* This is important to suppose use cases with negative number values.
|
|
* @param {KeyboardEvent} event The originating keydown event
|
|
* @protected
|
|
*/
|
|
_onAttributeKeydown(event) {
|
|
if ( (event.code === "Enter") || (event.code === "NumpadEnter") ) event.currentTarget.blur();
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Handle attribute updates
|
|
* @param {FocusEvent} event The originating focusout event
|
|
*/
|
|
_onAttributeUpdate(event) {
|
|
event.preventDefault();
|
|
if ( !this.object ) return;
|
|
const input = event.currentTarget;
|
|
this._updateAttribute(input.name, event.currentTarget.value.trim());
|
|
if ( !this.#hoverControlIcon ) this.clear();
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Handle attribute bar update
|
|
* @param {string} name The name of the attribute
|
|
* @param {string} input The raw string input value for the update
|
|
* @returns {Promise<void>}
|
|
* @protected
|
|
*/
|
|
async _updateAttribute(name, input) {
|
|
const current = foundry.utils.getProperty(this.object.document, name);
|
|
const {value} = this._parseAttributeInput(name, current, input);
|
|
await this.object.document.update({[name]: value});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Parse an attribute bar input string into a new value for the attribute field.
|
|
* @param {string} name The name of the attribute
|
|
* @param {object|number} attr The current value of the attribute
|
|
* @param {string} input The raw string input value
|
|
* @returns {{value: number, [delta]: number, isDelta: boolean, isBar: boolean}} The parsed input value
|
|
* @protected
|
|
*/
|
|
_parseAttributeInput(name, attr, input) {
|
|
const isBar = (typeof attr === "object") && ("max" in attr);
|
|
const isEqual = input.startsWith("=");
|
|
const isDelta = input.startsWith("+") || input.startsWith("-");
|
|
const current = isBar ? attr.value : attr;
|
|
let v;
|
|
|
|
// Explicit equality
|
|
if ( isEqual ) input = input.slice(1);
|
|
|
|
// Percentage change
|
|
if ( input.endsWith("%") ) {
|
|
const p = Number(input.slice(0, -1)) / 100;
|
|
if ( isBar ) v = attr.max * p;
|
|
else v = Math.abs(current) * p;
|
|
}
|
|
|
|
// Additive delta
|
|
else v = Number(input);
|
|
|
|
// Return parsed input
|
|
const value = isDelta ? current + v : v;
|
|
const delta = isDelta ? v : undefined;
|
|
return {value, delta, isDelta, isBar};
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Toggle the visible state of all controlled objects in the Layer
|
|
* @param {PointerEvent} event The originating click event
|
|
* @private
|
|
*/
|
|
async _onToggleVisibility(event) {
|
|
event.preventDefault();
|
|
|
|
// Toggle the visible state
|
|
const isHidden = this.object.document.hidden;
|
|
const updates = this.layer.controlled.map(o => {
|
|
return {_id: o.id, hidden: !isHidden};
|
|
});
|
|
|
|
// Update all objects
|
|
return canvas.scene.updateEmbeddedDocuments(this.object.document.documentName, updates);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Toggle locked state of all controlled objects in the Layer
|
|
* @param {PointerEvent} event The originating click event
|
|
* @private
|
|
*/
|
|
async _onToggleLocked(event) {
|
|
event.preventDefault();
|
|
|
|
// Toggle the visible state
|
|
const isLocked = this.object.document.locked;
|
|
const updates = this.layer.controlled.map(o => {
|
|
return {_id: o.id, locked: !isLocked};
|
|
});
|
|
|
|
// Update all objects
|
|
event.currentTarget.classList.toggle("active", !isLocked);
|
|
return canvas.scene.updateEmbeddedDocuments(this.object.document.documentName, updates);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Handle sorting the z-order of the object
|
|
* @param {PointerEvent} event The originating mouse click event
|
|
* @param {boolean} up Move the object upwards in the vertical stack?
|
|
* If false, the object is moved downwards.
|
|
* @returns {Promise<void>}
|
|
* @protected
|
|
*/
|
|
async _onSort(event, up) {
|
|
event.preventDefault();
|
|
this.layer._sendToBackOrBringToFront(up);
|
|
}
|
|
}
|