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}); } }