390 lines
11 KiB
JavaScript
390 lines
11 KiB
JavaScript
|
|
import ApplicationV2 from "../api/application.mjs";
|
||
|
|
import HandlebarsApplicationMixin from "../api/handlebars-application.mjs";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Scene Region Legend.
|
||
|
|
* @extends ApplicationV2
|
||
|
|
* @mixes HandlebarsApplication
|
||
|
|
* @alias RegionLegend
|
||
|
|
*/
|
||
|
|
export default class RegionLegend extends HandlebarsApplicationMixin(ApplicationV2) {
|
||
|
|
|
||
|
|
/** @inheritDoc */
|
||
|
|
static DEFAULT_OPTIONS = {
|
||
|
|
id: "region-legend",
|
||
|
|
tag: "aside",
|
||
|
|
position: {
|
||
|
|
width: 320,
|
||
|
|
height: "auto"
|
||
|
|
},
|
||
|
|
window: {
|
||
|
|
title: "REGION.LEGEND.title",
|
||
|
|
icon: "fa-regular fa-game-board",
|
||
|
|
minimizable: false
|
||
|
|
},
|
||
|
|
actions: {
|
||
|
|
config: RegionLegend.#onConfig,
|
||
|
|
control: RegionLegend.#onControl,
|
||
|
|
create: RegionLegend.#onCreate,
|
||
|
|
delete: RegionLegend.#onDelete,
|
||
|
|
lock: RegionLegend.#onLock
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
/** @override */
|
||
|
|
static PARTS = {
|
||
|
|
list: {
|
||
|
|
id: "list",
|
||
|
|
template: "templates/scene/region-legend.hbs",
|
||
|
|
scrollable: ["ol.region-list"]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The currently filtered Regions.
|
||
|
|
* @type {{bottom: number, top: number}}
|
||
|
|
*/
|
||
|
|
#visibleRegions = new Set();
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The currently viewed elevation range.
|
||
|
|
* @type {{bottom: number, top: number}}
|
||
|
|
*/
|
||
|
|
elevation = {bottom: -Infinity, top: Infinity};
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @type {SearchFilter} */
|
||
|
|
#searchFilter = new SearchFilter({
|
||
|
|
inputSelector: 'input[name="search"]',
|
||
|
|
contentSelector: ".region-list",
|
||
|
|
callback: this.#onSearchFilter.bind(this)
|
||
|
|
});
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Record a reference to the currently highlighted Region.
|
||
|
|
* @type {Region|null}
|
||
|
|
*/
|
||
|
|
#hoveredRegion = null;
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @override */
|
||
|
|
_configureRenderOptions(options) {
|
||
|
|
super._configureRenderOptions(options);
|
||
|
|
if ( options.isFirstRender ) {
|
||
|
|
options.position.left ??= ui.nav?.element[0].getBoundingClientRect().left;
|
||
|
|
options.position.top ??= ui.controls?.element[0].getBoundingClientRect().top;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @override */
|
||
|
|
_canRender(options) {
|
||
|
|
const rc = options.renderContext;
|
||
|
|
if ( rc && !["createregions", "updateregions", "deleteregions"].includes(rc) ) return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @inheritDoc */
|
||
|
|
async _renderFrame(options) {
|
||
|
|
const frame = await super._renderFrame(options);
|
||
|
|
this.window.close.remove(); // Prevent closing
|
||
|
|
return frame;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @inheritDoc */
|
||
|
|
async close(options={}) {
|
||
|
|
if ( !options.closeKey ) return super.close(options);
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @inheritDoc */
|
||
|
|
_onFirstRender(context, options) {
|
||
|
|
super._onFirstRender(context, options);
|
||
|
|
canvas.scene.apps[this.id] = this;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @inheritDoc */
|
||
|
|
_onRender(context, options) {
|
||
|
|
super._onRender(context, options);
|
||
|
|
this.#searchFilter.bind(this.element);
|
||
|
|
for ( const li of this.element.querySelectorAll(".region") ) {
|
||
|
|
li.addEventListener("mouseover", this.#onRegionHoverIn.bind(this));
|
||
|
|
li.addEventListener("mouseout", this.#onRegionHoverOut.bind(this));
|
||
|
|
}
|
||
|
|
this.element.querySelector(`input[name="elevationBottom"]`)
|
||
|
|
.addEventListener("change", this.#onElevationBottomChange.bind(this));
|
||
|
|
this.element.querySelector(`input[name="elevationTop"]`)
|
||
|
|
.addEventListener("change", this.#onElevationTopChange.bind(this));
|
||
|
|
this.#updateVisibleRegions();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @override */
|
||
|
|
_onClose(options) {
|
||
|
|
super._onClose(options);
|
||
|
|
this.#visibleRegions.clear();
|
||
|
|
this.elevation.bottom = -Infinity;
|
||
|
|
this.elevation.top = Infinity;
|
||
|
|
delete canvas.scene.apps[this.id];
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @override */
|
||
|
|
async _prepareContext(_options) {
|
||
|
|
const regions = canvas.scene.regions.map(r => this.#prepareRegion(r));
|
||
|
|
regions.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang));
|
||
|
|
return {
|
||
|
|
regions,
|
||
|
|
elevation: {
|
||
|
|
bottom: Number.isFinite(this.elevation.bottom) ? this.elevation.bottom : "",
|
||
|
|
top: Number.isFinite(this.elevation.top) ? this.elevation.top : "",
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/*-------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Prepare each Region for rendering in the legend.
|
||
|
|
* @param {Region} region
|
||
|
|
* @returns {object}
|
||
|
|
*/
|
||
|
|
#prepareRegion(region) {
|
||
|
|
const hasElv = (region.elevation.bottom !== null) || (region.elevation.top !== null);
|
||
|
|
return {
|
||
|
|
id: region.id,
|
||
|
|
name: region.name,
|
||
|
|
color: region.color.css,
|
||
|
|
elevation: region.elevation,
|
||
|
|
elevationLabel: hasElv ? `[${region.elevation.bottom ?? "∞"}, ${region.elevation.top ?? "∞"}]` : "",
|
||
|
|
empty: !region.shapes.length,
|
||
|
|
locked: region.locked,
|
||
|
|
controlled: region.object?.controlled,
|
||
|
|
hover: region.object?.hover,
|
||
|
|
buttons: [
|
||
|
|
{
|
||
|
|
action: "config",
|
||
|
|
icon: "fa-cogs",
|
||
|
|
tooltip: game.i18n.localize("REGION.LEGEND.config"),
|
||
|
|
disabled: ""
|
||
|
|
},
|
||
|
|
{
|
||
|
|
action: "lock",
|
||
|
|
icon: region.locked ? "fa-lock" : "fa-unlock",
|
||
|
|
tooltip: game.i18n.localize(region.locked ? "REGION.LEGEND.unlock" : "REGION.LEGEND.lock"),
|
||
|
|
disabled: ""
|
||
|
|
},
|
||
|
|
{
|
||
|
|
action: "delete",
|
||
|
|
icon: "fa-trash",
|
||
|
|
tooltip: game.i18n.localize("REGION.LEGEND.delete"),
|
||
|
|
disabled: region.locked ? "disabled" : ""
|
||
|
|
}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update the region list and hide regions that are not visible.
|
||
|
|
*/
|
||
|
|
#updateVisibleRegions() {
|
||
|
|
this.#visibleRegions.clear();
|
||
|
|
for ( const li of this.element.querySelectorAll(".region-list > .region") ) {
|
||
|
|
const id = li.dataset.regionId;
|
||
|
|
const region = canvas.scene.regions.get(id);
|
||
|
|
const hidden = !((this.#searchFilter.rgx?.test(SearchFilter.cleanQuery(region.name)) !== false)
|
||
|
|
&& (Math.max(region.object.bottom, this.elevation.bottom) <= Math.min(region.object.top, this.elevation.top)));
|
||
|
|
if ( !hidden ) this.#visibleRegions.add(region);
|
||
|
|
li.classList.toggle("hidden", hidden);
|
||
|
|
}
|
||
|
|
this.setPosition({height: "auto"});
|
||
|
|
for ( const region of canvas.regions.placeables ) region.renderFlags.set({refreshState: true});
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Filter regions.
|
||
|
|
* @param {KeyboardEvent} event The key-up event from keyboard input
|
||
|
|
* @param {string} query The raw string input to the search field
|
||
|
|
* @param {RegExp} rgx The regular expression to test against
|
||
|
|
* @param {HTMLElement} html The HTML element which should be filtered
|
||
|
|
*/
|
||
|
|
#onSearchFilter(event, query, rgx, html) {
|
||
|
|
if ( !this.rendered ) return;
|
||
|
|
this.#updateVisibleRegions();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle change events of the elevation range (bottom) input.
|
||
|
|
* @param {KeyboardEvent} event
|
||
|
|
*/
|
||
|
|
#onElevationBottomChange(event) {
|
||
|
|
this.elevation.bottom = Number(event.currentTarget.value || -Infinity);
|
||
|
|
this.#updateVisibleRegions();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle change events of the elevation range (top) input.
|
||
|
|
* @param {KeyboardEvent} event
|
||
|
|
*/
|
||
|
|
#onElevationTopChange(event) {
|
||
|
|
this.elevation.top = Number(event.currentTarget.value || Infinity);
|
||
|
|
this.#updateVisibleRegions();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Is this Region visible in this RegionLegend?
|
||
|
|
* @param {Region} region The region
|
||
|
|
* @returns {boolean}
|
||
|
|
* @internal
|
||
|
|
*/
|
||
|
|
_isRegionVisible(region) {
|
||
|
|
if ( !this.rendered ) return true;
|
||
|
|
return this.#visibleRegions.has(region.document);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle mouse-in events on a region in the legend.
|
||
|
|
* @param {PointerEvent} event
|
||
|
|
*/
|
||
|
|
#onRegionHoverIn(event) {
|
||
|
|
event.preventDefault();
|
||
|
|
if ( !canvas.ready ) return;
|
||
|
|
const li = event.currentTarget.closest(".region");
|
||
|
|
const region = canvas.regions.get(li.dataset.regionId);
|
||
|
|
region._onHoverIn(event, {hoverOutOthers: true, updateLegend: false});
|
||
|
|
this.#hoveredRegion = region;
|
||
|
|
li.classList.add("hovered");
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle mouse-out events for a region in the legend.
|
||
|
|
* @param {PointerEvent} event
|
||
|
|
*/
|
||
|
|
#onRegionHoverOut(event) {
|
||
|
|
event.preventDefault();
|
||
|
|
const li = event.currentTarget.closest(".region");
|
||
|
|
this.#hoveredRegion?._onHoverOut(event, {updateLegend: false});
|
||
|
|
this.#hoveredRegion = null;
|
||
|
|
li.classList.remove("hovered");
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Highlight a hovered region in the legend.
|
||
|
|
* @param {Region} region The Region
|
||
|
|
* @param {boolean} hover Whether they are being hovered in or out.
|
||
|
|
* @internal
|
||
|
|
*/
|
||
|
|
_hoverRegion(region, hover) {
|
||
|
|
if ( !this.rendered ) return;
|
||
|
|
const li = this.element.querySelector(`.region[data-region-id="${region.id}"]`);
|
||
|
|
if ( !li ) return;
|
||
|
|
if ( hover ) li.classList.add("hovered");
|
||
|
|
else li.classList.remove("hovered");
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle clicks to configure a Region.
|
||
|
|
* @param {PointerEvent} event
|
||
|
|
*/
|
||
|
|
static #onConfig(event) {
|
||
|
|
const regionId = event.target.closest(".region").dataset.regionId;
|
||
|
|
const region = canvas.scene.regions.get(regionId);
|
||
|
|
region.sheet.render({force: true});
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle clicks to assume control over a Region.
|
||
|
|
* @param {PointerEvent} event
|
||
|
|
*/
|
||
|
|
static #onControl(event) {
|
||
|
|
const regionId = event.target.closest(".region").dataset.regionId;
|
||
|
|
const region = canvas.scene.regions.get(regionId);
|
||
|
|
|
||
|
|
// Double-click = toggle sheet
|
||
|
|
if ( event.detail === 2 ) {
|
||
|
|
region.object.control({releaseOthers: true});
|
||
|
|
region.sheet.render({force: true});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Single-click = toggle control
|
||
|
|
else if ( event.detail === 1 ) {
|
||
|
|
if ( region.object.controlled ) region.object.release();
|
||
|
|
else region.object.control({releaseOthers: true});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle button clicks to create a new Region.
|
||
|
|
* @param {PointerEvent} event
|
||
|
|
*/
|
||
|
|
static async #onCreate(event) {
|
||
|
|
await canvas.scene.createEmbeddedDocuments("Region", [{
|
||
|
|
name: RegionDocument.implementation.defaultName({parent: canvas.scene})
|
||
|
|
}]);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle clicks to delete a Region.
|
||
|
|
* @param {PointerEvent} event
|
||
|
|
*/
|
||
|
|
static async #onDelete(event) {
|
||
|
|
const regionId = event.target.closest(".region").dataset.regionId;
|
||
|
|
const region = canvas.scene.regions.get(regionId);
|
||
|
|
await region.deleteDialog();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle clicks to toggle the locked state of a Region.
|
||
|
|
* @param {PointerEvent} event
|
||
|
|
*/
|
||
|
|
static async #onLock(event) {
|
||
|
|
const regionId = event.target.closest(".region").dataset.regionId;
|
||
|
|
const region = canvas.scene.regions.get(regionId);
|
||
|
|
await region.update({locked: !region.locked});
|
||
|
|
}
|
||
|
|
}
|