Files
Foundry-VTT-Docker/resources/app/client/core/tour.js
2025-01-04 00:34:03 +01:00

556 lines
17 KiB
JavaScript

/**
* @typedef {Object} TourStep A step in a Tour
* @property {string} id A machine-friendly id of the Tour Step
* @property {string} title The title of the step, displayed in the tooltip header
* @property {string} content Raw HTML content displayed during the step
* @property {string} [selector] A DOM selector which denotes an element to highlight during this step.
* If omitted, the step is displayed in the center of the screen.
* @property {TooltipManager.TOOLTIP_DIRECTIONS} [tooltipDirection] How the tooltip for the step should be displayed
* relative to the target element. If omitted, the best direction will be attempted to be auto-selected.
* @property {boolean} [restricted] Whether the Step is restricted to the GM only. Defaults to false.
*/
/**
* @typedef {Object} TourConfig Tour configuration data
* @property {string} namespace The namespace this Tour belongs to. Typically, the name of the package which
* implements the tour should be used
* @property {string} id A machine-friendly id of the Tour, must be unique within the provided namespace
* @property {string} title A human-readable name for this Tour. Localized.
* @property {TourStep[]} steps The list of Tour Steps
* @property {string} [description] A human-readable description of this Tour. Localized.
* @property {object} [localization] A map of localizations for the Tour that should be merged into the default localizations
* @property {boolean} [restricted] Whether the Tour is restricted to the GM only. Defaults to false.
* @property {boolean} [display] Whether the Tour should be displayed in the Manage Tours UI. Defaults to false.
* @property {boolean} [canBeResumed] Whether the Tour can be resumed or if it always needs to start from the beginning. Defaults to false.
* @property {string[]} [suggestedNextTours] A list of namespaced Tours that might be suggested to the user when this Tour is completed.
* The first non-completed Tour in the array will be recommended.
*/
/**
* A Tour that shows a series of guided steps.
* @param {TourConfig} config The configuration of the Tour
* @tutorial tours
*/
class Tour {
constructor(config, {id, namespace}={}) {
this.config = foundry.utils.deepClone(config);
if ( this.config.localization ) foundry.utils.mergeObject(game.i18n._fallback, this.config.localization);
this.#id = id ?? config.id;
this.#namespace = namespace ?? config.namespace;
this.#stepIndex = this._loadProgress();
}
/**
* A singleton reference which tracks the currently active Tour.
* @type {Tour|null}
*/
static #activeTour = null;
/**
* @enum {string}
*/
static STATUS = {
UNSTARTED: "unstarted",
IN_PROGRESS: "in-progress",
COMPLETED: "completed"
};
/**
* Indicates if a Tour is currently in progress.
* @returns {boolean}
*/
static get tourInProgress() {
return !!Tour.#activeTour;
}
/**
* Returns the active Tour, if any
* @returns {Tour|null}
*/
static get activeTour() {
return Tour.#activeTour;
}
/* -------------------------------------------- */
/**
* Handle a movement action to either progress or regress the Tour.
* @param @param {string[]} movementDirections The Directions being moved in
* @returns {boolean}
*/
static onMovementAction(movementDirections) {
if ( (movementDirections.includes(ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT))
&& (Tour.activeTour.hasNext) ) {
Tour.activeTour.next();
return true;
}
else if ( (movementDirections.includes(ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT))
&& (Tour.activeTour.hasPrevious) ) {
Tour.activeTour.previous();
return true;
}
}
/**
* Configuration of the tour. This object is cloned to avoid mutating the original configuration.
* @type {TourConfig}
*/
config;
/**
* The HTMLElement which is the focus of the current tour step.
* @type {HTMLElement}
*/
targetElement;
/**
* The HTMLElement that fades out the rest of the screen
* @type {HTMLElement}
*/
fadeElement;
/**
* The HTMLElement that blocks input while a Tour is active
*/
overlayElement;
/**
* Padding around a Highlighted Element
* @type {number}
*/
static HIGHLIGHT_PADDING = 10;
/**
* The unique identifier of the tour.
* @type {string}
*/
get id() {
return this.#id;
}
set id(value) {
if ( this.#id ) throw new Error("The Tour has already been assigned an ID");
this.#id = value;
}
#id;
/**
* The human-readable title for the tour.
* @type {string}
*/
get title() {
return game.i18n.localize(this.config.title);
}
/**
* The human-readable description of the tour.
* @type {string}
*/
get description() {
return game.i18n.localize(this.config.description);
}
/**
* The package namespace for the tour.
* @type {string}
*/
get namespace() {
return this.#namespace;
}
set namespace(value) {
if ( this.#namespace ) throw new Error("The Tour has already been assigned a namespace");
this.#namespace = value;
}
#namespace;
/**
* The key the Tour is stored under in game.tours, of the form `${namespace}.${id}`
* @returns {string}
*/
get key() {
return `${this.#namespace}.${this.#id}`;
}
/**
* The configuration of tour steps
* @type {TourStep[]}
*/
get steps() {
return this.config.steps.filter(step => !step.restricted || game.user.isGM);
}
/**
* Return the current Step, or null if the tour has not yet started.
* @type {TourStep|null}
*/
get currentStep() {
return this.steps[this.#stepIndex] ?? null;
}
/**
* The index of the current step; -1 if the tour has not yet started, or null if the tour is finished.
* @type {number|null}
*/
get stepIndex() {
return this.#stepIndex;
}
/** @private */
#stepIndex = -1;
/**
* Returns True if there is a next TourStep
* @type {boolean}
*/
get hasNext() {
return this.#stepIndex < this.steps.length - 1;
}
/**
* Returns True if there is a previous TourStep
* @type {boolean}
*/
get hasPrevious() {
return this.#stepIndex > 0;
}
/**
* Return whether this Tour is currently eligible to be started?
* This is useful for tours which can only be used in certain circumstances, like if the canvas is active.
* @type {boolean}
*/
get canStart() {
return true;
}
/**
* The current status of the Tour
* @returns {STATUS}
*/
get status() {
if ( this.#stepIndex === -1 ) return Tour.STATUS.UNSTARTED;
else if (this.#stepIndex === this.steps.length) return Tour.STATUS.COMPLETED;
else return Tour.STATUS.IN_PROGRESS;
}
/* -------------------------------------------- */
/* Tour Methods */
/* -------------------------------------------- */
/**
* Advance the tour to a completed state.
*/
async complete() {
return this.progress(this.steps.length);
}
/* -------------------------------------------- */
/**
* Exit the tour at the current step.
*/
exit() {
if ( this.currentStep ) this._postStep();
Tour.#activeTour = null;
}
/* -------------------------------------------- */
/**
* Reset the Tour to an un-started state.
*/
async reset() {
return this.progress(-1);
}
/* -------------------------------------------- */
/**
* Start the Tour at its current step, or at the beginning if the tour has not yet been started.
*/
async start() {
game.tooltip.clearPending();
switch ( this.status ) {
case Tour.STATUS.IN_PROGRESS:
return this.progress((this.config.canBeResumed && this.hasPrevious) ? this.#stepIndex : 0);
case Tour.STATUS.UNSTARTED:
case Tour.STATUS.COMPLETED:
return this.progress(0);
}
}
/* -------------------------------------------- */
/**
* Progress the Tour to the next step.
*/
async next() {
if ( this.status === Tour.STATUS.COMPLETED ) {
throw new Error(`Tour ${this.id} has already been completed`);
}
if ( !this.hasNext ) return this.complete();
return this.progress(this.#stepIndex + 1);
}
/* -------------------------------------------- */
/**
* Rewind the Tour to the previous step.
*/
async previous() {
if ( !this.hasPrevious ) return;
return this.progress(this.#stepIndex - 1);
}
/* -------------------------------------------- */
/**
* Progresses to a given Step
* @param {number} stepIndex The step to progress to
*/
async progress(stepIndex) {
// Ensure we are provided a valid tour step
if ( !Number.between(stepIndex, -1, this.steps.length) ) {
throw new Error(`Step index ${stepIndex} is not valid for Tour ${this.id} with ${this.steps.length} steps.`);
}
// Ensure that only one Tour is active at a given time
if ( Tour.#activeTour && (Tour.#activeTour !== this) ) {
if ( (stepIndex !== -1) && (stepIndex !== this.steps.length) ) throw new Error(`You cannot begin the ${this.title} Tour because the `
+ `${Tour.#activeTour.title} Tour is already in progress`);
else Tour.#activeTour = null;
}
else Tour.#activeTour = this;
// Tear down the prior step
if ( stepIndex > 0 ) {
await this._postStep();
console.debug(`Tour [${this.namespace}.${this.id}] | Completed step ${this.#stepIndex+1} of ${this.steps.length}`);
}
// Change the step and save progress
this.#stepIndex = stepIndex;
this._saveProgress();
// If the TourManager is active, update the UI
const tourManager = Object.values(ui.windows).find(x => x instanceof ToursManagement);
if ( tourManager ) {
tourManager._cachedData = null;
tourManager._render(true);
}
if ( this.status === Tour.STATUS.UNSTARTED ) return Tour.#activeTour = null;
if ( this.status === Tour.STATUS.COMPLETED ) {
Tour.#activeTour = null;
const suggestedTour = game.tours.get((this.config.suggestedNextTours || []).find(tourId => {
const tour = game.tours.get(tourId);
return tour && (tour.status !== Tour.STATUS.COMPLETED);
}));
if ( !suggestedTour ) return;
return Dialog.confirm({
title: game.i18n.localize("TOURS.SuggestedTitle"),
content: game.i18n.format("TOURS.SuggestedDescription", { currentTitle: this.title, nextTitle: suggestedTour.title }),
yes: () => suggestedTour.start(),
defaultYes: true
});
}
// Set up the next step
await this._preStep();
// Identify the target HTMLElement
this.targetElement = null;
const step = this.currentStep;
if ( step.selector ) {
this.targetElement = this._getTargetElement(step.selector);
if ( !this.targetElement ) console.warn(`Tour [${this.id}] target element "${step.selector}" was not found`);
}
// Display the step
try {
await this._renderStep();
}
catch(e) {
this.exit();
throw e;
}
}
/* -------------------------------------------- */
/**
* Query the DOM for the target element using the provided selector
* @param {string} selector A CSS selector
* @returns {Element|null} The target element, or null if not found
* @protected
*/
_getTargetElement(selector) {
return document.querySelector(selector);
}
/* -------------------------------------------- */
/**
* Creates and returns a Tour by loading a JSON file
* @param {string} filepath The path to the JSON file
* @returns {Promise<Tour>}
*/
static async fromJSON(filepath) {
const json = await foundry.utils.fetchJsonWithTimeout(foundry.utils.getRoute(filepath, {prefix: ROUTE_PREFIX}));
return new this(json);
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/**
* Set-up operations performed before a step is shown.
* @abstract
* @protected
*/
async _preStep() {}
/* -------------------------------------------- */
/**
* Clean-up operations performed after a step is completed.
* @abstract
* @protected
*/
async _postStep() {
if ( this.currentStep && !this.currentStep.selector ) this.targetElement?.remove();
else game.tooltip.deactivate();
if ( this.fadeElement ) {
this.fadeElement.remove();
this.fadeElement = undefined;
}
if ( this.overlayElement ) this.overlayElement = this.overlayElement.remove();
}
/* -------------------------------------------- */
/**
* Renders the current Step of the Tour
* @protected
*/
async _renderStep() {
const step = this.currentStep;
const data = {
title: game.i18n.localize(step.title),
content: game.i18n.localize(step.content).split("\n"),
step: this.#stepIndex + 1,
totalSteps: this.steps.length,
hasNext: this.hasNext,
hasPrevious: this.hasPrevious
};
const content = await renderTemplate("templates/apps/tour-step.html", data);
if ( step.selector ) {
if ( !this.targetElement ) {
throw new Error(`The expected targetElement ${step.selector} does not exist`);
}
this.targetElement.scrollIntoView();
game.tooltip.activate(this.targetElement, {text: content, cssClass: "tour", direction: step.tooltipDirection});
}
else {
// Display a general mid-screen Step
const wrapper = document.createElement("aside");
wrapper.innerHTML = content;
wrapper.classList.add("tour-center-step");
wrapper.classList.add("tour");
document.body.appendChild(wrapper);
this.targetElement = wrapper;
}
// Fade out rest of screen
this.fadeElement = document.createElement("div");
this.fadeElement.classList.add("tour-fadeout");
const targetBoundingRect = this.targetElement.getBoundingClientRect();
this.fadeElement.style.width = `${targetBoundingRect.width + (step.selector ? Tour.HIGHLIGHT_PADDING : 0)}px`;
this.fadeElement.style.height = `${targetBoundingRect.height + (step.selector ? Tour.HIGHLIGHT_PADDING : 0)}px`;
this.fadeElement.style.top = `${targetBoundingRect.top - ((step.selector ? Tour.HIGHLIGHT_PADDING : 0) / 2)}px`;
this.fadeElement.style.left = `${targetBoundingRect.left - ((step.selector ? Tour.HIGHLIGHT_PADDING : 0) / 2)}px`;
document.body.appendChild(this.fadeElement);
// Add Overlay to block input
this.overlayElement = document.createElement("div");
this.overlayElement.classList.add("tour-overlay");
document.body.appendChild(this.overlayElement);
// Activate Listeners
const buttons = step.selector ? game.tooltip.tooltip.querySelectorAll(".step-button")
: this.targetElement.querySelectorAll(".step-button");
for ( let button of buttons ) {
button.addEventListener("click", event => this._onButtonClick(event, buttons));
}
}
/* -------------------------------------------- */
/**
* Handle Tour Button clicks
* @param {Event} event A click event
* @param {HTMLElement[]} buttons The step buttons
* @private
*/
_onButtonClick(event, buttons) {
event.preventDefault();
// Disable all the buttons to prevent double-clicks
for ( let button of buttons ) {
button.classList.add("disabled");
}
// Handle action
const action = event.currentTarget.dataset.action;
switch ( action ) {
case "exit": return this.exit();
case "previous": return this.previous();
case "next": return this.next();
default: throw new Error(`Unexpected Tour button action - ${action}`);
}
}
/* -------------------------------------------- */
/**
* Saves the current progress of the Tour to a world setting
* @private
*/
_saveProgress() {
let progress = game.settings.get("core", "tourProgress");
if ( !(this.namespace in progress) ) progress[this.namespace] = {};
progress[this.namespace][this.id] = this.#stepIndex;
game.settings.set("core", "tourProgress", progress);
}
/* -------------------------------------------- */
/**
* Returns the User's current progress of this Tour
* @returns {null|number}
* @private
*/
_loadProgress() {
let progress = game.settings.get("core", "tourProgress");
return progress?.[this.namespace]?.[this.id] ?? -1;
}
/* -------------------------------------------- */
/**
* Reloads the Tour's current step from the saved progress
* @internal
*/
_reloadProgress() {
this.#stepIndex = this._loadProgress();
}
}