Files
2025-01-04 00:34:03 +01:00

386 lines
12 KiB
JavaScript

/**
* @typedef {object} NewFontDefinition
* @property {string} [family] The font family.
* @property {number} [weight=400] The font weight.
* @property {string} [style="normal"] The font style.
* @property {string} [src=""] The font file.
* @property {string} [preview] The text to preview the font.
*/
/**
* A class responsible for configuring custom fonts for the world.
* @extends {FormApplication}
*/
class FontConfig extends FormApplication {
/**
* An application for configuring custom world fonts.
* @param {NewFontDefinition} [object] The default settings for new font definition creation.
* @param {object} [options] Additional options to configure behaviour.
*/
constructor(object={}, options={}) {
foundry.utils.mergeObject(object, {
family: "",
weight: 400,
style: "normal",
src: "",
preview: game.i18n.localize("FONTS.FontPreview"),
type: FontConfig.FONT_TYPES.FILE
});
super(object, options);
}
/* -------------------------------------------- */
/**
* Whether fonts have been modified since opening the application.
* @type {boolean}
*/
#fontsModified = false;
/* -------------------------------------------- */
/**
* The currently selected font.
* @type {{family: string, index: number}|null}
*/
#selected = null;
/* -------------------------------------------- */
/**
* Whether the given font is currently selected.
* @param {{family: string, index: number}} selection The font selection information.
* @returns {boolean}
*/
#isSelected({family, index}) {
if ( !this.#selected ) return false;
return (family === this.#selected.family) && (index === this.#selected.index);
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
title: game.i18n.localize("SETTINGS.FontConfigL"),
id: "font-config",
template: "templates/sidebar/apps/font-config.html",
popOut: true,
width: 600,
height: "auto",
closeOnSubmit: false,
submitOnChange: true
});
}
/* -------------------------------------------- */
/**
* Whether a font is distributed to connected clients or found on their OS.
* @enum {string}
*/
static FONT_TYPES = {
FILE: "file",
SYSTEM: "system"
};
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
const definitions = game.settings.get("core", this.constructor.SETTING);
const fonts = Object.entries(definitions).flatMap(([family, definition]) => {
return this._getDataForDefinition(family, definition);
});
let selected;
if ( (this.#selected === null) && fonts.length ) {
fonts[0].selected = true;
this.#selected = {family: fonts[0].family, index: fonts[0].index};
}
if ( fonts.length ) selected = definitions[this.#selected.family].fonts[this.#selected.index];
return {
fonts, selected,
font: this.object,
family: this.#selected?.family,
weights: Object.entries(CONST.FONT_WEIGHTS).map(([k, v]) => ({value: v, label: `${k} ${v}`})),
styles: [{value: "normal", label: "Normal"}, {value: "italic", label: "Italic"}]
};
}
/* -------------------------------------------- */
/**
* Template data for a given font definition.
* @param {string} family The font family.
* @param {FontFamilyDefinition} definition The font family definition.
* @returns {object[]}
* @protected
*/
_getDataForDefinition(family, definition) {
const fonts = definition.fonts.length ? definition.fonts : [{}];
return fonts.map((f, i) => {
const data = {family, index: i};
if ( this.#isSelected(data) ) data.selected = true;
data.font = this.constructor._formatFont(family, f);
return data;
});
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
html.find("[contenteditable]").on("blur", this._onSubmit.bind(this));
html.find(".control").on("click", this._onClickControl.bind(this));
}
/* -------------------------------------------- */
/** @inheritdoc */
async _updateObject(event, formData) {
foundry.utils.mergeObject(this.object, formData);
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
await super.close(options);
if ( this.#fontsModified ) return SettingsConfig.reloadConfirm({world: true});
}
/* -------------------------------------------- */
/**
* Handle application controls.
* @param {MouseEvent} event The click event.
* @protected
*/
_onClickControl(event) {
switch ( event.currentTarget.dataset.action ) {
case "add": return this._onAddFont();
case "delete": return this._onDeleteFont(event);
case "select": return this._onSelectFont(event);
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async _onChangeInput(event) {
this._updateFontFields();
return super._onChangeInput(event);
}
/* -------------------------------------------- */
/**
* Update available font fields based on the font type selected.
* @protected
*/
_updateFontFields() {
const type = this.form.elements.type.value;
const isSystemFont = type === this.constructor.FONT_TYPES.SYSTEM;
["weight", "style", "src"].forEach(name => {
const input = this.form.elements[name];
if ( input ) input.closest(".form-group")?.classList.toggle("hidden", isSystemFont);
});
this.setPosition();
}
/* -------------------------------------------- */
/**
* Add a new custom font definition.
* @protected
*/
async _onAddFont() {
const {family, src, weight, style, type} = this._getSubmitData();
const definitions = game.settings.get("core", this.constructor.SETTING);
definitions[family] ??= {editor: true, fonts: []};
const definition = definitions[family];
const count = type === this.constructor.FONT_TYPES.FILE ? definition.fonts.push({urls: [src], weight, style}) : 1;
await game.settings.set("core", this.constructor.SETTING, definitions);
await this.constructor.loadFont(family, definition);
this.#selected = {family, index: count - 1};
this.#fontsModified = true;
this.render(true);
}
/* -------------------------------------------- */
/**
* Delete a font.
* @param {MouseEvent} event The click event.
* @protected
*/
async _onDeleteFont(event) {
event.preventDefault();
event.stopPropagation();
const target = event.currentTarget.closest("[data-family]");
const {family, index} = target.dataset;
const definitions = game.settings.get("core", this.constructor.SETTING);
const definition = definitions[family];
if ( !definition ) return;
this.#fontsModified = true;
definition.fonts.splice(Number(index), 1);
if ( !definition.fonts.length ) delete definitions[family];
await game.settings.set("core", this.constructor.SETTING, definitions);
if ( this.#isSelected({family, index: Number(index)}) ) this.#selected = null;
this.render(true);
}
/* -------------------------------------------- */
/**
* Select a font to preview.
* @param {MouseEvent} event The click event.
* @protected
*/
_onSelectFont(event) {
const {family, index} = event.currentTarget.dataset;
this.#selected = {family, index: Number(index)};
this.render(true);
}
/* -------------------------------------------- */
/* Font Management Methods */
/* -------------------------------------------- */
/**
* Define the setting key where this world's font information will be stored.
* @type {string}
*/
static SETTING = "fonts";
/* -------------------------------------------- */
/**
* A list of fonts that were correctly loaded and are available for use.
* @type {Set<string>}
* @private
*/
static #available = new Set();
/* -------------------------------------------- */
/**
* Get the list of fonts that successfully loaded.
* @returns {string[]}
*/
static getAvailableFonts() {
return Array.from(this.#available);
}
/* -------------------------------------------- */
/**
* Get the list of fonts formatted for display with selectOptions.
* @returns {Record<string, string>}
*/
static getAvailableFontChoices() {
return this.getAvailableFonts().reduce((obj, f) => {
obj[f] = f;
return obj;
}, {});
}
/* -------------------------------------------- */
/**
* Load a font definition.
* @param {string} family The font family name (case-sensitive).
* @param {FontFamilyDefinition} definition The font family definition.
* @returns {Promise<boolean>} Returns true if the font was successfully loaded.
*/
static async loadFont(family, definition) {
const font = `1rem "${family}"`;
try {
for ( const font of definition.fonts ) {
const fontFace = this._createFontFace(family, font);
await fontFace.load();
document.fonts.add(fontFace);
}
await document.fonts.load(font);
} catch(err) {
console.warn(`Font family "${family}" failed to load: `, err);
return false;
}
if ( !document.fonts.check(font) ) {
console.warn(`Font family "${family}" failed to load.`);
return false;
}
if ( definition.editor ) this.#available.add(family);
return true;
}
/* -------------------------------------------- */
/**
* Ensure that fonts have loaded and are ready for use.
* Enforce a maximum timeout in milliseconds.
* Proceed after that point even if fonts are not yet available.
* @param {number} [ms=4500] The maximum time to spend loading fonts before proceeding.
* @returns {Promise<void>}
* @internal
*/
static async _loadFonts(ms=4500) {
const allFonts = this._collectDefinitions();
const promises = [];
for ( const definitions of allFonts ) {
for ( const [family, definition] of Object.entries(definitions) ) {
promises.push(this.loadFont(family, definition));
}
}
const timeout = new Promise(resolve => setTimeout(resolve, ms));
const ready = Promise.all(promises).then(() => document.fonts.ready);
return Promise.race([ready, timeout]).then(() => console.log(`${vtt} | Fonts loaded and ready.`));
}
/* -------------------------------------------- */
/**
* Collect all the font definitions and combine them.
* @returns {Record<string, FontFamilyDefinition>[]}
* @protected
*/
static _collectDefinitions() {
return [CONFIG.fontDefinitions, game.settings.get("core", this.SETTING)];
}
/* -------------------------------------------- */
/**
* Create FontFace object from a FontDefinition.
* @param {string} family The font family name.
* @param {FontDefinition} font The font definition.
* @returns {FontFace}
* @protected
*/
static _createFontFace(family, font) {
const urls = font.urls.map(url => `url("${url}")`).join(", ");
return new FontFace(family, urls, font);
}
/* -------------------------------------------- */
/**
* Format a font definition for display.
* @param {string} family The font family.
* @param {FontDefinition} definition The font definition.
* @returns {string} The formatted definition.
* @private
*/
static _formatFont(family, definition) {
if ( foundry.utils.isEmpty(definition) ) return family;
const {weight, style} = definition;
const byWeight = Object.fromEntries(Object.entries(CONST.FONT_WEIGHTS).map(([k, v]) => [v, k]));
return `
${family},
<span style="font-weight: ${weight}">${byWeight[weight]} ${weight}</span>,
<span style="font-style: ${style}">${style.toLowerCase()}</span>
`;
}
}