386 lines
12 KiB
JavaScript
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>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
}
|