Files
Foundry-VTT-Docker/resources/app/client/apps/forms/scene-config.js

527 lines
19 KiB
JavaScript
Raw Normal View History

2025-01-04 00:34:03 +01:00
/**
* The Application responsible for configuring a single Scene document.
* @extends {DocumentSheet}
* @param {Scene} object The Scene Document which is being configured
* @param {DocumentSheetOptions} [options] Application configuration options.
*/
class SceneConfig extends DocumentSheet {
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "scene-config",
classes: ["sheet", "scene-sheet"],
template: "templates/scene/config.html",
width: 560,
height: "auto",
tabs: [
{navSelector: '.tabs[data-group="main"]', contentSelector: "form", initial: "basic"},
{navSelector: '.tabs[data-group="ambience"]', contentSelector: '.tab[data-tab="ambience"]', initial: "basic"}
]
});
}
/* -------------------------------------------- */
/**
* Indicates if width / height should change together to maintain aspect ratio
* @type {boolean}
*/
linkedDimensions = true;
/* -------------------------------------------- */
/** @override */
get title() {
return `${game.i18n.localize("SCENES.ConfigTitle")}: ${this.object.name}`;
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
this._resetScenePreview();
return super.close(options);
}
/* -------------------------------------------- */
/** @inheritdoc */
render(force, options={}) {
if ( options.renderContext && !["createScene", "updateScene"].includes(options.renderContext) ) return this;
return super.render(force, options);
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const context = super.getData(options);
context.data = this.document.toObject(); // Source data, not derived
context.playlistSound = this.document.playlistSound?.id || "";
context.foregroundElevation = this.document.foregroundElevation;
// Selectable types
context.minGrid = CONST.GRID_MIN_SIZE;
context.gridTypes = this.constructor._getGridTypes();
context.gridStyles = CONFIG.Canvas.gridStyles;
context.weatherTypes = this._getWeatherTypes();
context.ownerships = [
{value: 0, label: "SCENES.AccessibilityGM"},
{value: 2, label: "SCENES.AccessibilityAll"}
];
// Referenced documents
context.playlists = this._getDocuments(game.playlists);
context.sounds = this._getDocuments(this.object.playlist?.sounds ?? []);
context.journals = this._getDocuments(game.journal);
context.pages = this.object.journal?.pages.contents.sort((a, b) => a.sort - b.sort) ?? [];
context.isEnvironment = (this._tabs[0].active === "ambience") && (this._tabs[1].active === "environment");
context.baseHueSliderDisabled = (this.document.environment.base.intensity === 0);
context.darknessHueSliderDisabled = (this.document.environment.dark.intensity === 0);
return context;
}
/* -------------------------------------------- */
/**
* Get an enumeration of the available grid types which can be applied to this Scene
* @returns {object}
* @internal
*/
static _getGridTypes() {
const labels = {
GRIDLESS: "SCENES.GridGridless",
SQUARE: "SCENES.GridSquare",
HEXODDR: "SCENES.GridHexOddR",
HEXEVENR: "SCENES.GridHexEvenR",
HEXODDQ: "SCENES.GridHexOddQ",
HEXEVENQ: "SCENES.GridHexEvenQ"
};
return Object.keys(CONST.GRID_TYPES).reduce((obj, t) => {
obj[CONST.GRID_TYPES[t]] = labels[t];
return obj;
}, {});
}
/* --------------------------------------------- */
/** @inheritdoc */
async _renderInner(...args) {
await loadTemplates([
"templates/scene/parts/scene-ambience.html"
]);
return super._renderInner(...args);
}
/* -------------------------------------------- */
/**
* Get the available weather effect types which can be applied to this Scene
* @returns {object}
* @private
*/
_getWeatherTypes() {
const types = {};
for ( let [k, v] of Object.entries(CONFIG.weatherEffects) ) {
types[k] = game.i18n.localize(v.label);
}
return types;
}
/* -------------------------------------------- */
/**
* Get the alphabetized Documents which can be chosen as a configuration for the Scene
* @param {WorldCollection} collection
* @returns {object[]}
* @private
*/
_getDocuments(collection) {
const documents = collection.map(doc => {
return {id: doc.id, name: doc.name};
});
documents.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang));
return documents;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find("button.capture-position").click(this._onCapturePosition.bind(this));
html.find("button.grid-config").click(this._onGridConfig.bind(this));
html.find("button.dimension-link").click(this._onLinkDimensions.bind(this));
html.find("select[name='playlist']").change(this._onChangePlaylist.bind(this));
html.find('select[name="journal"]').change(this._onChangeJournal.bind(this));
html.find('button[type="reset"]').click(this._onResetForm.bind(this));
html.find("hue-slider").change(this._onChangeRange.bind(this));
}
/* -------------------------------------------- */
/**
* Capture the current Scene position and zoom level as the initial view in the Scene config
* @param {Event} event The originating click event
* @private
*/
_onCapturePosition(event) {
event.preventDefault();
if ( !canvas.ready ) return;
const btn = event.currentTarget;
const form = btn.form;
form["initial.x"].value = parseInt(canvas.stage.pivot.x);
form["initial.y"].value = parseInt(canvas.stage.pivot.y);
form["initial.scale"].value = canvas.stage.scale.x;
ui.notifications.info("SCENES.CaptureInitialViewPosition", {localize: true});
}
/* -------------------------------------------- */
/**
* Handle click events to open the grid configuration application
* @param {Event} event The originating click event
* @private
*/
async _onGridConfig(event) {
event.preventDefault();
new GridConfig(this.object, this).render(true);
return this.minimize();
}
/* -------------------------------------------- */
/**
* Handle click events to link or unlink the scene dimensions
* @param {Event} event
* @returns {Promise<void>}
* @private
*/
async _onLinkDimensions(event) {
event.preventDefault();
this.linkedDimensions = !this.linkedDimensions;
this.element.find("button.dimension-link > i").toggleClass("fa-link-simple", this.linkedDimensions);
this.element.find("button.dimension-link > i").toggleClass("fa-link-simple-slash", !this.linkedDimensions);
this.element.find("button.resize").attr("disabled", !this.linkedDimensions);
// Update Tooltip
const tooltip = game.i18n.localize(this.linkedDimensions ? "SCENES.DimensionLinked" : "SCENES.DimensionUnlinked");
this.element.find("button.dimension-link").attr("data-tooltip", tooltip);
game.tooltip.activate(this.element.find("button.dimension-link")[0], { text: tooltip });
}
/* -------------------------------------------- */
/** @override */
async _onChangeInput(event) {
if ( event.target.name === "width" || event.target.name === "height" ) this._onChangeDimensions(event);
if ( event.target.name === "environment.darknessLock" ) await this.#onDarknessLockChange(event.target.checked);
this._previewScene(event.target.name);
return super._onChangeInput(event);
}
/* -------------------------------------------- */
/**
* Handle darkness lock change and update immediately the database.
* @param {boolean} darknessLock If the darkness lock is checked or not.
* @returns {Promise<void>}
*/
async #onDarknessLockChange(darknessLock) {
const darknessLevelForm = this.form["environment.darknessLevel"];
darknessLevelForm.disabled = darknessLock;
await this.document.update({
environment: {
darknessLock,
darknessLevel: darknessLevelForm.valueAsNumber
}}, {render: false});
}
/* -------------------------------------------- */
/** @override */
_onChangeColorPicker(event) {
super._onChangeColorPicker(event);
this._previewScene(event.target.dataset.edit);
}
/* -------------------------------------------- */
/** @override */
_onChangeRange(event) {
super._onChangeRange(event);
for ( const target of ["base", "dark"] ) {
if ( event.target.name === `environment.${target}.intensity` ) {
const intensity = this.form[`environment.${target}.intensity`].valueAsNumber;
this.form[`environment.${target}.hue`].disabled = (intensity === 0);
}
}
this._previewScene(event.target.name);
}
/* -------------------------------------------- */
/** @inheritdoc */
_onChangeTab(event, tabs, active) {
super._onChangeTab(event, tabs, active);
const enabled = (this._tabs[0].active === "ambience") && (this._tabs[1].active === "environment");
this.element.find('button[type="reset"]').toggleClass("hidden", !enabled);
}
/* -------------------------------------------- */
/**
* Reset the values of the environment attributes to their default state.
* @param {PointerEvent} event The originating click event
* @private
*/
_onResetForm(event) {
event.preventDefault();
// Get base and dark ambience defaults and originals
const def = Scene.cleanData().environment;
const ori = this.document.toObject().environment;
const defaults = {base: def.base, dark: def.dark};
const original = {base: ori.base, dark: ori.dark};
// Reset the elements to the default values
for ( const target of ["base", "dark"] ) {
this.form[`environment.${target}.hue`].disabled = (defaults[target].intensity === 0);
this.form[`environment.${target}.intensity`].value = defaults[target].intensity;
this.form[`environment.${target}.luminosity`].value = defaults[target].luminosity;
this.form[`environment.${target}.saturation`].value = defaults[target].saturation;
this.form[`environment.${target}.shadows`].value = defaults[target].shadows;
this.form[`environment.${target}.hue`].value = defaults[target].hue;
}
// Update the document with the default environment values
this.document.updateSource({environment: defaults});
// Preview the scene and re-render the config
this._previewScene("forceEnvironmentPreview");
this.render();
// Restore original environment values
this.document.updateSource({environment: original});
}
/* -------------------------------------------- */
/**
* Live update the scene as certain properties are changed.
* @param {string} changed The changed property.
* @internal
*/
_previewScene(changed) {
if ( !this.object.isView || !canvas.ready || !changed ) return;
const force = changed.includes("force");
// Preview triggered for the grid
if ( ["grid.style", "grid.thickness", "grid.color", "grid.alpha"].includes(changed) || force ) {
canvas.interface.grid.initializeMesh({
style: this.form["grid.style"].value,
thickness: Number(this.form["grid.thickness"].value),
color: this.form["grid.color"].value,
alpha: Number(this.form["grid.alpha"].value)
});
}
// To easily track all the environment changes
const environmentChange = changed.includes("environment.") || changed.includes("forceEnvironmentPreview") || force;
// Preview triggered for the ambience manager
if ( ["backgroundColor", "fog.colors.explored", "fog.colors.unexplored"].includes(changed)
|| environmentChange ) {
canvas.environment.initialize(this.#getAmbienceFormData());
}
}
/* -------------------------------------------- */
/**
* Get the ambience form data.
* @returns {Object}
*/
#getAmbienceFormData() {
const fd = new FormDataExtended(this.form);
const formData = foundry.utils.expandObject(fd.object);
return {
backgroundColor: formData.backgroundColor,
fogExploredColor: formData.fog.colors.explored,
fogUnexploredColor: formData.fog.colors.unexplored,
environment: formData.environment
};
}
/* -------------------------------------------- */
/**
* Reset the previewed darkness level, background color, grid alpha, and grid color back to their true values.
* @private
*/
_resetScenePreview() {
if ( !this.object.isView || !canvas.ready ) return;
canvas.scene.reset();
canvas.environment.initialize();
canvas.interface.grid.initializeMesh(canvas.scene.grid);
}
/* -------------------------------------------- */
/**
* Handle updating the select menu of PlaylistSound options when the Playlist is changed
* @param {Event} event The initiating select change event
* @private
*/
_onChangePlaylist(event) {
event.preventDefault();
const playlist = game.playlists.get(event.target.value);
const sounds = this._getDocuments(playlist?.sounds || []);
const options = ['<option value=""></option>'].concat(sounds.map(s => {
return `<option value="${s.id}">${s.name}</option>`;
}));
const select = this.form.querySelector("select[name=\"playlistSound\"]");
select.innerHTML = options.join("");
}
/* -------------------------------------------- */
/**
* Handle updating the select menu of JournalEntryPage options when the JournalEntry is changed.
* @param {Event} event The initiating select change event.
* @protected
*/
_onChangeJournal(event) {
event.preventDefault();
const entry = game.journal.get(event.currentTarget.value);
const pages = entry?.pages.contents.sort((a, b) => a.sort - b.sort) ?? [];
const options = pages.map(page => {
const selected = (entry.id === this.object.journal?.id) && (page.id === this.object.journalEntryPage);
return `<option value="${page.id}"${selected ? " selected" : ""}>${page.name}</option>`;
});
this.form.elements.journalEntryPage.innerHTML = `<option></option>${options}`;
}
/* -------------------------------------------- */
/**
* Handle updating the select menu of JournalEntryPage options when the JournalEntry is changed.
* @param event
* @private
*/
_onChangeDimensions(event) {
event.preventDefault();
if ( !this.linkedDimensions ) return;
const name = event.currentTarget.name;
const value = Number(event.currentTarget.value);
const oldValue = name === "width" ? this.object.width : this.object.height;
const scale = value / oldValue;
const otherInput = this.form.elements[name === "width" ? "height" : "width"];
otherInput.value = otherInput.value * scale;
// If new value is not a round number, display an error and revert
if ( !Number.isInteger(parseFloat(otherInput.value)) ) {
ui.notifications.error(game.i18n.localize("SCENES.InvalidDimension"));
this.form.elements[name].value = oldValue;
otherInput.value = name === "width" ? this.object.height : this.object.width;
return;
}
}
/* -------------------------------------------- */
/** @override */
async _updateObject(event, formData) {
const scene = this.document;
// FIXME: Ideally, FormDataExtended would know to set these fields to null instead of keeping a blank string
// SceneData.texture.src is nullable in the schema, causing an empty string to be initialised to null. We need to
// match that logic here to ensure that comparisons to the existing scene image are accurate.
if ( formData["background.src"] === "" ) formData["background.src"] = null;
if ( formData.foreground === "" ) formData.foreground = null;
if ( formData["fog.overlay"] === "" ) formData["fog.overlay"] = null;
// The same for fog colors
if ( formData["fog.colors.unexplored"] === "" ) formData["fog.colors.unexplored"] = null;
if ( formData["fog.colors.explored"] === "" ) formData["fog.colors.explored"] = null;
// Determine what type of change has occurred
const hasDefaultDims = (scene.background.src === null) && (scene.width === 4000) && (scene.height === 3000);
const hasImage = formData["background.src"] || scene.background.src;
const changedBackground =
(formData["background.src"] !== undefined) && (formData["background.src"] !== scene.background.src);
const clearedDims = (formData.width === null) || (formData.height === null);
const needsThumb = changedBackground || !scene.thumb;
const needsDims = formData["background.src"] && (clearedDims || hasDefaultDims);
const createThumbnail = hasImage && (needsThumb || needsDims);
// Update thumbnail and image dimensions
if ( createThumbnail && game.settings.get("core", "noCanvas") ) {
ui.notifications.warn("SCENES.GenerateThumbNoCanvas", {localize: true});
formData.thumb = null;
} else if ( createThumbnail ) {
let td = {};
try {
td = await scene.createThumbnail({img: formData["background.src"] ?? scene.background.src});
} catch(err) {
Hooks.onError("SceneConfig#_updateObject", err, {
msg: "Thumbnail generation for Scene failed",
notify: "error",
log: "error",
scene: scene.id
});
}
if ( needsThumb ) formData.thumb = td.thumb || null;
if ( needsDims ) {
formData.width = td.width;
formData.height = td.height;
}
}
// Warn the user if Scene dimensions are changing
const delta = foundry.utils.diffObject(scene._source, foundry.utils.expandObject(formData));
const changes = foundry.utils.flattenObject(delta);
const textureChange = ["scaleX", "scaleY", "rotation"].map(k => `background.${k}`);
if ( ["grid.size", ...textureChange].some(k => k in changes) ) {
const confirm = await Dialog.confirm({
title: game.i18n.localize("SCENES.DimensionChangeTitle"),
content: `<p>${game.i18n.localize("SCENES.DimensionChangeWarning")}</p>`
});
if ( !confirm ) return;
}
// If the canvas size has changed in a nonuniform way, ask the user if they want to reposition
let autoReposition = false;
if ( (scene.background?.src || scene.foreground?.src) && (["width", "height", "padding", "background", "grid.size"].some(x => x in changes)) ) {
autoReposition = true;
// If aspect ratio changes, prompt to replace all tokens with new dimensions and warn about distortions
let showPrompt = false;
if ( "width" in changes && "height" in changes ) {
const currentScale = this.object.width / this.object.height;
const newScale = formData.width / formData.height;
if ( currentScale !== newScale ) {
showPrompt = true;
}
}
else if ( "width" in changes || "height" in changes ) {
showPrompt = true;
}
if ( showPrompt ) {
const confirm = await Dialog.confirm({
title: game.i18n.localize("SCENES.DistortedDimensionsTitle"),
content: game.i18n.localize("SCENES.DistortedDimensionsWarning"),
defaultYes: false
});
if ( !confirm ) autoReposition = false;
}
}
// Perform the update
delete formData["environment.darknessLock"];
return scene.update(formData, {autoReposition});
}
}