Initial
This commit is contained in:
78
resources/app/common/abstract/_types.mjs
Normal file
78
resources/app/common/abstract/_types.mjs
Normal file
@@ -0,0 +1,78 @@
|
||||
|
||||
/**
|
||||
* @typedef {Object} DatabaseGetOperation
|
||||
* @property {Record<string, any>} query A query object which identifies the set of Documents retrieved
|
||||
* @property {false} [broadcast] Get requests are never broadcast
|
||||
* @property {boolean} [index] Return indices only instead of full Document records
|
||||
* @property {string[]} [indexFields] An array of field identifiers which should be indexed
|
||||
* @property {string|null} [pack=null] A compendium collection ID which contains the Documents
|
||||
* @property {foundry.abstract.Document|null} [parent=null] A parent Document within which Documents are embedded
|
||||
* @property {string} [parentUuid] A parent Document UUID provided when the parent instance is unavailable
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DatabaseCreateOperation
|
||||
* @property {boolean} broadcast Whether the database operation is broadcast to other connected clients
|
||||
* @property {object[]} data An array of data objects from which to create Documents
|
||||
* @property {boolean} [keepId=false] Retain the _id values of provided data instead of generating new ids
|
||||
* @property {boolean} [keepEmbeddedIds=true] Retain the _id values of embedded document data instead of generating
|
||||
* new ids for each embedded document
|
||||
* @property {number} [modifiedTime] The timestamp when the operation was performed
|
||||
* @property {boolean} [noHook=false] Block the dispatch of hooks related to this operation
|
||||
* @property {boolean} [render=true] Re-render Applications whose display depends on the created Documents
|
||||
* @property {boolean} [renderSheet=false] Render the sheet Application for any created Documents
|
||||
* @property {foundry.abstract.Document|null} [parent=null] A parent Document within which Documents are embedded
|
||||
* @property {string|null} pack A compendium collection ID which contains the Documents
|
||||
* @property {string|null} [parentUuid] A parent Document UUID provided when the parent instance is unavailable
|
||||
* @property {(string|object)[]} [_result] An alias for 'data' used internally by the server-side backend
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DatabaseUpdateOperation
|
||||
* @property {boolean} broadcast Whether the database operation is broadcast to other connected clients
|
||||
* @property {object[]} updates An array of data objects used to update existing Documents.
|
||||
* Each update object must contain the _id of the target Document
|
||||
* @property {boolean} [diff=true] Difference each update object against current Document data and only use
|
||||
* differential data for the update operation
|
||||
* @property {number} [modifiedTime] The timestamp when the operation was performed
|
||||
* @property {boolean} [recursive=true] Merge objects recursively. If false, inner objects will be replaced
|
||||
* explicitly. Use with caution!
|
||||
* @property {boolean} [render=true] Re-render Applications whose display depends on the created Documents
|
||||
* @property {boolean} [noHook=false] Block the dispatch of hooks related to this operation
|
||||
* @property {foundry.abstract.Document|null} [parent=null] A parent Document within which Documents are embedded
|
||||
* @property {string|null} pack A compendium collection ID which contains the Documents
|
||||
* @property {string|null} [parentUuid] A parent Document UUID provided when the parent instance is unavailable
|
||||
* @property {(string|object)[]} [_result] An alias for 'updates' used internally by the server-side backend
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DatabaseDeleteOperation
|
||||
* @property {boolean} broadcast Whether the database operation is broadcast to other connected clients
|
||||
* @property {string[]} ids An array of Document ids which should be deleted
|
||||
* @property {boolean} [deleteAll=false] Delete all documents in the Collection, regardless of _id
|
||||
* @property {number} [modifiedTime] The timestamp when the operation was performed
|
||||
* @property {boolean} [noHook=false] Block the dispatch of hooks related to this operation
|
||||
* @property {boolean} [render=true] Re-render Applications whose display depends on the deleted Documents
|
||||
* @property {foundry.abstract.Document|null} [parent=null] A parent Document within which Documents are embedded
|
||||
* @property {string|null} pack A compendium collection ID which contains the Documents
|
||||
* @property {string|null} [parentUuid] A parent Document UUID provided when the parent instance is unavailable
|
||||
* @property {(string|object)[]} [_result] An alias for 'ids' used internally by the server-side backend
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {"get"|"create"|"update"|"delete"} DatabaseAction
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {DatabaseGetOperation|DatabaseCreateOperation|DatabaseUpdateOperation|DatabaseDeleteOperation} DatabaseOperation
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DocumentSocketRequest
|
||||
* @property {string} type The type of Document being transacted
|
||||
* @property {DatabaseAction} action The action of the request
|
||||
* @property {DatabaseOperation} operation Operation parameters for the request
|
||||
* @property {string} userId The id of the requesting User
|
||||
* @property {boolean} broadcast Should the response be broadcast to other connected clients?
|
||||
*/
|
||||
318
resources/app/common/abstract/backend.mjs
Normal file
318
resources/app/common/abstract/backend.mjs
Normal file
@@ -0,0 +1,318 @@
|
||||
import Document from "./document.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").DatabaseGetOperation} DatabaseGetOperation
|
||||
* @typedef {import("./_types.mjs").DatabaseCreateOperation} DatabaseCreateOperation
|
||||
* @typedef {import("./_types.mjs").DatabaseUpdateOperation} DatabaseUpdateOperation
|
||||
* @typedef {import("./_types.mjs").DatabaseDeleteOperation} DatabaseDeleteOperation
|
||||
*/
|
||||
|
||||
/**
|
||||
* An abstract base class extended on both the client and server which defines how Documents are retrieved, created,
|
||||
* updated, and deleted.
|
||||
* @alias foundry.abstract.DatabaseBackend
|
||||
* @abstract
|
||||
*/
|
||||
export default class DatabaseBackend {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Get Operations */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve Documents based on provided query parameters.
|
||||
* It recommended to use CompendiumCollection#getDocuments or CompendiumCollection#getIndex rather
|
||||
* than calling this method directly.
|
||||
* @param {typeof Document} documentClass The Document class definition
|
||||
* @param {DatabaseGetOperation} operation Parameters of the get operation
|
||||
* @param {BaseUser} [user] The requesting User
|
||||
* @returns {Promise<Document[]|object[]>} An array of retrieved Document instances or index objects
|
||||
*/
|
||||
async get(documentClass, operation, user) {
|
||||
operation = await this.#configureGet(operation);
|
||||
return this._getDocuments(documentClass, operation, user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate and configure the parameters of the get operation.
|
||||
* @param {DatabaseGetOperation} operation The requested operation
|
||||
*/
|
||||
async #configureGet(operation) {
|
||||
await this.#configureOperation(operation);
|
||||
operation.broadcast = false; // Get requests are never broadcast
|
||||
return operation;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve Document instances using the specified operation parameters.
|
||||
* @param {typeof Document} documentClass The Document class definition
|
||||
* @param {DatabaseGetOperation} operation Parameters of the get operation
|
||||
* @param {BaseUser} [user] The requesting User
|
||||
* @returns {Promise<Document[]|object[]>} An array of retrieved Document instances or index objects
|
||||
* @abstract
|
||||
* @internal
|
||||
* @ignore
|
||||
*/
|
||||
async _getDocuments(documentClass, operation, user) {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Create Operations */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create new Documents using provided data and context.
|
||||
* It is recommended to use {@link Document.createDocuments} or {@link Document.create} rather than calling this
|
||||
* method directly.
|
||||
* @param {typeof Document} documentClass The Document class definition
|
||||
* @param {DatabaseCreateOperation} operation Parameters of the create operation
|
||||
* @param {BaseUser} [user] The requesting User
|
||||
* @returns {Promise<Document[]>} An array of created Document instances
|
||||
*/
|
||||
async create(documentClass, operation, user) {
|
||||
operation = await this.#configureCreate(operation);
|
||||
return this._createDocuments(documentClass, operation, user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate and configure the parameters of the create operation.
|
||||
* @param {DatabaseCreateOperation} operation The requested operation
|
||||
*/
|
||||
async #configureCreate(operation) {
|
||||
if ( !Array.isArray(operation.data) ) {
|
||||
throw new Error("The data provided to the DatabaseBackend#create operation must be an array of data objects");
|
||||
}
|
||||
await this.#configureOperation(operation);
|
||||
operation.render ??= true;
|
||||
operation.renderSheet ??= false;
|
||||
return operation;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create Document instances using provided data and operation parameters.
|
||||
* @param {typeof Document} documentClass The Document class definition
|
||||
* @param {DatabaseCreateOperation} operation Parameters of the create operation
|
||||
* @param {BaseUser} [user] The requesting User
|
||||
* @returns {Promise<Document[]>} An array of created Document instances
|
||||
* @abstract
|
||||
* @internal
|
||||
* @ignore
|
||||
*/
|
||||
async _createDocuments(documentClass, operation, user) {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Update Operations */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update Documents using provided data and context.
|
||||
* It is recommended to use {@link Document.updateDocuments} or {@link Document#update} rather than calling this
|
||||
* method directly.
|
||||
* @param {typeof Document} documentClass The Document class definition
|
||||
* @param {DatabaseUpdateOperation} operation Parameters of the update operation
|
||||
* @param {BaseUser} [user] The requesting User
|
||||
* @returns {Promise<Document[]>} An array of updated Document instances
|
||||
*/
|
||||
async update(documentClass, operation, user) {
|
||||
operation = await this.#configureUpdate(operation);
|
||||
return this._updateDocuments(documentClass, operation, user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate and configure the parameters of the update operation.
|
||||
* @param {DatabaseUpdateOperation} operation The requested operation
|
||||
*/
|
||||
async #configureUpdate(operation) {
|
||||
if ( !Array.isArray(operation.updates) ) {
|
||||
throw new Error("The updates provided to the DatabaseBackend#update operation must be an array of data objects");
|
||||
}
|
||||
await this.#configureOperation(operation);
|
||||
operation.diff ??= true;
|
||||
operation.recursive ??= true;
|
||||
operation.render ??= true;
|
||||
return operation;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update Document instances using provided data and operation parameters.
|
||||
* @param {typeof Document} documentClass The Document class definition
|
||||
* @param {DatabaseUpdateOperation} operation Parameters of the update operation
|
||||
* @param {BaseUser} [user] The requesting User
|
||||
* @returns {Promise<Document[]>} An array of updated Document instances
|
||||
* @abstract
|
||||
* @internal
|
||||
* @ignore
|
||||
*/
|
||||
async _updateDocuments(documentClass, operation, user) {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Delete Operations */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Delete Documents using provided ids and context.
|
||||
* It is recommended to use {@link foundry.abstract.Document.deleteDocuments} or
|
||||
* {@link foundry.abstract.Document#delete} rather than calling this method directly.
|
||||
* @param {typeof Document} documentClass The Document class definition
|
||||
* @param {DatabaseDeleteOperation} operation Parameters of the delete operation
|
||||
* @param {BaseUser} [user] The requesting User
|
||||
* @returns {Promise<Document[]>} An array of deleted Document instances
|
||||
*/
|
||||
async delete(documentClass, operation, user) {
|
||||
operation = await this.#configureDelete(operation);
|
||||
return this._deleteDocuments(documentClass, operation, user);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate and configure the parameters of the delete operation.
|
||||
* @param {DatabaseDeleteOperation} operation The requested operation
|
||||
*/
|
||||
async #configureDelete(operation) {
|
||||
if ( !Array.isArray(operation.ids) ) {
|
||||
throw new Error("The document ids provided to the DatabaseBackend#delete operation must be an array of strings");
|
||||
}
|
||||
await this.#configureOperation(operation);
|
||||
operation.deleteAll ??= false;
|
||||
operation.render ??= true;
|
||||
return operation;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Delete Document instances using provided ids and operation parameters.
|
||||
* @param {typeof Document} documentClass The Document class definition
|
||||
* @param {DatabaseDeleteOperation} operation Parameters of the delete operation
|
||||
* @param {BaseUser} [user] The requesting User
|
||||
* @returns {Promise<Document[]>} An array of deleted Document instances
|
||||
* @abstract
|
||||
* @internal
|
||||
* @ignore
|
||||
*/
|
||||
async _deleteDocuments(documentClass, operation, user) {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Helper Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Common database operation configuration steps.
|
||||
* @param {DatabaseOperation} operation The requested operation
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async #configureOperation(operation) {
|
||||
if ( operation.pack && !this.getCompendiumScopes().includes(operation.pack) ) {
|
||||
throw new Error(`Compendium pack "${operation.pack}" is not a valid Compendium identifier`);
|
||||
}
|
||||
operation.parent = await this._getParent(operation);
|
||||
operation.modifiedTime = Date.now();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the parent Document (if any) associated with a request context.
|
||||
* @param {DatabaseOperation} operation The requested database operation
|
||||
* @return {Promise<Document|null>} The parent Document, or null
|
||||
* @internal
|
||||
* @ignore
|
||||
*/
|
||||
async _getParent(operation) {
|
||||
if ( operation.parent && !(operation.parent instanceof Document) ) {
|
||||
throw new Error("A parent Document provided to the database operation must be a Document instance");
|
||||
}
|
||||
else if ( operation.parent ) return operation.parent;
|
||||
if ( operation.parentUuid ) return globalThis.fromUuid(operation.parentUuid, {invalid: true});
|
||||
return null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Describe the scopes which are suitable as the namespace for a flag key
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getFlagScopes() {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Describe the scopes which are suitable as the namespace for a flag key
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getCompendiumScopes() {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Log a database operations message.
|
||||
* @param {string} level The logging level
|
||||
* @param {string} message The message
|
||||
* @abstract
|
||||
* @protected
|
||||
*/
|
||||
_log(level, message) {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Log a database operation for an embedded document, capturing the action taken and relevant IDs
|
||||
* @param {string} action The action performed
|
||||
* @param {string} type The document type
|
||||
* @param {abstract.Document[]} documents The documents modified
|
||||
* @param {string} [level=info] The logging level
|
||||
* @param {abstract.Document} [parent] A parent document
|
||||
* @param {string} [pack] A compendium pack within which the operation occurred
|
||||
* @protected
|
||||
*/
|
||||
_logOperation(action, type, documents, {parent, pack, level="info"}={}) {
|
||||
let msg = (documents.length === 1) ? `${action} ${type}` : `${action} ${documents.length} ${type} documents`;
|
||||
if (documents.length === 1) msg += ` with id [${documents[0].id}]`;
|
||||
else if (documents.length <= 5) msg += ` with ids: [${documents.map(d => d.id)}]`;
|
||||
msg += this.#logContext(parent, pack);
|
||||
this._log(level, msg);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Construct a standardized error message given the context of an attempted operation
|
||||
* @returns {string}
|
||||
* @protected
|
||||
*/
|
||||
_logError(user, action, subject, {parent, pack}={}) {
|
||||
if ( subject instanceof Document ) {
|
||||
subject = subject.id ? `${subject.documentName} [${subject.id}]` : `a new ${subject.documentName}`;
|
||||
}
|
||||
let msg = `User ${user.name} lacks permission to ${action} ${subject}`;
|
||||
return msg + this.#logContext(parent, pack);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine a string suffix for a log message based on the parent and/or compendium context.
|
||||
* @param {Document|null} parent
|
||||
* @param {string|null} pack
|
||||
* @returns {string}
|
||||
*/
|
||||
#logContext(parent, pack) {
|
||||
let context = "";
|
||||
if ( parent ) context += ` in parent ${parent.constructor.metadata.name} [${parent.id}]`;
|
||||
if ( pack ) context += ` in Compendium ${pack}`;
|
||||
return context;
|
||||
}
|
||||
}
|
||||
614
resources/app/common/abstract/data.mjs
Normal file
614
resources/app/common/abstract/data.mjs
Normal file
@@ -0,0 +1,614 @@
|
||||
import {deepClone, diffObject, expandObject, flattenObject, getType, isEmpty, mergeObject} from "../utils/helpers.mjs";
|
||||
import {
|
||||
DataField,
|
||||
SchemaField,
|
||||
EmbeddedDataField,
|
||||
EmbeddedCollectionField,
|
||||
ObjectField,
|
||||
TypeDataField, EmbeddedDocumentField
|
||||
} from "../data/fields.mjs";
|
||||
import {DataModelValidationFailure} from "../data/validation-failure.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {Record<string, DataField>} DataSchema
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DataValidationOptions
|
||||
* @property {boolean} [strict=true] Throw an error if validation fails.
|
||||
* @property {boolean} [fallback=false] Attempt to replace invalid values with valid defaults?
|
||||
* @property {boolean} [partial=false] Allow partial source data, ignoring absent fields?
|
||||
* @property {boolean} [dropInvalidEmbedded=false] If true, invalid embedded documents will emit a warning and be
|
||||
* placed in the invalidDocuments collection rather than causing the
|
||||
* parent to be considered invalid.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The abstract base class which defines the data schema contained within a Document.
|
||||
* @param {object} [data={}] Initial data used to construct the data object. The provided object
|
||||
* will be owned by the constructed model instance and may be mutated.
|
||||
* @param {DataValidationOptions} [options={}] Options which affect DataModel construction
|
||||
* @param {Document} [options.parent] A parent DataModel instance to which this DataModel belongs
|
||||
* @abstract
|
||||
*/
|
||||
export default class DataModel {
|
||||
constructor(data={}, {parent=null, strict=true, ...options}={}) {
|
||||
|
||||
// Parent model
|
||||
Object.defineProperty(this, "parent", {
|
||||
value: (() => {
|
||||
if ( parent === null ) return null;
|
||||
if ( parent instanceof DataModel ) return parent;
|
||||
throw new Error("The provided parent must be a DataModel instance");
|
||||
})(),
|
||||
writable: false,
|
||||
enumerable: false
|
||||
});
|
||||
|
||||
// Source data
|
||||
Object.defineProperty(this, "_source", {
|
||||
value: this._initializeSource(data, {strict, ...options}),
|
||||
writable: false,
|
||||
enumerable: false
|
||||
});
|
||||
Object.seal(this._source);
|
||||
|
||||
// Additional subclass configurations
|
||||
this._configure(options);
|
||||
|
||||
// Data validation and initialization
|
||||
const fallback = options.fallback ?? !strict;
|
||||
const dropInvalidEmbedded = options.dropInvalidEmbedded ?? !strict;
|
||||
this.validate({strict, fallback, dropInvalidEmbedded, fields: true, joint: true});
|
||||
this._initialize({strict, ...options});
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the data model instance before validation and initialization workflows are performed.
|
||||
* @protected
|
||||
*/
|
||||
_configure(options={}) {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The source data object for this DataModel instance.
|
||||
* Once constructed, the source object is sealed such that no keys may be added nor removed.
|
||||
* @type {object}
|
||||
*/
|
||||
_source;
|
||||
|
||||
/**
|
||||
* The defined and cached Data Schema for all instances of this DataModel.
|
||||
* @type {SchemaField}
|
||||
* @private
|
||||
*/
|
||||
static _schema;
|
||||
|
||||
/**
|
||||
* An immutable reverse-reference to a parent DataModel to which this model belongs.
|
||||
* @type {DataModel|null}
|
||||
*/
|
||||
parent;
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* Data Schema */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Define the data schema for documents of this type.
|
||||
* The schema is populated the first time it is accessed and cached for future reuse.
|
||||
* @virtual
|
||||
* @returns {DataSchema}
|
||||
*/
|
||||
static defineSchema() {
|
||||
throw new Error(`The ${this["name"]} subclass of DataModel must define its Document schema`);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* The Data Schema for all instances of this DataModel.
|
||||
* @type {SchemaField}
|
||||
*/
|
||||
static get schema() {
|
||||
if ( this.hasOwnProperty("_schema") ) return this._schema;
|
||||
const schema = new SchemaField(Object.freeze(this.defineSchema()));
|
||||
Object.defineProperty(this, "_schema", {value: schema, writable: false});
|
||||
return schema;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Define the data schema for this document instance.
|
||||
* @type {SchemaField}
|
||||
*/
|
||||
get schema() {
|
||||
return this.constructor.schema;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is the current state of this DataModel invalid?
|
||||
* The model is invalid if there is any unresolved failure.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get invalid() {
|
||||
return Object.values(this.#validationFailures).some(f => f?.unresolved);
|
||||
}
|
||||
|
||||
/**
|
||||
* An array of validation failure instances which may have occurred when this instance was last validated.
|
||||
* @type {{fields: DataModelValidationFailure|null, joint: DataModelValidationFailure|null}}
|
||||
*/
|
||||
get validationFailures() {
|
||||
return this.#validationFailures;
|
||||
}
|
||||
|
||||
#validationFailures = Object.seal({fields: null, joint: null });
|
||||
|
||||
/**
|
||||
* A set of localization prefix paths which are used by this DataModel.
|
||||
* @type {string[]}
|
||||
*/
|
||||
static LOCALIZATION_PREFIXES = [];
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* Data Cleaning Methods */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize the source data for a new DataModel instance.
|
||||
* One-time migrations and initial cleaning operations are applied to the source data.
|
||||
* @param {object|DataModel} data The candidate source data from which the model will be constructed
|
||||
* @param {object} [options] Options provided to the model constructor
|
||||
* @returns {object} Migrated and cleaned source data which will be stored to the model instance
|
||||
* @protected
|
||||
*/
|
||||
_initializeSource(data, options={}) {
|
||||
if ( data instanceof DataModel ) data = data.toObject();
|
||||
const dt = getType(data);
|
||||
if ( dt !== "Object" ) {
|
||||
logger.error(`${this.constructor.name} was incorrectly constructed with a ${dt} instead of an object.
|
||||
Attempting to fall back to default values.`)
|
||||
data = {};
|
||||
}
|
||||
data = this.constructor.migrateDataSafe(data); // Migrate old data to the new format
|
||||
data = this.constructor.cleanData(data); // Clean the data in the new format
|
||||
return this.constructor.shimData(data); // Apply shims which preserve backwards compatibility
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Clean a data source object to conform to a specific provided schema.
|
||||
* @param {object} [source] The source data object
|
||||
* @param {object} [options={}] Additional options which are passed to field cleaning methods
|
||||
* @returns {object} The cleaned source data
|
||||
*/
|
||||
static cleanData(source={}, options={}) {
|
||||
return this.schema.clean(source, options);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* Data Initialization */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* A generator that orders the DataFields in the DataSchema into an expected initialization order.
|
||||
* @returns {Generator<[string,DataField]>}
|
||||
* @protected
|
||||
*/
|
||||
static *_initializationOrder() {
|
||||
for ( const entry of this.schema.entries() ) yield entry;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize the instance by copying data from the source object to instance attributes.
|
||||
* This mirrors the workflow of SchemaField#initialize but with some added functionality.
|
||||
* @param {object} [options] Options provided to the model constructor
|
||||
* @protected
|
||||
*/
|
||||
_initialize(options={}) {
|
||||
for ( let [name, field] of this.constructor._initializationOrder() ) {
|
||||
const sourceValue = this._source[name];
|
||||
|
||||
// Field initialization
|
||||
const value = field.initialize(sourceValue, this, options);
|
||||
|
||||
// Special handling for Document IDs.
|
||||
if ( (name === "_id") && (!Object.getOwnPropertyDescriptor(this, "_id") || (this._id === null)) ) {
|
||||
Object.defineProperty(this, name, {value, writable: false, configurable: true});
|
||||
}
|
||||
|
||||
// Readonly fields
|
||||
else if ( field.readonly ) {
|
||||
if ( this[name] !== undefined ) continue;
|
||||
Object.defineProperty(this, name, {value, writable: false});
|
||||
}
|
||||
|
||||
// Getter fields
|
||||
else if ( value instanceof Function ) {
|
||||
Object.defineProperty(this, name, {get: value, set() {}, configurable: true});
|
||||
}
|
||||
|
||||
// Writable fields
|
||||
else this[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Reset the state of this data instance back to mirror the contained source data, erasing any changes.
|
||||
*/
|
||||
reset() {
|
||||
this._initialize();
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Clone a model, creating a new data model by combining current data with provided overrides.
|
||||
* @param {Object} [data={}] Additional data which overrides current document data at the time of creation
|
||||
* @param {object} [context={}] Context options passed to the data model constructor
|
||||
* @returns {Document|Promise<Document>} The cloned Document instance
|
||||
*/
|
||||
clone(data={}, context={}) {
|
||||
data = mergeObject(this.toObject(), data, {insertKeys: false, performDeletions: true, inplace: true});
|
||||
return new this.constructor(data, {parent: this.parent, ...context});
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* Data Validation Methods */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate the data contained in the document to check for type and content
|
||||
* This function throws an error if data within the document is not valid
|
||||
*
|
||||
* @param {object} options Optional parameters which customize how validation occurs.
|
||||
* @param {object} [options.changes] A specific set of proposed changes to validate, rather than the full
|
||||
* source data of the model.
|
||||
* @param {boolean} [options.clean=false] If changes are provided, attempt to clean the changes before validating
|
||||
* them?
|
||||
* @param {boolean} [options.fallback=false] Allow replacement of invalid values with valid defaults?
|
||||
* @param {boolean} [options.dropInvalidEmbedded=false] If true, invalid embedded documents will emit a warning and
|
||||
* be placed in the invalidDocuments collection rather than
|
||||
* causing the parent to be considered invalid.
|
||||
* @param {boolean} [options.strict=true] Throw if an invalid value is encountered, otherwise log a warning?
|
||||
* @param {boolean} [options.fields=true] Perform validation on individual fields?
|
||||
* @param {boolean} [options.joint] Perform joint validation on the full data model?
|
||||
* Joint validation will be performed by default if no changes are passed.
|
||||
* Joint validation will be disabled by default if changes are passed.
|
||||
* Joint validation can be performed on a complete set of changes (for
|
||||
* example testing a complete data model) by explicitly passing true.
|
||||
* @return {boolean} An indicator for whether the document contains valid data
|
||||
*/
|
||||
validate({changes, clean=false, fallback=false, dropInvalidEmbedded=false, strict=true, fields=true, joint}={}) {
|
||||
const source = changes ?? this._source;
|
||||
this.#validationFailures.fields = this.#validationFailures.joint = null; // Remove any prior failures
|
||||
|
||||
// Determine whether we are performing partial or joint validation
|
||||
const partial = !!changes;
|
||||
joint = joint ?? !changes;
|
||||
if ( partial && joint ) {
|
||||
throw new Error("It is not supported to perform joint data model validation with only a subset of changes");
|
||||
}
|
||||
|
||||
// Optionally clean the data before validating
|
||||
if ( partial && clean ) this.constructor.cleanData(source, {partial});
|
||||
|
||||
// Validate individual fields in the data or in a specific change-set, throwing errors if validation fails
|
||||
if ( fields ) {
|
||||
const failure = this.schema.validate(source, {partial, fallback, dropInvalidEmbedded});
|
||||
if ( failure ) {
|
||||
const id = this._source._id ? `[${this._source._id}] ` : "";
|
||||
failure.message = `${this.constructor.name} ${id}validation errors:`;
|
||||
this.#validationFailures.fields = failure;
|
||||
if ( strict && failure.unresolved ) throw failure.asError();
|
||||
else logger.warn(failure.asError());
|
||||
}
|
||||
}
|
||||
|
||||
// Perform joint document-level validations which consider all fields together
|
||||
if ( joint ) {
|
||||
try {
|
||||
this.schema._validateModel(source); // Validate inner models
|
||||
this.constructor.validateJoint(source); // Validate this model
|
||||
} catch (err) {
|
||||
const id = this._source._id ? `[${this._source._id}] ` : "";
|
||||
const message = [this.constructor.name, id, `Joint Validation Error:\n${err.message}`].filterJoin(" ");
|
||||
const failure = new DataModelValidationFailure({message, unresolved: true});
|
||||
this.#validationFailures.joint = failure;
|
||||
if ( strict ) throw failure.asError();
|
||||
else logger.warn(failure.asError());
|
||||
}
|
||||
}
|
||||
return !this.invalid;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Evaluate joint validation rules which apply validation conditions across multiple fields of the model.
|
||||
* Field-specific validation rules should be defined as part of the DataSchema for the model.
|
||||
* This method allows for testing aggregate rules which impose requirements on the overall model.
|
||||
* @param {object} data Candidate data for the model
|
||||
* @throws An error if a validation failure is detected
|
||||
*/
|
||||
static validateJoint(data) {
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
if ( this.prototype._validateModel instanceof Function ) {
|
||||
const msg = `${this.name} defines ${this.name}.prototype._validateModel instance method which should now be`
|
||||
+ ` declared as ${this.name}.validateJoint static method.`
|
||||
foundry.utils.logCompatibilityWarning(msg, {from: 11, until: 13});
|
||||
return this.prototype._validateModel.call(this, data);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* Data Management */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the DataModel locally by applying an object of changes to its source data.
|
||||
* The provided changes are cleaned, validated, and stored to the source data object for this model.
|
||||
* The source data is then re-initialized to apply those changes to the prepared data.
|
||||
* The method returns an object of differential changes which modified the original data.
|
||||
*
|
||||
* @param {object} changes New values which should be applied to the data model
|
||||
* @param {object} [options={}] Options which determine how the new data is merged
|
||||
* @returns {object} An object containing the changed keys and values
|
||||
*/
|
||||
updateSource(changes={}, options={}) {
|
||||
const schema = this.schema;
|
||||
const source = this._source;
|
||||
const _diff = {};
|
||||
const _backup = {};
|
||||
const _collections = this.collections;
|
||||
const _singletons = this.singletons;
|
||||
|
||||
// Expand the object, if dot-notation keys are provided
|
||||
if ( Object.keys(changes).some(k => /\./.test(k)) ) changes = expandObject(changes);
|
||||
|
||||
// Clean and validate the provided changes, throwing an error if any change is invalid
|
||||
this.validate({changes, clean: true, fallback: options.fallback, strict: true, fields: true, joint: false});
|
||||
|
||||
// Update the source data for all fields and validate the final combined model
|
||||
let error;
|
||||
try {
|
||||
DataModel.#updateData(schema, source, changes, {_backup, _collections, _singletons, _diff, ...options});
|
||||
this.validate({fields: this.invalid, joint: true, strict: true});
|
||||
} catch(err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
// Restore the backup data
|
||||
if ( error || options.dryRun ) {
|
||||
mergeObject(this._source, _backup, { recursive: false });
|
||||
if ( error ) throw error;
|
||||
}
|
||||
|
||||
// Initialize the updated data
|
||||
if ( !options.dryRun ) this._initialize();
|
||||
return _diff;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the source data for a specific DataSchema.
|
||||
* This method assumes that both source and changes are valid objects.
|
||||
* @param {SchemaField} schema The data schema to update
|
||||
* @param {object} source Source data to be updated
|
||||
* @param {object} changes Changes to apply to the source data
|
||||
* @param {object} [options={}] Options which modify the update workflow
|
||||
* @returns {object} The updated source data
|
||||
* @throws An error if the update operation was unsuccessful
|
||||
* @private
|
||||
*/
|
||||
static #updateData(schema, source, changes, options) {
|
||||
const {_backup, _diff} = options;
|
||||
for ( let [name, value] of Object.entries(changes) ) {
|
||||
const field = schema.get(name);
|
||||
if ( !field ) continue;
|
||||
|
||||
// Skip updates where the data is unchanged
|
||||
const prior = source[name];
|
||||
if ( (value?.equals instanceof Function) && value.equals(prior) ) continue; // Arrays, Sets, etc...
|
||||
if ( (prior === value) ) continue; // Direct comparison
|
||||
_backup[name] = deepClone(prior);
|
||||
_diff[name] = value;
|
||||
|
||||
// Field-specific updating logic
|
||||
this.#updateField(name, field, source, value, options);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the source data for a specific DataField.
|
||||
* @param {string} name The field name being updated
|
||||
* @param {DataField} field The field definition being updated
|
||||
* @param {object} source The source object being updated
|
||||
* @param {*} value The new value for the field
|
||||
* @param {object} options Options which modify the update workflow
|
||||
* @throws An error if the new candidate value is invalid
|
||||
* @private
|
||||
*/
|
||||
static #updateField(name, field, source, value, options) {
|
||||
const {dryRun, fallback, recursive, restoreDelta, _collections, _singletons, _diff, _backup} = options;
|
||||
let current = source?.[name]; // The current value may be null or undefined
|
||||
|
||||
// Special Case: Update Embedded Collection
|
||||
if ( field instanceof EmbeddedCollectionField ) {
|
||||
_backup[name] = current;
|
||||
if ( !dryRun ) _collections[name].update(value, {fallback, recursive, restoreDelta});
|
||||
return;
|
||||
}
|
||||
|
||||
// Special Case: Update Embedded Document
|
||||
if ( (field instanceof EmbeddedDocumentField) && _singletons[name] ) {
|
||||
_diff[name] = _singletons[name].updateSource(value ?? {}, {dryRun, fallback, recursive, restoreDelta});
|
||||
if ( isEmpty(_diff[name]) ) delete _diff[name];
|
||||
return;
|
||||
}
|
||||
|
||||
// Special Case: Inner Data Schema
|
||||
let innerSchema;
|
||||
if ( (field instanceof SchemaField) || (field instanceof EmbeddedDataField) ) innerSchema = field;
|
||||
else if ( field instanceof TypeDataField ) {
|
||||
const cls = field.getModelForType(source.type);
|
||||
if ( cls ) {
|
||||
innerSchema = cls.schema;
|
||||
if ( dryRun ) {
|
||||
_backup[name] = current;
|
||||
current = deepClone(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( innerSchema && current && value ) {
|
||||
_diff[name] = {};
|
||||
const recursiveOptions = {fallback, recursive, _backup: current, _collections, _diff: _diff[name]};
|
||||
this.#updateData(innerSchema, current, value, recursiveOptions);
|
||||
if ( isEmpty(_diff[name]) ) delete _diff[name];
|
||||
}
|
||||
|
||||
// Special Case: Object Field
|
||||
else if ( (field instanceof ObjectField) && current && value && (recursive !== false) ) {
|
||||
_diff[name] = diffObject(current, value);
|
||||
mergeObject(current, value, {insertKeys: true, insertValues: true, performDeletions: true});
|
||||
if ( isEmpty(_diff[name]) ) delete _diff[name];
|
||||
}
|
||||
|
||||
// Standard Case: Update Directly
|
||||
else source[name] = value;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* Serialization and Storage */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Copy and transform the DataModel into a plain object.
|
||||
* Draw the values of the extracted object from the data source (by default) otherwise from its transformed values.
|
||||
* @param {boolean} [source=true] Draw values from the underlying data source rather than transformed values
|
||||
* @returns {object} The extracted primitive object
|
||||
*/
|
||||
toObject(source=true) {
|
||||
if ( source ) return deepClone(this._source);
|
||||
|
||||
// We have use the schema of the class instead of the schema of the instance to prevent an infinite recursion:
|
||||
// the EmbeddedDataField replaces the schema of its model instance with itself
|
||||
// and EmbeddedDataField#toObject calls DataModel#toObject.
|
||||
return this.constructor.schema.toObject(this);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Extract the source data for the DataModel into a simple object format that can be serialized.
|
||||
* @returns {object} The document source data expressed as a plain object
|
||||
*/
|
||||
toJSON() {
|
||||
return this.toObject(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a new instance of this DataModel from a source record.
|
||||
* The source is presumed to be trustworthy and is not strictly validated.
|
||||
* @param {object} source Initial document data which comes from a trusted source.
|
||||
* @param {DocumentConstructionContext & DataValidationOptions} [context] Model construction context
|
||||
* @param {boolean} [context.strict=false] Models created from trusted source data are validated non-strictly
|
||||
* @returns {DataModel}
|
||||
*/
|
||||
static fromSource(source, {strict=false, ...context}={}) {
|
||||
return new this(source, {strict, ...context});
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a DataModel instance using a provided serialized JSON string.
|
||||
* @param {string} json Serialized document data in string format
|
||||
* @returns {DataModel} A constructed data model instance
|
||||
*/
|
||||
static fromJSON(json) {
|
||||
return this.fromSource(JSON.parse(json))
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Migrate candidate source data for this DataModel which may require initial cleaning or transformations.
|
||||
* @param {object} source The candidate source data from which the model will be constructed
|
||||
* @returns {object} Migrated source data, if necessary
|
||||
*/
|
||||
static migrateData(source) {
|
||||
if ( !source ) return source;
|
||||
this.schema.migrateSource(source, source);
|
||||
return source;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Wrap data migration in a try/catch which attempts it safely
|
||||
* @param {object} source The candidate source data from which the model will be constructed
|
||||
* @returns {object} Migrated source data, if necessary
|
||||
*/
|
||||
static migrateDataSafe(source) {
|
||||
try {
|
||||
this.migrateData(source);
|
||||
} catch(err) {
|
||||
err.message = `Failed data migration for ${this.name}: ${err.message}`;
|
||||
logger.warn(err);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Take data which conforms to the current data schema and add backwards-compatible accessors to it in order to
|
||||
* support older code which uses this data.
|
||||
* @param {object} data Data which matches the current schema
|
||||
* @param {object} [options={}] Additional shimming options
|
||||
* @param {boolean} [options.embedded=true] Apply shims to embedded models?
|
||||
* @returns {object} Data with added backwards-compatible properties
|
||||
*/
|
||||
static shimData(data, {embedded=true}={}) {
|
||||
if ( Object.isSealed(data) ) return data;
|
||||
const schema = this.schema;
|
||||
if ( embedded ) {
|
||||
for ( const [name, value] of Object.entries(data) ) {
|
||||
const field = schema.get(name);
|
||||
if ( (field instanceof EmbeddedDataField) && !Object.isSealed(value) ) {
|
||||
data[name] = field.model.shimData(value || {});
|
||||
}
|
||||
else if ( field instanceof EmbeddedCollectionField ) {
|
||||
for ( const d of (value || []) ) {
|
||||
if ( !Object.isSealed(d) ) field.model.shimData(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export {DataModel};
|
||||
1213
resources/app/common/abstract/document.mjs
Normal file
1213
resources/app/common/abstract/document.mjs
Normal file
File diff suppressed because it is too large
Load Diff
247
resources/app/common/abstract/embedded-collection-delta.mjs
Normal file
247
resources/app/common/abstract/embedded-collection-delta.mjs
Normal file
@@ -0,0 +1,247 @@
|
||||
import EmbeddedCollection from "./embedded-collection.mjs";
|
||||
import {deepClone, randomID} from "../utils/helpers.mjs";
|
||||
|
||||
/**
|
||||
* An embedded collection delta contains delta source objects that can be compared against other objects inside a base
|
||||
* embedded collection, and generate new embedded Documents by combining them.
|
||||
*/
|
||||
export default class EmbeddedCollectionDelta extends EmbeddedCollection {
|
||||
/**
|
||||
* Maintain a list of IDs that are managed by this collection delta to distinguish from those IDs that are inherited
|
||||
* from the base collection.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
#managedIds = new Set();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Maintain a list of IDs that are tombstone Documents.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
#tombstones = new Set();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience getter to return the corresponding base collection.
|
||||
* @type {EmbeddedCollection}
|
||||
*/
|
||||
get baseCollection() {
|
||||
return this.model.getBaseCollection?.(this.name);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience getter to return the corresponding synthetic collection.
|
||||
* @type {EmbeddedCollection}
|
||||
*/
|
||||
get syntheticCollection() {
|
||||
return this.model.syntheticActor?.getEmbeddedCollection(this.name);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
createDocument(data, context={}) {
|
||||
return new this.documentClass(data, {
|
||||
...context,
|
||||
parent: this.model.syntheticActor ?? this.model,
|
||||
parentCollection: this.name,
|
||||
pack: this.model.pack
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
initialize({full=false, ...options} = {}) {
|
||||
// Repeat initialization.
|
||||
if ( this._initialized && !full ) return;
|
||||
|
||||
// First-time initialization.
|
||||
this.clear();
|
||||
if ( !this.baseCollection ) return;
|
||||
|
||||
// Initialize the deltas.
|
||||
for ( const d of this._source ) {
|
||||
if ( d._tombstone ) this.#tombstones.add(d._id);
|
||||
else this._initializeDocument(d, options);
|
||||
this.#managedIds.add(d._id);
|
||||
}
|
||||
|
||||
// Include the Documents from the base collection.
|
||||
for ( const d of this.baseCollection._source ) {
|
||||
if ( this.has(d._id) || this.isTombstone(d._id) ) continue;
|
||||
this._initializeDocument(deepClone(d), options);
|
||||
}
|
||||
|
||||
this._initialized = true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_initializeDocument(data, context) {
|
||||
if ( !data._id ) data._id = randomID(16);
|
||||
let doc;
|
||||
if ( this.syntheticCollection ) doc = this.syntheticCollection.get(data._id);
|
||||
else {
|
||||
try {
|
||||
doc = this.createDocument(data, context);
|
||||
} catch(err) {
|
||||
this._handleInvalidDocument(data._id, err, context);
|
||||
}
|
||||
}
|
||||
if ( doc ) super.set(doc.id, doc, {modifySource: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_createOrUpdate(data, options) {
|
||||
if ( options.recursive === false ) {
|
||||
if ( data._tombstone ) return this.delete(data._id);
|
||||
else if ( this.isTombstone(data._id) ) return this.set(data._id, this.createDocument(data));
|
||||
}
|
||||
else if ( this.isTombstone(data._id) || data._tombstone ) return;
|
||||
let doc = this.get(data._id);
|
||||
if ( doc ) doc.updateSource(data, options);
|
||||
else doc = this.createDocument(data);
|
||||
this.set(doc.id, doc);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine whether a given ID is managed directly by this collection delta or inherited from the base collection.
|
||||
* @param {string} key The Document ID.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
manages(key) {
|
||||
return this.#managedIds.has(key);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine whether a given ID exists as a tombstone Document in the collection delta.
|
||||
* @param {string} key The Document ID.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isTombstone(key) {
|
||||
return this.#tombstones.has(key);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Restore a Document so that it is no longer managed by the collection delta and instead inherits from the base
|
||||
* Document.
|
||||
* @param {string} id The Document ID.
|
||||
* @returns {Promise<Document>} The restored Document.
|
||||
*/
|
||||
async restoreDocument(id) {
|
||||
const docs = await this.restoreDocuments([id]);
|
||||
return docs.shift();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Restore the given Documents so that they are no longer managed by the collection delta and instead inherit directly
|
||||
* from their counterparts in the base Actor.
|
||||
* @param {string[]} ids The IDs of the Documents to restore.
|
||||
* @returns {Promise<Document[]>} An array of updated Document instances.
|
||||
*/
|
||||
async restoreDocuments(ids) {
|
||||
if ( !this.model.syntheticActor ) return [];
|
||||
const baseActor = this.model.parent.baseActor;
|
||||
const embeddedName = this.documentClass.documentName;
|
||||
const {deltas, tombstones} = ids.reduce((obj, id) => {
|
||||
if ( !this.manages(id) ) return obj;
|
||||
const doc = baseActor.getEmbeddedCollection(this.name).get(id);
|
||||
if ( this.isTombstone(id) ) obj.tombstones.push(doc.toObject());
|
||||
else obj.deltas.push(doc.toObject());
|
||||
return obj;
|
||||
}, {deltas: [], tombstones: []});
|
||||
|
||||
// For the benefit of downstream CRUD workflows, we emulate events from the perspective of the synthetic Actor.
|
||||
// Restoring an Item to the version on the base Actor is equivalent to updating that Item on the synthetic Actor
|
||||
// with the version of the Item on the base Actor.
|
||||
// Restoring an Item that has been deleted on the synthetic Actor is equivalent to creating a new Item on the
|
||||
// synthetic Actor with the contents of the version on the base Actor.
|
||||
// On the ActorDelta, those Items are removed from this collection delta so that they are once again 'linked' to the
|
||||
// base Actor's Item, as though they had never been modified from the original in the first place.
|
||||
|
||||
let updated = [];
|
||||
if ( deltas.length ) {
|
||||
updated = await this.model.syntheticActor.updateEmbeddedDocuments(embeddedName, deltas, {
|
||||
diff: false, recursive: false, restoreDelta: true
|
||||
});
|
||||
}
|
||||
|
||||
let created = [];
|
||||
if ( tombstones.length ) {
|
||||
created = await this.model.syntheticActor.createEmbeddedDocuments(embeddedName, tombstones, {
|
||||
keepId: true, restoreDelta: true
|
||||
});
|
||||
}
|
||||
|
||||
return updated.concat(created);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
set(key, value, options={}) {
|
||||
super.set(key, value, options);
|
||||
this.syntheticCollection?.set(key, value, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_set(key, value, {restoreDelta=false}={}) {
|
||||
if ( restoreDelta ) {
|
||||
this._source.findSplice(entry => entry._id === key);
|
||||
this.#managedIds.delete(key);
|
||||
this.#tombstones.delete(key);
|
||||
return;
|
||||
}
|
||||
|
||||
if ( this.manages(key) ) this._source.findSplice(d => d._id === key, value._source);
|
||||
else this._source.push(value._source);
|
||||
this.#managedIds.add(key);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
delete(key, options={}) {
|
||||
super.delete(key, options);
|
||||
this.syntheticCollection?.delete(key, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_delete(key, {restoreDelta=false}={}) {
|
||||
if ( !this.baseCollection ) return;
|
||||
|
||||
// Remove the document from this collection, if it exists.
|
||||
if ( this.manages(key) ) {
|
||||
this._source.findSplice(entry => entry._id === key);
|
||||
this.#managedIds.delete(key);
|
||||
this.#tombstones.delete(key);
|
||||
}
|
||||
|
||||
// If the document exists in the base collection, push a tombstone in its place.
|
||||
if ( !restoreDelta && this.baseCollection.has(key) ) {
|
||||
this._source.push({_id: key, _tombstone: true});
|
||||
this.#managedIds.add(key);
|
||||
this.#tombstones.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
305
resources/app/common/abstract/embedded-collection.mjs
Normal file
305
resources/app/common/abstract/embedded-collection.mjs
Normal file
@@ -0,0 +1,305 @@
|
||||
import Collection from "../utils/collection.mjs";
|
||||
import {randomID} from "../utils/helpers.mjs";
|
||||
|
||||
/**
|
||||
* An extension of the Collection.
|
||||
* Used for the specific task of containing embedded Document instances within a parent Document.
|
||||
*/
|
||||
export default class EmbeddedCollection extends Collection {
|
||||
/**
|
||||
* @param {string} name The name of this collection in the parent Document.
|
||||
* @param {DataModel} parent The parent DataModel instance to which this collection belongs.
|
||||
* @param {object[]} sourceArray The source data array for the collection in the parent Document data.
|
||||
*/
|
||||
constructor(name, parent, sourceArray) {
|
||||
if ( typeof name !== "string" ) throw new Error("The signature of EmbeddedCollection has changed in v11.");
|
||||
super();
|
||||
Object.defineProperties(this, {
|
||||
_source: {value: sourceArray, writable: false},
|
||||
documentClass: {value: parent.constructor.hierarchy[name].model, writable: false},
|
||||
name: {value: name, writable: false},
|
||||
model: {value: parent, writable: false}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The Document implementation used to construct instances within this collection.
|
||||
* @type {typeof foundry.abstract.Document}
|
||||
*/
|
||||
documentClass;
|
||||
|
||||
/**
|
||||
* The name of this collection in the parent Document.
|
||||
* @type {string}
|
||||
*/
|
||||
name;
|
||||
|
||||
/**
|
||||
* The parent DataModel to which this EmbeddedCollection instance belongs.
|
||||
* @type {DataModel}
|
||||
*/
|
||||
model;
|
||||
|
||||
/**
|
||||
* Has this embedded collection been initialized as a one-time workflow?
|
||||
* @type {boolean}
|
||||
* @protected
|
||||
*/
|
||||
_initialized = false;
|
||||
|
||||
/**
|
||||
* The source data array from which the embedded collection is created
|
||||
* @type {object[]}
|
||||
* @private
|
||||
*/
|
||||
_source;
|
||||
|
||||
/**
|
||||
* Record the set of document ids where the Document was not initialized because of invalid source data
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
invalidDocumentIds = new Set();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Instantiate a Document for inclusion in the Collection.
|
||||
* @param {object} data The Document data.
|
||||
* @param {DocumentConstructionContext} [context] Document creation context.
|
||||
* @returns {Document}
|
||||
*/
|
||||
createDocument(data, context={}) {
|
||||
return new this.documentClass(data, {
|
||||
...context,
|
||||
parent: this.model,
|
||||
parentCollection: this.name,
|
||||
pack: this.model.pack
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize the EmbeddedCollection object by constructing its contained Document instances
|
||||
* @param {DocumentConstructionContext} [options] Initialization options.
|
||||
*/
|
||||
initialize(options={}) {
|
||||
|
||||
// Repeat initialization
|
||||
if ( this._initialized ) {
|
||||
for ( const doc of this ) doc._initialize(options);
|
||||
return;
|
||||
}
|
||||
|
||||
// First-time initialization
|
||||
this.clear();
|
||||
for ( const d of this._source ) this._initializeDocument(d, options);
|
||||
this._initialized = true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize an embedded document and store it in the collection.
|
||||
* @param {object} data The Document data.
|
||||
* @param {DocumentConstructionContext} [context] Context to configure Document initialization.
|
||||
* @protected
|
||||
*/
|
||||
_initializeDocument(data, context) {
|
||||
if ( !data._id ) data._id = randomID(16);
|
||||
let doc;
|
||||
try {
|
||||
doc = this.createDocument(data, context);
|
||||
super.set(doc.id, doc);
|
||||
} catch(err) {
|
||||
this._handleInvalidDocument(data._id, err, context);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Log warnings or errors when a Document is found to be invalid.
|
||||
* @param {string} id The invalid Document's ID.
|
||||
* @param {Error} err The validation error.
|
||||
* @param {object} [options] Options to configure invalid Document handling.
|
||||
* @param {boolean} [options.strict=true] Whether to throw an error or only log a warning.
|
||||
* @protected
|
||||
*/
|
||||
_handleInvalidDocument(id, err, {strict=true}={}) {
|
||||
const docName = this.documentClass.documentName;
|
||||
const parent = this.model;
|
||||
this.invalidDocumentIds.add(id);
|
||||
|
||||
// Wrap the error with more information
|
||||
const uuid = `${parent.uuid}.${docName}.${id}`;
|
||||
const msg = `Failed to initialize ${docName} [${uuid}]:\n${err.message}`;
|
||||
const error = new Error(msg, {cause: err});
|
||||
|
||||
if ( strict ) globalThis.logger.error(error);
|
||||
else globalThis.logger.warn(error);
|
||||
if ( globalThis.Hooks && strict ) {
|
||||
Hooks.onError(`${this.constructor.name}#_initializeDocument`, error, {id, documentName: docName});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get an element from the EmbeddedCollection by its ID.
|
||||
* @param {string} id The ID of the Embedded Document to retrieve.
|
||||
* @param {object} [options] Additional options to configure retrieval.
|
||||
* @param {boolean} [options.strict=false] Throw an Error if the requested Embedded Document does not exist.
|
||||
* @param {boolean} [options.invalid=false] Allow retrieving an invalid Embedded Document.
|
||||
* @returns {Document}
|
||||
* @throws If strict is true and the Embedded Document cannot be found.
|
||||
*/
|
||||
get(id, {invalid=false, strict=false}={}) {
|
||||
let result = super.get(id);
|
||||
if ( !result && invalid ) result = this.getInvalid(id, { strict: false });
|
||||
if ( !result && strict ) throw new Error(`${this.constructor.documentName} id [${id}] does not exist in the `
|
||||
+ `${this.constructor.name} collection.`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add an item to the collection.
|
||||
* @param {string} key The embedded Document ID.
|
||||
* @param {Document} value The embedded Document instance.
|
||||
* @param {object} [options] Additional options to the set operation.
|
||||
* @param {boolean} [options.modifySource=true] Whether to modify the collection's source as part of the operation.
|
||||
* */
|
||||
set(key, value, {modifySource=true, ...options}={}) {
|
||||
if ( modifySource ) this._set(key, value, options);
|
||||
return super.set(key, value);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Modify the underlying source array to include the Document.
|
||||
* @param {string} key The Document ID key.
|
||||
* @param {Document} value The Document.
|
||||
* @protected
|
||||
*/
|
||||
_set(key, value) {
|
||||
if ( this.has(key) || this.invalidDocumentIds.has(key) ) this._source.findSplice(d => d._id === key, value._source);
|
||||
else this._source.push(value._source);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* @param {string} key The embedded Document ID.
|
||||
* @param {object} [options] Additional options to the delete operation.
|
||||
* @param {boolean} [options.modifySource=true] Whether to modify the collection's source as part of the operation.
|
||||
* */
|
||||
delete(key, {modifySource=true, ...options}={}) {
|
||||
if ( modifySource ) this._delete(key, options);
|
||||
return super.delete(key);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove the value from the underlying source array.
|
||||
* @param {string} key The Document ID key.
|
||||
* @param {object} [options] Additional options to configure deletion behavior.
|
||||
* @protected
|
||||
*/
|
||||
_delete(key, options={}) {
|
||||
if ( this.has(key) || this.invalidDocumentIds.has(key) ) this._source.findSplice(d => d._id === key);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update an EmbeddedCollection using an array of provided document data.
|
||||
* @param {DataModel[]} changes An array of provided Document data
|
||||
* @param {object} [options={}] Additional options which modify how the collection is updated
|
||||
*/
|
||||
update(changes, options={}) {
|
||||
const updated = new Set();
|
||||
|
||||
// Create or update documents within the collection
|
||||
for ( let data of changes ) {
|
||||
if ( !data._id ) data._id = randomID(16);
|
||||
this._createOrUpdate(data, options);
|
||||
updated.add(data._id);
|
||||
}
|
||||
|
||||
// If the update was not recursive, remove all non-updated documents
|
||||
if ( options.recursive === false ) {
|
||||
for ( const id of this._source.map(d => d._id) ) {
|
||||
if ( !updated.has(id) ) this.delete(id, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create or update an embedded Document in this collection.
|
||||
* @param {DataModel} data The update delta.
|
||||
* @param {object} [options={}] Additional options which modify how the collection is updated.
|
||||
* @protected
|
||||
*/
|
||||
_createOrUpdate(data, options) {
|
||||
const current = this.get(data._id);
|
||||
if ( current ) current.updateSource(data, options);
|
||||
else {
|
||||
const doc = this.createDocument(data);
|
||||
this.set(doc.id, doc);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Obtain a temporary Document instance for a document id which currently has invalid source data.
|
||||
* @param {string} id A document ID with invalid source data.
|
||||
* @param {object} [options] Additional options to configure retrieval.
|
||||
* @param {boolean} [options.strict=true] Throw an Error if the requested ID is not in the set of invalid IDs for
|
||||
* this collection.
|
||||
* @returns {Document} An in-memory instance for the invalid Document
|
||||
* @throws If strict is true and the requested ID is not in the set of invalid IDs for this collection.
|
||||
*/
|
||||
getInvalid(id, {strict=true}={}) {
|
||||
if ( !this.invalidDocumentIds.has(id) ) {
|
||||
if ( strict ) throw new Error(`${this.constructor.documentName} id [${id}] is not in the set of invalid ids`);
|
||||
return;
|
||||
}
|
||||
const data = this._source.find(d => d._id === id);
|
||||
return this.documentClass.fromSource(foundry.utils.deepClone(data), {parent: this.model});
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert the EmbeddedCollection to an array of simple objects.
|
||||
* @param {boolean} [source=true] Draw data for contained Documents from the underlying data source?
|
||||
* @returns {object[]} The extracted array of primitive objects
|
||||
*/
|
||||
toObject(source=true) {
|
||||
const arr = [];
|
||||
for ( let doc of this.values() ) {
|
||||
arr.push(doc.toObject(source));
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Follow-up actions to take when a database operation modifies Documents in this EmbeddedCollection.
|
||||
* @param {DatabaseAction} action The database action performed
|
||||
* @param {foundry.abstract.Document[]} documents The array of modified Documents
|
||||
* @param {any[]} result The result of the database operation
|
||||
* @param {DatabaseOperation} operation Database operation details
|
||||
* @param {foundry.documents.BaseUser} user The User who performed the operation
|
||||
* @internal
|
||||
*/
|
||||
_onModifyContents(action, documents, result, operation, user) {}
|
||||
}
|
||||
9
resources/app/common/abstract/module.mjs
Normal file
9
resources/app/common/abstract/module.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
export * as types from "./_types.mjs";
|
||||
export {default as DataModel} from "./data.mjs";
|
||||
export {default as TypeDataModel} from "./type-data.mjs";
|
||||
export {default as Document} from "./document.mjs";
|
||||
export {default as DocumentSocketResponse} from "./socket.mjs";
|
||||
export {default as DatabaseBackend} from "./backend.mjs";
|
||||
export {default as EmbeddedCollection} from "./embedded-collection.mjs";
|
||||
export {default as EmbeddedCollectionDelta} from "./embedded-collection-delta.mjs";
|
||||
export {default as SingletonEmbeddedCollection} from "./singleton-collection.mjs";
|
||||
32
resources/app/common/abstract/singleton-collection.mjs
Normal file
32
resources/app/common/abstract/singleton-collection.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import EmbeddedCollection from "./embedded-collection.mjs";
|
||||
|
||||
/**
|
||||
* This class provides a {@link Collection} wrapper around a singleton embedded Document so that it can be interacted
|
||||
* with via a common interface.
|
||||
*/
|
||||
export default class SingletonEmbeddedCollection extends EmbeddedCollection {
|
||||
/** @inheritdoc */
|
||||
set(key, value) {
|
||||
if ( this.size && !this.has(key) ) {
|
||||
const embeddedName = this.documentClass.documentName;
|
||||
const parentName = this.model.documentName;
|
||||
throw new Error(`Cannot create singleton embedded ${embeddedName} [${key}] in parent ${parentName} `
|
||||
+ `[${this.model.id}] as it already has one assigned.`);
|
||||
}
|
||||
return super.set(key, value);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_set(key, value) {
|
||||
this.model._source[this.name] = value?._source ?? null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_delete(key) {
|
||||
this.model._source[this.name] = null;
|
||||
}
|
||||
}
|
||||
64
resources/app/common/abstract/socket.mjs
Normal file
64
resources/app/common/abstract/socket.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").DatabaseAction} DatabaseAction
|
||||
* @typedef {import("./_types.mjs").DatabaseOperation} DatabaseOperation
|
||||
* @typedef {import("./_types.mjs").DocumentSocketRequest} DocumentSocketRequest
|
||||
*/
|
||||
|
||||
/**
|
||||
* The data structure of a modifyDocument socket response.
|
||||
* @alias foundry.abstract.DocumentSocketResponse
|
||||
*/
|
||||
export default class DocumentSocketResponse {
|
||||
/**
|
||||
* Prepare a response for an incoming request.
|
||||
* @param {DocumentSocketRequest} request The incoming request that is being responded to
|
||||
*/
|
||||
constructor(request) {
|
||||
for ( const [k, v] of Object.entries(request) ) {
|
||||
if ( this.hasOwnProperty(k) ) this[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of Document being transacted.
|
||||
* @type {string}
|
||||
*/
|
||||
type;
|
||||
|
||||
/**
|
||||
* The database action that was performed.
|
||||
* @type {DatabaseAction}
|
||||
*/
|
||||
action;
|
||||
|
||||
/**
|
||||
* Was this response broadcast to other connected clients?
|
||||
* @type {boolean}
|
||||
*/
|
||||
broadcast;
|
||||
|
||||
/**
|
||||
* The database operation that was requested.
|
||||
* @type {DatabaseOperation}
|
||||
*/
|
||||
operation;
|
||||
|
||||
/**
|
||||
* The identifier of the requesting user.
|
||||
* @type {string}
|
||||
*/
|
||||
userId;
|
||||
|
||||
/**
|
||||
* The result of the request. Present if successful
|
||||
* @type {object[]|string[]}
|
||||
*/
|
||||
result;
|
||||
|
||||
/**
|
||||
* An error that occurred. Present if unsuccessful
|
||||
* @type {Error}
|
||||
*/
|
||||
error;
|
||||
}
|
||||
204
resources/app/common/abstract/type-data.mjs
Normal file
204
resources/app/common/abstract/type-data.mjs
Normal file
@@ -0,0 +1,204 @@
|
||||
import DataModel from "./data.mjs";
|
||||
import {TypeDataField} from "../data/fields.mjs";
|
||||
|
||||
/**
|
||||
* A specialized subclass of DataModel, intended to represent a Document's type-specific data.
|
||||
* Systems or Modules that provide DataModel implementations for sub-types of Documents (such as Actors or Items)
|
||||
* should subclass this class instead of the base DataModel class.
|
||||
*
|
||||
* @see {@link Document}
|
||||
* @extends {DataModel}
|
||||
* @abstract
|
||||
*
|
||||
* @example Registering a custom sub-type for a Module.
|
||||
*
|
||||
* **module.json**
|
||||
* ```json
|
||||
* {
|
||||
* "id": "my-module",
|
||||
* "esmodules": ["main.mjs"],
|
||||
* "documentTypes": {
|
||||
* "Actor": {
|
||||
* "sidekick": {},
|
||||
* "villain": {}
|
||||
* },
|
||||
* "JournalEntryPage": {
|
||||
* "dossier": {},
|
||||
* "quest": {
|
||||
* "htmlFields": ["description"]
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* **main.mjs**
|
||||
* ```js
|
||||
* Hooks.on("init", () => {
|
||||
* Object.assign(CONFIG.Actor.dataModels, {
|
||||
* "my-module.sidekick": SidekickModel,
|
||||
* "my-module.villain": VillainModel
|
||||
* });
|
||||
* Object.assign(CONFIG.JournalEntryPage.dataModels, {
|
||||
* "my-module.dossier": DossierModel,
|
||||
* "my-module.quest": QuestModel
|
||||
* });
|
||||
* });
|
||||
*
|
||||
* class QuestModel extends foundry.abstract.TypeDataModel {
|
||||
* static defineSchema() {
|
||||
* const fields = foundry.data.fields;
|
||||
* return {
|
||||
* description: new fields.HTMLField({required: false, blank: true, initial: ""}),
|
||||
* steps: new fields.ArrayField(new fields.StringField())
|
||||
* };
|
||||
* }
|
||||
*
|
||||
* prepareDerivedData() {
|
||||
* this.totalSteps = this.steps.length;
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export default class TypeDataModel extends DataModel {
|
||||
|
||||
/** @inheritdoc */
|
||||
constructor(data={}, options={}) {
|
||||
super(data, options);
|
||||
|
||||
/**
|
||||
* The package that is providing this DataModel for the given sub-type.
|
||||
* @type {System|Module|null}
|
||||
*/
|
||||
Object.defineProperty(this, "modelProvider", {value: TypeDataField.getModelProvider(this), writable: false});
|
||||
}
|
||||
|
||||
/**
|
||||
* A set of localization prefix paths which are used by this data model.
|
||||
* @type {string[]}
|
||||
*/
|
||||
static LOCALIZATION_PREFIXES = [];
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get schema() {
|
||||
if ( this.hasOwnProperty("_schema") ) return this._schema;
|
||||
const schema = super.schema;
|
||||
schema.name = "system";
|
||||
return schema;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prepare data related to this DataModel itself, before any derived data is computed.
|
||||
*
|
||||
* Called before {@link ClientDocument#prepareBaseData} in {@link ClientDocument#prepareData}.
|
||||
*/
|
||||
prepareBaseData() {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply transformations of derivations to the values of the source data object.
|
||||
* Compute data fields whose values are not stored to the database.
|
||||
*
|
||||
* Called before {@link ClientDocument#prepareDerivedData} in {@link ClientDocument#prepareData}.
|
||||
*/
|
||||
prepareDerivedData() {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert this Document to some HTML display for embedding purposes.
|
||||
* @param {DocumentHTMLEmbedConfig} config Configuration for embedding behavior.
|
||||
* @param {EnrichmentOptions} [options] The original enrichment options for cases where the Document embed content
|
||||
* also contains text that must be enriched.
|
||||
* @returns {Promise<HTMLElement|HTMLCollection|null>}
|
||||
*/
|
||||
async toEmbed(config, options={}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Database Operations */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Called by {@link ClientDocument#_preCreate}.
|
||||
*
|
||||
* @param {object} data The initial data object provided to the document creation request
|
||||
* @param {object} options Additional options which modify the creation request
|
||||
* @param {documents.BaseUser} user The User requesting the document creation
|
||||
* @returns {Promise<boolean|void>} Return false to exclude this Document from the creation operation
|
||||
* @internal
|
||||
*/
|
||||
async _preCreate(data, options, user) {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Called by {@link ClientDocument#_onCreate}.
|
||||
*
|
||||
* @param {object} data The initial data object provided to the document creation request
|
||||
* @param {object} options Additional options which modify the creation request
|
||||
* @param {string} userId The id of the User requesting the document update
|
||||
* @protected
|
||||
* @internal
|
||||
*/
|
||||
_onCreate(data, options, userId) {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Called by {@link ClientDocument#_preUpdate}.
|
||||
*
|
||||
* @param {object} changes The candidate changes to the Document
|
||||
* @param {object} options Additional options which modify the update request
|
||||
* @param {documents.BaseUser} user The User requesting the document update
|
||||
* @returns {Promise<boolean|void>} A return value of false indicates the update operation should be cancelled.
|
||||
* @protected
|
||||
* @internal
|
||||
*/
|
||||
async _preUpdate(changes, options, user) {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Called by {@link ClientDocument#_onUpdate}.
|
||||
*
|
||||
* @param {object} changed The differential data that was changed relative to the documents prior values
|
||||
* @param {object} options Additional options which modify the update request
|
||||
* @param {string} userId The id of the User requesting the document update
|
||||
* @protected
|
||||
* @internal
|
||||
*/
|
||||
_onUpdate(changed, options, userId) {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
|
||||
/**
|
||||
* Called by {@link ClientDocument#_preDelete}.
|
||||
*
|
||||
* @param {object} options Additional options which modify the deletion request
|
||||
* @param {documents.BaseUser} user The User requesting the document deletion
|
||||
* @returns {Promise<boolean|void>} A return value of false indicates the deletion operation should be cancelled.
|
||||
* @protected
|
||||
* @internal
|
||||
*/
|
||||
async _preDelete(options, user) {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Called by {@link ClientDocument#_onDelete}.
|
||||
*
|
||||
* @param {object} options Additional options which modify the deletion request
|
||||
* @param {string} userId The id of the User requesting the document update
|
||||
* @protected
|
||||
* @internal
|
||||
*/
|
||||
_onDelete(options, userId) {}
|
||||
}
|
||||
219
resources/app/common/config.mjs
Normal file
219
resources/app/common/config.mjs
Normal file
@@ -0,0 +1,219 @@
|
||||
import DataModel from "./abstract/data.mjs";
|
||||
import * as fields from "./data/fields.mjs";
|
||||
import {CSS_THEMES, SOFTWARE_UPDATE_CHANNELS} from "./constants.mjs";
|
||||
import {isNewerVersion} from "./utils/helpers.mjs";
|
||||
|
||||
/** @namespace config */
|
||||
|
||||
/**
|
||||
* A data model definition which describes the application configuration options.
|
||||
* These options are persisted in the user data Config folder in the options.json file.
|
||||
* The server-side software extends this class and provides additional validations and
|
||||
* @extends {DataModel}
|
||||
* @memberof config
|
||||
*
|
||||
* @property {string|null} adminPassword The server administrator password (obscured)
|
||||
* @property {string|null} awsConfig The relative path (to Config) of an AWS configuration file
|
||||
* @property {boolean} compressStatic Whether to compress static files? True by default
|
||||
* @property {string} dataPath The absolute path of the user data directory (obscured)
|
||||
* @property {boolean} fullscreen Whether the application should automatically start in fullscreen mode?
|
||||
* @property {string|null} hostname A custom hostname applied to internet invitation addresses and URLs
|
||||
* @property {string} language The default language for the application
|
||||
* @property {string|null} localHostname A custom hostname applied to local invitation addresses
|
||||
* @property {string|null} passwordSalt A custom salt used for hashing user passwords (obscured)
|
||||
* @property {number} port The port on which the server is listening
|
||||
* @property {number} [protocol] The Internet Protocol version to use, either 4 or 6.
|
||||
* @property {number} proxyPort An external-facing proxied port used for invitation addresses and URLs
|
||||
* @property {boolean} proxySSL Is the application running in SSL mode at a reverse-proxy level?
|
||||
* @property {string|null} routePrefix A URL path part which prefixes normal application routing
|
||||
* @property {string|null} sslCert The relative path (to Config) of a used SSL certificate
|
||||
* @property {string|null} sslKey The relative path (to Config) of a used SSL key
|
||||
* @property {string} updateChannel The current application update channel
|
||||
* @property {boolean} upnp Is UPNP activated?
|
||||
* @property {number} upnpLeaseDuration The duration in seconds of a UPNP lease, if UPNP is active
|
||||
* @property {string} world A default world name which starts automatically on launch
|
||||
*/
|
||||
class ApplicationConfiguration extends DataModel {
|
||||
static defineSchema() {
|
||||
return {
|
||||
adminPassword: new fields.StringField({required: true, blank: false, nullable: true, initial: null,
|
||||
label: "SETUP.AdminPasswordLabel", hint: "SETUP.AdminPasswordHint"}),
|
||||
awsConfig: new fields.StringField({label: "SETUP.AWSLabel", hint: "SETUP.AWSHint", blank: false, nullable: true,
|
||||
initial: null}),
|
||||
compressStatic: new fields.BooleanField({initial: true, label: "SETUP.CompressStaticLabel",
|
||||
hint: "SETUP.CompressStaticHint"}),
|
||||
compressSocket: new fields.BooleanField({initial: true, label: "SETUP.CompressSocketLabel",
|
||||
hint: "SETUP.CompressSocketHint"}),
|
||||
cssTheme: new fields.StringField({blank: false, choices: CSS_THEMES, initial: "foundry",
|
||||
label: "SETUP.CSSTheme", hint: "SETUP.CSSThemeHint"}),
|
||||
dataPath: new fields.StringField({label: "SETUP.DataPathLabel", hint: "SETUP.DataPathHint"}),
|
||||
deleteNEDB: new fields.BooleanField({label: "SETUP.DeleteNEDBLabel", hint: "SETUP.DeleteNEDBHint"}),
|
||||
fullscreen: new fields.BooleanField({initial: false}),
|
||||
hostname: new fields.StringField({required: true, blank: false, nullable: true, initial: null}),
|
||||
hotReload: new fields.BooleanField({initial: false, label: "SETUP.HotReloadLabel", hint: "SETUP.HotReloadHint"}),
|
||||
language: new fields.StringField({required: true, blank: false, initial: "en.core",
|
||||
label: "SETUP.DefaultLanguageLabel", hint: "SETUP.DefaultLanguageHint"}),
|
||||
localHostname: new fields.StringField({required: true, blank: false, nullable: true, initial: null}),
|
||||
passwordSalt: new fields.StringField({required: true, blank: false, nullable: true, initial: null}),
|
||||
port: new fields.NumberField({required: true, nullable: false, integer: true, initial: 30000,
|
||||
validate: this._validatePort, label: "SETUP.PortLabel", hint: "SETUP.PortHint"}),
|
||||
protocol: new fields.NumberField({integer: true, choices: [4, 6], nullable: true}),
|
||||
proxyPort: new fields.NumberField({required: true, nullable: true, integer: true, initial: null}),
|
||||
proxySSL: new fields.BooleanField({initial: false}),
|
||||
routePrefix: new fields.StringField({required: true, blank: false, nullable: true, initial: null}),
|
||||
sslCert: new fields.StringField({label: "SETUP.SSLCertLabel", hint: "SETUP.SSLCertHint", blank: false,
|
||||
nullable: true, initial: null}),
|
||||
sslKey: new fields.StringField({label: "SETUP.SSLKeyLabel", blank: false, nullable: true, initial: null}),
|
||||
telemetry: new fields.BooleanField({required: false, initial: undefined, label: "SETUP.Telemetry",
|
||||
hint: "SETUP.TelemetryHint"}),
|
||||
updateChannel: new fields.StringField({required: true, choices: SOFTWARE_UPDATE_CHANNELS, initial: "stable"}),
|
||||
upnp: new fields.BooleanField({initial: true}),
|
||||
upnpLeaseDuration: new fields.NumberField(),
|
||||
world: new fields.StringField({required: true, blank: false, nullable: true, initial: null,
|
||||
label: "SETUP.WorldLabel", hint: "SETUP.WorldHint"}),
|
||||
noBackups: new fields.BooleanField({required: false})
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static migrateData(data) {
|
||||
|
||||
// Backwards compatibility for -v9 update channels
|
||||
data.updateChannel = {
|
||||
"alpha": "prototype",
|
||||
"beta": "testing",
|
||||
"release": "stable"
|
||||
}[data.updateChannel] || data.updateChannel;
|
||||
|
||||
// Backwards compatibility for awsConfig of true
|
||||
if ( data.awsConfig === true ) data.awsConfig = "";
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate a port assignment.
|
||||
* @param {number} port The requested port
|
||||
* @throws An error if the requested port is invalid
|
||||
* @private
|
||||
*/
|
||||
static _validatePort(port) {
|
||||
if ( !Number.isNumeric(port) || ((port < 1024) && ![80, 443].includes(port)) || (port > 65535) ) {
|
||||
throw new Error(`The application port must be an integer, either 80, 443, or between 1024 and 65535`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* A data object which represents the details of this Release of Foundry VTT
|
||||
* @extends {DataModel}
|
||||
* @memberof config
|
||||
*
|
||||
* @property {number} generation The major generation of the Release
|
||||
* @property {number} [maxGeneration] The maximum available generation of the software.
|
||||
* @property {number} [maxStableGeneration] The maximum available stable generation of the software.
|
||||
* @property {string} channel The channel the Release belongs to, such as "stable"
|
||||
* @property {string} suffix An optional appended string display for the Release
|
||||
* @property {number} build The internal build number for the Release
|
||||
* @property {number} time When the Release was released
|
||||
* @property {number} [node_version] The minimum required Node.js major version
|
||||
* @property {string} [notes] Release notes for the update version
|
||||
* @property {string} [download] A temporary download URL where this version may be obtained
|
||||
*/
|
||||
class ReleaseData extends DataModel {
|
||||
/** @override */
|
||||
static defineSchema() {
|
||||
return {
|
||||
generation: new fields.NumberField({required: true, nullable: false, integer: true, min: 1}),
|
||||
maxGeneration: new fields.NumberField({
|
||||
required: false, nullable: false, integer: true, min: 1, initial: () => this.generation
|
||||
}),
|
||||
maxStableGeneration: new fields.NumberField({
|
||||
required: false, nullable: false, integer: true, min: 1, initial: () => this.generation
|
||||
}),
|
||||
channel: new fields.StringField({choices: SOFTWARE_UPDATE_CHANNELS, blank: false}),
|
||||
suffix: new fields.StringField(),
|
||||
build: new fields.NumberField({required: true, nullable: false, integer: true}),
|
||||
time: new fields.NumberField({nullable: false, initial: Date.now}),
|
||||
node_version: new fields.NumberField({required: true, nullable: false, integer: true, min: 10}),
|
||||
notes: new fields.StringField(),
|
||||
download: new fields.StringField()
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* A formatted string for shortened display, such as "Version 9"
|
||||
* @return {string}
|
||||
*/
|
||||
get shortDisplay() {
|
||||
return `Version ${this.generation} Build ${this.build}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A formatted string for general display, such as "V9 Prototype 1" or "Version 9"
|
||||
* @return {string}
|
||||
*/
|
||||
get display() {
|
||||
return ["Version", this.generation, this.suffix].filterJoin(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* A formatted string for Version compatibility checking, such as "9.150"
|
||||
* @return {string}
|
||||
*/
|
||||
get version() {
|
||||
return `${this.generation}.${this.build}`;
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
toString() {
|
||||
return this.shortDisplay;
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this ReleaseData object newer than some other version?
|
||||
* @param {string|ReleaseData} other Some other version to compare against
|
||||
* @returns {boolean} Is this ReleaseData a newer version?
|
||||
*/
|
||||
isNewer(other) {
|
||||
const version = other instanceof ReleaseData ? other.version : other;
|
||||
return isNewerVersion(this.version, version);
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this ReleaseData object a newer generation than some other version?
|
||||
* @param {string|ReleaseData} other Some other version to compare against
|
||||
* @returns {boolean} Is this ReleaseData a newer generation?
|
||||
*/
|
||||
isGenerationalChange(other) {
|
||||
if ( !other ) return true;
|
||||
let generation;
|
||||
if ( other instanceof ReleaseData ) generation = other.generation.toString();
|
||||
else {
|
||||
other = String(other);
|
||||
const parts = other.split(".");
|
||||
if ( parts[0] === "0" ) parts.shift()
|
||||
generation = parts[0];
|
||||
}
|
||||
return isNewerVersion(this.generation, generation);
|
||||
}
|
||||
}
|
||||
|
||||
// Module Exports
|
||||
export {
|
||||
ApplicationConfiguration,
|
||||
ReleaseData
|
||||
}
|
||||
1911
resources/app/common/constants.mjs
Normal file
1911
resources/app/common/constants.mjs
Normal file
File diff suppressed because it is too large
Load Diff
524
resources/app/common/data/data.mjs
Normal file
524
resources/app/common/data/data.mjs
Normal file
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* The collection of data schema and document definitions for primary documents which are shared between the both the
|
||||
* client and the server.
|
||||
* @namespace data
|
||||
*/
|
||||
|
||||
import {DataModel} from "../abstract/module.mjs";
|
||||
import * as fields from "./fields.mjs";
|
||||
import * as documents from "../documents/_module.mjs";
|
||||
import {logCompatibilityWarning} from "../utils/logging.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./fields.mjs").DataFieldOptions} DataFieldOptions
|
||||
* @typedef {import("./fields.mjs").FilePathFieldOptions} FilePathFieldOptions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} LightAnimationData
|
||||
* @property {string} type The animation type which is applied
|
||||
* @property {number} speed The speed of the animation, a number between 0 and 10
|
||||
* @property {number} intensity The intensity of the animation, a number between 1 and 10
|
||||
* @property {boolean} reverse Reverse the direction of animation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A reusable document structure for the internal data used to render the appearance of a light source.
|
||||
* This is re-used by both the AmbientLightData and TokenData classes.
|
||||
* @extends DataModel
|
||||
* @memberof data
|
||||
*
|
||||
* @property {boolean} negative Is this light source a negative source? (i.e. darkness source)
|
||||
* @property {number} alpha An opacity for the emitted light, if any
|
||||
* @property {number} angle The angle of emission for this point source
|
||||
* @property {number} bright The allowed radius of bright vision or illumination
|
||||
* @property {number} color A tint color for the emitted light, if any
|
||||
* @property {number} coloration The coloration technique applied in the shader
|
||||
* @property {number} contrast The amount of contrast this light applies to the background texture
|
||||
* @property {number} dim The allowed radius of dim vision or illumination
|
||||
* @property {number} attenuation Fade the difference between bright, dim, and dark gradually?
|
||||
* @property {number} luminosity The luminosity applied in the shader
|
||||
* @property {number} saturation The amount of color saturation this light applies to the background texture
|
||||
* @property {number} shadows The depth of shadows this light applies to the background texture
|
||||
* @property {LightAnimationData} animation An animation configuration for the source
|
||||
* @property {{min: number, max: number}} darkness A darkness range (min and max) for which the source should be active
|
||||
*/
|
||||
class LightData extends DataModel {
|
||||
static defineSchema() {
|
||||
return {
|
||||
negative: new fields.BooleanField(),
|
||||
priority: new fields.NumberField({required: true, nullable: false, integer: true, initial: 0, min: 0}),
|
||||
alpha: new fields.AlphaField({initial: 0.5}),
|
||||
angle: new fields.AngleField({initial: 360, normalize: false}),
|
||||
bright: new fields.NumberField({required: true, nullable: false, initial: 0, min: 0, step: 0.01}),
|
||||
color: new fields.ColorField({}),
|
||||
coloration: new fields.NumberField({required: true, integer: true, initial: 1}),
|
||||
dim: new fields.NumberField({required: true, nullable: false, initial: 0, min: 0, step: 0.01}),
|
||||
attenuation: new fields.AlphaField({initial: 0.5}),
|
||||
luminosity: new fields.NumberField({required: true, nullable: false, initial: 0.5, min: 0, max: 1}),
|
||||
saturation: new fields.NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1}),
|
||||
contrast: new fields.NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1}),
|
||||
shadows: new fields.NumberField({required: true, nullable: false, initial: 0, min: 0, max: 1}),
|
||||
animation: new fields.SchemaField({
|
||||
type: new fields.StringField({nullable: true, blank: false, initial: null}),
|
||||
speed: new fields.NumberField({required: true, nullable: false, integer: true, initial: 5, min: 0, max: 10,
|
||||
validationError: "Light animation speed must be an integer between 0 and 10"}),
|
||||
intensity: new fields.NumberField({required: true, nullable: false, integer: true, initial: 5, min: 1, max: 10,
|
||||
validationError: "Light animation intensity must be an integer between 1 and 10"}),
|
||||
reverse: new fields.BooleanField()
|
||||
}),
|
||||
darkness: new fields.SchemaField({
|
||||
min: new fields.AlphaField({initial: 0}),
|
||||
max: new fields.AlphaField({initial: 1})
|
||||
}, {
|
||||
validate: d => (d.min ?? 0) <= (d.max ?? 1),
|
||||
validationError: "darkness.max may not be less than darkness.min"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["LIGHT"];
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static migrateData(data) {
|
||||
/**
|
||||
* Migration of negative luminosity
|
||||
* @deprecated since v12
|
||||
*/
|
||||
const luminosity = data.luminosity;
|
||||
if ( luminosity < 0) {
|
||||
data.luminosity = 1 - luminosity;
|
||||
data.negative = true;
|
||||
}
|
||||
return super.migrateData(data);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* A data model intended to be used as an inner EmbeddedDataField which defines a geometric shape.
|
||||
* @extends DataModel
|
||||
* @memberof data
|
||||
*
|
||||
* @property {string} type The type of shape, a value in ShapeData.TYPES.
|
||||
* For rectangles, the x/y coordinates are the top-left corner.
|
||||
* For circles, the x/y coordinates are the center of the circle.
|
||||
* For polygons, the x/y coordinates are the first point of the polygon.
|
||||
* @property {number} [width] For rectangles, the pixel width of the shape.
|
||||
* @property {number} [height] For rectangles, the pixel width of the shape.
|
||||
* @property {number} [radius] For circles, the pixel radius of the shape.
|
||||
* @property {number[]} [points] For polygons, the array of polygon coordinates which comprise the shape.
|
||||
*/
|
||||
class ShapeData extends DataModel {
|
||||
static defineSchema() {
|
||||
return {
|
||||
type: new fields.StringField({required: true, blank: false, choices: Object.values(this.TYPES), initial: "r"}),
|
||||
width: new fields.NumberField({required: false, integer: true, min: 0}),
|
||||
height: new fields.NumberField({required: false, integer: true, min: 0}),
|
||||
radius: new fields.NumberField({required: false, integer: true, positive: true}),
|
||||
points: new fields.ArrayField(new fields.NumberField({nullable: false}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The primitive shape types which are supported
|
||||
* @enum {string}
|
||||
*/
|
||||
static TYPES = {
|
||||
RECTANGLE: "r",
|
||||
CIRCLE: "c",
|
||||
ELLIPSE: "e",
|
||||
POLYGON: "p"
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* A data model intended to be used as an inner EmbeddedDataField which defines a geometric shape.
|
||||
* @extends DataModel
|
||||
* @memberof data
|
||||
* @abstract
|
||||
*
|
||||
* @property {string} type The type of shape, a value in BaseShapeData.TYPES.
|
||||
* @property {{bottom: number|null, top: number|null}} [elevation] The bottom and top elevation of the shape.
|
||||
* A value of null means -/+Infinity.
|
||||
* @property {boolean} [hole=false] Is this shape a hole?
|
||||
*/
|
||||
class BaseShapeData extends DataModel {
|
||||
|
||||
/**
|
||||
* The possible shape types.
|
||||
* @type {Readonly<{
|
||||
* rectangle: RectangleShapeData,
|
||||
* circle: CircleShapeData,
|
||||
* ellipse: EllipseShapeData,
|
||||
* polygon: PolygonShapeData
|
||||
* }>}
|
||||
*/
|
||||
static get TYPES() {
|
||||
return BaseShapeData.#TYPES ??= Object.freeze({
|
||||
[RectangleShapeData.TYPE]: RectangleShapeData,
|
||||
[CircleShapeData.TYPE]: CircleShapeData,
|
||||
[EllipseShapeData.TYPE]: EllipseShapeData,
|
||||
[PolygonShapeData.TYPE]: PolygonShapeData
|
||||
});
|
||||
}
|
||||
|
||||
static #TYPES;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The type of this shape.
|
||||
* @type {string}
|
||||
*/
|
||||
static TYPE = "";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static defineSchema() {
|
||||
return {
|
||||
type: new fields.StringField({required: true, blank: false, initial: this.TYPE,
|
||||
validate: value => value === this.TYPE, validationError: `must be equal to "${this.TYPE}"`}),
|
||||
hole: new fields.BooleanField()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The data model for a rectangular shape.
|
||||
* @extends DataModel
|
||||
* @memberof data
|
||||
*
|
||||
* @property {number} x The top-left x-coordinate in pixels before rotation.
|
||||
* @property {number} y The top-left y-coordinate in pixels before rotation.
|
||||
* @property {number} width The width of the rectangle in pixels.
|
||||
* @property {number} height The height of the rectangle in pixels.
|
||||
* @property {number} [rotation=0] The rotation around the center of the rectangle in degrees.
|
||||
*/
|
||||
class RectangleShapeData extends BaseShapeData {
|
||||
|
||||
static {
|
||||
Object.defineProperty(this, "TYPE", {value: "rectangle"});
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return Object.assign(super.defineSchema(), {
|
||||
x: new fields.NumberField({required: true, nullable: false, initial: undefined}),
|
||||
y: new fields.NumberField({required: true, nullable: false, initial: undefined}),
|
||||
width: new fields.NumberField({required: true, nullable: false, initial: undefined, positive: true}),
|
||||
height: new fields.NumberField({required: true, nullable: false, initial: undefined, positive: true}),
|
||||
rotation: new fields.AngleField()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The data model for a circle shape.
|
||||
* @extends DataModel
|
||||
* @memberof data
|
||||
*
|
||||
* @property {number} x The x-coordinate of the center point in pixels.
|
||||
* @property {number} y The y-coordinate of the center point in pixels.
|
||||
* @property {number} radius The radius of the circle in pixels.
|
||||
*/
|
||||
class CircleShapeData extends BaseShapeData {
|
||||
|
||||
static {
|
||||
Object.defineProperty(this, "TYPE", {value: "circle"});
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return Object.assign(super.defineSchema(), {
|
||||
x: new fields.NumberField({required: true, nullable: false, initial: undefined}),
|
||||
y: new fields.NumberField({required: true, nullable: false, initial: undefined}),
|
||||
radius: new fields.NumberField({required: true, nullable: false, initial: undefined, positive: true})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The data model for an ellipse shape.
|
||||
* @extends DataModel
|
||||
* @memberof data
|
||||
*
|
||||
* @property {number} x The x-coordinate of the center point in pixels.
|
||||
* @property {number} y The y-coordinate of the center point in pixels.
|
||||
* @property {number} radiusX The x-radius of the circle in pixels.
|
||||
* @property {number} radiusY The y-radius of the circle in pixels.
|
||||
* @property {number} [rotation=0] The rotation around the center of the rectangle in degrees.
|
||||
*/
|
||||
class EllipseShapeData extends BaseShapeData {
|
||||
|
||||
static {
|
||||
Object.defineProperty(this, "TYPE", {value: "ellipse"});
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return Object.assign(super.defineSchema(), {
|
||||
x: new fields.NumberField({required: true, nullable: false, initial: undefined}),
|
||||
y: new fields.NumberField({required: true, nullable: false, initial: undefined}),
|
||||
radiusX: new fields.NumberField({required: true, nullable: false, initial: undefined, positive: true}),
|
||||
radiusY: new fields.NumberField({required: true, nullable: false, initial: undefined, positive: true}),
|
||||
rotation: new fields.AngleField()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The data model for a polygon shape.
|
||||
* @extends DataModel
|
||||
* @memberof data
|
||||
*
|
||||
* @property {number[]} points The points of the polygon ([x0, y0, x1, y1, ...]).
|
||||
* The polygon must not be self-intersecting.
|
||||
*/
|
||||
class PolygonShapeData extends BaseShapeData {
|
||||
|
||||
static {
|
||||
Object.defineProperty(this, "TYPE", {value: "polygon"});
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return Object.assign(super.defineSchema(), {
|
||||
points: new fields.ArrayField(new fields.NumberField({required: true, nullable: false, initial: undefined}),
|
||||
{validate: value => {
|
||||
if ( value.length % 2 !== 0 ) throw new Error("must have an even length");
|
||||
if ( value.length < 6 ) throw new Error("must have at least 3 points");
|
||||
}}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* A {@link fields.SchemaField} subclass used to represent texture data.
|
||||
* @property {string|null} src The URL of the texture source.
|
||||
* @property {number} [anchorX=0] The X coordinate of the texture anchor.
|
||||
* @property {number} [anchorY=0] The Y coordinate of the texture anchor.
|
||||
* @property {number} [scaleX=1] The scale of the texture in the X dimension.
|
||||
* @property {number} [scaleY=1] The scale of the texture in the Y dimension.
|
||||
* @property {number} [offsetX=0] The X offset of the texture with (0,0) in the top left.
|
||||
* @property {number} [offsetY=0] The Y offset of the texture with (0,0) in the top left.
|
||||
* @property {number} [rotation=0] An angle of rotation by which this texture is rotated around its center.
|
||||
* @property {string} [tint="#ffffff"] The tint applied to the texture.
|
||||
* @property {number} [alphaThreshold=0] Only pixels with an alpha value at or above this value are consider solid
|
||||
* w.r.t. to occlusion testing and light/weather blocking.
|
||||
*/
|
||||
class TextureData extends fields.SchemaField {
|
||||
/**
|
||||
* @param {DataFieldOptions} options Options which are forwarded to the SchemaField constructor
|
||||
* @param {FilePathFieldOptions} srcOptions Additional options for the src field
|
||||
*/
|
||||
constructor(options={}, {categories=["IMAGE", "VIDEO"], initial={}, wildcard=false, label=""}={}) {
|
||||
/** @deprecated since v12 */
|
||||
if ( typeof initial === "string" ) {
|
||||
const msg = "Passing the initial value of the src field as a string is deprecated. Pass {src} instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14});
|
||||
initial = {src: initial};
|
||||
}
|
||||
super({
|
||||
src: new fields.FilePathField({categories, initial: initial.src ?? null, label, wildcard}),
|
||||
anchorX: new fields.NumberField({nullable: false, initial: initial.anchorX ?? 0}),
|
||||
anchorY: new fields.NumberField({nullable: false, initial: initial.anchorY ?? 0}),
|
||||
offsetX: new fields.NumberField({nullable: false, integer: true, initial: initial.offsetX ?? 0}),
|
||||
offsetY: new fields.NumberField({nullable: false, integer: true, initial: initial.offsetY ?? 0}),
|
||||
fit: new fields.StringField({initial: initial.fit ?? "fill", choices: CONST.TEXTURE_DATA_FIT_MODES}),
|
||||
scaleX: new fields.NumberField({nullable: false, initial: initial.scaleX ?? 1}),
|
||||
scaleY: new fields.NumberField({nullable: false, initial: initial.scaleY ?? 1}),
|
||||
rotation: new fields.AngleField({initial: initial.rotation ?? 0}),
|
||||
tint: new fields.ColorField({nullable: false, initial: initial.tint ?? "#ffffff"}),
|
||||
alphaThreshold: new fields.AlphaField({nullable: false, initial: initial.alphaThreshold ?? 0})
|
||||
}, options);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Extend the base TokenData to define a PrototypeToken which exists within a parent Actor.
|
||||
* @extends abstract.DataModel
|
||||
* @memberof data
|
||||
* @property {boolean} randomImg Does the prototype token use a random wildcard image?
|
||||
* @alias {PrototypeToken}
|
||||
*/
|
||||
class PrototypeToken extends DataModel {
|
||||
constructor(data={}, options={}) {
|
||||
super(data, options);
|
||||
Object.defineProperty(this, "apps", {value: {}});
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static defineSchema() {
|
||||
const schema = documents.BaseToken.defineSchema();
|
||||
const excluded = ["_id", "actorId", "delta", "x", "y", "elevation", "sort", "hidden", "locked", "_regions"];
|
||||
for ( let x of excluded ) {
|
||||
delete schema[x];
|
||||
}
|
||||
schema.name.textSearch = schema.name.options.textSearch = false;
|
||||
schema.randomImg = new fields.BooleanField();
|
||||
PrototypeToken.#applyDefaultTokenSettings(schema);
|
||||
return schema;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["TOKEN"];
|
||||
|
||||
/**
|
||||
* The Actor which owns this Prototype Token
|
||||
* @type {documents.BaseActor}
|
||||
*/
|
||||
get actor() {
|
||||
return this.parent;
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toObject(source=true) {
|
||||
const data = super.toObject(source);
|
||||
data["actorId"] = this.document?.id;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see ClientDocument.database
|
||||
* @ignore
|
||||
*/
|
||||
static get database() {
|
||||
return globalThis.CONFIG.DatabaseBackend;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply configured default token settings to the schema.
|
||||
* @param {DataSchema} [schema] The schema to apply the settings to.
|
||||
*/
|
||||
static #applyDefaultTokenSettings(schema) {
|
||||
if ( typeof DefaultTokenConfig === "undefined" ) return;
|
||||
const settings = foundry.utils.flattenObject(game.settings.get("core", DefaultTokenConfig.SETTING) ?? {});
|
||||
for ( const [k, v] of Object.entries(settings) ) {
|
||||
const path = k.split(".");
|
||||
let field = schema[path.shift()];
|
||||
if ( path.length ) field = field._getField(path);
|
||||
if ( field ) field.initial = v;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Document Compatibility Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @see abstract.Document#update
|
||||
* @ignore
|
||||
*/
|
||||
update(data, options) {
|
||||
return this.actor.update({prototypeToken: data}, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @see abstract.Document#getFlag
|
||||
* @ignore
|
||||
*/
|
||||
getFlag(...args) {
|
||||
return foundry.abstract.Document.prototype.getFlag.call(this, ...args);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @see abstract.Document#getFlag
|
||||
* @ignore
|
||||
*/
|
||||
setFlag(...args) {
|
||||
return foundry.abstract.Document.prototype.setFlag.call(this, ...args);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @see abstract.Document#unsetFlag
|
||||
* @ignore
|
||||
*/
|
||||
async unsetFlag(...args) {
|
||||
return foundry.abstract.Document.prototype.unsetFlag.call(this, ...args);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @see abstract.Document#testUserPermission
|
||||
* @ignore
|
||||
*/
|
||||
testUserPermission(user, permission, {exact=false}={}) {
|
||||
return this.actor.testUserPermission(user, permission, {exact});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @see documents.BaseActor#isOwner
|
||||
* @ignore
|
||||
*/
|
||||
get isOwner() {
|
||||
return this.actor.isOwner;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A minimal data model used to represent a tombstone entry inside an EmbeddedCollectionDelta.
|
||||
* @see {EmbeddedCollectionDelta}
|
||||
* @extends DataModel
|
||||
* @memberof data
|
||||
*
|
||||
* @property {string} _id The _id of the base Document that this tombstone represents.
|
||||
* @property {boolean} _tombstone A property that identifies this entry as a tombstone.
|
||||
*/
|
||||
class TombstoneData extends DataModel {
|
||||
/** @override */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
_tombstone: new fields.BooleanField({initial: true, validate: v => v === true, validationError: "must be true"})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Exports need to be at the bottom so that class names appear correctly in JSDoc
|
||||
export {
|
||||
LightData,
|
||||
PrototypeToken,
|
||||
ShapeData,
|
||||
BaseShapeData,
|
||||
RectangleShapeData,
|
||||
CircleShapeData,
|
||||
EllipseShapeData,
|
||||
PolygonShapeData,
|
||||
TextureData,
|
||||
TombstoneData
|
||||
}
|
||||
2977
resources/app/common/data/fields.mjs
Normal file
2977
resources/app/common/data/fields.mjs
Normal file
File diff suppressed because it is too large
Load Diff
4
resources/app/common/data/module.mjs
Normal file
4
resources/app/common/data/module.mjs
Normal file
@@ -0,0 +1,4 @@
|
||||
export * as validators from "./validators.mjs";
|
||||
export * as validation from "./validation-failure.mjs";
|
||||
export * as fields from "./fields.mjs";
|
||||
export * from "./data.mjs";
|
||||
298
resources/app/common/data/validation-failure.mjs
Normal file
298
resources/app/common/data/validation-failure.mjs
Normal file
@@ -0,0 +1,298 @@
|
||||
import {isEmpty} from "../utils/helpers.mjs";
|
||||
|
||||
/**
|
||||
* A class responsible for recording information about a validation failure.
|
||||
*/
|
||||
export class DataModelValidationFailure {
|
||||
/**
|
||||
* @param {any} [invalidValue] The value that failed validation for this field.
|
||||
* @param {any} [fallback] The value it was replaced by, if any.
|
||||
* @param {boolean} [dropped=false] Whether the value was dropped from some parent collection.
|
||||
* @param {string} [message] The validation error message.
|
||||
* @param {boolean} [unresolved=false] Whether this failure was unresolved
|
||||
*/
|
||||
constructor({invalidValue, fallback, dropped=false, message, unresolved=false}={}) {
|
||||
this.invalidValue = invalidValue;
|
||||
this.fallback = fallback;
|
||||
this.dropped = dropped;
|
||||
this.message = message;
|
||||
this.unresolved = unresolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value that failed validation for this field.
|
||||
* @type {any}
|
||||
*/
|
||||
invalidValue;
|
||||
|
||||
/**
|
||||
* The value it was replaced by, if any.
|
||||
* @type {any}
|
||||
*/
|
||||
fallback;
|
||||
|
||||
/**
|
||||
* Whether the value was dropped from some parent collection.
|
||||
* @type {boolean}
|
||||
*/
|
||||
dropped;
|
||||
|
||||
/**
|
||||
* The validation error message.
|
||||
* @type {string}
|
||||
*/
|
||||
message;
|
||||
|
||||
/**
|
||||
* If this field contains other fields that are validated as part of its validation, their results are recorded here.
|
||||
* @type {Record<string, DataModelValidationFailure>}
|
||||
*/
|
||||
fields = {};
|
||||
|
||||
/**
|
||||
* @typedef {object} ElementValidationFailure
|
||||
* @property {string|number} id Either the element's index or some other identifier for it.
|
||||
* @property {string} [name] Optionally a user-friendly name for the element.
|
||||
* @property {DataModelValidationFailure} failure The element's validation failure.
|
||||
*/
|
||||
|
||||
/**
|
||||
* If this field contains a list of elements that are validated as part of its validation, their results are recorded
|
||||
* here.
|
||||
* @type {ElementValidationFailure[]}
|
||||
*/
|
||||
elements = [];
|
||||
|
||||
/**
|
||||
* Record whether a validation failure is unresolved.
|
||||
* This reports as true if validation for this field or any hierarchically contained field is unresolved.
|
||||
* A failure is unresolved if the value was invalid and there was no valid fallback value available.
|
||||
* @type {boolean}
|
||||
*/
|
||||
unresolved;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return this validation failure as an Error object.
|
||||
* @returns {DataModelValidationError}
|
||||
*/
|
||||
asError() {
|
||||
return new DataModelValidationError(this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Whether this failure contains other sub-failures.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isEmpty() {
|
||||
return isEmpty(this.fields) && isEmpty(this.elements);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the base properties of this failure, omitting any nested failures.
|
||||
* @returns {{invalidValue: any, fallback: any, dropped: boolean, message: string}}
|
||||
*/
|
||||
toObject() {
|
||||
const {invalidValue, fallback, dropped, message} = this;
|
||||
return {invalidValue, fallback, dropped, message};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Represent the DataModelValidationFailure as a string.
|
||||
* @returns {string}
|
||||
*/
|
||||
toString() {
|
||||
return DataModelValidationFailure.#formatString(this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Format a DataModelValidationFailure instance as a string message.
|
||||
* @param {DataModelValidationFailure} failure The failure instance
|
||||
* @param {number} _d An internal depth tracker
|
||||
* @returns {string} The formatted failure string
|
||||
*/
|
||||
static #formatString(failure, _d=0) {
|
||||
let message = failure.message ?? "";
|
||||
_d++;
|
||||
if ( !isEmpty(failure.fields) ) {
|
||||
message += "\n";
|
||||
const messages = [];
|
||||
for ( const [name, subFailure] of Object.entries(failure.fields) ) {
|
||||
const subMessage = DataModelValidationFailure.#formatString(subFailure, _d);
|
||||
messages.push(`${" ".repeat(2 * _d)}${name}: ${subMessage}`);
|
||||
}
|
||||
message += messages.join("\n");
|
||||
}
|
||||
if ( !isEmpty(failure.elements) ) {
|
||||
message += "\n";
|
||||
const messages = [];
|
||||
for ( const element of failure.elements ) {
|
||||
const subMessage = DataModelValidationFailure.#formatString(element.failure, _d);
|
||||
messages.push(`${" ".repeat(2 * _d)}${element.id}: ${subMessage}`);
|
||||
}
|
||||
message += messages.join("\n");
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A specialised Error to indicate a model validation failure.
|
||||
* @extends {Error}
|
||||
*/
|
||||
export class DataModelValidationError extends Error {
|
||||
/**
|
||||
* @param {DataModelValidationFailure|string} failure The failure that triggered this error or an error message
|
||||
* @param {...any} [params] Additional Error constructor parameters
|
||||
*/
|
||||
constructor(failure, ...params) {
|
||||
super(failure.toString(), ...params);
|
||||
if ( failure instanceof DataModelValidationFailure ) this.#failure = failure;
|
||||
}
|
||||
|
||||
/**
|
||||
* The root validation failure that triggered this error.
|
||||
* @type {DataModelValidationFailure}
|
||||
*/
|
||||
#failure;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve the root failure that caused this error, or a specific sub-failure via a path.
|
||||
* @param {string} [path] The property path to the failure.
|
||||
* @returns {DataModelValidationFailure}
|
||||
*
|
||||
* @example Retrieving a failure.
|
||||
* ```js
|
||||
* const changes = {
|
||||
* "foo.bar": "validValue",
|
||||
* "foo.baz": "invalidValue"
|
||||
* };
|
||||
* try {
|
||||
* doc.validate(expandObject(changes));
|
||||
* } catch ( err ) {
|
||||
* const failure = err.getFailure("foo.baz");
|
||||
* console.log(failure.invalidValue); // "invalidValue"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
getFailure(path) {
|
||||
if ( !this.#failure ) return;
|
||||
if ( !path ) return this.#failure;
|
||||
let failure = this.#failure;
|
||||
for ( const p of path.split(".") ) {
|
||||
if ( !failure ) return;
|
||||
if ( !isEmpty(failure.fields) ) failure = failure.fields[p];
|
||||
else if ( !isEmpty(failure.elements) ) failure = failure.elements.find(e => e.id?.toString() === p);
|
||||
}
|
||||
return failure;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve a flattened object of all the properties that failed validation as part of this error.
|
||||
* @returns {Record<string, DataModelValidationFailure>}
|
||||
*
|
||||
* @example Removing invalid changes from an update delta.
|
||||
* ```js
|
||||
* const changes = {
|
||||
* "foo.bar": "validValue",
|
||||
* "foo.baz": "invalidValue"
|
||||
* };
|
||||
* try {
|
||||
* doc.validate(expandObject(changes));
|
||||
* } catch ( err ) {
|
||||
* const failures = err.getAllFailures();
|
||||
* if ( failures ) {
|
||||
* for ( const prop in failures ) delete changes[prop];
|
||||
* doc.validate(expandObject(changes));
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
getAllFailures() {
|
||||
if ( !this.#failure || this.#failure.isEmpty() ) return;
|
||||
return DataModelValidationError.#aggregateFailures(this.#failure);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Log the validation error as a table.
|
||||
*/
|
||||
logAsTable() {
|
||||
const failures = this.getAllFailures();
|
||||
if ( isEmpty(failures) ) return;
|
||||
console.table(Object.entries(failures).reduce((table, [p, failure]) => {
|
||||
table[p] = failure.toObject();
|
||||
return table;
|
||||
}, {}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Generate a nested tree view of the error as an HTML string.
|
||||
* @returns {string}
|
||||
*/
|
||||
asHTML() {
|
||||
const renderFailureNode = failure => {
|
||||
if ( failure.isEmpty() ) return `<li>${failure.message || ""}</li>`;
|
||||
const nodes = [];
|
||||
for ( const [field, subFailure] of Object.entries(failure.fields) ) {
|
||||
nodes.push(`<li><details><summary>${field}</summary><ul>${renderFailureNode(subFailure)}</ul></details></li>`);
|
||||
}
|
||||
for ( const element of failure.elements ) {
|
||||
const name = element.name || element.id;
|
||||
const html = `
|
||||
<li><details><summary>${name}</summary><ul>${renderFailureNode(element.failure)}</ul></details></li>
|
||||
`;
|
||||
nodes.push(html);
|
||||
}
|
||||
return nodes.join("");
|
||||
};
|
||||
return `<ul class="summary-tree">${renderFailureNode(this.#failure)}</ul>`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Collect nested failures into an aggregate object.
|
||||
* @param {DataModelValidationFailure} failure The failure.
|
||||
* @returns {DataModelValidationFailure|Record<string, DataModelValidationFailure>} Returns the failure at the leaf of the
|
||||
* tree, otherwise an object of
|
||||
* sub-failures.
|
||||
*/
|
||||
static #aggregateFailures(failure) {
|
||||
if ( failure.isEmpty() ) return failure;
|
||||
const failures = {};
|
||||
const recordSubFailures = (field, subFailures) => {
|
||||
if ( subFailures instanceof DataModelValidationFailure ) failures[field] = subFailures;
|
||||
else {
|
||||
for ( const [k, v] of Object.entries(subFailures) ) {
|
||||
failures[`${field}.${k}`] = v;
|
||||
}
|
||||
}
|
||||
};
|
||||
for ( const [field, subFailure] of Object.entries(failure.fields) ) {
|
||||
recordSubFailures(field, DataModelValidationError.#aggregateFailures(subFailure));
|
||||
}
|
||||
for ( const element of failure.elements ) {
|
||||
recordSubFailures(element.id, DataModelValidationError.#aggregateFailures(element.failure));
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
}
|
||||
56
resources/app/common/data/validators.mjs
Normal file
56
resources/app/common/data/validators.mjs
Normal file
@@ -0,0 +1,56 @@
|
||||
/** @module validators */
|
||||
|
||||
/**
|
||||
* Test whether a string is a valid 16 character UID
|
||||
* @param {string} id
|
||||
* @return {boolean}
|
||||
*/
|
||||
export function isValidId(id) {
|
||||
return /^[a-zA-Z0-9]{16}$/.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a file path has an extension in a list of provided extensions
|
||||
* @param {string} path
|
||||
* @param {string[]} extensions
|
||||
* @return {boolean}
|
||||
*/
|
||||
export function hasFileExtension(path, extensions) {
|
||||
const xts = extensions.map(ext => `\\.${ext}`).join("|");
|
||||
const rgx = new RegExp(`(${xts})(\\?.*)?$`, "i");
|
||||
return !!path && rgx.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a string data blob contains base64 data, optionally of a specific type or types
|
||||
* @param {string} data The candidate string data
|
||||
* @param {string[]} [types] An array of allowed mime types to test
|
||||
* @return {boolean}
|
||||
*/
|
||||
export function isBase64Data(data, types) {
|
||||
if ( types === undefined ) return /^data:([a-z]+)\/([a-z0-9]+);base64,/.test(data);
|
||||
return types.some(type => data.startsWith(`data:${type};base64,`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether an input represents a valid 6-character color string
|
||||
* @param {string} color The input string to test
|
||||
* @return {boolean} Is the string a valid color?
|
||||
*/
|
||||
export function isColorString(color) {
|
||||
return /^#[0-9A-Fa-f]{6}$/.test(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the given value parses as a valid JSON string
|
||||
* @param {string} val The value to test
|
||||
* @return {boolean} Is the String valid JSON?
|
||||
*/
|
||||
export function isJSON(val) {
|
||||
try {
|
||||
JSON.parse(val);
|
||||
return true;
|
||||
} catch(err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
33
resources/app/common/documents/_module.mjs
Normal file
33
resources/app/common/documents/_module.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
/** @module foundry.documents */
|
||||
export {default as BaseActiveEffect} from "./active-effect.mjs";
|
||||
export {default as BaseActorDelta} from "./actor-delta.mjs";
|
||||
export {default as BaseActor} from "./actor.mjs";
|
||||
export {default as BaseAdventure} from "./adventure.mjs";
|
||||
export {default as BaseAmbientLight} from "./ambient-light.mjs";
|
||||
export {default as BaseAmbientSound} from "./ambient-sound.mjs";
|
||||
export {default as BaseCard} from "./card.mjs";
|
||||
export {default as BaseCards} from "./cards.mjs";
|
||||
export {default as BaseChatMessage} from "./chat-message.mjs";
|
||||
export {default as BaseCombat} from "./combat.mjs";
|
||||
export {default as BaseCombatant} from "./combatant.mjs";
|
||||
export {default as BaseDrawing} from "./drawing.mjs";
|
||||
export {default as BaseFogExploration} from "./fog-exploration.mjs";
|
||||
export {default as BaseFolder} from "./folder.mjs";
|
||||
export {default as BaseItem} from "./item.mjs";
|
||||
export {default as BaseJournalEntry} from "./journal-entry.mjs";
|
||||
export {default as BaseJournalEntryPage} from "./journal-entry-page.mjs";
|
||||
export {default as BaseMacro} from "./macro.mjs";
|
||||
export {default as BaseMeasuredTemplate} from "./measured-template.mjs";
|
||||
export {default as BaseNote} from "./note.mjs";
|
||||
export {default as BasePlaylist} from "./playlist.mjs";
|
||||
export {default as BasePlaylistSound} from "./playlist-sound.mjs";
|
||||
export {default as BaseRollTable} from "./roll-table.mjs";
|
||||
export {default as BaseScene} from "./scene.mjs";
|
||||
export {default as BaseRegion} from "./region.mjs";
|
||||
export {default as BaseRegionBehavior} from "./region-behavior.mjs";
|
||||
export {default as BaseSetting} from "./setting.mjs";
|
||||
export {default as BaseTableResult} from "./table-result.mjs";
|
||||
export {default as BaseTile} from "./tile.mjs";
|
||||
export {default as BaseToken} from "./token.mjs";
|
||||
export {default as BaseUser} from "./user.mjs";
|
||||
export {default as BaseWall} from "./wall.mjs";
|
||||
727
resources/app/common/documents/_types.mjs
Normal file
727
resources/app/common/documents/_types.mjs
Normal file
@@ -0,0 +1,727 @@
|
||||
/**
|
||||
* @typedef {Object} ActiveEffectData
|
||||
* @property {string} _id The _id that uniquely identifies the ActiveEffect within its parent collection
|
||||
* @property {string} name The name of the which describes the name of the ActiveEffect
|
||||
* @property {string} img An image path used to depict the ActiveEffect as an icon
|
||||
* @property {EffectChangeData[]} changes The array of EffectChangeData objects which the ActiveEffect applies
|
||||
* @property {boolean} [disabled=false] Is this ActiveEffect currently disabled?
|
||||
* @property {EffectDurationData} [duration] An EffectDurationData object which describes the duration of the ActiveEffect
|
||||
* @property {string} [description] The HTML text description for this ActiveEffect document.
|
||||
* @property {string} [origin] A UUID reference to the document from which this ActiveEffect originated
|
||||
* @property {string} [tint=null] A color string which applies a tint to the ActiveEffect icon
|
||||
* @property {boolean} [transfer=false] Does this ActiveEffect automatically transfer from an Item to an Actor?
|
||||
* @property {Set<string>} [statuses] Special status IDs that pertain to this effect
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} EffectDurationData
|
||||
* @property {number} [startTime] The world time when the active effect first started
|
||||
* @property {number} [seconds] The maximum duration of the effect, in seconds
|
||||
* @property {string} [combat] The _id of the CombatEncounter in which the effect first started
|
||||
* @property {number} [rounds] The maximum duration of the effect, in combat rounds
|
||||
* @property {number} [turns] The maximum duration of the effect, in combat turns
|
||||
* @property {number} [startRound] The round of the CombatEncounter in which the effect first started
|
||||
* @property {number} [startTurn] The turn of the CombatEncounter in which the effect first started
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} EffectChangeData
|
||||
* @property {string} key The attribute path in the Actor or Item data which the change modifies
|
||||
* @property {string} value The value of the change effect
|
||||
* @property {number} mode The modification mode with which the change is applied
|
||||
* @property {number} priority The priority level with which this change is applied
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ActorData
|
||||
* @property {string} _id The _id which uniquely identifies this Actor document
|
||||
* @property {string} name The name of this Actor
|
||||
* @property {string} type An Actor subtype which configures the system data model applied
|
||||
* @property {string} [img] An image file path which provides the artwork for this Actor
|
||||
* @property {object} [system] The system data object which is defined by the system template.json model
|
||||
* @property {data.PrototypeToken} [prototypeToken] Default Token settings which are used for Tokens created from
|
||||
* this Actor
|
||||
* @property {Collection<documents.BaseItem>} items A Collection of Item embedded Documents
|
||||
* @property {Collection<documents.BaseActiveEffect>} effects A Collection of ActiveEffect embedded Documents
|
||||
* @property {string|null} folder The _id of a Folder which contains this Actor
|
||||
* @property {number} [sort] The numeric sort value which orders this Actor relative to its siblings
|
||||
* @property {object} [ownership] An object which configures ownership of this Actor
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
* @property {DocumentStats} _stats An object of creation and access information
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ActorDeltaData
|
||||
* @property {string} _id The _id which uniquely identifies this ActorDelta document
|
||||
* @property {string} [name] The name override, if any.
|
||||
* @property {string} [type] The type override, if any.
|
||||
* @property {string} [img] The image override, if any.
|
||||
* @property {object} [system] The system data model override.
|
||||
* @property {Collection<BaseItem>} [items] An array of embedded item data overrides.
|
||||
* @property {Collection<BaseActiveEffect>} [effects] An array of embedded active effect data overrides.
|
||||
* @property {object} [ownership] Ownership overrides.
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AdventureData
|
||||
* @property {string} _id The _id which uniquely identifies this Adventure document
|
||||
* @property {string} name The human-readable name of the Adventure
|
||||
* @property {string} img The file path for the primary image of the adventure
|
||||
* @property {string} caption A string caption displayed under the primary image banner
|
||||
* @property {string} description An HTML text description for the adventure
|
||||
* @property {foundry.documents.BaseActor[]} actors An array of included Actor documents
|
||||
* @property {foundry.documents.BaseCombat[]} combats An array of included Combat documents
|
||||
* @property {foundry.documents.BaseItem[]} items An array of included Item documents
|
||||
* @property {foundry.documents.BaseScene[]} scenes An array of included Scene documents
|
||||
* @property {foundry.documents.BaseJournalEntry[]} journal An array of included JournalEntry documents
|
||||
* @property {foundry.documents.BaseRollTable[]} tables An array of included RollTable documents
|
||||
* @property {foundry.documents.BaseMacro[]} macros An array of included Macro documents
|
||||
* @property {foundry.documents.BaseCards[]} cards An array of included Cards documents
|
||||
* @property {foundry.documents.BasePlaylist[]} playlists An array of included Playlist documents
|
||||
* @property {foundry.documents.BaseFolder[]} folders An array of included Folder documents
|
||||
* @property {number} sort The sort order of this adventure relative to its siblings
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
* @property {DocumentStats} _stats An object of creation and access information
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AmbientLightData
|
||||
* @property {string} _id The _id which uniquely identifies this AmbientLight document
|
||||
* @property {number} x The x-coordinate position of the origin of the light
|
||||
* @property {number} y The y-coordinate position of the origin of the light
|
||||
* @property {number} [rotation=0] The angle of rotation for the tile between 0 and 360
|
||||
* @property {boolean} [walls=true] Whether or not this light source is constrained by Walls
|
||||
* @property {boolean} [vision=false] Whether or not this light source provides a source of vision
|
||||
* @property {LightData} config Light configuration data
|
||||
* @property {boolean} [hidden=false] Is the light source currently hidden?
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AmbientSoundData
|
||||
* @property {string} _id The _id which uniquely identifies this AmbientSound document
|
||||
* @property {number} x The x-coordinate position of the origin of the sound.
|
||||
* @property {number} y The y-coordinate position of the origin of the sound.
|
||||
* @property {number} radius The radius of the emitted sound.
|
||||
* @property {string} path The audio file path that is played by this sound
|
||||
* @property {boolean} [repeat=false] Does this sound loop?
|
||||
* @property {number} [volume=0.5] The audio volume of the sound, from 0 to 1
|
||||
* @property {boolean} walls Whether or not this sound source is constrained by Walls. True by default.
|
||||
* @property {boolean} easing Whether to adjust the volume of the sound heard by the listener based on how
|
||||
* close the listener is to the center of the sound source. True by default.
|
||||
* @property {boolean} hidden Is the sound source currently hidden? False by default.
|
||||
* @property {{min: number, max: number}} darkness A darkness range (min and max) for which the source should be active
|
||||
* @property {{base: AmbientSoundEffect, muffled: AmbientSoundEffect}} effects Special effects to apply to the sound
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AmbientSoundEffect
|
||||
* @param {string} type The type of effect in CONFIG.soundEffects
|
||||
* @param {number} intensity The intensity of the effect on the scale of [1, 10]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CardData
|
||||
* @property {string} _id The _id which uniquely identifies this Card document
|
||||
* @property {string} name The text name of this card
|
||||
* @property {string} [description] A text description of this card which applies to all faces
|
||||
* @property {string} type A category of card (for example, a suit) to which this card belongs
|
||||
* @property {object} [system] Game system data which is defined by the system template.json model
|
||||
* @property {string} [suit] An optional suit designation which is used by default sorting
|
||||
* @property {number} [value] An optional numeric value of the card which is used by default sorting
|
||||
* @property {CardFaceData} back An object of face data which describes the back of this card
|
||||
* @property {CardFaceData[]} faces An array of face data which represent displayable faces of this card
|
||||
* @property {number|null} face The index of the currently displayed face, or null if the card is face-down
|
||||
* @property {boolean} drawn Whether this card is currently drawn from its source deck
|
||||
* @property {string} origin The document ID of the origin deck to which this card belongs
|
||||
* @property {number} width The visible width of this card
|
||||
* @property {number} height The visible height of this card
|
||||
* @property {number} rotation The angle of rotation of this card
|
||||
* @property {number} sort The sort order of this card relative to others in the same stack
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CardFaceData
|
||||
* @property {string} [name] A name for this card face
|
||||
* @property {string} [text] Displayed text that belongs to this face
|
||||
* @property {string} [img] A displayed image or video file which depicts the face
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CardsData
|
||||
* @property {string} _id The _id which uniquely identifies this stack of Cards document
|
||||
* @property {string} name The text name of this stack
|
||||
* @property {string} type The type of this stack, in BaseCards.metadata.types
|
||||
* @property {object} [system] Game system data which is defined by the system template.json model
|
||||
* @property {string} [description] A text description of this stack
|
||||
* @property {string} [img] An image or video which is used to represent the stack of cards
|
||||
* @property {Collection<BaseCard>} cards A collection of Card documents which currently belong to this stack
|
||||
* @property {number} width The visible width of this stack
|
||||
* @property {number} height The visible height of this stack
|
||||
* @property {number} rotation The angle of rotation of this stack
|
||||
* @property {boolean} [displayCount] Whether or not to publicly display the number of cards in this stack
|
||||
* @property {string|null} folder The _id of a Folder which contains this document
|
||||
* @property {number} sort The sort order of this stack relative to others in its parent collection
|
||||
* @property {object} [ownership] An object which configures ownership of this Cards
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
* @property {DocumentStats} _stats An object of creation and access information
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ChatMessageData
|
||||
* @property {string} _id The _id which uniquely identifies this ChatMessage document
|
||||
* @property {number} [type=0] The message type from CONST.CHAT_MESSAGE_TYPES
|
||||
* @property {string} user The _id of the User document who generated this message
|
||||
* @property {number} timestamp The timestamp at which point this message was generated
|
||||
* @property {string} [flavor] An optional flavor text message which summarizes this message
|
||||
* @property {string} content The HTML content of this chat message
|
||||
* @property {ChatSpeakerData} speaker A ChatSpeakerData object which describes the origin of the ChatMessage
|
||||
* @property {string[]} whisper An array of User _id values to whom this message is privately whispered
|
||||
* @property {boolean} [blind=false] Is this message sent blindly where the creating User cannot see it?
|
||||
* @property {string[]} [rolls] Serialized content of any Roll instances attached to the ChatMessage
|
||||
* @property {string} [sound] The URL of an audio file which plays when this message is received
|
||||
* @property {boolean} [emote=false] Is this message styled as an emote?
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
* @property {DocumentStats} _stats An object of creation and access information
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ChatSpeakerData
|
||||
* @property {string} [scene] The _id of the Scene where this message was created
|
||||
* @property {string} [actor] The _id of the Actor who generated this message
|
||||
* @property {string} [token] The _id of the Token who generated this message
|
||||
* @property {string} [alias] An overridden alias name used instead of the Actor or Token name
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CombatData
|
||||
* @property {string} _id The _id which uniquely identifies this Combat document
|
||||
* @property {string} scene The _id of a Scene within which this Combat occurs
|
||||
* @property {Collection<BaseCombatant>} combatants A Collection of Combatant embedded Documents
|
||||
* @property {boolean} [active=false] Is the Combat encounter currently active?
|
||||
* @property {number} [round=0] The current round of the Combat encounter
|
||||
* @property {number|null} [turn=0] The current turn in the Combat round
|
||||
* @property {number} [sort=0] The current sort order of this Combat relative to others in the same Scene
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
* @property {DocumentStats} _stats An object of creation and access information
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CombatantData
|
||||
* @property {string} _id The _id which uniquely identifies this Combatant embedded document
|
||||
* @property {string} [actorId] The _id of an Actor associated with this Combatant
|
||||
* @property {string} [tokenId] The _id of a Token associated with this Combatant
|
||||
* @property {string} [name] A customized name which replaces the name of the Token in the tracker
|
||||
* @property {string} [img] A customized image which replaces the Token image in the tracker
|
||||
* @property {number} [initiative] The initiative score for the Combatant which determines its turn order
|
||||
* @property {boolean} [hidden=false] Is this Combatant currently hidden?
|
||||
* @property {boolean} [defeated=false] Has this Combatant been defeated?
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DrawingData
|
||||
* @property {string} _id The _id which uniquely identifies this BaseDrawing embedded document
|
||||
* @property {string} author The _id of the user who created the drawing
|
||||
* @property {data.ShapeData} shape The geometric shape of the drawing
|
||||
* @property {number} x The x-coordinate position of the top-left corner of the drawn shape
|
||||
* @property {number} y The y-coordinate position of the top-left corner of the drawn shape
|
||||
* @property {number} [elevation=0] The elevation of the drawing
|
||||
* @property {number} [sort=0] The z-index of this drawing relative to other siblings
|
||||
* @property {number} [rotation=0] The angle of rotation for the drawing figure
|
||||
* @property {number} [bezierFactor=0] An amount of bezier smoothing applied, between 0 and 1
|
||||
* @property {number} [fillType=0] The fill type of the drawing shape, a value from CONST.DRAWING_FILL_TYPES
|
||||
* @property {string} [fillColor] An optional color string with which to fill the drawing geometry
|
||||
* @property {number} [fillAlpha=0.5] The opacity of the fill applied to the drawing geometry
|
||||
* @property {number} [strokeWidth=8] The width in pixels of the boundary lines of the drawing geometry
|
||||
* @property {number} [strokeColor] The color of the boundary lines of the drawing geometry
|
||||
* @property {number} [strokeAlpha=1] The opacity of the boundary lines of the drawing geometry
|
||||
* @property {string} [texture] The path to a tiling image texture used to fill the drawing geometry
|
||||
* @property {string} [text] Optional text which is displayed overtop of the drawing
|
||||
* @property {string} [fontFamily] The font family used to display text within this drawing, defaults to
|
||||
* CONFIG.defaultFontFamily
|
||||
* @property {number} [fontSize=48] The font size used to display text within this drawing
|
||||
* @property {string} [textColor=#FFFFFF] The color of text displayed within this drawing
|
||||
* @property {number} [textAlpha=1] The opacity of text displayed within this drawing
|
||||
* @property {boolean} [hidden=false] Is the drawing currently hidden?
|
||||
* @property {boolean} [locked=false] Is the drawing currently locked?
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} FogExplorationData
|
||||
* @property {string} _id The _id which uniquely identifies this FogExploration document
|
||||
* @property {string} scene The _id of the Scene document to which this fog applies
|
||||
* @property {string} user The _id of the User document to which this fog applies
|
||||
* @property {string} explored The base64 image/jpeg of the explored fog polygon
|
||||
* @property {object} positions The object of scene positions which have been explored at a certain vision radius
|
||||
* @property {number} timestamp The timestamp at which this fog exploration was last updated
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* @typedef {Object} FolderData
|
||||
* @property {string} _id The _id which uniquely identifies this Folder document
|
||||
* @property {string} name The name of this Folder
|
||||
* @property {string} type The document type which this Folder contains, from CONST.FOLDER_DOCUMENT_TYPES
|
||||
* @property {string} description An HTML description of the contents of this folder
|
||||
* @property {string|null} [folder] The _id of a parent Folder which contains this Folder
|
||||
* @property {string} [sorting=a] The sorting mode used to organize documents within this Folder, in ["a", "m"]
|
||||
* @property {number} [sort] The numeric sort value which orders this Folder relative to its siblings
|
||||
* @property {string|null} [color] A color string used for the background color of this Folder
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
* @property {DocumentStats} _stats An object of creation and access information
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ItemData
|
||||
* @property {string} _id The _id which uniquely identifies this Item document
|
||||
* @property {string} name The name of this Item
|
||||
* @property {string} type An Item subtype which configures the system data model applied
|
||||
* @property {string} [img] An image file path which provides the artwork for this Item
|
||||
* @property {object} [system] The system data object which is defined by the system template.json model
|
||||
* @property {Collection<BaseActiveEffect>} effects A collection of ActiveEffect embedded Documents
|
||||
* @property {string|null} folder The _id of a Folder which contains this Item
|
||||
* @property {number} [sort] The numeric sort value which orders this Item relative to its siblings
|
||||
* @property {object} [ownership] An object which configures ownership of this Item
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
* @property {DocumentStats} _stats An object of creation and access information
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} JournalEntryData
|
||||
* @property {string} _id The _id which uniquely identifies this JournalEntry document
|
||||
* @property {string} name The name of this JournalEntry
|
||||
* @property {JournalEntryPageData[]} pages The pages contained within this JournalEntry document
|
||||
* @property {string|null} folder The _id of a Folder which contains this JournalEntry
|
||||
* @property {number} [sort] The numeric sort value which orders this JournalEntry relative to its siblings
|
||||
* @property {object} [ownership] An object which configures ownership of this JournalEntry
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
* @property {DocumentStats} _stats An object of creation and access information
|
||||
*/
|
||||
/**
|
||||
* @typedef {object} JournalEntryPageImageData
|
||||
* @property {string} [caption] A caption for the image.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} JournalEntryPageTextData
|
||||
* @property {string} [content] The content of the JournalEntryPage in a format appropriate for its type.
|
||||
* @property {string} [markdown] The original markdown source, if applicable.
|
||||
* @property {number} format The format of the page's content, in CONST.JOURNAL_ENTRY_PAGE_FORMATS.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} JournalEntryPageVideoData
|
||||
* @property {boolean} [loop] Automatically loop the video?
|
||||
* @property {boolean} [autoplay] Should the video play automatically?
|
||||
* @property {number} [volume] The volume level of any audio that the video file contains.
|
||||
* @property {number} [timestamp] The starting point of the video, in seconds.
|
||||
* @property {number} [width] The width of the video, otherwise it will fill the available container width.
|
||||
* @property {number} [height] The height of the video, otherwise it will use the aspect ratio of the source
|
||||
* video, or 16:9 if that aspect ratio is not available.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} JournalEntryPageTitleData
|
||||
* @property {boolean} show Whether to render the page's title in the overall journal view.
|
||||
* @property {number} level The heading level to render this page's title at in the overall journal view.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} JournalEntryPageData
|
||||
* @property {string} _id The _id which uniquely identifies this JournalEntryPage embedded document.
|
||||
* @property {string} name The text name of this page.
|
||||
* @property {string} type The type of this page.
|
||||
* @property {JournalEntryPageTitleData} title Data that control's the display of this page's title.
|
||||
* @property {JournalEntryPageImageData} image Data particular to image journal entry pages.
|
||||
* @property {JournalEntryPageTextData} text Data particular to text journal entry pages.
|
||||
* @property {JournalEntryPageVideoData} video Data particular to video journal entry pages.
|
||||
* @property {string} [src] The URI of the image or other external media to be used for this page.
|
||||
* @property {object} system System-specific data.
|
||||
* @property {number} sort The numeric sort value which orders this page relative to its siblings.
|
||||
* @property {object} [ownership] An object which configures the ownership of this page.
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
* @property {DocumentStats} _stats An object of creation and access information
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} MacroData
|
||||
* @property {string} _id The _id which uniquely identifies this Macro document
|
||||
* @property {string} name The name of this Macro
|
||||
* @property {string} type A Macro subtype from CONST.MACRO_TYPES
|
||||
* @property {string} author The _id of a User document which created this Macro *
|
||||
* @property {string} [img] An image file path which provides the thumbnail artwork for this Macro
|
||||
* @property {string} [scope=global] The scope of this Macro application from CONST.MACRO_SCOPES
|
||||
* @property {string} command The string content of the macro command
|
||||
* @property {string|null} folder The _id of a Folder which contains this Macro
|
||||
* @property {number} [sort] The numeric sort value which orders this Macro relative to its siblings
|
||||
* @property {object} [ownership] An object which configures ownership of this Macro
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
* @property {DocumentStats} _stats An object of creation and access information
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} MeasuredTemplateData
|
||||
* @property {string} _id The _id which uniquely identifies this BaseMeasuredTemplate embedded document
|
||||
* @property {string} user The _id of the user who created this measured template
|
||||
* @property {string} [t=circle] The value in CONST.MEASURED_TEMPLATE_TYPES which defines the geometry type of this template
|
||||
* @property {number} [x=0] The x-coordinate position of the origin of the template effect
|
||||
* @property {number} [y=0] The y-coordinate position of the origin of the template effect
|
||||
* @property {number} [distance] The distance of the template effect
|
||||
* @property {number} [direction=0] The angle of rotation for the measured template
|
||||
* @property {number} [angle=360] The angle of effect of the measured template, applies to cone types
|
||||
* @property {number} [width] The width of the measured template, applies to ray types
|
||||
* @property {string} [borderColor=#000000] A color string used to tint the border of the template shape
|
||||
* @property {string} [fillColor=#FF0000] A color string used to tint the fill of the template shape
|
||||
* @property {string} [texture] A repeatable tiling texture used to add a texture fill to the template shape
|
||||
* @property {boolean} [hidden=false] Is the template currently hidden?
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} NoteData
|
||||
* @property {string} _id The _id which uniquely identifies this BaseNote embedded document
|
||||
* @property {string|null} [entryId=null] The _id of a JournalEntry document which this Note represents
|
||||
* @property {string|null} [pageId=null] The _id of a specific JournalEntryPage document which this Note represents
|
||||
* @property {number} [x=0] The x-coordinate position of the center of the note icon
|
||||
* @property {number} [y=0] The y-coordinate position of the center of the note icon
|
||||
* @property {TextureData} [texture] An image icon used to represent this note
|
||||
* @property {number} [iconSize=40] The pixel size of the map note icon
|
||||
* @property {string} [text] Optional text which overrides the title of the linked Journal Entry
|
||||
* @property {string} [fontFamily] The font family used to display the text label on this note, defaults to
|
||||
* CONFIG.defaultFontFamily
|
||||
* @property {number} [fontSize=36] The font size used to display the text label on this note
|
||||
* @property {number} [textAnchor=1] A value in CONST.TEXT_ANCHOR_POINTS which defines where the text label anchors
|
||||
* to the note icon.
|
||||
* @property {string} [textColor=#FFFFFF] The string that defines the color with which the note text is rendered
|
||||
* @property {boolean} [global=false] Whether this map pin is globally visible or requires LoS to see.
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PlaylistData
|
||||
* @property {string} _id The _id which uniquely identifies this Playlist document
|
||||
* @property {string} name The name of this playlist
|
||||
* @property {string} description The description of this playlist
|
||||
* @property {Collection<BasePlaylistSound>} sounds A Collection of PlaylistSounds embedded documents which belong to this playlist
|
||||
* @property {number} [mode=0] The playback mode for sounds in this playlist
|
||||
* @property {string} channel A channel in CONST.AUDIO_CHANNELS where all sounds in this playlist are played
|
||||
* @property {boolean} [playing=false] Is this playlist currently playing?
|
||||
* @property {number} [fade] A duration in milliseconds to fade volume transition
|
||||
* @property {string|null} folder The _id of a Folder which contains this playlist
|
||||
* @property {string} sorting The sorting mode used for this playlist.
|
||||
* @property {number} [sort] The numeric sort value which orders this playlist relative to its siblings
|
||||
* @property {number} [seed] A seed used for playlist randomization to guarantee that all clients generate the same random order.
|
||||
* @property {object} [ownership] An object which configures ownership of this Playlist
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
* @property {DocumentStats} _stats An object of creation and access information
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PlaylistSoundData
|
||||
* @property {string} _id The _id which uniquely identifies this PlaylistSound document
|
||||
* @property {string} name The name of this sound
|
||||
* @property {string} description The description of this sound
|
||||
* @property {string} path The audio file path that is played by this sound
|
||||
* @property {string} channel A channel in CONST.AUDIO_CHANNELS where this sound is played
|
||||
* @property {boolean} [playing=false] Is this sound currently playing?
|
||||
* @property {number} [pausedTime=null] The time in seconds at which playback was paused
|
||||
* @property {boolean} [repeat=false] Does this sound loop?
|
||||
* @property {number} [volume=0.5] The audio volume of the sound, from 0 to 1
|
||||
* @property {number} [fade] A duration in milliseconds to fade volume transition
|
||||
* @property {number} [sort=0] The sort order of the PlaylistSound relative to others in the same collection
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RollTableData
|
||||
* @property {string} _id The _id which uniquely identifies this RollTable document
|
||||
* @property {string} name The name of this RollTable
|
||||
* @property {string} [img] An image file path which provides the thumbnail artwork for this RollTable
|
||||
* @property {string} [description] The HTML text description for this RollTable document
|
||||
* @property {Collection<BaseTableResult>} [results=[]] A Collection of TableResult embedded documents which belong to this RollTable
|
||||
* @property {string} formula The Roll formula which determines the results chosen from the table
|
||||
* @property {boolean} [replacement=true] Are results from this table drawn with replacement?
|
||||
* @property {boolean} [displayRoll=true] Is the Roll result used to draw from this RollTable displayed in chat?
|
||||
* @property {string|null} folder The _id of a Folder which contains this RollTable
|
||||
* @property {number} [sort] The numeric sort value which orders this RollTable relative to its siblings
|
||||
* @property {object} [ownership] An object which configures ownership of this RollTable
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
* @property {DocumentStats} _stats An object of creation and access information
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SceneData
|
||||
* @property {string} _id The _id which uniquely identifies this Scene document
|
||||
* @property {string} name The name of this scene
|
||||
* @property {boolean} [active=false] Is this scene currently active? Only one scene may be active at a given time
|
||||
* @property {boolean} [navigation=false] Is this scene displayed in the top navigation bar?
|
||||
* @property {number} [navOrder] The sorting order of this Scene in the navigation bar relative to siblings
|
||||
* @property {string} [navName] A string which overrides Scene name for display in the navigation bar
|
||||
* @property {TextureData|null} [background] An image or video file that provides the background texture for the scene.
|
||||
* @property {string|null} [foreground] An image or video file path providing foreground media for the scene
|
||||
* @property {number} [foregroundElevation=20] The elevation of the foreground image
|
||||
*
|
||||
* @property {string|null} [thumb] A thumbnail image which depicts the scene at lower resolution
|
||||
* @property {number} [width=4000] The width of the scene canvas, normally the width of the background media
|
||||
* @property {number} [height=3000] The height of the scene canvas, normally the height of the background media
|
||||
* @property {number} [padding=0.25] The proportion of canvas padding applied around the outside of the scene
|
||||
* dimensions to provide additional buffer space
|
||||
* @property {{x: number, y: number, scale: number}|null} [initial=null] The initial view coordinates for the scene
|
||||
* @property {string|null} [backgroundColor=#999999] The color of the canvas displayed behind the scene background
|
||||
* @property {GridData} [grid] Grid configuration for the scene
|
||||
* @property {boolean} [tokenVision=true] Do Tokens require vision in order to see the Scene environment?
|
||||
* @property {number} [darkness=0] The ambient darkness level in this Scene, where 0 represents midday
|
||||
* (maximum illumination) and 1 represents midnight (maximum darkness)
|
||||
*
|
||||
* @property {boolean} [fogExploration=true] Should fog exploration progress be tracked for this Scene?
|
||||
* @property {number} [fogReset] The timestamp at which fog of war was last reset for this Scene.
|
||||
* @property {string|null} [fogOverlay] A special overlay image or video texture which is used for fog of war
|
||||
* @property {string|null} [fogExploredColor] A color tint applied to explored regions of fog of war
|
||||
* @property {string|null} [fogUnexploredColor] A color tint applied to unexplored regions of fog of war
|
||||
*
|
||||
* @property {SceneEnvironmentData} [environment] The environment data applied to the Scene.
|
||||
* @property {boolean} [environment.cycle] If cycling is activated for the Scene, between base and darkness environment data.
|
||||
* @property {EnvironmentData} [environment.base] The base ambience values pertaining to the Scene.
|
||||
* @property {EnvironmentData} [environment.darkness] The darkness ambience values pertaining to the Scene.
|
||||
*
|
||||
* @property {Collection<BaseDrawing>} [drawings=[]] A collection of embedded Drawing objects.
|
||||
* @property {Collection<BaseTile>} [tiles=[]] A collection of embedded Tile objects.
|
||||
* @property {Collection<BaseToken>} [tokens=[]] A collection of embedded Token objects.
|
||||
* @property {Collection<BaseAmbientLight>} [lights=[]] A collection of embedded AmbientLight objects.
|
||||
* @property {Collection<BaseNote>} [notes=[]] A collection of embedded Note objects.
|
||||
* @property {Collection<BaseAmbientSound>} [sounds=[]] A collection of embedded AmbientSound objects.
|
||||
* @property {Collection<BaseMeasuredTemplate>} [templates=[]] A collection of embedded MeasuredTemplate objects.
|
||||
* @property {Collection<BaseWall>} [walls=[]] A collection of embedded Wall objects
|
||||
* @property {BasePlaylist} [playlist] A linked Playlist document which should begin automatically playing when this
|
||||
* Scene becomes active.
|
||||
* @property {BasePlaylistSound} [playlistSound] A linked PlaylistSound document from the selected playlist that will
|
||||
* begin automatically playing when this Scene becomes active
|
||||
* @property {JournalEntry} [journal] A JournalEntry document which provides narrative details about this Scene
|
||||
* @property {string} [weather] A named weather effect which should be rendered in this Scene.
|
||||
|
||||
* @property {string|null} folder The _id of a Folder which contains this Actor
|
||||
* @property {number} [sort] The numeric sort value which orders this Actor relative to its siblings
|
||||
* @property {object} [ownership] An object which configures ownership of this Scene
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
* @property {DocumentStats} _stats An object of creation and access information
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} GridData
|
||||
* @property {number} [type=1] The type of grid, a number from CONST.GRID_TYPES.
|
||||
* @property {number} [size=100] The grid size which represents the width (or height) of a single grid space.
|
||||
* @property {string} [color=#000000] A string representing the color used to render the grid lines.
|
||||
* @property {number} [alpha=0.2] A number between 0 and 1 for the opacity of the grid lines.
|
||||
* @property {number} [distance] The number of distance units which are represented by a single grid space.
|
||||
* @property {string} [units] A label for the units of measure which are used for grid distance.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {EnvironmentData} EnvironmentData
|
||||
* @property {number} [hue] The normalized hue angle.
|
||||
* @property {number} [intensity] The intensity of the tinting (0 = no tinting).
|
||||
* @property {number} [luminosity] The luminosity.
|
||||
* @property {number} [saturation] The saturation.
|
||||
* @property {number} [shadows] The strength of the shadows.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} _GlobalLightData
|
||||
* @property {number} [enabled] Is the global light enabled?
|
||||
* @property {boolean} [bright] Is the global light in bright mode?
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Pick<LightData, "alpha" | "color" | "coloration" | "contrast" | "luminosity" | "saturation" | "shadows" | "darkness"> & _GlobalLightData} GlobalLightData
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {SceneEnvironmentData} SceneEnvironmentData
|
||||
* @property {number} [darknessLevel] The environment darkness level.
|
||||
* @property {boolean} [darknessLevelLock] The darkness level lock state.
|
||||
* @property {GlobalLightData} [globalLight] The global light data configuration.
|
||||
* @property {boolean} [cycle] If cycling between Night and Day is activated.
|
||||
* @property {EnvironmentData} [base] The base (darkness level 0) ambience lighting data.
|
||||
* @property {EnvironmentData} [dark] The dark (darkness level 1) ambience lighting data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} RegionData
|
||||
* @property {string} _id The Region _id which uniquely identifies it within its parent Scene
|
||||
* @property {string} name The name used to describe the Region
|
||||
* @property {string} [color="#ffffff"] The color used to highlight the Region
|
||||
* @property {data.BaseShapeData[]} [shapes=[]] The shapes that make up the Region
|
||||
* @property {Collection<BaseRegionBehavior>} [behaviors=[]] A collection of embedded RegionBehavior objects
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RegionBehaviorData
|
||||
* @property {string} _id The _id which uniquely identifies this RegionBehavior document
|
||||
* @property {string} [name=""] The name used to describe the RegionBehavior
|
||||
* @property {string} type An RegionBehavior subtype which configures the system data model applied
|
||||
* @property {object} [system] The system data object which is defined by the system template.json model
|
||||
* @property {boolean} [disabled=false] Is the RegionBehavior currently disabled?
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
* @property {DocumentStats} _stats An object of creation and access information
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SettingData
|
||||
* @property {string} _id The _id which uniquely identifies this Setting document
|
||||
* @property {string} key The setting key, a composite of {scope}.{name}
|
||||
* @property {*} value The setting value, which is serialized to JSON
|
||||
* @property {DocumentStats} _stats An object of creation and access information
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TableResultData
|
||||
* @property {string} _id The _id which uniquely identifies this TableResult embedded document
|
||||
* @property {string} [type="text"] A result subtype from CONST.TABLE_RESULT_TYPES
|
||||
* @property {string} [text] The text which describes the table result
|
||||
* @property {string} [img] An image file url that represents the table result
|
||||
* @property {string} [documentCollection] A named collection from which this result is drawn
|
||||
* @property {string} [documentId] The _id of a Document within the collection this result references
|
||||
* @property {number} [weight=1] The probabilistic weight of this result relative to other results
|
||||
* @property {number[]} [range] A length 2 array of ascending integers which defines the range of dice roll
|
||||
* totals which produce this drawn result
|
||||
* @property {boolean} [drawn=false] Has this result already been drawn (without replacement)
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TileOcclusionData
|
||||
* @property {number} mode The occlusion mode from CONST.TILE_OCCLUSION_MODES
|
||||
* @property {number} alpha The occlusion alpha between 0 and 1
|
||||
* @property {number} [radius] An optional radius of occlusion used for RADIAL mode
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TileVideoData
|
||||
* @property {boolean} loop Automatically loop the video?
|
||||
* @property {boolean} autoplay Should the video play automatically?
|
||||
* @property {number} volume The volume level of any audio that the video file contains
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TileData
|
||||
* @property {string} _id The _id which uniquely identifies this Tile embedded document
|
||||
* @property {TextureData} [texture] An image or video texture which this tile displays.
|
||||
* @property {number} [width=0] The pixel width of the tile
|
||||
* @property {number} [height=0] The pixel height of the tile
|
||||
* @property {number} [x=0] The x-coordinate position of the top-left corner of the tile
|
||||
* @property {number} [y=0] The y-coordinate position of the top-left corner of the tile
|
||||
* @property {number} [elevation=0] The elevation of the tile
|
||||
* @property {number} [sort=0] The z-index ordering of this tile relative to its siblings
|
||||
* @property {number} [rotation=0] The angle of rotation for the tile between 0 and 360
|
||||
* @property {number} [alpha=1] The tile opacity
|
||||
* @property {boolean} [hidden=false] Is the tile currently hidden?
|
||||
* @property {boolean} [locked=false] Is the tile currently locked?
|
||||
* @property {TileOcclusionData} [occlusion] The tile's occlusion settings
|
||||
* @property {TileVideoData} [video] The tile's video settings
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TokenData
|
||||
* @property {string} _id The Token _id which uniquely identifies it within its parent Scene
|
||||
* @property {string} name The name used to describe the Token
|
||||
* @property {number} [displayName=0] The display mode of the Token nameplate, from CONST.TOKEN_DISPLAY_MODES
|
||||
* @property {string|null} actorId The _id of an Actor document which this Token represents
|
||||
* @property {boolean} [actorLink=false] Does this Token uniquely represent a singular Actor, or is it one of many?
|
||||
* @property {BaseActorDelta} [delta] The ActorDelta embedded document which stores the differences between this
|
||||
* token and the base actor it represents.
|
||||
* @property {TextureData} texture The token's texture on the canvas.
|
||||
* @property {number} [width=1] The width of the Token in grid units
|
||||
* @property {number} [height=1] The height of the Token in grid units
|
||||
* @property {number} [x=0] The x-coordinate of the top-left corner of the Token
|
||||
* @property {number} [y=0] The y-coordinate of the top-left corner of the Token
|
||||
* @property {number} [elevation=0] The vertical elevation of the Token, in distance units
|
||||
* @property {boolean} [locked=false] Is the Token currently locked? A locked token cannot be moved or rotated via
|
||||
* standard keyboard or mouse interaction.
|
||||
* @property {boolean} [lockRotation=false] Prevent the Token image from visually rotating?
|
||||
* @property {number} [rotation=0] The rotation of the Token in degrees, from 0 to 360. A value of 0 represents a southward-facing Token.
|
||||
* @property {number} [alpha=1] The opacity of the token image
|
||||
* @property {boolean} [hidden=false] Is the Token currently hidden from player view?
|
||||
* @property {number} [disposition=-1] A displayed Token disposition from CONST.TOKEN_DISPOSITIONS
|
||||
* @property {number} [displayBars=0] The display mode of Token resource bars, from CONST.TOKEN_DISPLAY_MODES
|
||||
* @property {TokenBarData} [bar1] The configuration of the Token's primary resource bar
|
||||
* @property {TokenBarData} [bar2] The configuration of the Token's secondary resource bar
|
||||
* @property {data.LightData} [light] Configuration of the light source that this Token emits
|
||||
* @property {TokenSightData} sight Configuration of sight and vision properties for the Token
|
||||
* @property {TokenDetectionMode[]} detectionModes An array of detection modes which are available to this Token
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TokenSightData
|
||||
* @property {boolean} enabled Should vision computation and rendering be active for this Token?
|
||||
* @property {number|null} range How far in distance units the Token can see without the aid of a light source.
|
||||
* If null, the sight range is unlimited.
|
||||
* @property {number} [angle=360] An angle at which the Token can see relative to their direction of facing
|
||||
* @property {string} [visionMode=basic] The vision mode which is used to render the appearance of the visible area
|
||||
* @property {string} [color] A special color which applies a hue to the visible area
|
||||
* @property {number} [attenuation] A degree of attenuation which gradually fades the edges of the visible area
|
||||
* @property {number} [brightness=0] An advanced customization for the perceived brightness of the visible area
|
||||
* @property {number} [saturation=0] An advanced customization of color saturation within the visible area
|
||||
* @property {number} [contrast=0] An advanced customization for contrast within the visible area
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TokenDetectionMode
|
||||
* @property {string} id The id of the detection mode, a key from CONFIG.Canvas.detectionModes
|
||||
* @property {boolean} enabled Whether or not this detection mode is presently enabled
|
||||
* @property {number|null} range The maximum range in distance units at which this mode can detect targets.
|
||||
* If null, the detection range is unlimited.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TokenBarData
|
||||
* @property {string} [attribute] The attribute path within the Token's Actor data which should be displayed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} UserData
|
||||
* @property {string} _id The _id which uniquely identifies this User document.
|
||||
* @property {string} name The user's name.
|
||||
* @property {string} [password] The user's password. Available only on the Server side for security.
|
||||
* @property {string} [passwordSalt] The user's password salt. Available only on the Server side for security.
|
||||
* @property {string|null} [avatar] The user's avatar image.
|
||||
* @property {BaseActor} [character] A linked Actor document that is this user's impersonated character.
|
||||
* @property {string} color A color to represent this user.
|
||||
* @property {object} hotbar A mapping of hotbar slot number to Macro id for the user.
|
||||
* @property {object} permissions The user's individual permission configuration, see CONST.USER_PERMISSIONS.
|
||||
* @property {number} role The user's role, see CONST.USER_ROLES.
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
* @property {DocumentStats} _stats An object of creation and access information
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} WallData
|
||||
* @property {string} _id The _id which uniquely identifies the embedded Wall document
|
||||
* @property {number[]} c The wall coordinates, a length-4 array of finite numbers [x0,y0,x1,y1]
|
||||
* @property {number} [light=0] The illumination restriction type of this wall
|
||||
* @property {number} [move=0] The movement restriction type of this wall
|
||||
* @property {number} [sight=0] The visual restriction type of this wall
|
||||
* @property {number} [sound=0] The auditory restriction type of this wall
|
||||
* @property {number} [dir=0] The direction of effect imposed by this wall
|
||||
* @property {number} [door=0] The type of door which this wall contains, if any
|
||||
* @property {number} [ds=0] The state of the door this wall contains, if any
|
||||
* @property {WallThresholdData} threshold Configuration of threshold data for this wall
|
||||
* @property {object} flags An object of optional key/value flags
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} WallThresholdData
|
||||
* @property {number} [light=0] Minimum distance from a light source for which this wall blocks light
|
||||
* @property {number} [sight=0] Minimum distance from a vision source for which this wall blocks vision
|
||||
* @property {number} [sound=0] Minimum distance from a sound source for which this wall blocks sound
|
||||
* @property {boolean} [attenuation=true] Whether to attenuate the source radius when passing through the wall
|
||||
*/
|
||||
177
resources/app/common/documents/active-effect.mjs
Normal file
177
resources/app/common/documents/active-effect.mjs
Normal file
@@ -0,0 +1,177 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").ActiveEffectData} ActiveEffectData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The ActiveEffect Document.
|
||||
* Defines the DataSchema and common behaviors for an ActiveEffect which are shared between both client and server.
|
||||
* @mixes {@link ActiveEffectData}
|
||||
*/
|
||||
export default class BaseActiveEffect extends Document {
|
||||
/**
|
||||
* Construct an ActiveEffect document using provided data and context.
|
||||
* @param {Partial<ActiveEffectData>} data Initial data from which to construct the ActiveEffect
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "ActiveEffect",
|
||||
collection: "effects",
|
||||
hasTypeData: true,
|
||||
label: "DOCUMENT.ActiveEffect",
|
||||
labelPlural: "DOCUMENT.ActiveEffects",
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
name: new fields.StringField({required: true, blank: false, label: "EFFECT.Name", textSearch: true}),
|
||||
img: new fields.FilePathField({categories: ["IMAGE"], label: "EFFECT.Image"}),
|
||||
type: new fields.DocumentTypeField(this, {initial: CONST.BASE_DOCUMENT_TYPE}),
|
||||
system: new fields.TypeDataField(this),
|
||||
changes: new fields.ArrayField(new fields.SchemaField({
|
||||
key: new fields.StringField({required: true, label: "EFFECT.ChangeKey"}),
|
||||
value: new fields.StringField({required: true, label: "EFFECT.ChangeValue"}),
|
||||
mode: new fields.NumberField({integer: true, initial: CONST.ACTIVE_EFFECT_MODES.ADD,
|
||||
label: "EFFECT.ChangeMode"}),
|
||||
priority: new fields.NumberField()
|
||||
})),
|
||||
disabled: new fields.BooleanField(),
|
||||
duration: new fields.SchemaField({
|
||||
startTime: new fields.NumberField({initial: null, label: "EFFECT.StartTime"}),
|
||||
seconds: new fields.NumberField({integer: true, min: 0, label: "EFFECT.DurationSecs"}),
|
||||
combat: new fields.ForeignDocumentField(documents.BaseCombat, {label: "EFFECT.Combat"}),
|
||||
rounds: new fields.NumberField({integer: true, min: 0}),
|
||||
turns: new fields.NumberField({integer: true, min: 0, label: "EFFECT.DurationTurns"}),
|
||||
startRound: new fields.NumberField({integer: true, min: 0}),
|
||||
startTurn: new fields.NumberField({integer: true, min: 0, label: "EFFECT.StartTurns"})
|
||||
}),
|
||||
description: new fields.HTMLField({label: "EFFECT.Description", textSearch: true}),
|
||||
origin: new fields.StringField({nullable: true, blank: false, initial: null, label: "EFFECT.Origin"}),
|
||||
tint: new fields.ColorField({nullable: false, initial: "#ffffff", label: "EFFECT.Tint"}),
|
||||
transfer: new fields.BooleanField({initial: true, label: "EFFECT.Transfer"}),
|
||||
statuses: new fields.SetField(new fields.StringField({required: true, blank: false})),
|
||||
sort: new fields.IntegerSortField(),
|
||||
flags: new fields.ObjectField(),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
canUserModify(user, action, data={}) {
|
||||
if ( this.isEmbedded ) return this.parent.canUserModify(user, "update");
|
||||
return super.canUserModify(user, action, data);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
testUserPermission(user, permission, {exact=false}={}) {
|
||||
if ( this.isEmbedded ) return this.parent.testUserPermission(user, permission, {exact});
|
||||
return super.testUserPermission(user, permission, {exact});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Database Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preCreate(data, options, user) {
|
||||
const allowed = await super._preCreate(data, options, user);
|
||||
if ( allowed === false ) return false;
|
||||
if ( this.parent instanceof documents.BaseActor ) {
|
||||
this.updateSource({transfer: false});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static migrateData(data) {
|
||||
/**
|
||||
* label -> name
|
||||
* @deprecated since v11
|
||||
*/
|
||||
this._addDataFieldMigration(data, "label", "name", d => d.label || "Unnamed Effect");
|
||||
/**
|
||||
* icon -> img
|
||||
* @deprecated since v12
|
||||
*/
|
||||
this._addDataFieldMigration(data, "icon", "img");
|
||||
return super.migrateData(data);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static shimData(data, options) {
|
||||
this._addDataFieldShim(data, "label", "name", {since: 11, until: 13});
|
||||
this._addDataFieldShim(data, "icon", "img", {since: 12, until: 14});
|
||||
return super.shimData(data, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
get label() {
|
||||
this.constructor._logDataFieldMigration("label", "name", {since: 11, until: 13, once: true});
|
||||
return this.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
set label(value) {
|
||||
this.constructor._logDataFieldMigration("label", "name", {since: 11, until: 13, once: true});
|
||||
this.name = value;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get icon() {
|
||||
this.constructor._logDataFieldMigration("icon", "img", {since: 12, until: 14, once: true});
|
||||
return this.img;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
set icon(value) {
|
||||
this.constructor._logDataFieldMigration("icon", "img", {since: 12, until: 14, once: true});
|
||||
this.img = value;
|
||||
}
|
||||
}
|
||||
176
resources/app/common/documents/actor-delta.mjs
Normal file
176
resources/app/common/documents/actor-delta.mjs
Normal file
@@ -0,0 +1,176 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {deepClone, mergeObject} from "../utils/helpers.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import BaseActor from "./actor.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").ActorDeltaData} ActorDeltaData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The ActorDelta Document.
|
||||
* Defines the DataSchema and common behaviors for an ActorDelta which are shared between both client and server.
|
||||
* ActorDeltas store a delta that can be applied to a particular Actor in order to produce a new Actor.
|
||||
* @mixes ActorDeltaData
|
||||
*/
|
||||
export default class BaseActorDelta extends Document {
|
||||
/**
|
||||
* Construct an ActorDelta document using provided data and context.
|
||||
* @param {Partial<ActorDeltaData>} data Initial data used to construct the ActorDelta.
|
||||
* @param {DocumentConstructionContext} context Construction context options.
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "ActorDelta",
|
||||
collection: "delta",
|
||||
label: "DOCUMENT.ActorDelta",
|
||||
labelPlural: "DOCUMENT.ActorDeltas",
|
||||
isEmbedded: true,
|
||||
embedded: {
|
||||
Item: "items",
|
||||
ActiveEffect: "effects"
|
||||
},
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @override */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
name: new fields.StringField({required: false, nullable: true, initial: null}),
|
||||
type: new fields.StringField({required: false, nullable: true, initial: null}),
|
||||
img: new fields.FilePathField({categories: ["IMAGE"], nullable: true, initial: null, required: false}),
|
||||
system: new fields.ObjectField(),
|
||||
items: new fields.EmbeddedCollectionDeltaField(documents.BaseItem),
|
||||
effects: new fields.EmbeddedCollectionDeltaField(documents.BaseActiveEffect),
|
||||
ownership: new fields.DocumentOwnershipField({required: false, nullable: true, initial: null}),
|
||||
flags: new fields.ObjectField()
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
canUserModify(user, action, data={}) {
|
||||
return this.parent.canUserModify(user, action, data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
testUserPermission(user, permission, { exact=false }={}) {
|
||||
return this.parent.testUserPermission(user, permission, { exact });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve the base actor's collection, if it exists.
|
||||
* @param {string} collectionName The collection name.
|
||||
* @returns {Collection}
|
||||
*/
|
||||
getBaseCollection(collectionName) {
|
||||
const baseActor = this.parent?.baseActor;
|
||||
return baseActor?.getEmbeddedCollection(collectionName);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply an ActorDelta to an Actor and return the resultant synthetic Actor.
|
||||
* @param {ActorDelta} delta The ActorDelta.
|
||||
* @param {Actor} baseActor The base Actor.
|
||||
* @param {object} [context] Context to supply to synthetic Actor instantiation.
|
||||
* @returns {Actor|null}
|
||||
*/
|
||||
static applyDelta(delta, baseActor, context={}) {
|
||||
if ( !baseActor ) return null;
|
||||
if ( delta.parent?.isLinked ) return baseActor;
|
||||
|
||||
// Get base actor data.
|
||||
const cls = game?.actors?.documentClass ?? db.Actor;
|
||||
const actorData = baseActor.toObject();
|
||||
const deltaData = delta.toObject();
|
||||
delete deltaData._id;
|
||||
|
||||
// Merge embedded collections.
|
||||
BaseActorDelta.#mergeEmbeddedCollections(cls, actorData, deltaData);
|
||||
|
||||
// Merge the rest of the delta.
|
||||
mergeObject(actorData, deltaData);
|
||||
return new cls(actorData, {parent: delta.parent, ...context});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Merge delta Document embedded collections with the base Document.
|
||||
* @param {typeof Document} documentClass The parent Document class.
|
||||
* @param {object} baseData The base Document data.
|
||||
* @param {object} deltaData The delta Document data.
|
||||
*/
|
||||
static #mergeEmbeddedCollections(documentClass, baseData, deltaData) {
|
||||
for ( const collectionName of Object.keys(documentClass.hierarchy) ) {
|
||||
const baseCollection = baseData[collectionName];
|
||||
const deltaCollection = deltaData[collectionName];
|
||||
baseData[collectionName] = BaseActorDelta.#mergeEmbeddedCollection(baseCollection, deltaCollection);
|
||||
delete deltaData[collectionName];
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply an embedded collection delta.
|
||||
* @param {object[]} base The base embedded collection.
|
||||
* @param {object[]} delta The delta embedded collection.
|
||||
* @returns {object[]}
|
||||
*/
|
||||
static #mergeEmbeddedCollection(base=[], delta=[]) {
|
||||
const deltaIds = new Set();
|
||||
const records = [];
|
||||
for ( const record of delta ) {
|
||||
if ( !record._tombstone ) records.push(record);
|
||||
deltaIds.add(record._id);
|
||||
}
|
||||
for ( const record of base ) {
|
||||
if ( !deltaIds.has(record._id) ) records.push(record);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static migrateData(source) {
|
||||
return BaseActor.migrateData(source);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Serialization */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
toObject(source=true) {
|
||||
const data = {};
|
||||
const value = source ? this._source : this;
|
||||
for ( const [name, field] of this.schema.entries() ) {
|
||||
const v = value[name];
|
||||
if ( !field.required && ((v === undefined) || (v === null)) ) continue; // Drop optional fields
|
||||
data[name] = source ? deepClone(value[name]) : field.toObject(value[name]);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
188
resources/app/common/documents/actor.mjs
Normal file
188
resources/app/common/documents/actor.mjs
Normal file
@@ -0,0 +1,188 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import {getProperty, mergeObject, setProperty} from "../utils/helpers.mjs";
|
||||
import {PrototypeToken} from "../data/data.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").ActorData} ActorData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Actor Document.
|
||||
* Defines the DataSchema and common behaviors for an Actor which are shared between both client and server.
|
||||
* @mixes ActorData
|
||||
*/
|
||||
export default class BaseActor extends Document {
|
||||
/**
|
||||
* Construct an Actor document using provided data and context.
|
||||
* @param {Partial<ActorData>} data Initial data from which to construct the Actor
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "Actor",
|
||||
collection: "actors",
|
||||
indexed: true,
|
||||
compendiumIndexFields: ["_id", "name", "img", "type", "sort", "folder"],
|
||||
embedded: {ActiveEffect: "effects", Item: "items"},
|
||||
hasTypeData: true,
|
||||
label: "DOCUMENT.Actor",
|
||||
labelPlural: "DOCUMENT.Actors",
|
||||
permissions: {
|
||||
create: this.#canCreate,
|
||||
update: this.#canUpdate
|
||||
},
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
name: new fields.StringField({required: true, blank: false, textSearch: true}),
|
||||
img: new fields.FilePathField({categories: ["IMAGE"], initial: data => {
|
||||
return this.implementation.getDefaultArtwork(data).img;
|
||||
}}),
|
||||
type: new fields.DocumentTypeField(this),
|
||||
system: new fields.TypeDataField(this),
|
||||
prototypeToken: new fields.EmbeddedDataField(PrototypeToken),
|
||||
items: new fields.EmbeddedCollectionField(documents.BaseItem),
|
||||
effects: new fields.EmbeddedCollectionField(documents.BaseActiveEffect),
|
||||
folder: new fields.ForeignDocumentField(documents.BaseFolder),
|
||||
sort: new fields.IntegerSortField(),
|
||||
ownership: new fields.DocumentOwnershipField(),
|
||||
flags: new fields.ObjectField(),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* The default icon used for newly created Actor documents.
|
||||
* @type {string}
|
||||
*/
|
||||
static DEFAULT_ICON = CONST.DEFAULT_TOKEN;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine default artwork based on the provided actor data.
|
||||
* @param {ActorData} actorData The source actor data.
|
||||
* @returns {{img: string, texture: {src: string}}} Candidate actor image and prototype token artwork.
|
||||
*/
|
||||
static getDefaultArtwork(actorData) {
|
||||
return {
|
||||
img: this.DEFAULT_ICON,
|
||||
texture: {
|
||||
src: this.DEFAULT_ICON
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_initializeSource(source, options) {
|
||||
source = super._initializeSource(source, options);
|
||||
source.prototypeToken.name = source.prototypeToken.name || source.name;
|
||||
source.prototypeToken.texture.src = source.prototypeToken.texture.src || source.img;
|
||||
return source;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static canUserCreate(user) {
|
||||
return user.hasPermission("ACTOR_CREATE");
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is a user able to create this actor?
|
||||
* @param {User} user The user attempting the creation operation.
|
||||
* @param {Actor} doc The Actor being created.
|
||||
*/
|
||||
static #canCreate(user, doc) {
|
||||
if ( !user.hasPermission("ACTOR_CREATE") ) return false; // User cannot create actors at all
|
||||
if ( doc._source.prototypeToken.randomImg && !user.hasPermission("FILES_BROWSE") ) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is a user able to update an existing actor?
|
||||
* @param {User} user The user attempting the update operation.
|
||||
* @param {Actor} doc The Actor being updated.
|
||||
* @param {object} data The update delta being applied.
|
||||
*/
|
||||
static #canUpdate(user, doc, data) {
|
||||
if ( !doc.testUserPermission(user, "OWNER") ) return false; // Ownership is required.
|
||||
|
||||
// Users can only enable token wildcard images if they have FILES_BROWSE permission.
|
||||
const tokenChange = data?.prototypeToken || {};
|
||||
const enablingRandomImage = tokenChange.randomImg === true;
|
||||
if ( enablingRandomImage ) return user.hasPermission("FILES_BROWSE");
|
||||
|
||||
// Users can only change a token wildcard path if they have FILES_BROWSE permission.
|
||||
const randomImageEnabled = doc._source.prototypeToken.randomImg && (tokenChange.randomImg !== false);
|
||||
const changingRandomImage = ("img" in tokenChange) && randomImageEnabled;
|
||||
if ( changingRandomImage ) return user.hasPermission("FILES_BROWSE");
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preCreate(data, options, user) {
|
||||
const allowed = await super._preCreate(data, options, user);
|
||||
if ( allowed === false ) return false;
|
||||
if ( !this.prototypeToken.name ) this.prototypeToken.updateSource({name: this.name});
|
||||
if ( !this.prototypeToken.texture.src || (this.prototypeToken.texture.src === CONST.DEFAULT_TOKEN)) {
|
||||
const { texture } = this.constructor.getDefaultArtwork(this.toObject());
|
||||
this.prototypeToken.updateSource("img" in data ? { texture: { src: this.img } } : { texture });
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preUpdate(changed, options, user) {
|
||||
const allowed = await super._preUpdate(changed, options, user);
|
||||
if ( allowed === false ) return false;
|
||||
if ( changed.img && !getProperty(changed, "prototypeToken.texture.src") ) {
|
||||
const { texture } = this.constructor.getDefaultArtwork(foundry.utils.mergeObject(this.toObject(), changed));
|
||||
if ( !this.prototypeToken.texture.src || (this.prototypeToken.texture.src === texture?.src) ) {
|
||||
setProperty(changed, "prototypeToken.texture.src", changed.img);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static migrateData(source) {
|
||||
/**
|
||||
* Migrate sourceId.
|
||||
* @deprecated since v12
|
||||
*/
|
||||
this._addDataFieldMigration(source, "flags.core.sourceId", "_stats.compendiumSource");
|
||||
|
||||
return super.migrateData(source);
|
||||
}
|
||||
}
|
||||
88
resources/app/common/documents/adventure.mjs
Normal file
88
resources/app/common/documents/adventure.mjs
Normal file
@@ -0,0 +1,88 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
import * as fields from "../data/fields.mjs"
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").AdventureData} AdventureData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Adventure Document.
|
||||
* Defines the DataSchema and common behaviors for an Adventure which are shared between both client and server.
|
||||
* @mixes AdventureData
|
||||
*/
|
||||
export default class BaseAdventure extends Document {
|
||||
/**
|
||||
* Construct an Adventure document using provided data and context.
|
||||
* @param {Partial<AdventureData>} data Initial data used to construct the Adventure.
|
||||
* @param {DocumentConstructionContext} context Construction context options.
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "Adventure",
|
||||
collection: "adventures",
|
||||
compendiumIndexFields: ["_id", "name", "description", "img", "sort", "folder"],
|
||||
label: "DOCUMENT.Adventure",
|
||||
labelPlural: "DOCUMENT.Adventures",
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
name: new fields.StringField({required: true, blank: false, label: "ADVENTURE.Name", hint: "ADVENTURE.NameHint", textSearch: true}),
|
||||
img: new fields.FilePathField({categories: ["IMAGE"], label: "ADVENTURE.Image", hint: "ADVENTURE.ImageHint"}),
|
||||
caption: new fields.HTMLField({label: "ADVENTURE.Caption", hint: "ADVENTURE.CaptionHint"}),
|
||||
description: new fields.HTMLField({label: "ADVENTURE.Description", hint: "ADVENTURE.DescriptionHint", textSearch: true}),
|
||||
actors: new fields.SetField(new fields.EmbeddedDataField(documents.BaseActor)),
|
||||
combats: new fields.SetField(new fields.EmbeddedDataField(documents.BaseCombat)),
|
||||
items: new fields.SetField(new fields.EmbeddedDataField(documents.BaseItem)),
|
||||
journal: new fields.SetField(new fields.EmbeddedDataField(documents.BaseJournalEntry)),
|
||||
scenes: new fields.SetField(new fields.EmbeddedDataField(documents.BaseScene)),
|
||||
tables: new fields.SetField(new fields.EmbeddedDataField(documents.BaseRollTable)),
|
||||
macros: new fields.SetField(new fields.EmbeddedDataField(documents.BaseMacro)),
|
||||
cards: new fields.SetField(new fields.EmbeddedDataField(documents.BaseCards)),
|
||||
playlists: new fields.SetField(new fields.EmbeddedDataField(documents.BasePlaylist)),
|
||||
folders: new fields.SetField(new fields.EmbeddedDataField(documents.BaseFolder)),
|
||||
folder: new fields.ForeignDocumentField(documents.BaseFolder),
|
||||
sort: new fields.IntegerSortField(),
|
||||
flags: new fields.ObjectField(),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* An array of the fields which provide imported content from the Adventure.
|
||||
* @type {Record<string, typeof Document>}
|
||||
*/
|
||||
static get contentFields() {
|
||||
const content = {};
|
||||
for ( const field of this.schema ) {
|
||||
if ( field instanceof fields.SetField ) content[field.name] = field.element.model.implementation;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a thumbnail image path used to represent the Adventure document.
|
||||
* @type {string}
|
||||
*/
|
||||
get thumbnail() {
|
||||
return this.img;
|
||||
}
|
||||
}
|
||||
57
resources/app/common/documents/ambient-light.mjs
Normal file
57
resources/app/common/documents/ambient-light.mjs
Normal file
@@ -0,0 +1,57 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import {LightData} from "../data/data.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").AmbientLightData} AmbientLightData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The AmbientLight Document.
|
||||
* Defines the DataSchema and common behaviors for an AmbientLight which are shared between both client and server.
|
||||
* @mixes AmbientLightData
|
||||
*/
|
||||
export default class BaseAmbientLight extends Document {
|
||||
/**
|
||||
* Construct an AmbientLight document using provided data and context.
|
||||
* @param {Partial<AmbientLightData>} data Initial data from which to construct the AmbientLight
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "AmbientLight",
|
||||
collection: "lights",
|
||||
label: "DOCUMENT.AmbientLight",
|
||||
labelPlural: "DOCUMENT.AmbientLights",
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
x: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0}),
|
||||
y: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0}),
|
||||
elevation: new fields.NumberField({required: true, nullable: false, initial: 0}),
|
||||
rotation: new fields.AngleField(),
|
||||
walls: new fields.BooleanField({initial: true}),
|
||||
vision: new fields.BooleanField(),
|
||||
config: new fields.EmbeddedDataField(LightData),
|
||||
hidden: new fields.BooleanField(),
|
||||
flags: new fields.ObjectField()
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["AMBIENT_LIGHT"];
|
||||
}
|
||||
73
resources/app/common/documents/ambient-sound.mjs
Normal file
73
resources/app/common/documents/ambient-sound.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").AmbientSoundData} AmbientSoundData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The AmbientSound Document.
|
||||
* Defines the DataSchema and common behaviors for an AmbientSound which are shared between both client and server.
|
||||
* @mixes AmbientSoundData
|
||||
*/
|
||||
export default class BaseAmbientSound extends Document {
|
||||
/**
|
||||
* Construct an AmbientSound document using provided data and context.
|
||||
* @param {Partial<AmbientSoundData>} data Initial data from which to construct the AmbientSound
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "AmbientSound",
|
||||
collection: "sounds",
|
||||
label: "DOCUMENT.AmbientSound",
|
||||
labelPlural: "DOCUMENT.AmbientSounds",
|
||||
isEmbedded: true,
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
x: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0}),
|
||||
y: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0}),
|
||||
elevation: new fields.NumberField({required: true, nullable: false, initial: 0}),
|
||||
radius: new fields.NumberField({required: true, nullable: false, initial: 0, min: 0, step: 0.01}),
|
||||
path: new fields.FilePathField({categories: ["AUDIO"]}),
|
||||
repeat: new fields.BooleanField(),
|
||||
volume: new fields.AlphaField({initial: 0.5, step: 0.01}),
|
||||
walls: new fields.BooleanField({initial: true}),
|
||||
easing: new fields.BooleanField({initial: true}),
|
||||
hidden: new fields.BooleanField(),
|
||||
darkness: new fields.SchemaField({
|
||||
min: new fields.AlphaField({initial: 0}),
|
||||
max: new fields.AlphaField({initial: 1})
|
||||
}),
|
||||
effects: new fields.SchemaField({
|
||||
base: new fields.SchemaField({
|
||||
type: new fields.StringField(),
|
||||
intensity: new fields.NumberField({required: true, integer: true, initial: 5, min: 1, max: 10, step: 1})
|
||||
}),
|
||||
muffled: new fields.SchemaField({
|
||||
type: new fields.StringField(),
|
||||
intensity: new fields.NumberField({required: true, integer: true, initial: 5, min: 1, max: 10, step: 1})
|
||||
})
|
||||
}),
|
||||
flags: new fields.ObjectField()
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["AMBIENT_SOUND"];
|
||||
}
|
||||
116
resources/app/common/documents/card.mjs
Normal file
116
resources/app/common/documents/card.mjs
Normal file
@@ -0,0 +1,116 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").CardData} CardData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Card Document.
|
||||
* Defines the DataSchema and common behaviors for a Card which are shared between both client and server.
|
||||
* @mixes CardData
|
||||
*/
|
||||
export default class BaseCard extends Document {
|
||||
/**
|
||||
* Construct a Card document using provided data and context.
|
||||
* @param {Partial<CardData>} data Initial data from which to construct the Card
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "Card",
|
||||
collection: "cards",
|
||||
hasTypeData: true,
|
||||
indexed: true,
|
||||
label: "DOCUMENT.Card",
|
||||
labelPlural: "DOCUMENT.Cards",
|
||||
permissions: {
|
||||
create: this.#canCreate,
|
||||
update: this.#canUpdate
|
||||
},
|
||||
compendiumIndexFields: ["name", "type", "suit", "sort"],
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
name: new fields.StringField({required: true, blank: false, label: "CARD.Name", textSearch: true}),
|
||||
description: new fields.HTMLField({label: "CARD.Description"}),
|
||||
type: new fields.DocumentTypeField(this, {initial: CONST.BASE_DOCUMENT_TYPE}),
|
||||
system: new fields.TypeDataField(this),
|
||||
suit: new fields.StringField({label: "CARD.Suit"}),
|
||||
value: new fields.NumberField({label: "CARD.Value"}),
|
||||
back: new fields.SchemaField({
|
||||
name: new fields.StringField({label: "CARD.BackName"}),
|
||||
text: new fields.HTMLField({label: "CARD.BackText"}),
|
||||
img: new fields.FilePathField({categories: ["IMAGE", "VIDEO"], label: "CARD.BackImage"}),
|
||||
}),
|
||||
faces: new fields.ArrayField(new fields.SchemaField({
|
||||
name: new fields.StringField({label: "CARD.FaceName"}),
|
||||
text: new fields.HTMLField({label: "CARD.FaceText"}),
|
||||
img: new fields.FilePathField({categories: ["IMAGE", "VIDEO"], initial: () => this.DEFAULT_ICON,
|
||||
label: "CARD.FaceImage"}),
|
||||
})),
|
||||
face: new fields.NumberField({required: true, initial: null, integer: true, min: 0, label: "CARD.Face"}),
|
||||
drawn: new fields.BooleanField({label: "CARD.Drawn"}),
|
||||
origin: new fields.ForeignDocumentField(documents.BaseCards),
|
||||
width: new fields.NumberField({integer: true, positive: true, label: "Width"}),
|
||||
height: new fields.NumberField({integer: true, positive: true, label: "Height"}),
|
||||
rotation: new fields.AngleField({label: "Rotation"}),
|
||||
sort: new fields.IntegerSortField(),
|
||||
flags: new fields.ObjectField(),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The default icon used for a Card face that does not have a custom image set
|
||||
* @type {string}
|
||||
*/
|
||||
static DEFAULT_ICON = "icons/svg/card-joker.svg";
|
||||
|
||||
/**
|
||||
* Is a User able to create a new Card within this parent?
|
||||
* @private
|
||||
*/
|
||||
static #canCreate(user, doc, data) {
|
||||
if ( user.isGM ) return true; // GM users can always create
|
||||
if ( doc.parent.type !== "deck" ) return true; // Users can pass cards to card hands or piles
|
||||
return doc.parent.canUserModify(user, "create", data); // Otherwise require parent document permission
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a user able to update an existing Card?
|
||||
* @private
|
||||
*/
|
||||
static #canUpdate(user, doc, data) {
|
||||
if ( user.isGM ) return true; // GM users can always update
|
||||
const wasDrawn = new Set(["drawn", "_id"]); // Users can draw cards from a deck
|
||||
if ( new Set(Object.keys(data)).equals(wasDrawn) ) return true;
|
||||
return doc.parent.canUserModify(user, "update", data); // Otherwise require parent document permission
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
testUserPermission(user, permission, {exact=false}={}) {
|
||||
if ( this.isEmbedded ) return this.parent.testUserPermission(user, permission, {exact});
|
||||
return super.testUserPermission(user, permission, {exact});
|
||||
}
|
||||
}
|
||||
87
resources/app/common/documents/cards.mjs
Normal file
87
resources/app/common/documents/cards.mjs
Normal file
@@ -0,0 +1,87 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").CardsData} CardsData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Cards Document.
|
||||
* Defines the DataSchema and common behaviors for a Cards Document which are shared between both client and server.
|
||||
* @mixes CardsData
|
||||
*/
|
||||
export default class BaseCards extends Document {
|
||||
/**
|
||||
* Construct a Cards document using provided data and context.
|
||||
* @param {Partial<CardsData>} data Initial data from which to construct the Cards
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "Cards",
|
||||
collection: "cards",
|
||||
indexed: true,
|
||||
compendiumIndexFields: ["_id", "name", "description", "img", "type", "sort", "folder"],
|
||||
embedded: {Card: "cards"},
|
||||
hasTypeData: true,
|
||||
label: "DOCUMENT.Cards",
|
||||
labelPlural: "DOCUMENT.CardsPlural",
|
||||
permissions: {create: "CARDS_CREATE"},
|
||||
coreTypes: ["deck", "hand", "pile"],
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
name: new fields.StringField({required: true, blank: false, label: "CARDS.Name", textSearch: true}),
|
||||
type: new fields.DocumentTypeField(this),
|
||||
description: new fields.HTMLField({label: "CARDS.Description", textSearch: true}),
|
||||
img: new fields.FilePathField({categories: ["IMAGE", "VIDEO"], initial: () => this.DEFAULT_ICON,
|
||||
label: "CARDS.Image"}),
|
||||
system: new fields.TypeDataField(this),
|
||||
cards: new fields.EmbeddedCollectionField(documents.BaseCard),
|
||||
width: new fields.NumberField({integer: true, positive: true, label: "Width"}),
|
||||
height: new fields.NumberField({integer: true, positive: true, label: "Height"}),
|
||||
rotation: new fields.AngleField({label: "Rotation"}),
|
||||
displayCount: new fields.BooleanField(),
|
||||
folder: new fields.ForeignDocumentField(documents.BaseFolder),
|
||||
sort: new fields.IntegerSortField(),
|
||||
ownership: new fields.DocumentOwnershipField(),
|
||||
flags: new fields.ObjectField(),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The default icon used for a cards stack that does not have a custom image set
|
||||
* @type {string}
|
||||
*/
|
||||
static DEFAULT_ICON = "icons/svg/card-hand.svg";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static migrateData(source) {
|
||||
/**
|
||||
* Migrate sourceId.
|
||||
* @deprecated since v12
|
||||
*/
|
||||
this._addDataFieldMigration(source, "flags.core.sourceId", "_stats.compendiumSource");
|
||||
|
||||
return super.migrateData(source);
|
||||
}
|
||||
}
|
||||
158
resources/app/common/documents/chat-message.mjs
Normal file
158
resources/app/common/documents/chat-message.mjs
Normal file
@@ -0,0 +1,158 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").ChatMessageData} ChatMessageData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The ChatMessage Document.
|
||||
* Defines the DataSchema and common behaviors for a ChatMessage which are shared between both client and server.
|
||||
* @mixes ChatMessageData
|
||||
*/
|
||||
export default class BaseChatMessage extends Document {
|
||||
/**
|
||||
* Construct a Cards document using provided data and context.
|
||||
* @param {Partial<ChatMessageData>} data Initial data from which to construct the ChatMessage
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "ChatMessage",
|
||||
collection: "messages",
|
||||
label: "DOCUMENT.ChatMessage",
|
||||
labelPlural: "DOCUMENT.ChatMessages",
|
||||
hasTypeData: true,
|
||||
isPrimary: true,
|
||||
permissions: {
|
||||
create: this.#canCreate,
|
||||
update: this.#canUpdate
|
||||
},
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
type: new fields.DocumentTypeField(this, {initial: CONST.BASE_DOCUMENT_TYPE}),
|
||||
system: new fields.TypeDataField(this),
|
||||
style: new fields.NumberField({required: true, choices: Object.values(CONST.CHAT_MESSAGE_STYLES),
|
||||
initial: CONST.CHAT_MESSAGE_STYLES.OTHER, validationError: "must be a value in CONST.CHAT_MESSAGE_STYLES"}),
|
||||
author: new fields.ForeignDocumentField(documents.BaseUser, {nullable: false, initial: () => game?.user?.id}),
|
||||
timestamp: new fields.NumberField({required: true, nullable: false, initial: Date.now}),
|
||||
flavor: new fields.HTMLField(),
|
||||
content: new fields.HTMLField({textSearch: true}),
|
||||
speaker: new fields.SchemaField({
|
||||
scene: new fields.ForeignDocumentField(documents.BaseScene, {idOnly: true}),
|
||||
actor: new fields.ForeignDocumentField(documents.BaseActor, {idOnly: true}),
|
||||
token: new fields.ForeignDocumentField(documents.BaseToken, {idOnly: true}),
|
||||
alias: new fields.StringField()
|
||||
}),
|
||||
whisper: new fields.ArrayField(new fields.ForeignDocumentField(documents.BaseUser, {idOnly: true})),
|
||||
blind: new fields.BooleanField(),
|
||||
rolls: new fields.ArrayField(new fields.JSONField({validate: BaseChatMessage.#validateRoll})),
|
||||
sound: new fields.FilePathField({categories: ["AUDIO"]}),
|
||||
emote: new fields.BooleanField(),
|
||||
flags: new fields.ObjectField(),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a user able to create a new chat message?
|
||||
*/
|
||||
static #canCreate(user, doc) {
|
||||
if ( user.isGM ) return true;
|
||||
if ( user.id !== doc._source.author ) return false; // You cannot impersonate a different user
|
||||
return user.hasRole("PLAYER"); // Any player can create messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a user able to update an existing chat message?
|
||||
*/
|
||||
static #canUpdate(user, doc, data) {
|
||||
if ( user.isGM ) return true; // GM users can do anything
|
||||
if ( user.id !== doc._source.author ) return false; // Otherwise, message authors
|
||||
if ( ("author" in data) && (data.author !== user.id) ) return false; // Message author is immutable
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate that Rolls belonging to the ChatMessage document are valid
|
||||
* @param {string} rollJSON The serialized Roll data
|
||||
*/
|
||||
static #validateRoll(rollJSON) {
|
||||
const roll = JSON.parse(rollJSON);
|
||||
if ( !roll.evaluated ) throw new Error(`Roll objects added to ChatMessage documents must be evaluated`);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
testUserPermission(user, permission, {exact=false}={}) {
|
||||
if ( !exact && (user.id === this._source.author) ) return true; // The user who created the chat message
|
||||
return super.testUserPermission(user, permission, {exact});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static migrateData(data) {
|
||||
/**
|
||||
* V12 migration from user to author
|
||||
* @deprecated since v12
|
||||
*/
|
||||
this._addDataFieldMigration(data, "user", "author");
|
||||
BaseChatMessage.#migrateTypeToStyle(data);
|
||||
return super.migrateData(data);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Migrate the type field to the style field in order to allow the type field to be used for system sub-types.
|
||||
* @param {Partial<ChatMessageData>} data
|
||||
*/
|
||||
static #migrateTypeToStyle(data) {
|
||||
if ( (typeof data.type !== "number") || ("style" in data) ) return;
|
||||
// WHISPER, ROLL, and any other invalid style are redirected to OTHER
|
||||
data.style = Object.values(CONST.CHAT_MESSAGE_STYLES).includes(data.type) ? data.type : 0;
|
||||
data.type = CONST.BASE_DOCUMENT_TYPE;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static shimData(data, options) {
|
||||
this._addDataFieldShim(data, "user", "author", {since: 12, until: 14})
|
||||
return super.shimData(data, options);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get user() {
|
||||
this.constructor._logDataFieldMigration("user", "author", {since: 12, until: 14});
|
||||
return this.author;
|
||||
}
|
||||
}
|
||||
141
resources/app/common/documents/combat.mjs
Normal file
141
resources/app/common/documents/combat.mjs
Normal file
@@ -0,0 +1,141 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
import {isValidId} from "../data/validators.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").CombatData} CombatData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Card Document.
|
||||
* Defines the DataSchema and common behaviors for a Combat which are shared between both client and server.
|
||||
* @mixes CombatData
|
||||
*/
|
||||
export default class BaseCombat extends Document {
|
||||
/**
|
||||
* Construct a Combat document using provided data and context.
|
||||
* @param {Partial<CombatData>} data Initial data from which to construct the Combat
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "Combat",
|
||||
collection: "combats",
|
||||
label: "DOCUMENT.Combat",
|
||||
labelPlural: "DOCUMENT.Combats",
|
||||
embedded: {
|
||||
Combatant: "combatants"
|
||||
},
|
||||
hasTypeData: true,
|
||||
permissions: {
|
||||
update: this.#canUpdate
|
||||
},
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
type: new fields.DocumentTypeField(this, {initial: CONST.BASE_DOCUMENT_TYPE}),
|
||||
system: new fields.TypeDataField(this),
|
||||
scene: new fields.ForeignDocumentField(documents.BaseScene),
|
||||
combatants: new fields.EmbeddedCollectionField(documents.BaseCombatant),
|
||||
active: new fields.BooleanField(),
|
||||
round: new fields.NumberField({required: true, nullable: false, integer: true, min: 0, initial: 0,
|
||||
label: "COMBAT.Round"}),
|
||||
turn: new fields.NumberField({required: true, integer: true, min: 0, initial: null, label: "COMBAT.Turn"}),
|
||||
sort: new fields.IntegerSortField(),
|
||||
flags: new fields.ObjectField(),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is a user able to update an existing Combat?
|
||||
* @protected
|
||||
*/
|
||||
static #canUpdate(user, doc, data) {
|
||||
if ( user.isGM ) return true; // GM users can do anything
|
||||
const turnOnly = ["_id", "round", "turn", "combatants"]; // Players may only modify a subset of fields
|
||||
if ( Object.keys(data).some(k => !turnOnly.includes(k)) ) return false;
|
||||
if ( ("round" in data) && !doc._canChangeRound(user) ) return false;
|
||||
if ( ("turn" in data) && !doc._canChangeTurn(user) ) return false;
|
||||
if ( ("combatants" in data) && !doc.#canModifyCombatants(user, data.combatants) ) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Can a certain User change the Combat round?
|
||||
* @param {User} user The user attempting to change the round
|
||||
* @returns {boolean} Is the user allowed to change the round?
|
||||
* @protected
|
||||
*/
|
||||
_canChangeRound(user) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Can a certain User change the Combat turn?
|
||||
* @param {User} user The user attempting to change the turn
|
||||
* @returns {boolean} Is the user allowed to change the turn?
|
||||
* @protected
|
||||
*/
|
||||
_canChangeTurn(user) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Can a certain user make modifications to the array of Combatants?
|
||||
* @param {User} user The user attempting to modify combatants
|
||||
* @param {Partial<CombatantData>[]} combatants Proposed combatant changes
|
||||
* @returns {boolean} Is the user allowed to make this change?
|
||||
*/
|
||||
#canModifyCombatants(user, combatants) {
|
||||
for ( const {_id, ...change} of combatants ) {
|
||||
const c = this.combatants.get(_id);
|
||||
if ( !c ) return false;
|
||||
if ( !c.canUserModify(user, "update", change) ) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preUpdate(changed, options, user) {
|
||||
const allowed = await super._preUpdate(changed, options, user);
|
||||
if ( allowed === false ) return false;
|
||||
// Don't allow linking to a Scene that doesn't contain all its Combatants
|
||||
if ( !("scene" in changed) ) return;
|
||||
const sceneId = this.schema.fields.scene.clean(changed.scene);
|
||||
if ( (sceneId !== null) && isValidId(sceneId)
|
||||
&& this.combatants.some(c => c.sceneId && (c.sceneId !== sceneId)) ) {
|
||||
throw new Error("You cannot link the Combat to a Scene that doesn't contain all its Combatants.");
|
||||
}
|
||||
}
|
||||
}
|
||||
94
resources/app/common/documents/combatant.mjs
Normal file
94
resources/app/common/documents/combatant.mjs
Normal file
@@ -0,0 +1,94 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").CombatantData} CombatantData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Combatant Document.
|
||||
* Defines the DataSchema and common behaviors for a Combatant which are shared between both client and server.
|
||||
* @mixes CombatantData
|
||||
*/
|
||||
export default class BaseCombatant extends Document {
|
||||
/**
|
||||
* Construct a Combatant document using provided data and context.
|
||||
* @param {Partial<CombatantData>} data Initial data from which to construct the Combatant
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "Combatant",
|
||||
collection: "combatants",
|
||||
label: "DOCUMENT.Combatant",
|
||||
labelPlural: "DOCUMENT.Combatants",
|
||||
isEmbedded: true,
|
||||
hasTypeData: true,
|
||||
permissions: {
|
||||
create: this.#canCreate,
|
||||
update: this.#canUpdate
|
||||
},
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
type: new fields.DocumentTypeField(this, {initial: CONST.BASE_DOCUMENT_TYPE}),
|
||||
system: new fields.TypeDataField(this),
|
||||
actorId: new fields.ForeignDocumentField(documents.BaseActor, {label: "COMBAT.CombatantActor", idOnly: true}),
|
||||
tokenId: new fields.ForeignDocumentField(documents.BaseToken, {label: "COMBAT.CombatantToken", idOnly: true}),
|
||||
sceneId: new fields.ForeignDocumentField(documents.BaseScene, {label: "COMBAT.CombatantScene", idOnly: true}),
|
||||
name: new fields.StringField({label: "COMBAT.CombatantName", textSearch: true}),
|
||||
img: new fields.FilePathField({categories: ["IMAGE"], label: "COMBAT.CombatantImage"}),
|
||||
initiative: new fields.NumberField({label: "COMBAT.CombatantInitiative"}),
|
||||
hidden: new fields.BooleanField({label: "COMBAT.CombatantHidden"}),
|
||||
defeated: new fields.BooleanField({label: "COMBAT.CombatantDefeated"}),
|
||||
flags: new fields.ObjectField(),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a user able to update an existing Combatant?
|
||||
* @private
|
||||
*/
|
||||
static #canUpdate(user, doc, data) {
|
||||
if ( user.isGM ) return true; // GM users can do anything
|
||||
if ( doc.actor && !doc.actor.canUserModify(user, "update", data) ) return false;
|
||||
const updateKeys = new Set(Object.keys(data));
|
||||
const allowedKeys = new Set(["_id", "initiative", "flags", "defeated"]);
|
||||
return updateKeys.isSubset(allowedKeys); // Players may only update initiative scores, flags, and the defeated state
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a user able to create this Combatant?
|
||||
* @private
|
||||
*/
|
||||
static #canCreate(user, doc, data) {
|
||||
if ( user.isGM ) return true;
|
||||
if ( doc.actor ) return doc.actor.canUserModify(user, "update", data);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
getUserLevel(user) {
|
||||
user = user || game.user;
|
||||
const {NONE, OWNER} = CONST.DOCUMENT_OWNERSHIP_LEVELS;
|
||||
if ( user.isGM ) return OWNER;
|
||||
return this.actor?.getUserLevel(user) ?? NONE;
|
||||
}
|
||||
}
|
||||
179
resources/app/common/documents/drawing.mjs
Normal file
179
resources/app/common/documents/drawing.mjs
Normal file
@@ -0,0 +1,179 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
import {ShapeData} from "../data/data.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").DrawingData} DrawingData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Drawing Document.
|
||||
* Defines the DataSchema and common behaviors for a Drawing which are shared between both client and server.
|
||||
* @mixes DrawingData
|
||||
*/
|
||||
export default class BaseDrawing extends Document {
|
||||
/**
|
||||
* Construct a Drawing document using provided data and context.
|
||||
* @param {Partial<DrawingData>} data Initial data from which to construct the Drawing
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "Drawing",
|
||||
collection: "drawings",
|
||||
label: "DOCUMENT.Drawing",
|
||||
labelPlural: "DOCUMENT.Drawings",
|
||||
isEmbedded: true,
|
||||
permissions: {
|
||||
create: this.#canCreate,
|
||||
update: this.#canUpdate
|
||||
},
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
author: new fields.ForeignDocumentField(documents.BaseUser, {nullable: false, initial: () => game.user?.id}),
|
||||
shape: new fields.EmbeddedDataField(ShapeData),
|
||||
x: new fields.NumberField({required: true, nullable: false, initial: 0, label: "XCoord"}),
|
||||
y: new fields.NumberField({required: true, nullable: false, initial: 0, label: "YCoord"}),
|
||||
elevation: new fields.NumberField({required: true, nullable: false, initial: 0}),
|
||||
sort: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0}),
|
||||
rotation: new fields.AngleField({label: "DRAWING.Rotation"}),
|
||||
bezierFactor: new fields.AlphaField({initial: 0, label: "DRAWING.SmoothingFactor", max: 0.5,
|
||||
hint: "DRAWING.SmoothingFactorHint"}),
|
||||
fillType: new fields.NumberField({required: true, nullable: false, initial: CONST.DRAWING_FILL_TYPES.NONE,
|
||||
choices: Object.values(CONST.DRAWING_FILL_TYPES), label: "DRAWING.FillTypes",
|
||||
validationError: "must be a value in CONST.DRAWING_FILL_TYPES"
|
||||
}),
|
||||
fillColor: new fields.ColorField({nullable: false, initial: () => game.user?.color.css || "#ffffff", label: "DRAWING.FillColor"}),
|
||||
fillAlpha: new fields.AlphaField({initial: 0.5, label: "DRAWING.FillOpacity"}),
|
||||
strokeWidth: new fields.NumberField({nullable: false, integer: true, initial: 8, min: 0, label: "DRAWING.LineWidth"}),
|
||||
strokeColor: new fields.ColorField({nullable: false, initial: () => game.user?.color.css || "#ffffff", label: "DRAWING.StrokeColor"}),
|
||||
strokeAlpha: new fields.AlphaField({initial: 1, label: "DRAWING.LineOpacity"}),
|
||||
texture: new fields.FilePathField({categories: ["IMAGE"], label: "DRAWING.FillTexture"}),
|
||||
text: new fields.StringField({label: "DRAWING.TextLabel"}),
|
||||
fontFamily: new fields.StringField({blank: false, label: "DRAWING.FontFamily",
|
||||
initial: () => globalThis.CONFIG?.defaultFontFamily || "Signika"}),
|
||||
fontSize: new fields.NumberField({nullable: false, integer: true, min: 8, max: 256, initial: 48, label: "DRAWING.FontSize",
|
||||
validationError: "must be an integer between 8 and 256"}),
|
||||
textColor: new fields.ColorField({nullable: false, initial: "#ffffff", label: "DRAWING.TextColor"}),
|
||||
textAlpha: new fields.AlphaField({label: "DRAWING.TextOpacity"}),
|
||||
hidden: new fields.BooleanField(),
|
||||
locked: new fields.BooleanField(),
|
||||
interface: new fields.BooleanField(),
|
||||
flags: new fields.ObjectField()
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate whether the drawing has some visible content (as required by validation).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static #validateVisibleContent(data) {
|
||||
const hasText = (data.text !== "") && (data.textAlpha > 0);
|
||||
const hasFill = (data.fillType !== CONST.DRAWING_FILL_TYPES.NONE) && (data.fillAlpha > 0);
|
||||
const hasLine = (data.strokeWidth > 0) && (data.strokeAlpha > 0);
|
||||
return hasText || hasFill || hasLine;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static validateJoint(data) {
|
||||
if ( !BaseDrawing.#validateVisibleContent(data) ) {
|
||||
throw new Error(game.i18n.localize("DRAWING.JointValidationError"));
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static canUserCreate(user) {
|
||||
return user.hasPermission("DRAWING_CREATE");
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is a user able to create a new Drawing?
|
||||
* @param {User} user The user attempting the creation operation.
|
||||
* @param {BaseDrawing} doc The Drawing being created.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static #canCreate(user, doc) {
|
||||
if ( !user.isGM && (doc._source.author !== user.id) ) return false;
|
||||
return user.hasPermission("DRAWING_CREATE");
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is a user able to update the Drawing document?
|
||||
*/
|
||||
static #canUpdate(user, doc, data) {
|
||||
if ( !user.isGM && ("author" in data) && (data.author !== user.id) ) return false;
|
||||
return doc.testUserPermission(user, "OWNER");
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* Model Methods */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
testUserPermission(user, permission, {exact=false}={}) {
|
||||
if ( !exact && (user.id === this._source.author) ) return true; // The user who created the drawing
|
||||
return super.testUserPermission(user, permission, {exact});
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static migrateData(data) {
|
||||
/**
|
||||
* V12 migration to elevation and sort fields
|
||||
* @deprecated since v12
|
||||
*/
|
||||
this._addDataFieldMigration(data, "z", "elevation");
|
||||
return super.migrateData(data);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static shimData(data, options) {
|
||||
this._addDataFieldShim(data, "z", "elevation", {since: 12, until: 14});
|
||||
return super.shimData(data, options);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get z() {
|
||||
this.constructor._logDataFieldMigration("z", "elevation", {since: 12, until: 14});
|
||||
return this.elevation;
|
||||
}
|
||||
}
|
||||
76
resources/app/common/documents/fog-exploration.mjs
Normal file
76
resources/app/common/documents/fog-exploration.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").FogExplorationData} FogExplorationData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The FogExploration Document.
|
||||
* Defines the DataSchema and common behaviors for a FogExploration which are shared between both client and server.
|
||||
* @mixes FogExplorationData
|
||||
*/
|
||||
export default class BaseFogExploration extends Document {
|
||||
/**
|
||||
* Construct a FogExploration document using provided data and context.
|
||||
* @param {Partial<FogExplorationData>} data Initial data from which to construct the FogExploration
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "FogExploration",
|
||||
collection: "fog",
|
||||
label: "DOCUMENT.FogExploration",
|
||||
labelPlural: "DOCUMENT.FogExplorations",
|
||||
isPrimary: true,
|
||||
permissions: {
|
||||
create: "PLAYER",
|
||||
update: this.#canModify,
|
||||
delete: this.#canModify
|
||||
},
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
scene: new fields.ForeignDocumentField(documents.BaseScene, {initial: () => canvas?.scene?.id}),
|
||||
user: new fields.ForeignDocumentField(documents.BaseUser, {initial: () => game?.user?.id}),
|
||||
explored: new fields.FilePathField({categories: ["IMAGE"], required: true, base64: true}),
|
||||
positions: new fields.ObjectField(),
|
||||
timestamp: new fields.NumberField({nullable: false, initial: Date.now}),
|
||||
flags: new fields.ObjectField(),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a User can modify a FogExploration document.
|
||||
*/
|
||||
static #canModify(user, doc) {
|
||||
return (user.id === doc._source.user) || user.hasRole("ASSISTANT");
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* Database Event Handlers */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preUpdate(changed, options, user) {
|
||||
const allowed = await super._preUpdate(changed, options, user);
|
||||
if ( allowed === false ) return false;
|
||||
changed.timestamp = Date.now();
|
||||
}
|
||||
}
|
||||
82
resources/app/common/documents/folder.mjs
Normal file
82
resources/app/common/documents/folder.mjs
Normal file
@@ -0,0 +1,82 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").FolderData} FolderData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Folder Document.
|
||||
* Defines the DataSchema and common behaviors for a Folder which are shared between both client and server.
|
||||
* @mixes FolderData
|
||||
*/
|
||||
export default class BaseFolder extends Document {
|
||||
/**
|
||||
* Construct a Folder document using provided data and context.
|
||||
* @param {Partial<FolderData>} data Initial data from which to construct the Folder
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "Folder",
|
||||
collection: "folders",
|
||||
label: "DOCUMENT.Folder",
|
||||
labelPlural: "DOCUMENT.Folders",
|
||||
coreTypes: CONST.FOLDER_DOCUMENT_TYPES,
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
name: new fields.StringField({required: true, blank: false, textSearch: true}),
|
||||
type: new fields.DocumentTypeField(this),
|
||||
description: new fields.HTMLField({textSearch: true}),
|
||||
folder: new fields.ForeignDocumentField(BaseFolder),
|
||||
sorting: new fields.StringField({required: true, initial: "a", choices: this.SORTING_MODES}),
|
||||
sort: new fields.IntegerSortField(),
|
||||
color: new fields.ColorField(),
|
||||
flags: new fields.ObjectField(),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
}
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
static validateJoint(data) {
|
||||
if ( (data.folder !== null) && (data.folder === data._id) ) {
|
||||
throw new Error("A Folder may not contain itself");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow folder sorting modes
|
||||
* @type {string[]}
|
||||
*/
|
||||
static SORTING_MODES = ["a", "m"];
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get(documentId, options={}) {
|
||||
if ( !documentId ) return null;
|
||||
if ( !options.pack ) return super.get(documentId, options);
|
||||
const pack = game.packs.get(options.pack);
|
||||
if ( !pack ) {
|
||||
console.error(`The ${this.name} model references a non-existent pack ${options.pack}.`);
|
||||
return null;
|
||||
}
|
||||
return pack.folders.get(documentId);
|
||||
}
|
||||
}
|
||||
112
resources/app/common/documents/item.mjs
Normal file
112
resources/app/common/documents/item.mjs
Normal file
@@ -0,0 +1,112 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").ItemData} ItemData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Item Document.
|
||||
* Defines the DataSchema and common behaviors for a Item which are shared between both client and server.
|
||||
* @mixes ItemData
|
||||
*/
|
||||
export default class BaseItem extends Document {
|
||||
/**
|
||||
* Construct a Item document using provided data and context.
|
||||
* @param {Partial<ItemData>} data Initial data from which to construct the Item
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "Item",
|
||||
collection: "items",
|
||||
hasTypeData: true,
|
||||
indexed: true,
|
||||
compendiumIndexFields: ["_id", "name", "img", "type", "sort", "folder"],
|
||||
embedded: {ActiveEffect: "effects"},
|
||||
label: "DOCUMENT.Item",
|
||||
labelPlural: "DOCUMENT.Items",
|
||||
permissions: {create: "ITEM_CREATE"},
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
name: new fields.StringField({required: true, blank: false, textSearch: true}),
|
||||
type: new fields.DocumentTypeField(this),
|
||||
img: new fields.FilePathField({categories: ["IMAGE"], initial: data => {
|
||||
return this.implementation.getDefaultArtwork(data).img;
|
||||
}}),
|
||||
system: new fields.TypeDataField(this),
|
||||
effects: new fields.EmbeddedCollectionField(documents.BaseActiveEffect),
|
||||
folder: new fields.ForeignDocumentField(documents.BaseFolder),
|
||||
sort: new fields.IntegerSortField(),
|
||||
ownership: new fields.DocumentOwnershipField(),
|
||||
flags: new fields.ObjectField(),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* The default icon used for newly created Item documents
|
||||
* @type {string}
|
||||
*/
|
||||
static DEFAULT_ICON = "icons/svg/item-bag.svg";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine default artwork based on the provided item data.
|
||||
* @param {ItemData} itemData The source item data.
|
||||
* @returns {{img: string}} Candidate item image.
|
||||
*/
|
||||
static getDefaultArtwork(itemData) {
|
||||
return { img: this.DEFAULT_ICON };
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
canUserModify(user, action, data={}) {
|
||||
if ( this.isEmbedded ) return this.parent.canUserModify(user, "update");
|
||||
return super.canUserModify(user, action, data);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
testUserPermission(user, permission, {exact=false}={}) {
|
||||
if ( this.isEmbedded ) return this.parent.testUserPermission(user, permission, {exact});
|
||||
return super.testUserPermission(user, permission, {exact});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static migrateData(source) {
|
||||
/**
|
||||
* Migrate sourceId.
|
||||
* @deprecated since v12
|
||||
*/
|
||||
this._addDataFieldMigration(source, "flags.core.sourceId", "_stats.compendiumSource");
|
||||
|
||||
return super.migrateData(source);
|
||||
}
|
||||
}
|
||||
88
resources/app/common/documents/journal-entry-page.mjs
Normal file
88
resources/app/common/documents/journal-entry-page.mjs
Normal file
@@ -0,0 +1,88 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").JournalEntryPageData} JournalEntryPageData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The JournalEntryPage Document.
|
||||
* Defines the DataSchema and common behaviors for a JournalEntryPage which are shared between both client and server.
|
||||
* @mixes JournalEntryPageData
|
||||
*/
|
||||
export default class BaseJournalEntryPage extends Document {
|
||||
/**
|
||||
* Construct a JournalEntryPage document using provided data and context.
|
||||
* @param {Partial<JournalEntryPageData>} data Initial data from which to construct the JournalEntryPage
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "JournalEntryPage",
|
||||
collection: "pages",
|
||||
hasTypeData: true,
|
||||
indexed: true,
|
||||
label: "DOCUMENT.JournalEntryPage",
|
||||
labelPlural: "DOCUMENT.JournalEntryPages",
|
||||
coreTypes: ["text", "image", "pdf", "video"],
|
||||
compendiumIndexFields: ["name", "type", "sort"],
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
name: new fields.StringField({required: true, blank: false, label: "JOURNALENTRYPAGE.PageTitle", textSearch: true}),
|
||||
type: new fields.DocumentTypeField(this, {initial: "text"}),
|
||||
system: new fields.TypeDataField(this),
|
||||
title: new fields.SchemaField({
|
||||
show: new fields.BooleanField({initial: true}),
|
||||
level: new fields.NumberField({required: true, initial: 1, min: 1, max: 6, integer: true, nullable: false})
|
||||
}),
|
||||
image: new fields.SchemaField({
|
||||
caption: new fields.StringField({required: false, initial: undefined})
|
||||
}),
|
||||
text: new fields.SchemaField({
|
||||
content: new fields.HTMLField({required: false, initial: undefined, textSearch: true}),
|
||||
markdown: new fields.StringField({required: false, initial: undefined}),
|
||||
format: new fields.NumberField({label: "JOURNALENTRYPAGE.Format",
|
||||
initial: CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML, choices: Object.values(CONST.JOURNAL_ENTRY_PAGE_FORMATS)})
|
||||
}),
|
||||
video: new fields.SchemaField({
|
||||
controls: new fields.BooleanField({initial: true}),
|
||||
loop: new fields.BooleanField({required: false, initial: undefined}),
|
||||
autoplay: new fields.BooleanField({required: false, initial: undefined}),
|
||||
volume: new fields.AlphaField({required: true, step: 0.01, initial: .5}),
|
||||
timestamp: new fields.NumberField({required: false, min: 0, initial: undefined}),
|
||||
width: new fields.NumberField({required: false, positive: true, integer: true, initial: undefined}),
|
||||
height: new fields.NumberField({required: false, positive: true, integer: true, initial: undefined})
|
||||
}),
|
||||
src: new fields.StringField({required: false, blank: false, nullable: true, initial: null,
|
||||
label: "JOURNALENTRYPAGE.Source"}),
|
||||
sort: new fields.IntegerSortField(),
|
||||
ownership: new fields.DocumentOwnershipField({initial: {default: CONST.DOCUMENT_OWNERSHIP_LEVELS.INHERIT}}),
|
||||
flags: new fields.ObjectField(),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
};
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getUserLevel(user) {
|
||||
user = user || game.user;
|
||||
const ownership = this.ownership[user.id] ?? this.ownership.default;
|
||||
const inherited = ownership === CONST.DOCUMENT_OWNERSHIP_LEVELS.INHERIT;
|
||||
return inherited ? this.parent.getUserLevel(user) : ownership;
|
||||
}
|
||||
}
|
||||
71
resources/app/common/documents/journal-entry.mjs
Normal file
71
resources/app/common/documents/journal-entry.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").JournalEntryData} JournalEntryData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The JournalEntry Document.
|
||||
* Defines the DataSchema and common behaviors for a JournalEntry which are shared between both client and server.
|
||||
* @mixes JournalEntryData
|
||||
*/
|
||||
export default class BaseJournalEntry extends Document {
|
||||
/**
|
||||
* Construct a JournalEntry document using provided data and context.
|
||||
* @param {Partial<JournalEntryData>} data Initial data from which to construct the JournalEntry
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "JournalEntry",
|
||||
collection: "journal",
|
||||
indexed: true,
|
||||
compendiumIndexFields: ["_id", "name", "sort", "folder"],
|
||||
embedded: {JournalEntryPage: "pages"},
|
||||
label: "DOCUMENT.JournalEntry",
|
||||
labelPlural: "DOCUMENT.JournalEntries",
|
||||
permissions: {
|
||||
create: "JOURNAL_CREATE"
|
||||
},
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
name: new fields.StringField({required: true, blank: false, textSearch: true}),
|
||||
pages: new fields.EmbeddedCollectionField(documents.BaseJournalEntryPage),
|
||||
folder: new fields.ForeignDocumentField(documents.BaseFolder),
|
||||
sort: new fields.IntegerSortField(),
|
||||
ownership: new fields.DocumentOwnershipField(),
|
||||
flags: new fields.ObjectField(),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static migrateData(source) {
|
||||
/**
|
||||
* Migrate sourceId.
|
||||
* @deprecated since v12
|
||||
*/
|
||||
this._addDataFieldMigration(source, "flags.core.sourceId", "_stats.compendiumSource");
|
||||
|
||||
return super.migrateData(source);
|
||||
}
|
||||
}
|
||||
147
resources/app/common/documents/macro.mjs
Normal file
147
resources/app/common/documents/macro.mjs
Normal file
@@ -0,0 +1,147 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").MacroData} MacroData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Macro Document.
|
||||
* Defines the DataSchema and common behaviors for a Macro which are shared between both client and server.
|
||||
* @mixes MacroData
|
||||
*/
|
||||
export default class BaseMacro extends Document {
|
||||
/**
|
||||
* Construct a Macro document using provided data and context.
|
||||
* @param {Partial<MacroData>} data Initial data from which to construct the Macro
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "Macro",
|
||||
collection: "macros",
|
||||
indexed: true,
|
||||
compendiumIndexFields: ["_id", "name", "img", "sort", "folder"],
|
||||
label: "DOCUMENT.Macro",
|
||||
labelPlural: "DOCUMENT.Macros",
|
||||
coreTypes: Object.values(CONST.MACRO_TYPES),
|
||||
permissions: {
|
||||
create: this.#canCreate,
|
||||
update: this.#canUpdate
|
||||
},
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
name: new fields.StringField({required: true, blank: false, label: "Name", textSearch: true}),
|
||||
type: new fields.DocumentTypeField(this, {initial: CONST.MACRO_TYPES.CHAT, label: "Type"}),
|
||||
author: new fields.ForeignDocumentField(documents.BaseUser, {initial: () => game?.user?.id}),
|
||||
img: new fields.FilePathField({categories: ["IMAGE"], initial: () => this.DEFAULT_ICON, label: "Image"}),
|
||||
scope: new fields.StringField({required: true, choices: CONST.MACRO_SCOPES, initial: CONST.MACRO_SCOPES[0],
|
||||
validationError: "must be a value in CONST.MACRO_SCOPES", label: "Scope"}),
|
||||
command: new fields.StringField({required: true, blank: true, label: "Command"}),
|
||||
folder: new fields.ForeignDocumentField(documents.BaseFolder),
|
||||
sort: new fields.IntegerSortField(),
|
||||
ownership: new fields.DocumentOwnershipField(),
|
||||
flags: new fields.ObjectField(),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The default icon used for newly created Macro documents.
|
||||
* @type {string}
|
||||
*/
|
||||
static DEFAULT_ICON = "icons/svg/dice-target.svg";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static migrateData(source) {
|
||||
/**
|
||||
* Migrate sourceId.
|
||||
* @deprecated since v12
|
||||
*/
|
||||
this._addDataFieldMigration(source, "flags.core.sourceId", "_stats.compendiumSource");
|
||||
|
||||
return super.migrateData(source);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static validateJoint(data) {
|
||||
if ( data.type !== CONST.MACRO_TYPES.SCRIPT ) return;
|
||||
const field = new fields.JavaScriptField({ async: true });
|
||||
const failure = field.validate(data.command);
|
||||
if ( failure ) throw failure.asError();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static canUserCreate(user) {
|
||||
return user.hasRole("PLAYER");
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is a user able to create the Macro document?
|
||||
*/
|
||||
static #canCreate(user, doc) {
|
||||
if ( !user.isGM && (doc._source.author !== user.id) ) return false;
|
||||
if ( (doc._source.type === "script") && !user.hasPermission("MACRO_SCRIPT") ) return false;
|
||||
return user.hasRole("PLAYER");
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is a user able to update the Macro document?
|
||||
*/
|
||||
static #canUpdate(user, doc, data) {
|
||||
if ( !user.isGM && ("author" in data) && (data.author !== user.id) ) return false;
|
||||
if ( !user.hasPermission("MACRO_SCRIPT") ) {
|
||||
if ( data.type === "script" ) return false;
|
||||
if ( (doc._source.type === "script") && ("command" in data) ) return false;
|
||||
}
|
||||
return doc.testUserPermission(user, "OWNER");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
testUserPermission(user, permission, {exact=false}={}) {
|
||||
if ( !exact && (user.id === this._source.author) ) return true; // Macro authors can edit
|
||||
return super.testUserPermission(user, permission, {exact});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Database Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _preCreate(data, options, user) {
|
||||
const allowed = await super._preCreate(data, options, user);
|
||||
if ( allowed === false ) return false;
|
||||
this.updateSource({author: user.id});
|
||||
}
|
||||
}
|
||||
135
resources/app/common/documents/measured-template.mjs
Normal file
135
resources/app/common/documents/measured-template.mjs
Normal file
@@ -0,0 +1,135 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").MeasuredTemplateData} MeasuredTemplateData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The MeasuredTemplate Document.
|
||||
* Defines the DataSchema and common behaviors for a MeasuredTemplate which are shared between both client and server.
|
||||
* @mixes MeasuredTemplateData
|
||||
*/
|
||||
export default class BaseMeasuredTemplate extends Document {
|
||||
/**
|
||||
* Construct a MeasuredTemplate document using provided data and context.
|
||||
* @param {Partial<MeasuredTemplateData>} data Initial data from which to construct the MeasuredTemplate
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = mergeObject(super.metadata, {
|
||||
name: "MeasuredTemplate",
|
||||
collection: "templates",
|
||||
label: "DOCUMENT.MeasuredTemplate",
|
||||
labelPlural: "DOCUMENT.MeasuredTemplates",
|
||||
isEmbedded: true,
|
||||
permissions: {
|
||||
create: this.#canCreate,
|
||||
update: this.#canUpdate
|
||||
},
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false});
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
author: new fields.ForeignDocumentField(documents.BaseUser, {initial: () => game?.user?.id}),
|
||||
t: new fields.StringField({required: true, choices: Object.values(CONST.MEASURED_TEMPLATE_TYPES), label: "Type",
|
||||
initial: CONST.MEASURED_TEMPLATE_TYPES.CIRCLE,
|
||||
validationError: "must be a value in CONST.MEASURED_TEMPLATE_TYPES",
|
||||
}),
|
||||
x: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0, label: "XCoord"}),
|
||||
y: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0, label: "YCoord"}),
|
||||
elevation: new fields.NumberField({required: true, nullable: false, initial: 0}),
|
||||
sort: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0}),
|
||||
distance: new fields.NumberField({required: true, nullable: false, initial: 0, min: 0, label: "Distance"}),
|
||||
direction: new fields.AngleField({label: "Direction"}),
|
||||
angle: new fields.AngleField({normalize: false, label: "Angle"}),
|
||||
width: new fields.NumberField({required: true, nullable: false, initial: 0, min: 0, step: 0.01, label: "Width"}),
|
||||
borderColor: new fields.ColorField({nullable: false, initial: "#000000"}),
|
||||
fillColor: new fields.ColorField({nullable: false, initial: () => game.user?.color.css || "#ffffff"}),
|
||||
texture: new fields.FilePathField({categories: ["IMAGE", "VIDEO"]}),
|
||||
hidden: new fields.BooleanField({label: "Hidden"}),
|
||||
flags: new fields.ObjectField()
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is a user able to create a new MeasuredTemplate?
|
||||
* @param {User} user The user attempting the creation operation.
|
||||
* @param {BaseMeasuredTemplate} doc The MeasuredTemplate being created.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static #canCreate(user, doc) {
|
||||
if ( !user.isGM && (doc._source.author !== user.id) ) return false;
|
||||
return user.hasPermission("TEMPLATE_CREATE");
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is a user able to update the MeasuredTemplate document?
|
||||
*/
|
||||
static #canUpdate(user, doc, data) {
|
||||
if ( !user.isGM && ("author" in data) && (data.author !== user.id) ) return false;
|
||||
return doc.testUserPermission(user, "OWNER");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
testUserPermission(user, permission, {exact=false}={}) {
|
||||
if ( !exact && (user.id === this._source.author) ) return true; // The user who created the template
|
||||
return super.testUserPermission(user, permission, {exact});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static migrateData(data) {
|
||||
/**
|
||||
* V12 migration from user to author
|
||||
* @deprecated since v12
|
||||
*/
|
||||
this._addDataFieldMigration(data, "user", "author");
|
||||
return super.migrateData(data);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static shimData(data, options) {
|
||||
this._addDataFieldShim(data, "user", "author", {since: 12, until: 14})
|
||||
return super.shimData(data, options);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get user() {
|
||||
this.constructor._logDataFieldMigration("user", "author", {since: 12, until: 14});
|
||||
return this.author;
|
||||
}
|
||||
}
|
||||
90
resources/app/common/documents/note.mjs
Normal file
90
resources/app/common/documents/note.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
import {TextureData} from "../data/data.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").NoteData} NoteData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Note Document.
|
||||
* Defines the DataSchema and common behaviors for a Note which are shared between both client and server.
|
||||
* @mixes NoteData
|
||||
*/
|
||||
export default class BaseNote extends Document {
|
||||
/**
|
||||
* Construct a Note document using provided data and context.
|
||||
* @param {Partial<NoteData>} data Initial data from which to construct the Note
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "Note",
|
||||
collection: "notes",
|
||||
label: "DOCUMENT.Note",
|
||||
labelPlural: "DOCUMENT.Notes",
|
||||
permissions: {
|
||||
create: "NOTE_CREATE"
|
||||
},
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
entryId: new fields.ForeignDocumentField(documents.BaseJournalEntry, {idOnly: true}),
|
||||
pageId: new fields.ForeignDocumentField(documents.BaseJournalEntryPage, {idOnly: true}),
|
||||
x: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0, label: "XCoord"}),
|
||||
y: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0, label: "YCoord"}),
|
||||
elevation: new fields.NumberField({required: true, nullable: false, initial: 0}),
|
||||
sort: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0}),
|
||||
texture: new TextureData({}, {categories: ["IMAGE"],
|
||||
initial: {src: () => this.DEFAULT_ICON, anchorX: 0.5, anchorY: 0.5, fit: "contain"}, label: "NOTE.EntryIcon"}),
|
||||
iconSize: new fields.NumberField({required: true, nullable: false, integer: true, min: 32, initial: 40,
|
||||
validationError: "must be an integer greater than 32", label: "NOTE.IconSize"}),
|
||||
text: new fields.StringField({label: "NOTE.TextLabel", textSearch: true}),
|
||||
fontFamily: new fields.StringField({required: true, label: "NOTE.FontFamily",
|
||||
initial: () => globalThis.CONFIG?.defaultFontFamily || "Signika"}),
|
||||
fontSize: new fields.NumberField({required: true, integer: true, min: 8, max: 128, initial: 32,
|
||||
validationError: "must be an integer between 8 and 128", label: "NOTE.FontSize"}),
|
||||
textAnchor: new fields.NumberField({required: true, choices: Object.values(CONST.TEXT_ANCHOR_POINTS),
|
||||
initial: CONST.TEXT_ANCHOR_POINTS.BOTTOM, label: "NOTE.AnchorPoint",
|
||||
validationError: "must be a value in CONST.TEXT_ANCHOR_POINTS"}),
|
||||
textColor: new fields.ColorField({required: true, nullable: false, initial: "#ffffff", label: "NOTE.TextColor"}),
|
||||
global: new fields.BooleanField(),
|
||||
flags: new fields.ObjectField()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The default icon used for newly created Note documents.
|
||||
* @type {string}
|
||||
*/
|
||||
static DEFAULT_ICON = "icons/svg/book.svg";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
testUserPermission(user, permission, {exact=false}={}) {
|
||||
if ( user.isGM ) return true; // Game-masters always have control
|
||||
// Players can create and edit unlinked notes with the appropriate permission.
|
||||
if ( !this.entryId ) return user.hasPermission("NOTE_CREATE");
|
||||
if ( !this.entry ) return false; // Otherwise, permission comes through the JournalEntry
|
||||
return this.entry.testUserPermission(user, permission, {exact});
|
||||
}
|
||||
}
|
||||
68
resources/app/common/documents/playlist-sound.mjs
Normal file
68
resources/app/common/documents/playlist-sound.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").PlaylistSoundData} PlaylistSoundData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The PlaylistSound Document.
|
||||
* Defines the DataSchema and common behaviors for a PlaylistSound which are shared between both client and server.
|
||||
* @mixes PlaylistSoundData
|
||||
*/
|
||||
export default class BasePlaylistSound extends Document {
|
||||
/**
|
||||
* Construct a PlaylistSound document using provided data and context.
|
||||
* @param {Partial<PlaylistSoundData>} data Initial data from which to construct the PlaylistSound
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "PlaylistSound",
|
||||
collection: "sounds",
|
||||
indexed: true,
|
||||
label: "DOCUMENT.PlaylistSound",
|
||||
labelPlural: "DOCUMENT.PlaylistSounds",
|
||||
compendiumIndexFields: ["name", "sort"],
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
name: new fields.StringField({required: true, blank: false, textSearch: true}),
|
||||
description: new fields.StringField(),
|
||||
path: new fields.FilePathField({categories: ["AUDIO"]}),
|
||||
channel: new fields.StringField({choices: CONST.AUDIO_CHANNELS, initial: "music", blank: true}),
|
||||
playing: new fields.BooleanField(),
|
||||
pausedTime: new fields.NumberField({min: 0}),
|
||||
repeat: new fields.BooleanField(),
|
||||
volume: new fields.AlphaField({initial: 0.5, step: 0.01}),
|
||||
fade: new fields.NumberField({integer: true, min: 0}),
|
||||
sort: new fields.IntegerSortField(),
|
||||
flags: new fields.ObjectField(),
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
testUserPermission(user, permission, {exact = false} = {}) {
|
||||
if ( this.isEmbedded ) return this.parent.testUserPermission(user, permission, {exact});
|
||||
return super.testUserPermission(user, permission, {exact});
|
||||
}
|
||||
}
|
||||
81
resources/app/common/documents/playlist.mjs
Normal file
81
resources/app/common/documents/playlist.mjs
Normal file
@@ -0,0 +1,81 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").PlaylistData} PlaylistData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Playlist Document.
|
||||
* Defines the DataSchema and common behaviors for a Playlist which are shared between both client and server.
|
||||
* @mixes PlaylistData
|
||||
*/
|
||||
export default class BasePlaylist extends Document {
|
||||
/**
|
||||
* Construct a Playlist document using provided data and context.
|
||||
* @param {Partial<PlaylistData>} data Initial data from which to construct the Playlist
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "Playlist",
|
||||
collection: "playlists",
|
||||
indexed: true,
|
||||
compendiumIndexFields: ["_id", "name", "description", "sort", "folder"],
|
||||
embedded: {PlaylistSound: "sounds"},
|
||||
label: "DOCUMENT.Playlist",
|
||||
labelPlural: "DOCUMENT.Playlists",
|
||||
permissions: {
|
||||
create: "PLAYLIST_CREATE"
|
||||
},
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
name: new fields.StringField({required: true, blank: false, textSearch: true}),
|
||||
description: new fields.StringField({textSearch: true}),
|
||||
sounds: new fields.EmbeddedCollectionField(documents.BasePlaylistSound),
|
||||
channel: new fields.StringField({choices: CONST.AUDIO_CHANNELS, initial: "music", blank: false}),
|
||||
mode: new fields.NumberField({required: true, choices: Object.values(CONST.PLAYLIST_MODES),
|
||||
initial: CONST.PLAYLIST_MODES.SEQUENTIAL, validationError: "must be a value in CONST.PLAYLIST_MODES"}),
|
||||
playing: new fields.BooleanField(),
|
||||
fade: new fields.NumberField({positive: true}),
|
||||
folder: new fields.ForeignDocumentField(documents.BaseFolder),
|
||||
sorting: new fields.StringField({required: true, choices: Object.values(CONST.PLAYLIST_SORT_MODES),
|
||||
initial: CONST.PLAYLIST_SORT_MODES.ALPHABETICAL,
|
||||
validationError: "must be a value in CONST.PLAYLIST_SORTING_MODES"}),
|
||||
seed: new fields.NumberField({integer: true, min: 0}),
|
||||
sort: new fields.IntegerSortField(),
|
||||
ownership: new fields.DocumentOwnershipField(),
|
||||
flags: new fields.ObjectField(),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static migrateData(source) {
|
||||
/**
|
||||
* Migrate sourceId.
|
||||
* @deprecated since v12
|
||||
*/
|
||||
this._addDataFieldMigration(source, "flags.core.sourceId", "_stats.compendiumSource");
|
||||
return super.migrateData(source);
|
||||
}
|
||||
}
|
||||
85
resources/app/common/documents/region-behavior.mjs
Normal file
85
resources/app/common/documents/region-behavior.mjs
Normal file
@@ -0,0 +1,85 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").RegionBehaviorData} RegionBehaviorData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The RegionBehavior Document.
|
||||
* Defines the DataSchema and common behaviors for a RegionBehavior which are shared between both client and server.
|
||||
* @mixes SceneRegionData
|
||||
*/
|
||||
export default class BaseRegionBehavior extends Document {
|
||||
/**
|
||||
* Construct a RegionBehavior document using provided data and context.
|
||||
* @param {Partial<RegionBehaviorData>} data Initial data from which to construct the RegionBehavior
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "RegionBehavior",
|
||||
collection: "behaviors",
|
||||
label: "DOCUMENT.RegionBehavior",
|
||||
labelPlural: "DOCUMENT.RegionBehaviors",
|
||||
coreTypes: ["adjustDarknessLevel", "displayScrollingText", "executeMacro", "executeScript", "pauseGame", "suppressWeather", "teleportToken", "toggleBehavior"],
|
||||
hasTypeData: true,
|
||||
isEmbedded: true,
|
||||
permissions: {
|
||||
create: this.#canCreate,
|
||||
update: this.#canUpdate
|
||||
},
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
name: new fields.StringField({required: true, blank: true, label: "Name", textSearch: true}),
|
||||
type: new fields.DocumentTypeField(this),
|
||||
system: new fields.TypeDataField(this),
|
||||
disabled: new fields.BooleanField({label: "BEHAVIOR.FIELDS.disabled.label", hint: "BEHAVIOR.FIELDS.disabled.hint"}),
|
||||
flags: new fields.ObjectField(),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static canUserCreate(user) {
|
||||
return user.isGM;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is a user able to create the RegionBehavior document?
|
||||
*/
|
||||
static #canCreate(user, doc) {
|
||||
if ( (doc._source.type === "executeScript") && !user.hasPermission("MACRO_SCRIPT") ) return false;
|
||||
return user.isGM;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is a user able to update the RegionBehavior document?
|
||||
*/
|
||||
static #canUpdate(user, doc, data) {
|
||||
if ( (((doc._source.type === "executeScript") && ("system" in data) && ("source" in data.system))
|
||||
|| (data.type === "executeScript")) && !user.hasPermission("MACRO_SCRIPT") ) return false;
|
||||
return user.isGM;
|
||||
}
|
||||
}
|
||||
81
resources/app/common/documents/region.mjs
Normal file
81
resources/app/common/documents/region.mjs
Normal file
@@ -0,0 +1,81 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
import {BaseShapeData} from "../data/data.mjs";
|
||||
import Color from "../utils/color.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").RegionData} RegionData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Region Document.
|
||||
* Defines the DataSchema and common behaviors for a Region which are shared between both client and server.
|
||||
* @mixes RegionData
|
||||
*/
|
||||
export default class BaseRegion extends Document {
|
||||
/**
|
||||
* Construct a Region document using provided data and context.
|
||||
* @param {Partial<RegionData>} data Initial data from which to construct the Region
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "Region",
|
||||
collection: "regions",
|
||||
label: "DOCUMENT.Region",
|
||||
labelPlural: "DOCUMENT.Regions",
|
||||
isEmbedded: true,
|
||||
embedded: {
|
||||
RegionBehavior: "behaviors"
|
||||
},
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
name: new fields.StringField({required: true, blank: false, label: "Name", textSearch: true}),
|
||||
color: new fields.ColorField({required: true, nullable: false,
|
||||
initial: () => Color.fromHSV([Math.random(), 0.8, 0.8]).css,
|
||||
label: "REGION.FIELDS.color.label",
|
||||
hint: "REGION.FIELDS.color.hint"}),
|
||||
shapes: new fields.ArrayField(new fields.TypedSchemaField(BaseShapeData.TYPES),
|
||||
{label: "REGION.FIELDS.shapes.label", hint: "REGION.FIELDS.shapes.hint"}),
|
||||
elevation: new fields.SchemaField({
|
||||
bottom: new fields.NumberField({required: true,
|
||||
label: "REGION.FIELDS.elevation.FIELDS.bottom.label",
|
||||
hint: "REGION.FIELDS.elevation.FIELDS.bottom.hint"}), // null -> -Infinity
|
||||
top: new fields.NumberField({required: true,
|
||||
label: "REGION.FIELDS.elevation.FIELDS.top.label",
|
||||
hint: "REGION.FIELDS.elevation.FIELDS.top.hint"}) // null -> +Infinity
|
||||
}, {
|
||||
label: "REGION.FIELDS.elevation.label",
|
||||
hint: "REGION.FIELDS.elevation.hint",
|
||||
validate: d => (d.bottom ?? -Infinity) <= (d.top ?? Infinity),
|
||||
validationError: "elevation.top may not be less than elevation.bottom"
|
||||
}),
|
||||
behaviors: new fields.EmbeddedCollectionField(documents.BaseRegionBehavior, {label: "REGION.FIELDS.behaviors.label",
|
||||
hint: "REGION.FIELDS.behaviors.hint"}),
|
||||
visibility: new fields.NumberField({required: true,
|
||||
initial: CONST.REGION_VISIBILITY.LAYER,
|
||||
choices: Object.fromEntries(Object.entries(CONST.REGION_VISIBILITY).map(([key, value]) =>
|
||||
[value, {label: `REGION.VISIBILITY.${key}.label`}])),
|
||||
label: "REGION.FIELDS.visibility.label",
|
||||
hint: "REGION.FIELDS.visibility.hint"}),
|
||||
locked: new fields.BooleanField(),
|
||||
flags: new fields.ObjectField()
|
||||
}
|
||||
};
|
||||
}
|
||||
79
resources/app/common/documents/roll-table.mjs
Normal file
79
resources/app/common/documents/roll-table.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").RollTableData} RollTableData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The RollTable Document.
|
||||
* Defines the DataSchema and common behaviors for a RollTable which are shared between both client and server.
|
||||
* @mixes RollTableData
|
||||
*/
|
||||
export default class BaseRollTable extends Document {
|
||||
/**
|
||||
* Construct a RollTable document using provided data and context.
|
||||
* @param {Partial<RollTableData>} data Initial data from which to construct the RollTable
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "RollTable",
|
||||
collection: "tables",
|
||||
indexed: true,
|
||||
compendiumIndexFields: ["_id", "name", "description", "img", "sort", "folder"],
|
||||
embedded: {TableResult: "results"},
|
||||
label: "DOCUMENT.RollTable",
|
||||
labelPlural: "DOCUMENT.RollTables",
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritDoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
name: new fields.StringField({required: true, blank: false, textSearch: true}),
|
||||
img: new fields.FilePathField({categories: ["IMAGE"], initial: () => this.DEFAULT_ICON}),
|
||||
description: new fields.HTMLField({textSearch: true}),
|
||||
results: new fields.EmbeddedCollectionField(documents.BaseTableResult),
|
||||
formula: new fields.StringField(),
|
||||
replacement: new fields.BooleanField({initial: true}),
|
||||
displayRoll: new fields.BooleanField({initial: true}),
|
||||
folder: new fields.ForeignDocumentField(documents.BaseFolder),
|
||||
sort: new fields.IntegerSortField(),
|
||||
ownership: new fields.DocumentOwnershipField(),
|
||||
flags: new fields.ObjectField(),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The default icon used for newly created Macro documents
|
||||
* @type {string}
|
||||
*/
|
||||
static DEFAULT_ICON = "icons/svg/d20-grey.svg";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static migrateData(source) {
|
||||
/**
|
||||
* Migrate sourceId.
|
||||
* @deprecated since v12
|
||||
*/
|
||||
this._addDataFieldMigration(source, "flags.core.sourceId", "_stats.compendiumSource");
|
||||
|
||||
return super.migrateData(source);
|
||||
}
|
||||
}
|
||||
262
resources/app/common/documents/scene.mjs
Normal file
262
resources/app/common/documents/scene.mjs
Normal file
@@ -0,0 +1,262 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import {TextureData} from "../data/data.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").SceneData} SceneData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Scene Document.
|
||||
* Defines the DataSchema and common behaviors for a Scene which are shared between both client and server.
|
||||
* @mixes SceneData
|
||||
*/
|
||||
export default class BaseScene extends Document {
|
||||
/**
|
||||
* Construct a Scene document using provided data and context.
|
||||
* @param {Partial<SceneData>} data Initial data from which to construct the Scene
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "Scene",
|
||||
collection: "scenes",
|
||||
indexed: true,
|
||||
compendiumIndexFields: ["_id", "name", "thumb", "sort", "folder"],
|
||||
embedded: {
|
||||
AmbientLight: "lights",
|
||||
AmbientSound: "sounds",
|
||||
Drawing: "drawings",
|
||||
MeasuredTemplate: "templates",
|
||||
Note: "notes",
|
||||
Region: "regions",
|
||||
Tile: "tiles",
|
||||
Token: "tokens",
|
||||
Wall: "walls"
|
||||
},
|
||||
label: "DOCUMENT.Scene",
|
||||
labelPlural: "DOCUMENT.Scenes",
|
||||
preserveOnImport: [...super.metadata.preserveOnImport, "active"],
|
||||
schemaVersion: "12.325"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
// Define reusable ambience schema for environment
|
||||
const environmentData = defaults => new fields.SchemaField({
|
||||
hue: new fields.HueField({required: true, initial: defaults.hue,
|
||||
label: "SCENES.ENVIRONMENT.Hue", hint: "SCENES.ENVIRONMENT.HueHint"}),
|
||||
intensity: new fields.AlphaField({required: true, nullable: false, initial: defaults.intensity,
|
||||
label: "SCENES.ENVIRONMENT.Intensity", hint: "SCENES.ENVIRONMENT.IntensityHint"}),
|
||||
luminosity: new fields.NumberField({required: true, nullable: false, initial: defaults.luminosity, min: -1, max: 1,
|
||||
label: "SCENES.ENVIRONMENT.Luminosity", hint: "SCENES.ENVIRONMENT.LuminosityHint"}),
|
||||
saturation: new fields.NumberField({required: true, nullable: false, initial: defaults.saturation, min: -1, max: 1,
|
||||
label: "SCENES.ENVIRONMENT.Saturation", hint: "SCENES.ENVIRONMENT.SaturationHint"}),
|
||||
shadows: new fields.NumberField({required: true, nullable: false, initial: defaults.shadows, min: 0, max: 1,
|
||||
label: "SCENES.ENVIRONMENT.Shadows", hint: "SCENES.ENVIRONMENT.ShadowsHint"})
|
||||
});
|
||||
// Reuse parts of the LightData schema for the global light
|
||||
const lightDataSchema = foundry.data.LightData.defineSchema();
|
||||
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
name: new fields.StringField({required: true, blank: false, textSearch: true}),
|
||||
|
||||
// Navigation
|
||||
active: new fields.BooleanField(),
|
||||
navigation: new fields.BooleanField({initial: true}),
|
||||
navOrder: new fields.NumberField({required: true, nullable: false, integer: true, initial: 0}),
|
||||
navName: new fields.HTMLField({textSearch: true}),
|
||||
|
||||
// Canvas Dimensions
|
||||
background: new TextureData(),
|
||||
foreground: new fields.FilePathField({categories: ["IMAGE", "VIDEO"]}),
|
||||
foregroundElevation: new fields.NumberField({required: true, positive: true, integer: true}),
|
||||
thumb: new fields.FilePathField({categories: ["IMAGE"]}),
|
||||
width: new fields.NumberField({integer: true, positive: true, initial: 4000}),
|
||||
height: new fields.NumberField({integer: true, positive: true, initial: 3000}),
|
||||
padding: new fields.NumberField({required: true, nullable: false, min: 0, max: 0.5, step: 0.05, initial: 0.25}),
|
||||
initial: new fields.SchemaField({
|
||||
x: new fields.NumberField({integer: true, required: true}),
|
||||
y: new fields.NumberField({integer: true, required: true}),
|
||||
scale: new fields.NumberField({required: true, max: 3, positive: true, initial: 0.5})
|
||||
}),
|
||||
backgroundColor: new fields.ColorField({nullable: false, initial: "#999999"}),
|
||||
|
||||
// Grid Configuration
|
||||
grid: new fields.SchemaField({
|
||||
type: new fields.NumberField({required: true, choices: Object.values(CONST.GRID_TYPES),
|
||||
initial: () => game.system.grid.type, validationError: "must be a value in CONST.GRID_TYPES"}),
|
||||
size: new fields.NumberField({required: true, nullable: false, integer: true, min: CONST.GRID_MIN_SIZE,
|
||||
initial: 100, validationError: `must be an integer number of pixels, ${CONST.GRID_MIN_SIZE} or greater`}),
|
||||
style: new fields.StringField({required: true, blank: false, initial: "solidLines"}),
|
||||
thickness: new fields.NumberField({required: true, nullable: false, positive: true, integer: true, initial: 1}),
|
||||
color: new fields.ColorField({required: true, nullable: false, initial: "#000000"}),
|
||||
alpha: new fields.AlphaField({initial: 0.2}),
|
||||
distance: new fields.NumberField({required: true, nullable: false, positive: true,
|
||||
initial: () => game.system.grid.distance}),
|
||||
units: new fields.StringField({required: true, initial: () => game.system.grid.units})
|
||||
}),
|
||||
|
||||
// Vision Configuration
|
||||
tokenVision: new fields.BooleanField({initial: true}),
|
||||
fog: new fields.SchemaField({
|
||||
exploration: new fields.BooleanField({initial: true}),
|
||||
reset: new fields.NumberField({required: false, initial: undefined}),
|
||||
overlay: new fields.FilePathField({categories: ["IMAGE", "VIDEO"]}),
|
||||
colors: new fields.SchemaField({
|
||||
explored: new fields.ColorField({label: "SCENES.FogExploredColor"}),
|
||||
unexplored: new fields.ColorField({label: "SCENES.FogUnexploredColor"})
|
||||
})
|
||||
}),
|
||||
|
||||
// Environment Configuration
|
||||
environment: new fields.SchemaField({
|
||||
darknessLevel: new fields.AlphaField({initial: 0}),
|
||||
darknessLock: new fields.BooleanField({initial: false}),
|
||||
globalLight: new fields.SchemaField({
|
||||
enabled: new fields.BooleanField({required: true, initial: false}),
|
||||
alpha: lightDataSchema.alpha,
|
||||
bright: new fields.BooleanField({required: true, initial: false}),
|
||||
color: lightDataSchema.color,
|
||||
coloration: lightDataSchema.coloration,
|
||||
luminosity: new fields.NumberField({required: true, nullable: false, initial: 0, min: 0, max: 1}),
|
||||
saturation: lightDataSchema.saturation,
|
||||
contrast: lightDataSchema.contrast,
|
||||
shadows: lightDataSchema.shadows,
|
||||
darkness: lightDataSchema.darkness
|
||||
}),
|
||||
cycle: new fields.BooleanField({initial: true}),
|
||||
base: environmentData({hue: 0, intensity: 0, luminosity: 0, saturation: 0, shadows: 0}),
|
||||
dark: environmentData({hue: 257/360, intensity: 0, luminosity: -0.25, saturation: 0, shadows: 0})
|
||||
}),
|
||||
|
||||
// Embedded Collections
|
||||
drawings: new fields.EmbeddedCollectionField(documents.BaseDrawing),
|
||||
tokens: new fields.EmbeddedCollectionField(documents.BaseToken),
|
||||
lights: new fields.EmbeddedCollectionField(documents.BaseAmbientLight),
|
||||
notes: new fields.EmbeddedCollectionField(documents.BaseNote),
|
||||
sounds: new fields.EmbeddedCollectionField(documents.BaseAmbientSound),
|
||||
regions: new fields.EmbeddedCollectionField(documents.BaseRegion),
|
||||
templates: new fields.EmbeddedCollectionField(documents.BaseMeasuredTemplate),
|
||||
tiles: new fields.EmbeddedCollectionField(documents.BaseTile),
|
||||
walls: new fields.EmbeddedCollectionField(documents.BaseWall),
|
||||
|
||||
// Linked Documents
|
||||
playlist: new fields.ForeignDocumentField(documents.BasePlaylist),
|
||||
playlistSound: new fields.ForeignDocumentField(documents.BasePlaylistSound, {idOnly: true}),
|
||||
journal: new fields.ForeignDocumentField(documents.BaseJournalEntry),
|
||||
journalEntryPage: new fields.ForeignDocumentField(documents.BaseJournalEntryPage, {idOnly: true}),
|
||||
weather: new fields.StringField({required: true}),
|
||||
|
||||
// Permissions
|
||||
folder: new fields.ForeignDocumentField(documents.BaseFolder),
|
||||
sort: new fields.IntegerSortField(),
|
||||
ownership: new fields.DocumentOwnershipField(),
|
||||
flags: new fields.ObjectField(),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Static Initializer Block for deprecated properties.
|
||||
* @see [Static Initialization Blocks](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Static_initialization_blocks)
|
||||
*/
|
||||
static {
|
||||
const migrations = {
|
||||
fogExploration: "fog.exploration",
|
||||
fogReset: "fog.reset",
|
||||
fogOverlay: "fog.overlay",
|
||||
fogExploredColor: "fog.colors.explored",
|
||||
fogUnexploredColor: "fog.colors.unexplored",
|
||||
globalLight: "environment.globalLight.enabled",
|
||||
globalLightThreshold: "environment.globalLight.darkness.max",
|
||||
darkness: "environment.darknessLevel"
|
||||
};
|
||||
Object.defineProperties(this.prototype, Object.fromEntries(
|
||||
Object.entries(migrations).map(([o, n]) => [o, {
|
||||
get() {
|
||||
this.constructor._logDataFieldMigration(o, n, {since: 12, until: 14});
|
||||
return foundry.utils.getProperty(this, n);
|
||||
},
|
||||
set(v) {
|
||||
this.constructor._logDataFieldMigration(o, n, {since: 12, until: 14});
|
||||
return foundry.utils.setProperty(this, n, v);
|
||||
},
|
||||
configurable: true
|
||||
}])));
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static migrateData(data) {
|
||||
/**
|
||||
* Migration to fog schema fields. Can be safely removed in V14+
|
||||
* @deprecated since v12
|
||||
*/
|
||||
for ( const [oldKey, newKey] of Object.entries({
|
||||
"fogExploration": "fog.exploration",
|
||||
"fogReset": "fog.reset",
|
||||
"fogOverlay": "fog.overlay",
|
||||
"fogExploredColor": "fog.colors.explored",
|
||||
"fogUnexploredColor": "fog.colors.unexplored"
|
||||
}) ) this._addDataFieldMigration(data, oldKey, newKey);
|
||||
|
||||
/**
|
||||
* Migration to global light embedded fields. Can be safely removed in V14+
|
||||
* @deprecated since v12
|
||||
*/
|
||||
this._addDataFieldMigration(data, "globalLight", "environment.globalLight.enabled");
|
||||
this._addDataFieldMigration(data, "globalLightThreshold", "environment.globalLight.darkness.max",
|
||||
d => d.globalLightThreshold ?? 1);
|
||||
|
||||
/**
|
||||
* Migration to environment darkness level. Can be safely removed in V14+
|
||||
* @deprecated since v12
|
||||
*/
|
||||
this._addDataFieldMigration(data, "darkness", "environment.darknessLevel");
|
||||
|
||||
/**
|
||||
* Migrate sourceId.
|
||||
* @deprecated since v12
|
||||
*/
|
||||
this._addDataFieldMigration(data, "flags.core.sourceId", "_stats.compendiumSource");
|
||||
|
||||
return super.migrateData(data);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static shimData(data, options) {
|
||||
/** @deprecated since v12 */
|
||||
this._addDataFieldShims(data, {
|
||||
fogExploration: "fog.exploration",
|
||||
fogReset: "fog.reset",
|
||||
fogOverlay: "fog.overlay",
|
||||
fogExploredColor: "fog.colors.explored",
|
||||
fogUnexploredColor: "fog.colors.unexplored",
|
||||
globalLight: "environment.globalLight.enabled",
|
||||
globalLightThreshold: "environment.globalLight.darkness.max",
|
||||
darkness: "environment.darknessLevel"
|
||||
}, {since: 12, until: 14});
|
||||
return super.shimData(data, options);
|
||||
}
|
||||
}
|
||||
93
resources/app/common/documents/setting.mjs
Normal file
93
resources/app/common/documents/setting.mjs
Normal file
@@ -0,0 +1,93 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").SettingData} SettingData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Setting Document.
|
||||
* Defines the DataSchema and common behaviors for a Setting which are shared between both client and server.
|
||||
* @mixes SettingData
|
||||
*/
|
||||
export default class BaseSetting extends Document {
|
||||
/**
|
||||
* Construct a Setting document using provided data and context.
|
||||
* @param {Partial<SettingData>} data Initial data from which to construct the Setting
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "Setting",
|
||||
collection: "settings",
|
||||
label: "DOCUMENT.Setting",
|
||||
labelPlural: "DOCUMENT.Settings",
|
||||
permissions: {
|
||||
create: this.#canModify,
|
||||
update: this.#canModify,
|
||||
delete: this.#canModify
|
||||
},
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
key: new fields.StringField({required: true, nullable: false, blank: false,
|
||||
validate: k => k.split(".").length >= 2,
|
||||
validationError: "must have the format {scope}.{field}"}),
|
||||
value: new fields.JSONField({required: true, nullable: true, initial: null}),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The settings that only full GMs can modify.
|
||||
* @type {string[]}
|
||||
*/
|
||||
static #GAMEMASTER_ONLY_KEYS = ["core.permissions"];
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The settings that assistant GMs can modify regardless of their permission.
|
||||
* @type {string[]}
|
||||
*/
|
||||
static #ALLOWED_ASSISTANT_KEYS = ["core.time", "core.combatTrackerConfig", "core.sheetClasses", "core.scrollingStatusText",
|
||||
"core.tokenDragPreview", "core.adventureImports", "core.gridDiagonals", "core.gridTemplates", "core.coneTemplateType"];
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static canUserCreate(user) {
|
||||
return user.hasPermission("SETTINGS_MODIFY");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Define special rules which allow certain settings to be updated.
|
||||
* @protected
|
||||
*/
|
||||
static #canModify(user, doc, data) {
|
||||
if ( BaseSetting.#GAMEMASTER_ONLY_KEYS.includes(doc._source.key)
|
||||
&& (!("key" in data) || BaseSetting.#GAMEMASTER_ONLY_KEYS.includes(data.key)) ) return user.hasRole("GAMEMASTER");
|
||||
if ( user.hasPermission("SETTINGS_MODIFY") ) return true;
|
||||
if ( !user.isGM ) return false;
|
||||
return BaseSetting.#ALLOWED_ASSISTANT_KEYS.includes(doc._source.key)
|
||||
&& (!("key" in data) || BaseSetting.#ALLOWED_ASSISTANT_KEYS.includes(data.key));
|
||||
}
|
||||
}
|
||||
104
resources/app/common/documents/table-result.mjs
Normal file
104
resources/app/common/documents/table-result.mjs
Normal file
@@ -0,0 +1,104 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").TableResultData} TableResultData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The TableResult Document.
|
||||
* Defines the DataSchema and common behaviors for a TableResult which are shared between both client and server.
|
||||
* @mixes TableResultData
|
||||
*/
|
||||
export default class BaseTableResult extends Document {
|
||||
/**
|
||||
* Construct a TableResult document using provided data and context.
|
||||
* @param {Partial<TableResultData>} data Initial data from which to construct the TableResult
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "TableResult",
|
||||
collection: "results",
|
||||
label: "DOCUMENT.TableResult",
|
||||
labelPlural: "DOCUMENT.TableResults",
|
||||
coreTypes: Object.values(CONST.TABLE_RESULT_TYPES),
|
||||
permissions: {
|
||||
update: this.#canUpdate
|
||||
},
|
||||
compendiumIndexFields: ["type"],
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
type: new fields.DocumentTypeField(this, {initial: CONST.TABLE_RESULT_TYPES.TEXT}),
|
||||
text: new fields.HTMLField({textSearch: true}),
|
||||
img: new fields.FilePathField({categories: ["IMAGE"]}),
|
||||
documentCollection: new fields.StringField(),
|
||||
documentId: new fields.ForeignDocumentField(Document, {idOnly: true}),
|
||||
weight: new fields.NumberField({required: true, integer: true, positive: true, nullable: false, initial: 1}),
|
||||
range: new fields.ArrayField(new fields.NumberField({integer: true}), {
|
||||
validate: r => (r.length === 2) && (r[1] >= r[0]),
|
||||
validationError: "must be a length-2 array of ascending integers"
|
||||
}),
|
||||
drawn: new fields.BooleanField(),
|
||||
flags: new fields.ObjectField()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a user able to update an existing TableResult?
|
||||
* @private
|
||||
*/
|
||||
static #canUpdate(user, doc, data) {
|
||||
if ( user.isGM ) return true; // GM users can do anything
|
||||
const wasDrawn = new Set(["drawn", "_id"]); // Users can update the drawn status of a result
|
||||
if ( new Set(Object.keys(data)).equals(wasDrawn) ) return true;
|
||||
return doc.parent.canUserModify(user, "update", data); // Otherwise, go by parent document permission
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
testUserPermission(user, permission, {exact=false}={}) {
|
||||
if ( this.isEmbedded ) return this.parent.testUserPermission(user, permission, {exact});
|
||||
return super.testUserPermission(user, permission, {exact});
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static migrateData(data) {
|
||||
|
||||
/**
|
||||
* V12 migration of type from number to string.
|
||||
* @deprecated since v12
|
||||
*/
|
||||
if ( typeof data.type === "number" ) {
|
||||
switch ( data.type ) {
|
||||
case 0: data.type = CONST.TABLE_RESULT_TYPES.TEXT; break;
|
||||
case 1: data.type = CONST.TABLE_RESULT_TYPES.DOCUMENT; break;
|
||||
case 2: data.type = CONST.TABLE_RESULT_TYPES.COMPENDIUM; break;
|
||||
}
|
||||
}
|
||||
return super.migrateData(data);
|
||||
}
|
||||
}
|
||||
151
resources/app/common/documents/tile.mjs
Normal file
151
resources/app/common/documents/tile.mjs
Normal file
@@ -0,0 +1,151 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {getProperty, hasProperty, mergeObject} from "../utils/helpers.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import {TextureData} from "../data/data.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").TileData} TileData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Tile Document.
|
||||
* Defines the DataSchema and common behaviors for a Tile which are shared between both client and server.
|
||||
* @mixes TileData
|
||||
*/
|
||||
export default class BaseTile extends Document {
|
||||
/**
|
||||
* Construct a Tile document using provided data and context.
|
||||
* @param {Partial<TileData>} data Initial data from which to construct the Tile
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "Tile",
|
||||
collection: "tiles",
|
||||
label: "DOCUMENT.Tile",
|
||||
labelPlural: "DOCUMENT.Tiles",
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
texture: new TextureData({}, {initial: {anchorX: 0.5, anchorY: 0.5, alphaThreshold: 0.75}}),
|
||||
width: new fields.NumberField({required: true, min: 0, nullable: false, step: 0.1}),
|
||||
height: new fields.NumberField({required: true, min: 0, nullable: false, step: 0.1}),
|
||||
x: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0, label: "XCoord"}),
|
||||
y: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0, label: "YCoord"}),
|
||||
elevation: new fields.NumberField({required: true, nullable: false, initial: 0}),
|
||||
sort: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0}),
|
||||
rotation: new fields.AngleField(),
|
||||
alpha: new fields.AlphaField(),
|
||||
hidden: new fields.BooleanField(),
|
||||
locked: new fields.BooleanField(),
|
||||
restrictions: new fields.SchemaField({
|
||||
light: new fields.BooleanField(),
|
||||
weather: new fields.BooleanField()
|
||||
}),
|
||||
occlusion: new fields.SchemaField({
|
||||
mode: new fields.NumberField({choices: Object.values(CONST.OCCLUSION_MODES),
|
||||
initial: CONST.OCCLUSION_MODES.NONE,
|
||||
validationError: "must be a value in CONST.TILE_OCCLUSION_MODES"}),
|
||||
alpha: new fields.AlphaField({initial: 0})
|
||||
}),
|
||||
video: new fields.SchemaField({
|
||||
loop: new fields.BooleanField({initial: true}),
|
||||
autoplay: new fields.BooleanField({initial: true}),
|
||||
volume: new fields.AlphaField({initial: 0, step: 0.01})
|
||||
}),
|
||||
flags: new fields.ObjectField()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static migrateData(data) {
|
||||
/**
|
||||
* V12 migration to elevation and sort
|
||||
* @deprecated since v12
|
||||
*/
|
||||
this._addDataFieldMigration(data, "z", "sort");
|
||||
|
||||
/**
|
||||
* V12 migration from roof to restrictions.light and restrictions.weather
|
||||
* @deprecated since v12
|
||||
*/
|
||||
if ( foundry.utils.hasProperty(data, "roof") ) {
|
||||
const value = foundry.utils.getProperty(data, "roof");
|
||||
if ( !foundry.utils.hasProperty(data, "restrictions.light") ) foundry.utils.setProperty(data, "restrictions.light", value);
|
||||
if ( !foundry.utils.hasProperty(data, "restrictions.weather") ) foundry.utils.setProperty(data, "restrictions.weather", value);
|
||||
delete data["roof"];
|
||||
}
|
||||
|
||||
return super.migrateData(data);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static shimData(data, options) {
|
||||
this._addDataFieldShim(data, "z", "sort", {since: 12, until: 14});
|
||||
return super.shimData(data, options);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
set roof(enabled) {
|
||||
this.constructor._logDataFieldMigration("roof", "restrictions.{light|weather}", {since: 12, until: 14});
|
||||
this.restrictions.light = enabled;
|
||||
this.restrictions.weather = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get roof() {
|
||||
this.constructor._logDataFieldMigration("roof", "restrictions.{light|weather}", {since: 12, until: 14});
|
||||
return this.restrictions.light && this.restrictions.weather;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get z() {
|
||||
this.constructor._logDataFieldMigration("z", "sort", {since: 12, until: 14});
|
||||
return this.sort;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get overhead() {
|
||||
foundry.utils.logCompatibilityWarning(`${this.constructor.name}#overhead is deprecated.`, {since: 12, until: 14})
|
||||
return this.elevation >= this.parent?.foregroundElevation;
|
||||
}
|
||||
}
|
||||
295
resources/app/common/documents/token.mjs
Normal file
295
resources/app/common/documents/token.mjs
Normal file
@@ -0,0 +1,295 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
import * as documents from "./_module.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import {LightData, TextureData} from "../data/data.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").TokenData} TokenData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Token Document.
|
||||
* Defines the DataSchema and common behaviors for a Token which are shared between both client and server.
|
||||
* @mixes TokenData
|
||||
*/
|
||||
export default class BaseToken extends Document {
|
||||
/**
|
||||
* Construct a Token document using provided data and context.
|
||||
* @param {Partial<TokenData>} data Initial data from which to construct the Token
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "Token",
|
||||
collection: "tokens",
|
||||
label: "DOCUMENT.Token",
|
||||
labelPlural: "DOCUMENT.Tokens",
|
||||
isEmbedded: true,
|
||||
embedded: {
|
||||
ActorDelta: "delta"
|
||||
},
|
||||
permissions: {
|
||||
create: "TOKEN_CREATE",
|
||||
update: this.#canUpdate,
|
||||
delete: "TOKEN_DELETE"
|
||||
},
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
name: new fields.StringField({required: true, blank: true, textSearch: true}),
|
||||
displayName: new fields.NumberField({required: true, initial: CONST.TOKEN_DISPLAY_MODES.NONE,
|
||||
choices: Object.values(CONST.TOKEN_DISPLAY_MODES),
|
||||
validationError: "must be a value in CONST.TOKEN_DISPLAY_MODES"
|
||||
}),
|
||||
actorId: new fields.ForeignDocumentField(documents.BaseActor, {idOnly: true}),
|
||||
actorLink: new fields.BooleanField(),
|
||||
delta: new ActorDeltaField(documents.BaseActorDelta),
|
||||
appendNumber: new fields.BooleanField(),
|
||||
prependAdjective: new fields.BooleanField(),
|
||||
width: new fields.NumberField({nullable: false, positive: true, initial: 1, step: 0.5, label: "Width"}),
|
||||
height: new fields.NumberField({nullable: false, positive: true, initial: 1, step: 0.5, label: "Height"}),
|
||||
texture: new TextureData({}, {initial: {src: () => this.DEFAULT_ICON, anchorX: 0.5, anchorY: 0.5, fit: "contain",
|
||||
alphaThreshold: 0.75}, wildcard: true}),
|
||||
hexagonalShape: new fields.NumberField({initial: CONST.TOKEN_HEXAGONAL_SHAPES.ELLIPSE_1,
|
||||
choices: Object.values(CONST.TOKEN_HEXAGONAL_SHAPES)}),
|
||||
x: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0, label: "XCoord"}),
|
||||
y: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0, label: "YCoord"}),
|
||||
elevation: new fields.NumberField({required: true, nullable: false, initial: 0}),
|
||||
sort: new fields.NumberField({required: true, integer: true, nullable: false, initial: 0}),
|
||||
locked: new fields.BooleanField(),
|
||||
lockRotation: new fields.BooleanField(),
|
||||
rotation: new fields.AngleField(),
|
||||
alpha: new fields.AlphaField(),
|
||||
hidden: new fields.BooleanField(),
|
||||
disposition: new fields.NumberField({required: true, choices: Object.values(CONST.TOKEN_DISPOSITIONS),
|
||||
initial: CONST.TOKEN_DISPOSITIONS.HOSTILE,
|
||||
validationError: "must be a value in CONST.TOKEN_DISPOSITIONS"
|
||||
}),
|
||||
displayBars: new fields.NumberField({required: true, choices: Object.values(CONST.TOKEN_DISPLAY_MODES),
|
||||
initial: CONST.TOKEN_DISPLAY_MODES.NONE,
|
||||
validationError: "must be a value in CONST.TOKEN_DISPLAY_MODES"
|
||||
}),
|
||||
bar1: new fields.SchemaField({
|
||||
attribute: new fields.StringField({required: true, nullable: true, blank: false,
|
||||
initial: () => game?.system.primaryTokenAttribute || null})
|
||||
}),
|
||||
bar2: new fields.SchemaField({
|
||||
attribute: new fields.StringField({required: true, nullable: true, blank: false,
|
||||
initial: () => game?.system.secondaryTokenAttribute || null})
|
||||
}),
|
||||
light: new fields.EmbeddedDataField(LightData),
|
||||
sight: new fields.SchemaField({
|
||||
enabled: new fields.BooleanField({initial: data => Number(data?.sight?.range) > 0}),
|
||||
range: new fields.NumberField({required: true, nullable: true, min: 0, step: 0.01, initial: 0}),
|
||||
angle: new fields.AngleField({initial: 360, normalize: false}),
|
||||
visionMode: new fields.StringField({required: true, blank: false, initial: "basic",
|
||||
label: "TOKEN.VisionMode", hint: "TOKEN.VisionModeHint"}),
|
||||
color: new fields.ColorField({label: "TOKEN.VisionColor"}),
|
||||
attenuation: new fields.AlphaField({initial: 0.1, label: "TOKEN.VisionAttenuation", hint: "TOKEN.VisionAttenuationHint"}),
|
||||
brightness: new fields.NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1,
|
||||
label: "TOKEN.VisionBrightness", hint: "TOKEN.VisionBrightnessHint"}),
|
||||
saturation: new fields.NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1,
|
||||
label: "TOKEN.VisionSaturation", hint: "TOKEN.VisionSaturationHint"}),
|
||||
contrast: new fields.NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1,
|
||||
label: "TOKEN.VisionContrast", hint: "TOKEN.VisionContrastHint"})
|
||||
}),
|
||||
detectionModes: new fields.ArrayField(new fields.SchemaField({
|
||||
id: new fields.StringField(),
|
||||
enabled: new fields.BooleanField({initial: true}),
|
||||
range: new fields.NumberField({required: true, min: 0, step: 0.01})
|
||||
}), {
|
||||
validate: BaseToken.#validateDetectionModes
|
||||
}),
|
||||
occludable: new fields.SchemaField({
|
||||
radius: new fields.NumberField({nullable: false, min: 0, step: 0.01, initial: 0})
|
||||
}),
|
||||
ring: new fields.SchemaField({
|
||||
enabled: new fields.BooleanField(),
|
||||
colors: new fields.SchemaField({
|
||||
ring: new fields.ColorField(),
|
||||
background: new fields.ColorField()
|
||||
}),
|
||||
effects: new fields.NumberField({initial: 1, min: 0, max: 8388607, integer: true}),
|
||||
subject: new fields.SchemaField({
|
||||
scale: new fields.NumberField({initial: 1, min: 0.5}),
|
||||
texture: new fields.FilePathField({categories: ["IMAGE"]})
|
||||
})
|
||||
}),
|
||||
/** @internal */
|
||||
_regions: new fields.ArrayField(new fields.ForeignDocumentField(documents.BaseRegion, {idOnly: true})),
|
||||
flags: new fields.ObjectField()
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["TOKEN"];
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate the structure of the detection modes array
|
||||
* @param {object[]} modes Configured detection modes
|
||||
* @throws An error if the array is invalid
|
||||
*/
|
||||
static #validateDetectionModes(modes) {
|
||||
const seen = new Set();
|
||||
for ( const mode of modes ) {
|
||||
if ( mode.id === "" ) continue;
|
||||
if ( seen.has(mode.id) ) {
|
||||
throw new Error(`may not have more than one configured detection mode of type "${mode.id}"`);
|
||||
}
|
||||
seen.add(mode.id);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The default icon used for newly created Token documents
|
||||
* @type {string}
|
||||
*/
|
||||
static DEFAULT_ICON = CONST.DEFAULT_TOKEN;
|
||||
|
||||
/**
|
||||
* Is a user able to update an existing Token?
|
||||
* @private
|
||||
*/
|
||||
static #canUpdate(user, doc, data) {
|
||||
if ( user.isGM ) return true; // GM users can do anything
|
||||
if ( doc.actor ) { // You can update Tokens for Actors you control
|
||||
return doc.actor.canUserModify(user, "update", data);
|
||||
}
|
||||
return !!doc.actorId; // It would be good to harden this in the future
|
||||
}
|
||||
|
||||
/** @override */
|
||||
testUserPermission(user, permission, {exact=false} = {}) {
|
||||
if ( this.actor ) return this.actor.testUserPermission(user, permission, {exact});
|
||||
else return super.testUserPermission(user, permission, {exact});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
updateSource(changes={}, options={}) {
|
||||
const diff = super.updateSource(changes, options);
|
||||
|
||||
// A copy of the source data is taken for the _backup in updateSource. When this backup is applied as part of a dry-
|
||||
// run, if a child singleton embedded document was updated, the reference to its source is broken. We restore it
|
||||
// here.
|
||||
if ( options.dryRun && ("delta" in changes) ) this._source.delta = this.delta._source;
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
toObject(source=true) {
|
||||
const obj = super.toObject(source);
|
||||
obj.delta = this.delta ? this.delta.toObject(source) : null;
|
||||
return obj;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static migrateData(data) {
|
||||
|
||||
// Remember that any migrations defined here may also be required for the PrototypeToken model.
|
||||
|
||||
/**
|
||||
* Migration of actorData field to ActorDelta document.
|
||||
* @deprecated since v11
|
||||
*/
|
||||
if ( ("actorData" in data) && !("delta" in data) ) {
|
||||
data.delta = data.actorData;
|
||||
if ( "_id" in data ) data.delta._id = data._id;
|
||||
}
|
||||
return super.migrateData(data);
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static shimData(data, options) {
|
||||
|
||||
// Remember that any shims defined here may also be required for the PrototypeToken model.
|
||||
|
||||
this._addDataFieldShim(data, "actorData", "delta", {value: data.delta, since: 11, until: 13});
|
||||
this._addDataFieldShim(data, "effects", undefined, {value: [], since: 12, until: 14,
|
||||
warning: "TokenDocument#effects is deprecated in favor of using ActiveEffect"
|
||||
+ " documents on the associated Actor"});
|
||||
this._addDataFieldShim(data, "overlayEffect", undefined, {value: "", since: 12, until: 14,
|
||||
warning: "TokenDocument#overlayEffect is deprecated in favor of using" +
|
||||
" ActiveEffect documents on the associated Actor"});
|
||||
return super.shimData(data, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get effects() {
|
||||
foundry.utils.logCompatibilityWarning("TokenDocument#effects is deprecated in favor of using ActiveEffect"
|
||||
+ " documents on the associated Actor", {since: 12, until: 14, once: true});
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get overlayEffect() {
|
||||
foundry.utils.logCompatibilityWarning("TokenDocument#overlayEffect is deprecated in favor of using" +
|
||||
" ActiveEffect documents on the associated Actor", {since: 12, until: 14, once: true});
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A special subclass of EmbeddedDocumentField which allows construction of the ActorDelta to be lazily evaluated.
|
||||
*/
|
||||
export class ActorDeltaField extends fields.EmbeddedDocumentField {
|
||||
/** @inheritdoc */
|
||||
initialize(value, model, options = {}) {
|
||||
if ( !value ) return value;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(model, this.name);
|
||||
if ( (descriptor === undefined) || (!descriptor.get && !descriptor.value) ) {
|
||||
return () => {
|
||||
const m = new this.model(value, {...options, parent: model, parentCollection: this.name});
|
||||
Object.defineProperty(m, "schema", {value: this});
|
||||
Object.defineProperty(model, this.name, {
|
||||
value: m,
|
||||
configurable: true,
|
||||
writable: true
|
||||
});
|
||||
return m;
|
||||
};
|
||||
}
|
||||
else if ( descriptor.get instanceof Function ) return descriptor.get;
|
||||
model[this.name]._initialize(options);
|
||||
return model[this.name];
|
||||
}
|
||||
}
|
||||
248
resources/app/common/documents/user.mjs
Normal file
248
resources/app/common/documents/user.mjs
Normal file
@@ -0,0 +1,248 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
import {isEmpty, mergeObject} from "../utils/helpers.mjs";
|
||||
import {isValidId} from "../data/validators.mjs";
|
||||
import Color from "../utils/color.mjs";
|
||||
import BaseActor from "./actor.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").UserData} UserData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The User Document.
|
||||
* Defines the DataSchema and common behaviors for a User which are shared between both client and server.
|
||||
* @mixes UserData
|
||||
*/
|
||||
export default class BaseUser extends Document {
|
||||
/**
|
||||
* Construct a User document using provided data and context.
|
||||
* @param {Partial<UserData>} data Initial data from which to construct the User
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "User",
|
||||
collection: "users",
|
||||
label: "DOCUMENT.User",
|
||||
labelPlural: "DOCUMENT.Users",
|
||||
permissions: {
|
||||
create: this.#canCreate,
|
||||
update: this.#canUpdate,
|
||||
delete: this.#canDelete
|
||||
},
|
||||
schemaVersion: "12.324",
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["USER"];
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
name: new fields.StringField({required: true, blank: false, textSearch: true}),
|
||||
role: new fields.NumberField({required: true, choices: Object.values(CONST.USER_ROLES),
|
||||
initial: CONST.USER_ROLES.PLAYER, readonly: true}),
|
||||
password: new fields.StringField({required: true, blank: true}),
|
||||
passwordSalt: new fields.StringField(),
|
||||
avatar: new fields.FilePathField({categories: ["IMAGE"]}),
|
||||
character: new fields.ForeignDocumentField(BaseActor),
|
||||
color: new fields.ColorField({required: true, nullable: false,
|
||||
initial: () => Color.fromHSV([Math.random(), 0.8, 0.8]).css
|
||||
}),
|
||||
pronouns: new fields.StringField({required: true}),
|
||||
hotbar: new fields.ObjectField({required: true, validate: BaseUser.#validateHotbar,
|
||||
validationError: "must be a mapping of slots to macro identifiers"}),
|
||||
permissions: new fields.ObjectField({required: true, validate: BaseUser.#validatePermissions,
|
||||
validationError: "must be a mapping of permission names to booleans"}),
|
||||
flags: new fields.ObjectField(),
|
||||
_stats: new fields.DocumentStatsField()
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate the structure of the User hotbar object
|
||||
* @param {object} bar The attempted hotbar data
|
||||
* @return {boolean}
|
||||
* @private
|
||||
*/
|
||||
static #validateHotbar(bar) {
|
||||
if ( typeof bar !== "object" ) return false;
|
||||
for ( let [k, v] of Object.entries(bar) ) {
|
||||
let slot = parseInt(k);
|
||||
if ( !slot || slot < 1 || slot > 50 ) return false;
|
||||
if ( !isValidId(v) ) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate the structure of the User permissions object
|
||||
* @param {object} perms The attempted permissions data
|
||||
* @return {boolean}
|
||||
*/
|
||||
static #validatePermissions(perms) {
|
||||
for ( let [k, v] of Object.entries(perms) ) {
|
||||
if ( typeof k !== "string" ) return false;
|
||||
if ( k.startsWith("-=") ) {
|
||||
if ( v !== null ) return false;
|
||||
} else {
|
||||
if ( typeof v !== "boolean" ) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience test for whether this User has the NONE role.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isBanned() {
|
||||
return this.role === CONST.USER_ROLES.NONE;
|
||||
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether the User has a GAMEMASTER or ASSISTANT role in this World?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isGM() {
|
||||
return this.hasRole(CONST.USER_ROLES.ASSISTANT);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether the User is able to perform a certain permission action.
|
||||
* The provided permission string may pertain to an explicit permission setting or a named user role.
|
||||
*
|
||||
* @param {string} action The action to test
|
||||
* @return {boolean} Does the user have the ability to perform this action?
|
||||
*/
|
||||
can(action) {
|
||||
if ( action in CONST.USER_PERMISSIONS ) return this.hasPermission(action);
|
||||
return this.hasRole(action);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getUserLevel(user) {
|
||||
return CONST.DOCUMENT_OWNERSHIP_LEVELS[user.id === this.id ? "OWNER" : "NONE"];
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether the User has at least a specific permission
|
||||
* @param {string} permission The permission name from USER_PERMISSIONS to test
|
||||
* @return {boolean} Does the user have at least this permission
|
||||
*/
|
||||
hasPermission(permission) {
|
||||
if ( this.isBanned ) return false;
|
||||
|
||||
// CASE 1: The user has the permission set explicitly
|
||||
const explicit = this.permissions[permission];
|
||||
if (explicit !== undefined) return explicit;
|
||||
|
||||
// CASE 2: Permission defined by the user's role
|
||||
const rolePerms = game.permissions[permission];
|
||||
return rolePerms ? rolePerms.includes(this.role) : false;
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether the User has at least the permission level of a certain role
|
||||
* @param {string|number} role The role name from USER_ROLES to test
|
||||
* @param {boolean} [exact] Require the role match to be exact
|
||||
* @return {boolean} Does the user have at this role level (or greater)?
|
||||
*/
|
||||
hasRole(role, {exact = false} = {}) {
|
||||
const level = typeof role === "string" ? CONST.USER_ROLES[role] : role;
|
||||
if (level === undefined) return false;
|
||||
return exact ? this.role === level : this.role >= level;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* Model Permissions */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is a user able to create an existing User?
|
||||
* @param {BaseUser} user The user attempting the creation.
|
||||
* @param {BaseUser} doc The User document being created.
|
||||
* @param {object} data The supplied creation data.
|
||||
* @private
|
||||
*/
|
||||
static #canCreate(user, doc, data) {
|
||||
if ( !user.isGM ) return false; // Only Assistants and above can create users.
|
||||
// Do not allow Assistants to create a new user with special permissions which might be greater than their own.
|
||||
if ( !isEmpty(doc.permissions) ) return user.hasRole(CONST.USER_ROLES.GAMEMASTER);
|
||||
return user.hasRole(doc.role);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is a user able to update an existing User?
|
||||
* @param {BaseUser} user The user attempting the update.
|
||||
* @param {BaseUser} doc The User document being updated.
|
||||
* @param {object} changes Proposed changes.
|
||||
* @private
|
||||
*/
|
||||
static #canUpdate(user, doc, changes) {
|
||||
const roles = CONST.USER_ROLES;
|
||||
if ( user.role === roles.GAMEMASTER ) return true; // Full GMs can do everything
|
||||
if ( user.role === roles.NONE ) return false; // Banned users can do nothing
|
||||
|
||||
// Non-GMs cannot update certain fields.
|
||||
const restricted = ["permissions", "passwordSalt"];
|
||||
if ( user.role < roles.ASSISTANT ) restricted.push("name", "role");
|
||||
if ( doc.role === roles.GAMEMASTER ) restricted.push("password");
|
||||
if ( restricted.some(k => k in changes) ) return false;
|
||||
|
||||
// Role changes may not escalate
|
||||
if ( ("role" in changes) && !user.hasRole(changes.role) ) return false;
|
||||
|
||||
// Assistant GMs may modify other users. Players may only modify themselves
|
||||
return user.isGM || (user.id === doc.id);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is a user able to delete an existing User?
|
||||
* Only Assistants and Gamemasters can delete users, and only if the target user has a lesser or equal role.
|
||||
* @param {BaseUser} user The user attempting the deletion.
|
||||
* @param {BaseUser} doc The User document being deleted.
|
||||
* @private
|
||||
*/
|
||||
static #canDelete(user, doc) {
|
||||
const role = Math.max(CONST.USER_ROLES.ASSISTANT, doc.role);
|
||||
return user.hasRole(role);
|
||||
}
|
||||
}
|
||||
93
resources/app/common/documents/wall.mjs
Normal file
93
resources/app/common/documents/wall.mjs
Normal file
@@ -0,0 +1,93 @@
|
||||
import Document from "../abstract/document.mjs";
|
||||
import {mergeObject} from "../utils/helpers.mjs";
|
||||
import * as CONST from "../constants.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./_types.mjs").WallData} WallData
|
||||
* @typedef {import("../types.mjs").DocumentConstructionContext} DocumentConstructionContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Wall Document.
|
||||
* Defines the DataSchema and common behaviors for a Wall which are shared between both client and server.
|
||||
* @mixes WallData
|
||||
*/
|
||||
export default class BaseWall extends Document {
|
||||
/**
|
||||
* Construct a Wall document using provided data and context.
|
||||
* @param {Partial<WallData>} data Initial data from which to construct the Wall
|
||||
* @param {DocumentConstructionContext} context Construction context options
|
||||
*/
|
||||
constructor(data, context) {
|
||||
super(data, context);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Model Configuration */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static metadata = Object.freeze(mergeObject(super.metadata, {
|
||||
name: "Wall",
|
||||
collection: "walls",
|
||||
label: "DOCUMENT.Wall",
|
||||
labelPlural: "DOCUMENT.Walls",
|
||||
permissions: {
|
||||
update: this.#canUpdate
|
||||
},
|
||||
schemaVersion: "12.324"
|
||||
}, {inplace: false}));
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
c: new fields.ArrayField(new fields.NumberField({required: true, integer: true, nullable: false}), {
|
||||
validate: c => (c.length === 4),
|
||||
validationError: "must be a length-4 array of integer coordinates"}),
|
||||
light: new fields.NumberField({required: true, choices: Object.values(CONST.WALL_SENSE_TYPES),
|
||||
initial: CONST.WALL_SENSE_TYPES.NORMAL,
|
||||
validationError: "must be a value in CONST.WALL_SENSE_TYPES"}),
|
||||
move: new fields.NumberField({required: true, choices: Object.values(CONST.WALL_MOVEMENT_TYPES),
|
||||
initial: CONST.WALL_MOVEMENT_TYPES.NORMAL,
|
||||
validationError: "must be a value in CONST.WALL_MOVEMENT_TYPES"}),
|
||||
sight: new fields.NumberField({required: true, choices: Object.values(CONST.WALL_SENSE_TYPES),
|
||||
initial: CONST.WALL_SENSE_TYPES.NORMAL,
|
||||
validationError: "must be a value in CONST.WALL_SENSE_TYPES"}),
|
||||
sound: new fields.NumberField({required: true, choices: Object.values(CONST.WALL_SENSE_TYPES),
|
||||
initial: CONST.WALL_SENSE_TYPES.NORMAL,
|
||||
validationError: "must be a value in CONST.WALL_SENSE_TYPES"}),
|
||||
dir: new fields.NumberField({required: true, choices: Object.values(CONST.WALL_DIRECTIONS),
|
||||
initial: CONST.WALL_DIRECTIONS.BOTH,
|
||||
validationError: "must be a value in CONST.WALL_DIRECTIONS"}),
|
||||
door: new fields.NumberField({required: true, choices: Object.values(CONST.WALL_DOOR_TYPES),
|
||||
initial: CONST.WALL_DOOR_TYPES.NONE,
|
||||
validationError: "must be a value in CONST.WALL_DOOR_TYPES"}),
|
||||
ds: new fields.NumberField({required: true, choices: Object.values(CONST.WALL_DOOR_STATES),
|
||||
initial: CONST.WALL_DOOR_STATES.CLOSED,
|
||||
validationError: "must be a value in CONST.WALL_DOOR_STATES"}),
|
||||
doorSound: new fields.StringField({required: false, blank: true, initial: undefined}),
|
||||
threshold: new fields.SchemaField({
|
||||
light: new fields.NumberField({required: true, nullable: true, initial: null, positive: true}),
|
||||
sight: new fields.NumberField({required: true, nullable: true, initial: null, positive: true}),
|
||||
sound: new fields.NumberField({required: true, nullable: true, initial: null, positive: true}),
|
||||
attenuation: new fields.BooleanField()
|
||||
}),
|
||||
flags: new fields.ObjectField()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a user able to update an existing Wall?
|
||||
* @private
|
||||
*/
|
||||
static #canUpdate(user, doc, data) {
|
||||
if ( user.isGM ) return true; // GM users can do anything
|
||||
const dsOnly = Object.keys(data).every(k => ["_id", "ds"].includes(k));
|
||||
if ( dsOnly && (doc.ds !== CONST.WALL_DOOR_STATES.LOCKED) && (data.ds !== CONST.WALL_DOOR_STATES.LOCKED) ) {
|
||||
return user.hasRole("PLAYER"); // Players may open and close unlocked doors
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
6
resources/app/common/grid/_module.mjs
Normal file
6
resources/app/common/grid/_module.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @module foundry.grid */
|
||||
export {default as BaseGrid} from "./base.mjs";
|
||||
export {default as GridHex} from "./grid-hex.mjs";
|
||||
export {default as GridlessGrid} from "./gridless.mjs";
|
||||
export {default as HexagonalGrid} from "./hexagonal.mjs";
|
||||
export {default as SquareGrid} from "./square.mjs";
|
||||
861
resources/app/common/grid/base.mjs
Normal file
861
resources/app/common/grid/base.mjs
Normal file
@@ -0,0 +1,861 @@
|
||||
import {GRID_TYPES} from "../constants.mjs";
|
||||
import Color from "../utils/color.mjs";
|
||||
import {lineLineIntersection} from "../utils/geometry.mjs";
|
||||
import {logCompatibilityWarning} from "../utils/logging.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {object} GridConfiguration
|
||||
* @property {number} size The size of a grid space in pixels (a positive number)
|
||||
* @property {number} [distance=1] The distance of a grid space in units (a positive number)
|
||||
* @property {string} [units=""] The units of measurement
|
||||
* @property {string} [style="solidLines"] The style of the grid
|
||||
* @property {ColorSource} [color=0] The color of the grid
|
||||
* @property {number} [alpha=1] The alpha of the grid
|
||||
* @property {number} [thickness=1] The line thickness of the grid
|
||||
*/
|
||||
|
||||
/**
|
||||
* A pair of row and column coordinates of a grid space.
|
||||
* @typedef {object} GridOffset
|
||||
* @property {number} i The row coordinate
|
||||
* @property {number} j The column coordinate
|
||||
*/
|
||||
|
||||
/**
|
||||
* An offset of a grid space or a point with pixel coordinates.
|
||||
* @typedef {GridOffset|Point} GridCoordinates
|
||||
*/
|
||||
|
||||
/**
|
||||
* Snapping behavior is defined by the snapping mode at the given resolution of the grid.
|
||||
* @typedef {object} GridSnappingBehavior
|
||||
* @property {number} mode The snapping mode (a union of {@link CONST.GRID_SNAPPING_MODES})
|
||||
* @property {number} [resolution=1] The resolution (a positive integer)
|
||||
*/
|
||||
|
||||
/**
|
||||
* The base grid class.
|
||||
* @abstract
|
||||
*/
|
||||
export default class BaseGrid {
|
||||
/**
|
||||
* The base grid constructor.
|
||||
* @param {GridConfiguration} config The grid configuration
|
||||
*/
|
||||
constructor({size, distance=1, units="", style="solidLines", thickness=1, color, alpha=1}) {
|
||||
/** @deprecated since v12 */
|
||||
if ( "dimensions" in arguments[0] ) {
|
||||
const msg = "The constructor BaseGrid({dimensions, color, alpha}) is deprecated "
|
||||
+ "in favor of BaseGrid({size, distance, units, style, thickness, color, alpha}).";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14});
|
||||
const dimensions = arguments[0].dimensions;
|
||||
size = dimensions.size;
|
||||
distance = dimensions.distance || 1;
|
||||
}
|
||||
|
||||
if ( size === undefined ) throw new Error(`${this.constructor.name} cannot be constructed without a size`);
|
||||
|
||||
// Convert the color to a CSS string
|
||||
if ( color ) color = Color.from(color);
|
||||
if ( !color?.valid ) color = new Color(0);
|
||||
|
||||
/**
|
||||
* The size of a grid space in pixels.
|
||||
* @type {number}
|
||||
*/
|
||||
this.size = size;
|
||||
|
||||
/**
|
||||
* The width of a grid space in pixels.
|
||||
* @type {number}
|
||||
*/
|
||||
this.sizeX = size;
|
||||
|
||||
/**
|
||||
* The height of a grid space in pixels.
|
||||
* @type {number}
|
||||
*/
|
||||
this.sizeY = size;
|
||||
|
||||
/**
|
||||
* The distance of a grid space in units.
|
||||
* @type {number}
|
||||
*/
|
||||
this.distance = distance;
|
||||
|
||||
/**
|
||||
* The distance units used in this grid.
|
||||
* @type {string}
|
||||
*/
|
||||
this.units = units;
|
||||
|
||||
/**
|
||||
* The style of the grid.
|
||||
* @type {string}
|
||||
*/
|
||||
this.style = style;
|
||||
|
||||
/**
|
||||
* The thickness of the grid.
|
||||
* @type {number}
|
||||
*/
|
||||
this.thickness = thickness;
|
||||
|
||||
/**
|
||||
* The color of the grid.
|
||||
* @type {Color}
|
||||
*/
|
||||
this.color = color;
|
||||
|
||||
/**
|
||||
* The opacity of the grid.
|
||||
* @type {number}
|
||||
*/
|
||||
this.alpha = alpha;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The grid type (see {@link CONST.GRID_TYPES}).
|
||||
* @type {number}
|
||||
*/
|
||||
type;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this a gridless grid?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isGridless() {
|
||||
return this.type === GRID_TYPES.GRIDLESS;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this a square grid?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isSquare() {
|
||||
return this.type === GRID_TYPES.SQUARE;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Is this a hexagonal grid?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isHexagonal() {
|
||||
return (this.type >= GRID_TYPES.HEXODDR) && (this.type <= GRID_TYPES.HEXEVENQ);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Calculate the total size of the canvas with padding applied, as well as the top-left coordinates of the inner
|
||||
* rectangle that houses the scene.
|
||||
* @param {number} sceneWidth The width of the scene.
|
||||
* @param {number} sceneHeight The height of the scene.
|
||||
* @param {number} padding The percentage of padding.
|
||||
* @returns {{width: number, height: number, x: number, y: number, rows: number, columns: number}}
|
||||
* @abstract
|
||||
*/
|
||||
calculateDimensions(sceneWidth, sceneHeight, padding) {
|
||||
throw new Error("A subclass of the BaseGrid must implement the calculateDimensions method");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Returns the offset of the grid space corresponding to the given coordinates.
|
||||
* @param {GridCoordinates} coords The coordinates
|
||||
* @returns {GridOffset} The offset
|
||||
* @abstract
|
||||
*/
|
||||
getOffset(coords) {
|
||||
throw new Error("A subclass of the BaseGrid must implement the getOffset method");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Returns the smallest possible range containing the offsets of all grid spaces that intersect the given bounds.
|
||||
* If the bounds are empty (nonpositive width or height), then the offset range is empty.
|
||||
* @example
|
||||
* ```js
|
||||
* const [i0, j0, i1, j1] = grid.getOffsetRange(bounds);
|
||||
* for ( let i = i0; i < i1; i++ ) {
|
||||
* for ( let j = j0; j < j1; j++ ) {
|
||||
* const offset = {i, j};
|
||||
* // ...
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
* @param {Rectangle} bounds The bounds
|
||||
* @returns {[i0: number, j0: number, i1: number, j1: number]} The offset range
|
||||
* @abstract
|
||||
*/
|
||||
getOffsetRange({x, y, width, height}) {
|
||||
throw new Error("A subclass of the BaseGrid must implement the getOffsetRange method");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Returns the offsets of the grid spaces adjacent to the one corresponding to the given coordinates.
|
||||
* Returns an empty array in gridless grids.
|
||||
* @param {GridCoordinates} coords The coordinates
|
||||
* @returns {GridOffset[]} The adjacent offsets
|
||||
* @abstract
|
||||
*/
|
||||
getAdjacentOffsets(coords) {
|
||||
throw new Error("A subclass of the BaseGrid must implement the getAdjacentOffsets method");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Returns true if the grid spaces corresponding to the given coordinates are adjacent to each other.
|
||||
* In square grids with illegal diagonals the diagonally neighboring grid spaces are not adjacent.
|
||||
* Returns false in gridless grids.
|
||||
* @param {GridCoordinates} coords1 The first coordinates
|
||||
* @param {GridCoordinates} coords2 The second coordinates
|
||||
* @returns {boolean}
|
||||
* @abstract
|
||||
*/
|
||||
testAdjacency(coords1, coords2) {
|
||||
throw new Error("A subclass of the BaseGrid must implement the testAdjacency method");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Returns the offset of the grid space corresponding to the given coordinates
|
||||
* shifted by one grid space in the given direction.
|
||||
* In square grids with illegal diagonals the offset of the given coordinates is returned
|
||||
* if the direction is diagonal.
|
||||
* @param {GridCoordinates} coords The coordinates
|
||||
* @param {number} direction The direction (see {@link CONST.MOVEMENT_DIRECTIONS})
|
||||
* @returns {GridOffset} The offset
|
||||
* @abstract
|
||||
*/
|
||||
getShiftedOffset(coords, direction) {
|
||||
throw new Error("A subclass of the BaseGrid must implement the getShiftedOffset method");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Returns the point shifted by the difference between the grid space corresponding to the given coordinates
|
||||
* and the shifted grid space in the given direction.
|
||||
* In square grids with illegal diagonals the point is not shifted if the direction is diagonal.
|
||||
* In gridless grids the point coordinates are shifted by the grid size.
|
||||
* @param {Point} point The point that is to be shifted
|
||||
* @param {number} direction The direction (see {@link CONST.MOVEMENT_DIRECTIONS})
|
||||
* @returns {Point} The shifted point
|
||||
* @abstract
|
||||
*/
|
||||
getShiftedPoint(point, direction) {
|
||||
throw new Error("A subclass of the BaseGrid must implement the getShiftedPoint method");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Returns the top-left point of the grid space corresponding to the given coordinates.
|
||||
* If given a point, the top-left point of the grid space that contains it is returned.
|
||||
* In gridless grids a point with the same coordinates as the given point is returned.
|
||||
* @param {GridCoordinates} coords The coordinates
|
||||
* @returns {Point} The top-left point
|
||||
* @abstract
|
||||
*/
|
||||
getTopLeftPoint(coords) {
|
||||
throw new Error("A subclass of the BaseGrid must implement the getTopLeftPoint method");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Returns the center point of the grid space corresponding to the given coordinates.
|
||||
* If given a point, the center point of the grid space that contains it is returned.
|
||||
* In gridless grids a point with the same coordinates as the given point is returned.
|
||||
* @param {GridCoordinates} coords The coordinates
|
||||
* @returns {Point} The center point
|
||||
* @abstract
|
||||
*/
|
||||
getCenterPoint(coords) {
|
||||
throw new Error("A subclass of the BaseGrid must implement the getCenterPoint method");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Returns the points of the grid space shape relative to the center point.
|
||||
* The points are returned in the same order as in {@link BaseGrid#getVertices}.
|
||||
* In gridless grids an empty array is returned.
|
||||
* @returns {Point[]} The points of the polygon
|
||||
* @abstract
|
||||
*/
|
||||
getShape() {
|
||||
throw new Error("A subclass of the BaseGrid must implement the getShape method");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Returns the vertices of the grid space corresponding to the given coordinates.
|
||||
* The vertices are returned ordered in positive orientation with the first vertex
|
||||
* being the top-left vertex in square grids, the top vertex in row-oriented
|
||||
* hexagonal grids, and the left vertex in column-oriented hexagonal grids.
|
||||
* In gridless grids an empty array is returned.
|
||||
* @param {GridCoordinates} coords The coordinates
|
||||
* @returns {Point[]} The vertices
|
||||
* @abstract
|
||||
*/
|
||||
getVertices(coords) {
|
||||
throw new Error("A subclass of the BaseGrid must implement the getVertices method");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Snaps the given point to the grid.
|
||||
* @param {Point} point The point that is to be snapped
|
||||
* @param {GridSnappingBehavior} behavior The snapping behavior
|
||||
* @returns {Point} The snapped point
|
||||
* @abstract
|
||||
*/
|
||||
getSnappedPoint({x, y}, behavior) {
|
||||
throw new Error("A subclass of the BaseGrid must implement the getSnappedPoint method");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {GridCoordinates | (GridCoordinates & {teleport: boolean})} GridMeasurePathWaypoint
|
||||
*/
|
||||
|
||||
/**
|
||||
* The measurements of a waypoint.
|
||||
* @typedef {object} GridMeasurePathResultWaypoint
|
||||
* @property {GridMeasurePathResultSegment|null} backward The segment from the previous waypoint to this waypoint.
|
||||
* @property {GridMeasurePathResultSegment|null} forward The segment from this waypoint to the next waypoint.
|
||||
* @property {number} distance The total distance travelled along the path up to this waypoint.
|
||||
* @property {number} spaces The total number of spaces moved along a direct path up to this waypoint.
|
||||
* @property {number} cost The total cost of the direct path ({@link BaseGrid#getDirectPath}) up to this waypoint.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The measurements of a segment.
|
||||
* @typedef {object} GridMeasurePathResultSegment
|
||||
* @property {GridMeasurePathResultWaypoint} from The waypoint that this segment starts from.
|
||||
* @property {GridMeasurePathResultWaypoint} to The waypoint that this segment goes to.
|
||||
* @property {boolean} teleport Is teleporation?
|
||||
* @property {number} distance The distance travelled in grid units along this segment.
|
||||
* @property {number} spaces The number of spaces moved along this segment.
|
||||
* @property {number} cost The cost of the direct path ({@link BaseGrid#getDirectPath}) between the two waypoints.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The measurements result of {@link BaseGrid#measurePath}.
|
||||
* @typedef {object} GridMeasurePathResult
|
||||
* @property {GridMeasurePathResultWaypoint[]} waypoints The measurements at each waypoint.
|
||||
* @property {GridMeasurePathResultSegment[]} segments The measurements at each segment.
|
||||
* @property {number} distance The total distance travelled along the path through all waypoints.
|
||||
* @property {number} spaces The total number of spaces moved along a direct path through all waypoints.
|
||||
* Moving from a grid space to any of its neighbors counts as 1 step.
|
||||
* Always 0 in gridless grids.
|
||||
* @property {number} cost The total cost of the direct path ({@link BaseGrid#getDirectPath}) through all waypoints.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A function that returns the cost for a given move between grid spaces.
|
||||
* In square and hexagonal grids the grid spaces are always adjacent unless teleported.
|
||||
* The distance is 0 if and only if teleported. The function is never called with the same offsets.
|
||||
* @callback GridMeasurePathCostFunction
|
||||
* @param {GridOffset} from The offset that is moved from.
|
||||
* @param {GridOffset} to The offset that is moved to.
|
||||
* @param {number} distance The distance between the grid spaces, or 0 if teleported.
|
||||
* @returns {number} The cost of the move between the grid spaces.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Measure a shortest, direct path through the given waypoints.
|
||||
* @param {GridMeasurePathWaypoint[]} waypoints The waypoints the path must pass through
|
||||
* @param {object} [options] Additional measurement options
|
||||
* @param {GridMeasurePathCostFunction} [options.cost] The function that returns the cost
|
||||
* for a given move between grid spaces (default is the distance travelled along the direct path)
|
||||
* @returns {GridMeasurePathResult} The measurements a shortest, direct path through the given waypoints.
|
||||
*/
|
||||
measurePath(waypoints, options={}) {
|
||||
const result = {
|
||||
waypoints: [],
|
||||
segments: []
|
||||
};
|
||||
if ( waypoints.length !== 0 ) {
|
||||
let from = {backward: null, forward: null};
|
||||
result.waypoints.push(from);
|
||||
for ( let i = 1; i < waypoints.length; i++ ) {
|
||||
const to = {backward: null, forward: null};
|
||||
const segment = {from, to};
|
||||
from.forward = to.backward = segment;
|
||||
result.waypoints.push(to);
|
||||
result.segments.push(segment);
|
||||
from = to;
|
||||
}
|
||||
}
|
||||
this._measurePath(waypoints, options, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Measures the path and writes the measurements into `result`.
|
||||
* Called by {@link BaseGrid#measurePath}.
|
||||
* @param {GridMeasurePathWaypoint[]} waypoints The waypoints the path must pass through
|
||||
* @param {object} options Additional measurement options
|
||||
* @param {GridMeasurePathCostFunction} [options.cost] The function that returns the cost
|
||||
* for a given move between grid spaces (default is the distance travelled)
|
||||
* @param {GridMeasurePathResult} result The measurement result that the measurements need to be written to
|
||||
* @protected
|
||||
* @abstract
|
||||
*/
|
||||
_measurePath(waypoints, options, result) {
|
||||
throw new Error("A subclass of the BaseGrid must implement the _measurePath method");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Returns the sequence of grid offsets of a shortest, direct path passing through the given waypoints.
|
||||
* @param {GridCoordinates[]} waypoints The waypoints the path must pass through
|
||||
* @returns {GridOffset[]} The sequence of grid offsets of a shortest, direct path
|
||||
* @abstract
|
||||
*/
|
||||
getDirectPath(waypoints) {
|
||||
throw new Error("A subclass of the BaseGrid must implement the getDirectPath method");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the point translated in a direction by a distance.
|
||||
* @param {Point} point The point that is to be translated.
|
||||
* @param {number} direction The angle of direction in degrees.
|
||||
* @param {number} distance The distance in grid units.
|
||||
* @returns {Point} The translated point.
|
||||
* @abstract
|
||||
*/
|
||||
getTranslatedPoint(point, direction, distance) {
|
||||
throw new Error("A subclass of the BaseGrid must implement the getTranslatedPoint method");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the circle polygon given the radius in grid units for this grid.
|
||||
* The points of the polygon are returned ordered in positive orientation.
|
||||
* In gridless grids an approximation of the true circle with a deviation of less than 0.25 pixels is returned.
|
||||
* @param {Point} center The center point of the circle.
|
||||
* @param {number} radius The radius in grid units.
|
||||
* @returns {Point[]} The points of the circle polygon.
|
||||
* @abstract
|
||||
*/
|
||||
getCircle(center, radius) {
|
||||
throw new Error("A subclass of the BaseGrid must implement the getCircle method");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the cone polygon given the radius in grid units and the angle in degrees for this grid.
|
||||
* The points of the polygon are returned ordered in positive orientation.
|
||||
* In gridless grids an approximation of the true cone with a deviation of less than 0.25 pixels is returned.
|
||||
* @param {Point} origin The origin point of the cone.
|
||||
* @param {number} radius The radius in grid units.
|
||||
* @param {number} direction The direction in degrees.
|
||||
* @param {number} angle The angle in degrees.
|
||||
* @returns {Point[]} The points of the cone polygon.
|
||||
*/
|
||||
getCone(origin, radius, direction, angle) {
|
||||
if ( (radius <= 0) || (angle <= 0) ) return [];
|
||||
const circle = this.getCircle(origin, radius);
|
||||
if ( angle >= 360 ) return circle;
|
||||
const n = circle.length;
|
||||
const aMin = Math.normalizeRadians(Math.toRadians(direction - (angle / 2)));
|
||||
const aMax = aMin + Math.toRadians(angle);
|
||||
const pMin = {x: origin.x + (Math.cos(aMin) * this.size), y: origin.y + (Math.sin(aMin) * this.size)};
|
||||
const pMax = {x: origin.x + (Math.cos(aMax) * this.size), y: origin.y + (Math.sin(aMax) * this.size)};
|
||||
const angles = circle.map(p => {
|
||||
const a = Math.atan2(p.y - origin.y, p.x - origin.x);
|
||||
return a >= aMin ? a : a + (2 * Math.PI);
|
||||
});
|
||||
const points = [{x: origin.x, y: origin.y}];
|
||||
for ( let i = 0, c0 = circle[n - 1], a0 = angles[n - 1]; i < n; i++ ) {
|
||||
let c1 = circle[i];
|
||||
let a1 = angles[i];
|
||||
if ( a0 > a1 ) {
|
||||
const {x: x1, y: y1} = lineLineIntersection(c0, c1, origin, pMin);
|
||||
points.push({x: x1, y: y1});
|
||||
while ( a1 < aMax ) {
|
||||
points.push(c1);
|
||||
i = (i + 1) % n;
|
||||
c0 = c1;
|
||||
c1 = circle[i];
|
||||
a0 = a1;
|
||||
a1 = angles[i];
|
||||
if ( a0 > a1 ) break;
|
||||
}
|
||||
const {x: x2, y: y2} = lineLineIntersection(c0, c1, origin, pMax);
|
||||
points.push({x: x2, y: y2});
|
||||
break;
|
||||
}
|
||||
c0 = c1;
|
||||
a0 = a1;
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
getRect(w, h) {
|
||||
const msg = "BaseGrid#getRect is deprecated. If you need the size of a Token, use Token#getSize instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
return new PIXI.Rectangle(0, 0, w * this.sizeX, h * this.sizeY);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
static calculatePadding(gridType, width, height, size, padding, options) {
|
||||
const msg = "BaseGrid.calculatePadding is deprecated in favor of BaseGrid#calculateDimensions.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
let grid;
|
||||
if ( gridType === GRID_TYPES.GRIDLESS ) {
|
||||
grid = new foundry.grid.GridlessGrid({size});
|
||||
} else if ( gridType === GRID_TYPES.SQUARE ) {
|
||||
grid = new foundry.grid.SquareGrid({size});
|
||||
} else if ( gridType.between(GRID_TYPES.HEXODDR, GRID_TYPES.HEXEVENQ) ) {
|
||||
const columns = (gridType === GRID_TYPES.HEXODDQ) || (gridType === GRID_TYPES.HEXEVENQ);
|
||||
if ( options?.legacy ) return HexagonalGrid._calculatePreV10Dimensions(columns, size,
|
||||
sceneWidth, sceneHeight, padding);
|
||||
grid = new foundry.grid.HexagonalGrid({
|
||||
columns,
|
||||
even: (gridType === GRID_TYPES.HEXEVENR) || (gridType === GRID_TYPES.HEXEVENQ),
|
||||
size
|
||||
});
|
||||
} else {
|
||||
throw new Error("Invalid grid type");
|
||||
}
|
||||
return grid.calculateDimensions(width, height, padding);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @ignore
|
||||
*/
|
||||
get w() {
|
||||
const msg = "BaseGrid#w is deprecated in favor of BaseGrid#sizeX.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
return this.sizeX;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
set w(value) {
|
||||
const msg = "BaseGrid#w is deprecated in favor of BaseGrid#sizeX.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
this.sizeX = value;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get h() {
|
||||
const msg = "BaseGrid#h is deprecated in favor of BaseGrid#sizeY.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
return this.sizeY;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
set h(value) {
|
||||
const msg = "BaseGrid#h is deprecated in favor of BaseGrid#sizeY.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
this.sizeY = value;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
getTopLeft(x, y) {
|
||||
const msg = "BaseGrid#getTopLeft is deprecated. Use BaseGrid#getTopLeftPoint instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
let [row, col] = this.getGridPositionFromPixels(x, y);
|
||||
return this.getPixelsFromGridPosition(row, col);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
getCenter(x, y) {
|
||||
const msg = "BaseGrid#getCenter is deprecated. Use BaseGrid#getCenterPoint instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
getNeighbors(row, col) {
|
||||
const msg = "BaseGrid#getNeighbors is deprecated. Use BaseGrid#getAdjacentOffsets instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
return this.getAdjacentOffsets({i: row, j: col}).map(({i, j}) => [i, j]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
getGridPositionFromPixels(x, y) {
|
||||
const msg = "BaseGrid#getGridPositionFromPixels is deprecated. Use BaseGrid#getOffset instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
return [y, x].map(Math.round);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
getPixelsFromGridPosition(row, col) {
|
||||
const msg = "BaseGrid#getPixelsFromGridPosition is deprecated. Use BaseGrid#getTopLeftPoint instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
return [col, row].map(Math.round);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
shiftPosition(x, y, dx, dy, options={}) {
|
||||
const msg = "BaseGrid#shiftPosition is deprecated. Use BaseGrid#getShiftedPoint instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
return [x + (dx * this.size), y + (dy * this.size)];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
measureDistances(segments, options={}) {
|
||||
const msg = "BaseGrid#measureDistances is deprecated. Use BaseGrid#measurePath instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
return segments.map(s => {
|
||||
return (s.ray.distance / this.size) * this.distance;
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
getSnappedPosition(x, y, interval=null, options={}) {
|
||||
const msg = "BaseGrid#getSnappedPosition is deprecated. Use BaseGrid#getSnappedPoint instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
if ( interval === 0 ) return {x: Math.round(x), y: Math.round(y)};
|
||||
interval = interval ?? 1;
|
||||
return {
|
||||
x: Math.round(x.toNearest(this.sizeX / interval)),
|
||||
y: Math.round(y.toNearest(this.sizeY / interval))
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
highlightGridPosition(layer, options) {
|
||||
const msg = "BaseGrid#highlightGridPosition is deprecated. Use GridLayer#highlightPosition instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
canvas.interface.grid.highlightPosition(layer.name, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get grid() {
|
||||
const msg = "canvas.grid.grid is deprecated. Use canvas.grid instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
isNeighbor(r0, c0, r1, c1) {
|
||||
const msg = "canvas.grid.isNeighbor is deprecated. Use canvas.grid.testAdjacency instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
return this.testAdjacency({i: r0, j: c0}, {i: r1, j: c1});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get isHex() {
|
||||
const msg = "canvas.grid.isHex is deprecated. Use of canvas.grid.isHexagonal instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
return this.isHexagonal;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
measureDistance(origin, target, options={}) {
|
||||
const msg = "canvas.grid.measureDistance is deprecated. "
|
||||
+ "Use canvas.grid.measurePath instead for non-Euclidean measurements.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
const ray = new Ray(origin, target);
|
||||
const segments = [{ray}];
|
||||
return this.measureDistances(segments, options)[0];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get highlight() {
|
||||
const msg = "canvas.grid.highlight is deprecated. Use canvas.interface.grid.highlight instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
return canvas.interface.grid.highlight;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
get highlightLayers() {
|
||||
const msg = "canvas.grid.highlightLayers is deprecated. Use canvas.interface.grid.highlightLayers instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
return canvas.interface.grid.highlightLayers;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
addHighlightLayer(name) {
|
||||
const msg = "canvas.grid.addHighlightLayer is deprecated. Use canvas.interface.grid.addHighlightLayer instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
return canvas.interface.grid.addHighlightLayer(name);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
clearHighlightLayer(name) {
|
||||
const msg = "canvas.grid.clearHighlightLayer is deprecated. Use canvas.interface.grid.clearHighlightLayer instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
canvas.interface.grid.clearHighlightLayer(name);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
destroyHighlightLayer(name) {
|
||||
const msg = "canvas.grid.destroyHighlightLayer is deprecated. Use canvas.interface.grid.destroyHighlightLayer instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
canvas.interface.grid.destroyHighlightLayer(name);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
getHighlightLayer(name) {
|
||||
const msg = "canvas.grid.getHighlightLayer is deprecated. Use canvas.interface.grid.getHighlightLayer instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
return canvas.interface.grid.getHighlightLayer(name);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
highlightPosition(name, options) {
|
||||
const msg = "canvas.grid.highlightPosition is deprecated. Use canvas.interface.grid.highlightPosition instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
canvas.interface.grid.highlightPosition(name, options);
|
||||
}
|
||||
}
|
||||
99
resources/app/common/grid/grid-hex.mjs
Normal file
99
resources/app/common/grid/grid-hex.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
import HexagonalGrid from "./hexagonal.mjs";
|
||||
|
||||
/**
|
||||
* A helper class which represents a single hexagon as part of a HexagonalGrid.
|
||||
* This class relies on having an active canvas scene in order to know the configuration of the hexagonal grid.
|
||||
*/
|
||||
export default class GridHex {
|
||||
/**
|
||||
* Construct a GridHex instance by providing a hex coordinate.
|
||||
* @param {HexagonalGridCoordinates} coordinates The coordinates of the hex to construct
|
||||
* @param {HexagonalGrid} grid The hexagonal grid instance to which this hex belongs
|
||||
*/
|
||||
constructor(coordinates, grid) {
|
||||
if ( !(grid instanceof HexagonalGrid) ) {
|
||||
grid = new HexagonalGrid(grid);
|
||||
foundry.utils.logCompatibilityWarning("The GridHex class now requires a HexagonalGrid instance to be passed to "
|
||||
+ "its constructor, rather than a HexagonalGridConfiguration", {since: 12, until: 14});
|
||||
}
|
||||
if ( "row" in coordinates ) {
|
||||
coordinates = {i: coordinates.row, j: coordinates.col};
|
||||
foundry.utils.logCompatibilityWarning("The coordinates used to construct the GridHex class are now a GridOffset"
|
||||
+ " with format {i, j}.", {since: 12, until: 14});
|
||||
}
|
||||
|
||||
/**
|
||||
* The hexagonal grid to which this hex belongs.
|
||||
* @type {HexagonalGrid}
|
||||
*/
|
||||
this.grid = grid;
|
||||
|
||||
/**
|
||||
* The cube coordinate of this hex
|
||||
* @type {HexagonalGridCube}
|
||||
*/
|
||||
this.cube = this.grid.getCube(coordinates);
|
||||
|
||||
/**
|
||||
* The offset coordinate of this hex
|
||||
* @type {GridOffset}
|
||||
*/
|
||||
this.offset = this.grid.cubeToOffset(this.cube);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a reference to the pixel point in the center of this hexagon.
|
||||
* @type {Point}
|
||||
*/
|
||||
get center() {
|
||||
return this.grid.getCenterPoint(this.cube);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a reference to the pixel point of the top-left corner of this hexagon.
|
||||
* @type {Point}
|
||||
*/
|
||||
get topLeft() {
|
||||
return this.grid.getTopLeftPoint(this.cube);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the array of hexagons which are neighbors of this one.
|
||||
* This result is un-bounded by the confines of the game canvas and may include hexes which are off-canvas.
|
||||
* @returns {GridHex[]}
|
||||
*/
|
||||
getNeighbors() {
|
||||
return this.grid.getAdjacentCubes(this.cube).map(c => new this.constructor(c, this.grid));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get a neighboring hex by shifting along cube coordinates
|
||||
* @param {number} dq A number of hexes to shift along the q axis
|
||||
* @param {number} dr A number of hexes to shift along the r axis
|
||||
* @param {number} ds A number of hexes to shift along the s axis
|
||||
* @returns {GridHex} The shifted hex
|
||||
*/
|
||||
shiftCube(dq, dr, ds) {
|
||||
const {q, r, s} = this.cube;
|
||||
return new this.constructor({q: q + dq, r: r + dr, s: s + ds}, this.grid);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return whether this GridHex equals the same position as some other GridHex instance.
|
||||
* @param {GridHex} other Some other GridHex
|
||||
* @returns {boolean} Are the positions equal?
|
||||
*/
|
||||
equals(other) {
|
||||
return (this.offset.i === other.offset.i) && (this.offset.j === other.offset.j);
|
||||
}
|
||||
}
|
||||
236
resources/app/common/grid/gridless.mjs
Normal file
236
resources/app/common/grid/gridless.mjs
Normal file
@@ -0,0 +1,236 @@
|
||||
import BaseGrid from "./base.mjs";
|
||||
import {GRID_TYPES, MOVEMENT_DIRECTIONS} from "../constants.mjs";
|
||||
|
||||
/**
|
||||
* The gridless grid class.
|
||||
*/
|
||||
export default class GridlessGrid extends BaseGrid {
|
||||
|
||||
/** @override */
|
||||
type = GRID_TYPES.GRIDLESS;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
calculateDimensions(sceneWidth, sceneHeight, padding) {
|
||||
// Note: Do not replace `* (1 / this.size)` by `/ this.size`!
|
||||
// It could change the result and therefore break certain scenes.
|
||||
const x = Math.ceil((padding * sceneWidth) * (1 / this.size)) * this.size;
|
||||
const y = Math.ceil((padding * sceneHeight) * (1 / this.size)) * this.size;
|
||||
const width = sceneWidth + (2 * x);
|
||||
const height = sceneHeight + (2 * y);
|
||||
return {width, height, x, y, rows: Math.ceil(height), columns: Math.ceil(width)};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getOffset(coords) {
|
||||
const i = coords.i;
|
||||
if ( i !== undefined ) return {i, j: coords.j};
|
||||
return {i: Math.round(coords.y) | 0, j: Math.round(coords.x) | 0};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getOffsetRange({x, y, width, height}) {
|
||||
const i0 = Math.floor(y);
|
||||
const j0 = Math.floor(x);
|
||||
if ( !((width > 0) && (height > 0)) ) return [i0, j0, i0, j0];
|
||||
return [i0, j0, Math.ceil(y + height) | 0, Math.ceil(x + width) | 0];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getAdjacentOffsets(coords) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
testAdjacency(coords1, coords2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getShiftedOffset(coords, direction) {
|
||||
const i = coords.i;
|
||||
if ( i !== undefined ) coords = {x: coords.j, y: i};
|
||||
return this.getOffset(this.getShiftedPoint(coords, direction));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getShiftedPoint(point, direction) {
|
||||
let di = 0;
|
||||
let dj = 0;
|
||||
if ( direction & MOVEMENT_DIRECTIONS.UP ) di--;
|
||||
if ( direction & MOVEMENT_DIRECTIONS.DOWN ) di++;
|
||||
if ( direction & MOVEMENT_DIRECTIONS.LEFT ) dj--;
|
||||
if ( direction & MOVEMENT_DIRECTIONS.RIGHT ) dj++;
|
||||
return {x: point.x + (dj * this.size), y: point.y + (di * this.size)};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getTopLeftPoint(coords) {
|
||||
const i = coords.i;
|
||||
if ( i !== undefined ) return {x: coords.j, y: i};
|
||||
return {x: coords.x, y: coords.y};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getCenterPoint(coords) {
|
||||
const i = coords.i;
|
||||
if ( i !== undefined ) return {x: coords.j, y: i};
|
||||
return {x: coords.x, y: coords.y};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getShape() {
|
||||
return [];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getVertices(coords) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getSnappedPoint({x, y}, behavior) {
|
||||
return {x, y};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_measurePath(waypoints, {cost}, result) {
|
||||
result.distance = 0;
|
||||
result.spaces = 0;
|
||||
result.cost = 0;
|
||||
|
||||
if ( waypoints.length === 0 ) return;
|
||||
|
||||
const from = result.waypoints[0];
|
||||
from.distance = 0;
|
||||
from.spaces = 0;
|
||||
from.cost = 0;
|
||||
|
||||
// Prepare data for the starting point
|
||||
const w0 = waypoints[0];
|
||||
let o0 = this.getOffset(w0);
|
||||
let p0 = this.getCenterPoint(w0);
|
||||
|
||||
// Iterate over additional path points
|
||||
for ( let i = 1; i < waypoints.length; i++ ) {
|
||||
const w1 = waypoints[i];
|
||||
const o1 = this.getOffset(w1);
|
||||
const p1 = this.getCenterPoint(w1);
|
||||
|
||||
// Measure segment
|
||||
const to = result.waypoints[i];
|
||||
const segment = to.backward;
|
||||
if ( !w1.teleport ) {
|
||||
|
||||
// Calculate the Euclidean distance
|
||||
segment.distance = Math.hypot(p0.x - p1.x, p0.y - p1.y) / this.size * this.distance;
|
||||
segment.spaces = 0;
|
||||
const offsetDistance = Math.hypot(o0.i - o1.i, o0.j - o1.j) / this.size * this.distance;
|
||||
segment.cost = cost && (offsetDistance !== 0) ? cost(o0, o1, offsetDistance) : offsetDistance;
|
||||
} else {
|
||||
segment.distance = 0;
|
||||
segment.spaces = 0;
|
||||
segment.cost = cost && ((o0.i !== o1.i) || (o0.j !== o1.j)) ? cost(o0, o1, 0) : 0;
|
||||
}
|
||||
|
||||
// Accumulate measurements
|
||||
result.distance += segment.distance;
|
||||
result.cost += segment.cost;
|
||||
|
||||
// Set waypoint measurements
|
||||
to.distance = result.distance;
|
||||
to.spaces = 0;
|
||||
to.cost = result.cost;
|
||||
|
||||
o0 = o1;
|
||||
p0 = p1;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getDirectPath(waypoints) {
|
||||
if ( waypoints.length === 0 ) return [];
|
||||
let o0 = this.getOffset(waypoints[0]);
|
||||
const path = [o0];
|
||||
for ( let i = 1; i < waypoints.length; i++ ) {
|
||||
const o1 = this.getOffset(waypoints[i]);
|
||||
if ( (o0.i === o1.i) && (o0.j === o1.j) ) continue;
|
||||
path.push(o1);
|
||||
o0 = o1;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getTranslatedPoint(point, direction, distance) {
|
||||
direction = Math.toRadians(direction);
|
||||
const dx = Math.cos(direction);
|
||||
const dy = Math.sin(direction);
|
||||
const s = distance / this.distance * this.size;
|
||||
return {x: point.x + (dx * s), y: point.y + (dy * s)};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getCircle({x, y}, radius) {
|
||||
if ( radius <= 0 ) return [];
|
||||
const r = radius / this.distance * this.size;
|
||||
const n = Math.max(Math.ceil(Math.PI / Math.acos(Math.max(r - 0.25, 0) / r)), 4);
|
||||
const points = new Array(n);
|
||||
for ( let i = 0; i < n; i++ ) {
|
||||
const a = 2 * Math.PI * (i / n);
|
||||
points[i] = {x: x + (Math.cos(a) * r), y: y + (Math.sin(a) * r)};
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
getCone(origin, radius, direction, angle) {
|
||||
if ( (radius <= 0) || (angle <= 0) ) return [];
|
||||
if ( angle >= 360 ) return this.getCircle(origin, radius);
|
||||
const r = radius / this.distance * this.size;
|
||||
const n = Math.max(Math.ceil(Math.PI / Math.acos(Math.max(r - 0.25, 0) / r) * (angle / 360)), 4);
|
||||
const a0 = Math.toRadians(direction - (angle / 2));
|
||||
const a1 = Math.toRadians(direction + (angle / 2));
|
||||
const points = new Array(n + 1);
|
||||
const {x, y} = origin;
|
||||
points[0] = {x, y};
|
||||
for ( let i = 0; i <= n; i++ ) {
|
||||
const a = Math.mix(a0, a1, i / n);
|
||||
points[i + 1] = {x: x + (Math.cos(a) * r), y: y + (Math.sin(a) * r)};
|
||||
}
|
||||
return points;
|
||||
}
|
||||
}
|
||||
1858
resources/app/common/grid/hexagonal.mjs
Normal file
1858
resources/app/common/grid/hexagonal.mjs
Normal file
File diff suppressed because it is too large
Load Diff
1036
resources/app/common/grid/square.mjs
Normal file
1036
resources/app/common/grid/square.mjs
Normal file
File diff suppressed because it is too large
Load Diff
33
resources/app/common/packages/base-module.mjs
Normal file
33
resources/app/common/packages/base-module.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
import BasePackage from "./base-package.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import AdditionalTypesField from "./sub-types.mjs";
|
||||
|
||||
/**
|
||||
* The data schema used to define Module manifest files.
|
||||
* Extends the basic PackageData schema with some additional module-specific fields.
|
||||
* @property {boolean} [coreTranslation] Does this module provide a translation for the core software?
|
||||
* @property {boolean} [library] A library module provides no user-facing functionality and is solely
|
||||
* for use by other modules. Loaded before any system or module scripts.
|
||||
* @property {Record<string, string[]>} [documentTypes] Additional document subtypes provided by this module.
|
||||
*/
|
||||
export default class BaseModule extends BasePackage {
|
||||
|
||||
/** @inheritDoc */
|
||||
static defineSchema() {
|
||||
const parentSchema = super.defineSchema();
|
||||
return Object.assign({}, parentSchema, {
|
||||
coreTranslation: new fields.BooleanField(),
|
||||
library: new fields.BooleanField(),
|
||||
documentTypes: new AdditionalTypesField()
|
||||
});
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static type = "module";
|
||||
|
||||
/**
|
||||
* The default icon used for this type of Package.
|
||||
* @type {string}
|
||||
*/
|
||||
static icon = "fa-plug";
|
||||
}
|
||||
826
resources/app/common/packages/base-package.mjs
Normal file
826
resources/app/common/packages/base-package.mjs
Normal file
@@ -0,0 +1,826 @@
|
||||
import DataModel from "../abstract/data.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import {
|
||||
COMPENDIUM_DOCUMENT_TYPES, DOCUMENT_OWNERSHIP_LEVELS,
|
||||
PACKAGE_AVAILABILITY_CODES,
|
||||
PACKAGE_TYPES,
|
||||
SYSTEM_SPECIFIC_COMPENDIUM_TYPES,
|
||||
USER_ROLES
|
||||
} from "../constants.mjs";
|
||||
import {isNewerVersion, logCompatibilityWarning, mergeObject} from "../utils/module.mjs";
|
||||
import BaseFolder from "../documents/folder.mjs";
|
||||
import {ObjectField} from "../data/fields.mjs";
|
||||
import {DataModelValidationFailure} from "../data/validation-failure.mjs";
|
||||
|
||||
|
||||
/**
|
||||
* A custom SchemaField for defining package compatibility versions.
|
||||
* @property {string} minimum The Package will not function before this version
|
||||
* @property {string} verified Verified compatible up to this version
|
||||
* @property {string} maximum The Package will not function after this version
|
||||
*/
|
||||
export class PackageCompatibility extends fields.SchemaField {
|
||||
constructor(options) {
|
||||
super({
|
||||
minimum: new fields.StringField({required: false, blank: false, initial: undefined}),
|
||||
verified: new fields.StringField({required: false, blank: false, initial: undefined}),
|
||||
maximum: new fields.StringField({required: false, blank: false, initial: undefined})
|
||||
}, options);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A custom SchemaField for defining package relationships.
|
||||
* @property {RelatedPackage[]} systems Systems that this Package supports
|
||||
* @property {RelatedPackage[]} requires Packages that are required for base functionality
|
||||
* @property {RelatedPackage[]} recommends Packages that are recommended for optimal functionality
|
||||
*/
|
||||
export class PackageRelationships extends fields.SchemaField {
|
||||
/** @inheritdoc */
|
||||
constructor(options) {
|
||||
super({
|
||||
systems: new PackageRelationshipField(new RelatedPackage({packageType: "system"})),
|
||||
requires: new PackageRelationshipField(new RelatedPackage()),
|
||||
recommends: new PackageRelationshipField(new RelatedPackage()),
|
||||
conflicts: new PackageRelationshipField(new RelatedPackage()),
|
||||
flags: new fields.ObjectField()
|
||||
}, options);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A SetField with custom casting behavior.
|
||||
*/
|
||||
class PackageRelationshipField extends fields.SetField {
|
||||
/** @override */
|
||||
_cast(value) {
|
||||
return value instanceof Array ? value : [value];
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A custom SchemaField for defining a related Package.
|
||||
* It may be required to be a specific type of package, by passing the packageType option to the constructor.
|
||||
*/
|
||||
export class RelatedPackage extends fields.SchemaField {
|
||||
constructor({packageType, ...options}={}) {
|
||||
let typeOptions = {choices: PACKAGE_TYPES, initial:"module"};
|
||||
if ( packageType ) typeOptions = {choices: [packageType], initial: packageType};
|
||||
super({
|
||||
id: new fields.StringField({required: true, blank: false}),
|
||||
type: new fields.StringField(typeOptions),
|
||||
manifest: new fields.StringField({required: false, blank: false, initial: undefined}),
|
||||
compatibility: new PackageCompatibility(),
|
||||
reason: new fields.StringField({required: false, blank: false, initial: undefined})
|
||||
}, options);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A custom SchemaField for defining the folder structure of the included compendium packs.
|
||||
*/
|
||||
export class PackageCompendiumFolder extends fields.SchemaField {
|
||||
constructor({depth=1, ...options}={}) {
|
||||
const schema = {
|
||||
name: new fields.StringField({required: true, blank: false}),
|
||||
sorting: new fields.StringField({required: false, blank: false, initial: undefined,
|
||||
choices: BaseFolder.SORTING_MODES}),
|
||||
color: new fields.ColorField(),
|
||||
packs: new fields.SetField(new fields.StringField({required: true, blank: false}))
|
||||
};
|
||||
if ( depth < 4 ) schema.folders = new fields.SetField(new PackageCompendiumFolder(
|
||||
{depth: depth+1, options}));
|
||||
super(schema, options);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A special ObjectField which captures a mapping of USER_ROLES to DOCUMENT_OWNERSHIP_LEVELS.
|
||||
*/
|
||||
export class CompendiumOwnershipField extends ObjectField {
|
||||
|
||||
/** @inheritdoc */
|
||||
static get _defaults() {
|
||||
return mergeObject(super._defaults, {
|
||||
initial: {PLAYER: "OBSERVER", ASSISTANT: "OWNER"},
|
||||
validationError: "is not a mapping of USER_ROLES to DOCUMENT_OWNERSHIP_LEVELS"
|
||||
});
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_validateType(value, options) {
|
||||
for ( let [k, v] of Object.entries(value) ) {
|
||||
if ( !(k in USER_ROLES) ) throw new Error(`Compendium ownership key "${k}" is not a valid choice in USER_ROLES`);
|
||||
if ( !(v in DOCUMENT_OWNERSHIP_LEVELS) ) throw new Error(`Compendium ownership value "${v}" is not a valid
|
||||
choice in DOCUMENT_OWNERSHIP_LEVELS`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A special SetField which provides additional validation and initialization behavior specific to compendium packs.
|
||||
*/
|
||||
export class PackageCompendiumPacks extends fields.SetField {
|
||||
|
||||
/** @override */
|
||||
_cleanType(value, options) {
|
||||
return value.map(v => {
|
||||
v = this.element.clean(v, options);
|
||||
if ( v.path ) v.path = v.path.replace(/\.db$/, ""); // Strip old NEDB extensions
|
||||
else v.path = `packs/${v.name}`; // Auto-populate a default pack path
|
||||
return v;
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
initialize(value, model, options={}) {
|
||||
const packs = new Set();
|
||||
const packageName = model._source.id;
|
||||
for ( let v of value ) {
|
||||
try {
|
||||
const pack = this.element.initialize(v, model, options);
|
||||
pack.packageType = model.constructor.type;
|
||||
pack.packageName = packageName;
|
||||
pack.id = `${model.constructor.type === "world" ? "world" : packageName}.${pack.name}`;
|
||||
packs.add(pack);
|
||||
} catch(err) {
|
||||
logger.warn(err.message);
|
||||
}
|
||||
}
|
||||
return packs;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Extend the logic for validating the complete set of packs to ensure uniqueness.
|
||||
* @inheritDoc
|
||||
*/
|
||||
_validateElements(value, options) {
|
||||
const packNames = new Set();
|
||||
const duplicateNames = new Set();
|
||||
const packPaths = new Set();
|
||||
const duplicatePaths = new Set();
|
||||
for ( const pack of value ) {
|
||||
if ( packNames.has(pack.name) ) duplicateNames.add(pack.name);
|
||||
packNames.add(pack.name);
|
||||
if ( pack.path ) {
|
||||
if ( packPaths.has(pack.path) ) duplicatePaths.add(pack.path);
|
||||
packPaths.add(pack.path);
|
||||
}
|
||||
}
|
||||
return super._validateElements(value, {...options, duplicateNames, duplicatePaths});
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate each individual compendium pack, ensuring its name and path are unique.
|
||||
* @inheritDoc
|
||||
*/
|
||||
_validateElement(value, {duplicateNames, duplicatePaths, ...options}={}) {
|
||||
if ( duplicateNames.has(value.name) ) {
|
||||
return new DataModelValidationFailure({
|
||||
invalidValue: value.name,
|
||||
message: `Duplicate Compendium name "${value.name}" already declared by some other pack`,
|
||||
unresolved: true
|
||||
});
|
||||
}
|
||||
if ( duplicatePaths.has(value.path) ) {
|
||||
return new DataModelValidationFailure({
|
||||
invalidValue: value.path,
|
||||
message: `Duplicate Compendium path "${value.path}" already declared by some other pack`,
|
||||
unresolved: true
|
||||
});
|
||||
}
|
||||
return this.element.validate(value, options);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The data schema used to define a Package manifest.
|
||||
* Specific types of packages extend this schema with additional fields.
|
||||
*/
|
||||
export default class BasePackage extends DataModel {
|
||||
/**
|
||||
* @param {PackageManifestData} data Source data for the package
|
||||
* @param {object} [options={}] Options which affect DataModel construction
|
||||
*/
|
||||
constructor(data, options={}) {
|
||||
const {availability, locked, exclusive, owned, tags, hasStorage} = data;
|
||||
super(data, options);
|
||||
|
||||
/**
|
||||
* An availability code in PACKAGE_AVAILABILITY_CODES which defines whether this package can be used.
|
||||
* @type {number}
|
||||
*/
|
||||
this.availability = availability ?? this.constructor.testAvailability(this);
|
||||
|
||||
/**
|
||||
* A flag which tracks whether this package is currently locked.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.locked = locked ?? false;
|
||||
|
||||
/**
|
||||
* A flag which tracks whether this package is a free Exclusive pack
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.exclusive = exclusive ?? false;
|
||||
|
||||
/**
|
||||
* A flag which tracks whether this package is owned, if it is protected.
|
||||
* @type {boolean|null}
|
||||
*/
|
||||
this.owned = owned ?? false;
|
||||
|
||||
/**
|
||||
* A set of Tags that indicate what kind of Package this is, provided by the Website
|
||||
* @type {string[]}
|
||||
*/
|
||||
this.tags = tags ?? [];
|
||||
|
||||
/**
|
||||
* A flag which tracks if this package has files stored in the persistent storage folder
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.hasStorage = hasStorage ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the package type in CONST.PACKAGE_TYPES that this class represents.
|
||||
* Each BasePackage subclass must define this attribute.
|
||||
* @virtual
|
||||
* @type {string}
|
||||
*/
|
||||
static type = "package";
|
||||
|
||||
/**
|
||||
* The type of this package instance. A value in CONST.PACKAGE_TYPES.
|
||||
* @type {string}
|
||||
*/
|
||||
get type() {
|
||||
return this.constructor.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical identifier for this package
|
||||
* @return {string}
|
||||
* @deprecated
|
||||
*/
|
||||
get name() {
|
||||
logCompatibilityWarning("You are accessing BasePackage#name which is now deprecated in favor of id.",
|
||||
{since: 10, until: 13});
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A flag which defines whether this package is unavailable to be used.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get unavailable() {
|
||||
return this.availability > PACKAGE_AVAILABILITY_CODES.UNVERIFIED_GENERATION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this Package incompatible with the currently installed core Foundry VTT software version?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get incompatibleWithCoreVersion() {
|
||||
return this.constructor.isIncompatibleWithCoreVersion(this.availability);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if a given availability is incompatible with the core version.
|
||||
* @param {number} availability The availability value to test.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isIncompatibleWithCoreVersion(availability) {
|
||||
const codes = CONST.PACKAGE_AVAILABILITY_CODES;
|
||||
return (availability >= codes.REQUIRES_CORE_DOWNGRADE) && (availability <= codes.REQUIRES_CORE_UPGRADE_UNSTABLE);
|
||||
}
|
||||
|
||||
/**
|
||||
* The named collection to which this package type belongs
|
||||
* @type {string}
|
||||
*/
|
||||
static get collection() {
|
||||
return `${this.type}s`;
|
||||
}
|
||||
|
||||
/** @inheritDoc */
|
||||
static defineSchema() {
|
||||
const optionalString = {required: false, blank: false, initial: undefined};
|
||||
return {
|
||||
|
||||
// Package metadata
|
||||
id: new fields.StringField({required: true, blank: false, validate: this.validateId}),
|
||||
title: new fields.StringField({required: true, blank: false}),
|
||||
description: new fields.StringField({required: true}),
|
||||
authors: new fields.SetField(new fields.SchemaField({
|
||||
name: new fields.StringField({required: true, blank: false}),
|
||||
email: new fields.StringField(optionalString),
|
||||
url: new fields.StringField(optionalString),
|
||||
discord: new fields.StringField(optionalString),
|
||||
flags: new fields.ObjectField(),
|
||||
})),
|
||||
url: new fields.StringField(optionalString),
|
||||
license: new fields.StringField(optionalString),
|
||||
readme: new fields.StringField(optionalString),
|
||||
bugs: new fields.StringField(optionalString),
|
||||
changelog: new fields.StringField(optionalString),
|
||||
flags: new fields.ObjectField(),
|
||||
media: new fields.SetField(new fields.SchemaField({
|
||||
type: new fields.StringField(optionalString),
|
||||
url: new fields.StringField(optionalString),
|
||||
caption: new fields.StringField(optionalString),
|
||||
loop: new fields.BooleanField({required: false, blank: false, initial: false}),
|
||||
thumbnail: new fields.StringField(optionalString),
|
||||
flags: new fields.ObjectField(),
|
||||
})),
|
||||
|
||||
// Package versioning
|
||||
version: new fields.StringField({required: true, blank: false, initial: "0"}),
|
||||
compatibility: new PackageCompatibility(),
|
||||
|
||||
// Included content
|
||||
scripts: new fields.SetField(new fields.StringField({required: true, blank: false})),
|
||||
esmodules: new fields.SetField(new fields.StringField({required: true, blank: false})),
|
||||
styles: new fields.SetField(new fields.StringField({required: true, blank: false})),
|
||||
languages: new fields.SetField(new fields.SchemaField({
|
||||
lang: new fields.StringField({required: true, blank: false, validate: Intl.getCanonicalLocales,
|
||||
validationError: "must be supported by the Intl.getCanonicalLocales function"
|
||||
}),
|
||||
name: new fields.StringField({required: false}),
|
||||
path: new fields.StringField({required: true, blank: false}),
|
||||
system: new fields.StringField(optionalString),
|
||||
module: new fields.StringField(optionalString),
|
||||
flags: new fields.ObjectField(),
|
||||
})),
|
||||
packs: new PackageCompendiumPacks(new fields.SchemaField({
|
||||
name: new fields.StringField({required: true, blank: false, validate: this.validateId}),
|
||||
label: new fields.StringField({required: true, blank: false}),
|
||||
banner: new fields.StringField({...optionalString, nullable: true}),
|
||||
path: new fields.StringField({required: false}),
|
||||
type: new fields.StringField({required: true, blank: false, choices: COMPENDIUM_DOCUMENT_TYPES,
|
||||
validationError: "must be a value in CONST.COMPENDIUM_DOCUMENT_TYPES"}),
|
||||
system: new fields.StringField(optionalString),
|
||||
ownership: new CompendiumOwnershipField(),
|
||||
flags: new fields.ObjectField(),
|
||||
}, {validate: BasePackage.#validatePack})),
|
||||
packFolders: new fields.SetField(new PackageCompendiumFolder()),
|
||||
|
||||
// Package relationships
|
||||
relationships: new PackageRelationships(),
|
||||
socket: new fields.BooleanField(),
|
||||
|
||||
// Package downloading
|
||||
manifest: new fields.StringField(),
|
||||
download: new fields.StringField({required: false, blank: false, initial: undefined}),
|
||||
protected: new fields.BooleanField(),
|
||||
exclusive: new fields.BooleanField(),
|
||||
persistentStorage: new fields.BooleanField(),
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Check the given compatibility data against the current installation state and determine its availability.
|
||||
* @param {Partial<PackageManifestData>} data The compatibility data to test.
|
||||
* @param {object} [options]
|
||||
* @param {ReleaseData} [options.release] A specific software release for which to test availability.
|
||||
* Tests against the current release by default.
|
||||
* @returns {number}
|
||||
*/
|
||||
static testAvailability({ compatibility }, { release }={}) {
|
||||
release ??= globalThis.release ?? game.release;
|
||||
const codes = CONST.PACKAGE_AVAILABILITY_CODES;
|
||||
const {minimum, maximum, verified} = compatibility;
|
||||
const isGeneration = version => Number.isInteger(Number(version));
|
||||
|
||||
// Require a certain minimum core version.
|
||||
if ( minimum && isNewerVersion(minimum, release.version) ) {
|
||||
const generation = Number(minimum.split(".").shift());
|
||||
const isStable = generation <= release.maxStableGeneration;
|
||||
const exists = generation <= release.maxGeneration;
|
||||
if ( isStable ) return codes.REQUIRES_CORE_UPGRADE_STABLE;
|
||||
return exists ? codes.REQUIRES_CORE_UPGRADE_UNSTABLE : codes.UNKNOWN;
|
||||
}
|
||||
|
||||
// Require a certain maximum core version.
|
||||
if ( maximum ) {
|
||||
const compatible = isGeneration(maximum)
|
||||
? release.generation <= Number(maximum)
|
||||
: !isNewerVersion(release.version, maximum);
|
||||
if ( !compatible ) return codes.REQUIRES_CORE_DOWNGRADE;
|
||||
}
|
||||
|
||||
// Require a certain compatible core version.
|
||||
if ( verified ) {
|
||||
const compatible = isGeneration(verified)
|
||||
? Number(verified) >= release.generation
|
||||
: !isNewerVersion(release.version, verified);
|
||||
const sameGeneration = release.generation === Number(verified.split(".").shift());
|
||||
if ( compatible ) return codes.VERIFIED;
|
||||
return sameGeneration ? codes.UNVERIFIED_BUILD : codes.UNVERIFIED_GENERATION;
|
||||
}
|
||||
|
||||
// FIXME: Why do we not check if all of this package's dependencies are satisfied?
|
||||
// Proposal: Check all relationships.requires and set MISSING_DEPENDENCY if any dependencies are not VERIFIED,
|
||||
// UNVERIFIED_BUILD, or UNVERIFIED_GENERATION, or if they do not satisfy the given compatibility range for the
|
||||
// relationship.
|
||||
|
||||
// No compatible version is specified.
|
||||
return codes.UNKNOWN;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test that the dependencies of a package are satisfied as compatible.
|
||||
* This method assumes that all packages in modulesCollection have already had their own availability tested.
|
||||
* @param {Collection<string,Module>} modulesCollection A collection which defines the set of available modules
|
||||
* @returns {Promise<boolean>} Are all required dependencies satisfied?
|
||||
* @internal
|
||||
*/
|
||||
async _testRequiredDependencies(modulesCollection) {
|
||||
const requirements = this.relationships.requires;
|
||||
for ( const {id, type, manifest, compatibility} of requirements ) {
|
||||
if ( type !== "module" ) continue; // Only test modules
|
||||
let pkg;
|
||||
|
||||
// If the requirement specifies an explicit remote manifest URL, we need to load it
|
||||
if ( manifest ) {
|
||||
try {
|
||||
pkg = await this.constructor.fromRemoteManifest(manifest, {strict: true});
|
||||
} catch(err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise the dependency must belong to the known modulesCollection
|
||||
else pkg = modulesCollection.get(id);
|
||||
if ( !pkg ) return false;
|
||||
|
||||
// Ensure that the package matches the required compatibility range
|
||||
if ( !this.constructor.testDependencyCompatibility(compatibility, pkg) ) return false;
|
||||
|
||||
// Test compatibility of the dependency
|
||||
if ( pkg.unavailable ) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test compatibility of a package's supported systems.
|
||||
* @param {Collection<string, System>} systemCollection A collection which defines the set of available systems.
|
||||
* @returns {Promise<boolean>} True if all supported systems which are currently installed
|
||||
* are compatible or if the package has no supported systems.
|
||||
* Returns false otherwise, or if no supported systems are
|
||||
* installed.
|
||||
* @internal
|
||||
*/
|
||||
async _testSupportedSystems(systemCollection) {
|
||||
const systems = this.relationships.systems;
|
||||
if ( !systems?.size ) return true;
|
||||
let supportedSystem = false;
|
||||
for ( const { id, compatibility } of systems ) {
|
||||
const pkg = systemCollection.get(id);
|
||||
if ( !pkg ) continue;
|
||||
if ( !this.constructor.testDependencyCompatibility(compatibility, pkg) || pkg.unavailable ) return false;
|
||||
supportedSystem = true;
|
||||
}
|
||||
return supportedSystem;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine if a dependency is within the given compatibility range.
|
||||
* @param {PackageCompatibility} compatibility The compatibility range declared for the dependency, if any
|
||||
* @param {BasePackage} dependency The known dependency package
|
||||
* @returns {boolean} Is the dependency compatible with the required range?
|
||||
*/
|
||||
static testDependencyCompatibility(compatibility, dependency) {
|
||||
if ( !compatibility ) return true;
|
||||
const {minimum, maximum} = compatibility;
|
||||
if ( minimum && isNewerVersion(minimum, dependency.version) ) return false;
|
||||
if ( maximum && isNewerVersion(dependency.version, maximum) ) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static cleanData(source={}, { installed, ...options }={}) {
|
||||
|
||||
// Auto-assign language name
|
||||
for ( let l of source.languages || [] ) {
|
||||
l.name = l.name ?? l.lang;
|
||||
}
|
||||
|
||||
// Identify whether this package depends on a single game system
|
||||
let systemId = undefined;
|
||||
if ( this.type === "system" ) systemId = source.id;
|
||||
else if ( this.type === "world" ) systemId = source.system;
|
||||
else if ( source.relationships?.systems?.length === 1 ) systemId = source.relationships.systems[0].id;
|
||||
|
||||
// Auto-configure some package data
|
||||
for ( const pack of source.packs || [] ) {
|
||||
if ( !pack.system && systemId ) pack.system = systemId; // System dependency
|
||||
if ( typeof pack.ownership === "string" ) pack.ownership = {PLAYER: pack.ownership};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean unsupported non-module dependencies in requires or recommends.
|
||||
* @deprecated since v11
|
||||
*/
|
||||
["requires", "recommends"].forEach(rel => {
|
||||
const pkgs = source.relationships?.[rel];
|
||||
if ( !Array.isArray(pkgs) ) return;
|
||||
const clean = [];
|
||||
for ( const pkg of pkgs ) {
|
||||
if ( !pkg.type || (pkg.type === "module") ) clean.push(pkg);
|
||||
}
|
||||
const diff = pkgs.length - clean.length;
|
||||
if ( diff ) {
|
||||
source.relationships[rel] = clean;
|
||||
this._logWarning(
|
||||
source.id,
|
||||
`The ${this.type} "${source.id}" has a ${rel} relationship on a non-module, which is not supported.`,
|
||||
{ since: 11, until: 13, stack: false, installed });
|
||||
}
|
||||
});
|
||||
return super.cleanData(source, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate that a Package ID is allowed.
|
||||
* @param {string} id The candidate ID
|
||||
* @throws An error if the candidate ID is invalid
|
||||
*/
|
||||
static validateId(id) {
|
||||
const allowed = /^[A-Za-z0-9-_]+$/;
|
||||
if ( !allowed.test(id) ) {
|
||||
throw new Error("Package and compendium pack IDs may only be alphanumeric with hyphens or underscores.");
|
||||
}
|
||||
const prohibited = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
||||
if ( prohibited.test(id) ) throw new Error(`The ID "${id}" uses an operating system prohibited value.`);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate a single compendium pack object
|
||||
* @param {PackageCompendiumData} packData Candidate compendium packs data
|
||||
* @throws An error if the data is invalid
|
||||
*/
|
||||
static #validatePack(packData) {
|
||||
if ( SYSTEM_SPECIFIC_COMPENDIUM_TYPES.includes(packData.type) && !packData.system ) {
|
||||
throw new Error(`The Compendium pack "${packData.name}" of the "${packData.type}" type must declare the "system"`
|
||||
+ " upon which it depends.");
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A wrapper around the default compatibility warning logger which handles some package-specific interactions.
|
||||
* @param {string} packageId The package ID being logged
|
||||
* @param {string} message The warning or error being logged
|
||||
* @param {object} options Logging options passed to foundry.utils.logCompatibilityWarning
|
||||
* @param {object} [options.installed] Is the package installed?
|
||||
* @internal
|
||||
*/
|
||||
static _logWarning(packageId, message, { installed, ...options }={}) {
|
||||
logCompatibilityWarning(message, options);
|
||||
if ( installed ) globalThis.packages?.warnings?.add(packageId, {type: this.type, level: "warning", message});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A set of package manifest keys that are migrated.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
static migratedKeys = new Set([
|
||||
/** @deprecated since 10 until 13 */
|
||||
"name", "dependencies", "minimumCoreVersion", "compatibleCoreVersion"
|
||||
]);
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static migrateData(data, { installed }={}) {
|
||||
this._migrateNameToId(data, {since: 10, until: 13, stack: false, installed});
|
||||
this._migrateDependenciesNameToId(data, {since: 10, until: 13, stack: false, installed});
|
||||
this._migrateToRelationships(data, {since: 10, until: 13, stack: false, installed});
|
||||
this._migrateCompatibility(data, {since: 10, until: 13, stack: false, installed});
|
||||
this._migrateMediaURL(data, {since: 11, until: 13, stack: false, installed});
|
||||
this._migrateOwnership(data, {since: 11, until: 13, stack: false, installed});
|
||||
this._migratePackIDs(data, {since: 12, until: 14, stack: false, installed});
|
||||
this._migratePackEntityToType(data, {since: 9, stack: false, installed});
|
||||
return super.migrateData(data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @internal */
|
||||
static _migrateNameToId(data, logOptions) {
|
||||
if ( data.name && !data.id ) {
|
||||
data.id = data.name;
|
||||
delete data.name;
|
||||
if ( this.type !== "world" ) {
|
||||
const warning = `The ${this.type} "${data.id}" is using "name" which is deprecated in favor of "id"`;
|
||||
this._logWarning(data.id, warning, logOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @internal */
|
||||
static _migrateDependenciesNameToId(data, logOptions) {
|
||||
if ( data.relationships ) return;
|
||||
if ( data.dependencies ) {
|
||||
let hasDependencyName = false;
|
||||
for ( const dependency of data.dependencies ) {
|
||||
if ( dependency.name && !dependency.id ) {
|
||||
hasDependencyName = true;
|
||||
dependency.id = dependency.name;
|
||||
delete dependency.name;
|
||||
}
|
||||
}
|
||||
if ( hasDependencyName ) {
|
||||
const msg = `The ${this.type} "${data.id}" contains dependencies using "name" which is deprecated in favor of "id"`;
|
||||
this._logWarning(data.id, msg, logOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @internal */
|
||||
static _migrateToRelationships(data, logOptions) {
|
||||
if ( data.relationships ) return;
|
||||
data.relationships = {
|
||||
requires: [],
|
||||
systems: []
|
||||
};
|
||||
|
||||
// Dependencies -> Relationships.Requires
|
||||
if ( data.dependencies ) {
|
||||
for ( const d of data.dependencies ) {
|
||||
const relationship = {
|
||||
"id": d.id,
|
||||
"type": d.type,
|
||||
"manifest": d.manifest,
|
||||
"compatibility": {
|
||||
"compatible": d.version
|
||||
}
|
||||
};
|
||||
d.type === "system" ? data.relationships.systems.push(relationship) : data.relationships.requires.push(relationship);
|
||||
}
|
||||
const msg = `The ${this.type} "${data.id}" contains "dependencies" which is deprecated in favor of "relationships.requires"`;
|
||||
this._logWarning(data.id, msg, logOptions);
|
||||
delete data.dependencies;
|
||||
}
|
||||
|
||||
// V9: system -> relationships.systems
|
||||
else if ( data.system && (this.type === "module") ) {
|
||||
data.system = data.system instanceof Array ? data.system : [data.system];
|
||||
const newSystems = data.system.map(id => ({id})).filter(s => !data.relationships.systems.find(x => x.id === s.id));
|
||||
data.relationships.systems = data.relationships.systems.concat(newSystems);
|
||||
const msg = `${this.type} "${data.id}" contains "system" which is deprecated in favor of "relationships.systems"`;
|
||||
this._logWarning(data.id, msg, logOptions);
|
||||
delete data.system;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @internal */
|
||||
static _migrateCompatibility(data, logOptions) {
|
||||
if ( !data.compatibility && (data.minimumCoreVersion || data.compatibleCoreVersion) ) {
|
||||
this._logWarning(data.id, `The ${this.type} "${data.id}" is using the old flat core compatibility fields which `
|
||||
+ `are deprecated in favor of the new "compatibility" object`,
|
||||
logOptions);
|
||||
data.compatibility = {
|
||||
minimum: data.minimumCoreVersion,
|
||||
verified: data.compatibleCoreVersion
|
||||
};
|
||||
delete data.minimumCoreVersion;
|
||||
delete data.compatibleCoreVersion;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @internal */
|
||||
static _migrateMediaURL(data, logOptions) {
|
||||
if ( !data.media ) return;
|
||||
let hasMediaLink = false;
|
||||
for ( const media of data.media ) {
|
||||
if ( "link" in media ) {
|
||||
hasMediaLink = true;
|
||||
media.url = media.link;
|
||||
delete media.link;
|
||||
}
|
||||
}
|
||||
if ( hasMediaLink ) {
|
||||
const msg = `${this.type} "${data.id}" declares media.link which is unsupported, media.url should be used`;
|
||||
this._logWarning(data.id, msg, logOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @internal */
|
||||
static _migrateOwnership(data, logOptions) {
|
||||
if ( !data.packs ) return;
|
||||
let hasPrivatePack = false;
|
||||
for ( const pack of data.packs ) {
|
||||
if ( pack.private && !("ownership" in pack) ) {
|
||||
pack.ownership = {PLAYER: "LIMITED", ASSISTANT: "OWNER"};
|
||||
hasPrivatePack = true;
|
||||
}
|
||||
delete pack.private;
|
||||
}
|
||||
if ( hasPrivatePack ) {
|
||||
const msg = `${this.type} "${data.id}" uses pack.private which has been replaced with pack.ownership`;
|
||||
this._logWarning(data.id, msg, logOptions);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @internal */
|
||||
static _migratePackIDs(data, logOptions) {
|
||||
if ( !data.packs ) return;
|
||||
for ( const pack of data.packs ) {
|
||||
const slugified = pack.name.replace(/[^A-Za-z0-9-_]/g, "");
|
||||
if ( pack.name !== slugified ) {
|
||||
const msg = `The ${this.type} "${data.id}" contains a pack with an invalid name "${pack.name}". `
|
||||
+ "Pack names containing any character that is non-alphanumeric or an underscore will cease loading in "
|
||||
+ "version 14 of the software.";
|
||||
pack.name = slugified;
|
||||
this._logWarning(data.id, msg, logOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @internal */
|
||||
static _migratePackEntityToType(data, logOptions) {
|
||||
if ( !data.packs ) return;
|
||||
let hasPackEntity = false;
|
||||
for ( const pack of data.packs ) {
|
||||
if ( ("entity" in pack) && !("type" in pack) ) {
|
||||
pack.type = pack.entity;
|
||||
hasPackEntity = true;
|
||||
}
|
||||
delete pack.entity;
|
||||
}
|
||||
if ( hasPackEntity ) {
|
||||
const msg = `${this.type} "${data.id}" uses pack.entity which has been replaced with pack.type`;
|
||||
this._logWarning(data.id, msg, logOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve the latest Package manifest from a provided remote location.
|
||||
* @param {string} manifestUrl A remote manifest URL to load
|
||||
* @param {object} options Additional options which affect package construction
|
||||
* @param {boolean} [options.strict=true] Whether to construct the remote package strictly
|
||||
* @return {Promise<ServerPackage>} A Promise which resolves to a constructed ServerPackage instance
|
||||
* @throws An error if the retrieved manifest data is invalid
|
||||
*/
|
||||
static async fromRemoteManifest(manifestUrl, {strict=true}={}) {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
}
|
||||
150
resources/app/common/packages/base-system.mjs
Normal file
150
resources/app/common/packages/base-system.mjs
Normal file
@@ -0,0 +1,150 @@
|
||||
import BasePackage from "./base-package.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import AdditionalTypesField from "./sub-types.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./sub-types.mjs").DocumentTypesConfiguration} DocumentTypesConfiguration
|
||||
*/
|
||||
|
||||
/**
|
||||
* The data schema used to define System manifest files.
|
||||
* Extends the basic PackageData schema with some additional system-specific fields.
|
||||
* @property {DocumentTypesConfiguration} [documentTypes] Additional document subtypes provided by this system.
|
||||
* @property {string} [background] A web URL or local file path which provides a default background banner for
|
||||
* worlds which are created using this system
|
||||
* @property {string} [initiative] A default initiative formula used for this system
|
||||
* @property {number} [grid] The default grid settings to use for Scenes in this system
|
||||
* @property {number} [grid.type] A default grid type to use for Scenes in this system
|
||||
* @property {number} [grid.distance] A default distance measurement to use for Scenes in this system
|
||||
* @property {string} [grid.units] A default unit of measure to use for distance measurement in this system
|
||||
* @property {number} [grid.diagonals] The default rule used by this system for diagonal measurement on square grids
|
||||
* @property {string} [primaryTokenAttribute] An Actor data attribute path to use for Token primary resource bars
|
||||
* @property {string} [secondaryTokenAttribute] An Actor data attribute path to use for Token secondary resource bars
|
||||
*/
|
||||
export default class BaseSystem extends BasePackage {
|
||||
|
||||
/** @inheritDoc */
|
||||
static defineSchema() {
|
||||
return Object.assign({}, super.defineSchema(), {
|
||||
documentTypes: new AdditionalTypesField(),
|
||||
background: new fields.StringField({required: false, blank: false}),
|
||||
initiative: new fields.StringField(),
|
||||
grid: new fields.SchemaField({
|
||||
type: new fields.NumberField({required: true, choices: Object.values(CONST.GRID_TYPES),
|
||||
initial: CONST.GRID_TYPES.SQUARE, validationError: "must be a value in CONST.GRID_TYPES"}),
|
||||
distance: new fields.NumberField({required: true, nullable: false, positive: true, initial: 1}),
|
||||
units: new fields.StringField({required: true}),
|
||||
diagonals: new fields.NumberField({required: true, choices: Object.values(CONST.GRID_DIAGONALS),
|
||||
initial: CONST.GRID_DIAGONALS.EQUIDISTANT, validationError: "must be a value in CONST.GRID_DIAGONALS"}),
|
||||
}),
|
||||
primaryTokenAttribute: new fields.StringField(),
|
||||
secondaryTokenAttribute: new fields.StringField()
|
||||
});
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
static type = "system";
|
||||
|
||||
/**
|
||||
* The default icon used for this type of Package.
|
||||
* @type {string}
|
||||
*/
|
||||
static icon = "fa-dice";
|
||||
|
||||
/**
|
||||
* Does the system template request strict type checking of data compared to template.json inferred types.
|
||||
* @type {boolean}
|
||||
*/
|
||||
strictDataCleaning = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Static initializer block for deprecated properties.
|
||||
*/
|
||||
static {
|
||||
/**
|
||||
* Shim grid distance and units.
|
||||
* @deprecated since v12
|
||||
*/
|
||||
Object.defineProperties(this.prototype, Object.fromEntries(
|
||||
Object.entries({
|
||||
gridDistance: "grid.distance",
|
||||
gridUnits: "grid.units"
|
||||
}).map(([o, n]) => [o, {
|
||||
get() {
|
||||
const msg = `You are accessing BasePackage#${o} which has been migrated to BasePackage#${n}.`;
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
|
||||
return foundry.utils.getProperty(this, n);
|
||||
},
|
||||
set(v) {
|
||||
const msg = `You are accessing BasePackage#${o} which has been migrated to BasePackage#${n}.`;
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
|
||||
return foundry.utils.setProperty(this, n, v);
|
||||
},
|
||||
configurable: true
|
||||
}])
|
||||
));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static migratedKeys = (function() {
|
||||
return BasePackage.migratedKeys.union(new Set([
|
||||
/** @deprecated since 12 until 14 */
|
||||
"gridDistance", "gridUnits"
|
||||
]));
|
||||
})();
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static migrateData(data, options) {
|
||||
/**
|
||||
* Migrate grid distance and units.
|
||||
* @deprecated since v12
|
||||
*/
|
||||
for ( const [oldKey, [newKey, apply]] of Object.entries({
|
||||
gridDistance: ["grid.distance", d => Math.max(d.gridDistance || 0, 1)],
|
||||
gridUnits: ["grid.units", d => d.gridUnits || ""]
|
||||
})) {
|
||||
if ( (oldKey in data) && !foundry.utils.hasProperty(data, newKey) ) {
|
||||
foundry.utils.setProperty(data, newKey, apply(data));
|
||||
delete data[oldKey];
|
||||
const warning = `The ${this.type} "${data.id}" is using "${oldKey}" which is deprecated in favor of "${newKey}".`;
|
||||
this._logWarning(data.id, warning, {since: 12, until: 14, stack: false, installed: options.installed});
|
||||
}
|
||||
}
|
||||
return super.migrateData(data, options);
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static shimData(data, options) {
|
||||
/**
|
||||
* Shim grid distance and units.
|
||||
* @deprecated since v12
|
||||
*/
|
||||
for ( const [oldKey, newKey] of Object.entries({
|
||||
gridDistance: "grid.distance",
|
||||
gridUnits: "grid.units"
|
||||
})) {
|
||||
if ( !data.hasOwnProperty(oldKey) && foundry.utils.hasProperty(data, newKey) ) {
|
||||
Object.defineProperty(data, oldKey, {
|
||||
get: () => {
|
||||
const msg = `You are accessing BasePackage#${oldKey} which has been migrated to BasePackage#${newKey}.`;
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
|
||||
return foundry.utils.getProperty(data, newKey);
|
||||
},
|
||||
set: value => foundry.utils.setProperty(data, newKey, value),
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
}
|
||||
return super.shimData(data, options);
|
||||
}
|
||||
}
|
||||
119
resources/app/common/packages/base-world.mjs
Normal file
119
resources/app/common/packages/base-world.mjs
Normal file
@@ -0,0 +1,119 @@
|
||||
import BasePackage from "./base-package.mjs";
|
||||
import * as fields from "../data/fields.mjs";
|
||||
import {WORLD_JOIN_THEMES} from "../constants.mjs";
|
||||
|
||||
/**
|
||||
* The data schema used to define World manifest files.
|
||||
* Extends the basic PackageData schema with some additional world-specific fields.
|
||||
* @property {string} system The game system name which this world relies upon
|
||||
* @property {string} coreVersion The version of the core software for which this world has been migrated
|
||||
* @property {string} systemVersion The version of the game system for which this world has been migrated
|
||||
* @property {string} [background] A web URL or local file path which provides a background banner image
|
||||
* @property {string} [nextSession] An ISO datetime string when the next game session is scheduled to occur
|
||||
* @property {boolean} [resetKeys] Should user access keys be reset as part of the next launch?
|
||||
* @property {boolean} [safeMode] Should the world launch in safe mode?
|
||||
* @property {string} [joinTheme] The theme to use for this world's join page.
|
||||
*/
|
||||
export default class BaseWorld extends BasePackage {
|
||||
|
||||
/** @inheritDoc */
|
||||
static defineSchema() {
|
||||
return Object.assign({}, super.defineSchema(), {
|
||||
system: new fields.StringField({required: true, blank: false}),
|
||||
background: new fields.StringField({required: false, blank: false}),
|
||||
joinTheme: new fields.StringField({
|
||||
required: false, initial: undefined, nullable: false, blank: false, choices: WORLD_JOIN_THEMES
|
||||
}),
|
||||
coreVersion: new fields.StringField({required: true, blank: false}),
|
||||
systemVersion: new fields.StringField({required: true, blank: false, initial: "0"}),
|
||||
lastPlayed: new fields.StringField(),
|
||||
playtime: new fields.NumberField({integer: true, min: 0, initial: 0}),
|
||||
nextSession: new fields.StringField({blank: false, nullable: true, initial: null}),
|
||||
resetKeys: new fields.BooleanField({required: false, initial: undefined}),
|
||||
safeMode: new fields.BooleanField({required: false, initial: undefined}),
|
||||
version: new fields.StringField({required: true, blank: false, nullable: true, initial: null})
|
||||
});
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static type = "world";
|
||||
|
||||
/**
|
||||
* The default icon used for this type of Package.
|
||||
* @type {string}
|
||||
*/
|
||||
static icon = "fa-globe-asia";
|
||||
|
||||
/** @inheritDoc */
|
||||
static migrateData(data) {
|
||||
super.migrateData(data);
|
||||
|
||||
// Legacy compatibility strings
|
||||
data.compatibility = data.compatibility || {};
|
||||
if ( data.compatibility.maximum === "1.0.0" ) data.compatibility.maximum = undefined;
|
||||
if ( data.coreVersion && !data.compatibility.verified ) {
|
||||
data.compatibility.minimum = data.compatibility.verified = data.coreVersion;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Check the given compatibility data against the current installation state and determine its availability.
|
||||
* @param {Partial<PackageManifestData>} data The compatibility data to test.
|
||||
* @param {object} [options]
|
||||
* @param {ReleaseData} [options.release] A specific software release for which to test availability.
|
||||
* Tests against the current release by default.
|
||||
* @param {Collection<string, Module>} [options.modules] A specific collection of modules to test availability
|
||||
* against. Tests against the currently installed modules by
|
||||
* default.
|
||||
* @param {Collection<string, System>} [options.systems] A specific collection of systems to test availability
|
||||
* against. Tests against the currently installed systems by
|
||||
* default.
|
||||
* @param {number} [options.systemAvailabilityThreshold] Ignore the world's own core software compatibility and
|
||||
* instead defer entirely to the system's core software
|
||||
* compatibility, if the world's availability is less than
|
||||
* this.
|
||||
* @returns {number}
|
||||
*/
|
||||
static testAvailability(data, { release, modules, systems, systemAvailabilityThreshold }={}) {
|
||||
systems ??= globalThis.packages?.System ?? game.systems;
|
||||
modules ??= globalThis.packages?.Module ?? game.modules;
|
||||
const { relationships } = data;
|
||||
const codes = CONST.PACKAGE_AVAILABILITY_CODES;
|
||||
systemAvailabilityThreshold ??= codes.UNKNOWN
|
||||
|
||||
// If the World itself is incompatible for some reason, report that directly.
|
||||
const wa = super.testAvailability(data, { release });
|
||||
if ( this.isIncompatibleWithCoreVersion(wa) ) return wa;
|
||||
|
||||
// If the System is missing or incompatible, report that directly.
|
||||
const system = data.system instanceof foundry.packages.BaseSystem ? data.system : systems.get(data.system);
|
||||
if ( !system ) return codes.MISSING_SYSTEM;
|
||||
const sa = system.availability;
|
||||
// FIXME: Why do we only check if the system is incompatible with the core version or UNKNOWN?
|
||||
// Proposal: If the system is anything but VERIFIED, UNVERIFIED_BUILD, or UNVERIFIED_GENERATION, we should return
|
||||
// the system availability.
|
||||
if ( system.incompatibleWithCoreVersion || (sa === codes.UNKNOWN) ) return sa;
|
||||
|
||||
// Test the availability of all required modules.
|
||||
const checkedModules = new Set();
|
||||
// TODO: We do not need to check system requirements here if the above proposal is implemented.
|
||||
const requirements = [...relationships.requires.values(), ...system.relationships.requires.values()];
|
||||
for ( const r of requirements ) {
|
||||
if ( (r.type !== "module") || checkedModules.has(r.id) ) continue;
|
||||
const module = modules.get(r.id);
|
||||
if ( !module ) return codes.MISSING_DEPENDENCY;
|
||||
// FIXME: Why do we only check if the module is incompatible with the core version?
|
||||
// Proposal: We should check the actual compatibility information for the relationship to ensure that the module
|
||||
// satisfies it.
|
||||
if ( module.incompatibleWithCoreVersion ) return codes.REQUIRES_DEPENDENCY_UPDATE;
|
||||
checkedModules.add(r.id);
|
||||
}
|
||||
|
||||
// Inherit from the System availability in certain cases.
|
||||
if ( wa <= systemAvailabilityThreshold ) return sa;
|
||||
return wa;
|
||||
}
|
||||
}
|
||||
76
resources/app/common/packages/module.mjs
Normal file
76
resources/app/common/packages/module.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
/** @module packages */
|
||||
|
||||
export {default as BasePackage} from "./base-package.mjs";
|
||||
export {default as BaseWorld} from "./base-world.mjs";
|
||||
export {default as BaseSystem} from "./base-system.mjs";
|
||||
export {default as BaseModule} from "./base-module.mjs";
|
||||
export {PackageCompatibility, RelatedPackage} from "./base-package.mjs";
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* Type Definitions */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {Object} PackageAuthorData
|
||||
* @property {string} name The author name
|
||||
* @property {string} [email] The author email address
|
||||
* @property {string} [url] A website url for the author
|
||||
* @property {string} [discord] A Discord username for the author
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PackageCompendiumData
|
||||
* @property {string} name The canonical compendium name. This should contain no spaces or special characters
|
||||
* @property {string} label The human-readable compendium name
|
||||
* @property {string} path The local relative path to the compendium source directory. The filename should match
|
||||
* the name attribute
|
||||
* @property {string} type The specific document type that is contained within this compendium pack
|
||||
* @property {string} [system] Denote that this compendium pack requires a specific game system to function properly
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PackageLanguageData
|
||||
* @property {string} lang A string language code which is validated by Intl.getCanonicalLocales
|
||||
* @property {string} name The human-readable language name
|
||||
* @property {string} path The relative path to included JSON translation strings
|
||||
* @property {string} [system] Only apply this set of translations when a specific system is being used
|
||||
* @property {string} [module] Only apply this set of translations when a specific module is active
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RelatedPackage
|
||||
* @property {string} id The id of the related package
|
||||
* @property {string} type The type of the related package
|
||||
* @property {string} [manifest] An explicit manifest URL, otherwise learned from the Foundry web server
|
||||
* @property {PackageCompatibility} [compatibility] The compatibility data with this related Package
|
||||
* @property {string} [reason] The reason for this relationship
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PackageManifestData
|
||||
* The data structure of a package manifest. This data structure is extended by BasePackage subclasses to add additional
|
||||
* type-specific fields.
|
||||
* [[include:full-manifest.md]]
|
||||
*
|
||||
* @property {string} id The machine-readable unique package id, should be lower-case with no spaces or special characters
|
||||
* @property {string} title The human-readable package title, containing spaces and special characters
|
||||
* @property {string} [description] An optional package description, may contain HTML
|
||||
* @property {PackageAuthorData[]} [authors] An array of author objects who are co-authors of this package. Preferred to the singular author field.
|
||||
* @property {string} [url] A web url where more details about the package may be found
|
||||
* @property {string} [license] A web url or relative file path where license details may be found
|
||||
* @property {string} [readme] A web url or relative file path where readme instructions may be found
|
||||
* @property {string} [bugs] A web url where bug reports may be submitted and tracked
|
||||
* @property {string} [changelog] A web url where notes detailing package updates are available
|
||||
* @property {string} version The current package version
|
||||
* @property {PackageCompatibility} [compatibility] The compatibility of this version with the core Foundry software
|
||||
* @property {string[]} [scripts] An array of urls or relative file paths for JavaScript files which should be included
|
||||
* @property {string[]} [esmodules] An array of urls or relative file paths for ESModule files which should be included
|
||||
* @property {string[]} [styles] An array of urls or relative file paths for CSS stylesheet files which should be included
|
||||
* @property {PackageLanguageData[]} [languages] An array of language data objects which are included by this package
|
||||
* @property {PackageCompendiumData[]} [packs] An array of compendium packs which are included by this package
|
||||
* @property {PackageRelationships} [relationships] An organized object of relationships to other Packages
|
||||
* @property {boolean} [socket] Whether to require a package-specific socket namespace for this package
|
||||
* @property {string} [manifest] A publicly accessible web URL which provides the latest available package manifest file. Required in order to support module updates.
|
||||
* @property {string} [download] A publicly accessible web URL where the source files for this package may be downloaded. Required in order to support module installation.
|
||||
* @property {boolean} [protected=false] Whether this package uses the protected content access system.
|
||||
*/
|
||||
56
resources/app/common/packages/sub-types.mjs
Normal file
56
resources/app/common/packages/sub-types.mjs
Normal file
@@ -0,0 +1,56 @@
|
||||
import {getType, mergeObject} from "../utils/helpers.mjs";
|
||||
import {ObjectField} from "../data/fields.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {Record<string, Record<string, object>>} DocumentTypesConfiguration
|
||||
*/
|
||||
|
||||
/**
|
||||
* A special [ObjectField]{@link ObjectField} available to packages which configures any additional Document subtypes
|
||||
* provided by the package.
|
||||
*/
|
||||
export default class AdditionalTypesField extends ObjectField {
|
||||
|
||||
/** @inheritDoc */
|
||||
static get _defaults() {
|
||||
return mergeObject(super._defaults, {
|
||||
readonly: true,
|
||||
validationError: "is not a valid sub-types configuration"
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_validateType(value, options={}) {
|
||||
super._validateType(value, options);
|
||||
for ( const [documentName, subtypes] of Object.entries(value) ) {
|
||||
const cls = getDocumentClass(documentName);
|
||||
if ( !cls ) throw new Error(`${this.validationError}: '${documentName}' is not a valid Document type`);
|
||||
if ( !cls.hasTypeData ) {
|
||||
throw new Error(`${this.validationError}: ${documentName} Documents do not support sub-types`);
|
||||
}
|
||||
if ( getType(subtypes) !== "Object" ) throw new Error(`Malformed ${documentName} documentTypes declaration`);
|
||||
for ( const [type, config] of Object.entries(subtypes) ) this.#validateSubtype(cls, type, config);
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate a single defined document subtype.
|
||||
* @param {typeof Document} documentClass The document for which the subtype is being registered
|
||||
* @param {string} type The requested subtype name
|
||||
* @param {object} config The provided subtype configuration
|
||||
* @throws {Error} An error if the subtype is invalid or malformed
|
||||
*/
|
||||
#validateSubtype(documentClass, type, config) {
|
||||
const dn = documentClass.documentName;
|
||||
if ( documentClass.metadata.coreTypes.includes(type) ) {
|
||||
throw new Error(`"${type}" is a reserved core type for the ${dn} document`);
|
||||
}
|
||||
if ( getType(config) !== "Object" ) {
|
||||
throw new Error(`Malformed "${type}" subtype declared for ${dn} documentTypes`);
|
||||
}
|
||||
}
|
||||
}
|
||||
92
resources/app/common/primitives/array.mjs
Normal file
92
resources/app/common/primitives/array.mjs
Normal file
@@ -0,0 +1,92 @@
|
||||
import {getType, objectsEqual} from "../utils/helpers.mjs";
|
||||
|
||||
/**
|
||||
* Flatten nested arrays by concatenating their contents
|
||||
* @returns {any[]} An array containing the concatenated inner values
|
||||
*/
|
||||
export function deepFlatten() {
|
||||
return this.reduce((acc, val) => Array.isArray(val) ? acc.concat(val.deepFlatten()) : acc.concat(val), []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test element-wise equality of the values of this array against the values of another array
|
||||
* @param {any[]} other Some other array against which to test equality
|
||||
* @returns {boolean} Are the two arrays element-wise equal?
|
||||
*/
|
||||
export function equals(other) {
|
||||
if ( !(other instanceof Array) || (other.length !== this.length) ) return false;
|
||||
return this.every((v0, i) => {
|
||||
const v1 = other[i];
|
||||
const t0 = getType(v0);
|
||||
const t1 = getType(v1);
|
||||
if ( t0 !== t1 ) return false;
|
||||
if ( v0?.equals instanceof Function ) return v0.equals(v1);
|
||||
if ( t0 === "Object" ) return objectsEqual(v0, v1);
|
||||
return v0 === v1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Partition an original array into two children array based on a logical test
|
||||
* Elements which test as false go into the first result while elements testing as true appear in the second
|
||||
* @param rule {Function}
|
||||
* @returns {Array} An Array of length two whose elements are the partitioned pieces of the original
|
||||
*/
|
||||
export function partition(rule) {
|
||||
return this.reduce((acc, val) => {
|
||||
let test = rule(val);
|
||||
acc[Number(test)].push(val);
|
||||
return acc;
|
||||
}, [[], []]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Join an Array using a string separator, first filtering out any parts which return a false-y value
|
||||
* @param {string} sep The separator string
|
||||
* @returns {string} The joined string, filtered of any false values
|
||||
*/
|
||||
export function filterJoin(sep) {
|
||||
return this.filter(p => !!p).join(sep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an element within the Array and remove it from the array
|
||||
* @param {Function} find A function to use as input to findIndex
|
||||
* @param {*} [replace] A replacement for the spliced element
|
||||
* @returns {*|null} The replacement element, the removed element, or null if no element was found.
|
||||
*/
|
||||
export function findSplice(find, replace) {
|
||||
const idx = this.findIndex(find);
|
||||
if ( idx === -1 ) return null;
|
||||
if ( replace !== undefined ) {
|
||||
this.splice(idx, 1, replace);
|
||||
return replace;
|
||||
} else {
|
||||
const item = this[idx];
|
||||
this.splice(idx, 1);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and initialize an array of length n with integers from 0 to n-1
|
||||
* @memberof Array
|
||||
* @param {number} n The desired array length
|
||||
* @param {number} [min=0] A desired minimum number from which the created array starts
|
||||
* @returns {number[]} An array of integers from min to min+n
|
||||
*/
|
||||
export function fromRange(n, min=0) {
|
||||
return Array.from({length: n}, (v, i) => i + min);
|
||||
}
|
||||
|
||||
// Define primitives on the Array prototype
|
||||
Object.defineProperties(Array.prototype, {
|
||||
deepFlatten: {value: deepFlatten},
|
||||
equals: {value: equals},
|
||||
filterJoin: {value: filterJoin},
|
||||
findSplice: {value: findSplice},
|
||||
partition: {value: partition}
|
||||
});
|
||||
Object.defineProperties(Array,{
|
||||
fromRange: {value: fromRange}
|
||||
});
|
||||
35
resources/app/common/primitives/date.mjs
Normal file
35
resources/app/common/primitives/date.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Test whether a Date instance is valid.
|
||||
* A valid date returns a number for its timestamp, and NaN otherwise.
|
||||
* NaN is never equal to itself.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isValid() {
|
||||
return this.getTime() === this.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a standard YYYY-MM-DD string for the Date instance.
|
||||
* @returns {string} The date in YYYY-MM-DD format
|
||||
*/
|
||||
export function toDateInputString() {
|
||||
const yyyy = this.getFullYear();
|
||||
const mm = (this.getMonth() + 1).paddedString(2);
|
||||
const dd = this.getDate().paddedString(2);
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a standard H:M:S.Z string for the Date instance.
|
||||
* @returns {string} The time in H:M:S format
|
||||
*/
|
||||
export function toTimeInputString() {
|
||||
return this.toTimeString().split(" ")[0];
|
||||
}
|
||||
|
||||
// Define primitives on the Date prototype
|
||||
Object.defineProperties(Date.prototype, {
|
||||
isValid: {value: isValid},
|
||||
toDateInputString: {value: toDateInputString},
|
||||
toTimeInputString: {value: toTimeInputString}
|
||||
});
|
||||
165
resources/app/common/primitives/math.mjs
Normal file
165
resources/app/common/primitives/math.mjs
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* √3
|
||||
* @type {number}
|
||||
*/
|
||||
export const SQRT3 = 1.7320508075688772;
|
||||
|
||||
/**
|
||||
* √⅓
|
||||
* @type {number}
|
||||
*/
|
||||
export const SQRT1_3 = 0.5773502691896257;
|
||||
|
||||
/**
|
||||
* Bound a number between some minimum and maximum value, inclusively.
|
||||
* @param {number} num The current value
|
||||
* @param {number} min The minimum allowed value
|
||||
* @param {number} max The maximum allowed value
|
||||
* @return {number} The clamped number
|
||||
* @memberof Math
|
||||
*/
|
||||
export function clamp(num, min, max) {
|
||||
return Math.min(Math.max(num, min), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
export function clamped(num, min, max) {
|
||||
const msg = "Math.clamped is deprecated in favor of Math.clamp.";
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
return clamp(num, min, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear interpolation function
|
||||
* @param {number} a An initial value when weight is 0.
|
||||
* @param {number} b A terminal value when weight is 1.
|
||||
* @param {number} w A weight between 0 and 1.
|
||||
* @return {number} The interpolated value between a and b with weight w.
|
||||
*/
|
||||
export function mix(a, b, w) {
|
||||
return a * (1 - w) + b * w;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform an angle in degrees to be bounded within the domain [0, 360)
|
||||
* @param {number} degrees An angle in degrees
|
||||
* @returns {number} The same angle on the range [0, 360)
|
||||
*/
|
||||
export function normalizeDegrees(degrees, base) {
|
||||
const d = degrees % 360;
|
||||
if ( base !== undefined ) {
|
||||
const msg = "Math.normalizeDegrees(degrees, base) is deprecated.";
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
if ( base === 360 ) return d <= 0 ? d + 360 : d;
|
||||
}
|
||||
return d < 0 ? d + 360 : d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform an angle in radians to be bounded within the domain [-PI, PI]
|
||||
* @param {number} radians An angle in degrees
|
||||
* @return {number} The same angle on the range [-PI, PI]
|
||||
*/
|
||||
export function normalizeRadians(radians) {
|
||||
const pi = Math.PI;
|
||||
const pi2 = pi * 2;
|
||||
return radians - (pi2 * Math.floor((radians + pi) / pi2));
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since v12
|
||||
* @ignore
|
||||
*/
|
||||
export function roundDecimals(number, places) {
|
||||
const msg = "Math.roundDecimals is deprecated.";
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
|
||||
places = Math.max(Math.trunc(places), 0);
|
||||
let scl = Math.pow(10, places);
|
||||
return Math.round(number * scl) / scl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform an angle in radians to a number in degrees
|
||||
* @param {number} angle An angle in radians
|
||||
* @return {number} An angle in degrees
|
||||
*/
|
||||
export function toDegrees(angle) {
|
||||
return angle * (180 / Math.PI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform an angle in degrees to an angle in radians
|
||||
* @param {number} angle An angle in degrees
|
||||
* @return {number} An angle in radians
|
||||
*/
|
||||
export function toRadians(angle) {
|
||||
return angle * (Math.PI / 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of the oscillation between `a` and `b` at time `t`.
|
||||
* @param {number} a The minimium value of the oscillation
|
||||
* @param {number} b The maximum value of the oscillation
|
||||
* @param {number} t The time
|
||||
* @param {number} [p=1] The period (must be nonzero)
|
||||
* @param {(x: number) => number} [f=Math.cos] The periodic function (its period must be 2π)
|
||||
* @returns {number} `((b - a) * (f(2π * t / p) + 1) / 2) + a`
|
||||
*/
|
||||
export function oscillation(a, b, t, p=1, f=Math.cos) {
|
||||
return ((b - a) * (f((2 * Math.PI * t) / p) + 1) / 2) + a;
|
||||
}
|
||||
|
||||
// Define properties on the Math environment
|
||||
Object.defineProperties(Math, {
|
||||
SQRT3: {value: SQRT3},
|
||||
SQRT1_3: {value: SQRT1_3},
|
||||
clamp: {
|
||||
value: clamp,
|
||||
configurable: true,
|
||||
writable: true
|
||||
},
|
||||
clamped: {
|
||||
value: clamped,
|
||||
configurable: true,
|
||||
writable: true
|
||||
},
|
||||
mix: {
|
||||
value: mix,
|
||||
configurable: true,
|
||||
writable: true
|
||||
},
|
||||
normalizeDegrees: {
|
||||
value: normalizeDegrees,
|
||||
configurable: true,
|
||||
writable: true
|
||||
},
|
||||
normalizeRadians: {
|
||||
value: normalizeRadians,
|
||||
configurable: true,
|
||||
writable: true
|
||||
},
|
||||
roundDecimals: {
|
||||
value: roundDecimals,
|
||||
configurable: true,
|
||||
writable: true
|
||||
},
|
||||
toDegrees: {
|
||||
value: toDegrees,
|
||||
configurable: true,
|
||||
writable: true
|
||||
},
|
||||
toRadians: {
|
||||
value: toRadians,
|
||||
configurable: true,
|
||||
writable: true
|
||||
},
|
||||
oscillation: {
|
||||
value: oscillation,
|
||||
configurable: true,
|
||||
writable: true
|
||||
}
|
||||
});
|
||||
|
||||
10
resources/app/common/primitives/module.mjs
Normal file
10
resources/app/common/primitives/module.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
/** @module primitives */
|
||||
|
||||
export * as Array from "./array.mjs";
|
||||
export * as Date from "./date.mjs";
|
||||
export * as Math from "./math.mjs";
|
||||
export * as Number from "./number.mjs";
|
||||
export * as Set from "./set.mjs";
|
||||
export * as String from "./string.mjs";
|
||||
export * as RegExp from "./regexp.mjs";
|
||||
export * as URL from "./url.mjs";
|
||||
128
resources/app/common/primitives/number.mjs
Normal file
128
resources/app/common/primitives/number.mjs
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Test for near-equivalence of two numbers within some permitted epsilon
|
||||
* @param {number} n Some other number
|
||||
* @param {number} e Some permitted epsilon, by default 1e-8
|
||||
* @returns {boolean} Are the numbers almost equal?
|
||||
*/
|
||||
export function almostEqual(n, e=1e-8) {
|
||||
return Math.abs(this - n) < e;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a number to an ordinal string representation. i.e.
|
||||
* 1 => 1st
|
||||
* 2 => 2nd
|
||||
* 3 => 3rd
|
||||
* @returns {string}
|
||||
*/
|
||||
export function ordinalString() {
|
||||
const s = ["th","st","nd","rd"];
|
||||
const v = this % 100;
|
||||
return this + (s[(v-20)%10]||s[v]||s[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a string front-padded by zeroes to reach a certain number of numeral characters
|
||||
* @param {number} digits The number of characters desired
|
||||
* @returns {string} The zero-padded number
|
||||
*/
|
||||
export function paddedString(digits) {
|
||||
return this.toString().padStart(digits, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a string prefaced by the sign of the number (+) or (-)
|
||||
* @returns {string} The signed number as a string
|
||||
*/
|
||||
export function signedString() {
|
||||
return (( this < 0 ) ? "" : "+") + this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Round a number to the closest number which is a multiple of the provided interval.
|
||||
* This is a convenience function intended to humanize issues of floating point precision.
|
||||
* The interval is treated as a standard string representation to determine the amount of decimal truncation applied.
|
||||
* @param {number} interval The interval to round the number to the nearest multiple of
|
||||
* @param {string} [method=round] The rounding method in: round, ceil, floor
|
||||
* @returns {number} The rounded number
|
||||
*
|
||||
* @example Round a number to the nearest step interval
|
||||
* ```js
|
||||
* let n = 17.18;
|
||||
* n.toNearest(5); // 15
|
||||
* n.toNearest(10); // 20
|
||||
* n.toNearest(10, "floor"); // 10
|
||||
* n.toNearest(10, "ceil"); // 20
|
||||
* n.toNearest(0.25); // 17.25
|
||||
* ```
|
||||
*/
|
||||
export function toNearest(interval=1, method="round") {
|
||||
if ( interval < 0 ) throw new Error(`Number#toNearest interval must be positive`);
|
||||
const float = Math[method](this / interval) * interval;
|
||||
const trunc = Number.isInteger(interval) ? 0 : String(interval).length - 2;
|
||||
return Number(float.toFixed(trunc));
|
||||
}
|
||||
|
||||
/**
|
||||
* A faster numeric between check which avoids type coercion to the Number object.
|
||||
* Since this avoids coercion, if non-numbers are passed in unpredictable results will occur. Use with caution.
|
||||
* @param {number} a The lower-bound
|
||||
* @param {number} b The upper-bound
|
||||
* @param {boolean} inclusive Include the bounding values as a true result?
|
||||
* @return {boolean} Is the number between the two bounds?
|
||||
*/
|
||||
export function between(a, b, inclusive=true) {
|
||||
const min = Math.min(a, b);
|
||||
const max = Math.max(a, b);
|
||||
return inclusive ? (this >= min) && (this <= max) : (this > min) && (this < max);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see Number#between
|
||||
* @ignore
|
||||
*/
|
||||
Number.between = function(num, a, b, inclusive=true) {
|
||||
let min = Math.min(a, b);
|
||||
let max = Math.max(a, b);
|
||||
return inclusive ? (num >= min) && (num <= max) : (num > min) && (num < max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a value is numeric.
|
||||
* This is the highest performing algorithm currently available, per https://jsperf.com/isnan-vs-typeof/5
|
||||
* @memberof Number
|
||||
* @param {*} n A value to test
|
||||
* @return {boolean} Is it a number?
|
||||
*/
|
||||
export function isNumeric(n) {
|
||||
if ( n instanceof Array ) return false;
|
||||
else if ( [null, ""].includes(n) ) return false;
|
||||
return +n === +n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to create a number from a user-provided string.
|
||||
* @memberof Number
|
||||
* @param {string|number} n The value to convert; typically a string, but may already be a number.
|
||||
* @return {number} The number that the string represents, or NaN if no number could be determined.
|
||||
*/
|
||||
export function fromString(n) {
|
||||
if ( typeof n === "number" ) return n;
|
||||
if ( (typeof n !== "string") || !n.length ) return NaN;
|
||||
n = n.replace(/\s+/g, "");
|
||||
return Number(n);
|
||||
}
|
||||
|
||||
// Define properties on the Number environment
|
||||
Object.defineProperties(Number.prototype, {
|
||||
almostEqual: {value: almostEqual},
|
||||
between: {value: between},
|
||||
ordinalString: {value: ordinalString},
|
||||
paddedString: {value: paddedString},
|
||||
signedString: {value: signedString},
|
||||
toNearest: {value: toNearest}
|
||||
});
|
||||
Object.defineProperties(Number, {
|
||||
isNumeric: {value: isNumeric},
|
||||
fromString: {value: fromString}
|
||||
});
|
||||
13
resources/app/common/primitives/regexp.mjs
Normal file
13
resources/app/common/primitives/regexp.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Escape a given input string, prefacing special characters with backslashes for use in a regular expression
|
||||
* @param {string} string The un-escaped input string
|
||||
* @returns {string} The escaped string, suitable for use in regular expression
|
||||
*/
|
||||
export function escape(string) {
|
||||
return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
}
|
||||
|
||||
// Define properties on the RegExp environment
|
||||
Object.defineProperties(RegExp, {
|
||||
escape: {value: escape}
|
||||
});
|
||||
232
resources/app/common/primitives/set.mjs
Normal file
232
resources/app/common/primitives/set.mjs
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Return the difference of two sets.
|
||||
* @param {Set} other Some other set to compare against
|
||||
* @returns {Set} The difference defined as objects in this which are not present in other
|
||||
*/
|
||||
export function difference(other) {
|
||||
if ( !(other instanceof Set) ) throw new Error("Some other Set instance must be provided.");
|
||||
const difference = new Set();
|
||||
for ( const element of this ) {
|
||||
if ( !other.has(element) ) difference.add(element);
|
||||
}
|
||||
return difference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the symmetric difference of two sets.
|
||||
* @param {Set} other Another set.
|
||||
* @returns {Set} The set of elements that exist in this or other, but not both.
|
||||
*/
|
||||
export function symmetricDifference(other) {
|
||||
if ( !(other instanceof Set) ) throw new Error("Some other Set instance must be provided.");
|
||||
const difference = new Set(this);
|
||||
for ( const element of other ) {
|
||||
if ( difference.has(element) ) difference.delete(element);
|
||||
else difference.add(element);
|
||||
}
|
||||
return difference
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether this set is equal to some other set.
|
||||
* Sets are equal if they share the same members, independent of order
|
||||
* @param {Set} other Some other set to compare against
|
||||
* @returns {boolean} Are the sets equal?
|
||||
*/
|
||||
export function equals(other) {
|
||||
if ( !(other instanceof Set ) ) return false;
|
||||
if ( other.size !== this.size ) return false;
|
||||
for ( let element of this ) {
|
||||
if ( !other.has(element) ) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first value from the set.
|
||||
* @returns {*} The first element in the set, or undefined
|
||||
*/
|
||||
export function first() {
|
||||
return this.values().next().value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the intersection of two sets.
|
||||
* @param {Set} other Some other set to compare against
|
||||
* @returns {Set} The intersection of both sets
|
||||
*/
|
||||
export function intersection(other) {
|
||||
const n = new Set();
|
||||
for ( let element of this ) {
|
||||
if ( other.has(element) ) n.add(element);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether this set has an intersection with another set.
|
||||
* @param {Set} other Another set to compare against
|
||||
* @returns {boolean} Do the sets intersect?
|
||||
*/
|
||||
export function intersects(other) {
|
||||
for ( let element of this ) {
|
||||
if ( other.has(element) ) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the union of two sets.
|
||||
* @param {Set} other The other set.
|
||||
* @returns {Set}
|
||||
*/
|
||||
export function union(other) {
|
||||
if ( !(other instanceof Set) ) throw new Error("Some other Set instance must be provided.");
|
||||
const union = new Set(this);
|
||||
for ( const element of other ) union.add(element);
|
||||
return union;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether this set is a subset of some other set.
|
||||
* A set is a subset if all its members are also present in the other set.
|
||||
* @param {Set} other Some other set that may be a subset of this one
|
||||
* @returns {boolean} Is the other set a subset of this one?
|
||||
*/
|
||||
export function isSubset(other) {
|
||||
if ( !(other instanceof Set ) ) return false;
|
||||
if ( other.size < this.size ) return false;
|
||||
for ( let element of this ) {
|
||||
if ( !other.has(element) ) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a set to a JSON object by mapping its contents to an array
|
||||
* @returns {Array} The set elements as an array.
|
||||
*/
|
||||
export function toObject() {
|
||||
return Array.from(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether every element in this Set satisfies a certain test criterion.
|
||||
* @see Array#every
|
||||
* @param {function(*,number,Set): boolean} test The test criterion to apply. Positional arguments are the value,
|
||||
* the index of iteration, and the set being tested.
|
||||
* @returns {boolean} Does every element in the set satisfy the test criterion?
|
||||
*/
|
||||
export function every(test) {
|
||||
let i = 0;
|
||||
for ( const v of this ) {
|
||||
if ( !test(v, i, this) ) return false;
|
||||
i++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter this set to create a subset of elements which satisfy a certain test criterion.
|
||||
* @see Array#filter
|
||||
* @param {function(*,number,Set): boolean} test The test criterion to apply. Positional arguments are the value,
|
||||
* the index of iteration, and the set being filtered.
|
||||
* @returns {Set} A new Set containing only elements which satisfy the test criterion.
|
||||
*/
|
||||
export function filter(test) {
|
||||
const filtered = new Set();
|
||||
let i = 0;
|
||||
for ( const v of this ) {
|
||||
if ( test(v, i, this) ) filtered.add(v);
|
||||
i++;
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first element in this set which satisfies a certain test criterion.
|
||||
* @see Array#find
|
||||
* @param {function(*,number,Set): boolean} test The test criterion to apply. Positional arguments are the value,
|
||||
* the index of iteration, and the set being searched.
|
||||
* @returns {*|undefined} The first element in the set which satisfies the test criterion, or undefined.
|
||||
*/
|
||||
export function find(test) {
|
||||
let i = 0;
|
||||
for ( const v of this ) {
|
||||
if ( test(v, i, this) ) return v;
|
||||
i++;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Set where every element is modified by a provided transformation function.
|
||||
* @see Array#map
|
||||
* @param {function(*,number,Set): boolean} transform The transformation function to apply.Positional arguments are
|
||||
* the value, the index of iteration, and the set being transformed.
|
||||
* @returns {Set} A new Set of equal size containing transformed elements.
|
||||
*/
|
||||
export function map(transform) {
|
||||
const mapped = new Set();
|
||||
let i = 0;
|
||||
for ( const v of this ) {
|
||||
mapped.add(transform(v, i, this));
|
||||
i++;
|
||||
}
|
||||
if ( mapped.size !== this.size ) {
|
||||
throw new Error("The Set#map operation illegally modified the size of the set");
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Set with elements that are filtered and transformed by a provided reducer function.
|
||||
* @see Array#reduce
|
||||
* @param {function(*,*,number,Set): *} reducer A reducer function applied to each value. Positional
|
||||
* arguments are the accumulator, the value, the index of iteration, and the set being reduced.
|
||||
* @param {*} accumulator The initial value of the returned accumulator.
|
||||
* @returns {*} The final value of the accumulator.
|
||||
*/
|
||||
export function reduce(reducer, accumulator) {
|
||||
let i = 0;
|
||||
for ( const v of this ) {
|
||||
accumulator = reducer(accumulator, v, i, this);
|
||||
i++;
|
||||
}
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether any element in this Set satisfies a certain test criterion.
|
||||
* @see Array#some
|
||||
* @param {function(*,number,Set): boolean} test The test criterion to apply. Positional arguments are the value,
|
||||
* the index of iteration, and the set being tested.
|
||||
* @returns {boolean} Does any element in the set satisfy the test criterion?
|
||||
*/
|
||||
export function some(test) {
|
||||
let i = 0;
|
||||
for ( const v of this ) {
|
||||
if ( test(v, i, this) ) return true;
|
||||
i++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Assign primitives to Set prototype
|
||||
Object.defineProperties(Set.prototype, {
|
||||
difference: {value: difference},
|
||||
symmetricDifference: {value: symmetricDifference},
|
||||
equals: {value: equals},
|
||||
every: {value: every},
|
||||
filter: {value: filter},
|
||||
find: {value: find},
|
||||
first: {value: first},
|
||||
intersection: {value: intersection},
|
||||
intersects: {value: intersects},
|
||||
union: {value: union},
|
||||
isSubset: {value: isSubset},
|
||||
map: {value: map},
|
||||
reduce: {value: reduce},
|
||||
some: {value: some},
|
||||
toObject: {value: toObject}
|
||||
});
|
||||
82
resources/app/common/primitives/string.mjs
Normal file
82
resources/app/common/primitives/string.mjs
Normal file
File diff suppressed because one or more lines are too long
16
resources/app/common/primitives/url.mjs
Normal file
16
resources/app/common/primitives/url.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Attempt to parse a URL without throwing an error.
|
||||
* @param {string} url The string to parse.
|
||||
* @returns {URL|null} The parsed URL if successful, otherwise null.
|
||||
*/
|
||||
export function parseSafe(url) {
|
||||
try {
|
||||
return new URL(url);
|
||||
} catch (err) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Define properties on the URL environment
|
||||
Object.defineProperties(URL, {
|
||||
parseSafe: {value: parseSafe}
|
||||
});
|
||||
67
resources/app/common/prosemirror/_module.mjs
Normal file
67
resources/app/common/prosemirror/_module.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
/** @module prosemirror */
|
||||
|
||||
import {EditorState, AllSelection, TextSelection, Plugin, PluginKey} from "prosemirror-state";
|
||||
import {EditorView} from "prosemirror-view";
|
||||
import {Schema, DOMSerializer} from "prosemirror-model";
|
||||
import ProseMirrorInputRules from "./input-rules.mjs";
|
||||
import {keymap} from "prosemirror-keymap";
|
||||
import {baseKeymap} from "prosemirror-commands";
|
||||
import {dropCursor} from "prosemirror-dropcursor";
|
||||
import {gapCursor} from "prosemirror-gapcursor";
|
||||
import {history} from "prosemirror-history";
|
||||
import ProseMirrorKeyMaps from "./keymaps.mjs";
|
||||
import ProseMirrorMenu from "./menu.mjs";
|
||||
import "./extensions.mjs";
|
||||
import * as collab from "prosemirror-collab";
|
||||
import {Step} from "prosemirror-transform";
|
||||
import {parseHTMLString, serializeHTMLString} from "./util.mjs";
|
||||
import {schema as defaultSchema} from "./schema.mjs";
|
||||
import ProseMirrorPlugin from "./plugin.mjs";
|
||||
import ProseMirrorImagePlugin from "./image-plugin.mjs";
|
||||
import ProseMirrorDirtyPlugin from "./dirty-plugin.mjs";
|
||||
import ProseMirrorContentLinkPlugin from "./content-link-plugin.mjs";
|
||||
import ProseMirrorHighlightMatchesPlugin from "./highlight-matches-plugin.mjs";
|
||||
import ProseMirrorClickHandler from "./click-handler.mjs";
|
||||
import {columnResizing, tableEditing} from "prosemirror-tables";
|
||||
import DOMParser from "./dom-parser.mjs";
|
||||
import ProseMirrorPasteTransformer from "./paste-transformer.mjs";
|
||||
|
||||
const dom = {
|
||||
parser: DOMParser.fromSchema(defaultSchema),
|
||||
serializer: DOMSerializer.fromSchema(defaultSchema),
|
||||
parseString: parseHTMLString,
|
||||
serializeString: serializeHTMLString
|
||||
};
|
||||
|
||||
const defaultPlugins = {
|
||||
inputRules: ProseMirrorInputRules.build(defaultSchema),
|
||||
keyMaps: ProseMirrorKeyMaps.build(defaultSchema),
|
||||
menu: ProseMirrorMenu.build(defaultSchema),
|
||||
isDirty: ProseMirrorDirtyPlugin.build(defaultSchema),
|
||||
clickHandler: ProseMirrorClickHandler.build(defaultSchema),
|
||||
pasteTransformer: ProseMirrorPasteTransformer.build(defaultSchema),
|
||||
baseKeyMap: keymap(baseKeymap),
|
||||
dropCursor: dropCursor(),
|
||||
gapCursor: gapCursor(),
|
||||
history: history(),
|
||||
columnResizing: columnResizing(),
|
||||
tables: tableEditing()
|
||||
};
|
||||
|
||||
export * as commands from "prosemirror-commands";
|
||||
export * as transform from "prosemirror-transform";
|
||||
export * as list from "prosemirror-schema-list";
|
||||
export * as tables from "prosemirror-tables";
|
||||
export * as input from "prosemirror-inputrules";
|
||||
export * as state from "prosemirror-state";
|
||||
|
||||
export {
|
||||
AllSelection, TextSelection,
|
||||
DOMParser, DOMSerializer,
|
||||
EditorState, EditorView,
|
||||
Schema, Step,
|
||||
Plugin, PluginKey, ProseMirrorPlugin, ProseMirrorContentLinkPlugin, ProseMirrorHighlightMatchesPlugin,
|
||||
ProseMirrorDirtyPlugin, ProseMirrorImagePlugin, ProseMirrorClickHandler,
|
||||
ProseMirrorInputRules, ProseMirrorKeyMaps, ProseMirrorMenu,
|
||||
collab, defaultPlugins, defaultSchema, dom, keymap
|
||||
}
|
||||
45
resources/app/common/prosemirror/click-handler.mjs
Normal file
45
resources/app/common/prosemirror/click-handler.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
import ProseMirrorPlugin from "./plugin.mjs";
|
||||
import {Plugin} from "prosemirror-state";
|
||||
|
||||
/**
|
||||
* A class responsible for managing click events inside a ProseMirror editor.
|
||||
* @extends {ProseMirrorPlugin}
|
||||
*/
|
||||
export default class ProseMirrorClickHandler extends ProseMirrorPlugin {
|
||||
/** @override */
|
||||
static build(schema, options={}) {
|
||||
const plugin = new ProseMirrorClickHandler(schema);
|
||||
return new Plugin({
|
||||
props: {
|
||||
handleClickOn: plugin._onClick.bind(plugin)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a click on the editor.
|
||||
* @param {EditorView} view The ProseMirror editor view.
|
||||
* @param {number} pos The position in the ProseMirror document that the click occurred at.
|
||||
* @param {Node} node The current ProseMirror Node that the click has bubbled to.
|
||||
* @param {number} nodePos The position of the click within this Node.
|
||||
* @param {PointerEvent} event The click event.
|
||||
* @param {boolean} direct Whether this Node is the one that was directly clicked on.
|
||||
* @returns {boolean|void} A return value of true indicates the event has been handled, it will not propagate to
|
||||
* other plugins, and ProseMirror will call preventDefault on it.
|
||||
* @protected
|
||||
*/
|
||||
_onClick(view, pos, node, nodePos, event, direct) {
|
||||
// If this is the inner-most click bubble, check marks for onClick handlers.
|
||||
if ( direct ) {
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
for ( const mark of $pos.marks() ) {
|
||||
if ( mark.type.onClick?.(view, pos, event, mark) === true ) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check the current Node for onClick handlers.
|
||||
return node.type.onClick?.(view, pos, event, node);
|
||||
}
|
||||
}
|
||||
86
resources/app/common/prosemirror/content-link-plugin.mjs
Normal file
86
resources/app/common/prosemirror/content-link-plugin.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
import ProseMirrorPlugin from "./plugin.mjs";
|
||||
import {Plugin} from "prosemirror-state";
|
||||
|
||||
/**
|
||||
* A class responsible for handling the dropping of Documents onto the editor and creating content links for them.
|
||||
* @extends {ProseMirrorPlugin}
|
||||
*/
|
||||
export default class ProseMirrorContentLinkPlugin extends ProseMirrorPlugin {
|
||||
/**
|
||||
* @typedef {object} ProseMirrorContentLinkOptions
|
||||
* @property {ClientDocument} [document] The parent document housing this editor.
|
||||
* @property {boolean} [relativeLinks=false] Whether to generate links relative to the parent document.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Schema} schema The ProseMirror schema.
|
||||
* @param {ProseMirrorContentLinkOptions} options Additional options to configure the plugin's behaviour.
|
||||
*/
|
||||
constructor(schema, {document, relativeLinks=false}={}) {
|
||||
super(schema);
|
||||
|
||||
if ( relativeLinks && !document ) {
|
||||
throw new Error("A document must be provided in order to generate relative links.");
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent document housing this editor.
|
||||
* @type {ClientDocument}
|
||||
*/
|
||||
Object.defineProperty(this, "document", {value: document, writable: false});
|
||||
|
||||
/**
|
||||
* Whether to generate links relative to the parent document.
|
||||
* @type {boolean}
|
||||
*/
|
||||
Object.defineProperty(this, "relativeLinks", {value: relativeLinks, writable: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static build(schema, options={}) {
|
||||
const plugin = new ProseMirrorContentLinkPlugin(schema, options);
|
||||
return new Plugin({
|
||||
props: {
|
||||
handleDrop: plugin._onDrop.bind(plugin)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a drop onto the editor.
|
||||
* @param {EditorView} view The ProseMirror editor view.
|
||||
* @param {DragEvent} event The drop event.
|
||||
* @param {Slice} slice A slice of editor content.
|
||||
* @param {boolean} moved Whether the slice has been moved from a different part of the editor.
|
||||
* @protected
|
||||
*/
|
||||
_onDrop(view, event, slice, moved) {
|
||||
if ( moved ) return;
|
||||
const pos = view.posAtCoords({left: event.clientX, top: event.clientY});
|
||||
const data = TextEditor.getDragEventData(event);
|
||||
if ( !data.type ) return;
|
||||
const options = {};
|
||||
if ( this.relativeLinks ) options.relativeTo = this.document;
|
||||
const selection = view.state.selection;
|
||||
if ( !selection.empty ) {
|
||||
const content = selection.content().content;
|
||||
options.label = content.textBetween(0, content.size);
|
||||
}
|
||||
TextEditor.getContentLink(data, options).then(link => {
|
||||
if ( !link ) return;
|
||||
const tr = view.state.tr;
|
||||
if ( selection.empty ) tr.insertText(link, pos.pos);
|
||||
else tr.replaceSelectionWith(this.schema.text(link));
|
||||
view.dispatch(tr);
|
||||
// Focusing immediately only seems to work in Chrome. In Firefox we must yield execution before attempting to
|
||||
// focus, otherwise the cursor becomes invisible until the user manually unfocuses and refocuses.
|
||||
setTimeout(view.focus.bind(view), 0);
|
||||
});
|
||||
event.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
22
resources/app/common/prosemirror/dirty-plugin.mjs
Normal file
22
resources/app/common/prosemirror/dirty-plugin.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import ProseMirrorPlugin from "./plugin.mjs";
|
||||
import {Plugin} from "prosemirror-state";
|
||||
|
||||
/**
|
||||
* A simple plugin that records the dirty state of the editor.
|
||||
* @extends {ProseMirrorPlugin}
|
||||
*/
|
||||
export default class ProseMirrorDirtyPlugin extends ProseMirrorPlugin {
|
||||
/** @inheritdoc */
|
||||
static build(schema, options={}) {
|
||||
return new Plugin({
|
||||
state: {
|
||||
init() {
|
||||
return false;
|
||||
},
|
||||
apply() {
|
||||
return true; // If any transaction is applied to the state, we mark the editor as dirty.
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
34
resources/app/common/prosemirror/dom-parser.mjs
Normal file
34
resources/app/common/prosemirror/dom-parser.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import {DOMParser as BaseDOMParser} from "prosemirror-model";
|
||||
|
||||
export default class DOMParser extends BaseDOMParser {
|
||||
/** @inheritdoc */
|
||||
parse(dom, options) {
|
||||
this.#unwrapImages(dom);
|
||||
return super.parse(dom, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Unwrap any image tags that may have been wrapped in <p></p> tags in earlier iterations of the schema.
|
||||
* @param {HTMLElement} dom The root HTML element to parse.
|
||||
*/
|
||||
#unwrapImages(dom) {
|
||||
dom.querySelectorAll("img").forEach(img => {
|
||||
const paragraph = img.parentElement;
|
||||
if ( paragraph?.tagName !== "P" ) return;
|
||||
const parent = paragraph.parentElement || dom;
|
||||
parent.insertBefore(img, paragraph);
|
||||
// If the paragraph element was purely holding the image element and is now empty, we can remove it.
|
||||
if ( !paragraph.childNodes.length ) paragraph.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static fromSchema(schema) {
|
||||
if ( schema.cached.domParser ) return schema.cached.domParser;
|
||||
return schema.cached.domParser = new this(schema, this.schemaRules(schema));
|
||||
}
|
||||
}
|
||||
199
resources/app/common/prosemirror/dropdown.mjs
Normal file
199
resources/app/common/prosemirror/dropdown.mjs
Normal file
@@ -0,0 +1,199 @@
|
||||
export default class ProseMirrorDropDown {
|
||||
/**
|
||||
* A class responsible for rendering a menu drop-down.
|
||||
* @param {string} title The default title.
|
||||
* @param {ProseMirrorDropDownEntry[]} items The configured menu items.
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.cssClass] The menu CSS class name. Required if providing an action.
|
||||
* @param {string} [options.icon] Use an icon for the dropdown rather than a text label.
|
||||
* @param {function(MouseEvent)} [options.onAction] A callback to fire when a menu item is clicked.
|
||||
*/
|
||||
constructor(title, items, {cssClass, icon, onAction}={}) {
|
||||
/**
|
||||
* The default title for this drop-down.
|
||||
* @type {string}
|
||||
*/
|
||||
Object.defineProperty(this, "title", {value: title, writable: false});
|
||||
|
||||
/**
|
||||
* The items configured for this drop-down.
|
||||
* @type {ProseMirrorDropDownEntry[]}
|
||||
*/
|
||||
Object.defineProperty(this, "items", {value: items, writable: false});
|
||||
this.#icon = icon;
|
||||
this.#cssClass = cssClass;
|
||||
this.#onAction = onAction;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The menu CSS class name.
|
||||
* @type {string}
|
||||
*/
|
||||
#cssClass;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The icon to use instead of a text label, if any.
|
||||
* @type {string}
|
||||
*/
|
||||
#icon;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The callback to fire when a menu item is clicked.
|
||||
* @type {function(MouseEvent)}
|
||||
*/
|
||||
#onAction;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Attach event listeners.
|
||||
* @param {HTMLMenuElement} html The root menu element.
|
||||
*/
|
||||
activateListeners(html) {
|
||||
if ( !this.#onAction ) return;
|
||||
html.querySelector(`.pm-dropdown.${this.#cssClass}`).onclick = event => this.#onActivate(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Construct the drop-down menu's HTML.
|
||||
* @returns {string} HTML contents as a string.
|
||||
*/
|
||||
render() {
|
||||
|
||||
// Record which dropdown options are currently active
|
||||
const activeItems = [];
|
||||
this.forEachItem(item => {
|
||||
if ( !item.active ) return;
|
||||
activeItems.push(item);
|
||||
});
|
||||
activeItems.sort((a, b) => a.priority - b.priority);
|
||||
const activeItem = activeItems.shift();
|
||||
|
||||
// Render the dropdown
|
||||
const active = game.i18n.localize(activeItem ? activeItem.title : this.title);
|
||||
const items = this.constructor._renderMenu(this.items);
|
||||
return `
|
||||
<button type="button" class="pm-dropdown ${this.#icon ? "icon" : ""} ${this.#cssClass}">
|
||||
${this.#icon ? this.#icon : `<span>${active}</span>`}
|
||||
<i class="fa-solid fa-chevron-down"></i>
|
||||
${items}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Recurse through the menu structure and apply a function to each item in it.
|
||||
* @param {function(ProseMirrorDropDownEntry):boolean} fn The function to call on each item. Return false to prevent
|
||||
* iterating over any further items.
|
||||
*/
|
||||
forEachItem(fn) {
|
||||
const forEach = items => {
|
||||
for ( const item of items ) {
|
||||
const result = fn(item);
|
||||
if ( result === false ) break;
|
||||
if ( item.children?.length ) forEach(item.children);
|
||||
}
|
||||
};
|
||||
forEach(this.items);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle spawning a drop-down menu.
|
||||
* @param {PointerEvent} event The triggering event.
|
||||
* @protected
|
||||
*/
|
||||
#onActivate(event) {
|
||||
document.getElementById("prosemirror-dropdown")?.remove();
|
||||
const menu = event.currentTarget.querySelector(":scope > ul");
|
||||
if ( !menu ) return;
|
||||
const { top, left, bottom } = event.currentTarget.getBoundingClientRect();
|
||||
const dropdown = document.createElement("div");
|
||||
dropdown.id = "prosemirror-dropdown";
|
||||
// Apply theme if App V2.
|
||||
if ( menu.closest(".application") ) {
|
||||
dropdown.classList.add(document.body.classList.contains("theme-dark") ? "theme-dark" : "theme-light");
|
||||
}
|
||||
dropdown.append(menu.cloneNode(true));
|
||||
Object.assign(dropdown.style, { left: `${left}px`, top: `${bottom}px` });
|
||||
document.body.append(dropdown);
|
||||
dropdown.querySelectorAll(`li`).forEach(item => {
|
||||
item.onclick = event => this.#onAction(event);
|
||||
item.onpointerover = event => this.#onHoverItem(event);
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
const { width, height } = dropdown.querySelector(":scope > ul").getBoundingClientRect();
|
||||
const { clientWidth, clientHeight } = document.documentElement;
|
||||
if ( left + width > clientWidth ) dropdown.style.left = `${left - width}px`;
|
||||
if ( bottom + height > clientHeight ) dropdown.style.top = `${top - height}px`;
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Adjust menu position when hovering over items.
|
||||
* @param {PointerEvent} event The triggering event.
|
||||
*/
|
||||
#onHoverItem(event) {
|
||||
const menu = event.currentTarget.querySelector(":scope > ul");
|
||||
if ( !menu ) return;
|
||||
const { clientWidth, clientHeight } = document.documentElement;
|
||||
const { top } = event.currentTarget.getBoundingClientRect();
|
||||
const { x, width, height } = menu.getBoundingClientRect();
|
||||
if ( top + height > clientHeight ) menu.style.top = `-${top + height - clientHeight}px`;
|
||||
if ( x + width > clientWidth ) menu.style.left = `-${width}px`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render a list of drop-down menu items.
|
||||
* @param {ProseMirrorDropDownEntry[]} entries The menu items.
|
||||
* @returns {string} HTML contents as a string.
|
||||
* @protected
|
||||
*/
|
||||
static _renderMenu(entries) {
|
||||
const groups = entries.reduce((arr, item) => {
|
||||
const group = item.group ?? 0;
|
||||
arr[group] ??= [];
|
||||
arr[group].push(this._renderMenuItem(item));
|
||||
return arr;
|
||||
}, []);
|
||||
const items = groups.reduce((arr, group) => {
|
||||
if ( group?.length ) arr.push(group.join(""));
|
||||
return arr;
|
||||
}, []);
|
||||
return `<ul>${items.join('<li class="divider"></li>')}</ul>`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render an individual drop-down menu item.
|
||||
* @param {ProseMirrorDropDownEntry} item The menu item.
|
||||
* @returns {string} HTML contents as a string.
|
||||
* @protected
|
||||
*/
|
||||
static _renderMenuItem(item) {
|
||||
const parts = [`<li data-action="${item.action}" class="${item.class ?? ""}">`];
|
||||
parts.push(`<span style="${item.style ?? ""}">${game.i18n.localize(item.title)}</span>`);
|
||||
if ( item.active && !item.children?.length ) parts.push('<i class="fa-solid fa-check"></i>');
|
||||
if ( item.children?.length ) {
|
||||
parts.push('<i class="fa-solid fa-chevron-right"></i>', this._renderMenu(item.children));
|
||||
}
|
||||
parts.push("</li>");
|
||||
return parts.join("");
|
||||
}
|
||||
}
|
||||
21
resources/app/common/prosemirror/extensions.mjs
Normal file
21
resources/app/common/prosemirror/extensions.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import {ResolvedPos} from "prosemirror-model";
|
||||
|
||||
/**
|
||||
* Determine whether a given position has an ancestor node of the given type.
|
||||
* @param {NodeType} other The other node type.
|
||||
* @param {object} [attrs] An object of attributes that must also match, if provided.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
ResolvedPos.prototype.hasAncestor = function(other, attrs) {
|
||||
if ( !this.depth ) return false;
|
||||
for ( let i = this.depth; i > 0; i-- ) { // Depth 0 is the root document, so we don't need to test that.
|
||||
const node = this.node(i);
|
||||
if ( node.type === other ) {
|
||||
const nodeAttrs = foundry.utils.deepClone(node.attrs);
|
||||
delete nodeAttrs._preserve; // Do not include our internal attributes in the comparison.
|
||||
if ( attrs ) return foundry.utils.objectsEqual(nodeAttrs, attrs);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
159
resources/app/common/prosemirror/highlight-matches-plugin.mjs
Normal file
159
resources/app/common/prosemirror/highlight-matches-plugin.mjs
Normal file
@@ -0,0 +1,159 @@
|
||||
import ProseMirrorPlugin from "./plugin.mjs";
|
||||
import {Plugin} from "prosemirror-state";
|
||||
|
||||
/**
|
||||
* A class responsible for handling the display of automated link recommendations when a user highlights text in a
|
||||
* ProseMirror editor.
|
||||
* @param {EditorView} view The editor view.
|
||||
*/
|
||||
class PossibleMatchesTooltip {
|
||||
|
||||
/**
|
||||
* @param {EditorView} view The editor view.
|
||||
*/
|
||||
constructor(view) {
|
||||
this.update(view, null);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A reference to any existing tooltip that has been generated as part of a highlight match.
|
||||
* @type {HTMLElement}
|
||||
*/
|
||||
tooltip;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the tooltip based on changes to the selected text.
|
||||
* @param {EditorView} view The editor view.
|
||||
* @param {State} lastState The previous state of the document.
|
||||
*/
|
||||
async update(view, lastState) {
|
||||
if ( !game.settings.get("core", "pmHighlightDocumentMatches") ) return;
|
||||
const state = view.state;
|
||||
|
||||
// Deactivate tooltip if the document/selection didn't change or is empty
|
||||
const stateUnchanged = lastState && (lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection));
|
||||
if ( stateUnchanged || state.selection.empty ) return this._deactivateTooltip();
|
||||
|
||||
const selection = state.selection.content().content;
|
||||
const highlighted = selection.textBetween(0, selection.size);
|
||||
|
||||
// If the user selected fewer than a certain amount of characters appropriate for the language, we bail out.
|
||||
if ( highlighted.length < CONFIG.i18n.searchMinimumCharacterLength ) return this._deactivateTooltip();
|
||||
|
||||
// Look for any matches based on the contents of the selection
|
||||
let html = this._findMatches(highlighted);
|
||||
|
||||
// If html is an empty string bail out and deactivate tooltip
|
||||
if ( !html ) return this._deactivateTooltip();
|
||||
|
||||
// Enrich the matches HTML to get proper content links
|
||||
html = await TextEditor.enrichHTML(html);
|
||||
html = html.replace(/data-tooltip="[^"]+"/g, "");
|
||||
const {from, to} = state.selection;
|
||||
|
||||
// In-screen coordinates
|
||||
const start = view.coordsAtPos(from);
|
||||
const end = view.coordsAtPos(to);
|
||||
|
||||
// Position the tooltip. This needs to be very close to the user's cursor, otherwise the locked tooltip will be
|
||||
// immediately dismissed for being too far from the tooltip.
|
||||
// TODO: We use the selection endpoints here which works fine for single-line selections, but not multi-line.
|
||||
const left = (start.left + 3) + "px";
|
||||
const bottom = window.innerHeight - start.bottom + 25 + "px";
|
||||
const position = {bottom, left};
|
||||
|
||||
if ( this.tooltip ) this._updateTooltip(html);
|
||||
else this._createTooltip(position, html, {cssClass: "link-matches"});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a locked tooltip at the given position.
|
||||
* @param {object} position A position object with coordinates for where the tooltip should be placed
|
||||
* @param {string} position.top Explicit top position for the tooltip
|
||||
* @param {string} position.right Explicit right position for the tooltip
|
||||
* @param {string} position.bottom Explicit bottom position for the tooltip
|
||||
* @param {string} position.left Explicit left position for the tooltip
|
||||
* @param {string} text Explicit tooltip text or HTML to display.
|
||||
* @param {object} [options={}] Additional options which can override tooltip behavior.
|
||||
* @param {array} [options.cssClass] An optional, space-separated list of CSS classes to apply to the activated
|
||||
* tooltip.
|
||||
*/
|
||||
_createTooltip(position, text, options) {
|
||||
this.tooltip = game.tooltip.createLockedTooltip(position, text, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update the tooltip with new HTML
|
||||
* @param {string} html The HTML to be included in the tooltip
|
||||
*/
|
||||
_updateTooltip(html) {
|
||||
this.tooltip.innerHTML = html;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Dismiss all locked tooltips and set this tooltip to undefined.
|
||||
*/
|
||||
_deactivateTooltip() {
|
||||
if ( !this.tooltip ) return;
|
||||
game.tooltip.dismissLockedTooltip(this.tooltip);
|
||||
this.tooltip = undefined;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Find all Documents in the world/compendia with names that match the selection insensitive to case.
|
||||
* @param {string} text A string which will be matched against document names
|
||||
* @returns {string}
|
||||
*/
|
||||
_findMatches(text) {
|
||||
let html = "";
|
||||
const matches = game.documentIndex.lookup(text, { ownership: "OBSERVER" });
|
||||
for ( const [type, collection] of Object.entries(matches) ) {
|
||||
if ( collection.length === 0 ) continue;
|
||||
html += `<section><h4>${type}</h4><p>`;
|
||||
for ( const document of collection ) {
|
||||
html += document.entry?.link ? document.entry.link : `@UUID[${document.uuid}]{${document.entry.name}}`;
|
||||
}
|
||||
html += "</p></section>";
|
||||
}
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A ProseMirrorPlugin wrapper around the {@link PossibleMatchesTooltip} class.
|
||||
* @extends {ProseMirrorPlugin}
|
||||
*/
|
||||
export default class ProseMirrorHighlightMatchesPlugin extends ProseMirrorPlugin {
|
||||
/**
|
||||
* @param {Schema} schema The ProseMirror schema.
|
||||
* @param {ProseMirrorMenuOptions} [options] Additional options to configure the plugin's behaviour.
|
||||
*/
|
||||
constructor(schema, options={}) {
|
||||
super(schema);
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static build(schema, options={}) {
|
||||
return new Plugin({
|
||||
view(editorView) {
|
||||
return new PossibleMatchesTooltip(editorView);
|
||||
},
|
||||
isHighlightMatchesPlugin: true
|
||||
});
|
||||
}
|
||||
}
|
||||
171
resources/app/common/prosemirror/image-plugin.mjs
Normal file
171
resources/app/common/prosemirror/image-plugin.mjs
Normal file
@@ -0,0 +1,171 @@
|
||||
import {Plugin} from "prosemirror-state";
|
||||
import ProseMirrorPlugin from "./plugin.mjs";
|
||||
import {hasFileExtension, isBase64Data} from "../data/validators.mjs";
|
||||
import {dom} from "./_module.mjs";
|
||||
|
||||
/**
|
||||
* A class responsible for handle drag-and-drop and pasting of image content. Ensuring no base64 data is injected
|
||||
* directly into the journal content and it is instead uploaded to the user's data directory.
|
||||
* @extends {ProseMirrorPlugin}
|
||||
*/
|
||||
export default class ProseMirrorImagePlugin extends ProseMirrorPlugin {
|
||||
/**
|
||||
* @param {Schema} schema The ProseMirror schema.
|
||||
* @param {object} options Additional options to configure the plugin's behaviour.
|
||||
* @param {ClientDocument} options.document A related Document to store extract base64 images for.
|
||||
*/
|
||||
constructor(schema, {document}={}) {
|
||||
super(schema);
|
||||
|
||||
if ( !document ) {
|
||||
throw new Error("The image drop and pasting plugin requires a reference to a related Document to function.");
|
||||
}
|
||||
|
||||
/**
|
||||
* The related Document to store extracted base64 images for.
|
||||
* @type {ClientDocument}
|
||||
*/
|
||||
Object.defineProperty(this, "document", {value: document, writable: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static build(schema, options={}) {
|
||||
const plugin = new ProseMirrorImagePlugin(schema, options);
|
||||
return new Plugin({
|
||||
props: {
|
||||
handleDrop: plugin._onDrop.bind(plugin),
|
||||
handlePaste: plugin._onPaste.bind(plugin)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a drop onto the editor.
|
||||
* @param {EditorView} view The ProseMirror editor view.
|
||||
* @param {DragEvent} event The drop event.
|
||||
* @param {Slice} slice A slice of editor content.
|
||||
* @param {boolean} moved Whether the slice has been moved from a different part of the editor.
|
||||
* @protected
|
||||
*/
|
||||
_onDrop(view, event, slice, moved) {
|
||||
// This is a drag-drop of internal editor content which we do not need to handle specially.
|
||||
if ( moved ) return;
|
||||
const pos = view.posAtCoords({left: event.clientX, top: event.clientY});
|
||||
if ( !pos ) return; // This was somehow dropped outside the editor content.
|
||||
|
||||
if ( event.dataTransfer.types.some(t => t === "text/uri-list") ) {
|
||||
const uri = event.dataTransfer.getData("text/uri-list");
|
||||
if ( !isBase64Data(uri) ) return; // This is a direct URL hotlink which we can just embed without issue.
|
||||
}
|
||||
|
||||
// Handle image drops.
|
||||
if ( event.dataTransfer.files.length ) {
|
||||
this._uploadImages(view, event.dataTransfer.files, pos.pos);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a paste into the editor.
|
||||
* @param {EditorView} view The ProseMirror editor view.
|
||||
* @param {ClipboardEvent} event The paste event.
|
||||
* @protected
|
||||
*/
|
||||
_onPaste(view, event) {
|
||||
if ( event.clipboardData.files.length ) {
|
||||
this._uploadImages(view, event.clipboardData.files);
|
||||
return true;
|
||||
}
|
||||
const html = event.clipboardData.getData("text/html");
|
||||
if ( !html ) return; // We only care about handling rich content.
|
||||
const images = this._extractBase64Images(html);
|
||||
if ( !images.length ) return; // If there were no base64 images, defer to the default paste handler.
|
||||
this._replaceBase64Images(view, html, images);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Upload any image files encountered in the drop.
|
||||
* @param {EditorView} view The ProseMirror editor view.
|
||||
* @param {FileList} files The files to upload.
|
||||
* @param {number} [pos] The position in the document to insert at. If not provided, the current selection will be
|
||||
* replaced instead.
|
||||
* @protected
|
||||
*/
|
||||
async _uploadImages(view, files, pos) {
|
||||
const image = this.schema.nodes.image;
|
||||
const imageExtensions = Object.keys(CONST.IMAGE_FILE_EXTENSIONS);
|
||||
for ( const file of files ) {
|
||||
if ( !hasFileExtension(file.name, imageExtensions) ) continue;
|
||||
const src = await TextEditor._uploadImage(this.document.uuid, file);
|
||||
if ( !src ) continue;
|
||||
const node = image.create({src});
|
||||
if ( pos === undefined ) {
|
||||
pos = view.state.selection.from;
|
||||
view.dispatch(view.state.tr.replaceSelectionWith(node));
|
||||
} else view.dispatch(view.state.tr.insert(pos, node));
|
||||
pos += 2; // Advance the position past the just-inserted image so the next image is inserted below it.
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Capture any base64-encoded images embedded in the rich text paste and upload them.
|
||||
* @param {EditorView} view The ProseMirror editor view.
|
||||
* @param {string} html The HTML data as a string.
|
||||
* @param {[full: string, mime: string, data: string][]} images An array of extracted base64 image data.
|
||||
* @protected
|
||||
*/
|
||||
async _replaceBase64Images(view, html, images) {
|
||||
const byMimetype = Object.fromEntries(Object.entries(CONST.IMAGE_FILE_EXTENSIONS).map(([k, v]) => [v, k]));
|
||||
let cleaned = html;
|
||||
for ( const [full, mime, data] of images ) {
|
||||
const file = this.constructor.base64ToFile(data, `pasted-image.${byMimetype[mime]}`, mime);
|
||||
const path = await TextEditor._uploadImage(this.document.uuid, file) ?? "";
|
||||
cleaned = cleaned.replace(full, path);
|
||||
}
|
||||
const doc = dom.parseString(cleaned);
|
||||
view.dispatch(view.state.tr.replaceSelectionWith(doc));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Detect base64 image data embedded in an HTML string and extract it.
|
||||
* @param {string} html The HTML data as a string.
|
||||
* @returns {[full: string, mime: string, data: string][]}
|
||||
* @protected
|
||||
*/
|
||||
_extractBase64Images(html) {
|
||||
const images = Object.values(CONST.IMAGE_FILE_EXTENSIONS);
|
||||
const rgx = new RegExp(`data:(${images.join("|")});base64,([^"']+)`, "g");
|
||||
return [...html.matchAll(rgx)];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert a base64 string into a File object.
|
||||
* @param {string} data Base64 encoded data.
|
||||
* @param {string} filename The filename.
|
||||
* @param {string} mimetype The file's mimetype.
|
||||
* @returns {File}
|
||||
*/
|
||||
static base64ToFile(data, filename, mimetype) {
|
||||
const bin = atob(data);
|
||||
let n = bin.length;
|
||||
const buf = new ArrayBuffer(n);
|
||||
const bytes = new Uint8Array(buf);
|
||||
while ( n-- ) bytes[n] = bin.charCodeAt(n);
|
||||
return new File([bytes], filename, {type: mimetype});
|
||||
}
|
||||
}
|
||||
133
resources/app/common/prosemirror/input-rules.mjs
Normal file
133
resources/app/common/prosemirror/input-rules.mjs
Normal file
@@ -0,0 +1,133 @@
|
||||
import {ellipsis, InputRule, inputRules, textblockTypeInputRule, wrappingInputRule} from "prosemirror-inputrules";
|
||||
import ProseMirrorPlugin from "./plugin.mjs";
|
||||
|
||||
/**
|
||||
* A class responsible for building the input rules for the ProseMirror editor.
|
||||
* @extends {ProseMirrorPlugin}
|
||||
*/
|
||||
export default class ProseMirrorInputRules extends ProseMirrorPlugin {
|
||||
/**
|
||||
* Build the plugin.
|
||||
* @param {Schema} schema The ProseMirror schema to build the plugin against.
|
||||
* @param {object} [options] Additional options to pass to the plugin.
|
||||
* @param {number} [options.minHeadingLevel=0] The minimum heading level to start from when generating heading input
|
||||
* rules. The resulting heading level for a heading rule is equal to the
|
||||
* number of leading hashes minus this number.
|
||||
* */
|
||||
static build(schema, {minHeadingLevel=0}={}) {
|
||||
const rules = new this(schema, {minHeadingLevel});
|
||||
return inputRules({rules: rules.buildRules()});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Build input rules for node types present in the schema.
|
||||
* @returns {InputRule[]}
|
||||
*/
|
||||
buildRules() {
|
||||
const rules = [ellipsis, ProseMirrorInputRules.#emDashRule()];
|
||||
if ( "blockquote" in this.schema.nodes ) rules.push(this.#blockQuoteRule());
|
||||
if ( "ordered_list" in this.schema.nodes ) rules.push(this.#orderedListRule());
|
||||
if ( "bullet_list" in this.schema.nodes ) rules.push(this.#bulletListRule());
|
||||
if ( "code_block" in this.schema.nodes ) rules.push(this.#codeBlockRule());
|
||||
if ( "heading" in this.schema.nodes ) rules.push(this.#headingRule(1, 6));
|
||||
if ( "horizontal_rule" in this.schema.nodes ) rules.push(this.#hrRule());
|
||||
return rules;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Turn a ">" at the start of a textblock into a blockquote.
|
||||
* @returns {InputRule}
|
||||
* @private
|
||||
*/
|
||||
#blockQuoteRule() {
|
||||
return wrappingInputRule(/^\s*>\s$/, this.schema.nodes.blockquote);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Turn a number followed by a dot at the start of a textblock into an ordered list.
|
||||
* @returns {InputRule}
|
||||
* @private
|
||||
*/
|
||||
#orderedListRule() {
|
||||
return wrappingInputRule(
|
||||
/^(\d+)\.\s$/, this.schema.nodes.ordered_list,
|
||||
match => ({order: Number(match[1])}),
|
||||
(match, node) => (node.childCount + node.attrs.order) === Number(match[1])
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Turn a -, +, or * at the start of a textblock into a bulleted list.
|
||||
* @returns {InputRule}
|
||||
* @private
|
||||
*/
|
||||
#bulletListRule() {
|
||||
return wrappingInputRule(/^\s*[-+*]\s$/, this.schema.nodes.bullet_list);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Turn three backticks at the start of a textblock into a code block.
|
||||
* @returns {InputRule}
|
||||
* @private
|
||||
*/
|
||||
#codeBlockRule() {
|
||||
return textblockTypeInputRule(/^```$/, this.schema.nodes.code_block);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Turns a double dash anywhere into an em-dash. Does not match at the start of the line to avoid conflict with the
|
||||
* HR rule.
|
||||
* @returns {InputRule}
|
||||
* @private
|
||||
*/
|
||||
static #emDashRule() {
|
||||
return new InputRule(/[^-]+(--)/, "—");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Turns a number of # characters followed by a space at the start of a textblock into a heading up to a maximum
|
||||
* level.
|
||||
* @param {number} minLevel The minimum heading level to start generating input rules for.
|
||||
* @param {number} maxLevel The maximum number of heading levels.
|
||||
* @returns {InputRule}
|
||||
* @private
|
||||
*/
|
||||
#headingRule(minLevel, maxLevel) {
|
||||
const range = maxLevel - minLevel + 1;
|
||||
return textblockTypeInputRule(
|
||||
new RegExp(`^(#{1,${range}})\\s$`), this.schema.nodes.heading,
|
||||
match => {
|
||||
const level = match[1].length;
|
||||
return {level: level + minLevel - 1};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Turns three hyphens at the start of a line into a horizontal rule.
|
||||
* @returns {InputRule}
|
||||
* @private
|
||||
*/
|
||||
#hrRule() {
|
||||
const hr = this.schema.nodes.horizontal_rule;
|
||||
return new InputRule(/^---$/, (state, match, start, end) => {
|
||||
return state.tr.replaceRangeWith(start, end, hr.create()).scrollIntoView();
|
||||
});
|
||||
}
|
||||
}
|
||||
207
resources/app/common/prosemirror/keymaps.mjs
Normal file
207
resources/app/common/prosemirror/keymaps.mjs
Normal file
@@ -0,0 +1,207 @@
|
||||
import {keymap} from "prosemirror-keymap";
|
||||
import {redo, undo} from "prosemirror-history";
|
||||
import {undoInputRule} from "prosemirror-inputrules";
|
||||
import {
|
||||
chainCommands,
|
||||
exitCode,
|
||||
joinDown,
|
||||
joinUp,
|
||||
lift,
|
||||
selectParentNode,
|
||||
setBlockType,
|
||||
toggleMark
|
||||
} from "prosemirror-commands";
|
||||
import {liftListItem, sinkListItem, wrapInList} from "prosemirror-schema-list";
|
||||
import ProseMirrorPlugin from "./plugin.mjs";
|
||||
|
||||
/**
|
||||
* A class responsible for building the keyboard commands for the ProseMirror editor.
|
||||
* @extends {ProseMirrorPlugin}
|
||||
*/
|
||||
export default class ProseMirrorKeyMaps extends ProseMirrorPlugin {
|
||||
/**
|
||||
* @param {Schema} schema The ProseMirror schema to build keymaps for.
|
||||
* @param {object} [options] Additional options to configure the plugin's behaviour.
|
||||
* @param {Function} [options.onSave] A function to call when Ctrl+S is pressed.
|
||||
*/
|
||||
constructor(schema, {onSave}={}) {
|
||||
super(schema);
|
||||
|
||||
/**
|
||||
* A function to call when Ctrl+S is pressed.
|
||||
* @type {Function}
|
||||
*/
|
||||
Object.defineProperty(this, "onSave", {value: onSave, writable: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static build(schema, options={}) {
|
||||
const keymaps = new this(schema, options);
|
||||
return keymap(keymaps.buildMapping());
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @callback ProseMirrorCommand
|
||||
* @param {EditorState} state The current editor state.
|
||||
* @param {function(Transaction)} dispatch A function to dispatch a transaction.
|
||||
* @param {EditorView} view Escape-hatch for when the command needs to interact directly with the UI.
|
||||
* @returns {boolean} Whether the command has performed any action and consumed the event.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build keyboard commands for nodes and marks present in the schema.
|
||||
* @returns {Record<string, ProseMirrorCommand>} An object of keyboard shortcuts to editor functions.
|
||||
*/
|
||||
buildMapping() {
|
||||
// TODO: Figure out how to integrate this with our keybindings system.
|
||||
const mapping = {};
|
||||
|
||||
// Undo, Redo, Backspace.
|
||||
mapping["Mod-z"] = undo;
|
||||
mapping["Shift-Mod-z"] = redo;
|
||||
mapping["Backspace"] = undoInputRule;
|
||||
|
||||
// ProseMirror-specific block operations.
|
||||
mapping["Alt-ArrowUp"] = joinUp;
|
||||
mapping["Alt-ArrowDown"] = joinDown;
|
||||
mapping["Mod-BracketLeft"] = lift;
|
||||
mapping["Escape"] = selectParentNode;
|
||||
|
||||
// Bold.
|
||||
if ( "strong" in this.schema.marks ) {
|
||||
mapping["Mod-b"] = toggleMark(this.schema.marks.strong);
|
||||
mapping["Mod-B"] = toggleMark(this.schema.marks.strong);
|
||||
}
|
||||
|
||||
// Italic.
|
||||
if ( "em" in this.schema.marks ) {
|
||||
mapping["Mod-i"] = toggleMark(this.schema.marks.em);
|
||||
mapping["Mod-I"] = toggleMark(this.schema.marks.em);
|
||||
}
|
||||
|
||||
// Underline.
|
||||
if ( "underline" in this.schema.marks ) {
|
||||
mapping["Mod-u"] = toggleMark(this.schema.marks.underline);
|
||||
mapping["Mod-U"] = toggleMark(this.schema.marks.underline);
|
||||
}
|
||||
|
||||
// Inline code.
|
||||
if ( "code" in this.schema.marks ) mapping["Mod-`"] = toggleMark(this.schema.marks.code);
|
||||
|
||||
// Bulleted list.
|
||||
if ( "bullet_list" in this.schema.nodes ) mapping["Shift-Mod-8"] = wrapInList(this.schema.nodes.bullet_list);
|
||||
|
||||
// Numbered list.
|
||||
if ( "ordered_list" in this.schema.nodes ) mapping["Shift-Mod-9"] = wrapInList(this.schema.nodes.ordered_list);
|
||||
|
||||
// Blockquotes.
|
||||
if ( "blockquote" in this.schema.nodes ) mapping["Mod->"] = wrapInList(this.schema.nodes.blockquote);
|
||||
|
||||
// Line breaks.
|
||||
if ( "hard_break" in this.schema.nodes ) this.#lineBreakMapping(mapping);
|
||||
|
||||
// Block splitting.
|
||||
this.#newLineMapping(mapping);
|
||||
|
||||
// List items.
|
||||
if ( "list_item" in this.schema.nodes ) {
|
||||
const li = this.schema.nodes.list_item;
|
||||
mapping["Shift-Tab"] = liftListItem(li);
|
||||
mapping["Tab"] = sinkListItem(li);
|
||||
}
|
||||
|
||||
// Paragraphs.
|
||||
if ( "paragraph" in this.schema.nodes ) mapping["Shift-Mod-0"] = setBlockType(this.schema.nodes.paragraph);
|
||||
|
||||
// Code blocks.
|
||||
if ( "code_block" in this.schema.nodes ) mapping["Shift-Mod-\\"] = setBlockType(this.schema.nodes.code_block);
|
||||
|
||||
// Headings.
|
||||
if ( "heading" in this.schema.nodes ) this.#headingsMapping(mapping, 6);
|
||||
|
||||
// Horizontal rules.
|
||||
if ( "horizontal_rule" in this.schema.nodes ) this.#horizontalRuleMapping(mapping);
|
||||
|
||||
// Saving.
|
||||
if ( this.onSave ) this.#addSaveMapping(mapping);
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Implement keyboard commands for heading levels.
|
||||
* @param {Record<string, ProseMirrorCommand>} mapping The keyboard mapping.
|
||||
* @param {number} maxLevel The maximum level of headings.
|
||||
*/
|
||||
#headingsMapping(mapping, maxLevel) {
|
||||
const h = this.schema.nodes.heading;
|
||||
Array.fromRange(maxLevel, 1).forEach(level => mapping[`Shift-Mod-${level}`] = setBlockType(h, {level}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Implement keyboard commands for horizontal rules.
|
||||
* @param {Record<string, ProseMirrorCommand>} mapping The keyboard mapping.
|
||||
*/
|
||||
#horizontalRuleMapping(mapping) {
|
||||
const hr = this.schema.nodes.horizontal_rule;
|
||||
mapping["Mod-_"] = (state, dispatch) => {
|
||||
dispatch(state.tr.replaceSelectionWith(hr.create()).scrollIntoView());
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Implement line-break keyboard commands.
|
||||
* @param {Record<string, ProseMirrorCommand>} mapping The keyboard mapping.
|
||||
*/
|
||||
#lineBreakMapping(mapping) {
|
||||
const br = this.schema.nodes.hard_break;
|
||||
|
||||
// Exit a code block if we're in one, then create a line-break.
|
||||
const cmd = chainCommands(exitCode, (state, dispatch) => {
|
||||
dispatch(state.tr.replaceSelectionWith(br.create()).scrollIntoView());
|
||||
return true;
|
||||
});
|
||||
|
||||
mapping["Mod-Enter"] = cmd;
|
||||
mapping["Shift-Enter"] = cmd;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Implement some custom logic for how to split special blocks.
|
||||
* @param {Record<string, ProseMirrorCommand>} mapping The keyboard mapping.
|
||||
*/
|
||||
#newLineMapping(mapping) {
|
||||
const cmds = Object.values(this.schema.nodes).reduce((arr, node) => {
|
||||
if ( node.split instanceof Function ) arr.push(node.split);
|
||||
return arr;
|
||||
}, []);
|
||||
if ( !cmds.length ) return;
|
||||
mapping["Enter"] = cmds.length < 2 ? cmds[0] : chainCommands(...cmds);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Implement save shortcut.
|
||||
* @param {Record<string, ProseMirrorCommand>} mapping The keyboard mapping.
|
||||
*/
|
||||
#addSaveMapping(mapping) {
|
||||
mapping["Mod-s"] = () => {
|
||||
this.onSave();
|
||||
return true;
|
||||
};
|
||||
}
|
||||
}
|
||||
1065
resources/app/common/prosemirror/menu.mjs
Normal file
1065
resources/app/common/prosemirror/menu.mjs
Normal file
File diff suppressed because it is too large
Load Diff
37
resources/app/common/prosemirror/paste-transformer.mjs
Normal file
37
resources/app/common/prosemirror/paste-transformer.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import ProseMirrorPlugin from "./plugin.mjs";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import { randomID } from "../utils/helpers.mjs";
|
||||
import { transformSlice } from "./util.mjs";
|
||||
|
||||
/**
|
||||
* A class responsible for applying transformations to content pasted inside the editor.
|
||||
*/
|
||||
export default class ProseMirrorPasteTransformer extends ProseMirrorPlugin {
|
||||
/** @override */
|
||||
static build(schema, options={}) {
|
||||
const plugin = new ProseMirrorPasteTransformer(schema);
|
||||
return new Plugin({
|
||||
props: {
|
||||
transformPasted: plugin._onPaste.bind(plugin)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Transform content before it is injected into the ProseMirror document.
|
||||
* @param {Slice} slice The content slice.
|
||||
* @param {EditorView} view The ProseMirror editor view.
|
||||
* @returns {Slice} The transformed content.
|
||||
*/
|
||||
_onPaste(slice, view) {
|
||||
// Give pasted secret blocks new IDs.
|
||||
const secret = view.state.schema.nodes.secret;
|
||||
return transformSlice(slice, node => {
|
||||
if ( node.type === secret ) {
|
||||
return secret.create({ ...node.attrs, id: `secret-${randomID()}` }, node.content, node.marks);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
30
resources/app/common/prosemirror/plugin.mjs
Normal file
30
resources/app/common/prosemirror/plugin.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @abstract
|
||||
*/
|
||||
export default class ProseMirrorPlugin {
|
||||
/**
|
||||
* An abstract class for building a ProseMirror Plugin.
|
||||
* @see {Plugin}
|
||||
* @param {Schema} schema The schema to build the plugin against.
|
||||
*/
|
||||
constructor(schema) {
|
||||
/**
|
||||
* The ProseMirror schema to build the plugin against.
|
||||
* @type {Schema}
|
||||
*/
|
||||
Object.defineProperty(this, "schema", {value: schema});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Build the plugin.
|
||||
* @param {Schema} schema The ProseMirror schema to build the plugin against.
|
||||
* @param {object} [options] Additional options to pass to the plugin.
|
||||
* @returns {Plugin}
|
||||
* @abstract
|
||||
*/
|
||||
static build(schema, options={}) {
|
||||
throw new Error("Subclasses of ProseMirrorPlugin must implement a static build method.");
|
||||
}
|
||||
}
|
||||
90
resources/app/common/prosemirror/schema.mjs
Normal file
90
resources/app/common/prosemirror/schema.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
import {Schema} from "prosemirror-model";
|
||||
import {splitListItem} from "prosemirror-schema-list";
|
||||
import {
|
||||
paragraph, blockquote, hr as horizontal_rule, heading, pre as code_block, br as hard_break
|
||||
} from "./schema/core.mjs";
|
||||
import {ol as ordered_list, ul as bullet_list, li as list_item, liText as list_item_text} from "./schema/lists.mjs";
|
||||
import{
|
||||
builtInTableNodes, tableComplex as table_complex, colgroup, col, thead, tbody, tfoot, caption,
|
||||
captionBlock as caption_block, tableRowComplex as table_row_complex, tableCellComplex as table_cell_complex,
|
||||
tableCellComplexBlock as table_cell_complex_block, tableHeaderComplex as table_header_complex,
|
||||
tableHeaderComplexBlock as table_header_complex_block
|
||||
} from "./schema/tables.mjs";
|
||||
import {
|
||||
details, summary, summaryBlock as summary_block, dl, dt, dd, fieldset, legend, picture, audio, video, track, source,
|
||||
object, figure, figcaption, small, ruby, rp, rt, iframe
|
||||
} from "./schema/other.mjs"
|
||||
import {
|
||||
superscript, subscript, span, font, em, strong, underline, strikethrough, code
|
||||
} from "./schema/marks.mjs";
|
||||
import ImageNode from "./schema/image-node.mjs";
|
||||
import LinkMark from "./schema/link-mark.mjs";
|
||||
import ImageLinkNode from "./schema/image-link-node.mjs";
|
||||
import SecretNode from "./schema/secret-node.mjs";
|
||||
import AttributeCapture from "./schema/attribute-capture.mjs";
|
||||
|
||||
const doc = {
|
||||
content: "block+"
|
||||
};
|
||||
|
||||
const text = {
|
||||
group: "inline"
|
||||
};
|
||||
|
||||
const secret = SecretNode.make();
|
||||
const link = LinkMark.make();
|
||||
const image = ImageNode.make();
|
||||
const imageLink = ImageLinkNode.make();
|
||||
|
||||
export const nodes = {
|
||||
// Core Nodes.
|
||||
doc, text, paragraph, blockquote, secret, horizontal_rule, heading, code_block, image_link: imageLink, image,
|
||||
hard_break,
|
||||
|
||||
// Lists.
|
||||
ordered_list, bullet_list, list_item, list_item_text,
|
||||
|
||||
// Tables
|
||||
table_complex, tbody, thead, tfoot, caption, caption_block, colgroup, col, table_row_complex, table_cell_complex,
|
||||
table_header_complex, table_cell_complex_block, table_header_complex_block,
|
||||
...builtInTableNodes,
|
||||
|
||||
// Misc.
|
||||
details, summary, summary_block, dl, dt, dd, fieldset, legend, picture, audio, video, track, source, object, figure,
|
||||
figcaption, small, ruby, rp, rt, iframe
|
||||
};
|
||||
|
||||
export const marks = {superscript, subscript, span, font, link, em, strong, underline, strikethrough, code};
|
||||
|
||||
// Auto-generated specifications for HTML preservation.
|
||||
["header", "main", "section", "article", "aside", "nav", "footer", "div", "address"].forEach(tag => {
|
||||
nodes[tag] = {
|
||||
content: "block+",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag}],
|
||||
toDOM: () => [tag, 0]
|
||||
};
|
||||
});
|
||||
|
||||
["abbr", "cite", "mark", "q", "time", "ins"].forEach(tag => {
|
||||
marks[tag] = {
|
||||
parseDOM: [{tag}],
|
||||
toDOM: () => [tag, 0]
|
||||
};
|
||||
});
|
||||
|
||||
const all = Object.values(nodes).concat(Object.values(marks));
|
||||
const capture = new AttributeCapture();
|
||||
all.forEach(capture.attributeCapture.bind(capture));
|
||||
|
||||
export const schema = new Schema({nodes, marks});
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
schema.nodes.list_item.split = splitListItem(schema.nodes.list_item);
|
||||
schema.nodes.secret.split = SecretNode.split;
|
||||
schema.marks.link.onClick = LinkMark.onClick;
|
||||
schema.nodes.image_link.onClick = ImageLinkNode.onClick;
|
||||
139
resources/app/common/prosemirror/schema/attribute-capture.mjs
Normal file
139
resources/app/common/prosemirror/schema/attribute-capture.mjs
Normal file
@@ -0,0 +1,139 @@
|
||||
import {ALLOWED_HTML_ATTRIBUTES} from "../../constants.mjs";
|
||||
import {getType, mergeObject} from "../../utils/helpers.mjs";
|
||||
import {classesFromString, mergeClass, mergeStyle, stylesFromString} from "./utils.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {object} AllowedAttributeConfiguration
|
||||
* @property {Set<string>} attrs The set of exactly-matching attribute names.
|
||||
* @property {string[]} wildcards A list of wildcard allowed prefixes for attributes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ManagedAttributesSpec
|
||||
* @property {string[]} attributes A list of managed attributes.
|
||||
* @property {string[]} styles A list of CSS property names that are managed as inline styles.
|
||||
* @property {string[]} classes A list of managed class names.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A class responsible for injecting attribute capture logic into the ProseMirror schema.
|
||||
*/
|
||||
export default class AttributeCapture {
|
||||
constructor() {
|
||||
this.#parseAllowedAttributesConfig(ALLOWED_HTML_ATTRIBUTES ?? {});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The configuration of attributes that are allowed on HTML elements.
|
||||
* @type {Record<string, AllowedAttributeConfiguration>}
|
||||
*/
|
||||
#allowedAttrs = {};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Augments the schema definition to allow each node or mark to capture all the attributes on an element and preserve
|
||||
* them when re-serialized back into the DOM.
|
||||
* @param {NodeSpec|MarkSpec} spec The schema specification.
|
||||
*/
|
||||
attributeCapture(spec) {
|
||||
if ( !spec.parseDOM ) return;
|
||||
if ( !spec.attrs ) spec.attrs = {};
|
||||
spec.attrs._preserve = { default: {}, formatting: true };
|
||||
spec.parseDOM.forEach(rule => {
|
||||
if ( rule.style ) return; // This doesn't work for style rules. We need a different solution there.
|
||||
const getAttrs = rule.getAttrs;
|
||||
rule.getAttrs = el => {
|
||||
let attrs = getAttrs?.(el);
|
||||
if ( attrs === false ) return false;
|
||||
if ( typeof attrs !== "object" ) attrs = {};
|
||||
mergeObject(attrs, rule.attrs);
|
||||
mergeObject(attrs, { _preserve: this.#captureAttributes(el, spec.managed) });
|
||||
return attrs;
|
||||
};
|
||||
});
|
||||
const toDOM = spec.toDOM;
|
||||
spec.toDOM = node => {
|
||||
const domSpec = toDOM(node);
|
||||
const attrs = domSpec[1];
|
||||
const preserved = node.attrs._preserve ?? {};
|
||||
if ( preserved.style ) preserved.style = preserved.style.replaceAll('"', "'");
|
||||
if ( getType(attrs) === "Object" ) {
|
||||
domSpec[1] = mergeObject(preserved, attrs, { inplace: false });
|
||||
if ( ("style" in preserved) && ("style" in attrs) ) domSpec[1].style = mergeStyle(preserved.style, attrs.style);
|
||||
if ( ("class" in preserved) && ("class" in attrs) ) domSpec[1].class = mergeClass(preserved.class, attrs.class);
|
||||
}
|
||||
else domSpec.splice(1, 0, { ...preserved });
|
||||
return domSpec;
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Capture all allowable attributes present on an HTML element and store them in an object for preservation in the
|
||||
* schema.
|
||||
* @param {HTMLElement} el The element.
|
||||
* @param {ManagedAttributesSpec} managed An object containing the attributes, styles, and classes that are managed
|
||||
* by the ProseMirror node and should not be preserved.
|
||||
* @returns {Attrs}
|
||||
*/
|
||||
#captureAttributes(el, managed={}) {
|
||||
const allowed = this.#allowedAttrs[el.tagName.toLowerCase()] ?? this.#allowedAttrs["*"];
|
||||
return Array.from(el.attributes).reduce((obj, attr) => {
|
||||
if ( attr.name.startsWith("data-pm-") ) return obj; // Ignore attributes managed by the ProseMirror editor itself.
|
||||
if ( managed.attributes?.includes(attr.name) ) return obj; // Ignore attributes managed by the node.
|
||||
// Ignore attributes that are not allowed.
|
||||
if ( !allowed.wildcards.some(prefix => attr.name.startsWith(prefix)) && !allowed.attrs.has(attr.name) ) {
|
||||
return obj;
|
||||
}
|
||||
if ( (attr.name === "class") && managed.classes?.length ) {
|
||||
obj.class = classesFromString(attr.value).filter(cls => !managed.classes.includes(cls)).join(" ");
|
||||
return obj;
|
||||
}
|
||||
if ( (attr.name === "style") && managed.styles?.length ) {
|
||||
const styles = stylesFromString(attr.value);
|
||||
managed.styles.forEach(style => delete styles[style]);
|
||||
obj.style = Object.entries(styles).map(([k, v]) => v ? `${k}: ${v}` : null).filterJoin("; ");
|
||||
return obj;
|
||||
}
|
||||
obj[attr.name] = attr.value;
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Parse the configuration of allowed attributes into a more performant structure.
|
||||
* @param {Record<string, string[]>} config The allowed attributes configuration.
|
||||
*/
|
||||
#parseAllowedAttributesConfig(config) {
|
||||
const all = this.#allowedAttrs["*"] = this.#parseAllowedAttributes(config["*"] ?? []);
|
||||
for ( const [tag, attrs] of Object.entries(config ?? {}) ) {
|
||||
if ( tag === "*" ) continue;
|
||||
const allowed = this.#allowedAttrs[tag] = this.#parseAllowedAttributes(attrs);
|
||||
all.attrs.forEach(allowed.attrs.add, allowed.attrs);
|
||||
allowed.wildcards.push(...all.wildcards);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Parse an allowed attributes configuration into a more efficient structure.
|
||||
* @param {string[]} attrs The list of allowed attributes.
|
||||
* @returns {AllowedAttributeConfiguration}
|
||||
*/
|
||||
#parseAllowedAttributes(attrs) {
|
||||
const allowed = { wildcards: [], attrs: new Set() };
|
||||
for ( const attr of attrs ) {
|
||||
const wildcard = attr.indexOf("*");
|
||||
if ( wildcard < 0 ) allowed.attrs.add(attr);
|
||||
else allowed.wildcards.push(attr.substring(0, wildcard));
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
}
|
||||
70
resources/app/common/prosemirror/schema/core.mjs
Normal file
70
resources/app/common/prosemirror/schema/core.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
export const paragraph = {
|
||||
attrs: {alignment: {default: "left", formatting: true}},
|
||||
managed: {styles: ["text-align"]},
|
||||
content: "inline*",
|
||||
group: "block",
|
||||
parseDOM: [{tag: "p", getAttrs: el => ({alignment: el.style.textAlign || "left"})}],
|
||||
toDOM: node => {
|
||||
const {alignment} = node.attrs;
|
||||
if ( alignment === "left" ) return ["p", 0];
|
||||
return ["p", {style: `text-align: ${alignment};`}, 0];
|
||||
}
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const blockquote = {
|
||||
content: "block+",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "blockquote"}],
|
||||
toDOM: () => ["blockquote", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const hr = {
|
||||
group: "block",
|
||||
parseDOM: [{tag: "hr"}],
|
||||
toDOM: () => ["hr"]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const heading = {
|
||||
attrs: {level: {default: 1}},
|
||||
content: "inline*",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [
|
||||
{tag: "h1", attrs: {level: 1}},
|
||||
{tag: "h2", attrs: {level: 2}},
|
||||
{tag: "h3", attrs: {level: 3}},
|
||||
{tag: "h4", attrs: {level: 4}},
|
||||
{tag: "h5", attrs: {level: 5}},
|
||||
{tag: "h6", attrs: {level: 6}}
|
||||
],
|
||||
toDOM: node => [`h${node.attrs.level}`, 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const pre = {
|
||||
content: "text*",
|
||||
marks: "",
|
||||
group: "block",
|
||||
code: true,
|
||||
defining: true,
|
||||
parseDOM: [{tag: "pre", preserveWhitespace: "full"}],
|
||||
toDOM: () => ["pre", ["code", 0]]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const br = {
|
||||
inline: true,
|
||||
group: "inline",
|
||||
selectable: false,
|
||||
parseDOM: [{tag: "br"}],
|
||||
toDOM: () => ["br"]
|
||||
};
|
||||
70
resources/app/common/prosemirror/schema/image-link-node.mjs
Normal file
70
resources/app/common/prosemirror/schema/image-link-node.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
import {mergeObject} from "../../utils/helpers.mjs";
|
||||
import ImageNode from "./image-node.mjs";
|
||||
import LinkMark from "./link-mark.mjs";
|
||||
import SchemaDefinition from "./schema-definition.mjs";
|
||||
|
||||
/**
|
||||
* A class responsible for encapsulating logic around image-link nodes in the ProseMirror schema.
|
||||
* @extends {SchemaDefinition}
|
||||
*/
|
||||
export default class ImageLinkNode extends SchemaDefinition {
|
||||
/** @override */
|
||||
static tag = "a";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get attrs() {
|
||||
return mergeObject(ImageNode.attrs, LinkMark.attrs);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static getAttrs(el) {
|
||||
if ( (el.children.length !== 1) || (el.children[0].tagName !== "IMG") ) return false;
|
||||
const attrs = ImageNode.getAttrs(el.children[0]);
|
||||
attrs.href = el.href;
|
||||
attrs.title = el.title;
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static toDOM(node) {
|
||||
const spec = LinkMark.toDOM(node);
|
||||
spec.push(ImageNode.toDOM(node));
|
||||
return spec;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static make() {
|
||||
return mergeObject(super.make(), {
|
||||
group: "block",
|
||||
draggable: true,
|
||||
managed: { styles: ["float"], classes: ["centered"] }
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle clicking on image links while editing.
|
||||
* @param {EditorView} view The ProseMirror editor view.
|
||||
* @param {number} pos The position in the ProseMirror document that the click occurred at.
|
||||
* @param {PointerEvent} event The click event.
|
||||
* @param {Node} node The Node instance.
|
||||
*/
|
||||
static onClick(view, pos, event, node) {
|
||||
if ( (event.ctrlKey || event.metaKey) && node.attrs.href ) window.open(node.attrs.href, "_blank");
|
||||
// For some reason, calling event.preventDefault in this (mouseup) handler is not enough to cancel the default click
|
||||
// behaviour. It seems to be related to the outer anchor being set to contenteditable="false" by ProseMirror.
|
||||
// This workaround seems to prevent the click.
|
||||
const parent = event.target.parentElement;
|
||||
if ( (parent.tagName === "A") && !parent.isContentEditable ) parent.contentEditable = "true";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
67
resources/app/common/prosemirror/schema/image-node.mjs
Normal file
67
resources/app/common/prosemirror/schema/image-node.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
import SchemaDefinition from "./schema-definition.mjs";
|
||||
import {mergeObject} from "../../utils/helpers.mjs";
|
||||
|
||||
/**
|
||||
* A class responsible for encapsulating logic around image nodes in the ProseMirror schema.
|
||||
* @extends {SchemaDefinition}
|
||||
*/
|
||||
export default class ImageNode extends SchemaDefinition {
|
||||
/** @override */
|
||||
static tag = "img[src]";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get attrs() {
|
||||
return {
|
||||
src: {},
|
||||
alt: {default: null},
|
||||
title: {default: null},
|
||||
width: {default: ""},
|
||||
height: {default: ""},
|
||||
alignment: {default: "", formatting: true}
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static getAttrs(el) {
|
||||
const attrs = {
|
||||
src: el.getAttribute("src"),
|
||||
title: el.title,
|
||||
alt: el.alt
|
||||
};
|
||||
if ( el.classList.contains("centered") ) attrs.alignment = "center";
|
||||
else if ( el.style.float ) attrs.alignment = el.style.float;
|
||||
if ( el.hasAttribute("width") ) attrs.width = el.width;
|
||||
if ( el.hasAttribute("height") ) attrs.height = el.height;
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static toDOM(node) {
|
||||
const {src, alt, title, width, height, alignment} = node.attrs;
|
||||
const attrs = {src};
|
||||
if ( alignment === "center" ) attrs.class = "centered";
|
||||
else if ( alignment ) attrs.style = `float: ${alignment};`;
|
||||
if ( alt ) attrs.alt = alt;
|
||||
if ( title ) attrs.title = title;
|
||||
if ( width ) attrs.width = width;
|
||||
if ( height ) attrs.height = height;
|
||||
return ["img", attrs];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static make() {
|
||||
return mergeObject(super.make(), {
|
||||
managed: {styles: ["float"], classes: ["centered"]},
|
||||
group: "block",
|
||||
draggable: true
|
||||
});
|
||||
}
|
||||
}
|
||||
65
resources/app/common/prosemirror/schema/link-mark.mjs
Normal file
65
resources/app/common/prosemirror/schema/link-mark.mjs
Normal file
@@ -0,0 +1,65 @@
|
||||
import SchemaDefinition from "./schema-definition.mjs";
|
||||
import {mergeObject} from "../../utils/helpers.mjs";
|
||||
|
||||
/**
|
||||
* A class responsible for encapsulating logic around link marks in the ProseMirror schema.
|
||||
* @extends {SchemaDefinition}
|
||||
*/
|
||||
export default class LinkMark extends SchemaDefinition {
|
||||
/** @override */
|
||||
static tag = "a";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get attrs() {
|
||||
return {
|
||||
href: { default: null },
|
||||
title: { default: null }
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static getAttrs(el) {
|
||||
if ( (el.children.length === 1) && (el.children[0]?.tagName === "IMG") ) return false;
|
||||
return { href: el.href, title: el.title };
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static toDOM(node) {
|
||||
const { href, title } = node.attrs;
|
||||
const attrs = {};
|
||||
if ( href ) attrs.href = href;
|
||||
if ( title ) attrs.title = title;
|
||||
return ["a", attrs];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static make() {
|
||||
return mergeObject(super.make(), {
|
||||
inclusive: false
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle clicks on link marks while editing.
|
||||
* @param {EditorView} view The ProseMirror editor view.
|
||||
* @param {number} pos The position in the ProseMirror document that the click occurred at.
|
||||
* @param {PointerEvent} event The click event.
|
||||
* @param {Mark} mark The Mark instance.
|
||||
* @returns {boolean|void} Returns true to indicate the click was handled here and should not be propagated to
|
||||
* other plugins.
|
||||
*/
|
||||
static onClick(view, pos, event, mark) {
|
||||
if ( (event.ctrlKey || event.metaKey) && mark.attrs.href ) window.open(mark.attrs.href, "_blank");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
73
resources/app/common/prosemirror/schema/lists.mjs
Normal file
73
resources/app/common/prosemirror/schema/lists.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
import {isElementEmpty, onlyInlineContent} from "./utils.mjs";
|
||||
|
||||
export const ol = {
|
||||
content: "(list_item | list_item_text)+",
|
||||
managed: {attributes: ["start"]},
|
||||
group: "block",
|
||||
attrs: {order: {default: 1}},
|
||||
parseDOM: [{tag: "ol", getAttrs: el => ({order: el.hasAttribute("start") ? Number(el.start) : 1})}],
|
||||
toDOM: node => node.attrs.order === 1 ? ["ol", 0] : ["ol", {start: node.attrs.order}, 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const ul = {
|
||||
content: "(list_item | list_item_text)+",
|
||||
group: "block",
|
||||
parseDOM: [{tag: "ul"}],
|
||||
toDOM: () => ["ul", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* ProseMirror enforces a stricter subset of HTML where block and inline content cannot be mixed. For example, the
|
||||
* following is valid HTML:
|
||||
* <ul>
|
||||
* <li>
|
||||
* The first list item.
|
||||
* <ul>
|
||||
* <li>An embedded list.</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ul>
|
||||
*
|
||||
* But, since the contents of the <li> would mix inline content (the text), with block content (the inner <ul>), the
|
||||
* schema is defined to only allow block content, and would transform the items to look like this:
|
||||
* <ul>
|
||||
* <li>
|
||||
* <p>The first list item.</p>
|
||||
* <ul>
|
||||
* <li><p>An embedded list.</p></li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ul>
|
||||
*
|
||||
* We can address this by hooking into the DOM parsing and 'tagging' the extra paragraph elements inserted this way so
|
||||
* that when the contents are serialized again, they can be removed. This is left as a TODO for now.
|
||||
*/
|
||||
|
||||
// In order to preserve existing HTML we define two types of list nodes. One that contains block content, and one that
|
||||
// contains text content. We default to block content if the element is empty, in order to make integration with the
|
||||
// wrapping and lifting helpers simpler.
|
||||
export const li = {
|
||||
content: "paragraph block*",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "li", getAttrs: el => {
|
||||
// If this contains only inline content and no other elements, do not use this node type.
|
||||
if ( !isElementEmpty(el) && onlyInlineContent(el) ) return false;
|
||||
}}],
|
||||
toDOM: () => ["li", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const liText = {
|
||||
content: "text*",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "li", getAttrs: el => {
|
||||
// If this contains any non-inline elements, do not use this node type.
|
||||
if ( isElementEmpty(el) || !onlyInlineContent(el) ) return false;
|
||||
}}],
|
||||
toDOM: () => ["li", 0]
|
||||
};
|
||||
70
resources/app/common/prosemirror/schema/marks.mjs
Normal file
70
resources/app/common/prosemirror/schema/marks.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
export const em = {
|
||||
parseDOM: [{tag: "i"}, {tag: "em"}, {style: "font-style=italic"}],
|
||||
toDOM: () => ["em", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const strong = {
|
||||
parseDOM: [
|
||||
{tag: "strong"},
|
||||
{tag: "b"},
|
||||
{style: "font-weight", getAttrs: weight => /^(bold(er)?|[5-9]\d{2})$/.test(weight) && null}
|
||||
],
|
||||
toDOM: () => ["strong", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const code = {
|
||||
parseDOM: [{tag: "code"}],
|
||||
toDOM: () => ["code", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const underline = {
|
||||
parseDOM: [{tag: "u"}, {style: "text-decoration=underline"}],
|
||||
toDOM: () => ["span", {style: "text-decoration: underline;"}, 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const strikethrough = {
|
||||
parseDOM: [{tag: "s"}, {tag: "del"}, {style: "text-decoration=line-through"}],
|
||||
toDOM: () => ["s", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const superscript = {
|
||||
parseDOM: [{tag: "sup"}, {style: "vertical-align=super"}],
|
||||
toDOM: () => ["sup", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const subscript = {
|
||||
parseDOM: [{tag: "sub"}, {style: "vertical-align=sub"}],
|
||||
toDOM: () => ["sub", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const span = {
|
||||
parseDOM: [{tag: "span", getAttrs: el => {
|
||||
if ( el.style.fontFamily ) return false;
|
||||
return {};
|
||||
}}],
|
||||
toDOM: () => ["span", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const font = {
|
||||
attrs: {
|
||||
family: {}
|
||||
},
|
||||
parseDOM: [{style: "font-family", getAttrs: family => ({family})}],
|
||||
toDOM: node => ["span", {style: `font-family: ${node.attrs.family.replaceAll('"', "'")}`}]
|
||||
};
|
||||
210
resources/app/common/prosemirror/schema/other.mjs
Normal file
210
resources/app/common/prosemirror/schema/other.mjs
Normal file
@@ -0,0 +1,210 @@
|
||||
import {isElementEmpty, onlyInlineContent} from "./utils.mjs";
|
||||
|
||||
// These nodes are supported for HTML preservation purposes, but do not have robust editing support for now.
|
||||
|
||||
export const details = {
|
||||
content: "(summary | summary_block) block*",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "details"}],
|
||||
toDOM: () => ["details", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const summary = {
|
||||
content: "text*",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "summary", getAttrs: el => {
|
||||
// If this contains any non-inline elements, do not use this node type.
|
||||
if ( !isElementEmpty(el) && !onlyInlineContent(el) ) return false;
|
||||
}}],
|
||||
toDOM: () => ["summary", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const summaryBlock = {
|
||||
content: "block+",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "summary", getAttrs: el => {
|
||||
// If this contains only text nodes and no elements, do not use this node type.
|
||||
if ( isElementEmpty(el) || onlyInlineContent(el) ) return false;
|
||||
}}],
|
||||
toDOM: () => ["summary", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const dl = {
|
||||
content: "(block|dt|dd)*",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "dl"}],
|
||||
toDOM: () => ["dl", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const dt = {
|
||||
content: "block+",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "dt"}],
|
||||
toDOM: () => ["dt", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const dd = {
|
||||
content: "block+",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "dd"}],
|
||||
toDOM: () => ["dd", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const fieldset = {
|
||||
content: "legend block*",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "fieldset"}],
|
||||
toDOM: () => ["fieldset", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const legend = {
|
||||
content: "inline+",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "legend"}],
|
||||
toDOM: () => ["legend", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const picture = {
|
||||
content: "source* image",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "picture"}],
|
||||
toDOM: () => ["picture", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const audio = {
|
||||
content: "source* track*",
|
||||
group: "block",
|
||||
parseDOM: [{tag: "audio"}],
|
||||
toDOM: () => ["audio", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const video = {
|
||||
content: "source* track*",
|
||||
group: "block",
|
||||
parseDOM: [{tag: "video"}],
|
||||
toDOM: () => ["video", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const track = {
|
||||
parseDOM: [{tag: "track"}],
|
||||
toDOM: () => ["track"]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const source = {
|
||||
parseDOM: [{tag: "source"}],
|
||||
toDOM: () => ["source"]
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const object = {
|
||||
inline: true,
|
||||
group: "inline",
|
||||
parseDOM: [{tag: "object"}],
|
||||
toDOM: () => ["object"]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const figure = {
|
||||
content: "(figcaption|block)*",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "figure"}],
|
||||
toDOM: () => ["figure", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const figcaption = {
|
||||
content: "inline+",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "figcaption"}],
|
||||
toDOM: () => ["figcaption", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const small = {
|
||||
content: "paragraph block*",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "small"}],
|
||||
toDOM: () => ["small", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const ruby = {
|
||||
content: "(rp|rt|block)+",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "ruby"}],
|
||||
toDOM: () => ["ruby", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const rp = {
|
||||
content: "inline+",
|
||||
parseDOM: [{tag: "rp"}],
|
||||
toDOM: () => ["rp", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const rt = {
|
||||
content: "inline+",
|
||||
parseDOM: [{tag: "rt"}],
|
||||
toDOM: () => ["rt", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const iframe = {
|
||||
attrs: { sandbox: { default: "allow-scripts allow-forms" } },
|
||||
managed: { attributes: ["sandbox"] },
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "iframe", getAttrs: el => {
|
||||
let sandbox = "allow-scripts allow-forms";
|
||||
const url = URL.parseSafe(el.src);
|
||||
const host = url?.hostname;
|
||||
const isTrusted = CONST.TRUSTED_IFRAME_DOMAINS.some(domain => (host === domain) || host?.endsWith(`.${domain}`));
|
||||
if ( isTrusted ) sandbox = null;
|
||||
return { sandbox };
|
||||
}}],
|
||||
toDOM: node => {
|
||||
const attrs = {};
|
||||
if ( node.attrs.sandbox ) attrs.sandbox = node.attrs.sandbox;
|
||||
return ["iframe", attrs];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* An abstract interface for a ProseMirror schema definition.
|
||||
* @abstract
|
||||
*/
|
||||
export default class SchemaDefinition {
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The HTML tag selector this node is associated with.
|
||||
* @type {string}
|
||||
*/
|
||||
static tag = "";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Schema attributes.
|
||||
* @returns {Record<string, AttributeSpec>}
|
||||
* @abstract
|
||||
*/
|
||||
static get attrs() {
|
||||
throw new Error("SchemaDefinition subclasses must implement the attrs getter.");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Check if an HTML element is appropriate to represent as this node, and if so, extract its schema attributes.
|
||||
* @param {HTMLElement} el The HTML element.
|
||||
* @returns {object|boolean} Returns false if the HTML element is not appropriate for this schema node, otherwise
|
||||
* returns its attributes.
|
||||
* @abstract
|
||||
*/
|
||||
static getAttrs(el) {
|
||||
throw new Error("SchemaDefinition subclasses must implement the getAttrs method.");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert a ProseMirror Node back into an HTML element.
|
||||
* @param {Node} node The ProseMirror node.
|
||||
* @returns {[string, any]}
|
||||
* @abstract
|
||||
*/
|
||||
static toDOM(node) {
|
||||
throw new Error("SchemaDefinition subclasses must implement the toDOM method.");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create the ProseMirror schema specification.
|
||||
* @returns {NodeSpec|MarkSpec}
|
||||
* @abstract
|
||||
*/
|
||||
static make() {
|
||||
return {
|
||||
attrs: this.attrs,
|
||||
parseDOM: [{tag: this.tag, getAttrs: this.getAttrs.bind(this)}],
|
||||
toDOM: this.toDOM.bind(this)
|
||||
};
|
||||
}
|
||||
}
|
||||
77
resources/app/common/prosemirror/schema/secret-node.mjs
Normal file
77
resources/app/common/prosemirror/schema/secret-node.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
import SchemaDefinition from "./schema-definition.mjs";
|
||||
import {mergeObject, randomID} from "../../utils/helpers.mjs";
|
||||
|
||||
/**
|
||||
* A class responsible for encapsulating logic around secret nodes in the ProseMirror schema.
|
||||
* @extends {SchemaDefinition}
|
||||
*/
|
||||
export default class SecretNode extends SchemaDefinition {
|
||||
/** @override */
|
||||
static tag = "section";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get attrs() {
|
||||
return {
|
||||
revealed: { default: false },
|
||||
id: {}
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static getAttrs(el) {
|
||||
if ( !el.classList.contains("secret") ) return false;
|
||||
return {
|
||||
revealed: el.classList.contains("revealed"),
|
||||
id: el.id || `secret-${randomID()}`
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static toDOM(node) {
|
||||
const attrs = {
|
||||
id: node.attrs.id,
|
||||
class: `secret${node.attrs.revealed ? " revealed" : ""}`
|
||||
};
|
||||
return ["section", attrs, 0];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
static make() {
|
||||
return mergeObject(super.make(), {
|
||||
content: "block+",
|
||||
group: "block",
|
||||
defining: true,
|
||||
managed: { attributes: ["id"], classes: ["revealed"] }
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle splitting a secret block in two, making sure the new block gets a unique ID.
|
||||
* @param {EditorState} state The ProseMirror editor state.
|
||||
* @param {(tr: Transaction) => void} dispatch The editor dispatch function.
|
||||
*/
|
||||
static split(state, dispatch) {
|
||||
const secret = state.schema.nodes.secret;
|
||||
const { $cursor } = state.selection;
|
||||
// Check we are actually on a blank line and not splitting text content.
|
||||
if ( !$cursor || $cursor.parent.content.size ) return false;
|
||||
// Check that we are actually in a secret block.
|
||||
if ( $cursor.node(-1).type !== secret ) return false;
|
||||
// Check that the block continues past the cursor.
|
||||
if ( $cursor.after() === $cursor.end(-1) ) return false;
|
||||
const before = $cursor.before(); // The previous line.
|
||||
// Ensure a new ID assigned to the new secret block.
|
||||
dispatch(state.tr.split(before, 1, [{type: secret, attrs: {id: `secret-${randomID()}`}}]));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
218
resources/app/common/prosemirror/schema/tables.mjs
Normal file
218
resources/app/common/prosemirror/schema/tables.mjs
Normal file
@@ -0,0 +1,218 @@
|
||||
import {tableNodes} from "prosemirror-tables";
|
||||
import {isElementEmpty, onlyInlineContent} from "./utils.mjs";
|
||||
|
||||
const CELL_ATTRS = {
|
||||
colspan: {default: 1},
|
||||
rowspan: {default: 1},
|
||||
colwidth: {default: null}
|
||||
};
|
||||
|
||||
const MANAGED_CELL_ATTRS = {
|
||||
attributes: ["colspan", "rowspan", "data-colwidth"]
|
||||
};
|
||||
|
||||
// If any of these elements are part of a table, consider it a 'complex' table and do not attempt to make it editable.
|
||||
const COMPLEX_TABLE_ELEMENTS = new Set(["CAPTION", "COLGROUP", "THEAD", "TFOOT"]);
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Utilities */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine node attributes for a table cell when parsing the DOM.
|
||||
* @param {HTMLTableCellElement} cell The table cell DOM node.
|
||||
* @returns {{colspan: number, rowspan: number}}
|
||||
*/
|
||||
function getTableCellAttrs(cell) {
|
||||
const colspan = cell.getAttribute("colspan") || 1;
|
||||
const rowspan = cell.getAttribute("rowspan") || 1;
|
||||
return {
|
||||
colspan: Number(colspan),
|
||||
rowspan: Number(rowspan)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the HTML attributes to be set on the table cell DOM node based on its ProseMirror node attributes.
|
||||
* @param {Node} node The table cell ProseMirror node.
|
||||
* @returns {object} An object of attribute name -> attribute value.
|
||||
*/
|
||||
function setTableCellAttrs(node) {
|
||||
const attrs = {};
|
||||
const {colspan, rowspan} = node.attrs;
|
||||
if ( colspan !== 1 ) attrs.colspan = colspan;
|
||||
if ( rowspan !== 1 ) attrs.rowspan = rowspan;
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this element exists as part of a 'complex' table.
|
||||
* @param {HTMLElement} el The element to test.
|
||||
* @returns {boolean|void}
|
||||
*/
|
||||
function inComplexTable(el) {
|
||||
const table = el.closest("table");
|
||||
if ( !table ) return;
|
||||
return Array.from(table.children).some(child => COMPLEX_TABLE_ELEMENTS.has(child.tagName));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Built-in Tables */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const builtInTableNodes = tableNodes({
|
||||
tableGroup: "block",
|
||||
cellContent: "block+"
|
||||
});
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* 'Complex' Tables */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const tableComplex = {
|
||||
content: "(caption | caption_block)? colgroup? thead? tbody tfoot?",
|
||||
isolating: true,
|
||||
group: "block",
|
||||
parseDOM: [{tag: "table", getAttrs: el => {
|
||||
if ( inComplexTable(el) === false ) return false;
|
||||
}}],
|
||||
toDOM: () => ["table", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const colgroup = {
|
||||
content: "col*",
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "colgroup"}],
|
||||
toDOM: () => ["colgroup", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const col = {
|
||||
tableRole: "col",
|
||||
parseDOM: [{tag: "col"}],
|
||||
toDOM: () => ["col"]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const thead = {
|
||||
content: "table_row_complex+",
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "thead"}],
|
||||
toDOM: () => ["thead", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const tbody = {
|
||||
content: "table_row_complex+",
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "tbody", getAttrs: el => {
|
||||
if ( inComplexTable(el) === false ) return false;
|
||||
}}],
|
||||
toDOM: () => ["tbody", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const tfoot = {
|
||||
content: "table_row_complex+",
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "tfoot"}],
|
||||
toDOM: () => ["tfoot", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const caption = {
|
||||
content: "text*",
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "caption", getAttrs: el => {
|
||||
if ( !isElementEmpty(el) && !onlyInlineContent(el) ) return false;
|
||||
}}],
|
||||
toDOM: () => ["caption", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const captionBlock = {
|
||||
content: "block*",
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "caption", getAttrs: el => {
|
||||
if ( isElementEmpty(el) || onlyInlineContent(el) ) return false;
|
||||
}}],
|
||||
toDOM: () => ["caption", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const tableRowComplex = {
|
||||
content: "(table_cell_complex | table_header_complex | table_cell_complex_block | table_header_complex_block)*",
|
||||
parseDOM: [{tag: "tr", getAttrs: el => {
|
||||
if ( inComplexTable(el) === false ) return false;
|
||||
}}],
|
||||
toDOM: () => ["tr", 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const tableCellComplex = {
|
||||
content: "text*",
|
||||
attrs: CELL_ATTRS,
|
||||
managed: MANAGED_CELL_ATTRS,
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "td", getAttrs: el => {
|
||||
if ( inComplexTable(el) === false ) return false;
|
||||
if ( !isElementEmpty(el) && !onlyInlineContent(el) ) return false;
|
||||
return getTableCellAttrs(el);
|
||||
}}],
|
||||
toDOM: node => ["td", setTableCellAttrs(node), 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const tableCellComplexBlock = {
|
||||
content: "block*",
|
||||
attrs: CELL_ATTRS,
|
||||
managed: MANAGED_CELL_ATTRS,
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "td", getAttrs: el => {
|
||||
if ( inComplexTable(el) === false ) return false;
|
||||
if ( isElementEmpty(el) || onlyInlineContent(el) ) return false;
|
||||
return getTableCellAttrs(el);
|
||||
}}],
|
||||
toDOM: node => ["td", setTableCellAttrs(node), 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const tableHeaderComplex = {
|
||||
content: "text*",
|
||||
attrs: CELL_ATTRS,
|
||||
managed: MANAGED_CELL_ATTRS,
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "th", getAttrs: el => {
|
||||
if ( inComplexTable(el) === false ) return false;
|
||||
if ( !isElementEmpty(el) && !onlyInlineContent(el) ) return false;
|
||||
return getTableCellAttrs(el);
|
||||
}}],
|
||||
toDOM: node => ["th", setTableCellAttrs(node), 0]
|
||||
};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
export const tableHeaderComplexBlock = {
|
||||
content: "block*",
|
||||
attrs: CELL_ATTRS,
|
||||
managed: MANAGED_CELL_ATTRS,
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "th", getAttrs: el => {
|
||||
if ( inComplexTable(el) === false ) return false;
|
||||
if ( isElementEmpty(el) || onlyInlineContent(el) ) return false;
|
||||
return getTableCellAttrs(el);
|
||||
}}],
|
||||
toDOM: node => ["th", setTableCellAttrs(node), 0]
|
||||
};
|
||||
75
resources/app/common/prosemirror/schema/utils.mjs
Normal file
75
resources/app/common/prosemirror/schema/utils.mjs
Normal file
@@ -0,0 +1,75 @@
|
||||
import {mergeObject} from "../../utils/helpers.mjs";
|
||||
|
||||
// A list of tag names that are considered allowable inside a node that only supports inline content.
|
||||
const INLINE_TAGS = new Set(["A", "EM", "I", "STRONG", "B", "CODE", "U", "S", "DEL", "SUP", "SUB", "SPAN"]);
|
||||
|
||||
/**
|
||||
* Determine if an HTML element contains purely inline content, i.e. only text nodes and 'mark' elements.
|
||||
* @param {HTMLElement} element The element.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function onlyInlineContent(element) {
|
||||
for ( const child of element.children ) {
|
||||
if ( !INLINE_TAGS.has(child.tagName) ) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine if an HTML element is empty.
|
||||
* @param {HTMLElement} element The element.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isElementEmpty(element) {
|
||||
return !element.childNodes.length;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert an element's style attribute string into an object.
|
||||
* @param {string} str The style string.
|
||||
* @returns {object}
|
||||
*/
|
||||
export function stylesFromString(str) {
|
||||
return Object.fromEntries(str.split(/;\s*/g).map(prop => prop.split(/:\s*/)));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Merge two style attribute strings.
|
||||
* @param {string} a The first style string.
|
||||
* @param {string} b The second style string.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function mergeStyle(a, b) {
|
||||
const allStyles = mergeObject(stylesFromString(a), stylesFromString(b));
|
||||
return Object.entries(allStyles).map(([k, v]) => v ? `${k}: ${v}` : null).filterJoin("; ");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert an element's class attribute string into an array of class names.
|
||||
* @param {string} str The class string.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function classesFromString(str) {
|
||||
return str.split(/\s+/g);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Merge two class attribute strings.
|
||||
* @param {string} a The first class string.
|
||||
* @param {string} b The second class string.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function mergeClass(a, b) {
|
||||
const allClasses = classesFromString(a).concat(classesFromString(b));
|
||||
return Array.from(new Set(allClasses)).join(" ");
|
||||
}
|
||||
310
resources/app/common/prosemirror/string-serializer.mjs
Normal file
310
resources/app/common/prosemirror/string-serializer.mjs
Normal file
@@ -0,0 +1,310 @@
|
||||
import {DOMSerializer} from "prosemirror-model";
|
||||
import {getType, isEmpty} from "../utils/helpers.mjs";
|
||||
|
||||
/**
|
||||
* @callback ProseMirrorNodeOutput
|
||||
* @param {Node} node The ProseMirror node.
|
||||
* @returns {DOMOutputSpec} The specification to build a DOM node for this ProseMirror node.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback ProseMirrorMarkOutput
|
||||
* @param {Mark} mark The ProseMirror mark.
|
||||
* @param {boolean} inline Is the mark appearing in an inline context?
|
||||
* @returns {DOMOutputSpec} The specification to build a DOM node for this ProseMirror mark.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A class responsible for serializing a ProseMirror document into a string of HTML.
|
||||
*/
|
||||
export default class StringSerializer {
|
||||
/**
|
||||
* @param {Record<string, ProseMirrorNodeOutput>} nodes The node output specs.
|
||||
* @param {Record<string, ProseMirrorMarkOutput>} marks The mark output specs.
|
||||
*/
|
||||
constructor(nodes, marks) {
|
||||
this.#nodes = nodes;
|
||||
this.#marks = marks;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The node output specs.
|
||||
* @type {Record<string, ProseMirrorNodeOutput>}
|
||||
*/
|
||||
#nodes;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The mark output specs.
|
||||
* @type {Record<string, ProseMirrorMarkOutput>}
|
||||
*/
|
||||
#marks;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Build a serializer for the given schema.
|
||||
* @param {Schema} schema The ProseMirror schema.
|
||||
* @returns {StringSerializer}
|
||||
*/
|
||||
static fromSchema(schema) {
|
||||
if ( schema.cached.stringSerializer ) return schema.cached.stringSerializer;
|
||||
return schema.cached.stringSerializer =
|
||||
new StringSerializer(DOMSerializer.nodesFromSchema(schema), DOMSerializer.marksFromSchema(schema));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a StringNode from a ProseMirror DOMOutputSpec.
|
||||
* @param {DOMOutputSpec} spec The specification.
|
||||
* @param {boolean} inline Whether this is a block or inline node.
|
||||
* @returns {{outer: StringNode, [content]: StringNode}} An object describing the outer node, and a reference to the
|
||||
* child node where content should be appended, if applicable.
|
||||
* @protected
|
||||
*/
|
||||
_specToStringNode(spec, inline) {
|
||||
if ( typeof spec === "string" ) {
|
||||
// This is raw text content.
|
||||
const node = new StringNode();
|
||||
node.appendChild(spec);
|
||||
return {outer: node};
|
||||
}
|
||||
|
||||
// Our schema only uses the array type of DOMOutputSpec so we don't need to support the other types here.
|
||||
// Array specs take the form of [tagName, ...tail], where the tail elements may be an object of attributes, another
|
||||
// array representing a child spec, or the value 0 (read 'hole').
|
||||
let attrs = {};
|
||||
let [tagName, ...tail] = spec;
|
||||
if ( getType(tail[0]) === "Object" ) attrs = tail.shift();
|
||||
const outer = new StringNode(tagName, attrs, inline);
|
||||
let content;
|
||||
|
||||
for ( const innerSpec of tail ) {
|
||||
if ( innerSpec === 0 ) {
|
||||
if ( tail.length > 1 ) throw new RangeError("Content hole must be the only child of its parent node.");
|
||||
// The outer node and the node to append content to are the same node. The vast majority of our output specs
|
||||
// are like this.
|
||||
return {outer, content: outer};
|
||||
}
|
||||
|
||||
// Otherwise, recursively build any inner specifications and update our content reference to point to wherever the
|
||||
// hole is found.
|
||||
const {outer: inner, content: innerContent} = this._specToStringNode(innerSpec, true);
|
||||
outer.appendChild(inner);
|
||||
if ( innerContent ) {
|
||||
if ( content ) throw new RangeError("Multiple content holes.");
|
||||
content = innerContent;
|
||||
}
|
||||
}
|
||||
return {outer, content};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Serialize a ProseMirror fragment into an HTML string.
|
||||
* @param {Fragment} fragment The ProseMirror fragment, a collection of ProseMirror nodes.
|
||||
* @param {StringNode} [target] The target to append to. Not required for the top-level invocation.
|
||||
* @returns {StringNode} A DOM tree representation as a StringNode.
|
||||
*/
|
||||
serializeFragment(fragment, target) {
|
||||
target = target ?? new StringNode();
|
||||
const stack = [];
|
||||
let parent = target;
|
||||
fragment.forEach(node => {
|
||||
/**
|
||||
* Handling marks is a little complicated as ProseMirror stores them in a 'flat' structure, rather than a
|
||||
* nested structure that is more natural for HTML. For example, the following HTML:
|
||||
* <em>Almost before <strong>we knew it</strong>, we had left the ground.</em>
|
||||
* is represented in ProseMirror's internal structure as:
|
||||
* {marks: [ITALIC], content: "Almost before "}, {marks: [ITALIC, BOLD], content: "we knew it"},
|
||||
* {marks: [ITALIC], content: ", we had left the ground"}
|
||||
* In order to translate from the latter back into the former, we maintain a stack. When we see a new mark, we
|
||||
* push it onto the stack so that content is appended to that mark. When the mark stops appearing in subsequent
|
||||
* nodes, we pop off the stack until we find a mark that does exist, and start appending to that one again.
|
||||
*
|
||||
* The order that marks appear in the node.marks array is guaranteed to be the order that they were declared in
|
||||
* the schema.
|
||||
*/
|
||||
if ( stack.length || node.marks.length ) {
|
||||
// Walk along the stack to find a mark that is not already pending (i.e. we haven't seen it yet).
|
||||
let pos = 0;
|
||||
while ( (pos < stack.length) && (pos < node.marks.length) ) {
|
||||
const next = node.marks[pos];
|
||||
// If the mark does not span multiple nodes, we can serialize it now rather than waiting.
|
||||
if ( !next.eq(stack[pos].mark) || (next.type.spec.spanning === false) ) break;
|
||||
pos++;
|
||||
}
|
||||
|
||||
// Pop off the stack to reach the position of our mark.
|
||||
while ( pos < stack.length ) parent = stack.pop().parent;
|
||||
|
||||
// Add the marks from this point.
|
||||
for ( let i = pos; i < node.marks.length; i++ ) {
|
||||
const mark = node.marks[i];
|
||||
const {outer, content} = this._serializeMark(mark, node.isInline);
|
||||
stack.push({mark, parent});
|
||||
parent.appendChild(outer);
|
||||
parent = content ?? outer;
|
||||
}
|
||||
}
|
||||
|
||||
// Finally append the content to whichever parent node we've arrived at.
|
||||
parent.appendChild(this._toStringNode(node));
|
||||
});
|
||||
return target;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert a ProseMirror node representation to a StringNode.
|
||||
* @param {Node} node The ProseMirror node.
|
||||
* @returns {StringNode}
|
||||
* @protected
|
||||
*/
|
||||
_toStringNode(node) {
|
||||
const {outer, content} = this._specToStringNode(this.#nodes[node.type.name](node), node.type.inlineContent);
|
||||
if ( content ) {
|
||||
if ( node.isLeaf ) throw new RangeError("Content hole not allowed in a leaf node spec.");
|
||||
this.serializeFragment(node.content, content);
|
||||
}
|
||||
return outer;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert a ProseMirror mark representation to a StringNode.
|
||||
* @param {Mark} mark The ProseMirror mark.
|
||||
* @param {boolean} inline Does the mark appear in an inline context?
|
||||
* @returns {{outer: StringNode, [content]: StringNode}}
|
||||
* @protected
|
||||
*/
|
||||
_serializeMark(mark, inline) {
|
||||
return this._specToStringNode(this.#marks[mark.type.name](mark, inline), true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A class that behaves like a lightweight DOM node, allowing children to be appended. Serializes to an HTML string.
|
||||
*/
|
||||
class StringNode {
|
||||
/**
|
||||
* @param {string} [tag] The tag name. If none is provided, this node's children will not be wrapped in an
|
||||
* outer tag.
|
||||
* @param {Record<string, string>} [attrs] The tag attributes.
|
||||
* @param {boolean} [inline=false] Whether the node appears inline or as a block.
|
||||
*/
|
||||
constructor(tag, attrs={}, inline=true) {
|
||||
/**
|
||||
* The tag name.
|
||||
* @type {string}
|
||||
*/
|
||||
Object.defineProperty(this, "tag", {value: tag, writable: false});
|
||||
|
||||
/**
|
||||
* The tag attributes.
|
||||
* @type {Record<string, string>}
|
||||
*/
|
||||
Object.defineProperty(this, "attrs", {value: attrs, writable: false});
|
||||
|
||||
this.#inline = inline;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A list of HTML void elements that do not have a closing tag.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
static #VOID = new Set([
|
||||
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"
|
||||
]);
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A list of children. Either other StringNodes, or plain strings.
|
||||
* @type {Array<StringNode|string>}
|
||||
* @private
|
||||
*/
|
||||
#children = [];
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*/
|
||||
#inline;
|
||||
|
||||
/**
|
||||
* Whether the node appears inline or as a block.
|
||||
*/
|
||||
get inline() {
|
||||
if ( !this.tag || StringNode.#VOID.has(this.tag) || !this.#children.length ) return true;
|
||||
return this.#inline;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Append a child to this string node.
|
||||
* @param {StringNode|string} child The child node or string.
|
||||
* @throws If attempting to append a child to a void element.
|
||||
*/
|
||||
appendChild(child) {
|
||||
if ( StringNode.#VOID.has(this.tag) ) throw new Error("Void elements cannot contain children.");
|
||||
this.#children.push(child);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Serialize the StringNode structure into a single string.
|
||||
* @param {string|number} spaces The number of spaces to use for indentation (maximum 10). If this value is a string,
|
||||
* that string is used as indentation instead (or the first 10 characters if it is
|
||||
* longer).
|
||||
*/
|
||||
toString(spaces=0, {_depth=0, _inlineParent=false}={}) {
|
||||
let indent = "";
|
||||
const isRoot = _depth < 1;
|
||||
if ( !_inlineParent ) {
|
||||
if ( typeof spaces === "number" ) indent = " ".repeat(Math.min(10, spaces));
|
||||
else if ( typeof spaces === "string" ) indent = spaces.substring(0, 10);
|
||||
indent = indent.repeat(Math.max(0, _depth - 1));
|
||||
}
|
||||
const attrs = isEmpty(this.attrs) ? "" : " " + Object.entries(this.attrs).map(([k, v]) => `${k}="${v}"`).join(" ");
|
||||
const open = this.tag ? `${indent}<${this.tag}${attrs}>` : "";
|
||||
if ( StringNode.#VOID.has(this.tag) ) return open;
|
||||
const close = this.tag ? `${this.inline && !isRoot ? "" : indent}</${this.tag}>` : "";
|
||||
const children = this.#children.map(c => {
|
||||
let content = c.toString(spaces, {_depth: _depth + 1, _inlineParent: this.inline});
|
||||
if ( !isRoot && !this.tag ) content = StringNode.#escapeHTML(content);
|
||||
return content;
|
||||
});
|
||||
const lineBreak = (this.inline && !isRoot) || !spaces ? "" : "\n";
|
||||
return [open, ...children, close].filterJoin(lineBreak);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Escape HTML tags within string content.
|
||||
* @param {string} content The string content.
|
||||
* @returns {string}
|
||||
*/
|
||||
static #escapeHTML(content) {
|
||||
return content.replace(/[<>]/g, char => {
|
||||
switch ( char ) {
|
||||
case "<": return "<";
|
||||
case ">": return ">";
|
||||
}
|
||||
return char;
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user