(function (exports) { 'use strict'; /** * @typedef {import("../types.mjs").ColorSource} ColorSource */ /** * A representation of a color in hexadecimal format. * This class provides methods for transformations and manipulations of colors. */ let Color$1 = class Color extends Number { /** * Is this a valid color? * @type {boolean} */ get valid() { const v = this.valueOf(); return Number.isInteger(v) && v >= 0 && v <= 0xFFFFFF; } /* ------------------------------------------ */ /** * A CSS-compatible color string. * If this color is not valid, the empty string is returned. * An alias for Color#toString. * @type {string} */ get css() { return this.toString(16); } /* ------------------------------------------ */ /** * The color represented as an RGB array. * @type {[number, number, number]} */ get rgb() { return [((this >> 16) & 0xFF) / 255, ((this >> 8) & 0xFF) / 255, (this & 0xFF) / 255]; } /* ------------------------------------------ */ /** * The numeric value of the red channel between [0, 1]. * @type {number} */ get r() { return ((this >> 16) & 0xFF) / 255; } /* ------------------------------------------ */ /** * The numeric value of the green channel between [0, 1]. * @type {number} */ get g() { return ((this >> 8) & 0xFF) / 255; } /* ------------------------------------------ */ /** * The numeric value of the blue channel between [0, 1]. * @type {number} */ get b() { return (this & 0xFF) / 255; } /* ------------------------------------------ */ /** * The maximum value of all channels. * @type {number} */ get maximum() { return Math.max(...this); } /* ------------------------------------------ */ /** * The minimum value of all channels. * @type {number} */ get minimum() { return Math.min(...this); } /* ------------------------------------------ */ /** * Get the value of this color in little endian format. * @type {number} */ get littleEndian() { return ((this >> 16) & 0xFF) + (this & 0x00FF00) + ((this & 0xFF) << 16); } /* ------------------------------------------ */ /** * The color represented as an HSV array. * Conversion formula adapted from http://en.wikipedia.org/wiki/HSV_color_space. * Assumes r, g, and b are contained in the set [0, 1] and returns h, s, and v in the set [0, 1]. * @type {[number, number, number]} */ get hsv() { const [r, g, b] = this.rgb; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const d = max - min; let h; const s = max === 0 ? 0 : d / max; const v = max; // Achromatic colors if (max === min) return [0, s, v]; // Normal colors switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } h /= 6; return [h, s, v]; } /* ------------------------------------------ */ /** * The color represented as an HSL array. * Assumes r, g, and b are contained in the set [0, 1] and returns h, s, and l in the set [0, 1]. * @type {[number, number, number]} */ get hsl() { const [r, g, b] = this.rgb; // Compute luminosity, saturation and hue const l = Math.max(r, g, b); const s = l - Math.min(r, g, b); let h = 0; if ( s > 0 ) { if ( l === r ) { h = (g - b) / s; } else if ( l === g ) { h = 2 + (b - r) / s; } else { h = 4 + (r - g) / s; } } const finalHue = (60 * h < 0 ? 60 * h + 360 : 60 * h) / 360; const finalSaturation = s ? (l <= 0.5 ? s / (2 * l - s) : s / (2 - (2 * l - s))) : 0; const finalLuminance = (2 * l - s) / 2; return [finalHue, finalSaturation, finalLuminance]; } /* ------------------------------------------ */ /** * The color represented as a linear RGB array. * Assumes r, g, and b are contained in the set [0, 1] and returns linear r, g, and b in the set [0, 1]. * @link https://en.wikipedia.org/wiki/SRGB#Transformation * @type {Color} */ get linear() { const toLinear = c => (c > 0.04045) ? Math.pow((c + 0.055) / 1.055, 2.4) : (c / 12.92); return this.constructor.fromRGB([toLinear(this.r), toLinear(this.g), toLinear(this.b)]); } /* ------------------------------------------ */ /* Color Manipulation Methods */ /* ------------------------------------------ */ /** @override */ toString(radix) { if ( !this.valid ) return ""; return `#${super.toString(16).padStart(6, "0")}`; } /* ------------------------------------------ */ /** * Serialize the Color. * @returns {string} The color as a CSS string */ toJSON() { return this.css; } /* ------------------------------------------ */ /** * Returns the color as a CSS string. * @returns {string} The color as a CSS string */ toHTML() { return this.css; } /* ------------------------------------------ */ /** * Test whether this color equals some other color * @param {Color|number} other Some other color or hex number * @returns {boolean} Are the colors equal? */ equals(other) { return this.valueOf() === other.valueOf(); } /* ------------------------------------------ */ /** * Get a CSS-compatible RGBA color string. * @param {number} alpha The desired alpha in the range [0, 1] * @returns {string} A CSS-compatible RGBA string */ toRGBA(alpha) { const rgba = [(this >> 16) & 0xFF, (this >> 8) & 0xFF, this & 0xFF, alpha]; return `rgba(${rgba.join(", ")})`; } /* ------------------------------------------ */ /** * Mix this Color with some other Color using a provided interpolation weight. * @param {Color} other Some other Color to mix with * @param {number} weight The mixing weight placed on this color where weight is placed on the other color * @returns {Color} The resulting mixed Color */ mix(other, weight) { return new Color(Color.mix(this, other, weight)); } /* ------------------------------------------ */ /** * Multiply this Color by another Color or a static scalar. * @param {Color|number} other Some other Color or a static scalar. * @returns {Color} The resulting Color. */ multiply(other) { if ( other instanceof Color ) return new Color(Color.multiply(this, other)); return new Color(Color.multiplyScalar(this, other)); } /* ------------------------------------------ */ /** * Add this Color by another Color or a static scalar. * @param {Color|number} other Some other Color or a static scalar. * @returns {Color} The resulting Color. */ add(other) { if ( other instanceof Color ) return new Color(Color.add(this, other)); return new Color(Color.addScalar(this, other)); } /* ------------------------------------------ */ /** * Subtract this Color by another Color or a static scalar. * @param {Color|number} other Some other Color or a static scalar. * @returns {Color} The resulting Color. */ subtract(other) { if ( other instanceof Color ) return new Color(Color.subtract(this, other)); return new Color(Color.subtractScalar(this, other)); } /* ------------------------------------------ */ /** * Max this color by another Color or a static scalar. * @param {Color|number} other Some other Color or a static scalar. * @returns {Color} The resulting Color. */ maximize(other) { if ( other instanceof Color ) return new Color(Color.maximize(this, other)); return new Color(Color.maximizeScalar(this, other)); } /* ------------------------------------------ */ /** * Min this color by another Color or a static scalar. * @param {Color|number} other Some other Color or a static scalar. * @returns {Color} The resulting Color. */ minimize(other) { if ( other instanceof Color ) return new Color(Color.minimize(this, other)); return new Color(Color.minimizeScalar(this, other)); } /* ------------------------------------------ */ /* Iterator */ /* ------------------------------------------ */ /** * Iterating over a Color is equivalent to iterating over its [r,g,b] color channels. * @returns {Generator} */ *[Symbol.iterator]() { yield this.r; yield this.g; yield this.b; } /* ------------------------------------------------------------------------------------------- */ /* Real-time performance Methods and Properties */ /* Important Note: */ /* These methods are not a replacement, but a tool when real-time performance is needed. */ /* They do not have the flexibility of the "classic" methods and come with some limitations. */ /* Unless you have to deal with real-time performance, you should use the "classic" methods. */ /* ------------------------------------------------------------------------------------------- */ /** * Set an rgb array with the rgb values contained in this Color class. * @param {number[]} vec3 Receive the result. Must be an array with at least a length of 3. */ applyRGB(vec3) { vec3[0] = ((this >> 16) & 0xFF) / 255; vec3[1] = ((this >> 8) & 0xFF) / 255; vec3[2] = (this & 0xFF) / 255; } /* ------------------------------------------ */ /** * Apply a linear interpolation between two colors, according to the weight. * @param {number} color1 The first color to mix. * @param {number} color2 The second color to mix. * @param {number} weight Weight of the linear interpolation. * @returns {number} The resulting mixed color */ static mix(color1, color2, weight) { return (((((color1 >> 16) & 0xFF) * (1 - weight) + ((color2 >> 16) & 0xFF) * weight) << 16) & 0xFF0000) | (((((color1 >> 8) & 0xFF) * (1 - weight) + ((color2 >> 8) & 0xFF) * weight) << 8) & 0x00FF00) | (((color1 & 0xFF) * (1 - weight) + (color2 & 0xFF) * weight) & 0x0000FF); } /* ------------------------------------------ */ /** * Multiply two colors. * @param {number} color1 The first color to multiply. * @param {number} color2 The second color to multiply. * @returns {number} The result. */ static multiply(color1, color2) { return ((((color1 >> 16) & 0xFF) / 255 * ((color2 >> 16) & 0xFF) / 255) * 255 << 16) | ((((color1 >> 8) & 0xFF) / 255 * ((color2 >> 8) & 0xFF) / 255) * 255 << 8) | (((color1 & 0xFF) / 255 * ((color2 & 0xFF) / 255)) * 255); } /* ------------------------------------------ */ /** * Multiply a color by a scalar * @param {number} color The color to multiply. * @param {number} scalar A static scalar to multiply with. * @returns {number} The resulting color as a number. */ static multiplyScalar(color, scalar) { return (Math.clamp(((color >> 16) & 0xFF) / 255 * scalar, 0, 1) * 255 << 16) | (Math.clamp(((color >> 8) & 0xFF) / 255 * scalar, 0, 1) * 255 << 8) | (Math.clamp((color & 0xFF) / 255 * scalar, 0, 1) * 255); } /* ------------------------------------------ */ /** * Maximize two colors. * @param {number} color1 The first color. * @param {number} color2 The second color. * @returns {number} The result. */ static maximize(color1, color2) { return (Math.clamp(Math.max((color1 >> 16) & 0xFF, (color2 >> 16) & 0xFF), 0, 0xFF) << 16) | (Math.clamp(Math.max((color1 >> 8) & 0xFF, (color2 >> 8) & 0xFF), 0, 0xFF) << 8) | Math.clamp(Math.max(color1 & 0xFF, color2 & 0xFF), 0, 0xFF); } /* ------------------------------------------ */ /** * Maximize a color by a static scalar. * @param {number} color The color to maximize. * @param {number} scalar Scalar to maximize with (normalized). * @returns {number} The resulting color as a number. */ static maximizeScalar(color, scalar) { return (Math.clamp(Math.max((color >> 16) & 0xFF, scalar * 255), 0, 0xFF) << 16) | (Math.clamp(Math.max((color >> 8) & 0xFF, scalar * 255), 0, 0xFF) << 8) | Math.clamp(Math.max(color & 0xFF, scalar * 255), 0, 0xFF); } /* ------------------------------------------ */ /** * Add two colors. * @param {number} color1 The first color. * @param {number} color2 The second color. * @returns {number} The resulting color as a number. */ static add(color1, color2) { return (Math.clamp((((color1 >> 16) & 0xFF) + ((color2 >> 16) & 0xFF)), 0, 0xFF) << 16) | (Math.clamp((((color1 >> 8) & 0xFF) + ((color2 >> 8) & 0xFF)), 0, 0xFF) << 8) | Math.clamp(((color1 & 0xFF) + (color2 & 0xFF)), 0, 0xFF); } /* ------------------------------------------ */ /** * Add a static scalar to a color. * @param {number} color The color. * @param {number} scalar Scalar to add with (normalized). * @returns {number} The resulting color as a number. */ static addScalar(color, scalar) { return (Math.clamp((((color >> 16) & 0xFF) + scalar * 255), 0, 0xFF) << 16) | (Math.clamp((((color >> 8) & 0xFF) + scalar * 255), 0, 0xFF) << 8) | Math.clamp(((color & 0xFF) + scalar * 255), 0, 0xFF); } /* ------------------------------------------ */ /** * Subtract two colors. * @param {number} color1 The first color. * @param {number} color2 The second color. */ static subtract(color1, color2) { return (Math.clamp((((color1 >> 16) & 0xFF) - ((color2 >> 16) & 0xFF)), 0, 0xFF) << 16) | (Math.clamp((((color1 >> 8) & 0xFF) - ((color2 >> 8) & 0xFF)), 0, 0xFF) << 8) | Math.clamp(((color1 & 0xFF) - (color2 & 0xFF)), 0, 0xFF); } /* ------------------------------------------ */ /** * Subtract a color by a static scalar. * @param {number} color The color. * @param {number} scalar Scalar to subtract with (normalized). * @returns {number} The resulting color as a number. */ static subtractScalar(color, scalar) { return (Math.clamp((((color >> 16) & 0xFF) - scalar * 255), 0, 0xFF) << 16) | (Math.clamp((((color >> 8) & 0xFF) - scalar * 255), 0, 0xFF) << 8) | Math.clamp(((color & 0xFF) - scalar * 255), 0, 0xFF); } /* ------------------------------------------ */ /** * Minimize two colors. * @param {number} color1 The first color. * @param {number} color2 The second color. */ static minimize(color1, color2) { return (Math.clamp(Math.min((color1 >> 16) & 0xFF, (color2 >> 16) & 0xFF), 0, 0xFF) << 16) | (Math.clamp(Math.min((color1 >> 8) & 0xFF, (color2 >> 8) & 0xFF), 0, 0xFF) << 8) | Math.clamp(Math.min(color1 & 0xFF, color2 & 0xFF), 0, 0xFF); } /* ------------------------------------------ */ /** * Minimize a color by a static scalar. * @param {number} color The color. * @param {number} scalar Scalar to minimize with (normalized). */ static minimizeScalar(color, scalar) { return (Math.clamp(Math.min((color >> 16) & 0xFF, scalar * 255), 0, 0xFF) << 16) | (Math.clamp(Math.min((color >> 8) & 0xFF, scalar * 255), 0, 0xFF) << 8) | Math.clamp(Math.min(color & 0xFF, scalar * 255), 0, 0xFF); } /* ------------------------------------------ */ /** * Convert a color to RGB and assign values to a passed array. * @param {number} color The color to convert to RGB values. * @param {number[]} vec3 Receive the result. Must be an array with at least a length of 3. */ static applyRGB(color, vec3) { vec3[0] = ((color >> 16) & 0xFF) / 255; vec3[1] = ((color >> 8) & 0xFF) / 255; vec3[2] = (color & 0xFF) / 255; } /* ------------------------------------------ */ /* Factory Methods */ /* ------------------------------------------ */ /** * Create a Color instance from an RGB array. * @param {ColorSource} color A color input * @returns {Color} The hex color instance or NaN */ static from(color) { if ( (color === null) || (color === undefined) ) return new this(NaN); if ( typeof color === "string" ) return this.fromString(color); if ( typeof color === "number" ) return new this(color); if ( (color instanceof Array) && (color.length === 3) ) return this.fromRGB(color); if ( color instanceof Color ) return color; return new this(color); } /* ------------------------------------------ */ /** * Create a Color instance from a color string which either includes or does not include a leading #. * @param {string} color A color string * @returns {Color} The hex color instance */ static fromString(color) { return new this(parseInt(color.startsWith("#") ? color.substring(1) : color, 16)); } /* ------------------------------------------ */ /** * Create a Color instance from an RGB array. * @param {[number, number, number]} rgb An RGB tuple * @returns {Color} The hex color instance */ static fromRGB(rgb) { return new this(((rgb[0] * 255) << 16) + ((rgb[1] * 255) << 8) + (rgb[2] * 255 | 0)); } /* ------------------------------------------ */ /** * Create a Color instance from an RGB normalized values. * @param {number} r The red value * @param {number} g The green value * @param {number} b The blue value * @returns {Color} The hex color instance */ static fromRGBvalues(r, g, b) { return new this(((r * 255) << 16) + ((g * 255) << 8) + (b * 255 | 0)); } /* ------------------------------------------ */ /** * Create a Color instance from an HSV array. * Conversion formula adapted from http://en.wikipedia.org/wiki/HSV_color_space. * Assumes h, s, and v are contained in the set [0, 1]. * @param {[number, number, number]} hsv An HSV tuple * @returns {Color} The hex color instance */ static fromHSV(hsv) { const [h, s, v] = hsv; const i = Math.floor(h * 6); const f = (h * 6) - i; const p = v * (1 - s); const q = v * (1 - f * s); const t = v * (1 - (1 - f) * s); let rgb; switch (i % 6) { case 0: rgb = [v, t, p]; break; case 1: rgb = [q, v, p]; break; case 2: rgb = [p, v, t]; break; case 3: rgb = [p, q, v]; break; case 4: rgb = [t, p, v]; break; case 5: rgb = [v, p, q]; break; } return this.fromRGB(rgb); } /* ------------------------------------------ */ /** * Create a Color instance from an HSL array. * Assumes h, s, and l are contained in the set [0, 1]. * @param {[number, number, number]} hsl An HSL tuple * @returns {Color} The hex color instance */ static fromHSL(hsl) { const [h, s, l] = hsl; // Calculate intermediate values for the RGB components const chroma = (1 - Math.abs(2 * l - 1)) * s; const hue = h * 6; const x = chroma * (1 - Math.abs(hue % 2 - 1)); const m = l - chroma / 2; let r, g, b; switch (Math.floor(hue)) { case 0: [r, g, b] = [chroma, x, 0]; break; case 1: [r, g, b] = [x, chroma, 0]; break; case 2: [r, g, b] = [0, chroma, x]; break; case 3: [r, g, b] = [0, x, chroma]; break; case 4: [r, g, b] = [x, 0, chroma]; break; case 5: case 6:[r, g, b] = [chroma, 0, x]; break; default: [r, g, b] = [0, 0, 0]; break; } // Adjust for luminance r += m; g += m; b += m; return this.fromRGB([r, g, b]); } /* ------------------------------------------ */ /** * Create a Color instance (sRGB) from a linear rgb array. * Assumes r, g, and b are contained in the set [0, 1]. * @link https://en.wikipedia.org/wiki/SRGB#Transformation * @param {[number, number, number]} linear The linear rgb array * @returns {Color} The hex color instance */ static fromLinearRGB(linear) { const [r, g, b] = linear; const tosrgb = c => (c <= 0.0031308) ? (12.92 * c) : (1.055 * Math.pow(c, 1 / 2.4) - 0.055); return this.fromRGB([tosrgb(r), tosrgb(g), tosrgb(b)]); } }; /** @module constants */ /** * The shortened software name * @type {string} */ const vtt$1 = "Foundry VTT"; /** * The full software name * @type {string} */ const VTT = "Foundry Virtual Tabletop"; /** * The software website URL * @type {string} */ const WEBSITE_URL = "https://foundryvtt.com"; /** * The serverless API URL */ const WEBSITE_API_URL = "https://api.foundryvtt.com"; /** * An ASCII greeting displayed to the client * @type {string} */ const ASCII = `_______________________________________________________________ _____ ___ _ _ _ _ ____ ______ __ __ _______ _____ | ___/ _ \\| | | | \\ | | _ \\| _ \\ \\ / / \\ \\ / |_ _|_ _| | |_ | | | | | | | \\| | | | | |_) \\ V / \\ \\ / / | | | | | _|| |_| | |_| | |\\ | |_| | _ < | | \\ V / | | | | |_| \\___/ \\___/|_| \\_|____/|_| \\_\\|_| \\_/ |_| |_| ===============================================================`; /** * Define the allowed ActiveEffect application modes. * @remarks * Other arbitrary mode numbers can be used by systems and modules to identify special behaviors and are ignored * @enum {number} */ const ACTIVE_EFFECT_MODES = { /** * Used to denote that the handling of the effect is programmatically provided by a system or module. */ CUSTOM: 0, /** * Multiplies a numeric base value by the numeric effect value * @example * 2 (base value) * 3 (effect value) = 6 (derived value) */ MULTIPLY: 1, /** * Adds a numeric base value to a numeric effect value, or concatenates strings * @example * 2 (base value) + 3 (effect value) = 5 (derived value) * @example * "Hello" (base value) + " World" (effect value) = "Hello World" */ ADD: 2, /** * Keeps the lower value of the base value and the effect value * @example * 2 (base value), 0 (effect value) = 0 (derived value) * @example * 2 (base value), 3 (effect value) = 2 (derived value) */ DOWNGRADE: 3, /** * Keeps the greater value of the base value and the effect value * @example * 2 (base value), 4 (effect value) = 4 (derived value) * @example * 2 (base value), 1 (effect value) = 2 (derived value) */ UPGRADE: 4, /** * Directly replaces the base value with the effect value * @example * 2 (base value), 4 (effect value) = 4 (derived value) */ OVERRIDE: 5 }; /** * Define the string name used for the base document type when specific sub-types are not defined by the system * @type {string} */ const BASE_DOCUMENT_TYPE = "base"; /** * Define the methods by which a Card can be drawn from a Cards stack * @enum {number} */ const CARD_DRAW_MODES = { /** * Draw the first card from the stack * Synonymous with {@link CARD_DRAW_MODES.TOP} */ FIRST: 0, /** * Draw the top card from the stack * Synonymous with {@link CARD_DRAW_MODES.FIRST} */ TOP: 0, /** * Draw the last card from the stack * Synonymous with {@link CARD_DRAW_MODES.BOTTOM} */ LAST: 1, /** * Draw the bottom card from the stack * Synonymous with {@link CARD_DRAW_MODES.LAST} */ BOTTOM: 1, /** * Draw a random card from the stack */ RANDOM: 2 }; /** * An enumeration of canvas performance modes. * @enum {number} */ const CANVAS_PERFORMANCE_MODES = { LOW: 0, MED: 1, HIGH: 2, MAX: 3 }; /** * Valid Chat Message styles which affect how the message is presented in the chat log. * @enum {number} */ const CHAT_MESSAGE_STYLES = { /** * An uncategorized chat message */ OTHER: 0, /** * The message is spoken out of character (OOC). * OOC messages will be outlined by the player's color to make them more easily recognizable. */ OOC: 1, /** * The message is spoken by an associated character. */ IC: 2, /** * The message is an emote performed by the selected character. * Entering "/emote waves his hand." while controlling a character named Simon will send the message, "Simon waves his hand." */ EMOTE: 3, }; /** * Define the set of languages which have built-in support in the core software * @type {string[]} */ const CORE_SUPPORTED_LANGUAGES = ["en"]; /** * Configure the severity of compatibility warnings. * @enum {number} */ const COMPATIBILITY_MODES = { /** * Nothing will be logged */ SILENT: 0, /** * A message will be logged at the "warn" level */ WARNING: 1, /** * A message will be logged at the "error" level */ ERROR: 2, /** * An Error will be thrown */ FAILURE: 3 }; /** * The lighting illumination levels which are supported. * @enum {number} */ const LIGHTING_LEVELS = { DARKNESS: -2, HALFDARK: -1, UNLIT: 0, DIM: 1, BRIGHT: 2, BRIGHTEST: 3 }; /** * The CSS themes which are currently supported for the V11 Setup menu. * @enum {{id: string, label: string}} */ const CSS_THEMES = Object.freeze({ foundry: "THEME.foundry", fantasy: "THEME.fantasy", scifi: "THEME.scifi" }); /** * The default artwork used for Token images if none is provided * @type {string} */ const DEFAULT_TOKEN = 'icons/svg/mystery-man.svg'; /** * The primary Document types. * @type {string[]} */ const PRIMARY_DOCUMENT_TYPES = [ "Actor", "Adventure", "Cards", "ChatMessage", "Combat", "FogExploration", "Folder", "Item", "JournalEntry", "Macro", "Playlist", "RollTable", "Scene", "Setting", "User" ]; /** * The embedded Document types. * @type {Readonly} */ const EMBEDDED_DOCUMENT_TYPES = [ "ActiveEffect", "ActorDelta", "AmbientLight", "AmbientSound", "Card", "Combatant", "Drawing", "Item", "JournalEntryPage", "MeasuredTemplate", "Note", "PlaylistSound", "Region", "RegionBehavior", "TableResult", "Tile", "Token", "Wall" ]; /** * A listing of all valid Document types, both primary and embedded. * @type {Readonly} */ const ALL_DOCUMENT_TYPES = Array.from(new Set([ ...PRIMARY_DOCUMENT_TYPES, ...EMBEDDED_DOCUMENT_TYPES ])).sort(); /** * The allowed primary Document types which may exist within a World. * @type {string[]} */ const WORLD_DOCUMENT_TYPES = [ "Actor", "Cards", "ChatMessage", "Combat", "FogExploration", "Folder", "Item", "JournalEntry", "Macro", "Playlist", "RollTable", "Scene", "Setting", "User" ]; /** * The allowed primary Document types which may exist within a Compendium pack. * @type {string[]} */ const COMPENDIUM_DOCUMENT_TYPES = [ "Actor", "Adventure", "Cards", "Item", "JournalEntry", "Macro", "Playlist", "RollTable", "Scene" ]; /** * Define the allowed ownership levels for a Document. * Each level is assigned a value in ascending order. * Higher levels grant more permissions. * @enum {number} * @see https://foundryvtt.com/article/users/ */ const DOCUMENT_OWNERSHIP_LEVELS = { /** * The User inherits permissions from the parent Folder. */ INHERIT: -1, /** * Restricts the associated Document so that it may not be seen by this User. */ NONE: 0, /** * Allows the User to interact with the Document in basic ways, allowing them to see it in sidebars and see only limited aspects of its contents. The limits of this interaction are defined by the game system being used. */ LIMITED: 1, /** * Allows the User to view this Document as if they were owner, but prevents them from making any changes to it. */ OBSERVER: 2, /** * Allows the User to view and make changes to the Document as its owner. Owned documents cannot be deleted by anyone other than a gamemaster level User. */ OWNER: 3 }; Object.freeze(DOCUMENT_OWNERSHIP_LEVELS); /** * Meta ownership levels that are used in the UI but never stored. * @enum {number} */ const DOCUMENT_META_OWNERSHIP_LEVELS = { DEFAULT: -20, NOCHANGE: -10 }; Object.freeze(DOCUMENT_META_OWNERSHIP_LEVELS); /** * Define the allowed Document types which may be dynamically linked in chat * @type {string[]} */ const DOCUMENT_LINK_TYPES = ["Actor", "Cards", "Item", "Scene", "JournalEntry", "Macro", "RollTable", "PlaylistSound"]; /** * The supported dice roll visibility modes * @enum {string} * @see https://foundryvtt.com/article/dice/ */ const DICE_ROLL_MODES = { /** * This roll is visible to all players. */ PUBLIC: "publicroll", /** * Rolls of this type are only visible to the player that rolled and any Game Master users. */ PRIVATE: "gmroll", /** * A private dice roll only visible to Game Master users. The rolling player will not see the result of their own roll. */ BLIND: "blindroll", /** * A private dice roll which is only visible to the user who rolled it. */ SELF: "selfroll" }; /** * The allowed fill types which a Drawing object may display * @enum {number} * @see https://foundryvtt.com/article/drawings/ */ const DRAWING_FILL_TYPES = { /** * The drawing is not filled */ NONE: 0, /** * The drawing is filled with a solid color */ SOLID: 1, /** * The drawing is filled with a tiled image pattern */ PATTERN: 2 }; /** * Define the allowed Document types which Folders may contain * @type {string[]} */ const FOLDER_DOCUMENT_TYPES = ["Actor", "Adventure", "Item", "Scene", "JournalEntry", "Playlist", "RollTable", "Cards", "Macro", "Compendium"]; /** * The maximum allowed level of depth for Folder nesting * @type {number} */ const FOLDER_MAX_DEPTH = 4; /** * A list of allowed game URL names * @type {string[]} */ const GAME_VIEWS = ["game", "stream"]; /** * The directions of movement. * @enum {number} */ const MOVEMENT_DIRECTIONS = { UP: 0x1, DOWN: 0x2, LEFT: 0x4, RIGHT: 0x8, UP_LEFT: 0x1 | 0x4, UP_RIGHT: 0x1 | 0x8, DOWN_LEFT: 0x2 | 0x4, DOWN_RIGHT: 0x2 | 0x8 }; /** * The minimum allowed grid size which is supported by the software * @type {number} */ const GRID_MIN_SIZE = 20; /** * The allowed Grid types which are supported by the software * @enum {number} * @see https://foundryvtt.com/article/scenes/ */ const GRID_TYPES = { /** * No fixed grid is used on this Scene allowing free-form point-to-point measurement without grid lines. */ GRIDLESS: 0, /** * A square grid is used with width and height of each grid space equal to the chosen grid size. */ SQUARE: 1, /** * A row-wise hexagon grid (pointy-topped) where odd-numbered rows are offset. */ HEXODDR: 2, /** * A row-wise hexagon grid (pointy-topped) where even-numbered rows are offset. */ HEXEVENR: 3, /** * A column-wise hexagon grid (flat-topped) where odd-numbered columns are offset. */ HEXODDQ: 4, /** * A column-wise hexagon grid (flat-topped) where even-numbered columns are offset. */ HEXEVENQ: 5 }; /** * The different rules to define and measure diagonal distance/cost in a square grid. * The description of each option refers to the distance/cost of moving diagonally relative to the distance/cost of a horizontal or vertical move. * @enum {number} */ const GRID_DIAGONALS = { /** * The diagonal distance is 1. Diagonal movement costs the same as horizontal/vertical movement. */ EQUIDISTANT: 0, /** * The diagonal distance is √2. Diagonal movement costs √2 times as much as horizontal/vertical movement. */ EXACT: 1, /** * The diagonal distance is 1.5. Diagonal movement costs 1.5 times as much as horizontal/vertical movement. */ APPROXIMATE: 2, /** * The diagonal distance is 2. Diagonal movement costs 2 times as much as horizontal/vertical movement. */ RECTILINEAR: 3, /** * The diagonal distance alternates between 1 and 2 starting at 1. * The first diagonal movement costs the same as horizontal/vertical movement * The second diagonal movement costs 2 times as much as horizontal/vertical movement. * And so on... */ ALTERNATING_1: 4, /** * The diagonal distance alternates between 2 and 1 starting at 2. * The first diagonal movement costs 2 times as much as horizontal/vertical movement. * The second diagonal movement costs the same as horizontal/vertical movement. * And so on... */ ALTERNATING_2: 5, /** * The diagonal distance is ∞. Diagonal movement is not allowed/possible. */ ILLEGAL: 6, }; /** * The grid snapping modes. * @enum {number} */ const GRID_SNAPPING_MODES = { /** * Nearest center point. */ CENTER: 0x1, /** * Nearest edge midpoint. */ EDGE_MIDPOINT: 0x2, /** * Nearest top-left vertex. */ TOP_LEFT_VERTEX: 0x10, /** * Nearest top-right vertex. */ TOP_RIGHT_VERTEX: 0x20, /** * Nearest bottom-left vertex. */ BOTTOM_LEFT_VERTEX: 0x40, /** * Nearest bottom-right vertex. */ BOTTOM_RIGHT_VERTEX: 0x80, /** * Nearest vertex. * Alias for `TOP_LEFT_VERTEX | TOP_RIGHT_VERTEX | BOTTOM_LEFT_VERTEX | BOTTOM_RIGHT_VERTEX`. */ VERTEX: 0xF0, /** * Nearest top-left corner. */ TOP_LEFT_CORNER: 0x100, /** * Nearest top-right corner. */ TOP_RIGHT_CORNER: 0x200, /** * Nearest bottom-left corner. */ BOTTOM_LEFT_CORNER: 0x400, /** * Nearest bottom-right corner. */ BOTTOM_RIGHT_CORNER: 0x800, /** * Nearest corner. * Alias for `TOP_LEFT_CORNER | TOP_RIGHT_CORNER | BOTTOM_LEFT_CORNER | BOTTOM_RIGHT_CORNER`. */ CORNER: 0xF00, /** * Nearest top side midpoint. */ TOP_SIDE_MIDPOINT: 0x1000, /** * Nearest bottom side midpoint. */ BOTTOM_SIDE_MIDPOINT: 0x2000, /** * Nearest left side midpoint. */ LEFT_SIDE_MIDPOINT: 0x4000, /** * Nearest right side midpoint. */ RIGHT_SIDE_MIDPOINT: 0x8000, /** * Nearest side midpoint. * Alias for `TOP_SIDE_MIDPOINT | BOTTOM_SIDE_MIDPOINT | LEFT_SIDE_MIDPOINT | RIGHT_SIDE_MIDPOINT`. */ SIDE_MIDPOINT: 0xF000, }; /** * A list of supported setup URL names * @type {string[]} */ const SETUP_VIEWS = ["auth", "license", "setup", "players", "join", "update"]; /** * An Array of valid MacroAction scope values * @type {string[]} */ const MACRO_SCOPES = ["global", "actors", "actor"]; /** * An enumeration of valid Macro types * @enum {string} * @see https://foundryvtt.com/article/macros/ */ const MACRO_TYPES = { /** * Complex and powerful macros which leverage the FVTT API through plain JavaScript to perform functions as simple or as advanced as you can imagine. */ SCRIPT: "script", /** * Simple and easy to use, chat macros post pre-defined chat messages to the chat log when executed. All users can execute chat macros by default. */ CHAT: "chat" }; /** * The allowed channels for audio playback. * @enum {string} */ const AUDIO_CHANNELS = { music: "AUDIO.CHANNELS.MUSIC.label", environment: "AUDIO.CHANNELS.ENVIRONMENT.label", interface: "AUDIO.CHANNELS.INTERFACE.label", }; /** * The allowed playback modes for an audio Playlist * @enum {number} * @see https://foundryvtt.com/article/playlists/ */ const PLAYLIST_MODES = { /** * The playlist does not play on its own, only individual Sound tracks played as a soundboard. */ DISABLED: -1, /** * The playlist plays sounds one at a time in sequence. */ SEQUENTIAL: 0, /** * The playlist plays sounds one at a time in randomized order. */ SHUFFLE: 1, /** * The playlist plays all contained sounds at the same time. */ SIMULTANEOUS: 2 }; /** * The available sort modes for an audio Playlist. * @enum {string} * @see https://foundryvtt.com/article/playlists/ */ const PLAYLIST_SORT_MODES = { /** * Sort sounds alphabetically. * @defaultValue */ ALPHABETICAL: "a", /** * Sort sounds by manual drag-and-drop. */ MANUAL: "m" }; /** * The available modes for searching within a DirectoryCollection * @type {{FULL: string, NAME: string}} */ const DIRECTORY_SEARCH_MODES = { FULL: "full", NAME: "name" }; /** * The allowed package types * @type {string[]} */ const PACKAGE_TYPES = ["world", "system", "module"]; /** * Encode the reasons why a package may be available or unavailable for use * @enum {number} */ const PACKAGE_AVAILABILITY_CODES = { /** * Package availability could not be determined */ UNKNOWN: 0, /** * The Package is verified to be compatible with the current core software build */ VERIFIED: 1, /** * Package is available for use, but not verified for the current core software build */ UNVERIFIED_BUILD: 2, /** * One or more installed system is incompatible with the Package. */ UNVERIFIED_SYSTEM: 3, /** * Package is available for use, but not verified for the current core software generation */ UNVERIFIED_GENERATION: 4, /** * The System that the Package relies on is not available */ MISSING_SYSTEM: 5, /** * A dependency of the Package is not available */ MISSING_DEPENDENCY: 6, /** * The Package is compatible with an older version of Foundry than the currently installed version */ REQUIRES_CORE_DOWNGRADE: 7, /** * The Package is compatible with a newer version of Foundry than the currently installed version, and that version is Stable */ REQUIRES_CORE_UPGRADE_STABLE: 8, /** * The Package is compatible with a newer version of Foundry than the currently installed version, and that version is not yet Stable */ REQUIRES_CORE_UPGRADE_UNSTABLE: 9, /** * A required dependency is not compatible with the current version of Foundry */ REQUIRES_DEPENDENCY_UPDATE: 10 }; /** * A safe password string which can be displayed * @type {string} */ const PASSWORD_SAFE_STRING = "•".repeat(16); /** * The allowed software update channels * @enum {string} */ const SOFTWARE_UPDATE_CHANNELS = { /** * The Stable release channel */ stable: "SETUP.UpdateStable", /** * The User Testing release channel */ testing: "SETUP.UpdateTesting", /** * The Development release channel */ development: "SETUP.UpdateDevelopment", /** * The Prototype release channel */ prototype: "SETUP.UpdatePrototype" }; /** * The default sorting density for manually ordering child objects within a parent * @type {number} */ const SORT_INTEGER_DENSITY = 100000; /** * The allowed types of a TableResult document * @enum {string} * @see https://foundryvtt.com/article/roll-tables/ */ const TABLE_RESULT_TYPES = { /** * Plain text or HTML scripted entries which will be output to Chat. */ TEXT: "text", /** * An in-World Document reference which will be linked to in the chat message. */ DOCUMENT: "document", /** * A Compendium Pack reference which will be linked to in the chat message. */ COMPENDIUM: "pack" }; /** * The allowed formats of a Journal Entry Page. * @enum {number} * @see https://foundryvtt.com/article/journal/ */ const JOURNAL_ENTRY_PAGE_FORMATS = { /** * The page is formatted as HTML. */ HTML: 1, /** * The page is formatted as Markdown. */ MARKDOWN: 2, }; /** * Define the valid anchor locations for a Tooltip displayed on a Placeable Object * @enum {number} * @see TooltipManager */ const TEXT_ANCHOR_POINTS = { /** * Anchor the tooltip to the center of the element. */ CENTER: 0, /** * Anchor the tooltip to the bottom of the element. */ BOTTOM: 1, /** * Anchor the tooltip to the top of the element. */ TOP: 2, /** * Anchor the tooltip to the left of the element. */ LEFT: 3, /** * Anchor the tooltip to the right of the element. */ RIGHT: 4 }; /** * Define the valid occlusion modes which a tile can use * @enum {number} * @see https://foundryvtt.com/article/tiles/ */ const OCCLUSION_MODES = { /** * Turns off occlusion, making the tile never fade while tokens are under it. */ NONE: 0, /** * Causes the whole tile to fade when an actor token moves under it. * @defaultValue */ FADE: 1, // ROOF: 2, This mode is no longer supported so we don't use 2 for any other mode /** * Causes the tile to reveal the background in the vicinity of an actor token under it. The radius is determined by the token's size. */ RADIAL: 3, /** * Causes the tile to be partially revealed based on the vision of the actor, which does not need to be under the tile to see what's beneath it. * * @remarks * This is useful for rooves on buildings where players could see through a window or door, viewing only a portion of what is obscured by the roof itself. */ VISION: 4 }; /** * Alias for old tile occlusion modes definition */ const TILE_OCCLUSION_MODES = OCCLUSION_MODES; /** * The occlusion modes that define the set of tokens that trigger occlusion. * @enum {number} */ const TOKEN_OCCLUSION_MODES = { /** * Owned tokens that aren't hidden. */ OWNED: 0x1, /** * Controlled tokens. */ CONTROLLED: 0x2, /** * Hovered tokens that are visible. */ HOVERED: 0x4, /** * Highlighted tokens that are visible. */ HIGHLIGHTED: 0x8, /** * All visible tokens. */ VISIBLE: 0x10 }; /** * Describe the various thresholds of token control upon which to show certain pieces of information * @enum {number} * @see https://foundryvtt.com/article/tokens/ */ const TOKEN_DISPLAY_MODES = { /** * No information is displayed. */ NONE: 0, /** * Displayed when the token is controlled. */ CONTROL: 10, /** * Displayed when hovered by a GM or a user who owns the actor. */ OWNER_HOVER: 20, /** * Displayed when hovered by any user. */ HOVER: 30, /** * Always displayed for a GM or for a user who owns the actor. */ OWNER: 40, /** * Always displayed for everyone. */ ALWAYS: 50 }; /** * The allowed Token disposition types * @enum {number} * @see https://foundryvtt.com/article/tokens/ */ const TOKEN_DISPOSITIONS = { /** * Displayed with a purple borders for owners and with no borders for others (and no pointer change). */ SECRET: -2, /** * Displayed as an enemy with a red border. */ HOSTILE: -1, /** * Displayed as neutral with a yellow border. */ NEUTRAL: 0, /** * Displayed as an ally with a cyan border. */ FRIENDLY: 1 }; /** * The possible shapes of Tokens in hexagonal grids. * @enum {number} */ const TOKEN_HEXAGONAL_SHAPES = { /** * Ellipse (Variant 1) */ ELLIPSE_1: 0, /** * Ellipse (Variant 2) */ ELLIPSE_2: 1, /** * Trapezoid (Variant 1) */ TRAPEZOID_1: 2, /** * Trapezoid (Variant 2) */ TRAPEZOID_2: 3, /** * Rectangle (Variant 1) */ RECTANGLE_1: 4, /** * Rectangle (Variant 2) */ RECTANGLE_2: 5, }; /** * Define the allowed User permission levels. * Each level is assigned a value in ascending order. Higher levels grant more permissions. * @enum {number} * @see https://foundryvtt.com/article/users/ */ const USER_ROLES = { /** * The User is blocked from taking actions in Foundry Virtual Tabletop. * You can use this role to temporarily or permanently ban a user from joining the game. */ NONE: 0, /** * The User is able to join the game with permissions available to a standard player. * They cannot take some more advanced actions which require Trusted permissions, but they have the basic functionalities needed to operate in the virtual tabletop. */ PLAYER: 1, /** * Similar to the Player role, except a Trusted User has the ability to perform some more advanced actions like create drawings, measured templates, or even to (optionally) upload media files to the server. */ TRUSTED: 2, /** * A special User who has many of the same in-game controls as a Game Master User, but does not have the ability to perform administrative actions like changing User roles or modifying World-level settings. */ ASSISTANT: 3, /** * A special User who has administrative control over this specific World. * Game Masters behave quite differently than Players in that they have the ability to see all Documents and Objects within the world as well as the capability to configure World settings. */ GAMEMASTER: 4 }; /** * Invert the User Role mapping to recover role names from a role integer * @enum {string} * @see USER_ROLES */ const USER_ROLE_NAMES = Object.entries(USER_ROLES).reduce((obj, r) => { obj[r[1]] = r[0]; return obj; }, {}); /** * An enumeration of the allowed types for a MeasuredTemplate embedded document * @enum {string} * @see https://foundryvtt.com/article/measurement/ */ const MEASURED_TEMPLATE_TYPES = { /** * Circular templates create a radius around the starting point. */ CIRCLE: "circle", /** * Cones create an effect in the shape of a triangle or pizza slice from the starting point. */ CONE: "cone", /** * A rectangle uses the origin point as one of the corners, treating the origin as being inside of the rectangle's area. */ RECTANGLE: "rect", /** * A ray creates a single line that is one square in width and as long as you want it to be. */ RAY: "ray" }; /** * @typedef {Object} UserPermission * @property {string} label * @property {string} hint * @property {boolean} disableGM * @property {number} defaultRole */ /** * Define the recognized User capabilities which individual Users or role levels may be permitted to perform * @type {Record} */ const USER_PERMISSIONS = { ACTOR_CREATE: { label: "PERMISSION.ActorCreate", hint: "PERMISSION.ActorCreateHint", disableGM: false, defaultRole: USER_ROLES.ASSISTANT }, BROADCAST_AUDIO: { label: "PERMISSION.BroadcastAudio", hint: "PERMISSION.BroadcastAudioHint", disableGM: true, defaultRole: USER_ROLES.TRUSTED }, BROADCAST_VIDEO: { label: "PERMISSION.BroadcastVideo", hint: "PERMISSION.BroadcastVideoHint", disableGM: true, defaultRole: USER_ROLES.TRUSTED }, CARDS_CREATE: { label: "PERMISSION.CardsCreate", hint: "PERMISSION.CardsCreateHint", disableGM: false, defaultRole: USER_ROLES.ASSISTANT }, DRAWING_CREATE: { label: "PERMISSION.DrawingCreate", hint: "PERMISSION.DrawingCreateHint", disableGM: false, defaultRole: USER_ROLES.TRUSTED }, ITEM_CREATE: { label: "PERMISSION.ItemCreate", hint: "PERMISSION.ItemCreateHint", disableGM: false, defaultRole: USER_ROLES.ASSISTANT }, FILES_BROWSE: { label: "PERMISSION.FilesBrowse", hint: "PERMISSION.FilesBrowseHint", disableGM: false, defaultRole: USER_ROLES.TRUSTED }, FILES_UPLOAD: { label: "PERMISSION.FilesUpload", hint: "PERMISSION.FilesUploadHint", disableGM: false, defaultRole: USER_ROLES.ASSISTANT }, JOURNAL_CREATE: { label: "PERMISSION.JournalCreate", hint: "PERMISSION.JournalCreateHint", disableGM: false, defaultRole: USER_ROLES.TRUSTED }, MACRO_SCRIPT: { label: "PERMISSION.MacroScript", hint: "PERMISSION.MacroScriptHint", disableGM: false, defaultRole: USER_ROLES.PLAYER }, MANUAL_ROLLS: { label: "PERMISSION.ManualRolls", hint: "PERMISSION.ManualRollsHint", disableGM: true, defaultRole: USER_ROLES.TRUSTED }, MESSAGE_WHISPER: { label: "PERMISSION.MessageWhisper", hint: "PERMISSION.MessageWhisperHint", disableGM: false, defaultRole: USER_ROLES.PLAYER }, NOTE_CREATE: { label: "PERMISSION.NoteCreate", hint: "PERMISSION.NoteCreateHint", disableGM: false, defaultRole: USER_ROLES.TRUSTED }, PING_CANVAS: { label: "PERMISSION.PingCanvas", hint: "PERMISSION.PingCanvasHint", disableGM: true, defaultRole: USER_ROLES.PLAYER }, PLAYLIST_CREATE: { label: "PERMISSION.PlaylistCreate", hint: "PERMISSION.PlaylistCreateHint", disableGM: false, defaultRole: USER_ROLES.ASSISTANT }, SETTINGS_MODIFY: { label: "PERMISSION.SettingsModify", hint: "PERMISSION.SettingsModifyHint", disableGM: false, defaultRole: USER_ROLES.ASSISTANT }, SHOW_CURSOR: { label: "PERMISSION.ShowCursor", hint: "PERMISSION.ShowCursorHint", disableGM: true, defaultRole: USER_ROLES.PLAYER }, SHOW_RULER: { label: "PERMISSION.ShowRuler", hint: "PERMISSION.ShowRulerHint", disableGM: true, defaultRole: USER_ROLES.PLAYER }, TEMPLATE_CREATE: { label: "PERMISSION.TemplateCreate", hint: "PERMISSION.TemplateCreateHint", disableGM: false, defaultRole: USER_ROLES.PLAYER }, TOKEN_CREATE: { label: "PERMISSION.TokenCreate", hint: "PERMISSION.TokenCreateHint", disableGM: false, defaultRole: USER_ROLES.ASSISTANT }, TOKEN_DELETE: { label: "PERMISSION.TokenDelete", hint: "PERMISSION.TokenDeleteHint", disableGM: false, defaultRole: USER_ROLES.ASSISTANT }, TOKEN_CONFIGURE: { label: "PERMISSION.TokenConfigure", hint: "PERMISSION.TokenConfigureHint", disableGM: false, defaultRole: USER_ROLES.TRUSTED }, WALL_DOORS: { label: "PERMISSION.WallDoors", hint: "PERMISSION.WallDoorsHint", disableGM: false, defaultRole: USER_ROLES.PLAYER } }; /** * The allowed directions of effect that a Wall can have * @enum {number} * @see https://foundryvtt.com/article/walls/ */ const WALL_DIRECTIONS = { /** * The wall collides from both directions. */ BOTH: 0, /** * The wall collides only when a ray strikes its left side. */ LEFT: 1, /** * The wall collides only when a ray strikes its right side. */ RIGHT: 2 }; /** * The allowed door types which a Wall may contain * @enum {number} * @see https://foundryvtt.com/article/walls/ */ const WALL_DOOR_TYPES = { /** * The wall does not contain a door. */ NONE: 0, /** * The wall contains a regular door. */ DOOR: 1, /** * The wall contains a secret door. */ SECRET: 2 }; /** * The allowed door states which may describe a Wall that contains a door * @enum {number} * @see https://foundryvtt.com/article/walls/ */ const WALL_DOOR_STATES = { /** * The door is closed. */ CLOSED: 0, /** * The door is open. */ OPEN: 1, /** * The door is closed and locked. */ LOCKED: 2 }; /** * The possible ways to interact with a door * @enum {string[]} */ const WALL_DOOR_INTERACTIONS = ["open", "close", "lock", "unlock", "test"]; /** * The wall properties which restrict the way interaction occurs with a specific wall * @type {string[]} */ const WALL_RESTRICTION_TYPES = ["light", "sight", "sound", "move"]; /** * The types of sensory collision which a Wall may impose * @enum {number} * @see https://foundryvtt.com/article/walls/ */ const WALL_SENSE_TYPES = { /** * Senses do not collide with this wall. */ NONE: 0, /** * Senses collide with this wall. */ LIMITED: 10, /** * Senses collide with the second intersection, bypassing the first. */ NORMAL: 20, /** * Senses bypass the wall within a certain proximity threshold. */ PROXIMITY: 30, /** * Senses bypass the wall outside a certain proximity threshold. */ DISTANCE: 40 }; /** * The types of movement collision which a Wall may impose * @enum {number} * @see https://foundryvtt.com/article/walls/ */ const WALL_MOVEMENT_TYPES = { /** * Movement does not collide with this wall. */ NONE: WALL_SENSE_TYPES.NONE, /** * Movement collides with this wall. */ NORMAL: WALL_SENSE_TYPES.NORMAL }; /** * The possible precedence values a Keybinding might run in * @enum {number} * @see https://foundryvtt.com/article/keybinds/ */ const KEYBINDING_PRECEDENCE = { /** * Runs in the first group along with other PRIORITY keybindings. */ PRIORITY: 0, /** * Runs after the PRIORITY group along with other NORMAL keybindings. */ NORMAL: 1, /** * Runs in the last group along with other DEFERRED keybindings. */ DEFERRED: 2 }; /** * The allowed set of HTML template extensions * @type {string[]} */ const HTML_FILE_EXTENSIONS = ["html", "handlebars", "hbs"]; /** * The supported file extensions for image-type files, and their corresponding mime types. * @type {Record} */ const IMAGE_FILE_EXTENSIONS = { apng: "image/apng", avif: "image/avif", bmp: "image/bmp", gif: "image/gif", jpeg: "image/jpeg", jpg: "image/jpeg", png: "image/png", svg: "image/svg+xml", tiff: "image/tiff", webp: "image/webp" }; /** * The supported file extensions for video-type files, and their corresponding mime types. * @type {Record} */ const VIDEO_FILE_EXTENSIONS = { m4v: "video/mp4", mp4: "video/mp4", ogv: "video/ogg", webm: "video/webm" }; /** * The supported file extensions for audio-type files, and their corresponding mime types. * @type {Record} */ const AUDIO_FILE_EXTENSIONS = { aac: "audio/aac", flac: "audio/flac", m4a: "audio/mp4", mid: "audio/midi", mp3: "audio/mpeg", ogg: "audio/ogg", opus: "audio/opus", wav: "audio/wav", webm: "audio/webm" }; /** * The supported file extensions for text files, and their corresponding mime types. * @type {Record} */ const TEXT_FILE_EXTENSIONS = { csv: "text/csv", json: "application/json", md: "text/markdown", pdf: "application/pdf", tsv: "text/tab-separated-values", txt: "text/plain", xml: "application/xml", yml: "application/yaml", yaml: "application/yaml" }; /** * Supported file extensions for font files, and their corresponding mime types. * @type {Record} */ const FONT_FILE_EXTENSIONS = { ttf: "font/ttf", otf: "font/otf", woff: "font/woff", woff2: "font/woff2" }; /** * Supported file extensions for 3D files, and their corresponding mime types. * @type {Record} */ const GRAPHICS_FILE_EXTENSIONS = { fbx: "application/octet-stream", glb: "model/gltf-binary", gltf: "model/gltf+json", mtl: "model/mtl", obj: "model/obj", stl: "model/stl", usdz: "model/vnd.usdz+zip" }; /** * A consolidated mapping of all extensions permitted for upload. * @type {Record} */ const UPLOADABLE_FILE_EXTENSIONS = { ...IMAGE_FILE_EXTENSIONS, ...VIDEO_FILE_EXTENSIONS, ...AUDIO_FILE_EXTENSIONS, ...TEXT_FILE_EXTENSIONS, ...FONT_FILE_EXTENSIONS, ...GRAPHICS_FILE_EXTENSIONS }; /** * A list of MIME types which are treated as uploaded "media", which are allowed to overwrite existing files. * Any non-media MIME type is not allowed to replace an existing file. * @type {string[]} */ const MEDIA_MIME_TYPES = Object.values(UPLOADABLE_FILE_EXTENSIONS); /** * An enumeration of file type categories which can be selected * @enum {Record} */ const FILE_CATEGORIES = { HTML: HTML_FILE_EXTENSIONS, IMAGE: IMAGE_FILE_EXTENSIONS, VIDEO: VIDEO_FILE_EXTENSIONS, AUDIO: AUDIO_FILE_EXTENSIONS, TEXT: TEXT_FILE_EXTENSIONS, FONT: FONT_FILE_EXTENSIONS, GRAPHICS: GRAPHICS_FILE_EXTENSIONS, MEDIA: MEDIA_MIME_TYPES, }; /** * A font weight to name mapping. * @enum {number} */ const FONT_WEIGHTS = { Thin: 100, ExtraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, ExtraBold: 800, Black: 900 }; /** * Stores shared commonly used timeouts, measured in MS * @enum {number} */ const TIMEOUTS = { /** * The default timeout for interacting with the foundryvtt.com API. */ FOUNDRY_WEBSITE: 10000, /** * The specific timeout for loading the list of packages from the foundryvtt.com API. */ PACKAGE_REPOSITORY: 5000, /** * The specific timeout for the IP address lookup service. */ IP_DISCOVERY: 5000 }; /** * A subset of Compendium types which require a specific system to be designated * @type {string[]} */ const SYSTEM_SPECIFIC_COMPENDIUM_TYPES = ["Actor", "Item"]; /** * The configured showdown bi-directional HTML <-> Markdown converter options. * @type {Record} */ const SHOWDOWN_OPTIONS = { disableForced4SpacesIndentedSublists: true, noHeaderId: true, parseImgDimensions: true, strikethrough: true, tables: true, tablesHeaderId: true }; /** * The list of allowed attributes in HTML elements. * @type {Record} */ const ALLOWED_HTML_ATTRIBUTES = Object.freeze({ "*": Object.freeze([ "class", "data-*", "id", "title", "style", "draggable", "aria-*", "tabindex", "dir", "hidden", "inert", "role", "is", "lang", "popover" ]), a: Object.freeze(["href", "name", "target", "rel"]), area: Object.freeze(["alt", "coords", "href", "rel", "shape", "target"]), audio: Object.freeze(["controls", "loop", "muted", "src", "autoplay"]), blockquote: Object.freeze(["cite"]), button: Object.freeze(["disabled", "name", "type", "value"]), col: Object.freeze(["span"]), colgroup: Object.freeze(["span"]), details: Object.freeze(["open"]), fieldset: Object.freeze(["disabled"]), form: Object.freeze(["name"]), iframe: Object.freeze(["src", "srcdoc", "name", "height", "width", "loading", "sandbox"]), img: Object.freeze(["height", "src", "width", "usemap", "sizes", "srcset", "alt"]), input: Object.freeze([ "checked", "disabled", "name", "value", "placeholder", "type", "alt", "height", "list", "max", "min", "placeholder", "readonly", "size", "src", "step", "width" ]), label: Object.freeze(["for"]), li: Object.freeze(["value"]), map: Object.freeze(["name"]), meter: Object.freeze(["value", "min", "max", "low", "high", "optimum"]), ol: Object.freeze(["reversed", "start", "type"]), optgroup: Object.freeze(["disabled", "label"]), option: Object.freeze(["disabled", "selected", "label", "value"]), progress: Object.freeze(["max", "value"]), select: Object.freeze(["name", "disabled", "multiple", "size"]), source: Object.freeze(["media", "sizes", "src", "srcset", "type"]), table: Object.freeze(["border"]), td: Object.freeze(["colspan", "headers", "rowspan"]), textarea: Object.freeze(["rows", "cols", "disabled", "name", "readonly", "wrap"]), time: Object.freeze(["datetime"]), th: Object.freeze(["abbr", "colspan", "headers", "rowspan", "scope", "sorted"]), track: Object.freeze(["default", "kind", "label", "src", "srclang"]), video: Object.freeze(["controls", "height", "width", "loop", "muted", "poster", "src", "autoplay"]) }); /** * The list of trusted iframe domains. * @type {string[]} */ const TRUSTED_IFRAME_DOMAINS = Object.freeze(["google.com", "youtube.com"]); /** * Available themes for the world join page. * @enum {string} */ const WORLD_JOIN_THEMES = { default: "WORLD.JoinThemeDefault", minimal: "WORLD.JoinThemeMinimal" }; /** * Setup page package progress protocol. * @type {{ACTIONS: Record, STEPS: Record}} */ const SETUP_PACKAGE_PROGRESS = { ACTIONS: { CREATE_BACKUP: "createBackup", RESTORE_BACKUP: "restoreBackup", DELETE_BACKUP: "deleteBackup", CREATE_SNAPSHOT: "createSnapshot", RESTORE_SNAPSHOT: "restoreSnapshot", DELETE_SNAPSHOT: "deleteSnapshot", INSTALL_PKG: "installPackage", LAUNCH_WORLD: "launchWorld", UPDATE_CORE: "updateCore", UPDATE_DOWNLOAD: "updateDownload" }, STEPS: { ARCHIVE: "archive", CHECK_DISK_SPACE: "checkDiskSpace", CONNECT_WORLD: "connectWorld", MIGRATE_WORLD: "migrateWorld", CONNECT_PKG: "connectPackage", MIGRATE_PKG: "migratePackage", MIGRATE_CORE: "migrateCore", MIGRATE_SYSTEM: "migrateSystem", DOWNLOAD: "download", EXTRACT: "extract", INSTALL: "install", CLEANUP: "cleanup", COMPLETE: "complete", DELETE: "delete", ERROR: "error", VEND: "vend", SNAPSHOT_MODULES: "snapshotModules", SNAPSHOT_SYSTEMS: "snapshotSystems", SNAPSHOT_WORLDS: "snapshotWorlds" } }; /** * The combat announcements. * @type {string[]} */ const COMBAT_ANNOUNCEMENTS = ["startEncounter", "nextUp", "yourTurn"]; /** * The fit modes of {@link foundry.data.TextureData#fit}. * @type {string[]} */ const TEXTURE_DATA_FIT_MODES = ["fill", "contain", "cover", "width", "height"]; /** * The maximum depth to recurse to when embedding enriched text. * @type {number} */ const TEXT_ENRICH_EMBED_MAX_DEPTH = 5; /** * The Region events that are supported by core. * @enum {string} */ const REGION_EVENTS = { /** * Triggered when the shapes or bottom/top elevation of the Region are changed. */ REGION_BOUNDARY: "regionBoundary", /** * Triggered when the behavior is enabled/disabled or the Scene its Region is in is viewed/unviewed. */ BEHAVIOR_STATUS: "behaviorStatus", /** * Triggered when a Token enters a Region. */ TOKEN_ENTER: "tokenEnter", /** * Triggered when a Token exists a Region. */ TOKEN_EXIT: "tokenExit", /** * Triggered when a Token is about to move into, out of, through, or within a Region. */ TOKEN_PRE_MOVE: "tokenPreMove", /** * Triggered when a Token moves into, out of, through, or within a Region. */ TOKEN_MOVE: "tokenMove", /** * Triggered when a Token moves into a Region. */ TOKEN_MOVE_IN: "tokenMoveIn", /** * Triggered when a Token moves out of a Region. */ TOKEN_MOVE_OUT: "tokenMoveOut", /** * Triggered when a Token starts its Combat turn in a Region. */ TOKEN_TURN_START: "tokenTurnStart", /** * Triggered when a Token ends its Combat turn in a Region. */ TOKEN_TURN_END: "tokenTurnEnd", /** * Triggered when a Token starts the Combat round in a Region. */ TOKEN_ROUND_START: "tokenRoundStart", /** * Triggered when a Token ends the Combat round in a Region. */ TOKEN_ROUND_END: "tokenRoundEnd" }; /** * The possible visibility state of Region. * @enum {string} */ const REGION_VISIBILITY = { /** * Only visible on the RegionLayer. */ LAYER: 0, /** * Only visible to Gamemasters. */ GAMEMASTER: 1, /** * Visible to anyone. */ ALWAYS: 2 }; /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ const CHAT_MESSAGE_TYPES = new Proxy(CHAT_MESSAGE_STYLES, { get(target, prop, receiver) { const msg = "CONST.CHAT_MESSAGE_TYPES is deprecated in favor of CONST.CHAT_MESSAGE_STYLES because the " + "ChatMessage#type field has been renamed to ChatMessage#style"; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return Reflect.get(...arguments); } }); // Deprecated chat message styles Object.defineProperties(CHAT_MESSAGE_STYLES, { /** * @deprecated since v12 * @ignore */ ROLL: { get() { foundry.utils.logCompatibilityWarning("CONST.CHAT_MESSAGE_STYLES.ROLL is deprecated in favor of defining " + "rolls directly in ChatMessage#rolls", {since: 12, until: 14, once: true}); return 0; } }, /** * @deprecated since v12 * @ignore */ WHISPER: { get() { foundry.utils.logCompatibilityWarning("CONST.CHAT_MESSAGE_STYLES.WHISPER is deprecated in favor of defining " + "whisper recipients directly in ChatMessage#whisper", {since: 12, until: 14, once: true}); return 0; } } }); /** * @deprecated since v12 * @ignore */ const _DOCUMENT_TYPES = Object.freeze(WORLD_DOCUMENT_TYPES.filter(t => { const excluded = ["FogExploration", "Setting"]; return !excluded.includes(t); })); /** * @deprecated since v12 * @ignore */ const DOCUMENT_TYPES = new Proxy(_DOCUMENT_TYPES, { get(target, prop, receiver) { const msg = "CONST.DOCUMENT_TYPES is deprecated in favor of either CONST.WORLD_DOCUMENT_TYPES or " + "CONST.COMPENDIUM_DOCUMENT_TYPES."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return Reflect.get(...arguments); } }); var CONST$1 = /*#__PURE__*/Object.freeze({ __proto__: null, ACTIVE_EFFECT_MODES: ACTIVE_EFFECT_MODES, ALLOWED_HTML_ATTRIBUTES: ALLOWED_HTML_ATTRIBUTES, ALL_DOCUMENT_TYPES: ALL_DOCUMENT_TYPES, ASCII: ASCII, AUDIO_CHANNELS: AUDIO_CHANNELS, AUDIO_FILE_EXTENSIONS: AUDIO_FILE_EXTENSIONS, BASE_DOCUMENT_TYPE: BASE_DOCUMENT_TYPE, CANVAS_PERFORMANCE_MODES: CANVAS_PERFORMANCE_MODES, CARD_DRAW_MODES: CARD_DRAW_MODES, CHAT_MESSAGE_STYLES: CHAT_MESSAGE_STYLES, CHAT_MESSAGE_TYPES: CHAT_MESSAGE_TYPES, COMBAT_ANNOUNCEMENTS: COMBAT_ANNOUNCEMENTS, COMPATIBILITY_MODES: COMPATIBILITY_MODES, COMPENDIUM_DOCUMENT_TYPES: COMPENDIUM_DOCUMENT_TYPES, CORE_SUPPORTED_LANGUAGES: CORE_SUPPORTED_LANGUAGES, CSS_THEMES: CSS_THEMES, DEFAULT_TOKEN: DEFAULT_TOKEN, DICE_ROLL_MODES: DICE_ROLL_MODES, DIRECTORY_SEARCH_MODES: DIRECTORY_SEARCH_MODES, DOCUMENT_LINK_TYPES: DOCUMENT_LINK_TYPES, DOCUMENT_META_OWNERSHIP_LEVELS: DOCUMENT_META_OWNERSHIP_LEVELS, DOCUMENT_OWNERSHIP_LEVELS: DOCUMENT_OWNERSHIP_LEVELS, DOCUMENT_TYPES: DOCUMENT_TYPES, DRAWING_FILL_TYPES: DRAWING_FILL_TYPES, EMBEDDED_DOCUMENT_TYPES: EMBEDDED_DOCUMENT_TYPES, FILE_CATEGORIES: FILE_CATEGORIES, FOLDER_DOCUMENT_TYPES: FOLDER_DOCUMENT_TYPES, FOLDER_MAX_DEPTH: FOLDER_MAX_DEPTH, FONT_FILE_EXTENSIONS: FONT_FILE_EXTENSIONS, FONT_WEIGHTS: FONT_WEIGHTS, GAME_VIEWS: GAME_VIEWS, GRAPHICS_FILE_EXTENSIONS: GRAPHICS_FILE_EXTENSIONS, GRID_DIAGONALS: GRID_DIAGONALS, GRID_MIN_SIZE: GRID_MIN_SIZE, GRID_SNAPPING_MODES: GRID_SNAPPING_MODES, GRID_TYPES: GRID_TYPES, HTML_FILE_EXTENSIONS: HTML_FILE_EXTENSIONS, IMAGE_FILE_EXTENSIONS: IMAGE_FILE_EXTENSIONS, JOURNAL_ENTRY_PAGE_FORMATS: JOURNAL_ENTRY_PAGE_FORMATS, KEYBINDING_PRECEDENCE: KEYBINDING_PRECEDENCE, LIGHTING_LEVELS: LIGHTING_LEVELS, MACRO_SCOPES: MACRO_SCOPES, MACRO_TYPES: MACRO_TYPES, MEASURED_TEMPLATE_TYPES: MEASURED_TEMPLATE_TYPES, MEDIA_MIME_TYPES: MEDIA_MIME_TYPES, MOVEMENT_DIRECTIONS: MOVEMENT_DIRECTIONS, OCCLUSION_MODES: OCCLUSION_MODES, PACKAGE_AVAILABILITY_CODES: PACKAGE_AVAILABILITY_CODES, PACKAGE_TYPES: PACKAGE_TYPES, PASSWORD_SAFE_STRING: PASSWORD_SAFE_STRING, PLAYLIST_MODES: PLAYLIST_MODES, PLAYLIST_SORT_MODES: PLAYLIST_SORT_MODES, PRIMARY_DOCUMENT_TYPES: PRIMARY_DOCUMENT_TYPES, REGION_EVENTS: REGION_EVENTS, REGION_VISIBILITY: REGION_VISIBILITY, SETUP_PACKAGE_PROGRESS: SETUP_PACKAGE_PROGRESS, SETUP_VIEWS: SETUP_VIEWS, SHOWDOWN_OPTIONS: SHOWDOWN_OPTIONS, SOFTWARE_UPDATE_CHANNELS: SOFTWARE_UPDATE_CHANNELS, SORT_INTEGER_DENSITY: SORT_INTEGER_DENSITY, SYSTEM_SPECIFIC_COMPENDIUM_TYPES: SYSTEM_SPECIFIC_COMPENDIUM_TYPES, TABLE_RESULT_TYPES: TABLE_RESULT_TYPES, TEXTURE_DATA_FIT_MODES: TEXTURE_DATA_FIT_MODES, TEXT_ANCHOR_POINTS: TEXT_ANCHOR_POINTS, TEXT_ENRICH_EMBED_MAX_DEPTH: TEXT_ENRICH_EMBED_MAX_DEPTH, TEXT_FILE_EXTENSIONS: TEXT_FILE_EXTENSIONS, TILE_OCCLUSION_MODES: TILE_OCCLUSION_MODES, TIMEOUTS: TIMEOUTS, TOKEN_DISPLAY_MODES: TOKEN_DISPLAY_MODES, TOKEN_DISPOSITIONS: TOKEN_DISPOSITIONS, TOKEN_HEXAGONAL_SHAPES: TOKEN_HEXAGONAL_SHAPES, TOKEN_OCCLUSION_MODES: TOKEN_OCCLUSION_MODES, TRUSTED_IFRAME_DOMAINS: TRUSTED_IFRAME_DOMAINS, UPLOADABLE_FILE_EXTENSIONS: UPLOADABLE_FILE_EXTENSIONS, USER_PERMISSIONS: USER_PERMISSIONS, USER_ROLES: USER_ROLES, USER_ROLE_NAMES: USER_ROLE_NAMES, VIDEO_FILE_EXTENSIONS: VIDEO_FILE_EXTENSIONS, VTT: VTT, WALL_DIRECTIONS: WALL_DIRECTIONS, WALL_DOOR_INTERACTIONS: WALL_DOOR_INTERACTIONS, WALL_DOOR_STATES: WALL_DOOR_STATES, WALL_DOOR_TYPES: WALL_DOOR_TYPES, WALL_MOVEMENT_TYPES: WALL_MOVEMENT_TYPES, WALL_RESTRICTION_TYPES: WALL_RESTRICTION_TYPES, WALL_SENSE_TYPES: WALL_SENSE_TYPES, WEBSITE_API_URL: WEBSITE_API_URL, WEBSITE_URL: WEBSITE_URL, WORLD_DOCUMENT_TYPES: WORLD_DOCUMENT_TYPES, WORLD_JOIN_THEMES: WORLD_JOIN_THEMES, vtt: vtt$1 }); /** @module helpers */ /** * Benchmark the performance of a function, calling it a requested number of iterations. * @param {Function} func The function to benchmark * @param {number} iterations The number of iterations to test * @param {...any} args Additional arguments passed to the benchmarked function */ async function benchmark(func, iterations, ...args) { const start = performance.now(); for ( let i=0; i} */ async function threadLock(ms, debug=false) { const t0 = performance.now(); let d = 0; while ( d < ms ) { d = performance.now() - t0; if ( debug && (d % 1000 === 0) ) { console.debug(`Thread lock for ${d / 1000} of ${ms / 1000} seconds`); } } } /* -------------------------------------------- */ /** * Wrap a callback in a debounced timeout. * Delay execution of the callback function until the function has not been called for delay milliseconds * @param {Function} callback A function to execute once the debounced threshold has been passed * @param {number} delay An amount of time in milliseconds to delay * @return {Function} A wrapped function which can be called to debounce execution */ function debounce(callback, delay) { let timeoutId; return function(...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => { callback.apply(this, args); }, delay); } } /* -------------------------------------------- */ /** * Wrap a callback in a throttled timeout. * Delay execution of the callback function when the last time the function was called was delay milliseconds ago * @param {Function} callback A function to execute once the throttled threshold has been passed * @param {number} delay A maximum amount of time in milliseconds between to execution * @return {Function} A wrapped function which can be called to throttle execution */ function throttle(callback, delay) { let pending; let lastTime = -delay; return function(...args) { if ( pending ) { pending.thisArg = this; pending.args = args; return; } pending = {thisArg: this, args}; setTimeout(() => { const {thisArg, args} = pending; pending = null; callback.apply(thisArg, args); lastTime = performance.now(); }, Math.max(delay - (performance.now() - lastTime), 0)); } } /* -------------------------------------------- */ /** * A utility function to reload the page with a debounce. * @callback debouncedReload */ const debouncedReload = debounce( () => window.location.reload(), 250); /* -------------------------------------------- */ /** * Quickly clone a simple piece of data, returning a copy which can be mutated safely. * This method DOES support recursive data structures containing inner objects or arrays. * This method DOES NOT support advanced object types like Set, Map, or other specialized classes. * @param {*} original Some sort of data * @param {object} [options] Options to configure the behaviour of deepClone * @param {boolean} [options.strict=false] Throw an Error if deepClone is unable to clone something instead of * returning the original * @param {number} [options._d] An internal depth tracker * @return {*} The clone of that data */ function deepClone(original, {strict=false, _d=0}={}) { if ( _d > 100 ) { throw new Error("Maximum depth exceeded. Be sure your object does not contain cyclical data structures."); } _d++; // Simple types if ( (typeof original !== "object") || (original === null) ) return original; // Arrays if ( original instanceof Array ) return original.map(o => deepClone(o, {strict, _d})); // Dates if ( original instanceof Date ) return new Date(original); // Unsupported advanced objects if ( original.constructor && (original.constructor !== Object) ) { if ( strict ) throw new Error("deepClone cannot clone advanced objects"); return original; } // Other objects const clone = {}; for ( let k of Object.keys(original) ) { clone[k] = deepClone(original[k], {strict, _d}); } return clone; } /* -------------------------------------------- */ /** * Deeply difference an object against some other, returning the update keys and values. * @param {object} original An object comparing data against which to compare * @param {object} other An object containing potentially different data * @param {object} [options={}] Additional options which configure the diff operation * @param {boolean} [options.inner=false] Only recognize differences in other for keys which also exist in original * @param {boolean} [options.deletionKeys=false] Apply special logic to deletion keys. They will only be kept if the * original object has a corresponding key that could be deleted. * @param {number} [options._d] An internal depth tracker * @return {object} An object of the data in other which differs from that in original */ function diffObject(original, other, {inner=false, deletionKeys=false, _d=0}={}) { if ( _d > 100 ) { throw new Error("Maximum depth exceeded. Be careful that your object does not contain a cyclical data structure.") } _d++; function _difference(v0, v1) { // Eliminate differences in types let t0 = getType(v0); let t1 = getType(v1); if ( t0 !== t1 ) return [true, v1]; // null and undefined if ( ["null", "undefined"].includes(t0) ) return [v0 !== v1, v1]; // If the prototype explicitly exposes an equality-testing method, use it if ( v0?.equals instanceof Function ) return [!v0.equals(v1), v1]; // Recursively diff objects if ( t0 === "Object" ) { if ( isEmpty$1(v1) ) return [false, {}]; if ( isEmpty$1(v0) ) return [true, v1]; let d = diffObject(v0, v1, {inner, deletionKeys, _d}); return [!isEmpty$1(d), d]; } // Differences in primitives return [v0.valueOf() !== v1.valueOf(), v1]; } // Recursively call the _difference function return Object.keys(other).reduce((obj, key) => { const isDeletionKey = key.startsWith("-="); if ( isDeletionKey && deletionKeys ) { const otherKey = key.substring(2); if ( otherKey in original ) obj[key] = other[key]; return obj; } if ( inner && !(key in original) ) return obj; let [isDifferent, difference] = _difference(original[key], other[key]); if ( isDifferent ) obj[key] = difference; return obj; }, {}); } /* -------------------------------------------- */ /** * Test if two objects contain the same enumerable keys and values. * @param {object} a The first object. * @param {object} b The second object. * @returns {boolean} */ function objectsEqual(a, b) { if ( (a == null) || (b == null) ) return a === b; if ( (getType(a) !== "Object") || (getType(b) !== "Object") ) return a === b; if ( Object.keys(a).length !== Object.keys(b).length ) return false; return Object.entries(a).every(([k, v0]) => { const v1 = b[k]; 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; }); } /* -------------------------------------------- */ /** * A cheap data duplication trick which is relatively robust. * For a subset of cases the deepClone function will offer better performance. * @param {Object} original Some sort of data */ function duplicate(original) { return JSON.parse(JSON.stringify(original)); } /* -------------------------------------------- */ /** * Test whether some class is a subclass of a parent. * Returns true if the classes are identical. * @param {Function} cls The class to test * @param {Function} parent Some other class which may be a parent * @returns {boolean} Is the class a subclass of the parent? */ function isSubclass(cls, parent) { if ( typeof cls !== "function" ) return false; if ( cls === parent ) return true; return parent.isPrototypeOf(cls); } /* -------------------------------------------- */ /** * Search up the prototype chain and return the class that defines the given property. * @param {Object|Constructor} obj A class instance or class definition which contains a property. * If a class instance is passed the property is treated as an instance attribute. * If a class constructor is passed the property is treated as a static attribute. * @param {string} property The property name * @returns {Constructor} The class that defines the property */ function getDefiningClass(obj, property) { const isStatic = obj.hasOwnProperty("prototype"); let target = isStatic ? obj : Object.getPrototypeOf(obj); while ( target ) { if ( target.hasOwnProperty(property) ) return isStatic ? target : target.constructor; target = Object.getPrototypeOf(target); } } /* -------------------------------------------- */ /** * Encode a url-like string by replacing any characters which need encoding * To reverse this encoding, the native decodeURIComponent can be used on the whole encoded string, without adjustment. * @param {string} path A fully-qualified URL or url component (like a relative path) * @return {string} An encoded URL string */ function encodeURL(path) { // Determine whether the path is a well-formed URL const url = URL.parseSafe(path); // If URL, remove the initial protocol if ( url ) path = path.replace(url.protocol, ""); // Split and encode each URL part path = path.split("/").map(p => encodeURIComponent(p).replace(/'/g, "%27")).join("/"); // Return the encoded URL return url ? url.protocol + path : path; } /* -------------------------------------------- */ /** * Expand a flattened object to be a standard nested Object by converting all dot-notation keys to inner objects. * Only simple objects will be expanded. Other Object types like class instances will be retained as-is. * @param {object} obj The object to expand * @return {object} An expanded object */ function expandObject(obj) { function _expand(value, depth) { if ( depth > 32 ) throw new Error("Maximum object expansion depth exceeded"); if ( !value ) return value; if ( Array.isArray(value) ) return value.map(v => _expand(v, depth+1)); // Map arrays if ( value.constructor?.name !== "Object" ) return value; // Return advanced objects directly const expanded = {}; // Expand simple objects for ( let [k, v] of Object.entries(value) ) { setProperty(expanded, k, _expand(v, depth+1)); } return expanded; } return _expand(obj, 0); } /* -------------------------------------------- */ /** * Filter the contents of some source object using the structure of a template object. * Only keys which exist in the template are preserved in the source object. * * @param {object} source An object which contains the data you wish to filter * @param {object} template An object which contains the structure you wish to preserve * @param {object} [options={}] Additional options which customize the filtration * @param {boolean} [options.deletionKeys=false] Whether to keep deletion keys * @param {boolean} [options.templateValues=false] Instead of keeping values from the source, instead draw values from the template * * @example Filter an object * ```js * const source = {foo: {number: 1, name: "Tim", topping: "olives"}, bar: "baz"}; * const template = {foo: {number: 0, name: "Mit", style: "bold"}, other: 72}; * filterObject(source, template); // {foo: {number: 1, name: "Tim"}}; * filterObject(source, template, {templateValues: true}); // {foo: {number: 0, name: "Mit"}}; * ``` */ function filterObject(source, template, {deletionKeys=false, templateValues=false}={}) { // Validate input const ts = getType(source); const tt = getType(template); if ( (ts !== "Object") || (tt !== "Object")) throw new Error("One of source or template are not Objects!"); // Define recursive filtering function const _filter = function(s, t, filtered) { for ( let [k, v] of Object.entries(s) ) { let has = t.hasOwnProperty(k); let x = t[k]; // Case 1 - inner object if ( has && (getType(v) === "Object") && (getType(x) === "Object") ) { filtered[k] = _filter(v, x, {}); } // Case 2 - inner key else if ( has ) { filtered[k] = templateValues ? x : v; } // Case 3 - special key else if ( deletionKeys && k.startsWith("-=") ) { filtered[k] = v; } } return filtered; }; // Begin filtering at the outer-most layer return _filter(source, template, {}); } /* -------------------------------------------- */ /** * Flatten a possibly multi-dimensional object to a one-dimensional one by converting all nested keys to dot notation * @param {object} obj The object to flatten * @param {number} [_d=0] Track the recursion depth to prevent overflow * @return {object} A flattened object */ function flattenObject(obj, _d=0) { const flat = {}; if ( _d > 100 ) { throw new Error("Maximum depth exceeded"); } for ( let [k, v] of Object.entries(obj) ) { let t = getType(v); if ( t === "Object" ) { if ( isEmpty$1(v) ) flat[k] = v; let inner = flattenObject(v, _d+1); for ( let [ik, iv] of Object.entries(inner) ) { flat[`${k}.${ik}`] = iv; } } else flat[k] = v; } return flat; } /* -------------------------------------------- */ /** * Obtain references to the parent classes of a certain class. * @param {Function} cls An class definition * @return {Array} An array of parent classes which the provided class extends */ function getParentClasses(cls) { if ( typeof cls !== "function" ) { throw new Error("The provided class is not a type of Function"); } const parents = []; let parent = Object.getPrototypeOf(cls); while ( parent ) { parents.push(parent); parent = Object.getPrototypeOf(parent); } return parents.slice(0, -2) } /* -------------------------------------------- */ /** * Get the URL route for a certain path which includes a path prefix, if one is set * @param {string} path The Foundry URL path * @param {string|null} [prefix] A path prefix to apply * @returns {string} The absolute URL path */ function getRoute(path, {prefix}={}) { prefix = prefix === undefined ? globalThis.ROUTE_PREFIX : prefix || null; path = path.replace(/(^[\/]+)|([\/]+$)/g, ""); // Strip leading and trailing slashes let paths = [""]; if ( prefix ) paths.push(prefix); paths = paths.concat([path.replace(/(^\/)|(\/$)/g, "")]); return paths.join("/"); } /* -------------------------------------------- */ /** * Learn the underlying data type of some variable. Supported identifiable types include: * undefined, null, number, string, boolean, function, Array, Set, Map, Promise, Error, * HTMLElement (client side only), Object (catchall for other object types) * @param {*} variable A provided variable * @return {string} The named type of the token */ function getType(variable) { // Primitive types, handled with simple typeof check const typeOf = typeof variable; if ( typeOf !== "object" ) return typeOf; // Special cases of object if ( variable === null ) return "null"; if ( !variable.constructor ) return "Object"; // Object with the null prototype. if ( variable.constructor.name === "Object" ) return "Object"; // simple objects // Match prototype instances const prototypes = [ [Array, "Array"], [Set, "Set"], [Map, "Map"], [Promise, "Promise"], [Error, "Error"], [Color$1, "number"] ]; if ( "HTMLElement" in globalThis ) prototypes.push([globalThis.HTMLElement, "HTMLElement"]); for ( const [cls, type] of prototypes ) { if ( variable instanceof cls ) return type; } // Unknown Object type return "Object"; } /* -------------------------------------------- */ /** * A helper function which tests whether an object has a property or nested property given a string key. * The method also supports arrays if the provided key is an integer index of the array. * The string key supports the notation a.b.c which would return true if object[a][b][c] exists * @param {object} object The object to traverse * @param {string} key An object property with notation a.b.c * @returns {boolean} An indicator for whether the property exists */ function hasProperty(object, key) { if ( !key || !object ) return false; if ( key in object ) return true; let target = object; for ( let p of key.split('.') ) { if ( !target || (typeof target !== "object") ) return false; if ( p in target ) target = target[p]; else return false; } return true; } /* -------------------------------------------- */ /** * A helper function which searches through an object to retrieve a value by a string key. * The method also supports arrays if the provided key is an integer index of the array. * The string key supports the notation a.b.c which would return object[a][b][c] * @param {object} object The object to traverse * @param {string} key An object property with notation a.b.c * @return {*} The value of the found property */ function getProperty(object, key) { if ( !key || !object ) return undefined; if ( key in object ) return object[key]; let target = object; for ( let p of key.split('.') ) { if ( !target || (typeof target !== "object") ) return undefined; if ( p in target ) target = target[p]; else return undefined; } return target; } /* -------------------------------------------- */ /** * A helper function which searches through an object to assign a value using a string key * This string key supports the notation a.b.c which would target object[a][b][c] * @param {object} object The object to update * @param {string} key The string key * @param {*} value The value to be assigned * @return {boolean} Whether the value was changed from its previous value */ function setProperty(object, key, value) { if ( !key ) return false; // Convert the key to an object reference if it contains dot notation let target = object; if ( key.indexOf('.') !== -1 ) { let parts = key.split('.'); key = parts.pop(); target = parts.reduce((o, i) => { if ( !o.hasOwnProperty(i) ) o[i] = {}; return o[i]; }, object); } // Update the target if ( !(key in target) || (target[key] !== value) ) { target[key] = value; return true; } return false; } /* -------------------------------------------- */ /** * Invert an object by assigning its values as keys and its keys as values. * @param {object} obj The original object to invert * @returns {object} The inverted object with keys and values swapped */ function invertObject(obj) { const inverted = {}; for ( let [k, v] of Object.entries(obj) ) { if ( v in inverted ) throw new Error("The values of the provided object must be unique in order to invert it."); inverted[v] = k; } return inverted; } /* -------------------------------------------- */ /** * Return whether a target version (v1) is more advanced than some other reference version (v0). * Supports either numeric or string version comparison with version parts separated by periods. * @param {number|string} v1 The target version * @param {number|string} v0 The reference version * @return {boolean} Is v1 a more advanced version than v0? */ function isNewerVersion(v1, v0) { // Handle numeric versions if ( (typeof v1 === "number") && (typeof v0 === "number") ) return v1 > v0; // Handle string parts let v1Parts = String(v1).split("."); let v0Parts = String(v0).split("."); // Iterate over version parts for ( let [i, p1] of v1Parts.entries() ) { let p0 = v0Parts[i]; // If the prior version doesn't have a part, v1 wins if ( p0 === undefined ) return true; // If both parts are numbers, use numeric comparison to avoid cases like "12" < "5" if ( Number.isNumeric(p0) && Number.isNumeric(p1) ) { if ( Number(p1) !== Number(p0) ) return Number(p1) > Number(p0); } // Otherwise, compare as strings if ( p1 !== p0 ) return p1 > p0; } // If there are additional parts to v0, it is not newer if ( v0Parts.length > v1Parts.length ) return false; // If we have not returned false by now, it's either newer or the same return !v1Parts.equals(v0Parts); } /* -------------------------------------------- */ /** * Test whether a value is empty-like; either undefined or a content-less object. * @param {*} value The value to test * @returns {boolean} Is the value empty-like? */ function isEmpty$1(value) { const t = getType(value); switch ( t ) { case "undefined": return true; case "null": return true; case "Array": return !value.length; case "Object": return !Object.keys(value).length; case "Set": case "Map": return !value.size; default: return false; } } /* -------------------------------------------- */ /** * Update a source object by replacing its keys and values with those from a target object. * * @param {object} original The initial object which should be updated with values from the * target * @param {object} [other={}] A new object whose values should replace those in the source * @param {object} [options={}] Additional options which configure the merge * @param {boolean} [options.insertKeys=true] Control whether to insert new top-level objects into the resulting * structure which do not previously exist in the original object. * @param {boolean} [options.insertValues=true] Control whether to insert new nested values into child objects in * the resulting structure which did not previously exist in the * original object. * @param {boolean} [options.overwrite=true] Control whether to replace existing values in the source, or only * merge values which do not already exist in the original object. * @param {boolean} [options.recursive=true] Control whether to merge inner-objects recursively (if true), or * whether to simply replace inner objects with a provided new value. * @param {boolean} [options.inplace=true] Control whether to apply updates to the original object in-place * (if true), otherwise the original object is duplicated and the * copy is merged. * @param {boolean} [options.enforceTypes=false] Control whether strict type checking requires that the value of a * key in the other object must match the data type in the original * data to be merged. * @param {boolean} [options.performDeletions=false] Control whether to perform deletions on the original object if * deletion keys are present in the other object. * @param {number} [_d=0] A privately used parameter to track recursion depth. * @returns {object} The original source object including updated, inserted, or * overwritten records. * * @example Control how new keys and values are added * ```js * mergeObject({k1: "v1"}, {k2: "v2"}, {insertKeys: false}); // {k1: "v1"} * mergeObject({k1: "v1"}, {k2: "v2"}, {insertKeys: true}); // {k1: "v1", k2: "v2"} * mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {insertValues: false}); // {k1: {i1: "v1"}} * mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {insertValues: true}); // {k1: {i1: "v1", i2: "v2"}} * ``` * * @example Control how existing data is overwritten * ```js * mergeObject({k1: "v1"}, {k1: "v2"}, {overwrite: true}); // {k1: "v2"} * mergeObject({k1: "v1"}, {k1: "v2"}, {overwrite: false}); // {k1: "v1"} * ``` * * @example Control whether merges are performed recursively * ```js * mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {recursive: false}); // {k1: {i2: "v2"}} * mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {recursive: true}); // {k1: {i1: "v1", i2: "v2"}} * ``` * * @example Deleting an existing object key * ```js * mergeObject({k1: "v1", k2: "v2"}, {"-=k1": null}, {performDeletions: true}); // {k2: "v2"} * ``` */ function mergeObject(original, other={}, { insertKeys=true, insertValues=true, overwrite=true, recursive=true, inplace=true, enforceTypes=false, performDeletions=false }={}, _d=0) { other = other || {}; if (!(original instanceof Object) || !(other instanceof Object)) { throw new Error("One of original or other are not Objects!"); } const options = {insertKeys, insertValues, overwrite, recursive, inplace, enforceTypes, performDeletions}; // Special handling at depth 0 if ( _d === 0 ) { if ( Object.keys(other).some(k => /\./.test(k)) ) other = expandObject(other); if ( Object.keys(original).some(k => /\./.test(k)) ) { const expanded = expandObject(original); if ( inplace ) { Object.keys(original).forEach(k => delete original[k]); Object.assign(original, expanded); } else original = expanded; } else if ( !inplace ) original = deepClone(original); } // Iterate over the other object for ( let k of Object.keys(other) ) { const v = other[k]; if ( original.hasOwnProperty(k) ) _mergeUpdate(original, k, v, options, _d+1); else _mergeInsert(original, k, v, options, _d+1); } return original; } /** * A helper function for merging objects when the target key does not exist in the original * @private */ function _mergeInsert(original, k, v, {insertKeys, insertValues, performDeletions}={}, _d) { // Delete a key if ( k.startsWith("-=") && performDeletions ) { delete original[k.slice(2)]; return; } const canInsert = ((_d <= 1) && insertKeys) || ((_d > 1) && insertValues); if ( !canInsert ) return; // Recursively create simple objects if ( v?.constructor === Object ) { original[k] = mergeObject({}, v, {insertKeys: true, inplace: true, performDeletions}); return; } // Insert a key original[k] = v; } /** * A helper function for merging objects when the target key exists in the original * @private */ function _mergeUpdate(original, k, v, { insertKeys, insertValues, enforceTypes, overwrite, recursive, performDeletions }={}, _d) { const x = original[k]; const tv = getType(v); const tx = getType(x); // Recursively merge an inner object if ( (tv === "Object") && (tx === "Object") && recursive) { return mergeObject(x, v, { insertKeys, insertValues, overwrite, enforceTypes, performDeletions, inplace: true }, _d); } // Overwrite an existing value if ( overwrite ) { if ( (tx !== "undefined") && (tv !== tx) && enforceTypes ) { throw new Error(`Mismatched data types encountered during object merge.`); } original[k] = v; } } /* -------------------------------------------- */ /** * Parse an S3 key to learn the bucket and the key prefix used for the request. * @param {string} key A fully qualified key name or prefix path. * @returns {{bucket: string|null, keyPrefix: string}} */ function parseS3URL(key) { const url = URL.parseSafe(key); if ( url ) return { bucket: url.host.split(".").shift(), keyPrefix: url.pathname.slice(1) }; return { bucket: null, keyPrefix: "" }; } /* -------------------------------------------- */ /** * Generate a random alphanumeric string ID of a given requested length using `crypto.getRandomValues()`. * @param {number} length The length of the random string to generate, which must be at most 16384. * @return {string} A string containing random letters (A-Z, a-z) and numbers (0-9). */ function randomID(length=16) { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; const cutoff = 0x100000000 - (0x100000000 % chars.length); const random = new Uint32Array(length); do { crypto.getRandomValues(random); } while ( random.some(x => x >= cutoff) ); let id = ""; for ( let i = 0; i < length; i++ ) id += chars[random[i] % chars.length]; return id; } /* -------------------------------------------- */ /** * Express a timestamp as a relative string * @param {Date|string} timeStamp A timestamp string or Date object to be formatted as a relative time * @return {string} A string expression for the relative time */ function timeSince(timeStamp) { timeStamp = new Date(timeStamp); const now = new Date(); const secondsPast = (now - timeStamp) / 1000; let since = ""; // Format the time if (secondsPast < 60) { since = secondsPast; if ( since < 1 ) return game.i18n.localize("TIME.Now"); else since = Math.round(since) + game.i18n.localize("TIME.SecondsAbbreviation"); } else if (secondsPast < 3600) since = Math.round(secondsPast / 60) + game.i18n.localize("TIME.MinutesAbbreviation"); else if (secondsPast <= 86400) since = Math.round(secondsPast / 3600) + game.i18n.localize("TIME.HoursAbbreviation"); else { const hours = Math.round(secondsPast / 3600); const days = Math.floor(hours / 24); since = `${days}${game.i18n.localize("TIME.DaysAbbreviation")} ${hours % 24}${game.i18n.localize("TIME.HoursAbbreviation")}`; } // Return the string return game.i18n.format("TIME.Since", {since: since}); } /* -------------------------------------------- */ /** * Format a file size to an appropriate order of magnitude. * @param {number} size The size in bytes. * @param {object} [options] * @param {number} [options.decimalPlaces=2] The number of decimal places to round to. * @param {2|10} [options.base=10] The base to use. In base 10 a kilobyte is 1000 bytes. In base 2 it is * 1024 bytes. * @returns {string} */ function formatFileSize(size, { decimalPlaces=2, base=10 }={}) { const units = ["B", "kB", "MB", "GB", "TB"]; const divisor = base === 2 ? 1024 : 1000; let iterations = 0; while ( (iterations < units.length) && (size > divisor) ) { size /= divisor; iterations++; } return `${size.toFixed(decimalPlaces)} ${units[iterations]}`; } /* -------------------------------------------- */ /** * @typedef {object} ResolvedUUID * @property {string} uuid The original UUID. * @property {string} [type] The type of Document referenced. Legacy compendium UUIDs will not * populate this field if the compendium is not active in the World. * @property {string} id The ID of the Document referenced. * @property {string} [primaryType] The primary Document type of this UUID. Only present if the Document * is embedded. * @property {string} [primaryId] The primary Document ID of this UUID. Only present if the Document * is embedded. * @property {DocumentCollection} [collection] The collection that the primary Document belongs to. * @property {string[]} embedded Additional Embedded Document parts. * @property {Document} [doc] An already-resolved parent Document. * @property {string} [documentType] Either the document type or the parent type. Retained for backwards * compatibility. * @property {string} [documentId] Either the document id or the parent id. Retained for backwards * compatibility. */ /** * Parse a UUID into its constituent parts, identifying the type and ID of the referenced document. * The ResolvedUUID result also identifies a "primary" document which is a root-level document either in the game * World or in a Compendium pack which is a parent of the referenced document. * @param {string} uuid The UUID to parse. * @param {object} [options] Options to configure parsing behavior. * @param {foundry.abstract.Document} [options.relative] A document to resolve relative UUIDs against. * @returns {ResolvedUUID} Returns the Collection, Document Type, and Document ID to resolve the parent * document, as well as the remaining Embedded Document parts, if any. * @throws {Error} An error if the provided uuid string is incorrectly structured */ function parseUuid(uuid, {relative}={}) { if ( !uuid ) throw new Error("A uuid string is required"); const packs = game.packs; // Relative UUID if ( uuid.startsWith(".") && relative ) return _resolveRelativeUuid(uuid, relative); // Split UUID parts const parts = uuid.split("."); // Check for redirects. if ( game.compendiumUUIDRedirects ) { const node = game.compendiumUUIDRedirects.nodeAtPrefix(parts, { hasLeaves: true }); const [redirect] = node?.[foundry.utils.StringTree.leaves]; if ( redirect?.length ) parts.splice(0, redirect.length, ...redirect); } let id; let type; let primaryId; let primaryType; let collection; // Compendium Documents. if ( parts[0] === "Compendium" ) { const [, scope, packName] = parts.splice(0, 3); collection = packs.get(`${scope}.${packName}`); // Re-interpret legacy compendium UUIDs which did not explicitly include their parent document type if ( !(COMPENDIUM_DOCUMENT_TYPES.includes(parts[0]) || (parts[0] === "Folder")) ) { const type = collection?.documentName; parts.unshift(type); if ( type ) uuid = ["Compendium", scope, packName, ...parts].filterJoin("."); } [primaryType, primaryId] = parts.splice(0, 2); } // World Documents else { [primaryType, primaryId] = parts.splice(0, 2); collection = globalThis.db?.[primaryType] ?? CONFIG[primaryType]?.collection?.instance; } // Embedded Documents if ( parts.length ) { if ( parts.length % 2 ) throw new Error("Invalid number of embedded UUID parts"); id = parts.at(-1); type = parts.at(-2); } // Primary Documents else { id = primaryId; type = primaryType; primaryId = primaryType = undefined; } // Return resolved UUID return {uuid, type, id, collection, embedded: parts, primaryType, primaryId, documentType: primaryType ?? type, documentId: primaryId ?? id}; } /* -------------------------------------------- */ /** * Resolve a UUID relative to another document. * The general-purpose algorithm for resolving relative UUIDs is as follows: * 1. If the number of parts is odd, remove the first part and resolve it against the current document and update the * current document. * 2. If the number of parts is even, resolve embedded documents against the current document. * @param {string} uuid The UUID to resolve. * @param {foundry.abstract.Document} relative The document to resolve against. * @returns {ResolvedUUID} A resolved UUID object * @private */ function _resolveRelativeUuid(uuid, relative) { if ( !(relative instanceof foundry.abstract.Document) ) { throw new Error("A relative Document instance must be provided to _resolveRelativeUuid"); } uuid = uuid.substring(1); const parts = uuid.split("."); if ( !parts.length ) throw new Error("Invalid relative UUID"); let id; let type; let root; let primaryType; let primaryId; let collection; // Identify the root document and its collection const getRoot = (doc) => { if ( doc.parent ) parts.unshift(doc.documentName, doc.id); return doc.parent ? getRoot(doc.parent) : doc; }; // Even-numbered parts include an explicit child document type if ( (parts.length % 2) === 0 ) { root = getRoot(relative); id = parts.at(-1); type = parts.at(-2); primaryType = root.documentName; primaryId = root.id; uuid = [primaryType, primaryId, ...parts].join("."); } // Relative Embedded Document else if ( relative.parent ) { root = getRoot(relative.parent); id = parts.at(-1); type = relative.documentName; parts.unshift(type); primaryType = root.documentName; primaryId = root.id; uuid = [primaryType, primaryId, ...parts].join("."); } // Relative Document else { root = relative; id = parts.pop(); type = relative.documentName; uuid = [type, id].join("."); } // Recreate fully-qualified UUID and return the resolved result collection = root.pack ? root.compendium : root.collection; if ( root.pack ) uuid = `Compendium.${root.pack}.${uuid}`; return {uuid, type, id, collection, primaryType, primaryId, embedded: parts, documentType: primaryType ?? type, documentId: primaryId ?? id}; } /** * Flatten nested arrays by concatenating their contents * @returns {any[]} An array containing the concatenated inner values */ 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? */ function equals$1(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 */ 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 */ 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. */ 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 */ 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$1}, filterJoin: {value: filterJoin}, findSplice: {value: findSplice}, partition: {value: partition} }); Object.defineProperties(Array,{ fromRange: {value: fromRange} }); /** * 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} */ 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 */ 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 */ 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} }); /** * √3 * @type {number} */ const SQRT3 = 1.7320508075688772; /** * √⅓ * @type {number} */ 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 */ function clamp(num, min, max) { return Math.min(Math.max(num, min), max); } /** * @deprecated since v12 * @ignore */ 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. */ 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) */ 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] */ function normalizeRadians(radians) { const pi = Math.PI; const pi2 = pi * 2; return radians - (pi2 * Math.floor((radians + pi) / pi2)); } /** * @deprecated since v12 * @ignore */ 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 */ 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 */ 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` */ 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 } }); /** * 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? */ 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} */ 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 */ 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 */ 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 * ``` */ 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? */ 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? */ 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. */ 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} }); /** * 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 */ 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. */ 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? */ 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 */ 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 */ 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? */ 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} */ 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? */ 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. */ 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? */ 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. */ 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. */ 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. */ 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. */ 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? */ 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} }); /** * Capitalize a string, transforming it's first character to a capital letter. * @returns {string} */ function capitalize() { if ( !this.length ) return this; return this.charAt(0).toUpperCase() + this.slice(1); } /** * Compare this string (x) with the other string (y) by comparing each character's Unicode code point value. * Returns a negative Number if x < y, a positive Number if x > y, or a zero otherwise. * This is the same comparision function that used by Array#sort if the compare function argument is omitted. * The result is host/locale-independent. * @param {string} other The other string to compare this string to. * @returns {number} */ function compare(other) { return this < other ? -1 : this > other ? 1 : 0; } /** * Convert a string to Title Case where the first letter of each word is capitalized. * @returns {string} */ function titleCase() { if (!this.length) return this; return this.toLowerCase().split(' ').reduce((parts, word) => { if ( !word ) return parts; const title = word.replace(word[0], word[0].toUpperCase()); parts.push(title); return parts; }, []).join(' '); } /** * Strip any script tags which were included within a provided string. * @returns {string} */ function stripScripts() { let el = document.createElement("div"); el.innerHTML = this; for ( let s of el.getElementsByTagName("script") ) { s.parentNode.removeChild(s); } return el.innerHTML; } /** * Map characters to lower case ASCII * @type {Record} */ const CHAR_MAP = JSON.parse('{"$":"dollar","%":"percent","&":"and","<":"less",">":"greater","|":"or","¢":"cent","£":"pound","¤":"currency","¥":"yen","©":"(c)","ª":"a","®":"(r)","º":"o","À":"A","Á":"A","Â":"A","Ã":"A","Ä":"A","Å":"A","Æ":"AE","Ç":"C","È":"E","É":"E","Ê":"E","Ë":"E","Ì":"I","Í":"I","Î":"I","Ï":"I","Ð":"D","Ñ":"N","Ò":"O","Ó":"O","Ô":"O","Õ":"O","Ö":"O","Ø":"O","Ù":"U","Ú":"U","Û":"U","Ü":"U","Ý":"Y","Þ":"TH","ß":"ss","à":"a","á":"a","â":"a","ã":"a","ä":"a","å":"a","æ":"ae","ç":"c","è":"e","é":"e","ê":"e","ë":"e","ì":"i","í":"i","î":"i","ï":"i","ð":"d","ñ":"n","ò":"o","ó":"o","ô":"o","õ":"o","ö":"o","ø":"o","ù":"u","ú":"u","û":"u","ü":"u","ý":"y","þ":"th","ÿ":"y","Ā":"A","ā":"a","Ă":"A","ă":"a","Ą":"A","ą":"a","Ć":"C","ć":"c","Č":"C","č":"c","Ď":"D","ď":"d","Đ":"DJ","đ":"dj","Ē":"E","ē":"e","Ė":"E","ė":"e","Ę":"e","ę":"e","Ě":"E","ě":"e","Ğ":"G","ğ":"g","Ģ":"G","ģ":"g","Ĩ":"I","ĩ":"i","Ī":"i","ī":"i","Į":"I","į":"i","İ":"I","ı":"i","Ķ":"k","ķ":"k","Ļ":"L","ļ":"l","Ľ":"L","ľ":"l","Ł":"L","ł":"l","Ń":"N","ń":"n","Ņ":"N","ņ":"n","Ň":"N","ň":"n","Ő":"O","ő":"o","Œ":"OE","œ":"oe","Ŕ":"R","ŕ":"r","Ř":"R","ř":"r","Ś":"S","ś":"s","Ş":"S","ş":"s","Š":"S","š":"s","Ţ":"T","ţ":"t","Ť":"T","ť":"t","Ũ":"U","ũ":"u","Ū":"u","ū":"u","Ů":"U","ů":"u","Ű":"U","ű":"u","Ų":"U","ų":"u","Ŵ":"W","ŵ":"w","Ŷ":"Y","ŷ":"y","Ÿ":"Y","Ź":"Z","ź":"z","Ż":"Z","ż":"z","Ž":"Z","ž":"z","ƒ":"f","Ơ":"O","ơ":"o","Ư":"U","ư":"u","Lj":"LJ","lj":"lj","Nj":"NJ","nj":"nj","Ș":"S","ș":"s","Ț":"T","ț":"t","˚":"o","Ά":"A","Έ":"E","Ή":"H","Ί":"I","Ό":"O","Ύ":"Y","Ώ":"W","ΐ":"i","Α":"A","Β":"B","Γ":"G","Δ":"D","Ε":"E","Ζ":"Z","Η":"H","Θ":"8","Ι":"I","Κ":"K","Λ":"L","Μ":"M","Ν":"N","Ξ":"3","Ο":"O","Π":"P","Ρ":"R","Σ":"S","Τ":"T","Υ":"Y","Φ":"F","Χ":"X","Ψ":"PS","Ω":"W","Ϊ":"I","Ϋ":"Y","ά":"a","έ":"e","ή":"h","ί":"i","ΰ":"y","α":"a","β":"b","γ":"g","δ":"d","ε":"e","ζ":"z","η":"h","θ":"8","ι":"i","κ":"k","λ":"l","μ":"m","ν":"n","ξ":"3","ο":"o","π":"p","ρ":"r","ς":"s","σ":"s","τ":"t","υ":"y","φ":"f","χ":"x","ψ":"ps","ω":"w","ϊ":"i","ϋ":"y","ό":"o","ύ":"y","ώ":"w","Ё":"Yo","Ђ":"DJ","Є":"Ye","І":"I","Ї":"Yi","Ј":"J","Љ":"LJ","Њ":"NJ","Ћ":"C","Џ":"DZ","А":"A","Б":"B","В":"V","Г":"G","Д":"D","Е":"E","Ж":"Zh","З":"Z","И":"I","Й":"J","К":"K","Л":"L","М":"M","Н":"N","О":"O","П":"P","Р":"R","С":"S","Т":"T","У":"U","Ф":"F","Х":"H","Ц":"C","Ч":"Ch","Ш":"Sh","Щ":"Sh","Ъ":"U","Ы":"Y","Ь":"","Э":"E","Ю":"Yu","Я":"Ya","а":"a","б":"b","в":"v","г":"g","д":"d","е":"e","ж":"zh","з":"z","и":"i","й":"j","к":"k","л":"l","м":"m","н":"n","о":"o","п":"p","р":"r","с":"s","т":"t","у":"u","ф":"f","х":"h","ц":"c","ч":"ch","ш":"sh","щ":"sh","ъ":"u","ы":"y","ь":"","э":"e","ю":"yu","я":"ya","ё":"yo","ђ":"dj","є":"ye","і":"i","ї":"yi","ј":"j","љ":"lj","њ":"nj","ћ":"c","ѝ":"u","џ":"dz","Ґ":"G","ґ":"g","Ғ":"GH","ғ":"gh","Қ":"KH","қ":"kh","Ң":"NG","ң":"ng","Ү":"UE","ү":"ue","Ұ":"U","ұ":"u","Һ":"H","һ":"h","Ә":"AE","ә":"ae","Ө":"OE","ө":"oe","฿":"baht","ა":"a","ბ":"b","გ":"g","დ":"d","ე":"e","ვ":"v","ზ":"z","თ":"t","ი":"i","კ":"k","ლ":"l","მ":"m","ნ":"n","ო":"o","პ":"p","ჟ":"zh","რ":"r","ს":"s","ტ":"t","უ":"u","ფ":"f","ქ":"k","ღ":"gh","ყ":"q","შ":"sh","ჩ":"ch","ც":"ts","ძ":"dz","წ":"ts","ჭ":"ch","ხ":"kh","ჯ":"j","ჰ":"h","Ẁ":"W","ẁ":"w","Ẃ":"W","ẃ":"w","Ẅ":"W","ẅ":"w","ẞ":"SS","Ạ":"A","ạ":"a","Ả":"A","ả":"a","Ấ":"A","ấ":"a","Ầ":"A","ầ":"a","Ẩ":"A","ẩ":"a","Ẫ":"A","ẫ":"a","Ậ":"A","ậ":"a","Ắ":"A","ắ":"a","Ằ":"A","ằ":"a","Ẳ":"A","ẳ":"a","Ẵ":"A","ẵ":"a","Ặ":"A","ặ":"a","Ẹ":"E","ẹ":"e","Ẻ":"E","ẻ":"e","Ẽ":"E","ẽ":"e","Ế":"E","ế":"e","Ề":"E","ề":"e","Ể":"E","ể":"e","Ễ":"E","ễ":"e","Ệ":"E","ệ":"e","Ỉ":"I","ỉ":"i","Ị":"I","ị":"i","Ọ":"O","ọ":"o","Ỏ":"O","ỏ":"o","Ố":"O","ố":"o","Ồ":"O","ồ":"o","Ổ":"O","ổ":"o","Ỗ":"O","ỗ":"o","Ộ":"O","ộ":"o","Ớ":"O","ớ":"o","Ờ":"O","ờ":"o","Ở":"O","ở":"o","Ỡ":"O","ỡ":"o","Ợ":"O","ợ":"o","Ụ":"U","ụ":"u","Ủ":"U","ủ":"u","Ứ":"U","ứ":"u","Ừ":"U","ừ":"u","Ử":"U","ử":"u","Ữ":"U","ữ":"u","Ự":"U","ự":"u","Ỳ":"Y","ỳ":"y","Ỵ":"Y","ỵ":"y","Ỷ":"Y","ỷ":"y","Ỹ":"Y","ỹ":"y","‘":"\'","’":"\'","“":"\\\"","”":"\\\"","†":"+","•":"*","…":"...","₠":"ecu","₢":"cruzeiro","₣":"french franc","₤":"lira","₥":"mill","₦":"naira","₧":"peseta","₨":"rupee","₩":"won","₪":"new shequel","₫":"dong","€":"euro","₭":"kip","₮":"tugrik","₯":"drachma","₰":"penny","₱":"peso","₲":"guarani","₳":"austral","₴":"hryvnia","₵":"cedi","₸":"kazakhstani tenge","₹":"indian rupee","₽":"russian ruble","₿":"bitcoin","℠":"sm","™":"tm","∂":"d","∆":"delta","∑":"sum","∞":"infinity","♥":"love","元":"yuan","円":"yen","﷼":"rial"}'); /** * Transform any string into an url-viable slug string * @param {object} [options] Optional arguments which customize how the slugify operation is performed * @param {string} [options.replacement="-"] The replacement character to separate terms, default is '-' * @param {boolean} [options.strict=false] Replace all non-alphanumeric characters, or allow them? Default false * @param {boolean} [options.lowercase=true] Lowercase the string. * @returns {string} The slugified input string */ function slugify({replacement='-', strict=false, lowercase=true}={}) { let slug = this.split("").reduce((result, char) => result + (CHAR_MAP[char] || char), "").trim(); if ( lowercase ) slug = slug.toLowerCase(); // Convert any spaces to the replacement character and de-dupe slug = slug.replace(new RegExp('[\\s' + replacement + ']+', 'g'), replacement); // If we're being strict, replace anything that is not alphanumeric if ( strict ) slug = slug.replace(new RegExp('[^a-zA-Z0-9' + replacement + ']', 'g'), ''); return slug; } // Define properties on the String environment Object.defineProperties(String.prototype, { capitalize: {value: capitalize}, compare: {value: compare}, titleCase: {value: titleCase}, stripScripts: {value: stripScripts}, slugify: {value: slugify} }); /** * 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 */ function escape$1(string) { return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); } // Define properties on the RegExp environment Object.defineProperties(RegExp, { escape: {value: escape$1} }); /** * 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. */ function parseSafe(url) { try { return new URL(url); } catch (err) {} return null; } // Define properties on the URL environment Object.defineProperties(URL, { parseSafe: {value: parseSafe} }); /** * @typedef {Object} DatabaseGetOperation * @property {Record} 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? */ var _types$4 = /*#__PURE__*/Object.freeze({ __proto__: null }); /** @module validators */ /** * Test whether a string is a valid 16 character UID * @param {string} id * @return {boolean} */ 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} */ 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} */ 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? */ 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? */ function isJSON(val) { try { JSON.parse(val); return true; } catch(err) { return false; } } var validators = /*#__PURE__*/Object.freeze({ __proto__: null, hasFileExtension: hasFileExtension, isBase64Data: isBase64Data, isColorString: isColorString, isJSON: isJSON, isValidId: isValidId }); /** * The messages that have been logged already and should not be logged again. * @type {Set} */ const loggedCompatibilityWarnings = new Set(); /** * Log a compatibility warning which is filtered based on the client's defined compatibility settings. * @param {string} message The original warning or error message * @param {object} [options={}] Additional options which customize logging * @param {number} [options.mode] A logging level in COMPATIBILITY_MODES which overrides the configured default * @param {number|string} [options.since] A version identifier since which a change was made * @param {number|string} [options.until] A version identifier until which a change remains supported * @param {string} [options.details] Additional details to append to the logged message * @param {boolean} [options.stack=true] Include the message stack trace * @param {boolean} [options.once=false] Log this the message only once? * @throws An Error if the mode is ERROR */ function logCompatibilityWarning(message, {mode, since, until, details, stack=true, once=false}={}) { // Determine the logging mode const modes = COMPATIBILITY_MODES; const compatibility = globalThis.CONFIG?.compatibility || { mode: modes.WARNING, includePatterns: [], excludePatterns: [] }; mode ??= compatibility.mode; if ( mode === modes.SILENT ) return; // Compose the message since = since ? `Deprecated since Version ${since}` : null; until = until ? `Backwards-compatible support will be removed in Version ${until}`: null; message = [message, since, until, details].filterJoin("\n"); // Filter the message by its stack trace const error = new Error(message); if ( compatibility.includePatterns.length ) { if ( !compatibility.includePatterns.some(rgx => rgx.test(error.message) || rgx.test(error.stack)) ) return; } if ( compatibility.excludePatterns.length ) { if ( compatibility.excludePatterns.some(rgx => rgx.test(error.message) || rgx.test(error.stack)) ) return; } // Log the message const log = !(once && loggedCompatibilityWarnings.has(error.stack)); switch ( mode ) { case modes.WARNING: if ( log ) globalThis.logger.warn(stack ? error : error.message); break; case modes.ERROR: if ( log ) globalThis.logger.error(stack ? error : error.message); break; case modes.FAILURE: throw error; } if ( log && once ) loggedCompatibilityWarnings.add(error.stack); } /** * A class responsible for recording information about a validation failure. */ 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} */ 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$1(this.fields) && isEmpty$1(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$1(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$1(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} */ 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$1(failure.fields) ) failure = failure.fields[p]; else if ( !isEmpty$1(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} * * @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$1(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 `
  • ${failure.message || ""}
  • `; const nodes = []; for ( const [field, subFailure] of Object.entries(failure.fields) ) { nodes.push(`
  • ${field}
      ${renderFailureNode(subFailure)}
  • `); } for ( const element of failure.elements ) { const name = element.name || element.id; const html = `
  • ${name}
      ${renderFailureNode(element.failure)}
  • `; nodes.push(html); } return nodes.join(""); }; return `
      ${renderFailureNode(this.#failure)}
    `; } /* -------------------------------------------- */ /** * Collect nested failures into an aggregate object. * @param {DataModelValidationFailure} failure The failure. * @returns {DataModelValidationFailure|Record} 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; } } var validationFailure = /*#__PURE__*/Object.freeze({ __proto__: null, DataModelValidationError: DataModelValidationError, DataModelValidationFailure: DataModelValidationFailure }); /** * A reusable storage concept which blends the functionality of an Array with the efficient key-based lookup of a Map. * This concept is reused throughout Foundry VTT where a collection of uniquely identified elements is required. * @template {string} K * @template {*} V * @extends {Map} */ class Collection extends Map { constructor(entries) { super(entries); } /* -------------------------------------------- */ /** * Then iterating over a Collection, we should iterate over its values instead of over its entries * @returns {IterableIterator} */ [Symbol.iterator]() { return this.values(); } /* -------------------------------------------- */ /** * Return an Array of all the entry values in the Collection * @type {V[]} */ get contents() { return Array.from(this.values()); } /* -------------------------------------------- */ /** * Find an entry in the Map using a functional condition. * @see {Array#find} * @param {function(*,number,Collection): boolean} condition The functional condition to test. Positional * arguments are the value, the index of iteration, and the collection being searched. * @return {*} The value, if found, otherwise undefined * * @example Create a new Collection and reference its contents * ```js * let c = new Collection([["a", "A"], ["b", "B"], ["c", "C"]]); * c.get("a") === c.find(entry => entry === "A"); // true * ``` */ find(condition) { let i = 0; for ( let v of this.values() ) { if ( condition(v, i, this) ) return v; i++; } return undefined; } /* -------------------------------------------- */ /** * Filter the Collection, returning an Array of entries which match a functional condition. * @see {Array#filter} * @param {function(*,number,Collection): boolean} condition The functional condition to test. Positional * arguments are the value, the index of iteration, and the collection being filtered. * @return {Array<*>} An Array of matched values * * @example Filter the Collection for specific entries * ```js * let c = new Collection([["a", "AA"], ["b", "AB"], ["c", "CC"]]); * let hasA = c.filters(entry => entry.slice(0) === "A"); * ``` */ filter(condition) { const entries = []; let i = 0; for ( let v of this.values() ) { if ( condition(v, i , this) ) entries.push(v); i++; } return entries; } /* -------------------------------------------- */ /** * Apply a function to each element of the collection * @see Array#forEach * @param {function(*): void} fn A function to apply to each element * * @example Apply a function to each value in the collection * ```js * let c = new Collection([["a", {active: false}], ["b", {active: false}], ["c", {active: false}]]); * c.forEach(e => e.active = true); * ``` */ forEach(fn) { for ( let e of this.values() ) { fn(e); } } /* -------------------------------------------- */ /** * Get an element from the Collection by its key. * @param {string} key The key of the entry to retrieve * @param {object} [options] Additional options that affect how entries are retrieved * @param {boolean} [options.strict=false] Throw an Error if the requested key does not exist. Default false. * @return {*|undefined} The retrieved entry value, if the key exists, otherwise undefined * * @example Get an element from the Collection by key * ```js * let c = new Collection([["a", "Alfred"], ["b", "Bob"], ["c", "Cynthia"]]); * c.get("a"); // "Alfred" * c.get("d"); // undefined * c.get("d", {strict: true}); // throws Error * ``` */ get(key, {strict=false}={}) { const entry = super.get(key); if ( strict && (entry === undefined) ) { throw new Error(`The key ${key} does not exist in the ${this.constructor.name} Collection`); } return entry; } /* -------------------------------------------- */ /** * Get an entry from the Collection by name. * Use of this method assumes that the objects stored in the collection have a "name" attribute. * @param {string} name The name of the entry to retrieve * @param {object} [options] Additional options that affect how entries are retrieved * @param {boolean} [options.strict=false] Throw an Error if the requested name does not exist. Default false. * @return {*} The retrieved entry value, if one was found, otherwise undefined * * @example Get an element from the Collection by name (if applicable) * ```js * let c = new Collection([["a", "Alfred"], ["b", "Bob"], ["c", "Cynthia"]]); * c.getName("Alfred"); // "Alfred" * c.getName("D"); // undefined * c.getName("D", {strict: true}); // throws Error * ``` */ getName(name, {strict=false} = {}) { const entry = this.find(e => e.name === name); if ( strict && (entry === undefined) ) { throw new Error(`An entry with name ${name} does not exist in the collection`); } return entry ?? undefined; } /* -------------------------------------------- */ /** * Transform each element of the Collection into a new form, returning an Array of transformed values * @param {function(*,number,Collection): *} transformer A transformation function applied to each entry value. * Positional arguments are the value, the index of iteration, and the collection being mapped. * @return {Array<*>} An Array of transformed values */ map(transformer) { const transformed = []; let i = 0; for ( let v of this.values() ) { transformed.push(transformer(v, i, this)); i++; } return transformed; } /* -------------------------------------------- */ /** * Reduce the Collection by applying an evaluator function and accumulating entries * @see {Array#reduce} * @param {function(*,*,number,Collection): *} reducer A reducer function applied to each entry value. Positional * arguments are the accumulator, the value, the index of iteration, and the collection being reduced. * @param {*} initial An initial value which accumulates with each iteration * @return {*} The accumulated result * * @example Reduce a collection to an array of transformed values * ```js * let c = new Collection([["a", "A"], ["b", "B"], ["c", "C"]]); * let letters = c.reduce((s, l) => { * return s + l; * }, ""); // "ABC" * ``` */ reduce(reducer, initial) { let accumulator = initial; let i = 0; for ( let v of this.values() ) { accumulator = reducer(accumulator, v, i, this); i++; } return accumulator; } /* -------------------------------------------- */ /** * Test whether a condition is met by some entry in the Collection. * @see {Array#some} * @param {function(*,number,Collection): boolean} condition The functional condition to test. Positional * arguments are the value, the index of iteration, and the collection being tested. * @return {boolean} Was the test condition passed by at least one entry? */ some(condition) { let i = 0; for ( let v of this.values() ) { const pass = condition(v, i, this); i++; if ( pass ) return true; } return false; } /* -------------------------------------------- */ /** * Convert the Collection to a primitive array of its contents. * @returns {object[]} An array of contained values */ toJSON() { return this.map(e => e.toJSON ? e.toJSON() : e); } } /** * An extension of the Collection. * Used for the specific task of containing embedded Document instances within a parent Document. */ 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} */ 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) {} } /** * This class provides a {@link Collection} wrapper around a singleton embedded Document so that it can be interacted * with via a common interface. */ 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; } } /** * 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. */ 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} */ #managedIds = new Set(); /* -------------------------------------------- */ /** * Maintain a list of IDs that are tombstone Documents. * @type {Set} */ #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} 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} 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); } } } /** * Determine the relative orientation of three points in two-dimensional space. * The result is also an approximation of twice the signed area of the triangle defined by the three points. * This method is fast - but not robust against issues of floating point precision. Best used with integer coordinates. * Adapted from https://github.com/mourner/robust-predicates. * @param {Point} a An endpoint of segment AB, relative to which point C is tested * @param {Point} b An endpoint of segment AB, relative to which point C is tested * @param {Point} c A point that is tested relative to segment AB * @returns {number} The relative orientation of points A, B, and C * A positive value if the points are in counter-clockwise order (C lies to the left of AB) * A negative value if the points are in clockwise order (C lies to the right of AB) * Zero if the points A, B, and C are collinear. */ function orient2dFast(a, b, c) { return (a.y - c.y) * (b.x - c.x) - (a.x - c.x) * (b.y - c.y); } /* -------------------------------------------- */ /** * Quickly test whether the line segment AB intersects with the line segment CD. * This method does not determine the point of intersection, for that use lineLineIntersection. * @param {Point} a The first endpoint of segment AB * @param {Point} b The second endpoint of segment AB * @param {Point} c The first endpoint of segment CD * @param {Point} d The second endpoint of segment CD * @returns {boolean} Do the line segments intersect? */ function lineSegmentIntersects(a, b, c, d) { // First test the orientation of A and B with respect to CD to reject collinear cases const xa = foundry.utils.orient2dFast(a, b, c); const xb = foundry.utils.orient2dFast(a, b, d); if ( !xa && !xb ) return false; const xab = (xa * xb) <= 0; // Also require an intersection of CD with respect to AB const xcd = (foundry.utils.orient2dFast(c, d, a) * foundry.utils.orient2dFast(c, d, b)) <= 0; return xab && xcd; } /* -------------------------------------------- */ /** * @typedef {Object} LineIntersection * @property {number} x The x-coordinate of intersection * @property {number} y The y-coordinate of intersection * @property {number} t0 The vector distance from A to B on segment AB * @property {number} [t1] The vector distance from C to D on segment CD */ /** * An internal helper method for computing the intersection between two infinite-length lines. * Adapted from http://paulbourke.net/geometry/pointlineplane/. * @param {Point} a The first endpoint of segment AB * @param {Point} b The second endpoint of segment AB * @param {Point} c The first endpoint of segment CD * @param {Point} d The second endpoint of segment CD * @param {object} [options] Options which affect the intersection test * @param {boolean} [options.t1=false] Return the optional vector distance from C to D on CD * @returns {LineIntersection|null} An intersection point, or null if no intersection occurred */ function lineLineIntersection(a, b, c, d, {t1=false}={}) { // If either line is length 0, they cannot intersect if (((a.x === b.x) && (a.y === b.y)) || ((c.x === d.x) && (c.y === d.y))) return null; // Check denominator - avoid parallel lines where d = 0 const dnm = ((d.y - c.y) * (b.x - a.x) - (d.x - c.x) * (b.y - a.y)); if (dnm === 0) return null; // Vector distances const t0 = ((d.x - c.x) * (a.y - c.y) - (d.y - c.y) * (a.x - c.x)) / dnm; t1 = t1 ? ((b.x - a.x) * (a.y - c.y) - (b.y - a.y) * (a.x - c.x)) / dnm : undefined; // Return the point of intersection return { x: a.x + t0 * (b.x - a.x), y: a.y + t0 * (b.y - a.y), t0: t0, t1: t1 } } /* -------------------------------------------- */ /** * An internal helper method for computing the intersection between two finite line segments. * Adapted from http://paulbourke.net/geometry/pointlineplane/ * @param {Point} a The first endpoint of segment AB * @param {Point} b The second endpoint of segment AB * @param {Point} c The first endpoint of segment CD * @param {Point} d The second endpoint of segment CD * @param {number} [epsilon] A small epsilon which defines a tolerance for near-equality * @returns {LineIntersection|null} An intersection point, or null if no intersection occurred */ function lineSegmentIntersection(a, b, c, d, epsilon=1e-8) { // If either line is length 0, they cannot intersect if (((a.x === b.x) && (a.y === b.y)) || ((c.x === d.x) && (c.y === d.y))) return null; // Check denominator - avoid parallel lines where d = 0 const dnm = ((d.y - c.y) * (b.x - a.x) - (d.x - c.x) * (b.y - a.y)); if (dnm === 0) return null; // Vector distance from a const t0 = ((d.x - c.x) * (a.y - c.y) - (d.y - c.y) * (a.x - c.x)) / dnm; if ( !Number.between(t0, 0-epsilon, 1+epsilon) ) return null; // Vector distance from c const t1 = ((b.x - a.x) * (a.y - c.y) - (b.y - a.y) * (a.x - c.x)) / dnm; if ( !Number.between(t1, 0-epsilon, 1+epsilon) ) return null; // Return the point of intersection and the vector distance from both line origins return { x: a.x + t0 * (b.x - a.x), y: a.y + t0 * (b.y - a.y), t0: Math.clamp(t0, 0, 1), t1: Math.clamp(t1, 0, 1) } } /* -------------------------------------------- */ /** * @typedef {Object} LineCircleIntersection * @property {boolean} aInside Is point A inside the circle? * @property {boolean} bInside Is point B inside the circle? * @property {boolean} contained Is the segment AB contained within the circle? * @property {boolean} outside Is the segment AB fully outside the circle? * @property {boolean} tangent Is the segment AB tangent to the circle? * @property {Point[]} intersections Intersection points: zero, one, or two */ /** * Determine the intersection between a line segment and a circle. * @param {Point} a The first vertex of the segment * @param {Point} b The second vertex of the segment * @param {Point} center The center of the circle * @param {number} radius The radius of the circle * @param {number} epsilon A small tolerance for floating point precision * @returns {LineCircleIntersection} The intersection of the segment AB with the circle */ function lineCircleIntersection(a, b, center, radius, epsilon=1e-8) { const r2 = Math.pow(radius, 2); let intersections = []; // Test whether endpoint A is contained const ar2 = Math.pow(a.x - center.x, 2) + Math.pow(a.y - center.y, 2); const aInside = ar2 < r2 - epsilon; // Test whether endpoint B is contained const br2 = Math.pow(b.x - center.x, 2) + Math.pow(b.y - center.y, 2); const bInside = br2 < r2 - epsilon; // Find quadratic intersection points const contained = aInside && bInside; if ( !contained ) intersections = quadraticIntersection(a, b, center, radius, epsilon); // Return the intersection data return { aInside, bInside, contained, outside: !contained && !intersections.length, tangent: !aInside && !bInside && intersections.length === 1, intersections }; } /* -------------------------------------------- */ /** * Identify the point closest to C on segment AB * @param {Point} c The reference point C * @param {Point} a Point A on segment AB * @param {Point} b Point B on segment AB * @returns {Point} The closest point to C on segment AB */ function closestPointToSegment(c, a, b) { const dx = b.x - a.x; const dy = b.y - a.y; if (( dx === 0 ) && ( dy === 0 )) { throw new Error("Zero-length segment AB not supported"); } const u = (((c.x - a.x) * dx) + ((c.y - a.y) * dy)) / (dx * dx + dy * dy); if ( u < 0 ) return a; if ( u > 1 ) return b; else return { x: a.x + (u * dx), y: a.y + (u * dy) } } /* -------------------------------------------- */ /** * Determine the points of intersection between a line segment (p0,p1) and a circle. * There will be zero, one, or two intersections * See https://math.stackexchange.com/a/311956. * @param {Point} p0 The initial point of the line segment * @param {Point} p1 The terminal point of the line segment * @param {Point} center The center of the circle * @param {number} radius The radius of the circle * @param {number} [epsilon=0] A small tolerance for floating point precision */ function quadraticIntersection(p0, p1, center, radius, epsilon=0) { const dx = p1.x - p0.x; const dy = p1.y - p0.y; // Quadratic terms where at^2 + bt + c = 0 const a = Math.pow(dx, 2) + Math.pow(dy, 2); const b = (2 * dx * (p0.x - center.x)) + (2 * dy * (p0.y - center.y)); const c = Math.pow(p0.x - center.x, 2) + Math.pow(p0.y - center.y, 2) - Math.pow(radius, 2); // Discriminant let disc2 = Math.pow(b, 2) - (4 * a * c); if ( disc2.almostEqual(0) ) disc2 = 0; // segment endpoint touches the circle; 1 intersection else if ( disc2 < 0 ) return []; // no intersections // Roots const disc = Math.sqrt(disc2); const t1 = (-b - disc) / (2 * a); // If t1 hits (between 0 and 1) it indicates an "entry" const intersections = []; if ( t1.between(0-epsilon, 1+epsilon) ) { intersections.push({ x: p0.x + (dx * t1), y: p0.y + (dy * t1) }); } if ( !disc2 ) return intersections; // 1 intersection // If t2 hits (between 0 and 1) it indicates an "exit" const t2 = (-b + disc) / (2 * a); if ( t2.between(0-epsilon, 1+epsilon) ) { intersections.push({ x: p0.x + (dx * t2), y: p0.y + (dy * t2) }); } return intersections; } /* -------------------------------------------- */ /** * Calculate the centroid non-self-intersecting closed polygon. * See https://en.wikipedia.org/wiki/Centroid#Of_a_polygon. * @param {Point[]|number[]} points The points of the polygon * @returns {Point} The centroid of the polygon */ function polygonCentroid(points) { const n = points.length; if ( n === 0 ) return {x: 0, y: 0}; let x = 0; let y = 0; let a = 0; if ( typeof points[0] === "number" ) { let x0 = points[n - 2]; let y0 = points[n - 1]; for ( let i = 0; i < n; i += 2 ) { const x1 = points[i]; const y1 = points[i + 1]; const z = (x0 * y1) - (x1 * y0); x += (x0 + x1) * z; y += (y0 + y1) * z; x0 = x1; y0 = y1; a += z; } } else { let {x: x0, y: y0} = points[n - 1]; for ( let i = 0; i < n; i++ ) { const {x: x1, y: y1} = points[i]; const z = (x0 * y1) - (x1 * y0); x += (x0 + x1) * z; y += (y0 + y1) * z; x0 = x1; y0 = y1; a += z; } } a *= 3; x /= a; y /= a; return {x, y}; } /* -------------------------------------------- */ /** * Test whether the circle given by the center and radius intersects the path (open or closed). * @param {Point[]|number[]} points The points of the path * @param {boolean} close If true, the edge from the last to the first point is tested * @param {Point} center The center of the circle * @param {number} radius The radius of the circle * @returns {boolean} Does the circle intersect the path? */ function pathCircleIntersects(points, close, center, radius) { const n = points.length; if ( n === 0 ) return false; const {x: cx, y: cy} = center; const rr = radius * radius; let i; let x0; let y0; if ( typeof points[0] === "number" ) { if ( close ) { i = 0; x0 = points[n - 2]; y0 = points[n - 1]; } else { i = 2; x0 = points[0]; y0 = points[1]; } for ( ; i < n; i += 2 ) { const x1 = points[i]; const y1 = points[i + 1]; let dx = cx - x0; let dy = cy - y0; const nx = x1 - x0; const ny = y1 - y0; const t = Math.clamp(((dx * nx) + (dy * ny)) / ((nx * nx) + (ny * ny)), 0, 1); dx = (t * nx) - dx; dy = (t * ny) - dy; if ( (dx * dx) + (dy * dy) <= rr ) return true; x0 = x1; y0 = y1; } } else { if ( close ) { i = 0; ({x: x0, y: y0} = points[n - 1]); } else { i = 1; ({x: x0, y: y0} = points[0]); } for ( ; i < n; i++ ) { const {x: x2, y: y2} = points[i]; let dx = cx - x0; let dy = cy - y0; const nx = x1 - x0; const ny = y1 - y0; const t = Math.clamp(((dx * nx) + (dy * ny)) / ((nx * nx) + (ny * ny)), 0, 1); dx = (t * nx) - dx; dy = (t * ny) - dy; if ( (dx * dx) + (dy * dy) <= rr ) return true; x0 = x2; y0 = y2; } } return false; } /* -------------------------------------------- */ /** * Test whether two circles (with position and radius) intersect. * @param {number} x0 x center coordinate of circle A. * @param {number} y0 y center coordinate of circle A. * @param {number} r0 radius of circle A. * @param {number} x1 x center coordinate of circle B. * @param {number} y1 y center coordinate of circle B. * @param {number} r1 radius of circle B. * @returns {boolean} True if the two circles intersect, false otherwise. */ function circleCircleIntersects(x0, y0, r0, x1, y1, r1) { return Math.hypot(x0 - x1, y0 - y1) <= (r0 + r1); } /** * A wrapper method around `fetch` that attaches an AbortController signal to the `fetch` call for clean timeouts * @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal#aborting_a_fetch_with_timeout_or_explicit_abort * @param {string} url The URL to make the Request to * @param {Object} data The data of the Request * @param {number|null} timeoutMs How long to wait for a Response before cleanly aborting. * If null, no timeout is applied * @param {function} onTimeout A method to invoke if and when the timeout is reached * @return {Promise} * @throws {HttpError} */ async function fetchWithTimeout(url, data = {}, {timeoutMs=30000, onTimeout = () => {}} = {}) { const controller = new AbortController(); data.signal = controller.signal; let timedOut = false; const enforceTimeout = timeoutMs !== null; // Enforce a timeout let timeout; if ( enforceTimeout ) { timeout = setTimeout(() => { timedOut = true; controller.abort(); onTimeout(); }, timeoutMs); } // Attempt the request let response; try { response = await fetch(url, data); } catch(err) { if ( timedOut ) { const timeoutS = Math.round(timeoutMs / 1000); const msg = game.i18n ? game.i18n.format("SETUP.ErrorTimeout", { url, timeout: timeoutS }) : `The request to ${url} timed out after ${timeoutS}s.`; throw new HttpError("Timed Out", 408, msg); } throw err; } finally { if ( enforceTimeout ) clearTimeout(timeout); } // Return the response if ( !response.ok && (response.type !== "opaqueredirect") ) { const responseBody = response.body ? await response.text() : ""; throw new HttpError(response.statusText, response.status, responseBody); } return response; } /* ----------------------------------------- */ /** * A small wrapper that automatically asks for JSON with a Timeout * @param {string} url The URL to make the Request to * @param {Object} data The data of the Request * @param {int} timeoutMs How long to wait for a Response before cleanly aborting * @param {function} onTimeout A method to invoke if and when the timeout is reached * @returns {Promise<*>} */ async function fetchJsonWithTimeout(url, data = {}, {timeoutMs=30000, onTimeout = () => {}} = {}) { let response = await fetchWithTimeout(url, data, {timeoutMs: timeoutMs, onTimeout: onTimeout}); return response.json(); } /* ----------------------------------------- */ /** * Represents an HTTP Error when a non-OK response is returned by Fetch * @extends {Error} */ class HttpError extends Error { constructor(statusText, code, displayMessage="") { super(statusText); this.code = code; this.displayMessage = displayMessage; } /* -------------------------------------------- */ /** @override */ toString() { return this.displayMessage; } } /** * @typedef {import("../types.mjs").Constructor} Constructor */ /** * @callback EmittedEventListener * @param {Event} event The emitted event * @returns {any} */ /** * Augment a base class with EventEmitter behavior. * @template {Constructor} BaseClass * @param {BaseClass} BaseClass Some base class augmented with event emitter functionality */ function EventEmitterMixin(BaseClass) { /** * A mixin class which implements the behavior of EventTarget. * This is useful in cases where a class wants EventTarget-like behavior but needs to extend some other class. * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget */ class EventEmitter extends BaseClass { /** * An array of event types which are valid for this class. * @type {string[]} */ static emittedEvents = []; /** * A mapping of registered events. * @type {Record>} */ #events = {}; /* -------------------------------------------- */ /** * Add a new event listener for a certain type of event. * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener * @param {string} type The type of event being registered for * @param {EmittedEventListener} listener The listener function called when the event occurs * @param {object} [options={}] Options which configure the event listener * @param {boolean} [options.once=false] Should the event only be responded to once and then removed */ addEventListener(type, listener, {once = false} = {}) { if ( !this.constructor.emittedEvents.includes(type) ) { throw new Error(`"${type}" is not a supported event of the ${this.constructor.name} class`); } this.#events[type] ||= new Map(); this.#events[type].set(listener, {fn: listener, once}); } /* -------------------------------------------- */ /** * Remove an event listener for a certain type of event. * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener * @param {string} type The type of event being removed * @param {EmittedEventListener} listener The listener function being removed */ removeEventListener(type, listener) { this.#events[type]?.delete(listener); } /* -------------------------------------------- */ /** * Dispatch an event on this target. * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent * @param {Event} event The Event to dispatch * @returns {boolean} Was default behavior for the event prevented? */ dispatchEvent(event) { if ( !(event instanceof Event) ) { throw new Error("EventEmitter#dispatchEvent must be provided an Event instance"); } if ( !this.constructor.emittedEvents.includes(event?.type) ) { throw new Error(`"${event.type}" is not a supported event of the ${this.constructor.name} class`); } const listeners = this.#events[event.type]; if ( !listeners ) return true; // Extend and configure the Event Object.defineProperties(event, { target: {value: this}, stopPropagation: {value: function() { event.propagationStopped = true; Event.prototype.stopPropagation.call(this); }}, stopImmediatePropagation: {value: function() { event.propagationStopped = true; Event.prototype.stopImmediatePropagation.call(this); }} }); // Call registered listeners for ( const listener of listeners.values() ) { listener.fn(event); if ( listener.once ) this.removeEventListener(event.type, listener.fn); if ( event.propagationStopped ) break; } return event.defaultPrevented; } } return EventEmitter; } /** * Stores a map of objects with weak references to the keys, allowing them to be garbage collected. Both keys and values * can be iterated over, unlike a WeakMap. */ class IterableWeakMap extends WeakMap { /** * @typedef {object} IterableWeakMapHeldValue * @property {Set>} set The set to be cleaned. * @property {WeakRef} ref The ref to remove. */ /** * @typedef {object} IterableWeakMapValue * @property {any} value The value. * @property {WeakRef} ref The weak ref of the key. */ /** * A set of weak refs to the map's keys, allowing enumeration. * @type {Set>} */ #refs = new Set(); /** * A FinalizationRegistry instance to clean up the ref set when objects are garbage collected. * @type {FinalizationRegistry} */ #finalizer = new FinalizationRegistry(IterableWeakMap.#cleanup); /** * @param {Iterable<[any, any]>} [entries] The initial entries. */ constructor(entries=[]) { super(); for ( const [key, value] of entries ) this.set(key, value); } /* -------------------------------------------- */ /** * Clean up the corresponding ref in the set when its value is garbage collected. * @param {IterableWeakMapHeldValue} heldValue The value held by the finalizer. */ static #cleanup({ set, ref }) { set.delete(ref); } /* -------------------------------------------- */ /** * Remove a key from the map. * @param {any} key The key to remove. * @returns {boolean} */ delete(key) { const entry = super.get(key); if ( !entry ) return false; super.delete(key); this.#refs.delete(entry.ref); this.#finalizer.unregister(key); return true; } /* -------------------------------------------- */ /** * Retrieve a value from the map. * @param {any} key The value's key. * @returns {any} */ get(key) { const entry = super.get(key); return entry && entry.value; } /* -------------------------------------------- */ /** * Place a value in the map. * @param {any} key The key. * @param {any} value The value. * @returns {IterableWeakMap} */ set(key, value) { const entry = super.get(key); if ( entry ) this.#refs.delete(entry.ref); const ref = new WeakRef(key); super.set(key, { value, ref }); this.#refs.add(ref); this.#finalizer.register(key, { ref, set: this.#refs }, key); return this; } /* -------------------------------------------- */ /** * Clear all values from the map. */ clear() { for ( const ref of this.#refs ) { const key = ref.deref(); if ( key ) this.delete(key); else this.#refs.delete(ref); } } /* -------------------------------------------- */ /** * Enumerate the entries. * @returns {Generator<[any, any], void, any>} */ *[Symbol.iterator]() { for ( const ref of this.#refs ) { const key = ref.deref(); if ( !key ) continue; const { value } = super.get(key); yield [key, value]; } } /* -------------------------------------------- */ /** * Enumerate the entries. * @returns {Generator<[any, any], void, any>} */ entries() { return this[Symbol.iterator](); } /* -------------------------------------------- */ /** * Enumerate the keys. * @returns {Generator} */ *keys() { for ( const [key] of this ) yield key; } /* -------------------------------------------- */ /** * Enumerate the values. * @returns {Generator} */ *values() { for ( const [, value] of this ) yield value; } } /** * Stores a set of objects with weak references to them, allowing them to be garbage collected. Can be iterated over, * unlike a WeakSet. */ class IterableWeakSet extends WeakSet { /** * The backing iterable weak map. * @type {IterableWeakMap} */ #map = new IterableWeakMap(); /** * @param {Iterable} [entries] The initial entries. */ constructor(entries=[]) { super(); for ( const entry of entries ) this.add(entry); } /* -------------------------------------------- */ /** * Enumerate the values. * @returns {Generator} */ [Symbol.iterator]() { return this.values(); } /* -------------------------------------------- */ /** * Add a value to the set. * @param {any} value The value to add. * @returns {IterableWeakSet} */ add(value) { this.#map.set(value, value); return this; } /* -------------------------------------------- */ /** * Delete a value from the set. * @param {any} value The value to delete. * @returns {boolean} */ delete(value) { return this.#map.delete(value); } /* -------------------------------------------- */ /** * Whether this set contains the given value. * @param {any} value The value to test. * @returns {boolean} */ has(value) { return this.#map.has(value); } /* -------------------------------------------- */ /** * Enumerate the collection. * @returns {Generator} */ values() { return this.#map.values(); } /* -------------------------------------------- */ /** * Clear all values from the set. */ clear() { this.#map.clear(); } } /** * A simple Semaphore implementation which provides a limited queue for ensuring proper concurrency. * @param {number} [max=1] The maximum number of tasks which are allowed concurrently. * * @example Using a Semaphore * ```js * // Some async function that takes time to execute * function fn(x) { * return new Promise(resolve => { * setTimeout(() => { * console.log(x); * resolve(x); * }, 1000)); * } * }; * * // Create a Semaphore and add many concurrent tasks * const semaphore = new Semaphore(1); * for ( let i of Array.fromRange(100) ) { * semaphore.add(fn, i); * } * ``` */ class Semaphore { constructor(max=1) { /** * The maximum number of tasks which can be simultaneously attempted. * @type {number} */ this.max = max; /** * A queue of pending function signatures * @type {Array>} * @private */ this._queue = []; /** * The number of tasks which are currently underway * @type {number} * @private */ this._active = 0; } /** * The number of pending tasks remaining in the queue * @type {number} */ get remaining() { return this._queue.length; } /** * The number of actively executing tasks * @type {number} */ get active() { return this._active; } /** * Add a new tasks to the managed queue * @param {Function} fn A callable function * @param {...*} [args] Function arguments * @returns {Promise} A promise that resolves once the added function is executed */ add(fn, ...args) { return new Promise((resolve, reject) => { this._queue.push([fn, args, resolve, reject]); return this._try(); }); } /** * Abandon any tasks which have not yet concluded */ clear() { this._queue = []; } /** * Attempt to perform a task from the queue. * If all workers are busy, do nothing. * If successful, try again. * @private */ async _try() { if ( (this.active === this.max) || !this.remaining ) return false; // Obtain the next task from the queue const next = this._queue.shift(); if ( !next ) return; this._active += 1; // Try and execute it, resolving its promise const [fn, args, resolve, reject] = next; try { const r = await fn(...args); resolve(r); } catch(err) { reject(err); } // Try the next function in the queue this._active -= 1; return this._try(); } } /** * Create a new BitMask instance. * @param {Record} [states=null] An object containing valid states and their corresponding initial boolean values (default is null). */ class BitMask extends Number { constructor(states=null) { super(); this.#generateValidStates(states); this.#generateEnum(); this.#value = this.#computeValue(states); } /** * The real value behind the bitmask instance. * @type {number} */ #value; /** * The structure of valid states and their associated values. * @type {Map} */ #validStates; /** * The enum associated with this structure. * @type {Record} * @readonly */ states; /* -------------------------------------------- */ /* Internals */ /* -------------------------------------------- */ /** * Generates the valid states and their associated values. * @param {Record} [states=null] The structure defining the valid states and their associated values. */ #generateValidStates(states) { this.#validStates = new Map(); let bitIndex = 0; for ( const state of Object.keys(states || {}) ) { if ( bitIndex >= 32 ) throw new Error("A bitmask can't handle more than 32 states"); this.#validStates.set(state, 1 << bitIndex++); } } /* -------------------------------------------- */ /** * Generates an enum based on the provided valid states. */ #generateEnum() { this.states = {}; for ( const state of this.#validStates.keys() ) this.states[state] = state; Object.freeze(this.states); } /* -------------------------------------------- */ /** * Calculate the default value of the bitmask based on the initial states * @param {Record} [initialStates={}] The structure defining the valid states and their associated values. * @returns {number} */ #computeValue(initialStates={}) { let defaultValue = 0; for ( const state in initialStates ) { if ( !initialStates.hasOwnProperty(state) ) continue; this.#checkState(state); if ( initialStates[state] ) defaultValue |= this.#validStates.get(state); } return defaultValue; } /* -------------------------------------------- */ /** * Checks a state and throws an error if it doesn't exist. * @param {string} state Name of the state to check. */ #checkState(state) { if ( !this.#validStates.has(state) ) { throw new Error(`${state} is an invalid state for this BitMask instance: ${this.toJSON()}`); } } /* -------------------------------------------- */ /* Properties */ /* -------------------------------------------- */ /** * True if this bitmask is empty (no active states). * @type {boolean} */ get isEmpty() { return this.#value === 0; } /* -------------------------------------------- */ /* Methods for Handling states */ /* -------------------------------------------- */ /** * Check if a specific state is active. * @param {string} state The state to check. * @returns {boolean} True if the state is active, false otherwise. */ hasState(state) { return (this.#value & this.#validStates.get(state)) !== 0; } /* -------------------------------------------- */ /** * Add a state to the bitmask. * @param {string} state The state to add. * @throws {Error} Throws an error if the provided state is not valid. */ addState(state) { this.#checkState(state); this.#value |= this.#validStates.get(state); } /* -------------------------------------------- */ /** * Remove a state from the bitmask. * @param {string} state The state to remove. * @throws {Error} Throws an error if the provided state is not valid. */ removeState(state) { this.#checkState(state); this.#value &= ~this.#validStates.get(state); } /* -------------------------------------------- */ /** * Toggle the state of a specific state in the bitmask. * @param {string} state The state to toggle. * @param {boolean} [enabled] Toggle on (true) or off (false)? If undefined, the state is switched automatically. * @throws {Error} Throws an error if the provided state is not valid. */ toggleState(state, enabled) { this.#checkState(state); if ( enabled === undefined ) return (this.#value ^= this.#validStates.get(state)); if ( enabled ) this.addState(state); else this.removeState(state); } /* -------------------------------------------- */ /** * Clear the bitmask, setting all states to inactive. */ clear() { this.#value = 0; } /* -------------------------------------------- */ /* bitmask representations */ /* -------------------------------------------- */ /** * Get the current value of the bitmask. * @returns {number} The current value of the bitmask. */ valueOf() { return this.#value; } /* -------------------------------------------- */ /** * Get a string representation of the bitmask in binary format. * @returns {string} The string representation of the bitmask. */ toString() { return String(this.#value.toString(2)).padStart(this.#validStates.size, '0'); } /* -------------------------------------------- */ /** * Checks if two bitmasks structures are compatible (the same valid states). * @param {BitMask} otherBitMask The bitmask structure to compare with. * @returns {boolean} True if the two bitmasks have the same structure, false otherwise. */ isCompatible(otherBitMask) { const states1 = Array.from(this.#validStates.keys()).sort().join(','); const states2 = Array.from(otherBitMask.#validStates.keys()).sort().join(','); return states1 === states2; } /* -------------------------------------------- */ /** * Serializes the bitmask to a JSON string. * @returns {string} The JSON string representing the bitmask. */ toJSON() { return JSON.stringify(this.toObject()); } /* -------------------------------------------- */ /** * Creates a new BitMask instance from a JSON string. * @param {string} jsonString The JSON string representing the bitmask. * @returns {BitMask} A new BitMask instance created from the JSON string. */ static fromJSON(jsonString) { const data = JSON.parse(jsonString); return new BitMask(data); } /* -------------------------------------------- */ /** * Convert value of this BitMask to object representation according to structure. * @returns {Object} The data represented by the bitmask. */ toObject() { const result = {}; for ( const [validState, value] of this.#validStates ) result[validState] = ((this.#value & value) !== 0); return result; } /* -------------------------------------------- */ /** * Creates a clone of this BitMask instance. * @returns {BitMask} A new BitMask instance with the same value and valid states as this instance. */ clone() { return new BitMask(this.toObject()); } /* -------------------------------------------- */ /* Static Helpers */ /* -------------------------------------------- */ /** * Generates shader constants based on the provided states. * @param {string[]} states An array containing valid states. * @returns {string} Shader bit mask constants generated from the states. */ static generateShaderBitMaskConstants(states) { let shaderConstants = ''; let bitIndex = 0; for ( const state of states ) { shaderConstants += `const uint ${state.toUpperCase()} = 0x${(1 << bitIndex).toString(16).toUpperCase()}U;\n`; bitIndex++; } return shaderConstants; } } /** * A string tree node consists of zero-or-more string keys, and a leaves property that contains any objects that * terminate at the current node. * @typedef {object} StringTreeNode */ /** * @callback StringTreeEntryFilter * @param {any} entry The entry to filter. * @returns {boolean} Whether the entry should be included in the result set. */ /** * A data structure representing a tree of string nodes with arbitrary object leaves. */ class StringTree { /** * The key symbol that stores the leaves of any given node. * @type {symbol} */ static get leaves() { return StringTree.#leaves; } static #leaves = Symbol(); /* -------------------------------------------- */ /** * The tree's root. * @type {StringTreeNode} */ #root = this.#createNode(); /* -------------------------------------------- */ /** * Create a new node. * @returns {StringTreeNode} */ #createNode() { return { [StringTree.leaves]: [] }; } /* -------------------------------------------- */ /** * Insert an entry into the tree. * @param {string[]} strings The string parents for the entry. * @param {any} entry The entry to store. * @returns {StringTreeNode} The node the entry was added to. */ addLeaf(strings, entry) { let node = this.#root; for ( const string of strings ) { node[string] ??= this.#createNode(); node = node[string]; } // Once we've traversed the tree, we add our entry. node[StringTree.leaves].push(entry); return node; } /* -------------------------------------------- */ /** * Traverse the tree along the given string path and return any entries reachable from the node. * @param {string[]} strings The string path to the desired node. * @param {object} [options] * @param {number} [options.limit] The maximum number of items to retrieve. * @param {StringTreeEntryFilter} [options.filterEntries] A filter function to apply to each candidate entry. * @returns {any[]} */ lookup(strings, { limit, filterEntries }={}) { const entries = []; const node = this.nodeAtPrefix(strings); if ( !node ) return []; // No matching entries. const queue = [node]; while ( queue.length ) { if ( limit && (entries.length >= limit) ) break; this._breadthFirstSearch(queue.shift(), entries, queue, { limit, filterEntries }); } return entries; } /* -------------------------------------------- */ /** * Returns the node at the given path through the tree. * @param {string[]} strings The string path to the desired node. * @param {object} [options] * @param {boolean} [options.hasLeaves=false] Only return the most recently visited node that has leaves, otherwise * return the exact node at the prefix, if it exists. * @returns {StringTreeNode|void} */ nodeAtPrefix(strings, { hasLeaves=false }={}) { let node = this.#root; let withLeaves = node; for ( const string of strings ) { if ( !(string in node) ) return hasLeaves ? withLeaves : undefined; node = node[string]; if ( node[StringTree.leaves].length ) withLeaves = node; } return hasLeaves ? withLeaves : node; } /* -------------------------------------------- */ /** * Perform a breadth-first search starting from the given node and retrieving any entries reachable from that node, * until we reach the limit. * @param {StringTreeNode} node The starting node. * @param {any[]} entries The accumulated entries. * @param {StringTreeNode[]} queue The working queue of nodes to search. * @param {object} [options] * @param {number} [options.limit] The maximum number of entries to retrieve before stopping. * @param {StringTreeEntryFilter} [options.filterEntries] A filter function to apply to each candidate entry. * @protected */ _breadthFirstSearch(node, entries, queue, { limit, filterEntries }={}) { // Retrieve the entries at this node. let leaves = node[StringTree.leaves]; if ( filterEntries instanceof Function ) leaves = leaves.filter(filterEntries); entries.push(...leaves); if ( limit && (entries.length >= limit) ) return; // Push this node's children onto the end of the queue. for ( const key of Object.keys(node) ) { if ( typeof key === "string" ) queue.push(node[key]); } } } /** * @typedef {import("./string-tree.mjs").StringTreeNode} StringTreeNode */ /** * A leaf entry in the tree. * @typedef {object} WordTreeEntry * @property {Document|object} entry An object that this entry represents. * @property {string} documentName The document type. * @property {string} uuid The document's UUID. * @property {string} [pack] The pack ID. */ /** * A data structure for quickly retrieving objects by a string prefix. * Note that this works well for languages with alphabets (latin, cyrillic, korean, etc.), but may need more nuanced * handling for languages that compose characters and letters. * @extends {StringTree} */ class WordTree extends StringTree { /** * Insert an entry into the tree. * @param {string} string The string key for the entry. * @param {WordTreeEntry} entry The entry to store. * @returns {StringTreeNode} The node the entry was added to. */ addLeaf(string, entry) { string = string.toLocaleLowerCase(game.i18n.lang); return super.addLeaf(Array.from(string), entry); } /* -------------------------------------------- */ /** * Return entries that match the given string prefix. * @param {string} prefix The prefix. * @param {object} [options] Additional options to configure behaviour. * @param {number} [options.limit=10] The maximum number of items to retrieve. It is important to set this value as * very short prefixes will naturally match large numbers of entries. * @param {StringTreeEntryFilter} [options.filterEntries] A filter function to apply to each candidate entry. * @returns {WordTreeEntry[]} A number of entries that have the given prefix. */ lookup(prefix, { limit=10, filterEntries }={}) { return super.lookup(prefix, { limit, filterEntries }); } /* -------------------------------------------- */ /** * Returns the node at the given prefix. * @param {string} prefix The prefix. * @returns {StringTreeNode} */ nodeAtPrefix(prefix) { prefix = prefix.toLocaleLowerCase(game.i18n.lang); return super.nodeAtPrefix(Array.from(prefix)); } } /** * The constructor of an async function. * @type {typeof AsyncFunction} */ const AsyncFunction = (async function() {}).constructor; var utils = /*#__PURE__*/Object.freeze({ __proto__: null, AsyncFunction: AsyncFunction, BitMask: BitMask, Collection: Collection, Color: Color$1, EventEmitterMixin: EventEmitterMixin, HttpError: HttpError, IterableWeakMap: IterableWeakMap, IterableWeakSet: IterableWeakSet, Semaphore: Semaphore, StringTree: StringTree, WordTree: WordTree, benchmark: benchmark, circleCircleIntersects: circleCircleIntersects, closestPointToSegment: closestPointToSegment, debounce: debounce, debouncedReload: debouncedReload, deepClone: deepClone, diffObject: diffObject, duplicate: duplicate, encodeURL: encodeURL, expandObject: expandObject, fetchJsonWithTimeout: fetchJsonWithTimeout, fetchWithTimeout: fetchWithTimeout, filterObject: filterObject, flattenObject: flattenObject, formatFileSize: formatFileSize, getDefiningClass: getDefiningClass, getParentClasses: getParentClasses, getProperty: getProperty, getRoute: getRoute, getType: getType, hasProperty: hasProperty, invertObject: invertObject, isEmpty: isEmpty$1, isNewerVersion: isNewerVersion, isSubclass: isSubclass, lineCircleIntersection: lineCircleIntersection, lineLineIntersection: lineLineIntersection, lineSegmentIntersection: lineSegmentIntersection, lineSegmentIntersects: lineSegmentIntersects, logCompatibilityWarning: logCompatibilityWarning, mergeObject: mergeObject, objectsEqual: objectsEqual, orient2dFast: orient2dFast, parseS3URL: parseS3URL, parseUuid: parseUuid, pathCircleIntersects: pathCircleIntersects, polygonCentroid: polygonCentroid, quadraticIntersection: quadraticIntersection, randomID: randomID, setProperty: setProperty, threadLock: threadLock, throttle: throttle, timeSince: timeSince }); /** * This module contains data field classes which are used to define a data schema. * A data field is responsible for cleaning, validation, and initialization of the value assigned to it. * Each data field extends the [DataField]{@link DataField} class to implement logic specific to its * contained data type. * @module fields */ /* ---------------------------------------- */ /* Abstract Data Field */ /* ---------------------------------------- */ /** * @callback DataFieldValidator * A Custom DataField validator function. * * A boolean return value indicates that the value is valid (true) or invalid (false) with certainty. With an explicit * boolean return value no further validation functions will be evaluated. * * An undefined return indicates that the value may be valid but further validation functions should be performed, * if defined. * * An Error may be thrown which provides a custom error message explaining the reason the value is invalid. * * @param {any} value The value provided for validation * @param {DataFieldValidationOptions} options Validation options * @returns {boolean|void} * @throws {Error} */ /** * @typedef {Object} DataFieldOptions * @property {boolean} [required=false] Is this field required to be populated? * @property {boolean} [nullable=false] Can this field have null values? * @property {boolean} [gmOnly=false] Can this field only be modified by a gamemaster or assistant gamemaster? * @property {Function|*} [initial] The initial value of a field, or a function which assigns that initial value. * @property {string} [label] A localizable label displayed on forms which render this field. * @property {string} [hint] Localizable help text displayed on forms which render this field. * @property {DataFieldValidator} [validate] A custom data field validation function. * @property {string} [validationError] A custom validation error string. When displayed will be prepended with the * document name, field name, and candidate value. This error string is only * used when the return type of the validate function is a boolean. If an Error * is thrown in the validate function, the string message of that Error is used. */ /** * @typedef {Object} DataFieldContext * @property {string} [name] A field name to assign to the constructed field * @property {DataField} [parent] Another data field which is a hierarchical parent of this one */ /** * @typedef {object} DataFieldValidationOptions * @property {boolean} [partial] Whether this is a partial schema validation, or a complete one. * @property {boolean} [fallback] Whether to allow replacing invalid values with valid fallbacks. * @property {object} [source] The full source object being evaluated. * @property {boolean} [dropInvalidEmbedded] 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. */ /** * An abstract class that defines the base pattern for a data field within a data schema. * @abstract * @property {string} name The name of this data field within the schema that contains it. * @mixes DataFieldOptions */ class DataField { /** * @param {DataFieldOptions} [options] Options which configure the behavior of the field * @param {DataFieldContext} [context] Additional context which describes the field */ constructor(options={}, {name, parent}={}) { this.name = name; this.parent = parent; this.options = options; for ( let k in this.constructor._defaults ) { this[k] = k in this.options ? this.options[k] : this.constructor._defaults[k]; } } /** * The field name of this DataField instance. * This is assigned by SchemaField#initialize. * @internal */ name; /** * A reference to the parent schema to which this DataField belongs. * This is assigned by SchemaField#initialize. * @internal */ parent; /** * The initially provided options which configure the data field * @type {DataFieldOptions} */ options; /** * Whether this field defines part of a Document/Embedded Document hierarchy. * @type {boolean} */ static hierarchical = false; /** * Does this field type contain other fields in a recursive structure? * Examples of recursive fields are SchemaField, ArrayField, or TypeDataField * Examples of non-recursive fields are StringField, NumberField, or ObjectField * @type {boolean} */ static recursive = false; /** * Default parameters for this field type * @return {DataFieldOptions} * @protected */ static get _defaults() { return { required: false, nullable: false, initial: undefined, readonly: false, gmOnly: false, label: "", hint: "", validationError: "is not a valid value" } } /** * A dot-separated string representation of the field path within the parent schema. * @type {string} */ get fieldPath() { return [this.parent?.fieldPath, this.name].filterJoin("."); } /** * Apply a function to this DataField which propagates through recursively to any contained data schema. * @param {string|function} fn The function to apply * @param {*} value The current value of this field * @param {object} [options={}] Additional options passed to the applied function * @returns {object} The results object */ apply(fn, value, options={}) { if ( typeof fn === "string" ) fn = this[fn]; return fn.call(this, value, options); } /* -------------------------------------------- */ /* Field Cleaning */ /* -------------------------------------------- */ /** * Coerce source data to ensure that it conforms to the correct data type for the field. * Data coercion operations should be simple and synchronous as these are applied whenever a DataModel is constructed. * For one-off cleaning of user-provided input the sanitize method should be used. * @param {*} value The initial value * @param {object} [options] Additional options for how the field is cleaned * @param {boolean} [options.partial] Whether to perform partial cleaning? * @param {object} [options.source] The root data model being cleaned * @returns {*} The cast value */ clean(value, options={}) { // Permit explicitly null values for nullable fields if ( value === null ) { if ( this.nullable ) return value; value = undefined; } // Get an initial value for the field if ( value === undefined ) return this.getInitialValue(options.source); // Cast a provided value to the correct type value = this._cast(value); // Cleaning logic specific to the DataField. return this._cleanType(value, options); } /* -------------------------------------------- */ /** * Apply any cleaning logic specific to this DataField type. * @param {*} value The appropriately coerced value. * @param {object} [options] Additional options for how the field is cleaned. * @returns {*} The cleaned value. * @protected */ _cleanType(value, options) { return value; } /* -------------------------------------------- */ /** * Cast a non-default value to ensure it is the correct type for the field * @param {*} value The provided non-default value * @returns {*} The standardized value * @protected */ _cast(value) { throw new Error(`Subclasses of DataField must implement the _cast method`); } /* -------------------------------------------- */ /** * Attempt to retrieve a valid initial value for the DataField. * @param {object} data The source data object for which an initial value is required * @returns {*} A valid initial value * @throws An error if there is no valid initial value defined */ getInitialValue(data) { return this.initial instanceof Function ? this.initial(data) : this.initial; } /* -------------------------------------------- */ /* Field Validation */ /* -------------------------------------------- */ /** * Validate a candidate input for this field, ensuring it meets the field requirements. * A validation failure can be provided as a raised Error (with a string message), by returning false, or by returning * a DataModelValidationFailure instance. * A validator which returns true denotes that the result is certainly valid and further validations are unnecessary. * @param {*} value The initial value * @param {DataFieldValidationOptions} [options={}] Options which affect validation behavior * @returns {DataModelValidationFailure} Returns a DataModelValidationFailure if a validation failure * occurred. */ validate(value, options={}) { const validators = [this._validateSpecial, this._validateType]; if ( this.options.validate ) validators.push(this.options.validate); try { for ( const validator of validators ) { const isValid = validator.call(this, value, options); if ( isValid === true ) return undefined; if ( isValid === false ) { return new DataModelValidationFailure({ invalidValue: value, message: this.validationError, unresolved: true }); } if ( isValid instanceof DataModelValidationFailure ) return isValid; } } catch(err) { return new DataModelValidationFailure({invalidValue: value, message: err.message, unresolved: true}); } } /* -------------------------------------------- */ /** * Special validation rules which supersede regular field validation. * This validator screens for certain values which are otherwise incompatible with this field like null or undefined. * @param {*} value The candidate value * @returns {boolean|void} A boolean to indicate with certainty whether the value is valid. * Otherwise, return void. * @throws May throw a specific error if the value is not valid * @protected */ _validateSpecial(value) { // Allow null values for explicitly nullable fields if ( value === null ) { if ( this.nullable ) return true; else throw new Error("may not be null"); } // Allow undefined if the field is not required if ( value === undefined ) { if ( this.required ) throw new Error("may not be undefined"); else return true; } } /* -------------------------------------------- */ /** * A default type-specific validator that can be overridden by child classes * @param {*} value The candidate value * @param {DataFieldValidationOptions} [options={}] Options which affect validation behavior * @returns {boolean|DataModelValidationFailure|void} A boolean to indicate with certainty whether the value is * valid, or specific DataModelValidationFailure information, * otherwise void. * @throws May throw a specific error if the value is not valid * @protected */ _validateType(value, options={}) {} /* -------------------------------------------- */ /** * Certain fields may declare joint data validation criteria. * This method will only be called if the field is designated as recursive. * @param {object} data Candidate data for joint model validation * @param {object} options Options which modify joint model validation * @throws An error if joint model validation fails * @internal */ _validateModel(data, options={}) {} /* -------------------------------------------- */ /* Initialization and Serialization */ /* -------------------------------------------- */ /** * Initialize the original source data into a mutable copy for the DataModel instance. * @param {*} value The source value of the field * @param {Object} model The DataModel instance that this field belongs to * @param {object} [options] Initialization options * @returns {*} An initialized copy of the source data */ initialize(value, model, options={}) { return value; } /** * Export the current value of the field into a serializable object. * @param {*} value The initialized value of the field * @returns {*} An exported representation of the field */ toObject(value) { return value; } /** * Recursively traverse a schema and retrieve a field specification by a given path * @param {string[]} path The field path as an array of strings * @internal */ _getField(path) { return path.length ? undefined : this; } /* -------------------------------------------- */ /* Form Field Integration */ /* -------------------------------------------- */ /** * Does this form field class have defined form support? * @type {boolean} */ static get hasFormSupport() { return this.prototype._toInput !== DataField.prototype._toInput; } /* -------------------------------------------- */ /** * Render this DataField as an HTML element. * @param {FormInputConfig} config Form element configuration parameters * @throws {Error} An Error if this DataField subclass does not support input rendering * @returns {HTMLElement|HTMLCollection} A rendered HTMLElement for the field */ toInput(config={}) { const inputConfig = {name: this.fieldPath, ...config}; if ( inputConfig.input instanceof Function ) return config.input(this, inputConfig); return this._toInput(inputConfig); } /* -------------------------------------------- */ /** * Render this DataField as an HTML element. * Subclasses should implement this method rather than the public toInput method which wraps it. * @param {FormInputConfig} config Form element configuration parameters * @throws {Error} An Error if this DataField subclass does not support input rendering * @returns {HTMLElement|HTMLCollection} A rendered HTMLElement for the field * @protected */ _toInput(config) { throw new Error(`The ${this.constructor.name} class does not implement the _toInput method`); } /* -------------------------------------------- */ /** * Render this DataField as a standardized form-group element. * @param {FormGroupConfig} groupConfig Configuration options passed to the wrapping form-group * @param {FormInputConfig} inputConfig Input element configuration options passed to DataField#toInput * @returns {HTMLDivElement} The rendered form group element */ toFormGroup(groupConfig={}, inputConfig={}) { if ( groupConfig.widget instanceof Function ) return groupConfig.widget(this, groupConfig, inputConfig); groupConfig.label ??= this.label ?? this.fieldPath; groupConfig.hint ??= this.hint; groupConfig.input ??= this.toInput(inputConfig); return foundry.applications.fields.createFormGroup(groupConfig); } /* -------------------------------------------- */ /* Active Effect Integration */ /* -------------------------------------------- */ /** * Apply an ActiveEffectChange to this field. * @param {*} value The field's current value. * @param {DataModel} model The model instance. * @param {EffectChangeData} change The change to apply. * @returns {*} The updated value. */ applyChange(value, model, change) { const delta = this._castChangeDelta(change.value); switch ( change.mode ) { case CONST.ACTIVE_EFFECT_MODES.ADD: return this._applyChangeAdd(value, delta, model, change); case CONST.ACTIVE_EFFECT_MODES.MULTIPLY: return this._applyChangeMultiply(value, delta, model, change); case CONST.ACTIVE_EFFECT_MODES.OVERRIDE: return this._applyChangeOverride(value, delta, model, change); case CONST.ACTIVE_EFFECT_MODES.UPGRADE: return this._applyChangeUpgrade(value, delta, model, change); case CONST.ACTIVE_EFFECT_MODES.DOWNGRADE: return this._applyChangeDowngrade(value, delta, model, change); } return this._applyChangeCustom(value, delta, model, change); } /* -------------------------------------------- */ /** * Cast a change delta into an appropriate type to be applied to this field. * @param {*} delta The change delta. * @returns {*} * @internal */ _castChangeDelta(delta) { return this._cast(delta); } /* -------------------------------------------- */ /** * Apply an ADD change to this field. * @param {*} value The field's current value. * @param {*} delta The change delta. * @param {DataModel} model The model instance. * @param {EffectChangeData} change The original change data. * @returns {*} The updated value. * @protected */ _applyChangeAdd(value, delta, model, change) { return value + delta; } /* -------------------------------------------- */ /** * Apply a MULTIPLY change to this field. * @param {*} value The field's current value. * @param {*} delta The change delta. * @param {DataModel} model The model instance. * @param {EffectChangeData} change The original change data. * @returns {*} The updated value. * @protected */ _applyChangeMultiply(value, delta, model, change) {} /* -------------------------------------------- */ /** * Apply an OVERRIDE change to this field. * @param {*} value The field's current value. * @param {*} delta The change delta. * @param {DataModel} model The model instance. * @param {EffectChangeData} change The original change data. * @returns {*} The updated value. * @protected */ _applyChangeOverride(value, delta, model, change) { return delta; } /* -------------------------------------------- */ /** * Apply an UPGRADE change to this field. * @param {*} value The field's current value. * @param {*} delta The change delta. * @param {DataModel} model The model instance. * @param {EffectChangeData} change The original change data. * @returns {*} The updated value. * @protected */ _applyChangeUpgrade(value, delta, model, change) {} /* -------------------------------------------- */ /** * Apply a DOWNGRADE change to this field. * @param {*} value The field's current value. * @param {*} delta The change delta. * @param {DataModel} model The model instance. * @param {EffectChangeData} change The original change data. * @returns {*} The updated value. * @protected */ _applyChangeDowngrade(value, delta, model, change) {} /* -------------------------------------------- */ /** * Apply a CUSTOM change to this field. * @param {*} value The field's current value. * @param {*} delta The change delta. * @param {DataModel} model The model instance. * @param {EffectChangeData} change The original change data. * @returns {*} The updated value. * @protected */ _applyChangeCustom(value, delta, model, change) { const preHook = foundry.utils.getProperty(model, change.key); Hooks.call("applyActiveEffect", model, change, value, delta, {}); const postHook = foundry.utils.getProperty(model, change.key); if ( postHook !== preHook ) return postHook; } } /* -------------------------------------------- */ /* Data Schema Field */ /* -------------------------------------------- */ /** * A special class of {@link DataField} which defines a data schema. */ class SchemaField extends DataField { /** * @param {DataSchema} fields The contained field definitions * @param {DataFieldOptions} [options] Options which configure the behavior of the field * @param {DataFieldContext} [context] Additional context which describes the field */ constructor(fields, options, context={}) { super(options, context); this.fields = this._initialize(fields); } /* -------------------------------------------- */ /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, { required: true, nullable: false, initial() { return this.clean({}); } }); } /** @override */ static recursive = true; /* -------------------------------------------- */ /** * The contained field definitions. * @type {DataSchema} */ fields; /** * Any unknown keys encountered during the last cleaning. * @type {string[]} */ unknownKeys; /* -------------------------------------------- */ /** * Initialize and validate the structure of the provided field definitions. * @param {DataSchema} fields The provided field definitions * @returns {DataSchema} The validated schema * @protected */ _initialize(fields) { if ( (typeof fields !== "object") ) { throw new Error("A DataSchema must be an object with string keys and DataField values."); } fields = {...fields}; for ( const [name, field] of Object.entries(fields) ) { if ( !(field instanceof DataField) ) { throw new Error(`The "${name}" field is not an instance of the DataField class.`); } if ( field.parent !== undefined ) { throw new Error(`The "${field.fieldPath}" field already belongs to some other parent and may not be reused.`); } field.name = name; field.parent = this; } return fields; } /* -------------------------------------------- */ /* Schema Iteration */ /* -------------------------------------------- */ /** * Iterate over a SchemaField by iterating over its fields. * @type {Iterable} */ *[Symbol.iterator]() { for ( const field of Object.values(this.fields) ) { yield field; } } /** * An array of field names which are present in the schema. * @returns {string[]} */ keys() { return Object.keys(this.fields); } /** * An array of DataField instances which are present in the schema. * @returns {DataField[]} */ values() { return Object.values(this.fields); } /** * An array of [name, DataField] tuples which define the schema. * @returns {Array<[string, DataField]>} */ entries() { return Object.entries(this.fields); } /** * Test whether a certain field name belongs to this schema definition. * @param {string} fieldName The field name * @returns {boolean} Does the named field exist in this schema? */ has(fieldName) { return fieldName in this.fields; } /** * Get a DataField instance from the schema by name * @param {string} fieldName The field name * @returns {DataField} The DataField instance or undefined */ get(fieldName) { return this.fields[fieldName]; } /** * Traverse the schema, obtaining the DataField definition for a particular field. * @param {string[]|string} fieldName A field path like ["abilities", "strength"] or "abilities.strength" * @returns {SchemaField|DataField} The corresponding DataField definition for that field, or undefined */ getField(fieldName) { let path; if ( typeof fieldName === "string" ) path = fieldName.split("."); else if ( Array.isArray(fieldName) ) path = fieldName.slice(); else throw new Error("A field path must be an array of strings or a dot-delimited string"); return this._getField(path); } /** @override */ _getField(path) { if ( !path.length ) return this; const field = this.get(path.shift()); return field?._getField(path); } /* -------------------------------------------- */ /* Data Field Methods */ /* -------------------------------------------- */ /** @override */ _cast(value) { return typeof value === "object" ? value : {}; } /* -------------------------------------------- */ /** @inheritdoc */ _cleanType(data, options={}) { options.source = options.source || data; // Clean each field which belongs to the schema for ( const [name, field] of this.entries() ) { if ( !(name in data) && options.partial ) continue; data[name] = field.clean(data[name], options); } // Delete any keys which do not this.unknownKeys = []; for ( const k of Object.keys(data) ) { if ( this.has(k) ) continue; this.unknownKeys.push(k); delete data[k]; } return data; } /* -------------------------------------------- */ /** @override */ initialize(value, model, options={}) { if ( !value ) return value; const data = {}; for ( let [name, field] of this.entries() ) { const v = field.initialize(value[name], model, options); // Readonly fields if ( field.readonly ) { Object.defineProperty(data, name, {value: v, writable: false}); } // Getter fields else if ( (typeof v === "function") && !v.prototype ) { Object.defineProperty(data, name, {get: v, set() {}, configurable: true}); } // Writable fields else data[name] = v; } return data; } /* -------------------------------------------- */ /** @override */ _validateType(data, options={}) { if ( !(data instanceof Object) ) throw new Error("must be an object"); options.source = options.source || data; const schemaFailure = new DataModelValidationFailure(); for ( const [key, field] of this.entries() ) { if ( options.partial && !(key in data) ) continue; // Validate the field's current value const value = data[key]; const failure = field.validate(value, options); // Failure may be permitted if fallback replacement is allowed if ( failure ) { schemaFailure.fields[field.name] = failure; // If the field internally applied fallback logic if ( !failure.unresolved ) continue; // If fallback is allowed at the schema level if ( options.fallback ) { const initial = field.getInitialValue(options.source); if ( field.validate(initial, {source: options.source}) === undefined ) { // Ensure initial is valid data[key] = initial; failure.fallback = initial; failure.unresolved = false; } else failure.unresolved = schemaFailure.unresolved = true; } // Otherwise the field-level failure is unresolved else failure.unresolved = schemaFailure.unresolved = true; } } if ( !isEmpty$1(schemaFailure.fields) ) return schemaFailure; } /* ---------------------------------------- */ /** @override */ _validateModel(changes, options={}) { options.source = options.source || changes; if ( !changes ) return; for ( const [name, field] of this.entries() ) { const change = changes[name]; // May be nullish if ( change && field.constructor.recursive ) field._validateModel(change, options); } } /* -------------------------------------------- */ /** @override */ toObject(value) { if ( (value === undefined) || (value === null) ) return value; const data = {}; for ( const [name, field] of this.entries() ) { data[name] = field.toObject(value[name]); } return data; } /* -------------------------------------------- */ /** @override */ apply(fn, data={}, options={}) { // Apply to this SchemaField const thisFn = typeof fn === "string" ? this[fn] : fn; thisFn?.call(this, data, options); // Recursively apply to inner fields const results = {}; for ( const [key, field] of this.entries() ) { if ( options.partial && !(key in data) ) continue; const r = field.apply(fn, data[key], options); if ( !options.filter || !isEmpty$1(r) ) results[key] = r; } return results; } /* -------------------------------------------- */ /** * Migrate this field's candidate source data. * @param {object} sourceData Candidate source data of the root model * @param {any} fieldData The value of this field within the source data */ migrateSource(sourceData, fieldData) { for ( const [key, field] of this.entries() ) { const canMigrate = field.migrateSource instanceof Function; if ( canMigrate && fieldData[key] ) field.migrateSource(sourceData, fieldData[key]); } } } /* -------------------------------------------- */ /* Basic Field Types */ /* -------------------------------------------- */ /** * A subclass of [DataField]{@link DataField} which deals with boolean-typed data. */ class BooleanField extends DataField { /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, { required: true, nullable: false, initial: false }); } /** @override */ _cast(value) { if ( typeof value === "string" ) return value === "true"; if ( typeof value === "object" ) return false; return Boolean(value); } /** @override */ _validateType(value) { if (typeof value !== "boolean") throw new Error("must be a boolean"); } /** @override */ _toInput(config) { return foundry.applications.fields.createCheckboxInput(config); } /* -------------------------------------------- */ /* Active Effect Integration */ /* -------------------------------------------- */ /** @override */ _applyChangeAdd(value, delta, model, change) { return value || delta; } /** @override */ _applyChangeMultiply(value, delta, model, change) { return value && delta; } /** @override */ _applyChangeUpgrade(value, delta, model, change) { return delta > value ? delta : value; } _applyChangeDowngrade(value, delta, model, change) { return delta < value ? delta : value; } } /* ---------------------------------------- */ /** * @typedef {DataFieldOptions} NumberFieldOptions * @property {number} [min] A minimum allowed value * @property {number} [max] A maximum allowed value * @property {number} [step] A permitted step size * @property {boolean} [integer=false] Must the number be an integer? * @property {number} [positive=false] Must the number be positive? * @property {number[]|object|function} [choices] An array of values or an object of values/labels which represent * allowed choices for the field. A function may be provided which dynamically * returns the array of choices. */ /** * A subclass of [DataField]{@link DataField} which deals with number-typed data. * * @property {number} min A minimum allowed value * @property {number} max A maximum allowed value * @property {number} step A permitted step size * @property {boolean} integer=false Must the number be an integer? * @property {number} positive=false Must the number be positive? * @property {number[]|object|function} [choices] An array of values or an object of values/labels which represent * allowed choices for the field. A function may be provided which dynamically * returns the array of choices. */ class NumberField extends DataField { /** * @param {NumberFieldOptions} options Options which configure the behavior of the field * @param {DataFieldContext} [context] Additional context which describes the field */ constructor(options={}, context={}) { super(options, context); // If choices are provided, the field should not be null by default if ( this.choices ) { this.nullable = options.nullable ?? false; } if ( Number.isFinite(this.min) && Number.isFinite(this.max) && (this.min > this.max) ) { throw new Error("NumberField minimum constraint cannot exceed its maximum constraint"); } } /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, { initial: null, nullable: true, min: undefined, max: undefined, step: undefined, integer: false, positive: false, choices: undefined }); } /** @override */ _cast(value) { return Number(value); } /** @inheritdoc */ _cleanType(value, options) { value = super._cleanType(value, options); if ( typeof value !== "number" ) return value; if ( this.integer ) value = Math.round(value); if ( Number.isFinite(this.min) ) value = Math.max(value, this.min); if ( Number.isFinite(this.max) ) value = Math.min(value, this.max); if ( Number.isFinite(this.step) ) value = value.toNearest(this.step); return value; } /** @override */ _validateType(value) { if ( typeof value !== "number" ) throw new Error("must be a number"); if ( this.positive && (value <= 0) ) throw new Error("must be a positive number"); if ( Number.isFinite(this.min) && (value < this.min) ) throw new Error(`must be at least ${this.min}`); if ( Number.isFinite(this.max) && (value > this.max) ) throw new Error(`must be at most ${this.max}`); if ( Number.isFinite(this.step) && (value.toNearest(this.step) !== value) ) { throw new Error(`must be an increment of ${this.step}`); } if ( this.choices && !this.#isValidChoice(value) ) throw new Error(`${value} is not a valid choice`); if ( this.integer ) { if ( !Number.isInteger(value) ) throw new Error("must be an integer"); } else if ( !Number.isFinite(value) ) throw new Error("must be a finite number"); } /** * Test whether a provided value is a valid choice from the allowed choice set * @param {number} value The provided value * @returns {boolean} Is the choice valid? */ #isValidChoice(value) { let choices = this.choices; if ( choices instanceof Function ) choices = choices(); if ( choices instanceof Array ) return choices.includes(value); return String(value) in choices; } /* -------------------------------------------- */ /* Form Field Integration */ /* -------------------------------------------- */ /** @override */ _toInput(config) { config.min ??= this.min; config.max ??= this.max; config.step ??= this.step; if ( config.value === undefined ) config.value = this.getInitialValue({}); if ( this.integer ) { if ( Number.isNumeric(config.value) ) config.value = Math.round(config.value); config.step ??= 1; } if ( this.positive && Number.isFinite(config.step) ) config.min ??= config.step; // Number Select config.choices ??= this.choices; if ( config.choices && !config.options ) { config.options = StringField._getChoices(config); delete config.valueAttr; delete config.labelAttr; config.dataset ||= {}; config.dataset.dtype = "Number"; } if ( config.options ) return foundry.applications.fields.createSelectInput(config); // Range Slider if ( ["min", "max", "step"].every(k => config[k] !== undefined) && (config.type !== "number") ) { return foundry.applications.elements.HTMLRangePickerElement.create(config); } // Number Input return foundry.applications.fields.createNumberInput(config); } /* -------------------------------------------- */ /* Active Effect Integration */ /* -------------------------------------------- */ /** @override */ _applyChangeMultiply(value, delta, model, change) { return value * delta; } /** @override */ _applyChangeUpgrade(value, delta, model, change) { return delta > value ? delta : value; } /** @override */ _applyChangeDowngrade(value, delta, model, change) { return delta < value ? delta : value; } } /* ---------------------------------------- */ /** * @typedef {Object} StringFieldParams * @property {boolean} [blank=true] Is the string allowed to be blank (empty)? * @property {boolean} [trim=true] Should any provided string be trimmed as part of cleaning? * @property {string[]|object|function} [choices] An array of values or an object of values/labels which represent * allowed choices for the field. A function may be provided which dynamically * returns the array of choices. * @property {boolean} [textSearch=false] Is this string field a target for text search? * @typedef {DataFieldOptions&StringFieldParams} StringFieldOptions */ /** * A subclass of {@link DataField} which deals with string-typed data. */ class StringField extends DataField { /** * @param {StringFieldOptions} [options] Options which configure the behavior of the field * @param {DataFieldContext} [context] Additional context which describes the field */ constructor(options={}, context={}) { super(options, context); // If choices are provided, the field should not be null or blank by default if ( this.choices ) { this.nullable = options.nullable ?? false; this.blank = options.blank ?? false; } } /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, { blank: true, trim: true, nullable: false, initial() { // The initial value depends on the field configuration if ( !this.required ) return undefined; else if ( this.blank ) return ""; else if ( this.nullable ) return null; return undefined; }, choices: undefined, textSearch: false }); } /** * Is the string allowed to be blank (empty)? * @type {boolean} */ blank = this.blank; /** * Should any provided string be trimmed as part of cleaning? * @type {boolean} */ trim = this.trim; /** * An array of values or an object of values/labels which represent * allowed choices for the field. A function may be provided which dynamically * returns the array of choices. * @type {string[]|object|function} */ choices = this.choices; /** * Is this string field a target for text search? * @type {boolean} */ textSearch = this.textSearch; /** @inheritdoc */ clean(value, options) { if ( (typeof value === "string") && this.trim ) value = value.trim(); // Trim input strings if ( value === "" ) { // Permit empty strings for blank fields if ( this.blank ) return value; value = undefined; } return super.clean(value, options); } /** @override */ _cast(value) { return String(value); } /** @inheritdoc */ _validateSpecial(value) { if ( value === "" ) { if ( this.blank ) return true; else throw new Error("may not be a blank string"); } return super._validateSpecial(value); } /** @override */ _validateType(value) { if ( typeof value !== "string" ) throw new Error("must be a string"); else if ( this.choices ) { if ( this._isValidChoice(value) ) return true; else throw new Error(`${value} is not a valid choice`); } } /** * Test whether a provided value is a valid choice from the allowed choice set * @param {string} value The provided value * @returns {boolean} Is the choice valid? * @protected */ _isValidChoice(value) { let choices = this.choices; if ( choices instanceof Function ) choices = choices(); if ( choices instanceof Array ) return choices.includes(value); return String(value) in choices; } /* -------------------------------------------- */ /* Form Field Integration */ /* -------------------------------------------- */ /** * Get a record of eligible choices for the field. * @param {object} [options] * @param {Record|Array} options.choices * @param {string} [options.labelAttr="label"] The property in the choice object values to use as the option label. * @param {string} [options.valueAttr] * @param {boolean} [options.localize=false] Pass each label through string localization? * @returns {FormSelectOption[]} * @internal */ static _getChoices({choices, labelAttr="label", valueAttr, localize=false}={}) { if ( choices instanceof Function ) choices = choices(); if ( typeof choices === "object" ) { choices = Object.entries(choices).reduce((arr, [value, label]) => { if ( typeof label !== "string" ) { if ( valueAttr && (valueAttr in label) ) value = label[valueAttr]; label = label[labelAttr] ?? "undefined"; } if ( localize ) label = game.i18n.localize(label); arr.push({value, label}); return arr; }, []); } return choices; } /* -------------------------------------------- */ /** @override */ _toInput(config) { if ( config.value === undefined ) config.value = this.getInitialValue({}); config.choices ??= this.choices; if ( config.choices && !config.options ) { config.options = StringField._getChoices(config); delete config.choices; delete config.valueAttr; delete config.labelAttr; if ( this.blank || !this.required ) config.blank ??= ""; } if ( config.options ) return foundry.applications.fields.createSelectInput(config); return foundry.applications.fields.createTextInput(config); } } /* ---------------------------------------- */ /** * A subclass of [DataField]{@link DataField} which deals with object-typed data. */ class ObjectField extends DataField { /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, { required: true, nullable: false }); } /* -------------------------------------------- */ /** @override */ getInitialValue(data) { const initial = super.getInitialValue(data); if ( initial ) return initial; // Explicit initial value defined by subclass if ( !this.required ) return undefined; // The ObjectField may be undefined if ( this.nullable ) return null; // The ObjectField may be null return {}; // Otherwise an empty object } /** @override */ _cast(value) { return getType(value) === "Object" ? value : {}; } /** @override */ initialize(value, model, options={}) { if ( !value ) return value; return deepClone(value); } /** @override */ toObject(value) { return deepClone(value); } /** @override */ _validateType(value, options={}) { if ( getType(value) !== "Object" ) throw new Error("must be an object"); } } /* -------------------------------------------- */ /** * @typedef {DataFieldOptions} ArrayFieldOptions * @property {number} [min] The minimum number of elements. * @property {number} [max] The maximum number of elements. */ /** * A subclass of [DataField]{@link DataField} which deals with array-typed data. * @property {number} min The minimum number of elements. * @property {number} max The maximum number of elements. */ class ArrayField extends DataField { /** * @param {DataField} element A DataField instance which defines the type of element contained in the Array * @param {ArrayFieldOptions} [options] Options which configure the behavior of the field * @param {DataFieldContext} [context] Additional context which describes the field */ constructor(element, options={}, context={}) { super(options, context); /** * The data type of each element in this array * @type {DataField} */ this.element = this.constructor._validateElementType(element); if ( this.min > this.max ) throw new Error("ArrayField minimum length cannot exceed maximum length"); } /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, { required: true, nullable: false, empty: true, exact: undefined, min: 0, max: Infinity, initial: () => [] }); } /** @override */ static recursive = true; /* ---------------------------------------- */ /** * Validate the contained element type of the ArrayField * @param {*} element The type of Array element * @returns {*} The validated element type * @throws An error if the element is not a valid type * @protected */ static _validateElementType(element) { if ( !(element instanceof DataField) ) { throw new Error(`${this.name} must have a DataField as its contained element`); } return element; } /* ---------------------------------------- */ /** @override */ _validateModel(changes, options) { if ( !this.element.constructor.recursive ) return; for ( const element of changes ) { this.element._validateModel(element, options); } } /* ---------------------------------------- */ /** @override */ _cast(value) { const t = getType(value); if ( t === "Object" ) { const arr = []; for ( const [k, v] of Object.entries(value) ) { const i = Number(k); if ( Number.isInteger(i) && (i >= 0) ) arr[i] = v; } return arr; } else if ( t === "Set" ) return Array.from(value); return value instanceof Array ? value : [value]; } /** @override */ _cleanType(value, options) { // Force partial as false for array cleaning. Arrays are updated by replacing the entire array, so partial data // must be initialized. return value.map(v => this.element.clean(v, { ...options, partial: false })); } /** @override */ _validateType(value, options={}) { if ( !(value instanceof Array) ) throw new Error("must be an Array"); if ( value.length < this.min ) throw new Error(`cannot have fewer than ${this.min} elements`); if ( value.length > this.max ) throw new Error(`cannot have more than ${this.max} elements`); return this._validateElements(value, options); } /** * Validate every element of the ArrayField * @param {Array} value The array to validate * @param {DataFieldValidationOptions} options Validation options * @returns {DataModelValidationFailure|void} A validation failure if any of the elements failed validation, * otherwise void. * @protected */ _validateElements(value, options) { const arrayFailure = new DataModelValidationFailure(); for ( let i=0; i this.element.initialize(v, model, options)); } /** @override */ toObject(value) { if ( !value ) return value; return value.map(v => this.element.toObject(v)); } /** @override */ apply(fn, value=[], options={}) { // Apply to this ArrayField const thisFn = typeof fn === "string" ? this[fn] : fn; thisFn?.call(this, value, options); // Recursively apply to array elements const results = []; if ( !value.length && options.initializeArrays ) value = [undefined]; for ( const v of value ) { const r = this.element.apply(fn, v, options); if ( !options.filter || !isEmpty$1(r) ) results.push(r); } return results; } /** @override */ _getField(path) { if ( !path.length ) return this; if ( path[0] === "element" ) path.shift(); return this.element._getField(path); } /** * Migrate this field's candidate source data. * @param {object} sourceData Candidate source data of the root model * @param {any} fieldData The value of this field within the source data */ migrateSource(sourceData, fieldData) { const canMigrate = this.element.migrateSource instanceof Function; if ( canMigrate && (fieldData instanceof Array) ) { for ( const entry of fieldData ) this.element.migrateSource(sourceData, entry); } } /* -------------------------------------------- */ /* Active Effect Integration */ /* -------------------------------------------- */ /** @override */ _castChangeDelta(raw) { let delta; try { delta = JSON.parse(raw); delta = Array.isArray(delta) ? delta : [delta]; } catch { delta = [raw]; } return delta.map(value => this.element._castChangeDelta(value)); } /** @override */ _applyChangeAdd(value, delta, model, change) { value.push(...delta); return value; } } /* -------------------------------------------- */ /* Specialized Field Types */ /* -------------------------------------------- */ /** * A subclass of [ArrayField]{@link ArrayField} which supports a set of contained elements. * Elements in this set are treated as fungible and may be represented in any order or discarded if invalid. */ class SetField extends ArrayField { /** @override */ _validateElements(value, options) { const setFailure = new DataModelValidationFailure(); for ( let i=value.length-1; i>=0; i-- ) { // iterate backwards so we can splice as we go const failure = this._validateElement(value[i], options); if ( failure ) { setFailure.elements.unshift({id: i, failure}); // The failure may have been internally resolved by fallback logic if ( !failure.unresolved && failure.fallback ) continue; // If fallback is allowed, remove invalid elements from the set if ( options.fallback ) { value.splice(i, 1); failure.dropped = true; } // Otherwise the set failure is unresolved else setFailure.unresolved = true; } } // Return a record of any failed set elements if ( setFailure.elements.length ) { if ( options.fallback && !setFailure.unresolved ) setFailure.fallback = value; return setFailure; } } /** @override */ initialize(value, model, options={}) { if ( !value ) return value; return new Set(super.initialize(value, model, options)); } /** @override */ toObject(value) { if ( !value ) return value; return Array.from(value).map(v => this.element.toObject(v)); } /* -------------------------------------------- */ /* Form Field Integration */ /* -------------------------------------------- */ /** @override */ _toInput(config) { const e = this.element; // Document UUIDs if ( e instanceof DocumentUUIDField ) { Object.assign(config, {type: e.type, single: false}); return foundry.applications.elements.HTMLDocumentTagsElement.create(config); } // Multi-Select Input if ( e.choices && !config.options ) { config.options = StringField._getChoices({choices: e.choices, ...config}); } if ( config.options ) return foundry.applications.fields.createMultiSelectInput(config); // Arbitrary String Tags if ( e instanceof StringField ) return foundry.applications.elements.HTMLStringTagsElement.create(config); throw new Error(`SetField#toInput is not supported for a ${e.constructor.name} element type`); } /* -------------------------------------------- */ /* Active Effect Integration */ /* -------------------------------------------- */ /** @inheritDoc */ _castChangeDelta(raw) { return new Set(super._castChangeDelta(raw)); } /** @override */ _applyChangeAdd(value, delta, model, change) { for ( const element of delta ) value.add(element); return value; } } /* ---------------------------------------- */ /** * A subclass of [ObjectField]{@link ObjectField} which embeds some other DataModel definition as an inner object. */ class EmbeddedDataField extends SchemaField { /** * @param {typeof DataModel} model The class of DataModel which should be embedded in this field * @param {DataFieldOptions} [options] Options which configure the behavior of the field * @param {DataFieldContext} [context] Additional context which describes the field */ constructor(model, options={}, context={}) { if ( !isSubclass(model, DataModel) ) { throw new Error("An EmbeddedDataField must specify a DataModel class as its type"); } // Create an independent copy of the model schema const fields = model.defineSchema(); super(fields, options, context); /** * The base DataModel definition which is contained in this field. * @type {typeof DataModel} */ this.model = model; } /** @inheritdoc */ clean(value, options) { return super.clean(value, {...options, source: value}); } /** @inheritdoc */ validate(value, options) { return super.validate(value, {...options, source: value}); } /** @override */ initialize(value, model, options={}) { if ( !value ) return value; const m = new this.model(value, {parent: model, ...options}); Object.defineProperty(m, "schema", {value: this}); return m; } /** @override */ toObject(value) { if ( !value ) return value; return value.toObject(false); } /** @override */ migrateSource(sourceData, fieldData) { if ( fieldData ) this.model.migrateDataSafe(fieldData); } /** @override */ _validateModel(changes, options) { this.model.validateJoint(changes); } } /* ---------------------------------------- */ /** * A subclass of [ArrayField]{@link ArrayField} which supports an embedded Document collection. * Invalid elements will be dropped from the collection during validation rather than failing for the field entirely. */ class EmbeddedCollectionField extends ArrayField { /** * @param {typeof foundry.abstract.Document} element The type of Document which belongs to this embedded collection * @param {DataFieldOptions} [options] Options which configure the behavior of the field * @param {DataFieldContext} [context] Additional context which describes the field */ constructor(element, options={}, context={}) { super(element, options, context); this.readonly = true; // Embedded collections are always immutable } /** @override */ static _validateElementType(element) { if ( isSubclass(element, foundry.abstract.Document) ) return element; throw new Error("An EmbeddedCollectionField must specify a Document subclass as its type"); } /** * The Collection implementation to use when initializing the collection. * @type {typeof EmbeddedCollection} */ static get implementation() { return EmbeddedCollection; } /** @override */ static hierarchical = true; /** * A reference to the DataModel subclass of the embedded document element * @type {typeof foundry.abstract.Document} */ get model() { return this.element.implementation; } /** * The DataSchema of the contained Document model. * @type {SchemaField} */ get schema() { return this.model.schema; } /** @inheritDoc */ _cast(value) { if ( getType(value) !== "Map" ) return super._cast(value); const arr = []; for ( const [id, v] of value.entries() ) { if ( !("_id" in v) ) v._id = id; arr.push(v); } return super._cast(arr); } /** @override */ _cleanType(value, options) { return value.map(v => this.schema.clean(v, {...options, source: v})); } /** @override */ _validateElements(value, options) { const collectionFailure = new DataModelValidationFailure(); for ( const v of value ) { const failure = this.schema.validate(v, {...options, source: v}); if ( failure && !options.dropInvalidEmbedded ) { collectionFailure.elements.push({id: v._id, name: v.name, failure}); collectionFailure.unresolved ||= failure.unresolved; } } if ( collectionFailure.elements.length ) return collectionFailure; } /** @override */ initialize(value, model, options={}) { const collection = model.collections[this.name]; collection.initialize(options); return collection; } /** @override */ toObject(value) { return value.toObject(false); } /** @override */ apply(fn, value=[], options={}) { // Apply to this EmbeddedCollectionField const thisFn = typeof fn === "string" ? this[fn] : fn; thisFn?.call(this, value, options); // Recursively apply to inner fields const results = []; if ( !value.length && options.initializeArrays ) value = [undefined]; for ( const v of value ) { const r = this.schema.apply(fn, v, options); if ( !options.filter || !isEmpty$1(r) ) results.push(r); } return results; } /** * Migrate this field's candidate source data. * @param {object} sourceData Candidate source data of the root model * @param {any} fieldData The value of this field within the source data */ migrateSource(sourceData, fieldData) { if ( fieldData instanceof Array ) { for ( const entry of fieldData ) this.model.migrateDataSafe(entry); } } /* -------------------------------------------- */ /* Embedded Document Operations */ /* -------------------------------------------- */ /** * Return the embedded document(s) as a Collection. * @param {foundry.abstract.Document} parent The parent document. * @returns {DocumentCollection} */ getCollection(parent) { return parent[this.name]; } } /* -------------------------------------------- */ /** * A subclass of {@link EmbeddedCollectionField} which manages a collection of delta objects relative to another * collection. */ class EmbeddedCollectionDeltaField extends EmbeddedCollectionField { /** @override */ static get implementation() { return EmbeddedCollectionDelta; } /** @override */ _cleanType(value, options) { return value.map(v => { if ( v._tombstone ) return foundry.data.TombstoneData.schema.clean(v, {...options, source: v}); return this.schema.clean(v, {...options, source: v}); }); } /** @override */ _validateElements(value, options) { const collectionFailure = new DataModelValidationFailure(); for ( const v of value ) { const validationOptions = {...options, source: v}; const failure = v._tombstone ? foundry.data.TombstoneData.schema.validate(v, validationOptions) : this.schema.validate(v, validationOptions); if ( failure && !options.dropInvalidEmbedded ) { collectionFailure.elements.push({id: v._id, name: v.name, failure}); collectionFailure.unresolved ||= failure.unresolved; } } if ( collectionFailure.elements.length ) return collectionFailure; } } /* -------------------------------------------- */ /** * A subclass of {@link EmbeddedDataField} which supports a single embedded Document. */ class EmbeddedDocumentField extends EmbeddedDataField { /** * @param {typeof foundry.abstract.Document} model The type of Document which is embedded. * @param {DataFieldOptions} [options] Options which configure the behavior of the field. * @param {DataFieldContext} [context] Additional context which describes the field */ constructor(model, options={}, context={}) { if ( !isSubclass(model, foundry.abstract.Document) ) { throw new Error("An EmbeddedDocumentField must specify a Document subclass as its type."); } super(model.implementation, options, context); } /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, { nullable: true }); } /** @override */ static hierarchical = true; /** @override */ initialize(value, model, options={}) { if ( !value ) return value; if ( model[this.name] ) { model[this.name]._initialize(options); return model[this.name]; } const m = new this.model(value, {...options, parent: model, parentCollection: this.name}); Object.defineProperty(m, "schema", {value: this}); return m; } /* -------------------------------------------- */ /* Embedded Document Operations */ /* -------------------------------------------- */ /** * Return the embedded document(s) as a Collection. * @param {Document} parent The parent document. * @returns {Collection} */ getCollection(parent) { const collection = new SingletonEmbeddedCollection(this.name, parent, []); const doc = parent[this.name]; if ( !doc ) return collection; collection.set(doc.id, doc); return collection; } } /* -------------------------------------------- */ /* Special Field Types */ /* -------------------------------------------- */ /** * A subclass of [StringField]{@link StringField} which provides the primary _id for a Document. * The field may be initially null, but it must be non-null when it is saved to the database. */ class DocumentIdField extends StringField { /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, { required: true, blank: false, nullable: true, initial: null, readonly: true, validationError: "is not a valid Document ID string" }); } /** @override */ _cast(value) { if ( value instanceof foundry.abstract.Document ) return value._id; else return String(value); } /** @override */ _validateType(value) { if ( !isValidId(value) ) throw new Error("must be a valid 16-character alphanumeric ID"); } } /* ---------------------------------------- */ /** * @typedef {Object} DocumentUUIDFieldOptions * @property {string} [type] A specific document type in CONST.ALL_DOCUMENT_TYPES required by this field * @property {boolean} [embedded] Does this field require (or prohibit) embedded documents? */ /** * A subclass of {@link StringField} which supports referencing some other Document by its UUID. * This field may not be blank, but may be null to indicate that no UUID is referenced. */ class DocumentUUIDField extends StringField { /** * @param {StringFieldOptions & DocumentUUIDFieldOptions} [options] Options which configure the behavior of the field * @param {DataFieldContext} [context] Additional context which describes the field */ constructor(options, context) { super(options, context); } /** @inheritdoc */ static get _defaults() { return Object.assign(super._defaults, { required: true, blank: false, nullable: true, initial: null, type: undefined, embedded: undefined }); } /** @override */ _validateType(value) { const p = parseUuid(value); if ( this.type ) { if ( p.type !== this.type ) throw new Error(`Invalid document type "${p.type}" which must be a "${this.type}"`); } else if ( p.type && !ALL_DOCUMENT_TYPES.includes(p.type) ) throw new Error(`Invalid document type "${p.type}"`); if ( (this.embedded === true) && !p.embedded.length ) throw new Error("must be an embedded document"); if ( (this.embedded === false) && p.embedded.length ) throw new Error("may not be an embedded document"); if ( !isValidId(p.documentId) ) throw new Error(`Invalid document ID "${p.documentId}"`); } /* -------------------------------------------- */ /* Form Field Integration */ /* -------------------------------------------- */ /** @override */ _toInput(config) { Object.assign(config, {type: this.type, single: true}); return foundry.applications.elements.HTMLDocumentTagsElement.create(config); } } /* ---------------------------------------- */ /** * A special class of [StringField]{@link StringField} field which references another DataModel by its id. * This field may also be null to indicate that no foreign model is linked. */ class ForeignDocumentField extends DocumentIdField { /** * @param {typeof foundry.abstract.Document} model The foreign DataModel class definition which this field links to * @param {StringFieldOptions} [options] Options which configure the behavior of the field * @param {DataFieldContext} [context] Additional context which describes the field */ constructor(model, options={}, context={}) { super(options, context); if ( !isSubclass(model, DataModel) ) { throw new Error("A ForeignDocumentField must specify a DataModel subclass as its type"); } /** * A reference to the model class which is stored in this field * @type {typeof foundry.abstract.Document} */ this.model = model; } /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, { nullable: true, readonly: false, idOnly: false }); } /** @override */ _cast(value) { if ( typeof value === "string" ) return value; if ( (value instanceof this.model) ) return value._id; throw new Error(`The value provided to a ForeignDocumentField must be a ${this.model.name} instance.`); } /** @inheritdoc */ initialize(value, model, options={}) { if ( this.idOnly ) return value; if ( model?.pack && !foundry.utils.isSubclass(this.model, foundry.documents.BaseFolder) ) return null; if ( !game.collections ) return value; // server-side return () => this.model?.get(value, {pack: model?.pack, ...options}) ?? null; } /** @inheritdoc */ toObject(value) { return value?._id ?? value } /* -------------------------------------------- */ /* Form Field Integration */ /* -------------------------------------------- */ /** @override */ _toInput(config) { // Prepare array of visible options const collection = game.collections.get(this.model.documentName); const current = collection.get(config.value); let hasCurrent = false; const options = collection.reduce((arr, doc) => { if ( !doc.visible ) return arr; if ( doc === current ) hasCurrent = true; arr.push({value: doc.id, label: doc.name}); return arr; }, []); if ( current && !hasCurrent ) options.unshift({value: config.value, label: current.name}); Object.assign(config, {options}); // Allow blank if ( !this.required || this.nullable ) config.blank = ""; // Create select input return foundry.applications.fields.createSelectInput(config); } } /* -------------------------------------------- */ /** * A special [StringField]{@link StringField} which records a standardized CSS color string. */ class ColorField extends StringField { /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, { nullable: true, initial: null, blank: false, validationError: "is not a valid hexadecimal color string" }); } /** @override */ initialize(value, model, options={}) { if ( (value === null) || (value === undefined) ) return value; return Color.from(value); } /** @override */ getInitialValue(data) { const value = super.getInitialValue(data); if ( (value === undefined) || (value === null) || (value === "") ) return value; const color = Color.from(value); if ( !color.valid ) throw new Error("Invalid initial value for ColorField"); return color.css; } /** @override */ _cast(value) { if ( value === "" ) return value; return Color.from(value); } /** @override */ _cleanType(value, options) { if ( value === "" ) return value; if ( value.valid ) return value.css; return this.getInitialValue(options.source); } /** @inheritdoc */ _validateType(value, options) { const result = super._validateType(value, options); if ( result !== undefined ) return result; if ( !isColorString(value) ) throw new Error("must be a valid color string"); } /* -------------------------------------------- */ /* Form Field Integration */ /* -------------------------------------------- */ /** @override */ _toInput(config) { if ( (config.placeholder === undefined) && !this.nullable && !(this.initial instanceof Function) ) { config.placeholder = this.initial; } return foundry.applications.elements.HTMLColorPickerElement.create(config); } } /* -------------------------------------------- */ /** * @typedef {StringFieldOptions} FilePathFieldOptions * @property {string[]} [categories] A set of categories in CONST.FILE_CATEGORIES which this field supports * @property {boolean} [base64=false] Is embedded base64 data supported in lieu of a file path? * @property {boolean} [wildcard=false] Does this file path field allow wildcard characters? * @property {object} [initial] The initial values of the fields */ /** * A special [StringField]{@link StringField} which records a file path or inline base64 data. * @property {string[]} categories A set of categories in CONST.FILE_CATEGORIES which this field supports * @property {boolean} base64=false Is embedded base64 data supported in lieu of a file path? * @property {boolean} wildcard=false Does this file path field allow wildcard characters? */ class FilePathField extends StringField { /** * @param {FilePathFieldOptions} [options] Options which configure the behavior of the field * @param {DataFieldContext} [context] Additional context which describes the field */ constructor(options={}, context={}) { super(options, context); if ( !this.categories.length || this.categories.some(c => !(c in FILE_CATEGORIES)) ) { throw new Error("The categories of a FilePathField must be keys in CONST.FILE_CATEGORIES"); } } /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, { categories: [], base64: false, wildcard: false, nullable: true, blank: false, initial: null }); } /* -------------------------------------------- */ /** @inheritdoc */ _validateType(value) { // Wildcard paths if ( this.wildcard && value.includes("*") ) return true; // Allowed extension or base64 const isValid = this.categories.some(c => { const category = FILE_CATEGORIES[c]; if ( hasFileExtension(value, Object.keys(category)) ) return true; /** * If the field contains base64 data, it is allowed (for now) regardless of the base64 setting for the field. * Eventually, this will become more strict and only be valid if base64 is configured as true for the field. * @deprecated since v10 */ return isBase64Data(value, Object.values(category)); }); // Throw an error for invalid paths if ( !isValid ) { let err = "does not have a valid file extension"; if ( this.base64 ) err += " or provide valid base64 data"; throw new Error(err); } } /* -------------------------------------------- */ /* Form Field Integration */ /* -------------------------------------------- */ /** @override */ _toInput(config) { // FIXME: This logic is fragile and would require a mapping between CONST.FILE_CATEGORIES and FilePicker.TYPES config.type = this.categories.length === 1 ? this.categories[0].toLowerCase() : "any"; return foundry.applications.elements.HTMLFilePickerElement.create(config); } } /* -------------------------------------------- */ /** * A special {@link NumberField} which represents an angle of rotation in degrees between 0 and 360. * @property {boolean} normalize Whether the angle should be normalized to [0,360) before being clamped to [0,360]. The default is true. */ class AngleField extends NumberField { constructor(options={}, context={}) { super(options, context); if ( "base" in this.options ) this.base = this.options.base; } /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, { required: true, nullable: false, initial: 0, normalize: true, min: 0, max: 360, validationError: "is not a number between 0 and 360" }); } /** @inheritdoc */ _cast(value) { value = super._cast(value); if ( !this.normalize ) return value; value = Math.normalizeDegrees(value); /** @deprecated since v12 */ if ( (this.#base === 360) && (value === 0) ) value = 360; return value; } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get base() { const msg = "The AngleField#base is deprecated in favor of AngleField#normalize."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); return this.#base; } /** * @deprecated since v12 * @ignore */ set base(v) { const msg = "The AngleField#base is deprecated in favor of AngleField#normalize."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); this.#base = v; } /** * @deprecated since v12 * @ignore */ #base = 0; } /* -------------------------------------------- */ /** * A special [NumberField]{@link NumberField} represents a number between 0 and 1. */ class AlphaField extends NumberField { static get _defaults() { return mergeObject(super._defaults, { required: true, nullable: false, initial: 1, min: 0, max: 1, validationError: "is not a number between 0 and 1" }); } } /* -------------------------------------------- */ /** * A special [NumberField]{@link NumberField} represents a number between 0 (inclusive) and 1 (exclusive). * Its values are normalized (modulo 1) to the range [0, 1) instead of being clamped. */ class HueField extends NumberField { static get _defaults() { return mergeObject(super._defaults, { required: true, nullable: false, initial: 0, min: 0, max: 1, validationError: "is not a number between 0 (inclusive) and 1 (exclusive)" }); } /* -------------------------------------------- */ /** @inheritdoc */ _cast(value) { value = super._cast(value) % 1; if ( value < 0 ) value += 1; return value; } } /* -------------------------------------------- */ /** * A special [ObjectField]{@link ObjectField} which captures a mapping of User IDs to Document permission levels. */ class DocumentOwnershipField extends ObjectField { /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, { initial: {"default": DOCUMENT_OWNERSHIP_LEVELS.NONE}, validationError: "is not a mapping of user IDs and document permission levels" }); } /** @override */ _validateType(value) { for ( let [k, v] of Object.entries(value) ) { if ( k.startsWith("-=") ) return isValidId(k.slice(2)) && (v === null); // Allow removals if ( (k !== "default") && !isValidId(k) ) return false; if ( !Object.values(DOCUMENT_OWNERSHIP_LEVELS).includes(v) ) return false; } } } /* -------------------------------------------- */ /** * A special [StringField]{@link StringField} which contains serialized JSON data. */ class JSONField extends StringField { constructor(options, context) { super(options, context); this.blank = false; this.trim = false; this.choices = undefined; } /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, { blank: false, trim: false, initial: undefined, validationError: "is not a valid JSON string" }); } /** @inheritdoc */ clean(value, options) { if ( value === "" ) return '""'; // Special case for JSON fields return super.clean(value, options); } /** @override */ _cast(value) { if ( (typeof value !== "string") || !isJSON(value) ) return JSON.stringify(value); return value; } /** @override */ _validateType(value, options) { if ( (typeof value !== "string") || !isJSON(value) ) throw new Error("must be a serialized JSON string"); } /** @override */ initialize(value, model, options={}) { if ( (value === undefined) || (value === null) ) return value; return JSON.parse(value); } /** @override */ toObject(value) { if ( (value === undefined) || (this.nullable && (value === null)) ) return value; return JSON.stringify(value); } /* -------------------------------------------- */ /* Form Field Integration */ /* -------------------------------------------- */ /** @override */ _toInput(config) { if ( config.value !== "" ) config.value = JSON.stringify(config.value, null, 2); return foundry.applications.fields.createTextareaInput(config); } } /* -------------------------------------------- */ /** * A special subclass of {@link DataField} which can contain any value of any type. * Any input is accepted and is treated as valid. * It is not recommended to use this class except for very specific circumstances. */ class AnyField extends DataField { /** @override */ _cast(value) { return value; } /** @override */ _validateType(value) { return true; } } /* -------------------------------------------- */ /** * A subclass of [StringField]{@link StringField} which contains a sanitized HTML string. * This class does not override any StringField behaviors, but is used by the server-side to identify fields which * require sanitization of user input. */ class HTMLField extends StringField { /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, { required: true, blank: true }); } /** @override */ toFormGroup(groupConfig={}, inputConfig) { groupConfig.stacked ??= true; return super.toFormGroup(groupConfig, inputConfig); } /** @override */ _toInput(config) { return foundry.applications.elements.HTMLProseMirrorElement.create(config); } } /* ---------------------------------------- */ /** * A subclass of {@link NumberField} which is used for storing integer sort keys. */ class IntegerSortField extends NumberField { /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, { required: true, nullable: false, integer: true, initial: 0, label: "FOLDER.DocumentSort", hint: "FOLDER.DocumentSortHint" }); } } /* ---------------------------------------- */ /** * @typedef {Object} DocumentStats * @property {string|null} coreVersion The core version whose schema the Document data is in. * It is NOT the version the Document was created or last modified in. * @property {string|null} systemId The package name of the system the Document was created in. * @property {string|null} systemVersion The version of the system the Document was created or last modified in. * @property {number|null} createdTime A timestamp of when the Document was created. * @property {number|null} modifiedTime A timestamp of when the Document was last modified. * @property {string|null} lastModifiedBy The ID of the user who last modified the Document. * @property {string|null} compendiumSource The UUID of the compendium Document this one was imported from. * @property {string|null} duplicateSource The UUID of the Document this one is a duplicate of. */ /** * A subclass of {@link SchemaField} which stores document metadata in the _stats field. * @mixes DocumentStats */ class DocumentStatsField extends SchemaField { /** * @param {DataFieldOptions} [options] Options which configure the behavior of the field * @param {DataFieldContext} [context] Additional context which describes the field */ constructor(options={}, context={}) { super({ coreVersion: new StringField({required: true, blank: false, nullable: true, initial: () => game.release.version}), systemId: new StringField({required: true, blank: false, nullable: true, initial: () => game.system?.id ?? null}), systemVersion: new StringField({required: true, blank: false, nullable: true, initial: () => game.system?.version ?? null}), createdTime: new NumberField(), modifiedTime: new NumberField(), lastModifiedBy: new ForeignDocumentField(foundry.documents.BaseUser, {idOnly: true}), compendiumSource: new DocumentUUIDField(), duplicateSource: new DocumentUUIDField() }, options, context); } /** * All Document stats. * @type {string[]} */ static fields = [ "coreVersion", "systemId", "systemVersion", "createdTime", "modifiedTime", "lastModifiedBy", "compendiumSource", "duplicateSource" ]; /** * These fields are managed by the server and are ignored if they appear in creation or update data. * @type {string[]} */ static managedFields = ["coreVersion", "systemId", "systemVersion", "createdTime", "modifiedTime", "lastModifiedBy"]; } /* ---------------------------------------- */ /** * A subclass of [StringField]{@link StringField} that is used specifically for the Document "type" field. */ class DocumentTypeField extends StringField { /** * @param {typeof foundry.abstract.Document} documentClass The base document class which belongs in this field * @param {StringFieldOptions} [options] Options which configure the behavior of the field * @param {DataFieldContext} [context] Additional context which describes the field */ constructor(documentClass, options={}, context={}) { options.choices = () => documentClass.TYPES; options.validationError = `is not a valid type for the ${documentClass.documentName} Document class`; super(options, context); } /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, { required: true, nullable: false, blank: false }); } /** @override */ _validateType(value, options) { if ( (typeof value !== "string") || !value ) throw new Error("must be a non-blank string"); if ( this._isValidChoice(value) ) return true; // Allow unrecognized types if we are allowed to fallback (non-strict validation) if (options.fallback ) return true; throw new Error(`"${value}" ${this.options.validationError}`); } } /* ---------------------------------------- */ /** * A subclass of [ObjectField]{@link ObjectField} which supports a type-specific data object. */ class TypeDataField extends ObjectField { /** * @param {typeof foundry.abstract.Document} document The base document class which belongs in this field * @param {DataFieldOptions} [options] Options which configure the behavior of the field * @param {DataFieldContext} [context] Additional context which describes the field */ constructor(document, options={}, context={}) { super(options, context); /** * The canonical document name of the document type which belongs in this field * @type {typeof foundry.abstract.Document} */ this.document = document; } /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, {required: true}); } /** @override */ static recursive = true; /** * Return the package that provides the sub-type for the given model. * @param {DataModel} model The model instance created for this sub-type. * @returns {System|Module|null} */ static getModelProvider(model) { const document = model.parent; if ( !document ) return null; const documentClass = document.constructor; const documentName = documentClass.documentName; const type = document.type; // Unrecognized type if ( !documentClass.TYPES.includes(type) ) return null; // Core-defined sub-type const coreTypes = documentClass.metadata.coreTypes; if ( coreTypes.includes(type) ) return null; // System-defined sub-type const systemTypes = game.system.documentTypes[documentName]; if ( systemTypes && (type in systemTypes) ) return game.system; // Module-defined sub-type const moduleId = type.substring(0, type.indexOf(".")); return game.modules.get(moduleId) ?? null; } /** * A convenience accessor for the name of the document type associated with this TypeDataField * @type {string} */ get documentName() { return this.document.documentName; } /** * Get the DataModel definition that should be used for this type of document. * @param {string} type The Document instance type * @returns {typeof DataModel|null} The DataModel class or null */ getModelForType(type) { if ( !type ) return null; return globalThis.CONFIG?.[this.documentName]?.dataModels?.[type] ?? null; } /** @override */ getInitialValue(data) { const cls = this.getModelForType(data.type); if ( cls ) return cls.cleanData(); const template = game?.model[this.documentName]?.[data.type]; if ( template ) return foundry.utils.deepClone(template); return {}; } /** @override */ _cleanType(value, options) { if ( !(typeof value === "object") ) value = {}; // Use a defined DataModel const type = options.source?.type; const cls = this.getModelForType(type); if ( cls ) return cls.cleanData(value, {...options, source: value}); if ( options.partial ) return value; // Use the defined template.json const template = this.getInitialValue(options.source); const insertKeys = (type === BASE_DOCUMENT_TYPE) || !game?.system?.strictDataCleaning; return mergeObject(template, value, {insertKeys, inplace: true}); } /** @override */ initialize(value, model, options={}) { const cls = this.getModelForType(model._source.type); if ( cls ) { const instance = new cls(value, {parent: model, ...options}); if ( !("modelProvider" in instance) ) Object.defineProperty(instance, "modelProvider", { value: this.constructor.getModelProvider(instance), writable: false }); return instance; } return deepClone(value); } /** @inheritdoc */ _validateType(data, options={}) { const result = super._validateType(data, options); if ( result !== undefined ) return result; const cls = this.getModelForType(options.source?.type); const schema = cls?.schema; return schema?.validate(data, {...options, source: data}); } /* ---------------------------------------- */ /** @override */ _validateModel(changes, options={}) { const cls = this.getModelForType(options.source?.type); return cls?.validateJoint(changes); } /* ---------------------------------------- */ /** @override */ toObject(value) { return value.toObject instanceof Function ? value.toObject(false) : deepClone(value); } /** * Migrate this field's candidate source data. * @param {object} sourceData Candidate source data of the root model * @param {any} fieldData The value of this field within the source data */ migrateSource(sourceData, fieldData) { const cls = this.getModelForType(sourceData.type); if ( cls ) cls.migrateDataSafe(fieldData); } } /* ---------------------------------------- */ /** * A subclass of [DataField]{@link DataField} which allows to typed schemas. */ class TypedSchemaField extends DataField { /** * @param {{[type: string]: DataSchema|SchemaField|typeof DataModel}} types The different types this field can represent. * @param {DataFieldOptions} [options] Options which configure the behavior of the field * @param {DataFieldContext} [context] Additional context which describes the field */ constructor(types, options, context) { super(options, context); this.types = this.#configureTypes(types); } /* ---------------------------------------- */ /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, {required: true}); } /* ---------------------------------------- */ /** * The types of this field. * @type {{[type: string]: SchemaField}} */ types; /* -------------------------------------------- */ /** * Initialize and validate the structure of the provided type definitions. * @param {{[type: string]: DataSchema|SchemaField|typeof DataModel}} types The provided field definitions * @returns {{[type: string]: SchemaField}} The validated fields */ #configureTypes(types) { if ( (typeof types !== "object") ) { throw new Error("A DataFields must be an object with string keys and DataField values."); } types = {...types}; for ( let [type, field] of Object.entries(types) ) { if ( isSubclass(field, DataModel) ) field = new EmbeddedDataField(field); if ( field?.constructor?.name === "Object" ) { const schema = {...field}; if ( !("type" in schema) ) { schema.type = new StringField({required: true, blank: false, initial: field, validate: value => value === type, validationError: `must be equal to "${type}"`}); } field = new SchemaField(schema); } if ( !(field instanceof SchemaField) ) { throw new Error(`The "${type}" field is not an instance of the SchemaField class or a subclass of DataModel.`); } if ( field.name !== undefined ) throw new Error(`The "${field.fieldPath}" field must not have a name.`); if ( field.parent !== undefined ) { throw new Error(`The "${field.fieldPath}" field already belongs to some other parent and may not be reused.`); } types[type] = field; field.parent = this; if ( !field.required ) throw new Error(`The "${field.fieldPath}" field must be required.`); if ( field.nullable ) throw new Error(`The "${field.fieldPath}" field must not be nullable.`); const typeField = field.fields.type; if ( !(typeField instanceof StringField) ) throw new Error(`The "${field.fieldPath}" field must have a "type" StringField.`); if ( !typeField.required ) throw new Error(`The "${typeField.fieldPath}" field must be required.`); if ( typeField.nullable ) throw new Error(`The "${typeField.fieldPath}" field must not be nullable.`); if ( typeField.blank ) throw new Error(`The "${typeField.fieldPath}" field must not be blank.`); if ( typeField.validate(type, {fallback: false}) !== undefined ) throw new Error(`"${type}" must be a valid type of "${typeField.fieldPath}".`); } return types; } /* ---------------------------------------- */ /** @override */ _getField(path) { if ( !path.length ) return this; return this.types[path.shift()]?._getField(path); } /* -------------------------------------------- */ /* Data Field Methods */ /* -------------------------------------------- */ /** @override */ _cleanType(value, options) { const field = this.types[value?.type]; if ( !field ) return value; return field.clean(value, options); } /* ---------------------------------------- */ /** @override */ _cast(value) { return typeof value === "object" ? value : {}; } /* ---------------------------------------- */ /** @override */ _validateSpecial(value) { const result = super._validateSpecial(value); if ( result !== undefined ) return result; const field = this.types[value?.type]; if ( !field ) throw new Error("does not have a valid type"); } /* ---------------------------------------- */ /** @override */ _validateType(value, options) { return this.types[value.type].validate(value, options); } /* ---------------------------------------- */ /** @override */ initialize(value, model, options) { const field = this.types[value?.type]; if ( !field ) return value; return field.initialize(value, model, options); } /* ---------------------------------------- */ /** @override */ toObject(value) { if ( !value ) return value; return this.types[value.type]?.toObject(value) ?? value; } /* -------------------------------------------- */ /** @override */ apply(fn, data, options) { // Apply to this TypedSchemaField const thisFn = typeof fn === "string" ? this[fn] : fn; thisFn?.call(this, data, options); // Apply to the inner typed field const typeField = this.types[data?.type]; return typeField?.apply(fn, data, options); } /* -------------------------------------------- */ /** * Migrate this field's candidate source data. * @param {object} sourceData Candidate source data of the root model * @param {any} fieldData The value of this field within the source data */ migrateSource(sourceData, fieldData) { const field = this.types[fieldData?.type]; const canMigrate = field?.migrateSource instanceof Function; if ( canMigrate ) field.migrateSource(sourceData, fieldData); } } /* ---------------------------------------- */ /* DEPRECATIONS */ /* ---------------------------------------- */ /** * @deprecated since v11 * @see DataModelValidationError * @ignore */ class ModelValidationError extends Error { constructor(errors) { logCompatibilityWarning( "ModelValidationError is deprecated. Please use DataModelValidationError instead.", {since: 11, until: 13}); const message = ModelValidationError.formatErrors(errors); super(message); this.errors = errors; } /** * Collect all the errors into a single message for consumers who do not handle the ModelValidationError specially. * @param {Record|Error[]|string} errors The raw error structure * @returns {string} A formatted error message */ static formatErrors(errors) { if ( typeof errors === "string" ) return errors; const message = ["Model Validation Errors"]; if ( errors instanceof Array ) message.push(...errors.map(e => e.message)); else message.push(...Object.entries(errors).map(([k, e]) => `[${k}]: ${e.message}`)); return message.join("\n"); } } /* -------------------------------------------- */ /** * @typedef {Object} JavaScriptFieldOptions * @property {boolean} [async=false] Does the field allow async code? */ /** * A subclass of {@link StringField} which contains JavaScript code. */ class JavaScriptField extends StringField { /** * @param {StringFieldOptions & JavaScriptFieldOptions} [options] Options which configure the behavior of the field * @param {DataFieldContext} [context] Additional context which describes the field */ constructor(options, context) { super(options, context); this.choices = undefined; } /** @inheritdoc */ static get _defaults() { return mergeObject(super._defaults, { required: true, blank: true, nullable: false, async: false }); } /** @inheritdoc */ _validateType(value, options) { const result = super._validateType(value, options); if ( result !== undefined ) return result; try { new (this.async ? AsyncFunction : Function)(value); } catch(err) { const scope = this.async ? "an asynchronous" : "a synchronous"; err.message = `must be valid JavaScript for ${scope} scope:\n${err.message}`; throw new Error(err); } } /* -------------------------------------------- */ /* Form Field Integration */ /* -------------------------------------------- */ /** @override */ toFormGroup(groupConfig={}, inputConfig) { groupConfig.stacked ??= true; return super.toFormGroup(groupConfig, inputConfig); } /** @override */ _toInput(config) { return foundry.applications.fields.createTextareaInput(config); } } var fields$1 = /*#__PURE__*/Object.freeze({ __proto__: null, AlphaField: AlphaField, AngleField: AngleField, AnyField: AnyField, ArrayField: ArrayField, BooleanField: BooleanField, ColorField: ColorField, DataField: DataField, DocumentIdField: DocumentIdField, DocumentOwnershipField: DocumentOwnershipField, DocumentStatsField: DocumentStatsField, DocumentTypeField: DocumentTypeField, DocumentUUIDField: DocumentUUIDField, EmbeddedCollectionDeltaField: EmbeddedCollectionDeltaField, EmbeddedCollectionField: EmbeddedCollectionField, EmbeddedDataField: EmbeddedDataField, EmbeddedDocumentField: EmbeddedDocumentField, FilePathField: FilePathField, ForeignDocumentField: ForeignDocumentField, HTMLField: HTMLField, HueField: HueField, IntegerSortField: IntegerSortField, JSONField: JSONField, JavaScriptField: JavaScriptField, ModelValidationError: ModelValidationError, NumberField: NumberField, ObjectField: ObjectField, SchemaField: SchemaField, SetField: SetField, StringField: StringField, TypeDataField: TypeDataField, TypedSchemaField: TypedSchemaField }); /** * @typedef {Record} 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 */ 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} 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$1(_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$1(_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$1(_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; } } /** * 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; * } * } * ``` */ 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} */ 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} 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} 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} 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) {} } /** * An extension of the base DataModel which defines a Document. * Documents are special in that they are persisted to the database and referenced by _id. * @memberof abstract * @abstract * @alias foundry.abstract.Document * * @param {object} data Initial data from which to construct the Document * @param {DocumentConstructionContext} context Construction context options * * @property {string|null} _id The document identifier, unique within its Collection, or null if the * Document has not yet been assigned an identifier * @property {string} [name] Documents typically have a human-readable name * @property {DataModel} [system] Certain document types may have a system data model which contains * subtype-specific data defined by the game system or a module * @property {DocumentStats} [_stats] Primary document types have a _stats object which provides metadata * about their status * @property {Record} flags Documents each have an object of arbitrary flags which are used by * systems or modules to store additional Document-specific data */ class Document extends DataModel { /** @override */ _configure({pack=null, parentCollection=null}={}) { /** * An immutable reverse-reference to the name of the collection that this Document exists in on its parent, if any. * @type {string|null} */ Object.defineProperty(this, "parentCollection", { value: this._getParentCollection(parentCollection), writable: false }); /** * An immutable reference to a containing Compendium collection to which this Document belongs. * @type {string|null} */ Object.defineProperty(this, "pack", { value: (() => { if ( typeof pack === "string" ) return pack; if ( this.parent?.pack ) return this.parent.pack; if ( pack === null ) return null; throw new Error("The provided compendium pack ID must be a string"); })(), writable: false }); // Construct Embedded Collections const collections = {}; for ( const [fieldName, field] of Object.entries(this.constructor.hierarchy) ) { if ( !field.constructor.implementation ) continue; const data = this._source[fieldName]; const c = collections[fieldName] = new field.constructor.implementation(fieldName, this, data); Object.defineProperty(this, fieldName, {value: c, writable: false}); } /** * A mapping of embedded Document collections which exist in this model. * @type {Record} */ Object.defineProperty(this, "collections", {value: Object.seal(collections), writable: false}); } /* ---------------------------------------- */ /** * Ensure that all Document classes share the same schema of their base declaration. * @type {SchemaField} * @override */ static get schema() { if ( this._schema ) return this._schema; const base = this.baseDocument; if ( !base.hasOwnProperty("_schema") ) { const schema = new SchemaField(Object.freeze(base.defineSchema())); Object.defineProperty(base, "_schema", {value: schema, writable: false}); } Object.defineProperty(this, "_schema", {value: base._schema, writable: false}); return base._schema; } /* -------------------------------------------- */ /** @inheritdoc */ _initialize(options={}) { super._initialize(options); const singletons = {}; for ( const [fieldName, field] of Object.entries(this.constructor.hierarchy) ) { if ( field instanceof foundry.data.fields.EmbeddedDocumentField ) { Object.defineProperty(singletons, fieldName, { get: () => this[fieldName] }); } } /** * A mapping of singleton embedded Documents which exist in this model. * @type {Record} */ Object.defineProperty(this, "singletons", {value: Object.seal(singletons), configurable: true}); } /* -------------------------------------------- */ /** @override */ static *_initializationOrder() { const hierarchy = this.hierarchy; // Initialize non-hierarchical fields first for ( const [name, field] of this.schema.entries() ) { if ( name in hierarchy ) continue; yield [name, field]; } // Initialize hierarchical fields last for ( const [name, field] of Object.entries(hierarchy) ) { yield [name, field]; } } /* -------------------------------------------- */ /* Model Configuration */ /* -------------------------------------------- */ /** * Default metadata which applies to each instance of this Document type. * @type {object} */ static metadata = Object.freeze({ name: "Document", collection: "documents", indexed: false, compendiumIndexFields: [], label: "DOCUMENT.Document", coreTypes: [BASE_DOCUMENT_TYPE], embedded: {}, permissions: { create: "ASSISTANT", update: "ASSISTANT", delete: "ASSISTANT" }, preserveOnImport: ["_id", "sort", "ownership"], /* * The metadata has to include the version of this Document schema, which needs to be increased * whenever the schema is changed such that Document data created before this version * would come out different if `fromSource(data).toObject()` was applied to it so that * we always vend data to client that is in the schema of the current core version. * The schema version needs to be bumped if * - a field was added or removed, * - the class/type of any field was changed, * - the casting or cleaning behavior of any field class was changed, * - the data model of an embedded data field was changed, * - certain field properties are changed (e.g. required, nullable, blank, ...), or * - there have been changes to cleanData or migrateData of the Document. * * Moreover, the schema version needs to be bumped if the sanitization behavior * of any field in the schema was changed. */ schemaVersion: undefined }); /* -------------------------------------------- */ /** * The database backend used to execute operations and handle results. * @type {abstract.DatabaseBackend} */ static get database() { return globalThis.CONFIG.DatabaseBackend; } /* -------------------------------------------- */ /** * Return a reference to the configured subclass of this base Document type. * @type {typeof Document} */ static get implementation() { return globalThis.CONFIG[this.documentName]?.documentClass || this; } /* -------------------------------------------- */ /** * The base document definition that this document class extends from. * @type {typeof Document} */ static get baseDocument() { let cls; let parent = this; while ( parent ) { cls = parent; parent = Object.getPrototypeOf(cls); if ( parent === Document ) return cls; } throw new Error(`Base Document class identification failed for "${this.documentName}"`); } /* -------------------------------------------- */ /** * The named collection to which this Document belongs. * @type {string} */ static get collectionName() { return this.metadata.collection; } get collectionName() { return this.constructor.collectionName; } /* -------------------------------------------- */ /** * The canonical name of this Document type, for example "Actor". * @type {string} */ static get documentName() { return this.metadata.name; } get documentName() { return this.constructor.documentName; } /* ---------------------------------------- */ /** * The allowed types which may exist for this Document class. * @type {string[]} */ static get TYPES() { return Object.keys(game.model[this.metadata.name]); } /* -------------------------------------------- */ /** * Does this Document support additional subtypes? * @type {boolean} */ static get hasTypeData() { return this.metadata.hasTypeData; } /* -------------------------------------------- */ /* Model Properties */ /* -------------------------------------------- */ /** * The Embedded Document hierarchy for this Document. * @returns {Readonly>} */ static get hierarchy() { const hierarchy = {}; for ( const [fieldName, field] of this.schema.entries() ) { if ( field.constructor.hierarchical ) hierarchy[fieldName] = field; } Object.defineProperty(this, "hierarchy", {value: Object.freeze(hierarchy), writable: false}); return hierarchy; } /* -------------------------------------------- */ /** * Identify the collection in a parent Document that this Document belongs to, if any. * @param {string|null} [parentCollection] An explicitly provided parent collection name. * @returns {string|null} * @internal */ _getParentCollection(parentCollection) { if ( !this.parent ) return null; if ( parentCollection ) return parentCollection; return this.parent.constructor.getCollectionName(this.documentName); } /** * The canonical identifier for this Document. * @type {string|null} */ get id() { return this._id; } /** * Test whether this Document is embedded within a parent Document * @type {boolean} */ get isEmbedded() { return !!(this.parent && this.parentCollection); } /* -------------------------------------------- */ /** * A Universally Unique Identifier (uuid) for this Document instance. * @type {string} */ get uuid() { let parts = [this.documentName, this.id]; if ( this.parent ) parts = [this.parent.uuid].concat(parts); else if ( this.pack ) parts = ["Compendium", this.pack].concat(parts); return parts.join("."); } /* ---------------------------------------- */ /* Model Permissions */ /* ---------------------------------------- */ /** * Test whether a given User has a sufficient role in order to create Documents of this type in general. * @param {documents.BaseUser} user The User being tested * @return {boolean} Does the User have a sufficient role to create? */ static canUserCreate(user) { // TODO: https://github.com/foundryvtt/foundryvtt/issues/11280 const perm = this.metadata.permissions.create; if ( perm instanceof Function ) { throw new Error('Document.canUserCreate is not supported for this document type. ' + 'Use Document#canUserModify(user, "create") to test whether a user is permitted to create a ' + 'specific document instead.'); } return user.hasPermission(perm) || user.hasRole(perm, {exact: false}); } /* ---------------------------------------- */ /** * Get the explicit permission level that a User has over this Document, a value in CONST.DOCUMENT_OWNERSHIP_LEVELS. * This method returns the value recorded in Document ownership, regardless of the User's role. * To test whether a user has a certain capability over the document, testUserPermission should be used. * @param {documents.BaseUser} [user=game.user] The User being tested * @returns {number|null} A numeric permission level from CONST.DOCUMENT_OWNERSHIP_LEVELS or null */ getUserLevel(user) { user = user || game.user; // Compendium content uses role-based ownership if ( this.pack ) return this.compendium.getUserLevel(user); // World content uses granular per-User ownership const ownership = this["ownership"] || {}; return ownership[user.id] ?? ownership.default ?? null; } /* ---------------------------------------- */ /** * Test whether a certain User has a requested permission level (or greater) over the Document * @param {documents.BaseUser} user The User being tested * @param {string|number} permission The permission level from DOCUMENT_OWNERSHIP_LEVELS to test * @param {object} options Additional options involved in the permission test * @param {boolean} [options.exact=false] Require the exact permission level requested? * @return {boolean} Does the user have this permission level over the Document? */ testUserPermission(user, permission, {exact=false}={}) { const perms = DOCUMENT_OWNERSHIP_LEVELS; let level; if ( user.isGM ) level = perms.OWNER; else if ( user.isBanned ) level = perms.NONE; else level = this.getUserLevel(user); const target = (typeof permission === "string") ? (perms[permission] ?? perms.OWNER) : permission; return exact ? level === target : level >= target; } /* ---------------------------------------- */ /** * Test whether a given User has permission to perform some action on this Document * @param {documents.BaseUser} user The User attempting modification * @param {string} action The attempted action * @param {object} [data] Data involved in the attempted action * @return {boolean} Does the User have permission? */ canUserModify(user, action, data={}) { const permissions = this.constructor.metadata.permissions; const perm = permissions[action]; // Specialized permission test function if ( perm instanceof Function ) return perm(user, this, data); // User-level permission else if ( perm in USER_PERMISSIONS ) return user.hasPermission(perm); // Document-level permission const isOwner = this.testUserPermission(user, "OWNER"); const hasRole = (perm in USER_ROLES) && user.hasRole(perm); return isOwner || hasRole; } /* ---------------------------------------- */ /* Model Methods */ /* ---------------------------------------- */ /** * Clone a document, creating a new document by combining current data with provided overrides. * The cloned document is ephemeral and not yet saved to the database. * @param {Object} [data={}] Additional data which overrides current document data at the time * of creation * @param {DocumentConstructionContext} [context={}] Additional context options passed to the create method * @param {boolean} [context.save=false] Save the clone to the World database? * @param {boolean} [context.keepId=false] Keep the same ID of the original document * @param {boolean} [context.addSource=false] Track the clone source. * @returns {Document|Promise} The cloned Document instance */ clone(data={}, {save=false, keepId=false, addSource=false, ...context}={}) { if ( !keepId ) data["-=_id"] = null; if ( addSource ) data["_stats.duplicateSource"] = this.uuid; context.parent = this.parent; context.pack = this.pack; context.strict = false; const doc = super.clone(data, context); return save ? this.constructor.create(doc, context) : doc; } /* -------------------------------------------- */ /** * For Documents which include game system data, migrate the system data object to conform to its latest data model. * The data model is defined by the template.json specification included by the game system. * @returns {object} The migrated system data object */ migrateSystemData() { if ( !this.constructor.hasTypeData ) { throw new Error(`The ${this.documentName} Document does not include a TypeDataField.`); } if ( (this.system instanceof DataModel) && !(this.system.modelProvider instanceof System) ) { throw new Error(`The ${this.documentName} Document does not have system-provided package data.`); } const model = game.model[this.documentName]?.[this["type"]] || {}; return mergeObject(model, this["system"], { insertKeys: false, insertValues: true, enforceTypes: false, overwrite: true, inplace: false }); } /* ---------------------------------------- */ /** @inheritdoc */ toObject(source=true) { const data = super.toObject(source); return this.constructor.shimData(data); } /* -------------------------------------------- */ /* Database Operations */ /* -------------------------------------------- */ /** * Create multiple Documents using provided input data. * Data is provided as an array of objects where each individual object becomes one new Document. * * @param {Array} data An array of data objects or existing Documents to persist. * @param {Partial>} [operation={}] Parameters of the requested creation * operation * @return {Promise} An array of created Document instances * * @example Create a single Document * ```js * const data = [{name: "New Actor", type: "character", img: "path/to/profile.jpg"}]; * const created = await Actor.createDocuments(data); * ``` * * @example Create multiple Documents * ```js * const data = [{name: "Tim", type: "npc"], [{name: "Tom", type: "npc"}]; * const created = await Actor.createDocuments(data); * ``` * * @example Create multiple embedded Documents within a parent * ```js * const actor = game.actors.getName("Tim"); * const data = [{name: "Sword", type: "weapon"}, {name: "Breastplate", type: "equipment"}]; * const created = await Item.createDocuments(data, {parent: actor}); * ``` * * @example Create a Document within a Compendium pack * ```js * const data = [{name: "Compendium Actor", type: "character", img: "path/to/profile.jpg"}]; * const created = await Actor.createDocuments(data, {pack: "mymodule.mypack"}); * ``` */ static async createDocuments(data=[], operation={}) { if ( operation.parent?.pack ) operation.pack = operation.parent.pack; operation.data = data; const created = await this.database.create(this.implementation, operation); /** @deprecated since v12 */ if ( getDefiningClass(this, "_onCreateDocuments") !== Document ) { foundry.utils.logCompatibilityWarning("The Document._onCreateDocuments static method is deprecated in favor of " + "Document._onCreateOperation", {since: 12, until: 14}); await this._onCreateDocuments(created, operation); } return created; } /* -------------------------------------------- */ /** * Update multiple Document instances using provided differential data. * Data is provided as an array of objects where each individual object updates one existing Document. * * @param {object[]} updates An array of differential data objects, each used to update a single Document * @param {Partial>} [operation={}] Parameters of the database update * operation * @return {Promise} An array of updated Document instances * * @example Update a single Document * ```js * const updates = [{_id: "12ekjf43kj2312ds", name: "Timothy"}]; * const updated = await Actor.updateDocuments(updates); * ``` * * @example Update multiple Documents * ```js * const updates = [{_id: "12ekjf43kj2312ds", name: "Timothy"}, {_id: "kj549dk48k34jk34", name: "Thomas"}]}; * const updated = await Actor.updateDocuments(updates); * ``` * * @example Update multiple embedded Documents within a parent * ```js * const actor = game.actors.getName("Timothy"); * const updates = [{_id: sword.id, name: "Magic Sword"}, {_id: shield.id, name: "Magic Shield"}]; * const updated = await Item.updateDocuments(updates, {parent: actor}); * ``` * * @example Update Documents within a Compendium pack * ```js * const actor = await pack.getDocument(documentId); * const updated = await Actor.updateDocuments([{_id: actor.id, name: "New Name"}], {pack: "mymodule.mypack"}); * ``` */ static async updateDocuments(updates=[], operation={}) { if ( operation.parent?.pack ) operation.pack = operation.parent.pack; operation.updates = updates; const updated = await this.database.update(this.implementation, operation); /** @deprecated since v12 */ if ( getDefiningClass(this, "_onUpdateDocuments") !== Document ) { foundry.utils.logCompatibilityWarning("The Document._onUpdateDocuments static method is deprecated in favor of " + "Document._onUpdateOperation", {since: 12, until: 14}); await this._onUpdateDocuments(updated, operation); } return updated; } /* -------------------------------------------- */ /** * Delete one or multiple existing Documents using an array of provided ids. * Data is provided as an array of string ids for the documents to delete. * * @param {string[]} ids An array of string ids for the documents to be deleted * @param {Partial>} [operation={}] Parameters of the database deletion * operation * @return {Promise} An array of deleted Document instances * * @example Delete a single Document * ```js * const tim = game.actors.getName("Tim"); * const deleted = await Actor.deleteDocuments([tim.id]); * ``` * * @example Delete multiple Documents * ```js * const tim = game.actors.getName("Tim"); * const tom = game.actors.getName("Tom"); * const deleted = await Actor.deleteDocuments([tim.id, tom.id]); * ``` * * @example Delete multiple embedded Documents within a parent * ```js * const tim = game.actors.getName("Tim"); * const sword = tim.items.getName("Sword"); * const shield = tim.items.getName("Shield"); * const deleted = await Item.deleteDocuments([sword.id, shield.id], parent: actor}); * ``` * * @example Delete Documents within a Compendium pack * ```js * const actor = await pack.getDocument(documentId); * const deleted = await Actor.deleteDocuments([actor.id], {pack: "mymodule.mypack"}); * ``` */ static async deleteDocuments(ids=[], operation={}) { if ( operation.parent?.pack ) operation.pack = operation.parent.pack; operation.ids = ids; const deleted = await this.database.delete(this.implementation, operation); /** @deprecated since v12 */ if ( getDefiningClass(this, "_onDeleteDocuments") !== Document ) { foundry.utils.logCompatibilityWarning("The Document._onDeleteDocuments static method is deprecated in favor of " + "Document._onDeleteOperation", {since: 12, until: 14}); await this._onDeleteDocuments(deleted, operation); } return deleted; } /* -------------------------------------------- */ /** * Create a new Document using provided input data, saving it to the database. * @see Document.createDocuments * @param {object|Document|(object|Document)[]} [data={}] Initial data used to create this Document, or a Document * instance to persist. * @param {Partial>} [operation={}] Parameters of the creation operation * @returns {Promise} The created Document instance * * @example Create a World-level Item * ```js * const data = [{name: "Special Sword", type: "weapon"}]; * const created = await Item.create(data); * ``` * * @example Create an Actor-owned Item * ```js * const data = [{name: "Special Sword", type: "weapon"}]; * const actor = game.actors.getName("My Hero"); * const created = await Item.create(data, {parent: actor}); * ``` * * @example Create an Item in a Compendium pack * ```js * const data = [{name: "Special Sword", type: "weapon"}]; * const created = await Item.create(data, {pack: "mymodule.mypack"}); * ``` */ static async create(data, operation={}) { const createData = data instanceof Array ? data : [data]; const created = await this.createDocuments(createData, operation); return data instanceof Array ? created : created.shift(); } /* -------------------------------------------- */ /** * Update this Document using incremental data, saving it to the database. * @see Document.updateDocuments * @param {object} [data={}] Differential update data which modifies the existing values of this document * @param {Partial>} [operation={}] Parameters of the update operation * @returns {Promise} The updated Document instance */ async update(data={}, operation={}) { data._id = this.id; operation.parent = this.parent; operation.pack = this.pack; const updates = await this.constructor.updateDocuments([data], operation); return updates.shift(); } /* -------------------------------------------- */ /** * Delete this Document, removing it from the database. * @see Document.deleteDocuments * @param {Partial>} [operation={}] Parameters of the deletion operation * @returns {Promise} The deleted Document instance */ async delete(operation={}) { operation.parent = this.parent; operation.pack = this.pack; const deleted = await this.constructor.deleteDocuments([this.id], operation); return deleted.shift(); } /* -------------------------------------------- */ /** * Get a World-level Document of this type by its id. * @param {string} documentId The Document ID * @param {DatabaseGetOperation} [operation={}] Parameters of the get operation * @returns {abstract.Document|null} The retrieved Document, or null */ static get(documentId, operation={}) { if ( !documentId ) return null; if ( operation.pack ) { const pack = game.packs.get(operation.pack); return pack?.index.get(documentId) || null; } else { const collection = game.collections?.get(this.documentName); return collection?.get(documentId) || null; } } /* -------------------------------------------- */ /* Embedded Operations */ /* -------------------------------------------- */ /** * A compatibility method that returns the appropriate name of an embedded collection within this Document. * @param {string} name An existing collection name or a document name. * @returns {string|null} The provided collection name if it exists, the first available collection for the * document name provided, or null if no appropriate embedded collection could be found. * @example Passing an existing collection name. * ```js * Actor.getCollectionName("items"); * // returns "items" * ``` * * @example Passing a document name. * ```js * Actor.getCollectionName("Item"); * // returns "items" * ``` */ static getCollectionName(name) { if ( name in this.hierarchy ) return name; for ( const [collectionName, field] of Object.entries(this.hierarchy) ) { if ( field.model.documentName === name ) return collectionName; } return null; } /* -------------------------------------------- */ /** * Obtain a reference to the Array of source data within the data object for a certain embedded Document name * @param {string} embeddedName The name of the embedded Document type * @return {DocumentCollection} The Collection instance of embedded Documents of the requested type */ getEmbeddedCollection(embeddedName) { const collectionName = this.constructor.getCollectionName(embeddedName); if ( !collectionName ) { throw new Error(`${embeddedName} is not a valid embedded Document within the ${this.documentName} Document`); } const field = this.constructor.hierarchy[collectionName]; return field.getCollection(this); } /* -------------------------------------------- */ /** * Get an embedded document by its id from a named collection in the parent document. * @param {string} embeddedName The name of the embedded Document type * @param {string} id The id of the child document to retrieve * @param {object} [options] Additional options which modify how embedded documents are retrieved * @param {boolean} [options.strict=false] Throw an Error if the requested id does not exist. See Collection#get * @param {boolean} [options.invalid=false] Allow retrieving an invalid Embedded Document. * @return {Document} The retrieved embedded Document instance, or undefined * @throws If the embedded collection does not exist, or if strict is true and the Embedded Document could not be * found. */ getEmbeddedDocument(embeddedName, id, {invalid=false, strict=false}={}) { const collection = this.getEmbeddedCollection(embeddedName); return collection.get(id, {invalid, strict}); } /* -------------------------------------------- */ /** * Create multiple embedded Document instances within this parent Document using provided input data. * @see Document.createDocuments * @param {string} embeddedName The name of the embedded Document type * @param {object[]} data An array of data objects used to create multiple documents * @param {DatabaseCreateOperation} [operation={}] Parameters of the database creation workflow * @return {Promise} An array of created Document instances */ async createEmbeddedDocuments(embeddedName, data=[], operation={}) { this.getEmbeddedCollection(embeddedName); // Validation only operation.parent = this; operation.pack = this.pack; const cls = getDocumentClass(embeddedName); return cls.createDocuments(data, operation); } /* -------------------------------------------- */ /** * Update multiple embedded Document instances within a parent Document using provided differential data. * @see Document.updateDocuments * @param {string} embeddedName The name of the embedded Document type * @param {object[]} updates An array of differential data objects, each used to update a * single Document * @param {DatabaseUpdateOperation} [operation={}] Parameters of the database update workflow * @return {Promise} An array of updated Document instances */ async updateEmbeddedDocuments(embeddedName, updates=[], operation={}) { this.getEmbeddedCollection(embeddedName); // Validation only operation.parent = this; operation.pack = this.pack; const cls = getDocumentClass(embeddedName); return cls.updateDocuments(updates, operation); } /* -------------------------------------------- */ /** * Delete multiple embedded Document instances within a parent Document using provided string ids. * @see Document.deleteDocuments * @param {string} embeddedName The name of the embedded Document type * @param {string[]} ids An array of string ids for each Document to be deleted * @param {DatabaseDeleteOperation} [operation={}] Parameters of the database deletion workflow * @return {Promise} An array of deleted Document instances */ async deleteEmbeddedDocuments(embeddedName, ids, operation={}) { this.getEmbeddedCollection(embeddedName); // Validation only operation.parent = this; operation.pack = this.pack; const cls = getDocumentClass(embeddedName); return cls.deleteDocuments(ids, operation); } /* -------------------------------------------- */ /** * Iterate over all embedded Documents that are hierarchical children of this Document. * @param {string} [_parentPath] A parent field path already traversed * @returns {Generator<[string, Document]>} */ * traverseEmbeddedDocuments(_parentPath) { for ( const [fieldName, field] of Object.entries(this.constructor.hierarchy) ) { let fieldPath = _parentPath ? `${_parentPath}.${fieldName}` : fieldName; // Singleton embedded document if ( field instanceof foundry.data.fields.EmbeddedDocumentField ) { const document = this[fieldName]; if ( document ) { yield [fieldPath, document]; yield* document.traverseEmbeddedDocuments(fieldPath); } } // Embedded document collection else if ( field instanceof foundry.data.fields.EmbeddedCollectionField ) { const collection = this[fieldName]; const isDelta = field instanceof foundry.data.fields.EmbeddedCollectionDeltaField; for ( const document of collection.values() ) { if ( isDelta && !collection.manages(document.id) ) continue; yield [fieldPath, document]; yield* document.traverseEmbeddedDocuments(fieldPath); } } } } /* -------------------------------------------- */ /* Flag Operations */ /* -------------------------------------------- */ /** * Get the value of a "flag" for this document * See the setFlag method for more details on flags * * @param {string} scope The flag scope which namespaces the key * @param {string} key The flag key * @return {*} The flag value */ getFlag(scope, key) { const scopes = this.constructor.database.getFlagScopes(); if ( !scopes.includes(scope) ) throw new Error(`Flag scope "${scope}" is not valid or not currently active`); /** @deprecated since v12 */ if ( (scope === "core") && (key === "sourceId") ) { foundry.utils.logCompatibilityWarning("The core.sourceId flag has been deprecated. " + "Please use the _stats.compendiumSource property instead.", { since: 12, until: 14 }); return this._stats?.compendiumSource; } if ( !this.flags || !(scope in this.flags) ) return undefined; return getProperty(this.flags?.[scope], key); } /* -------------------------------------------- */ /** * Assign a "flag" to this document. * Flags represent key-value type data which can be used to store flexible or arbitrary data required by either * the core software, game systems, or user-created modules. * * Each flag should be set using a scope which provides a namespace for the flag to help prevent collisions. * * Flags set by the core software use the "core" scope. * Flags set by game systems or modules should use the canonical name attribute for the module * Flags set by an individual world should "world" as the scope. * * Flag values can assume almost any data type. Setting a flag value to null will delete that flag. * * @param {string} scope The flag scope which namespaces the key * @param {string} key The flag key * @param {*} value The flag value * @return {Promise} A Promise resolving to the updated document */ async setFlag(scope, key, value) { const scopes = this.constructor.database.getFlagScopes(); if ( !scopes.includes(scope) ) throw new Error(`Flag scope "${scope}" is not valid or not currently active`); return this.update({ flags: { [scope]: { [key]: value } } }); } /* -------------------------------------------- */ /** * Remove a flag assigned to the document * @param {string} scope The flag scope which namespaces the key * @param {string} key The flag key * @return {Promise} The updated document instance */ async unsetFlag(scope, key) { const scopes = this.constructor.database.getFlagScopes(); if ( !scopes.includes(scope) ) throw new Error(`Flag scope "${scope}" is not valid or not currently active`); const head = key.split("."); const tail = `-=${head.pop()}`; key = ["flags", scope, ...head, tail].join("."); return this.update({[key]: null}); } /* -------------------------------------------- */ /* Database Creation Operations */ /* -------------------------------------------- */ /** * Pre-process a creation operation for a single Document instance. Pre-operation events only occur for the client * which requested the operation. * * Modifications to the pending Document instance must be performed using {@link Document#updateSource}. * * @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} Return false to exclude this Document from the creation operation * @internal */ async _preCreate(data, options, user) {} /** * Post-process a creation operation for a single Document instance. Post-operation events occur for all connected * clients. * * @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 * @internal */ _onCreate(data, options, userId) {} /** * Pre-process a creation operation, potentially altering its instructions or input data. Pre-operation events only * occur for the client which requested the operation. * * This batch-wise workflow occurs after individual {@link Document#_preCreate} workflows and provides a final * pre-flight check before a database operation occurs. * * Modifications to pending documents must mutate the documents array or alter individual document instances using * {@link Document#updateSource}. * * @param {Document[]} documents Pending document instances to be created * @param {DatabaseCreateOperation} operation Parameters of the database creation operation * @param {documents.BaseUser} user The User requesting the creation operation * @returns {Promise} Return false to cancel the creation operation entirely * @internal */ static async _preCreateOperation(documents, operation, user) {} /** * Post-process a creation operation, reacting to database changes which have occurred. Post-operation events occur * for all connected clients. * * This batch-wise workflow occurs after individual {@link Document#_onCreate} workflows. * * @param {Document[]} documents The Document instances which were created * @param {DatabaseCreateOperation} operation Parameters of the database creation operation * @param {documents.BaseUser} user The User who performed the creation operation * @returns {Promise} * @internal */ static async _onCreateOperation(documents, operation, user) {} /* -------------------------------------------- */ /* Database Update Operations */ /* -------------------------------------------- */ /** * Pre-process an update operation for a single Document instance. Pre-operation events only occur for the client * which requested the operation. * * @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} A return value of false indicates the update operation should be cancelled. * @internal */ async _preUpdate(changes, options, user) {} /** * Post-process an update operation for a single Document instance. Post-operation events occur for all connected * clients. * * @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 * @internal */ _onUpdate(changed, options, userId) {} /** * Pre-process an update operation, potentially altering its instructions or input data. Pre-operation events only * occur for the client which requested the operation. * * This batch-wise workflow occurs after individual {@link Document#_preUpdate} workflows and provides a final * pre-flight check before a database operation occurs. * * Modifications to the requested updates are performed by mutating the data array of the operation. * {@link Document#updateSource}. * * @param {Document[]} documents Document instances to be updated * @param {DatabaseUpdateOperation} operation Parameters of the database update operation * @param {documents.BaseUser} user The User requesting the update operation * @returns {Promise} Return false to cancel the update operation entirely * @internal */ static async _preUpdateOperation(documents, operation, user) {} /** * Post-process an update operation, reacting to database changes which have occurred. Post-operation events occur * for all connected clients. * * This batch-wise workflow occurs after individual {@link Document#_onUpdate} workflows. * * @param {Document[]} documents The Document instances which were updated * @param {DatabaseUpdateOperation} operation Parameters of the database update operation * @param {documents.BaseUser} user The User who performed the update operation * @returns {Promise} * @internal */ static async _onUpdateOperation(documents, operation, user) {} /* -------------------------------------------- */ /* Database Delete Operations */ /* -------------------------------------------- */ /** * Pre-process a deletion operation for a single Document instance. Pre-operation events only occur for the client * which requested the operation. * * @param {object} options Additional options which modify the deletion request * @param {documents.BaseUser} user The User requesting the document deletion * @returns {Promise} A return value of false indicates the deletion operation should be cancelled. * @internal */ async _preDelete(options, user) {} /** * Post-process a deletion operation for a single Document instance. Post-operation events occur for all connected * clients. * * @param {object} options Additional options which modify the deletion request * @param {string} userId The id of the User requesting the document update * @internal */ _onDelete(options, userId) {} /** * Pre-process a deletion operation, potentially altering its instructions or input data. Pre-operation events only * occur for the client which requested the operation. * * This batch-wise workflow occurs after individual {@link Document#_preDelete} workflows and provides a final * pre-flight check before a database operation occurs. * * Modifications to the requested deletions are performed by mutating the operation object. * {@link Document#updateSource}. * * @param {Document[]} documents Document instances to be deleted * @param {DatabaseDeleteOperation} operation Parameters of the database update operation * @param {documents.BaseUser} user The User requesting the deletion operation * @returns {Promise} Return false to cancel the deletion operation entirely * @internal */ static async _preDeleteOperation(documents, operation, user) {} /** * Post-process a deletion operation, reacting to database changes which have occurred. Post-operation events occur * for all connected clients. * * This batch-wise workflow occurs after individual {@link Document#_onDelete} workflows. * * @param {Document[]} documents The Document instances which were deleted * @param {DatabaseDeleteOperation} operation Parameters of the database deletion operation * @param {documents.BaseUser} user The User who performed the deletion operation * @returns {Promise} * @internal */ static async _onDeleteOperation(documents, operation, user) {} /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v10 * @ignore */ get data() { if ( this.constructor.schema.has("system") ) { throw new Error(`You are accessing the ${this.constructor.name} "data" field of which was deprecated in v10 and ` + `replaced with "system". Continued usage of pre-v10 ".data" paths is no longer supported"`); } } /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ static get hasSystemData() { foundry.utils.logCompatibilityWarning(`You are accessing ${this.name}.hasSystemData which is deprecated. ` + `Please use ${this.name}.hasTypeData instead.`, {since: 11, until: 13}); return this.hasTypeData; } /* ---------------------------------------- */ /** * A reusable helper for adding migration shims. * @protected * @ignore */ static _addDataFieldShims(data, shims, options) { for ( const [oldKey, newKey] of Object.entries(shims) ) { this._addDataFieldShim(data, oldKey, newKey, options); } } /* ---------------------------------------- */ /** * A reusable helper for adding a migration shim * @protected * @ignore */ static _addDataFieldShim(data, oldKey, newKey, options={}) { if ( data.hasOwnProperty(oldKey) ) return; Object.defineProperty(data, oldKey, { get: () => { if ( options.warning ) logCompatibilityWarning(options.warning); else this._logDataFieldMigration(oldKey, newKey, options); return ("value" in options) ? options.value : getProperty(data, newKey); }, set: value => { if ( newKey ) setProperty(data, newKey, value); }, configurable: true, enumerable: false }); } /* ---------------------------------------- */ /** * Define a simple migration from one field name to another. * The value of the data can be transformed during the migration by an optional application function. * @param {object} data The data object being migrated * @param {string} oldKey The old field name * @param {string} newKey The new field name * @param {function(data: object): any} [apply] An application function, otherwise the old value is applied * @returns {boolean} Whether a migration was applied. * @internal */ static _addDataFieldMigration(data, oldKey, newKey, apply) { if ( !hasProperty(data, newKey) && hasProperty(data, oldKey) ) { const prop = Object.getOwnPropertyDescriptor(data, oldKey); if ( prop && !prop.writable ) return false; setProperty(data, newKey, apply ? apply(data) : getProperty(data, oldKey)); delete data[oldKey]; return true; } return false; } /* ---------------------------------------- */ /** @protected */ static _logDataFieldMigration(oldKey, newKey, options={}) { const msg = `You are accessing ${this.name}#${oldKey} which has been migrated to ${this.name}#${newKey}`; return logCompatibilityWarning(msg, {...options}) } /* ---------------------------------------- */ /** * @deprecated since v12 * @ignore */ static async _onCreateDocuments(documents, operation) {} /** * @deprecated since v12 * @ignore */ static async _onUpdateDocuments(documents, operation) {} /** * @deprecated since v12 * @ignore */ static async _onDeleteDocuments(documents, operation) {} } /** * @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 */ 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; } /** * @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 */ 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} 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} 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} 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} 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} 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} 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} 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} 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} */ 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} 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; } } var abstract = /*#__PURE__*/Object.freeze({ __proto__: null, DataModel: DataModel, DatabaseBackend: DatabaseBackend, Document: Document, DocumentSocketResponse: DocumentSocketResponse, EmbeddedCollection: EmbeddedCollection, EmbeddedCollectionDelta: EmbeddedCollectionDelta, SingletonEmbeddedCollection: SingletonEmbeddedCollection, TypeDataModel: TypeDataModel, types: _types$4 }); /** * @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} */ class BaseActiveEffect extends Document { /** * Construct an ActiveEffect document using provided data and context. * @param {Partial} 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 DocumentIdField(), name: new StringField({required: true, blank: false, label: "EFFECT.Name", textSearch: true}), img: new FilePathField({categories: ["IMAGE"], label: "EFFECT.Image"}), type: new DocumentTypeField(this, {initial: BASE_DOCUMENT_TYPE}), system: new TypeDataField(this), changes: new ArrayField(new SchemaField({ key: new StringField({required: true, label: "EFFECT.ChangeKey"}), value: new StringField({required: true, label: "EFFECT.ChangeValue"}), mode: new NumberField({integer: true, initial: ACTIVE_EFFECT_MODES.ADD, label: "EFFECT.ChangeMode"}), priority: new NumberField() })), disabled: new BooleanField(), duration: new SchemaField({ startTime: new NumberField({initial: null, label: "EFFECT.StartTime"}), seconds: new NumberField({integer: true, min: 0, label: "EFFECT.DurationSecs"}), combat: new ForeignDocumentField(BaseCombat, {label: "EFFECT.Combat"}), rounds: new NumberField({integer: true, min: 0}), turns: new NumberField({integer: true, min: 0, label: "EFFECT.DurationTurns"}), startRound: new NumberField({integer: true, min: 0}), startTurn: new NumberField({integer: true, min: 0, label: "EFFECT.StartTurns"}) }), description: new HTMLField({label: "EFFECT.Description", textSearch: true}), origin: new StringField({nullable: true, blank: false, initial: null, label: "EFFECT.Origin"}), tint: new ColorField({nullable: false, initial: "#ffffff", label: "EFFECT.Tint"}), transfer: new BooleanField({initial: true, label: "EFFECT.Transfer"}), statuses: new SetField(new StringField({required: true, blank: false})), sort: new IntegerSortField(), flags: new ObjectField(), _stats: new 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 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; } } /** * The collection of data schema and document definitions for primary documents which are shared between the both the * client and the server. * @namespace data */ /** * @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 BooleanField(), priority: new NumberField({required: true, nullable: false, integer: true, initial: 0, min: 0}), alpha: new AlphaField({initial: 0.5}), angle: new AngleField({initial: 360, normalize: false}), bright: new NumberField({required: true, nullable: false, initial: 0, min: 0, step: 0.01}), color: new ColorField({}), coloration: new NumberField({required: true, integer: true, initial: 1}), dim: new NumberField({required: true, nullable: false, initial: 0, min: 0, step: 0.01}), attenuation: new AlphaField({initial: 0.5}), luminosity: new NumberField({required: true, nullable: false, initial: 0.5, min: 0, max: 1}), saturation: new NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1}), contrast: new NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1}), shadows: new NumberField({required: true, nullable: false, initial: 0, min: 0, max: 1}), animation: new SchemaField({ type: new StringField({nullable: true, blank: false, initial: null}), speed: new 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 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 BooleanField() }), darkness: new SchemaField({ min: new AlphaField({initial: 0}), max: new 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 StringField({required: true, blank: false, choices: Object.values(this.TYPES), initial: "r"}), width: new NumberField({required: false, integer: true, min: 0}), height: new NumberField({required: false, integer: true, min: 0}), radius: new NumberField({required: false, integer: true, positive: true}), points: new ArrayField(new 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 StringField({required: true, blank: false, initial: this.TYPE, validate: value => value === this.TYPE, validationError: `must be equal to "${this.TYPE}"`}), hole: new 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 NumberField({required: true, nullable: false, initial: undefined}), y: new NumberField({required: true, nullable: false, initial: undefined}), width: new NumberField({required: true, nullable: false, initial: undefined, positive: true}), height: new NumberField({required: true, nullable: false, initial: undefined, positive: true}), rotation: new 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 NumberField({required: true, nullable: false, initial: undefined}), y: new NumberField({required: true, nullable: false, initial: undefined}), radius: new 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 NumberField({required: true, nullable: false, initial: undefined}), y: new NumberField({required: true, nullable: false, initial: undefined}), radiusX: new NumberField({required: true, nullable: false, initial: undefined, positive: true}), radiusY: new NumberField({required: true, nullable: false, initial: undefined, positive: true}), rotation: new 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 ArrayField(new 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 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 FilePathField({categories, initial: initial.src ?? null, label, wildcard}), anchorX: new NumberField({nullable: false, initial: initial.anchorX ?? 0}), anchorY: new NumberField({nullable: false, initial: initial.anchorY ?? 0}), offsetX: new NumberField({nullable: false, integer: true, initial: initial.offsetX ?? 0}), offsetY: new NumberField({nullable: false, integer: true, initial: initial.offsetY ?? 0}), fit: new StringField({initial: initial.fit ?? "fill", choices: CONST.TEXTURE_DATA_FIT_MODES}), scaleX: new NumberField({nullable: false, initial: initial.scaleX ?? 1}), scaleY: new NumberField({nullable: false, initial: initial.scaleY ?? 1}), rotation: new AngleField({initial: initial.rotation ?? 0}), tint: new ColorField({nullable: false, initial: initial.tint ?? "#ffffff"}), alphaThreshold: new 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 = 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 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 DocumentIdField(), _tombstone: new BooleanField({initial: true, validate: v => v === true, validationError: "must be true"}) }; } } /** * @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 */ class BaseActor extends Document { /** * Construct an Actor document using provided data and context. * @param {Partial} 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 DocumentIdField(), name: new StringField({required: true, blank: false, textSearch: true}), img: new FilePathField({categories: ["IMAGE"], initial: data => { return this.implementation.getDefaultArtwork(data).img; }}), type: new DocumentTypeField(this), system: new TypeDataField(this), prototypeToken: new EmbeddedDataField(PrototypeToken), items: new EmbeddedCollectionField(BaseItem), effects: new EmbeddedCollectionField(BaseActiveEffect), folder: new ForeignDocumentField(BaseFolder), sort: new IntegerSortField(), ownership: new DocumentOwnershipField(), flags: new ObjectField(), _stats: new DocumentStatsField() }; } /* ---------------------------------------- */ /** * The default icon used for newly created Actor documents. * @type {string} */ static DEFAULT_ICON = 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 === 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); } } /** * @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 */ class BaseActorDelta extends Document { /** * Construct an ActorDelta document using provided data and context. * @param {Partial} 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 DocumentIdField(), name: new StringField({required: false, nullable: true, initial: null}), type: new StringField({required: false, nullable: true, initial: null}), img: new FilePathField({categories: ["IMAGE"], nullable: true, initial: null, required: false}), system: new ObjectField(), items: new EmbeddedCollectionDeltaField(BaseItem), effects: new EmbeddedCollectionDeltaField(BaseActiveEffect), ownership: new DocumentOwnershipField({required: false, nullable: true, initial: null}), flags: new 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; } } /** * @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 */ class BaseAdventure extends Document { /** * Construct an Adventure document using provided data and context. * @param {Partial} 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 DocumentIdField(), name: new StringField({required: true, blank: false, label: "ADVENTURE.Name", hint: "ADVENTURE.NameHint", textSearch: true}), img: new FilePathField({categories: ["IMAGE"], label: "ADVENTURE.Image", hint: "ADVENTURE.ImageHint"}), caption: new HTMLField({label: "ADVENTURE.Caption", hint: "ADVENTURE.CaptionHint"}), description: new HTMLField({label: "ADVENTURE.Description", hint: "ADVENTURE.DescriptionHint", textSearch: true}), actors: new SetField(new EmbeddedDataField(BaseActor)), combats: new SetField(new EmbeddedDataField(BaseCombat)), items: new SetField(new EmbeddedDataField(BaseItem)), journal: new SetField(new EmbeddedDataField(BaseJournalEntry)), scenes: new SetField(new EmbeddedDataField(BaseScene)), tables: new SetField(new EmbeddedDataField(BaseRollTable)), macros: new SetField(new EmbeddedDataField(BaseMacro)), cards: new SetField(new EmbeddedDataField(BaseCards)), playlists: new SetField(new EmbeddedDataField(BasePlaylist)), folders: new SetField(new EmbeddedDataField(BaseFolder)), folder: new ForeignDocumentField(BaseFolder), sort: new IntegerSortField(), flags: new ObjectField(), _stats: new DocumentStatsField() }; } /* -------------------------------------------- */ /* Model Properties */ /* -------------------------------------------- */ /** * An array of the fields which provide imported content from the Adventure. * @type {Record} */ static get contentFields() { const content = {}; for ( const field of this.schema ) { if ( field instanceof 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; } } /** * @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 */ class BaseAmbientLight extends Document { /** * Construct an AmbientLight document using provided data and context. * @param {Partial} 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 DocumentIdField(), x: new NumberField({required: true, integer: true, nullable: false, initial: 0}), y: new NumberField({required: true, integer: true, nullable: false, initial: 0}), elevation: new NumberField({required: true, nullable: false, initial: 0}), rotation: new AngleField(), walls: new BooleanField({initial: true}), vision: new BooleanField(), config: new EmbeddedDataField(LightData), hidden: new BooleanField(), flags: new ObjectField() } } /** @override */ static LOCALIZATION_PREFIXES = ["AMBIENT_LIGHT"]; } /** * @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 */ class BaseAmbientSound extends Document { /** * Construct an AmbientSound document using provided data and context. * @param {Partial} 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 DocumentIdField(), x: new NumberField({required: true, integer: true, nullable: false, initial: 0}), y: new NumberField({required: true, integer: true, nullable: false, initial: 0}), elevation: new NumberField({required: true, nullable: false, initial: 0}), radius: new NumberField({required: true, nullable: false, initial: 0, min: 0, step: 0.01}), path: new FilePathField({categories: ["AUDIO"]}), repeat: new BooleanField(), volume: new AlphaField({initial: 0.5, step: 0.01}), walls: new BooleanField({initial: true}), easing: new BooleanField({initial: true}), hidden: new BooleanField(), darkness: new SchemaField({ min: new AlphaField({initial: 0}), max: new AlphaField({initial: 1}) }), effects: new SchemaField({ base: new SchemaField({ type: new StringField(), intensity: new NumberField({required: true, integer: true, initial: 5, min: 1, max: 10, step: 1}) }), muffled: new SchemaField({ type: new StringField(), intensity: new NumberField({required: true, integer: true, initial: 5, min: 1, max: 10, step: 1}) }) }), flags: new ObjectField() } } /** @override */ static LOCALIZATION_PREFIXES = ["AMBIENT_SOUND"]; } /** * @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 */ class BaseCard extends Document { /** * Construct a Card document using provided data and context. * @param {Partial} 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 DocumentIdField(), name: new StringField({required: true, blank: false, label: "CARD.Name", textSearch: true}), description: new HTMLField({label: "CARD.Description"}), type: new DocumentTypeField(this, {initial: BASE_DOCUMENT_TYPE}), system: new TypeDataField(this), suit: new StringField({label: "CARD.Suit"}), value: new NumberField({label: "CARD.Value"}), back: new SchemaField({ name: new StringField({label: "CARD.BackName"}), text: new HTMLField({label: "CARD.BackText"}), img: new FilePathField({categories: ["IMAGE", "VIDEO"], label: "CARD.BackImage"}), }), faces: new ArrayField(new SchemaField({ name: new StringField({label: "CARD.FaceName"}), text: new HTMLField({label: "CARD.FaceText"}), img: new FilePathField({categories: ["IMAGE", "VIDEO"], initial: () => this.DEFAULT_ICON, label: "CARD.FaceImage"}), })), face: new NumberField({required: true, initial: null, integer: true, min: 0, label: "CARD.Face"}), drawn: new BooleanField({label: "CARD.Drawn"}), origin: new ForeignDocumentField(BaseCards), width: new NumberField({integer: true, positive: true, label: "Width"}), height: new NumberField({integer: true, positive: true, label: "Height"}), rotation: new AngleField({label: "Rotation"}), sort: new IntegerSortField(), flags: new ObjectField(), _stats: new 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}); } } /** * @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 */ class BaseCards extends Document { /** * Construct a Cards document using provided data and context. * @param {Partial} 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 DocumentIdField(), name: new StringField({required: true, blank: false, label: "CARDS.Name", textSearch: true}), type: new DocumentTypeField(this), description: new HTMLField({label: "CARDS.Description", textSearch: true}), img: new FilePathField({categories: ["IMAGE", "VIDEO"], initial: () => this.DEFAULT_ICON, label: "CARDS.Image"}), system: new TypeDataField(this), cards: new EmbeddedCollectionField(BaseCard), width: new NumberField({integer: true, positive: true, label: "Width"}), height: new NumberField({integer: true, positive: true, label: "Height"}), rotation: new AngleField({label: "Rotation"}), displayCount: new BooleanField(), folder: new ForeignDocumentField(BaseFolder), sort: new IntegerSortField(), ownership: new DocumentOwnershipField(), flags: new ObjectField(), _stats: new 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); } } /** * @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 */ class BaseChatMessage extends Document { /** * Construct a Cards document using provided data and context. * @param {Partial} 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 DocumentIdField(), type: new DocumentTypeField(this, {initial: BASE_DOCUMENT_TYPE}), system: new TypeDataField(this), style: new NumberField({required: true, choices: Object.values(CHAT_MESSAGE_STYLES), initial: CHAT_MESSAGE_STYLES.OTHER, validationError: "must be a value in CONST.CHAT_MESSAGE_STYLES"}), author: new ForeignDocumentField(BaseUser, {nullable: false, initial: () => game?.user?.id}), timestamp: new NumberField({required: true, nullable: false, initial: Date.now}), flavor: new HTMLField(), content: new HTMLField({textSearch: true}), speaker: new SchemaField({ scene: new ForeignDocumentField(BaseScene, {idOnly: true}), actor: new ForeignDocumentField(BaseActor, {idOnly: true}), token: new ForeignDocumentField(BaseToken, {idOnly: true}), alias: new StringField() }), whisper: new ArrayField(new ForeignDocumentField(BaseUser, {idOnly: true})), blind: new BooleanField(), rolls: new ArrayField(new JSONField({validate: BaseChatMessage.#validateRoll})), sound: new FilePathField({categories: ["AUDIO"]}), emote: new BooleanField(), flags: new ObjectField(), _stats: new 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} 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(CHAT_MESSAGE_STYLES).includes(data.type) ? data.type : 0; data.type = 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; } } /** * @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 */ class BaseCombat extends Document { /** * Construct a Combat document using provided data and context. * @param {Partial} 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 DocumentIdField(), type: new DocumentTypeField(this, {initial: BASE_DOCUMENT_TYPE}), system: new TypeDataField(this), scene: new ForeignDocumentField(BaseScene), combatants: new EmbeddedCollectionField(BaseCombatant), active: new BooleanField(), round: new NumberField({required: true, nullable: false, integer: true, min: 0, initial: 0, label: "COMBAT.Round"}), turn: new NumberField({required: true, integer: true, min: 0, initial: null, label: "COMBAT.Turn"}), sort: new IntegerSortField(), flags: new ObjectField(), _stats: new 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[]} 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."); } } } /** * @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 */ class BaseCombatant extends Document { /** * Construct a Combatant document using provided data and context. * @param {Partial} 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 DocumentIdField(), type: new DocumentTypeField(this, {initial: BASE_DOCUMENT_TYPE}), system: new TypeDataField(this), actorId: new ForeignDocumentField(BaseActor, {label: "COMBAT.CombatantActor", idOnly: true}), tokenId: new ForeignDocumentField(BaseToken, {label: "COMBAT.CombatantToken", idOnly: true}), sceneId: new ForeignDocumentField(BaseScene, {label: "COMBAT.CombatantScene", idOnly: true}), name: new StringField({label: "COMBAT.CombatantName", textSearch: true}), img: new FilePathField({categories: ["IMAGE"], label: "COMBAT.CombatantImage"}), initiative: new NumberField({label: "COMBAT.CombatantInitiative"}), hidden: new BooleanField({label: "COMBAT.CombatantHidden"}), defeated: new BooleanField({label: "COMBAT.CombatantDefeated"}), flags: new ObjectField(), _stats: new 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} = DOCUMENT_OWNERSHIP_LEVELS; if ( user.isGM ) return OWNER; return this.actor?.getUserLevel(user) ?? NONE; } } /** * @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 */ class BaseDrawing extends Document { /** * Construct a Drawing document using provided data and context. * @param {Partial} 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 DocumentIdField(), author: new ForeignDocumentField(BaseUser, {nullable: false, initial: () => game.user?.id}), shape: new EmbeddedDataField(ShapeData), x: new NumberField({required: true, nullable: false, initial: 0, label: "XCoord"}), y: new NumberField({required: true, nullable: false, initial: 0, label: "YCoord"}), elevation: new NumberField({required: true, nullable: false, initial: 0}), sort: new NumberField({required: true, integer: true, nullable: false, initial: 0}), rotation: new AngleField({label: "DRAWING.Rotation"}), bezierFactor: new AlphaField({initial: 0, label: "DRAWING.SmoothingFactor", max: 0.5, hint: "DRAWING.SmoothingFactorHint"}), fillType: new NumberField({required: true, nullable: false, initial: DRAWING_FILL_TYPES.NONE, choices: Object.values(DRAWING_FILL_TYPES), label: "DRAWING.FillTypes", validationError: "must be a value in CONST.DRAWING_FILL_TYPES" }), fillColor: new ColorField({nullable: false, initial: () => game.user?.color.css || "#ffffff", label: "DRAWING.FillColor"}), fillAlpha: new AlphaField({initial: 0.5, label: "DRAWING.FillOpacity"}), strokeWidth: new NumberField({nullable: false, integer: true, initial: 8, min: 0, label: "DRAWING.LineWidth"}), strokeColor: new ColorField({nullable: false, initial: () => game.user?.color.css || "#ffffff", label: "DRAWING.StrokeColor"}), strokeAlpha: new AlphaField({initial: 1, label: "DRAWING.LineOpacity"}), texture: new FilePathField({categories: ["IMAGE"], label: "DRAWING.FillTexture"}), text: new StringField({label: "DRAWING.TextLabel"}), fontFamily: new StringField({blank: false, label: "DRAWING.FontFamily", initial: () => globalThis.CONFIG?.defaultFontFamily || "Signika"}), fontSize: new 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 ColorField({nullable: false, initial: "#ffffff", label: "DRAWING.TextColor"}), textAlpha: new AlphaField({label: "DRAWING.TextOpacity"}), hidden: new BooleanField(), locked: new BooleanField(), interface: new BooleanField(), flags: new 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 !== 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; } } /** * @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 */ class BaseFogExploration extends Document { /** * Construct a FogExploration document using provided data and context. * @param {Partial} 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 DocumentIdField(), scene: new ForeignDocumentField(BaseScene, {initial: () => canvas?.scene?.id}), user: new ForeignDocumentField(BaseUser, {initial: () => game?.user?.id}), explored: new FilePathField({categories: ["IMAGE"], required: true, base64: true}), positions: new ObjectField(), timestamp: new NumberField({nullable: false, initial: Date.now}), flags: new ObjectField(), _stats: new 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(); } } /** * @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 */ class BaseFolder extends Document { /** * Construct a Folder document using provided data and context. * @param {Partial} 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: FOLDER_DOCUMENT_TYPES, schemaVersion: "12.324" }, {inplace: false})); /** @inheritdoc */ static defineSchema() { return { _id: new DocumentIdField(), name: new StringField({required: true, blank: false, textSearch: true}), type: new DocumentTypeField(this), description: new HTMLField({textSearch: true}), folder: new ForeignDocumentField(BaseFolder), sorting: new StringField({required: true, initial: "a", choices: this.SORTING_MODES}), sort: new IntegerSortField(), color: new ColorField(), flags: new ObjectField(), _stats: new 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); } } /** * @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 */ class BaseItem extends Document { /** * Construct a Item document using provided data and context. * @param {Partial} 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 DocumentIdField(), name: new StringField({required: true, blank: false, textSearch: true}), type: new DocumentTypeField(this), img: new FilePathField({categories: ["IMAGE"], initial: data => { return this.implementation.getDefaultArtwork(data).img; }}), system: new TypeDataField(this), effects: new EmbeddedCollectionField(BaseActiveEffect), folder: new ForeignDocumentField(BaseFolder), sort: new IntegerSortField(), ownership: new DocumentOwnershipField(), flags: new ObjectField(), _stats: new 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); } } /** * @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 */ class BaseJournalEntry extends Document { /** * Construct a JournalEntry document using provided data and context. * @param {Partial} 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 DocumentIdField(), name: new StringField({required: true, blank: false, textSearch: true}), pages: new EmbeddedCollectionField(BaseJournalEntryPage), folder: new ForeignDocumentField(BaseFolder), sort: new IntegerSortField(), ownership: new DocumentOwnershipField(), flags: new ObjectField(), _stats: new DocumentStatsField() } } /* -------------------------------------------- */ /** @inheritDoc */ static migrateData(source) { /** * Migrate sourceId. * @deprecated since v12 */ this._addDataFieldMigration(source, "flags.core.sourceId", "_stats.compendiumSource"); return super.migrateData(source); } } /** * @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 */ class BaseJournalEntryPage extends Document { /** * Construct a JournalEntryPage document using provided data and context. * @param {Partial} 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 DocumentIdField(), name: new StringField({required: true, blank: false, label: "JOURNALENTRYPAGE.PageTitle", textSearch: true}), type: new DocumentTypeField(this, {initial: "text"}), system: new TypeDataField(this), title: new SchemaField({ show: new BooleanField({initial: true}), level: new NumberField({required: true, initial: 1, min: 1, max: 6, integer: true, nullable: false}) }), image: new SchemaField({ caption: new StringField({required: false, initial: undefined}) }), text: new SchemaField({ content: new HTMLField({required: false, initial: undefined, textSearch: true}), markdown: new StringField({required: false, initial: undefined}), format: new NumberField({label: "JOURNALENTRYPAGE.Format", initial: JOURNAL_ENTRY_PAGE_FORMATS.HTML, choices: Object.values(JOURNAL_ENTRY_PAGE_FORMATS)}) }), video: new SchemaField({ controls: new BooleanField({initial: true}), loop: new BooleanField({required: false, initial: undefined}), autoplay: new BooleanField({required: false, initial: undefined}), volume: new AlphaField({required: true, step: 0.01, initial: .5}), timestamp: new NumberField({required: false, min: 0, initial: undefined}), width: new NumberField({required: false, positive: true, integer: true, initial: undefined}), height: new NumberField({required: false, positive: true, integer: true, initial: undefined}) }), src: new StringField({required: false, blank: false, nullable: true, initial: null, label: "JOURNALENTRYPAGE.Source"}), sort: new IntegerSortField(), ownership: new DocumentOwnershipField({initial: {default: DOCUMENT_OWNERSHIP_LEVELS.INHERIT}}), flags: new ObjectField(), _stats: new DocumentStatsField() }; } /** @inheritdoc */ getUserLevel(user) { user = user || game.user; const ownership = this.ownership[user.id] ?? this.ownership.default; const inherited = ownership === DOCUMENT_OWNERSHIP_LEVELS.INHERIT; return inherited ? this.parent.getUserLevel(user) : ownership; } } /** * @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 */ class BaseMacro extends Document { /** * Construct a Macro document using provided data and context. * @param {Partial} 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(MACRO_TYPES), permissions: { create: this.#canCreate, update: this.#canUpdate }, schemaVersion: "12.324" }, {inplace: false})); /** @inheritdoc */ static defineSchema() { return { _id: new DocumentIdField(), name: new StringField({required: true, blank: false, label: "Name", textSearch: true}), type: new DocumentTypeField(this, {initial: MACRO_TYPES.CHAT, label: "Type"}), author: new ForeignDocumentField(BaseUser, {initial: () => game?.user?.id}), img: new FilePathField({categories: ["IMAGE"], initial: () => this.DEFAULT_ICON, label: "Image"}), scope: new StringField({required: true, choices: MACRO_SCOPES, initial: MACRO_SCOPES[0], validationError: "must be a value in CONST.MACRO_SCOPES", label: "Scope"}), command: new StringField({required: true, blank: true, label: "Command"}), folder: new ForeignDocumentField(BaseFolder), sort: new IntegerSortField(), ownership: new DocumentOwnershipField(), flags: new ObjectField(), _stats: new 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 !== MACRO_TYPES.SCRIPT ) return; const field = new 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}); } } /** * @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 */ class BaseMeasuredTemplate extends Document { /** * Construct a MeasuredTemplate document using provided data and context. * @param {Partial} 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 DocumentIdField(), author: new ForeignDocumentField(BaseUser, {initial: () => game?.user?.id}), t: new StringField({required: true, choices: Object.values(MEASURED_TEMPLATE_TYPES), label: "Type", initial: MEASURED_TEMPLATE_TYPES.CIRCLE, validationError: "must be a value in CONST.MEASURED_TEMPLATE_TYPES", }), x: new NumberField({required: true, integer: true, nullable: false, initial: 0, label: "XCoord"}), y: new NumberField({required: true, integer: true, nullable: false, initial: 0, label: "YCoord"}), elevation: new NumberField({required: true, nullable: false, initial: 0}), sort: new NumberField({required: true, integer: true, nullable: false, initial: 0}), distance: new NumberField({required: true, nullable: false, initial: 0, min: 0, label: "Distance"}), direction: new AngleField({label: "Direction"}), angle: new AngleField({normalize: false, label: "Angle"}), width: new NumberField({required: true, nullable: false, initial: 0, min: 0, step: 0.01, label: "Width"}), borderColor: new ColorField({nullable: false, initial: "#000000"}), fillColor: new ColorField({nullable: false, initial: () => game.user?.color.css || "#ffffff"}), texture: new FilePathField({categories: ["IMAGE", "VIDEO"]}), hidden: new BooleanField({label: "Hidden"}), flags: new 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; } } /** * @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 */ class BaseNote extends Document { /** * Construct a Note document using provided data and context. * @param {Partial} 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 DocumentIdField(), entryId: new ForeignDocumentField(BaseJournalEntry, {idOnly: true}), pageId: new ForeignDocumentField(BaseJournalEntryPage, {idOnly: true}), x: new NumberField({required: true, integer: true, nullable: false, initial: 0, label: "XCoord"}), y: new NumberField({required: true, integer: true, nullable: false, initial: 0, label: "YCoord"}), elevation: new NumberField({required: true, nullable: false, initial: 0}), sort: new 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 NumberField({required: true, nullable: false, integer: true, min: 32, initial: 40, validationError: "must be an integer greater than 32", label: "NOTE.IconSize"}), text: new StringField({label: "NOTE.TextLabel", textSearch: true}), fontFamily: new StringField({required: true, label: "NOTE.FontFamily", initial: () => globalThis.CONFIG?.defaultFontFamily || "Signika"}), fontSize: new 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 NumberField({required: true, choices: Object.values(TEXT_ANCHOR_POINTS), initial: TEXT_ANCHOR_POINTS.BOTTOM, label: "NOTE.AnchorPoint", validationError: "must be a value in CONST.TEXT_ANCHOR_POINTS"}), textColor: new ColorField({required: true, nullable: false, initial: "#ffffff", label: "NOTE.TextColor"}), global: new BooleanField(), flags: new 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}); } } /** * @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 */ class BasePlaylist extends Document { /** * Construct a Playlist document using provided data and context. * @param {Partial} 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 DocumentIdField(), name: new StringField({required: true, blank: false, textSearch: true}), description: new StringField({textSearch: true}), sounds: new EmbeddedCollectionField(BasePlaylistSound), channel: new StringField({choices: AUDIO_CHANNELS, initial: "music", blank: false}), mode: new NumberField({required: true, choices: Object.values(PLAYLIST_MODES), initial: PLAYLIST_MODES.SEQUENTIAL, validationError: "must be a value in CONST.PLAYLIST_MODES"}), playing: new BooleanField(), fade: new NumberField({positive: true}), folder: new ForeignDocumentField(BaseFolder), sorting: new StringField({required: true, choices: Object.values(PLAYLIST_SORT_MODES), initial: PLAYLIST_SORT_MODES.ALPHABETICAL, validationError: "must be a value in CONST.PLAYLIST_SORTING_MODES"}), seed: new NumberField({integer: true, min: 0}), sort: new IntegerSortField(), ownership: new DocumentOwnershipField(), flags: new ObjectField(), _stats: new DocumentStatsField() } } /* -------------------------------------------- */ /** @inheritDoc */ static migrateData(source) { /** * Migrate sourceId. * @deprecated since v12 */ this._addDataFieldMigration(source, "flags.core.sourceId", "_stats.compendiumSource"); return super.migrateData(source); } } /** * @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 */ class BasePlaylistSound extends Document { /** * Construct a PlaylistSound document using provided data and context. * @param {Partial} 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 DocumentIdField(), name: new StringField({required: true, blank: false, textSearch: true}), description: new StringField(), path: new FilePathField({categories: ["AUDIO"]}), channel: new StringField({choices: AUDIO_CHANNELS, initial: "music", blank: true}), playing: new BooleanField(), pausedTime: new NumberField({min: 0}), repeat: new BooleanField(), volume: new AlphaField({initial: 0.5, step: 0.01}), fade: new NumberField({integer: true, min: 0}), sort: new IntegerSortField(), flags: new 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}); } } /** * @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 */ class BaseRollTable extends Document { /** * Construct a RollTable document using provided data and context. * @param {Partial} 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 DocumentIdField(), name: new StringField({required: true, blank: false, textSearch: true}), img: new FilePathField({categories: ["IMAGE"], initial: () => this.DEFAULT_ICON}), description: new HTMLField({textSearch: true}), results: new EmbeddedCollectionField(BaseTableResult), formula: new StringField(), replacement: new BooleanField({initial: true}), displayRoll: new BooleanField({initial: true}), folder: new ForeignDocumentField(BaseFolder), sort: new IntegerSortField(), ownership: new DocumentOwnershipField(), flags: new ObjectField(), _stats: new 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); } } /** * @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 */ class BaseScene extends Document { /** * Construct a Scene document using provided data and context. * @param {Partial} 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 SchemaField({ hue: new HueField({required: true, initial: defaults.hue, label: "SCENES.ENVIRONMENT.Hue", hint: "SCENES.ENVIRONMENT.HueHint"}), intensity: new AlphaField({required: true, nullable: false, initial: defaults.intensity, label: "SCENES.ENVIRONMENT.Intensity", hint: "SCENES.ENVIRONMENT.IntensityHint"}), luminosity: new NumberField({required: true, nullable: false, initial: defaults.luminosity, min: -1, max: 1, label: "SCENES.ENVIRONMENT.Luminosity", hint: "SCENES.ENVIRONMENT.LuminosityHint"}), saturation: new NumberField({required: true, nullable: false, initial: defaults.saturation, min: -1, max: 1, label: "SCENES.ENVIRONMENT.Saturation", hint: "SCENES.ENVIRONMENT.SaturationHint"}), shadows: new 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 DocumentIdField(), name: new StringField({required: true, blank: false, textSearch: true}), // Navigation active: new BooleanField(), navigation: new BooleanField({initial: true}), navOrder: new NumberField({required: true, nullable: false, integer: true, initial: 0}), navName: new HTMLField({textSearch: true}), // Canvas Dimensions background: new TextureData(), foreground: new FilePathField({categories: ["IMAGE", "VIDEO"]}), foregroundElevation: new NumberField({required: true, positive: true, integer: true}), thumb: new FilePathField({categories: ["IMAGE"]}), width: new NumberField({integer: true, positive: true, initial: 4000}), height: new NumberField({integer: true, positive: true, initial: 3000}), padding: new NumberField({required: true, nullable: false, min: 0, max: 0.5, step: 0.05, initial: 0.25}), initial: new SchemaField({ x: new NumberField({integer: true, required: true}), y: new NumberField({integer: true, required: true}), scale: new NumberField({required: true, max: 3, positive: true, initial: 0.5}) }), backgroundColor: new ColorField({nullable: false, initial: "#999999"}), // Grid Configuration grid: new SchemaField({ type: new NumberField({required: true, choices: Object.values(GRID_TYPES), initial: () => game.system.grid.type, validationError: "must be a value in CONST.GRID_TYPES"}), size: new NumberField({required: true, nullable: false, integer: true, min: GRID_MIN_SIZE, initial: 100, validationError: `must be an integer number of pixels, ${GRID_MIN_SIZE} or greater`}), style: new StringField({required: true, blank: false, initial: "solidLines"}), thickness: new NumberField({required: true, nullable: false, positive: true, integer: true, initial: 1}), color: new ColorField({required: true, nullable: false, initial: "#000000"}), alpha: new AlphaField({initial: 0.2}), distance: new NumberField({required: true, nullable: false, positive: true, initial: () => game.system.grid.distance}), units: new StringField({required: true, initial: () => game.system.grid.units}) }), // Vision Configuration tokenVision: new BooleanField({initial: true}), fog: new SchemaField({ exploration: new BooleanField({initial: true}), reset: new NumberField({required: false, initial: undefined}), overlay: new FilePathField({categories: ["IMAGE", "VIDEO"]}), colors: new SchemaField({ explored: new ColorField({label: "SCENES.FogExploredColor"}), unexplored: new ColorField({label: "SCENES.FogUnexploredColor"}) }) }), // Environment Configuration environment: new SchemaField({ darknessLevel: new AlphaField({initial: 0}), darknessLock: new BooleanField({initial: false}), globalLight: new SchemaField({ enabled: new BooleanField({required: true, initial: false}), alpha: lightDataSchema.alpha, bright: new BooleanField({required: true, initial: false}), color: lightDataSchema.color, coloration: lightDataSchema.coloration, luminosity: new 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 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 EmbeddedCollectionField(BaseDrawing), tokens: new EmbeddedCollectionField(BaseToken), lights: new EmbeddedCollectionField(BaseAmbientLight), notes: new EmbeddedCollectionField(BaseNote), sounds: new EmbeddedCollectionField(BaseAmbientSound), regions: new EmbeddedCollectionField(BaseRegion), templates: new EmbeddedCollectionField(BaseMeasuredTemplate), tiles: new EmbeddedCollectionField(BaseTile), walls: new EmbeddedCollectionField(BaseWall), // Linked Documents playlist: new ForeignDocumentField(BasePlaylist), playlistSound: new ForeignDocumentField(BasePlaylistSound, {idOnly: true}), journal: new ForeignDocumentField(BaseJournalEntry), journalEntryPage: new ForeignDocumentField(BaseJournalEntryPage, {idOnly: true}), weather: new StringField({required: true}), // Permissions folder: new ForeignDocumentField(BaseFolder), sort: new IntegerSortField(), ownership: new DocumentOwnershipField(), flags: new ObjectField(), _stats: new 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); } } /** * @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 */ class BaseRegion extends Document { /** * Construct a Region document using provided data and context. * @param {Partial} 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 DocumentIdField(), name: new StringField({required: true, blank: false, label: "Name", textSearch: true}), color: new ColorField({required: true, nullable: false, initial: () => Color$1.fromHSV([Math.random(), 0.8, 0.8]).css, label: "REGION.FIELDS.color.label", hint: "REGION.FIELDS.color.hint"}), shapes: new ArrayField(new TypedSchemaField(BaseShapeData.TYPES), {label: "REGION.FIELDS.shapes.label", hint: "REGION.FIELDS.shapes.hint"}), elevation: new SchemaField({ bottom: new NumberField({required: true, label: "REGION.FIELDS.elevation.FIELDS.bottom.label", hint: "REGION.FIELDS.elevation.FIELDS.bottom.hint"}), // null -> -Infinity top: new 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 EmbeddedCollectionField(BaseRegionBehavior, {label: "REGION.FIELDS.behaviors.label", hint: "REGION.FIELDS.behaviors.hint"}), visibility: new 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 BooleanField(), flags: new ObjectField() } }; } /** * @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 */ class BaseRegionBehavior extends Document { /** * Construct a RegionBehavior document using provided data and context. * @param {Partial} 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 DocumentIdField(), name: new StringField({required: true, blank: true, label: "Name", textSearch: true}), type: new DocumentTypeField(this), system: new TypeDataField(this), disabled: new BooleanField({label: "BEHAVIOR.FIELDS.disabled.label", hint: "BEHAVIOR.FIELDS.disabled.hint"}), flags: new ObjectField(), _stats: new 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; } } /** * @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 */ class BaseSetting extends Document { /** * Construct a Setting document using provided data and context. * @param {Partial} 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 DocumentIdField(), key: new StringField({required: true, nullable: false, blank: false, validate: k => k.split(".").length >= 2, validationError: "must have the format {scope}.{field}"}), value: new JSONField({required: true, nullable: true, initial: null}), _stats: new 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)); } } /** * @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 */ class BaseTableResult extends Document { /** * Construct a TableResult document using provided data and context. * @param {Partial} 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(TABLE_RESULT_TYPES), permissions: { update: this.#canUpdate }, compendiumIndexFields: ["type"], schemaVersion: "12.324" }, {inplace: false})); /** @inheritdoc */ static defineSchema() { return { _id: new DocumentIdField(), type: new DocumentTypeField(this, {initial: TABLE_RESULT_TYPES.TEXT}), text: new HTMLField({textSearch: true}), img: new FilePathField({categories: ["IMAGE"]}), documentCollection: new StringField(), documentId: new ForeignDocumentField(Document, {idOnly: true}), weight: new NumberField({required: true, integer: true, positive: true, nullable: false, initial: 1}), range: new ArrayField(new NumberField({integer: true}), { validate: r => (r.length === 2) && (r[1] >= r[0]), validationError: "must be a length-2 array of ascending integers" }), drawn: new BooleanField(), flags: new 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 = TABLE_RESULT_TYPES.TEXT; break; case 1: data.type = TABLE_RESULT_TYPES.DOCUMENT; break; case 2: data.type = TABLE_RESULT_TYPES.COMPENDIUM; break; } } return super.migrateData(data); } } /** * @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 */ class BaseTile extends Document { /** * Construct a Tile document using provided data and context. * @param {Partial} 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 DocumentIdField(), texture: new TextureData({}, {initial: {anchorX: 0.5, anchorY: 0.5, alphaThreshold: 0.75}}), width: new NumberField({required: true, min: 0, nullable: false, step: 0.1}), height: new NumberField({required: true, min: 0, nullable: false, step: 0.1}), x: new NumberField({required: true, integer: true, nullable: false, initial: 0, label: "XCoord"}), y: new NumberField({required: true, integer: true, nullable: false, initial: 0, label: "YCoord"}), elevation: new NumberField({required: true, nullable: false, initial: 0}), sort: new NumberField({required: true, integer: true, nullable: false, initial: 0}), rotation: new AngleField(), alpha: new AlphaField(), hidden: new BooleanField(), locked: new BooleanField(), restrictions: new SchemaField({ light: new BooleanField(), weather: new BooleanField() }), occlusion: new SchemaField({ mode: new NumberField({choices: Object.values(OCCLUSION_MODES), initial: OCCLUSION_MODES.NONE, validationError: "must be a value in CONST.TILE_OCCLUSION_MODES"}), alpha: new AlphaField({initial: 0}) }), video: new SchemaField({ loop: new BooleanField({initial: true}), autoplay: new BooleanField({initial: true}), volume: new AlphaField({initial: 0, step: 0.01}) }), flags: new 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; } } /** * @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 */ class BaseToken extends Document { /** * Construct a Token document using provided data and context. * @param {Partial} 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 DocumentIdField(), name: new StringField({required: true, blank: true, textSearch: true}), displayName: new NumberField({required: true, initial: TOKEN_DISPLAY_MODES.NONE, choices: Object.values(TOKEN_DISPLAY_MODES), validationError: "must be a value in CONST.TOKEN_DISPLAY_MODES" }), actorId: new ForeignDocumentField(BaseActor, {idOnly: true}), actorLink: new BooleanField(), delta: new ActorDeltaField(BaseActorDelta), appendNumber: new BooleanField(), prependAdjective: new BooleanField(), width: new NumberField({nullable: false, positive: true, initial: 1, step: 0.5, label: "Width"}), height: new 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 NumberField({initial: TOKEN_HEXAGONAL_SHAPES.ELLIPSE_1, choices: Object.values(TOKEN_HEXAGONAL_SHAPES)}), x: new NumberField({required: true, integer: true, nullable: false, initial: 0, label: "XCoord"}), y: new NumberField({required: true, integer: true, nullable: false, initial: 0, label: "YCoord"}), elevation: new NumberField({required: true, nullable: false, initial: 0}), sort: new NumberField({required: true, integer: true, nullable: false, initial: 0}), locked: new BooleanField(), lockRotation: new BooleanField(), rotation: new AngleField(), alpha: new AlphaField(), hidden: new BooleanField(), disposition: new NumberField({required: true, choices: Object.values(TOKEN_DISPOSITIONS), initial: TOKEN_DISPOSITIONS.HOSTILE, validationError: "must be a value in CONST.TOKEN_DISPOSITIONS" }), displayBars: new NumberField({required: true, choices: Object.values(TOKEN_DISPLAY_MODES), initial: TOKEN_DISPLAY_MODES.NONE, validationError: "must be a value in CONST.TOKEN_DISPLAY_MODES" }), bar1: new SchemaField({ attribute: new StringField({required: true, nullable: true, blank: false, initial: () => game?.system.primaryTokenAttribute || null}) }), bar2: new SchemaField({ attribute: new StringField({required: true, nullable: true, blank: false, initial: () => game?.system.secondaryTokenAttribute || null}) }), light: new EmbeddedDataField(LightData), sight: new SchemaField({ enabled: new BooleanField({initial: data => Number(data?.sight?.range) > 0}), range: new NumberField({required: true, nullable: true, min: 0, step: 0.01, initial: 0}), angle: new AngleField({initial: 360, normalize: false}), visionMode: new StringField({required: true, blank: false, initial: "basic", label: "TOKEN.VisionMode", hint: "TOKEN.VisionModeHint"}), color: new ColorField({label: "TOKEN.VisionColor"}), attenuation: new AlphaField({initial: 0.1, label: "TOKEN.VisionAttenuation", hint: "TOKEN.VisionAttenuationHint"}), brightness: new NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1, label: "TOKEN.VisionBrightness", hint: "TOKEN.VisionBrightnessHint"}), saturation: new NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1, label: "TOKEN.VisionSaturation", hint: "TOKEN.VisionSaturationHint"}), contrast: new NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1, label: "TOKEN.VisionContrast", hint: "TOKEN.VisionContrastHint"}) }), detectionModes: new ArrayField(new SchemaField({ id: new StringField(), enabled: new BooleanField({initial: true}), range: new NumberField({required: true, min: 0, step: 0.01}) }), { validate: BaseToken.#validateDetectionModes }), occludable: new SchemaField({ radius: new NumberField({nullable: false, min: 0, step: 0.01, initial: 0}) }), ring: new SchemaField({ enabled: new BooleanField(), colors: new SchemaField({ ring: new ColorField(), background: new ColorField() }), effects: new NumberField({initial: 1, min: 0, max: 8388607, integer: true}), subject: new SchemaField({ scale: new NumberField({initial: 1, min: 0.5}), texture: new FilePathField({categories: ["IMAGE"]}) }) }), /** @internal */ _regions: new ArrayField(new ForeignDocumentField(BaseRegion, {idOnly: true})), flags: new 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 = 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. */ class ActorDeltaField extends 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]; } } /** * @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 */ class BaseUser extends Document { /** * Construct a User document using provided data and context. * @param {Partial} 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 DocumentIdField(), name: new StringField({required: true, blank: false, textSearch: true}), role: new NumberField({required: true, choices: Object.values(USER_ROLES), initial: USER_ROLES.PLAYER, readonly: true}), password: new StringField({required: true, blank: true}), passwordSalt: new StringField(), avatar: new FilePathField({categories: ["IMAGE"]}), character: new ForeignDocumentField(BaseActor), color: new ColorField({required: true, nullable: false, initial: () => Color$1.fromHSV([Math.random(), 0.8, 0.8]).css }), pronouns: new StringField({required: true}), hotbar: new ObjectField({required: true, validate: BaseUser.#validateHotbar, validationError: "must be a mapping of slots to macro identifiers"}), permissions: new ObjectField({required: true, validate: BaseUser.#validatePermissions, validationError: "must be a mapping of permission names to booleans"}), flags: new ObjectField(), _stats: new 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 === USER_ROLES.NONE; } /* -------------------------------------------- */ /** * Test whether the User has a GAMEMASTER or ASSISTANT role in this World? * @type {boolean} */ get isGM() { return this.hasRole(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 USER_PERMISSIONS ) return this.hasPermission(action); return this.hasRole(action); } /* ---------------------------------------- */ /** @inheritdoc */ getUserLevel(user) { return 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" ? 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$1(doc.permissions) ) return user.hasRole(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 = 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(USER_ROLES.ASSISTANT, doc.role); return user.hasRole(role); } } /** * @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 */ class BaseWall extends Document { /** * Construct a Wall document using provided data and context. * @param {Partial} 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 DocumentIdField(), c: new ArrayField(new NumberField({required: true, integer: true, nullable: false}), { validate: c => (c.length === 4), validationError: "must be a length-4 array of integer coordinates"}), light: new NumberField({required: true, choices: Object.values(WALL_SENSE_TYPES), initial: WALL_SENSE_TYPES.NORMAL, validationError: "must be a value in CONST.WALL_SENSE_TYPES"}), move: new NumberField({required: true, choices: Object.values(WALL_MOVEMENT_TYPES), initial: WALL_MOVEMENT_TYPES.NORMAL, validationError: "must be a value in CONST.WALL_MOVEMENT_TYPES"}), sight: new NumberField({required: true, choices: Object.values(WALL_SENSE_TYPES), initial: WALL_SENSE_TYPES.NORMAL, validationError: "must be a value in CONST.WALL_SENSE_TYPES"}), sound: new NumberField({required: true, choices: Object.values(WALL_SENSE_TYPES), initial: WALL_SENSE_TYPES.NORMAL, validationError: "must be a value in CONST.WALL_SENSE_TYPES"}), dir: new NumberField({required: true, choices: Object.values(WALL_DIRECTIONS), initial: WALL_DIRECTIONS.BOTH, validationError: "must be a value in CONST.WALL_DIRECTIONS"}), door: new NumberField({required: true, choices: Object.values(WALL_DOOR_TYPES), initial: WALL_DOOR_TYPES.NONE, validationError: "must be a value in CONST.WALL_DOOR_TYPES"}), ds: new NumberField({required: true, choices: Object.values(WALL_DOOR_STATES), initial: WALL_DOOR_STATES.CLOSED, validationError: "must be a value in CONST.WALL_DOOR_STATES"}), doorSound: new StringField({required: false, blank: true, initial: undefined}), threshold: new SchemaField({ light: new NumberField({required: true, nullable: true, initial: null, positive: true}), sight: new NumberField({required: true, nullable: true, initial: null, positive: true}), sound: new NumberField({required: true, nullable: true, initial: null, positive: true}), attenuation: new BooleanField() }), flags: new 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 !== WALL_DOOR_STATES.LOCKED) && (data.ds !== WALL_DOOR_STATES.LOCKED) ) { return user.hasRole("PLAYER"); // Players may open and close unlocked doors } return false; } } /** @module foundry.documents */ var documents = /*#__PURE__*/Object.freeze({ __proto__: null, BaseActiveEffect: BaseActiveEffect, BaseActor: BaseActor, BaseActorDelta: BaseActorDelta, BaseAdventure: BaseAdventure, BaseAmbientLight: BaseAmbientLight, BaseAmbientSound: BaseAmbientSound, BaseCard: BaseCard, BaseCards: BaseCards, BaseChatMessage: BaseChatMessage, BaseCombat: BaseCombat, BaseCombatant: BaseCombatant, BaseDrawing: BaseDrawing, BaseFogExploration: BaseFogExploration, BaseFolder: BaseFolder, BaseItem: BaseItem, BaseJournalEntry: BaseJournalEntry, BaseJournalEntryPage: BaseJournalEntryPage, BaseMacro: BaseMacro, BaseMeasuredTemplate: BaseMeasuredTemplate, BaseNote: BaseNote, BasePlaylist: BasePlaylist, BasePlaylistSound: BasePlaylistSound, BaseRegion: BaseRegion, BaseRegionBehavior: BaseRegionBehavior, BaseRollTable: BaseRollTable, BaseScene: BaseScene, BaseSetting: BaseSetting, BaseTableResult: BaseTableResult, BaseTile: BaseTile, BaseToken: BaseToken, BaseUser: BaseUser, BaseWall: BaseWall }); /** * 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 */ class PackageCompatibility extends SchemaField { constructor(options) { super({ minimum: new StringField({required: false, blank: false, initial: undefined}), verified: new StringField({required: false, blank: false, initial: undefined}), maximum: new 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 */ class PackageRelationships extends 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 ObjectField() }, options); } } /* -------------------------------------------- */ /** * A SetField with custom casting behavior. */ class PackageRelationshipField extends 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. */ class RelatedPackage extends SchemaField { constructor({packageType, ...options}={}) { let typeOptions = {choices: PACKAGE_TYPES, initial:"module"}; if ( packageType ) typeOptions = {choices: [packageType], initial: packageType}; super({ id: new StringField({required: true, blank: false}), type: new StringField(typeOptions), manifest: new StringField({required: false, blank: false, initial: undefined}), compatibility: new PackageCompatibility(), reason: new StringField({required: false, blank: false, initial: undefined}) }, options); } } /* -------------------------------------------- */ /** * A custom SchemaField for defining the folder structure of the included compendium packs. */ class PackageCompendiumFolder extends SchemaField { constructor({depth=1, ...options}={}) { const schema = { name: new StringField({required: true, blank: false}), sorting: new StringField({required: false, blank: false, initial: undefined, choices: BaseFolder.SORTING_MODES}), color: new ColorField(), packs: new SetField(new StringField({required: true, blank: false})) }; if ( depth < 4 ) schema.folders = new SetField(new PackageCompendiumFolder( {depth: depth+1, options})); super(schema, options); } } /* -------------------------------------------- */ /** * A special ObjectField which captures a mapping of USER_ROLES to DOCUMENT_OWNERSHIP_LEVELS. */ 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. */ class PackageCompendiumPacks extends 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. */ 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 StringField({required: true, blank: false, validate: this.validateId}), title: new StringField({required: true, blank: false}), description: new StringField({required: true}), authors: new SetField(new SchemaField({ name: new StringField({required: true, blank: false}), email: new StringField(optionalString), url: new StringField(optionalString), discord: new StringField(optionalString), flags: new ObjectField(), })), url: new StringField(optionalString), license: new StringField(optionalString), readme: new StringField(optionalString), bugs: new StringField(optionalString), changelog: new StringField(optionalString), flags: new ObjectField(), media: new SetField(new SchemaField({ type: new StringField(optionalString), url: new StringField(optionalString), caption: new StringField(optionalString), loop: new BooleanField({required: false, blank: false, initial: false}), thumbnail: new StringField(optionalString), flags: new ObjectField(), })), // Package versioning version: new StringField({required: true, blank: false, initial: "0"}), compatibility: new PackageCompatibility(), // Included content scripts: new SetField(new StringField({required: true, blank: false})), esmodules: new SetField(new StringField({required: true, blank: false})), styles: new SetField(new StringField({required: true, blank: false})), languages: new SetField(new SchemaField({ lang: new StringField({required: true, blank: false, validate: Intl.getCanonicalLocales, validationError: "must be supported by the Intl.getCanonicalLocales function" }), name: new StringField({required: false}), path: new StringField({required: true, blank: false}), system: new StringField(optionalString), module: new StringField(optionalString), flags: new ObjectField(), })), packs: new PackageCompendiumPacks(new SchemaField({ name: new StringField({required: true, blank: false, validate: this.validateId}), label: new StringField({required: true, blank: false}), banner: new StringField({...optionalString, nullable: true}), path: new StringField({required: false}), type: new StringField({required: true, blank: false, choices: COMPENDIUM_DOCUMENT_TYPES, validationError: "must be a value in CONST.COMPENDIUM_DOCUMENT_TYPES"}), system: new StringField(optionalString), ownership: new CompendiumOwnershipField(), flags: new ObjectField(), }, {validate: BasePackage.#validatePack})), packFolders: new SetField(new PackageCompendiumFolder()), // Package relationships relationships: new PackageRelationships(), socket: new BooleanField(), // Package downloading manifest: new StringField(), download: new StringField({required: false, blank: false, initial: undefined}), protected: new BooleanField(), exclusive: new BooleanField(), persistentStorage: new BooleanField(), } } /* -------------------------------------------- */ /** * Check the given compatibility data against the current installation state and determine its availability. * @param {Partial} 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} modulesCollection A collection which defines the set of available modules * @returns {Promise} 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} systemCollection A collection which defines the set of available systems. * @returns {Promise} 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} */ 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} 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"); } } /** * 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. */ class BaseWorld extends BasePackage { /** @inheritDoc */ static defineSchema() { return Object.assign({}, super.defineSchema(), { system: new StringField({required: true, blank: false}), background: new StringField({required: false, blank: false}), joinTheme: new StringField({ required: false, initial: undefined, nullable: false, blank: false, choices: WORLD_JOIN_THEMES }), coreVersion: new StringField({required: true, blank: false}), systemVersion: new StringField({required: true, blank: false, initial: "0"}), lastPlayed: new StringField(), playtime: new NumberField({integer: true, min: 0, initial: 0}), nextSession: new StringField({blank: false, nullable: true, initial: null}), resetKeys: new BooleanField({required: false, initial: undefined}), safeMode: new BooleanField({required: false, initial: undefined}), version: new 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} 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} [options.modules] A specific collection of modules to test availability * against. Tests against the currently installed modules by * default. * @param {Collection} [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; } } /** * @typedef {Record>} DocumentTypesConfiguration */ /** * A special [ObjectField]{@link ObjectField} available to packages which configures any additional Document subtypes * provided by the package. */ 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`); } } } /** * @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 */ class BaseSystem extends BasePackage { /** @inheritDoc */ static defineSchema() { return Object.assign({}, super.defineSchema(), { documentTypes: new AdditionalTypesField(), background: new StringField({required: false, blank: false}), initiative: new StringField(), grid: new SchemaField({ type: new 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 NumberField({required: true, nullable: false, positive: true, initial: 1}), units: new StringField({required: true}), diagonals: new 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 StringField(), secondaryTokenAttribute: new 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); } } /** * 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} [documentTypes] Additional document subtypes provided by this module. */ class BaseModule extends BasePackage { /** @inheritDoc */ static defineSchema() { const parentSchema = super.defineSchema(); return Object.assign({}, parentSchema, { coreTranslation: new BooleanField(), library: new BooleanField(), documentTypes: new AdditionalTypesField() }); } /** @override */ static type = "module"; /** * The default icon used for this type of Package. * @type {string} */ static icon = "fa-plug"; } /** @module packages */ /* ---------------------------------------- */ /* 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. */ var packages = /*#__PURE__*/Object.freeze({ __proto__: null, BaseModule: BaseModule, BasePackage: BasePackage, BaseSystem: BaseSystem, BaseWorld: BaseWorld, PackageCompatibility: PackageCompatibility, RelatedPackage: RelatedPackage }); /** @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 StringField({required: true, blank: false, nullable: true, initial: null, label: "SETUP.AdminPasswordLabel", hint: "SETUP.AdminPasswordHint"}), awsConfig: new StringField({label: "SETUP.AWSLabel", hint: "SETUP.AWSHint", blank: false, nullable: true, initial: null}), compressStatic: new BooleanField({initial: true, label: "SETUP.CompressStaticLabel", hint: "SETUP.CompressStaticHint"}), compressSocket: new BooleanField({initial: true, label: "SETUP.CompressSocketLabel", hint: "SETUP.CompressSocketHint"}), cssTheme: new StringField({blank: false, choices: CSS_THEMES, initial: "foundry", label: "SETUP.CSSTheme", hint: "SETUP.CSSThemeHint"}), dataPath: new StringField({label: "SETUP.DataPathLabel", hint: "SETUP.DataPathHint"}), deleteNEDB: new BooleanField({label: "SETUP.DeleteNEDBLabel", hint: "SETUP.DeleteNEDBHint"}), fullscreen: new BooleanField({initial: false}), hostname: new StringField({required: true, blank: false, nullable: true, initial: null}), hotReload: new BooleanField({initial: false, label: "SETUP.HotReloadLabel", hint: "SETUP.HotReloadHint"}), language: new StringField({required: true, blank: false, initial: "en.core", label: "SETUP.DefaultLanguageLabel", hint: "SETUP.DefaultLanguageHint"}), localHostname: new StringField({required: true, blank: false, nullable: true, initial: null}), passwordSalt: new StringField({required: true, blank: false, nullable: true, initial: null}), port: new NumberField({required: true, nullable: false, integer: true, initial: 30000, validate: this._validatePort, label: "SETUP.PortLabel", hint: "SETUP.PortHint"}), protocol: new NumberField({integer: true, choices: [4, 6], nullable: true}), proxyPort: new NumberField({required: true, nullable: true, integer: true, initial: null}), proxySSL: new BooleanField({initial: false}), routePrefix: new StringField({required: true, blank: false, nullable: true, initial: null}), sslCert: new StringField({label: "SETUP.SSLCertLabel", hint: "SETUP.SSLCertHint", blank: false, nullable: true, initial: null}), sslKey: new StringField({label: "SETUP.SSLKeyLabel", blank: false, nullable: true, initial: null}), telemetry: new BooleanField({required: false, initial: undefined, label: "SETUP.Telemetry", hint: "SETUP.TelemetryHint"}), updateChannel: new StringField({required: true, choices: SOFTWARE_UPDATE_CHANNELS, initial: "stable"}), upnp: new BooleanField({initial: true}), upnpLeaseDuration: new NumberField(), world: new StringField({required: true, blank: false, nullable: true, initial: null, label: "SETUP.WorldLabel", hint: "SETUP.WorldHint"}), noBackups: new 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 NumberField({required: true, nullable: false, integer: true, min: 1}), maxGeneration: new NumberField({ required: false, nullable: false, integer: true, min: 1, initial: () => this.generation }), maxStableGeneration: new NumberField({ required: false, nullable: false, integer: true, min: 1, initial: () => this.generation }), channel: new StringField({choices: SOFTWARE_UPDATE_CHANNELS, blank: false}), suffix: new StringField(), build: new NumberField({required: true, nullable: false, integer: true}), time: new NumberField({nullable: false, initial: Date.now}), node_version: new NumberField({required: true, nullable: false, integer: true, min: 10}), notes: new StringField(), download: new 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); } } var config = /*#__PURE__*/Object.freeze({ __proto__: null, ApplicationConfiguration: ApplicationConfiguration, ReleaseData: ReleaseData }); // ::- Persistent data structure representing an ordered mapping from // strings to values, with some convenient update methods. function OrderedMap(content) { this.content = content; } OrderedMap.prototype = { constructor: OrderedMap, find: function(key) { for (var i = 0; i < this.content.length; i += 2) if (this.content[i] === key) return i return -1 }, // :: (string) → ?any // Retrieve the value stored under `key`, or return undefined when // no such key exists. get: function(key) { var found = this.find(key); return found == -1 ? undefined : this.content[found + 1] }, // :: (string, any, ?string) → OrderedMap // Create a new map by replacing the value of `key` with a new // value, or adding a binding to the end of the map. If `newKey` is // given, the key of the binding will be replaced with that key. update: function(key, value, newKey) { var self = newKey && newKey != key ? this.remove(newKey) : this; var found = self.find(key), content = self.content.slice(); if (found == -1) { content.push(newKey || key, value); } else { content[found + 1] = value; if (newKey) content[found] = newKey; } return new OrderedMap(content) }, // :: (string) → OrderedMap // Return a map with the given key removed, if it existed. remove: function(key) { var found = this.find(key); if (found == -1) return this var content = this.content.slice(); content.splice(found, 2); return new OrderedMap(content) }, // :: (string, any) → OrderedMap // Add a new key to the start of the map. addToStart: function(key, value) { return new OrderedMap([key, value].concat(this.remove(key).content)) }, // :: (string, any) → OrderedMap // Add a new key to the end of the map. addToEnd: function(key, value) { var content = this.remove(key).content.slice(); content.push(key, value); return new OrderedMap(content) }, // :: (string, string, any) → OrderedMap // Add a key after the given key. If `place` is not found, the new // key is added to the end. addBefore: function(place, key, value) { var without = this.remove(key), content = without.content.slice(); var found = without.find(place); content.splice(found == -1 ? content.length : found, 0, key, value); return new OrderedMap(content) }, // :: ((key: string, value: any)) // Call the given function for each key/value pair in the map, in // order. forEach: function(f) { for (var i = 0; i < this.content.length; i += 2) f(this.content[i], this.content[i + 1]); }, // :: (union) → OrderedMap // Create a new map by prepending the keys in this map that don't // appear in `map` before the keys in `map`. prepend: function(map) { map = OrderedMap.from(map); if (!map.size) return this return new OrderedMap(map.content.concat(this.subtract(map).content)) }, // :: (union) → OrderedMap // Create a new map by appending the keys in this map that don't // appear in `map` after the keys in `map`. append: function(map) { map = OrderedMap.from(map); if (!map.size) return this return new OrderedMap(this.subtract(map).content.concat(map.content)) }, // :: (union) → OrderedMap // Create a map containing all the keys in this map that don't // appear in `map`. subtract: function(map) { var result = this; map = OrderedMap.from(map); for (var i = 0; i < map.content.length; i += 2) result = result.remove(map.content[i]); return result }, // :: () → Object // Turn ordered map into a plain object. toObject: function() { var result = {}; this.forEach(function(key, value) { result[key] = value; }); return result }, // :: number // The amount of keys in this map. get size() { return this.content.length >> 1 } }; // :: (?union) → OrderedMap // Return a map with the given content. If null, create an empty // map. If given an ordered map, return that map itself. If given an // object, create a map from the object's properties. OrderedMap.from = function(value) { if (value instanceof OrderedMap) return value var content = []; if (value) for (var prop in value) content.push(prop, value[prop]); return new OrderedMap(content) }; function findDiffStart(a, b, pos) { for (let i = 0;; i++) { if (i == a.childCount || i == b.childCount) return a.childCount == b.childCount ? null : pos; let childA = a.child(i), childB = b.child(i); if (childA == childB) { pos += childA.nodeSize; continue; } if (!childA.sameMarkup(childB)) return pos; if (childA.isText && childA.text != childB.text) { for (let j = 0; childA.text[j] == childB.text[j]; j++) pos++; return pos; } if (childA.content.size || childB.content.size) { let inner = findDiffStart(childA.content, childB.content, pos + 1); if (inner != null) return inner; } pos += childA.nodeSize; } } function findDiffEnd(a, b, posA, posB) { for (let iA = a.childCount, iB = b.childCount;;) { if (iA == 0 || iB == 0) return iA == iB ? null : { a: posA, b: posB }; let childA = a.child(--iA), childB = b.child(--iB), size = childA.nodeSize; if (childA == childB) { posA -= size; posB -= size; continue; } if (!childA.sameMarkup(childB)) return { a: posA, b: posB }; if (childA.isText && childA.text != childB.text) { let same = 0, minSize = Math.min(childA.text.length, childB.text.length); while (same < minSize && childA.text[childA.text.length - same - 1] == childB.text[childB.text.length - same - 1]) { same++; posA--; posB--; } return { a: posA, b: posB }; } if (childA.content.size || childB.content.size) { let inner = findDiffEnd(childA.content, childB.content, posA - 1, posB - 1); if (inner) return inner; } posA -= size; posB -= size; } } /** A fragment represents a node's collection of child nodes. Like nodes, fragments are persistent data structures, and you should not mutate them or their content. Rather, you create new instances whenever needed. The API tries to make this easy. */ class Fragment { /** @internal */ constructor( /** @internal */ content, size) { this.content = content; this.size = size || 0; if (size == null) for (let i = 0; i < content.length; i++) this.size += content[i].nodeSize; } /** Invoke a callback for all descendant nodes between the given two positions (relative to start of this fragment). Doesn't descend into a node when the callback returns `false`. */ nodesBetween(from, to, f, nodeStart = 0, parent) { for (let i = 0, pos = 0; pos < to; i++) { let child = this.content[i], end = pos + child.nodeSize; if (end > from && f(child, nodeStart + pos, parent || null, i) !== false && child.content.size) { let start = pos + 1; child.nodesBetween(Math.max(0, from - start), Math.min(child.content.size, to - start), f, nodeStart + start); } pos = end; } } /** Call the given callback for every descendant node. `pos` will be relative to the start of the fragment. The callback may return `false` to prevent traversal of a given node's children. */ descendants(f) { this.nodesBetween(0, this.size, f); } /** Extract the text between `from` and `to`. See the same method on [`Node`](https://prosemirror.net/docs/ref/#model.Node.textBetween). */ textBetween(from, to, blockSeparator, leafText) { let text = "", first = true; this.nodesBetween(from, to, (node, pos) => { let nodeText = node.isText ? node.text.slice(Math.max(from, pos) - pos, to - pos) : !node.isLeaf ? "" : leafText ? (typeof leafText === "function" ? leafText(node) : leafText) : node.type.spec.leafText ? node.type.spec.leafText(node) : ""; if (node.isBlock && (node.isLeaf && nodeText || node.isTextblock) && blockSeparator) { if (first) first = false; else text += blockSeparator; } text += nodeText; }, 0); return text; } /** Create a new fragment containing the combined content of this fragment and the other. */ append(other) { if (!other.size) return this; if (!this.size) return other; let last = this.lastChild, first = other.firstChild, content = this.content.slice(), i = 0; if (last.isText && last.sameMarkup(first)) { content[content.length - 1] = last.withText(last.text + first.text); i = 1; } for (; i < other.content.length; i++) content.push(other.content[i]); return new Fragment(content, this.size + other.size); } /** Cut out the sub-fragment between the two given positions. */ cut(from, to = this.size) { if (from == 0 && to == this.size) return this; let result = [], size = 0; if (to > from) for (let i = 0, pos = 0; pos < to; i++) { let child = this.content[i], end = pos + child.nodeSize; if (end > from) { if (pos < from || end > to) { if (child.isText) child = child.cut(Math.max(0, from - pos), Math.min(child.text.length, to - pos)); else child = child.cut(Math.max(0, from - pos - 1), Math.min(child.content.size, to - pos - 1)); } result.push(child); size += child.nodeSize; } pos = end; } return new Fragment(result, size); } /** @internal */ cutByIndex(from, to) { if (from == to) return Fragment.empty; if (from == 0 && to == this.content.length) return this; return new Fragment(this.content.slice(from, to)); } /** Create a new fragment in which the node at the given index is replaced by the given node. */ replaceChild(index, node) { let current = this.content[index]; if (current == node) return this; let copy = this.content.slice(); let size = this.size + node.nodeSize - current.nodeSize; copy[index] = node; return new Fragment(copy, size); } /** Create a new fragment by prepending the given node to this fragment. */ addToStart(node) { return new Fragment([node].concat(this.content), this.size + node.nodeSize); } /** Create a new fragment by appending the given node to this fragment. */ addToEnd(node) { return new Fragment(this.content.concat(node), this.size + node.nodeSize); } /** Compare this fragment to another one. */ eq(other) { if (this.content.length != other.content.length) return false; for (let i = 0; i < this.content.length; i++) if (!this.content[i].eq(other.content[i])) return false; return true; } /** The first child of the fragment, or `null` if it is empty. */ get firstChild() { return this.content.length ? this.content[0] : null; } /** The last child of the fragment, or `null` if it is empty. */ get lastChild() { return this.content.length ? this.content[this.content.length - 1] : null; } /** The number of child nodes in this fragment. */ get childCount() { return this.content.length; } /** Get the child node at the given index. Raise an error when the index is out of range. */ child(index) { let found = this.content[index]; if (!found) throw new RangeError("Index " + index + " out of range for " + this); return found; } /** Get the child node at the given index, if it exists. */ maybeChild(index) { return this.content[index] || null; } /** Call `f` for every child node, passing the node, its offset into this parent node, and its index. */ forEach(f) { for (let i = 0, p = 0; i < this.content.length; i++) { let child = this.content[i]; f(child, p, i); p += child.nodeSize; } } /** Find the first position at which this fragment and another fragment differ, or `null` if they are the same. */ findDiffStart(other, pos = 0) { return findDiffStart(this, other, pos); } /** Find the first position, searching from the end, at which this fragment and the given fragment differ, or `null` if they are the same. Since this position will not be the same in both nodes, an object with two separate positions is returned. */ findDiffEnd(other, pos = this.size, otherPos = other.size) { return findDiffEnd(this, other, pos, otherPos); } /** Find the index and inner offset corresponding to a given relative position in this fragment. The result object will be reused (overwritten) the next time the function is called. (Not public.) */ findIndex(pos, round = -1) { if (pos == 0) return retIndex(0, pos); if (pos == this.size) return retIndex(this.content.length, pos); if (pos > this.size || pos < 0) throw new RangeError(`Position ${pos} outside of fragment (${this})`); for (let i = 0, curPos = 0;; i++) { let cur = this.child(i), end = curPos + cur.nodeSize; if (end >= pos) { if (end == pos || round > 0) return retIndex(i + 1, end); return retIndex(i, curPos); } curPos = end; } } /** Return a debugging string that describes this fragment. */ toString() { return "<" + this.toStringInner() + ">"; } /** @internal */ toStringInner() { return this.content.join(", "); } /** Create a JSON-serializeable representation of this fragment. */ toJSON() { return this.content.length ? this.content.map(n => n.toJSON()) : null; } /** Deserialize a fragment from its JSON representation. */ static fromJSON(schema, value) { if (!value) return Fragment.empty; if (!Array.isArray(value)) throw new RangeError("Invalid input for Fragment.fromJSON"); return new Fragment(value.map(schema.nodeFromJSON)); } /** Build a fragment from an array of nodes. Ensures that adjacent text nodes with the same marks are joined together. */ static fromArray(array) { if (!array.length) return Fragment.empty; let joined, size = 0; for (let i = 0; i < array.length; i++) { let node = array[i]; size += node.nodeSize; if (i && node.isText && array[i - 1].sameMarkup(node)) { if (!joined) joined = array.slice(0, i); joined[joined.length - 1] = node .withText(joined[joined.length - 1].text + node.text); } else if (joined) { joined.push(node); } } return new Fragment(joined || array, size); } /** Create a fragment from something that can be interpreted as a set of nodes. For `null`, it returns the empty fragment. For a fragment, the fragment itself. For a node or array of nodes, a fragment containing those nodes. */ static from(nodes) { if (!nodes) return Fragment.empty; if (nodes instanceof Fragment) return nodes; if (Array.isArray(nodes)) return this.fromArray(nodes); if (nodes.attrs) return new Fragment([nodes], nodes.nodeSize); throw new RangeError("Can not convert " + nodes + " to a Fragment" + (nodes.nodesBetween ? " (looks like multiple versions of prosemirror-model were loaded)" : "")); } } /** An empty fragment. Intended to be reused whenever a node doesn't contain anything (rather than allocating a new empty fragment for each leaf node). */ Fragment.empty = new Fragment([], 0); const found = { index: 0, offset: 0 }; function retIndex(index, offset) { found.index = index; found.offset = offset; return found; } function compareDeep(a, b) { if (a === b) return true; if (!(a && typeof a == "object") || !(b && typeof b == "object")) return false; let array = Array.isArray(a); if (Array.isArray(b) != array) return false; if (array) { if (a.length != b.length) return false; for (let i = 0; i < a.length; i++) if (!compareDeep(a[i], b[i])) return false; } else { for (let p in a) if (!(p in b) || !compareDeep(a[p], b[p])) return false; for (let p in b) if (!(p in a)) return false; } return true; } /** A mark is a piece of information that can be attached to a node, such as it being emphasized, in code font, or a link. It has a type and optionally a set of attributes that provide further information (such as the target of the link). Marks are created through a `Schema`, which controls which types exist and which attributes they have. */ class Mark { /** @internal */ constructor( /** The type of this mark. */ type, /** The attributes associated with this mark. */ attrs) { this.type = type; this.attrs = attrs; } /** Given a set of marks, create a new set which contains this one as well, in the right position. If this mark is already in the set, the set itself is returned. If any marks that are set to be [exclusive](https://prosemirror.net/docs/ref/#model.MarkSpec.excludes) with this mark are present, those are replaced by this one. */ addToSet(set) { let copy, placed = false; for (let i = 0; i < set.length; i++) { let other = set[i]; if (this.eq(other)) return set; if (this.type.excludes(other.type)) { if (!copy) copy = set.slice(0, i); } else if (other.type.excludes(this.type)) { return set; } else { if (!placed && other.type.rank > this.type.rank) { if (!copy) copy = set.slice(0, i); copy.push(this); placed = true; } if (copy) copy.push(other); } } if (!copy) copy = set.slice(); if (!placed) copy.push(this); return copy; } /** Remove this mark from the given set, returning a new set. If this mark is not in the set, the set itself is returned. */ removeFromSet(set) { for (let i = 0; i < set.length; i++) if (this.eq(set[i])) return set.slice(0, i).concat(set.slice(i + 1)); return set; } /** Test whether this mark is in the given set of marks. */ isInSet(set) { for (let i = 0; i < set.length; i++) if (this.eq(set[i])) return true; return false; } /** Test whether this mark has the same type and attributes as another mark. */ eq(other) { return this == other || (this.type == other.type && compareDeep(this.attrs, other.attrs)); } /** Convert this mark to a JSON-serializeable representation. */ toJSON() { let obj = { type: this.type.name }; for (let _ in this.attrs) { obj.attrs = this.attrs; break; } return obj; } /** Deserialize a mark from JSON. */ static fromJSON(schema, json) { if (!json) throw new RangeError("Invalid input for Mark.fromJSON"); let type = schema.marks[json.type]; if (!type) throw new RangeError(`There is no mark type ${json.type} in this schema`); return type.create(json.attrs); } /** Test whether two sets of marks are identical. */ static sameSet(a, b) { if (a == b) return true; if (a.length != b.length) return false; for (let i = 0; i < a.length; i++) if (!a[i].eq(b[i])) return false; return true; } /** Create a properly sorted mark set from null, a single mark, or an unsorted array of marks. */ static setFrom(marks) { if (!marks || Array.isArray(marks) && marks.length == 0) return Mark.none; if (marks instanceof Mark) return [marks]; let copy = marks.slice(); copy.sort((a, b) => a.type.rank - b.type.rank); return copy; } } /** The empty set of marks. */ Mark.none = []; /** Error type raised by [`Node.replace`](https://prosemirror.net/docs/ref/#model.Node.replace) when given an invalid replacement. */ class ReplaceError extends Error { } /* ReplaceError = function(this: any, message: string) { let err = Error.call(this, message) ;(err as any).__proto__ = ReplaceError.prototype return err } as any ReplaceError.prototype = Object.create(Error.prototype) ReplaceError.prototype.constructor = ReplaceError ReplaceError.prototype.name = "ReplaceError" */ /** A slice represents a piece cut out of a larger document. It stores not only a fragment, but also the depth up to which nodes on both side are ‘open’ (cut through). */ class Slice { /** Create a slice. When specifying a non-zero open depth, you must make sure that there are nodes of at least that depth at the appropriate side of the fragment—i.e. if the fragment is an empty paragraph node, `openStart` and `openEnd` can't be greater than 1. It is not necessary for the content of open nodes to conform to the schema's content constraints, though it should be a valid start/end/middle for such a node, depending on which sides are open. */ constructor( /** The slice's content. */ content, /** The open depth at the start of the fragment. */ openStart, /** The open depth at the end. */ openEnd) { this.content = content; this.openStart = openStart; this.openEnd = openEnd; } /** The size this slice would add when inserted into a document. */ get size() { return this.content.size - this.openStart - this.openEnd; } /** @internal */ insertAt(pos, fragment) { let content = insertInto(this.content, pos + this.openStart, fragment); return content && new Slice(content, this.openStart, this.openEnd); } /** @internal */ removeBetween(from, to) { return new Slice(removeRange(this.content, from + this.openStart, to + this.openStart), this.openStart, this.openEnd); } /** Tests whether this slice is equal to another slice. */ eq(other) { return this.content.eq(other.content) && this.openStart == other.openStart && this.openEnd == other.openEnd; } /** @internal */ toString() { return this.content + "(" + this.openStart + "," + this.openEnd + ")"; } /** Convert a slice to a JSON-serializable representation. */ toJSON() { if (!this.content.size) return null; let json = { content: this.content.toJSON() }; if (this.openStart > 0) json.openStart = this.openStart; if (this.openEnd > 0) json.openEnd = this.openEnd; return json; } /** Deserialize a slice from its JSON representation. */ static fromJSON(schema, json) { if (!json) return Slice.empty; let openStart = json.openStart || 0, openEnd = json.openEnd || 0; if (typeof openStart != "number" || typeof openEnd != "number") throw new RangeError("Invalid input for Slice.fromJSON"); return new Slice(Fragment.fromJSON(schema, json.content), openStart, openEnd); } /** Create a slice from a fragment by taking the maximum possible open value on both side of the fragment. */ static maxOpen(fragment, openIsolating = true) { let openStart = 0, openEnd = 0; for (let n = fragment.firstChild; n && !n.isLeaf && (openIsolating || !n.type.spec.isolating); n = n.firstChild) openStart++; for (let n = fragment.lastChild; n && !n.isLeaf && (openIsolating || !n.type.spec.isolating); n = n.lastChild) openEnd++; return new Slice(fragment, openStart, openEnd); } } /** The empty slice. */ Slice.empty = new Slice(Fragment.empty, 0, 0); function removeRange(content, from, to) { let { index, offset } = content.findIndex(from), child = content.maybeChild(index); let { index: indexTo, offset: offsetTo } = content.findIndex(to); if (offset == from || child.isText) { if (offsetTo != to && !content.child(indexTo).isText) throw new RangeError("Removing non-flat range"); return content.cut(0, from).append(content.cut(to)); } if (index != indexTo) throw new RangeError("Removing non-flat range"); return content.replaceChild(index, child.copy(removeRange(child.content, from - offset - 1, to - offset - 1))); } function insertInto(content, dist, insert, parent) { let { index, offset } = content.findIndex(dist), child = content.maybeChild(index); if (offset == dist || child.isText) { return content.cut(0, dist).append(insert).append(content.cut(dist)); } let inner = insertInto(child.content, dist - offset - 1, insert); return inner && content.replaceChild(index, child.copy(inner)); } function replace($from, $to, slice) { if (slice.openStart > $from.depth) throw new ReplaceError("Inserted content deeper than insertion position"); if ($from.depth - slice.openStart != $to.depth - slice.openEnd) throw new ReplaceError("Inconsistent open depths"); return replaceOuter($from, $to, slice, 0); } function replaceOuter($from, $to, slice, depth) { let index = $from.index(depth), node = $from.node(depth); if (index == $to.index(depth) && depth < $from.depth - slice.openStart) { let inner = replaceOuter($from, $to, slice, depth + 1); return node.copy(node.content.replaceChild(index, inner)); } else if (!slice.content.size) { return close(node, replaceTwoWay($from, $to, depth)); } else if (!slice.openStart && !slice.openEnd && $from.depth == depth && $to.depth == depth) { // Simple, flat case let parent = $from.parent, content = parent.content; return close(parent, content.cut(0, $from.parentOffset).append(slice.content).append(content.cut($to.parentOffset))); } else { let { start, end } = prepareSliceForReplace(slice, $from); return close(node, replaceThreeWay($from, start, end, $to, depth)); } } function checkJoin(main, sub) { if (!sub.type.compatibleContent(main.type)) throw new ReplaceError("Cannot join " + sub.type.name + " onto " + main.type.name); } function joinable$1($before, $after, depth) { let node = $before.node(depth); checkJoin(node, $after.node(depth)); return node; } function addNode(child, target) { let last = target.length - 1; if (last >= 0 && child.isText && child.sameMarkup(target[last])) target[last] = child.withText(target[last].text + child.text); else target.push(child); } function addRange($start, $end, depth, target) { let node = ($end || $start).node(depth); let startIndex = 0, endIndex = $end ? $end.index(depth) : node.childCount; if ($start) { startIndex = $start.index(depth); if ($start.depth > depth) { startIndex++; } else if ($start.textOffset) { addNode($start.nodeAfter, target); startIndex++; } } for (let i = startIndex; i < endIndex; i++) addNode(node.child(i), target); if ($end && $end.depth == depth && $end.textOffset) addNode($end.nodeBefore, target); } function close(node, content) { node.type.checkContent(content); return node.copy(content); } function replaceThreeWay($from, $start, $end, $to, depth) { let openStart = $from.depth > depth && joinable$1($from, $start, depth + 1); let openEnd = $to.depth > depth && joinable$1($end, $to, depth + 1); let content = []; addRange(null, $from, depth, content); if (openStart && openEnd && $start.index(depth) == $end.index(depth)) { checkJoin(openStart, openEnd); addNode(close(openStart, replaceThreeWay($from, $start, $end, $to, depth + 1)), content); } else { if (openStart) addNode(close(openStart, replaceTwoWay($from, $start, depth + 1)), content); addRange($start, $end, depth, content); if (openEnd) addNode(close(openEnd, replaceTwoWay($end, $to, depth + 1)), content); } addRange($to, null, depth, content); return new Fragment(content); } function replaceTwoWay($from, $to, depth) { let content = []; addRange(null, $from, depth, content); if ($from.depth > depth) { let type = joinable$1($from, $to, depth + 1); addNode(close(type, replaceTwoWay($from, $to, depth + 1)), content); } addRange($to, null, depth, content); return new Fragment(content); } function prepareSliceForReplace(slice, $along) { let extra = $along.depth - slice.openStart, parent = $along.node(extra); let node = parent.copy(slice.content); for (let i = extra - 1; i >= 0; i--) node = $along.node(i).copy(Fragment.from(node)); return { start: node.resolveNoCache(slice.openStart + extra), end: node.resolveNoCache(node.content.size - slice.openEnd - extra) }; } /** You can [_resolve_](https://prosemirror.net/docs/ref/#model.Node.resolve) a position to get more information about it. Objects of this class represent such a resolved position, providing various pieces of context information, and some helper methods. Throughout this interface, methods that take an optional `depth` parameter will interpret undefined as `this.depth` and negative numbers as `this.depth + value`. */ class ResolvedPos { /** @internal */ constructor( /** The position that was resolved. */ pos, /** @internal */ path, /** The offset this position has into its parent node. */ parentOffset) { this.pos = pos; this.path = path; this.parentOffset = parentOffset; this.depth = path.length / 3 - 1; } /** @internal */ resolveDepth(val) { if (val == null) return this.depth; if (val < 0) return this.depth + val; return val; } /** The parent node that the position points into. Note that even if a position points into a text node, that node is not considered the parent—text nodes are ‘flat’ in this model, and have no content. */ get parent() { return this.node(this.depth); } /** The root node in which the position was resolved. */ get doc() { return this.node(0); } /** The ancestor node at the given level. `p.node(p.depth)` is the same as `p.parent`. */ node(depth) { return this.path[this.resolveDepth(depth) * 3]; } /** The index into the ancestor at the given level. If this points at the 3rd node in the 2nd paragraph on the top level, for example, `p.index(0)` is 1 and `p.index(1)` is 2. */ index(depth) { return this.path[this.resolveDepth(depth) * 3 + 1]; } /** The index pointing after this position into the ancestor at the given level. */ indexAfter(depth) { depth = this.resolveDepth(depth); return this.index(depth) + (depth == this.depth && !this.textOffset ? 0 : 1); } /** The (absolute) position at the start of the node at the given level. */ start(depth) { depth = this.resolveDepth(depth); return depth == 0 ? 0 : this.path[depth * 3 - 1] + 1; } /** The (absolute) position at the end of the node at the given level. */ end(depth) { depth = this.resolveDepth(depth); return this.start(depth) + this.node(depth).content.size; } /** The (absolute) position directly before the wrapping node at the given level, or, when `depth` is `this.depth + 1`, the original position. */ before(depth) { depth = this.resolveDepth(depth); if (!depth) throw new RangeError("There is no position before the top-level node"); return depth == this.depth + 1 ? this.pos : this.path[depth * 3 - 1]; } /** The (absolute) position directly after the wrapping node at the given level, or the original position when `depth` is `this.depth + 1`. */ after(depth) { depth = this.resolveDepth(depth); if (!depth) throw new RangeError("There is no position after the top-level node"); return depth == this.depth + 1 ? this.pos : this.path[depth * 3 - 1] + this.path[depth * 3].nodeSize; } /** When this position points into a text node, this returns the distance between the position and the start of the text node. Will be zero for positions that point between nodes. */ get textOffset() { return this.pos - this.path[this.path.length - 1]; } /** Get the node directly after the position, if any. If the position points into a text node, only the part of that node after the position is returned. */ get nodeAfter() { let parent = this.parent, index = this.index(this.depth); if (index == parent.childCount) return null; let dOff = this.pos - this.path[this.path.length - 1], child = parent.child(index); return dOff ? parent.child(index).cut(dOff) : child; } /** Get the node directly before the position, if any. If the position points into a text node, only the part of that node before the position is returned. */ get nodeBefore() { let index = this.index(this.depth); let dOff = this.pos - this.path[this.path.length - 1]; if (dOff) return this.parent.child(index).cut(0, dOff); return index == 0 ? null : this.parent.child(index - 1); } /** Get the position at the given index in the parent node at the given depth (which defaults to `this.depth`). */ posAtIndex(index, depth) { depth = this.resolveDepth(depth); let node = this.path[depth * 3], pos = depth == 0 ? 0 : this.path[depth * 3 - 1] + 1; for (let i = 0; i < index; i++) pos += node.child(i).nodeSize; return pos; } /** Get the marks at this position, factoring in the surrounding marks' [`inclusive`](https://prosemirror.net/docs/ref/#model.MarkSpec.inclusive) property. If the position is at the start of a non-empty node, the marks of the node after it (if any) are returned. */ marks() { let parent = this.parent, index = this.index(); // In an empty parent, return the empty array if (parent.content.size == 0) return Mark.none; // When inside a text node, just return the text node's marks if (this.textOffset) return parent.child(index).marks; let main = parent.maybeChild(index - 1), other = parent.maybeChild(index); // If the `after` flag is true of there is no node before, make // the node after this position the main reference. if (!main) { let tmp = main; main = other; other = tmp; } // Use all marks in the main node, except those that have // `inclusive` set to false and are not present in the other node. let marks = main.marks; for (var i = 0; i < marks.length; i++) if (marks[i].type.spec.inclusive === false && (!other || !marks[i].isInSet(other.marks))) marks = marks[i--].removeFromSet(marks); return marks; } /** Get the marks after the current position, if any, except those that are non-inclusive and not present at position `$end`. This is mostly useful for getting the set of marks to preserve after a deletion. Will return `null` if this position is at the end of its parent node or its parent node isn't a textblock (in which case no marks should be preserved). */ marksAcross($end) { let after = this.parent.maybeChild(this.index()); if (!after || !after.isInline) return null; let marks = after.marks, next = $end.parent.maybeChild($end.index()); for (var i = 0; i < marks.length; i++) if (marks[i].type.spec.inclusive === false && (!next || !marks[i].isInSet(next.marks))) marks = marks[i--].removeFromSet(marks); return marks; } /** The depth up to which this position and the given (non-resolved) position share the same parent nodes. */ sharedDepth(pos) { for (let depth = this.depth; depth > 0; depth--) if (this.start(depth) <= pos && this.end(depth) >= pos) return depth; return 0; } /** Returns a range based on the place where this position and the given position diverge around block content. If both point into the same textblock, for example, a range around that textblock will be returned. If they point into different blocks, the range around those blocks in their shared ancestor is returned. You can pass in an optional predicate that will be called with a parent node to see if a range into that parent is acceptable. */ blockRange(other = this, pred) { if (other.pos < this.pos) return other.blockRange(this); for (let d = this.depth - (this.parent.inlineContent || this.pos == other.pos ? 1 : 0); d >= 0; d--) if (other.pos <= this.end(d) && (!pred || pred(this.node(d)))) return new NodeRange(this, other, d); return null; } /** Query whether the given position shares the same parent node. */ sameParent(other) { return this.pos - this.parentOffset == other.pos - other.parentOffset; } /** Return the greater of this and the given position. */ max(other) { return other.pos > this.pos ? other : this; } /** Return the smaller of this and the given position. */ min(other) { return other.pos < this.pos ? other : this; } /** @internal */ toString() { let str = ""; for (let i = 1; i <= this.depth; i++) str += (str ? "/" : "") + this.node(i).type.name + "_" + this.index(i - 1); return str + ":" + this.parentOffset; } /** @internal */ static resolve(doc, pos) { if (!(pos >= 0 && pos <= doc.content.size)) throw new RangeError("Position " + pos + " out of range"); let path = []; let start = 0, parentOffset = pos; for (let node = doc;;) { let { index, offset } = node.content.findIndex(parentOffset); let rem = parentOffset - offset; path.push(node, index, start + offset); if (!rem) break; node = node.child(index); if (node.isText) break; parentOffset = rem - 1; start += offset + 1; } return new ResolvedPos(pos, path, parentOffset); } /** @internal */ static resolveCached(doc, pos) { for (let i = 0; i < resolveCache.length; i++) { let cached = resolveCache[i]; if (cached.pos == pos && cached.doc == doc) return cached; } let result = resolveCache[resolveCachePos] = ResolvedPos.resolve(doc, pos); resolveCachePos = (resolveCachePos + 1) % resolveCacheSize; return result; } } let resolveCache = [], resolveCachePos = 0, resolveCacheSize = 12; /** Represents a flat range of content, i.e. one that starts and ends in the same node. */ class NodeRange { /** Construct a node range. `$from` and `$to` should point into the same node until at least the given `depth`, since a node range denotes an adjacent set of nodes in a single parent node. */ constructor( /** A resolved position along the start of the content. May have a `depth` greater than this object's `depth` property, since these are the positions that were used to compute the range, not re-resolved positions directly at its boundaries. */ $from, /** A position along the end of the content. See caveat for [`$from`](https://prosemirror.net/docs/ref/#model.NodeRange.$from). */ $to, /** The depth of the node that this range points into. */ depth) { this.$from = $from; this.$to = $to; this.depth = depth; } /** The position at the start of the range. */ get start() { return this.$from.before(this.depth + 1); } /** The position at the end of the range. */ get end() { return this.$to.after(this.depth + 1); } /** The parent node that the range points into. */ get parent() { return this.$from.node(this.depth); } /** The start index of the range in the parent node. */ get startIndex() { return this.$from.index(this.depth); } /** The end index of the range in the parent node. */ get endIndex() { return this.$to.indexAfter(this.depth); } } const emptyAttrs = Object.create(null); /** This class represents a node in the tree that makes up a ProseMirror document. So a document is an instance of `Node`, with children that are also instances of `Node`. Nodes are persistent data structures. Instead of changing them, you create new ones with the content you want. Old ones keep pointing at the old document shape. This is made cheaper by sharing structure between the old and new data as much as possible, which a tree shape like this (without back pointers) makes easy. **Do not** directly mutate the properties of a `Node` object. See [the guide](/docs/guide/#doc) for more information. */ class Node { /** @internal */ constructor( /** The type of node that this is. */ type, /** An object mapping attribute names to values. The kind of attributes allowed and required are [determined](https://prosemirror.net/docs/ref/#model.NodeSpec.attrs) by the node type. */ attrs, // A fragment holding the node's children. content, /** The marks (things like whether it is emphasized or part of a link) applied to this node. */ marks = Mark.none) { this.type = type; this.attrs = attrs; this.marks = marks; this.content = content || Fragment.empty; } /** The size of this node, as defined by the integer-based [indexing scheme](/docs/guide/#doc.indexing). For text nodes, this is the amount of characters. For other leaf nodes, it is one. For non-leaf nodes, it is the size of the content plus two (the start and end token). */ get nodeSize() { return this.isLeaf ? 1 : 2 + this.content.size; } /** The number of children that the node has. */ get childCount() { return this.content.childCount; } /** Get the child node at the given index. Raises an error when the index is out of range. */ child(index) { return this.content.child(index); } /** Get the child node at the given index, if it exists. */ maybeChild(index) { return this.content.maybeChild(index); } /** Call `f` for every child node, passing the node, its offset into this parent node, and its index. */ forEach(f) { this.content.forEach(f); } /** Invoke a callback for all descendant nodes recursively between the given two positions that are relative to start of this node's content. The callback is invoked with the node, its position relative to the original node (method receiver), its parent node, and its child index. When the callback returns false for a given node, that node's children will not be recursed over. The last parameter can be used to specify a starting position to count from. */ nodesBetween(from, to, f, startPos = 0) { this.content.nodesBetween(from, to, f, startPos, this); } /** Call the given callback for every descendant node. Doesn't descend into a node when the callback returns `false`. */ descendants(f) { this.nodesBetween(0, this.content.size, f); } /** Concatenates all the text nodes found in this fragment and its children. */ get textContent() { return (this.isLeaf && this.type.spec.leafText) ? this.type.spec.leafText(this) : this.textBetween(0, this.content.size, ""); } /** Get all text between positions `from` and `to`. When `blockSeparator` is given, it will be inserted to separate text from different block nodes. If `leafText` is given, it'll be inserted for every non-text leaf node encountered, otherwise [`leafText`](https://prosemirror.net/docs/ref/#model.NodeSpec^leafText) will be used. */ textBetween(from, to, blockSeparator, leafText) { return this.content.textBetween(from, to, blockSeparator, leafText); } /** Returns this node's first child, or `null` if there are no children. */ get firstChild() { return this.content.firstChild; } /** Returns this node's last child, or `null` if there are no children. */ get lastChild() { return this.content.lastChild; } /** Test whether two nodes represent the same piece of document. */ eq(other) { return this == other || (this.sameMarkup(other) && this.content.eq(other.content)); } /** Compare the markup (type, attributes, and marks) of this node to those of another. Returns `true` if both have the same markup. */ sameMarkup(other) { return this.hasMarkup(other.type, other.attrs, other.marks); } /** Check whether this node's markup correspond to the given type, attributes, and marks. */ hasMarkup(type, attrs, marks) { return this.type == type && compareDeep(this.attrs, attrs || type.defaultAttrs || emptyAttrs) && Mark.sameSet(this.marks, marks || Mark.none); } /** Create a new node with the same markup as this node, containing the given content (or empty, if no content is given). */ copy(content = null) { if (content == this.content) return this; return new Node(this.type, this.attrs, content, this.marks); } /** Create a copy of this node, with the given set of marks instead of the node's own marks. */ mark(marks) { return marks == this.marks ? this : new Node(this.type, this.attrs, this.content, marks); } /** Create a copy of this node with only the content between the given positions. If `to` is not given, it defaults to the end of the node. */ cut(from, to = this.content.size) { if (from == 0 && to == this.content.size) return this; return this.copy(this.content.cut(from, to)); } /** Cut out the part of the document between the given positions, and return it as a `Slice` object. */ slice(from, to = this.content.size, includeParents = false) { if (from == to) return Slice.empty; let $from = this.resolve(from), $to = this.resolve(to); let depth = includeParents ? 0 : $from.sharedDepth(to); let start = $from.start(depth), node = $from.node(depth); let content = node.content.cut($from.pos - start, $to.pos - start); return new Slice(content, $from.depth - depth, $to.depth - depth); } /** Replace the part of the document between the given positions with the given slice. The slice must 'fit', meaning its open sides must be able to connect to the surrounding content, and its content nodes must be valid children for the node they are placed into. If any of this is violated, an error of type [`ReplaceError`](https://prosemirror.net/docs/ref/#model.ReplaceError) is thrown. */ replace(from, to, slice) { return replace(this.resolve(from), this.resolve(to), slice); } /** Find the node directly after the given position. */ nodeAt(pos) { for (let node = this;;) { let { index, offset } = node.content.findIndex(pos); node = node.maybeChild(index); if (!node) return null; if (offset == pos || node.isText) return node; pos -= offset + 1; } } /** Find the (direct) child node after the given offset, if any, and return it along with its index and offset relative to this node. */ childAfter(pos) { let { index, offset } = this.content.findIndex(pos); return { node: this.content.maybeChild(index), index, offset }; } /** Find the (direct) child node before the given offset, if any, and return it along with its index and offset relative to this node. */ childBefore(pos) { if (pos == 0) return { node: null, index: 0, offset: 0 }; let { index, offset } = this.content.findIndex(pos); if (offset < pos) return { node: this.content.child(index), index, offset }; let node = this.content.child(index - 1); return { node, index: index - 1, offset: offset - node.nodeSize }; } /** Resolve the given position in the document, returning an [object](https://prosemirror.net/docs/ref/#model.ResolvedPos) with information about its context. */ resolve(pos) { return ResolvedPos.resolveCached(this, pos); } /** @internal */ resolveNoCache(pos) { return ResolvedPos.resolve(this, pos); } /** Test whether a given mark or mark type occurs in this document between the two given positions. */ rangeHasMark(from, to, type) { let found = false; if (to > from) this.nodesBetween(from, to, node => { if (type.isInSet(node.marks)) found = true; return !found; }); return found; } /** True when this is a block (non-inline node) */ get isBlock() { return this.type.isBlock; } /** True when this is a textblock node, a block node with inline content. */ get isTextblock() { return this.type.isTextblock; } /** True when this node allows inline content. */ get inlineContent() { return this.type.inlineContent; } /** True when this is an inline node (a text node or a node that can appear among text). */ get isInline() { return this.type.isInline; } /** True when this is a text node. */ get isText() { return this.type.isText; } /** True when this is a leaf node. */ get isLeaf() { return this.type.isLeaf; } /** True when this is an atom, i.e. when it does not have directly editable content. This is usually the same as `isLeaf`, but can be configured with the [`atom` property](https://prosemirror.net/docs/ref/#model.NodeSpec.atom) on a node's spec (typically used when the node is displayed as an uneditable [node view](https://prosemirror.net/docs/ref/#view.NodeView)). */ get isAtom() { return this.type.isAtom; } /** Return a string representation of this node for debugging purposes. */ toString() { if (this.type.spec.toDebugString) return this.type.spec.toDebugString(this); let name = this.type.name; if (this.content.size) name += "(" + this.content.toStringInner() + ")"; return wrapMarks(this.marks, name); } /** Get the content match in this node at the given index. */ contentMatchAt(index) { let match = this.type.contentMatch.matchFragment(this.content, 0, index); if (!match) throw new Error("Called contentMatchAt on a node with invalid content"); return match; } /** Test whether replacing the range between `from` and `to` (by child index) with the given replacement fragment (which defaults to the empty fragment) would leave the node's content valid. You can optionally pass `start` and `end` indices into the replacement fragment. */ canReplace(from, to, replacement = Fragment.empty, start = 0, end = replacement.childCount) { let one = this.contentMatchAt(from).matchFragment(replacement, start, end); let two = one && one.matchFragment(this.content, to); if (!two || !two.validEnd) return false; for (let i = start; i < end; i++) if (!this.type.allowsMarks(replacement.child(i).marks)) return false; return true; } /** Test whether replacing the range `from` to `to` (by index) with a node of the given type would leave the node's content valid. */ canReplaceWith(from, to, type, marks) { if (marks && !this.type.allowsMarks(marks)) return false; let start = this.contentMatchAt(from).matchType(type); let end = start && start.matchFragment(this.content, to); return end ? end.validEnd : false; } /** Test whether the given node's content could be appended to this node. If that node is empty, this will only return true if there is at least one node type that can appear in both nodes (to avoid merging completely incompatible nodes). */ canAppend(other) { if (other.content.size) return this.canReplace(this.childCount, this.childCount, other.content); else return this.type.compatibleContent(other.type); } /** Check whether this node and its descendants conform to the schema, and raise error when they do not. */ check() { this.type.checkContent(this.content); let copy = Mark.none; for (let i = 0; i < this.marks.length; i++) copy = this.marks[i].addToSet(copy); if (!Mark.sameSet(copy, this.marks)) throw new RangeError(`Invalid collection of marks for node ${this.type.name}: ${this.marks.map(m => m.type.name)}`); this.content.forEach(node => node.check()); } /** Return a JSON-serializeable representation of this node. */ toJSON() { let obj = { type: this.type.name }; for (let _ in this.attrs) { obj.attrs = this.attrs; break; } if (this.content.size) obj.content = this.content.toJSON(); if (this.marks.length) obj.marks = this.marks.map(n => n.toJSON()); return obj; } /** Deserialize a node from its JSON representation. */ static fromJSON(schema, json) { if (!json) throw new RangeError("Invalid input for Node.fromJSON"); let marks = null; if (json.marks) { if (!Array.isArray(json.marks)) throw new RangeError("Invalid mark data for Node.fromJSON"); marks = json.marks.map(schema.markFromJSON); } if (json.type == "text") { if (typeof json.text != "string") throw new RangeError("Invalid text node in JSON"); return schema.text(json.text, marks); } let content = Fragment.fromJSON(schema, json.content); return schema.nodeType(json.type).create(json.attrs, content, marks); } } Node.prototype.text = undefined; class TextNode extends Node { /** @internal */ constructor(type, attrs, content, marks) { super(type, attrs, null, marks); if (!content) throw new RangeError("Empty text nodes are not allowed"); this.text = content; } toString() { if (this.type.spec.toDebugString) return this.type.spec.toDebugString(this); return wrapMarks(this.marks, JSON.stringify(this.text)); } get textContent() { return this.text; } textBetween(from, to) { return this.text.slice(from, to); } get nodeSize() { return this.text.length; } mark(marks) { return marks == this.marks ? this : new TextNode(this.type, this.attrs, this.text, marks); } withText(text) { if (text == this.text) return this; return new TextNode(this.type, this.attrs, text, this.marks); } cut(from = 0, to = this.text.length) { if (from == 0 && to == this.text.length) return this; return this.withText(this.text.slice(from, to)); } eq(other) { return this.sameMarkup(other) && this.text == other.text; } toJSON() { let base = super.toJSON(); base.text = this.text; return base; } } function wrapMarks(marks, str) { for (let i = marks.length - 1; i >= 0; i--) str = marks[i].type.name + "(" + str + ")"; return str; } /** Instances of this class represent a match state of a node type's [content expression](https://prosemirror.net/docs/ref/#model.NodeSpec.content), and can be used to find out whether further content matches here, and whether a given position is a valid end of the node. */ class ContentMatch { /** @internal */ constructor( /** True when this match state represents a valid end of the node. */ validEnd) { this.validEnd = validEnd; /** @internal */ this.next = []; /** @internal */ this.wrapCache = []; } /** @internal */ static parse(string, nodeTypes) { let stream = new TokenStream(string, nodeTypes); if (stream.next == null) return ContentMatch.empty; let expr = parseExpr(stream); if (stream.next) stream.err("Unexpected trailing text"); let match = dfa(nfa(expr)); checkForDeadEnds(match, stream); return match; } /** Match a node type, returning a match after that node if successful. */ matchType(type) { for (let i = 0; i < this.next.length; i++) if (this.next[i].type == type) return this.next[i].next; return null; } /** Try to match a fragment. Returns the resulting match when successful. */ matchFragment(frag, start = 0, end = frag.childCount) { let cur = this; for (let i = start; cur && i < end; i++) cur = cur.matchType(frag.child(i).type); return cur; } /** @internal */ get inlineContent() { return this.next.length != 0 && this.next[0].type.isInline; } /** Get the first matching node type at this match position that can be generated. */ get defaultType() { for (let i = 0; i < this.next.length; i++) { let { type } = this.next[i]; if (!(type.isText || type.hasRequiredAttrs())) return type; } return null; } /** @internal */ compatible(other) { for (let i = 0; i < this.next.length; i++) for (let j = 0; j < other.next.length; j++) if (this.next[i].type == other.next[j].type) return true; return false; } /** Try to match the given fragment, and if that fails, see if it can be made to match by inserting nodes in front of it. When successful, return a fragment of inserted nodes (which may be empty if nothing had to be inserted). When `toEnd` is true, only return a fragment if the resulting match goes to the end of the content expression. */ fillBefore(after, toEnd = false, startIndex = 0) { let seen = [this]; function search(match, types) { let finished = match.matchFragment(after, startIndex); if (finished && (!toEnd || finished.validEnd)) return Fragment.from(types.map(tp => tp.createAndFill())); for (let i = 0; i < match.next.length; i++) { let { type, next } = match.next[i]; if (!(type.isText || type.hasRequiredAttrs()) && seen.indexOf(next) == -1) { seen.push(next); let found = search(next, types.concat(type)); if (found) return found; } } return null; } return search(this, []); } /** Find a set of wrapping node types that would allow a node of the given type to appear at this position. The result may be empty (when it fits directly) and will be null when no such wrapping exists. */ findWrapping(target) { for (let i = 0; i < this.wrapCache.length; i += 2) if (this.wrapCache[i] == target) return this.wrapCache[i + 1]; let computed = this.computeWrapping(target); this.wrapCache.push(target, computed); return computed; } /** @internal */ computeWrapping(target) { let seen = Object.create(null), active = [{ match: this, type: null, via: null }]; while (active.length) { let current = active.shift(), match = current.match; if (match.matchType(target)) { let result = []; for (let obj = current; obj.type; obj = obj.via) result.push(obj.type); return result.reverse(); } for (let i = 0; i < match.next.length; i++) { let { type, next } = match.next[i]; if (!type.isLeaf && !type.hasRequiredAttrs() && !(type.name in seen) && (!current.type || next.validEnd)) { active.push({ match: type.contentMatch, type, via: current }); seen[type.name] = true; } } } return null; } /** The number of outgoing edges this node has in the finite automaton that describes the content expression. */ get edgeCount() { return this.next.length; } /** Get the _n_​th outgoing edge from this node in the finite automaton that describes the content expression. */ edge(n) { if (n >= this.next.length) throw new RangeError(`There's no ${n}th edge in this content match`); return this.next[n]; } /** @internal */ toString() { let seen = []; function scan(m) { seen.push(m); for (let i = 0; i < m.next.length; i++) if (seen.indexOf(m.next[i].next) == -1) scan(m.next[i].next); } scan(this); return seen.map((m, i) => { let out = i + (m.validEnd ? "*" : " ") + " "; for (let i = 0; i < m.next.length; i++) out += (i ? ", " : "") + m.next[i].type.name + "->" + seen.indexOf(m.next[i].next); return out; }).join("\n"); } } /** @internal */ ContentMatch.empty = new ContentMatch(true); class TokenStream { constructor(string, nodeTypes) { this.string = string; this.nodeTypes = nodeTypes; this.inline = null; this.pos = 0; this.tokens = string.split(/\s*(?=\b|\W|$)/); if (this.tokens[this.tokens.length - 1] == "") this.tokens.pop(); if (this.tokens[0] == "") this.tokens.shift(); } get next() { return this.tokens[this.pos]; } eat(tok) { return this.next == tok && (this.pos++ || true); } err(str) { throw new SyntaxError(str + " (in content expression '" + this.string + "')"); } } function parseExpr(stream) { let exprs = []; do { exprs.push(parseExprSeq(stream)); } while (stream.eat("|")); return exprs.length == 1 ? exprs[0] : { type: "choice", exprs }; } function parseExprSeq(stream) { let exprs = []; do { exprs.push(parseExprSubscript(stream)); } while (stream.next && stream.next != ")" && stream.next != "|"); return exprs.length == 1 ? exprs[0] : { type: "seq", exprs }; } function parseExprSubscript(stream) { let expr = parseExprAtom(stream); for (;;) { if (stream.eat("+")) expr = { type: "plus", expr }; else if (stream.eat("*")) expr = { type: "star", expr }; else if (stream.eat("?")) expr = { type: "opt", expr }; else if (stream.eat("{")) expr = parseExprRange(stream, expr); else break; } return expr; } function parseNum(stream) { if (/\D/.test(stream.next)) stream.err("Expected number, got '" + stream.next + "'"); let result = Number(stream.next); stream.pos++; return result; } function parseExprRange(stream, expr) { let min = parseNum(stream), max = min; if (stream.eat(",")) { if (stream.next != "}") max = parseNum(stream); else max = -1; } if (!stream.eat("}")) stream.err("Unclosed braced range"); return { type: "range", min, max, expr }; } function resolveName(stream, name) { let types = stream.nodeTypes, type = types[name]; if (type) return [type]; let result = []; for (let typeName in types) { let type = types[typeName]; if (type.groups.indexOf(name) > -1) result.push(type); } if (result.length == 0) stream.err("No node type or group '" + name + "' found"); return result; } function parseExprAtom(stream) { if (stream.eat("(")) { let expr = parseExpr(stream); if (!stream.eat(")")) stream.err("Missing closing paren"); return expr; } else if (!/\W/.test(stream.next)) { let exprs = resolveName(stream, stream.next).map(type => { if (stream.inline == null) stream.inline = type.isInline; else if (stream.inline != type.isInline) stream.err("Mixing inline and block content"); return { type: "name", value: type }; }); stream.pos++; return exprs.length == 1 ? exprs[0] : { type: "choice", exprs }; } else { stream.err("Unexpected token '" + stream.next + "'"); } } /** Construct an NFA from an expression as returned by the parser. The NFA is represented as an array of states, which are themselves arrays of edges, which are `{term, to}` objects. The first state is the entry state and the last node is the success state. Note that unlike typical NFAs, the edge ordering in this one is significant, in that it is used to contruct filler content when necessary. */ function nfa(expr) { let nfa = [[]]; connect(compile(expr, 0), node()); return nfa; function node() { return nfa.push([]) - 1; } function edge(from, to, term) { let edge = { term, to }; nfa[from].push(edge); return edge; } function connect(edges, to) { edges.forEach(edge => edge.to = to); } function compile(expr, from) { if (expr.type == "choice") { return expr.exprs.reduce((out, expr) => out.concat(compile(expr, from)), []); } else if (expr.type == "seq") { for (let i = 0;; i++) { let next = compile(expr.exprs[i], from); if (i == expr.exprs.length - 1) return next; connect(next, from = node()); } } else if (expr.type == "star") { let loop = node(); edge(from, loop); connect(compile(expr.expr, loop), loop); return [edge(loop)]; } else if (expr.type == "plus") { let loop = node(); connect(compile(expr.expr, from), loop); connect(compile(expr.expr, loop), loop); return [edge(loop)]; } else if (expr.type == "opt") { return [edge(from)].concat(compile(expr.expr, from)); } else if (expr.type == "range") { let cur = from; for (let i = 0; i < expr.min; i++) { let next = node(); connect(compile(expr.expr, cur), next); cur = next; } if (expr.max == -1) { connect(compile(expr.expr, cur), cur); } else { for (let i = expr.min; i < expr.max; i++) { let next = node(); edge(cur, next); connect(compile(expr.expr, cur), next); cur = next; } } return [edge(cur)]; } else if (expr.type == "name") { return [edge(from, undefined, expr.value)]; } else { throw new Error("Unknown expr type"); } } } function cmp(a, b) { return b - a; } // Get the set of nodes reachable by null edges from `node`. Omit // nodes with only a single null-out-edge, since they may lead to // needless duplicated nodes. function nullFrom(nfa, node) { let result = []; scan(node); return result.sort(cmp); function scan(node) { let edges = nfa[node]; if (edges.length == 1 && !edges[0].term) return scan(edges[0].to); result.push(node); for (let i = 0; i < edges.length; i++) { let { term, to } = edges[i]; if (!term && result.indexOf(to) == -1) scan(to); } } } // Compiles an NFA as produced by `nfa` into a DFA, modeled as a set // of state objects (`ContentMatch` instances) with transitions // between them. function dfa(nfa) { let labeled = Object.create(null); return explore(nullFrom(nfa, 0)); function explore(states) { let out = []; states.forEach(node => { nfa[node].forEach(({ term, to }) => { if (!term) return; let set; for (let i = 0; i < out.length; i++) if (out[i][0] == term) set = out[i][1]; nullFrom(nfa, to).forEach(node => { if (!set) out.push([term, set = []]); if (set.indexOf(node) == -1) set.push(node); }); }); }); let state = labeled[states.join(",")] = new ContentMatch(states.indexOf(nfa.length - 1) > -1); for (let i = 0; i < out.length; i++) { let states = out[i][1].sort(cmp); state.next.push({ type: out[i][0], next: labeled[states.join(",")] || explore(states) }); } return state; } } function checkForDeadEnds(match, stream) { for (let i = 0, work = [match]; i < work.length; i++) { let state = work[i], dead = !state.validEnd, nodes = []; for (let j = 0; j < state.next.length; j++) { let { type, next } = state.next[j]; nodes.push(type.name); if (dead && !(type.isText || type.hasRequiredAttrs())) dead = false; if (work.indexOf(next) == -1) work.push(next); } if (dead) stream.err("Only non-generatable nodes (" + nodes.join(", ") + ") in a required position (see https://prosemirror.net/docs/guide/#generatable)"); } } // For node types where all attrs have a default value (or which don't // have any attributes), build up a single reusable default attribute // object, and use it for all nodes that don't specify specific // attributes. function defaultAttrs(attrs) { let defaults = Object.create(null); for (let attrName in attrs) { let attr = attrs[attrName]; if (!attr.hasDefault) return null; defaults[attrName] = attr.default; } return defaults; } function computeAttrs(attrs, value) { let built = Object.create(null); for (let name in attrs) { let given = value && value[name]; if (given === undefined) { let attr = attrs[name]; if (attr.hasDefault) given = attr.default; else throw new RangeError("No value supplied for attribute " + name); } built[name] = given; } return built; } function initAttrs(attrs) { let result = Object.create(null); if (attrs) for (let name in attrs) result[name] = new Attribute(attrs[name]); return result; } /** Node types are objects allocated once per `Schema` and used to [tag](https://prosemirror.net/docs/ref/#model.Node.type) `Node` instances. They contain information about the node type, such as its name and what kind of node it represents. */ let NodeType$1 = class NodeType { /** @internal */ constructor( /** The name the node type has in this schema. */ name, /** A link back to the `Schema` the node type belongs to. */ schema, /** The spec that this type is based on */ spec) { this.name = name; this.schema = schema; this.spec = spec; /** The set of marks allowed in this node. `null` means all marks are allowed. */ this.markSet = null; this.groups = spec.group ? spec.group.split(" ") : []; this.attrs = initAttrs(spec.attrs); this.defaultAttrs = defaultAttrs(this.attrs); this.contentMatch = null; this.inlineContent = null; this.isBlock = !(spec.inline || name == "text"); this.isText = name == "text"; } /** True if this is an inline type. */ get isInline() { return !this.isBlock; } /** True if this is a textblock type, a block that contains inline content. */ get isTextblock() { return this.isBlock && this.inlineContent; } /** True for node types that allow no content. */ get isLeaf() { return this.contentMatch == ContentMatch.empty; } /** True when this node is an atom, i.e. when it does not have directly editable content. */ get isAtom() { return this.isLeaf || !!this.spec.atom; } /** The node type's [whitespace](https://prosemirror.net/docs/ref/#model.NodeSpec.whitespace) option. */ get whitespace() { return this.spec.whitespace || (this.spec.code ? "pre" : "normal"); } /** Tells you whether this node type has any required attributes. */ hasRequiredAttrs() { for (let n in this.attrs) if (this.attrs[n].isRequired) return true; return false; } /** Indicates whether this node allows some of the same content as the given node type. */ compatibleContent(other) { return this == other || this.contentMatch.compatible(other.contentMatch); } /** @internal */ computeAttrs(attrs) { if (!attrs && this.defaultAttrs) return this.defaultAttrs; else return computeAttrs(this.attrs, attrs); } /** Create a `Node` of this type. The given attributes are checked and defaulted (you can pass `null` to use the type's defaults entirely, if no required attributes exist). `content` may be a `Fragment`, a node, an array of nodes, or `null`. Similarly `marks` may be `null` to default to the empty set of marks. */ create(attrs = null, content, marks) { if (this.isText) throw new Error("NodeType.create can't construct text nodes"); return new Node(this, this.computeAttrs(attrs), Fragment.from(content), Mark.setFrom(marks)); } /** Like [`create`](https://prosemirror.net/docs/ref/#model.NodeType.create), but check the given content against the node type's content restrictions, and throw an error if it doesn't match. */ createChecked(attrs = null, content, marks) { content = Fragment.from(content); this.checkContent(content); return new Node(this, this.computeAttrs(attrs), content, Mark.setFrom(marks)); } /** Like [`create`](https://prosemirror.net/docs/ref/#model.NodeType.create), but see if it is necessary to add nodes to the start or end of the given fragment to make it fit the node. If no fitting wrapping can be found, return null. Note that, due to the fact that required nodes can always be created, this will always succeed if you pass null or `Fragment.empty` as content. */ createAndFill(attrs = null, content, marks) { attrs = this.computeAttrs(attrs); content = Fragment.from(content); if (content.size) { let before = this.contentMatch.fillBefore(content); if (!before) return null; content = before.append(content); } let matched = this.contentMatch.matchFragment(content); let after = matched && matched.fillBefore(Fragment.empty, true); if (!after) return null; return new Node(this, attrs, content.append(after), Mark.setFrom(marks)); } /** Returns true if the given fragment is valid content for this node type with the given attributes. */ validContent(content) { let result = this.contentMatch.matchFragment(content); if (!result || !result.validEnd) return false; for (let i = 0; i < content.childCount; i++) if (!this.allowsMarks(content.child(i).marks)) return false; return true; } /** Throws a RangeError if the given fragment is not valid content for this node type. @internal */ checkContent(content) { if (!this.validContent(content)) throw new RangeError(`Invalid content for node ${this.name}: ${content.toString().slice(0, 50)}`); } /** Check whether the given mark type is allowed in this node. */ allowsMarkType(markType) { return this.markSet == null || this.markSet.indexOf(markType) > -1; } /** Test whether the given set of marks are allowed in this node. */ allowsMarks(marks) { if (this.markSet == null) return true; for (let i = 0; i < marks.length; i++) if (!this.allowsMarkType(marks[i].type)) return false; return true; } /** Removes the marks that are not allowed in this node from the given set. */ allowedMarks(marks) { if (this.markSet == null) return marks; let copy; for (let i = 0; i < marks.length; i++) { if (!this.allowsMarkType(marks[i].type)) { if (!copy) copy = marks.slice(0, i); } else if (copy) { copy.push(marks[i]); } } return !copy ? marks : copy.length ? copy : Mark.none; } /** @internal */ static compile(nodes, schema) { let result = Object.create(null); nodes.forEach((name, spec) => result[name] = new NodeType(name, schema, spec)); let topType = schema.spec.topNode || "doc"; if (!result[topType]) throw new RangeError("Schema is missing its top node type ('" + topType + "')"); if (!result.text) throw new RangeError("Every schema needs a 'text' type"); for (let _ in result.text.attrs) throw new RangeError("The text node type should not have attributes"); return result; } }; // Attribute descriptors class Attribute { constructor(options) { this.hasDefault = Object.prototype.hasOwnProperty.call(options, "default"); this.default = options.default; } get isRequired() { return !this.hasDefault; } } // Marks /** Like nodes, marks (which are associated with nodes to signify things like emphasis or being part of a link) are [tagged](https://prosemirror.net/docs/ref/#model.Mark.type) with type objects, which are instantiated once per `Schema`. */ class MarkType { /** @internal */ constructor( /** The name of the mark type. */ name, /** @internal */ rank, /** The schema that this mark type instance is part of. */ schema, /** The spec on which the type is based. */ spec) { this.name = name; this.rank = rank; this.schema = schema; this.spec = spec; this.attrs = initAttrs(spec.attrs); this.excluded = null; let defaults = defaultAttrs(this.attrs); this.instance = defaults ? new Mark(this, defaults) : null; } /** Create a mark of this type. `attrs` may be `null` or an object containing only some of the mark's attributes. The others, if they have defaults, will be added. */ create(attrs = null) { if (!attrs && this.instance) return this.instance; return new Mark(this, computeAttrs(this.attrs, attrs)); } /** @internal */ static compile(marks, schema) { let result = Object.create(null), rank = 0; marks.forEach((name, spec) => result[name] = new MarkType(name, rank++, schema, spec)); return result; } /** When there is a mark of this type in the given set, a new set without it is returned. Otherwise, the input set is returned. */ removeFromSet(set) { for (var i = 0; i < set.length; i++) if (set[i].type == this) { set = set.slice(0, i).concat(set.slice(i + 1)); i--; } return set; } /** Tests whether there is a mark of this type in the given set. */ isInSet(set) { for (let i = 0; i < set.length; i++) if (set[i].type == this) return set[i]; } /** Queries whether a given mark type is [excluded](https://prosemirror.net/docs/ref/#model.MarkSpec.excludes) by this one. */ excludes(other) { return this.excluded.indexOf(other) > -1; } } /** A document schema. Holds [node](https://prosemirror.net/docs/ref/#model.NodeType) and [mark type](https://prosemirror.net/docs/ref/#model.MarkType) objects for the nodes and marks that may occur in conforming documents, and provides functionality for creating and deserializing such documents. When given, the type parameters provide the names of the nodes and marks in this schema. */ class Schema { /** Construct a schema from a schema [specification](https://prosemirror.net/docs/ref/#model.SchemaSpec). */ constructor(spec) { /** The [linebreak replacement](https://prosemirror.net/docs/ref/#model.NodeSpec.linebreakReplacement) node defined in this schema, if any. */ this.linebreakReplacement = null; /** An object for storing whatever values modules may want to compute and cache per schema. (If you want to store something in it, try to use property names unlikely to clash.) */ this.cached = Object.create(null); let instanceSpec = this.spec = {}; for (let prop in spec) instanceSpec[prop] = spec[prop]; instanceSpec.nodes = OrderedMap.from(spec.nodes), instanceSpec.marks = OrderedMap.from(spec.marks || {}), this.nodes = NodeType$1.compile(this.spec.nodes, this); this.marks = MarkType.compile(this.spec.marks, this); let contentExprCache = Object.create(null); for (let prop in this.nodes) { if (prop in this.marks) throw new RangeError(prop + " can not be both a node and a mark"); let type = this.nodes[prop], contentExpr = type.spec.content || "", markExpr = type.spec.marks; type.contentMatch = contentExprCache[contentExpr] || (contentExprCache[contentExpr] = ContentMatch.parse(contentExpr, this.nodes)); type.inlineContent = type.contentMatch.inlineContent; if (type.spec.linebreakReplacement) { if (this.linebreakReplacement) throw new RangeError("Multiple linebreak nodes defined"); if (!type.isInline || !type.isLeaf) throw new RangeError("Linebreak replacement nodes must be inline leaf nodes"); this.linebreakReplacement = type; } type.markSet = markExpr == "_" ? null : markExpr ? gatherMarks(this, markExpr.split(" ")) : markExpr == "" || !type.inlineContent ? [] : null; } for (let prop in this.marks) { let type = this.marks[prop], excl = type.spec.excludes; type.excluded = excl == null ? [type] : excl == "" ? [] : gatherMarks(this, excl.split(" ")); } this.nodeFromJSON = this.nodeFromJSON.bind(this); this.markFromJSON = this.markFromJSON.bind(this); this.topNodeType = this.nodes[this.spec.topNode || "doc"]; this.cached.wrappings = Object.create(null); } /** Create a node in this schema. The `type` may be a string or a `NodeType` instance. Attributes will be extended with defaults, `content` may be a `Fragment`, `null`, a `Node`, or an array of nodes. */ node(type, attrs = null, content, marks) { if (typeof type == "string") type = this.nodeType(type); else if (!(type instanceof NodeType$1)) throw new RangeError("Invalid node type: " + type); else if (type.schema != this) throw new RangeError("Node type from different schema used (" + type.name + ")"); return type.createChecked(attrs, content, marks); } /** Create a text node in the schema. Empty text nodes are not allowed. */ text(text, marks) { let type = this.nodes.text; return new TextNode(type, type.defaultAttrs, text, Mark.setFrom(marks)); } /** Create a mark with the given type and attributes. */ mark(type, attrs) { if (typeof type == "string") type = this.marks[type]; return type.create(attrs); } /** Deserialize a node from its JSON representation. This method is bound. */ nodeFromJSON(json) { return Node.fromJSON(this, json); } /** Deserialize a mark from its JSON representation. This method is bound. */ markFromJSON(json) { return Mark.fromJSON(this, json); } /** @internal */ nodeType(name) { let found = this.nodes[name]; if (!found) throw new RangeError("Unknown node type: " + name); return found; } } function gatherMarks(schema, marks) { let found = []; for (let i = 0; i < marks.length; i++) { let name = marks[i], mark = schema.marks[name], ok = mark; if (mark) { found.push(mark); } else { for (let prop in schema.marks) { let mark = schema.marks[prop]; if (name == "_" || (mark.spec.group && mark.spec.group.split(" ").indexOf(name) > -1)) found.push(ok = mark); } } if (!ok) throw new SyntaxError("Unknown mark type: '" + marks[i] + "'"); } return found; } function isTagRule(rule) { return rule.tag != null; } function isStyleRule(rule) { return rule.style != null; } /** A DOM parser represents a strategy for parsing DOM content into a ProseMirror document conforming to a given schema. Its behavior is defined by an array of [rules](https://prosemirror.net/docs/ref/#model.ParseRule). */ let DOMParser$1 = class DOMParser { /** Create a parser that targets the given schema, using the given parsing rules. */ constructor( /** The schema into which the parser parses. */ schema, /** The set of [parse rules](https://prosemirror.net/docs/ref/#model.ParseRule) that the parser uses, in order of precedence. */ rules) { this.schema = schema; this.rules = rules; /** @internal */ this.tags = []; /** @internal */ this.styles = []; rules.forEach(rule => { if (isTagRule(rule)) this.tags.push(rule); else if (isStyleRule(rule)) this.styles.push(rule); }); // Only normalize list elements when lists in the schema can't directly contain themselves this.normalizeLists = !this.tags.some(r => { if (!/^(ul|ol)\b/.test(r.tag) || !r.node) return false; let node = schema.nodes[r.node]; return node.contentMatch.matchType(node); }); } /** Parse a document from the content of a DOM node. */ parse(dom, options = {}) { let context = new ParseContext(this, options, false); context.addAll(dom, options.from, options.to); return context.finish(); } /** Parses the content of the given DOM node, like [`parse`](https://prosemirror.net/docs/ref/#model.DOMParser.parse), and takes the same set of options. But unlike that method, which produces a whole node, this one returns a slice that is open at the sides, meaning that the schema constraints aren't applied to the start of nodes to the left of the input and the end of nodes at the end. */ parseSlice(dom, options = {}) { let context = new ParseContext(this, options, true); context.addAll(dom, options.from, options.to); return Slice.maxOpen(context.finish()); } /** @internal */ matchTag(dom, context, after) { for (let i = after ? this.tags.indexOf(after) + 1 : 0; i < this.tags.length; i++) { let rule = this.tags[i]; if (matches(dom, rule.tag) && (rule.namespace === undefined || dom.namespaceURI == rule.namespace) && (!rule.context || context.matchesContext(rule.context))) { if (rule.getAttrs) { let result = rule.getAttrs(dom); if (result === false) continue; rule.attrs = result || undefined; } return rule; } } } /** @internal */ matchStyle(prop, value, context, after) { for (let i = after ? this.styles.indexOf(after) + 1 : 0; i < this.styles.length; i++) { let rule = this.styles[i], style = rule.style; if (style.indexOf(prop) != 0 || rule.context && !context.matchesContext(rule.context) || // Test that the style string either precisely matches the prop, // or has an '=' sign after the prop, followed by the given // value. style.length > prop.length && (style.charCodeAt(prop.length) != 61 || style.slice(prop.length + 1) != value)) continue; if (rule.getAttrs) { let result = rule.getAttrs(value); if (result === false) continue; rule.attrs = result || undefined; } return rule; } } /** @internal */ static schemaRules(schema) { let result = []; function insert(rule) { let priority = rule.priority == null ? 50 : rule.priority, i = 0; for (; i < result.length; i++) { let next = result[i], nextPriority = next.priority == null ? 50 : next.priority; if (nextPriority < priority) break; } result.splice(i, 0, rule); } for (let name in schema.marks) { let rules = schema.marks[name].spec.parseDOM; if (rules) rules.forEach(rule => { insert(rule = copy(rule)); if (!(rule.mark || rule.ignore || rule.clearMark)) rule.mark = name; }); } for (let name in schema.nodes) { let rules = schema.nodes[name].spec.parseDOM; if (rules) rules.forEach(rule => { insert(rule = copy(rule)); if (!(rule.node || rule.ignore || rule.mark)) rule.node = name; }); } return result; } /** Construct a DOM parser using the parsing rules listed in a schema's [node specs](https://prosemirror.net/docs/ref/#model.NodeSpec.parseDOM), reordered by [priority](https://prosemirror.net/docs/ref/#model.ParseRule.priority). */ static fromSchema(schema) { return schema.cached.domParser || (schema.cached.domParser = new DOMParser(schema, DOMParser.schemaRules(schema))); } }; const blockTags = { address: true, article: true, aside: true, blockquote: true, canvas: true, dd: true, div: true, dl: true, fieldset: true, figcaption: true, figure: true, footer: true, form: true, h1: true, h2: true, h3: true, h4: true, h5: true, h6: true, header: true, hgroup: true, hr: true, li: true, noscript: true, ol: true, output: true, p: true, pre: true, section: true, table: true, tfoot: true, ul: true }; const ignoreTags = { head: true, noscript: true, object: true, script: true, style: true, title: true }; const listTags = { ol: true, ul: true }; // Using a bitfield for node context options const OPT_PRESERVE_WS = 1, OPT_PRESERVE_WS_FULL = 2, OPT_OPEN_LEFT = 4; function wsOptionsFor(type, preserveWhitespace, base) { if (preserveWhitespace != null) return (preserveWhitespace ? OPT_PRESERVE_WS : 0) | (preserveWhitespace === "full" ? OPT_PRESERVE_WS_FULL : 0); return type && type.whitespace == "pre" ? OPT_PRESERVE_WS | OPT_PRESERVE_WS_FULL : base & ~OPT_OPEN_LEFT; } class NodeContext { constructor(type, attrs, // Marks applied to this node itself marks, // Marks that can't apply here, but will be used in children if possible pendingMarks, solid, match, options) { this.type = type; this.attrs = attrs; this.marks = marks; this.pendingMarks = pendingMarks; this.solid = solid; this.options = options; this.content = []; // Marks applied to the node's children this.activeMarks = Mark.none; // Nested Marks with same type this.stashMarks = []; this.match = match || (options & OPT_OPEN_LEFT ? null : type.contentMatch); } findWrapping(node) { if (!this.match) { if (!this.type) return []; let fill = this.type.contentMatch.fillBefore(Fragment.from(node)); if (fill) { this.match = this.type.contentMatch.matchFragment(fill); } else { let start = this.type.contentMatch, wrap; if (wrap = start.findWrapping(node.type)) { this.match = start; return wrap; } else { return null; } } } return this.match.findWrapping(node.type); } finish(openEnd) { if (!(this.options & OPT_PRESERVE_WS)) { // Strip trailing whitespace let last = this.content[this.content.length - 1], m; if (last && last.isText && (m = /[ \t\r\n\u000c]+$/.exec(last.text))) { let text = last; if (last.text.length == m[0].length) this.content.pop(); else this.content[this.content.length - 1] = text.withText(text.text.slice(0, text.text.length - m[0].length)); } } let content = Fragment.from(this.content); if (!openEnd && this.match) content = content.append(this.match.fillBefore(Fragment.empty, true)); return this.type ? this.type.create(this.attrs, content, this.marks) : content; } popFromStashMark(mark) { for (let i = this.stashMarks.length - 1; i >= 0; i--) if (mark.eq(this.stashMarks[i])) return this.stashMarks.splice(i, 1)[0]; } applyPending(nextType) { for (let i = 0, pending = this.pendingMarks; i < pending.length; i++) { let mark = pending[i]; if ((this.type ? this.type.allowsMarkType(mark.type) : markMayApply(mark.type, nextType)) && !mark.isInSet(this.activeMarks)) { this.activeMarks = mark.addToSet(this.activeMarks); this.pendingMarks = mark.removeFromSet(this.pendingMarks); } } } inlineContext(node) { if (this.type) return this.type.inlineContent; if (this.content.length) return this.content[0].isInline; return node.parentNode && !blockTags.hasOwnProperty(node.parentNode.nodeName.toLowerCase()); } } class ParseContext { constructor( // The parser we are using. parser, // The options passed to this parse. options, isOpen) { this.parser = parser; this.options = options; this.isOpen = isOpen; this.open = 0; let topNode = options.topNode, topContext; let topOptions = wsOptionsFor(null, options.preserveWhitespace, 0) | (isOpen ? OPT_OPEN_LEFT : 0); if (topNode) topContext = new NodeContext(topNode.type, topNode.attrs, Mark.none, Mark.none, true, options.topMatch || topNode.type.contentMatch, topOptions); else if (isOpen) topContext = new NodeContext(null, null, Mark.none, Mark.none, true, null, topOptions); else topContext = new NodeContext(parser.schema.topNodeType, null, Mark.none, Mark.none, true, null, topOptions); this.nodes = [topContext]; this.find = options.findPositions; this.needsBlock = false; } get top() { return this.nodes[this.open]; } // Add a DOM node to the content. Text is inserted as text node, // otherwise, the node is passed to `addElement` or, if it has a // `style` attribute, `addElementWithStyles`. addDOM(dom) { if (dom.nodeType == 3) this.addTextNode(dom); else if (dom.nodeType == 1) this.addElement(dom); } withStyleRules(dom, f) { let style = dom.getAttribute("style"); if (!style) return f(); let marks = this.readStyles(parseStyles(style)); if (!marks) return; // A style with ignore: true let [addMarks, removeMarks] = marks, top = this.top; for (let i = 0; i < removeMarks.length; i++) this.removePendingMark(removeMarks[i], top); for (let i = 0; i < addMarks.length; i++) this.addPendingMark(addMarks[i]); f(); for (let i = 0; i < addMarks.length; i++) this.removePendingMark(addMarks[i], top); for (let i = 0; i < removeMarks.length; i++) this.addPendingMark(removeMarks[i]); } addTextNode(dom) { let value = dom.nodeValue; let top = this.top; if (top.options & OPT_PRESERVE_WS_FULL || top.inlineContext(dom) || /[^ \t\r\n\u000c]/.test(value)) { if (!(top.options & OPT_PRESERVE_WS)) { value = value.replace(/[ \t\r\n\u000c]+/g, " "); // If this starts with whitespace, and there is no node before it, or // a hard break, or a text node that ends with whitespace, strip the // leading space. if (/^[ \t\r\n\u000c]/.test(value) && this.open == this.nodes.length - 1) { let nodeBefore = top.content[top.content.length - 1]; let domNodeBefore = dom.previousSibling; if (!nodeBefore || (domNodeBefore && domNodeBefore.nodeName == 'BR') || (nodeBefore.isText && /[ \t\r\n\u000c]$/.test(nodeBefore.text))) value = value.slice(1); } } else if (!(top.options & OPT_PRESERVE_WS_FULL)) { value = value.replace(/\r?\n|\r/g, " "); } else { value = value.replace(/\r\n?/g, "\n"); } if (value) this.insertNode(this.parser.schema.text(value)); this.findInText(dom); } else { this.findInside(dom); } } // Try to find a handler for the given tag and use that to parse. If // none is found, the element's content nodes are added directly. addElement(dom, matchAfter) { let name = dom.nodeName.toLowerCase(), ruleID; if (listTags.hasOwnProperty(name) && this.parser.normalizeLists) normalizeList(dom); let rule = (this.options.ruleFromNode && this.options.ruleFromNode(dom)) || (ruleID = this.parser.matchTag(dom, this, matchAfter)); if (rule ? rule.ignore : ignoreTags.hasOwnProperty(name)) { this.findInside(dom); this.ignoreFallback(dom); } else if (!rule || rule.skip || rule.closeParent) { if (rule && rule.closeParent) this.open = Math.max(0, this.open - 1); else if (rule && rule.skip.nodeType) dom = rule.skip; let sync, top = this.top, oldNeedsBlock = this.needsBlock; if (blockTags.hasOwnProperty(name)) { if (top.content.length && top.content[0].isInline && this.open) { this.open--; top = this.top; } sync = true; if (!top.type) this.needsBlock = true; } else if (!dom.firstChild) { this.leafFallback(dom); return; } if (rule && rule.skip) this.addAll(dom); else this.withStyleRules(dom, () => this.addAll(dom)); if (sync) this.sync(top); this.needsBlock = oldNeedsBlock; } else { this.withStyleRules(dom, () => { this.addElementByRule(dom, rule, rule.consuming === false ? ruleID : undefined); }); } } // Called for leaf DOM nodes that would otherwise be ignored leafFallback(dom) { if (dom.nodeName == "BR" && this.top.type && this.top.type.inlineContent) this.addTextNode(dom.ownerDocument.createTextNode("\n")); } // Called for ignored nodes ignoreFallback(dom) { // Ignored BR nodes should at least create an inline context if (dom.nodeName == "BR" && (!this.top.type || !this.top.type.inlineContent)) this.findPlace(this.parser.schema.text("-")); } // Run any style parser associated with the node's styles. Either // return an array of marks, or null to indicate some of the styles // had a rule with `ignore` set. readStyles(styles) { let add = Mark.none, remove = Mark.none; for (let i = 0; i < styles.length; i += 2) { for (let after = undefined;;) { let rule = this.parser.matchStyle(styles[i], styles[i + 1], this, after); if (!rule) break; if (rule.ignore) return null; if (rule.clearMark) { this.top.pendingMarks.concat(this.top.activeMarks).forEach(m => { if (rule.clearMark(m)) remove = m.addToSet(remove); }); } else { add = this.parser.schema.marks[rule.mark].create(rule.attrs).addToSet(add); } if (rule.consuming === false) after = rule; else break; } } return [add, remove]; } // Look up a handler for the given node. If none are found, return // false. Otherwise, apply it, use its return value to drive the way // the node's content is wrapped, and return true. addElementByRule(dom, rule, continueAfter) { let sync, nodeType, mark; if (rule.node) { nodeType = this.parser.schema.nodes[rule.node]; if (!nodeType.isLeaf) { sync = this.enter(nodeType, rule.attrs || null, rule.preserveWhitespace); } else if (!this.insertNode(nodeType.create(rule.attrs))) { this.leafFallback(dom); } } else { let markType = this.parser.schema.marks[rule.mark]; mark = markType.create(rule.attrs); this.addPendingMark(mark); } let startIn = this.top; if (nodeType && nodeType.isLeaf) { this.findInside(dom); } else if (continueAfter) { this.addElement(dom, continueAfter); } else if (rule.getContent) { this.findInside(dom); rule.getContent(dom, this.parser.schema).forEach(node => this.insertNode(node)); } else { let contentDOM = dom; if (typeof rule.contentElement == "string") contentDOM = dom.querySelector(rule.contentElement); else if (typeof rule.contentElement == "function") contentDOM = rule.contentElement(dom); else if (rule.contentElement) contentDOM = rule.contentElement; this.findAround(dom, contentDOM, true); this.addAll(contentDOM); } if (sync && this.sync(startIn)) this.open--; if (mark) this.removePendingMark(mark, startIn); } // Add all child nodes between `startIndex` and `endIndex` (or the // whole node, if not given). If `sync` is passed, use it to // synchronize after every block element. addAll(parent, startIndex, endIndex) { let index = startIndex || 0; for (let dom = startIndex ? parent.childNodes[startIndex] : parent.firstChild, end = endIndex == null ? null : parent.childNodes[endIndex]; dom != end; dom = dom.nextSibling, ++index) { this.findAtPoint(parent, index); this.addDOM(dom); } this.findAtPoint(parent, index); } // Try to find a way to fit the given node type into the current // context. May add intermediate wrappers and/or leave non-solid // nodes that we're in. findPlace(node) { let route, sync; for (let depth = this.open; depth >= 0; depth--) { let cx = this.nodes[depth]; let found = cx.findWrapping(node); if (found && (!route || route.length > found.length)) { route = found; sync = cx; if (!found.length) break; } if (cx.solid) break; } if (!route) return false; this.sync(sync); for (let i = 0; i < route.length; i++) this.enterInner(route[i], null, false); return true; } // Try to insert the given node, adjusting the context when needed. insertNode(node) { if (node.isInline && this.needsBlock && !this.top.type) { let block = this.textblockFromContext(); if (block) this.enterInner(block); } if (this.findPlace(node)) { this.closeExtra(); let top = this.top; top.applyPending(node.type); if (top.match) top.match = top.match.matchType(node.type); let marks = top.activeMarks; for (let i = 0; i < node.marks.length; i++) if (!top.type || top.type.allowsMarkType(node.marks[i].type)) marks = node.marks[i].addToSet(marks); top.content.push(node.mark(marks)); return true; } return false; } // Try to start a node of the given type, adjusting the context when // necessary. enter(type, attrs, preserveWS) { let ok = this.findPlace(type.create(attrs)); if (ok) this.enterInner(type, attrs, true, preserveWS); return ok; } // Open a node of the given type enterInner(type, attrs = null, solid = false, preserveWS) { this.closeExtra(); let top = this.top; top.applyPending(type); top.match = top.match && top.match.matchType(type); let options = wsOptionsFor(type, preserveWS, top.options); if ((top.options & OPT_OPEN_LEFT) && top.content.length == 0) options |= OPT_OPEN_LEFT; this.nodes.push(new NodeContext(type, attrs, top.activeMarks, top.pendingMarks, solid, null, options)); this.open++; } // Make sure all nodes above this.open are finished and added to // their parents closeExtra(openEnd = false) { let i = this.nodes.length - 1; if (i > this.open) { for (; i > this.open; i--) this.nodes[i - 1].content.push(this.nodes[i].finish(openEnd)); this.nodes.length = this.open + 1; } } finish() { this.open = 0; this.closeExtra(this.isOpen); return this.nodes[0].finish(this.isOpen || this.options.topOpen); } sync(to) { for (let i = this.open; i >= 0; i--) if (this.nodes[i] == to) { this.open = i; return true; } return false; } get currentPos() { this.closeExtra(); let pos = 0; for (let i = this.open; i >= 0; i--) { let content = this.nodes[i].content; for (let j = content.length - 1; j >= 0; j--) pos += content[j].nodeSize; if (i) pos++; } return pos; } findAtPoint(parent, offset) { if (this.find) for (let i = 0; i < this.find.length; i++) { if (this.find[i].node == parent && this.find[i].offset == offset) this.find[i].pos = this.currentPos; } } findInside(parent) { if (this.find) for (let i = 0; i < this.find.length; i++) { if (this.find[i].pos == null && parent.nodeType == 1 && parent.contains(this.find[i].node)) this.find[i].pos = this.currentPos; } } findAround(parent, content, before) { if (parent != content && this.find) for (let i = 0; i < this.find.length; i++) { if (this.find[i].pos == null && parent.nodeType == 1 && parent.contains(this.find[i].node)) { let pos = content.compareDocumentPosition(this.find[i].node); if (pos & (before ? 2 : 4)) this.find[i].pos = this.currentPos; } } } findInText(textNode) { if (this.find) for (let i = 0; i < this.find.length; i++) { if (this.find[i].node == textNode) this.find[i].pos = this.currentPos - (textNode.nodeValue.length - this.find[i].offset); } } // Determines whether the given context string matches this context. matchesContext(context) { if (context.indexOf("|") > -1) return context.split(/\s*\|\s*/).some(this.matchesContext, this); let parts = context.split("/"); let option = this.options.context; let useRoot = !this.isOpen && (!option || option.parent.type == this.nodes[0].type); let minDepth = -(option ? option.depth + 1 : 0) + (useRoot ? 0 : 1); let match = (i, depth) => { for (; i >= 0; i--) { let part = parts[i]; if (part == "") { if (i == parts.length - 1 || i == 0) continue; for (; depth >= minDepth; depth--) if (match(i - 1, depth)) return true; return false; } else { let next = depth > 0 || (depth == 0 && useRoot) ? this.nodes[depth].type : option && depth >= minDepth ? option.node(depth - minDepth).type : null; if (!next || (next.name != part && next.groups.indexOf(part) == -1)) return false; depth--; } } return true; }; return match(parts.length - 1, this.open); } textblockFromContext() { let $context = this.options.context; if ($context) for (let d = $context.depth; d >= 0; d--) { let deflt = $context.node(d).contentMatchAt($context.indexAfter(d)).defaultType; if (deflt && deflt.isTextblock && deflt.defaultAttrs) return deflt; } for (let name in this.parser.schema.nodes) { let type = this.parser.schema.nodes[name]; if (type.isTextblock && type.defaultAttrs) return type; } } addPendingMark(mark) { let found = findSameMarkInSet(mark, this.top.pendingMarks); if (found) this.top.stashMarks.push(found); this.top.pendingMarks = mark.addToSet(this.top.pendingMarks); } removePendingMark(mark, upto) { for (let depth = this.open; depth >= 0; depth--) { let level = this.nodes[depth]; let found = level.pendingMarks.lastIndexOf(mark); if (found > -1) { level.pendingMarks = mark.removeFromSet(level.pendingMarks); } else { level.activeMarks = mark.removeFromSet(level.activeMarks); let stashMark = level.popFromStashMark(mark); if (stashMark && level.type && level.type.allowsMarkType(stashMark.type)) level.activeMarks = stashMark.addToSet(level.activeMarks); } if (level == upto) break; } } } // Kludge to work around directly nested list nodes produced by some // tools and allowed by browsers to mean that the nested list is // actually part of the list item above it. function normalizeList(dom) { for (let child = dom.firstChild, prevItem = null; child; child = child.nextSibling) { let name = child.nodeType == 1 ? child.nodeName.toLowerCase() : null; if (name && listTags.hasOwnProperty(name) && prevItem) { prevItem.appendChild(child); child = prevItem; } else if (name == "li") { prevItem = child; } else if (name) { prevItem = null; } } } // Apply a CSS selector. function matches(dom, selector) { return (dom.matches || dom.msMatchesSelector || dom.webkitMatchesSelector || dom.mozMatchesSelector).call(dom, selector); } // Tokenize a style attribute into property/value pairs. function parseStyles(style) { let re = /\s*([\w-]+)\s*:\s*([^;]+)/g, m, result = []; while (m = re.exec(style)) result.push(m[1], m[2].trim()); return result; } function copy(obj) { let copy = {}; for (let prop in obj) copy[prop] = obj[prop]; return copy; } // Used when finding a mark at the top level of a fragment parse. // Checks whether it would be reasonable to apply a given mark type to // a given node, by looking at the way the mark occurs in the schema. function markMayApply(markType, nodeType) { let nodes = nodeType.schema.nodes; for (let name in nodes) { let parent = nodes[name]; if (!parent.allowsMarkType(markType)) continue; let seen = [], scan = (match) => { seen.push(match); for (let i = 0; i < match.edgeCount; i++) { let { type, next } = match.edge(i); if (type == nodeType) return true; if (seen.indexOf(next) < 0 && scan(next)) return true; } }; if (scan(parent.contentMatch)) return true; } } function findSameMarkInSet(mark, set) { for (let i = 0; i < set.length; i++) { if (mark.eq(set[i])) return set[i]; } } /** A DOM serializer knows how to convert ProseMirror nodes and marks of various types to DOM nodes. */ class DOMSerializer { /** Create a serializer. `nodes` should map node names to functions that take a node and return a description of the corresponding DOM. `marks` does the same for mark names, but also gets an argument that tells it whether the mark's content is block or inline content (for typical use, it'll always be inline). A mark serializer may be `null` to indicate that marks of that type should not be serialized. */ constructor( /** The node serialization functions. */ nodes, /** The mark serialization functions. */ marks) { this.nodes = nodes; this.marks = marks; } /** Serialize the content of this fragment to a DOM fragment. When not in the browser, the `document` option, containing a DOM document, should be passed so that the serializer can create nodes. */ serializeFragment(fragment, options = {}, target) { if (!target) target = doc$2(options).createDocumentFragment(); let top = target, active = []; fragment.forEach(node => { if (active.length || node.marks.length) { let keep = 0, rendered = 0; while (keep < active.length && rendered < node.marks.length) { let next = node.marks[rendered]; if (!this.marks[next.type.name]) { rendered++; continue; } if (!next.eq(active[keep][0]) || next.type.spec.spanning === false) break; keep++; rendered++; } while (keep < active.length) top = active.pop()[1]; while (rendered < node.marks.length) { let add = node.marks[rendered++]; let markDOM = this.serializeMark(add, node.isInline, options); if (markDOM) { active.push([add, top]); top.appendChild(markDOM.dom); top = markDOM.contentDOM || markDOM.dom; } } } top.appendChild(this.serializeNodeInner(node, options)); }); return target; } /** @internal */ serializeNodeInner(node, options) { let { dom, contentDOM } = DOMSerializer.renderSpec(doc$2(options), this.nodes[node.type.name](node)); if (contentDOM) { if (node.isLeaf) throw new RangeError("Content hole not allowed in a leaf node spec"); this.serializeFragment(node.content, options, contentDOM); } return dom; } /** Serialize this node to a DOM node. This can be useful when you need to serialize a part of a document, as opposed to the whole document. To serialize a whole document, use [`serializeFragment`](https://prosemirror.net/docs/ref/#model.DOMSerializer.serializeFragment) on its [content](https://prosemirror.net/docs/ref/#model.Node.content). */ serializeNode(node, options = {}) { let dom = this.serializeNodeInner(node, options); for (let i = node.marks.length - 1; i >= 0; i--) { let wrap = this.serializeMark(node.marks[i], node.isInline, options); if (wrap) { (wrap.contentDOM || wrap.dom).appendChild(dom); dom = wrap.dom; } } return dom; } /** @internal */ serializeMark(mark, inline, options = {}) { let toDOM = this.marks[mark.type.name]; return toDOM && DOMSerializer.renderSpec(doc$2(options), toDOM(mark, inline)); } /** Render an [output spec](https://prosemirror.net/docs/ref/#model.DOMOutputSpec) to a DOM node. If the spec has a hole (zero) in it, `contentDOM` will point at the node with the hole. */ static renderSpec(doc, structure, xmlNS = null) { if (typeof structure == "string") return { dom: doc.createTextNode(structure) }; if (structure.nodeType != null) return { dom: structure }; if (structure.dom && structure.dom.nodeType != null) return structure; let tagName = structure[0], space = tagName.indexOf(" "); if (space > 0) { xmlNS = tagName.slice(0, space); tagName = tagName.slice(space + 1); } let contentDOM; let dom = (xmlNS ? doc.createElementNS(xmlNS, tagName) : doc.createElement(tagName)); let attrs = structure[1], start = 1; if (attrs && typeof attrs == "object" && attrs.nodeType == null && !Array.isArray(attrs)) { start = 2; for (let name in attrs) if (attrs[name] != null) { let space = name.indexOf(" "); if (space > 0) dom.setAttributeNS(name.slice(0, space), name.slice(space + 1), attrs[name]); else dom.setAttribute(name, attrs[name]); } } for (let i = start; i < structure.length; i++) { let child = structure[i]; if (child === 0) { if (i < structure.length - 1 || i > start) throw new RangeError("Content hole must be the only child of its parent node"); return { dom, contentDOM: dom }; } else { let { dom: inner, contentDOM: innerContent } = DOMSerializer.renderSpec(doc, child, xmlNS); dom.appendChild(inner); if (innerContent) { if (contentDOM) throw new RangeError("Multiple content holes"); contentDOM = innerContent; } } } return { dom, contentDOM }; } /** Build a serializer using the [`toDOM`](https://prosemirror.net/docs/ref/#model.NodeSpec.toDOM) properties in a schema's node and mark specs. */ static fromSchema(schema) { return schema.cached.domSerializer || (schema.cached.domSerializer = new DOMSerializer(this.nodesFromSchema(schema), this.marksFromSchema(schema))); } /** Gather the serializers in a schema's node specs into an object. This can be useful as a base to build a custom serializer from. */ static nodesFromSchema(schema) { let result = gatherToDOM(schema.nodes); if (!result.text) result.text = node => node.text; return result; } /** Gather the serializers in a schema's mark specs into an object. */ static marksFromSchema(schema) { return gatherToDOM(schema.marks); } } function gatherToDOM(obj) { let result = {}; for (let name in obj) { let toDOM = obj[name].spec.toDOM; if (toDOM) result[name] = toDOM; } return result; } function doc$2(options) { return options.document || window.document; } // Recovery values encode a range index and an offset. They are // represented as numbers, because tons of them will be created when // mapping, for example, a large number of decorations. The number's // lower 16 bits provide the index, the remaining bits the offset. // // Note: We intentionally don't use bit shift operators to en- and // decode these, since those clip to 32 bits, which we might in rare // cases want to overflow. A 64-bit float can represent 48-bit // integers precisely. const lower16 = 0xffff; const factor16 = Math.pow(2, 16); function makeRecover(index, offset) { return index + offset * factor16; } function recoverIndex(value) { return value & lower16; } function recoverOffset(value) { return (value - (value & lower16)) / factor16; } const DEL_BEFORE = 1, DEL_AFTER = 2, DEL_ACROSS = 4, DEL_SIDE = 8; /** An object representing a mapped position with extra information. */ class MapResult { /** @internal */ constructor( /** The mapped version of the position. */ pos, /** @internal */ delInfo, /** @internal */ recover) { this.pos = pos; this.delInfo = delInfo; this.recover = recover; } /** Tells you whether the position was deleted, that is, whether the step removed the token on the side queried (via the `assoc`) argument from the document. */ get deleted() { return (this.delInfo & DEL_SIDE) > 0; } /** Tells you whether the token before the mapped position was deleted. */ get deletedBefore() { return (this.delInfo & (DEL_BEFORE | DEL_ACROSS)) > 0; } /** True when the token after the mapped position was deleted. */ get deletedAfter() { return (this.delInfo & (DEL_AFTER | DEL_ACROSS)) > 0; } /** Tells whether any of the steps mapped through deletes across the position (including both the token before and after the position). */ get deletedAcross() { return (this.delInfo & DEL_ACROSS) > 0; } } /** A map describing the deletions and insertions made by a step, which can be used to find the correspondence between positions in the pre-step version of a document and the same position in the post-step version. */ class StepMap { /** Create a position map. The modifications to the document are represented as an array of numbers, in which each group of three represents a modified chunk as `[start, oldSize, newSize]`. */ constructor( /** @internal */ ranges, /** @internal */ inverted = false) { this.ranges = ranges; this.inverted = inverted; if (!ranges.length && StepMap.empty) return StepMap.empty; } /** @internal */ recover(value) { let diff = 0, index = recoverIndex(value); if (!this.inverted) for (let i = 0; i < index; i++) diff += this.ranges[i * 3 + 2] - this.ranges[i * 3 + 1]; return this.ranges[index * 3] + diff + recoverOffset(value); } mapResult(pos, assoc = 1) { return this._map(pos, assoc, false); } map(pos, assoc = 1) { return this._map(pos, assoc, true); } /** @internal */ _map(pos, assoc, simple) { let diff = 0, oldIndex = this.inverted ? 2 : 1, newIndex = this.inverted ? 1 : 2; for (let i = 0; i < this.ranges.length; i += 3) { let start = this.ranges[i] - (this.inverted ? diff : 0); if (start > pos) break; let oldSize = this.ranges[i + oldIndex], newSize = this.ranges[i + newIndex], end = start + oldSize; if (pos <= end) { let side = !oldSize ? assoc : pos == start ? -1 : pos == end ? 1 : assoc; let result = start + diff + (side < 0 ? 0 : newSize); if (simple) return result; let recover = pos == (assoc < 0 ? start : end) ? null : makeRecover(i / 3, pos - start); let del = pos == start ? DEL_AFTER : pos == end ? DEL_BEFORE : DEL_ACROSS; if (assoc < 0 ? pos != start : pos != end) del |= DEL_SIDE; return new MapResult(result, del, recover); } diff += newSize - oldSize; } return simple ? pos + diff : new MapResult(pos + diff, 0, null); } /** @internal */ touches(pos, recover) { let diff = 0, index = recoverIndex(recover); let oldIndex = this.inverted ? 2 : 1, newIndex = this.inverted ? 1 : 2; for (let i = 0; i < this.ranges.length; i += 3) { let start = this.ranges[i] - (this.inverted ? diff : 0); if (start > pos) break; let oldSize = this.ranges[i + oldIndex], end = start + oldSize; if (pos <= end && i == index * 3) return true; diff += this.ranges[i + newIndex] - oldSize; } return false; } /** Calls the given function on each of the changed ranges included in this map. */ forEach(f) { let oldIndex = this.inverted ? 2 : 1, newIndex = this.inverted ? 1 : 2; for (let i = 0, diff = 0; i < this.ranges.length; i += 3) { let start = this.ranges[i], oldStart = start - (this.inverted ? diff : 0), newStart = start + (this.inverted ? 0 : diff); let oldSize = this.ranges[i + oldIndex], newSize = this.ranges[i + newIndex]; f(oldStart, oldStart + oldSize, newStart, newStart + newSize); diff += newSize - oldSize; } } /** Create an inverted version of this map. The result can be used to map positions in the post-step document to the pre-step document. */ invert() { return new StepMap(this.ranges, !this.inverted); } /** @internal */ toString() { return (this.inverted ? "-" : "") + JSON.stringify(this.ranges); } /** Create a map that moves all positions by offset `n` (which may be negative). This can be useful when applying steps meant for a sub-document to a larger document, or vice-versa. */ static offset(n) { return n == 0 ? StepMap.empty : new StepMap(n < 0 ? [0, -n, 0] : [0, 0, n]); } } /** A StepMap that contains no changed ranges. */ StepMap.empty = new StepMap([]); /** A mapping represents a pipeline of zero or more [step maps](https://prosemirror.net/docs/ref/#transform.StepMap). It has special provisions for losslessly handling mapping positions through a series of steps in which some steps are inverted versions of earlier steps. (This comes up when ‘[rebasing](/docs/guide/#transform.rebasing)’ steps for collaboration or history management.) */ class Mapping { /** Create a new mapping with the given position maps. */ constructor( /** The step maps in this mapping. */ maps = [], /** @internal */ mirror, /** The starting position in the `maps` array, used when `map` or `mapResult` is called. */ from = 0, /** The end position in the `maps` array. */ to = maps.length) { this.maps = maps; this.mirror = mirror; this.from = from; this.to = to; } /** Create a mapping that maps only through a part of this one. */ slice(from = 0, to = this.maps.length) { return new Mapping(this.maps, this.mirror, from, to); } /** @internal */ copy() { return new Mapping(this.maps.slice(), this.mirror && this.mirror.slice(), this.from, this.to); } /** Add a step map to the end of this mapping. If `mirrors` is given, it should be the index of the step map that is the mirror image of this one. */ appendMap(map, mirrors) { this.to = this.maps.push(map); if (mirrors != null) this.setMirror(this.maps.length - 1, mirrors); } /** Add all the step maps in a given mapping to this one (preserving mirroring information). */ appendMapping(mapping) { for (let i = 0, startSize = this.maps.length; i < mapping.maps.length; i++) { let mirr = mapping.getMirror(i); this.appendMap(mapping.maps[i], mirr != null && mirr < i ? startSize + mirr : undefined); } } /** Finds the offset of the step map that mirrors the map at the given offset, in this mapping (as per the second argument to `appendMap`). */ getMirror(n) { if (this.mirror) for (let i = 0; i < this.mirror.length; i++) if (this.mirror[i] == n) return this.mirror[i + (i % 2 ? -1 : 1)]; } /** @internal */ setMirror(n, m) { if (!this.mirror) this.mirror = []; this.mirror.push(n, m); } /** Append the inverse of the given mapping to this one. */ appendMappingInverted(mapping) { for (let i = mapping.maps.length - 1, totalSize = this.maps.length + mapping.maps.length; i >= 0; i--) { let mirr = mapping.getMirror(i); this.appendMap(mapping.maps[i].invert(), mirr != null && mirr > i ? totalSize - mirr - 1 : undefined); } } /** Create an inverted version of this mapping. */ invert() { let inverse = new Mapping; inverse.appendMappingInverted(this); return inverse; } /** Map a position through this mapping. */ map(pos, assoc = 1) { if (this.mirror) return this._map(pos, assoc, true); for (let i = this.from; i < this.to; i++) pos = this.maps[i].map(pos, assoc); return pos; } /** Map a position through this mapping, returning a mapping result. */ mapResult(pos, assoc = 1) { return this._map(pos, assoc, false); } /** @internal */ _map(pos, assoc, simple) { let delInfo = 0; for (let i = this.from; i < this.to; i++) { let map = this.maps[i], result = map.mapResult(pos, assoc); if (result.recover != null) { let corr = this.getMirror(i); if (corr != null && corr > i && corr < this.to) { i = corr; pos = this.maps[corr].recover(result.recover); continue; } } delInfo |= result.delInfo; pos = result.pos; } return simple ? pos : new MapResult(pos, delInfo, null); } } const stepsByID = Object.create(null); /** A step object represents an atomic change. It generally applies only to the document it was created for, since the positions stored in it will only make sense for that document. New steps are defined by creating classes that extend `Step`, overriding the `apply`, `invert`, `map`, `getMap` and `fromJSON` methods, and registering your class with a unique JSON-serialization identifier using [`Step.jsonID`](https://prosemirror.net/docs/ref/#transform.Step^jsonID). */ class Step { /** Get the step map that represents the changes made by this step, and which can be used to transform between positions in the old and the new document. */ getMap() { return StepMap.empty; } /** Try to merge this step with another one, to be applied directly after it. Returns the merged step when possible, null if the steps can't be merged. */ merge(other) { return null; } /** Deserialize a step from its JSON representation. Will call through to the step class' own implementation of this method. */ static fromJSON(schema, json) { if (!json || !json.stepType) throw new RangeError("Invalid input for Step.fromJSON"); let type = stepsByID[json.stepType]; if (!type) throw new RangeError(`No step type ${json.stepType} defined`); return type.fromJSON(schema, json); } /** To be able to serialize steps to JSON, each step needs a string ID to attach to its JSON representation. Use this method to register an ID for your step classes. Try to pick something that's unlikely to clash with steps from other modules. */ static jsonID(id, stepClass) { if (id in stepsByID) throw new RangeError("Duplicate use of step JSON ID " + id); stepsByID[id] = stepClass; stepClass.prototype.jsonID = id; return stepClass; } } /** The result of [applying](https://prosemirror.net/docs/ref/#transform.Step.apply) a step. Contains either a new document or a failure value. */ class StepResult { /** @internal */ constructor( /** The transformed document, if successful. */ doc, /** The failure message, if unsuccessful. */ failed) { this.doc = doc; this.failed = failed; } /** Create a successful step result. */ static ok(doc) { return new StepResult(doc, null); } /** Create a failed step result. */ static fail(message) { return new StepResult(null, message); } /** Call [`Node.replace`](https://prosemirror.net/docs/ref/#model.Node.replace) with the given arguments. Create a successful result if it succeeds, and a failed one if it throws a `ReplaceError`. */ static fromReplace(doc, from, to, slice) { try { return StepResult.ok(doc.replace(from, to, slice)); } catch (e) { if (e instanceof ReplaceError) return StepResult.fail(e.message); throw e; } } } function mapFragment(fragment, f, parent) { let mapped = []; for (let i = 0; i < fragment.childCount; i++) { let child = fragment.child(i); if (child.content.size) child = child.copy(mapFragment(child.content, f, child)); if (child.isInline) child = f(child, parent, i); mapped.push(child); } return Fragment.fromArray(mapped); } /** Add a mark to all inline content between two positions. */ class AddMarkStep extends Step { /** Create a mark step. */ constructor( /** The start of the marked range. */ from, /** The end of the marked range. */ to, /** The mark to add. */ mark) { super(); this.from = from; this.to = to; this.mark = mark; } apply(doc) { let oldSlice = doc.slice(this.from, this.to), $from = doc.resolve(this.from); let parent = $from.node($from.sharedDepth(this.to)); let slice = new Slice(mapFragment(oldSlice.content, (node, parent) => { if (!node.isAtom || !parent.type.allowsMarkType(this.mark.type)) return node; return node.mark(this.mark.addToSet(node.marks)); }, parent), oldSlice.openStart, oldSlice.openEnd); return StepResult.fromReplace(doc, this.from, this.to, slice); } invert() { return new RemoveMarkStep(this.from, this.to, this.mark); } map(mapping) { let from = mapping.mapResult(this.from, 1), to = mapping.mapResult(this.to, -1); if (from.deleted && to.deleted || from.pos >= to.pos) return null; return new AddMarkStep(from.pos, to.pos, this.mark); } merge(other) { if (other instanceof AddMarkStep && other.mark.eq(this.mark) && this.from <= other.to && this.to >= other.from) return new AddMarkStep(Math.min(this.from, other.from), Math.max(this.to, other.to), this.mark); return null; } toJSON() { return { stepType: "addMark", mark: this.mark.toJSON(), from: this.from, to: this.to }; } /** @internal */ static fromJSON(schema, json) { if (typeof json.from != "number" || typeof json.to != "number") throw new RangeError("Invalid input for AddMarkStep.fromJSON"); return new AddMarkStep(json.from, json.to, schema.markFromJSON(json.mark)); } } Step.jsonID("addMark", AddMarkStep); /** Remove a mark from all inline content between two positions. */ class RemoveMarkStep extends Step { /** Create a mark-removing step. */ constructor( /** The start of the unmarked range. */ from, /** The end of the unmarked range. */ to, /** The mark to remove. */ mark) { super(); this.from = from; this.to = to; this.mark = mark; } apply(doc) { let oldSlice = doc.slice(this.from, this.to); let slice = new Slice(mapFragment(oldSlice.content, node => { return node.mark(this.mark.removeFromSet(node.marks)); }, doc), oldSlice.openStart, oldSlice.openEnd); return StepResult.fromReplace(doc, this.from, this.to, slice); } invert() { return new AddMarkStep(this.from, this.to, this.mark); } map(mapping) { let from = mapping.mapResult(this.from, 1), to = mapping.mapResult(this.to, -1); if (from.deleted && to.deleted || from.pos >= to.pos) return null; return new RemoveMarkStep(from.pos, to.pos, this.mark); } merge(other) { if (other instanceof RemoveMarkStep && other.mark.eq(this.mark) && this.from <= other.to && this.to >= other.from) return new RemoveMarkStep(Math.min(this.from, other.from), Math.max(this.to, other.to), this.mark); return null; } toJSON() { return { stepType: "removeMark", mark: this.mark.toJSON(), from: this.from, to: this.to }; } /** @internal */ static fromJSON(schema, json) { if (typeof json.from != "number" || typeof json.to != "number") throw new RangeError("Invalid input for RemoveMarkStep.fromJSON"); return new RemoveMarkStep(json.from, json.to, schema.markFromJSON(json.mark)); } } Step.jsonID("removeMark", RemoveMarkStep); /** Add a mark to a specific node. */ class AddNodeMarkStep extends Step { /** Create a node mark step. */ constructor( /** The position of the target node. */ pos, /** The mark to add. */ mark) { super(); this.pos = pos; this.mark = mark; } apply(doc) { let node = doc.nodeAt(this.pos); if (!node) return StepResult.fail("No node at mark step's position"); let updated = node.type.create(node.attrs, null, this.mark.addToSet(node.marks)); return StepResult.fromReplace(doc, this.pos, this.pos + 1, new Slice(Fragment.from(updated), 0, node.isLeaf ? 0 : 1)); } invert(doc) { let node = doc.nodeAt(this.pos); if (node) { let newSet = this.mark.addToSet(node.marks); if (newSet.length == node.marks.length) { for (let i = 0; i < node.marks.length; i++) if (!node.marks[i].isInSet(newSet)) return new AddNodeMarkStep(this.pos, node.marks[i]); return new AddNodeMarkStep(this.pos, this.mark); } } return new RemoveNodeMarkStep(this.pos, this.mark); } map(mapping) { let pos = mapping.mapResult(this.pos, 1); return pos.deletedAfter ? null : new AddNodeMarkStep(pos.pos, this.mark); } toJSON() { return { stepType: "addNodeMark", pos: this.pos, mark: this.mark.toJSON() }; } /** @internal */ static fromJSON(schema, json) { if (typeof json.pos != "number") throw new RangeError("Invalid input for AddNodeMarkStep.fromJSON"); return new AddNodeMarkStep(json.pos, schema.markFromJSON(json.mark)); } } Step.jsonID("addNodeMark", AddNodeMarkStep); /** Remove a mark from a specific node. */ class RemoveNodeMarkStep extends Step { /** Create a mark-removing step. */ constructor( /** The position of the target node. */ pos, /** The mark to remove. */ mark) { super(); this.pos = pos; this.mark = mark; } apply(doc) { let node = doc.nodeAt(this.pos); if (!node) return StepResult.fail("No node at mark step's position"); let updated = node.type.create(node.attrs, null, this.mark.removeFromSet(node.marks)); return StepResult.fromReplace(doc, this.pos, this.pos + 1, new Slice(Fragment.from(updated), 0, node.isLeaf ? 0 : 1)); } invert(doc) { let node = doc.nodeAt(this.pos); if (!node || !this.mark.isInSet(node.marks)) return this; return new AddNodeMarkStep(this.pos, this.mark); } map(mapping) { let pos = mapping.mapResult(this.pos, 1); return pos.deletedAfter ? null : new RemoveNodeMarkStep(pos.pos, this.mark); } toJSON() { return { stepType: "removeNodeMark", pos: this.pos, mark: this.mark.toJSON() }; } /** @internal */ static fromJSON(schema, json) { if (typeof json.pos != "number") throw new RangeError("Invalid input for RemoveNodeMarkStep.fromJSON"); return new RemoveNodeMarkStep(json.pos, schema.markFromJSON(json.mark)); } } Step.jsonID("removeNodeMark", RemoveNodeMarkStep); /** Replace a part of the document with a slice of new content. */ class ReplaceStep extends Step { /** The given `slice` should fit the 'gap' between `from` and `to`—the depths must line up, and the surrounding nodes must be able to be joined with the open sides of the slice. When `structure` is true, the step will fail if the content between from and to is not just a sequence of closing and then opening tokens (this is to guard against rebased replace steps overwriting something they weren't supposed to). */ constructor( /** The start position of the replaced range. */ from, /** The end position of the replaced range. */ to, /** The slice to insert. */ slice, /** @internal */ structure = false) { super(); this.from = from; this.to = to; this.slice = slice; this.structure = structure; } apply(doc) { if (this.structure && contentBetween(doc, this.from, this.to)) return StepResult.fail("Structure replace would overwrite content"); return StepResult.fromReplace(doc, this.from, this.to, this.slice); } getMap() { return new StepMap([this.from, this.to - this.from, this.slice.size]); } invert(doc) { return new ReplaceStep(this.from, this.from + this.slice.size, doc.slice(this.from, this.to)); } map(mapping) { let from = mapping.mapResult(this.from, 1), to = mapping.mapResult(this.to, -1); if (from.deletedAcross && to.deletedAcross) return null; return new ReplaceStep(from.pos, Math.max(from.pos, to.pos), this.slice); } merge(other) { if (!(other instanceof ReplaceStep) || other.structure || this.structure) return null; if (this.from + this.slice.size == other.from && !this.slice.openEnd && !other.slice.openStart) { let slice = this.slice.size + other.slice.size == 0 ? Slice.empty : new Slice(this.slice.content.append(other.slice.content), this.slice.openStart, other.slice.openEnd); return new ReplaceStep(this.from, this.to + (other.to - other.from), slice, this.structure); } else if (other.to == this.from && !this.slice.openStart && !other.slice.openEnd) { let slice = this.slice.size + other.slice.size == 0 ? Slice.empty : new Slice(other.slice.content.append(this.slice.content), other.slice.openStart, this.slice.openEnd); return new ReplaceStep(other.from, this.to, slice, this.structure); } else { return null; } } toJSON() { let json = { stepType: "replace", from: this.from, to: this.to }; if (this.slice.size) json.slice = this.slice.toJSON(); if (this.structure) json.structure = true; return json; } /** @internal */ static fromJSON(schema, json) { if (typeof json.from != "number" || typeof json.to != "number") throw new RangeError("Invalid input for ReplaceStep.fromJSON"); return new ReplaceStep(json.from, json.to, Slice.fromJSON(schema, json.slice), !!json.structure); } } Step.jsonID("replace", ReplaceStep); /** Replace a part of the document with a slice of content, but preserve a range of the replaced content by moving it into the slice. */ class ReplaceAroundStep extends Step { /** Create a replace-around step with the given range and gap. `insert` should be the point in the slice into which the content of the gap should be moved. `structure` has the same meaning as it has in the [`ReplaceStep`](https://prosemirror.net/docs/ref/#transform.ReplaceStep) class. */ constructor( /** The start position of the replaced range. */ from, /** The end position of the replaced range. */ to, /** The start of preserved range. */ gapFrom, /** The end of preserved range. */ gapTo, /** The slice to insert. */ slice, /** The position in the slice where the preserved range should be inserted. */ insert, /** @internal */ structure = false) { super(); this.from = from; this.to = to; this.gapFrom = gapFrom; this.gapTo = gapTo; this.slice = slice; this.insert = insert; this.structure = structure; } apply(doc) { if (this.structure && (contentBetween(doc, this.from, this.gapFrom) || contentBetween(doc, this.gapTo, this.to))) return StepResult.fail("Structure gap-replace would overwrite content"); let gap = doc.slice(this.gapFrom, this.gapTo); if (gap.openStart || gap.openEnd) return StepResult.fail("Gap is not a flat range"); let inserted = this.slice.insertAt(this.insert, gap.content); if (!inserted) return StepResult.fail("Content does not fit in gap"); return StepResult.fromReplace(doc, this.from, this.to, inserted); } getMap() { return new StepMap([this.from, this.gapFrom - this.from, this.insert, this.gapTo, this.to - this.gapTo, this.slice.size - this.insert]); } invert(doc) { let gap = this.gapTo - this.gapFrom; return new ReplaceAroundStep(this.from, this.from + this.slice.size + gap, this.from + this.insert, this.from + this.insert + gap, doc.slice(this.from, this.to).removeBetween(this.gapFrom - this.from, this.gapTo - this.from), this.gapFrom - this.from, this.structure); } map(mapping) { let from = mapping.mapResult(this.from, 1), to = mapping.mapResult(this.to, -1); let gapFrom = this.from == this.gapFrom ? from.pos : mapping.map(this.gapFrom, -1); let gapTo = this.to == this.gapTo ? to.pos : mapping.map(this.gapTo, 1); if ((from.deletedAcross && to.deletedAcross) || gapFrom < from.pos || gapTo > to.pos) return null; return new ReplaceAroundStep(from.pos, to.pos, gapFrom, gapTo, this.slice, this.insert, this.structure); } toJSON() { let json = { stepType: "replaceAround", from: this.from, to: this.to, gapFrom: this.gapFrom, gapTo: this.gapTo, insert: this.insert }; if (this.slice.size) json.slice = this.slice.toJSON(); if (this.structure) json.structure = true; return json; } /** @internal */ static fromJSON(schema, json) { if (typeof json.from != "number" || typeof json.to != "number" || typeof json.gapFrom != "number" || typeof json.gapTo != "number" || typeof json.insert != "number") throw new RangeError("Invalid input for ReplaceAroundStep.fromJSON"); return new ReplaceAroundStep(json.from, json.to, json.gapFrom, json.gapTo, Slice.fromJSON(schema, json.slice), json.insert, !!json.structure); } } Step.jsonID("replaceAround", ReplaceAroundStep); function contentBetween(doc, from, to) { let $from = doc.resolve(from), dist = to - from, depth = $from.depth; while (dist > 0 && depth > 0 && $from.indexAfter(depth) == $from.node(depth).childCount) { depth--; dist--; } if (dist > 0) { let next = $from.node(depth).maybeChild($from.indexAfter(depth)); while (dist > 0) { if (!next || next.isLeaf) return true; next = next.firstChild; dist--; } } return false; } function addMark(tr, from, to, mark) { let removed = [], added = []; let removing, adding; tr.doc.nodesBetween(from, to, (node, pos, parent) => { if (!node.isInline) return; let marks = node.marks; if (!mark.isInSet(marks) && parent.type.allowsMarkType(mark.type)) { let start = Math.max(pos, from), end = Math.min(pos + node.nodeSize, to); let newSet = mark.addToSet(marks); for (let i = 0; i < marks.length; i++) { if (!marks[i].isInSet(newSet)) { if (removing && removing.to == start && removing.mark.eq(marks[i])) removing.to = end; else removed.push(removing = new RemoveMarkStep(start, end, marks[i])); } } if (adding && adding.to == start) adding.to = end; else added.push(adding = new AddMarkStep(start, end, mark)); } }); removed.forEach(s => tr.step(s)); added.forEach(s => tr.step(s)); } function removeMark(tr, from, to, mark) { let matched = [], step = 0; tr.doc.nodesBetween(from, to, (node, pos) => { if (!node.isInline) return; step++; let toRemove = null; if (mark instanceof MarkType) { let set = node.marks, found; while (found = mark.isInSet(set)) { (toRemove || (toRemove = [])).push(found); set = found.removeFromSet(set); } } else if (mark) { if (mark.isInSet(node.marks)) toRemove = [mark]; } else { toRemove = node.marks; } if (toRemove && toRemove.length) { let end = Math.min(pos + node.nodeSize, to); for (let i = 0; i < toRemove.length; i++) { let style = toRemove[i], found; for (let j = 0; j < matched.length; j++) { let m = matched[j]; if (m.step == step - 1 && style.eq(matched[j].style)) found = m; } if (found) { found.to = end; found.step = step; } else { matched.push({ style, from: Math.max(pos, from), to: end, step }); } } } }); matched.forEach(m => tr.step(new RemoveMarkStep(m.from, m.to, m.style))); } function clearIncompatible(tr, pos, parentType, match = parentType.contentMatch, clearNewlines = true) { let node = tr.doc.nodeAt(pos); let replSteps = [], cur = pos + 1; for (let i = 0; i < node.childCount; i++) { let child = node.child(i), end = cur + child.nodeSize; let allowed = match.matchType(child.type); if (!allowed) { replSteps.push(new ReplaceStep(cur, end, Slice.empty)); } else { match = allowed; for (let j = 0; j < child.marks.length; j++) if (!parentType.allowsMarkType(child.marks[j].type)) tr.step(new RemoveMarkStep(cur, end, child.marks[j])); if (clearNewlines && child.isText && parentType.whitespace != "pre") { let m, newline = /\r?\n|\r/g, slice; while (m = newline.exec(child.text)) { if (!slice) slice = new Slice(Fragment.from(parentType.schema.text(" ", parentType.allowedMarks(child.marks))), 0, 0); replSteps.push(new ReplaceStep(cur + m.index, cur + m.index + m[0].length, slice)); } } } cur = end; } if (!match.validEnd) { let fill = match.fillBefore(Fragment.empty, true); tr.replace(cur, cur, new Slice(fill, 0, 0)); } for (let i = replSteps.length - 1; i >= 0; i--) tr.step(replSteps[i]); } function canCut(node, start, end) { return (start == 0 || node.canReplace(start, node.childCount)) && (end == node.childCount || node.canReplace(0, end)); } /** Try to find a target depth to which the content in the given range can be lifted. Will not go across [isolating](https://prosemirror.net/docs/ref/#model.NodeSpec.isolating) parent nodes. */ function liftTarget(range) { let parent = range.parent; let content = parent.content.cutByIndex(range.startIndex, range.endIndex); for (let depth = range.depth;; --depth) { let node = range.$from.node(depth); let index = range.$from.index(depth), endIndex = range.$to.indexAfter(depth); if (depth < range.depth && node.canReplace(index, endIndex, content)) return depth; if (depth == 0 || node.type.spec.isolating || !canCut(node, index, endIndex)) break; } return null; } function lift$1(tr, range, target) { let { $from, $to, depth } = range; let gapStart = $from.before(depth + 1), gapEnd = $to.after(depth + 1); let start = gapStart, end = gapEnd; let before = Fragment.empty, openStart = 0; for (let d = depth, splitting = false; d > target; d--) if (splitting || $from.index(d) > 0) { splitting = true; before = Fragment.from($from.node(d).copy(before)); openStart++; } else { start--; } let after = Fragment.empty, openEnd = 0; for (let d = depth, splitting = false; d > target; d--) if (splitting || $to.after(d + 1) < $to.end(d)) { splitting = true; after = Fragment.from($to.node(d).copy(after)); openEnd++; } else { end++; } tr.step(new ReplaceAroundStep(start, end, gapStart, gapEnd, new Slice(before.append(after), openStart, openEnd), before.size - openStart, true)); } /** Try to find a valid way to wrap the content in the given range in a node of the given type. May introduce extra nodes around and inside the wrapper node, if necessary. Returns null if no valid wrapping could be found. When `innerRange` is given, that range's content is used as the content to fit into the wrapping, instead of the content of `range`. */ function findWrapping(range, nodeType, attrs = null, innerRange = range) { let around = findWrappingOutside(range, nodeType); let inner = around && findWrappingInside(innerRange, nodeType); if (!inner) return null; return around.map(withAttrs) .concat({ type: nodeType, attrs }).concat(inner.map(withAttrs)); } function withAttrs(type) { return { type, attrs: null }; } function findWrappingOutside(range, type) { let { parent, startIndex, endIndex } = range; let around = parent.contentMatchAt(startIndex).findWrapping(type); if (!around) return null; let outer = around.length ? around[0] : type; return parent.canReplaceWith(startIndex, endIndex, outer) ? around : null; } function findWrappingInside(range, type) { let { parent, startIndex, endIndex } = range; let inner = parent.child(startIndex); let inside = type.contentMatch.findWrapping(inner.type); if (!inside) return null; let lastType = inside.length ? inside[inside.length - 1] : type; let innerMatch = lastType.contentMatch; for (let i = startIndex; innerMatch && i < endIndex; i++) innerMatch = innerMatch.matchType(parent.child(i).type); if (!innerMatch || !innerMatch.validEnd) return null; return inside; } function wrap(tr, range, wrappers) { let content = Fragment.empty; for (let i = wrappers.length - 1; i >= 0; i--) { if (content.size) { let match = wrappers[i].type.contentMatch.matchFragment(content); if (!match || !match.validEnd) throw new RangeError("Wrapper type given to Transform.wrap does not form valid content of its parent wrapper"); } content = Fragment.from(wrappers[i].type.create(wrappers[i].attrs, content)); } let start = range.start, end = range.end; tr.step(new ReplaceAroundStep(start, end, start, end, new Slice(content, 0, 0), wrappers.length, true)); } function setBlockType$1(tr, from, to, type, attrs) { if (!type.isTextblock) throw new RangeError("Type given to setBlockType should be a textblock"); let mapFrom = tr.steps.length; tr.doc.nodesBetween(from, to, (node, pos) => { if (node.isTextblock && !node.hasMarkup(type, attrs) && canChangeType(tr.doc, tr.mapping.slice(mapFrom).map(pos), type)) { let convertNewlines = null; if (type.schema.linebreakReplacement) { let pre = type.whitespace == "pre", supportLinebreak = !!type.contentMatch.matchType(type.schema.linebreakReplacement); if (pre && !supportLinebreak) convertNewlines = false; else if (!pre && supportLinebreak) convertNewlines = true; } // Ensure all markup that isn't allowed in the new node type is cleared if (convertNewlines === false) replaceLinebreaks(tr, node, pos, mapFrom); clearIncompatible(tr, tr.mapping.slice(mapFrom).map(pos, 1), type, undefined, convertNewlines === null); let mapping = tr.mapping.slice(mapFrom); let startM = mapping.map(pos, 1), endM = mapping.map(pos + node.nodeSize, 1); tr.step(new ReplaceAroundStep(startM, endM, startM + 1, endM - 1, new Slice(Fragment.from(type.create(attrs, null, node.marks)), 0, 0), 1, true)); if (convertNewlines === true) replaceNewlines(tr, node, pos, mapFrom); return false; } }); } function replaceNewlines(tr, node, pos, mapFrom) { node.forEach((child, offset) => { if (child.isText) { let m, newline = /\r?\n|\r/g; while (m = newline.exec(child.text)) { let start = tr.mapping.slice(mapFrom).map(pos + 1 + offset + m.index); tr.replaceWith(start, start + 1, node.type.schema.linebreakReplacement.create()); } } }); } function replaceLinebreaks(tr, node, pos, mapFrom) { node.forEach((child, offset) => { if (child.type == child.type.schema.linebreakReplacement) { let start = tr.mapping.slice(mapFrom).map(pos + 1 + offset); tr.replaceWith(start, start + 1, node.type.schema.text("\n")); } }); } function canChangeType(doc, pos, type) { let $pos = doc.resolve(pos), index = $pos.index(); return $pos.parent.canReplaceWith(index, index + 1, type); } /** Change the type, attributes, and/or marks of the node at `pos`. When `type` isn't given, the existing node type is preserved, */ function setNodeMarkup(tr, pos, type, attrs, marks) { let node = tr.doc.nodeAt(pos); if (!node) throw new RangeError("No node at given position"); if (!type) type = node.type; let newNode = type.create(attrs, null, marks || node.marks); if (node.isLeaf) return tr.replaceWith(pos, pos + node.nodeSize, newNode); if (!type.validContent(node.content)) throw new RangeError("Invalid content for node type " + type.name); tr.step(new ReplaceAroundStep(pos, pos + node.nodeSize, pos + 1, pos + node.nodeSize - 1, new Slice(Fragment.from(newNode), 0, 0), 1, true)); } /** Check whether splitting at the given position is allowed. */ function canSplit(doc, pos, depth = 1, typesAfter) { let $pos = doc.resolve(pos), base = $pos.depth - depth; let innerType = (typesAfter && typesAfter[typesAfter.length - 1]) || $pos.parent; if (base < 0 || $pos.parent.type.spec.isolating || !$pos.parent.canReplace($pos.index(), $pos.parent.childCount) || !innerType.type.validContent($pos.parent.content.cutByIndex($pos.index(), $pos.parent.childCount))) return false; for (let d = $pos.depth - 1, i = depth - 2; d > base; d--, i--) { let node = $pos.node(d), index = $pos.index(d); if (node.type.spec.isolating) return false; let rest = node.content.cutByIndex(index, node.childCount); let overrideChild = typesAfter && typesAfter[i + 1]; if (overrideChild) rest = rest.replaceChild(0, overrideChild.type.create(overrideChild.attrs)); let after = (typesAfter && typesAfter[i]) || node; if (!node.canReplace(index + 1, node.childCount) || !after.type.validContent(rest)) return false; } let index = $pos.indexAfter(base); let baseType = typesAfter && typesAfter[0]; return $pos.node(base).canReplaceWith(index, index, baseType ? baseType.type : $pos.node(base + 1).type); } function split(tr, pos, depth = 1, typesAfter) { let $pos = tr.doc.resolve(pos), before = Fragment.empty, after = Fragment.empty; for (let d = $pos.depth, e = $pos.depth - depth, i = depth - 1; d > e; d--, i--) { before = Fragment.from($pos.node(d).copy(before)); let typeAfter = typesAfter && typesAfter[i]; after = Fragment.from(typeAfter ? typeAfter.type.create(typeAfter.attrs, after) : $pos.node(d).copy(after)); } tr.step(new ReplaceStep(pos, pos, new Slice(before.append(after), depth, depth), true)); } /** Test whether the blocks before and after a given position can be joined. */ function canJoin(doc, pos) { let $pos = doc.resolve(pos), index = $pos.index(); return joinable($pos.nodeBefore, $pos.nodeAfter) && $pos.parent.canReplace(index, index + 1); } function joinable(a, b) { return !!(a && b && !a.isLeaf && a.canAppend(b)); } /** Find an ancestor of the given position that can be joined to the block before (or after if `dir` is positive). Returns the joinable point, if any. */ function joinPoint(doc, pos, dir = -1) { let $pos = doc.resolve(pos); for (let d = $pos.depth;; d--) { let before, after, index = $pos.index(d); if (d == $pos.depth) { before = $pos.nodeBefore; after = $pos.nodeAfter; } else if (dir > 0) { before = $pos.node(d + 1); index++; after = $pos.node(d).maybeChild(index); } else { before = $pos.node(d).maybeChild(index - 1); after = $pos.node(d + 1); } if (before && !before.isTextblock && joinable(before, after) && $pos.node(d).canReplace(index, index + 1)) return pos; if (d == 0) break; pos = dir < 0 ? $pos.before(d) : $pos.after(d); } } function join(tr, pos, depth) { let step = new ReplaceStep(pos - depth, pos + depth, Slice.empty, true); tr.step(step); } /** Try to find a point where a node of the given type can be inserted near `pos`, by searching up the node hierarchy when `pos` itself isn't a valid place but is at the start or end of a node. Return null if no position was found. */ function insertPoint(doc, pos, nodeType) { let $pos = doc.resolve(pos); if ($pos.parent.canReplaceWith($pos.index(), $pos.index(), nodeType)) return pos; if ($pos.parentOffset == 0) for (let d = $pos.depth - 1; d >= 0; d--) { let index = $pos.index(d); if ($pos.node(d).canReplaceWith(index, index, nodeType)) return $pos.before(d + 1); if (index > 0) return null; } if ($pos.parentOffset == $pos.parent.content.size) for (let d = $pos.depth - 1; d >= 0; d--) { let index = $pos.indexAfter(d); if ($pos.node(d).canReplaceWith(index, index, nodeType)) return $pos.after(d + 1); if (index < $pos.node(d).childCount) return null; } return null; } /** Finds a position at or around the given position where the given slice can be inserted. Will look at parent nodes' nearest boundary and try there, even if the original position wasn't directly at the start or end of that node. Returns null when no position was found. */ function dropPoint(doc, pos, slice) { let $pos = doc.resolve(pos); if (!slice.content.size) return pos; let content = slice.content; for (let i = 0; i < slice.openStart; i++) content = content.firstChild.content; for (let pass = 1; pass <= (slice.openStart == 0 && slice.size ? 2 : 1); pass++) { for (let d = $pos.depth; d >= 0; d--) { let bias = d == $pos.depth ? 0 : $pos.pos <= ($pos.start(d + 1) + $pos.end(d + 1)) / 2 ? -1 : 1; let insertPos = $pos.index(d) + (bias > 0 ? 1 : 0); let parent = $pos.node(d), fits = false; if (pass == 1) { fits = parent.canReplace(insertPos, insertPos, content); } else { let wrapping = parent.contentMatchAt(insertPos).findWrapping(content.firstChild.type); fits = wrapping && parent.canReplaceWith(insertPos, insertPos, wrapping[0]); } if (fits) return bias == 0 ? $pos.pos : bias < 0 ? $pos.before(d + 1) : $pos.after(d + 1); } } return null; } /** ‘Fit’ a slice into a given position in the document, producing a [step](https://prosemirror.net/docs/ref/#transform.Step) that inserts it. Will return null if there's no meaningful way to insert the slice here, or inserting it would be a no-op (an empty slice over an empty range). */ function replaceStep(doc, from, to = from, slice = Slice.empty) { if (from == to && !slice.size) return null; let $from = doc.resolve(from), $to = doc.resolve(to); // Optimization -- avoid work if it's obvious that it's not needed. if (fitsTrivially($from, $to, slice)) return new ReplaceStep(from, to, slice); return new Fitter($from, $to, slice).fit(); } function fitsTrivially($from, $to, slice) { return !slice.openStart && !slice.openEnd && $from.start() == $to.start() && $from.parent.canReplace($from.index(), $to.index(), slice.content); } // Algorithm for 'placing' the elements of a slice into a gap: // // We consider the content of each node that is open to the left to be // independently placeable. I.e. in , when the // paragraph on the left is open, "foo" can be placed (somewhere on // the left side of the replacement gap) independently from p("bar"). // // This class tracks the state of the placement progress in the // following properties: // // - `frontier` holds a stack of `{type, match}` objects that // represent the open side of the replacement. It starts at // `$from`, then moves forward as content is placed, and is finally // reconciled with `$to`. // // - `unplaced` is a slice that represents the content that hasn't // been placed yet. // // - `placed` is a fragment of placed content. Its open-start value // is implicit in `$from`, and its open-end value in `frontier`. class Fitter { constructor($from, $to, unplaced) { this.$from = $from; this.$to = $to; this.unplaced = unplaced; this.frontier = []; this.placed = Fragment.empty; for (let i = 0; i <= $from.depth; i++) { let node = $from.node(i); this.frontier.push({ type: node.type, match: node.contentMatchAt($from.indexAfter(i)) }); } for (let i = $from.depth; i > 0; i--) this.placed = Fragment.from($from.node(i).copy(this.placed)); } get depth() { return this.frontier.length - 1; } fit() { // As long as there's unplaced content, try to place some of it. // If that fails, either increase the open score of the unplaced // slice, or drop nodes from it, and then try again. while (this.unplaced.size) { let fit = this.findFittable(); if (fit) this.placeNodes(fit); else this.openMore() || this.dropNode(); } // When there's inline content directly after the frontier _and_ // directly after `this.$to`, we must generate a `ReplaceAround` // step that pulls that content into the node after the frontier. // That means the fitting must be done to the end of the textblock // node after `this.$to`, not `this.$to` itself. let moveInline = this.mustMoveInline(), placedSize = this.placed.size - this.depth - this.$from.depth; let $from = this.$from, $to = this.close(moveInline < 0 ? this.$to : $from.doc.resolve(moveInline)); if (!$to) return null; // If closing to `$to` succeeded, create a step let content = this.placed, openStart = $from.depth, openEnd = $to.depth; while (openStart && openEnd && content.childCount == 1) { // Normalize by dropping open parent nodes content = content.firstChild.content; openStart--; openEnd--; } let slice = new Slice(content, openStart, openEnd); if (moveInline > -1) return new ReplaceAroundStep($from.pos, moveInline, this.$to.pos, this.$to.end(), slice, placedSize); if (slice.size || $from.pos != this.$to.pos) // Don't generate no-op steps return new ReplaceStep($from.pos, $to.pos, slice); return null; } // Find a position on the start spine of `this.unplaced` that has // content that can be moved somewhere on the frontier. Returns two // depths, one for the slice and one for the frontier. findFittable() { let startDepth = this.unplaced.openStart; for (let cur = this.unplaced.content, d = 0, openEnd = this.unplaced.openEnd; d < startDepth; d++) { let node = cur.firstChild; if (cur.childCount > 1) openEnd = 0; if (node.type.spec.isolating && openEnd <= d) { startDepth = d; break; } cur = node.content; } // Only try wrapping nodes (pass 2) after finding a place without // wrapping failed. for (let pass = 1; pass <= 2; pass++) { for (let sliceDepth = pass == 1 ? startDepth : this.unplaced.openStart; sliceDepth >= 0; sliceDepth--) { let fragment, parent = null; if (sliceDepth) { parent = contentAt(this.unplaced.content, sliceDepth - 1).firstChild; fragment = parent.content; } else { fragment = this.unplaced.content; } let first = fragment.firstChild; for (let frontierDepth = this.depth; frontierDepth >= 0; frontierDepth--) { let { type, match } = this.frontier[frontierDepth], wrap, inject = null; // In pass 1, if the next node matches, or there is no next // node but the parents look compatible, we've found a // place. if (pass == 1 && (first ? match.matchType(first.type) || (inject = match.fillBefore(Fragment.from(first), false)) : parent && type.compatibleContent(parent.type))) return { sliceDepth, frontierDepth, parent, inject }; // In pass 2, look for a set of wrapping nodes that make // `first` fit here. else if (pass == 2 && first && (wrap = match.findWrapping(first.type))) return { sliceDepth, frontierDepth, parent, wrap }; // Don't continue looking further up if the parent node // would fit here. if (parent && match.matchType(parent.type)) break; } } } } openMore() { let { content, openStart, openEnd } = this.unplaced; let inner = contentAt(content, openStart); if (!inner.childCount || inner.firstChild.isLeaf) return false; this.unplaced = new Slice(content, openStart + 1, Math.max(openEnd, inner.size + openStart >= content.size - openEnd ? openStart + 1 : 0)); return true; } dropNode() { let { content, openStart, openEnd } = this.unplaced; let inner = contentAt(content, openStart); if (inner.childCount <= 1 && openStart > 0) { let openAtEnd = content.size - openStart <= openStart + inner.size; this.unplaced = new Slice(dropFromFragment(content, openStart - 1, 1), openStart - 1, openAtEnd ? openStart - 1 : openEnd); } else { this.unplaced = new Slice(dropFromFragment(content, openStart, 1), openStart, openEnd); } } // Move content from the unplaced slice at `sliceDepth` to the // frontier node at `frontierDepth`. Close that frontier node when // applicable. placeNodes({ sliceDepth, frontierDepth, parent, inject, wrap }) { while (this.depth > frontierDepth) this.closeFrontierNode(); if (wrap) for (let i = 0; i < wrap.length; i++) this.openFrontierNode(wrap[i]); let slice = this.unplaced, fragment = parent ? parent.content : slice.content; let openStart = slice.openStart - sliceDepth; let taken = 0, add = []; let { match, type } = this.frontier[frontierDepth]; if (inject) { for (let i = 0; i < inject.childCount; i++) add.push(inject.child(i)); match = match.matchFragment(inject); } // Computes the amount of (end) open nodes at the end of the // fragment. When 0, the parent is open, but no more. When // negative, nothing is open. let openEndCount = (fragment.size + sliceDepth) - (slice.content.size - slice.openEnd); // Scan over the fragment, fitting as many child nodes as // possible. while (taken < fragment.childCount) { let next = fragment.child(taken), matches = match.matchType(next.type); if (!matches) break; taken++; if (taken > 1 || openStart == 0 || next.content.size) { // Drop empty open nodes match = matches; add.push(closeNodeStart(next.mark(type.allowedMarks(next.marks)), taken == 1 ? openStart : 0, taken == fragment.childCount ? openEndCount : -1)); } } let toEnd = taken == fragment.childCount; if (!toEnd) openEndCount = -1; this.placed = addToFragment(this.placed, frontierDepth, Fragment.from(add)); this.frontier[frontierDepth].match = match; // If the parent types match, and the entire node was moved, and // it's not open, close this frontier node right away. if (toEnd && openEndCount < 0 && parent && parent.type == this.frontier[this.depth].type && this.frontier.length > 1) this.closeFrontierNode(); // Add new frontier nodes for any open nodes at the end. for (let i = 0, cur = fragment; i < openEndCount; i++) { let node = cur.lastChild; this.frontier.push({ type: node.type, match: node.contentMatchAt(node.childCount) }); cur = node.content; } // Update `this.unplaced`. Drop the entire node from which we // placed it we got to its end, otherwise just drop the placed // nodes. this.unplaced = !toEnd ? new Slice(dropFromFragment(slice.content, sliceDepth, taken), slice.openStart, slice.openEnd) : sliceDepth == 0 ? Slice.empty : new Slice(dropFromFragment(slice.content, sliceDepth - 1, 1), sliceDepth - 1, openEndCount < 0 ? slice.openEnd : sliceDepth - 1); } mustMoveInline() { if (!this.$to.parent.isTextblock) return -1; let top = this.frontier[this.depth], level; if (!top.type.isTextblock || !contentAfterFits(this.$to, this.$to.depth, top.type, top.match, false) || (this.$to.depth == this.depth && (level = this.findCloseLevel(this.$to)) && level.depth == this.depth)) return -1; let { depth } = this.$to, after = this.$to.after(depth); while (depth > 1 && after == this.$to.end(--depth)) ++after; return after; } findCloseLevel($to) { scan: for (let i = Math.min(this.depth, $to.depth); i >= 0; i--) { let { match, type } = this.frontier[i]; let dropInner = i < $to.depth && $to.end(i + 1) == $to.pos + ($to.depth - (i + 1)); let fit = contentAfterFits($to, i, type, match, dropInner); if (!fit) continue; for (let d = i - 1; d >= 0; d--) { let { match, type } = this.frontier[d]; let matches = contentAfterFits($to, d, type, match, true); if (!matches || matches.childCount) continue scan; } return { depth: i, fit, move: dropInner ? $to.doc.resolve($to.after(i + 1)) : $to }; } } close($to) { let close = this.findCloseLevel($to); if (!close) return null; while (this.depth > close.depth) this.closeFrontierNode(); if (close.fit.childCount) this.placed = addToFragment(this.placed, close.depth, close.fit); $to = close.move; for (let d = close.depth + 1; d <= $to.depth; d++) { let node = $to.node(d), add = node.type.contentMatch.fillBefore(node.content, true, $to.index(d)); this.openFrontierNode(node.type, node.attrs, add); } return $to; } openFrontierNode(type, attrs = null, content) { let top = this.frontier[this.depth]; top.match = top.match.matchType(type); this.placed = addToFragment(this.placed, this.depth, Fragment.from(type.create(attrs, content))); this.frontier.push({ type, match: type.contentMatch }); } closeFrontierNode() { let open = this.frontier.pop(); let add = open.match.fillBefore(Fragment.empty, true); if (add.childCount) this.placed = addToFragment(this.placed, this.frontier.length, add); } } function dropFromFragment(fragment, depth, count) { if (depth == 0) return fragment.cutByIndex(count, fragment.childCount); return fragment.replaceChild(0, fragment.firstChild.copy(dropFromFragment(fragment.firstChild.content, depth - 1, count))); } function addToFragment(fragment, depth, content) { if (depth == 0) return fragment.append(content); return fragment.replaceChild(fragment.childCount - 1, fragment.lastChild.copy(addToFragment(fragment.lastChild.content, depth - 1, content))); } function contentAt(fragment, depth) { for (let i = 0; i < depth; i++) fragment = fragment.firstChild.content; return fragment; } function closeNodeStart(node, openStart, openEnd) { if (openStart <= 0) return node; let frag = node.content; if (openStart > 1) frag = frag.replaceChild(0, closeNodeStart(frag.firstChild, openStart - 1, frag.childCount == 1 ? openEnd - 1 : 0)); if (openStart > 0) { frag = node.type.contentMatch.fillBefore(frag).append(frag); if (openEnd <= 0) frag = frag.append(node.type.contentMatch.matchFragment(frag).fillBefore(Fragment.empty, true)); } return node.copy(frag); } function contentAfterFits($to, depth, type, match, open) { let node = $to.node(depth), index = open ? $to.indexAfter(depth) : $to.index(depth); if (index == node.childCount && !type.compatibleContent(node.type)) return null; let fit = match.fillBefore(node.content, true, index); return fit && !invalidMarks(type, node.content, index) ? fit : null; } function invalidMarks(type, fragment, start) { for (let i = start; i < fragment.childCount; i++) if (!type.allowsMarks(fragment.child(i).marks)) return true; return false; } function definesContent(type) { return type.spec.defining || type.spec.definingForContent; } function replaceRange(tr, from, to, slice) { if (!slice.size) return tr.deleteRange(from, to); let $from = tr.doc.resolve(from), $to = tr.doc.resolve(to); if (fitsTrivially($from, $to, slice)) return tr.step(new ReplaceStep(from, to, slice)); let targetDepths = coveredDepths($from, tr.doc.resolve(to)); // Can't replace the whole document, so remove 0 if it's present if (targetDepths[targetDepths.length - 1] == 0) targetDepths.pop(); // Negative numbers represent not expansion over the whole node at // that depth, but replacing from $from.before(-D) to $to.pos. let preferredTarget = -($from.depth + 1); targetDepths.unshift(preferredTarget); // This loop picks a preferred target depth, if one of the covering // depths is not outside of a defining node, and adds negative // depths for any depth that has $from at its start and does not // cross a defining node. for (let d = $from.depth, pos = $from.pos - 1; d > 0; d--, pos--) { let spec = $from.node(d).type.spec; if (spec.defining || spec.definingAsContext || spec.isolating) break; if (targetDepths.indexOf(d) > -1) preferredTarget = d; else if ($from.before(d) == pos) targetDepths.splice(1, 0, -d); } // Try to fit each possible depth of the slice into each possible // target depth, starting with the preferred depths. let preferredTargetIndex = targetDepths.indexOf(preferredTarget); let leftNodes = [], preferredDepth = slice.openStart; for (let content = slice.content, i = 0;; i++) { let node = content.firstChild; leftNodes.push(node); if (i == slice.openStart) break; content = node.content; } // Back up preferredDepth to cover defining textblocks directly // above it, possibly skipping a non-defining textblock. for (let d = preferredDepth - 1; d >= 0; d--) { let leftNode = leftNodes[d], def = definesContent(leftNode.type); if (def && !leftNode.sameMarkup($from.node(Math.abs(preferredTarget) - 1))) preferredDepth = d; else if (def || !leftNode.type.isTextblock) break; } for (let j = slice.openStart; j >= 0; j--) { let openDepth = (j + preferredDepth + 1) % (slice.openStart + 1); let insert = leftNodes[openDepth]; if (!insert) continue; for (let i = 0; i < targetDepths.length; i++) { // Loop over possible expansion levels, starting with the // preferred one let targetDepth = targetDepths[(i + preferredTargetIndex) % targetDepths.length], expand = true; if (targetDepth < 0) { expand = false; targetDepth = -targetDepth; } let parent = $from.node(targetDepth - 1), index = $from.index(targetDepth - 1); if (parent.canReplaceWith(index, index, insert.type, insert.marks)) return tr.replace($from.before(targetDepth), expand ? $to.after(targetDepth) : to, new Slice(closeFragment(slice.content, 0, slice.openStart, openDepth), openDepth, slice.openEnd)); } } let startSteps = tr.steps.length; for (let i = targetDepths.length - 1; i >= 0; i--) { tr.replace(from, to, slice); if (tr.steps.length > startSteps) break; let depth = targetDepths[i]; if (depth < 0) continue; from = $from.before(depth); to = $to.after(depth); } } function closeFragment(fragment, depth, oldOpen, newOpen, parent) { if (depth < oldOpen) { let first = fragment.firstChild; fragment = fragment.replaceChild(0, first.copy(closeFragment(first.content, depth + 1, oldOpen, newOpen, first))); } if (depth > newOpen) { let match = parent.contentMatchAt(0); let start = match.fillBefore(fragment).append(fragment); fragment = start.append(match.matchFragment(start).fillBefore(Fragment.empty, true)); } return fragment; } function replaceRangeWith(tr, from, to, node) { if (!node.isInline && from == to && tr.doc.resolve(from).parent.content.size) { let point = insertPoint(tr.doc, from, node.type); if (point != null) from = to = point; } tr.replaceRange(from, to, new Slice(Fragment.from(node), 0, 0)); } function deleteRange(tr, from, to) { let $from = tr.doc.resolve(from), $to = tr.doc.resolve(to); let covered = coveredDepths($from, $to); for (let i = 0; i < covered.length; i++) { let depth = covered[i], last = i == covered.length - 1; if ((last && depth == 0) || $from.node(depth).type.contentMatch.validEnd) return tr.delete($from.start(depth), $to.end(depth)); if (depth > 0 && (last || $from.node(depth - 1).canReplace($from.index(depth - 1), $to.indexAfter(depth - 1)))) return tr.delete($from.before(depth), $to.after(depth)); } for (let d = 1; d <= $from.depth && d <= $to.depth; d++) { if (from - $from.start(d) == $from.depth - d && to > $from.end(d) && $to.end(d) - to != $to.depth - d) return tr.delete($from.before(d), to); } tr.delete(from, to); } // Returns an array of all depths for which $from - $to spans the // whole content of the nodes at that depth. function coveredDepths($from, $to) { let result = [], minDepth = Math.min($from.depth, $to.depth); for (let d = minDepth; d >= 0; d--) { let start = $from.start(d); if (start < $from.pos - ($from.depth - d) || $to.end(d) > $to.pos + ($to.depth - d) || $from.node(d).type.spec.isolating || $to.node(d).type.spec.isolating) break; if (start == $to.start(d) || (d == $from.depth && d == $to.depth && $from.parent.inlineContent && $to.parent.inlineContent && d && $to.start(d - 1) == start - 1)) result.push(d); } return result; } /** Update an attribute in a specific node. */ class AttrStep extends Step { /** Construct an attribute step. */ constructor( /** The position of the target node. */ pos, /** The attribute to set. */ attr, // The attribute's new value. value) { super(); this.pos = pos; this.attr = attr; this.value = value; } apply(doc) { let node = doc.nodeAt(this.pos); if (!node) return StepResult.fail("No node at attribute step's position"); let attrs = Object.create(null); for (let name in node.attrs) attrs[name] = node.attrs[name]; attrs[this.attr] = this.value; let updated = node.type.create(attrs, null, node.marks); return StepResult.fromReplace(doc, this.pos, this.pos + 1, new Slice(Fragment.from(updated), 0, node.isLeaf ? 0 : 1)); } getMap() { return StepMap.empty; } invert(doc) { return new AttrStep(this.pos, this.attr, doc.nodeAt(this.pos).attrs[this.attr]); } map(mapping) { let pos = mapping.mapResult(this.pos, 1); return pos.deletedAfter ? null : new AttrStep(pos.pos, this.attr, this.value); } toJSON() { return { stepType: "attr", pos: this.pos, attr: this.attr, value: this.value }; } static fromJSON(schema, json) { if (typeof json.pos != "number" || typeof json.attr != "string") throw new RangeError("Invalid input for AttrStep.fromJSON"); return new AttrStep(json.pos, json.attr, json.value); } } Step.jsonID("attr", AttrStep); /** Update an attribute in the doc node. */ class DocAttrStep extends Step { /** Construct an attribute step. */ constructor( /** The attribute to set. */ attr, // The attribute's new value. value) { super(); this.attr = attr; this.value = value; } apply(doc) { let attrs = Object.create(null); for (let name in doc.attrs) attrs[name] = doc.attrs[name]; attrs[this.attr] = this.value; let updated = doc.type.create(attrs, doc.content, doc.marks); return StepResult.ok(updated); } getMap() { return StepMap.empty; } invert(doc) { return new DocAttrStep(this.attr, doc.attrs[this.attr]); } map(mapping) { return this; } toJSON() { return { stepType: "docAttr", attr: this.attr, value: this.value }; } static fromJSON(schema, json) { if (typeof json.attr != "string") throw new RangeError("Invalid input for DocAttrStep.fromJSON"); return new DocAttrStep(json.attr, json.value); } } Step.jsonID("docAttr", DocAttrStep); /** @internal */ let TransformError = class extends Error { }; TransformError = function TransformError(message) { let err = Error.call(this, message); err.__proto__ = TransformError.prototype; return err; }; TransformError.prototype = Object.create(Error.prototype); TransformError.prototype.constructor = TransformError; TransformError.prototype.name = "TransformError"; /** Abstraction to build up and track an array of [steps](https://prosemirror.net/docs/ref/#transform.Step) representing a document transformation. Most transforming methods return the `Transform` object itself, so that they can be chained. */ class Transform { /** Create a transform that starts with the given document. */ constructor( /** The current document (the result of applying the steps in the transform). */ doc) { this.doc = doc; /** The steps in this transform. */ this.steps = []; /** The documents before each of the steps. */ this.docs = []; /** A mapping with the maps for each of the steps in this transform. */ this.mapping = new Mapping; } /** The starting document. */ get before() { return this.docs.length ? this.docs[0] : this.doc; } /** Apply a new step in this transform, saving the result. Throws an error when the step fails. */ step(step) { let result = this.maybeStep(step); if (result.failed) throw new TransformError(result.failed); return this; } /** Try to apply a step in this transformation, ignoring it if it fails. Returns the step result. */ maybeStep(step) { let result = step.apply(this.doc); if (!result.failed) this.addStep(step, result.doc); return result; } /** True when the document has been changed (when there are any steps). */ get docChanged() { return this.steps.length > 0; } /** @internal */ addStep(step, doc) { this.docs.push(this.doc); this.steps.push(step); this.mapping.appendMap(step.getMap()); this.doc = doc; } /** Replace the part of the document between `from` and `to` with the given `slice`. */ replace(from, to = from, slice = Slice.empty) { let step = replaceStep(this.doc, from, to, slice); if (step) this.step(step); return this; } /** Replace the given range with the given content, which may be a fragment, node, or array of nodes. */ replaceWith(from, to, content) { return this.replace(from, to, new Slice(Fragment.from(content), 0, 0)); } /** Delete the content between the given positions. */ delete(from, to) { return this.replace(from, to, Slice.empty); } /** Insert the given content at the given position. */ insert(pos, content) { return this.replaceWith(pos, pos, content); } /** Replace a range of the document with a given slice, using `from`, `to`, and the slice's [`openStart`](https://prosemirror.net/docs/ref/#model.Slice.openStart) property as hints, rather than fixed start and end points. This method may grow the replaced area or close open nodes in the slice in order to get a fit that is more in line with WYSIWYG expectations, by dropping fully covered parent nodes of the replaced region when they are marked [non-defining as context](https://prosemirror.net/docs/ref/#model.NodeSpec.definingAsContext), or including an open parent node from the slice that _is_ marked as [defining its content](https://prosemirror.net/docs/ref/#model.NodeSpec.definingForContent). This is the method, for example, to handle paste. The similar [`replace`](https://prosemirror.net/docs/ref/#transform.Transform.replace) method is a more primitive tool which will _not_ move the start and end of its given range, and is useful in situations where you need more precise control over what happens. */ replaceRange(from, to, slice) { replaceRange(this, from, to, slice); return this; } /** Replace the given range with a node, but use `from` and `to` as hints, rather than precise positions. When from and to are the same and are at the start or end of a parent node in which the given node doesn't fit, this method may _move_ them out towards a parent that does allow the given node to be placed. When the given range completely covers a parent node, this method may completely replace that parent node. */ replaceRangeWith(from, to, node) { replaceRangeWith(this, from, to, node); return this; } /** Delete the given range, expanding it to cover fully covered parent nodes until a valid replace is found. */ deleteRange(from, to) { deleteRange(this, from, to); return this; } /** Split the content in the given range off from its parent, if there is sibling content before or after it, and move it up the tree to the depth specified by `target`. You'll probably want to use [`liftTarget`](https://prosemirror.net/docs/ref/#transform.liftTarget) to compute `target`, to make sure the lift is valid. */ lift(range, target) { lift$1(this, range, target); return this; } /** Join the blocks around the given position. If depth is 2, their last and first siblings are also joined, and so on. */ join(pos, depth = 1) { join(this, pos, depth); return this; } /** Wrap the given [range](https://prosemirror.net/docs/ref/#model.NodeRange) in the given set of wrappers. The wrappers are assumed to be valid in this position, and should probably be computed with [`findWrapping`](https://prosemirror.net/docs/ref/#transform.findWrapping). */ wrap(range, wrappers) { wrap(this, range, wrappers); return this; } /** Set the type of all textblocks (partly) between `from` and `to` to the given node type with the given attributes. */ setBlockType(from, to = from, type, attrs = null) { setBlockType$1(this, from, to, type, attrs); return this; } /** Change the type, attributes, and/or marks of the node at `pos`. When `type` isn't given, the existing node type is preserved, */ setNodeMarkup(pos, type, attrs = null, marks) { setNodeMarkup(this, pos, type, attrs, marks); return this; } /** Set a single attribute on a given node to a new value. The `pos` addresses the document content. Use `setDocAttribute` to set attributes on the document itself. */ setNodeAttribute(pos, attr, value) { this.step(new AttrStep(pos, attr, value)); return this; } /** Set a single attribute on the document to a new value. */ setDocAttribute(attr, value) { this.step(new DocAttrStep(attr, value)); return this; } /** Add a mark to the node at position `pos`. */ addNodeMark(pos, mark) { this.step(new AddNodeMarkStep(pos, mark)); return this; } /** Remove a mark (or a mark of the given type) from the node at position `pos`. */ removeNodeMark(pos, mark) { if (!(mark instanceof Mark)) { let node = this.doc.nodeAt(pos); if (!node) throw new RangeError("No node at position " + pos); mark = mark.isInSet(node.marks); if (!mark) return this; } this.step(new RemoveNodeMarkStep(pos, mark)); return this; } /** Split the node at the given position, and optionally, if `depth` is greater than one, any number of nodes above that. By default, the parts split off will inherit the node type of the original node. This can be changed by passing an array of types and attributes to use after the split. */ split(pos, depth = 1, typesAfter) { split(this, pos, depth, typesAfter); return this; } /** Add the given mark to the inline content between `from` and `to`. */ addMark(from, to, mark) { addMark(this, from, to, mark); return this; } /** Remove marks from inline nodes between `from` and `to`. When `mark` is a single mark, remove precisely that mark. When it is a mark type, remove all marks of that type. When it is null, remove all marks of any type. */ removeMark(from, to, mark) { removeMark(this, from, to, mark); return this; } /** Removes all marks and nodes from the content of the node at `pos` that don't match the given new parent node type. Accepts an optional starting [content match](https://prosemirror.net/docs/ref/#model.ContentMatch) as third argument. */ clearIncompatible(pos, parentType, match) { clearIncompatible(this, pos, parentType, match); return this; } } var index$6 = /*#__PURE__*/Object.freeze({ __proto__: null, AddMarkStep: AddMarkStep, AddNodeMarkStep: AddNodeMarkStep, AttrStep: AttrStep, DocAttrStep: DocAttrStep, MapResult: MapResult, Mapping: Mapping, RemoveMarkStep: RemoveMarkStep, RemoveNodeMarkStep: RemoveNodeMarkStep, ReplaceAroundStep: ReplaceAroundStep, ReplaceStep: ReplaceStep, Step: Step, StepMap: StepMap, StepResult: StepResult, Transform: Transform, get TransformError () { return TransformError; }, canJoin: canJoin, canSplit: canSplit, dropPoint: dropPoint, findWrapping: findWrapping, insertPoint: insertPoint, joinPoint: joinPoint, liftTarget: liftTarget, replaceStep: replaceStep }); const classesById = Object.create(null); /** Superclass for editor selections. Every selection type should extend this. Should not be instantiated directly. */ class Selection { /** Initialize a selection with the head and anchor and ranges. If no ranges are given, constructs a single range across `$anchor` and `$head`. */ constructor( /** The resolved anchor of the selection (the side that stays in place when the selection is modified). */ $anchor, /** The resolved head of the selection (the side that moves when the selection is modified). */ $head, ranges) { this.$anchor = $anchor; this.$head = $head; this.ranges = ranges || [new SelectionRange($anchor.min($head), $anchor.max($head))]; } /** The selection's anchor, as an unresolved position. */ get anchor() { return this.$anchor.pos; } /** The selection's head. */ get head() { return this.$head.pos; } /** The lower bound of the selection's main range. */ get from() { return this.$from.pos; } /** The upper bound of the selection's main range. */ get to() { return this.$to.pos; } /** The resolved lower bound of the selection's main range. */ get $from() { return this.ranges[0].$from; } /** The resolved upper bound of the selection's main range. */ get $to() { return this.ranges[0].$to; } /** Indicates whether the selection contains any content. */ get empty() { let ranges = this.ranges; for (let i = 0; i < ranges.length; i++) if (ranges[i].$from.pos != ranges[i].$to.pos) return false; return true; } /** Get the content of this selection as a slice. */ content() { return this.$from.doc.slice(this.from, this.to, true); } /** Replace the selection with a slice or, if no slice is given, delete the selection. Will append to the given transaction. */ replace(tr, content = Slice.empty) { // Put the new selection at the position after the inserted // content. When that ended in an inline node, search backwards, // to get the position after that node. If not, search forward. let lastNode = content.content.lastChild, lastParent = null; for (let i = 0; i < content.openEnd; i++) { lastParent = lastNode; lastNode = lastNode.lastChild; } let mapFrom = tr.steps.length, ranges = this.ranges; for (let i = 0; i < ranges.length; i++) { let { $from, $to } = ranges[i], mapping = tr.mapping.slice(mapFrom); tr.replaceRange(mapping.map($from.pos), mapping.map($to.pos), i ? Slice.empty : content); if (i == 0) selectionToInsertionEnd(tr, mapFrom, (lastNode ? lastNode.isInline : lastParent && lastParent.isTextblock) ? -1 : 1); } } /** Replace the selection with the given node, appending the changes to the given transaction. */ replaceWith(tr, node) { let mapFrom = tr.steps.length, ranges = this.ranges; for (let i = 0; i < ranges.length; i++) { let { $from, $to } = ranges[i], mapping = tr.mapping.slice(mapFrom); let from = mapping.map($from.pos), to = mapping.map($to.pos); if (i) { tr.deleteRange(from, to); } else { tr.replaceRangeWith(from, to, node); selectionToInsertionEnd(tr, mapFrom, node.isInline ? -1 : 1); } } } /** Find a valid cursor or leaf node selection starting at the given position and searching back if `dir` is negative, and forward if positive. When `textOnly` is true, only consider cursor selections. Will return null when no valid selection position is found. */ static findFrom($pos, dir, textOnly = false) { let inner = $pos.parent.inlineContent ? new TextSelection($pos) : findSelectionIn($pos.node(0), $pos.parent, $pos.pos, $pos.index(), dir, textOnly); if (inner) return inner; for (let depth = $pos.depth - 1; depth >= 0; depth--) { let found = dir < 0 ? findSelectionIn($pos.node(0), $pos.node(depth), $pos.before(depth + 1), $pos.index(depth), dir, textOnly) : findSelectionIn($pos.node(0), $pos.node(depth), $pos.after(depth + 1), $pos.index(depth) + 1, dir, textOnly); if (found) return found; } return null; } /** Find a valid cursor or leaf node selection near the given position. Searches forward first by default, but if `bias` is negative, it will search backwards first. */ static near($pos, bias = 1) { return this.findFrom($pos, bias) || this.findFrom($pos, -bias) || new AllSelection($pos.node(0)); } /** Find the cursor or leaf node selection closest to the start of the given document. Will return an [`AllSelection`](https://prosemirror.net/docs/ref/#state.AllSelection) if no valid position exists. */ static atStart(doc) { return findSelectionIn(doc, doc, 0, 0, 1) || new AllSelection(doc); } /** Find the cursor or leaf node selection closest to the end of the given document. */ static atEnd(doc) { return findSelectionIn(doc, doc, doc.content.size, doc.childCount, -1) || new AllSelection(doc); } /** Deserialize the JSON representation of a selection. Must be implemented for custom classes (as a static class method). */ static fromJSON(doc, json) { if (!json || !json.type) throw new RangeError("Invalid input for Selection.fromJSON"); let cls = classesById[json.type]; if (!cls) throw new RangeError(`No selection type ${json.type} defined`); return cls.fromJSON(doc, json); } /** To be able to deserialize selections from JSON, custom selection classes must register themselves with an ID string, so that they can be disambiguated. Try to pick something that's unlikely to clash with classes from other modules. */ static jsonID(id, selectionClass) { if (id in classesById) throw new RangeError("Duplicate use of selection JSON ID " + id); classesById[id] = selectionClass; selectionClass.prototype.jsonID = id; return selectionClass; } /** Get a [bookmark](https://prosemirror.net/docs/ref/#state.SelectionBookmark) for this selection, which is a value that can be mapped without having access to a current document, and later resolved to a real selection for a given document again. (This is used mostly by the history to track and restore old selections.) The default implementation of this method just converts the selection to a text selection and returns the bookmark for that. */ getBookmark() { return TextSelection.between(this.$anchor, this.$head).getBookmark(); } } Selection.prototype.visible = true; /** Represents a selected range in a document. */ class SelectionRange { /** Create a range. */ constructor( /** The lower bound of the range. */ $from, /** The upper bound of the range. */ $to) { this.$from = $from; this.$to = $to; } } let warnedAboutTextSelection = false; function checkTextSelection($pos) { if (!warnedAboutTextSelection && !$pos.parent.inlineContent) { warnedAboutTextSelection = true; console["warn"]("TextSelection endpoint not pointing into a node with inline content (" + $pos.parent.type.name + ")"); } } /** A text selection represents a classical editor selection, with a head (the moving side) and anchor (immobile side), both of which point into textblock nodes. It can be empty (a regular cursor position). */ class TextSelection extends Selection { /** Construct a text selection between the given points. */ constructor($anchor, $head = $anchor) { checkTextSelection($anchor); checkTextSelection($head); super($anchor, $head); } /** Returns a resolved position if this is a cursor selection (an empty text selection), and null otherwise. */ get $cursor() { return this.$anchor.pos == this.$head.pos ? this.$head : null; } map(doc, mapping) { let $head = doc.resolve(mapping.map(this.head)); if (!$head.parent.inlineContent) return Selection.near($head); let $anchor = doc.resolve(mapping.map(this.anchor)); return new TextSelection($anchor.parent.inlineContent ? $anchor : $head, $head); } replace(tr, content = Slice.empty) { super.replace(tr, content); if (content == Slice.empty) { let marks = this.$from.marksAcross(this.$to); if (marks) tr.ensureMarks(marks); } } eq(other) { return other instanceof TextSelection && other.anchor == this.anchor && other.head == this.head; } getBookmark() { return new TextBookmark(this.anchor, this.head); } toJSON() { return { type: "text", anchor: this.anchor, head: this.head }; } /** @internal */ static fromJSON(doc, json) { if (typeof json.anchor != "number" || typeof json.head != "number") throw new RangeError("Invalid input for TextSelection.fromJSON"); return new TextSelection(doc.resolve(json.anchor), doc.resolve(json.head)); } /** Create a text selection from non-resolved positions. */ static create(doc, anchor, head = anchor) { let $anchor = doc.resolve(anchor); return new this($anchor, head == anchor ? $anchor : doc.resolve(head)); } /** Return a text selection that spans the given positions or, if they aren't text positions, find a text selection near them. `bias` determines whether the method searches forward (default) or backwards (negative number) first. Will fall back to calling [`Selection.near`](https://prosemirror.net/docs/ref/#state.Selection^near) when the document doesn't contain a valid text position. */ static between($anchor, $head, bias) { let dPos = $anchor.pos - $head.pos; if (!bias || dPos) bias = dPos >= 0 ? 1 : -1; if (!$head.parent.inlineContent) { let found = Selection.findFrom($head, bias, true) || Selection.findFrom($head, -bias, true); if (found) $head = found.$head; else return Selection.near($head, bias); } if (!$anchor.parent.inlineContent) { if (dPos == 0) { $anchor = $head; } else { $anchor = (Selection.findFrom($anchor, -bias, true) || Selection.findFrom($anchor, bias, true)).$anchor; if (($anchor.pos < $head.pos) != (dPos < 0)) $anchor = $head; } } return new TextSelection($anchor, $head); } } Selection.jsonID("text", TextSelection); class TextBookmark { constructor(anchor, head) { this.anchor = anchor; this.head = head; } map(mapping) { return new TextBookmark(mapping.map(this.anchor), mapping.map(this.head)); } resolve(doc) { return TextSelection.between(doc.resolve(this.anchor), doc.resolve(this.head)); } } /** A node selection is a selection that points at a single node. All nodes marked [selectable](https://prosemirror.net/docs/ref/#model.NodeSpec.selectable) can be the target of a node selection. In such a selection, `from` and `to` point directly before and after the selected node, `anchor` equals `from`, and `head` equals `to`.. */ class NodeSelection extends Selection { /** Create a node selection. Does not verify the validity of its argument. */ constructor($pos) { let node = $pos.nodeAfter; let $end = $pos.node(0).resolve($pos.pos + node.nodeSize); super($pos, $end); this.node = node; } map(doc, mapping) { let { deleted, pos } = mapping.mapResult(this.anchor); let $pos = doc.resolve(pos); if (deleted) return Selection.near($pos); return new NodeSelection($pos); } content() { return new Slice(Fragment.from(this.node), 0, 0); } eq(other) { return other instanceof NodeSelection && other.anchor == this.anchor; } toJSON() { return { type: "node", anchor: this.anchor }; } getBookmark() { return new NodeBookmark(this.anchor); } /** @internal */ static fromJSON(doc, json) { if (typeof json.anchor != "number") throw new RangeError("Invalid input for NodeSelection.fromJSON"); return new NodeSelection(doc.resolve(json.anchor)); } /** Create a node selection from non-resolved positions. */ static create(doc, from) { return new NodeSelection(doc.resolve(from)); } /** Determines whether the given node may be selected as a node selection. */ static isSelectable(node) { return !node.isText && node.type.spec.selectable !== false; } } NodeSelection.prototype.visible = false; Selection.jsonID("node", NodeSelection); class NodeBookmark { constructor(anchor) { this.anchor = anchor; } map(mapping) { let { deleted, pos } = mapping.mapResult(this.anchor); return deleted ? new TextBookmark(pos, pos) : new NodeBookmark(pos); } resolve(doc) { let $pos = doc.resolve(this.anchor), node = $pos.nodeAfter; if (node && NodeSelection.isSelectable(node)) return new NodeSelection($pos); return Selection.near($pos); } } /** A selection type that represents selecting the whole document (which can not necessarily be expressed with a text selection, when there are for example leaf block nodes at the start or end of the document). */ class AllSelection extends Selection { /** Create an all-selection over the given document. */ constructor(doc) { super(doc.resolve(0), doc.resolve(doc.content.size)); } replace(tr, content = Slice.empty) { if (content == Slice.empty) { tr.delete(0, tr.doc.content.size); let sel = Selection.atStart(tr.doc); if (!sel.eq(tr.selection)) tr.setSelection(sel); } else { super.replace(tr, content); } } toJSON() { return { type: "all" }; } /** @internal */ static fromJSON(doc) { return new AllSelection(doc); } map(doc) { return new AllSelection(doc); } eq(other) { return other instanceof AllSelection; } getBookmark() { return AllBookmark; } } Selection.jsonID("all", AllSelection); const AllBookmark = { map() { return this; }, resolve(doc) { return new AllSelection(doc); } }; // FIXME we'll need some awareness of text direction when scanning for selections // Try to find a selection inside the given node. `pos` points at the // position where the search starts. When `text` is true, only return // text selections. function findSelectionIn(doc, node, pos, index, dir, text = false) { if (node.inlineContent) return TextSelection.create(doc, pos); for (let i = index - (dir > 0 ? 0 : 1); dir > 0 ? i < node.childCount : i >= 0; i += dir) { let child = node.child(i); if (!child.isAtom) { let inner = findSelectionIn(doc, child, pos + dir, dir < 0 ? child.childCount : 0, dir, text); if (inner) return inner; } else if (!text && NodeSelection.isSelectable(child)) { return NodeSelection.create(doc, pos - (dir < 0 ? child.nodeSize : 0)); } pos += child.nodeSize * dir; } return null; } function selectionToInsertionEnd(tr, startLen, bias) { let last = tr.steps.length - 1; if (last < startLen) return; let step = tr.steps[last]; if (!(step instanceof ReplaceStep || step instanceof ReplaceAroundStep)) return; let map = tr.mapping.maps[last], end; map.forEach((_from, _to, _newFrom, newTo) => { if (end == null) end = newTo; }); tr.setSelection(Selection.near(tr.doc.resolve(end), bias)); } const UPDATED_SEL = 1, UPDATED_MARKS = 2, UPDATED_SCROLL = 4; /** An editor state transaction, which can be applied to a state to create an updated state. Use [`EditorState.tr`](https://prosemirror.net/docs/ref/#state.EditorState.tr) to create an instance. Transactions track changes to the document (they are a subclass of [`Transform`](https://prosemirror.net/docs/ref/#transform.Transform)), but also other state changes, like selection updates and adjustments of the set of [stored marks](https://prosemirror.net/docs/ref/#state.EditorState.storedMarks). In addition, you can store metadata properties in a transaction, which are extra pieces of information that client code or plugins can use to describe what a transaction represents, so that they can update their [own state](https://prosemirror.net/docs/ref/#state.StateField) accordingly. The [editor view](https://prosemirror.net/docs/ref/#view.EditorView) uses a few metadata properties: it will attach a property `"pointer"` with the value `true` to selection transactions directly caused by mouse or touch input, a `"composition"` property holding an ID identifying the composition that caused it to transactions caused by composed DOM input, and a `"uiEvent"` property of that may be `"paste"`, `"cut"`, or `"drop"`. */ class Transaction extends Transform { /** @internal */ constructor(state) { super(state.doc); // The step count for which the current selection is valid. this.curSelectionFor = 0; // Bitfield to track which aspects of the state were updated by // this transaction. this.updated = 0; // Object used to store metadata properties for the transaction. this.meta = Object.create(null); this.time = Date.now(); this.curSelection = state.selection; this.storedMarks = state.storedMarks; } /** The transaction's current selection. This defaults to the editor selection [mapped](https://prosemirror.net/docs/ref/#state.Selection.map) through the steps in the transaction, but can be overwritten with [`setSelection`](https://prosemirror.net/docs/ref/#state.Transaction.setSelection). */ get selection() { if (this.curSelectionFor < this.steps.length) { this.curSelection = this.curSelection.map(this.doc, this.mapping.slice(this.curSelectionFor)); this.curSelectionFor = this.steps.length; } return this.curSelection; } /** Update the transaction's current selection. Will determine the selection that the editor gets when the transaction is applied. */ setSelection(selection) { if (selection.$from.doc != this.doc) throw new RangeError("Selection passed to setSelection must point at the current document"); this.curSelection = selection; this.curSelectionFor = this.steps.length; this.updated = (this.updated | UPDATED_SEL) & ~UPDATED_MARKS; this.storedMarks = null; return this; } /** Whether the selection was explicitly updated by this transaction. */ get selectionSet() { return (this.updated & UPDATED_SEL) > 0; } /** Set the current stored marks. */ setStoredMarks(marks) { this.storedMarks = marks; this.updated |= UPDATED_MARKS; return this; } /** Make sure the current stored marks or, if that is null, the marks at the selection, match the given set of marks. Does nothing if this is already the case. */ ensureMarks(marks) { if (!Mark.sameSet(this.storedMarks || this.selection.$from.marks(), marks)) this.setStoredMarks(marks); return this; } /** Add a mark to the set of stored marks. */ addStoredMark(mark) { return this.ensureMarks(mark.addToSet(this.storedMarks || this.selection.$head.marks())); } /** Remove a mark or mark type from the set of stored marks. */ removeStoredMark(mark) { return this.ensureMarks(mark.removeFromSet(this.storedMarks || this.selection.$head.marks())); } /** Whether the stored marks were explicitly set for this transaction. */ get storedMarksSet() { return (this.updated & UPDATED_MARKS) > 0; } /** @internal */ addStep(step, doc) { super.addStep(step, doc); this.updated = this.updated & ~UPDATED_MARKS; this.storedMarks = null; } /** Update the timestamp for the transaction. */ setTime(time) { this.time = time; return this; } /** Replace the current selection with the given slice. */ replaceSelection(slice) { this.selection.replace(this, slice); return this; } /** Replace the selection with the given node. When `inheritMarks` is true and the content is inline, it inherits the marks from the place where it is inserted. */ replaceSelectionWith(node, inheritMarks = true) { let selection = this.selection; if (inheritMarks) node = node.mark(this.storedMarks || (selection.empty ? selection.$from.marks() : (selection.$from.marksAcross(selection.$to) || Mark.none))); selection.replaceWith(this, node); return this; } /** Delete the selection. */ deleteSelection() { this.selection.replace(this); return this; } /** Replace the given range, or the selection if no range is given, with a text node containing the given string. */ insertText(text, from, to) { let schema = this.doc.type.schema; if (from == null) { if (!text) return this.deleteSelection(); return this.replaceSelectionWith(schema.text(text), true); } else { if (to == null) to = from; to = to == null ? from : to; if (!text) return this.deleteRange(from, to); let marks = this.storedMarks; if (!marks) { let $from = this.doc.resolve(from); marks = to == from ? $from.marks() : $from.marksAcross(this.doc.resolve(to)); } this.replaceRangeWith(from, to, schema.text(text, marks)); if (!this.selection.empty) this.setSelection(Selection.near(this.selection.$to)); return this; } } /** Store a metadata property in this transaction, keyed either by name or by plugin. */ setMeta(key, value) { this.meta[typeof key == "string" ? key : key.key] = value; return this; } /** Retrieve a metadata property for a given name or plugin. */ getMeta(key) { return this.meta[typeof key == "string" ? key : key.key]; } /** Returns true if this transaction doesn't contain any metadata, and can thus safely be extended. */ get isGeneric() { for (let _ in this.meta) return false; return true; } /** Indicate that the editor should scroll the selection into view when updated to the state produced by this transaction. */ scrollIntoView() { this.updated |= UPDATED_SCROLL; return this; } /** True when this transaction has had `scrollIntoView` called on it. */ get scrolledIntoView() { return (this.updated & UPDATED_SCROLL) > 0; } } function bind(f, self) { return !self || !f ? f : f.bind(self); } class FieldDesc { constructor(name, desc, self) { this.name = name; this.init = bind(desc.init, self); this.apply = bind(desc.apply, self); } } const baseFields = [ new FieldDesc("doc", { init(config) { return config.doc || config.schema.topNodeType.createAndFill(); }, apply(tr) { return tr.doc; } }), new FieldDesc("selection", { init(config, instance) { return config.selection || Selection.atStart(instance.doc); }, apply(tr) { return tr.selection; } }), new FieldDesc("storedMarks", { init(config) { return config.storedMarks || null; }, apply(tr, _marks, _old, state) { return state.selection.$cursor ? tr.storedMarks : null; } }), new FieldDesc("scrollToSelection", { init() { return 0; }, apply(tr, prev) { return tr.scrolledIntoView ? prev + 1 : prev; } }) ]; // Object wrapping the part of a state object that stays the same // across transactions. Stored in the state's `config` property. class Configuration { constructor(schema, plugins) { this.schema = schema; this.plugins = []; this.pluginsByKey = Object.create(null); this.fields = baseFields.slice(); if (plugins) plugins.forEach(plugin => { if (this.pluginsByKey[plugin.key]) throw new RangeError("Adding different instances of a keyed plugin (" + plugin.key + ")"); this.plugins.push(plugin); this.pluginsByKey[plugin.key] = plugin; if (plugin.spec.state) this.fields.push(new FieldDesc(plugin.key, plugin.spec.state, plugin)); }); } } /** The state of a ProseMirror editor is represented by an object of this type. A state is a persistent data structure—it isn't updated, but rather a new state value is computed from an old one using the [`apply`](https://prosemirror.net/docs/ref/#state.EditorState.apply) method. A state holds a number of built-in fields, and plugins can [define](https://prosemirror.net/docs/ref/#state.PluginSpec.state) additional fields. */ class EditorState { /** @internal */ constructor( /** @internal */ config) { this.config = config; } /** The schema of the state's document. */ get schema() { return this.config.schema; } /** The plugins that are active in this state. */ get plugins() { return this.config.plugins; } /** Apply the given transaction to produce a new state. */ apply(tr) { return this.applyTransaction(tr).state; } /** @internal */ filterTransaction(tr, ignore = -1) { for (let i = 0; i < this.config.plugins.length; i++) if (i != ignore) { let plugin = this.config.plugins[i]; if (plugin.spec.filterTransaction && !plugin.spec.filterTransaction.call(plugin, tr, this)) return false; } return true; } /** Verbose variant of [`apply`](https://prosemirror.net/docs/ref/#state.EditorState.apply) that returns the precise transactions that were applied (which might be influenced by the [transaction hooks](https://prosemirror.net/docs/ref/#state.PluginSpec.filterTransaction) of plugins) along with the new state. */ applyTransaction(rootTr) { if (!this.filterTransaction(rootTr)) return { state: this, transactions: [] }; let trs = [rootTr], newState = this.applyInner(rootTr), seen = null; // This loop repeatedly gives plugins a chance to respond to // transactions as new transactions are added, making sure to only // pass the transactions the plugin did not see before. for (;;) { let haveNew = false; for (let i = 0; i < this.config.plugins.length; i++) { let plugin = this.config.plugins[i]; if (plugin.spec.appendTransaction) { let n = seen ? seen[i].n : 0, oldState = seen ? seen[i].state : this; let tr = n < trs.length && plugin.spec.appendTransaction.call(plugin, n ? trs.slice(n) : trs, oldState, newState); if (tr && newState.filterTransaction(tr, i)) { tr.setMeta("appendedTransaction", rootTr); if (!seen) { seen = []; for (let j = 0; j < this.config.plugins.length; j++) seen.push(j < i ? { state: newState, n: trs.length } : { state: this, n: 0 }); } trs.push(tr); newState = newState.applyInner(tr); haveNew = true; } if (seen) seen[i] = { state: newState, n: trs.length }; } } if (!haveNew) return { state: newState, transactions: trs }; } } /** @internal */ applyInner(tr) { if (!tr.before.eq(this.doc)) throw new RangeError("Applying a mismatched transaction"); let newInstance = new EditorState(this.config), fields = this.config.fields; for (let i = 0; i < fields.length; i++) { let field = fields[i]; newInstance[field.name] = field.apply(tr, this[field.name], this, newInstance); } return newInstance; } /** Start a [transaction](https://prosemirror.net/docs/ref/#state.Transaction) from this state. */ get tr() { return new Transaction(this); } /** Create a new state. */ static create(config) { let $config = new Configuration(config.doc ? config.doc.type.schema : config.schema, config.plugins); let instance = new EditorState($config); for (let i = 0; i < $config.fields.length; i++) instance[$config.fields[i].name] = $config.fields[i].init(config, instance); return instance; } /** Create a new state based on this one, but with an adjusted set of active plugins. State fields that exist in both sets of plugins are kept unchanged. Those that no longer exist are dropped, and those that are new are initialized using their [`init`](https://prosemirror.net/docs/ref/#state.StateField.init) method, passing in the new configuration object.. */ reconfigure(config) { let $config = new Configuration(this.schema, config.plugins); let fields = $config.fields, instance = new EditorState($config); for (let i = 0; i < fields.length; i++) { let name = fields[i].name; instance[name] = this.hasOwnProperty(name) ? this[name] : fields[i].init(config, instance); } return instance; } /** Serialize this state to JSON. If you want to serialize the state of plugins, pass an object mapping property names to use in the resulting JSON object to plugin objects. The argument may also be a string or number, in which case it is ignored, to support the way `JSON.stringify` calls `toString` methods. */ toJSON(pluginFields) { let result = { doc: this.doc.toJSON(), selection: this.selection.toJSON() }; if (this.storedMarks) result.storedMarks = this.storedMarks.map(m => m.toJSON()); if (pluginFields && typeof pluginFields == 'object') for (let prop in pluginFields) { if (prop == "doc" || prop == "selection") throw new RangeError("The JSON fields `doc` and `selection` are reserved"); let plugin = pluginFields[prop], state = plugin.spec.state; if (state && state.toJSON) result[prop] = state.toJSON.call(plugin, this[plugin.key]); } return result; } /** Deserialize a JSON representation of a state. `config` should have at least a `schema` field, and should contain array of plugins to initialize the state with. `pluginFields` can be used to deserialize the state of plugins, by associating plugin instances with the property names they use in the JSON object. */ static fromJSON(config, json, pluginFields) { if (!json) throw new RangeError("Invalid input for EditorState.fromJSON"); if (!config.schema) throw new RangeError("Required config field 'schema' missing"); let $config = new Configuration(config.schema, config.plugins); let instance = new EditorState($config); $config.fields.forEach(field => { if (field.name == "doc") { instance.doc = Node.fromJSON(config.schema, json.doc); } else if (field.name == "selection") { instance.selection = Selection.fromJSON(instance.doc, json.selection); } else if (field.name == "storedMarks") { if (json.storedMarks) instance.storedMarks = json.storedMarks.map(config.schema.markFromJSON); } else { if (pluginFields) for (let prop in pluginFields) { let plugin = pluginFields[prop], state = plugin.spec.state; if (plugin.key == field.name && state && state.fromJSON && Object.prototype.hasOwnProperty.call(json, prop)) { instance[field.name] = state.fromJSON.call(plugin, config, json[prop], instance); return; } } instance[field.name] = field.init(config, instance); } }); return instance; } } function bindProps(obj, self, target) { for (let prop in obj) { let val = obj[prop]; if (val instanceof Function) val = val.bind(self); else if (prop == "handleDOMEvents") val = bindProps(val, self, {}); target[prop] = val; } return target; } /** Plugins bundle functionality that can be added to an editor. They are part of the [editor state](https://prosemirror.net/docs/ref/#state.EditorState) and may influence that state and the view that contains it. */ class Plugin { /** Create a plugin. */ constructor( /** The plugin's [spec object](https://prosemirror.net/docs/ref/#state.PluginSpec). */ spec) { this.spec = spec; /** The [props](https://prosemirror.net/docs/ref/#view.EditorProps) exported by this plugin. */ this.props = {}; if (spec.props) bindProps(spec.props, this, this.props); this.key = spec.key ? spec.key.key : createKey("plugin"); } /** Extract the plugin's state field from an editor state. */ getState(state) { return state[this.key]; } } const keys = Object.create(null); function createKey(name) { if (name in keys) return name + "$" + ++keys[name]; keys[name] = 0; return name + "$"; } /** A key is used to [tag](https://prosemirror.net/docs/ref/#state.PluginSpec.key) plugins in a way that makes it possible to find them, given an editor state. Assigning a key does mean only one plugin of that type can be active in a state. */ class PluginKey { /** Create a plugin key. */ constructor(name = "key") { this.key = createKey(name); } /** Get the active plugin with this key, if any, from an editor state. */ get(state) { return state.config.pluginsByKey[this.key]; } /** Get the plugin's state from an editor state. */ getState(state) { return state[this.key]; } } var index$5 = /*#__PURE__*/Object.freeze({ __proto__: null, AllSelection: AllSelection, EditorState: EditorState, NodeSelection: NodeSelection, Plugin: Plugin, PluginKey: PluginKey, Selection: Selection, SelectionRange: SelectionRange, TextSelection: TextSelection, Transaction: Transaction }); const domIndex = function (node) { for (var index = 0;; index++) { node = node.previousSibling; if (!node) return index; } }; const parentNode = function (node) { let parent = node.assignedSlot || node.parentNode; return parent && parent.nodeType == 11 ? parent.host : parent; }; let reusedRange = null; // Note that this will always return the same range, because DOM range // objects are every expensive, and keep slowing down subsequent DOM // updates, for some reason. const textRange = function (node, from, to) { let range = reusedRange || (reusedRange = document.createRange()); range.setEnd(node, to == null ? node.nodeValue.length : to); range.setStart(node, from || 0); return range; }; const clearReusedRange = function () { reusedRange = null; }; // Scans forward and backward through DOM positions equivalent to the // given one to see if the two are in the same place (i.e. after a // text node vs at the end of that text node) const isEquivalentPosition = function (node, off, targetNode, targetOff) { return targetNode && (scanFor(node, off, targetNode, targetOff, -1) || scanFor(node, off, targetNode, targetOff, 1)); }; const atomElements = /^(img|br|input|textarea|hr)$/i; function scanFor(node, off, targetNode, targetOff, dir) { for (;;) { if (node == targetNode && off == targetOff) return true; if (off == (dir < 0 ? 0 : nodeSize(node))) { let parent = node.parentNode; if (!parent || parent.nodeType != 1 || hasBlockDesc(node) || atomElements.test(node.nodeName) || node.contentEditable == "false") return false; off = domIndex(node) + (dir < 0 ? 0 : 1); node = parent; } else if (node.nodeType == 1) { node = node.childNodes[off + (dir < 0 ? -1 : 0)]; if (node.contentEditable == "false") return false; off = dir < 0 ? nodeSize(node) : 0; } else { return false; } } } function nodeSize(node) { return node.nodeType == 3 ? node.nodeValue.length : node.childNodes.length; } function textNodeBefore$1(node, offset) { for (;;) { if (node.nodeType == 3 && offset) return node; if (node.nodeType == 1 && offset > 0) { if (node.contentEditable == "false") return null; node = node.childNodes[offset - 1]; offset = nodeSize(node); } else if (node.parentNode && !hasBlockDesc(node)) { offset = domIndex(node); node = node.parentNode; } else { return null; } } } function textNodeAfter$1(node, offset) { for (;;) { if (node.nodeType == 3 && offset < node.nodeValue.length) return node; if (node.nodeType == 1 && offset < node.childNodes.length) { if (node.contentEditable == "false") return null; node = node.childNodes[offset]; offset = 0; } else if (node.parentNode && !hasBlockDesc(node)) { offset = domIndex(node) + 1; node = node.parentNode; } else { return null; } } } function isOnEdge(node, offset, parent) { for (let atStart = offset == 0, atEnd = offset == nodeSize(node); atStart || atEnd;) { if (node == parent) return true; let index = domIndex(node); node = node.parentNode; if (!node) return false; atStart = atStart && index == 0; atEnd = atEnd && index == nodeSize(node); } } function hasBlockDesc(dom) { let desc; for (let cur = dom; cur; cur = cur.parentNode) if (desc = cur.pmViewDesc) break; return desc && desc.node && desc.node.isBlock && (desc.dom == dom || desc.contentDOM == dom); } // Work around Chrome issue https://bugs.chromium.org/p/chromium/issues/detail?id=447523 // (isCollapsed inappropriately returns true in shadow dom) const selectionCollapsed = function (domSel) { return domSel.focusNode && isEquivalentPosition(domSel.focusNode, domSel.focusOffset, domSel.anchorNode, domSel.anchorOffset); }; function keyEvent(keyCode, key) { let event = document.createEvent("Event"); event.initEvent("keydown", true, true); event.keyCode = keyCode; event.key = event.code = key; return event; } function deepActiveElement(doc) { let elt = doc.activeElement; while (elt && elt.shadowRoot) elt = elt.shadowRoot.activeElement; return elt; } function caretFromPoint(doc, x, y) { if (doc.caretPositionFromPoint) { try { // Firefox throws for this call in hard-to-predict circumstances (#994) let pos = doc.caretPositionFromPoint(x, y); if (pos) return { node: pos.offsetNode, offset: pos.offset }; } catch (_) { } } if (doc.caretRangeFromPoint) { let range = doc.caretRangeFromPoint(x, y); if (range) return { node: range.startContainer, offset: range.startOffset }; } } const nav = typeof navigator != "undefined" ? navigator : null; const doc$1 = typeof document != "undefined" ? document : null; const agent = (nav && nav.userAgent) || ""; const ie_edge = /Edge\/(\d+)/.exec(agent); const ie_upto10 = /MSIE \d/.exec(agent); const ie_11up = /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(agent); const ie$1 = !!(ie_upto10 || ie_11up || ie_edge); const ie_version = ie_upto10 ? document.documentMode : ie_11up ? +ie_11up[1] : ie_edge ? +ie_edge[1] : 0; const gecko = !ie$1 && /gecko\/(\d+)/i.test(agent); gecko && +(/Firefox\/(\d+)/.exec(agent) || [0, 0])[1]; const _chrome = !ie$1 && /Chrome\/(\d+)/.exec(agent); const chrome = !!_chrome; const chrome_version = _chrome ? +_chrome[1] : 0; const safari = !ie$1 && !!nav && /Apple Computer/.test(nav.vendor); // Is true for both iOS and iPadOS for convenience const ios = safari && (/Mobile\/\w+/.test(agent) || !!nav && nav.maxTouchPoints > 2); const mac$3 = ios || (nav ? /Mac/.test(nav.platform) : false); const windows = nav ? /Win/.test(nav.platform) : false; const android = /Android \d/.test(agent); const webkit = !!doc$1 && "webkitFontSmoothing" in doc$1.documentElement.style; const webkit_version = webkit ? +(/\bAppleWebKit\/(\d+)/.exec(navigator.userAgent) || [0, 0])[1] : 0; function windowRect(doc) { let vp = doc.defaultView && doc.defaultView.visualViewport; if (vp) return { left: 0, right: vp.width, top: 0, bottom: vp.height }; return { left: 0, right: doc.documentElement.clientWidth, top: 0, bottom: doc.documentElement.clientHeight }; } function getSide(value, side) { return typeof value == "number" ? value : value[side]; } function clientRect(node) { let rect = node.getBoundingClientRect(); // Adjust for elements with style "transform: scale()" let scaleX = (rect.width / node.offsetWidth) || 1; let scaleY = (rect.height / node.offsetHeight) || 1; // Make sure scrollbar width isn't included in the rectangle return { left: rect.left, right: rect.left + node.clientWidth * scaleX, top: rect.top, bottom: rect.top + node.clientHeight * scaleY }; } function scrollRectIntoView(view, rect, startDOM) { let scrollThreshold = view.someProp("scrollThreshold") || 0, scrollMargin = view.someProp("scrollMargin") || 5; let doc = view.dom.ownerDocument; for (let parent = startDOM || view.dom;; parent = parentNode(parent)) { if (!parent) break; if (parent.nodeType != 1) continue; let elt = parent; let atTop = elt == doc.body; let bounding = atTop ? windowRect(doc) : clientRect(elt); let moveX = 0, moveY = 0; if (rect.top < bounding.top + getSide(scrollThreshold, "top")) moveY = -(bounding.top - rect.top + getSide(scrollMargin, "top")); else if (rect.bottom > bounding.bottom - getSide(scrollThreshold, "bottom")) moveY = rect.bottom - rect.top > bounding.bottom - bounding.top ? rect.top + getSide(scrollMargin, "top") - bounding.top : rect.bottom - bounding.bottom + getSide(scrollMargin, "bottom"); if (rect.left < bounding.left + getSide(scrollThreshold, "left")) moveX = -(bounding.left - rect.left + getSide(scrollMargin, "left")); else if (rect.right > bounding.right - getSide(scrollThreshold, "right")) moveX = rect.right - bounding.right + getSide(scrollMargin, "right"); if (moveX || moveY) { if (atTop) { doc.defaultView.scrollBy(moveX, moveY); } else { let startX = elt.scrollLeft, startY = elt.scrollTop; if (moveY) elt.scrollTop += moveY; if (moveX) elt.scrollLeft += moveX; let dX = elt.scrollLeft - startX, dY = elt.scrollTop - startY; rect = { left: rect.left - dX, top: rect.top - dY, right: rect.right - dX, bottom: rect.bottom - dY }; } } if (atTop || /^(fixed|sticky)$/.test(getComputedStyle(parent).position)) break; } } // Store the scroll position of the editor's parent nodes, along with // the top position of an element near the top of the editor, which // will be used to make sure the visible viewport remains stable even // when the size of the content above changes. function storeScrollPos(view) { let rect = view.dom.getBoundingClientRect(), startY = Math.max(0, rect.top); let refDOM, refTop; for (let x = (rect.left + rect.right) / 2, y = startY + 1; y < Math.min(innerHeight, rect.bottom); y += 5) { let dom = view.root.elementFromPoint(x, y); if (!dom || dom == view.dom || !view.dom.contains(dom)) continue; let localRect = dom.getBoundingClientRect(); if (localRect.top >= startY - 20) { refDOM = dom; refTop = localRect.top; break; } } return { refDOM: refDOM, refTop: refTop, stack: scrollStack(view.dom) }; } function scrollStack(dom) { let stack = [], doc = dom.ownerDocument; for (let cur = dom; cur; cur = parentNode(cur)) { stack.push({ dom: cur, top: cur.scrollTop, left: cur.scrollLeft }); if (dom == doc) break; } return stack; } // Reset the scroll position of the editor's parent nodes to that what // it was before, when storeScrollPos was called. function resetScrollPos({ refDOM, refTop, stack }) { let newRefTop = refDOM ? refDOM.getBoundingClientRect().top : 0; restoreScrollStack(stack, newRefTop == 0 ? 0 : newRefTop - refTop); } function restoreScrollStack(stack, dTop) { for (let i = 0; i < stack.length; i++) { let { dom, top, left } = stack[i]; if (dom.scrollTop != top + dTop) dom.scrollTop = top + dTop; if (dom.scrollLeft != left) dom.scrollLeft = left; } } let preventScrollSupported = null; // Feature-detects support for .focus({preventScroll: true}), and uses // a fallback kludge when not supported. function focusPreventScroll(dom) { if (dom.setActive) return dom.setActive(); // in IE if (preventScrollSupported) return dom.focus(preventScrollSupported); let stored = scrollStack(dom); dom.focus(preventScrollSupported == null ? { get preventScroll() { preventScrollSupported = { preventScroll: true }; return true; } } : undefined); if (!preventScrollSupported) { preventScrollSupported = false; restoreScrollStack(stored, 0); } } function findOffsetInNode(node, coords) { let closest, dxClosest = 2e8, coordsClosest, offset = 0; let rowBot = coords.top, rowTop = coords.top; let firstBelow, coordsBelow; for (let child = node.firstChild, childIndex = 0; child; child = child.nextSibling, childIndex++) { let rects; if (child.nodeType == 1) rects = child.getClientRects(); else if (child.nodeType == 3) rects = textRange(child).getClientRects(); else continue; for (let i = 0; i < rects.length; i++) { let rect = rects[i]; if (rect.top <= rowBot && rect.bottom >= rowTop) { rowBot = Math.max(rect.bottom, rowBot); rowTop = Math.min(rect.top, rowTop); let dx = rect.left > coords.left ? rect.left - coords.left : rect.right < coords.left ? coords.left - rect.right : 0; if (dx < dxClosest) { closest = child; dxClosest = dx; coordsClosest = dx && closest.nodeType == 3 ? { left: rect.right < coords.left ? rect.right : rect.left, top: coords.top } : coords; if (child.nodeType == 1 && dx) offset = childIndex + (coords.left >= (rect.left + rect.right) / 2 ? 1 : 0); continue; } } else if (rect.top > coords.top && !firstBelow && rect.left <= coords.left && rect.right >= coords.left) { firstBelow = child; coordsBelow = { left: Math.max(rect.left, Math.min(rect.right, coords.left)), top: rect.top }; } if (!closest && (coords.left >= rect.right && coords.top >= rect.top || coords.left >= rect.left && coords.top >= rect.bottom)) offset = childIndex + 1; } } if (!closest && firstBelow) { closest = firstBelow; coordsClosest = coordsBelow; dxClosest = 0; } if (closest && closest.nodeType == 3) return findOffsetInText(closest, coordsClosest); if (!closest || (dxClosest && closest.nodeType == 1)) return { node, offset }; return findOffsetInNode(closest, coordsClosest); } function findOffsetInText(node, coords) { let len = node.nodeValue.length; let range = document.createRange(); for (let i = 0; i < len; i++) { range.setEnd(node, i + 1); range.setStart(node, i); let rect = singleRect(range, 1); if (rect.top == rect.bottom) continue; if (inRect(coords, rect)) return { node, offset: i + (coords.left >= (rect.left + rect.right) / 2 ? 1 : 0) }; } return { node, offset: 0 }; } function inRect(coords, rect) { return coords.left >= rect.left - 1 && coords.left <= rect.right + 1 && coords.top >= rect.top - 1 && coords.top <= rect.bottom + 1; } function targetKludge(dom, coords) { let parent = dom.parentNode; if (parent && /^li$/i.test(parent.nodeName) && coords.left < dom.getBoundingClientRect().left) return parent; return dom; } function posFromElement(view, elt, coords) { let { node, offset } = findOffsetInNode(elt, coords), bias = -1; if (node.nodeType == 1 && !node.firstChild) { let rect = node.getBoundingClientRect(); bias = rect.left != rect.right && coords.left > (rect.left + rect.right) / 2 ? 1 : -1; } return view.docView.posFromDOM(node, offset, bias); } function posFromCaret(view, node, offset, coords) { // Browser (in caretPosition/RangeFromPoint) will agressively // normalize towards nearby inline nodes. Since we are interested in // positions between block nodes too, we first walk up the hierarchy // of nodes to see if there are block nodes that the coordinates // fall outside of. If so, we take the position before/after that // block. If not, we call `posFromDOM` on the raw node/offset. let outsideBlock = -1; for (let cur = node, sawBlock = false;;) { if (cur == view.dom) break; let desc = view.docView.nearestDesc(cur, true); if (!desc) return null; if (desc.dom.nodeType == 1 && (desc.node.isBlock && desc.parent && !sawBlock || !desc.contentDOM)) { let rect = desc.dom.getBoundingClientRect(); if (desc.node.isBlock && desc.parent && !sawBlock) { sawBlock = true; if (rect.left > coords.left || rect.top > coords.top) outsideBlock = desc.posBefore; else if (rect.right < coords.left || rect.bottom < coords.top) outsideBlock = desc.posAfter; } if (!desc.contentDOM && outsideBlock < 0 && !desc.node.isText) { // If we are inside a leaf, return the side of the leaf closer to the coords let before = desc.node.isBlock ? coords.top < (rect.top + rect.bottom) / 2 : coords.left < (rect.left + rect.right) / 2; return before ? desc.posBefore : desc.posAfter; } } cur = desc.dom.parentNode; } return outsideBlock > -1 ? outsideBlock : view.docView.posFromDOM(node, offset, -1); } function elementFromPoint(element, coords, box) { let len = element.childNodes.length; if (len && box.top < box.bottom) { for (let startI = Math.max(0, Math.min(len - 1, Math.floor(len * (coords.top - box.top) / (box.bottom - box.top)) - 2)), i = startI;;) { let child = element.childNodes[i]; if (child.nodeType == 1) { let rects = child.getClientRects(); for (let j = 0; j < rects.length; j++) { let rect = rects[j]; if (inRect(coords, rect)) return elementFromPoint(child, coords, rect); } } if ((i = (i + 1) % len) == startI) break; } } return element; } // Given an x,y position on the editor, get the position in the document. function posAtCoords(view, coords) { let doc = view.dom.ownerDocument, node, offset = 0; let caret = caretFromPoint(doc, coords.left, coords.top); if (caret) ({ node, offset } = caret); let elt = (view.root.elementFromPoint ? view.root : doc) .elementFromPoint(coords.left, coords.top); let pos; if (!elt || !view.dom.contains(elt.nodeType != 1 ? elt.parentNode : elt)) { let box = view.dom.getBoundingClientRect(); if (!inRect(coords, box)) return null; elt = elementFromPoint(view.dom, coords, box); if (!elt) return null; } // Safari's caretRangeFromPoint returns nonsense when on a draggable element if (safari) { for (let p = elt; node && p; p = parentNode(p)) if (p.draggable) node = undefined; } elt = targetKludge(elt, coords); if (node) { if (gecko && node.nodeType == 1) { // Firefox will sometimes return offsets into nodes, which // have no actual children, from caretPositionFromPoint (#953) offset = Math.min(offset, node.childNodes.length); // It'll also move the returned position before image nodes, // even if those are behind it. if (offset < node.childNodes.length) { let next = node.childNodes[offset], box; if (next.nodeName == "IMG" && (box = next.getBoundingClientRect()).right <= coords.left && box.bottom > coords.top) offset++; } } let prev; // When clicking above the right side of an uneditable node, Chrome will report a cursor position after that node. if (webkit && offset && node.nodeType == 1 && (prev = node.childNodes[offset - 1]).nodeType == 1 && prev.contentEditable == "false" && prev.getBoundingClientRect().top >= coords.top) offset--; // Suspiciously specific kludge to work around caret*FromPoint // never returning a position at the end of the document if (node == view.dom && offset == node.childNodes.length - 1 && node.lastChild.nodeType == 1 && coords.top > node.lastChild.getBoundingClientRect().bottom) pos = view.state.doc.content.size; // Ignore positions directly after a BR, since caret*FromPoint // 'round up' positions that would be more accurately placed // before the BR node. else if (offset == 0 || node.nodeType != 1 || node.childNodes[offset - 1].nodeName != "BR") pos = posFromCaret(view, node, offset, coords); } if (pos == null) pos = posFromElement(view, elt, coords); let desc = view.docView.nearestDesc(elt, true); return { pos, inside: desc ? desc.posAtStart - desc.border : -1 }; } function nonZero(rect) { return rect.top < rect.bottom || rect.left < rect.right; } function singleRect(target, bias) { let rects = target.getClientRects(); if (rects.length) { let first = rects[bias < 0 ? 0 : rects.length - 1]; if (nonZero(first)) return first; } return Array.prototype.find.call(rects, nonZero) || target.getBoundingClientRect(); } const BIDI = /[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac]/; // Given a position in the document model, get a bounding box of the // character at that position, relative to the window. function coordsAtPos(view, pos, side) { let { node, offset, atom } = view.docView.domFromPos(pos, side < 0 ? -1 : 1); let supportEmptyRange = webkit || gecko; if (node.nodeType == 3) { // These browsers support querying empty text ranges. Prefer that in // bidi context or when at the end of a node. if (supportEmptyRange && (BIDI.test(node.nodeValue) || (side < 0 ? !offset : offset == node.nodeValue.length))) { let rect = singleRect(textRange(node, offset, offset), side); // Firefox returns bad results (the position before the space) // when querying a position directly after line-broken // whitespace. Detect this situation and and kludge around it if (gecko && offset && /\s/.test(node.nodeValue[offset - 1]) && offset < node.nodeValue.length) { let rectBefore = singleRect(textRange(node, offset - 1, offset - 1), -1); if (rectBefore.top == rect.top) { let rectAfter = singleRect(textRange(node, offset, offset + 1), -1); if (rectAfter.top != rect.top) return flattenV(rectAfter, rectAfter.left < rectBefore.left); } } return rect; } else { let from = offset, to = offset, takeSide = side < 0 ? 1 : -1; if (side < 0 && !offset) { to++; takeSide = -1; } else if (side >= 0 && offset == node.nodeValue.length) { from--; takeSide = 1; } else if (side < 0) { from--; } else { to++; } return flattenV(singleRect(textRange(node, from, to), takeSide), takeSide < 0); } } let $dom = view.state.doc.resolve(pos - (atom || 0)); // Return a horizontal line in block context if (!$dom.parent.inlineContent) { if (atom == null && offset && (side < 0 || offset == nodeSize(node))) { let before = node.childNodes[offset - 1]; if (before.nodeType == 1) return flattenH(before.getBoundingClientRect(), false); } if (atom == null && offset < nodeSize(node)) { let after = node.childNodes[offset]; if (after.nodeType == 1) return flattenH(after.getBoundingClientRect(), true); } return flattenH(node.getBoundingClientRect(), side >= 0); } // Inline, not in text node (this is not Bidi-safe) if (atom == null && offset && (side < 0 || offset == nodeSize(node))) { let before = node.childNodes[offset - 1]; let target = before.nodeType == 3 ? textRange(before, nodeSize(before) - (supportEmptyRange ? 0 : 1)) // BR nodes tend to only return the rectangle before them. // Only use them if they are the last element in their parent : before.nodeType == 1 && (before.nodeName != "BR" || !before.nextSibling) ? before : null; if (target) return flattenV(singleRect(target, 1), false); } if (atom == null && offset < nodeSize(node)) { let after = node.childNodes[offset]; while (after.pmViewDesc && after.pmViewDesc.ignoreForCoords) after = after.nextSibling; let target = !after ? null : after.nodeType == 3 ? textRange(after, 0, (supportEmptyRange ? 0 : 1)) : after.nodeType == 1 ? after : null; if (target) return flattenV(singleRect(target, -1), true); } // All else failed, just try to get a rectangle for the target node return flattenV(singleRect(node.nodeType == 3 ? textRange(node) : node, -side), side >= 0); } function flattenV(rect, left) { if (rect.width == 0) return rect; let x = left ? rect.left : rect.right; return { top: rect.top, bottom: rect.bottom, left: x, right: x }; } function flattenH(rect, top) { if (rect.height == 0) return rect; let y = top ? rect.top : rect.bottom; return { top: y, bottom: y, left: rect.left, right: rect.right }; } function withFlushedState(view, state, f) { let viewState = view.state, active = view.root.activeElement; if (viewState != state) view.updateState(state); if (active != view.dom) view.focus(); try { return f(); } finally { if (viewState != state) view.updateState(viewState); if (active != view.dom && active) active.focus(); } } // Whether vertical position motion in a given direction // from a position would leave a text block. function endOfTextblockVertical(view, state, dir) { let sel = state.selection; let $pos = dir == "up" ? sel.$from : sel.$to; return withFlushedState(view, state, () => { let { node: dom } = view.docView.domFromPos($pos.pos, dir == "up" ? -1 : 1); for (;;) { let nearest = view.docView.nearestDesc(dom, true); if (!nearest) break; if (nearest.node.isBlock) { dom = nearest.contentDOM || nearest.dom; break; } dom = nearest.dom.parentNode; } let coords = coordsAtPos(view, $pos.pos, 1); for (let child = dom.firstChild; child; child = child.nextSibling) { let boxes; if (child.nodeType == 1) boxes = child.getClientRects(); else if (child.nodeType == 3) boxes = textRange(child, 0, child.nodeValue.length).getClientRects(); else continue; for (let i = 0; i < boxes.length; i++) { let box = boxes[i]; if (box.bottom > box.top + 1 && (dir == "up" ? coords.top - box.top > (box.bottom - coords.top) * 2 : box.bottom - coords.bottom > (coords.bottom - box.top) * 2)) return false; } } return true; }); } const maybeRTL = /[\u0590-\u08ac]/; function endOfTextblockHorizontal(view, state, dir) { let { $head } = state.selection; if (!$head.parent.isTextblock) return false; let offset = $head.parentOffset, atStart = !offset, atEnd = offset == $head.parent.content.size; let sel = view.domSelection(); // If the textblock is all LTR, or the browser doesn't support // Selection.modify (Edge), fall back to a primitive approach if (!maybeRTL.test($head.parent.textContent) || !sel.modify) return dir == "left" || dir == "backward" ? atStart : atEnd; return withFlushedState(view, state, () => { // This is a huge hack, but appears to be the best we can // currently do: use `Selection.modify` to move the selection by // one character, and see if that moves the cursor out of the // textblock (or doesn't move it at all, when at the start/end of // the document). let { focusNode: oldNode, focusOffset: oldOff, anchorNode, anchorOffset } = view.domSelectionRange(); let oldBidiLevel = sel.caretBidiLevel // Only for Firefox ; sel.modify("move", dir, "character"); let parentDOM = $head.depth ? view.docView.domAfterPos($head.before()) : view.dom; let { focusNode: newNode, focusOffset: newOff } = view.domSelectionRange(); let result = newNode && !parentDOM.contains(newNode.nodeType == 1 ? newNode : newNode.parentNode) || (oldNode == newNode && oldOff == newOff); // Restore the previous selection try { sel.collapse(anchorNode, anchorOffset); if (oldNode && (oldNode != anchorNode || oldOff != anchorOffset) && sel.extend) sel.extend(oldNode, oldOff); } catch (_) { } if (oldBidiLevel != null) sel.caretBidiLevel = oldBidiLevel; return result; }); } let cachedState = null; let cachedDir = null; let cachedResult = false; function endOfTextblock(view, state, dir) { if (cachedState == state && cachedDir == dir) return cachedResult; cachedState = state; cachedDir = dir; return cachedResult = dir == "up" || dir == "down" ? endOfTextblockVertical(view, state, dir) : endOfTextblockHorizontal(view, state, dir); } // View descriptions are data structures that describe the DOM that is // used to represent the editor's content. They are used for: // // - Incremental redrawing when the document changes // // - Figuring out what part of the document a given DOM position // corresponds to // // - Wiring in custom implementations of the editing interface for a // given node // // They form a doubly-linked mutable tree, starting at `view.docView`. const NOT_DIRTY = 0, CHILD_DIRTY = 1, CONTENT_DIRTY = 2, NODE_DIRTY = 3; // Superclass for the various kinds of descriptions. Defines their // basic structure and shared methods. class ViewDesc { constructor(parent, children, dom, // This is the node that holds the child views. It may be null for // descs that don't have children. contentDOM) { this.parent = parent; this.children = children; this.dom = dom; this.contentDOM = contentDOM; this.dirty = NOT_DIRTY; // An expando property on the DOM node provides a link back to its // description. dom.pmViewDesc = this; } // Used to check whether a given description corresponds to a // widget/mark/node. matchesWidget(widget) { return false; } matchesMark(mark) { return false; } matchesNode(node, outerDeco, innerDeco) { return false; } matchesHack(nodeName) { return false; } // When parsing in-editor content (in domchange.js), we allow // descriptions to determine the parse rules that should be used to // parse them. parseRule() { return null; } // Used by the editor's event handler to ignore events that come // from certain descs. stopEvent(event) { return false; } // The size of the content represented by this desc. get size() { let size = 0; for (let i = 0; i < this.children.length; i++) size += this.children[i].size; return size; } // For block nodes, this represents the space taken up by their // start/end tokens. get border() { return 0; } destroy() { this.parent = undefined; if (this.dom.pmViewDesc == this) this.dom.pmViewDesc = undefined; for (let i = 0; i < this.children.length; i++) this.children[i].destroy(); } posBeforeChild(child) { for (let i = 0, pos = this.posAtStart;; i++) { let cur = this.children[i]; if (cur == child) return pos; pos += cur.size; } } get posBefore() { return this.parent.posBeforeChild(this); } get posAtStart() { return this.parent ? this.parent.posBeforeChild(this) + this.border : 0; } get posAfter() { return this.posBefore + this.size; } get posAtEnd() { return this.posAtStart + this.size - 2 * this.border; } localPosFromDOM(dom, offset, bias) { // If the DOM position is in the content, use the child desc after // it to figure out a position. if (this.contentDOM && this.contentDOM.contains(dom.nodeType == 1 ? dom : dom.parentNode)) { if (bias < 0) { let domBefore, desc; if (dom == this.contentDOM) { domBefore = dom.childNodes[offset - 1]; } else { while (dom.parentNode != this.contentDOM) dom = dom.parentNode; domBefore = dom.previousSibling; } while (domBefore && !((desc = domBefore.pmViewDesc) && desc.parent == this)) domBefore = domBefore.previousSibling; return domBefore ? this.posBeforeChild(desc) + desc.size : this.posAtStart; } else { let domAfter, desc; if (dom == this.contentDOM) { domAfter = dom.childNodes[offset]; } else { while (dom.parentNode != this.contentDOM) dom = dom.parentNode; domAfter = dom.nextSibling; } while (domAfter && !((desc = domAfter.pmViewDesc) && desc.parent == this)) domAfter = domAfter.nextSibling; return domAfter ? this.posBeforeChild(desc) : this.posAtEnd; } } // Otherwise, use various heuristics, falling back on the bias // parameter, to determine whether to return the position at the // start or at the end of this view desc. let atEnd; if (dom == this.dom && this.contentDOM) { atEnd = offset > domIndex(this.contentDOM); } else if (this.contentDOM && this.contentDOM != this.dom && this.dom.contains(this.contentDOM)) { atEnd = dom.compareDocumentPosition(this.contentDOM) & 2; } else if (this.dom.firstChild) { if (offset == 0) for (let search = dom;; search = search.parentNode) { if (search == this.dom) { atEnd = false; break; } if (search.previousSibling) break; } if (atEnd == null && offset == dom.childNodes.length) for (let search = dom;; search = search.parentNode) { if (search == this.dom) { atEnd = true; break; } if (search.nextSibling) break; } } return (atEnd == null ? bias > 0 : atEnd) ? this.posAtEnd : this.posAtStart; } nearestDesc(dom, onlyNodes = false) { for (let first = true, cur = dom; cur; cur = cur.parentNode) { let desc = this.getDesc(cur), nodeDOM; if (desc && (!onlyNodes || desc.node)) { // If dom is outside of this desc's nodeDOM, don't count it. if (first && (nodeDOM = desc.nodeDOM) && !(nodeDOM.nodeType == 1 ? nodeDOM.contains(dom.nodeType == 1 ? dom : dom.parentNode) : nodeDOM == dom)) first = false; else return desc; } } } getDesc(dom) { let desc = dom.pmViewDesc; for (let cur = desc; cur; cur = cur.parent) if (cur == this) return desc; } posFromDOM(dom, offset, bias) { for (let scan = dom; scan; scan = scan.parentNode) { let desc = this.getDesc(scan); if (desc) return desc.localPosFromDOM(dom, offset, bias); } return -1; } // Find the desc for the node after the given pos, if any. (When a // parent node overrode rendering, there might not be one.) descAt(pos) { for (let i = 0, offset = 0; i < this.children.length; i++) { let child = this.children[i], end = offset + child.size; if (offset == pos && end != offset) { while (!child.border && child.children.length) child = child.children[0]; return child; } if (pos < end) return child.descAt(pos - offset - child.border); offset = end; } } domFromPos(pos, side) { if (!this.contentDOM) return { node: this.dom, offset: 0, atom: pos + 1 }; // First find the position in the child array let i = 0, offset = 0; for (let curPos = 0; i < this.children.length; i++) { let child = this.children[i], end = curPos + child.size; if (end > pos || child instanceof TrailingHackViewDesc) { offset = pos - curPos; break; } curPos = end; } // If this points into the middle of a child, call through if (offset) return this.children[i].domFromPos(offset - this.children[i].border, side); // Go back if there were any zero-length widgets with side >= 0 before this point for (let prev; i && !(prev = this.children[i - 1]).size && prev instanceof WidgetViewDesc && prev.side >= 0; i--) { } // Scan towards the first useable node if (side <= 0) { let prev, enter = true; for (;; i--, enter = false) { prev = i ? this.children[i - 1] : null; if (!prev || prev.dom.parentNode == this.contentDOM) break; } if (prev && side && enter && !prev.border && !prev.domAtom) return prev.domFromPos(prev.size, side); return { node: this.contentDOM, offset: prev ? domIndex(prev.dom) + 1 : 0 }; } else { let next, enter = true; for (;; i++, enter = false) { next = i < this.children.length ? this.children[i] : null; if (!next || next.dom.parentNode == this.contentDOM) break; } if (next && enter && !next.border && !next.domAtom) return next.domFromPos(0, side); return { node: this.contentDOM, offset: next ? domIndex(next.dom) : this.contentDOM.childNodes.length }; } } // Used to find a DOM range in a single parent for a given changed // range. parseRange(from, to, base = 0) { if (this.children.length == 0) return { node: this.contentDOM, from, to, fromOffset: 0, toOffset: this.contentDOM.childNodes.length }; let fromOffset = -1, toOffset = -1; for (let offset = base, i = 0;; i++) { let child = this.children[i], end = offset + child.size; if (fromOffset == -1 && from <= end) { let childBase = offset + child.border; // FIXME maybe descend mark views to parse a narrower range? if (from >= childBase && to <= end - child.border && child.node && child.contentDOM && this.contentDOM.contains(child.contentDOM)) return child.parseRange(from, to, childBase); from = offset; for (let j = i; j > 0; j--) { let prev = this.children[j - 1]; if (prev.size && prev.dom.parentNode == this.contentDOM && !prev.emptyChildAt(1)) { fromOffset = domIndex(prev.dom) + 1; break; } from -= prev.size; } if (fromOffset == -1) fromOffset = 0; } if (fromOffset > -1 && (end > to || i == this.children.length - 1)) { to = end; for (let j = i + 1; j < this.children.length; j++) { let next = this.children[j]; if (next.size && next.dom.parentNode == this.contentDOM && !next.emptyChildAt(-1)) { toOffset = domIndex(next.dom); break; } to += next.size; } if (toOffset == -1) toOffset = this.contentDOM.childNodes.length; break; } offset = end; } return { node: this.contentDOM, from, to, fromOffset, toOffset }; } emptyChildAt(side) { if (this.border || !this.contentDOM || !this.children.length) return false; let child = this.children[side < 0 ? 0 : this.children.length - 1]; return child.size == 0 || child.emptyChildAt(side); } domAfterPos(pos) { let { node, offset } = this.domFromPos(pos, 0); if (node.nodeType != 1 || offset == node.childNodes.length) throw new RangeError("No node after pos " + pos); return node.childNodes[offset]; } // View descs are responsible for setting any selection that falls // entirely inside of them, so that custom implementations can do // custom things with the selection. Note that this falls apart when // a selection starts in such a node and ends in another, in which // case we just use whatever domFromPos produces as a best effort. setSelection(anchor, head, root, force = false) { // If the selection falls entirely in a child, give it to that child let from = Math.min(anchor, head), to = Math.max(anchor, head); for (let i = 0, offset = 0; i < this.children.length; i++) { let child = this.children[i], end = offset + child.size; if (from > offset && to < end) return child.setSelection(anchor - offset - child.border, head - offset - child.border, root, force); offset = end; } let anchorDOM = this.domFromPos(anchor, anchor ? -1 : 1); let headDOM = head == anchor ? anchorDOM : this.domFromPos(head, head ? -1 : 1); let domSel = root.getSelection(); let brKludge = false; // On Firefox, using Selection.collapse to put the cursor after a // BR node for some reason doesn't always work (#1073). On Safari, // the cursor sometimes inexplicable visually lags behind its // reported position in such situations (#1092). if ((gecko || safari) && anchor == head) { let { node, offset } = anchorDOM; if (node.nodeType == 3) { brKludge = !!(offset && node.nodeValue[offset - 1] == "\n"); // Issue #1128 if (brKludge && offset == node.nodeValue.length) { for (let scan = node, after; scan; scan = scan.parentNode) { if (after = scan.nextSibling) { if (after.nodeName == "BR") anchorDOM = headDOM = { node: after.parentNode, offset: domIndex(after) + 1 }; break; } let desc = scan.pmViewDesc; if (desc && desc.node && desc.node.isBlock) break; } } } else { let prev = node.childNodes[offset - 1]; brKludge = prev && (prev.nodeName == "BR" || prev.contentEditable == "false"); } } // Firefox can act strangely when the selection is in front of an // uneditable node. See #1163 and https://bugzilla.mozilla.org/show_bug.cgi?id=1709536 if (gecko && domSel.focusNode && domSel.focusNode != headDOM.node && domSel.focusNode.nodeType == 1) { let after = domSel.focusNode.childNodes[domSel.focusOffset]; if (after && after.contentEditable == "false") force = true; } if (!(force || brKludge && safari) && isEquivalentPosition(anchorDOM.node, anchorDOM.offset, domSel.anchorNode, domSel.anchorOffset) && isEquivalentPosition(headDOM.node, headDOM.offset, domSel.focusNode, domSel.focusOffset)) return; // Selection.extend can be used to create an 'inverted' selection // (one where the focus is before the anchor), but not all // browsers support it yet. let domSelExtended = false; if ((domSel.extend || anchor == head) && !brKludge) { domSel.collapse(anchorDOM.node, anchorDOM.offset); try { if (anchor != head) domSel.extend(headDOM.node, headDOM.offset); domSelExtended = true; } catch (_) { // In some cases with Chrome the selection is empty after calling // collapse, even when it should be valid. This appears to be a bug, but // it is difficult to isolate. If this happens fallback to the old path // without using extend. // Similarly, this could crash on Safari if the editor is hidden, and // there was no selection. } } if (!domSelExtended) { if (anchor > head) { let tmp = anchorDOM; anchorDOM = headDOM; headDOM = tmp; } let range = document.createRange(); range.setEnd(headDOM.node, headDOM.offset); range.setStart(anchorDOM.node, anchorDOM.offset); domSel.removeAllRanges(); domSel.addRange(range); } } ignoreMutation(mutation) { return !this.contentDOM && mutation.type != "selection"; } get contentLost() { return this.contentDOM && this.contentDOM != this.dom && !this.dom.contains(this.contentDOM); } // Remove a subtree of the element tree that has been touched // by a DOM change, so that the next update will redraw it. markDirty(from, to) { for (let offset = 0, i = 0; i < this.children.length; i++) { let child = this.children[i], end = offset + child.size; if (offset == end ? from <= end && to >= offset : from < end && to > offset) { let startInside = offset + child.border, endInside = end - child.border; if (from >= startInside && to <= endInside) { this.dirty = from == offset || to == end ? CONTENT_DIRTY : CHILD_DIRTY; if (from == startInside && to == endInside && (child.contentLost || child.dom.parentNode != this.contentDOM)) child.dirty = NODE_DIRTY; else child.markDirty(from - startInside, to - startInside); return; } else { child.dirty = child.dom == child.contentDOM && child.dom.parentNode == this.contentDOM && !child.children.length ? CONTENT_DIRTY : NODE_DIRTY; } } offset = end; } this.dirty = CONTENT_DIRTY; } markParentsDirty() { let level = 1; for (let node = this.parent; node; node = node.parent, level++) { let dirty = level == 1 ? CONTENT_DIRTY : CHILD_DIRTY; if (node.dirty < dirty) node.dirty = dirty; } } get domAtom() { return false; } get ignoreForCoords() { return false; } isText(text) { return false; } } // A widget desc represents a widget decoration, which is a DOM node // drawn between the document nodes. class WidgetViewDesc extends ViewDesc { constructor(parent, widget, view, pos) { let self, dom = widget.type.toDOM; if (typeof dom == "function") dom = dom(view, () => { if (!self) return pos; if (self.parent) return self.parent.posBeforeChild(self); }); if (!widget.type.spec.raw) { if (dom.nodeType != 1) { let wrap = document.createElement("span"); wrap.appendChild(dom); dom = wrap; } dom.contentEditable = "false"; dom.classList.add("ProseMirror-widget"); } super(parent, [], dom, null); this.widget = widget; this.widget = widget; self = this; } matchesWidget(widget) { return this.dirty == NOT_DIRTY && widget.type.eq(this.widget.type); } parseRule() { return { ignore: true }; } stopEvent(event) { let stop = this.widget.spec.stopEvent; return stop ? stop(event) : false; } ignoreMutation(mutation) { return mutation.type != "selection" || this.widget.spec.ignoreSelection; } destroy() { this.widget.type.destroy(this.dom); super.destroy(); } get domAtom() { return true; } get side() { return this.widget.type.side; } } class CompositionViewDesc extends ViewDesc { constructor(parent, dom, textDOM, text) { super(parent, [], dom, null); this.textDOM = textDOM; this.text = text; } get size() { return this.text.length; } localPosFromDOM(dom, offset) { if (dom != this.textDOM) return this.posAtStart + (offset ? this.size : 0); return this.posAtStart + offset; } domFromPos(pos) { return { node: this.textDOM, offset: pos }; } ignoreMutation(mut) { return mut.type === 'characterData' && mut.target.nodeValue == mut.oldValue; } } // A mark desc represents a mark. May have multiple children, // depending on how the mark is split. Note that marks are drawn using // a fixed nesting order, for simplicity and predictability, so in // some cases they will be split more often than would appear // necessary. class MarkViewDesc extends ViewDesc { constructor(parent, mark, dom, contentDOM) { super(parent, [], dom, contentDOM); this.mark = mark; } static create(parent, mark, inline, view) { let custom = view.nodeViews[mark.type.name]; let spec = custom && custom(mark, view, inline); if (!spec || !spec.dom) spec = DOMSerializer.renderSpec(document, mark.type.spec.toDOM(mark, inline)); return new MarkViewDesc(parent, mark, spec.dom, spec.contentDOM || spec.dom); } parseRule() { if ((this.dirty & NODE_DIRTY) || this.mark.type.spec.reparseInView) return null; return { mark: this.mark.type.name, attrs: this.mark.attrs, contentElement: this.contentDOM }; } matchesMark(mark) { return this.dirty != NODE_DIRTY && this.mark.eq(mark); } markDirty(from, to) { super.markDirty(from, to); // Move dirty info to nearest node view if (this.dirty != NOT_DIRTY) { let parent = this.parent; while (!parent.node) parent = parent.parent; if (parent.dirty < this.dirty) parent.dirty = this.dirty; this.dirty = NOT_DIRTY; } } slice(from, to, view) { let copy = MarkViewDesc.create(this.parent, this.mark, true, view); let nodes = this.children, size = this.size; if (to < size) nodes = replaceNodes(nodes, to, size, view); if (from > 0) nodes = replaceNodes(nodes, 0, from, view); for (let i = 0; i < nodes.length; i++) nodes[i].parent = copy; copy.children = nodes; return copy; } } // Node view descs are the main, most common type of view desc, and // correspond to an actual node in the document. Unlike mark descs, // they populate their child array themselves. class NodeViewDesc extends ViewDesc { constructor(parent, node, outerDeco, innerDeco, dom, contentDOM, nodeDOM, view, pos) { super(parent, [], dom, contentDOM); this.node = node; this.outerDeco = outerDeco; this.innerDeco = innerDeco; this.nodeDOM = nodeDOM; } // By default, a node is rendered using the `toDOM` method from the // node type spec. But client code can use the `nodeViews` spec to // supply a custom node view, which can influence various aspects of // the way the node works. // // (Using subclassing for this was intentionally decided against, // since it'd require exposing a whole slew of finicky // implementation details to the user code that they probably will // never need.) static create(parent, node, outerDeco, innerDeco, view, pos) { let custom = view.nodeViews[node.type.name], descObj; let spec = custom && custom(node, view, () => { // (This is a function that allows the custom view to find its // own position) if (!descObj) return pos; if (descObj.parent) return descObj.parent.posBeforeChild(descObj); }, outerDeco, innerDeco); let dom = spec && spec.dom, contentDOM = spec && spec.contentDOM; if (node.isText) { if (!dom) dom = document.createTextNode(node.text); else if (dom.nodeType != 3) throw new RangeError("Text must be rendered as a DOM text node"); } else if (!dom) { ({ dom, contentDOM } = DOMSerializer.renderSpec(document, node.type.spec.toDOM(node))); } if (!contentDOM && !node.isText && dom.nodeName != "BR") { // Chrome gets confused by
    if (!dom.hasAttribute("contenteditable")) dom.contentEditable = "false"; if (node.type.spec.draggable) dom.draggable = true; } let nodeDOM = dom; dom = applyOuterDeco(dom, outerDeco, node); if (spec) return descObj = new CustomNodeViewDesc(parent, node, outerDeco, innerDeco, dom, contentDOM || null, nodeDOM, spec, view, pos + 1); else if (node.isText) return new TextViewDesc(parent, node, outerDeco, innerDeco, dom, nodeDOM, view); else return new NodeViewDesc(parent, node, outerDeco, innerDeco, dom, contentDOM || null, nodeDOM, view, pos + 1); } parseRule() { // Experimental kludge to allow opt-in re-parsing of nodes if (this.node.type.spec.reparseInView) return null; // FIXME the assumption that this can always return the current // attrs means that if the user somehow manages to change the // attrs in the dom, that won't be picked up. Not entirely sure // whether this is a problem let rule = { node: this.node.type.name, attrs: this.node.attrs }; if (this.node.type.whitespace == "pre") rule.preserveWhitespace = "full"; if (!this.contentDOM) { rule.getContent = () => this.node.content; } else if (!this.contentLost) { rule.contentElement = this.contentDOM; } else { // Chrome likes to randomly recreate parent nodes when // backspacing things. When that happens, this tries to find the // new parent. for (let i = this.children.length - 1; i >= 0; i--) { let child = this.children[i]; if (this.dom.contains(child.dom.parentNode)) { rule.contentElement = child.dom.parentNode; break; } } if (!rule.contentElement) rule.getContent = () => Fragment.empty; } return rule; } matchesNode(node, outerDeco, innerDeco) { return this.dirty == NOT_DIRTY && node.eq(this.node) && sameOuterDeco(outerDeco, this.outerDeco) && innerDeco.eq(this.innerDeco); } get size() { return this.node.nodeSize; } get border() { return this.node.isLeaf ? 0 : 1; } // Syncs `this.children` to match `this.node.content` and the local // decorations, possibly introducing nesting for marks. Then, in a // separate step, syncs the DOM inside `this.contentDOM` to // `this.children`. updateChildren(view, pos) { let inline = this.node.inlineContent, off = pos; let composition = view.composing ? this.localCompositionInfo(view, pos) : null; let localComposition = composition && composition.pos > -1 ? composition : null; let compositionInChild = composition && composition.pos < 0; let updater = new ViewTreeUpdater(this, localComposition && localComposition.node, view); iterDeco(this.node, this.innerDeco, (widget, i, insideNode) => { if (widget.spec.marks) updater.syncToMarks(widget.spec.marks, inline, view); else if (widget.type.side >= 0 && !insideNode) updater.syncToMarks(i == this.node.childCount ? Mark.none : this.node.child(i).marks, inline, view); // If the next node is a desc matching this widget, reuse it, // otherwise insert the widget as a new view desc. updater.placeWidget(widget, view, off); }, (child, outerDeco, innerDeco, i) => { // Make sure the wrapping mark descs match the node's marks. updater.syncToMarks(child.marks, inline, view); // Try several strategies for drawing this node let compIndex; if (updater.findNodeMatch(child, outerDeco, innerDeco, i)) ; else if (compositionInChild && view.state.selection.from > off && view.state.selection.to < off + child.nodeSize && (compIndex = updater.findIndexWithChild(composition.node)) > -1 && updater.updateNodeAt(child, outerDeco, innerDeco, compIndex, view)) ; else if (updater.updateNextNode(child, outerDeco, innerDeco, view, i, off)) ; else { // Add it as a new view updater.addNode(child, outerDeco, innerDeco, view, off); } off += child.nodeSize; }); // Drop all remaining descs after the current position. updater.syncToMarks([], inline, view); if (this.node.isTextblock) updater.addTextblockHacks(); updater.destroyRest(); // Sync the DOM if anything changed if (updater.changed || this.dirty == CONTENT_DIRTY) { // May have to protect focused DOM from being changed if a composition is active if (localComposition) this.protectLocalComposition(view, localComposition); renderDescs(this.contentDOM, this.children, view); if (ios) iosHacks(this.dom); } } localCompositionInfo(view, pos) { // Only do something if both the selection and a focused text node // are inside of this node let { from, to } = view.state.selection; if (!(view.state.selection instanceof TextSelection) || from < pos || to > pos + this.node.content.size) return null; let textNode = view.input.compositionNode; if (!textNode || !this.dom.contains(textNode.parentNode)) return null; if (this.node.inlineContent) { // Find the text in the focused node in the node, stop if it's not // there (may have been modified through other means, in which // case it should overwritten) let text = textNode.nodeValue; let textPos = findTextInFragment(this.node.content, text, from - pos, to - pos); return textPos < 0 ? null : { node: textNode, pos: textPos, text }; } else { return { node: textNode, pos: -1, text: "" }; } } protectLocalComposition(view, { node, pos, text }) { // The node is already part of a local view desc, leave it there if (this.getDesc(node)) return; // Create a composition view for the orphaned nodes let topNode = node; for (;; topNode = topNode.parentNode) { if (topNode.parentNode == this.contentDOM) break; while (topNode.previousSibling) topNode.parentNode.removeChild(topNode.previousSibling); while (topNode.nextSibling) topNode.parentNode.removeChild(topNode.nextSibling); if (topNode.pmViewDesc) topNode.pmViewDesc = undefined; } let desc = new CompositionViewDesc(this, topNode, node, text); view.input.compositionNodes.push(desc); // Patch up this.children to contain the composition view this.children = replaceNodes(this.children, pos, pos + text.length, view, desc); } // If this desc must be updated to match the given node decoration, // do so and return true. update(node, outerDeco, innerDeco, view) { if (this.dirty == NODE_DIRTY || !node.sameMarkup(this.node)) return false; this.updateInner(node, outerDeco, innerDeco, view); return true; } updateInner(node, outerDeco, innerDeco, view) { this.updateOuterDeco(outerDeco); this.node = node; this.innerDeco = innerDeco; if (this.contentDOM) this.updateChildren(view, this.posAtStart); this.dirty = NOT_DIRTY; } updateOuterDeco(outerDeco) { if (sameOuterDeco(outerDeco, this.outerDeco)) return; let needsWrap = this.nodeDOM.nodeType != 1; let oldDOM = this.dom; this.dom = patchOuterDeco(this.dom, this.nodeDOM, computeOuterDeco(this.outerDeco, this.node, needsWrap), computeOuterDeco(outerDeco, this.node, needsWrap)); if (this.dom != oldDOM) { oldDOM.pmViewDesc = undefined; this.dom.pmViewDesc = this; } this.outerDeco = outerDeco; } // Mark this node as being the selected node. selectNode() { if (this.nodeDOM.nodeType == 1) this.nodeDOM.classList.add("ProseMirror-selectednode"); if (this.contentDOM || !this.node.type.spec.draggable) this.dom.draggable = true; } // Remove selected node marking from this node. deselectNode() { if (this.nodeDOM.nodeType == 1) this.nodeDOM.classList.remove("ProseMirror-selectednode"); if (this.contentDOM || !this.node.type.spec.draggable) this.dom.removeAttribute("draggable"); } get domAtom() { return this.node.isAtom; } } // Create a view desc for the top-level document node, to be exported // and used by the view class. function docViewDesc(doc, outerDeco, innerDeco, dom, view) { applyOuterDeco(dom, outerDeco, doc); let docView = new NodeViewDesc(undefined, doc, outerDeco, innerDeco, dom, dom, dom, view, 0); if (docView.contentDOM) docView.updateChildren(view, 0); return docView; } class TextViewDesc extends NodeViewDesc { constructor(parent, node, outerDeco, innerDeco, dom, nodeDOM, view) { super(parent, node, outerDeco, innerDeco, dom, null, nodeDOM, view, 0); } parseRule() { let skip = this.nodeDOM.parentNode; while (skip && skip != this.dom && !skip.pmIsDeco) skip = skip.parentNode; return { skip: (skip || true) }; } update(node, outerDeco, innerDeco, view) { if (this.dirty == NODE_DIRTY || (this.dirty != NOT_DIRTY && !this.inParent()) || !node.sameMarkup(this.node)) return false; this.updateOuterDeco(outerDeco); if ((this.dirty != NOT_DIRTY || node.text != this.node.text) && node.text != this.nodeDOM.nodeValue) { this.nodeDOM.nodeValue = node.text; if (view.trackWrites == this.nodeDOM) view.trackWrites = null; } this.node = node; this.dirty = NOT_DIRTY; return true; } inParent() { let parentDOM = this.parent.contentDOM; for (let n = this.nodeDOM; n; n = n.parentNode) if (n == parentDOM) return true; return false; } domFromPos(pos) { return { node: this.nodeDOM, offset: pos }; } localPosFromDOM(dom, offset, bias) { if (dom == this.nodeDOM) return this.posAtStart + Math.min(offset, this.node.text.length); return super.localPosFromDOM(dom, offset, bias); } ignoreMutation(mutation) { return mutation.type != "characterData" && mutation.type != "selection"; } slice(from, to, view) { let node = this.node.cut(from, to), dom = document.createTextNode(node.text); return new TextViewDesc(this.parent, node, this.outerDeco, this.innerDeco, dom, dom, view); } markDirty(from, to) { super.markDirty(from, to); if (this.dom != this.nodeDOM && (from == 0 || to == this.nodeDOM.nodeValue.length)) this.dirty = NODE_DIRTY; } get domAtom() { return false; } isText(text) { return this.node.text == text; } } // A dummy desc used to tag trailing BR or IMG nodes created to work // around contentEditable terribleness. class TrailingHackViewDesc extends ViewDesc { parseRule() { return { ignore: true }; } matchesHack(nodeName) { return this.dirty == NOT_DIRTY && this.dom.nodeName == nodeName; } get domAtom() { return true; } get ignoreForCoords() { return this.dom.nodeName == "IMG"; } } // A separate subclass is used for customized node views, so that the // extra checks only have to be made for nodes that are actually // customized. class CustomNodeViewDesc extends NodeViewDesc { constructor(parent, node, outerDeco, innerDeco, dom, contentDOM, nodeDOM, spec, view, pos) { super(parent, node, outerDeco, innerDeco, dom, contentDOM, nodeDOM, view, pos); this.spec = spec; } // A custom `update` method gets to decide whether the update goes // through. If it does, and there's a `contentDOM` node, our logic // updates the children. update(node, outerDeco, innerDeco, view) { if (this.dirty == NODE_DIRTY) return false; if (this.spec.update) { let result = this.spec.update(node, outerDeco, innerDeco); if (result) this.updateInner(node, outerDeco, innerDeco, view); return result; } else if (!this.contentDOM && !node.isLeaf) { return false; } else { return super.update(node, outerDeco, innerDeco, view); } } selectNode() { this.spec.selectNode ? this.spec.selectNode() : super.selectNode(); } deselectNode() { this.spec.deselectNode ? this.spec.deselectNode() : super.deselectNode(); } setSelection(anchor, head, root, force) { this.spec.setSelection ? this.spec.setSelection(anchor, head, root) : super.setSelection(anchor, head, root, force); } destroy() { if (this.spec.destroy) this.spec.destroy(); super.destroy(); } stopEvent(event) { return this.spec.stopEvent ? this.spec.stopEvent(event) : false; } ignoreMutation(mutation) { return this.spec.ignoreMutation ? this.spec.ignoreMutation(mutation) : super.ignoreMutation(mutation); } } // Sync the content of the given DOM node with the nodes associated // with the given array of view descs, recursing into mark descs // because this should sync the subtree for a whole node at a time. function renderDescs(parentDOM, descs, view) { let dom = parentDOM.firstChild, written = false; for (let i = 0; i < descs.length; i++) { let desc = descs[i], childDOM = desc.dom; if (childDOM.parentNode == parentDOM) { while (childDOM != dom) { dom = rm(dom); written = true; } dom = dom.nextSibling; } else { written = true; parentDOM.insertBefore(childDOM, dom); } if (desc instanceof MarkViewDesc) { let pos = dom ? dom.previousSibling : parentDOM.lastChild; renderDescs(desc.contentDOM, desc.children, view); dom = pos ? pos.nextSibling : parentDOM.firstChild; } } while (dom) { dom = rm(dom); written = true; } if (written && view.trackWrites == parentDOM) view.trackWrites = null; } const OuterDecoLevel = function (nodeName) { if (nodeName) this.nodeName = nodeName; }; OuterDecoLevel.prototype = Object.create(null); const noDeco = [new OuterDecoLevel]; function computeOuterDeco(outerDeco, node, needsWrap) { if (outerDeco.length == 0) return noDeco; let top = needsWrap ? noDeco[0] : new OuterDecoLevel, result = [top]; for (let i = 0; i < outerDeco.length; i++) { let attrs = outerDeco[i].type.attrs; if (!attrs) continue; if (attrs.nodeName) result.push(top = new OuterDecoLevel(attrs.nodeName)); for (let name in attrs) { let val = attrs[name]; if (val == null) continue; if (needsWrap && result.length == 1) result.push(top = new OuterDecoLevel(node.isInline ? "span" : "div")); if (name == "class") top.class = (top.class ? top.class + " " : "") + val; else if (name == "style") top.style = (top.style ? top.style + ";" : "") + val; else if (name != "nodeName") top[name] = val; } } return result; } function patchOuterDeco(outerDOM, nodeDOM, prevComputed, curComputed) { // Shortcut for trivial case if (prevComputed == noDeco && curComputed == noDeco) return nodeDOM; let curDOM = nodeDOM; for (let i = 0; i < curComputed.length; i++) { let deco = curComputed[i], prev = prevComputed[i]; if (i) { let parent; if (prev && prev.nodeName == deco.nodeName && curDOM != outerDOM && (parent = curDOM.parentNode) && parent.nodeName.toLowerCase() == deco.nodeName) { curDOM = parent; } else { parent = document.createElement(deco.nodeName); parent.pmIsDeco = true; parent.appendChild(curDOM); prev = noDeco[0]; curDOM = parent; } } patchAttributes(curDOM, prev || noDeco[0], deco); } return curDOM; } function patchAttributes(dom, prev, cur) { for (let name in prev) if (name != "class" && name != "style" && name != "nodeName" && !(name in cur)) dom.removeAttribute(name); for (let name in cur) if (name != "class" && name != "style" && name != "nodeName" && cur[name] != prev[name]) dom.setAttribute(name, cur[name]); if (prev.class != cur.class) { let prevList = prev.class ? prev.class.split(" ").filter(Boolean) : []; let curList = cur.class ? cur.class.split(" ").filter(Boolean) : []; for (let i = 0; i < prevList.length; i++) if (curList.indexOf(prevList[i]) == -1) dom.classList.remove(prevList[i]); for (let i = 0; i < curList.length; i++) if (prevList.indexOf(curList[i]) == -1) dom.classList.add(curList[i]); if (dom.classList.length == 0) dom.removeAttribute("class"); } if (prev.style != cur.style) { if (prev.style) { let prop = /\s*([\w\-\xa1-\uffff]+)\s*:(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\(.*?\)|[^;])*/g, m; while (m = prop.exec(prev.style)) dom.style.removeProperty(m[1]); } if (cur.style) dom.style.cssText += cur.style; } } function applyOuterDeco(dom, deco, node) { return patchOuterDeco(dom, dom, noDeco, computeOuterDeco(deco, node, dom.nodeType != 1)); } function sameOuterDeco(a, b) { if (a.length != b.length) return false; for (let i = 0; i < a.length; i++) if (!a[i].type.eq(b[i].type)) return false; return true; } // Remove a DOM node and return its next sibling. function rm(dom) { let next = dom.nextSibling; dom.parentNode.removeChild(dom); return next; } // Helper class for incrementally updating a tree of mark descs and // the widget and node descs inside of them. class ViewTreeUpdater { constructor(top, lock, view) { this.lock = lock; this.view = view; // Index into `this.top`'s child array, represents the current // update position. this.index = 0; // When entering a mark, the current top and index are pushed // onto this. this.stack = []; // Tracks whether anything was changed this.changed = false; this.top = top; this.preMatch = preMatch(top.node.content, top); } // Destroy and remove the children between the given indices in // `this.top`. destroyBetween(start, end) { if (start == end) return; for (let i = start; i < end; i++) this.top.children[i].destroy(); this.top.children.splice(start, end - start); this.changed = true; } // Destroy all remaining children in `this.top`. destroyRest() { this.destroyBetween(this.index, this.top.children.length); } // Sync the current stack of mark descs with the given array of // marks, reusing existing mark descs when possible. syncToMarks(marks, inline, view) { let keep = 0, depth = this.stack.length >> 1; let maxKeep = Math.min(depth, marks.length); while (keep < maxKeep && (keep == depth - 1 ? this.top : this.stack[(keep + 1) << 1]) .matchesMark(marks[keep]) && marks[keep].type.spec.spanning !== false) keep++; while (keep < depth) { this.destroyRest(); this.top.dirty = NOT_DIRTY; this.index = this.stack.pop(); this.top = this.stack.pop(); depth--; } while (depth < marks.length) { this.stack.push(this.top, this.index + 1); let found = -1; for (let i = this.index; i < Math.min(this.index + 3, this.top.children.length); i++) { let next = this.top.children[i]; if (next.matchesMark(marks[depth]) && !this.isLocked(next.dom)) { found = i; break; } } if (found > -1) { if (found > this.index) { this.changed = true; this.destroyBetween(this.index, found); } this.top = this.top.children[this.index]; } else { let markDesc = MarkViewDesc.create(this.top, marks[depth], inline, view); this.top.children.splice(this.index, 0, markDesc); this.top = markDesc; this.changed = true; } this.index = 0; depth++; } } // Try to find a node desc matching the given data. Skip over it and // return true when successful. findNodeMatch(node, outerDeco, innerDeco, index) { let found = -1, targetDesc; if (index >= this.preMatch.index && (targetDesc = this.preMatch.matches[index - this.preMatch.index]).parent == this.top && targetDesc.matchesNode(node, outerDeco, innerDeco)) { found = this.top.children.indexOf(targetDesc, this.index); } else { for (let i = this.index, e = Math.min(this.top.children.length, i + 5); i < e; i++) { let child = this.top.children[i]; if (child.matchesNode(node, outerDeco, innerDeco) && !this.preMatch.matched.has(child)) { found = i; break; } } } if (found < 0) return false; this.destroyBetween(this.index, found); this.index++; return true; } updateNodeAt(node, outerDeco, innerDeco, index, view) { let child = this.top.children[index]; if (child.dirty == NODE_DIRTY && child.dom == child.contentDOM) child.dirty = CONTENT_DIRTY; if (!child.update(node, outerDeco, innerDeco, view)) return false; this.destroyBetween(this.index, index); this.index++; return true; } findIndexWithChild(domNode) { for (;;) { let parent = domNode.parentNode; if (!parent) return -1; if (parent == this.top.contentDOM) { let desc = domNode.pmViewDesc; if (desc) for (let i = this.index; i < this.top.children.length; i++) { if (this.top.children[i] == desc) return i; } return -1; } domNode = parent; } } // Try to update the next node, if any, to the given data. Checks // pre-matches to avoid overwriting nodes that could still be used. updateNextNode(node, outerDeco, innerDeco, view, index, pos) { for (let i = this.index; i < this.top.children.length; i++) { let next = this.top.children[i]; if (next instanceof NodeViewDesc) { let preMatch = this.preMatch.matched.get(next); if (preMatch != null && preMatch != index) return false; let nextDOM = next.dom, updated; // Can't update if nextDOM is or contains this.lock, except if // it's a text node whose content already matches the new text // and whose decorations match the new ones. let locked = this.isLocked(nextDOM) && !(node.isText && next.node && next.node.isText && next.nodeDOM.nodeValue == node.text && next.dirty != NODE_DIRTY && sameOuterDeco(outerDeco, next.outerDeco)); if (!locked && next.update(node, outerDeco, innerDeco, view)) { this.destroyBetween(this.index, i); if (next.dom != nextDOM) this.changed = true; this.index++; return true; } else if (!locked && (updated = this.recreateWrapper(next, node, outerDeco, innerDeco, view, pos))) { this.top.children[this.index] = updated; if (updated.contentDOM) { updated.dirty = CONTENT_DIRTY; updated.updateChildren(view, pos + 1); updated.dirty = NOT_DIRTY; } this.changed = true; this.index++; return true; } break; } } return false; } // When a node with content is replaced by a different node with // identical content, move over its children. recreateWrapper(next, node, outerDeco, innerDeco, view, pos) { if (next.dirty || node.isAtom || !next.children.length || !next.node.content.eq(node.content)) return null; let wrapper = NodeViewDesc.create(this.top, node, outerDeco, innerDeco, view, pos); if (wrapper.contentDOM) { wrapper.children = next.children; next.children = []; for (let ch of wrapper.children) ch.parent = wrapper; } next.destroy(); return wrapper; } // Insert the node as a newly created node desc. addNode(node, outerDeco, innerDeco, view, pos) { let desc = NodeViewDesc.create(this.top, node, outerDeco, innerDeco, view, pos); if (desc.contentDOM) desc.updateChildren(view, pos + 1); this.top.children.splice(this.index++, 0, desc); this.changed = true; } placeWidget(widget, view, pos) { let next = this.index < this.top.children.length ? this.top.children[this.index] : null; if (next && next.matchesWidget(widget) && (widget == next.widget || !next.widget.type.toDOM.parentNode)) { this.index++; } else { let desc = new WidgetViewDesc(this.top, widget, view, pos); this.top.children.splice(this.index++, 0, desc); this.changed = true; } } // Make sure a textblock looks and behaves correctly in // contentEditable. addTextblockHacks() { let lastChild = this.top.children[this.index - 1], parent = this.top; while (lastChild instanceof MarkViewDesc) { parent = lastChild; lastChild = parent.children[parent.children.length - 1]; } if (!lastChild || // Empty textblock !(lastChild instanceof TextViewDesc) || /\n$/.test(lastChild.node.text) || (this.view.requiresGeckoHackNode && /\s$/.test(lastChild.node.text))) { // Avoid bugs in Safari's cursor drawing (#1165) and Chrome's mouse selection (#1152) if ((safari || chrome) && lastChild && lastChild.dom.contentEditable == "false") this.addHackNode("IMG", parent); this.addHackNode("BR", this.top); } } addHackNode(nodeName, parent) { if (parent == this.top && this.index < parent.children.length && parent.children[this.index].matchesHack(nodeName)) { this.index++; } else { let dom = document.createElement(nodeName); if (nodeName == "IMG") { dom.className = "ProseMirror-separator"; dom.alt = ""; } if (nodeName == "BR") dom.className = "ProseMirror-trailingBreak"; let hack = new TrailingHackViewDesc(this.top, [], dom, null); if (parent != this.top) parent.children.push(hack); else parent.children.splice(this.index++, 0, hack); this.changed = true; } } isLocked(node) { return this.lock && (node == this.lock || node.nodeType == 1 && node.contains(this.lock.parentNode)); } } // Iterate from the end of the fragment and array of descs to find // directly matching ones, in order to avoid overeagerly reusing those // for other nodes. Returns the fragment index of the first node that // is part of the sequence of matched nodes at the end of the // fragment. function preMatch(frag, parentDesc) { let curDesc = parentDesc, descI = curDesc.children.length; let fI = frag.childCount, matched = new Map, matches = []; outer: while (fI > 0) { let desc; for (;;) { if (descI) { let next = curDesc.children[descI - 1]; if (next instanceof MarkViewDesc) { curDesc = next; descI = next.children.length; } else { desc = next; descI--; break; } } else if (curDesc == parentDesc) { break outer; } else { // FIXME descI = curDesc.parent.children.indexOf(curDesc); curDesc = curDesc.parent; } } let node = desc.node; if (!node) continue; if (node != frag.child(fI - 1)) break; --fI; matched.set(desc, fI); matches.push(desc); } return { index: fI, matched, matches: matches.reverse() }; } function compareSide(a, b) { return a.type.side - b.type.side; } // This function abstracts iterating over the nodes and decorations in // a fragment. Calls `onNode` for each node, with its local and child // decorations. Splits text nodes when there is a decoration starting // or ending inside of them. Calls `onWidget` for each widget. function iterDeco(parent, deco, onWidget, onNode) { let locals = deco.locals(parent), offset = 0; // Simple, cheap variant for when there are no local decorations if (locals.length == 0) { for (let i = 0; i < parent.childCount; i++) { let child = parent.child(i); onNode(child, locals, deco.forChild(offset, child), i); offset += child.nodeSize; } return; } let decoIndex = 0, active = [], restNode = null; for (let parentIndex = 0;;) { let widget, widgets; while (decoIndex < locals.length && locals[decoIndex].to == offset) { let next = locals[decoIndex++]; if (next.widget) { if (!widget) widget = next; else (widgets || (widgets = [widget])).push(next); } } if (widget) { if (widgets) { widgets.sort(compareSide); for (let i = 0; i < widgets.length; i++) onWidget(widgets[i], parentIndex, !!restNode); } else { onWidget(widget, parentIndex, !!restNode); } } let child, index; if (restNode) { index = -1; child = restNode; restNode = null; } else if (parentIndex < parent.childCount) { index = parentIndex; child = parent.child(parentIndex++); } else { break; } for (let i = 0; i < active.length; i++) if (active[i].to <= offset) active.splice(i--, 1); while (decoIndex < locals.length && locals[decoIndex].from <= offset && locals[decoIndex].to > offset) active.push(locals[decoIndex++]); let end = offset + child.nodeSize; if (child.isText) { let cutAt = end; if (decoIndex < locals.length && locals[decoIndex].from < cutAt) cutAt = locals[decoIndex].from; for (let i = 0; i < active.length; i++) if (active[i].to < cutAt) cutAt = active[i].to; if (cutAt < end) { restNode = child.cut(cutAt - offset); child = child.cut(0, cutAt - offset); end = cutAt; index = -1; } } else { while (decoIndex < locals.length && locals[decoIndex].to < end) decoIndex++; } let outerDeco = child.isInline && !child.isLeaf ? active.filter(d => !d.inline) : active.slice(); onNode(child, outerDeco, deco.forChild(offset, child), index); offset = end; } } // List markers in Mobile Safari will mysteriously disappear // sometimes. This works around that. function iosHacks(dom) { if (dom.nodeName == "UL" || dom.nodeName == "OL") { let oldCSS = dom.style.cssText; dom.style.cssText = oldCSS + "; list-style: square !important"; window.getComputedStyle(dom).listStyle; dom.style.cssText = oldCSS; } } // Find a piece of text in an inline fragment, overlapping from-to function findTextInFragment(frag, text, from, to) { for (let i = 0, pos = 0; i < frag.childCount && pos <= to;) { let child = frag.child(i++), childStart = pos; pos += child.nodeSize; if (!child.isText) continue; let str = child.text; while (i < frag.childCount) { let next = frag.child(i++); pos += next.nodeSize; if (!next.isText) break; str += next.text; } if (pos >= from) { if (pos >= to && str.slice(to - text.length - childStart, to - childStart) == text) return to - text.length; let found = childStart < to ? str.lastIndexOf(text, to - childStart - 1) : -1; if (found >= 0 && found + text.length + childStart >= from) return childStart + found; if (from == to && str.length >= (to + text.length) - childStart && str.slice(to - childStart, to - childStart + text.length) == text) return to; } } return -1; } // Replace range from-to in an array of view descs with replacement // (may be null to just delete). This goes very much against the grain // of the rest of this code, which tends to create nodes with the // right shape in one go, rather than messing with them after // creation, but is necessary in the composition hack. function replaceNodes(nodes, from, to, view, replacement) { let result = []; for (let i = 0, off = 0; i < nodes.length; i++) { let child = nodes[i], start = off, end = off += child.size; if (start >= to || end <= from) { result.push(child); } else { if (start < from) result.push(child.slice(0, from - start, view)); if (replacement) { result.push(replacement); replacement = undefined; } if (end > to) result.push(child.slice(to - start, child.size, view)); } } return result; } function selectionFromDOM(view, origin = null) { let domSel = view.domSelectionRange(), doc = view.state.doc; if (!domSel.focusNode) return null; let nearestDesc = view.docView.nearestDesc(domSel.focusNode), inWidget = nearestDesc && nearestDesc.size == 0; let head = view.docView.posFromDOM(domSel.focusNode, domSel.focusOffset, 1); if (head < 0) return null; let $head = doc.resolve(head), $anchor, selection; if (selectionCollapsed(domSel)) { $anchor = $head; while (nearestDesc && !nearestDesc.node) nearestDesc = nearestDesc.parent; let nearestDescNode = nearestDesc.node; if (nearestDesc && nearestDescNode.isAtom && NodeSelection.isSelectable(nearestDescNode) && nearestDesc.parent && !(nearestDescNode.isInline && isOnEdge(domSel.focusNode, domSel.focusOffset, nearestDesc.dom))) { let pos = nearestDesc.posBefore; selection = new NodeSelection(head == pos ? $head : doc.resolve(pos)); } } else { let anchor = view.docView.posFromDOM(domSel.anchorNode, domSel.anchorOffset, 1); if (anchor < 0) return null; $anchor = doc.resolve(anchor); } if (!selection) { let bias = origin == "pointer" || (view.state.selection.head < $head.pos && !inWidget) ? 1 : -1; selection = selectionBetween(view, $anchor, $head, bias); } return selection; } function editorOwnsSelection(view) { return view.editable ? view.hasFocus() : hasSelection(view) && document.activeElement && document.activeElement.contains(view.dom); } function selectionToDOM(view, force = false) { let sel = view.state.selection; syncNodeSelection(view, sel); if (!editorOwnsSelection(view)) return; // The delayed drag selection causes issues with Cell Selections // in Safari. And the drag selection delay is to workarond issues // which only present in Chrome. if (!force && view.input.mouseDown && view.input.mouseDown.allowDefault && chrome) { let domSel = view.domSelectionRange(), curSel = view.domObserver.currentSelection; if (domSel.anchorNode && curSel.anchorNode && isEquivalentPosition(domSel.anchorNode, domSel.anchorOffset, curSel.anchorNode, curSel.anchorOffset)) { view.input.mouseDown.delayedSelectionSync = true; view.domObserver.setCurSelection(); return; } } view.domObserver.disconnectSelection(); if (view.cursorWrapper) { selectCursorWrapper(view); } else { let { anchor, head } = sel, resetEditableFrom, resetEditableTo; if (brokenSelectBetweenUneditable && !(sel instanceof TextSelection)) { if (!sel.$from.parent.inlineContent) resetEditableFrom = temporarilyEditableNear(view, sel.from); if (!sel.empty && !sel.$from.parent.inlineContent) resetEditableTo = temporarilyEditableNear(view, sel.to); } view.docView.setSelection(anchor, head, view.root, force); if (brokenSelectBetweenUneditable) { if (resetEditableFrom) resetEditable(resetEditableFrom); if (resetEditableTo) resetEditable(resetEditableTo); } if (sel.visible) { view.dom.classList.remove("ProseMirror-hideselection"); } else { view.dom.classList.add("ProseMirror-hideselection"); if ("onselectionchange" in document) removeClassOnSelectionChange(view); } } view.domObserver.setCurSelection(); view.domObserver.connectSelection(); } // Kludge to work around Webkit not allowing a selection to start/end // between non-editable block nodes. We briefly make something // editable, set the selection, then set it uneditable again. const brokenSelectBetweenUneditable = safari || chrome && chrome_version < 63; function temporarilyEditableNear(view, pos) { let { node, offset } = view.docView.domFromPos(pos, 0); let after = offset < node.childNodes.length ? node.childNodes[offset] : null; let before = offset ? node.childNodes[offset - 1] : null; if (safari && after && after.contentEditable == "false") return setEditable(after); if ((!after || after.contentEditable == "false") && (!before || before.contentEditable == "false")) { if (after) return setEditable(after); else if (before) return setEditable(before); } } function setEditable(element) { element.contentEditable = "true"; if (safari && element.draggable) { element.draggable = false; element.wasDraggable = true; } return element; } function resetEditable(element) { element.contentEditable = "false"; if (element.wasDraggable) { element.draggable = true; element.wasDraggable = null; } } function removeClassOnSelectionChange(view) { let doc = view.dom.ownerDocument; doc.removeEventListener("selectionchange", view.input.hideSelectionGuard); let domSel = view.domSelectionRange(); let node = domSel.anchorNode, offset = domSel.anchorOffset; doc.addEventListener("selectionchange", view.input.hideSelectionGuard = () => { if (domSel.anchorNode != node || domSel.anchorOffset != offset) { doc.removeEventListener("selectionchange", view.input.hideSelectionGuard); setTimeout(() => { if (!editorOwnsSelection(view) || view.state.selection.visible) view.dom.classList.remove("ProseMirror-hideselection"); }, 20); } }); } function selectCursorWrapper(view) { let domSel = view.domSelection(), range = document.createRange(); let node = view.cursorWrapper.dom, img = node.nodeName == "IMG"; if (img) range.setEnd(node.parentNode, domIndex(node) + 1); else range.setEnd(node, 0); range.collapse(false); domSel.removeAllRanges(); domSel.addRange(range); // Kludge to kill 'control selection' in IE11 when selecting an // invisible cursor wrapper, since that would result in those weird // resize handles and a selection that considers the absolutely // positioned wrapper, rather than the root editable node, the // focused element. if (!img && !view.state.selection.visible && ie$1 && ie_version <= 11) { node.disabled = true; node.disabled = false; } } function syncNodeSelection(view, sel) { if (sel instanceof NodeSelection) { let desc = view.docView.descAt(sel.from); if (desc != view.lastSelectedViewDesc) { clearNodeSelection(view); if (desc) desc.selectNode(); view.lastSelectedViewDesc = desc; } } else { clearNodeSelection(view); } } // Clear all DOM statefulness of the last node selection. function clearNodeSelection(view) { if (view.lastSelectedViewDesc) { if (view.lastSelectedViewDesc.parent) view.lastSelectedViewDesc.deselectNode(); view.lastSelectedViewDesc = undefined; } } function selectionBetween(view, $anchor, $head, bias) { return view.someProp("createSelectionBetween", f => f(view, $anchor, $head)) || TextSelection.between($anchor, $head, bias); } function hasFocusAndSelection(view) { if (view.editable && !view.hasFocus()) return false; return hasSelection(view); } function hasSelection(view) { let sel = view.domSelectionRange(); if (!sel.anchorNode) return false; try { // Firefox will raise 'permission denied' errors when accessing // properties of `sel.anchorNode` when it's in a generated CSS // element. return view.dom.contains(sel.anchorNode.nodeType == 3 ? sel.anchorNode.parentNode : sel.anchorNode) && (view.editable || view.dom.contains(sel.focusNode.nodeType == 3 ? sel.focusNode.parentNode : sel.focusNode)); } catch (_) { return false; } } function anchorInRightPlace(view) { let anchorDOM = view.docView.domFromPos(view.state.selection.anchor, 0); let domSel = view.domSelectionRange(); return isEquivalentPosition(anchorDOM.node, anchorDOM.offset, domSel.anchorNode, domSel.anchorOffset); } function moveSelectionBlock(state, dir) { let { $anchor, $head } = state.selection; let $side = dir > 0 ? $anchor.max($head) : $anchor.min($head); let $start = !$side.parent.inlineContent ? $side : $side.depth ? state.doc.resolve(dir > 0 ? $side.after() : $side.before()) : null; return $start && Selection.findFrom($start, dir); } function apply(view, sel) { view.dispatch(view.state.tr.setSelection(sel).scrollIntoView()); return true; } function selectHorizontally(view, dir, mods) { let sel = view.state.selection; if (sel instanceof TextSelection) { if (mods.indexOf("s") > -1) { let { $head } = sel, node = $head.textOffset ? null : dir < 0 ? $head.nodeBefore : $head.nodeAfter; if (!node || node.isText || !node.isLeaf) return false; let $newHead = view.state.doc.resolve($head.pos + node.nodeSize * (dir < 0 ? -1 : 1)); return apply(view, new TextSelection(sel.$anchor, $newHead)); } else if (!sel.empty) { return false; } else if (view.endOfTextblock(dir > 0 ? "forward" : "backward")) { let next = moveSelectionBlock(view.state, dir); if (next && (next instanceof NodeSelection)) return apply(view, next); return false; } else if (!(mac$3 && mods.indexOf("m") > -1)) { let $head = sel.$head, node = $head.textOffset ? null : dir < 0 ? $head.nodeBefore : $head.nodeAfter, desc; if (!node || node.isText) return false; let nodePos = dir < 0 ? $head.pos - node.nodeSize : $head.pos; if (!(node.isAtom || (desc = view.docView.descAt(nodePos)) && !desc.contentDOM)) return false; if (NodeSelection.isSelectable(node)) { return apply(view, new NodeSelection(dir < 0 ? view.state.doc.resolve($head.pos - node.nodeSize) : $head)); } else if (webkit) { // Chrome and Safari will introduce extra pointless cursor // positions around inline uneditable nodes, so we have to // take over and move the cursor past them (#937) return apply(view, new TextSelection(view.state.doc.resolve(dir < 0 ? nodePos : nodePos + node.nodeSize))); } else { return false; } } } else if (sel instanceof NodeSelection && sel.node.isInline) { return apply(view, new TextSelection(dir > 0 ? sel.$to : sel.$from)); } else { let next = moveSelectionBlock(view.state, dir); if (next) return apply(view, next); return false; } } function nodeLen(node) { return node.nodeType == 3 ? node.nodeValue.length : node.childNodes.length; } function isIgnorable(dom, dir) { let desc = dom.pmViewDesc; return desc && desc.size == 0 && (dir < 0 || dom.nextSibling || dom.nodeName != "BR"); } function skipIgnoredNodes(view, dir) { return dir < 0 ? skipIgnoredNodesBefore(view) : skipIgnoredNodesAfter(view); } // Make sure the cursor isn't directly after one or more ignored // nodes, which will confuse the browser's cursor motion logic. function skipIgnoredNodesBefore(view) { let sel = view.domSelectionRange(); let node = sel.focusNode, offset = sel.focusOffset; if (!node) return; let moveNode, moveOffset, force = false; // Gecko will do odd things when the selection is directly in front // of a non-editable node, so in that case, move it into the next // node if possible. Issue prosemirror/prosemirror#832. if (gecko && node.nodeType == 1 && offset < nodeLen(node) && isIgnorable(node.childNodes[offset], -1)) force = true; for (;;) { if (offset > 0) { if (node.nodeType != 1) { break; } else { let before = node.childNodes[offset - 1]; if (isIgnorable(before, -1)) { moveNode = node; moveOffset = --offset; } else if (before.nodeType == 3) { node = before; offset = node.nodeValue.length; } else break; } } else if (isBlockNode(node)) { break; } else { let prev = node.previousSibling; while (prev && isIgnorable(prev, -1)) { moveNode = node.parentNode; moveOffset = domIndex(prev); prev = prev.previousSibling; } if (!prev) { node = node.parentNode; if (node == view.dom) break; offset = 0; } else { node = prev; offset = nodeLen(node); } } } if (force) setSelFocus(view, node, offset); else if (moveNode) setSelFocus(view, moveNode, moveOffset); } // Make sure the cursor isn't directly before one or more ignored // nodes. function skipIgnoredNodesAfter(view) { let sel = view.domSelectionRange(); let node = sel.focusNode, offset = sel.focusOffset; if (!node) return; let len = nodeLen(node); let moveNode, moveOffset; for (;;) { if (offset < len) { if (node.nodeType != 1) break; let after = node.childNodes[offset]; if (isIgnorable(after, 1)) { moveNode = node; moveOffset = ++offset; } else break; } else if (isBlockNode(node)) { break; } else { let next = node.nextSibling; while (next && isIgnorable(next, 1)) { moveNode = next.parentNode; moveOffset = domIndex(next) + 1; next = next.nextSibling; } if (!next) { node = node.parentNode; if (node == view.dom) break; offset = len = 0; } else { node = next; offset = 0; len = nodeLen(node); } } } if (moveNode) setSelFocus(view, moveNode, moveOffset); } function isBlockNode(dom) { let desc = dom.pmViewDesc; return desc && desc.node && desc.node.isBlock; } function textNodeAfter(node, offset) { while (node && offset == node.childNodes.length && !hasBlockDesc(node)) { offset = domIndex(node) + 1; node = node.parentNode; } while (node && offset < node.childNodes.length) { let next = node.childNodes[offset]; if (next.nodeType == 3) return next; if (next.nodeType == 1 && next.contentEditable == "false") break; node = next; offset = 0; } } function textNodeBefore(node, offset) { while (node && !offset && !hasBlockDesc(node)) { offset = domIndex(node); node = node.parentNode; } while (node && offset) { let next = node.childNodes[offset - 1]; if (next.nodeType == 3) return next; if (next.nodeType == 1 && next.contentEditable == "false") break; node = next; offset = node.childNodes.length; } } function setSelFocus(view, node, offset) { if (node.nodeType != 3) { let before, after; if (after = textNodeAfter(node, offset)) { node = after; offset = 0; } else if (before = textNodeBefore(node, offset)) { node = before; offset = before.nodeValue.length; } } let sel = view.domSelection(); if (selectionCollapsed(sel)) { let range = document.createRange(); range.setEnd(node, offset); range.setStart(node, offset); sel.removeAllRanges(); sel.addRange(range); } else if (sel.extend) { sel.extend(node, offset); } view.domObserver.setCurSelection(); let { state } = view; // If no state update ends up happening, reset the selection. setTimeout(() => { if (view.state == state) selectionToDOM(view); }, 50); } function findDirection(view, pos) { let $pos = view.state.doc.resolve(pos); if (!(chrome || windows) && $pos.parent.inlineContent) { let coords = view.coordsAtPos(pos); if (pos > $pos.start()) { let before = view.coordsAtPos(pos - 1); let mid = (before.top + before.bottom) / 2; if (mid > coords.top && mid < coords.bottom && Math.abs(before.left - coords.left) > 1) return before.left < coords.left ? "ltr" : "rtl"; } if (pos < $pos.end()) { let after = view.coordsAtPos(pos + 1); let mid = (after.top + after.bottom) / 2; if (mid > coords.top && mid < coords.bottom && Math.abs(after.left - coords.left) > 1) return after.left > coords.left ? "ltr" : "rtl"; } } let computed = getComputedStyle(view.dom).direction; return computed == "rtl" ? "rtl" : "ltr"; } // Check whether vertical selection motion would involve node // selections. If so, apply it (if not, the result is left to the // browser) function selectVertically(view, dir, mods) { let sel = view.state.selection; if (sel instanceof TextSelection && !sel.empty || mods.indexOf("s") > -1) return false; if (mac$3 && mods.indexOf("m") > -1) return false; let { $from, $to } = sel; if (!$from.parent.inlineContent || view.endOfTextblock(dir < 0 ? "up" : "down")) { let next = moveSelectionBlock(view.state, dir); if (next && (next instanceof NodeSelection)) return apply(view, next); } if (!$from.parent.inlineContent) { let side = dir < 0 ? $from : $to; let beyond = sel instanceof AllSelection ? Selection.near(side, dir) : Selection.findFrom(side, dir); return beyond ? apply(view, beyond) : false; } return false; } function stopNativeHorizontalDelete(view, dir) { if (!(view.state.selection instanceof TextSelection)) return true; let { $head, $anchor, empty } = view.state.selection; if (!$head.sameParent($anchor)) return true; if (!empty) return false; if (view.endOfTextblock(dir > 0 ? "forward" : "backward")) return true; let nextNode = !$head.textOffset && (dir < 0 ? $head.nodeBefore : $head.nodeAfter); if (nextNode && !nextNode.isText) { let tr = view.state.tr; if (dir < 0) tr.delete($head.pos - nextNode.nodeSize, $head.pos); else tr.delete($head.pos, $head.pos + nextNode.nodeSize); view.dispatch(tr); return true; } return false; } function switchEditable(view, node, state) { view.domObserver.stop(); node.contentEditable = state; view.domObserver.start(); } // Issue #867 / #1090 / https://bugs.chromium.org/p/chromium/issues/detail?id=903821 // In which Safari (and at some point in the past, Chrome) does really // wrong things when the down arrow is pressed when the cursor is // directly at the start of a textblock and has an uneditable node // after it function safariDownArrowBug(view) { if (!safari || view.state.selection.$head.parentOffset > 0) return false; let { focusNode, focusOffset } = view.domSelectionRange(); if (focusNode && focusNode.nodeType == 1 && focusOffset == 0 && focusNode.firstChild && focusNode.firstChild.contentEditable == "false") { let child = focusNode.firstChild; switchEditable(view, child, "true"); setTimeout(() => switchEditable(view, child, "false"), 20); } return false; } // A backdrop key mapping used to make sure we always suppress keys // that have a dangerous default effect, even if the commands they are // bound to return false, and to make sure that cursor-motion keys // find a cursor (as opposed to a node selection) when pressed. For // cursor-motion keys, the code in the handlers also takes care of // block selections. function getMods(event) { let result = ""; if (event.ctrlKey) result += "c"; if (event.metaKey) result += "m"; if (event.altKey) result += "a"; if (event.shiftKey) result += "s"; return result; } function captureKeyDown(view, event) { let code = event.keyCode, mods = getMods(event); if (code == 8 || (mac$3 && code == 72 && mods == "c")) { // Backspace, Ctrl-h on Mac return stopNativeHorizontalDelete(view, -1) || skipIgnoredNodes(view, -1); } else if ((code == 46 && !event.shiftKey) || (mac$3 && code == 68 && mods == "c")) { // Delete, Ctrl-d on Mac return stopNativeHorizontalDelete(view, 1) || skipIgnoredNodes(view, 1); } else if (code == 13 || code == 27) { // Enter, Esc return true; } else if (code == 37 || (mac$3 && code == 66 && mods == "c")) { // Left arrow, Ctrl-b on Mac let dir = code == 37 ? (findDirection(view, view.state.selection.from) == "ltr" ? -1 : 1) : -1; return selectHorizontally(view, dir, mods) || skipIgnoredNodes(view, dir); } else if (code == 39 || (mac$3 && code == 70 && mods == "c")) { // Right arrow, Ctrl-f on Mac let dir = code == 39 ? (findDirection(view, view.state.selection.from) == "ltr" ? 1 : -1) : 1; return selectHorizontally(view, dir, mods) || skipIgnoredNodes(view, dir); } else if (code == 38 || (mac$3 && code == 80 && mods == "c")) { // Up arrow, Ctrl-p on Mac return selectVertically(view, -1, mods) || skipIgnoredNodes(view, -1); } else if (code == 40 || (mac$3 && code == 78 && mods == "c")) { // Down arrow, Ctrl-n on Mac return safariDownArrowBug(view) || selectVertically(view, 1, mods) || skipIgnoredNodes(view, 1); } else if (mods == (mac$3 ? "m" : "c") && (code == 66 || code == 73 || code == 89 || code == 90)) { // Mod-[biyz] return true; } return false; } function serializeForClipboard(view, slice) { view.someProp("transformCopied", f => { slice = f(slice, view); }); let context = [], { content, openStart, openEnd } = slice; while (openStart > 1 && openEnd > 1 && content.childCount == 1 && content.firstChild.childCount == 1) { openStart--; openEnd--; let node = content.firstChild; context.push(node.type.name, node.attrs != node.type.defaultAttrs ? node.attrs : null); content = node.content; } let serializer = view.someProp("clipboardSerializer") || DOMSerializer.fromSchema(view.state.schema); let doc = detachedDoc(), wrap = doc.createElement("div"); wrap.appendChild(serializer.serializeFragment(content, { document: doc })); let firstChild = wrap.firstChild, needsWrap, wrappers = 0; while (firstChild && firstChild.nodeType == 1 && (needsWrap = wrapMap[firstChild.nodeName.toLowerCase()])) { for (let i = needsWrap.length - 1; i >= 0; i--) { let wrapper = doc.createElement(needsWrap[i]); while (wrap.firstChild) wrapper.appendChild(wrap.firstChild); wrap.appendChild(wrapper); wrappers++; } firstChild = wrap.firstChild; } if (firstChild && firstChild.nodeType == 1) firstChild.setAttribute("data-pm-slice", `${openStart} ${openEnd}${wrappers ? ` -${wrappers}` : ""} ${JSON.stringify(context)}`); let text = view.someProp("clipboardTextSerializer", f => f(slice, view)) || slice.content.textBetween(0, slice.content.size, "\n\n"); return { dom: wrap, text, slice }; } // Read a slice of content from the clipboard (or drop data). function parseFromClipboard(view, text, html, plainText, $context) { let inCode = $context.parent.type.spec.code; let dom, slice; if (!html && !text) return null; let asText = text && (plainText || inCode || !html); if (asText) { view.someProp("transformPastedText", f => { text = f(text, inCode || plainText, view); }); if (inCode) return text ? new Slice(Fragment.from(view.state.schema.text(text.replace(/\r\n?/g, "\n"))), 0, 0) : Slice.empty; let parsed = view.someProp("clipboardTextParser", f => f(text, $context, plainText, view)); if (parsed) { slice = parsed; } else { let marks = $context.marks(); let { schema } = view.state, serializer = DOMSerializer.fromSchema(schema); dom = document.createElement("div"); text.split(/(?:\r\n?|\n)+/).forEach(block => { let p = dom.appendChild(document.createElement("p")); if (block) p.appendChild(serializer.serializeNode(schema.text(block, marks))); }); } } else { view.someProp("transformPastedHTML", f => { html = f(html, view); }); dom = readHTML(html); if (webkit) restoreReplacedSpaces(dom); } let contextNode = dom && dom.querySelector("[data-pm-slice]"); let sliceData = contextNode && /^(\d+) (\d+)(?: -(\d+))? (.*)/.exec(contextNode.getAttribute("data-pm-slice") || ""); if (sliceData && sliceData[3]) for (let i = +sliceData[3]; i > 0; i--) { let child = dom.firstChild; while (child && child.nodeType != 1) child = child.nextSibling; if (!child) break; dom = child; } if (!slice) { let parser = view.someProp("clipboardParser") || view.someProp("domParser") || DOMParser$1.fromSchema(view.state.schema); slice = parser.parseSlice(dom, { preserveWhitespace: !!(asText || sliceData), context: $context, ruleFromNode(dom) { if (dom.nodeName == "BR" && !dom.nextSibling && dom.parentNode && !inlineParents.test(dom.parentNode.nodeName)) return { ignore: true }; return null; } }); } if (sliceData) { slice = addContext(closeSlice(slice, +sliceData[1], +sliceData[2]), sliceData[4]); } else { // HTML wasn't created by ProseMirror. Make sure top-level siblings are coherent slice = Slice.maxOpen(normalizeSiblings(slice.content, $context), true); if (slice.openStart || slice.openEnd) { let openStart = 0, openEnd = 0; for (let node = slice.content.firstChild; openStart < slice.openStart && !node.type.spec.isolating; openStart++, node = node.firstChild) { } for (let node = slice.content.lastChild; openEnd < slice.openEnd && !node.type.spec.isolating; openEnd++, node = node.lastChild) { } slice = closeSlice(slice, openStart, openEnd); } } view.someProp("transformPasted", f => { slice = f(slice, view); }); return slice; } const inlineParents = /^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var)$/i; // Takes a slice parsed with parseSlice, which means there hasn't been // any content-expression checking done on the top nodes, tries to // find a parent node in the current context that might fit the nodes, // and if successful, rebuilds the slice so that it fits into that parent. // // This addresses the problem that Transform.replace expects a // coherent slice, and will fail to place a set of siblings that don't // fit anywhere in the schema. function normalizeSiblings(fragment, $context) { if (fragment.childCount < 2) return fragment; for (let d = $context.depth; d >= 0; d--) { let parent = $context.node(d); let match = parent.contentMatchAt($context.index(d)); let lastWrap, result = []; fragment.forEach(node => { if (!result) return; let wrap = match.findWrapping(node.type), inLast; if (!wrap) return result = null; if (inLast = result.length && lastWrap.length && addToSibling(wrap, lastWrap, node, result[result.length - 1], 0)) { result[result.length - 1] = inLast; } else { if (result.length) result[result.length - 1] = closeRight(result[result.length - 1], lastWrap.length); let wrapped = withWrappers(node, wrap); result.push(wrapped); match = match.matchType(wrapped.type); lastWrap = wrap; } }); if (result) return Fragment.from(result); } return fragment; } function withWrappers(node, wrap, from = 0) { for (let i = wrap.length - 1; i >= from; i--) node = wrap[i].create(null, Fragment.from(node)); return node; } // Used to group adjacent nodes wrapped in similar parents by // normalizeSiblings into the same parent node function addToSibling(wrap, lastWrap, node, sibling, depth) { if (depth < wrap.length && depth < lastWrap.length && wrap[depth] == lastWrap[depth]) { let inner = addToSibling(wrap, lastWrap, node, sibling.lastChild, depth + 1); if (inner) return sibling.copy(sibling.content.replaceChild(sibling.childCount - 1, inner)); let match = sibling.contentMatchAt(sibling.childCount); if (match.matchType(depth == wrap.length - 1 ? node.type : wrap[depth + 1])) return sibling.copy(sibling.content.append(Fragment.from(withWrappers(node, wrap, depth + 1)))); } } function closeRight(node, depth) { if (depth == 0) return node; let fragment = node.content.replaceChild(node.childCount - 1, closeRight(node.lastChild, depth - 1)); let fill = node.contentMatchAt(node.childCount).fillBefore(Fragment.empty, true); return node.copy(fragment.append(fill)); } function closeRange(fragment, side, from, to, depth, openEnd) { let node = side < 0 ? fragment.firstChild : fragment.lastChild, inner = node.content; if (fragment.childCount > 1) openEnd = 0; if (depth < to - 1) inner = closeRange(inner, side, from, to, depth + 1, openEnd); if (depth >= from) inner = side < 0 ? node.contentMatchAt(0).fillBefore(inner, openEnd <= depth).append(inner) : inner.append(node.contentMatchAt(node.childCount).fillBefore(Fragment.empty, true)); return fragment.replaceChild(side < 0 ? 0 : fragment.childCount - 1, node.copy(inner)); } function closeSlice(slice, openStart, openEnd) { if (openStart < slice.openStart) slice = new Slice(closeRange(slice.content, -1, openStart, slice.openStart, 0, slice.openEnd), openStart, slice.openEnd); if (openEnd < slice.openEnd) slice = new Slice(closeRange(slice.content, 1, openEnd, slice.openEnd, 0, 0), slice.openStart, openEnd); return slice; } // Trick from jQuery -- some elements must be wrapped in other // elements for innerHTML to work. I.e. if you do `div.innerHTML = // ".."` the table cells are ignored. const wrapMap = { thead: ["table"], tbody: ["table"], tfoot: ["table"], caption: ["table"], colgroup: ["table"], col: ["table", "colgroup"], tr: ["table", "tbody"], td: ["table", "tbody", "tr"], th: ["table", "tbody", "tr"] }; let _detachedDoc = null; function detachedDoc() { return _detachedDoc || (_detachedDoc = document.implementation.createHTMLDocument("title")); } function readHTML(html) { let metas = /^(\s*]*>)*/.exec(html); if (metas) html = html.slice(metas[0].length); let elt = detachedDoc().createElement("div"); let firstTag = /<([a-z][^>\s]+)/i.exec(html), wrap; if (wrap = firstTag && wrapMap[firstTag[1].toLowerCase()]) html = wrap.map(n => "<" + n + ">").join("") + html + wrap.map(n => "").reverse().join(""); elt.innerHTML = html; if (wrap) for (let i = 0; i < wrap.length; i++) elt = elt.querySelector(wrap[i]) || elt; return elt; } // Webkit browsers do some hard-to-predict replacement of regular // spaces with non-breaking spaces when putting content on the // clipboard. This tries to convert such non-breaking spaces (which // will be wrapped in a plain span on Chrome, a span with class // Apple-converted-space on Safari) back to regular spaces. function restoreReplacedSpaces(dom) { let nodes = dom.querySelectorAll(chrome ? "span:not([class]):not([style])" : "span.Apple-converted-space"); for (let i = 0; i < nodes.length; i++) { let node = nodes[i]; if (node.childNodes.length == 1 && node.textContent == "\u00a0" && node.parentNode) node.parentNode.replaceChild(dom.ownerDocument.createTextNode(" "), node); } } function addContext(slice, context) { if (!slice.size) return slice; let schema = slice.content.firstChild.type.schema, array; try { array = JSON.parse(context); } catch (e) { return slice; } let { content, openStart, openEnd } = slice; for (let i = array.length - 2; i >= 0; i -= 2) { let type = schema.nodes[array[i]]; if (!type || type.hasRequiredAttrs()) break; content = Fragment.from(type.create(array[i + 1], content)); openStart++; openEnd++; } return new Slice(content, openStart, openEnd); } // A collection of DOM events that occur within the editor, and callback functions // to invoke when the event fires. const handlers = {}; const editHandlers = {}; const passiveHandlers = { touchstart: true, touchmove: true }; class InputState { constructor() { this.shiftKey = false; this.mouseDown = null; this.lastKeyCode = null; this.lastKeyCodeTime = 0; this.lastClick = { time: 0, x: 0, y: 0, type: "" }; this.lastSelectionOrigin = null; this.lastSelectionTime = 0; this.lastIOSEnter = 0; this.lastIOSEnterFallbackTimeout = -1; this.lastFocus = 0; this.lastTouch = 0; this.lastAndroidDelete = 0; this.composing = false; this.compositionNode = null; this.composingTimeout = -1; this.compositionNodes = []; this.compositionEndedAt = -2e8; this.compositionID = 1; // Set to a composition ID when there are pending changes at compositionend this.compositionPendingChanges = 0; this.domChangeCount = 0; this.eventHandlers = Object.create(null); this.hideSelectionGuard = null; } } function initInput(view) { for (let event in handlers) { let handler = handlers[event]; view.dom.addEventListener(event, view.input.eventHandlers[event] = (event) => { if (eventBelongsToView(view, event) && !runCustomHandler(view, event) && (view.editable || !(event.type in editHandlers))) handler(view, event); }, passiveHandlers[event] ? { passive: true } : undefined); } // On Safari, for reasons beyond my understanding, adding an input // event handler makes an issue where the composition vanishes when // you press enter go away. if (safari) view.dom.addEventListener("input", () => null); ensureListeners(view); } function setSelectionOrigin(view, origin) { view.input.lastSelectionOrigin = origin; view.input.lastSelectionTime = Date.now(); } function destroyInput(view) { view.domObserver.stop(); for (let type in view.input.eventHandlers) view.dom.removeEventListener(type, view.input.eventHandlers[type]); clearTimeout(view.input.composingTimeout); clearTimeout(view.input.lastIOSEnterFallbackTimeout); } function ensureListeners(view) { view.someProp("handleDOMEvents", currentHandlers => { for (let type in currentHandlers) if (!view.input.eventHandlers[type]) view.dom.addEventListener(type, view.input.eventHandlers[type] = event => runCustomHandler(view, event)); }); } function runCustomHandler(view, event) { return view.someProp("handleDOMEvents", handlers => { let handler = handlers[event.type]; return handler ? handler(view, event) || event.defaultPrevented : false; }); } function eventBelongsToView(view, event) { if (!event.bubbles) return true; if (event.defaultPrevented) return false; for (let node = event.target; node != view.dom; node = node.parentNode) if (!node || node.nodeType == 11 || (node.pmViewDesc && node.pmViewDesc.stopEvent(event))) return false; return true; } function dispatchEvent(view, event) { if (!runCustomHandler(view, event) && handlers[event.type] && (view.editable || !(event.type in editHandlers))) handlers[event.type](view, event); } editHandlers.keydown = (view, _event) => { let event = _event; view.input.shiftKey = event.keyCode == 16 || event.shiftKey; if (inOrNearComposition(view, event)) return; view.input.lastKeyCode = event.keyCode; view.input.lastKeyCodeTime = Date.now(); // Suppress enter key events on Chrome Android, because those tend // to be part of a confused sequence of composition events fired, // and handling them eagerly tends to corrupt the input. if (android && chrome && event.keyCode == 13) return; if (event.keyCode != 229) view.domObserver.forceFlush(); // On iOS, if we preventDefault enter key presses, the virtual // keyboard gets confused. So the hack here is to set a flag that // makes the DOM change code recognize that what just happens should // be replaced by whatever the Enter key handlers do. if (ios && event.keyCode == 13 && !event.ctrlKey && !event.altKey && !event.metaKey) { let now = Date.now(); view.input.lastIOSEnter = now; view.input.lastIOSEnterFallbackTimeout = setTimeout(() => { if (view.input.lastIOSEnter == now) { view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter"))); view.input.lastIOSEnter = 0; } }, 200); } else if (view.someProp("handleKeyDown", f => f(view, event)) || captureKeyDown(view, event)) { event.preventDefault(); } else { setSelectionOrigin(view, "key"); } }; editHandlers.keyup = (view, event) => { if (event.keyCode == 16) view.input.shiftKey = false; }; editHandlers.keypress = (view, _event) => { let event = _event; if (inOrNearComposition(view, event) || !event.charCode || event.ctrlKey && !event.altKey || mac$3 && event.metaKey) return; if (view.someProp("handleKeyPress", f => f(view, event))) { event.preventDefault(); return; } let sel = view.state.selection; if (!(sel instanceof TextSelection) || !sel.$from.sameParent(sel.$to)) { let text = String.fromCharCode(event.charCode); if (!/[\r\n]/.test(text) && !view.someProp("handleTextInput", f => f(view, sel.$from.pos, sel.$to.pos, text))) view.dispatch(view.state.tr.insertText(text).scrollIntoView()); event.preventDefault(); } }; function eventCoords(event) { return { left: event.clientX, top: event.clientY }; } function isNear(event, click) { let dx = click.x - event.clientX, dy = click.y - event.clientY; return dx * dx + dy * dy < 100; } function runHandlerOnContext(view, propName, pos, inside, event) { if (inside == -1) return false; let $pos = view.state.doc.resolve(inside); for (let i = $pos.depth + 1; i > 0; i--) { if (view.someProp(propName, f => i > $pos.depth ? f(view, pos, $pos.nodeAfter, $pos.before(i), event, true) : f(view, pos, $pos.node(i), $pos.before(i), event, false))) return true; } return false; } function updateSelection(view, selection, origin) { if (!view.focused) view.focus(); let tr = view.state.tr.setSelection(selection); tr.setMeta("pointer", true); view.dispatch(tr); } function selectClickedLeaf(view, inside) { if (inside == -1) return false; let $pos = view.state.doc.resolve(inside), node = $pos.nodeAfter; if (node && node.isAtom && NodeSelection.isSelectable(node)) { updateSelection(view, new NodeSelection($pos)); return true; } return false; } function selectClickedNode(view, inside) { if (inside == -1) return false; let sel = view.state.selection, selectedNode, selectAt; if (sel instanceof NodeSelection) selectedNode = sel.node; let $pos = view.state.doc.resolve(inside); for (let i = $pos.depth + 1; i > 0; i--) { let node = i > $pos.depth ? $pos.nodeAfter : $pos.node(i); if (NodeSelection.isSelectable(node)) { if (selectedNode && sel.$from.depth > 0 && i >= sel.$from.depth && $pos.before(sel.$from.depth + 1) == sel.$from.pos) selectAt = $pos.before(sel.$from.depth); else selectAt = $pos.before(i); break; } } if (selectAt != null) { updateSelection(view, NodeSelection.create(view.state.doc, selectAt)); return true; } else { return false; } } function handleSingleClick(view, pos, inside, event, selectNode) { return runHandlerOnContext(view, "handleClickOn", pos, inside, event) || view.someProp("handleClick", f => f(view, pos, event)) || (selectNode ? selectClickedNode(view, inside) : selectClickedLeaf(view, inside)); } function handleDoubleClick(view, pos, inside, event) { return runHandlerOnContext(view, "handleDoubleClickOn", pos, inside, event) || view.someProp("handleDoubleClick", f => f(view, pos, event)); } function handleTripleClick$1(view, pos, inside, event) { return runHandlerOnContext(view, "handleTripleClickOn", pos, inside, event) || view.someProp("handleTripleClick", f => f(view, pos, event)) || defaultTripleClick(view, inside, event); } function defaultTripleClick(view, inside, event) { if (event.button != 0) return false; let doc = view.state.doc; if (inside == -1) { if (doc.inlineContent) { updateSelection(view, TextSelection.create(doc, 0, doc.content.size)); return true; } return false; } let $pos = doc.resolve(inside); for (let i = $pos.depth + 1; i > 0; i--) { let node = i > $pos.depth ? $pos.nodeAfter : $pos.node(i); let nodePos = $pos.before(i); if (node.inlineContent) updateSelection(view, TextSelection.create(doc, nodePos + 1, nodePos + 1 + node.content.size)); else if (NodeSelection.isSelectable(node)) updateSelection(view, NodeSelection.create(doc, nodePos)); else continue; return true; } } function forceDOMFlush(view) { return endComposition(view); } const selectNodeModifier = mac$3 ? "metaKey" : "ctrlKey"; handlers.mousedown = (view, _event) => { let event = _event; view.input.shiftKey = event.shiftKey; let flushed = forceDOMFlush(view); let now = Date.now(), type = "singleClick"; if (now - view.input.lastClick.time < 500 && isNear(event, view.input.lastClick) && !event[selectNodeModifier]) { if (view.input.lastClick.type == "singleClick") type = "doubleClick"; else if (view.input.lastClick.type == "doubleClick") type = "tripleClick"; } view.input.lastClick = { time: now, x: event.clientX, y: event.clientY, type }; let pos = view.posAtCoords(eventCoords(event)); if (!pos) return; if (type == "singleClick") { if (view.input.mouseDown) view.input.mouseDown.done(); view.input.mouseDown = new MouseDown(view, pos, event, !!flushed); } else if ((type == "doubleClick" ? handleDoubleClick : handleTripleClick$1)(view, pos.pos, pos.inside, event)) { event.preventDefault(); } else { setSelectionOrigin(view, "pointer"); } }; class MouseDown { constructor(view, pos, event, flushed) { this.view = view; this.pos = pos; this.event = event; this.flushed = flushed; this.delayedSelectionSync = false; this.mightDrag = null; this.startDoc = view.state.doc; this.selectNode = !!event[selectNodeModifier]; this.allowDefault = event.shiftKey; let targetNode, targetPos; if (pos.inside > -1) { targetNode = view.state.doc.nodeAt(pos.inside); targetPos = pos.inside; } else { let $pos = view.state.doc.resolve(pos.pos); targetNode = $pos.parent; targetPos = $pos.depth ? $pos.before() : 0; } const target = flushed ? null : event.target; const targetDesc = target ? view.docView.nearestDesc(target, true) : null; this.target = targetDesc ? targetDesc.dom : null; let { selection } = view.state; if (event.button == 0 && targetNode.type.spec.draggable && targetNode.type.spec.selectable !== false || selection instanceof NodeSelection && selection.from <= targetPos && selection.to > targetPos) this.mightDrag = { node: targetNode, pos: targetPos, addAttr: !!(this.target && !this.target.draggable), setUneditable: !!(this.target && gecko && !this.target.hasAttribute("contentEditable")) }; if (this.target && this.mightDrag && (this.mightDrag.addAttr || this.mightDrag.setUneditable)) { this.view.domObserver.stop(); if (this.mightDrag.addAttr) this.target.draggable = true; if (this.mightDrag.setUneditable) setTimeout(() => { if (this.view.input.mouseDown == this) this.target.setAttribute("contentEditable", "false"); }, 20); this.view.domObserver.start(); } view.root.addEventListener("mouseup", this.up = this.up.bind(this)); view.root.addEventListener("mousemove", this.move = this.move.bind(this)); setSelectionOrigin(view, "pointer"); } done() { this.view.root.removeEventListener("mouseup", this.up); this.view.root.removeEventListener("mousemove", this.move); if (this.mightDrag && this.target) { this.view.domObserver.stop(); if (this.mightDrag.addAttr) this.target.removeAttribute("draggable"); if (this.mightDrag.setUneditable) this.target.removeAttribute("contentEditable"); this.view.domObserver.start(); } if (this.delayedSelectionSync) setTimeout(() => selectionToDOM(this.view)); this.view.input.mouseDown = null; } up(event) { this.done(); if (!this.view.dom.contains(event.target)) return; let pos = this.pos; if (this.view.state.doc != this.startDoc) pos = this.view.posAtCoords(eventCoords(event)); this.updateAllowDefault(event); if (this.allowDefault || !pos) { setSelectionOrigin(this.view, "pointer"); } else if (handleSingleClick(this.view, pos.pos, pos.inside, event, this.selectNode)) { event.preventDefault(); } else if (event.button == 0 && (this.flushed || // Safari ignores clicks on draggable elements (safari && this.mightDrag && !this.mightDrag.node.isAtom) || // Chrome will sometimes treat a node selection as a // cursor, but still report that the node is selected // when asked through getSelection. You'll then get a // situation where clicking at the point where that // (hidden) cursor is doesn't change the selection, and // thus doesn't get a reaction from ProseMirror. This // works around that. (chrome && !this.view.state.selection.visible && Math.min(Math.abs(pos.pos - this.view.state.selection.from), Math.abs(pos.pos - this.view.state.selection.to)) <= 2))) { updateSelection(this.view, Selection.near(this.view.state.doc.resolve(pos.pos))); event.preventDefault(); } else { setSelectionOrigin(this.view, "pointer"); } } move(event) { this.updateAllowDefault(event); setSelectionOrigin(this.view, "pointer"); if (event.buttons == 0) this.done(); } updateAllowDefault(event) { if (!this.allowDefault && (Math.abs(this.event.x - event.clientX) > 4 || Math.abs(this.event.y - event.clientY) > 4)) this.allowDefault = true; } } handlers.touchstart = view => { view.input.lastTouch = Date.now(); forceDOMFlush(view); setSelectionOrigin(view, "pointer"); }; handlers.touchmove = view => { view.input.lastTouch = Date.now(); setSelectionOrigin(view, "pointer"); }; handlers.contextmenu = view => forceDOMFlush(view); function inOrNearComposition(view, event) { if (view.composing) return true; // See https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/. // On Japanese input method editors (IMEs), the Enter key is used to confirm character // selection. On Safari, when Enter is pressed, compositionend and keydown events are // emitted. The keydown event triggers newline insertion, which we don't want. // This method returns true if the keydown event should be ignored. // We only ignore it once, as pressing Enter a second time *should* insert a newline. // Furthermore, the keydown event timestamp must be close to the compositionEndedAt timestamp. // This guards against the case where compositionend is triggered without the keyboard // (e.g. character confirmation may be done with the mouse), and keydown is triggered // afterwards- we wouldn't want to ignore the keydown event in this case. if (safari && Math.abs(event.timeStamp - view.input.compositionEndedAt) < 500) { view.input.compositionEndedAt = -2e8; return true; } return false; } // Drop active composition after 5 seconds of inactivity on Android const timeoutComposition = android ? 5000 : -1; editHandlers.compositionstart = editHandlers.compositionupdate = view => { if (!view.composing) { view.domObserver.flush(); let { state } = view, $pos = state.selection.$from; if (state.selection.empty && (state.storedMarks || (!$pos.textOffset && $pos.parentOffset && $pos.nodeBefore.marks.some(m => m.type.spec.inclusive === false)))) { // Need to wrap the cursor in mark nodes different from the ones in the DOM context view.markCursor = view.state.storedMarks || $pos.marks(); endComposition(view, true); view.markCursor = null; } else { endComposition(view); // In firefox, if the cursor is after but outside a marked node, // the inserted text won't inherit the marks. So this moves it // inside if necessary. if (gecko && state.selection.empty && $pos.parentOffset && !$pos.textOffset && $pos.nodeBefore.marks.length) { let sel = view.domSelectionRange(); for (let node = sel.focusNode, offset = sel.focusOffset; node && node.nodeType == 1 && offset != 0;) { let before = offset < 0 ? node.lastChild : node.childNodes[offset - 1]; if (!before) break; if (before.nodeType == 3) { view.domSelection().collapse(before, before.nodeValue.length); break; } else { node = before; offset = -1; } } } } view.input.composing = true; } scheduleComposeEnd(view, timeoutComposition); }; editHandlers.compositionend = (view, event) => { if (view.composing) { view.input.composing = false; view.input.compositionEndedAt = event.timeStamp; view.input.compositionPendingChanges = view.domObserver.pendingRecords().length ? view.input.compositionID : 0; view.input.compositionNode = null; if (view.input.compositionPendingChanges) Promise.resolve().then(() => view.domObserver.flush()); view.input.compositionID++; scheduleComposeEnd(view, 20); } }; function scheduleComposeEnd(view, delay) { clearTimeout(view.input.composingTimeout); if (delay > -1) view.input.composingTimeout = setTimeout(() => endComposition(view), delay); } function clearComposition(view) { if (view.composing) { view.input.composing = false; view.input.compositionEndedAt = timestampFromCustomEvent(); } while (view.input.compositionNodes.length > 0) view.input.compositionNodes.pop().markParentsDirty(); } function findCompositionNode(view) { let sel = view.domSelectionRange(); if (!sel.focusNode) return null; let textBefore = textNodeBefore$1(sel.focusNode, sel.focusOffset); let textAfter = textNodeAfter$1(sel.focusNode, sel.focusOffset); if (textBefore && textAfter && textBefore != textAfter) { let descAfter = textAfter.pmViewDesc; if (!descAfter || !descAfter.isText(textAfter.nodeValue)) { return textAfter; } else if (view.input.compositionNode == textAfter) { let descBefore = textBefore.pmViewDesc; if (!(!descBefore || !descBefore.isText(textBefore.nodeValue))) return textAfter; } } return textBefore || textAfter; } function timestampFromCustomEvent() { let event = document.createEvent("Event"); event.initEvent("event", true, true); return event.timeStamp; } /** @internal */ function endComposition(view, forceUpdate = false) { if (android && view.domObserver.flushingSoon >= 0) return; view.domObserver.forceFlush(); clearComposition(view); if (forceUpdate || view.docView && view.docView.dirty) { let sel = selectionFromDOM(view); if (sel && !sel.eq(view.state.selection)) view.dispatch(view.state.tr.setSelection(sel)); else view.updateState(view.state); return true; } return false; } function captureCopy(view, dom) { // The extra wrapper is somehow necessary on IE/Edge to prevent the // content from being mangled when it is put onto the clipboard if (!view.dom.parentNode) return; let wrap = view.dom.parentNode.appendChild(document.createElement("div")); wrap.appendChild(dom); wrap.style.cssText = "position: fixed; left: -10000px; top: 10px"; let sel = getSelection(), range = document.createRange(); range.selectNodeContents(dom); // Done because IE will fire a selectionchange moving the selection // to its start when removeAllRanges is called and the editor still // has focus (which will mess up the editor's selection state). view.dom.blur(); sel.removeAllRanges(); sel.addRange(range); setTimeout(() => { if (wrap.parentNode) wrap.parentNode.removeChild(wrap); view.focus(); }, 50); } // This is very crude, but unfortunately both these browsers _pretend_ // that they have a clipboard API—all the objects and methods are // there, they just don't work, and they are hard to test. const brokenClipboardAPI = (ie$1 && ie_version < 15) || (ios && webkit_version < 604); handlers.copy = editHandlers.cut = (view, _event) => { let event = _event; let sel = view.state.selection, cut = event.type == "cut"; if (sel.empty) return; // IE and Edge's clipboard interface is completely broken let data = brokenClipboardAPI ? null : event.clipboardData; let slice = sel.content(), { dom, text } = serializeForClipboard(view, slice); if (data) { event.preventDefault(); data.clearData(); data.setData("text/html", dom.innerHTML); data.setData("text/plain", text); } else { captureCopy(view, dom); } if (cut) view.dispatch(view.state.tr.deleteSelection().scrollIntoView().setMeta("uiEvent", "cut")); }; function sliceSingleNode(slice) { return slice.openStart == 0 && slice.openEnd == 0 && slice.content.childCount == 1 ? slice.content.firstChild : null; } function capturePaste(view, event) { if (!view.dom.parentNode) return; let plainText = view.input.shiftKey || view.state.selection.$from.parent.type.spec.code; let target = view.dom.parentNode.appendChild(document.createElement(plainText ? "textarea" : "div")); if (!plainText) target.contentEditable = "true"; target.style.cssText = "position: fixed; left: -10000px; top: 10px"; target.focus(); let plain = view.input.shiftKey && view.input.lastKeyCode != 45; setTimeout(() => { view.focus(); if (target.parentNode) target.parentNode.removeChild(target); if (plainText) doPaste(view, target.value, null, plain, event); else doPaste(view, target.textContent, target.innerHTML, plain, event); }, 50); } function doPaste(view, text, html, preferPlain, event) { let slice = parseFromClipboard(view, text, html, preferPlain, view.state.selection.$from); if (view.someProp("handlePaste", f => f(view, event, slice || Slice.empty))) return true; if (!slice) return false; let singleNode = sliceSingleNode(slice); let tr = singleNode ? view.state.tr.replaceSelectionWith(singleNode, preferPlain) : view.state.tr.replaceSelection(slice); view.dispatch(tr.scrollIntoView().setMeta("paste", true).setMeta("uiEvent", "paste")); return true; } function getText(clipboardData) { let text = clipboardData.getData("text/plain") || clipboardData.getData("Text"); if (text) return text; let uris = clipboardData.getData("text/uri-list"); return uris ? uris.replace(/\r?\n/g, " ") : ""; } editHandlers.paste = (view, _event) => { let event = _event; // Handling paste from JavaScript during composition is very poorly // handled by browsers, so as a dodgy but preferable kludge, we just // let the browser do its native thing there, except on Android, // where the editor is almost always composing. if (view.composing && !android) return; let data = brokenClipboardAPI ? null : event.clipboardData; let plain = view.input.shiftKey && view.input.lastKeyCode != 45; if (data && doPaste(view, getText(data), data.getData("text/html"), plain, event)) event.preventDefault(); else capturePaste(view, event); }; class Dragging { constructor(slice, move, node) { this.slice = slice; this.move = move; this.node = node; } } const dragCopyModifier = mac$3 ? "altKey" : "ctrlKey"; handlers.dragstart = (view, _event) => { let event = _event; let mouseDown = view.input.mouseDown; if (mouseDown) mouseDown.done(); if (!event.dataTransfer) return; let sel = view.state.selection; let pos = sel.empty ? null : view.posAtCoords(eventCoords(event)); let node; if (pos && pos.pos >= sel.from && pos.pos <= (sel instanceof NodeSelection ? sel.to - 1 : sel.to)) ; else if (mouseDown && mouseDown.mightDrag) { node = NodeSelection.create(view.state.doc, mouseDown.mightDrag.pos); } else if (event.target && event.target.nodeType == 1) { let desc = view.docView.nearestDesc(event.target, true); if (desc && desc.node.type.spec.draggable && desc != view.docView) node = NodeSelection.create(view.state.doc, desc.posBefore); } let draggedSlice = (node || view.state.selection).content(); let { dom, text, slice } = serializeForClipboard(view, draggedSlice); event.dataTransfer.clearData(); event.dataTransfer.setData(brokenClipboardAPI ? "Text" : "text/html", dom.innerHTML); // See https://github.com/ProseMirror/prosemirror/issues/1156 event.dataTransfer.effectAllowed = "copyMove"; if (!brokenClipboardAPI) event.dataTransfer.setData("text/plain", text); view.dragging = new Dragging(slice, !event[dragCopyModifier], node); }; handlers.dragend = view => { let dragging = view.dragging; window.setTimeout(() => { if (view.dragging == dragging) view.dragging = null; }, 50); }; editHandlers.dragover = editHandlers.dragenter = (_, e) => e.preventDefault(); editHandlers.drop = (view, _event) => { let event = _event; let dragging = view.dragging; view.dragging = null; if (!event.dataTransfer) return; let eventPos = view.posAtCoords(eventCoords(event)); if (!eventPos) return; let $mouse = view.state.doc.resolve(eventPos.pos); let slice = dragging && dragging.slice; if (slice) { view.someProp("transformPasted", f => { slice = f(slice, view); }); } else { slice = parseFromClipboard(view, getText(event.dataTransfer), brokenClipboardAPI ? null : event.dataTransfer.getData("text/html"), false, $mouse); } let move = !!(dragging && !event[dragCopyModifier]); if (view.someProp("handleDrop", f => f(view, event, slice || Slice.empty, move))) { event.preventDefault(); return; } if (!slice) return; event.preventDefault(); let insertPos = slice ? dropPoint(view.state.doc, $mouse.pos, slice) : $mouse.pos; if (insertPos == null) insertPos = $mouse.pos; let tr = view.state.tr; if (move) { let { node } = dragging; if (node) node.replace(tr); else tr.deleteSelection(); } let pos = tr.mapping.map(insertPos); let isNode = slice.openStart == 0 && slice.openEnd == 0 && slice.content.childCount == 1; let beforeInsert = tr.doc; if (isNode) tr.replaceRangeWith(pos, pos, slice.content.firstChild); else tr.replaceRange(pos, pos, slice); if (tr.doc.eq(beforeInsert)) return; let $pos = tr.doc.resolve(pos); if (isNode && NodeSelection.isSelectable(slice.content.firstChild) && $pos.nodeAfter && $pos.nodeAfter.sameMarkup(slice.content.firstChild)) { tr.setSelection(new NodeSelection($pos)); } else { let end = tr.mapping.map(insertPos); tr.mapping.maps[tr.mapping.maps.length - 1].forEach((_from, _to, _newFrom, newTo) => end = newTo); tr.setSelection(selectionBetween(view, $pos, tr.doc.resolve(end))); } view.focus(); view.dispatch(tr.setMeta("uiEvent", "drop")); }; handlers.focus = view => { view.input.lastFocus = Date.now(); if (!view.focused) { view.domObserver.stop(); view.dom.classList.add("ProseMirror-focused"); view.domObserver.start(); view.focused = true; setTimeout(() => { if (view.docView && view.hasFocus() && !view.domObserver.currentSelection.eq(view.domSelectionRange())) selectionToDOM(view); }, 20); } }; handlers.blur = (view, _event) => { let event = _event; if (view.focused) { view.domObserver.stop(); view.dom.classList.remove("ProseMirror-focused"); view.domObserver.start(); if (event.relatedTarget && view.dom.contains(event.relatedTarget)) view.domObserver.currentSelection.clear(); view.focused = false; } }; handlers.beforeinput = (view, _event) => { let event = _event; // We should probably do more with beforeinput events, but support // is so spotty that I'm still waiting to see where they are going. // Very specific hack to deal with backspace sometimes failing on // Chrome Android when after an uneditable node. if (chrome && android && event.inputType == "deleteContentBackward") { view.domObserver.flushSoon(); let { domChangeCount } = view.input; setTimeout(() => { if (view.input.domChangeCount != domChangeCount) return; // Event already had some effect // This bug tends to close the virtual keyboard, so we refocus view.dom.blur(); view.focus(); if (view.someProp("handleKeyDown", f => f(view, keyEvent(8, "Backspace")))) return; let { $cursor } = view.state.selection; // Crude approximation of backspace behavior when no command handled it if ($cursor && $cursor.pos > 0) view.dispatch(view.state.tr.delete($cursor.pos - 1, $cursor.pos).scrollIntoView()); }, 50); } }; // Make sure all handlers get registered for (let prop in editHandlers) handlers[prop] = editHandlers[prop]; function compareObjs(a, b) { if (a == b) return true; for (let p in a) if (a[p] !== b[p]) return false; for (let p in b) if (!(p in a)) return false; return true; } class WidgetType { constructor(toDOM, spec) { this.toDOM = toDOM; this.spec = spec || noSpec; this.side = this.spec.side || 0; } map(mapping, span, offset, oldOffset) { let { pos, deleted } = mapping.mapResult(span.from + oldOffset, this.side < 0 ? -1 : 1); return deleted ? null : new Decoration(pos - offset, pos - offset, this); } valid() { return true; } eq(other) { return this == other || (other instanceof WidgetType && (this.spec.key && this.spec.key == other.spec.key || this.toDOM == other.toDOM && compareObjs(this.spec, other.spec))); } destroy(node) { if (this.spec.destroy) this.spec.destroy(node); } } class InlineType { constructor(attrs, spec) { this.attrs = attrs; this.spec = spec || noSpec; } map(mapping, span, offset, oldOffset) { let from = mapping.map(span.from + oldOffset, this.spec.inclusiveStart ? -1 : 1) - offset; let to = mapping.map(span.to + oldOffset, this.spec.inclusiveEnd ? 1 : -1) - offset; return from >= to ? null : new Decoration(from, to, this); } valid(_, span) { return span.from < span.to; } eq(other) { return this == other || (other instanceof InlineType && compareObjs(this.attrs, other.attrs) && compareObjs(this.spec, other.spec)); } static is(span) { return span.type instanceof InlineType; } destroy() { } } class NodeType { constructor(attrs, spec) { this.attrs = attrs; this.spec = spec || noSpec; } map(mapping, span, offset, oldOffset) { let from = mapping.mapResult(span.from + oldOffset, 1); if (from.deleted) return null; let to = mapping.mapResult(span.to + oldOffset, -1); if (to.deleted || to.pos <= from.pos) return null; return new Decoration(from.pos - offset, to.pos - offset, this); } valid(node, span) { let { index, offset } = node.content.findIndex(span.from), child; return offset == span.from && !(child = node.child(index)).isText && offset + child.nodeSize == span.to; } eq(other) { return this == other || (other instanceof NodeType && compareObjs(this.attrs, other.attrs) && compareObjs(this.spec, other.spec)); } destroy() { } } /** Decoration objects can be provided to the view through the [`decorations` prop](https://prosemirror.net/docs/ref/#view.EditorProps.decorations). They come in several variants—see the static members of this class for details. */ class Decoration { /** @internal */ constructor( /** The start position of the decoration. */ from, /** The end position. Will be the same as `from` for [widget decorations](https://prosemirror.net/docs/ref/#view.Decoration^widget). */ to, /** @internal */ type) { this.from = from; this.to = to; this.type = type; } /** @internal */ copy(from, to) { return new Decoration(from, to, this.type); } /** @internal */ eq(other, offset = 0) { return this.type.eq(other.type) && this.from + offset == other.from && this.to + offset == other.to; } /** @internal */ map(mapping, offset, oldOffset) { return this.type.map(mapping, this, offset, oldOffset); } /** Creates a widget decoration, which is a DOM node that's shown in the document at the given position. It is recommended that you delay rendering the widget by passing a function that will be called when the widget is actually drawn in a view, but you can also directly pass a DOM node. `getPos` can be used to find the widget's current document position. */ static widget(pos, toDOM, spec) { return new Decoration(pos, pos, new WidgetType(toDOM, spec)); } /** Creates an inline decoration, which adds the given attributes to each inline node between `from` and `to`. */ static inline(from, to, attrs, spec) { return new Decoration(from, to, new InlineType(attrs, spec)); } /** Creates a node decoration. `from` and `to` should point precisely before and after a node in the document. That node, and only that node, will receive the given attributes. */ static node(from, to, attrs, spec) { return new Decoration(from, to, new NodeType(attrs, spec)); } /** The spec provided when creating this decoration. Can be useful if you've stored extra information in that object. */ get spec() { return this.type.spec; } /** @internal */ get inline() { return this.type instanceof InlineType; } /** @internal */ get widget() { return this.type instanceof WidgetType; } } const none = [], noSpec = {}; /** A collection of [decorations](https://prosemirror.net/docs/ref/#view.Decoration), organized in such a way that the drawing algorithm can efficiently use and compare them. This is a persistent data structure—it is not modified, updates create a new value. */ class DecorationSet { /** @internal */ constructor(local, children) { this.local = local.length ? local : none; this.children = children.length ? children : none; } /** Create a set of decorations, using the structure of the given document. This will consume (modify) the `decorations` array, so you must make a copy if you want need to preserve that. */ static create(doc, decorations) { return decorations.length ? buildTree(decorations, doc, 0, noSpec) : empty; } /** Find all decorations in this set which touch the given range (including decorations that start or end directly at the boundaries) and match the given predicate on their spec. When `start` and `end` are omitted, all decorations in the set are considered. When `predicate` isn't given, all decorations are assumed to match. */ find(start, end, predicate) { let result = []; this.findInner(start == null ? 0 : start, end == null ? 1e9 : end, result, 0, predicate); return result; } findInner(start, end, result, offset, predicate) { for (let i = 0; i < this.local.length; i++) { let span = this.local[i]; if (span.from <= end && span.to >= start && (!predicate || predicate(span.spec))) result.push(span.copy(span.from + offset, span.to + offset)); } for (let i = 0; i < this.children.length; i += 3) { if (this.children[i] < end && this.children[i + 1] > start) { let childOff = this.children[i] + 1; this.children[i + 2].findInner(start - childOff, end - childOff, result, offset + childOff, predicate); } } } /** Map the set of decorations in response to a change in the document. */ map(mapping, doc, options) { if (this == empty || mapping.maps.length == 0) return this; return this.mapInner(mapping, doc, 0, 0, options || noSpec); } /** @internal */ mapInner(mapping, node, offset, oldOffset, options) { let newLocal; for (let i = 0; i < this.local.length; i++) { let mapped = this.local[i].map(mapping, offset, oldOffset); if (mapped && mapped.type.valid(node, mapped)) (newLocal || (newLocal = [])).push(mapped); else if (options.onRemove) options.onRemove(this.local[i].spec); } if (this.children.length) return mapChildren(this.children, newLocal || [], mapping, node, offset, oldOffset, options); else return newLocal ? new DecorationSet(newLocal.sort(byPos), none) : empty; } /** Add the given array of decorations to the ones in the set, producing a new set. Consumes the `decorations` array. Needs access to the current document to create the appropriate tree structure. */ add(doc, decorations) { if (!decorations.length) return this; if (this == empty) return DecorationSet.create(doc, decorations); return this.addInner(doc, decorations, 0); } addInner(doc, decorations, offset) { let children, childIndex = 0; doc.forEach((childNode, childOffset) => { let baseOffset = childOffset + offset, found; if (!(found = takeSpansForNode(decorations, childNode, baseOffset))) return; if (!children) children = this.children.slice(); while (childIndex < children.length && children[childIndex] < childOffset) childIndex += 3; if (children[childIndex] == childOffset) children[childIndex + 2] = children[childIndex + 2].addInner(childNode, found, baseOffset + 1); else children.splice(childIndex, 0, childOffset, childOffset + childNode.nodeSize, buildTree(found, childNode, baseOffset + 1, noSpec)); childIndex += 3; }); let local = moveSpans(childIndex ? withoutNulls(decorations) : decorations, -offset); for (let i = 0; i < local.length; i++) if (!local[i].type.valid(doc, local[i])) local.splice(i--, 1); return new DecorationSet(local.length ? this.local.concat(local).sort(byPos) : this.local, children || this.children); } /** Create a new set that contains the decorations in this set, minus the ones in the given array. */ remove(decorations) { if (decorations.length == 0 || this == empty) return this; return this.removeInner(decorations, 0); } removeInner(decorations, offset) { let children = this.children, local = this.local; for (let i = 0; i < children.length; i += 3) { let found; let from = children[i] + offset, to = children[i + 1] + offset; for (let j = 0, span; j < decorations.length; j++) if (span = decorations[j]) { if (span.from > from && span.to < to) { decorations[j] = null; (found || (found = [])).push(span); } } if (!found) continue; if (children == this.children) children = this.children.slice(); let removed = children[i + 2].removeInner(found, from + 1); if (removed != empty) { children[i + 2] = removed; } else { children.splice(i, 3); i -= 3; } } if (local.length) for (let i = 0, span; i < decorations.length; i++) if (span = decorations[i]) { for (let j = 0; j < local.length; j++) if (local[j].eq(span, offset)) { if (local == this.local) local = this.local.slice(); local.splice(j--, 1); } } if (children == this.children && local == this.local) return this; return local.length || children.length ? new DecorationSet(local, children) : empty; } forChild(offset, node) { if (this == empty) return this; if (node.isLeaf) return DecorationSet.empty; let child, local; for (let i = 0; i < this.children.length; i += 3) if (this.children[i] >= offset) { if (this.children[i] == offset) child = this.children[i + 2]; break; } let start = offset + 1, end = start + node.content.size; for (let i = 0; i < this.local.length; i++) { let dec = this.local[i]; if (dec.from < end && dec.to > start && (dec.type instanceof InlineType)) { let from = Math.max(start, dec.from) - start, to = Math.min(end, dec.to) - start; if (from < to) (local || (local = [])).push(dec.copy(from, to)); } } if (local) { let localSet = new DecorationSet(local.sort(byPos), none); return child ? new DecorationGroup([localSet, child]) : localSet; } return child || empty; } /** @internal */ eq(other) { if (this == other) return true; if (!(other instanceof DecorationSet) || this.local.length != other.local.length || this.children.length != other.children.length) return false; for (let i = 0; i < this.local.length; i++) if (!this.local[i].eq(other.local[i])) return false; for (let i = 0; i < this.children.length; i += 3) if (this.children[i] != other.children[i] || this.children[i + 1] != other.children[i + 1] || !this.children[i + 2].eq(other.children[i + 2])) return false; return true; } /** @internal */ locals(node) { return removeOverlap(this.localsInner(node)); } /** @internal */ localsInner(node) { if (this == empty) return none; if (node.inlineContent || !this.local.some(InlineType.is)) return this.local; let result = []; for (let i = 0; i < this.local.length; i++) { if (!(this.local[i].type instanceof InlineType)) result.push(this.local[i]); } return result; } } /** The empty set of decorations. */ DecorationSet.empty = new DecorationSet([], []); /** @internal */ DecorationSet.removeOverlap = removeOverlap; const empty = DecorationSet.empty; // An abstraction that allows the code dealing with decorations to // treat multiple DecorationSet objects as if it were a single object // with (a subset of) the same interface. class DecorationGroup { constructor(members) { this.members = members; } map(mapping, doc) { const mappedDecos = this.members.map(member => member.map(mapping, doc, noSpec)); return DecorationGroup.from(mappedDecos); } forChild(offset, child) { if (child.isLeaf) return DecorationSet.empty; let found = []; for (let i = 0; i < this.members.length; i++) { let result = this.members[i].forChild(offset, child); if (result == empty) continue; if (result instanceof DecorationGroup) found = found.concat(result.members); else found.push(result); } return DecorationGroup.from(found); } eq(other) { if (!(other instanceof DecorationGroup) || other.members.length != this.members.length) return false; for (let i = 0; i < this.members.length; i++) if (!this.members[i].eq(other.members[i])) return false; return true; } locals(node) { let result, sorted = true; for (let i = 0; i < this.members.length; i++) { let locals = this.members[i].localsInner(node); if (!locals.length) continue; if (!result) { result = locals; } else { if (sorted) { result = result.slice(); sorted = false; } for (let j = 0; j < locals.length; j++) result.push(locals[j]); } } return result ? removeOverlap(sorted ? result : result.sort(byPos)) : none; } // Create a group for the given array of decoration sets, or return // a single set when possible. static from(members) { switch (members.length) { case 0: return empty; case 1: return members[0]; default: return new DecorationGroup(members.every(m => m instanceof DecorationSet) ? members : members.reduce((r, m) => r.concat(m instanceof DecorationSet ? m : m.members), [])); } } } function mapChildren(oldChildren, newLocal, mapping, node, offset, oldOffset, options) { let children = oldChildren.slice(); // Mark the children that are directly touched by changes, and // move those that are after the changes. for (let i = 0, baseOffset = oldOffset; i < mapping.maps.length; i++) { let moved = 0; mapping.maps[i].forEach((oldStart, oldEnd, newStart, newEnd) => { let dSize = (newEnd - newStart) - (oldEnd - oldStart); for (let i = 0; i < children.length; i += 3) { let end = children[i + 1]; if (end < 0 || oldStart > end + baseOffset - moved) continue; let start = children[i] + baseOffset - moved; if (oldEnd >= start) { children[i + 1] = oldStart <= start ? -2 : -1; } else if (oldStart >= baseOffset && dSize) { children[i] += dSize; children[i + 1] += dSize; } } moved += dSize; }); baseOffset = mapping.maps[i].map(baseOffset, -1); } // Find the child nodes that still correspond to a single node, // recursively call mapInner on them and update their positions. let mustRebuild = false; for (let i = 0; i < children.length; i += 3) if (children[i + 1] < 0) { // Touched nodes if (children[i + 1] == -2) { mustRebuild = true; children[i + 1] = -1; continue; } let from = mapping.map(oldChildren[i] + oldOffset), fromLocal = from - offset; if (fromLocal < 0 || fromLocal >= node.content.size) { mustRebuild = true; continue; } // Must read oldChildren because children was tagged with -1 let to = mapping.map(oldChildren[i + 1] + oldOffset, -1), toLocal = to - offset; let { index, offset: childOffset } = node.content.findIndex(fromLocal); let childNode = node.maybeChild(index); if (childNode && childOffset == fromLocal && childOffset + childNode.nodeSize == toLocal) { let mapped = children[i + 2] .mapInner(mapping, childNode, from + 1, oldChildren[i] + oldOffset + 1, options); if (mapped != empty) { children[i] = fromLocal; children[i + 1] = toLocal; children[i + 2] = mapped; } else { children[i + 1] = -2; mustRebuild = true; } } else { mustRebuild = true; } } // Remaining children must be collected and rebuilt into the appropriate structure if (mustRebuild) { let decorations = mapAndGatherRemainingDecorations(children, oldChildren, newLocal, mapping, offset, oldOffset, options); let built = buildTree(decorations, node, 0, options); newLocal = built.local; for (let i = 0; i < children.length; i += 3) if (children[i + 1] < 0) { children.splice(i, 3); i -= 3; } for (let i = 0, j = 0; i < built.children.length; i += 3) { let from = built.children[i]; while (j < children.length && children[j] < from) j += 3; children.splice(j, 0, built.children[i], built.children[i + 1], built.children[i + 2]); } } return new DecorationSet(newLocal.sort(byPos), children); } function moveSpans(spans, offset) { if (!offset || !spans.length) return spans; let result = []; for (let i = 0; i < spans.length; i++) { let span = spans[i]; result.push(new Decoration(span.from + offset, span.to + offset, span.type)); } return result; } function mapAndGatherRemainingDecorations(children, oldChildren, decorations, mapping, offset, oldOffset, options) { // Gather all decorations from the remaining marked children function gather(set, oldOffset) { for (let i = 0; i < set.local.length; i++) { let mapped = set.local[i].map(mapping, offset, oldOffset); if (mapped) decorations.push(mapped); else if (options.onRemove) options.onRemove(set.local[i].spec); } for (let i = 0; i < set.children.length; i += 3) gather(set.children[i + 2], set.children[i] + oldOffset + 1); } for (let i = 0; i < children.length; i += 3) if (children[i + 1] == -1) gather(children[i + 2], oldChildren[i] + oldOffset + 1); return decorations; } function takeSpansForNode(spans, node, offset) { if (node.isLeaf) return null; let end = offset + node.nodeSize, found = null; for (let i = 0, span; i < spans.length; i++) { if ((span = spans[i]) && span.from > offset && span.to < end) { (found || (found = [])).push(span); spans[i] = null; } } return found; } function withoutNulls(array) { let result = []; for (let i = 0; i < array.length; i++) if (array[i] != null) result.push(array[i]); return result; } // Build up a tree that corresponds to a set of decorations. `offset` // is a base offset that should be subtracted from the `from` and `to` // positions in the spans (so that we don't have to allocate new spans // for recursive calls). function buildTree(spans, node, offset, options) { let children = [], hasNulls = false; node.forEach((childNode, localStart) => { let found = takeSpansForNode(spans, childNode, localStart + offset); if (found) { hasNulls = true; let subtree = buildTree(found, childNode, offset + localStart + 1, options); if (subtree != empty) children.push(localStart, localStart + childNode.nodeSize, subtree); } }); let locals = moveSpans(hasNulls ? withoutNulls(spans) : spans, -offset).sort(byPos); for (let i = 0; i < locals.length; i++) if (!locals[i].type.valid(node, locals[i])) { if (options.onRemove) options.onRemove(locals[i].spec); locals.splice(i--, 1); } return locals.length || children.length ? new DecorationSet(locals, children) : empty; } // Used to sort decorations so that ones with a low start position // come first, and within a set with the same start position, those // with an smaller end position come first. function byPos(a, b) { return a.from - b.from || a.to - b.to; } // Scan a sorted array of decorations for partially overlapping spans, // and split those so that only fully overlapping spans are left (to // make subsequent rendering easier). Will return the input array if // no partially overlapping spans are found (the common case). function removeOverlap(spans) { let working = spans; for (let i = 0; i < working.length - 1; i++) { let span = working[i]; if (span.from != span.to) for (let j = i + 1; j < working.length; j++) { let next = working[j]; if (next.from == span.from) { if (next.to != span.to) { if (working == spans) working = spans.slice(); // Followed by a partially overlapping larger span. Split that // span. working[j] = next.copy(next.from, span.to); insertAhead(working, j + 1, next.copy(span.to, next.to)); } continue; } else { if (next.from < span.to) { if (working == spans) working = spans.slice(); // The end of this one overlaps with a subsequent span. Split // this one. working[i] = span.copy(span.from, next.from); insertAhead(working, j, span.copy(next.from, span.to)); } break; } } } return working; } function insertAhead(array, i, deco) { while (i < array.length && byPos(deco, array[i]) > 0) i++; array.splice(i, 0, deco); } // Get the decorations associated with the current props of a view. function viewDecorations(view) { let found = []; view.someProp("decorations", f => { let result = f(view.state); if (result && result != empty) found.push(result); }); if (view.cursorWrapper) found.push(DecorationSet.create(view.state.doc, [view.cursorWrapper.deco])); return DecorationGroup.from(found); } const observeOptions = { childList: true, characterData: true, characterDataOldValue: true, attributes: true, attributeOldValue: true, subtree: true }; // IE11 has very broken mutation observers, so we also listen to DOMCharacterDataModified const useCharData = ie$1 && ie_version <= 11; class SelectionState { constructor() { this.anchorNode = null; this.anchorOffset = 0; this.focusNode = null; this.focusOffset = 0; } set(sel) { this.anchorNode = sel.anchorNode; this.anchorOffset = sel.anchorOffset; this.focusNode = sel.focusNode; this.focusOffset = sel.focusOffset; } clear() { this.anchorNode = this.focusNode = null; } eq(sel) { return sel.anchorNode == this.anchorNode && sel.anchorOffset == this.anchorOffset && sel.focusNode == this.focusNode && sel.focusOffset == this.focusOffset; } } class DOMObserver { constructor(view, handleDOMChange) { this.view = view; this.handleDOMChange = handleDOMChange; this.queue = []; this.flushingSoon = -1; this.observer = null; this.currentSelection = new SelectionState; this.onCharData = null; this.suppressingSelectionUpdates = false; this.observer = window.MutationObserver && new window.MutationObserver(mutations => { for (let i = 0; i < mutations.length; i++) this.queue.push(mutations[i]); // IE11 will sometimes (on backspacing out a single character // text node after a BR node) call the observer callback // before actually updating the DOM, which will cause // ProseMirror to miss the change (see #930) if (ie$1 && ie_version <= 11 && mutations.some(m => m.type == "childList" && m.removedNodes.length || m.type == "characterData" && m.oldValue.length > m.target.nodeValue.length)) this.flushSoon(); else this.flush(); }); if (useCharData) { this.onCharData = e => { this.queue.push({ target: e.target, type: "characterData", oldValue: e.prevValue }); this.flushSoon(); }; } this.onSelectionChange = this.onSelectionChange.bind(this); } flushSoon() { if (this.flushingSoon < 0) this.flushingSoon = window.setTimeout(() => { this.flushingSoon = -1; this.flush(); }, 20); } forceFlush() { if (this.flushingSoon > -1) { window.clearTimeout(this.flushingSoon); this.flushingSoon = -1; this.flush(); } } start() { if (this.observer) { this.observer.takeRecords(); this.observer.observe(this.view.dom, observeOptions); } if (this.onCharData) this.view.dom.addEventListener("DOMCharacterDataModified", this.onCharData); this.connectSelection(); } stop() { if (this.observer) { let take = this.observer.takeRecords(); if (take.length) { for (let i = 0; i < take.length; i++) this.queue.push(take[i]); window.setTimeout(() => this.flush(), 20); } this.observer.disconnect(); } if (this.onCharData) this.view.dom.removeEventListener("DOMCharacterDataModified", this.onCharData); this.disconnectSelection(); } connectSelection() { this.view.dom.ownerDocument.addEventListener("selectionchange", this.onSelectionChange); } disconnectSelection() { this.view.dom.ownerDocument.removeEventListener("selectionchange", this.onSelectionChange); } suppressSelectionUpdates() { this.suppressingSelectionUpdates = true; setTimeout(() => this.suppressingSelectionUpdates = false, 50); } onSelectionChange() { if (!hasFocusAndSelection(this.view)) return; if (this.suppressingSelectionUpdates) return selectionToDOM(this.view); // Deletions on IE11 fire their events in the wrong order, giving // us a selection change event before the DOM changes are // reported. if (ie$1 && ie_version <= 11 && !this.view.state.selection.empty) { let sel = this.view.domSelectionRange(); // Selection.isCollapsed isn't reliable on IE if (sel.focusNode && isEquivalentPosition(sel.focusNode, sel.focusOffset, sel.anchorNode, sel.anchorOffset)) return this.flushSoon(); } this.flush(); } setCurSelection() { this.currentSelection.set(this.view.domSelectionRange()); } ignoreSelectionChange(sel) { if (!sel.focusNode) return true; let ancestors = new Set, container; for (let scan = sel.focusNode; scan; scan = parentNode(scan)) ancestors.add(scan); for (let scan = sel.anchorNode; scan; scan = parentNode(scan)) if (ancestors.has(scan)) { container = scan; break; } let desc = container && this.view.docView.nearestDesc(container); if (desc && desc.ignoreMutation({ type: "selection", target: container.nodeType == 3 ? container.parentNode : container })) { this.setCurSelection(); return true; } } pendingRecords() { if (this.observer) for (let mut of this.observer.takeRecords()) this.queue.push(mut); return this.queue; } flush() { let { view } = this; if (!view.docView || this.flushingSoon > -1) return; let mutations = this.pendingRecords(); if (mutations.length) this.queue = []; let sel = view.domSelectionRange(); let newSel = !this.suppressingSelectionUpdates && !this.currentSelection.eq(sel) && hasFocusAndSelection(view) && !this.ignoreSelectionChange(sel); let from = -1, to = -1, typeOver = false, added = []; if (view.editable) { for (let i = 0; i < mutations.length; i++) { let result = this.registerMutation(mutations[i], added); if (result) { from = from < 0 ? result.from : Math.min(result.from, from); to = to < 0 ? result.to : Math.max(result.to, to); if (result.typeOver) typeOver = true; } } } if (gecko && added.length > 1) { let brs = added.filter(n => n.nodeName == "BR"); if (brs.length == 2) { let a = brs[0], b = brs[1]; if (a.parentNode && a.parentNode.parentNode == b.parentNode) b.remove(); else a.remove(); } } let readSel = null; // If it looks like the browser has reset the selection to the // start of the document after focus, restore the selection from // the state if (from < 0 && newSel && view.input.lastFocus > Date.now() - 200 && Math.max(view.input.lastTouch, view.input.lastClick.time) < Date.now() - 300 && selectionCollapsed(sel) && (readSel = selectionFromDOM(view)) && readSel.eq(Selection.near(view.state.doc.resolve(0), 1))) { view.input.lastFocus = 0; selectionToDOM(view); this.currentSelection.set(sel); view.scrollToSelection(); } else if (from > -1 || newSel) { if (from > -1) { view.docView.markDirty(from, to); checkCSS(view); } this.handleDOMChange(from, to, typeOver, added); if (view.docView && view.docView.dirty) view.updateState(view.state); else if (!this.currentSelection.eq(sel)) selectionToDOM(view); this.currentSelection.set(sel); } } registerMutation(mut, added) { // Ignore mutations inside nodes that were already noted as inserted if (added.indexOf(mut.target) > -1) return null; let desc = this.view.docView.nearestDesc(mut.target); if (mut.type == "attributes" && (desc == this.view.docView || mut.attributeName == "contenteditable" || // Firefox sometimes fires spurious events for null/empty styles (mut.attributeName == "style" && !mut.oldValue && !mut.target.getAttribute("style")))) return null; if (!desc || desc.ignoreMutation(mut)) return null; if (mut.type == "childList") { for (let i = 0; i < mut.addedNodes.length; i++) added.push(mut.addedNodes[i]); if (desc.contentDOM && desc.contentDOM != desc.dom && !desc.contentDOM.contains(mut.target)) return { from: desc.posBefore, to: desc.posAfter }; let prev = mut.previousSibling, next = mut.nextSibling; if (ie$1 && ie_version <= 11 && mut.addedNodes.length) { // IE11 gives us incorrect next/prev siblings for some // insertions, so if there are added nodes, recompute those for (let i = 0; i < mut.addedNodes.length; i++) { let { previousSibling, nextSibling } = mut.addedNodes[i]; if (!previousSibling || Array.prototype.indexOf.call(mut.addedNodes, previousSibling) < 0) prev = previousSibling; if (!nextSibling || Array.prototype.indexOf.call(mut.addedNodes, nextSibling) < 0) next = nextSibling; } } let fromOffset = prev && prev.parentNode == mut.target ? domIndex(prev) + 1 : 0; let from = desc.localPosFromDOM(mut.target, fromOffset, -1); let toOffset = next && next.parentNode == mut.target ? domIndex(next) : mut.target.childNodes.length; let to = desc.localPosFromDOM(mut.target, toOffset, 1); return { from, to }; } else if (mut.type == "attributes") { return { from: desc.posAtStart - desc.border, to: desc.posAtEnd + desc.border }; } else { // "characterData" return { from: desc.posAtStart, to: desc.posAtEnd, // An event was generated for a text change that didn't change // any text. Mark the dom change to fall back to assuming the // selection was typed over with an identical value if it can't // find another change. typeOver: mut.target.nodeValue == mut.oldValue }; } } } let cssChecked = new WeakMap(); let cssCheckWarned = false; function checkCSS(view) { if (cssChecked.has(view)) return; cssChecked.set(view, null); if (['normal', 'nowrap', 'pre-line'].indexOf(getComputedStyle(view.dom).whiteSpace) !== -1) { view.requiresGeckoHackNode = gecko; if (cssCheckWarned) return; console["warn"]("ProseMirror expects the CSS white-space property to be set, preferably to 'pre-wrap'. It is recommended to load style/prosemirror.css from the prosemirror-view package."); cssCheckWarned = true; } } function rangeToSelectionRange(view, range) { let anchorNode = range.startContainer, anchorOffset = range.startOffset; let focusNode = range.endContainer, focusOffset = range.endOffset; let currentAnchor = view.domAtPos(view.state.selection.anchor); // Since such a range doesn't distinguish between anchor and head, // use a heuristic that flips it around if its end matches the // current anchor. if (isEquivalentPosition(currentAnchor.node, currentAnchor.offset, focusNode, focusOffset)) [anchorNode, anchorOffset, focusNode, focusOffset] = [focusNode, focusOffset, anchorNode, anchorOffset]; return { anchorNode, anchorOffset, focusNode, focusOffset }; } // Used to work around a Safari Selection/shadow DOM bug // Based on https://github.com/codemirror/dev/issues/414 fix function safariShadowSelectionRange(view, selection) { if (selection.getComposedRanges) { let range = selection.getComposedRanges(view.root)[0]; if (range) return rangeToSelectionRange(view, range); } let found; function read(event) { event.preventDefault(); event.stopImmediatePropagation(); found = event.getTargetRanges()[0]; } // Because Safari (at least in 2018-2022) doesn't provide regular // access to the selection inside a shadowRoot, we have to perform a // ridiculous hack to get at it—using `execCommand` to trigger a // `beforeInput` event so that we can read the target range from the // event. view.dom.addEventListener("beforeinput", read, true); document.execCommand("indent"); view.dom.removeEventListener("beforeinput", read, true); return found ? rangeToSelectionRange(view, found) : null; } // Note that all referencing and parsing is done with the // start-of-operation selection and document, since that's the one // that the DOM represents. If any changes came in in the meantime, // the modification is mapped over those before it is applied, in // readDOMChange. function parseBetween(view, from_, to_) { let { node: parent, fromOffset, toOffset, from, to } = view.docView.parseRange(from_, to_); let domSel = view.domSelectionRange(); let find; let anchor = domSel.anchorNode; if (anchor && view.dom.contains(anchor.nodeType == 1 ? anchor : anchor.parentNode)) { find = [{ node: anchor, offset: domSel.anchorOffset }]; if (!selectionCollapsed(domSel)) find.push({ node: domSel.focusNode, offset: domSel.focusOffset }); } // Work around issue in Chrome where backspacing sometimes replaces // the deleted content with a random BR node (issues #799, #831) if (chrome && view.input.lastKeyCode === 8) { for (let off = toOffset; off > fromOffset; off--) { let node = parent.childNodes[off - 1], desc = node.pmViewDesc; if (node.nodeName == "BR" && !desc) { toOffset = off; break; } if (!desc || desc.size) break; } } let startDoc = view.state.doc; let parser = view.someProp("domParser") || DOMParser$1.fromSchema(view.state.schema); let $from = startDoc.resolve(from); let sel = null, doc = parser.parse(parent, { topNode: $from.parent, topMatch: $from.parent.contentMatchAt($from.index()), topOpen: true, from: fromOffset, to: toOffset, preserveWhitespace: $from.parent.type.whitespace == "pre" ? "full" : true, findPositions: find, ruleFromNode, context: $from }); if (find && find[0].pos != null) { let anchor = find[0].pos, head = find[1] && find[1].pos; if (head == null) head = anchor; sel = { anchor: anchor + from, head: head + from }; } return { doc, sel, from, to }; } function ruleFromNode(dom) { let desc = dom.pmViewDesc; if (desc) { return desc.parseRule(); } else if (dom.nodeName == "BR" && dom.parentNode) { // Safari replaces the list item or table cell with a BR // directly in the list node (?!) if you delete the last // character in a list item or table cell (#708, #862) if (safari && /^(ul|ol)$/i.test(dom.parentNode.nodeName)) { let skip = document.createElement("div"); skip.appendChild(document.createElement("li")); return { skip }; } else if (dom.parentNode.lastChild == dom || safari && /^(tr|table)$/i.test(dom.parentNode.nodeName)) { return { ignore: true }; } } else if (dom.nodeName == "IMG" && dom.getAttribute("mark-placeholder")) { return { ignore: true }; } return null; } const isInline = /^(a|abbr|acronym|b|bd[io]|big|br|button|cite|code|data(list)?|del|dfn|em|i|ins|kbd|label|map|mark|meter|output|q|ruby|s|samp|small|span|strong|su[bp]|time|u|tt|var)$/i; function readDOMChange(view, from, to, typeOver, addedNodes) { let compositionID = view.input.compositionPendingChanges || (view.composing ? view.input.compositionID : 0); view.input.compositionPendingChanges = 0; if (from < 0) { let origin = view.input.lastSelectionTime > Date.now() - 50 ? view.input.lastSelectionOrigin : null; let newSel = selectionFromDOM(view, origin); if (newSel && !view.state.selection.eq(newSel)) { if (chrome && android && view.input.lastKeyCode === 13 && Date.now() - 100 < view.input.lastKeyCodeTime && view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter")))) return; let tr = view.state.tr.setSelection(newSel); if (origin == "pointer") tr.setMeta("pointer", true); else if (origin == "key") tr.scrollIntoView(); if (compositionID) tr.setMeta("composition", compositionID); view.dispatch(tr); } return; } let $before = view.state.doc.resolve(from); let shared = $before.sharedDepth(to); from = $before.before(shared + 1); to = view.state.doc.resolve(to).after(shared + 1); let sel = view.state.selection; let parse = parseBetween(view, from, to); let doc = view.state.doc, compare = doc.slice(parse.from, parse.to); let preferredPos, preferredSide; // Prefer anchoring to end when Backspace is pressed if (view.input.lastKeyCode === 8 && Date.now() - 100 < view.input.lastKeyCodeTime) { preferredPos = view.state.selection.to; preferredSide = "end"; } else { preferredPos = view.state.selection.from; preferredSide = "start"; } view.input.lastKeyCode = null; let change = findDiff(compare.content, parse.doc.content, parse.from, preferredPos, preferredSide); if ((ios && view.input.lastIOSEnter > Date.now() - 225 || android) && addedNodes.some(n => n.nodeType == 1 && !isInline.test(n.nodeName)) && (!change || change.endA >= change.endB) && view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter")))) { view.input.lastIOSEnter = 0; return; } if (!change) { if (typeOver && sel instanceof TextSelection && !sel.empty && sel.$head.sameParent(sel.$anchor) && !view.composing && !(parse.sel && parse.sel.anchor != parse.sel.head)) { change = { start: sel.from, endA: sel.to, endB: sel.to }; } else { if (parse.sel) { let sel = resolveSelection(view, view.state.doc, parse.sel); if (sel && !sel.eq(view.state.selection)) { let tr = view.state.tr.setSelection(sel); if (compositionID) tr.setMeta("composition", compositionID); view.dispatch(tr); } } return; } } view.input.domChangeCount++; // Handle the case where overwriting a selection by typing matches // the start or end of the selected content, creating a change // that's smaller than what was actually overwritten. if (view.state.selection.from < view.state.selection.to && change.start == change.endB && view.state.selection instanceof TextSelection) { if (change.start > view.state.selection.from && change.start <= view.state.selection.from + 2 && view.state.selection.from >= parse.from) { change.start = view.state.selection.from; } else if (change.endA < view.state.selection.to && change.endA >= view.state.selection.to - 2 && view.state.selection.to <= parse.to) { change.endB += (view.state.selection.to - change.endA); change.endA = view.state.selection.to; } } // IE11 will insert a non-breaking space _ahead_ of the space after // the cursor space when adding a space before another space. When // that happened, adjust the change to cover the space instead. if (ie$1 && ie_version <= 11 && change.endB == change.start + 1 && change.endA == change.start && change.start > parse.from && parse.doc.textBetween(change.start - parse.from - 1, change.start - parse.from + 1) == " \u00a0") { change.start--; change.endA--; change.endB--; } let $from = parse.doc.resolveNoCache(change.start - parse.from); let $to = parse.doc.resolveNoCache(change.endB - parse.from); let $fromA = doc.resolve(change.start); let inlineChange = $from.sameParent($to) && $from.parent.inlineContent && $fromA.end() >= change.endA; let nextSel; // If this looks like the effect of pressing Enter (or was recorded // as being an iOS enter press), just dispatch an Enter key instead. if (((ios && view.input.lastIOSEnter > Date.now() - 225 && (!inlineChange || addedNodes.some(n => n.nodeName == "DIV" || n.nodeName == "P"))) || (!inlineChange && $from.pos < parse.doc.content.size && !$from.sameParent($to) && (nextSel = Selection.findFrom(parse.doc.resolve($from.pos + 1), 1, true)) && nextSel.head == $to.pos)) && view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter")))) { view.input.lastIOSEnter = 0; return; } // Same for backspace if (view.state.selection.anchor > change.start && looksLikeBackspace(doc, change.start, change.endA, $from, $to) && view.someProp("handleKeyDown", f => f(view, keyEvent(8, "Backspace")))) { if (android && chrome) view.domObserver.suppressSelectionUpdates(); // #820 return; } // Chrome Android will occasionally, during composition, delete the // entire composition and then immediately insert it again. This is // used to detect that situation. if (chrome && android && change.endB == change.start) view.input.lastAndroidDelete = Date.now(); // This tries to detect Android virtual keyboard // enter-and-pick-suggestion action. That sometimes (see issue // #1059) first fires a DOM mutation, before moving the selection to // the newly created block. And then, because ProseMirror cleans up // the DOM selection, it gives up moving the selection entirely, // leaving the cursor in the wrong place. When that happens, we drop // the new paragraph from the initial change, and fire a simulated // enter key afterwards. if (android && !inlineChange && $from.start() != $to.start() && $to.parentOffset == 0 && $from.depth == $to.depth && parse.sel && parse.sel.anchor == parse.sel.head && parse.sel.head == change.endA) { change.endB -= 2; $to = parse.doc.resolveNoCache(change.endB - parse.from); setTimeout(() => { view.someProp("handleKeyDown", function (f) { return f(view, keyEvent(13, "Enter")); }); }, 20); } let chFrom = change.start, chTo = change.endA; let tr, storedMarks, markChange; if (inlineChange) { if ($from.pos == $to.pos) { // Deletion // IE11 sometimes weirdly moves the DOM selection around after // backspacing out the first element in a textblock if (ie$1 && ie_version <= 11 && $from.parentOffset == 0) { view.domObserver.suppressSelectionUpdates(); setTimeout(() => selectionToDOM(view), 20); } tr = view.state.tr.delete(chFrom, chTo); storedMarks = doc.resolve(change.start).marksAcross(doc.resolve(change.endA)); } else if ( // Adding or removing a mark change.endA == change.endB && (markChange = isMarkChange($from.parent.content.cut($from.parentOffset, $to.parentOffset), $fromA.parent.content.cut($fromA.parentOffset, change.endA - $fromA.start())))) { tr = view.state.tr; if (markChange.type == "add") tr.addMark(chFrom, chTo, markChange.mark); else tr.removeMark(chFrom, chTo, markChange.mark); } else if ($from.parent.child($from.index()).isText && $from.index() == $to.index() - ($to.textOffset ? 0 : 1)) { // Both positions in the same text node -- simply insert text let text = $from.parent.textBetween($from.parentOffset, $to.parentOffset); if (view.someProp("handleTextInput", f => f(view, chFrom, chTo, text))) return; tr = view.state.tr.insertText(text, chFrom, chTo); } } if (!tr) tr = view.state.tr.replace(chFrom, chTo, parse.doc.slice(change.start - parse.from, change.endB - parse.from)); if (parse.sel) { let sel = resolveSelection(view, tr.doc, parse.sel); // Chrome Android will sometimes, during composition, report the // selection in the wrong place. If it looks like that is // happening, don't update the selection. // Edge just doesn't move the cursor forward when you start typing // in an empty block or between br nodes. if (sel && !(chrome && android && view.composing && sel.empty && (change.start != change.endB || view.input.lastAndroidDelete < Date.now() - 100) && (sel.head == chFrom || sel.head == tr.mapping.map(chTo) - 1) || ie$1 && sel.empty && sel.head == chFrom)) tr.setSelection(sel); } if (storedMarks) tr.ensureMarks(storedMarks); if (compositionID) tr.setMeta("composition", compositionID); view.dispatch(tr.scrollIntoView()); } function resolveSelection(view, doc, parsedSel) { if (Math.max(parsedSel.anchor, parsedSel.head) > doc.content.size) return null; return selectionBetween(view, doc.resolve(parsedSel.anchor), doc.resolve(parsedSel.head)); } // Given two same-length, non-empty fragments of inline content, // determine whether the first could be created from the second by // removing or adding a single mark type. function isMarkChange(cur, prev) { let curMarks = cur.firstChild.marks, prevMarks = prev.firstChild.marks; let added = curMarks, removed = prevMarks, type, mark, update; for (let i = 0; i < prevMarks.length; i++) added = prevMarks[i].removeFromSet(added); for (let i = 0; i < curMarks.length; i++) removed = curMarks[i].removeFromSet(removed); if (added.length == 1 && removed.length == 0) { mark = added[0]; type = "add"; update = (node) => node.mark(mark.addToSet(node.marks)); } else if (added.length == 0 && removed.length == 1) { mark = removed[0]; type = "remove"; update = (node) => node.mark(mark.removeFromSet(node.marks)); } else { return null; } let updated = []; for (let i = 0; i < prev.childCount; i++) updated.push(update(prev.child(i))); if (Fragment.from(updated).eq(cur)) return { mark, type }; } function looksLikeBackspace(old, start, end, $newStart, $newEnd) { if ( // The content must have shrunk end - start <= $newEnd.pos - $newStart.pos || // newEnd must point directly at or after the end of the block that newStart points into skipClosingAndOpening($newStart, true, false) < $newEnd.pos) return false; let $start = old.resolve(start); // Handle the case where, rather than joining blocks, the change just removed an entire block if (!$newStart.parent.isTextblock) { let after = $start.nodeAfter; return after != null && end == start + after.nodeSize; } // Start must be at the end of a block if ($start.parentOffset < $start.parent.content.size || !$start.parent.isTextblock) return false; let $next = old.resolve(skipClosingAndOpening($start, true, true)); // The next textblock must start before end and end near it if (!$next.parent.isTextblock || $next.pos > end || skipClosingAndOpening($next, true, false) < end) return false; // The fragments after the join point must match return $newStart.parent.content.cut($newStart.parentOffset).eq($next.parent.content); } function skipClosingAndOpening($pos, fromEnd, mayOpen) { let depth = $pos.depth, end = fromEnd ? $pos.end() : $pos.pos; while (depth > 0 && (fromEnd || $pos.indexAfter(depth) == $pos.node(depth).childCount)) { depth--; end++; fromEnd = false; } if (mayOpen) { let next = $pos.node(depth).maybeChild($pos.indexAfter(depth)); while (next && !next.isLeaf) { next = next.firstChild; end++; } } return end; } function findDiff(a, b, pos, preferredPos, preferredSide) { let start = a.findDiffStart(b, pos); if (start == null) return null; let { a: endA, b: endB } = a.findDiffEnd(b, pos + a.size, pos + b.size); if (preferredSide == "end") { let adjust = Math.max(0, start - Math.min(endA, endB)); preferredPos -= endA + adjust - start; } if (endA < start && a.size < b.size) { let move = preferredPos <= start && preferredPos >= endA ? start - preferredPos : 0; start -= move; if (start && start < b.size && isSurrogatePair(b.textBetween(start - 1, start + 1))) start += move ? 1 : -1; endB = start + (endB - endA); endA = start; } else if (endB < start) { let move = preferredPos <= start && preferredPos >= endB ? start - preferredPos : 0; start -= move; if (start && start < a.size && isSurrogatePair(a.textBetween(start - 1, start + 1))) start += move ? 1 : -1; endA = start + (endA - endB); endB = start; } return { start, endA, endB }; } function isSurrogatePair(str) { if (str.length != 2) return false; let a = str.charCodeAt(0), b = str.charCodeAt(1); return a >= 0xDC00 && a <= 0xDFFF && b >= 0xD800 && b <= 0xDBFF; } /** An editor view manages the DOM structure that represents an editable document. Its state and behavior are determined by its [props](https://prosemirror.net/docs/ref/#view.DirectEditorProps). */ class EditorView { /** Create a view. `place` may be a DOM node that the editor should be appended to, a function that will place it into the document, or an object whose `mount` property holds the node to use as the document container. If it is `null`, the editor will not be added to the document. */ constructor(place, props) { this._root = null; /** @internal */ this.focused = false; /** Kludge used to work around a Chrome bug @internal */ this.trackWrites = null; this.mounted = false; /** @internal */ this.markCursor = null; /** @internal */ this.cursorWrapper = null; /** @internal */ this.lastSelectedViewDesc = undefined; /** @internal */ this.input = new InputState; this.prevDirectPlugins = []; this.pluginViews = []; /** Holds `true` when a hack node is needed in Firefox to prevent the [space is eaten issue](https://github.com/ProseMirror/prosemirror/issues/651) @internal */ this.requiresGeckoHackNode = false; /** When editor content is being dragged, this object contains information about the dragged slice and whether it is being copied or moved. At any other time, it is null. */ this.dragging = null; this._props = props; this.state = props.state; this.directPlugins = props.plugins || []; this.directPlugins.forEach(checkStateComponent); this.dispatch = this.dispatch.bind(this); this.dom = (place && place.mount) || document.createElement("div"); if (place) { if (place.appendChild) place.appendChild(this.dom); else if (typeof place == "function") place(this.dom); else if (place.mount) this.mounted = true; } this.editable = getEditable(this); updateCursorWrapper(this); this.nodeViews = buildNodeViews(this); this.docView = docViewDesc(this.state.doc, computeDocDeco(this), viewDecorations(this), this.dom, this); this.domObserver = new DOMObserver(this, (from, to, typeOver, added) => readDOMChange(this, from, to, typeOver, added)); this.domObserver.start(); initInput(this); this.updatePluginViews(); } /** Holds `true` when a [composition](https://w3c.github.io/uievents/#events-compositionevents) is active. */ get composing() { return this.input.composing; } /** The view's current [props](https://prosemirror.net/docs/ref/#view.EditorProps). */ get props() { if (this._props.state != this.state) { let prev = this._props; this._props = {}; for (let name in prev) this._props[name] = prev[name]; this._props.state = this.state; } return this._props; } /** Update the view's props. Will immediately cause an update to the DOM. */ update(props) { if (props.handleDOMEvents != this._props.handleDOMEvents) ensureListeners(this); let prevProps = this._props; this._props = props; if (props.plugins) { props.plugins.forEach(checkStateComponent); this.directPlugins = props.plugins; } this.updateStateInner(props.state, prevProps); } /** Update the view by updating existing props object with the object given as argument. Equivalent to `view.update(Object.assign({}, view.props, props))`. */ setProps(props) { let updated = {}; for (let name in this._props) updated[name] = this._props[name]; updated.state = this.state; for (let name in props) updated[name] = props[name]; this.update(updated); } /** Update the editor's `state` prop, without touching any of the other props. */ updateState(state) { this.updateStateInner(state, this._props); } updateStateInner(state, prevProps) { var _a; let prev = this.state, redraw = false, updateSel = false; // When stored marks are added, stop composition, so that they can // be displayed. if (state.storedMarks && this.composing) { clearComposition(this); updateSel = true; } this.state = state; let pluginsChanged = prev.plugins != state.plugins || this._props.plugins != prevProps.plugins; if (pluginsChanged || this._props.plugins != prevProps.plugins || this._props.nodeViews != prevProps.nodeViews) { let nodeViews = buildNodeViews(this); if (changedNodeViews(nodeViews, this.nodeViews)) { this.nodeViews = nodeViews; redraw = true; } } if (pluginsChanged || prevProps.handleDOMEvents != this._props.handleDOMEvents) { ensureListeners(this); } this.editable = getEditable(this); updateCursorWrapper(this); let innerDeco = viewDecorations(this), outerDeco = computeDocDeco(this); let scroll = prev.plugins != state.plugins && !prev.doc.eq(state.doc) ? "reset" : state.scrollToSelection > prev.scrollToSelection ? "to selection" : "preserve"; let updateDoc = redraw || !this.docView.matchesNode(state.doc, outerDeco, innerDeco); if (updateDoc || !state.selection.eq(prev.selection)) updateSel = true; let oldScrollPos = scroll == "preserve" && updateSel && this.dom.style.overflowAnchor == null && storeScrollPos(this); if (updateSel) { this.domObserver.stop(); // Work around an issue in Chrome, IE, and Edge where changing // the DOM around an active selection puts it into a broken // state where the thing the user sees differs from the // selection reported by the Selection object (#710, #973, // #1011, #1013, #1035). let forceSelUpdate = updateDoc && (ie$1 || chrome) && !this.composing && !prev.selection.empty && !state.selection.empty && selectionContextChanged(prev.selection, state.selection); if (updateDoc) { // If the node that the selection points into is written to, // Chrome sometimes starts misreporting the selection, so this // tracks that and forces a selection reset when our update // did write to the node. let chromeKludge = chrome ? (this.trackWrites = this.domSelectionRange().focusNode) : null; if (this.composing) this.input.compositionNode = findCompositionNode(this); if (redraw || !this.docView.update(state.doc, outerDeco, innerDeco, this)) { this.docView.updateOuterDeco(outerDeco); this.docView.destroy(); this.docView = docViewDesc(state.doc, outerDeco, innerDeco, this.dom, this); } if (chromeKludge && !this.trackWrites) forceSelUpdate = true; } // Work around for an issue where an update arriving right between // a DOM selection change and the "selectionchange" event for it // can cause a spurious DOM selection update, disrupting mouse // drag selection. if (forceSelUpdate || !(this.input.mouseDown && this.domObserver.currentSelection.eq(this.domSelectionRange()) && anchorInRightPlace(this))) { selectionToDOM(this, forceSelUpdate); } else { syncNodeSelection(this, state.selection); this.domObserver.setCurSelection(); } this.domObserver.start(); } this.updatePluginViews(prev); if (((_a = this.dragging) === null || _a === void 0 ? void 0 : _a.node) && !prev.doc.eq(state.doc)) this.updateDraggedNode(this.dragging, prev); if (scroll == "reset") { this.dom.scrollTop = 0; } else if (scroll == "to selection") { this.scrollToSelection(); } else if (oldScrollPos) { resetScrollPos(oldScrollPos); } } /** @internal */ scrollToSelection() { let startDOM = this.domSelectionRange().focusNode; if (this.someProp("handleScrollToSelection", f => f(this))) ; else if (this.state.selection instanceof NodeSelection) { let target = this.docView.domAfterPos(this.state.selection.from); if (target.nodeType == 1) scrollRectIntoView(this, target.getBoundingClientRect(), startDOM); } else { scrollRectIntoView(this, this.coordsAtPos(this.state.selection.head, 1), startDOM); } } destroyPluginViews() { let view; while (view = this.pluginViews.pop()) if (view.destroy) view.destroy(); } updatePluginViews(prevState) { if (!prevState || prevState.plugins != this.state.plugins || this.directPlugins != this.prevDirectPlugins) { this.prevDirectPlugins = this.directPlugins; this.destroyPluginViews(); for (let i = 0; i < this.directPlugins.length; i++) { let plugin = this.directPlugins[i]; if (plugin.spec.view) this.pluginViews.push(plugin.spec.view(this)); } for (let i = 0; i < this.state.plugins.length; i++) { let plugin = this.state.plugins[i]; if (plugin.spec.view) this.pluginViews.push(plugin.spec.view(this)); } } else { for (let i = 0; i < this.pluginViews.length; i++) { let pluginView = this.pluginViews[i]; if (pluginView.update) pluginView.update(this, prevState); } } } updateDraggedNode(dragging, prev) { let sel = dragging.node, found = -1; if (this.state.doc.nodeAt(sel.from) == sel.node) { found = sel.from; } else { let movedPos = sel.from + (this.state.doc.content.size - prev.doc.content.size); let moved = movedPos > 0 && this.state.doc.nodeAt(movedPos); if (moved == sel.node) found = movedPos; } this.dragging = new Dragging(dragging.slice, dragging.move, found < 0 ? undefined : NodeSelection.create(this.state.doc, found)); } someProp(propName, f) { let prop = this._props && this._props[propName], value; if (prop != null && (value = f ? f(prop) : prop)) return value; for (let i = 0; i < this.directPlugins.length; i++) { let prop = this.directPlugins[i].props[propName]; if (prop != null && (value = f ? f(prop) : prop)) return value; } let plugins = this.state.plugins; if (plugins) for (let i = 0; i < plugins.length; i++) { let prop = plugins[i].props[propName]; if (prop != null && (value = f ? f(prop) : prop)) return value; } } /** Query whether the view has focus. */ hasFocus() { // Work around IE not handling focus correctly if resize handles are shown. // If the cursor is inside an element with resize handles, activeElement // will be that element instead of this.dom. if (ie$1) { // If activeElement is within this.dom, and there are no other elements // setting `contenteditable` to false in between, treat it as focused. let node = this.root.activeElement; if (node == this.dom) return true; if (!node || !this.dom.contains(node)) return false; while (node && this.dom != node && this.dom.contains(node)) { if (node.contentEditable == 'false') return false; node = node.parentElement; } return true; } return this.root.activeElement == this.dom; } /** Focus the editor. */ focus() { this.domObserver.stop(); if (this.editable) focusPreventScroll(this.dom); selectionToDOM(this); this.domObserver.start(); } /** Get the document root in which the editor exists. This will usually be the top-level `document`, but might be a [shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Shadow_DOM) root if the editor is inside one. */ get root() { let cached = this._root; if (cached == null) for (let search = this.dom.parentNode; search; search = search.parentNode) { if (search.nodeType == 9 || (search.nodeType == 11 && search.host)) { if (!search.getSelection) Object.getPrototypeOf(search).getSelection = () => search.ownerDocument.getSelection(); return this._root = search; } } return cached || document; } /** When an existing editor view is moved to a new document or shadow tree, call this to make it recompute its root. */ updateRoot() { this._root = null; } /** Given a pair of viewport coordinates, return the document position that corresponds to them. May return null if the given coordinates aren't inside of the editor. When an object is returned, its `pos` property is the position nearest to the coordinates, and its `inside` property holds the position of the inner node that the position falls inside of, or -1 if it is at the top level, not in any node. */ posAtCoords(coords) { return posAtCoords(this, coords); } /** Returns the viewport rectangle at a given document position. `left` and `right` will be the same number, as this returns a flat cursor-ish rectangle. If the position is between two things that aren't directly adjacent, `side` determines which element is used. When < 0, the element before the position is used, otherwise the element after. */ coordsAtPos(pos, side = 1) { return coordsAtPos(this, pos, side); } /** Find the DOM position that corresponds to the given document position. When `side` is negative, find the position as close as possible to the content before the position. When positive, prefer positions close to the content after the position. When zero, prefer as shallow a position as possible. Note that you should **not** mutate the editor's internal DOM, only inspect it (and even that is usually not necessary). */ domAtPos(pos, side = 0) { return this.docView.domFromPos(pos, side); } /** Find the DOM node that represents the document node after the given position. May return `null` when the position doesn't point in front of a node or if the node is inside an opaque node view. This is intended to be able to call things like `getBoundingClientRect` on that DOM node. Do **not** mutate the editor DOM directly, or add styling this way, since that will be immediately overriden by the editor as it redraws the node. */ nodeDOM(pos) { let desc = this.docView.descAt(pos); return desc ? desc.nodeDOM : null; } /** Find the document position that corresponds to a given DOM position. (Whenever possible, it is preferable to inspect the document structure directly, rather than poking around in the DOM, but sometimes—for example when interpreting an event target—you don't have a choice.) The `bias` parameter can be used to influence which side of a DOM node to use when the position is inside a leaf node. */ posAtDOM(node, offset, bias = -1) { let pos = this.docView.posFromDOM(node, offset, bias); if (pos == null) throw new RangeError("DOM position not inside the editor"); return pos; } /** Find out whether the selection is at the end of a textblock when moving in a given direction. When, for example, given `"left"`, it will return true if moving left from the current cursor position would leave that position's parent textblock. Will apply to the view's current state by default, but it is possible to pass a different state. */ endOfTextblock(dir, state) { return endOfTextblock(this, state || this.state, dir); } /** Run the editor's paste logic with the given HTML string. The `event`, if given, will be passed to the [`handlePaste`](https://prosemirror.net/docs/ref/#view.EditorProps.handlePaste) hook. */ pasteHTML(html, event) { return doPaste(this, "", html, false, event || new ClipboardEvent("paste")); } /** Run the editor's paste logic with the given plain-text input. */ pasteText(text, event) { return doPaste(this, text, null, true, event || new ClipboardEvent("paste")); } /** Removes the editor from the DOM and destroys all [node views](https://prosemirror.net/docs/ref/#view.NodeView). */ destroy() { if (!this.docView) return; destroyInput(this); this.destroyPluginViews(); if (this.mounted) { this.docView.update(this.state.doc, [], viewDecorations(this), this); this.dom.textContent = ""; } else if (this.dom.parentNode) { this.dom.parentNode.removeChild(this.dom); } this.docView.destroy(); this.docView = null; clearReusedRange(); } /** This is true when the view has been [destroyed](https://prosemirror.net/docs/ref/#view.EditorView.destroy) (and thus should not be used anymore). */ get isDestroyed() { return this.docView == null; } /** Used for testing. */ dispatchEvent(event) { return dispatchEvent(this, event); } /** Dispatch a transaction. Will call [`dispatchTransaction`](https://prosemirror.net/docs/ref/#view.DirectEditorProps.dispatchTransaction) when given, and otherwise defaults to applying the transaction to the current state and calling [`updateState`](https://prosemirror.net/docs/ref/#view.EditorView.updateState) with the result. This method is bound to the view instance, so that it can be easily passed around. */ dispatch(tr) { let dispatchTransaction = this._props.dispatchTransaction; if (dispatchTransaction) dispatchTransaction.call(this, tr); else this.updateState(this.state.apply(tr)); } /** @internal */ domSelectionRange() { let sel = this.domSelection(); return safari && this.root.nodeType === 11 && deepActiveElement(this.dom.ownerDocument) == this.dom && safariShadowSelectionRange(this, sel) || sel; } /** @internal */ domSelection() { return this.root.getSelection(); } } function computeDocDeco(view) { let attrs = Object.create(null); attrs.class = "ProseMirror"; attrs.contenteditable = String(view.editable); view.someProp("attributes", value => { if (typeof value == "function") value = value(view.state); if (value) for (let attr in value) { if (attr == "class") attrs.class += " " + value[attr]; else if (attr == "style") attrs.style = (attrs.style ? attrs.style + ";" : "") + value[attr]; else if (!attrs[attr] && attr != "contenteditable" && attr != "nodeName") attrs[attr] = String(value[attr]); } }); if (!attrs.translate) attrs.translate = "no"; return [Decoration.node(0, view.state.doc.content.size, attrs)]; } function updateCursorWrapper(view) { if (view.markCursor) { let dom = document.createElement("img"); dom.className = "ProseMirror-separator"; dom.setAttribute("mark-placeholder", "true"); dom.setAttribute("alt", ""); view.cursorWrapper = { dom, deco: Decoration.widget(view.state.selection.head, dom, { raw: true, marks: view.markCursor }) }; } else { view.cursorWrapper = null; } } function getEditable(view) { return !view.someProp("editable", value => value(view.state) === false); } function selectionContextChanged(sel1, sel2) { let depth = Math.min(sel1.$anchor.sharedDepth(sel1.head), sel2.$anchor.sharedDepth(sel2.head)); return sel1.$anchor.start(depth) != sel2.$anchor.start(depth); } function buildNodeViews(view) { let result = Object.create(null); function add(obj) { for (let prop in obj) if (!Object.prototype.hasOwnProperty.call(result, prop)) result[prop] = obj[prop]; } view.someProp("nodeViews", add); view.someProp("markViews", add); return result; } function changedNodeViews(a, b) { let nA = 0, nB = 0; for (let prop in a) { if (a[prop] != b[prop]) return true; nA++; } for (let _ in b) nB++; return nA != nB; } function checkStateComponent(plugin) { if (plugin.spec.state || plugin.spec.filterTransaction || plugin.spec.appendTransaction) throw new RangeError("Plugins passed directly to the view must not have a state component"); } /** Input rules are regular expressions describing a piece of text that, when typed, causes something to happen. This might be changing two dashes into an emdash, wrapping a paragraph starting with `"> "` into a blockquote, or something entirely different. */ class InputRule { // :: (RegExp, union) /** Create an input rule. The rule applies when the user typed something and the text directly in front of the cursor matches `match`, which should end with `$`. The `handler` can be a string, in which case the matched text, or the first matched group in the regexp, is replaced by that string. Or a it can be a function, which will be called with the match array produced by [`RegExp.exec`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec), as well as the start and end of the matched range, and which can return a [transaction](https://prosemirror.net/docs/ref/#state.Transaction) that describes the rule's effect, or null to indicate the input was not handled. */ constructor( /** @internal */ match, handler, options = {}) { this.match = match; this.match = match; this.handler = typeof handler == "string" ? stringHandler(handler) : handler; this.undoable = options.undoable !== false; this.inCode = options.inCode || false; } } function stringHandler(string) { return function (state, match, start, end) { let insert = string; if (match[1]) { let offset = match[0].lastIndexOf(match[1]); insert += match[0].slice(offset + match[1].length); start += offset; let cutOff = start - end; if (cutOff > 0) { insert = match[0].slice(offset - cutOff, offset) + insert; start = end; } } return state.tr.insertText(insert, start, end); }; } const MAX_MATCH = 500; /** Create an input rules plugin. When enabled, it will cause text input that matches any of the given rules to trigger the rule's action. */ function inputRules({ rules }) { let plugin = new Plugin({ state: { init() { return null; }, apply(tr, prev) { let stored = tr.getMeta(this); if (stored) return stored; return tr.selectionSet || tr.docChanged ? null : prev; } }, props: { handleTextInput(view, from, to, text) { return run(view, from, to, text, rules, plugin); }, handleDOMEvents: { compositionend: (view) => { setTimeout(() => { let { $cursor } = view.state.selection; if ($cursor) run(view, $cursor.pos, $cursor.pos, "", rules, plugin); }); } } }, isInputRules: true }); return plugin; } function run(view, from, to, text, rules, plugin) { if (view.composing) return false; let state = view.state, $from = state.doc.resolve(from); let textBefore = $from.parent.textBetween(Math.max(0, $from.parentOffset - MAX_MATCH), $from.parentOffset, null, "\ufffc") + text; for (let i = 0; i < rules.length; i++) { let rule = rules[i]; if ($from.parent.type.spec.code) { if (!rule.inCode) continue; } else if (rule.inCode === "only") { continue; } let match = rule.match.exec(textBefore); let tr = match && rule.handler(state, match, from - (match[0].length - text.length), to); if (!tr) continue; if (rule.undoable) tr.setMeta(plugin, { transform: tr, from, to, text }); view.dispatch(tr); return true; } return false; } /** This is a command that will undo an input rule, if applying such a rule was the last thing that the user did. */ const undoInputRule = (state, dispatch) => { let plugins = state.plugins; for (let i = 0; i < plugins.length; i++) { let plugin = plugins[i], undoable; if (plugin.spec.isInputRules && (undoable = plugin.getState(state))) { if (dispatch) { let tr = state.tr, toUndo = undoable.transform; for (let j = toUndo.steps.length - 1; j >= 0; j--) tr.step(toUndo.steps[j].invert(toUndo.docs[j])); if (undoable.text) { let marks = tr.doc.resolve(undoable.from).marks(); tr.replaceWith(undoable.from, undoable.to, state.schema.text(undoable.text, marks)); } else { tr.delete(undoable.from, undoable.to); } dispatch(tr); } return true; } } return false; }; /** Converts double dashes to an emdash. */ const emDash = new InputRule(/--$/, "—"); /** Converts three dots to an ellipsis character. */ const ellipsis = new InputRule(/\.\.\.$/, "…"); /** “Smart” opening double quotes. */ const openDoubleQuote = new InputRule(/(?:^|[\s\{\[\(\<'"\u2018\u201C])(")$/, "“"); /** “Smart” closing double quotes. */ const closeDoubleQuote = new InputRule(/"$/, "”"); /** “Smart” opening single quotes. */ const openSingleQuote = new InputRule(/(?:^|[\s\{\[\(\<'"\u2018\u201C])(')$/, "‘"); /** “Smart” closing single quotes. */ const closeSingleQuote = new InputRule(/'$/, "’"); /** Smart-quote related input rules. */ const smartQuotes = [openDoubleQuote, closeDoubleQuote, openSingleQuote, closeSingleQuote]; /** Build an input rule for automatically wrapping a textblock when a given string is typed. The `regexp` argument is directly passed through to the `InputRule` constructor. You'll probably want the regexp to start with `^`, so that the pattern can only occur at the start of a textblock. `nodeType` is the type of node to wrap in. If it needs attributes, you can either pass them directly, or pass a function that will compute them from the regular expression match. By default, if there's a node with the same type above the newly wrapped node, the rule will try to [join](https://prosemirror.net/docs/ref/#transform.Transform.join) those two nodes. You can pass a join predicate, which takes a regular expression match and the node before the wrapped node, and can return a boolean to indicate whether a join should happen. */ function wrappingInputRule(regexp, nodeType, getAttrs = null, joinPredicate) { return new InputRule(regexp, (state, match, start, end) => { let attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs; let tr = state.tr.delete(start, end); let $start = tr.doc.resolve(start), range = $start.blockRange(), wrapping = range && findWrapping(range, nodeType, attrs); if (!wrapping) return null; tr.wrap(range, wrapping); let before = tr.doc.resolve(start - 1).nodeBefore; if (before && before.type == nodeType && canJoin(tr.doc, start - 1) && (!joinPredicate || joinPredicate(match, before))) tr.join(start - 1); return tr; }); } /** Build an input rule that changes the type of a textblock when the matched text is typed into it. You'll usually want to start your regexp with `^` to that it is only matched at the start of a textblock. The optional `getAttrs` parameter can be used to compute the new node's attributes, and works the same as in the `wrappingInputRule` function. */ function textblockTypeInputRule(regexp, nodeType, getAttrs = null) { return new InputRule(regexp, (state, match, start, end) => { let $start = state.doc.resolve(start); let attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs; if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), nodeType)) return null; return state.tr .delete(start, end) .setBlockType(start, start, nodeType, attrs); }); } var index$4 = /*#__PURE__*/Object.freeze({ __proto__: null, InputRule: InputRule, closeDoubleQuote: closeDoubleQuote, closeSingleQuote: closeSingleQuote, ellipsis: ellipsis, emDash: emDash, inputRules: inputRules, openDoubleQuote: openDoubleQuote, openSingleQuote: openSingleQuote, smartQuotes: smartQuotes, textblockTypeInputRule: textblockTypeInputRule, undoInputRule: undoInputRule, wrappingInputRule: wrappingInputRule }); /** * @abstract */ 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."); } } /** * A class responsible for building the input rules for the ProseMirror editor. * @extends {ProseMirrorPlugin} */ 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(); }); } } var base = { 8: "Backspace", 9: "Tab", 10: "Enter", 12: "NumLock", 13: "Enter", 16: "Shift", 17: "Control", 18: "Alt", 20: "CapsLock", 27: "Escape", 32: " ", 33: "PageUp", 34: "PageDown", 35: "End", 36: "Home", 37: "ArrowLeft", 38: "ArrowUp", 39: "ArrowRight", 40: "ArrowDown", 44: "PrintScreen", 45: "Insert", 46: "Delete", 59: ";", 61: "=", 91: "Meta", 92: "Meta", 106: "*", 107: "+", 108: ",", 109: "-", 110: ".", 111: "/", 144: "NumLock", 145: "ScrollLock", 160: "Shift", 161: "Shift", 162: "Control", 163: "Control", 164: "Alt", 165: "Alt", 173: "-", 186: ";", 187: "=", 188: ",", 189: "-", 190: ".", 191: "/", 192: "`", 219: "[", 220: "\\", 221: "]", 222: "'" }; var shift = { 48: ")", 49: "!", 50: "@", 51: "#", 52: "$", 53: "%", 54: "^", 55: "&", 56: "*", 57: "(", 59: ":", 61: "+", 173: "_", 186: ":", 187: "+", 188: "<", 189: "_", 190: ">", 191: "?", 192: "~", 219: "{", 220: "|", 221: "}", 222: "\"" }; var mac$2 = typeof navigator != "undefined" && /Mac/.test(navigator.platform); var ie = typeof navigator != "undefined" && /MSIE \d|Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(navigator.userAgent); // Fill in the digit keys for (var i = 0; i < 10; i++) base[48 + i] = base[96 + i] = String(i); // The function keys for (var i = 1; i <= 24; i++) base[i + 111] = "F" + i; // And the alphabetic keys for (var i = 65; i <= 90; i++) { base[i] = String.fromCharCode(i + 32); shift[i] = String.fromCharCode(i); } // For each code that doesn't have a shift-equivalent, copy the base name for (var code$1 in base) if (!shift.hasOwnProperty(code$1)) shift[code$1] = base[code$1]; function keyName(event) { // On macOS, keys held with Shift and Cmd don't reflect the effect of Shift in `.key`. // On IE, shift effect is never included in `.key`. var ignoreKey = mac$2 && event.metaKey && event.shiftKey && !event.ctrlKey && !event.altKey || ie && event.shiftKey && event.key && event.key.length == 1 || event.key == "Unidentified"; var name = (!ignoreKey && event.key) || (event.shiftKey ? shift : base)[event.keyCode] || event.key || "Unidentified"; // Edge sometimes produces wrong names (Issue #3) if (name == "Esc") name = "Escape"; if (name == "Del") name = "Delete"; // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/8860571/ if (name == "Left") name = "ArrowLeft"; if (name == "Up") name = "ArrowUp"; if (name == "Right") name = "ArrowRight"; if (name == "Down") name = "ArrowDown"; return name } const mac$1 = typeof navigator != "undefined" ? /Mac|iP(hone|[oa]d)/.test(navigator.platform) : false; function normalizeKeyName(name) { let parts = name.split(/-(?!$)/), result = parts[parts.length - 1]; if (result == "Space") result = " "; let alt, ctrl, shift, meta; for (let i = 0; i < parts.length - 1; i++) { let mod = parts[i]; if (/^(cmd|meta|m)$/i.test(mod)) meta = true; else if (/^a(lt)?$/i.test(mod)) alt = true; else if (/^(c|ctrl|control)$/i.test(mod)) ctrl = true; else if (/^s(hift)?$/i.test(mod)) shift = true; else if (/^mod$/i.test(mod)) { if (mac$1) meta = true; else ctrl = true; } else throw new Error("Unrecognized modifier name: " + mod); } if (alt) result = "Alt-" + result; if (ctrl) result = "Ctrl-" + result; if (meta) result = "Meta-" + result; if (shift) result = "Shift-" + result; return result; } function normalize(map) { let copy = Object.create(null); for (let prop in map) copy[normalizeKeyName(prop)] = map[prop]; return copy; } function modifiers(name, event, shift = true) { if (event.altKey) name = "Alt-" + name; if (event.ctrlKey) name = "Ctrl-" + name; if (event.metaKey) name = "Meta-" + name; if (shift && event.shiftKey) name = "Shift-" + name; return name; } /** Create a keymap plugin for the given set of bindings. Bindings should map key names to [command](https://prosemirror.net/docs/ref/#commands)-style functions, which will be called with `(EditorState, dispatch, EditorView)` arguments, and should return true when they've handled the key. Note that the view argument isn't part of the command protocol, but can be used as an escape hatch if a binding needs to directly interact with the UI. Key names may be strings like `"Shift-Ctrl-Enter"`—a key identifier prefixed with zero or more modifiers. Key identifiers are based on the strings that can appear in [`KeyEvent.key`](https:developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key). Use lowercase letters to refer to letter keys (or uppercase letters if you want shift to be held). You may use `"Space"` as an alias for the `" "` name. Modifiers can be given in any order. `Shift-` (or `s-`), `Alt-` (or `a-`), `Ctrl-` (or `c-` or `Control-`) and `Cmd-` (or `m-` or `Meta-`) are recognized. For characters that are created by holding shift, the `Shift-` prefix is implied, and should not be added explicitly. You can use `Mod-` as a shorthand for `Cmd-` on Mac and `Ctrl-` on other platforms. You can add multiple keymap plugins to an editor. The order in which they appear determines their precedence (the ones early in the array get to dispatch first). */ function keymap(bindings) { return new Plugin({ props: { handleKeyDown: keydownHandler(bindings) } }); } /** Given a set of bindings (using the same format as [`keymap`](https://prosemirror.net/docs/ref/#keymap.keymap)), return a [keydown handler](https://prosemirror.net/docs/ref/#view.EditorProps.handleKeyDown) that handles them. */ function keydownHandler(bindings) { let map = normalize(bindings); return function (view, event) { let name = keyName(event), baseName, direct = map[modifiers(name, event)]; if (direct && direct(view.state, view.dispatch, view)) return true; // A character key if (name.length == 1 && name != " ") { if (event.shiftKey) { // In case the name was already modified by shift, try looking // it up without its shift modifier let noShift = map[modifiers(name, event, false)]; if (noShift && noShift(view.state, view.dispatch, view)) return true; } if ((event.shiftKey || event.altKey || event.metaKey || name.charCodeAt(0) > 127) && (baseName = base[event.keyCode]) && baseName != name) { // Try falling back to the keyCode when there's a modifier // active or the character produced isn't ASCII, and our table // produces a different name from the the keyCode. See #668, // #1060 let fromCode = map[modifiers(baseName, event)]; if (fromCode && fromCode(view.state, view.dispatch, view)) return true; } } return false; }; } /** Delete the selection, if there is one. */ const deleteSelection = (state, dispatch) => { if (state.selection.empty) return false; if (dispatch) dispatch(state.tr.deleteSelection().scrollIntoView()); return true; }; function atBlockStart(state, view) { let { $cursor } = state.selection; if (!$cursor || (view ? !view.endOfTextblock("backward", state) : $cursor.parentOffset > 0)) return null; return $cursor; } /** If the selection is empty and at the start of a textblock, try to reduce the distance between that block and the one before it—if there's a block directly before it that can be joined, join them. If not, try to move the selected block closer to the next one in the document structure by lifting it out of its parent or moving it into a parent of the previous block. Will use the view for accurate (bidi-aware) start-of-textblock detection if given. */ const joinBackward = (state, dispatch, view) => { let $cursor = atBlockStart(state, view); if (!$cursor) return false; let $cut = findCutBefore($cursor); // If there is no node before this, try to lift if (!$cut) { let range = $cursor.blockRange(), target = range && liftTarget(range); if (target == null) return false; if (dispatch) dispatch(state.tr.lift(range, target).scrollIntoView()); return true; } let before = $cut.nodeBefore; // Apply the joining algorithm if (!before.type.spec.isolating && deleteBarrier(state, $cut, dispatch)) return true; // If the node below has no content and the node above is // selectable, delete the node below and select the one above. if ($cursor.parent.content.size == 0 && (textblockAt(before, "end") || NodeSelection.isSelectable(before))) { let delStep = replaceStep(state.doc, $cursor.before(), $cursor.after(), Slice.empty); if (delStep && delStep.slice.size < delStep.to - delStep.from) { if (dispatch) { let tr = state.tr.step(delStep); tr.setSelection(textblockAt(before, "end") ? Selection.findFrom(tr.doc.resolve(tr.mapping.map($cut.pos, -1)), -1) : NodeSelection.create(tr.doc, $cut.pos - before.nodeSize)); dispatch(tr.scrollIntoView()); } return true; } } // If the node before is an atom, delete it if (before.isAtom && $cut.depth == $cursor.depth - 1) { if (dispatch) dispatch(state.tr.delete($cut.pos - before.nodeSize, $cut.pos).scrollIntoView()); return true; } return false; }; /** A more limited form of [`joinBackward`]($commands.joinBackward) that only tries to join the current textblock to the one before it, if the cursor is at the start of a textblock. */ const joinTextblockBackward = (state, dispatch, view) => { let $cursor = atBlockStart(state, view); if (!$cursor) return false; let $cut = findCutBefore($cursor); return $cut ? joinTextblocksAround(state, $cut, dispatch) : false; }; /** A more limited form of [`joinForward`]($commands.joinForward) that only tries to join the current textblock to the one after it, if the cursor is at the end of a textblock. */ const joinTextblockForward = (state, dispatch, view) => { let $cursor = atBlockEnd(state, view); if (!$cursor) return false; let $cut = findCutAfter($cursor); return $cut ? joinTextblocksAround(state, $cut, dispatch) : false; }; function joinTextblocksAround(state, $cut, dispatch) { let before = $cut.nodeBefore, beforeText = before, beforePos = $cut.pos - 1; for (; !beforeText.isTextblock; beforePos--) { if (beforeText.type.spec.isolating) return false; let child = beforeText.lastChild; if (!child) return false; beforeText = child; } let after = $cut.nodeAfter, afterText = after, afterPos = $cut.pos + 1; for (; !afterText.isTextblock; afterPos++) { if (afterText.type.spec.isolating) return false; let child = afterText.firstChild; if (!child) return false; afterText = child; } let step = replaceStep(state.doc, beforePos, afterPos, Slice.empty); if (!step || step.from != beforePos || step instanceof ReplaceStep && step.slice.size >= afterPos - beforePos) return false; if (dispatch) { let tr = state.tr.step(step); tr.setSelection(TextSelection.create(tr.doc, beforePos)); dispatch(tr.scrollIntoView()); } return true; } function textblockAt(node, side, only = false) { for (let scan = node; scan; scan = (side == "start" ? scan.firstChild : scan.lastChild)) { if (scan.isTextblock) return true; if (only && scan.childCount != 1) return false; } return false; } /** When the selection is empty and at the start of a textblock, select the node before that textblock, if possible. This is intended to be bound to keys like backspace, after [`joinBackward`](https://prosemirror.net/docs/ref/#commands.joinBackward) or other deleting commands, as a fall-back behavior when the schema doesn't allow deletion at the selected point. */ const selectNodeBackward = (state, dispatch, view) => { let { $head, empty } = state.selection, $cut = $head; if (!empty) return false; if ($head.parent.isTextblock) { if (view ? !view.endOfTextblock("backward", state) : $head.parentOffset > 0) return false; $cut = findCutBefore($head); } let node = $cut && $cut.nodeBefore; if (!node || !NodeSelection.isSelectable(node)) return false; if (dispatch) dispatch(state.tr.setSelection(NodeSelection.create(state.doc, $cut.pos - node.nodeSize)).scrollIntoView()); return true; }; function findCutBefore($pos) { if (!$pos.parent.type.spec.isolating) for (let i = $pos.depth - 1; i >= 0; i--) { if ($pos.index(i) > 0) return $pos.doc.resolve($pos.before(i + 1)); if ($pos.node(i).type.spec.isolating) break; } return null; } function atBlockEnd(state, view) { let { $cursor } = state.selection; if (!$cursor || (view ? !view.endOfTextblock("forward", state) : $cursor.parentOffset < $cursor.parent.content.size)) return null; return $cursor; } /** If the selection is empty and the cursor is at the end of a textblock, try to reduce or remove the boundary between that block and the one after it, either by joining them or by moving the other block closer to this one in the tree structure. Will use the view for accurate start-of-textblock detection if given. */ const joinForward = (state, dispatch, view) => { let $cursor = atBlockEnd(state, view); if (!$cursor) return false; let $cut = findCutAfter($cursor); // If there is no node after this, there's nothing to do if (!$cut) return false; let after = $cut.nodeAfter; // Try the joining algorithm if (deleteBarrier(state, $cut, dispatch)) return true; // If the node above has no content and the node below is // selectable, delete the node above and select the one below. if ($cursor.parent.content.size == 0 && (textblockAt(after, "start") || NodeSelection.isSelectable(after))) { let delStep = replaceStep(state.doc, $cursor.before(), $cursor.after(), Slice.empty); if (delStep && delStep.slice.size < delStep.to - delStep.from) { if (dispatch) { let tr = state.tr.step(delStep); tr.setSelection(textblockAt(after, "start") ? Selection.findFrom(tr.doc.resolve(tr.mapping.map($cut.pos)), 1) : NodeSelection.create(tr.doc, tr.mapping.map($cut.pos))); dispatch(tr.scrollIntoView()); } return true; } } // If the next node is an atom, delete it if (after.isAtom && $cut.depth == $cursor.depth - 1) { if (dispatch) dispatch(state.tr.delete($cut.pos, $cut.pos + after.nodeSize).scrollIntoView()); return true; } return false; }; /** When the selection is empty and at the end of a textblock, select the node coming after that textblock, if possible. This is intended to be bound to keys like delete, after [`joinForward`](https://prosemirror.net/docs/ref/#commands.joinForward) and similar deleting commands, to provide a fall-back behavior when the schema doesn't allow deletion at the selected point. */ const selectNodeForward = (state, dispatch, view) => { let { $head, empty } = state.selection, $cut = $head; if (!empty) return false; if ($head.parent.isTextblock) { if (view ? !view.endOfTextblock("forward", state) : $head.parentOffset < $head.parent.content.size) return false; $cut = findCutAfter($head); } let node = $cut && $cut.nodeAfter; if (!node || !NodeSelection.isSelectable(node)) return false; if (dispatch) dispatch(state.tr.setSelection(NodeSelection.create(state.doc, $cut.pos)).scrollIntoView()); return true; }; function findCutAfter($pos) { if (!$pos.parent.type.spec.isolating) for (let i = $pos.depth - 1; i >= 0; i--) { let parent = $pos.node(i); if ($pos.index(i) + 1 < parent.childCount) return $pos.doc.resolve($pos.after(i + 1)); if (parent.type.spec.isolating) break; } return null; } /** Join the selected block or, if there is a text selection, the closest ancestor block of the selection that can be joined, with the sibling above it. */ const joinUp = (state, dispatch) => { let sel = state.selection, nodeSel = sel instanceof NodeSelection, point; if (nodeSel) { if (sel.node.isTextblock || !canJoin(state.doc, sel.from)) return false; point = sel.from; } else { point = joinPoint(state.doc, sel.from, -1); if (point == null) return false; } if (dispatch) { let tr = state.tr.join(point); if (nodeSel) tr.setSelection(NodeSelection.create(tr.doc, point - state.doc.resolve(point).nodeBefore.nodeSize)); dispatch(tr.scrollIntoView()); } return true; }; /** Join the selected block, or the closest ancestor of the selection that can be joined, with the sibling after it. */ const joinDown = (state, dispatch) => { let sel = state.selection, point; if (sel instanceof NodeSelection) { if (sel.node.isTextblock || !canJoin(state.doc, sel.to)) return false; point = sel.to; } else { point = joinPoint(state.doc, sel.to, 1); if (point == null) return false; } if (dispatch) dispatch(state.tr.join(point).scrollIntoView()); return true; }; /** Lift the selected block, or the closest ancestor block of the selection that can be lifted, out of its parent node. */ const lift = (state, dispatch) => { let { $from, $to } = state.selection; let range = $from.blockRange($to), target = range && liftTarget(range); if (target == null) return false; if (dispatch) dispatch(state.tr.lift(range, target).scrollIntoView()); return true; }; /** If the selection is in a node whose type has a truthy [`code`](https://prosemirror.net/docs/ref/#model.NodeSpec.code) property in its spec, replace the selection with a newline character. */ const newlineInCode = (state, dispatch) => { let { $head, $anchor } = state.selection; if (!$head.parent.type.spec.code || !$head.sameParent($anchor)) return false; if (dispatch) dispatch(state.tr.insertText("\n").scrollIntoView()); return true; }; function defaultBlockAt(match) { for (let i = 0; i < match.edgeCount; i++) { let { type } = match.edge(i); if (type.isTextblock && !type.hasRequiredAttrs()) return type; } return null; } /** When the selection is in a node with a truthy [`code`](https://prosemirror.net/docs/ref/#model.NodeSpec.code) property in its spec, create a default block after the code block, and move the cursor there. */ const exitCode = (state, dispatch) => { let { $head, $anchor } = state.selection; if (!$head.parent.type.spec.code || !$head.sameParent($anchor)) return false; let above = $head.node(-1), after = $head.indexAfter(-1), type = defaultBlockAt(above.contentMatchAt(after)); if (!type || !above.canReplaceWith(after, after, type)) return false; if (dispatch) { let pos = $head.after(), tr = state.tr.replaceWith(pos, pos, type.createAndFill()); tr.setSelection(Selection.near(tr.doc.resolve(pos), 1)); dispatch(tr.scrollIntoView()); } return true; }; /** If a block node is selected, create an empty paragraph before (if it is its parent's first child) or after it. */ const createParagraphNear = (state, dispatch) => { let sel = state.selection, { $from, $to } = sel; if (sel instanceof AllSelection || $from.parent.inlineContent || $to.parent.inlineContent) return false; let type = defaultBlockAt($to.parent.contentMatchAt($to.indexAfter())); if (!type || !type.isTextblock) return false; if (dispatch) { let side = (!$from.parentOffset && $to.index() < $to.parent.childCount ? $from : $to).pos; let tr = state.tr.insert(side, type.createAndFill()); tr.setSelection(TextSelection.create(tr.doc, side + 1)); dispatch(tr.scrollIntoView()); } return true; }; /** If the cursor is in an empty textblock that can be lifted, lift the block. */ const liftEmptyBlock = (state, dispatch) => { let { $cursor } = state.selection; if (!$cursor || $cursor.parent.content.size) return false; if ($cursor.depth > 1 && $cursor.after() != $cursor.end(-1)) { let before = $cursor.before(); if (canSplit(state.doc, before)) { if (dispatch) dispatch(state.tr.split(before).scrollIntoView()); return true; } } let range = $cursor.blockRange(), target = range && liftTarget(range); if (target == null) return false; if (dispatch) dispatch(state.tr.lift(range, target).scrollIntoView()); return true; }; /** Create a variant of [`splitBlock`](https://prosemirror.net/docs/ref/#commands.splitBlock) that uses a custom function to determine the type of the newly split off block. */ function splitBlockAs(splitNode) { return (state, dispatch) => { let { $from, $to } = state.selection; if (state.selection instanceof NodeSelection && state.selection.node.isBlock) { if (!$from.parentOffset || !canSplit(state.doc, $from.pos)) return false; if (dispatch) dispatch(state.tr.split($from.pos).scrollIntoView()); return true; } if (!$from.parent.isBlock) return false; if (dispatch) { let atEnd = $to.parentOffset == $to.parent.content.size; let tr = state.tr; if (state.selection instanceof TextSelection || state.selection instanceof AllSelection) tr.deleteSelection(); let deflt = $from.depth == 0 ? null : defaultBlockAt($from.node(-1).contentMatchAt($from.indexAfter(-1))); let splitType = splitNode && splitNode($to.parent, atEnd); let types = splitType ? [splitType] : atEnd && deflt ? [{ type: deflt }] : undefined; let can = canSplit(tr.doc, tr.mapping.map($from.pos), 1, types); if (!types && !can && canSplit(tr.doc, tr.mapping.map($from.pos), 1, deflt ? [{ type: deflt }] : undefined)) { if (deflt) types = [{ type: deflt }]; can = true; } if (can) { tr.split(tr.mapping.map($from.pos), 1, types); if (!atEnd && !$from.parentOffset && $from.parent.type != deflt) { let first = tr.mapping.map($from.before()), $first = tr.doc.resolve(first); if (deflt && $from.node(-1).canReplaceWith($first.index(), $first.index() + 1, deflt)) tr.setNodeMarkup(tr.mapping.map($from.before()), deflt); } } dispatch(tr.scrollIntoView()); } return true; }; } /** Split the parent block of the selection. If the selection is a text selection, also delete its content. */ const splitBlock = splitBlockAs(); /** Acts like [`splitBlock`](https://prosemirror.net/docs/ref/#commands.splitBlock), but without resetting the set of active marks at the cursor. */ const splitBlockKeepMarks = (state, dispatch) => { return splitBlock(state, dispatch && (tr => { let marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks()); if (marks) tr.ensureMarks(marks); dispatch(tr); })); }; /** Move the selection to the node wrapping the current selection, if any. (Will not select the document node.) */ const selectParentNode = (state, dispatch) => { let { $from, to } = state.selection, pos; let same = $from.sharedDepth(to); if (same == 0) return false; pos = $from.before(same); if (dispatch) dispatch(state.tr.setSelection(NodeSelection.create(state.doc, pos))); return true; }; /** Select the whole document. */ const selectAll = (state, dispatch) => { if (dispatch) dispatch(state.tr.setSelection(new AllSelection(state.doc))); return true; }; function joinMaybeClear(state, $pos, dispatch) { let before = $pos.nodeBefore, after = $pos.nodeAfter, index = $pos.index(); if (!before || !after || !before.type.compatibleContent(after.type)) return false; if (!before.content.size && $pos.parent.canReplace(index - 1, index)) { if (dispatch) dispatch(state.tr.delete($pos.pos - before.nodeSize, $pos.pos).scrollIntoView()); return true; } if (!$pos.parent.canReplace(index, index + 1) || !(after.isTextblock || canJoin(state.doc, $pos.pos))) return false; if (dispatch) dispatch(state.tr .clearIncompatible($pos.pos, before.type, before.contentMatchAt(before.childCount)) .join($pos.pos) .scrollIntoView()); return true; } function deleteBarrier(state, $cut, dispatch) { let before = $cut.nodeBefore, after = $cut.nodeAfter, conn, match; if (before.type.spec.isolating || after.type.spec.isolating) return false; if (joinMaybeClear(state, $cut, dispatch)) return true; let canDelAfter = $cut.parent.canReplace($cut.index(), $cut.index() + 1); if (canDelAfter && (conn = (match = before.contentMatchAt(before.childCount)).findWrapping(after.type)) && match.matchType(conn[0] || after.type).validEnd) { if (dispatch) { let end = $cut.pos + after.nodeSize, wrap = Fragment.empty; for (let i = conn.length - 1; i >= 0; i--) wrap = Fragment.from(conn[i].create(null, wrap)); wrap = Fragment.from(before.copy(wrap)); let tr = state.tr.step(new ReplaceAroundStep($cut.pos - 1, end, $cut.pos, end, new Slice(wrap, 1, 0), conn.length, true)); let joinAt = end + 2 * conn.length; if (canJoin(tr.doc, joinAt)) tr.join(joinAt); dispatch(tr.scrollIntoView()); } return true; } let selAfter = Selection.findFrom($cut, 1); let range = selAfter && selAfter.$from.blockRange(selAfter.$to), target = range && liftTarget(range); if (target != null && target >= $cut.depth) { if (dispatch) dispatch(state.tr.lift(range, target).scrollIntoView()); return true; } if (canDelAfter && textblockAt(after, "start", true) && textblockAt(before, "end")) { let at = before, wrap = []; for (;;) { wrap.push(at); if (at.isTextblock) break; at = at.lastChild; } let afterText = after, afterDepth = 1; for (; !afterText.isTextblock; afterText = afterText.firstChild) afterDepth++; if (at.canReplace(at.childCount, at.childCount, afterText.content)) { if (dispatch) { let end = Fragment.empty; for (let i = wrap.length - 1; i >= 0; i--) end = Fragment.from(wrap[i].copy(end)); let tr = state.tr.step(new ReplaceAroundStep($cut.pos - wrap.length, $cut.pos + after.nodeSize, $cut.pos + afterDepth, $cut.pos + after.nodeSize - afterDepth, new Slice(end, wrap.length, 0), 0, true)); dispatch(tr.scrollIntoView()); } return true; } } return false; } function selectTextblockSide(side) { return function (state, dispatch) { let sel = state.selection, $pos = side < 0 ? sel.$from : sel.$to; let depth = $pos.depth; while ($pos.node(depth).isInline) { if (!depth) return false; depth--; } if (!$pos.node(depth).isTextblock) return false; if (dispatch) dispatch(state.tr.setSelection(TextSelection.create(state.doc, side < 0 ? $pos.start(depth) : $pos.end(depth)))); return true; }; } /** Moves the cursor to the start of current text block. */ const selectTextblockStart = selectTextblockSide(-1); /** Moves the cursor to the end of current text block. */ const selectTextblockEnd = selectTextblockSide(1); // Parameterized commands /** Wrap the selection in a node of the given type with the given attributes. */ function wrapIn(nodeType, attrs = null) { return function (state, dispatch) { let { $from, $to } = state.selection; let range = $from.blockRange($to), wrapping = range && findWrapping(range, nodeType, attrs); if (!wrapping) return false; if (dispatch) dispatch(state.tr.wrap(range, wrapping).scrollIntoView()); return true; }; } /** Returns a command that tries to set the selected textblocks to the given node type with the given attributes. */ function setBlockType(nodeType, attrs = null) { return function (state, dispatch) { let applicable = false; for (let i = 0; i < state.selection.ranges.length && !applicable; i++) { let { $from: { pos: from }, $to: { pos: to } } = state.selection.ranges[i]; state.doc.nodesBetween(from, to, (node, pos) => { if (applicable) return false; if (!node.isTextblock || node.hasMarkup(nodeType, attrs)) return; if (node.type == nodeType) { applicable = true; } else { let $pos = state.doc.resolve(pos), index = $pos.index(); applicable = $pos.parent.canReplaceWith(index, index + 1, nodeType); } }); } if (!applicable) return false; if (dispatch) { let tr = state.tr; for (let i = 0; i < state.selection.ranges.length; i++) { let { $from: { pos: from }, $to: { pos: to } } = state.selection.ranges[i]; tr.setBlockType(from, to, nodeType, attrs); } dispatch(tr.scrollIntoView()); } return true; }; } function markApplies(doc, ranges, type) { for (let i = 0; i < ranges.length; i++) { let { $from, $to } = ranges[i]; let can = $from.depth == 0 ? doc.inlineContent && doc.type.allowsMarkType(type) : false; doc.nodesBetween($from.pos, $to.pos, node => { if (can) return false; can = node.inlineContent && node.type.allowsMarkType(type); }); if (can) return true; } return false; } /** Create a command function that toggles the given mark with the given attributes. Will return `false` when the current selection doesn't support that mark. This will remove the mark if any marks of that type exist in the selection, or add it otherwise. If the selection is empty, this applies to the [stored marks](https://prosemirror.net/docs/ref/#state.EditorState.storedMarks) instead of a range of the document. */ function toggleMark(markType, attrs = null) { return function (state, dispatch) { let { empty, $cursor, ranges } = state.selection; if ((empty && !$cursor) || !markApplies(state.doc, ranges, markType)) return false; if (dispatch) { if ($cursor) { if (markType.isInSet(state.storedMarks || $cursor.marks())) dispatch(state.tr.removeStoredMark(markType)); else dispatch(state.tr.addStoredMark(markType.create(attrs))); } else { let has = false, tr = state.tr; for (let i = 0; !has && i < ranges.length; i++) { let { $from, $to } = ranges[i]; has = state.doc.rangeHasMark($from.pos, $to.pos, markType); } for (let i = 0; i < ranges.length; i++) { let { $from, $to } = ranges[i]; if (has) { tr.removeMark($from.pos, $to.pos, markType); } else { let from = $from.pos, to = $to.pos, start = $from.nodeAfter, end = $to.nodeBefore; let spaceStart = start && start.isText ? /^\s*/.exec(start.text)[0].length : 0; let spaceEnd = end && end.isText ? /\s*$/.exec(end.text)[0].length : 0; if (from + spaceStart < to) { from += spaceStart; to -= spaceEnd; } tr.addMark(from, to, markType.create(attrs)); } } dispatch(tr.scrollIntoView()); } } return true; }; } function wrapDispatchForJoin(dispatch, isJoinable) { return (tr) => { if (!tr.isGeneric) return dispatch(tr); let ranges = []; for (let i = 0; i < tr.mapping.maps.length; i++) { let map = tr.mapping.maps[i]; for (let j = 0; j < ranges.length; j++) ranges[j] = map.map(ranges[j]); map.forEach((_s, _e, from, to) => ranges.push(from, to)); } // Figure out which joinable points exist inside those ranges, // by checking all node boundaries in their parent nodes. let joinable = []; for (let i = 0; i < ranges.length; i += 2) { let from = ranges[i], to = ranges[i + 1]; let $from = tr.doc.resolve(from), depth = $from.sharedDepth(to), parent = $from.node(depth); for (let index = $from.indexAfter(depth), pos = $from.after(depth + 1); pos <= to; ++index) { let after = parent.maybeChild(index); if (!after) break; if (index && joinable.indexOf(pos) == -1) { let before = parent.child(index - 1); if (before.type == after.type && isJoinable(before, after)) joinable.push(pos); } pos += after.nodeSize; } } // Join the joinable points joinable.sort((a, b) => a - b); for (let i = joinable.length - 1; i >= 0; i--) { if (canJoin(tr.doc, joinable[i])) tr.join(joinable[i]); } dispatch(tr); }; } /** Wrap a command so that, when it produces a transform that causes two joinable nodes to end up next to each other, those are joined. Nodes are considered joinable when they are of the same type and when the `isJoinable` predicate returns true for them or, if an array of strings was passed, if their node type name is in that array. */ function autoJoin(command, isJoinable) { let canJoin = Array.isArray(isJoinable) ? (node) => isJoinable.indexOf(node.type.name) > -1 : isJoinable; return (state, dispatch, view) => command(state, dispatch && wrapDispatchForJoin(dispatch, canJoin), view); } /** Combine a number of command functions into a single function (which calls them one by one until one returns true). */ function chainCommands(...commands) { return function (state, dispatch, view) { for (let i = 0; i < commands.length; i++) if (commands[i](state, dispatch, view)) return true; return false; }; } let backspace = chainCommands(deleteSelection, joinBackward, selectNodeBackward); let del = chainCommands(deleteSelection, joinForward, selectNodeForward); /** A basic keymap containing bindings not specific to any schema. Binds the following keys (when multiple commands are listed, they are chained with [`chainCommands`](https://prosemirror.net/docs/ref/#commands.chainCommands)): * **Enter** to `newlineInCode`, `createParagraphNear`, `liftEmptyBlock`, `splitBlock` * **Mod-Enter** to `exitCode` * **Backspace** and **Mod-Backspace** to `deleteSelection`, `joinBackward`, `selectNodeBackward` * **Delete** and **Mod-Delete** to `deleteSelection`, `joinForward`, `selectNodeForward` * **Mod-Delete** to `deleteSelection`, `joinForward`, `selectNodeForward` * **Mod-a** to `selectAll` */ const pcBaseKeymap = { "Enter": chainCommands(newlineInCode, createParagraphNear, liftEmptyBlock, splitBlock), "Mod-Enter": exitCode, "Backspace": backspace, "Mod-Backspace": backspace, "Shift-Backspace": backspace, "Delete": del, "Mod-Delete": del, "Mod-a": selectAll }; /** A copy of `pcBaseKeymap` that also binds **Ctrl-h** like Backspace, **Ctrl-d** like Delete, **Alt-Backspace** like Ctrl-Backspace, and **Ctrl-Alt-Backspace**, **Alt-Delete**, and **Alt-d** like Ctrl-Delete. */ const macBaseKeymap = { "Ctrl-h": pcBaseKeymap["Backspace"], "Alt-Backspace": pcBaseKeymap["Mod-Backspace"], "Ctrl-d": pcBaseKeymap["Delete"], "Ctrl-Alt-Backspace": pcBaseKeymap["Mod-Delete"], "Alt-Delete": pcBaseKeymap["Mod-Delete"], "Alt-d": pcBaseKeymap["Mod-Delete"], "Ctrl-a": selectTextblockStart, "Ctrl-e": selectTextblockEnd }; for (let key in pcBaseKeymap) macBaseKeymap[key] = pcBaseKeymap[key]; const mac = typeof navigator != "undefined" ? /Mac|iP(hone|[oa]d)/.test(navigator.platform) // @ts-ignore : typeof os != "undefined" && os.platform ? os.platform() == "darwin" : false; /** Depending on the detected platform, this will hold [`pcBasekeymap`](https://prosemirror.net/docs/ref/#commands.pcBaseKeymap) or [`macBaseKeymap`](https://prosemirror.net/docs/ref/#commands.macBaseKeymap). */ const baseKeymap = mac ? macBaseKeymap : pcBaseKeymap; var index$3 = /*#__PURE__*/Object.freeze({ __proto__: null, autoJoin: autoJoin, baseKeymap: baseKeymap, chainCommands: chainCommands, createParagraphNear: createParagraphNear, deleteSelection: deleteSelection, exitCode: exitCode, joinBackward: joinBackward, joinDown: joinDown, joinForward: joinForward, joinTextblockBackward: joinTextblockBackward, joinTextblockForward: joinTextblockForward, joinUp: joinUp, lift: lift, liftEmptyBlock: liftEmptyBlock, macBaseKeymap: macBaseKeymap, newlineInCode: newlineInCode, pcBaseKeymap: pcBaseKeymap, selectAll: selectAll, selectNodeBackward: selectNodeBackward, selectNodeForward: selectNodeForward, selectParentNode: selectParentNode, selectTextblockEnd: selectTextblockEnd, selectTextblockStart: selectTextblockStart, setBlockType: setBlockType, splitBlock: splitBlock, splitBlockAs: splitBlockAs, splitBlockKeepMarks: splitBlockKeepMarks, toggleMark: toggleMark, wrapIn: wrapIn }); /** Create a plugin that, when added to a ProseMirror instance, causes a decoration to show up at the drop position when something is dragged over the editor. Nodes may add a `disableDropCursor` property to their spec to control the showing of a drop cursor inside them. This may be a boolean or a function, which will be called with a view and a position, and should return a boolean. */ function dropCursor(options = {}) { return new Plugin({ view(editorView) { return new DropCursorView(editorView, options); } }); } class DropCursorView { constructor(editorView, options) { var _a; this.editorView = editorView; this.cursorPos = null; this.element = null; this.timeout = -1; this.width = (_a = options.width) !== null && _a !== void 0 ? _a : 1; this.color = options.color === false ? undefined : (options.color || "black"); this.class = options.class; this.handlers = ["dragover", "dragend", "drop", "dragleave"].map(name => { let handler = (e) => { this[name](e); }; editorView.dom.addEventListener(name, handler); return { name, handler }; }); } destroy() { this.handlers.forEach(({ name, handler }) => this.editorView.dom.removeEventListener(name, handler)); } update(editorView, prevState) { if (this.cursorPos != null && prevState.doc != editorView.state.doc) { if (this.cursorPos > editorView.state.doc.content.size) this.setCursor(null); else this.updateOverlay(); } } setCursor(pos) { if (pos == this.cursorPos) return; this.cursorPos = pos; if (pos == null) { this.element.parentNode.removeChild(this.element); this.element = null; } else { this.updateOverlay(); } } updateOverlay() { let $pos = this.editorView.state.doc.resolve(this.cursorPos); let isBlock = !$pos.parent.inlineContent, rect; if (isBlock) { let before = $pos.nodeBefore, after = $pos.nodeAfter; if (before || after) { let node = this.editorView.nodeDOM(this.cursorPos - (before ? before.nodeSize : 0)); if (node) { let nodeRect = node.getBoundingClientRect(); let top = before ? nodeRect.bottom : nodeRect.top; if (before && after) top = (top + this.editorView.nodeDOM(this.cursorPos).getBoundingClientRect().top) / 2; rect = { left: nodeRect.left, right: nodeRect.right, top: top - this.width / 2, bottom: top + this.width / 2 }; } } } if (!rect) { let coords = this.editorView.coordsAtPos(this.cursorPos); rect = { left: coords.left - this.width / 2, right: coords.left + this.width / 2, top: coords.top, bottom: coords.bottom }; } let parent = this.editorView.dom.offsetParent; if (!this.element) { this.element = parent.appendChild(document.createElement("div")); if (this.class) this.element.className = this.class; this.element.style.cssText = "position: absolute; z-index: 50; pointer-events: none;"; if (this.color) { this.element.style.backgroundColor = this.color; } } this.element.classList.toggle("prosemirror-dropcursor-block", isBlock); this.element.classList.toggle("prosemirror-dropcursor-inline", !isBlock); let parentLeft, parentTop; if (!parent || parent == document.body && getComputedStyle(parent).position == "static") { parentLeft = -pageXOffset; parentTop = -pageYOffset; } else { let rect = parent.getBoundingClientRect(); parentLeft = rect.left - parent.scrollLeft; parentTop = rect.top - parent.scrollTop; } this.element.style.left = (rect.left - parentLeft) + "px"; this.element.style.top = (rect.top - parentTop) + "px"; this.element.style.width = (rect.right - rect.left) + "px"; this.element.style.height = (rect.bottom - rect.top) + "px"; } scheduleRemoval(timeout) { clearTimeout(this.timeout); this.timeout = setTimeout(() => this.setCursor(null), timeout); } dragover(event) { if (!this.editorView.editable) return; let pos = this.editorView.posAtCoords({ left: event.clientX, top: event.clientY }); let node = pos && pos.inside >= 0 && this.editorView.state.doc.nodeAt(pos.inside); let disableDropCursor = node && node.type.spec.disableDropCursor; let disabled = typeof disableDropCursor == "function" ? disableDropCursor(this.editorView, pos, event) : disableDropCursor; if (pos && !disabled) { let target = pos.pos; if (this.editorView.dragging && this.editorView.dragging.slice) { let point = dropPoint(this.editorView.state.doc, target, this.editorView.dragging.slice); if (point != null) target = point; } this.setCursor(target); this.scheduleRemoval(5000); } } dragend() { this.scheduleRemoval(20); } drop() { this.scheduleRemoval(20); } dragleave(event) { if (event.target == this.editorView.dom || !this.editorView.dom.contains(event.relatedTarget)) this.setCursor(null); } } /** Gap cursor selections are represented using this class. Its `$anchor` and `$head` properties both point at the cursor position. */ class GapCursor extends Selection { /** Create a gap cursor. */ constructor($pos) { super($pos, $pos); } map(doc, mapping) { let $pos = doc.resolve(mapping.map(this.head)); return GapCursor.valid($pos) ? new GapCursor($pos) : Selection.near($pos); } content() { return Slice.empty; } eq(other) { return other instanceof GapCursor && other.head == this.head; } toJSON() { return { type: "gapcursor", pos: this.head }; } /** @internal */ static fromJSON(doc, json) { if (typeof json.pos != "number") throw new RangeError("Invalid input for GapCursor.fromJSON"); return new GapCursor(doc.resolve(json.pos)); } /** @internal */ getBookmark() { return new GapBookmark(this.anchor); } /** @internal */ static valid($pos) { let parent = $pos.parent; if (parent.isTextblock || !closedBefore($pos) || !closedAfter($pos)) return false; let override = parent.type.spec.allowGapCursor; if (override != null) return override; let deflt = parent.contentMatchAt($pos.index()).defaultType; return deflt && deflt.isTextblock; } /** @internal */ static findGapCursorFrom($pos, dir, mustMove = false) { search: for (;;) { if (!mustMove && GapCursor.valid($pos)) return $pos; let pos = $pos.pos, next = null; // Scan up from this position for (let d = $pos.depth;; d--) { let parent = $pos.node(d); if (dir > 0 ? $pos.indexAfter(d) < parent.childCount : $pos.index(d) > 0) { next = parent.child(dir > 0 ? $pos.indexAfter(d) : $pos.index(d) - 1); break; } else if (d == 0) { return null; } pos += dir; let $cur = $pos.doc.resolve(pos); if (GapCursor.valid($cur)) return $cur; } // And then down into the next node for (;;) { let inside = dir > 0 ? next.firstChild : next.lastChild; if (!inside) { if (next.isAtom && !next.isText && !NodeSelection.isSelectable(next)) { $pos = $pos.doc.resolve(pos + next.nodeSize * dir); mustMove = false; continue search; } break; } next = inside; pos += dir; let $cur = $pos.doc.resolve(pos); if (GapCursor.valid($cur)) return $cur; } return null; } } } GapCursor.prototype.visible = false; GapCursor.findFrom = GapCursor.findGapCursorFrom; Selection.jsonID("gapcursor", GapCursor); class GapBookmark { constructor(pos) { this.pos = pos; } map(mapping) { return new GapBookmark(mapping.map(this.pos)); } resolve(doc) { let $pos = doc.resolve(this.pos); return GapCursor.valid($pos) ? new GapCursor($pos) : Selection.near($pos); } } function closedBefore($pos) { for (let d = $pos.depth; d >= 0; d--) { let index = $pos.index(d), parent = $pos.node(d); // At the start of this parent, look at next one if (index == 0) { if (parent.type.spec.isolating) return true; continue; } // See if the node before (or its first ancestor) is closed for (let before = parent.child(index - 1);; before = before.lastChild) { if ((before.childCount == 0 && !before.inlineContent) || before.isAtom || before.type.spec.isolating) return true; if (before.inlineContent) return false; } } // Hit start of document return true; } function closedAfter($pos) { for (let d = $pos.depth; d >= 0; d--) { let index = $pos.indexAfter(d), parent = $pos.node(d); if (index == parent.childCount) { if (parent.type.spec.isolating) return true; continue; } for (let after = parent.child(index);; after = after.firstChild) { if ((after.childCount == 0 && !after.inlineContent) || after.isAtom || after.type.spec.isolating) return true; if (after.inlineContent) return false; } } return true; } /** Create a gap cursor plugin. When enabled, this will capture clicks near and arrow-key-motion past places that don't have a normally selectable position nearby, and create a gap cursor selection for them. The cursor is drawn as an element with class `ProseMirror-gapcursor`. You can either include `style/gapcursor.css` from the package's directory or add your own styles to make it visible. */ function gapCursor() { return new Plugin({ props: { decorations: drawGapCursor, createSelectionBetween(_view, $anchor, $head) { return $anchor.pos == $head.pos && GapCursor.valid($head) ? new GapCursor($head) : null; }, handleClick, handleKeyDown: handleKeyDown$1, handleDOMEvents: { beforeinput: beforeinput } } }); } const handleKeyDown$1 = keydownHandler({ "ArrowLeft": arrow$1("horiz", -1), "ArrowRight": arrow$1("horiz", 1), "ArrowUp": arrow$1("vert", -1), "ArrowDown": arrow$1("vert", 1) }); function arrow$1(axis, dir) { const dirStr = axis == "vert" ? (dir > 0 ? "down" : "up") : (dir > 0 ? "right" : "left"); return function (state, dispatch, view) { let sel = state.selection; let $start = dir > 0 ? sel.$to : sel.$from, mustMove = sel.empty; if (sel instanceof TextSelection) { if (!view.endOfTextblock(dirStr) || $start.depth == 0) return false; mustMove = false; $start = state.doc.resolve(dir > 0 ? $start.after() : $start.before()); } let $found = GapCursor.findGapCursorFrom($start, dir, mustMove); if (!$found) return false; if (dispatch) dispatch(state.tr.setSelection(new GapCursor($found))); return true; }; } function handleClick(view, pos, event) { if (!view || !view.editable) return false; let $pos = view.state.doc.resolve(pos); if (!GapCursor.valid($pos)) return false; let clickPos = view.posAtCoords({ left: event.clientX, top: event.clientY }); if (clickPos && clickPos.inside > -1 && NodeSelection.isSelectable(view.state.doc.nodeAt(clickPos.inside))) return false; view.dispatch(view.state.tr.setSelection(new GapCursor($pos))); return true; } // This is a hack that, when a composition starts while a gap cursor // is active, quickly creates an inline context for the composition to // happen in, to avoid it being aborted by the DOM selection being // moved into a valid position. function beforeinput(view, event) { if (event.inputType != "insertCompositionText" || !(view.state.selection instanceof GapCursor)) return false; let { $from } = view.state.selection; let insert = $from.parent.contentMatchAt($from.index()).findWrapping(view.state.schema.nodes.text); if (!insert) return false; let frag = Fragment.empty; for (let i = insert.length - 1; i >= 0; i--) frag = Fragment.from(insert[i].createAndFill(null, frag)); let tr = view.state.tr.replace($from.pos, $from.pos, new Slice(frag, 0, 0)); tr.setSelection(TextSelection.near(tr.doc.resolve($from.pos + 1))); view.dispatch(tr); return false; } function drawGapCursor(state) { if (!(state.selection instanceof GapCursor)) return null; let node = document.createElement("div"); node.className = "ProseMirror-gapcursor"; return DecorationSet.create(state.doc, [Decoration.widget(state.selection.head, node, { key: "gapcursor" })]); } var GOOD_LEAF_SIZE = 200; // :: class A rope sequence is a persistent sequence data structure // that supports appending, prepending, and slicing without doing a // full copy. It is represented as a mostly-balanced tree. var RopeSequence = function RopeSequence () {}; RopeSequence.prototype.append = function append (other) { if (!other.length) { return this } other = RopeSequence.from(other); return (!this.length && other) || (other.length < GOOD_LEAF_SIZE && this.leafAppend(other)) || (this.length < GOOD_LEAF_SIZE && other.leafPrepend(this)) || this.appendInner(other) }; // :: (union<[T], RopeSequence>) → RopeSequence // Prepend an array or other rope to this one, returning a new rope. RopeSequence.prototype.prepend = function prepend (other) { if (!other.length) { return this } return RopeSequence.from(other).append(this) }; RopeSequence.prototype.appendInner = function appendInner (other) { return new Append(this, other) }; // :: (?number, ?number) → RopeSequence // Create a rope repesenting a sub-sequence of this rope. RopeSequence.prototype.slice = function slice (from, to) { if ( from === void 0 ) from = 0; if ( to === void 0 ) to = this.length; if (from >= to) { return RopeSequence.empty } return this.sliceInner(Math.max(0, from), Math.min(this.length, to)) }; // :: (number) → T // Retrieve the element at the given position from this rope. RopeSequence.prototype.get = function get (i) { if (i < 0 || i >= this.length) { return undefined } return this.getInner(i) }; // :: ((element: T, index: number) → ?bool, ?number, ?number) // Call the given function for each element between the given // indices. This tends to be more efficient than looping over the // indices and calling `get`, because it doesn't have to descend the // tree for every element. RopeSequence.prototype.forEach = function forEach (f, from, to) { if ( from === void 0 ) from = 0; if ( to === void 0 ) to = this.length; if (from <= to) { this.forEachInner(f, from, to, 0); } else { this.forEachInvertedInner(f, from, to, 0); } }; // :: ((element: T, index: number) → U, ?number, ?number) → [U] // Map the given functions over the elements of the rope, producing // a flat array. RopeSequence.prototype.map = function map (f, from, to) { if ( from === void 0 ) from = 0; if ( to === void 0 ) to = this.length; var result = []; this.forEach(function (elt, i) { return result.push(f(elt, i)); }, from, to); return result }; // :: (?union<[T], RopeSequence>) → RopeSequence // Create a rope representing the given array, or return the rope // itself if a rope was given. RopeSequence.from = function from (values) { if (values instanceof RopeSequence) { return values } return values && values.length ? new Leaf(values) : RopeSequence.empty }; var Leaf = /*@__PURE__*/(function (RopeSequence) { function Leaf(values) { RopeSequence.call(this); this.values = values; } if ( RopeSequence ) Leaf.__proto__ = RopeSequence; Leaf.prototype = Object.create( RopeSequence && RopeSequence.prototype ); Leaf.prototype.constructor = Leaf; var prototypeAccessors = { length: { configurable: true },depth: { configurable: true } }; Leaf.prototype.flatten = function flatten () { return this.values }; Leaf.prototype.sliceInner = function sliceInner (from, to) { if (from == 0 && to == this.length) { return this } return new Leaf(this.values.slice(from, to)) }; Leaf.prototype.getInner = function getInner (i) { return this.values[i] }; Leaf.prototype.forEachInner = function forEachInner (f, from, to, start) { for (var i = from; i < to; i++) { if (f(this.values[i], start + i) === false) { return false } } }; Leaf.prototype.forEachInvertedInner = function forEachInvertedInner (f, from, to, start) { for (var i = from - 1; i >= to; i--) { if (f(this.values[i], start + i) === false) { return false } } }; Leaf.prototype.leafAppend = function leafAppend (other) { if (this.length + other.length <= GOOD_LEAF_SIZE) { return new Leaf(this.values.concat(other.flatten())) } }; Leaf.prototype.leafPrepend = function leafPrepend (other) { if (this.length + other.length <= GOOD_LEAF_SIZE) { return new Leaf(other.flatten().concat(this.values)) } }; prototypeAccessors.length.get = function () { return this.values.length }; prototypeAccessors.depth.get = function () { return 0 }; Object.defineProperties( Leaf.prototype, prototypeAccessors ); return Leaf; }(RopeSequence)); // :: RopeSequence // The empty rope sequence. RopeSequence.empty = new Leaf([]); var Append = /*@__PURE__*/(function (RopeSequence) { function Append(left, right) { RopeSequence.call(this); this.left = left; this.right = right; this.length = left.length + right.length; this.depth = Math.max(left.depth, right.depth) + 1; } if ( RopeSequence ) Append.__proto__ = RopeSequence; Append.prototype = Object.create( RopeSequence && RopeSequence.prototype ); Append.prototype.constructor = Append; Append.prototype.flatten = function flatten () { return this.left.flatten().concat(this.right.flatten()) }; Append.prototype.getInner = function getInner (i) { return i < this.left.length ? this.left.get(i) : this.right.get(i - this.left.length) }; Append.prototype.forEachInner = function forEachInner (f, from, to, start) { var leftLen = this.left.length; if (from < leftLen && this.left.forEachInner(f, from, Math.min(to, leftLen), start) === false) { return false } if (to > leftLen && this.right.forEachInner(f, Math.max(from - leftLen, 0), Math.min(this.length, to) - leftLen, start + leftLen) === false) { return false } }; Append.prototype.forEachInvertedInner = function forEachInvertedInner (f, from, to, start) { var leftLen = this.left.length; if (from > leftLen && this.right.forEachInvertedInner(f, from - leftLen, Math.max(to, leftLen) - leftLen, start + leftLen) === false) { return false } if (to < leftLen && this.left.forEachInvertedInner(f, Math.min(from, leftLen), to, start) === false) { return false } }; Append.prototype.sliceInner = function sliceInner (from, to) { if (from == 0 && to == this.length) { return this } var leftLen = this.left.length; if (to <= leftLen) { return this.left.slice(from, to) } if (from >= leftLen) { return this.right.slice(from - leftLen, to - leftLen) } return this.left.slice(from, leftLen).append(this.right.slice(0, to - leftLen)) }; Append.prototype.leafAppend = function leafAppend (other) { var inner = this.right.leafAppend(other); if (inner) { return new Append(this.left, inner) } }; Append.prototype.leafPrepend = function leafPrepend (other) { var inner = this.left.leafPrepend(other); if (inner) { return new Append(inner, this.right) } }; Append.prototype.appendInner = function appendInner (other) { if (this.left.depth >= Math.max(this.right.depth, other.depth) + 1) { return new Append(this.left, new Append(this.right, other)) } return new Append(this, other) }; return Append; }(RopeSequence)); // ProseMirror's history isn't simply a way to roll back to a previous // state, because ProseMirror supports applying changes without adding // them to the history (for example during collaboration). // // To this end, each 'Branch' (one for the undo history and one for // the redo history) keeps an array of 'Items', which can optionally // hold a step (an actual undoable change), and always hold a position // map (which is needed to move changes below them to apply to the // current document). // // An item that has both a step and a selection bookmark is the start // of an 'event' — a group of changes that will be undone or redone at // once. (It stores only the bookmark, since that way we don't have to // provide a document until the selection is actually applied, which // is useful when compressing.) // Used to schedule history compression const max_empty_items = 500; class Branch { constructor(items, eventCount) { this.items = items; this.eventCount = eventCount; } // Pop the latest event off the branch's history and apply it // to a document transform. popEvent(state, preserveItems) { if (this.eventCount == 0) return null; let end = this.items.length; for (;; end--) { let next = this.items.get(end - 1); if (next.selection) { --end; break; } } let remap, mapFrom; if (preserveItems) { remap = this.remapping(end, this.items.length); mapFrom = remap.maps.length; } let transform = state.tr; let selection, remaining; let addAfter = [], addBefore = []; this.items.forEach((item, i) => { if (!item.step) { if (!remap) { remap = this.remapping(end, i + 1); mapFrom = remap.maps.length; } mapFrom--; addBefore.push(item); return; } if (remap) { addBefore.push(new Item(item.map)); let step = item.step.map(remap.slice(mapFrom)), map; if (step && transform.maybeStep(step).doc) { map = transform.mapping.maps[transform.mapping.maps.length - 1]; addAfter.push(new Item(map, undefined, undefined, addAfter.length + addBefore.length)); } mapFrom--; if (map) remap.appendMap(map, mapFrom); } else { transform.maybeStep(item.step); } if (item.selection) { selection = remap ? item.selection.map(remap.slice(mapFrom)) : item.selection; remaining = new Branch(this.items.slice(0, end).append(addBefore.reverse().concat(addAfter)), this.eventCount - 1); return false; } }, this.items.length, 0); return { remaining: remaining, transform, selection: selection }; } // Create a new branch with the given transform added. addTransform(transform, selection, histOptions, preserveItems) { let newItems = [], eventCount = this.eventCount; let oldItems = this.items, lastItem = !preserveItems && oldItems.length ? oldItems.get(oldItems.length - 1) : null; for (let i = 0; i < transform.steps.length; i++) { let step = transform.steps[i].invert(transform.docs[i]); let item = new Item(transform.mapping.maps[i], step, selection), merged; if (merged = lastItem && lastItem.merge(item)) { item = merged; if (i) newItems.pop(); else oldItems = oldItems.slice(0, oldItems.length - 1); } newItems.push(item); if (selection) { eventCount++; selection = undefined; } if (!preserveItems) lastItem = item; } let overflow = eventCount - histOptions.depth; if (overflow > DEPTH_OVERFLOW) { oldItems = cutOffEvents(oldItems, overflow); eventCount -= overflow; } return new Branch(oldItems.append(newItems), eventCount); } remapping(from, to) { let maps = new Mapping; this.items.forEach((item, i) => { let mirrorPos = item.mirrorOffset != null && i - item.mirrorOffset >= from ? maps.maps.length - item.mirrorOffset : undefined; maps.appendMap(item.map, mirrorPos); }, from, to); return maps; } addMaps(array) { if (this.eventCount == 0) return this; return new Branch(this.items.append(array.map(map => new Item(map))), this.eventCount); } // When the collab module receives remote changes, the history has // to know about those, so that it can adjust the steps that were // rebased on top of the remote changes, and include the position // maps for the remote changes in its array of items. rebased(rebasedTransform, rebasedCount) { if (!this.eventCount) return this; let rebasedItems = [], start = Math.max(0, this.items.length - rebasedCount); let mapping = rebasedTransform.mapping; let newUntil = rebasedTransform.steps.length; let eventCount = this.eventCount; this.items.forEach(item => { if (item.selection) eventCount--; }, start); let iRebased = rebasedCount; this.items.forEach(item => { let pos = mapping.getMirror(--iRebased); if (pos == null) return; newUntil = Math.min(newUntil, pos); let map = mapping.maps[pos]; if (item.step) { let step = rebasedTransform.steps[pos].invert(rebasedTransform.docs[pos]); let selection = item.selection && item.selection.map(mapping.slice(iRebased + 1, pos)); if (selection) eventCount++; rebasedItems.push(new Item(map, step, selection)); } else { rebasedItems.push(new Item(map)); } }, start); let newMaps = []; for (let i = rebasedCount; i < newUntil; i++) newMaps.push(new Item(mapping.maps[i])); let items = this.items.slice(0, start).append(newMaps).append(rebasedItems); let branch = new Branch(items, eventCount); if (branch.emptyItemCount() > max_empty_items) branch = branch.compress(this.items.length - rebasedItems.length); return branch; } emptyItemCount() { let count = 0; this.items.forEach(item => { if (!item.step) count++; }); return count; } // Compressing a branch means rewriting it to push the air (map-only // items) out. During collaboration, these naturally accumulate // because each remote change adds one. The `upto` argument is used // to ensure that only the items below a given level are compressed, // because `rebased` relies on a clean, untouched set of items in // order to associate old items with rebased steps. compress(upto = this.items.length) { let remap = this.remapping(0, upto), mapFrom = remap.maps.length; let items = [], events = 0; this.items.forEach((item, i) => { if (i >= upto) { items.push(item); if (item.selection) events++; } else if (item.step) { let step = item.step.map(remap.slice(mapFrom)), map = step && step.getMap(); mapFrom--; if (map) remap.appendMap(map, mapFrom); if (step) { let selection = item.selection && item.selection.map(remap.slice(mapFrom)); if (selection) events++; let newItem = new Item(map.invert(), step, selection), merged, last = items.length - 1; if (merged = items.length && items[last].merge(newItem)) items[last] = merged; else items.push(newItem); } } else if (item.map) { mapFrom--; } }, this.items.length, 0); return new Branch(RopeSequence.from(items.reverse()), events); } } Branch.empty = new Branch(RopeSequence.empty, 0); function cutOffEvents(items, n) { let cutPoint; items.forEach((item, i) => { if (item.selection && (n-- == 0)) { cutPoint = i; return false; } }); return items.slice(cutPoint); } class Item { constructor( // The (forward) step map for this item. map, // The inverted step step, // If this is non-null, this item is the start of a group, and // this selection is the starting selection for the group (the one // that was active before the first step was applied) selection, // If this item is the inverse of a previous mapping on the stack, // this points at the inverse's offset mirrorOffset) { this.map = map; this.step = step; this.selection = selection; this.mirrorOffset = mirrorOffset; } merge(other) { if (this.step && other.step && !other.selection) { let step = other.step.merge(this.step); if (step) return new Item(step.getMap().invert(), step, this.selection); } } } // The value of the state field that tracks undo/redo history for that // state. Will be stored in the plugin state when the history plugin // is active. class HistoryState { constructor(done, undone, prevRanges, prevTime, prevComposition) { this.done = done; this.undone = undone; this.prevRanges = prevRanges; this.prevTime = prevTime; this.prevComposition = prevComposition; } } const DEPTH_OVERFLOW = 20; // Record a transformation in undo history. function applyTransaction(history, state, tr, options) { let historyTr = tr.getMeta(historyKey), rebased; if (historyTr) return historyTr.historyState; if (tr.getMeta(closeHistoryKey)) history = new HistoryState(history.done, history.undone, null, 0, -1); let appended = tr.getMeta("appendedTransaction"); if (tr.steps.length == 0) { return history; } else if (appended && appended.getMeta(historyKey)) { if (appended.getMeta(historyKey).redo) return new HistoryState(history.done.addTransform(tr, undefined, options, mustPreserveItems(state)), history.undone, rangesFor(tr.mapping.maps[tr.steps.length - 1]), history.prevTime, history.prevComposition); else return new HistoryState(history.done, history.undone.addTransform(tr, undefined, options, mustPreserveItems(state)), null, history.prevTime, history.prevComposition); } else if (tr.getMeta("addToHistory") !== false && !(appended && appended.getMeta("addToHistory") === false)) { // Group transforms that occur in quick succession into one event. let composition = tr.getMeta("composition"); let newGroup = history.prevTime == 0 || (!appended && history.prevComposition != composition && (history.prevTime < (tr.time || 0) - options.newGroupDelay || !isAdjacentTo(tr, history.prevRanges))); let prevRanges = appended ? mapRanges(history.prevRanges, tr.mapping) : rangesFor(tr.mapping.maps[tr.steps.length - 1]); return new HistoryState(history.done.addTransform(tr, newGroup ? state.selection.getBookmark() : undefined, options, mustPreserveItems(state)), Branch.empty, prevRanges, tr.time, composition == null ? history.prevComposition : composition); } else if (rebased = tr.getMeta("rebased")) { // Used by the collab module to tell the history that some of its // content has been rebased. return new HistoryState(history.done.rebased(tr, rebased), history.undone.rebased(tr, rebased), mapRanges(history.prevRanges, tr.mapping), history.prevTime, history.prevComposition); } else { return new HistoryState(history.done.addMaps(tr.mapping.maps), history.undone.addMaps(tr.mapping.maps), mapRanges(history.prevRanges, tr.mapping), history.prevTime, history.prevComposition); } } function isAdjacentTo(transform, prevRanges) { if (!prevRanges) return false; if (!transform.docChanged) return true; let adjacent = false; transform.mapping.maps[0].forEach((start, end) => { for (let i = 0; i < prevRanges.length; i += 2) if (start <= prevRanges[i + 1] && end >= prevRanges[i]) adjacent = true; }); return adjacent; } function rangesFor(map) { let result = []; map.forEach((_from, _to, from, to) => result.push(from, to)); return result; } function mapRanges(ranges, mapping) { if (!ranges) return null; let result = []; for (let i = 0; i < ranges.length; i += 2) { let from = mapping.map(ranges[i], 1), to = mapping.map(ranges[i + 1], -1); if (from <= to) result.push(from, to); } return result; } // Apply the latest event from one branch to the document and shift the event // onto the other branch. function histTransaction(history, state, redo) { let preserveItems = mustPreserveItems(state); let histOptions = historyKey.get(state).spec.config; let pop = (redo ? history.undone : history.done).popEvent(state, preserveItems); if (!pop) return null; let selection = pop.selection.resolve(pop.transform.doc); let added = (redo ? history.done : history.undone).addTransform(pop.transform, state.selection.getBookmark(), histOptions, preserveItems); let newHist = new HistoryState(redo ? added : pop.remaining, redo ? pop.remaining : added, null, 0, -1); return pop.transform.setSelection(selection).setMeta(historyKey, { redo, historyState: newHist }); } let cachedPreserveItems = false, cachedPreserveItemsPlugins = null; // Check whether any plugin in the given state has a // `historyPreserveItems` property in its spec, in which case we must // preserve steps exactly as they came in, so that they can be // rebased. function mustPreserveItems(state) { let plugins = state.plugins; if (cachedPreserveItemsPlugins != plugins) { cachedPreserveItems = false; cachedPreserveItemsPlugins = plugins; for (let i = 0; i < plugins.length; i++) if (plugins[i].spec.historyPreserveItems) { cachedPreserveItems = true; break; } } return cachedPreserveItems; } const historyKey = new PluginKey("history"); const closeHistoryKey = new PluginKey("closeHistory"); /** Returns a plugin that enables the undo history for an editor. The plugin will track undo and redo stacks, which can be used with the [`undo`](https://prosemirror.net/docs/ref/#history.undo) and [`redo`](https://prosemirror.net/docs/ref/#history.redo) commands. You can set an `"addToHistory"` [metadata property](https://prosemirror.net/docs/ref/#state.Transaction.setMeta) of `false` on a transaction to prevent it from being rolled back by undo. */ function history(config = {}) { config = { depth: config.depth || 100, newGroupDelay: config.newGroupDelay || 500 }; return new Plugin({ key: historyKey, state: { init() { return new HistoryState(Branch.empty, Branch.empty, null, 0, -1); }, apply(tr, hist, state) { return applyTransaction(hist, state, tr, config); } }, config, props: { handleDOMEvents: { beforeinput(view, e) { let inputType = e.inputType; let command = inputType == "historyUndo" ? undo : inputType == "historyRedo" ? redo : null; if (!command) return false; e.preventDefault(); return command(view.state, view.dispatch); } } } }); } function buildCommand(redo, scroll) { return (state, dispatch) => { let hist = historyKey.getState(state); if (!hist || (redo ? hist.undone : hist.done).eventCount == 0) return false; if (dispatch) { let tr = histTransaction(hist, state, redo); if (tr) dispatch(scroll ? tr.scrollIntoView() : tr); } return true; }; } /** A command function that undoes the last change, if any. */ const undo = buildCommand(false, true); /** A command function that redoes the last undone change, if any. */ const redo = buildCommand(true, true); const olDOM = ["ol", 0], ulDOM = ["ul", 0], liDOM = ["li", 0]; /** An ordered list [node spec](https://prosemirror.net/docs/ref/#model.NodeSpec). Has a single attribute, `order`, which determines the number at which the list starts counting, and defaults to 1. Represented as an `
      ` element. */ const orderedList = { attrs: { order: { default: 1 } }, parseDOM: [{ tag: "ol", getAttrs(dom) { return { order: dom.hasAttribute("start") ? +dom.getAttribute("start") : 1 }; } }], toDOM(node) { return node.attrs.order == 1 ? olDOM : ["ol", { start: node.attrs.order }, 0]; } }; /** A bullet list node spec, represented in the DOM as `
        `. */ const bulletList = { parseDOM: [{ tag: "ul" }], toDOM() { return ulDOM; } }; /** A list item (`
      • `) spec. */ const listItem = { parseDOM: [{ tag: "li" }], toDOM() { return liDOM; }, defining: true }; function add(obj, props) { let copy = {}; for (let prop in obj) copy[prop] = obj[prop]; for (let prop in props) copy[prop] = props[prop]; return copy; } /** Convenience function for adding list-related node types to a map specifying the nodes for a schema. Adds [`orderedList`](https://prosemirror.net/docs/ref/#schema-list.orderedList) as `"ordered_list"`, [`bulletList`](https://prosemirror.net/docs/ref/#schema-list.bulletList) as `"bullet_list"`, and [`listItem`](https://prosemirror.net/docs/ref/#schema-list.listItem) as `"list_item"`. `itemContent` determines the content expression for the list items. If you want the commands defined in this module to apply to your list structure, it should have a shape like `"paragraph block*"` or `"paragraph (ordered_list | bullet_list)*"`. `listGroup` can be given to assign a group name to the list node types, for example `"block"`. */ function addListNodes(nodes, itemContent, listGroup) { return nodes.append({ ordered_list: add(orderedList, { content: "list_item+", group: listGroup }), bullet_list: add(bulletList, { content: "list_item+", group: listGroup }), list_item: add(listItem, { content: itemContent }) }); } /** Returns a command function that wraps the selection in a list with the given type an attributes. If `dispatch` is null, only return a value to indicate whether this is possible, but don't actually perform the change. */ function wrapInList(listType, attrs = null) { return function (state, dispatch) { let { $from, $to } = state.selection; let range = $from.blockRange($to), doJoin = false, outerRange = range; if (!range) return false; // This is at the top of an existing list item if (range.depth >= 2 && $from.node(range.depth - 1).type.compatibleContent(listType) && range.startIndex == 0) { // Don't do anything if this is the top of the list if ($from.index(range.depth - 1) == 0) return false; let $insert = state.doc.resolve(range.start - 2); outerRange = new NodeRange($insert, $insert, range.depth); if (range.endIndex < range.parent.childCount) range = new NodeRange($from, state.doc.resolve($to.end(range.depth)), range.depth); doJoin = true; } let wrap = findWrapping(outerRange, listType, attrs, range); if (!wrap) return false; if (dispatch) dispatch(doWrapInList(state.tr, range, wrap, doJoin, listType).scrollIntoView()); return true; }; } function doWrapInList(tr, range, wrappers, joinBefore, listType) { let content = Fragment.empty; for (let i = wrappers.length - 1; i >= 0; i--) content = Fragment.from(wrappers[i].type.create(wrappers[i].attrs, content)); tr.step(new ReplaceAroundStep(range.start - (joinBefore ? 2 : 0), range.end, range.start, range.end, new Slice(content, 0, 0), wrappers.length, true)); let found = 0; for (let i = 0; i < wrappers.length; i++) if (wrappers[i].type == listType) found = i + 1; let splitDepth = wrappers.length - found; let splitPos = range.start + wrappers.length - (joinBefore ? 2 : 0), parent = range.parent; for (let i = range.startIndex, e = range.endIndex, first = true; i < e; i++, first = false) { if (!first && canSplit(tr.doc, splitPos, splitDepth)) { tr.split(splitPos, splitDepth); splitPos += 2 * splitDepth; } splitPos += parent.child(i).nodeSize; } return tr; } /** Build a command that splits a non-empty textblock at the top level of a list item by also splitting that list item. */ function splitListItem(itemType, itemAttrs) { return function (state, dispatch) { let { $from, $to, node } = state.selection; if ((node && node.isBlock) || $from.depth < 2 || !$from.sameParent($to)) return false; let grandParent = $from.node(-1); if (grandParent.type != itemType) return false; if ($from.parent.content.size == 0 && $from.node(-1).childCount == $from.indexAfter(-1)) { // In an empty block. If this is a nested list, the wrapping // list item should be split. Otherwise, bail out and let next // command handle lifting. if ($from.depth == 3 || $from.node(-3).type != itemType || $from.index(-2) != $from.node(-2).childCount - 1) return false; if (dispatch) { let wrap = Fragment.empty; let depthBefore = $from.index(-1) ? 1 : $from.index(-2) ? 2 : 3; // Build a fragment containing empty versions of the structure // from the outer list item to the parent node of the cursor for (let d = $from.depth - depthBefore; d >= $from.depth - 3; d--) wrap = Fragment.from($from.node(d).copy(wrap)); let depthAfter = $from.indexAfter(-1) < $from.node(-2).childCount ? 1 : $from.indexAfter(-2) < $from.node(-3).childCount ? 2 : 3; // Add a second list item with an empty default start node wrap = wrap.append(Fragment.from(itemType.createAndFill())); let start = $from.before($from.depth - (depthBefore - 1)); let tr = state.tr.replace(start, $from.after(-depthAfter), new Slice(wrap, 4 - depthBefore, 0)); let sel = -1; tr.doc.nodesBetween(start, tr.doc.content.size, (node, pos) => { if (sel > -1) return false; if (node.isTextblock && node.content.size == 0) sel = pos + 1; }); if (sel > -1) tr.setSelection(Selection.near(tr.doc.resolve(sel))); dispatch(tr.scrollIntoView()); } return true; } let nextType = $to.pos == $from.end() ? grandParent.contentMatchAt(0).defaultType : null; let tr = state.tr.delete($from.pos, $to.pos); let types = nextType ? [itemAttrs ? { type: itemType, attrs: itemAttrs } : null, { type: nextType }] : undefined; if (!canSplit(tr.doc, $from.pos, 2, types)) return false; if (dispatch) dispatch(tr.split($from.pos, 2, types).scrollIntoView()); return true; }; } /** Create a command to lift the list item around the selection up into a wrapping list. */ function liftListItem(itemType) { return function (state, dispatch) { let { $from, $to } = state.selection; let range = $from.blockRange($to, node => node.childCount > 0 && node.firstChild.type == itemType); if (!range) return false; if (!dispatch) return true; if ($from.node(range.depth - 1).type == itemType) // Inside a parent list return liftToOuterList(state, dispatch, itemType, range); else // Outer list node return liftOutOfList(state, dispatch, range); }; } function liftToOuterList(state, dispatch, itemType, range) { let tr = state.tr, end = range.end, endOfList = range.$to.end(range.depth); if (end < endOfList) { // There are siblings after the lifted items, which must become // children of the last item tr.step(new ReplaceAroundStep(end - 1, endOfList, end, endOfList, new Slice(Fragment.from(itemType.create(null, range.parent.copy())), 1, 0), 1, true)); range = new NodeRange(tr.doc.resolve(range.$from.pos), tr.doc.resolve(endOfList), range.depth); } const target = liftTarget(range); if (target == null) return false; tr.lift(range, target); let after = tr.mapping.map(end, -1) - 1; if (canJoin(tr.doc, after)) tr.join(after); dispatch(tr.scrollIntoView()); return true; } function liftOutOfList(state, dispatch, range) { let tr = state.tr, list = range.parent; // Merge the list items into a single big item for (let pos = range.end, i = range.endIndex - 1, e = range.startIndex; i > e; i--) { pos -= list.child(i).nodeSize; tr.delete(pos - 1, pos + 1); } let $start = tr.doc.resolve(range.start), item = $start.nodeAfter; if (tr.mapping.map(range.end) != range.start + $start.nodeAfter.nodeSize) return false; let atStart = range.startIndex == 0, atEnd = range.endIndex == list.childCount; let parent = $start.node(-1), indexBefore = $start.index(-1); if (!parent.canReplace(indexBefore + (atStart ? 0 : 1), indexBefore + 1, item.content.append(atEnd ? Fragment.empty : Fragment.from(list)))) return false; let start = $start.pos, end = start + item.nodeSize; // Strip off the surrounding list. At the sides where we're not at // the end of the list, the existing list is closed. At sides where // this is the end, it is overwritten to its end. tr.step(new ReplaceAroundStep(start - (atStart ? 1 : 0), end + (atEnd ? 1 : 0), start + 1, end - 1, new Slice((atStart ? Fragment.empty : Fragment.from(list.copy(Fragment.empty))) .append(atEnd ? Fragment.empty : Fragment.from(list.copy(Fragment.empty))), atStart ? 0 : 1, atEnd ? 0 : 1), atStart ? 0 : 1)); dispatch(tr.scrollIntoView()); return true; } /** Create a command to sink the list item around the selection down into an inner list. */ function sinkListItem(itemType) { return function (state, dispatch) { let { $from, $to } = state.selection; let range = $from.blockRange($to, node => node.childCount > 0 && node.firstChild.type == itemType); if (!range) return false; let startIndex = range.startIndex; if (startIndex == 0) return false; let parent = range.parent, nodeBefore = parent.child(startIndex - 1); if (nodeBefore.type != itemType) return false; if (dispatch) { let nestedBefore = nodeBefore.lastChild && nodeBefore.lastChild.type == parent.type; let inner = Fragment.from(nestedBefore ? itemType.create() : null); let slice = new Slice(Fragment.from(itemType.create(null, Fragment.from(parent.type.create(null, inner)))), nestedBefore ? 3 : 1, 0); let before = range.start, after = range.end; dispatch(state.tr.step(new ReplaceAroundStep(before - (nestedBefore ? 3 : 1), after, before, after, slice, 1, true)) .scrollIntoView()); } return true; }; } var index$2 = /*#__PURE__*/Object.freeze({ __proto__: null, addListNodes: addListNodes, bulletList: bulletList, liftListItem: liftListItem, listItem: listItem, orderedList: orderedList, sinkListItem: sinkListItem, splitListItem: splitListItem, wrapInList: wrapInList }); /** * A class responsible for building the keyboard commands for the ProseMirror editor. * @extends {ProseMirrorPlugin} */ 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} 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} 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} 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} 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} 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} mapping The keyboard mapping. */ #addSaveMapping(mapping) { mapping["Mod-s"] = () => { this.onSave(); return true; }; } } 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 ` `; } /* -------------------------------------------- */ /** * 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 `
          ${items.join('
        • ')}
        `; } /* -------------------------------------------- */ /** * 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 = [`
      • `]; parts.push(`${game.i18n.localize(item.title)}`); if ( item.active && !item.children?.length ) parts.push(''); if ( item.children?.length ) { parts.push('', this._renderMenu(item.children)); } parts.push("
      • "); return parts.join(""); } } // src/index.ts // src/tablemap.ts var readFromCache; var addToCache; if (typeof WeakMap != "undefined") { let cache = /* @__PURE__ */ new WeakMap(); readFromCache = (key) => cache.get(key); addToCache = (key, value) => { cache.set(key, value); return value; }; } else { const cache = []; const cacheSize = 10; let cachePos = 0; readFromCache = (key) => { for (let i = 0; i < cache.length; i += 2) if (cache[i] == key) return cache[i + 1]; }; addToCache = (key, value) => { if (cachePos == cacheSize) cachePos = 0; cache[cachePos++] = key; return cache[cachePos++] = value; }; } var TableMap = class { constructor(width, height, map, problems) { this.width = width; this.height = height; this.map = map; this.problems = problems; } // Find the dimensions of the cell at the given position. findCell(pos) { for (let i = 0; i < this.map.length; i++) { const curPos = this.map[i]; if (curPos != pos) continue; const left = i % this.width; const top = i / this.width | 0; let right = left + 1; let bottom = top + 1; for (let j = 1; right < this.width && this.map[i + j] == curPos; j++) { right++; } for (let j = 1; bottom < this.height && this.map[i + this.width * j] == curPos; j++) { bottom++; } return { left, top, right, bottom }; } throw new RangeError(`No cell with offset ${pos} found`); } // Find the left side of the cell at the given position. colCount(pos) { for (let i = 0; i < this.map.length; i++) { if (this.map[i] == pos) { return i % this.width; } } throw new RangeError(`No cell with offset ${pos} found`); } // Find the next cell in the given direction, starting from the cell // at `pos`, if any. nextCell(pos, axis, dir) { const { left, right, top, bottom } = this.findCell(pos); if (axis == "horiz") { if (dir < 0 ? left == 0 : right == this.width) return null; return this.map[top * this.width + (dir < 0 ? left - 1 : right)]; } else { if (dir < 0 ? top == 0 : bottom == this.height) return null; return this.map[left + this.width * (dir < 0 ? top - 1 : bottom)]; } } // Get the rectangle spanning the two given cells. rectBetween(a, b) { const { left: leftA, right: rightA, top: topA, bottom: bottomA } = this.findCell(a); const { left: leftB, right: rightB, top: topB, bottom: bottomB } = this.findCell(b); return { left: Math.min(leftA, leftB), top: Math.min(topA, topB), right: Math.max(rightA, rightB), bottom: Math.max(bottomA, bottomB) }; } // Return the position of all cells that have the top left corner in // the given rectangle. cellsInRect(rect) { const result = []; const seen = {}; for (let row = rect.top; row < rect.bottom; row++) { for (let col = rect.left; col < rect.right; col++) { const index = row * this.width + col; const pos = this.map[index]; if (seen[pos]) continue; seen[pos] = true; if (col == rect.left && col && this.map[index - 1] == pos || row == rect.top && row && this.map[index - this.width] == pos) { continue; } result.push(pos); } } return result; } // Return the position at which the cell at the given row and column // starts, or would start, if a cell started there. positionAt(row, col, table) { for (let i = 0, rowStart = 0; ; i++) { const rowEnd = rowStart + table.child(i).nodeSize; if (i == row) { let index = col + row * this.width; const rowEndIndex = (row + 1) * this.width; while (index < rowEndIndex && this.map[index] < rowStart) index++; return index == rowEndIndex ? rowEnd - 1 : this.map[index]; } rowStart = rowEnd; } } // Find the table map for the given table node. static get(table) { return readFromCache(table) || addToCache(table, computeMap(table)); } }; function computeMap(table) { if (table.type.spec.tableRole != "table") throw new RangeError("Not a table node: " + table.type.name); const width = findWidth(table), height = table.childCount; const map = []; let mapPos = 0; let problems = null; const colWidths = []; for (let i = 0, e = width * height; i < e; i++) map[i] = 0; for (let row = 0, pos = 0; row < height; row++) { const rowNode = table.child(row); pos++; for (let i = 0; ; i++) { while (mapPos < map.length && map[mapPos] != 0) mapPos++; if (i == rowNode.childCount) break; const cellNode = rowNode.child(i); const { colspan, rowspan, colwidth } = cellNode.attrs; for (let h = 0; h < rowspan; h++) { if (h + row >= height) { (problems || (problems = [])).push({ type: "overlong_rowspan", pos, n: rowspan - h }); break; } const start = mapPos + h * width; for (let w = 0; w < colspan; w++) { if (map[start + w] == 0) map[start + w] = pos; else (problems || (problems = [])).push({ type: "collision", row, pos, n: colspan - w }); const colW = colwidth && colwidth[w]; if (colW) { const widthIndex = (start + w) % width * 2, prev = colWidths[widthIndex]; if (prev == null || prev != colW && colWidths[widthIndex + 1] == 1) { colWidths[widthIndex] = colW; colWidths[widthIndex + 1] = 1; } else if (prev == colW) { colWidths[widthIndex + 1]++; } } } } mapPos += colspan; pos += cellNode.nodeSize; } const expectedPos = (row + 1) * width; let missing = 0; while (mapPos < expectedPos) if (map[mapPos++] == 0) missing++; if (missing) (problems || (problems = [])).push({ type: "missing", row, n: missing }); pos++; } const tableMap = new TableMap(width, height, map, problems); let badWidths = false; for (let i = 0; !badWidths && i < colWidths.length; i += 2) if (colWidths[i] != null && colWidths[i + 1] < height) badWidths = true; if (badWidths) findBadColWidths(tableMap, colWidths, table); return tableMap; } function findWidth(table) { let width = -1; let hasRowSpan = false; for (let row = 0; row < table.childCount; row++) { const rowNode = table.child(row); let rowWidth = 0; if (hasRowSpan) for (let j = 0; j < row; j++) { const prevRow = table.child(j); for (let i = 0; i < prevRow.childCount; i++) { const cell = prevRow.child(i); if (j + cell.attrs.rowspan > row) rowWidth += cell.attrs.colspan; } } for (let i = 0; i < rowNode.childCount; i++) { const cell = rowNode.child(i); rowWidth += cell.attrs.colspan; if (cell.attrs.rowspan > 1) hasRowSpan = true; } if (width == -1) width = rowWidth; else if (width != rowWidth) width = Math.max(width, rowWidth); } return width; } function findBadColWidths(map, colWidths, table) { if (!map.problems) map.problems = []; const seen = {}; for (let i = 0; i < map.map.length; i++) { const pos = map.map[i]; if (seen[pos]) continue; seen[pos] = true; const node = table.nodeAt(pos); if (!node) { throw new RangeError(`No cell with offset ${pos} found`); } let updated = null; const attrs = node.attrs; for (let j = 0; j < attrs.colspan; j++) { const col = (i + j) % map.width; const colWidth = colWidths[col * 2]; if (colWidth != null && (!attrs.colwidth || attrs.colwidth[j] != colWidth)) (updated || (updated = freshColWidth(attrs)))[j] = colWidth; } if (updated) map.problems.unshift({ type: "colwidth mismatch", pos, colwidth: updated }); } } function freshColWidth(attrs) { if (attrs.colwidth) return attrs.colwidth.slice(); const result = []; for (let i = 0; i < attrs.colspan; i++) result.push(0); return result; } // src/schema.ts function getCellAttrs(dom, extraAttrs) { if (typeof dom === "string") { return {}; } const widthAttr = dom.getAttribute("data-colwidth"); const widths = widthAttr && /^\d+(,\d+)*$/.test(widthAttr) ? widthAttr.split(",").map((s) => Number(s)) : null; const colspan = Number(dom.getAttribute("colspan") || 1); const result = { colspan, rowspan: Number(dom.getAttribute("rowspan") || 1), colwidth: widths && widths.length == colspan ? widths : null }; for (const prop in extraAttrs) { const getter = extraAttrs[prop].getFromDOM; const value = getter && getter(dom); if (value != null) { result[prop] = value; } } return result; } function setCellAttrs(node, extraAttrs) { const attrs = {}; if (node.attrs.colspan != 1) attrs.colspan = node.attrs.colspan; if (node.attrs.rowspan != 1) attrs.rowspan = node.attrs.rowspan; if (node.attrs.colwidth) attrs["data-colwidth"] = node.attrs.colwidth.join(","); for (const prop in extraAttrs) { const setter = extraAttrs[prop].setDOMAttr; if (setter) setter(node.attrs[prop], attrs); } return attrs; } function tableNodes(options) { const extraAttrs = options.cellAttributes || {}; const cellAttrs = { colspan: { default: 1 }, rowspan: { default: 1 }, colwidth: { default: null } }; for (const prop in extraAttrs) cellAttrs[prop] = { default: extraAttrs[prop].default }; return { table: { content: "table_row+", tableRole: "table", isolating: true, group: options.tableGroup, parseDOM: [{ tag: "table" }], toDOM() { return ["table", ["tbody", 0]]; } }, table_row: { content: "(table_cell | table_header)*", tableRole: "row", parseDOM: [{ tag: "tr" }], toDOM() { return ["tr", 0]; } }, table_cell: { content: options.cellContent, attrs: cellAttrs, tableRole: "cell", isolating: true, parseDOM: [ { tag: "td", getAttrs: (dom) => getCellAttrs(dom, extraAttrs) } ], toDOM(node) { return ["td", setCellAttrs(node, extraAttrs), 0]; } }, table_header: { content: options.cellContent, attrs: cellAttrs, tableRole: "header_cell", isolating: true, parseDOM: [ { tag: "th", getAttrs: (dom) => getCellAttrs(dom, extraAttrs) } ], toDOM(node) { return ["th", setCellAttrs(node, extraAttrs), 0]; } } }; } function tableNodeTypes(schema) { let result = schema.cached.tableNodeTypes; if (!result) { result = schema.cached.tableNodeTypes = {}; for (const name in schema.nodes) { const type = schema.nodes[name], role = type.spec.tableRole; if (role) result[role] = type; } } return result; } // src/util.ts var tableEditingKey = new PluginKey("selectingCells"); function cellAround($pos) { for (let d = $pos.depth - 1; d > 0; d--) if ($pos.node(d).type.spec.tableRole == "row") return $pos.node(0).resolve($pos.before(d + 1)); return null; } function cellWrapping($pos) { for (let d = $pos.depth; d > 0; d--) { const role = $pos.node(d).type.spec.tableRole; if (role === "cell" || role === "header_cell") return $pos.node(d); } return null; } function isInTable(state) { const $head = state.selection.$head; for (let d = $head.depth; d > 0; d--) if ($head.node(d).type.spec.tableRole == "row") return true; return false; } function selectionCell(state) { const sel = state.selection; if ("$anchorCell" in sel && sel.$anchorCell) { return sel.$anchorCell.pos > sel.$headCell.pos ? sel.$anchorCell : sel.$headCell; } else if ("node" in sel && sel.node && sel.node.type.spec.tableRole == "cell") { return sel.$anchor; } const $cell = cellAround(sel.$head) || cellNear(sel.$head); if ($cell) { return $cell; } throw new RangeError(`No cell found around position ${sel.head}`); } function cellNear($pos) { for (let after = $pos.nodeAfter, pos = $pos.pos; after; after = after.firstChild, pos++) { const role = after.type.spec.tableRole; if (role == "cell" || role == "header_cell") return $pos.doc.resolve(pos); } for (let before = $pos.nodeBefore, pos = $pos.pos; before; before = before.lastChild, pos--) { const role = before.type.spec.tableRole; if (role == "cell" || role == "header_cell") return $pos.doc.resolve(pos - before.nodeSize); } } function pointsAtCell($pos) { return $pos.parent.type.spec.tableRole == "row" && !!$pos.nodeAfter; } function moveCellForward($pos) { return $pos.node(0).resolve($pos.pos + $pos.nodeAfter.nodeSize); } function inSameTable($cellA, $cellB) { return $cellA.depth == $cellB.depth && $cellA.pos >= $cellB.start(-1) && $cellA.pos <= $cellB.end(-1); } function findCell($pos) { return TableMap.get($pos.node(-1)).findCell($pos.pos - $pos.start(-1)); } function colCount($pos) { return TableMap.get($pos.node(-1)).colCount($pos.pos - $pos.start(-1)); } function nextCell($pos, axis, dir) { const table = $pos.node(-1); const map = TableMap.get(table); const tableStart = $pos.start(-1); const moved = map.nextCell($pos.pos - tableStart, axis, dir); return moved == null ? null : $pos.node(0).resolve(tableStart + moved); } function removeColSpan(attrs, pos, n = 1) { const result = { ...attrs, colspan: attrs.colspan - n }; if (result.colwidth) { result.colwidth = result.colwidth.slice(); result.colwidth.splice(pos, n); if (!result.colwidth.some((w) => w > 0)) result.colwidth = null; } return result; } function addColSpan(attrs, pos, n = 1) { const result = { ...attrs, colspan: attrs.colspan + n }; if (result.colwidth) { result.colwidth = result.colwidth.slice(); for (let i = 0; i < n; i++) result.colwidth.splice(pos, 0, 0); } return result; } function columnIsHeader(map, table, col) { const headerCell = tableNodeTypes(table.type.schema).header_cell; for (let row = 0; row < map.height; row++) if (table.nodeAt(map.map[col + row * map.width]).type != headerCell) return false; return true; } // src/cellselection.ts var CellSelection = class _CellSelection extends Selection { // A table selection is identified by its anchor and head cells. The // positions given to this constructor should point _before_ two // cells in the same table. They may be the same, to select a single // cell. constructor($anchorCell, $headCell = $anchorCell) { const table = $anchorCell.node(-1); const map = TableMap.get(table); const tableStart = $anchorCell.start(-1); const rect = map.rectBetween( $anchorCell.pos - tableStart, $headCell.pos - tableStart ); const doc = $anchorCell.node(0); const cells = map.cellsInRect(rect).filter((p) => p != $headCell.pos - tableStart); cells.unshift($headCell.pos - tableStart); const ranges = cells.map((pos) => { const cell = table.nodeAt(pos); if (!cell) { throw RangeError(`No cell with offset ${pos} found`); } const from = tableStart + pos + 1; return new SelectionRange( doc.resolve(from), doc.resolve(from + cell.content.size) ); }); super(ranges[0].$from, ranges[0].$to, ranges); this.$anchorCell = $anchorCell; this.$headCell = $headCell; } map(doc, mapping) { const $anchorCell = doc.resolve(mapping.map(this.$anchorCell.pos)); const $headCell = doc.resolve(mapping.map(this.$headCell.pos)); if (pointsAtCell($anchorCell) && pointsAtCell($headCell) && inSameTable($anchorCell, $headCell)) { const tableChanged = this.$anchorCell.node(-1) != $anchorCell.node(-1); if (tableChanged && this.isRowSelection()) return _CellSelection.rowSelection($anchorCell, $headCell); else if (tableChanged && this.isColSelection()) return _CellSelection.colSelection($anchorCell, $headCell); else return new _CellSelection($anchorCell, $headCell); } return TextSelection.between($anchorCell, $headCell); } // Returns a rectangular slice of table rows containing the selected // cells. content() { const table = this.$anchorCell.node(-1); const map = TableMap.get(table); const tableStart = this.$anchorCell.start(-1); const rect = map.rectBetween( this.$anchorCell.pos - tableStart, this.$headCell.pos - tableStart ); const seen = {}; const rows = []; for (let row = rect.top; row < rect.bottom; row++) { const rowContent = []; for (let index = row * map.width + rect.left, col = rect.left; col < rect.right; col++, index++) { const pos = map.map[index]; if (seen[pos]) continue; seen[pos] = true; const cellRect = map.findCell(pos); let cell = table.nodeAt(pos); if (!cell) { throw RangeError(`No cell with offset ${pos} found`); } const extraLeft = rect.left - cellRect.left; const extraRight = cellRect.right - rect.right; if (extraLeft > 0 || extraRight > 0) { let attrs = cell.attrs; if (extraLeft > 0) { attrs = removeColSpan(attrs, 0, extraLeft); } if (extraRight > 0) { attrs = removeColSpan( attrs, attrs.colspan - extraRight, extraRight ); } if (cellRect.left < rect.left) { cell = cell.type.createAndFill(attrs); if (!cell) { throw RangeError( `Could not create cell with attrs ${JSON.stringify(attrs)}` ); } } else { cell = cell.type.create(attrs, cell.content); } } if (cellRect.top < rect.top || cellRect.bottom > rect.bottom) { const attrs = { ...cell.attrs, rowspan: Math.min(cellRect.bottom, rect.bottom) - Math.max(cellRect.top, rect.top) }; if (cellRect.top < rect.top) { cell = cell.type.createAndFill(attrs); } else { cell = cell.type.create(attrs, cell.content); } } rowContent.push(cell); } rows.push(table.child(row).copy(Fragment.from(rowContent))); } const fragment = this.isColSelection() && this.isRowSelection() ? table : rows; return new Slice(Fragment.from(fragment), 1, 1); } replace(tr, content = Slice.empty) { const mapFrom = tr.steps.length, ranges = this.ranges; for (let i = 0; i < ranges.length; i++) { const { $from, $to } = ranges[i], mapping = tr.mapping.slice(mapFrom); tr.replace( mapping.map($from.pos), mapping.map($to.pos), i ? Slice.empty : content ); } const sel = Selection.findFrom( tr.doc.resolve(tr.mapping.slice(mapFrom).map(this.to)), -1 ); if (sel) tr.setSelection(sel); } replaceWith(tr, node) { this.replace(tr, new Slice(Fragment.from(node), 0, 0)); } forEachCell(f) { const table = this.$anchorCell.node(-1); const map = TableMap.get(table); const tableStart = this.$anchorCell.start(-1); const cells = map.cellsInRect( map.rectBetween( this.$anchorCell.pos - tableStart, this.$headCell.pos - tableStart ) ); for (let i = 0; i < cells.length; i++) { f(table.nodeAt(cells[i]), tableStart + cells[i]); } } // True if this selection goes all the way from the top to the // bottom of the table. isColSelection() { const anchorTop = this.$anchorCell.index(-1); const headTop = this.$headCell.index(-1); if (Math.min(anchorTop, headTop) > 0) return false; const anchorBottom = anchorTop + this.$anchorCell.nodeAfter.attrs.rowspan; const headBottom = headTop + this.$headCell.nodeAfter.attrs.rowspan; return Math.max(anchorBottom, headBottom) == this.$headCell.node(-1).childCount; } // Returns the smallest column selection that covers the given anchor // and head cell. static colSelection($anchorCell, $headCell = $anchorCell) { const table = $anchorCell.node(-1); const map = TableMap.get(table); const tableStart = $anchorCell.start(-1); const anchorRect = map.findCell($anchorCell.pos - tableStart); const headRect = map.findCell($headCell.pos - tableStart); const doc = $anchorCell.node(0); if (anchorRect.top <= headRect.top) { if (anchorRect.top > 0) $anchorCell = doc.resolve(tableStart + map.map[anchorRect.left]); if (headRect.bottom < map.height) $headCell = doc.resolve( tableStart + map.map[map.width * (map.height - 1) + headRect.right - 1] ); } else { if (headRect.top > 0) $headCell = doc.resolve(tableStart + map.map[headRect.left]); if (anchorRect.bottom < map.height) $anchorCell = doc.resolve( tableStart + map.map[map.width * (map.height - 1) + anchorRect.right - 1] ); } return new _CellSelection($anchorCell, $headCell); } // True if this selection goes all the way from the left to the // right of the table. isRowSelection() { const table = this.$anchorCell.node(-1); const map = TableMap.get(table); const tableStart = this.$anchorCell.start(-1); const anchorLeft = map.colCount(this.$anchorCell.pos - tableStart); const headLeft = map.colCount(this.$headCell.pos - tableStart); if (Math.min(anchorLeft, headLeft) > 0) return false; const anchorRight = anchorLeft + this.$anchorCell.nodeAfter.attrs.colspan; const headRight = headLeft + this.$headCell.nodeAfter.attrs.colspan; return Math.max(anchorRight, headRight) == map.width; } eq(other) { return other instanceof _CellSelection && other.$anchorCell.pos == this.$anchorCell.pos && other.$headCell.pos == this.$headCell.pos; } // Returns the smallest row selection that covers the given anchor // and head cell. static rowSelection($anchorCell, $headCell = $anchorCell) { const table = $anchorCell.node(-1); const map = TableMap.get(table); const tableStart = $anchorCell.start(-1); const anchorRect = map.findCell($anchorCell.pos - tableStart); const headRect = map.findCell($headCell.pos - tableStart); const doc = $anchorCell.node(0); if (anchorRect.left <= headRect.left) { if (anchorRect.left > 0) $anchorCell = doc.resolve( tableStart + map.map[anchorRect.top * map.width] ); if (headRect.right < map.width) $headCell = doc.resolve( tableStart + map.map[map.width * (headRect.top + 1) - 1] ); } else { if (headRect.left > 0) $headCell = doc.resolve(tableStart + map.map[headRect.top * map.width]); if (anchorRect.right < map.width) $anchorCell = doc.resolve( tableStart + map.map[map.width * (anchorRect.top + 1) - 1] ); } return new _CellSelection($anchorCell, $headCell); } toJSON() { return { type: "cell", anchor: this.$anchorCell.pos, head: this.$headCell.pos }; } static fromJSON(doc, json) { return new _CellSelection(doc.resolve(json.anchor), doc.resolve(json.head)); } static create(doc, anchorCell, headCell = anchorCell) { return new _CellSelection(doc.resolve(anchorCell), doc.resolve(headCell)); } getBookmark() { return new CellBookmark(this.$anchorCell.pos, this.$headCell.pos); } }; CellSelection.prototype.visible = false; Selection.jsonID("cell", CellSelection); var CellBookmark = class _CellBookmark { constructor(anchor, head) { this.anchor = anchor; this.head = head; } map(mapping) { return new _CellBookmark(mapping.map(this.anchor), mapping.map(this.head)); } resolve(doc) { const $anchorCell = doc.resolve(this.anchor), $headCell = doc.resolve(this.head); if ($anchorCell.parent.type.spec.tableRole == "row" && $headCell.parent.type.spec.tableRole == "row" && $anchorCell.index() < $anchorCell.parent.childCount && $headCell.index() < $headCell.parent.childCount && inSameTable($anchorCell, $headCell)) return new CellSelection($anchorCell, $headCell); else return Selection.near($headCell, 1); } }; function drawCellSelection(state) { if (!(state.selection instanceof CellSelection)) return null; const cells = []; state.selection.forEachCell((node, pos) => { cells.push( Decoration.node(pos, pos + node.nodeSize, { class: "selectedCell" }) ); }); return DecorationSet.create(state.doc, cells); } function isCellBoundarySelection({ $from, $to }) { if ($from.pos == $to.pos || $from.pos < $from.pos - 6) return false; let afterFrom = $from.pos; let beforeTo = $to.pos; let depth = $from.depth; for (; depth >= 0; depth--, afterFrom++) if ($from.after(depth + 1) < $from.end(depth)) break; for (let d = $to.depth; d >= 0; d--, beforeTo--) if ($to.before(d + 1) > $to.start(d)) break; return afterFrom == beforeTo && /row|table/.test($from.node(depth).type.spec.tableRole); } function isTextSelectionAcrossCells({ $from, $to }) { let fromCellBoundaryNode; let toCellBoundaryNode; for (let i = $from.depth; i > 0; i--) { const node = $from.node(i); if (node.type.spec.tableRole === "cell" || node.type.spec.tableRole === "header_cell") { fromCellBoundaryNode = node; break; } } for (let i = $to.depth; i > 0; i--) { const node = $to.node(i); if (node.type.spec.tableRole === "cell" || node.type.spec.tableRole === "header_cell") { toCellBoundaryNode = node; break; } } return fromCellBoundaryNode !== toCellBoundaryNode && $to.parentOffset === 0; } function normalizeSelection(state, tr, allowTableNodeSelection) { const sel = (tr || state).selection; const doc = (tr || state).doc; let normalize; let role; if (sel instanceof NodeSelection && (role = sel.node.type.spec.tableRole)) { if (role == "cell" || role == "header_cell") { normalize = CellSelection.create(doc, sel.from); } else if (role == "row") { const $cell = doc.resolve(sel.from + 1); normalize = CellSelection.rowSelection($cell, $cell); } else if (!allowTableNodeSelection) { const map = TableMap.get(sel.node); const start = sel.from + 1; const lastCell = start + map.map[map.width * map.height - 1]; normalize = CellSelection.create(doc, start + 1, lastCell); } } else if (sel instanceof TextSelection && isCellBoundarySelection(sel)) { normalize = TextSelection.create(doc, sel.from); } else if (sel instanceof TextSelection && isTextSelectionAcrossCells(sel)) { normalize = TextSelection.create(doc, sel.$from.start(), sel.$from.end()); } if (normalize) (tr || (tr = state.tr)).setSelection(normalize); return tr; } var fixTablesKey = new PluginKey("fix-tables"); function changedDescendants(old, cur, offset, f) { const oldSize = old.childCount, curSize = cur.childCount; outer: for (let i = 0, j = 0; i < curSize; i++) { const child = cur.child(i); for (let scan = j, e = Math.min(oldSize, i + 3); scan < e; scan++) { if (old.child(scan) == child) { j = scan + 1; offset += child.nodeSize; continue outer; } } f(child, offset); if (j < oldSize && old.child(j).sameMarkup(child)) changedDescendants(old.child(j), child, offset + 1, f); else child.nodesBetween(0, child.content.size, f, offset + 1); offset += child.nodeSize; } } function fixTables(state, oldState) { let tr; const check = (node, pos) => { if (node.type.spec.tableRole == "table") tr = fixTable(state, node, pos, tr); }; if (!oldState) state.doc.descendants(check); else if (oldState.doc != state.doc) changedDescendants(oldState.doc, state.doc, 0, check); return tr; } function fixTable(state, table, tablePos, tr) { const map = TableMap.get(table); if (!map.problems) return tr; if (!tr) tr = state.tr; const mustAdd = []; for (let i = 0; i < map.height; i++) mustAdd.push(0); for (let i = 0; i < map.problems.length; i++) { const prob = map.problems[i]; if (prob.type == "collision") { const cell = table.nodeAt(prob.pos); if (!cell) continue; const attrs = cell.attrs; for (let j = 0; j < attrs.rowspan; j++) mustAdd[prob.row + j] += prob.n; tr.setNodeMarkup( tr.mapping.map(tablePos + 1 + prob.pos), null, removeColSpan(attrs, attrs.colspan - prob.n, prob.n) ); } else if (prob.type == "missing") { mustAdd[prob.row] += prob.n; } else if (prob.type == "overlong_rowspan") { const cell = table.nodeAt(prob.pos); if (!cell) continue; tr.setNodeMarkup(tr.mapping.map(tablePos + 1 + prob.pos), null, { ...cell.attrs, rowspan: cell.attrs.rowspan - prob.n }); } else if (prob.type == "colwidth mismatch") { const cell = table.nodeAt(prob.pos); if (!cell) continue; tr.setNodeMarkup(tr.mapping.map(tablePos + 1 + prob.pos), null, { ...cell.attrs, colwidth: prob.colwidth }); } } let first, last; for (let i = 0; i < mustAdd.length; i++) if (mustAdd[i]) { if (first == null) first = i; last = i; } for (let i = 0, pos = tablePos + 1; i < map.height; i++) { const row = table.child(i); const end = pos + row.nodeSize; const add = mustAdd[i]; if (add > 0) { let role = "cell"; if (row.firstChild) { role = row.firstChild.type.spec.tableRole; } const nodes = []; for (let j = 0; j < add; j++) { const node = tableNodeTypes(state.schema)[role].createAndFill(); if (node) nodes.push(node); } const side = (i == 0 || first == i - 1) && last == i ? pos + 1 : end - 1; tr.insert(tr.mapping.map(side), nodes); } pos = end; } return tr.setMeta(fixTablesKey, { fixTables: true }); } function pastedCells(slice) { if (!slice.size) return null; let { content, openStart, openEnd } = slice; while (content.childCount == 1 && (openStart > 0 && openEnd > 0 || content.child(0).type.spec.tableRole == "table")) { openStart--; openEnd--; content = content.child(0).content; } const first = content.child(0); const role = first.type.spec.tableRole; const schema = first.type.schema, rows = []; if (role == "row") { for (let i = 0; i < content.childCount; i++) { let cells = content.child(i).content; const left = i ? 0 : Math.max(0, openStart - 1); const right = i < content.childCount - 1 ? 0 : Math.max(0, openEnd - 1); if (left || right) cells = fitSlice( tableNodeTypes(schema).row, new Slice(cells, left, right) ).content; rows.push(cells); } } else if (role == "cell" || role == "header_cell") { rows.push( openStart || openEnd ? fitSlice( tableNodeTypes(schema).row, new Slice(content, openStart, openEnd) ).content : content ); } else { return null; } return ensureRectangular(schema, rows); } function ensureRectangular(schema, rows) { const widths = []; for (let i = 0; i < rows.length; i++) { const row = rows[i]; for (let j = row.childCount - 1; j >= 0; j--) { const { rowspan, colspan } = row.child(j).attrs; for (let r = i; r < i + rowspan; r++) widths[r] = (widths[r] || 0) + colspan; } } let width = 0; for (let r = 0; r < widths.length; r++) width = Math.max(width, widths[r]); for (let r = 0; r < widths.length; r++) { if (r >= rows.length) rows.push(Fragment.empty); if (widths[r] < width) { const empty = tableNodeTypes(schema).cell.createAndFill(); const cells = []; for (let i = widths[r]; i < width; i++) { cells.push(empty); } rows[r] = rows[r].append(Fragment.from(cells)); } } return { height: rows.length, width, rows }; } function fitSlice(nodeType, slice) { const node = nodeType.createAndFill(); const tr = new Transform(node).replace(0, node.content.size, slice); return tr.doc; } function clipCells({ width, height, rows }, newWidth, newHeight) { if (width != newWidth) { const added = []; const newRows = []; for (let row = 0; row < rows.length; row++) { const frag = rows[row], cells = []; for (let col = added[row] || 0, i = 0; col < newWidth; i++) { let cell = frag.child(i % frag.childCount); if (col + cell.attrs.colspan > newWidth) cell = cell.type.createChecked( removeColSpan( cell.attrs, cell.attrs.colspan, col + cell.attrs.colspan - newWidth ), cell.content ); cells.push(cell); col += cell.attrs.colspan; for (let j = 1; j < cell.attrs.rowspan; j++) added[row + j] = (added[row + j] || 0) + cell.attrs.colspan; } newRows.push(Fragment.from(cells)); } rows = newRows; width = newWidth; } if (height != newHeight) { const newRows = []; for (let row = 0, i = 0; row < newHeight; row++, i++) { const cells = [], source = rows[i % height]; for (let j = 0; j < source.childCount; j++) { let cell = source.child(j); if (row + cell.attrs.rowspan > newHeight) cell = cell.type.create( { ...cell.attrs, rowspan: Math.max(1, newHeight - cell.attrs.rowspan) }, cell.content ); cells.push(cell); } newRows.push(Fragment.from(cells)); } rows = newRows; height = newHeight; } return { width, height, rows }; } function growTable(tr, map, table, start, width, height, mapFrom) { const schema = tr.doc.type.schema; const types = tableNodeTypes(schema); let empty; let emptyHead; if (width > map.width) { for (let row = 0, rowEnd = 0; row < map.height; row++) { const rowNode = table.child(row); rowEnd += rowNode.nodeSize; const cells = []; let add; if (rowNode.lastChild == null || rowNode.lastChild.type == types.cell) add = empty || (empty = types.cell.createAndFill()); else add = emptyHead || (emptyHead = types.header_cell.createAndFill()); for (let i = map.width; i < width; i++) cells.push(add); tr.insert(tr.mapping.slice(mapFrom).map(rowEnd - 1 + start), cells); } } if (height > map.height) { const cells = []; for (let i = 0, start2 = (map.height - 1) * map.width; i < Math.max(map.width, width); i++) { const header = i >= map.width ? false : table.nodeAt(map.map[start2 + i]).type == types.header_cell; cells.push( header ? emptyHead || (emptyHead = types.header_cell.createAndFill()) : empty || (empty = types.cell.createAndFill()) ); } const emptyRow = types.row.create(null, Fragment.from(cells)), rows = []; for (let i = map.height; i < height; i++) rows.push(emptyRow); tr.insert(tr.mapping.slice(mapFrom).map(start + table.nodeSize - 2), rows); } return !!(empty || emptyHead); } function isolateHorizontal(tr, map, table, start, left, right, top, mapFrom) { if (top == 0 || top == map.height) return false; let found = false; for (let col = left; col < right; col++) { const index = top * map.width + col, pos = map.map[index]; if (map.map[index - map.width] == pos) { found = true; const cell = table.nodeAt(pos); const { top: cellTop, left: cellLeft } = map.findCell(pos); tr.setNodeMarkup(tr.mapping.slice(mapFrom).map(pos + start), null, { ...cell.attrs, rowspan: top - cellTop }); tr.insert( tr.mapping.slice(mapFrom).map(map.positionAt(top, cellLeft, table)), cell.type.createAndFill({ ...cell.attrs, rowspan: cellTop + cell.attrs.rowspan - top }) ); col += cell.attrs.colspan - 1; } } return found; } function isolateVertical(tr, map, table, start, top, bottom, left, mapFrom) { if (left == 0 || left == map.width) return false; let found = false; for (let row = top; row < bottom; row++) { const index = row * map.width + left, pos = map.map[index]; if (map.map[index - 1] == pos) { found = true; const cell = table.nodeAt(pos); const cellLeft = map.colCount(pos); const updatePos = tr.mapping.slice(mapFrom).map(pos + start); tr.setNodeMarkup( updatePos, null, removeColSpan( cell.attrs, left - cellLeft, cell.attrs.colspan - (left - cellLeft) ) ); tr.insert( updatePos + cell.nodeSize, cell.type.createAndFill( removeColSpan(cell.attrs, 0, left - cellLeft) ) ); row += cell.attrs.rowspan - 1; } } return found; } function insertCells(state, dispatch, tableStart, rect, cells) { let table = tableStart ? state.doc.nodeAt(tableStart - 1) : state.doc; if (!table) { throw new Error("No table found"); } let map = TableMap.get(table); const { top, left } = rect; const right = left + cells.width, bottom = top + cells.height; const tr = state.tr; let mapFrom = 0; function recomp() { table = tableStart ? tr.doc.nodeAt(tableStart - 1) : tr.doc; if (!table) { throw new Error("No table found"); } map = TableMap.get(table); mapFrom = tr.mapping.maps.length; } if (growTable(tr, map, table, tableStart, right, bottom, mapFrom)) recomp(); if (isolateHorizontal(tr, map, table, tableStart, left, right, top, mapFrom)) recomp(); if (isolateHorizontal(tr, map, table, tableStart, left, right, bottom, mapFrom)) recomp(); if (isolateVertical(tr, map, table, tableStart, top, bottom, left, mapFrom)) recomp(); if (isolateVertical(tr, map, table, tableStart, top, bottom, right, mapFrom)) recomp(); for (let row = top; row < bottom; row++) { const from = map.positionAt(row, left, table), to = map.positionAt(row, right, table); tr.replace( tr.mapping.slice(mapFrom).map(from + tableStart), tr.mapping.slice(mapFrom).map(to + tableStart), new Slice(cells.rows[row - top], 0, 0) ); } recomp(); tr.setSelection( new CellSelection( tr.doc.resolve(tableStart + map.positionAt(top, left, table)), tr.doc.resolve(tableStart + map.positionAt(bottom - 1, right - 1, table)) ) ); dispatch(tr); } // src/input.ts var handleKeyDown = keydownHandler({ ArrowLeft: arrow("horiz", -1), ArrowRight: arrow("horiz", 1), ArrowUp: arrow("vert", -1), ArrowDown: arrow("vert", 1), "Shift-ArrowLeft": shiftArrow("horiz", -1), "Shift-ArrowRight": shiftArrow("horiz", 1), "Shift-ArrowUp": shiftArrow("vert", -1), "Shift-ArrowDown": shiftArrow("vert", 1), Backspace: deleteCellSelection, "Mod-Backspace": deleteCellSelection, Delete: deleteCellSelection, "Mod-Delete": deleteCellSelection }); function maybeSetSelection(state, dispatch, selection) { if (selection.eq(state.selection)) return false; if (dispatch) dispatch(state.tr.setSelection(selection).scrollIntoView()); return true; } function arrow(axis, dir) { return (state, dispatch, view) => { if (!view) return false; const sel = state.selection; if (sel instanceof CellSelection) { return maybeSetSelection( state, dispatch, Selection.near(sel.$headCell, dir) ); } if (axis != "horiz" && !sel.empty) return false; const end = atEndOfCell(view, axis, dir); if (end == null) return false; if (axis == "horiz") { return maybeSetSelection( state, dispatch, Selection.near(state.doc.resolve(sel.head + dir), dir) ); } else { const $cell = state.doc.resolve(end); const $next = nextCell($cell, axis, dir); let newSel; if ($next) newSel = Selection.near($next, 1); else if (dir < 0) newSel = Selection.near(state.doc.resolve($cell.before(-1)), -1); else newSel = Selection.near(state.doc.resolve($cell.after(-1)), 1); return maybeSetSelection(state, dispatch, newSel); } }; } function shiftArrow(axis, dir) { return (state, dispatch, view) => { if (!view) return false; const sel = state.selection; let cellSel; if (sel instanceof CellSelection) { cellSel = sel; } else { const end = atEndOfCell(view, axis, dir); if (end == null) return false; cellSel = new CellSelection(state.doc.resolve(end)); } const $head = nextCell(cellSel.$headCell, axis, dir); if (!$head) return false; return maybeSetSelection( state, dispatch, new CellSelection(cellSel.$anchorCell, $head) ); }; } function deleteCellSelection(state, dispatch) { const sel = state.selection; if (!(sel instanceof CellSelection)) return false; if (dispatch) { const tr = state.tr; const baseContent = tableNodeTypes(state.schema).cell.createAndFill().content; sel.forEachCell((cell, pos) => { if (!cell.content.eq(baseContent)) tr.replace( tr.mapping.map(pos + 1), tr.mapping.map(pos + cell.nodeSize - 1), new Slice(baseContent, 0, 0) ); }); if (tr.docChanged) dispatch(tr); } return true; } function handleTripleClick(view, pos) { const doc = view.state.doc, $cell = cellAround(doc.resolve(pos)); if (!$cell) return false; view.dispatch(view.state.tr.setSelection(new CellSelection($cell))); return true; } function handlePaste(view, _, slice) { if (!isInTable(view.state)) return false; let cells = pastedCells(slice); const sel = view.state.selection; if (sel instanceof CellSelection) { if (!cells) cells = { width: 1, height: 1, rows: [ Fragment.from( fitSlice(tableNodeTypes(view.state.schema).cell, slice) ) ] }; const table = sel.$anchorCell.node(-1); const start = sel.$anchorCell.start(-1); const rect = TableMap.get(table).rectBetween( sel.$anchorCell.pos - start, sel.$headCell.pos - start ); cells = clipCells(cells, rect.right - rect.left, rect.bottom - rect.top); insertCells(view.state, view.dispatch, start, rect, cells); return true; } else if (cells) { const $cell = selectionCell(view.state); const start = $cell.start(-1); insertCells( view.state, view.dispatch, start, TableMap.get($cell.node(-1)).findCell($cell.pos - start), cells ); return true; } else { return false; } } function handleMouseDown(view, startEvent) { var _a; if (startEvent.ctrlKey || startEvent.metaKey) return; const startDOMCell = domInCell(view, startEvent.target); let $anchor; if (startEvent.shiftKey && view.state.selection instanceof CellSelection) { setCellSelection(view.state.selection.$anchorCell, startEvent); startEvent.preventDefault(); } else if (startEvent.shiftKey && startDOMCell && ($anchor = cellAround(view.state.selection.$anchor)) != null && ((_a = cellUnderMouse(view, startEvent)) == null ? void 0 : _a.pos) != $anchor.pos) { setCellSelection($anchor, startEvent); startEvent.preventDefault(); } else if (!startDOMCell) { return; } function setCellSelection($anchor2, event) { let $head = cellUnderMouse(view, event); const starting = tableEditingKey.getState(view.state) == null; if (!$head || !inSameTable($anchor2, $head)) { if (starting) $head = $anchor2; else return; } const selection = new CellSelection($anchor2, $head); if (starting || !view.state.selection.eq(selection)) { const tr = view.state.tr.setSelection(selection); if (starting) tr.setMeta(tableEditingKey, $anchor2.pos); view.dispatch(tr); } } function stop() { view.root.removeEventListener("mouseup", stop); view.root.removeEventListener("dragstart", stop); view.root.removeEventListener("mousemove", move); if (tableEditingKey.getState(view.state) != null) view.dispatch(view.state.tr.setMeta(tableEditingKey, -1)); } function move(_event) { const event = _event; const anchor = tableEditingKey.getState(view.state); let $anchor2; if (anchor != null) { $anchor2 = view.state.doc.resolve(anchor); } else if (domInCell(view, event.target) != startDOMCell) { $anchor2 = cellUnderMouse(view, startEvent); if (!$anchor2) return stop(); } if ($anchor2) setCellSelection($anchor2, event); } view.root.addEventListener("mouseup", stop); view.root.addEventListener("dragstart", stop); view.root.addEventListener("mousemove", move); } function atEndOfCell(view, axis, dir) { if (!(view.state.selection instanceof TextSelection)) return null; const { $head } = view.state.selection; for (let d = $head.depth - 1; d >= 0; d--) { const parent = $head.node(d), index = dir < 0 ? $head.index(d) : $head.indexAfter(d); if (index != (dir < 0 ? 0 : parent.childCount)) return null; if (parent.type.spec.tableRole == "cell" || parent.type.spec.tableRole == "header_cell") { const cellPos = $head.before(d); const dirStr = axis == "vert" ? dir > 0 ? "down" : "up" : dir > 0 ? "right" : "left"; return view.endOfTextblock(dirStr) ? cellPos : null; } } return null; } function domInCell(view, dom) { for (; dom && dom != view.dom; dom = dom.parentNode) { if (dom.nodeName == "TD" || dom.nodeName == "TH") { return dom; } } return null; } function cellUnderMouse(view, event) { const mousePos = view.posAtCoords({ left: event.clientX, top: event.clientY }); if (!mousePos) return null; return mousePos ? cellAround(view.state.doc.resolve(mousePos.pos)) : null; } // src/tableview.ts var TableView = class { constructor(node, cellMinWidth) { this.node = node; this.cellMinWidth = cellMinWidth; this.dom = document.createElement("div"); this.dom.className = "tableWrapper"; this.table = this.dom.appendChild(document.createElement("table")); this.colgroup = this.table.appendChild(document.createElement("colgroup")); updateColumnsOnResize(node, this.colgroup, this.table, cellMinWidth); this.contentDOM = this.table.appendChild(document.createElement("tbody")); } update(node) { if (node.type != this.node.type) return false; this.node = node; updateColumnsOnResize(node, this.colgroup, this.table, this.cellMinWidth); return true; } ignoreMutation(record) { return record.type == "attributes" && (record.target == this.table || this.colgroup.contains(record.target)); } }; function updateColumnsOnResize(node, colgroup, table, cellMinWidth, overrideCol, overrideValue) { var _a; let totalWidth = 0; let fixedWidth = true; let nextDOM = colgroup.firstChild; const row = node.firstChild; if (!row) return; for (let i = 0, col = 0; i < row.childCount; i++) { const { colspan, colwidth } = row.child(i).attrs; for (let j = 0; j < colspan; j++, col++) { const hasWidth = overrideCol == col ? overrideValue : colwidth && colwidth[j]; const cssWidth = hasWidth ? hasWidth + "px" : ""; totalWidth += hasWidth || cellMinWidth; if (!hasWidth) fixedWidth = false; if (!nextDOM) { colgroup.appendChild(document.createElement("col")).style.width = cssWidth; } else { if (nextDOM.style.width != cssWidth) nextDOM.style.width = cssWidth; nextDOM = nextDOM.nextSibling; } } } while (nextDOM) { const after = nextDOM.nextSibling; (_a = nextDOM.parentNode) == null ? void 0 : _a.removeChild(nextDOM); nextDOM = after; } if (fixedWidth) { table.style.width = totalWidth + "px"; table.style.minWidth = ""; } else { table.style.width = ""; table.style.minWidth = totalWidth + "px"; } } // src/columnresizing.ts var columnResizingPluginKey = new PluginKey( "tableColumnResizing" ); function columnResizing({ handleWidth = 5, cellMinWidth = 25, View = TableView, lastColumnResizable = true } = {}) { const plugin = new Plugin({ key: columnResizingPluginKey, state: { init(_, state) { plugin.spec.props.nodeViews[tableNodeTypes(state.schema).table.name] = (node, view) => new View(node, cellMinWidth, view); return new ResizeState(-1, false); }, apply(tr, prev) { return prev.apply(tr); } }, props: { attributes: (state) => { const pluginState = columnResizingPluginKey.getState(state); return pluginState && pluginState.activeHandle > -1 ? { class: "resize-cursor" } : {}; }, handleDOMEvents: { mousemove: (view, event) => { handleMouseMove( view, event, handleWidth, cellMinWidth, lastColumnResizable ); }, mouseleave: (view) => { handleMouseLeave(view); }, mousedown: (view, event) => { handleMouseDown2(view, event, cellMinWidth); } }, decorations: (state) => { const pluginState = columnResizingPluginKey.getState(state); if (pluginState && pluginState.activeHandle > -1) { return handleDecorations(state, pluginState.activeHandle); } }, nodeViews: {} } }); return plugin; } var ResizeState = class _ResizeState { constructor(activeHandle, dragging) { this.activeHandle = activeHandle; this.dragging = dragging; } apply(tr) { const state = this; const action = tr.getMeta(columnResizingPluginKey); if (action && action.setHandle != null) return new _ResizeState(action.setHandle, false); if (action && action.setDragging !== void 0) return new _ResizeState(state.activeHandle, action.setDragging); if (state.activeHandle > -1 && tr.docChanged) { let handle = tr.mapping.map(state.activeHandle, -1); if (!pointsAtCell(tr.doc.resolve(handle))) { handle = -1; } return new _ResizeState(handle, state.dragging); } return state; } }; function handleMouseMove(view, event, handleWidth, cellMinWidth, lastColumnResizable) { const pluginState = columnResizingPluginKey.getState(view.state); if (!pluginState) return; if (!pluginState.dragging) { const target = domCellAround(event.target); let cell = -1; if (target) { const { left, right } = target.getBoundingClientRect(); if (event.clientX - left <= handleWidth) cell = edgeCell(view, event, "left", handleWidth); else if (right - event.clientX <= handleWidth) cell = edgeCell(view, event, "right", handleWidth); } if (cell != pluginState.activeHandle) { if (!lastColumnResizable && cell !== -1) { const $cell = view.state.doc.resolve(cell); const table = $cell.node(-1); const map = TableMap.get(table); const tableStart = $cell.start(-1); const col = map.colCount($cell.pos - tableStart) + $cell.nodeAfter.attrs.colspan - 1; if (col == map.width - 1) { return; } } updateHandle(view, cell); } } } function handleMouseLeave(view) { const pluginState = columnResizingPluginKey.getState(view.state); if (pluginState && pluginState.activeHandle > -1 && !pluginState.dragging) updateHandle(view, -1); } function handleMouseDown2(view, event, cellMinWidth) { var _a; const win = (_a = view.dom.ownerDocument.defaultView) != null ? _a : window; const pluginState = columnResizingPluginKey.getState(view.state); if (!pluginState || pluginState.activeHandle == -1 || pluginState.dragging) return false; const cell = view.state.doc.nodeAt(pluginState.activeHandle); const width = currentColWidth(view, pluginState.activeHandle, cell.attrs); view.dispatch( view.state.tr.setMeta(columnResizingPluginKey, { setDragging: { startX: event.clientX, startWidth: width } }) ); function finish(event2) { win.removeEventListener("mouseup", finish); win.removeEventListener("mousemove", move); const pluginState2 = columnResizingPluginKey.getState(view.state); if (pluginState2 == null ? void 0 : pluginState2.dragging) { updateColumnWidth( view, pluginState2.activeHandle, draggedWidth(pluginState2.dragging, event2, cellMinWidth) ); view.dispatch( view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }) ); } } function move(event2) { if (!event2.which) return finish(event2); const pluginState2 = columnResizingPluginKey.getState(view.state); if (!pluginState2) return; if (pluginState2.dragging) { const dragged = draggedWidth(pluginState2.dragging, event2, cellMinWidth); displayColumnWidth(view, pluginState2.activeHandle, dragged, cellMinWidth); } } win.addEventListener("mouseup", finish); win.addEventListener("mousemove", move); event.preventDefault(); return true; } function currentColWidth(view, cellPos, { colspan, colwidth }) { const width = colwidth && colwidth[colwidth.length - 1]; if (width) return width; const dom = view.domAtPos(cellPos); const node = dom.node.childNodes[dom.offset]; let domWidth = node.offsetWidth, parts = colspan; if (colwidth) { for (let i = 0; i < colspan; i++) if (colwidth[i]) { domWidth -= colwidth[i]; parts--; } } return domWidth / parts; } function domCellAround(target) { while (target && target.nodeName != "TD" && target.nodeName != "TH") target = target.classList && target.classList.contains("ProseMirror") ? null : target.parentNode; return target; } function edgeCell(view, event, side, handleWidth) { const offset = side == "right" ? -handleWidth : handleWidth; const found = view.posAtCoords({ left: event.clientX + offset, top: event.clientY }); if (!found) return -1; const { pos } = found; const $cell = cellAround(view.state.doc.resolve(pos)); if (!$cell) return -1; if (side == "right") return $cell.pos; const map = TableMap.get($cell.node(-1)), start = $cell.start(-1); const index = map.map.indexOf($cell.pos - start); return index % map.width == 0 ? -1 : start + map.map[index - 1]; } function draggedWidth(dragging, event, cellMinWidth) { const offset = event.clientX - dragging.startX; return Math.max(cellMinWidth, dragging.startWidth + offset); } function updateHandle(view, value) { view.dispatch( view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }) ); } function updateColumnWidth(view, cell, width) { const $cell = view.state.doc.resolve(cell); const table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1); const col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1; const tr = view.state.tr; for (let row = 0; row < map.height; row++) { const mapIndex = row * map.width + col; if (row && map.map[mapIndex] == map.map[mapIndex - map.width]) continue; const pos = map.map[mapIndex]; const attrs = table.nodeAt(pos).attrs; const index = attrs.colspan == 1 ? 0 : col - map.colCount(pos); if (attrs.colwidth && attrs.colwidth[index] == width) continue; const colwidth = attrs.colwidth ? attrs.colwidth.slice() : zeroes(attrs.colspan); colwidth[index] = width; tr.setNodeMarkup(start + pos, null, { ...attrs, colwidth }); } if (tr.docChanged) view.dispatch(tr); } function displayColumnWidth(view, cell, width, cellMinWidth) { const $cell = view.state.doc.resolve(cell); const table = $cell.node(-1), start = $cell.start(-1); const col = TableMap.get(table).colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1; let dom = view.domAtPos($cell.start(-1)).node; while (dom && dom.nodeName != "TABLE") { dom = dom.parentNode; } if (!dom) return; updateColumnsOnResize( table, dom.firstChild, dom, cellMinWidth, col, width ); } function zeroes(n) { return Array(n).fill(0); } function handleDecorations(state, cell) { const decorations = []; const $cell = state.doc.resolve(cell); const table = $cell.node(-1); if (!table) { return DecorationSet.empty; } const map = TableMap.get(table); const start = $cell.start(-1); const col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan; for (let row = 0; row < map.height; row++) { const index = col + row * map.width - 1; if ((col == map.width || map.map[index] != map.map[index + 1]) && (row == 0 || map.map[index] != map.map[index - map.width])) { const cellPos = map.map[index]; const pos = start + cellPos + table.nodeAt(cellPos).nodeSize - 1; const dom = document.createElement("div"); dom.className = "column-resize-handle"; decorations.push(Decoration.widget(pos, dom)); } } return DecorationSet.create(state.doc, decorations); } function selectedRect(state) { const sel = state.selection; const $pos = selectionCell(state); const table = $pos.node(-1); const tableStart = $pos.start(-1); const map = TableMap.get(table); const rect = sel instanceof CellSelection ? map.rectBetween( sel.$anchorCell.pos - tableStart, sel.$headCell.pos - tableStart ) : map.findCell($pos.pos - tableStart); return { ...rect, tableStart, map, table }; } function addColumn(tr, { map, tableStart, table }, col) { let refColumn = col > 0 ? -1 : 0; if (columnIsHeader(map, table, col + refColumn)) { refColumn = col == 0 || col == map.width ? null : 0; } for (let row = 0; row < map.height; row++) { const index = row * map.width + col; if (col > 0 && col < map.width && map.map[index - 1] == map.map[index]) { const pos = map.map[index]; const cell = table.nodeAt(pos); tr.setNodeMarkup( tr.mapping.map(tableStart + pos), null, addColSpan(cell.attrs, col - map.colCount(pos)) ); row += cell.attrs.rowspan - 1; } else { const type = refColumn == null ? tableNodeTypes(table.type.schema).cell : table.nodeAt(map.map[index + refColumn]).type; const pos = map.positionAt(row, col, table); tr.insert(tr.mapping.map(tableStart + pos), type.createAndFill()); } } return tr; } function addColumnBefore(state, dispatch) { if (!isInTable(state)) return false; if (dispatch) { const rect = selectedRect(state); dispatch(addColumn(state.tr, rect, rect.left)); } return true; } function addColumnAfter(state, dispatch) { if (!isInTable(state)) return false; if (dispatch) { const rect = selectedRect(state); dispatch(addColumn(state.tr, rect, rect.right)); } return true; } function removeColumn(tr, { map, table, tableStart }, col) { const mapStart = tr.mapping.maps.length; for (let row = 0; row < map.height; ) { const index = row * map.width + col; const pos = map.map[index]; const cell = table.nodeAt(pos); const attrs = cell.attrs; if (col > 0 && map.map[index - 1] == pos || col < map.width - 1 && map.map[index + 1] == pos) { tr.setNodeMarkup( tr.mapping.slice(mapStart).map(tableStart + pos), null, removeColSpan(attrs, col - map.colCount(pos)) ); } else { const start = tr.mapping.slice(mapStart).map(tableStart + pos); tr.delete(start, start + cell.nodeSize); } row += attrs.rowspan; } } function deleteColumn(state, dispatch) { if (!isInTable(state)) return false; if (dispatch) { const rect = selectedRect(state); const tr = state.tr; if (rect.left == 0 && rect.right == rect.map.width) return false; for (let i = rect.right - 1; ; i--) { removeColumn(tr, rect, i); if (i == rect.left) break; const table = rect.tableStart ? tr.doc.nodeAt(rect.tableStart - 1) : tr.doc; if (!table) { throw RangeError("No table found"); } rect.table = table; rect.map = TableMap.get(table); } dispatch(tr); } return true; } function rowIsHeader(map, table, row) { var _a; const headerCell = tableNodeTypes(table.type.schema).header_cell; for (let col = 0; col < map.width; col++) if (((_a = table.nodeAt(map.map[col + row * map.width])) == null ? void 0 : _a.type) != headerCell) return false; return true; } function addRow(tr, { map, tableStart, table }, row) { var _a; let rowPos = tableStart; for (let i = 0; i < row; i++) rowPos += table.child(i).nodeSize; const cells = []; let refRow = row > 0 ? -1 : 0; if (rowIsHeader(map, table, row + refRow)) refRow = row == 0 || row == map.height ? null : 0; for (let col = 0, index = map.width * row; col < map.width; col++, index++) { if (row > 0 && row < map.height && map.map[index] == map.map[index - map.width]) { const pos = map.map[index]; const attrs = table.nodeAt(pos).attrs; tr.setNodeMarkup(tableStart + pos, null, { ...attrs, rowspan: attrs.rowspan + 1 }); col += attrs.colspan - 1; } else { const type = refRow == null ? tableNodeTypes(table.type.schema).cell : (_a = table.nodeAt(map.map[index + refRow * map.width])) == null ? void 0 : _a.type; const node = type == null ? void 0 : type.createAndFill(); if (node) cells.push(node); } } tr.insert(rowPos, tableNodeTypes(table.type.schema).row.create(null, cells)); return tr; } function addRowBefore(state, dispatch) { if (!isInTable(state)) return false; if (dispatch) { const rect = selectedRect(state); dispatch(addRow(state.tr, rect, rect.top)); } return true; } function addRowAfter(state, dispatch) { if (!isInTable(state)) return false; if (dispatch) { const rect = selectedRect(state); dispatch(addRow(state.tr, rect, rect.bottom)); } return true; } function removeRow(tr, { map, table, tableStart }, row) { let rowPos = 0; for (let i = 0; i < row; i++) rowPos += table.child(i).nodeSize; const nextRow = rowPos + table.child(row).nodeSize; const mapFrom = tr.mapping.maps.length; tr.delete(rowPos + tableStart, nextRow + tableStart); const seen = /* @__PURE__ */ new Set(); for (let col = 0, index = row * map.width; col < map.width; col++, index++) { const pos = map.map[index]; if (seen.has(pos)) continue; seen.add(pos); if (row > 0 && pos == map.map[index - map.width]) { const attrs = table.nodeAt(pos).attrs; tr.setNodeMarkup(tr.mapping.slice(mapFrom).map(pos + tableStart), null, { ...attrs, rowspan: attrs.rowspan - 1 }); col += attrs.colspan - 1; } else if (row < map.height && pos == map.map[index + map.width]) { const cell = table.nodeAt(pos); const attrs = cell.attrs; const copy = cell.type.create( { ...attrs, rowspan: cell.attrs.rowspan - 1 }, cell.content ); const newPos = map.positionAt(row + 1, col, table); tr.insert(tr.mapping.slice(mapFrom).map(tableStart + newPos), copy); col += attrs.colspan - 1; } } } function deleteRow(state, dispatch) { if (!isInTable(state)) return false; if (dispatch) { const rect = selectedRect(state), tr = state.tr; if (rect.top == 0 && rect.bottom == rect.map.height) return false; for (let i = rect.bottom - 1; ; i--) { removeRow(tr, rect, i); if (i == rect.top) break; const table = rect.tableStart ? tr.doc.nodeAt(rect.tableStart - 1) : tr.doc; if (!table) { throw RangeError("No table found"); } rect.table = table; rect.map = TableMap.get(rect.table); } dispatch(tr); } return true; } function isEmpty(cell) { const c = cell.content; return c.childCount == 1 && c.child(0).isTextblock && c.child(0).childCount == 0; } function cellsOverlapRectangle({ width, height, map }, rect) { let indexTop = rect.top * width + rect.left, indexLeft = indexTop; let indexBottom = (rect.bottom - 1) * width + rect.left, indexRight = indexTop + (rect.right - rect.left - 1); for (let i = rect.top; i < rect.bottom; i++) { if (rect.left > 0 && map[indexLeft] == map[indexLeft - 1] || rect.right < width && map[indexRight] == map[indexRight + 1]) return true; indexLeft += width; indexRight += width; } for (let i = rect.left; i < rect.right; i++) { if (rect.top > 0 && map[indexTop] == map[indexTop - width] || rect.bottom < height && map[indexBottom] == map[indexBottom + width]) return true; indexTop++; indexBottom++; } return false; } function mergeCells(state, dispatch) { const sel = state.selection; if (!(sel instanceof CellSelection) || sel.$anchorCell.pos == sel.$headCell.pos) return false; const rect = selectedRect(state), { map } = rect; if (cellsOverlapRectangle(map, rect)) return false; if (dispatch) { const tr = state.tr; const seen = {}; let content = Fragment.empty; let mergedPos; let mergedCell; for (let row = rect.top; row < rect.bottom; row++) { for (let col = rect.left; col < rect.right; col++) { const cellPos = map.map[row * map.width + col]; const cell = rect.table.nodeAt(cellPos); if (seen[cellPos] || !cell) continue; seen[cellPos] = true; if (mergedPos == null) { mergedPos = cellPos; mergedCell = cell; } else { if (!isEmpty(cell)) content = content.append(cell.content); const mapped = tr.mapping.map(cellPos + rect.tableStart); tr.delete(mapped, mapped + cell.nodeSize); } } } if (mergedPos == null || mergedCell == null) { return true; } tr.setNodeMarkup(mergedPos + rect.tableStart, null, { ...addColSpan( mergedCell.attrs, mergedCell.attrs.colspan, rect.right - rect.left - mergedCell.attrs.colspan ), rowspan: rect.bottom - rect.top }); if (content.size) { const end = mergedPos + 1 + mergedCell.content.size; const start = isEmpty(mergedCell) ? mergedPos + 1 : end; tr.replaceWith(start + rect.tableStart, end + rect.tableStart, content); } tr.setSelection( new CellSelection(tr.doc.resolve(mergedPos + rect.tableStart)) ); dispatch(tr); } return true; } function splitCell(state, dispatch) { const nodeTypes = tableNodeTypes(state.schema); return splitCellWithType(({ node }) => { return nodeTypes[node.type.spec.tableRole]; })(state, dispatch); } function splitCellWithType(getCellType) { return (state, dispatch) => { var _a; const sel = state.selection; let cellNode; let cellPos; if (!(sel instanceof CellSelection)) { cellNode = cellWrapping(sel.$from); if (!cellNode) return false; cellPos = (_a = cellAround(sel.$from)) == null ? void 0 : _a.pos; } else { if (sel.$anchorCell.pos != sel.$headCell.pos) return false; cellNode = sel.$anchorCell.nodeAfter; cellPos = sel.$anchorCell.pos; } if (cellNode == null || cellPos == null) { return false; } if (cellNode.attrs.colspan == 1 && cellNode.attrs.rowspan == 1) { return false; } if (dispatch) { let baseAttrs = cellNode.attrs; const attrs = []; const colwidth = baseAttrs.colwidth; if (baseAttrs.rowspan > 1) baseAttrs = { ...baseAttrs, rowspan: 1 }; if (baseAttrs.colspan > 1) baseAttrs = { ...baseAttrs, colspan: 1 }; const rect = selectedRect(state), tr = state.tr; for (let i = 0; i < rect.right - rect.left; i++) attrs.push( colwidth ? { ...baseAttrs, colwidth: colwidth && colwidth[i] ? [colwidth[i]] : null } : baseAttrs ); let lastCell; for (let row = rect.top; row < rect.bottom; row++) { let pos = rect.map.positionAt(row, rect.left, rect.table); if (row == rect.top) pos += cellNode.nodeSize; for (let col = rect.left, i = 0; col < rect.right; col++, i++) { if (col == rect.left && row == rect.top) continue; tr.insert( lastCell = tr.mapping.map(pos + rect.tableStart, 1), getCellType({ node: cellNode, row, col }).createAndFill(attrs[i]) ); } } tr.setNodeMarkup( cellPos, getCellType({ node: cellNode, row: rect.top, col: rect.left }), attrs[0] ); if (sel instanceof CellSelection) tr.setSelection( new CellSelection( tr.doc.resolve(sel.$anchorCell.pos), lastCell ? tr.doc.resolve(lastCell) : void 0 ) ); dispatch(tr); } return true; }; } function setCellAttr(name, value) { return function(state, dispatch) { if (!isInTable(state)) return false; const $cell = selectionCell(state); if ($cell.nodeAfter.attrs[name] === value) return false; if (dispatch) { const tr = state.tr; if (state.selection instanceof CellSelection) state.selection.forEachCell((node, pos) => { if (node.attrs[name] !== value) tr.setNodeMarkup(pos, null, { ...node.attrs, [name]: value }); }); else tr.setNodeMarkup($cell.pos, null, { ...$cell.nodeAfter.attrs, [name]: value }); dispatch(tr); } return true; }; } function deprecated_toggleHeader(type) { return function(state, dispatch) { if (!isInTable(state)) return false; if (dispatch) { const types = tableNodeTypes(state.schema); const rect = selectedRect(state), tr = state.tr; const cells = rect.map.cellsInRect( type == "column" ? { left: rect.left, top: 0, right: rect.right, bottom: rect.map.height } : type == "row" ? { left: 0, top: rect.top, right: rect.map.width, bottom: rect.bottom } : rect ); const nodes = cells.map((pos) => rect.table.nodeAt(pos)); for (let i = 0; i < cells.length; i++) if (nodes[i].type == types.header_cell) tr.setNodeMarkup( rect.tableStart + cells[i], types.cell, nodes[i].attrs ); if (tr.steps.length == 0) for (let i = 0; i < cells.length; i++) tr.setNodeMarkup( rect.tableStart + cells[i], types.header_cell, nodes[i].attrs ); dispatch(tr); } return true; }; } function isHeaderEnabledByType(type, rect, types) { const cellPositions = rect.map.cellsInRect({ left: 0, top: 0, right: type == "row" ? rect.map.width : 1, bottom: type == "column" ? rect.map.height : 1 }); for (let i = 0; i < cellPositions.length; i++) { const cell = rect.table.nodeAt(cellPositions[i]); if (cell && cell.type !== types.header_cell) { return false; } } return true; } function toggleHeader(type, options) { options = options || { useDeprecatedLogic: false }; if (options.useDeprecatedLogic) return deprecated_toggleHeader(type); return function(state, dispatch) { if (!isInTable(state)) return false; if (dispatch) { const types = tableNodeTypes(state.schema); const rect = selectedRect(state), tr = state.tr; const isHeaderRowEnabled = isHeaderEnabledByType("row", rect, types); const isHeaderColumnEnabled = isHeaderEnabledByType( "column", rect, types ); const isHeaderEnabled = type === "column" ? isHeaderRowEnabled : type === "row" ? isHeaderColumnEnabled : false; const selectionStartsAt = isHeaderEnabled ? 1 : 0; const cellsRect = type == "column" ? { left: 0, top: selectionStartsAt, right: 1, bottom: rect.map.height } : type == "row" ? { left: selectionStartsAt, top: 0, right: rect.map.width, bottom: 1 } : rect; const newType = type == "column" ? isHeaderColumnEnabled ? types.cell : types.header_cell : type == "row" ? isHeaderRowEnabled ? types.cell : types.header_cell : types.cell; rect.map.cellsInRect(cellsRect).forEach((relativeCellPos) => { const cellPos = relativeCellPos + rect.tableStart; const cell = tr.doc.nodeAt(cellPos); if (cell) { tr.setNodeMarkup(cellPos, newType, cell.attrs); } }); dispatch(tr); } return true; }; } var toggleHeaderRow = toggleHeader("row", { useDeprecatedLogic: true }); var toggleHeaderColumn = toggleHeader("column", { useDeprecatedLogic: true }); var toggleHeaderCell = toggleHeader("cell", { useDeprecatedLogic: true }); function findNextCell($cell, dir) { if (dir < 0) { const before = $cell.nodeBefore; if (before) return $cell.pos - before.nodeSize; for (let row = $cell.index(-1) - 1, rowEnd = $cell.before(); row >= 0; row--) { const rowNode = $cell.node(-1).child(row); const lastChild = rowNode.lastChild; if (lastChild) { return rowEnd - 1 - lastChild.nodeSize; } rowEnd -= rowNode.nodeSize; } } else { if ($cell.index() < $cell.parent.childCount - 1) { return $cell.pos + $cell.nodeAfter.nodeSize; } const table = $cell.node(-1); for (let row = $cell.indexAfter(-1), rowStart = $cell.after(); row < table.childCount; row++) { const rowNode = table.child(row); if (rowNode.childCount) return rowStart + 1; rowStart += rowNode.nodeSize; } } return null; } function goToNextCell(direction) { return function(state, dispatch) { if (!isInTable(state)) return false; const cell = findNextCell(selectionCell(state), direction); if (cell == null) return false; if (dispatch) { const $cell = state.doc.resolve(cell); dispatch( state.tr.setSelection(TextSelection.between($cell, moveCellForward($cell))).scrollIntoView() ); } return true; }; } function deleteTable(state, dispatch) { const $pos = state.selection.$anchor; for (let d = $pos.depth; d > 0; d--) { const node = $pos.node(d); if (node.type.spec.tableRole == "table") { if (dispatch) dispatch( state.tr.delete($pos.before(d), $pos.after(d)).scrollIntoView() ); return true; } } return false; } // src/index.ts function tableEditing({ allowTableNodeSelection = false } = {}) { return new Plugin({ key: tableEditingKey, // This piece of state is used to remember when a mouse-drag // cell-selection is happening, so that it can continue even as // transactions (which might move its anchor cell) come in. state: { init() { return null; }, apply(tr, cur) { const set = tr.getMeta(tableEditingKey); if (set != null) return set == -1 ? null : set; if (cur == null || !tr.docChanged) return cur; const { deleted, pos } = tr.mapping.mapResult(cur); return deleted ? null : pos; } }, props: { decorations: drawCellSelection, handleDOMEvents: { mousedown: handleMouseDown }, createSelectionBetween(view) { return tableEditingKey.getState(view.state) != null ? view.state.selection : null; }, handleTripleClick, handleKeyDown, handlePaste }, appendTransaction(_, oldState, state) { return normalizeSelection( state, fixTables(state, oldState), allowTableNodeSelection ); } }); } var index$1 = /*#__PURE__*/Object.freeze({ __proto__: null, CellBookmark: CellBookmark, CellSelection: CellSelection, ResizeState: ResizeState, TableMap: TableMap, TableView: TableView, __clipCells: clipCells, __insertCells: insertCells, __pastedCells: pastedCells, addColSpan: addColSpan, addColumn: addColumn, addColumnAfter: addColumnAfter, addColumnBefore: addColumnBefore, addRow: addRow, addRowAfter: addRowAfter, addRowBefore: addRowBefore, cellAround: cellAround, colCount: colCount, columnIsHeader: columnIsHeader, columnResizing: columnResizing, columnResizingPluginKey: columnResizingPluginKey, deleteColumn: deleteColumn, deleteRow: deleteRow, deleteTable: deleteTable, findCell: findCell, fixTables: fixTables, fixTablesKey: fixTablesKey, goToNextCell: goToNextCell, handlePaste: handlePaste, inSameTable: inSameTable, isInTable: isInTable, mergeCells: mergeCells, moveCellForward: moveCellForward, nextCell: nextCell, pointsAtCell: pointsAtCell, removeColSpan: removeColSpan, removeColumn: removeColumn, removeRow: removeRow, rowIsHeader: rowIsHeader, selectedRect: selectedRect, selectionCell: selectionCell, setCellAttr: setCellAttr, splitCell: splitCell, splitCellWithType: splitCellWithType, tableEditing: tableEditing, tableEditingKey: tableEditingKey, tableNodeTypes: tableNodeTypes, tableNodes: tableNodes, toggleHeader: toggleHeader, toggleHeaderCell: toggleHeaderCell, toggleHeaderColumn: toggleHeaderColumn, toggleHeaderRow: toggleHeaderRow, updateColumnsOnResize: updateColumnsOnResize }); /** * A class responsible for building a menu for a ProseMirror instance. * @extends {ProseMirrorPlugin} */ class ProseMirrorMenu extends ProseMirrorPlugin { /** * @typedef {object} ProseMirrorMenuOptions * @property {Function} [onSave] A function to call when the save button is pressed. * @property {boolean} [destroyOnSave] Whether this editor instance is intended to be destroyed when saved. * @property {boolean} [compact] Whether to display a more compact version of the menu. */ /** * @param {Schema} schema The ProseMirror schema to build a menu for. * @param {EditorView} view The editor view. * @param {ProseMirrorMenuOptions} [options] Additional options to configure the plugin's behaviour. */ constructor(schema, view, options={}) { super(schema); this.options = options; /** * The editor view. * @type {EditorView} */ Object.defineProperty(this, "view", {value: view}); /** * The items configured for this menu. * @type {ProseMirrorMenuItem[]} */ Object.defineProperty(this, "items", {value: this._getMenuItems()}); /** * The ID of the menu element in the DOM. * @type {string} */ Object.defineProperty(this, "id", {value: `prosemirror-menu-${foundry.utils.randomID()}`, writable: false}); this._createDropDowns(); this._wrapEditor(); } /* -------------------------------------------- */ /** * An enumeration of editor scopes in which a menu item can appear * @enum {string} * @protected */ static _MENU_ITEM_SCOPES = { BOTH: "", TEXT: "text", HTML: "html" } /* -------------------------------------------- */ /** * Additional options to configure the plugin's behaviour. * @type {ProseMirrorMenuOptions} */ options; /* -------------------------------------------- */ /** * An HTML element that we write HTML to before injecting it into the DOM. * @type {HTMLTemplateElement} * @private */ #renderTarget = document.createElement("template"); /* -------------------------------------------- */ /** * Track whether we are currently in a state of editing the HTML source. * @type {boolean} */ #editingSource = false; get editingSource() { return this.#editingSource; } /* -------------------------------------------- */ /** @inheritdoc */ static build(schema, options={}) { return new Plugin({ view: editorView => { return new this(schema, editorView, options).render(); } }); } /* -------------------------------------------- */ /** * Render the menu's HTML. * @returns {ProseMirrorMenu} */ render() { const scopes = this.constructor._MENU_ITEM_SCOPES; const scopeKey = this.editingSource ? "HTML" : "TEXT"; // Dropdown Menus const dropdowns = this.dropdowns.map(d => `
      • ${d.render()}
      • `); // Button items const buttons = this.items.reduce((buttons, item) => { if ( ![scopes.BOTH, scopes[scopeKey]].includes(item.scope) ) return buttons; const liClass = [item.active ? "active" : "", item.cssClass, item.scope].filterJoin(" "); const bClass = item.active ? "active" : ""; const tip = game.i18n.localize(item.title); buttons.push(`
      • `); return buttons; }, []); // Add collaboration indicator. const collaborating = document.getElementById(this.id)?.querySelector(".concurrent-users"); const tooltip = collaborating?.dataset.tooltip || game.i18n.localize("EDITOR.CollaboratingUsers"); buttons.push(`
      • ${collaborating?.innerHTML || ""}
      • `); // Replace Menu HTML this.#renderTarget.innerHTML = ` ${dropdowns.join("")} ${buttons.join("")} `; document.getElementById(this.id).replaceWith(this.#renderTarget.content.getElementById(this.id)); // Toggle source editing state for the parent const editor = this.view.dom.closest(".editor"); editor.classList.toggle("editing-source", this.editingSource); // Menu interactivity this.activateListeners(document.getElementById(this.id)); return this; } /* -------------------------------------------- */ /** * Attach event listeners. * @param {HTMLMenuElement} html The root menu element. */ activateListeners(html) { html.querySelectorAll("button[data-action]").forEach(button => button.onclick = evt => this._onAction(evt)); this.dropdowns.map(d => d.activateListeners(html)); } /* -------------------------------------------- */ /** * Called whenever the view's state is updated. * @param {EditorView} view The current editor state. * @param {EditorView} prevState The previous editor state. */ update(view, prevState) { this.dropdowns.forEach(d => d.forEachItem(item => { item.active = this._isItemActive(item); })); this.items.forEach(item => item.active = this._isItemActive(item)); this.render(); } /* -------------------------------------------- */ /** * Called when the view is destroyed or receives a state with different plugins. */ destroy() { const menu = this.view.dom.closest(".editor").querySelector("menu"); menu.nextElementSibling.remove(); menu.remove(); } /* -------------------------------------------- */ /** * Instantiate the ProseMirrorDropDown instances and configure them with the defined menu items. * @protected */ _createDropDowns() { const dropdowns = Object.values(this._getDropDownMenus()).map(({title, cssClass, icon, entries}) => { return new ProseMirrorDropDown(title, entries, { cssClass, icon, onAction: this._onAction.bind(this) }); }); /** * The dropdowns configured for this menu. * @type {ProseMirrorDropDown[]} */ Object.defineProperty(this, "dropdowns", {value: dropdowns}); } /* -------------------------------------------- */ /** * @typedef {object} ProseMirrorMenuItem * @property {string} action A string identifier for this menu item. * @property {string} title The description of the menu item. * @property {string} [class] An optional class to apply to the menu item. * @property {string} [style] An optional style to apply to the title text. * @property {string} [icon] The menu item's icon HTML. * @property {MarkType} [mark] The mark to apply to the selected text. * @property {NodeType} [node] The node to wrap the selected text in. * @property {object} [attrs] An object of attributes for the node or mark. * @property {number} [group] Entries with the same group number will be grouped together in the drop-down. * Lower-numbered groups appear higher in the list. * @property {number} [priority] A numeric priority which determines whether this item is displayed as the * dropdown title. Lower priority takes precedence. * @property {ProseMirrorCommand} [cmd] The command to run when the menu item is clicked. * @property {boolean} [active=false] Whether the current item is active under the given selection or cursor. */ /** * @typedef {ProseMirrorMenuItem} ProseMirrorDropDownEntry * @property {ProseMirrorDropDownEntry[]} [children] Any child entries. */ /** * @typedef {object} ProseMirrorDropDownConfig * @property {string} title The default title of the drop-down. * @property {string} cssClass The menu CSS class. * @property {string} [icon] An optional icon to use instead of a text label. * @property {ProseMirrorDropDownEntry[]} entries The drop-down entries. */ /** * Configure dropdowns for this menu. Each entry in the top-level array corresponds to a separate drop-down. * @returns {Record} * @protected */ _getDropDownMenus() { const menus = { format: { title: "EDITOR.Format", cssClass: "format", entries: [ { action: "block", title: "EDITOR.Block", children: [{ action: "paragraph", title: "EDITOR.Paragraph", priority: 3, node: this.schema.nodes.paragraph }, { action: "blockquote", title: "EDITOR.Blockquote", priority: 1, node: this.schema.nodes.blockquote, cmd: () => this._toggleBlock(this.schema.nodes.blockquote, wrapIn) }, { action: "code-block", title: "EDITOR.CodeBlock", priority: 1, node: this.schema.nodes.code_block, cmd: () => this._toggleTextBlock(this.schema.nodes.code_block) }, { action: "secret", title: "EDITOR.Secret", priority: 1, node: this.schema.nodes.secret, cmd: () => { this._toggleBlock(this.schema.nodes.secret, wrapIn, { attrs: { id: `secret-${foundry.utils.randomID()}` } }); } }] }, { action: "inline", title: "EDITOR.Inline", children: [{ action: "bold", title: "EDITOR.Bold", priority: 2, style: "font-weight: bold;", mark: this.schema.marks.strong, cmd: toggleMark(this.schema.marks.strong) }, { action: "italic", title: "EDITOR.Italic", priority: 2, style: "font-style: italic;", mark: this.schema.marks.em, cmd: toggleMark(this.schema.marks.em) }, { action: "code", title: "EDITOR.Code", priority: 2, style: "font-family: monospace;", mark: this.schema.marks.code, cmd: toggleMark(this.schema.marks.code) }, { action: "underline", title: "EDITOR.Underline", priority: 2, style: "text-decoration: underline;", mark: this.schema.marks.underline, cmd: toggleMark(this.schema.marks.underline) }, { action: "strikethrough", title: "EDITOR.Strikethrough", priority: 2, style: "text-decoration: line-through;", mark: this.schema.marks.strikethrough, cmd: toggleMark(this.schema.marks.strikethrough) }, { action: "superscript", title: "EDITOR.Superscript", priority: 2, mark: this.schema.marks.superscript, cmd: toggleMark(this.schema.marks.superscript) }, { action: "subscript", title: "EDITOR.Subscript", priority: 2, mark: this.schema.marks.subscript, cmd: toggleMark(this.schema.marks.subscript) }] }, { action: "alignment", title: "EDITOR.Alignment", children: [{ action: "align-left", title: "EDITOR.AlignmentLeft", priority: 4, node: this.schema.nodes.paragraph, attrs: {alignment: "left"}, cmd: () => this.#toggleAlignment("left") }, { action: "align-center", title: "EDITOR.AlignmentCenter", priority: 4, node: this.schema.nodes.paragraph, attrs: {alignment: "center"}, cmd: () => this.#toggleAlignment("center") }, { action: "align-justify", title: "EDITOR.AlignmentJustify", priority: 4, node: this.schema.nodes.paragraph, attrs: {alignment: "justify"}, cmd: () => this.#toggleAlignment("justify") }, { action: "align-right", title: "EDITOR.AlignmentRight", priority: 4, node: this.schema.nodes.paragraph, attrs: {alignment: "right"}, cmd: () => this.#toggleAlignment("right") }] } ] } }; const headings = Array.fromRange(6, 1).map(level => ({ action: `h${level}`, title: game.i18n.format("EDITOR.Heading", {level}), priority: 1, class: `level${level}`, node: this.schema.nodes.heading, attrs: {level}, cmd: () => this._toggleTextBlock(this.schema.nodes.heading, {attrs: {level}}) })); menus.format.entries.unshift({ action: "headings", title: "EDITOR.Headings", children: headings }); const fonts = FontConfig.getAvailableFonts().sort().map(family => ({ action: `font-family-${family.slugify()}`, title: family, priority: 2, style: `font-family: '${family}';`, mark: this.schema.marks.font, attrs: {family}, cmd: toggleMark(this.schema.marks.font, {family}) })); if ( this.options.compact ) { menus.format.entries.push({ action: "fonts", title: "EDITOR.Font", children: fonts }); } else { menus.fonts = { title: "EDITOR.Font", cssClass: "fonts", entries: fonts }; } menus.table = { title: "EDITOR.Table", cssClass: "tables", icon: '', entries: [{ action: "insert-table", title: "EDITOR.TableInsert", group: 1, cmd: this._insertTablePrompt.bind(this) }, { action: "delete-table", title: "EDITOR.TableDelete", group: 1, cmd: deleteTable }, { action: "add-col-after", title: "EDITOR.TableAddColumnAfter", group: 2, cmd: addColumnAfter }, { action: "add-col-before", title: "EDITOR.TableAddColumnBefore", group: 2, cmd: addColumnBefore }, { action: "delete-col", title: "EDITOR.TableDeleteColumn", group: 2, cmd: deleteColumn }, { action: "add-row-after", title: "EDITOR.TableAddRowAfter", group: 3, cmd: addRowAfter }, { action: "add-row-before", title: "EDITOR.TableAddRowBefore", group: 3, cmd: addRowBefore }, { action: "delete-row", title: "EDITOR.TableDeleteRow", group: 3, cmd: deleteRow }, { action: "merge-cells", title: "EDITOR.TableMergeCells", group: 4, cmd: mergeCells }, { action: "split-cell", title: "EDITOR.TableSplitCell", group: 4, cmd: splitCell }] }; Hooks.callAll("getProseMirrorMenuDropDowns", this, menus); return menus; } /* -------------------------------------------- */ /** * Configure the items for this menu. * @returns {ProseMirrorMenuItem[]} * @protected */ _getMenuItems() { const scopes = this.constructor._MENU_ITEM_SCOPES; const items = [ { action: "bullet-list", title: "EDITOR.BulletList", icon: '', node: this.schema.nodes.bullet_list, scope: scopes.TEXT, cmd: () => this._toggleBlock(this.schema.nodes.bullet_list, wrapInList) }, { action: "number-list", title: "EDITOR.NumberList", icon: '', node: this.schema.nodes.ordered_list, scope: scopes.TEXT, cmd: () => this._toggleBlock(this.schema.nodes.ordered_list, wrapInList) }, { action: "horizontal-rule", title: "EDITOR.HorizontalRule", icon: '', scope: scopes.TEXT, cmd: this.#insertHorizontalRule.bind(this) }, { action: "image", title: "EDITOR.InsertImage", icon: '', scope: scopes.TEXT, node: this.schema.nodes.image, cmd: this._insertImagePrompt.bind(this) }, { action: "link", title: "EDITOR.Link", icon: '', scope: scopes.TEXT, mark: this.schema.marks.link, cmd: this._insertLinkPrompt.bind(this) }, { action: "clear-formatting", title: "EDITOR.ClearFormatting", icon: '', scope: scopes.TEXT, cmd: this._clearFormatting.bind(this) }, { action: "cancel-html", title: "EDITOR.DiscardHTML", icon: '', scope: scopes.HTML, cmd: this.#clearSourceTextarea.bind(this) } ]; if ( this.view.state.plugins.some(p => p.spec.isHighlightMatchesPlugin) ) { items.push({ action: "toggle-matches", title: "EDITOR.EnableHighlightDocumentMatches", icon: '', scope: scopes.TEXT, cssClass: "toggle-matches", cmd: this._toggleMatches.bind(this), active: game.settings.get("core", "pmHighlightDocumentMatches") }); } if ( this.options.onSave ) { items.push({ action: "save", title: `EDITOR.${this.options.destroyOnSave ? "SaveAndClose" : "Save"}`, icon: ``, scope: scopes.BOTH, cssClass: "right", cmd: this._handleSave.bind(this) }); } items.push({ action: "source-code", title: "EDITOR.SourceHTML", icon: '', scope: scopes.BOTH, cssClass: "source-code-edit right", cmd: this.#toggleSource.bind(this) }); Hooks.callAll("getProseMirrorMenuItems", this, items); return items; } /* -------------------------------------------- */ /** * Determine whether the given menu item is currently active or not. * @param {ProseMirrorMenuItem} item The menu item. * @returns {boolean} Whether the cursor or selection is in a state represented by the given menu * item. * @protected */ _isItemActive(item) { if ( item.action === "source-code" ) return !!this.#editingSource; if ( item.action === "toggle-matches" ) return game.settings.get("core", "pmHighlightDocumentMatches"); if ( item.mark ) return this._isMarkActive(item); if ( item.node ) return this._isNodeActive(item); return false; } /* -------------------------------------------- */ /** * Determine whether the given menu item representing a mark is active or not. * @param {ProseMirrorMenuItem} item The menu item representing a {@link MarkType}. * @returns {boolean} Whether the cursor or selection is in a state represented by the given mark. * @protected */ _isMarkActive(item) { const state = this.view.state; const {from, $from, to, empty} = state.selection; const markCompare = mark => { if ( mark.type !== item.mark ) return false; const attrs = foundry.utils.deepClone(mark.attrs); delete attrs._preserve; if ( item.attrs ) return foundry.utils.objectsEqual(attrs, item.attrs); return true; }; if ( empty ) return $from.marks().some(markCompare); let active = false; state.doc.nodesBetween(from, to, node => { if ( node.marks.some(markCompare) ) active = true; return !active; }); return active; } /* -------------------------------------------- */ /** * Determine whether the given menu item representing a node is active or not. * @param {ProseMirrorMenuItem} item The menu item representing a {@link NodeType}. * @returns {boolean} Whether the cursor or selection is currently within a block of this menu item's * node type. * @protected */ _isNodeActive(item) { const state = this.view.state; const {$from, $to, empty} = state.selection; const sameParent = empty || $from.sameParent($to); // If the selection spans multiple nodes, give up on detecting whether we're in a given block. // TODO: Add more complex logic for detecting if all selected nodes belong to the same parent. if ( !sameParent ) return false; return (state.doc.nodeAt($from.pos)?.type === item.node) || $from.hasAncestor(item.node, item.attrs); } /* -------------------------------------------- */ /** * Handle a button press. * @param {MouseEvent} event The click event. * @protected */ _onAction(event) { event.preventDefault(); const action = event.currentTarget.dataset.action; let item; // Check dropdowns first this.dropdowns.forEach(d => d.forEachItem(i => { if ( i.action !== action ) return; item = i; return false; })); // Menu items if ( !item ) item = this.items.find(i => i.action === action); item?.cmd?.(this.view.state, this.view.dispatch, this.view); // Destroy the dropdown, if present, & refocus the editor. document.getElementById("prosemirror-dropdown")?.remove(); this.view.focus(); } /* -------------------------------------------- */ /** * Wrap the editor view element and inject our template ready to be rendered into. * @protected */ _wrapEditor() { const wrapper = document.createElement("div"); const template = document.createElement("template"); wrapper.classList.add("editor-container"); template.setAttribute("id", this.id); this.view.dom.before(template); this.view.dom.replaceWith(wrapper); wrapper.appendChild(this.view.dom); } /* -------------------------------------------- */ /** * Handle requests to save the editor contents * @protected */ _handleSave() { if ( this.#editingSource ) this.#commitSourceTextarea(); return this.options.onSave?.(); } /* -------------------------------------------- */ /** * Global listeners for the drop-down menu. */ static eventListeners() { document.addEventListener("pointerdown", event => { if ( !event.target.closest("#prosemirror-dropdown") ) { document.getElementById("prosemirror-dropdown")?.remove(); } }, { passive: true, capture: true }); } /* -------------------------------------------- */ /* Source Code Textarea Management */ /* -------------------------------------------- */ /** * Handle a request to edit the source HTML directly. * @protected */ #toggleSource () { if ( this.editingSource ) return this.#commitSourceTextarea(); this.#activateSourceTextarea(); } /* -------------------------------------------- */ /** * Conclude editing the source HTML textarea. Clear its contents and return the HTML which was contained. * @returns {string} The HTML text contained within the textarea before it was cleared */ #clearSourceTextarea() { const editor = this.view.dom.closest(".editor"); const textarea = editor.querySelector(":scope > textarea"); const html = textarea.value; textarea.remove(); this.#editingSource = false; this.items.find(i => i.action === "source-code").active = false; this.render(); return html; } /* -------------------------------------------- */ /** * Create and activate the source code editing textarea */ #activateSourceTextarea() { const editor = this.view.dom.closest(".editor"); const original = ProseMirror.dom.serializeString(this.view.state.doc.content, {spaces: 4}); const textarea = document.createElement("textarea"); textarea.value = original; editor.appendChild(textarea); textarea.addEventListener("keydown", event => this.#handleSourceKeydown(event)); this.#editingSource = true; this.items.find(i => i.action === "source-code").active = true; this.render(); } /* -------------------------------------------- */ /** * Commit changes from the source textarea to the view. */ #commitSourceTextarea() { const html = this.#clearSourceTextarea(); const newDoc = ProseMirror.dom.parseString(html); const selection = new ProseMirror.AllSelection(this.view.state.doc); this.view.dispatch(this.view.state.tr.setSelection(selection).replaceSelectionWith(newDoc)); } /* -------------------------------------------- */ /** * Handle keypresses while editing editor source. * @param {KeyboardEvent} event The keyboard event. */ #handleSourceKeydown(event) { if ( game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL) && (event.key === "s") ) { event.preventDefault(); this._handleSave(); } } /* -------------------------------------------- */ /** * Display the insert image prompt. * @protected */ async _insertImagePrompt() { const state = this.view.state; const { $from, empty } = state.selection; const image = this.schema.nodes.image; const data = { src: "", alt: "", width: "", height: "" }; if ( !empty ) { const selected = state.doc.nodeAt($from.pos); Object.assign(data, selected?.attrs ?? {}); } const dialog = await this._showDialog("image", "templates/journal/insert-image.html", { data }); const form = dialog.querySelector("form"); const src = form.elements.src; form.elements.save.addEventListener("click", () => { if ( !src.value ) return; this.view.dispatch(this.view.state.tr.replaceSelectionWith(image.create({ src: src.value, alt: form.elements.alt.value, width: form.elements.width.value, height: form.elements.height.value })).scrollIntoView()); }); } /* -------------------------------------------- */ /** * Display the insert link prompt. * @protected */ async _insertLinkPrompt() { const state = this.view.state; const {$from, $to, $cursor} = state.selection; // Capture the selected text. const selection = state.selection.content().content; const data = {text: selection.textBetween(0, selection.size), href: "", title: ""}; // Check if the user has placed the cursor within a single link, or has selected a single link. let links = []; state.doc.nodesBetween($from.pos, $to.pos, (node, pos) => { if ( node.marks.some(m => m.type === this.schema.marks.link) ) links.push([node, pos]); }); const existing = links.length === 1 && links[0]; if ( existing ) { const [node] = existing; if ( $cursor ) data.text = node.text; // Pre-fill the dialog with the existing link's attributes. const link = node.marks.find(m => m.type === this.schema.marks.link); data.href = link.attrs.href; data.title = link.attrs.title; } const dialog = await this._showDialog("link", "templates/journal/insert-link.html", {data}); const form = dialog.querySelector("form"); form.elements.save.addEventListener("click", () => { const href = form.elements.href.value; const text = form.elements.text.value || href; if ( !href ) return; const link = this.schema.marks.link.create({href, title: form.elements.title.value}); const tr = state.tr; // The user has placed the cursor within a link they wish to edit. if ( existing && $cursor ) { const [node, pos] = existing; const selection = TextSelection.create(state.doc, pos, pos + node.nodeSize); tr.setSelection(selection); } tr.addStoredMark(link).replaceSelectionWith(this.schema.text(text)).scrollIntoView(); this.view.dispatch(tr); }); } /* -------------------------------------------- */ /** * Display the insert table prompt. * @protected */ async _insertTablePrompt() { const dialog = await this._showDialog("insert-table", "templates/journal/insert-table.html"); const form = dialog.querySelector("form"); form.elements.save.addEventListener("click", () => { const rows = Number(form.elements.rows.value) || 1; const cols = Number(form.elements.cols.value) || 1; const html = ` ${Array.fromRange(rows).reduce(row => row + ` ${Array.fromRange(cols).reduce(col => col + "", "")} `, "")}
        `; const table = ProseMirror.dom.parseString(html, this.schema); this.view.dispatch(this.view.state.tr.replaceSelectionWith(table).scrollIntoView()); }); } /* -------------------------------------------- */ /** * Create a dialog for a menu button. * @param {string} action The unique menu button action. * @param {string} template The dialog's template. * @param {object} [options] Additional options to configure the dialog's behaviour. * @param {object} [options.data={}] Data to pass to the template. * @returns {HTMLDialogElement} * @protected */ async _showDialog(action, template, {data={}}={}) { let button = document.getElementById("prosemirror-dropdown")?.querySelector(`[data-action="${action}"]`); button ??= this.view.dom.closest(".editor").querySelector(`[data-action="${action}"]`); button.classList.add("active"); const rect = button.getBoundingClientRect(); const dialog = document.createElement("dialog"); dialog.classList.add("menu-dialog", "prosemirror"); dialog.innerHTML = await renderTemplate(template, data); document.body.appendChild(dialog); dialog.addEventListener("click", event => { if ( event.target.closest("form") ) return; button.classList.remove("active"); dialog.remove(); }); const form = dialog.querySelector("form"); form.style.top = `${rect.top + 30}px`; form.style.left = `${rect.left - 200 + 15}px`; dialog.style.zIndex = ++_maxZ; form.elements.save?.addEventListener("click", () => { button.classList.remove("active"); dialog.remove(); this.view.focus(); }); dialog.open = true; return dialog; } /* -------------------------------------------- */ /** * Clear any marks from the current selection. * @protected */ _clearFormatting() { const state = this.view.state; const {empty, $from, $to} = state.selection; if ( empty ) return; const tr = this.view.state.tr; for ( const markType of Object.values(this.schema.marks) ) { if ( state.doc.rangeHasMark($from.pos, $to.pos, markType) ) tr.removeMark($from.pos, $to.pos, markType); } const range = $from.blockRange($to); const nodePositions = []; // Capture any nodes that are completely encompassed by the selection, or ones that begin and end exactly at the // selection boundaries (i.e., the user has selected all text inside the node). tr.doc.nodesBetween($from.pos, $to.pos, (node, pos) => { if ( node.isText ) return false; // Node is entirely contained within the selection. if ( (pos >= range.start) && (pos + node.nodeSize <= range.end) ) nodePositions.push(pos); }); // Clear marks and attributes from all eligible nodes. nodePositions.forEach(pos => { const node = state.doc.nodeAt(pos); const attrs = {...node.attrs}; for ( const [attr, spec] of Object.entries(node.type.spec.attrs) ) { if ( spec.formatting ) delete attrs[attr]; } tr.setNodeMarkup(pos, null, attrs); }); this.view.dispatch(tr); } /* -------------------------------------------- */ /** * Toggle link recommendations * @protected */ async _toggleMatches() { const enabled = game.settings.get("core", "pmHighlightDocumentMatches"); await game.settings.set("core", "pmHighlightDocumentMatches", !enabled); this.items.find(i => i.action === "toggle-matches").active = !enabled; this.render(); } /* -------------------------------------------- */ /** * Inserts a horizontal rule at the cursor. */ #insertHorizontalRule() { const hr = this.schema.nodes.horizontal_rule; this.view.dispatch(this.view.state.tr.replaceSelectionWith(hr.create()).scrollIntoView()); } /* -------------------------------------------- */ /** * Toggle a particular alignment for the given selection. * @param {string} alignment The text alignment to toggle. */ #toggleAlignment(alignment) { const state = this.view.state; const {$from, $to} = state.selection; const range = $from.blockRange($to); if ( !range ) return; const {paragraph, image} = this.schema.nodes; const positions = []; // The range positions are absolute, so we need to convert them to be relative to the parent node. const blockStart = range.parent.eq(state.doc) ? 0 : range.start; // Calculate the positions of all the paragraph nodes that are direct descendents of the blockRange parent node. range.parent.nodesBetween(range.start - blockStart, range.end - blockStart, (node, pos) => { if ( ![paragraph, image].includes(node.type) ) return false; positions.push({pos: blockStart + pos, attrs: node.attrs}); }); const tr = state.tr; positions.forEach(({pos, attrs}) => { const node = state.doc.nodeAt(pos); tr.setNodeMarkup(pos, null, { ...attrs, alignment: attrs.alignment === alignment ? node.type.attrs.alignment.default : alignment }); }); this.view.dispatch(tr); } /* -------------------------------------------- */ /** * @callback MenuToggleBlockWrapCommand * @param {NodeType} node The node to wrap the selection in. * @param {object} [attrs] Attributes for the node. * @returns ProseMirrorCommand */ /** * Toggle the given selection by wrapping it in a given block or lifting it out of one. * @param {NodeType} node The type of node being interacted with. * @param {MenuToggleBlockWrapCommand} wrap The wrap command specific to the given node. * @param {object} [options] Additional options to configure behaviour. * @param {object} [options.attrs] Attributes for the node. * @protected */ _toggleBlock(node, wrap, {attrs=null}={}) { const state = this.view.state; const {$from, $to} = state.selection; const range = $from.blockRange($to); if ( !range ) return; const inBlock = $from.hasAncestor(node); if ( inBlock ) { // FIXME: This will lift out of the closest block rather than only the given one, and doesn't work on multiple // list elements. const target = liftTarget(range); if ( target != null ) this.view.dispatch(state.tr.lift(range, target)); } else autoJoin(wrap(node, attrs), [node.name])(state, this.view.dispatch); } /* -------------------------------------------- */ /** * Toggle the given selection by wrapping it in a given text block, or reverting to a paragraph block. * @param {NodeType} node The type of node being interacted with. * @param {object} [options] Additional options to configure behaviour. * @param {object} [options.attrs] Attributes for the node. * @protected */ _toggleTextBlock(node, {attrs=null}={}) { const state = this.view.state; const {$from, $to} = state.selection; const range = $from.blockRange($to); if ( !range ) return; const inBlock = $from.hasAncestor(node, attrs); if ( inBlock ) node = this.schema.nodes.paragraph; this.view.dispatch(state.tr.setBlockType(range.start, range.end, node, attrs)); } } /** * 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; }; class Rebaseable { constructor(step, inverted, origin) { this.step = step; this.inverted = inverted; this.origin = origin; } } /** Undo a given set of steps, apply a set of other steps, and then redo them @internal */ function rebaseSteps(steps, over, transform) { for (let i = steps.length - 1; i >= 0; i--) transform.step(steps[i].inverted); for (let i = 0; i < over.length; i++) transform.step(over[i]); let result = []; for (let i = 0, mapFrom = steps.length; i < steps.length; i++) { let mapped = steps[i].step.map(transform.mapping.slice(mapFrom)); mapFrom--; if (mapped && !transform.maybeStep(mapped).failed) { transform.mapping.setMirror(mapFrom, transform.steps.length - 1); result.push(new Rebaseable(mapped, mapped.invert(transform.docs[transform.docs.length - 1]), steps[i].origin)); } } return result; } // This state field accumulates changes that have to be sent to the // central authority in the collaborating group and makes it possible // to integrate changes made by peers into our local document. It is // defined by the plugin, and will be available as the `collab` field // in the resulting editor state. class CollabState { constructor( // The version number of the last update received from the central // authority. Starts at 0 or the value of the `version` property // in the option object, for the editor's value when the option // was enabled. version, // The local steps that havent been successfully sent to the // server yet. unconfirmed) { this.version = version; this.unconfirmed = unconfirmed; } } function unconfirmedFrom(transform) { let result = []; for (let i = 0; i < transform.steps.length; i++) result.push(new Rebaseable(transform.steps[i], transform.steps[i].invert(transform.docs[i]), transform)); return result; } const collabKey = new PluginKey("collab"); /** Creates a plugin that enables the collaborative editing framework for the editor. */ function collab(config = {}) { let conf = { version: config.version || 0, clientID: config.clientID == null ? Math.floor(Math.random() * 0xFFFFFFFF) : config.clientID }; return new Plugin({ key: collabKey, state: { init: () => new CollabState(conf.version, []), apply(tr, collab) { let newState = tr.getMeta(collabKey); if (newState) return newState; if (tr.docChanged) return new CollabState(collab.version, collab.unconfirmed.concat(unconfirmedFrom(tr))); return collab; } }, config: conf, // This is used to notify the history plugin to not merge steps, // so that the history can be rebased. historyPreserveItems: true }); } /** Create a transaction that represents a set of new steps received from the authority. Applying this transaction moves the state forward to adjust to the authority's view of the document. */ function receiveTransaction(state, steps, clientIDs, options = {}) { // Pushes a set of steps (received from the central authority) into // the editor state (which should have the collab plugin enabled). // Will recognize its own changes, and confirm unconfirmed steps as // appropriate. Remaining unconfirmed steps will be rebased over // remote steps. let collabState = collabKey.getState(state); let version = collabState.version + steps.length; let ourID = collabKey.get(state).spec.config.clientID; // Find out which prefix of the steps originated with us let ours = 0; while (ours < clientIDs.length && clientIDs[ours] == ourID) ++ours; let unconfirmed = collabState.unconfirmed.slice(ours); steps = ours ? steps.slice(ours) : steps; // If all steps originated with us, we're done. if (!steps.length) return state.tr.setMeta(collabKey, new CollabState(version, unconfirmed)); let nUnconfirmed = unconfirmed.length; let tr = state.tr; if (nUnconfirmed) { unconfirmed = rebaseSteps(unconfirmed, steps, tr); } else { for (let i = 0; i < steps.length; i++) tr.step(steps[i]); unconfirmed = []; } let newCollabState = new CollabState(version, unconfirmed); if (options && options.mapSelectionBackward && state.selection instanceof TextSelection) { tr.setSelection(TextSelection.between(tr.doc.resolve(tr.mapping.map(state.selection.anchor, -1)), tr.doc.resolve(tr.mapping.map(state.selection.head, -1)), -1)); tr.updated &= ~1; } return tr.setMeta("rebased", nUnconfirmed).setMeta("addToHistory", false).setMeta(collabKey, newCollabState); } /** Provides data describing the editor's unconfirmed steps, which need to be sent to the central authority. Returns null when there is nothing to send. `origins` holds the _original_ transactions that produced each steps. This can be useful for looking up time stamps and other metadata for the steps, but note that the steps may have been rebased, whereas the origin transactions are still the old, unchanged objects. */ function sendableSteps(state) { let collabState = collabKey.getState(state); if (collabState.unconfirmed.length == 0) return null; return { version: collabState.version, steps: collabState.unconfirmed.map(s => s.step), clientID: collabKey.get(state).spec.config.clientID, get origins() { return this._origins || (this._origins = collabState.unconfirmed.map(s => s.origin)); } }; } /** Get the version up to which the collab plugin has synced with the central authority. */ function getVersion(state) { return collabKey.getState(state).version; } var index = /*#__PURE__*/Object.freeze({ __proto__: null, collab: collab, getVersion: getVersion, rebaseSteps: rebaseSteps, receiveTransaction: receiveTransaction, sendableSteps: sendableSteps }); class DOMParser extends DOMParser$1 { /** @inheritdoc */ parse(dom, options) { this.#unwrapImages(dom); return super.parse(dom, options); } /* -------------------------------------------- */ /** * Unwrap any image tags that may have been wrapped in

        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)); } } /** * @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. */ class StringSerializer { /** * @param {Record} nodes The node output specs. * @param {Record} marks The mark output specs. */ constructor(nodes, marks) { this.#nodes = nodes; this.#marks = marks; } /* -------------------------------------------- */ /** * The node output specs. * @type {Record} */ #nodes; /* -------------------------------------------- */ /** * The mark output specs. * @type {Record} */ #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: * Almost before we knew it, we had left the ground. * 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} [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} */ 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} */ 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} * @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$1(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}` : ""; 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; }); } } /** * Use the DOM and ProseMirror's DOMParser to construct a ProseMirror document state from an HTML string. This cannot be * used server-side. * @param {string} htmlString A string of HTML. * @param {Schema} [schema] The ProseMirror schema to use instead of the default one. * @returns {Node} The document node. */ function parseHTMLString(htmlString, schema$1) { const target = document.createElement("template"); target.innerHTML = htmlString; return DOMParser.fromSchema(schema$1 ?? schema).parse(target.content); } /** * Use the StringSerializer to convert a ProseMirror document into an HTML string. This can be used server-side. * @param {Node} doc The ProseMirror document. * @param {object} [options] Additional options to configure serialization behavior. * @param {Schema} [options.schema] The ProseMirror schema to use instead of the default one. * @param {string|number} [options.spaces] The number of spaces to use for indentation. See {@link StringNode#toString} * for details. * @returns {string} */ function serializeHTMLString(doc, {schema: schema$1, spaces}={}) { schema$1 = schema$1 ?? schema; // If the only content is an empty

        tag, return an empty string. if ( (doc.size < 3) && (doc.content[0].type === schema$1.nodes.paragraph) ) return ""; return StringSerializer.fromSchema(schema$1).serializeFragment(doc.content).toString(spaces); } /** * @callback ProseMirrorSliceTransformer * @param {Node} node The candidate node. * @returns {Node|void} A new node to replace the candidate node, or nothing if a replacement should not be made. */ /** * Apply a transformation to some nodes in a slice, and return the new slice. * @param {Slice} slice The slice to transform. * @param {function} transformer The transformation function. * @returns {Slice} Either the original slice if no changes were made, or the newly-transformed slice. */ function transformSlice(slice, transformer) { const nodeTree = new Map(); slice.content.nodesBetween(0, slice.content.size, (node, start, parent, index) => { nodeTree.set(node, { parent, index }); }); let newSlice; const replaceNode = (node, { parent, index }) => { // If there is a parent, make the replacement, then recurse up the tree to the root, creating new nodes as we go. if ( parent ) { const newContent = parent.content.replaceChild(index, node); const newParent = parent.copy(newContent); replaceNode(newParent, nodeTree.get(parent)); return; } // Otherwise, handle replacing the root slice's content. const targetSlice = newSlice ?? slice; const fragment = targetSlice.content; const newFragment = fragment.replaceChild(index, node); newSlice = new Slice(newFragment, targetSlice.openStart, targetSlice.openEnd); }; for ( const [node, treeInfo] of nodeTree.entries() ) { const newNode = transformer(node); if ( newNode ) replaceNode(newNode, treeInfo); } return newSlice ?? slice; } 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]; } }; /* -------------------------------------------- */ const blockquote = { content: "block+", group: "block", defining: true, parseDOM: [{tag: "blockquote"}], toDOM: () => ["blockquote", 0] }; /* -------------------------------------------- */ const hr = { group: "block", parseDOM: [{tag: "hr"}], toDOM: () => ["hr"] }; /* -------------------------------------------- */ 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] }; /* -------------------------------------------- */ const pre = { content: "text*", marks: "", group: "block", code: true, defining: true, parseDOM: [{tag: "pre", preserveWhitespace: "full"}], toDOM: () => ["pre", ["code", 0]] }; /* -------------------------------------------- */ const br = { inline: true, group: "inline", selectable: false, parseDOM: [{tag: "br"}], toDOM: () => ["br"] }; // 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} */ 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} */ 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} */ 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} */ 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[]} */ 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} */ function mergeClass(a, b) { const allClasses = classesFromString(a).concat(classesFromString(b)); return Array.from(new Set(allClasses)).join(" "); } 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] }; /* -------------------------------------------- */ 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: *
          *
        • * The first list item. *
            *
          • An embedded list.
          • *
          *
        • *
        * * But, since the contents of the
      • would mix inline content (the text), with block content (the inner
          ), the * schema is defined to only allow block content, and would transform the items to look like this: *
            *
          • *

            The first list item.

            *
              *
            • An embedded list.

            • *
            *
          • *
          * * 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. 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] }; /* -------------------------------------------- */ 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] }; 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 */ /* -------------------------------------------- */ const builtInTableNodes = tableNodes({ tableGroup: "block", cellContent: "block+" }); /* -------------------------------------------- */ /* 'Complex' Tables */ /* -------------------------------------------- */ 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] }; /* -------------------------------------------- */ const colgroup = { content: "col*", isolating: true, parseDOM: [{tag: "colgroup"}], toDOM: () => ["colgroup", 0] }; /* -------------------------------------------- */ const col = { tableRole: "col", parseDOM: [{tag: "col"}], toDOM: () => ["col"] }; /* -------------------------------------------- */ const thead = { content: "table_row_complex+", isolating: true, parseDOM: [{tag: "thead"}], toDOM: () => ["thead", 0] }; /* -------------------------------------------- */ const tbody = { content: "table_row_complex+", isolating: true, parseDOM: [{tag: "tbody", getAttrs: el => { if ( inComplexTable(el) === false ) return false; }}], toDOM: () => ["tbody", 0] }; /* -------------------------------------------- */ const tfoot = { content: "table_row_complex+", isolating: true, parseDOM: [{tag: "tfoot"}], toDOM: () => ["tfoot", 0] }; /* -------------------------------------------- */ const caption = { content: "text*", isolating: true, parseDOM: [{tag: "caption", getAttrs: el => { if ( !isElementEmpty(el) && !onlyInlineContent(el) ) return false; }}], toDOM: () => ["caption", 0] }; /* -------------------------------------------- */ const captionBlock = { content: "block*", isolating: true, parseDOM: [{tag: "caption", getAttrs: el => { if ( isElementEmpty(el) || onlyInlineContent(el) ) return false; }}], toDOM: () => ["caption", 0] }; /* -------------------------------------------- */ 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] }; /* -------------------------------------------- */ 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] }; /* -------------------------------------------- */ 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] }; /* -------------------------------------------- */ 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] }; /* -------------------------------------------- */ 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] }; // These nodes are supported for HTML preservation purposes, but do not have robust editing support for now. const details = { content: "(summary | summary_block) block*", group: "block", defining: true, parseDOM: [{tag: "details"}], toDOM: () => ["details", 0] }; /* -------------------------------------------- */ 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] }; /* -------------------------------------------- */ 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] }; /* -------------------------------------------- */ const dl = { content: "(block|dt|dd)*", group: "block", defining: true, parseDOM: [{tag: "dl"}], toDOM: () => ["dl", 0] }; /* -------------------------------------------- */ const dt = { content: "block+", defining: true, parseDOM: [{tag: "dt"}], toDOM: () => ["dt", 0] }; /* -------------------------------------------- */ const dd = { content: "block+", defining: true, parseDOM: [{tag: "dd"}], toDOM: () => ["dd", 0] }; /* -------------------------------------------- */ const fieldset = { content: "legend block*", group: "block", defining: true, parseDOM: [{tag: "fieldset"}], toDOM: () => ["fieldset", 0] }; /* -------------------------------------------- */ const legend = { content: "inline+", defining: true, parseDOM: [{tag: "legend"}], toDOM: () => ["legend", 0] }; /* -------------------------------------------- */ const picture = { content: "source* image", group: "block", defining: true, parseDOM: [{tag: "picture"}], toDOM: () => ["picture", 0] }; /* -------------------------------------------- */ const audio$1 = { content: "source* track*", group: "block", parseDOM: [{tag: "audio"}], toDOM: () => ["audio", 0] }; /* -------------------------------------------- */ const video = { content: "source* track*", group: "block", parseDOM: [{tag: "video"}], toDOM: () => ["video", 0] }; /* -------------------------------------------- */ const track = { parseDOM: [{tag: "track"}], toDOM: () => ["track"] }; /* -------------------------------------------- */ const source = { parseDOM: [{tag: "source"}], toDOM: () => ["source"] }; /* -------------------------------------------- */ const object = { inline: true, group: "inline", parseDOM: [{tag: "object"}], toDOM: () => ["object"] }; /* -------------------------------------------- */ const figure = { content: "(figcaption|block)*", group: "block", defining: true, parseDOM: [{tag: "figure"}], toDOM: () => ["figure", 0] }; /* -------------------------------------------- */ const figcaption = { content: "inline+", defining: true, parseDOM: [{tag: "figcaption"}], toDOM: () => ["figcaption", 0] }; /* -------------------------------------------- */ const small = { content: "paragraph block*", group: "block", defining: true, parseDOM: [{tag: "small"}], toDOM: () => ["small", 0] }; /* -------------------------------------------- */ const ruby = { content: "(rp|rt|block)+", group: "block", defining: true, parseDOM: [{tag: "ruby"}], toDOM: () => ["ruby", 0] }; /* -------------------------------------------- */ const rp = { content: "inline+", parseDOM: [{tag: "rp"}], toDOM: () => ["rp", 0] }; /* -------------------------------------------- */ const rt = { content: "inline+", parseDOM: [{tag: "rt"}], toDOM: () => ["rt", 0] }; /* -------------------------------------------- */ 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]; } }; const em = { parseDOM: [{tag: "i"}, {tag: "em"}, {style: "font-style=italic"}], toDOM: () => ["em", 0] }; /* -------------------------------------------- */ const strong = { parseDOM: [ {tag: "strong"}, {tag: "b"}, {style: "font-weight", getAttrs: weight => /^(bold(er)?|[5-9]\d{2})$/.test(weight) && null} ], toDOM: () => ["strong", 0] }; /* -------------------------------------------- */ const code = { parseDOM: [{tag: "code"}], toDOM: () => ["code", 0] }; /* -------------------------------------------- */ const underline = { parseDOM: [{tag: "u"}, {style: "text-decoration=underline"}], toDOM: () => ["span", {style: "text-decoration: underline;"}, 0] }; /* -------------------------------------------- */ const strikethrough = { parseDOM: [{tag: "s"}, {tag: "del"}, {style: "text-decoration=line-through"}], toDOM: () => ["s", 0] }; /* -------------------------------------------- */ const superscript = { parseDOM: [{tag: "sup"}, {style: "vertical-align=super"}], toDOM: () => ["sup", 0] }; /* -------------------------------------------- */ const subscript = { parseDOM: [{tag: "sub"}, {style: "vertical-align=sub"}], toDOM: () => ["sub", 0] }; /* -------------------------------------------- */ const span = { parseDOM: [{tag: "span", getAttrs: el => { if ( el.style.fontFamily ) return false; return {}; }}], toDOM: () => ["span", 0] }; /* -------------------------------------------- */ const font = { attrs: { family: {} }, parseDOM: [{style: "font-family", getAttrs: family => ({family})}], toDOM: node => ["span", {style: `font-family: ${node.attrs.family.replaceAll('"', "'")}`}] }; /** * An abstract interface for a ProseMirror schema definition. * @abstract */ class SchemaDefinition { /* -------------------------------------------- */ /* Properties */ /* -------------------------------------------- */ /** * The HTML tag selector this node is associated with. * @type {string} */ static tag = ""; /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** * Schema attributes. * @returns {Record} * @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) }; } } /** * A class responsible for encapsulating logic around image nodes in the ProseMirror schema. * @extends {SchemaDefinition} */ 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 }); } } /** * A class responsible for encapsulating logic around link marks in the ProseMirror schema. * @extends {SchemaDefinition} */ 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; } } /** * A class responsible for encapsulating logic around image-link nodes in the ProseMirror schema. * @extends {SchemaDefinition} */ 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; } } /** * A class responsible for encapsulating logic around secret nodes in the ProseMirror schema. * @extends {SchemaDefinition} */ 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; } } /** * @typedef {object} AllowedAttributeConfiguration * @property {Set} 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. */ class AttributeCapture { constructor() { this.#parseAllowedAttributesConfig(ALLOWED_HTML_ATTRIBUTES ?? {}); } /* -------------------------------------------- */ /** * The configuration of attributes that are allowed on HTML elements. * @type {Record} */ #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} 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; } } const doc = { content: "block+" }; const text = { group: "inline" }; const secret = SecretNode.make(); const link = LinkMark.make(); const image = ImageNode.make(); const imageLink = ImageLinkNode.make(); const nodes = { // Core Nodes. doc, text, paragraph, blockquote, secret, horizontal_rule: hr, heading, code_block: pre, image_link: imageLink, image, hard_break: br, // Lists. ordered_list: ol, bullet_list: ul, list_item: li, list_item_text: liText, // Tables table_complex: tableComplex, tbody, thead, tfoot, caption, caption_block: captionBlock, colgroup, col, table_row_complex: tableRowComplex, table_cell_complex: tableCellComplex, table_header_complex: tableHeaderComplex, table_cell_complex_block: tableCellComplexBlock, table_header_complex_block: tableHeaderComplexBlock, ...builtInTableNodes, // Misc. details, summary, summary_block: summaryBlock, dl, dt, dd, fieldset, legend, picture, audio: audio$1, video, track, source, object, figure, figcaption, small, ruby, rp, rt, iframe }; 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)); 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; /** * 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} */ 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}); } } /** * A simple plugin that records the dirty state of the editor. * @extends {ProseMirrorPlugin} */ 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. } } }); } } /** * A class responsible for handling the dropping of Documents onto the editor and creating content links for them. * @extends {ProseMirrorPlugin} */ 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; } } /** * 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); 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 += `

          ${type}

          `; for ( const document of collection ) { html += document.entry?.link ? document.entry.link : `@UUID[${document.uuid}]{${document.entry.name}}`; } html += "

          "; } return html; } } /** * A ProseMirrorPlugin wrapper around the {@link PossibleMatchesTooltip} class. * @extends {ProseMirrorPlugin} */ 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 }); } } /** * A class responsible for managing click events inside a ProseMirror editor. * @extends {ProseMirrorPlugin} */ 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); } } /** * A class responsible for applying transformations to content pasted inside the editor. */ 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); } }); } } /** @module prosemirror */ const dom = { parser: DOMParser.fromSchema(schema), serializer: DOMSerializer.fromSchema(schema), parseString: parseHTMLString, serializeString: serializeHTMLString }; const defaultPlugins = { inputRules: ProseMirrorInputRules.build(schema), keyMaps: ProseMirrorKeyMaps.build(schema), menu: ProseMirrorMenu.build(schema), isDirty: ProseMirrorDirtyPlugin.build(schema), clickHandler: ProseMirrorClickHandler.build(schema), pasteTransformer: ProseMirrorPasteTransformer.build(schema), baseKeyMap: keymap(baseKeymap), dropCursor: dropCursor(), gapCursor: gapCursor(), history: history(), columnResizing: columnResizing(), tables: tableEditing() }; var prosemirror = /*#__PURE__*/Object.freeze({ __proto__: null, AllSelection: AllSelection, DOMParser: DOMParser, DOMSerializer: DOMSerializer, EditorState: EditorState, EditorView: EditorView, Plugin: Plugin, PluginKey: PluginKey, ProseMirrorClickHandler: ProseMirrorClickHandler, ProseMirrorContentLinkPlugin: ProseMirrorContentLinkPlugin, ProseMirrorDirtyPlugin: ProseMirrorDirtyPlugin, ProseMirrorHighlightMatchesPlugin: ProseMirrorHighlightMatchesPlugin, ProseMirrorImagePlugin: ProseMirrorImagePlugin, ProseMirrorInputRules: ProseMirrorInputRules, ProseMirrorKeyMaps: ProseMirrorKeyMaps, ProseMirrorMenu: ProseMirrorMenu, ProseMirrorPlugin: ProseMirrorPlugin, Schema: Schema, Step: Step, TextSelection: TextSelection, collab: index, commands: index$3, defaultPlugins: defaultPlugins, defaultSchema: schema, dom: dom, input: index$4, keymap: keymap, list: index$2, state: index$5, tables: index$1, transform: index$6 }); /** * @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 */ 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$1.from(color); if ( !color?.valid ) color = new Color$1(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); } } /** * @typedef {object} _HexagonalGridConfiguration * @property {boolean} [columns=false] Is this grid column-based (flat-topped) or row-based (pointy-topped)? * @property {boolean} [even=false] Is this grid even or odd? */ /** * @typedef {GridConfiguration&_HexagonalGridConfiguration} HexagonalGridConfiguration */ /** * Cube coordinates in a hexagonal grid. q + r + s = 0. * @typedef {object} HexagonalGridCube * @property {number} q The coordinate along the E-W (columns) or SW-NE (rows) axis. * Equal to the offset column coordinate if column orientation. * @property {number} r The coordinate along the NE-SW (columns) or N-S (rows) axis. * Equal to the offset row coordinate if row orientation. * @property {number} s The coordinate along the SE-NW axis. */ /** * Hex cube coordinates, an offset of a grid space, or a point with pixel coordinates. * @typedef {GridCoordinates|HexagonalGridCube} HexagonalGridCoordinates */ /* -------------------------------------------- */ /** * The hexagonal grid class. */ let HexagonalGrid$1 = class HexagonalGrid extends BaseGrid { /** * The hexagonal grid constructor. * @param {HexagonalGridConfiguration} config The grid configuration */ constructor(config) { super(config); const {columns, even} = config; /** * Is this grid column-based (flat-topped) or row-based (pointy-topped)? * @type {boolean} */ this.columns = !!columns; /** * Is this grid even or odd? * @type {boolean} */ this.even = !!even; // Set the type and size of the grid if ( columns ) { if ( even ) this.type = GRID_TYPES.HEXEVENQ; else this.type = GRID_TYPES.HEXODDQ; this.sizeX *= (2 * Math.SQRT1_3); } else { if ( even ) this.type = GRID_TYPES.HEXEVENR; else this.type = GRID_TYPES.HEXODDR; this.sizeY *= (2 * Math.SQRT1_3); } } /* -------------------------------------------- */ /** * Returns the offset of the grid space corresponding to the given coordinates. * @param {HexagonalGridCoordinates} coords The coordinates * @returns {GridOffset} The offset */ getOffset(coords) { if ( coords.i !== undefined ) return {i: coords.i, j: coords.j}; const cube = coords.q !== undefined ? coords : this.pointToCube(coords); return this.cubeToOffset(HexagonalGrid.cubeRound(cube)); } /* -------------------------------------------- */ /** @override */ getOffsetRange({x, y, width, height}) { const x0 = x; const y0 = y; const {i: i00, j: j00} = this.getOffset({x: x0, y: y0}); if ( !((width > 0) && (height > 0)) ) return [i00, j00, i00, j00]; const x1 = x + width; const y1 = y + height; const {i: i01, j: j01} = this.getOffset({x: x1, y: y0}); const {i: i10, j: j10} = this.getOffset({x: x0, y: y1}); const {i: i11, j: j11} = this.getOffset({x: x1, y: y1}); let i0 = Math.min(i00, i01, i10, i11); let j0 = Math.min(j00, j01, j10, j11); let i1 = Math.max(i00, i01, i10, i11) + 1; let j1 = Math.max(j00, j01, j10, j11) + 1; // While the corners of the rectangle are included in this range, the edges of the rectangle might // intersect rows or columns outside of the range. So we need to expand the range if necessary. if ( this.columns ) { if ( (i00 === i01) && (j00 < j01) && (!(j00 % 2) !== this.even) && (y0 < i00 * this.sizeY) ) i0--; if ( (i10 === i11) && (j10 < j11) && (!(j00 % 2) === this.even) && (y1 > (i10 + 0.5) * this.sizeY) ) i1++; if ( (j00 === j10) && (i00 < i10) && (x0 < ((j00 * 0.75) + 0.25) * this.sizeX) ) j0--; if ( (j01 === j11) && (i01 < i11) && (x1 > ((j01 * 0.75) + 0.75) * this.sizeX) ) j1++; } else { if ( (j00 === j10) && (i00 < i10) && (!(i00 % 2) !== this.even) && (x0 < j00 * this.sizeX) ) j0--; if ( (j01 === j11) && (i01 < i11) && (!(i00 % 2) === this.even) && (x1 > (j01 + 0.5) * this.sizeX) ) j1++; if ( (i00 === i01) && (j00 < j01) && (y0 < ((i00 * 0.75) + 0.25) * this.sizeY) ) i0--; if ( (i10 === i11) && (j10 < j11) && (y1 > ((i10 * 0.75) + 0.75) * this.sizeY) ) i1++; } return [i0, j0, i1, j1]; } /* -------------------------------------------- */ /** @override */ getAdjacentOffsets(coords) { return this.getAdjacentCubes(coords).map(cube => this.getOffset(cube)); } /* -------------------------------------------- */ /** @override */ testAdjacency(coords1, coords2) { return HexagonalGrid.cubeDistance(this.getCube(coords1), this.getCube(coords2)) === 1; } /* -------------------------------------------- */ /** @override */ getShiftedOffset(coords, direction) { const offset = this.getOffset(coords); if ( this.columns ) { if ( !(direction & MOVEMENT_DIRECTIONS.LEFT) !== !(direction & MOVEMENT_DIRECTIONS.RIGHT) ) { const even = (offset.j % 2 === 0) === this.even; if ( (even && (direction & MOVEMENT_DIRECTIONS.UP)) || (!even && (direction & MOVEMENT_DIRECTIONS.DOWN)) ) { direction &= ~(MOVEMENT_DIRECTIONS.UP | MOVEMENT_DIRECTIONS.DOWN); } } } else { if ( !(direction & MOVEMENT_DIRECTIONS.UP) !== !(direction & MOVEMENT_DIRECTIONS.DOWN) ) { const even = (offset.i % 2 === 0) === this.even; if ( (even && (direction & MOVEMENT_DIRECTIONS.LEFT)) || (!even && (direction & MOVEMENT_DIRECTIONS.RIGHT)) ) { direction &= ~(MOVEMENT_DIRECTIONS.LEFT | MOVEMENT_DIRECTIONS.RIGHT); } } } if ( direction & MOVEMENT_DIRECTIONS.UP ) offset.i--; if ( direction & MOVEMENT_DIRECTIONS.DOWN ) offset.i++; if ( direction & MOVEMENT_DIRECTIONS.LEFT ) offset.j--; if ( direction & MOVEMENT_DIRECTIONS.RIGHT ) offset.j++; return offset; } /* -------------------------------------------- */ /** @override */ getShiftedPoint(point, direction) { const center = this.getCenterPoint(point); const shifted = this.getCenterPoint(this.getShiftedOffset(center, direction)); shifted.x = point.x + (shifted.x - center.x); shifted.y = point.y + (shifted.y - center.y); return shifted; } /* -------------------------------------------- */ /** * Returns the cube coordinates of the grid space corresponding to the given coordinates. * @param {HexagonalGridCoordinates} coords The coordinates * @returns {HexagonalGridCube} The cube coordinates */ getCube(coords) { if ( coords.i !== undefined ) return this.offsetToCube(coords); const cube = coords.q !== undefined ? coords : this.pointToCube(coords); return HexagonalGrid.cubeRound(cube); } /* -------------------------------------------- */ /** * Returns the cube coordinates of grid spaces adjacent to the one corresponding to the given coordinates. * @param {HexagonalGridCoordinates} coords The coordinates * @returns {HexagonalGridCube[]} The adjacent cube coordinates */ getAdjacentCubes(coords) { const {q, r, s} = this.getCube(coords); return [ {q, r: r - 1, s: s + 1}, {q: q + 1, r: r - 1, s}, {q: q + 1, r, s: s - 1}, {q, r: r + 1, s: s - 1}, {q: q - 1, r: r + 1, s}, {q: q - 1, r, s: s + 1} ]; } /* -------------------------------------------- */ /** * Returns the cube coordinates of the grid space corresponding to the given coordinates * shifted by one grid space in the given direction. * @param {GridCoordinates} coords The coordinates * @param {number} direction The direction (see {@link CONST.MOVEMENT_DIRECTIONS}) * @returns {HexagonalGridCube} The cube coordinates */ getShiftedCube(coords, direction) { return this.getCube(this.getShiftedOffset(coords, direction)); } /* -------------------------------------------- */ /** * 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. * @param {HexagonalGridCoordinates} coords The coordinates * @returns {Point} The top-left point */ getTopLeftPoint(coords) { const point = this.getCenterPoint(coords); point.x -= (this.sizeX / 2); point.y -= (this.sizeY / 2); return point; } /* -------------------------------------------- */ /** * 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. * @param {HexagonalGridCoordinates} coords The coordinates * @returns {Point} The center point */ getCenterPoint(coords) { if ( coords.i !== undefined ) { const {i, j} = coords; let x; let y; if ( this.columns ) { x = (2 * Math.SQRT1_3) * ((0.75 * j) + 0.5); const even = (j + 1) % 2 === 0; y = i + (this.even === even ? 0 : 0.5); } else { y = (2 * Math.SQRT1_3) * ((0.75 * i) + 0.5); const even = (i + 1) % 2 === 0; x = j + (this.even === even ? 0 : 0.5); } const size = this.size; x *= size; y *= size; return {x, y}; } const cube = coords.q !== undefined ? coords : this.pointToCube(coords); return this.cubeToPoint(HexagonalGrid.cubeRound(cube)); } /* -------------------------------------------- */ /** @override */ getShape() { const scaleX = this.sizeX / 4; const scaleY = this.sizeY / 4; if ( this.columns ) { const x0 = -2 * scaleX; const x1 = -scaleX; const x2 = scaleX; const x3 = 2 * scaleX; const y0 = -2 * scaleY; const y1 = 2 * scaleY; return [{x: x0, y: 0}, {x: x1, y: y0}, {x: x2, y: y0}, {x: x3, y: 0}, {x: x2, y: y1}, {x: x1, y: y1}]; } else { const y0 = -2 * scaleY; const y1 = -scaleY; const y2 = scaleY; const y3 = 2 * scaleY; const x0 = -2 * scaleX; const x1 = 2 * scaleX; return [{x: 0, y: y0}, {x: x1, y: y1}, {x: x1, y: y2}, {x: 0, y: y3}, {x: x0, y: y2}, {x: x0, y: y1}]; } } /* -------------------------------------------- */ /** @override */ getVertices(coords) { const {i, j} = this.getOffset(coords); const scaleX = this.sizeX / 4; const scaleY = this.sizeY / 4; if ( this.columns ) { const x = 3 * j; const x0 = x * scaleX; const x1 = (x + 1) * scaleX; const x2 = (x + 3) * scaleX; const x3 = (x + 4) * scaleX; const even = (j + 1) % 2 === 0; const y = (4 * i) - (this.even === even ? 2 : 0); const y0 = y * scaleY; const y1 = (y + 2) * scaleY; const y2 = (y + 4) * scaleY; return [{x: x0, y: y1}, {x: x1, y: y0}, {x: x2, y: y0}, {x: x3, y: y1}, {x: x2, y: y2}, {x: x1, y: y2}]; } else { const y = 3 * i; const y0 = y * scaleY; const y1 = (y + 1) * scaleY; const y2 = (y + 3) * scaleY; const y3 = (y + 4) * scaleY; const even = (i + 1) % 2 === 0; const x = (4 * j) - (this.even === even ? 2 : 0); const x0 = x * scaleX; const x1 = (x + 2) * scaleX; const x2 = (x + 4) * scaleX; return [{x: x1, y: y0}, {x: x2, y: y1}, {x: x2, y: y2}, {x: x1, y: y3}, {x: x0, y: y2}, {x: x0, y: y1}]; } } /* -------------------------------------------- */ /** @override */ getSnappedPoint(point, {mode, resolution=1}) { if ( mode & ~0xFFF3 ) throw new Error("Invalid snapping mode"); if ( mode === 0 ) return {x: point.x, y: point.y}; let nearest; let distance; const keepNearest = candidate => { if ( !nearest ) return nearest = candidate; const {x, y} = point; distance ??= ((nearest.x - x) ** 2) + ((nearest.y - y) ** 2); const d = ((candidate.x - x) ** 2) + ((candidate.y - y) ** 2); if ( d < distance ) { nearest = candidate; distance = d; } return nearest; }; // Symmetries and identities if ( this.columns ) { // Top-Left = Bottom-Left if ( mode & 0x50 ) mode |= 0x50; // Vertex if ( mode & 0x500 ) mode |= 0x500; // Corner // Top-Right = Bottom-Right if ( mode & 0xA0 ) mode |= 0xA0; // Vertex if ( mode & 0xA00 ) mode |= 0xA00; // Corner // Left Side = Right Vertex if ( mode & 0x4000 ) mode |= 0xA0; // Right Side = Left Vertex if ( mode & 0x8000 ) mode |= 0x50; } else { // Top-Left = Top-Right if ( mode & 0x30 ) mode |= 0x30; // Vertex if ( mode & 0x300 ) mode |= 0x300; // Corner // Bottom-Left = Bottom-Right if ( mode & 0xC0 ) mode |= 0xC0; // Vertex if ( mode & 0xC00 ) mode |= 0xC00; // Corner // Top Side = Bottom Vertex if ( mode & 0x1000 ) mode |= 0xC0; // Bottom Side = Top Vertex if ( mode & 0x2000 ) mode |= 0x30; } // Only top/bottom or left/right edges if ( !(mode & 0x2) ) { if ( this.columns ) { // Top/Left side (= edge) if ( mode & 0x3000 ) keepNearest(this.#snapToTopOrBottom(point, resolution)); } else { // Left/Right side (= edge) if ( mode & 0xC000 ) keepNearest(this.#snapToLeftOrRight(point, resolution)); } } // Any vertex (plus edge/center) if ( (mode & 0xF0) === 0xF0 ) { switch ( mode & 0x3 ) { case 0x0: keepNearest(this.#snapToVertex(point, resolution)); break; case 0x1: keepNearest(this.#snapToVertexOrCenter(point, resolution)); break; case 0x2: keepNearest(this.#snapToEdgeOrVertex(point, resolution)); break; case 0x3: keepNearest(this.#snapToEdgeOrVertexOrCenter(point, resolution)); break; } } // A specific vertex else if ( mode & 0xF0 ) { // Center if ( (mode & 0x3) === 0x1 ) { keepNearest(this.#snapToSpecificVertexOrCenter(point, !(mode & 0x10), resolution)); } else { // Edge and/or center switch ( mode & 0x3 ) { case 0x2: keepNearest(this.#snapToEdge(point, resolution)); break; case 0x3: keepNearest(this.#snapToEdgeOrCenter(point, resolution)); break; } // A combination of specific vertices and corners that results in a rectangular grid if ( ((mode & 0xF0) ^ ((mode & 0xF00) >> 4)) === 0xF0 ) { return keepNearest(this.#snapToRectangularGrid(point, !(mode & 0x100), resolution)); } keepNearest(this.#snapToSpecificVertex(point, !(mode & 0x10), resolution)); } } // Edges and/or centers else { switch ( mode & 0x3 ) { case 0x1: keepNearest(this.#snapToCenter(point, resolution)); break; case 0x2: keepNearest(this.#snapToEdge(point, resolution)); break; case 0x3: keepNearest(this.#snapToEdgeOrCenter(point, resolution)); break; } } // Any corner if ( (mode & 0xF00) === 0xF00 ) { keepNearest(this.#snapToCorner(point, resolution)); } // A specific corner else if ( mode & 0xF00 ) { keepNearest(this.#snapToSpecificCorner(point, !(mode & 0x100), resolution)); } return nearest; } /* -------------------------------------------- */ /** * Snap the point to the nearest center of a hexagon. * @param {Point} point The point * @param {number} resolution The grid resolution * @param {number} [dx=0] The x-translation of the grid * @param {number} [dy=0] The y-translation of the grid * @param {boolean} [columns] Flat-top instead of pointy-top? * @param {boolean} [even] Start at a full grid space? * @param {number} [size] The size of a grid space * @returns {Point} The snapped point */ #snapToCenter({x, y}, resolution, dx=0, dy=0, columns=this.columns, even=this.even, size=this.size) { // Subdivide the hex grid const grid = HexagonalGrid.#TEMP_GRID; grid.columns = columns; grid.size = size / resolution; // Align the subdivided grid with this hex grid if ( columns ) { dx += ((size - grid.size) * Math.SQRT1_3); if ( even ) dy += (size / 2); } else { if ( even ) dx += (size / 2); dy += ((size - grid.size) * Math.SQRT1_3); } // Get the snapped center point for the subdivision const point = HexagonalGrid.#TEMP_POINT; point.x = x - dx; point.y = y - dy; const snapped = grid.getCenterPoint(point); snapped.x += dx; snapped.y += dy; return snapped; } /* -------------------------------------------- */ /** * Snap the point to the nearest vertex of a hexagon. * @param {Point} point The point * @param {number} resolution The grid resolution * @param {number} [dx=0] The x-offset of the grid * @param {number} [dy=0] The y-offset of the grid * @returns {Point} The snapped point */ #snapToVertex(point, resolution, dx, dy) { const center = this.#snapToCenter(point, resolution, dx, dy); const {x: x0, y: y0} = center; let angle = Math.atan2(point.y - y0, point.x - x0); if ( this.columns ) angle = Math.round(angle / (Math.PI / 3)) * (Math.PI / 3); else angle = (Math.floor(angle / (Math.PI / 3)) + 0.5) * (Math.PI / 3); const radius = Math.max(this.sizeX, this.sizeY) / (2 * resolution); const vertex = center; // Reuse the object vertex.x = x0 + (Math.cos(angle) * radius); vertex.y = y0 + (Math.sin(angle) * radius); return vertex; } /* -------------------------------------------- */ /** * Snap the point to the nearest vertex or center of a hexagon. * @param {Point} point The point * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToVertexOrCenter(point, resolution) { let size; let dx = 0; let dy = 0; if ( this.columns ) { size = this.sizeX / 2; dy = size * (Math.SQRT1_3 / 2); } else { size = this.sizeY / 2; dx = size * (Math.SQRT1_3 / 2); } return this.#snapToCenter(point, resolution, dx, dy, !this.columns, !this.even, size); } /* -------------------------------------------- */ /** * Snap the point to the nearest edge of a hexagon. * @param {Point} point The point * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToEdge(point, resolution) { const center = this.#snapToCenter(point, resolution); const {x: x0, y: y0} = center; let angle = Math.atan2(point.y - y0, point.x - x0); if ( this.columns ) angle = (Math.floor(angle / (Math.PI / 3)) + 0.5) * (Math.PI / 3); else angle = Math.round(angle / (Math.PI / 3)) * (Math.PI / 3); const radius = Math.min(this.sizeX, this.sizeY) / (2 * resolution); const vertex = center; // Reuse the object vertex.x = x0 + (Math.cos(angle) * radius); vertex.y = y0 + (Math.sin(angle) * radius); return vertex; } /* -------------------------------------------- */ /** * Snap the point to the nearest edge or center of a hexagon. * @param {Point} point The point * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToEdgeOrCenter(point, resolution) { let size; let dx = 0; let dy = 0; if ( this.columns ) { size = this.sizeY / 2; dx = size * Math.SQRT1_3; } else { size = this.sizeX / 2; dy = size * Math.SQRT1_3; } return this.#snapToCenter(point, resolution, dx, dy, this.columns, false, size); } /* -------------------------------------------- */ /** * Snap the point to the nearest edge or vertex of a hexagon. * @param {Point} point The point * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToEdgeOrVertex(point, resolution) { const {x, y} = point; point = this.#snapToCenter(point, resolution); const {x: x0, y: y0} = point; const dx = x - x0; const dy = y - y0; let angle = Math.atan2(dy, dx); if ( this.columns ) angle = (Math.floor(angle / (Math.PI / 3)) + 0.5) * (Math.PI / 3); else angle = Math.round(angle / (Math.PI / 3)) * (Math.PI / 3); const s = 2 * resolution; let radius1 = this.sizeX / s; let radius2 = this.sizeY / s; if ( radius1 > radius2 ) [radius1, radius2] = [radius2, radius1]; const cos = Math.cos(angle); const sin = Math.sin(angle); const d = (cos * dy) - (sin * dx); if ( Math.abs(d) <= radius2 / 4 ) { point.x = x0 + (cos * radius1); point.y = y0 + (sin * radius1); } else { angle += ((Math.PI / 6) * Math.sign(d)); point.x = x0 + (Math.cos(angle) * radius2); point.y = y0 + (Math.sin(angle) * radius2); } return point; } /* -------------------------------------------- */ /** * Snap the point to the nearest edge, vertex, center of a hexagon. * @param {Point} point The point * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToEdgeOrVertexOrCenter(point, resolution) { const {x, y} = point; point = this.#snapToCenter(point, resolution); const {x: x0, y: y0} = point; const dx = x - x0; const dy = y - y0; let angle = Math.atan2(dy, dx); if ( this.columns ) angle = (Math.floor(angle / (Math.PI / 3)) + 0.5) * (Math.PI / 3); else angle = Math.round(angle / (Math.PI / 3)) * (Math.PI / 3); const s = 2 * resolution; let radius1 = this.sizeX / s; let radius2 = this.sizeY / s; if ( radius1 > radius2 ) [radius1, radius2] = [radius2, radius1]; const cos = Math.cos(angle); const sin = Math.sin(angle); const d1 = (cos * dx) + (sin * dy); if ( d1 <= radius1 / 2 ) return point; const d2 = (cos * dy) - (sin * dx); if ( Math.abs(d2) <= radius2 / 4 ) { point.x = x0 + (cos * radius1); point.y = y0 + (sin * radius1); } else { angle += ((Math.PI / 6) * Math.sign(d2)); point.x = x0 + (Math.cos(angle) * radius2); point.y = y0 + (Math.sin(angle) * radius2); } return point; } /* -------------------------------------------- */ /** * Snap the point to the nearest corner of a hexagon. * @param {Point} point The point * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToCorner(point, resolution) { let dx = 0; let dy = 0; const s = 2 * resolution; if ( this.columns ) dy = this.sizeY / s; else dx = this.sizeX / s; return this.#snapToVertex(point, resolution, dx, dy); } /* -------------------------------------------- */ /** * Snap the point to the nearest top/bottom-left/right vertex of a hexagon. * @param {Point} point The point * @param {boolean} other Bottom-right instead of top-left vertex? * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToSpecificVertex(point, other, resolution) { let dx = 0; let dy = 0; const s = (other ? -2 : 2) * resolution; if ( this.columns ) dx = this.sizeX / s; else dy = this.sizeY / s; return this.#snapToCenter(point, resolution, dx, dy); } /* -------------------------------------------- */ /** * Snap the point to the nearest top/bottom-left/right vertex or center of a hexagon. * @param {Point} point The point * @param {boolean} other Bottom-right instead of top-left vertex? * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToSpecificVertexOrCenter(point, other, resolution) { let dx = 0; let dy = 0; const s = (other ? 2 : -2) * resolution; if ( this.columns ) dx = this.sizeX / s; else dy = this.sizeY / s; return this.#snapToVertex(point, resolution, dx, dy); } /* -------------------------------------------- */ /** * Snap the point to the nearest top/bottom-left/right corner of a hexagon. * @param {Point} point The point * @param {boolean} other Bottom-right instead of top-left corner? * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToSpecificCorner(point, other, resolution) { let dx = 0; let dy = 0; const s = (other ? -4 : 4) * resolution; if ( this.columns ) dx = this.sizeX / s; else dy = this.sizeY / s; return this.#snapToCenter(point, resolution, dx, dy); } /* -------------------------------------------- */ /** * Snap the point to the nearest grid intersection of the rectanglar grid. * @param {Point} point The point * @param {boolean} other Align rectangles with top-left vertices instead of top-left corners? * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToRectangularGrid(point, other, resolution) { const tx = this.sizeX / 2; const ty = this.sizeY / 2; let sx = tx; let sy = ty; let dx = 0; let dy = 0; const d = other ? 1 / 3 : 2 / 3; if ( this.columns ) { sx *= 1.5; dx = d; } else { sy *= 1.5; dy = d; } sx /= resolution; sy /= resolution; return { x: ((Math.round(((point.x - tx) / sx) + dx) - dx) * sx) + tx, y: ((Math.round(((point.y - ty) / sy) + dy) - dy) * sy) + ty }; } /** * Snap the point to the nearest top/bottom side of the bounds of a hexagon. * @param {Point} point The point * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToTopOrBottom(point, resolution) { return this.#snapToCenter(point, resolution, 0, this.sizeY / (2 * resolution)); } /* -------------------------------------------- */ /** * Snap the point to the nearest left/right side of the bounds of a hexagon. * @param {Point} point The point * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToLeftOrRight(point, resolution) { return this.#snapToCenter(point, resolution, this.sizeX / (2 * resolution), 0); } /* -------------------------------------------- */ /** @inheritdoc */ calculateDimensions(sceneWidth, sceneHeight, padding) { const {columns, size} = this; const sizeX = columns ? (2 * size) / Math.SQRT3 : size; const sizeY = columns ? size : (2 * size) / Math.SQRT3; const strideX = columns ? 0.75 * sizeX : sizeX; const strideY = columns ? sizeY : 0.75 * sizeY; // Skip padding computation for Scenes which do not include padding if ( !padding ) { const cols = Math.ceil(((sceneWidth + (columns ? -sizeX / 4 : sizeX / 2)) / strideX) - 1e-6); const rows = Math.ceil(((sceneHeight + (columns ? sizeY / 2 : -sizeY / 4)) / strideY) - 1e-6); return {width: sceneWidth, height: sceneHeight, x: 0, y: 0, rows, columns: cols}; } // The grid size is equal to the short diagonal of the hexagon, so padding in that axis will divide evenly by the // grid size. In the cross-axis, however, the hexagons do not stack but instead interleave. Multiplying the long // diagonal by 75% gives us the amount of space each hexagon takes up in that axis without overlapping. // Note: Do not replace `* (1 / strideX)` by `/ strideX` and `* (1 / strideY)` by `/ strideY`! // It could change the result and therefore break certain scenes. let x = Math.ceil((padding * sceneWidth) * (1 / strideX)) * strideX; let y = Math.ceil((padding * sceneHeight) * (1 / strideY)) * strideY; // Note: The width and height calculation needs rounded x/y. If we were to remove the rounding here, // the result of the rounding of the width and height below would change in certain scenes. let width = sceneWidth + (2 * Math.round(Math.ceil((padding * sceneWidth) * (1 / strideX)) / (1 / strideX))); let height = sceneHeight + (2 * Math.round(Math.ceil((padding * sceneHeight) * (1 / strideY)) / (1 / strideY))); // Ensure that the top-left hexagon of the scene rectangle is always a full hexagon for even grids and always a // half hexagon for odd grids, by shifting the padding in the main axis by half a hex if the number of hexagons in // the cross-axis is odd. const crossEven = Math.round(columns ? x / strideX : y / strideY) % 2 === 0; if ( !crossEven ) { if ( columns ) { y += (sizeY / 2); height += sizeY; } else { x += (sizeX / 2); width += sizeX; } } // The height (if column orientation) or width (if row orientation) must be a multiple of the grid size, and // the last column (if column orientation) or row (if row orientation) must be fully within the bounds. // Note: Do not replace `* (1 / strideX)` by `/ strideX` and `* (1 / strideY)` by `/ strideY`! // It could change the result and therefore break certain scenes. let cols = Math.round(width * (1 / strideX)); let rows = Math.round(height * (1 / strideY)); width = cols * strideX; height = rows * strideY; if ( columns ) { rows++; width += (sizeX / 4); } else { cols++; height += (sizeY / 4); } return {width, height, x, y, rows, columns: cols}; } /* -------------------------------------------- */ /** * 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. (Legacy) * @param {number} columns Column or row orientation? * @param {number} legacySize The legacy size of the grid. * @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}} * @internal */ static _calculatePreV10Dimensions(columns, legacySize, sceneWidth, sceneHeight, padding) { // Note: Do not replace `* (1 / legacySize)` by `/ legacySize`! // It could change the result and therefore break certain scenes. const x = Math.ceil((padding * sceneWidth) * (1 / legacySize)) * legacySize; const y = Math.ceil((padding * sceneHeight) * (1 / legacySize)) * legacySize; const width = sceneWidth + (2 * x); const height = sceneHeight + (2 * y); const size = legacySize * (Math.SQRT3 / 2); const sizeX = columns ? legacySize : size; const sizeY = columns ? size : legacySize; const strideX = columns ? 0.75 * sizeX : sizeX; const strideY = columns ? sizeY : 0.75 * sizeY; const cols = Math.floor(((width + (columns ? sizeX / 4 : sizeX)) / strideX) + 1e-6); const rows = Math.floor(((height + (columns ? sizeY : sizeY / 4)) / strideY) + 1e-6); return {width, height, x, y, rows, columns: cols}; } /* -------------------------------------------- */ /** @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; // Convert to (fractional) cube coordinates const toCube = coords => { if ( coords.x !== undefined ) return this.pointToCube(coords); if ( coords.i !== undefined ) return this.offsetToCube(coords); return coords; }; // Prepare data for the starting point const w0 = waypoints[0]; let o0 = this.getOffset(w0); let c0 = this.offsetToCube(o0); let d0 = toCube(w0); // Iterate over additional path points for ( let i = 1; i < waypoints.length; i++ ) { const w1 = waypoints[i]; const o1 = this.getOffset(w1); const c1 = this.offsetToCube(o1); const d1 = toCube(w1); // Measure segment const to = result.waypoints[i]; const segment = to.backward; if ( !w1.teleport ) { // Determine the number of hexes and cube distance const c = HexagonalGrid.cubeDistance(c0, c1); let d = HexagonalGrid.cubeDistance(d0, d1); if ( d.almostEqual(c) ) d = c; // Calculate the distance based on the cube distance segment.distance = d * this.distance; segment.spaces = c; segment.cost = cost ? this.#calculateCost(c0, c1, cost) : c * this.distance; } 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.spaces += segment.spaces; result.cost += segment.cost; // Set waypoint measurements to.distance = result.distance; to.spaces = result.spaces; to.cost = result.cost; o0 = o1; c0 = c1; d0 = d1; } } /* -------------------------------------------- */ /** * Calculate the cost of the direct path segment. * @param {HexagonalGridCube} from The coordinates the segment starts from * @param {HexagonalGridCube} to The coordinates the segment goes to * @param {GridMeasurePathCostFunction} cost The cost function * @returns {number} The cost of the path segment */ #calculateCost(from, to, cost) { const path = this.getDirectPath([from, to]); if ( path.length <= 1 ) return 0; // Prepare data for the starting point let o0 = path[0]; let c = 0; // Iterate over additional path points for ( let i = 1; i < path.length; i++ ) { const o1 = path[i]; // Calculate and accumulate the cost c += cost(o0, o1, this.distance); o0 = o1; } return c; } /* -------------------------------------------- */ /** * @see {@link https://www.redblobgames.com/grids/hexagons/#line-drawing} * @override */ getDirectPath(waypoints) { if ( waypoints.length === 0 ) return []; // Prepare data for the starting point let c0 = this.getCube(waypoints[0]); let {q: q0, r: r0} = c0; const path = [this.getOffset(c0)]; // Iterate over additional path points for ( let i = 1; i < waypoints.length; i++ ) { const c1 = this.getCube(waypoints[i]); const {q: q1, r: r1} = c1; if ( (q0 === q1) && (r0 === r1) ) continue; // Walk from (q0, r0, s0) to (q1, r1, s1) const dq = q0 - q1; const dr = r0 - r1; // If the path segment is collinear with some hexagon edge, we need to nudge // the cube coordinates in the right direction so that we get a consistent, clean path. const EPS = 1e-6; let eq = 0; let er = 0; if ( this.columns ) { // Collinear with SE-NW edges if ( dq === dr ) { // Prefer movement such that we have symmetry with the E-W case er = !(q0 & 1) === this.even ? EPS : -EPS; eq = -er; } // Collinear with SW-NE edges else if ( -2 * dq === dr ) { // Prefer movement such that we have symmetry with the E-W case eq = !(q0 & 1) === this.even ? EPS : -EPS; } // Collinear with E-W edges else if ( dq === -2 * dr ) { // Move such we don't leave the row that we're in er = !(q0 & 1) === this.even ? -EPS : EPS; } } else { // Collinear with SE-NW edges if ( dq === dr ) { // Prefer movement such that we have symmetry with the S-N case eq = !(r0 & 1) === this.even ? EPS : -EPS; er = -eq; } // Collinear with SW-NE edges else if ( dq === -2 * dr ) { // Prefer movement such that we have symmetry with the S-N case er = !(r0 & 1) === this.even ? EPS : -EPS; } // Collinear with S-N edges else if ( -2 * dq === dr ) { // Move such we don't leave the column that we're in eq = !(r0 & 1) === this.even ? -EPS : EPS; } } const n = HexagonalGrid.cubeDistance(c0, c1); for ( let j = 1; j < n; j++ ) { // Break tries on E-W (if columns) / S-N (if rows) edges const t = (j + EPS) / n; const q = Math.mix(q0, q1, t) + eq; const r = Math.mix(r0, r1, t) + er; const s = 0 - q - r; path.push(this.getOffset({q, r, s})); } path.push(this.getOffset(c1)); c0 = c1; q0 = q1; r0 = r1; } return path; } /* -------------------------------------------- */ /** @override */ getTranslatedPoint(point, direction, distance) { direction = Math.toRadians(direction); const dx = Math.cos(direction); const dy = Math.sin(direction); let q; let r; if ( this.columns ) { q = (2 * Math.SQRT1_3) * dx; r = (-0.5 * q) + dy; } else { r = (2 * Math.SQRT1_3) * dy; q = (-0.5 * r) + dx; } const s = distance / this.distance * this.size / ((Math.abs(r) + Math.abs(q) + Math.abs(q + r)) / 2); return {x: point.x + (dx * s), y: point.y + (dy * s)}; } /* -------------------------------------------- */ /** @override */ getCircle({x, y}, radius) { // TODO: Move to BaseGrid once BaseGrid -> GridlessGrid if ( radius <= 0 ) return []; const r = radius / this.distance * this.size; if ( this.columns ) { const x0 = r * (Math.SQRT3 / 2); const x1 = -x0; const y0 = r; const y1 = y0 / 2; const y2 = -y1; const y3 = -y0; return [{x: x, y: y + y0}, {x: x + x1, y: y + y1}, {x: x + x1, y: y + y2}, {x: x, y: y + y3}, {x: x + x0, y: y + y2}, {x: x + x0, y: y + y1}]; } else { const y0 = r * (Math.SQRT3 / 2); const y1 = -y0; const x0 = r; const x1 = x0 / 2; const x2 = -x1; const x3 = -x0; return [{x: x + x0, y: y}, {x: x + x1, y: y + y0}, {x: x + x2, y: y + y0}, {x: x + x3, y: y}, {x: x + x2, y: y + y1}, {x: x + x1, y: y + y1}]; } } /* -------------------------------------------- */ /* Conversion Functions */ /* -------------------------------------------- */ /** * Round the fractional cube coordinates (q, r, s). * @see {@link https://www.redblobgames.com/grids/hexagons/} * @param {HexagonalGridCube} cube The fractional cube coordinates * @returns {HexagonalGridCube} The rounded integer cube coordinates */ static cubeRound({q, r, s}) { let iq = Math.round(q); let ir = Math.round(r); let is = Math.round(s); const dq = Math.abs(iq - q); const dr = Math.abs(ir - r); const ds = Math.abs(is - s); if ( (dq > dr) && (dq > ds) ) { iq = -ir - is; } else if ( dr > ds ) { ir = -iq - is; } else { is = -iq - ir; } return {q: iq | 0, r: ir | 0, s: is | 0}; } /* -------------------------------------------- */ /** * Convert point coordinates (x, y) into cube coordinates (q, r, s). * Inverse of {@link HexagonalGrid#cubeToPoint}. * @see {@link https://www.redblobgames.com/grids/hexagons/} * @param {Point} point The point * @returns {HexagonalGridCube} The (fractional) cube coordinates */ pointToCube({x, y}) { let q; let r; const size = this.size; x /= size; y /= size; if ( this.columns ) { q = ((2 * Math.SQRT1_3) * x) - (2 / 3); r = (-0.5 * (q + (this.even ? 1 : 0))) + y; } else { r = ((2 * Math.SQRT1_3) * y) - (2 / 3); q = (-0.5 * (r + (this.even ? 1 : 0))) + x; } return {q, r, s: 0 - q - r}; } /* -------------------------------------------- */ /** * Convert cube coordinates (q, r, s) into point coordinates (x, y). * Inverse of {@link HexagonalGrid#pointToCube}. * @see {@link https://www.redblobgames.com/grids/hexagons/} * @param {HexagonalGridCube} cube The cube coordinates * @returns {Point} The point coordinates */ cubeToPoint({q, r}) { let x; let y; if ( this.columns ) { x = (Math.SQRT3 / 2) * (q + (2 / 3)); y = (0.5 * (q + (this.even ? 1 : 0))) + r; } else { y = (Math.SQRT3 / 2) * (r + (2 / 3)); x = (0.5 * (r + (this.even ? 1 : 0))) + q; } const size = this.size; x *= size; y *= size; return {x, y}; } /* -------------------------------------------- */ /** * Convert offset coordinates (i, j) into integer cube coordinates (q, r, s). * Inverse of {@link HexagonalGrid#cubeToOffset}. * @see {@link https://www.redblobgames.com/grids/hexagons/} * @param {GridOffset} offset The offset coordinates * @returns {HexagonalGridCube} The integer cube coordinates */ offsetToCube({i, j}) { let q; let r; if ( this.columns ) { q = j; r = i - ((j + ((this.even ? 1 : -1) * (j & 1))) >> 1); } else { q = j - ((i + ((this.even ? 1 : -1) * (i & 1))) >> 1); r = i; } return {q, r, s: 0 - q - r}; } /* -------------------------------------------- */ /** * Convert integer cube coordinates (q, r, s) into offset coordinates (i, j). * Inverse of {@link HexagonalGrid#offsetToCube}. * @see {@link https://www.redblobgames.com/grids/hexagons/} * @param {HexagonalGridCube} cube The cube coordinates * @returns {GridOffset} The offset coordinates */ cubeToOffset({q, r}) { let i; let j; if ( this.columns ) { j = q; i = r + ((q + ((this.even ? 1 : -1) * (q & 1))) >> 1); } else { i = r; j = q + ((r + ((this.even ? 1 : -1) * (r & 1))) >> 1); } return {i, j}; } /* -------------------------------------------- */ /** * Measure the distance in hexagons between two cube coordinates. * @see {@link https://www.redblobgames.com/grids/hexagons/} * @param {HexagonalGridCube} a The first cube coordinates * @param {HexagonalGridCube} b The second cube coordinates * @returns {number} The distance between the two cube coordinates in hexagons */ static cubeDistance(a, b) { const dq = a.q - b.q; const dr = a.r - b.r; return (Math.abs(dq) + Math.abs(dr) + Math.abs(dq + dr)) / 2; } /* -------------------------------------------- */ /** * Used by {@link HexagonalGrid#snapToCenter}. * @type {Point} */ static #TEMP_POINT = {x: 0, y: 0}; /* -------------------------------------------- */ /** * Used by {@link HexagonalGrid#snapToCenter}. * Always an odd grid! * @type {HexagonalGrid} */ static #TEMP_GRID = new HexagonalGrid({size: 1}); /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ static get POINTY_HEX_BORDERS() { const msg = "HexagonalGrid.POINTY_HEX_BORDERS is deprecated without replacement."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.#POINTY_HEX_BORDERS; } /** * @deprecated since v12 * @ignore */ static #POINTY_HEX_BORDERS = { 0.5: [[0, 0.25], [0.5, 0], [1, 0.25], [1, 0.75], [0.5, 1], [0, 0.75]], 1: [[0, 0.25], [0.5, 0], [1, 0.25], [1, 0.75], [0.5, 1], [0, 0.75]], 2: [ [.5, 0], [.75, 1/7], [.75, 3/7], [1, 4/7], [1, 6/7], [.75, 1], [.5, 6/7], [.25, 1], [0, 6/7], [0, 4/7], [.25, 3/7], [.25, 1/7] ], 3: [ [.5, .1], [2/3, 0], [5/6, .1], [5/6, .3], [1, .4], [1, .6], [5/6, .7], [5/6, .9], [2/3, 1], [.5, .9], [1/3, 1], [1/6, .9], [1/6, .7], [0, .6], [0, .4], [1/6, .3], [1/6, .1], [1/3, 0] ], 4: [ [.5, 0], [5/8, 1/13], [.75, 0], [7/8, 1/13], [7/8, 3/13], [1, 4/13], [1, 6/13], [7/8, 7/13], [7/8, 9/13], [.75, 10/13], [.75, 12/13], [5/8, 1], [.5, 12/13], [3/8, 1], [.25, 12/13], [.25, 10/13], [1/8, 9/13], [1/8, 7/13], [0, 6/13], [0, 4/13], [1/8, 3/13], [1/8, 1/13], [.25, 0], [3/8, 1/13] ] }; /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ static get FLAT_HEX_BORDERS() { const msg = "HexagonalGrid.FLAT_HEX_BORDERS is deprecated without replacement."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.#FLAT_HEX_BORDERS; } /** * @deprecated since v12 * @ignore */ static #FLAT_HEX_BORDERS = { 0.5: [[0, 0.5], [0.25, 0], [0.75, 0], [1, 0.5], [0.75, 1], [0.25, 1]], 1: [[0, 0.5], [0.25, 0], [0.75, 0], [1, 0.5], [0.75, 1], [0.25, 1]], 2: [ [3/7, .25], [4/7, 0], [6/7, 0], [1, .25], [6/7, .5], [1, .75], [6/7, 1], [4/7, 1], [3/7, .75], [1/7, .75], [0, .5], [1/7, .25] ], 3: [ [.4, 0], [.6, 0], [.7, 1/6], [.9, 1/6], [1, 1/3], [.9, .5], [1, 2/3], [.9, 5/6], [.7, 5/6], [.6, 1], [.4, 1], [.3, 5/6], [.1, 5/6], [0, 2/3], [.1, .5], [0, 1/3], [.1, 1/6], [.3, 1/6] ], 4: [ [6/13, 0], [7/13, 1/8], [9/13, 1/8], [10/13, .25], [12/13, .25], [1, 3/8], [12/13, .5], [1, 5/8], [12/13, .75], [10/13, .75], [9/13, 7/8], [7/13, 7/8], [6/13, 1], [4/13, 1], [3/13, 7/8], [1/13, 7/8], [0, .75], [1/13, 5/8], [0, .5], [1/13, 3/8], [0, .25], [1/13, 1/8], [3/13, 1/8], [4/13, 0] ] }; /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ static get pointyHexPoints() { const msg = "HexagonalGrid.pointyHexPoints is deprecated without replacement."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.#POINTY_HEX_BORDERS[1]; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ static get flatHexPoints() { const msg = "HexagonalGrid.flatHexPoints is deprecated without replacement."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.#FLAT_HEX_BORDERS[1]; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get hexPoints() { const msg = "HexagonalGrid#hexPoints is deprecated without replacement."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.columns ? this.constructor.flatHexPoints : this.constructor.pointyHexPoints; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ getPolygon(x, y, w, h, points) { const msg = "HexagonalGrid#getPolygon is deprecated. You can get the shape of the hex with HexagonalGrid#getShape " + "and the polygon of any hex with HexagonalGrid#getVertices."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); w = w ?? this.sizeX; h = h ?? this.sizeY; points ??= this.hexPoints; const poly = []; for ( let i=0; i < points.length; i++ ) { poly.push(x + (w * points[i][0]), y + (h * points[i][1])); } return poly; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ getBorderPolygon(w, h, p) { const msg = "HexagonalGrid#getBorderPolygon is deprecated. " + "If you need the shape of a Token, use Token#shape/getShape instead."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); const points = this.columns ? this.constructor.FLAT_HEX_BORDERS[w] : this.constructor.POINTY_HEX_BORDERS[w]; if ( (w !== h) || !points ) return null; const p2 = p / 2; const p4 = p / 4; const r = this.getRect(w, h); return this.getPolygon(-p4, -p4, r.width + p2, r.height + p2, points); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ getRect(w, h) { const msg = "HexagonalGrid#getRect is deprecated. If you need the size of a Token, use Token#getSize instead."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); if ( !this.columns || (w < 1) ) w *= this.sizeX; else w = (this.sizeX * .75 * (w - 1)) + this.sizeX; if ( this.columns || (h < 1) ) h *= this.sizeY; else h = (this.sizeY * .75 * (h - 1)) + this.sizeY; return new PIXI.Rectangle(0, 0, w, h); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ _adjustSnapForTokenSize(x, y, token) { const msg = "HexagonalGrid#_adjustSnapForTokenSize is deprecated."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); if ( (token.document.width <= 1) && (token.document.height <= 1) ) { const [row, col] = this.getGridPositionFromPixels(x, y); const [x0, y0] = this.getPixelsFromGridPosition(row, col); return [x0 + (this.sizeX / 2) - (token.w / 2), y0 + (this.sizeY / 2) - (token.h / 2)]; } if ( this.columns && (token.document.height > 1) ) y -= this.sizeY / 2; if ( !this.columns && (token.document.width > 1) ) x -= this.sizeX / 2; return [x, y]; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ static computeDimensions({columns, size, legacy}) { const msg = "HexagonalGrid.computeDimensions is deprecated without replacement."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); // Legacy dimensions (deprecated) if ( legacy ) { if ( columns ) return { width: size, height: (Math.SQRT3 / 2) * size }; return { width: (Math.SQRT3 / 2) * size, height: size }; } // Columnar orientation if ( columns ) return { width: (2 * size) / Math.SQRT3, height: size }; // Row orientation return { width: size, height: (2 * size) / Math.SQRT3 }; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get columnar() { const msg = "HexagonalGrid#columnar is deprecated in favor of HexagonalGrid#columns."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.columns; } /** * @deprecated since v12 * @ignore */ set columnar(value) { const msg = "HexagonalGrid#columnar is deprecated in favor of HexagonalGrid#columns."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); this.columns = value; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ getCenter(x, y) { const msg = "HexagonalGrid#getCenter is deprecated. Use HexagonalGrid#getCenterPoint instead."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); let [x0, y0] = this.getTopLeft(x, y); return [x0 + (this.sizeX / 2), y0 + (this.sizeY / 2)]; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ getSnappedPosition(x, y, interval=1, {token}={}) { const msg = "HexagonalGrid#getSnappedPosition is deprecated. Use HexagonalGrid#getSnappedPoint instead."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); if ( interval === 0 ) return {x: Math.round(x), y: Math.round(y)}; // At precision 5, return the center or nearest vertex if ( interval === 5) { const w4 = this.w / 4; const h4 = this.h / 4; // Distance relative to center let [xc, yc] = this.getCenter(x, y); let dx = x - xc; let dy = y - yc; let ox = dx.between(-w4, w4) ? 0 : Math.sign(dx); let oy = dy.between(-h4, h4) ? 0 : Math.sign(dy); // Closest to the center if ( (ox === 0) && (oy === 0) ) return {x: xc, y: yc}; // Closest vertex based on offset if ( this.columns && (ox === 0) ) ox = Math.sign(dx) ?? -1; if ( !this.columns && (oy === 0) ) oy = Math.sign(dy) ?? -1; const {x: x0, y: y0 } = this.#getClosestVertex(xc, yc, ox, oy); return {x: Math.round(x0), y: Math.round(y0)}; } // Start with the closest top-left grid position if ( token ) { if ( this.columns && (token.document.height > 1) ) y += this.sizeY / 2; if ( !this.columns && (token.document.width > 1) ) x += this.sizeX / 2; } const options = { columns: this.columns, even: this.even, size: this.size, width: this.sizeX, height: this.sizeY }; const offset = HexagonalGrid.pixelsToOffset({x, y}, options, "round"); const point = HexagonalGrid.offsetToPixels(offset, options); // Adjust pixel coordinate for token size let x0 = point.x; let y0 = point.y; if ( token ) [x0, y0] = this._adjustSnapForTokenSize(x0, y0, token); // Snap directly at interval 1 if ( interval === 1 ) return {x: x0, y: y0}; // Round the remainder const dx = (x - x0).toNearest(this.w / interval); const dy = (y - y0).toNearest(this.h / interval); return {x: Math.round(x0 + dx), y: Math.round(y0 + dy)}; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ #getClosestVertex(xc, yc, ox, oy) { const b = ox + (oy << 2); // Bit shift to make a unique reference const vertices = this.columns ? {"-1": 0, "-5": 1, "-3": 2, 1: 3, 5: 4, 3: 5} // Flat hex vertices : {"-5": 0, "-4": 1, "-3": 2, 5: 3, 4: 4, 3: 5}; // Pointy hex vertices const idx = vertices[b]; const pt = this.hexPoints[idx]; return { x: (xc - (this.sizeX / 2)) + (pt[0] * this.sizeX), y: (yc - (this.sizeY / 2)) + (pt[1] * this.sizeY) }; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ #measureDistance(p0, p1) { const [i0, j0] = this.getGridPositionFromPixels(p0.x, p0.y); const [i1, j1] = this.getGridPositionFromPixels(p1.x, p1.y); const c0 = this.getCube({i: i0, j: j0}); const c1 = this.getCube({i: i1, j: j1}); return HexagonalGrid.cubeDistance(c0, c1); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ getGridPositionFromPixels(x, y) { const msg = "HexagonalGrid#getGridPositionFromPixels is deprecated. This function is based on the \"brick wall\" grid. " + " For getting the offset coordinates of the hex containing the given point use HexagonalGrid#getOffset."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); let {row, col} = HexagonalGrid.pixelsToOffset({x, y}, { columns: this.columns, even: this.even, size: this.size, width: this.sizeX, height: this.sizeY }); return [row, col]; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ getPixelsFromGridPosition(row, col) { const msg = "HexagonalGrid#getPixelsFromGridPosition is deprecated. This function is based on the \"brick wall\" grid. " + " For getting the top-left coordinates of the hex at the given offset coordinates use HexagonalGrid#getTopLeftPoint."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); const {x, y} = HexagonalGrid.offsetToPixels({row, col}, { columns: this.columns, even: this.even, size: this.size, width: this.sizeX, height: this.sizeY }); return [Math.ceil(x), Math.ceil(y)]; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ shiftPosition(x, y, dx, dy, {token}={}) { const msg = "BaseGrid#shiftPosition is deprecated. Use BaseGrid#getShiftedPoint instead."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); let [row, col] = this.getGridPositionFromPixels(x, y); // Adjust diagonal moves for offset let isDiagonal = (dx !== 0) && (dy !== 0); if ( isDiagonal ) { // Column orientation if ( this.columns ) { let isEven = ((col+1) % 2 === 0) === this.even; if ( isEven && (dy > 0)) dy--; else if ( !isEven && (dy < 0)) dy++; } // Row orientation else { let isEven = ((row + 1) % 2 === 0) === this.even; if ( isEven && (dx > 0) ) dx--; else if ( !isEven && (dx < 0 ) ) dx++; } } const [shiftX, shiftY] = this.getPixelsFromGridPosition(row+dy, col+dx); if ( token ) return this._adjustSnapForTokenSize(shiftX, shiftY, token); return [shiftX, shiftY]; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ measureDistances(segments, options={}) { const msg = "HexagonalGrid#measureDistances is deprecated. " + "Use BaseGrid#measurePath instead for non-Euclidean measurements."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); if ( !options.gridSpaces ) return super.measureDistances(segments, options); return segments.map(s => { let r = s.ray; return this.#measureDistance(r.A, r.B) * this.distance; }); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ _adjustPositionForTokenSize(row, col, token) { const msg = "HexagonalGrid#_adjustPositionForTokenSize is deprecated."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); if ( this.columns && (token.document.height > 1) ) row++; if ( !this.columns && (token.document.width > 1) ) col++; return [row, col]; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ static getConfig(type, size) { const msg = "HexagonalGrid.getConfig is deprecated without replacement."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); const config = { columns: [GRID_TYPES.HEXODDQ, GRID_TYPES.HEXEVENQ].includes(type), even: [GRID_TYPES.HEXEVENR, GRID_TYPES.HEXEVENQ].includes(type), size: size }; const {width, height} = HexagonalGrid.computeDimensions(config); config.width = width; config.height = height; return config; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ static offsetToCube({row, col}={}, {columns=true, even=false}={}) { const msg = "HexagonalGrid.offsetToCube is deprecated. Use HexagonalGrid#offsetToCube instead."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return new HexagonalGrid({size: 100, columns, even}).offsetToCube({i: row, j: col}); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ static cubeToOffset(cube={}, {columns=true, even=false}={}) { const msg = "HexagonalGrid.cubeToOffset is deprecated. Use HexagonalGrid#cubeToOffset instead."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); const {i: row, j: col} = new HexagonalGrid({size: 100, columns, even}).cubeToOffset(cube); return {row, col}; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ static pixelToCube({x, y}={}, config) { const msg = "HexagonalGrid.pixelToCube is deprecated. Use HexagonalGrid#pointToCube instead."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); const {size} = config; const cx = x / (size / 2); const cy = y / (size / 2); // Fractional hex coordinates, might not satisfy (fx + fy + fz = 0) due to rounding const fr = (2/3) * cx; const fq = ((-1/3) * cx) + ((1 / Math.sqrt(3)) * cy); const fs = ((-1/3) * cx) - ((1 / Math.sqrt(3)) * cy); // Convert to integer triangle coordinates const a = Math.ceil(fr - fq); const b = Math.ceil(fq - fs); const c = Math.ceil(fs - fr); // Convert back to cube coordinates return { q: Math.round((a - c) / 3), r: Math.round((c - b) / 3), s: Math.round((b - a) / 3) }; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ static offsetToPixels({row, col}, {columns, even, width, height}) { const msg = "HexagonalGrid.offsetToPixels is deprecated. Use HexagonalGrid#getTopLeftPoint instead."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); let x; let y; // Flat-topped hexes if ( columns ) { x = Math.ceil(col * (width * 0.75)); const isEven = (col + 1) % 2 === 0; y = Math.ceil((row - (even === isEven ? 0.5 : 0)) * height); } // Pointy-topped hexes else { y = Math.ceil(row * (height * 0.75)); const isEven = (row + 1) % 2 === 0; x = Math.ceil((col - (even === isEven ? 0.5 : 0)) * width); } // Return the pixel coordinate return {x, y}; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ static pixelsToOffset({x, y}, config, method="floor") { const msg = "HexagonalGrid.pixelsToOffset is deprecated without replacement. This function is based on the \"brick wall\" grid. " + " For getting the offset coordinates of the hex containing the given point use HexagonalGrid#getOffset."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); const {columns, even, width, height} = config; const fn = Math[method]; let row; let col; // Columnar orientation if ( columns ) { col = fn(x / (width * 0.75)); const isEven = (col + 1) % 2 === 0; row = fn((y / height) + (even === isEven ? 0.5 : 0)); } // Row orientation else { row = fn(y / (height * 0.75)); const isEven = (row + 1) % 2 === 0; col = fn((x / width) + (even === isEven ? 0.5 : 0)); } return {row, col}; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ getAStarPath(start, goal, options) { const msg = "HexagonalGrid#getAStarPath is deprecated without replacement."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); const costs = new Map(); // Create a prioritized frontier sorted by increasing cost const frontier = []; const explore = (hex, from, cost) => { const idx = frontier.findIndex(l => l.cost > cost); if ( idx === -1 ) frontier.push({hex, cost, from}); else frontier.splice(idx, 0, {hex, cost, from}); costs.set(hex, cost); }; explore(start, null, 0); // Expand the frontier, exploring towards the goal let current; let solution; while ( frontier.length ) { current = frontier.shift(); if ( current.cost === Infinity ) break; if ( current.hex.equals(goal) ) { solution = current; break; } for ( const next of current.hex.getNeighbors() ) { const deltaCost = next.getTravelCost instanceof Function ? next.getTravelCost(current.hex, options) : 1; const newCost = current.cost + deltaCost; // Total cost of reaching this hex if ( costs.get(next) <= newCost ) continue; // We already made it here in the lowest-cost way explore(next, current, newCost); } } // Ensure a path was achieved if ( !solution ) { throw new Error("No valid path between these positions exists"); } // Return the optimal path and cost const path = []; let c = solution; while ( c.from ) { path.unshift(c.hex); c = c.from; } return {from: start, to: goal, cost: solution.cost, path}; } }; /** * 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. */ 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$1) ) { grid = new HexagonalGrid$1(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); } } /** * The gridless grid class. */ 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; } } /** * @typedef {object} _SquareGridConfiguration * @property {number} [diagonals=CONST.GRID_DIAGONALS.EQUIDISTANT] The rule for diagonal measurement * (see {@link CONST.GRID_DIAGONALS}) */ /** * @typedef {GridConfiguration&_SquareGridConfiguration} SquareGridConfiguration */ /** * An offset of a grid space or a point with pixel coordinates. * @typedef {GridCoordinates} SquareGridCoordinates */ /** * The square grid class. */ class SquareGrid extends BaseGrid { /** * The square grid constructor. * @param {SquareGridConfiguration} config The grid configuration */ constructor(config) { super(config); this.type = GRID_TYPES.SQUARE; /** * The rule for diagonal measurement (see {@link CONST.GRID_DIAGONALS}). * @type {number} */ this.diagonals = config.diagonals ?? GRID_DIAGONALS.EQUIDISTANT; } /* -------------------------------------------- */ /** * Returns the offset of the grid space corresponding to the given coordinates. * @param {SquareGridCoordinates} coords The coordinates * @returns {GridOffset} The offset */ getOffset(coords) { let i = coords.i; let j; if ( i !== undefined ) { j = coords.j; } else { j = Math.floor(coords.x / this.size); i = Math.floor(coords.y / this.size); } return {i, j}; } /* -------------------------------------------- */ /** @override */ getOffsetRange({x, y, width, height}) { const i0 = Math.floor(y / this.size); const j0 = Math.floor(x / this.size); if ( !((width > 0) && (height > 0)) ) return [i0, j0, i0, j0]; return [i0, j0, Math.ceil((y + height) / this.size) | 0, Math.ceil((x + width) / this.size) | 0]; } /* -------------------------------------------- */ /** @override */ getAdjacentOffsets(coords) { const {i, j} = this.getOffset(coords); // Non-diagonals const offsets = [ {i: i - 1, j}, {i, j: j + 1}, {i: i + 1, j}, {i, j: j - 1} ]; if ( this.diagonals === GRID_DIAGONALS.ILLEGAL ) return offsets; // Diagonals offsets.push( {i: i - 1, j: j - 1}, {i: i - 1, j: j + 1}, {i: i + 1, j: j + 1}, {i: i + 1, j: j - 1} ); return offsets; } /* -------------------------------------------- */ /** @override */ testAdjacency(coords1, coords2) { const {i: i1, j: j1} = this.getOffset(coords1); const {i: i2, j: j2} = this.getOffset(coords2); const di = Math.abs(i1 - i2); const dj = Math.abs(j1 - j2); const diagonals = this.diagonals !== GRID_DIAGONALS.ILLEGAL; return diagonals ? Math.max(di, dj) === 1 : (di + dj) === 1; } /* -------------------------------------------- */ /** @override */ getShiftedOffset(coords, 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++; if ( di && dj && (this.diagonals === GRID_DIAGONALS.ILLEGAL) ) { // Diagonal movement is not allowed di = 0; dj = 0; } const offset = this.getOffset(coords); offset.i += di; offset.j += dj; return offset; } /* -------------------------------------------- */ /** @override */ getShiftedPoint(point, direction) { const topLeft = this.getTopLeftPoint(point); const shifted = this.getTopLeftPoint(this.getShiftedOffset(topLeft, direction)); shifted.x = point.x + (shifted.x - topLeft.x); shifted.y = point.y + (shifted.y - topLeft.y); return shifted; } /* -------------------------------------------- */ /** * 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. * @param {SquareGridCoordinates} coords The coordinates * @returns {Point} The top-left point */ getTopLeftPoint(coords) { let i = coords.i; let j; if ( i !== undefined ) { j = coords.j; } else { j = Math.floor(coords.x / this.size); i = Math.floor(coords.y / this.size); } return {x: j * this.size, y: i * this.size}; } /* -------------------------------------------- */ /** * 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. * @param {SquareGridCoordinates} coords The coordinates * @returns {Point} The center point */ getCenterPoint(coords) { const point = this.getTopLeftPoint(coords); const halfSize = this.size / 2; point.x += halfSize; point.y += halfSize; return point; } /* -------------------------------------------- */ /** @override */ getShape() { const s = this.size / 2; return [{x: -s, y: -s}, {x: s, y: -s}, {x: s, y: s}, {x: -s, y: s}]; } /* -------------------------------------------- */ /** @override */ getVertices(coords) { const {i, j} = this.getOffset(coords); const x0 = j * this.size; const x1 = (j + 1) * this.size; const y0 = i * this.size; const y1 = (i + 1) * this.size; return [{x: x0, y: y0}, {x: x1, y: y0}, {x: x1, y: y1}, {x: x0, y: y1}]; } /* -------------------------------------------- */ /** @override */ getSnappedPoint(point, {mode, resolution=1}) { if ( mode & ~0xFFF3 ) throw new Error("Invalid snapping mode"); if ( mode === 0 ) return {x: point.x, y: point.y}; let nearest; let distance; const keepNearest = candidate => { if ( !nearest ) return nearest = candidate; const {x, y} = point; distance ??= ((nearest.x - x) ** 2) + ((nearest.y - y) ** 2); const d = ((candidate.x - x) ** 2) + ((candidate.y - y) ** 2); if ( d < distance ) { nearest = candidate; distance = d; } return nearest; }; // Any edge = Any side if ( !(mode & 0x2) ) { // Horizontal (Top/Bottom) side + Vertical (Left/Right) side = Any edge if ( (mode & 0x3000) && (mode & 0xC000) ) mode |= 0x2; // Horizontal (Top/Bottom) side else if ( mode & 0x3000 ) keepNearest(this.#snapToTopOrBottom(point, resolution)); // Vertical (Left/Right) side else if ( mode & 0xC000 ) keepNearest(this.#snapToLeftOrRight(point, resolution)); } // With vertices (= corners) if ( mode & 0xFF0 ) { switch ( mode & ~0xFFF0 ) { case 0x0: keepNearest(this.#snapToVertex(point, resolution)); break; case 0x1: keepNearest(this.#snapToVertexOrCenter(point, resolution)); break; case 0x2: keepNearest(this.#snapToEdgeOrVertex(point, resolution)); break; case 0x3: keepNearest(this.#snapToEdgeOrVertexOrCenter(point, resolution)); break; } } // Without vertices else { switch ( mode & ~0xFFF0 ) { case 0x1: keepNearest(this.#snapToCenter(point, resolution)); break; case 0x2: keepNearest(this.#snapToEdge(point, resolution)); break; case 0x3: keepNearest(this.#snapToEdgeOrCenter(point, resolution)); break; } } return nearest; } /* -------------------------------------------- */ /** * Snap the point to the nearest center of a square. * @param {Point} point The point * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToCenter({x, y}, resolution) { const s = this.size / resolution; const t = this.size / 2; return { x: (Math.round((x - t) / s) * s) + t, y: (Math.round((y - t) / s) * s) + t }; } /* -------------------------------------------- */ /** * Snap the point to the nearest vertex of a square. * @param {Point} point The point * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToVertex({x, y}, resolution) { const s = this.size / resolution; const t = this.size / 2; return { x: ((Math.ceil((x - t) / s) - 0.5) * s) + t, y: ((Math.ceil((y - t) / s) - 0.5) * s) + t }; } /* -------------------------------------------- */ /** * Snap the point to the nearest vertex or center of a square. * @param {Point} point The point * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToVertexOrCenter({x, y}, resolution) { const s = this.size / resolution; const t = this.size / 2; const c0 = (x - t) / s; const r0 = (y - t) / s; const c1 = Math.round(c0 + r0); const r1 = Math.round(r0 - c0); return { x: ((c1 - r1) * s / 2) + t, y: ((c1 + r1) * s / 2) + t }; } /* -------------------------------------------- */ /** * Snap the point to the nearest edge of a square. * @param {Point} point The point * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToEdge({x, y}, resolution) { const s = this.size / resolution; const t = this.size / 2; const c0 = (x - t) / s; const r0 = (y - t) / s; const c1 = Math.floor(c0 + r0); const r1 = Math.floor(r0 - c0); return { x: ((c1 - r1) * s / 2) + t, y: ((c1 + r1 + 1) * s / 2) + t }; } /* -------------------------------------------- */ /** * Snap the point to the nearest edge or center of a square. * @param {Point} point The point * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToEdgeOrCenter({x, y}, resolution) { const s = this.size / resolution; const t = this.size / 2; const c0 = (x - t) / s; const r0 = (y - t) / s; const x0 = (Math.round(c0) * s) + t; const y0 = (Math.round(r0) * s) + t; if ( Math.max(Math.abs(x - x0), Math.abs(y - y0)) <= s / 4 ) { return {x: x0, y: y0}; } const c1 = Math.floor(c0 + r0); const r1 = Math.floor(r0 - c0); return { x: ((c1 - r1) * s / 2) + t, y: ((c1 + r1 + 1) * s / 2) + t }; } /* -------------------------------------------- */ /** * Snap the point to the nearest edge or vertex of a square. * @param {Point} point The point * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToEdgeOrVertex({x, y}, resolution) { const s = this.size / resolution; const t = this.size / 2; const c0 = (x - t) / s; const r0 = (y - t) / s; const x0 = ((Math.floor(c0) + 0.5) * s) + t; const y0 = ((Math.floor(r0) + 0.5) * s) + t; if ( Math.max(Math.abs(x - x0), Math.abs(y - y0)) <= s / 4 ) { return {x: x0, y: y0}; } const c1 = Math.floor(c0 + r0); const r1 = Math.floor(r0 - c0); return { x: ((c1 - r1) * s / 2) + t, y: ((c1 + r1 + 1) * s / 2) + t }; } /* -------------------------------------------- */ /** * Snap the point to the nearest edge, vertex, or center of a square. * @param {Point} point The point * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToEdgeOrVertexOrCenter({x, y}, resolution) { const s = this.size / (resolution * 2); return { x: Math.round(x / s) * s, y: Math.round(y / s) * s }; } /* -------------------------------------------- */ /** * Snap the point to the nearest top/bottom side of a square. * @param {Point} point The point * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToTopOrBottom({x, y}, resolution) { const s = this.size / resolution; const t = this.size / 2; return { x: (Math.round((x - t) / s) * s) + t, y: ((Math.ceil((y - t) / s) - 0.5) * s) + t }; } /* -------------------------------------------- */ /** * Snap the point to the nearest left/right side of a square. * @param {Point} point The point * @param {number} resolution The grid resolution * @returns {Point} The snapped point */ #snapToLeftOrRight({x, y}, resolution) { const s = this.size / resolution; const t = this.size / 2; return { x: ((Math.ceil((x - t) / s) - 0.5) * s) + t, y: (Math.round((y - t) / s) * s) + t }; } /* -------------------------------------------- */ /** * @typedef {object} _SquareGridMeasurePathResultWaypoint * @property {number} diagonals The total number of diagonals moved along a direct path up to this waypoint. */ /** * @typedef {GridMeasurePathResultWaypoint & _SquareGridMeasurePathResultWaypoint} SquareGridMeasurePathResultWaypoint */ /** * @typedef {object} _SquareGridMeasurePathResultWaypoint * @property {number} diagonals The number of diagonals moved along this segment. */ /** * @typedef {GridMeasurePathResultWaypoint & _SquareGridMeasurePathResultWaypoint} SquareGridMeasurePathResultWaypoint */ /** * @typedef {object} _SquareGridMeasurePathResult * @property {number} diagonals The total number of diagonals moved along a direct path through all waypoints. */ /** * @typedef {GridMeasurePathResult & _SquareGridMeasurePathResult} SquareGridMeasurePathResult */ /** * Measure a shortest, direct path through the given waypoints. * @function measurePath * @memberof SquareGrid * @instance * * @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) * @returns {SquareGridMeasurePathResult} The measurements a shortest, direct path through the given waypoints. */ /** @override */ _measurePath(waypoints, {cost}, result) { result.distance = 0; result.spaces = 0; result.cost = 0; result.diagonals = 0; if ( waypoints.length === 0 ) return; const from = result.waypoints[0]; from.distance = 0; from.spaces = 0; from.cost = 0; from.diagonals = 0; // Convert to point coordiantes const toPoint = coords => { if ( coords.x !== undefined ) return coords; return this.getCenterPoint(coords); }; // Prepare data for the starting point const w0 = waypoints[0]; let o0 = this.getOffset(w0); let p0 = toPoint(w0); // Iterate over additional path points const diagonals = this.diagonals; let da = 0; let db = 0; let l0 = 0; for ( let i = 1; i < waypoints.length; i++ ) { const w1 = waypoints[i]; const o1 = this.getOffset(w1); const p1 = toPoint(w1); // Measure segment const to = result.waypoints[i]; const segment = to.backward; if ( !w1.teleport ) { const di = Math.abs(o0.i - o1.i); const dj = Math.abs(o0.j - o1.j); const ns = Math.abs(di - dj); // The number of straight moves let nd = Math.min(di, dj); // The number of diagonal moves let n = ns + nd; // The number of moves total // Determine the offset distance of the diagonal moves let cd; switch ( diagonals ) { case GRID_DIAGONALS.EQUIDISTANT: cd = nd; break; case GRID_DIAGONALS.EXACT: cd = Math.SQRT2 * nd; break; case GRID_DIAGONALS.APPROXIMATE: cd = 1.5 * nd; break; case GRID_DIAGONALS.RECTILINEAR: cd = 2 * nd; break; case GRID_DIAGONALS.ALTERNATING_1: if ( result.diagonals & 1 ) cd = ((nd + 1) & -2) + (nd >> 1); else cd = (nd & -2) + ((nd + 1) >> 1); break; case GRID_DIAGONALS.ALTERNATING_2: if ( result.diagonals & 1 ) cd = (nd & -2) + ((nd + 1) >> 1); else cd = ((nd + 1) & -2) + (nd >> 1); break; case GRID_DIAGONALS.ILLEGAL: cd = 2 * nd; nd = 0; n = di + dj; break; } // Determine the distance of the segment const dx = Math.abs(p0.x - p1.x) / this.size; const dy = Math.abs(p0.y - p1.y) / this.size; let l; switch ( diagonals ) { case GRID_DIAGONALS.EQUIDISTANT: l = Math.max(dx, dy); break; case GRID_DIAGONALS.EXACT: l = Math.max(dx, dy) + ((Math.SQRT2 - 1) * Math.min(dx, dy)); break; case GRID_DIAGONALS.APPROXIMATE: l = Math.max(dx, dy) + (0.5 * Math.min(dx, dy)); break; case GRID_DIAGONALS.ALTERNATING_1: case GRID_DIAGONALS.ALTERNATING_2: { const a = da += Math.max(dx, dy); const b = db += Math.min(dx, dy); const c = Math.floor(b / 2); const d = b - (2 * c); const e = Math.min(d, 1); const f = Math.max(d, 1) - 1; const l1 = a - b + (3 * c) + e + f + (diagonals === GRID_DIAGONALS.ALTERNATING_1 ? f : e); l = l1 - l0; l0 = l1; } break; case GRID_DIAGONALS.RECTILINEAR: case GRID_DIAGONALS.ILLEGAL: l = dx + dy; break; } if ( l.almostEqual(ns + cd) ) l = ns + cd; // Calculate the distance: the cost of the straight moves plus the cost of the diagonal moves segment.distance = l * this.distance; segment.spaces = n; segment.cost = cost ? this.#calculateCost(o0, o1, cost, result.diagonals) : (ns + cd) * this.distance; segment.diagonals = nd; } else { segment.distance = 0; segment.spaces = 0; segment.cost = cost && ((o0.i !== o1.i) || (o0.j !== o1.j)) ? cost(o0, o1, 0) : 0; segment.diagonals = 0; } // Accumulate measurements result.distance += segment.distance; result.spaces += segment.spaces; result.cost += segment.cost; result.diagonals += segment.diagonals; // Set waypoint measurements to.distance = result.distance; to.spaces = result.spaces; to.cost = result.cost; to.diagonals = result.diagonals; o0 = o1; p0 = p1; } } /* -------------------------------------------- */ /** * Calculate the cost of the direct path segment. * @param {GridOffset} from The coordinates the segment starts from * @param {GridOffset} to The coordinates the segment goes to * @param {GridMeasurePathCostFunction} cost The cost function * @param {number} diagonals The number of diagonal moves that have been performed already * @returns {number} The cost of the path segment */ #calculateCost(from, to, cost, diagonals) { const path = this.getDirectPath([from, to]); if ( path.length <= 1 ) return 0; // Prepare data for the starting point let o0 = path[0]; let c = 0; // Iterate over additional path points for ( let i = 1; i < path.length; i++ ) { const o1 = path[i]; // Determine the normalized distance let k; if ( (o0.i === o1.i) || (o0.j === o1.j) ) k = 1; else { switch ( this.diagonals ) { case GRID_DIAGONALS.EQUIDISTANT: k = 1; break; case GRID_DIAGONALS.EXACT: k = Math.SQRT2; break; case GRID_DIAGONALS.APPROXIMATE: k = 1.5; break; case GRID_DIAGONALS.RECTILINEAR: k = 2; break; case GRID_DIAGONALS.ALTERNATING_1: k = diagonals & 1 ? 2 : 1; break; case GRID_DIAGONALS.ALTERNATING_2: k = diagonals & 1 ? 1 : 2; break; } diagonals++; } // Calculate and accumulate the cost c += cost(o0, o1, k * this.distance); o0 = o1; } return c; } /* -------------------------------------------- */ /** * @see {@link https://en.wikipedia.org/wiki/Bresenham's_line_algorithm} * @override */ getDirectPath(waypoints) { if ( waypoints.length === 0 ) return []; // Prepare data for the starting point const o0 = this.getOffset(waypoints[0]); let {i: i0, j: j0} = o0; const path = [o0]; // Iterate over additional path points const diagonals = this.diagonals !== GRID_DIAGONALS.ILLEGAL; for ( let i = 1; i < waypoints.length; i++ ) { const o1 = this.getOffset(waypoints[i]); const {i: i1, j: j1} = o1; if ( (i0 === i1) && (j0 === j1) ) continue; // Walk from (r0, c0) to (r1, c1) const di = Math.abs(i0 - i1); const dj = 0 - Math.abs(j0 - j1); const si = i0 < i1 ? 1 : -1; const sj = j0 < j1 ? 1 : -1; let e = di + dj; for ( ;; ) { const e2 = e * 2; if ( diagonals ) { if ( e2 >= dj ) { e += dj; i0 += si; } if ( e2 <= di ) { e += di; j0 += sj; } } else { if ( e2 - dj >= di - e2 ) { e += dj; i0 += si; } else { e += di; j0 += sj; } } if ( (i0 === i1) && (j0 === j1) ) break; path.push({i: i0, j: j0}); } path.push(o1); i0 = i1; j0 = j1; } return path; } /* -------------------------------------------- */ /** @override */ getTranslatedPoint(point, direction, distance) { direction = Math.toRadians(direction); const dx = Math.cos(direction); const dy = Math.sin(direction); const adx = Math.abs(dx); const ady = Math.abs(dy); let s = distance / this.distance; switch ( this.diagonals ) { case GRID_DIAGONALS.EQUIDISTANT: s /= Math.max(adx, ady); break; case GRID_DIAGONALS.EXACT: s /= (Math.max(adx, ady) + ((Math.SQRT2 - 1) * Math.min(adx, ady))); break; case GRID_DIAGONALS.APPROXIMATE: s /= (Math.max(adx, ady) + (0.5 * Math.min(adx, ady))); break; case GRID_DIAGONALS.ALTERNATING_1: { let a = Math.max(adx, ady); const b = Math.min(adx, ady); const t = (2 * a) + b; let k = Math.floor(s * b / t); if ( (s * b) - (k * t) > a ) { a += b; k = -1 - k; } s = (s - k) / a; } break; case GRID_DIAGONALS.ALTERNATING_2: { let a = Math.max(adx, ady); const b = Math.min(adx, ady); const t = (2 * a) + b; let k = Math.floor(s * b / t); if ( (s * b) - (k * t) > a + b ) { k += 1; } else { a += b; k = -k; } s = (s - k) / a; } break; case GRID_DIAGONALS.RECTILINEAR: case GRID_DIAGONALS.ILLEGAL: s /= (adx + ady); break; } s *= this.size; return {x: point.x + (dx * s), y: point.y + (dy * s)}; } /* -------------------------------------------- */ /** @override */ getCircle(center, radius) { if ( radius <= 0 ) return []; switch ( this.diagonals ) { case GRID_DIAGONALS.EQUIDISTANT: return this.#getCircleEquidistant(center, radius); case GRID_DIAGONALS.EXACT: return this.#getCircleExact(center, radius); case GRID_DIAGONALS.APPROXIMATE: return this.#getCircleApproximate(center, radius); case GRID_DIAGONALS.ALTERNATING_1: return this.#getCircleAlternating(center, radius, false); case GRID_DIAGONALS.ALTERNATING_2: return this.#getCircleAlternating(center, radius, true); case GRID_DIAGONALS.RECTILINEAR: case GRID_DIAGONALS.ILLEGAL: return this.#getCircleRectilinear(center, radius); } } /* -------------------------------------------- */ /** * Get the circle polygon given the radius in grid units (EQUIDISTANT). * @param {Point} center The center point of the circle. * @param {number} radius The radius in grid units (positive). * @returns {Point[]} The points of the circle polygon. */ #getCircleEquidistant({x, y}, radius) { const r = radius / this.distance * this.size; const x0 = x + r; const x1 = x - r; const y0 = y + r; const y1 = y - r; return [{x: x0, y: y0}, {x: x1, y: y0}, {x: x1, y: y1}, {x: x0, y: y1}]; } /* -------------------------------------------- */ /** * Get the circle polygon given the radius in grid units (EXACT). * @param {Point} center The center point of the circle. * @param {number} radius The radius in grid units (positive). * @returns {Point[]} The points of the circle polygon. */ #getCircleExact({x, y}, radius) { const r = radius / this.distance * this.size; const s = r / Math.SQRT2; return [ {x: x + r, y}, {x: x + s, y: y + s}, {x: x, y: y + r }, {x: x - s, y: y + s}, {x: x - r, y}, {x: x - s, y: y - s}, {x: x, y: y - r}, {x: x + s, y: y - s} ]; } /* -------------------------------------------- */ /** * Get the circle polygon given the radius in grid units (APPROXIMATE). * @param {Point} center The center point of the circle. * @param {number} radius The radius in grid units (positive). * @returns {Point[]} The points of the circle polygon. */ #getCircleApproximate({x, y}, radius) { const r = radius / this.distance * this.size; const s = r / 1.5; return [ {x: x + r, y}, {x: x + s, y: y + s}, {x: x, y: y + r }, {x: x - s, y: y + s}, {x: x - r, y}, {x: x - s, y: y - s}, {x: x, y: y - r}, {x: x + s, y: y - s} ]; } /* -------------------------------------------- */ /** * Get the circle polygon given the radius in grid units (ALTERNATING_1/2). * @param {Point} center The center point of the circle. * @param {number} radius The radius in grid units (positive). * @param {boolean} firstDouble 2/1/2 instead of 1/2/1? * @returns {Point[]} The points of the circle polygon. */ #getCircleAlternating(center, radius, firstDouble) { const r = radius / this.distance; const points = []; let dx = 0; let dy = 0; // Generate points of the first quarter if ( firstDouble ) { points.push({x: r - dx, y: dy}); dx++; dy++; } for ( ;; ) { if ( r - dx < dy ) { [dx, dy] = [dy - 1, dx - 1]; break; } points.push({x: r - dx, y: dy}); dy++; if ( r - dx < dy ) { points.push({x: r - dx, y: r - dx}); if ( dx === 0 ) dy = 0; else { points.push({x: dy - 1, y: r - dx}); [dx, dy] = [dy - 2, dx - 1]; } break; } points.push({x: r - dx, y: dy}); dx++; dy++; } for ( ;; ) { if ( dx === 0 ) break; points.push({x: dx, y: r - dy}); dx--; if ( dx === 0 ) break; points.push({x: dx, y: r - dy}); dx--; dy--; } // Generate the points of the other three quarters by mirroring the first const n = points.length; for ( let i = 0; i < n; i++ ) { const p = points[i]; points.push({x: -p.y, y: p.x}); } for ( let i = 0; i < n; i++ ) { const p = points[i]; points.push({x: -p.x, y: -p.y}); } for ( let i = 0; i < n; i++ ) { const p = points[i]; points.push({x: p.y, y: -p.x}); } // Scale and center the polygon points for ( let i = 0; i < 4 * n; i++ ) { const p = points[i]; p.x = (p.x * this.size) + center.x; p.y = (p.y * this.size) + center.y; } return points; } /* -------------------------------------------- */ /** * Get the circle polygon given the radius in grid units (RECTILINEAR/ILLEGAL). * @param {Point} center The center point of the circle. * @param {number} radius The radius in grid units (positive). * @returns {Point[]} The points of the circle polygon. */ #getCircleRectilinear({x, y}, radius) { const r = radius / this.distance * this.size; return [{x: x + r, y}, {x, y: y + r}, {x: x - r, y}, {x, y: y - r}]; } /* -------------------------------------------- */ /** @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); const rows = Math.ceil((height / this.size) - 1e-6); const columns = Math.ceil((width / this.size) - 1e-6); return {width, height, x, y, rows, columns}; } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ getCenter(x, y) { const msg = "SquareGrid#getCenter is deprecated. Use SquareGrid#getCenterPoint instead."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.getTopLeft(x, y).map(c => c + (this.size / 2)); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ getSnappedPosition(x, y, interval=1, options={}) { const msg = "SquareGrid#getSnappedPosition is deprecated. " + "Use BaseGrid#getSnappedPoint instead for non-Euclidean measurements."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); if ( interval === 0 ) return {x: Math.round(x), y: Math.round(y)}; let [x0, y0] = this.#getNearestVertex(x, y); let dx = 0; let dy = 0; if ( interval !== 1 ) { let delta = this.size / interval; dx = Math.round((x - x0) / delta) * delta; dy = Math.round((y - y0) / delta) * delta; } return { x: Math.round(x0 + dx), y: Math.round(y0 + dy) }; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ #getNearestVertex(x, y) { return [x.toNearest(this.size), y.toNearest(this.size)]; } /* -------------------------------------------- */ /** * @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 [Math.floor(y / this.size), Math.floor(x / this.size)]; } /* -------------------------------------------- */ /** * @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 * this.size, row * this.size]; } /* -------------------------------------------- */ /** * @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}); let [row, col] = this.getGridPositionFromPixels(x, y); return this.getPixelsFromGridPosition(row+dy, col+dx); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ measureDistances(segments, options={}) { const msg = "SquareGrid#measureDistances is deprecated. " + "Use BaseGrid#measurePath instead for non-Euclidean measurements."; logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); if ( !options.gridSpaces ) return super.measureDistances(segments, options); return segments.map(s => { let r = s.ray; let nx = Math.abs(Math.ceil(r.dx / this.size)); let ny = Math.abs(Math.ceil(r.dy / this.size)); // Determine the number of straight and diagonal moves let nd = Math.min(nx, ny); let ns = Math.abs(ny - nx); // Linear distance for all moves return (nd + ns) * this.distance; }); } } /** @module foundry.grid */ var grid = /*#__PURE__*/Object.freeze({ __proto__: null, BaseGrid: BaseGrid, GridHex: GridHex, GridlessGrid: GridlessGrid, HexagonalGrid: HexagonalGrid$1, SquareGrid: SquareGrid }); /** * @typedef {Object} ApplicationConfiguration * @property {string} id An HTML element identifier used for this Application instance * @property {string} uniqueId An string discriminator substituted for {id} in the default * HTML element identifier for the class * @property {string[]} classes An array of CSS classes to apply to the Application * @property {string} tag The HTMLElement tag type used for the outer Application frame * @property {ApplicationWindowConfiguration} window Configuration of the window behaviors for this Application * @property {Record} actions * Click actions supported by the Application and their event handler * functions. A handler function can be defined directly which only * responds to left-click events. Otherwise, an object can be declared * containing both a handler function and an array of buttons which are * matched against the PointerEvent#button property. * @property {ApplicationFormConfiguration} [form] Configuration used if the application top-level element is a form or * dialog * @property {Partial} position Default positioning data for the application */ /** * @typedef {Object} ApplicationPosition * @property {number} top Window offset pixels from top * @property {number} left Window offset pixels from left * @property {number|"auto"} width Un-scaled pixels in width or "auto" * @property {number|"auto"} height Un-scaled pixels in height or "auto" * @property {number} scale A numeric scaling factor applied to application dimensions * @property {number} zIndex A z-index of the application relative to siblings */ /** * @typedef {Object} ApplicationWindowConfiguration * @property {boolean} [frame=true] Is this Application rendered inside a window frame? * @property {boolean} [positioned=true] Can this Application be positioned via JavaScript or only by CSS * @property {string} [title] The window title. Displayed only if the application is framed * @property {string|false} [icon] An optional Font Awesome icon class displayed left of the window title * @property {ApplicationHeaderControlsEntry[]} [controls] An array of window control entries * @property {boolean} [minimizable=true] Can the window app be minimized by double-clicking on the title * @property {boolean} [resizable=false] Is this window resizable? * @property {string} [contentTag="section"] A specific tag name to use for the .window-content element * @property {string[]} [contentClasses] Additional CSS classes to apply to the .window-content element */ /** * @typedef {Object} ApplicationFormConfiguration * @property {ApplicationFormSubmission} handler * @property {boolean} submitOnChange * @property {boolean} closeOnSubmit */ /** * @typedef {Object} ApplicationHeaderControlsEntry * @property {string} icon A font-awesome icon class which denotes the control button * @property {string} label The text label for the control button. This label will be automatically * localized when the button is rendered * @property {string} action The action name triggered by clicking the control button * @property {boolean} [visible] Is the control button visible for the current client? * @property {string|number} [ownership] A key or value in CONST.DOCUMENT_OWNERSHIP_LEVELS that restricts * visibility of this option for the current user. This option only * applies to DocumentSheetV2 instances. */ /** * @typedef {Object} ApplicationConstructorParams * @property {ApplicationPosition} position */ /** * @typedef {Object} ApplicationRenderOptions * @property {boolean} [force=false] Force application rendering. If true, an application which does not * yet exist in the DOM is added. If false, only applications which * already exist are rendered. * @property {ApplicationPosition} [position] A specific position at which to render the Application * @property {ApplicationWindowRenderOptions} [window] Updates to the Application window frame * @property {string[]} [parts] Some Application classes, for example the HandlebarsApplication, * support re-rendering a subset of application parts instead of the full * Application HTML. * @property {boolean} [isFirstRender] Is this render the first one for the application? This property is * populated automatically. */ /** * @typedef {Object} ApplicationWindowRenderOptions * @property {string} title Update the window title with a new value? * @property {string|false} icon Update the window icon with a new value? * @property {boolean} controls Re-render the window controls menu? */ /** * @typedef {Object} ApplicationRenderContext Context data provided to the renderer */ /** * @typedef {Object} ApplicationClosingOptions * @property {boolean} animate Whether to animate the close, or perform it instantaneously * @property {boolean} closeKey Whether the application was closed via keypress. */ /** * @callback ApplicationClickAction An on-click action supported by the Application. Run in the context of * a {@link HandlebarsApplication}. * @param {PointerEvent} event The originating click event * @param {HTMLElement} target The capturing HTML element which defines the [data-action] * @returns {Promise} */ /** * @callback ApplicationFormSubmission A form submission handler method. Run in the context of a * {@link HandlebarsApplication}. * @param {SubmitEvent|Event} event The originating form submission or input change event * @param {HTMLFormElement} form The form element that was submitted * @param {FormDataExtended} formData Processed data for the submitted form * @returns {Promise} */ /** * @typedef {Object} ApplicationTab * @property {string} id * @property {string} group * @property {string} icon * @property {string} label * @property {boolean} active * @property {string} cssClass */ /** * @typedef {Object} FormNode * @property {boolean} fieldset * @property {string} [legend] * @property {FormNode[]} [fields] * @property {DataField} [field] * @property {any} [value] */ /** * @typedef {Object} FormFooterButton * @property {string} type * @property {string} [name] * @property {string} [icon] * @property {string} [label] * @property {string} [action] * @property {string} [cssClass] * @property {boolean} [disabled=false] */ var _types$3 = /*#__PURE__*/Object.freeze({ __proto__: null }); /** * @typedef {import("../_types.mjs").ApplicationConfiguration} ApplicationConfiguration * @typedef {import("../_types.mjs").ApplicationRenderOptions} ApplicationRenderOptions * @typedef {import("../_types.mjs").ApplicationRenderContext} ApplicationRenderContext * @typedef {import("../_types.mjs").ApplicationClosingOptions} ApplicationClosingOptions * @typedef {import("../_types.mjs").ApplicationPosition} ApplicationPosition * @typedef {import("../_types.mjs").ApplicationHeaderControlsEntry} ApplicationHeaderControlsEntry */ /** * The Application class is responsible for rendering an HTMLElement into the Foundry Virtual Tabletop user interface. * @template {ApplicationConfiguration} Configuration * @template {ApplicationRenderOptions} RenderOptions * @alias ApplicationV2 */ class ApplicationV2 extends EventEmitterMixin(Object) { /** * Applications are constructed by providing an object of configuration options. * @param {Partial} [options] Options used to configure the Application instance */ constructor(options={}) { super(); // Configure Application Options this.options = Object.freeze(this._initializeApplicationOptions(options)); this.#id = this.options.id.replace("{id}", this.options.uniqueId); Object.assign(this.#position, this.options.position); // Verify the Application class is renderable this.#renderable = (this._renderHTML !== ApplicationV2.prototype._renderHTML) && (this._replaceHTML !== ApplicationV2.prototype._replaceHTML); } /** * Designates which upstream Application class in this class' inheritance chain is the base application. * Any DEFAULT_OPTIONS of super-classes further upstream of the BASE_APPLICATION are ignored. * Hook events for super-classes further upstream of the BASE_APPLICATION are not dispatched. * @type {typeof ApplicationV2} */ static BASE_APPLICATION = ApplicationV2; /** * The default configuration options which are assigned to every instance of this Application class. * @type {Partial} */ static DEFAULT_OPTIONS = { id: "app-{id}", classes: [], tag: "div", window: { frame: true, positioned: true, title: "", icon: "", controls: [], minimizable: true, resizable: false, contentTag: "section", contentClasses: [] }, actions: {}, form: { handler: undefined, submitOnChange: false, closeOnSubmit: false }, position: {} } /** * The sequence of rendering states that describe the Application life-cycle. * @enum {number} */ static RENDER_STATES = Object.freeze({ ERROR: -3, CLOSING: -2, CLOSED: -1, NONE: 0, RENDERING: 1, RENDERED: 2 }); /** * Which application is currently "in front" with the maximum z-index * @type {ApplicationV2} */ static #frontApp; /** @override */ static emittedEvents = Object.freeze(["render", "close", "position"]); /** * Application instance configuration options. * @type {Configuration} */ options; /** * @type {string} */ #id; /** * Flag that this Application instance is renderable. * Applications are not renderable unless a subclass defines the _renderHTML and _replaceHTML methods. */ #renderable = true; /** * The outermost HTMLElement of this rendered Application. * For window applications this is ApplicationV2##frame. * For non-window applications this ApplicationV2##content. * @type {HTMLDivElement} */ #element; /** * The HTMLElement within which inner HTML is rendered. * For non-window applications this is the same as ApplicationV2##element. * @type {HTMLElement} */ #content; /** * Data pertaining to the minimization status of the Application. * @type {{ * active: boolean, * [priorWidth]: number, * [priorHeight]: number, * [priorBoundingWidth]: number, * [priorBoundingHeight]: number * }} */ #minimization = Object.seal({ active: false, priorWidth: undefined, priorHeight: undefined, priorBoundingWidth: undefined, priorBoundingHeight: undefined }); /** * The rendered position of the Application. * @type {ApplicationPosition} */ #position = Object.seal({ top: undefined, left: undefined, width: undefined, height: "auto", scale: 1, zIndex: _maxZ }); /** * @type {ApplicationV2.RENDER_STATES} */ #state = ApplicationV2.RENDER_STATES.NONE; /** * A Semaphore used to enqueue asynchronous operations. * @type {Semaphore} */ #semaphore = new Semaphore(1); /** * Convenience references to window header elements. * @type {{ * header: HTMLElement, * resize: HTMLElement, * title: HTMLHeadingElement, * icon: HTMLElement, * close: HTMLButtonElement, * controls: HTMLButtonElement, * controlsDropdown: HTMLDivElement, * onDrag: Function, * onResize: Function, * pointerStartPosition: ApplicationPosition, * pointerMoveThrottle: boolean * }} */ get window() { return this.#window; } #window = { title: undefined, icon: undefined, close: undefined, controls: undefined, controlsDropdown: undefined, onDrag: this.#onWindowDragMove.bind(this), onResize: this.#onWindowResizeMove.bind(this), pointerStartPosition: undefined, pointerMoveThrottle: false }; /** * If this Application uses tabbed navigation groups, this mapping is updated whenever the changeTab method is called. * Reports the active tab for each group. * Subclasses may override this property to define default tabs for each group. * @type {Record} */ tabGroups = {}; /* -------------------------------------------- */ /* Application Properties */ /* -------------------------------------------- */ /** * The CSS class list of this Application instance * @type {DOMTokenList} */ get classList() { return this.#element?.classList; } /** * The HTML element ID of this Application instance. * @type {string} */ get id() { return this.#id; } /** * A convenience reference to the title of the Application window. * @type {string} */ get title() { return game.i18n.localize(this.options.window.title); } /** * The HTMLElement which renders this Application into the DOM. * @type {HTMLElement} */ get element() { return this.#element; } /** * Is this Application instance currently minimized? * @type {boolean} */ get minimized() { return this.#minimization.active; } /** * The current position of the application with respect to the window.document.body. * @type {ApplicationPosition} */ position = new Proxy(this.#position, { set: (obj, prop, value) => { if ( prop in obj ) { obj[prop] = value; this._updatePosition(this.#position); return value; } } }); /** * Is this Application instance currently rendered? * @type {boolean} */ get rendered() { return this.#state === ApplicationV2.RENDER_STATES.RENDERED; } /** * The current render state of the Application. * @type {ApplicationV2.RENDER_STATES} */ get state() { return this.#state; } /** * Does this Application instance render within an outer window frame? * @type {boolean} */ get hasFrame() { return this.options.window.frame; } /* -------------------------------------------- */ /* Initialization */ /* -------------------------------------------- */ /** * Iterate over the inheritance chain of this Application. * The chain includes this Application itself and all parents until the base application is encountered. * @see ApplicationV2.BASE_APPLICATION * @generator * @yields {typeof ApplicationV2} */ static *inheritanceChain() { let cls = this; while ( cls ) { yield cls; if ( cls === this.BASE_APPLICATION ) return; cls = Object.getPrototypeOf(cls); } } /* -------------------------------------------- */ /** * Initialize configuration options for the Application instance. * The default behavior of this method is to intelligently merge options for each class with those of their parents. * - Array-based options are concatenated * - Inner objects are merged * - Otherwise, properties in the subclass replace those defined by a parent * @param {Partial} options Options provided directly to the constructor * @returns {ApplicationConfiguration} Configured options for the application instance * @protected */ _initializeApplicationOptions(options) { // Options initialization order const order = [options]; for ( const cls of this.constructor.inheritanceChain() ) { order.unshift(cls.DEFAULT_OPTIONS); } // Intelligently merge with parent class options const applicationOptions = {}; for ( const opts of order ) { for ( const [k, v] of Object.entries(opts) ) { if ( (k in applicationOptions) ) { const v0 = applicationOptions[k]; if ( Array.isArray(v0) ) applicationOptions[k].push(...v); // Concatenate arrays else if ( foundry.utils.getType(v0) === "Object") Object.assign(v0, v); // Merge objects else applicationOptions[k] = foundry.utils.deepClone(v); // Override option } else applicationOptions[k] = foundry.utils.deepClone(v); } } // Unique application ID applicationOptions.uniqueId = String(++globalThis._appId); // Special handling for classes if ( applicationOptions.window.frame ) applicationOptions.classes.unshift("application"); applicationOptions.classes = Array.from(new Set(applicationOptions.classes)); return applicationOptions; } /* -------------------------------------------- */ /* Rendering */ /* -------------------------------------------- */ /** * Render the Application, creating its HTMLElement and replacing its innerHTML. * Add it to the DOM if it is not currently rendered and rendering is forced. Otherwise, re-render its contents. * @param {boolean|RenderOptions} [options] Options which configure application rendering behavior. * A boolean is interpreted as the "force" option. * @param {RenderOptions} [_options] Legacy options for backwards-compatibility with the original * ApplicationV1#render signature. * @returns {Promise} A Promise which resolves to the rendered Application instance */ async render(options={}, _options={}) { if ( typeof options === "boolean" ) options = Object.assign(_options, {force: options}); return this.#semaphore.add(this.#render.bind(this), options); } /* -------------------------------------------- */ /** * Manage the rendering step of the Application life-cycle. * This private method delegates out to several protected methods which can be defined by the subclass. * @param {RenderOptions} [options] Options which configure application rendering behavior * @returns {Promise} A Promise which resolves to the rendered Application instance */ async #render(options) { const states = ApplicationV2.RENDER_STATES; if ( !this.#renderable ) throw new Error(`The ${this.constructor.name} Application class is not renderable because` + " it does not define the _renderHTML and _replaceHTML methods which are required."); // Verify that the Application is allowed to be rendered try { const canRender = this._canRender(options); if ( canRender === false ) return this; } catch(err) { ui.notifications.warn(err.message); return this; } options.isFirstRender = this.#state <= states.NONE; // Prepare rendering context data this._configureRenderOptions(options); const context = await this._prepareContext(options); // Pre-render life-cycle events (awaited) if ( options.isFirstRender ) { if ( !options.force ) return this; await this.#doEvent(this._preFirstRender, {async: true, handlerArgs: [context, options], debugText: "Before first render"}); } await this.#doEvent(this._preRender, {async: true, handlerArgs: [context, options], debugText: "Before render"}); // Render the Application frame this.#state = states.RENDERING; if ( options.isFirstRender ) { this.#element = await this._renderFrame(options); this.#content = this.hasFrame ? this.#element.querySelector(".window-content") : this.#element; this._attachFrameListeners(); } // Render Application content try { const result = await this._renderHTML(context, options); this._replaceHTML(result, this.#content, options); } catch(err) { if ( this.#element ) { this.#element.remove(); this.#element = null; } this.#state = states.ERROR; throw new Error(`Failed to render Application "${this.id}":\n${err.message}`, { cause: err }); } // Register the rendered Application if ( options.isFirstRender ) { foundry.applications.instances.set(this.#id, this); this._insertElement(this.#element); } if ( this.hasFrame ) this._updateFrame(options); this.#state = states.RENDERED; // Post-render life-cycle events (not awaited) if ( options.isFirstRender ) { // noinspection ES6MissingAwait this.#doEvent(this._onFirstRender, {handlerArgs: [context, options], debugText: "After first render"}); } // noinspection ES6MissingAwait this.#doEvent(this._onRender, {handlerArgs: [context, options], debugText: "After render", eventName: "render", hookName: "render", hookArgs: [this.#element]}); // Update application position if ( "position" in options ) this.setPosition(options.position); if ( options.force && this.minimized ) this.maximize(); return this; } /* -------------------------------------------- */ /** * Modify the provided options passed to a render request. * @param {RenderOptions} options Options which configure application rendering behavior * @protected */ _configureRenderOptions(options) { const isFirstRender = this.#state <= ApplicationV2.RENDER_STATES.NONE; const {window, position} = this.options; // Initial frame options if ( isFirstRender ) { if ( this.hasFrame ) { options.window ||= {}; options.window.title ||= this.title; options.window.icon ||= window.icon; options.window.controls = true; options.window.resizable = window.resizable; } } // Automatic repositioning if ( isFirstRender ) options.position = Object.assign(this.#position, options.position); else { if ( position.width === "auto" ) options.position = Object.assign({width: "auto"}, options.position); if ( position.height === "auto" ) options.position = Object.assign({height: "auto"}, options.position); } } /* -------------------------------------------- */ /** * Prepare application rendering context data for a given render request. * @param {RenderOptions} options Options which configure application rendering behavior * @returns {Promise} Context data for the render operation * @protected */ async _prepareContext(options) { return {}; } /* -------------------------------------------- */ /** * Configure the array of header control menu options * @returns {ApplicationHeaderControlsEntry[]} * @protected */ _getHeaderControls() { return this.options.window.controls || []; } /* -------------------------------------------- */ /** * Iterate over header control buttons, filtering for controls which are visible for the current client. * @returns {Generator} * @yields {ApplicationHeaderControlsEntry} * @protected */ *_headerControlButtons() { for ( const control of this._getHeaderControls() ) { if ( control.visible === false ) continue; yield control; } } /* -------------------------------------------- */ /** * Render an HTMLElement for the Application. * An Application subclass must implement this method in order for the Application to be renderable. * @param {ApplicationRenderContext} context Context data for the render operation * @param {RenderOptions} options Options which configure application rendering behavior * @returns {Promise} The result of HTML rendering may be implementation specific. * Whatever value is returned here is passed to _replaceHTML * @abstract */ async _renderHTML(context, options) {} /* -------------------------------------------- */ /** * Replace the HTML of the application with the result provided by the rendering backend. * An Application subclass should implement this method in order for the Application to be renderable. * @param {any} result The result returned by the application rendering backend * @param {HTMLElement} content The content element into which the rendered result must be inserted * @param {RenderOptions} options Options which configure application rendering behavior * @protected */ _replaceHTML(result, content, options) {} /* -------------------------------------------- */ /** * Render the outer framing HTMLElement which wraps the inner HTML of the Application. * @param {RenderOptions} options Options which configure application rendering behavior * @returns {Promise} * @protected */ async _renderFrame(options) { const frame = document.createElement(this.options.tag); frame.id = this.#id; if ( this.options.classes.length ) frame.className = this.options.classes.join(" "); if ( !this.hasFrame ) return frame; // Window applications const labels = { controls: game.i18n.localize("APPLICATION.TOOLS.ControlsMenu"), toggleControls: game.i18n.localize("APPLICATION.TOOLS.ToggleControls"), close: game.i18n.localize("APPLICATION.TOOLS.Close") }; const contentClasses = ["window-content", ...this.options.window.contentClasses].join(" "); frame.innerHTML = `

          <${this.options.window.contentTag} class="${contentClasses}"> ${this.options.window.resizable ? `
          ` : ""}`; // Reference elements this.#window.header = frame.querySelector(".window-header"); this.#window.title = frame.querySelector(".window-title"); this.#window.icon = frame.querySelector(".window-icon"); this.#window.resize = frame.querySelector(".window-resize-handle"); this.#window.close = frame.querySelector("button[data-action=close]"); this.#window.controls = frame.querySelector("button[data-action=toggleControls]"); this.#window.controlsDropdown = frame.querySelector(".controls-dropdown"); return frame; } /* -------------------------------------------- */ /** * Render a header control button. * @param {ApplicationHeaderControlsEntry} control * @returns {HTMLLIElement} * @protected */ _renderHeaderControl(control) { const li = document.createElement("li"); li.className = "header-control"; li.dataset.action = control.action; const label = game.i18n.localize(control.label); li.innerHTML = ``; return li; } /* -------------------------------------------- */ /** * When the Application is rendered, optionally update aspects of the window frame. * @param {RenderOptions} options Options provided at render-time * @protected */ _updateFrame(options) { const window = options.window; if ( !window ) return; if ( "title" in window ) this.#window.title.innerText = window.title; if ( "icon" in window ) this.#window.icon.className = `window-icon fa-fw ${window.icon || "hidden"}`; // Window header controls if ( "controls" in window ) { const controls = []; for ( const c of this._headerControlButtons() ) { controls.push(this._renderHeaderControl(c)); } this.#window.controlsDropdown.replaceChildren(...controls); this.#window.controls.classList.toggle("hidden", !controls.length); } } /* -------------------------------------------- */ /** * Insert the application HTML element into the DOM. * Subclasses may override this method to customize how the application is inserted. * @param {HTMLElement} element The element to insert * @protected */ _insertElement(element) { const existing = document.getElementById(element.id); if ( existing ) existing.replaceWith(element); else document.body.append(element); element.querySelector("[autofocus]")?.focus(); } /* -------------------------------------------- */ /* Closing */ /* -------------------------------------------- */ /** * Close the Application, removing it from the DOM. * @param {ApplicationClosingOptions} [options] Options which modify how the application is closed. * @returns {Promise} A Promise which resolves to the closed Application instance */ async close(options={}) { return this.#semaphore.add(this.#close.bind(this), options); } /* -------------------------------------------- */ /** * Manage the closing step of the Application life-cycle. * This private method delegates out to several protected methods which can be defined by the subclass. * @param {ApplicationClosingOptions} [options] Options which modify how the application is closed * @returns {Promise} A Promise which resolves to the rendered Application instance */ async #close(options) { const states = ApplicationV2.RENDER_STATES; if ( !this.#element ) { this.#state = states.CLOSED; return this; } // Pre-close life-cycle events (awaited) await this.#doEvent(this._preClose, {async: true, handlerArgs: [options], debugText: "Before close"}); // Set explicit dimensions for the transition. if ( options.animate !== false ) { const { width, height } = this.#element.getBoundingClientRect(); this.#applyPosition({ ...this.#position, width, height }); } // Remove the application element this.#element.classList.add("minimizing"); this.#element.style.maxHeight = "0px"; this.#state = states.CLOSING; if ( options.animate !== false ) await this._awaitTransition(this.#element, 1000); // Remove the closed element this._removeElement(this.#element); this.#element = null; this.#state = states.CLOSED; foundry.applications.instances.delete(this.#id); // Reset minimization state this.#minimization.active = false; // Post-close life-cycle events (not awaited) // noinspection ES6MissingAwait this.#doEvent(this._onClose, {handlerArgs: [options], debugText: "After close", eventName: "close", hookName: "close"}); return this; } /* -------------------------------------------- */ /** * Remove the application HTML element from the DOM. * Subclasses may override this method to customize how the application element is removed. * @param {HTMLElement} element The element to be removed * @protected */ _removeElement(element) { element.remove(); } /* -------------------------------------------- */ /* Positioning */ /* -------------------------------------------- */ /** * Update the Application element position using provided data which is merged with the prior position. * @param {Partial} [position] New Application positioning data * @returns {ApplicationPosition} The updated application position */ setPosition(position) { if ( !this.options.window.positioned ) return; position = Object.assign(this.#position, position); this.#doEvent(this._prePosition, {handlerArgs: [position], debugText: "Before reposition"}); // Update resolved position const updated = this._updatePosition(position); Object.assign(this.#position, updated); // Assign CSS styles this.#applyPosition(updated); this.#doEvent(this._onPosition, {handlerArgs: [position], debugText: "After reposition", eventName: "position"}); return position; } /* -------------------------------------------- */ /** * Translate a requested application position updated into a resolved allowed position for the Application. * Subclasses may override this method to implement more advanced positioning behavior. * @param {ApplicationPosition} position Requested Application positioning data * @returns {ApplicationPosition} Resolved Application positioning data * @protected */ _updatePosition(position) { if ( !this.#element ) return position; const el = this.#element; let {width, height, left, top, scale} = position; scale ??= 1.0; const computedStyle = getComputedStyle(el); let minWidth = ApplicationV2.parseCSSDimension(computedStyle.minWidth, el.parentElement.offsetWidth) || 0; let maxWidth = ApplicationV2.parseCSSDimension(computedStyle.maxWidth, el.parentElement.offsetWidth) || Infinity; let minHeight = ApplicationV2.parseCSSDimension(computedStyle.minHeight, el.parentElement.offsetHeight) || 0; let maxHeight = ApplicationV2.parseCSSDimension(computedStyle.maxHeight, el.parentElement.offsetHeight) || Infinity; let bounds = el.getBoundingClientRect(); const {clientWidth, clientHeight} = document.documentElement; // Explicit width const autoWidth = width === "auto"; if ( !autoWidth ) { const targetWidth = Number(width || bounds.width); minWidth = parseInt(minWidth) || 0; maxWidth = parseInt(maxWidth) || (clientWidth / scale); width = Math.clamp(targetWidth, minWidth, maxWidth); } // Explicit height const autoHeight = height === "auto"; if ( !autoHeight ) { const targetHeight = Number(height || bounds.height); minHeight = parseInt(minHeight) || 0; maxHeight = parseInt(maxHeight) || (clientHeight / scale); height = Math.clamp(targetHeight, minHeight, maxHeight); } // Implicit height if ( autoHeight ) { Object.assign(el.style, {width: `${width}px`, height: ""}); bounds = el.getBoundingClientRect(); height = bounds.height; } // Implicit width if ( autoWidth ) { Object.assign(el.style, {height: `${height}px`, width: ""}); bounds = el.getBoundingClientRect(); width = bounds.width; } // Left Offset const scaledWidth = width * scale; const targetLeft = left ?? ((clientWidth - scaledWidth) / 2); const maxLeft = Math.max(clientWidth - scaledWidth, 0); left = Math.clamp(targetLeft, 0, maxLeft); // Top Offset const scaledHeight = height * scale; const targetTop = top ?? ((clientHeight - scaledHeight) / 2); const maxTop = Math.max(clientHeight - scaledHeight, 0); top = Math.clamp(targetTop, 0, maxTop); // Scale scale ??= 1.0; return {width: autoWidth ? "auto" : width, height: autoHeight ? "auto" : height, left, top, scale}; } /* -------------------------------------------- */ /** * Apply validated position changes to the element. * @param {ApplicationPosition} position The new position data to apply. */ #applyPosition(position) { Object.assign(this.#element.style, { width: position.width === "auto" ? "" : `${position.width}px`, height: position.height === "auto" ? "" : `${position.height}px`, left: `${position.left}px`, top: `${position.top}px`, transform: position.scale === 1 ? "" : `scale(${position.scale})` }); } /* -------------------------------------------- */ /* Other Public Methods */ /* -------------------------------------------- */ /** * Is the window control buttons menu currently expanded? * @type {boolean} */ #controlsExpanded = false; /** * Toggle display of the Application controls menu. * Only applicable to window Applications. * @param {boolean} [expanded] Set the controls visibility to a specific state. * Otherwise, the visible state is toggled from its current value */ toggleControls(expanded) { expanded ??= !this.#controlsExpanded; if ( expanded === this.#controlsExpanded ) return; const dropdown = this.#element.querySelector(".controls-dropdown"); dropdown.classList.toggle("expanded", expanded); this.#controlsExpanded = expanded; game.tooltip.deactivate(); } /* -------------------------------------------- */ /** * Minimize the Application, collapsing it to a minimal header. * @returns {Promise} */ async minimize() { if ( this.minimized || !this.hasFrame || !this.options.window.minimizable ) return; this.#minimization.active = true; // Set explicit dimensions for the transition. const { width, height } = this.#element.getBoundingClientRect(); this.#applyPosition({ ...this.#position, width, height }); // Record pre-minimization data this.#minimization.priorWidth = this.#position.width; this.#minimization.priorHeight = this.#position.height; this.#minimization.priorBoundingWidth = width; this.#minimization.priorBoundingHeight = height; // Animate to collapsed size this.#element.classList.add("minimizing"); this.#element.style.maxWidth = "var(--minimized-width)"; this.#element.style.maxHeight = "var(--header-height)"; await this._awaitTransition(this.#element, 1000); this.#element.classList.add("minimized"); this.#element.classList.remove("minimizing"); } /* -------------------------------------------- */ /** * Restore the Application to its original dimensions. * @returns {Promise} */ async maximize() { if ( !this.minimized ) return; this.#minimization.active = false; // Animate back to full size const { priorBoundingWidth: width, priorBoundingHeight: height } = this.#minimization; this.#element.classList.remove("minimized"); this.#element.classList.add("maximizing"); this.#element.style.maxWidth = ""; this.#element.style.maxHeight = ""; this.#applyPosition({ ...this.#position, width, height }); await this._awaitTransition(this.#element, 1000); this.#element.classList.remove("maximizing"); // Restore the application position this._updatePosition(Object.assign(this.#position, { width: this.#minimization.priorWidth, height: this.#minimization.priorHeight })); } /* -------------------------------------------- */ /** * Bring this Application window to the front of the rendering stack by increasing its z-index. * Once ApplicationV1 is deprecated we should switch from _maxZ to ApplicationV2#maxZ * We should also eliminate ui.activeWindow in favor of only ApplicationV2#frontApp */ bringToFront() { if ( !((ApplicationV2.#frontApp === this) && (ui.activeWindow === this)) ) this.#position.zIndex = ++_maxZ; this.#element.style.zIndex = String(this.#position.zIndex); ApplicationV2.#frontApp = this; ui.activeWindow = this; // ApplicationV1 compatibility } /* -------------------------------------------- */ /** * Change the active tab within a tab group in this Application instance. * @param {string} tab The name of the tab which should become active * @param {string} group The name of the tab group which defines the set of tabs * @param {object} [options] Additional options which affect tab navigation * @param {Event} [options.event] An interaction event which caused the tab change, if any * @param {HTMLElement} [options.navElement] An explicit navigation element being modified * @param {boolean} [options.force=false] Force changing the tab even if the new tab is already active * @param {boolean} [options.updatePosition=true] Update application position after changing the tab? */ changeTab(tab, group, {event, navElement, force=false, updatePosition=true}={}) { if ( !tab || !group ) throw new Error("You must pass both the tab and tab group identifier"); if ( (this.tabGroups[group] === tab) && !force ) return; // No change necessary const tabElement = this.#content.querySelector(`.tabs > [data-group="${group}"][data-tab="${tab}"]`); if ( !tabElement ) throw new Error(`No matching tab element found for group "${group}" and tab "${tab}"`); // Update tab navigation for ( const t of this.#content.querySelectorAll(`.tabs > [data-group="${group}"]`) ) { t.classList.toggle("active", t.dataset.tab === tab); } // Update tab contents for ( const section of this.#content.querySelectorAll(`.tab[data-group="${group}"]`) ) { section.classList.toggle("active", section.dataset.tab === tab); } this.tabGroups[group] = tab; // Update automatic width or height if ( !updatePosition ) return; const positionUpdate = {}; if ( this.options.position.width === "auto" ) positionUpdate.width = "auto"; if ( this.options.position.height === "auto" ) positionUpdate.height = "auto"; if ( !foundry.utils.isEmpty(positionUpdate) ) this.setPosition(positionUpdate); } /* -------------------------------------------- */ /* Life-Cycle Handlers */ /* -------------------------------------------- */ /** * Perform an event in the application life-cycle. * Await an internal life-cycle method defined by the class. * Optionally dispatch an event for any registered listeners. * @param {Function} handler A handler function to call * @param {object} options Options which configure event handling * @param {boolean} [options.async] Await the result of the handler function? * @param {any[]} [options.handlerArgs] Arguments passed to the handler function * @param {string} [options.debugText] Debugging text to log for the event * @param {string} [options.eventName] An event name to dispatch for registered listeners * @param {string} [options.hookName] A hook name to dispatch for this and all parent classes * @param {any[]} [options.hookArgs] Arguments passed to the requested hook function * @returns {Promise} A promise which resoles once the handler is complete */ async #doEvent(handler, {async=false, handlerArgs, debugText, eventName, hookName, hookArgs=[]}={}) { // Debug logging if ( debugText && CONFIG.debug.applications ) { console.debug(`${this.constructor.name} | ${debugText}`); } // Call handler function const response = handler.call(this, ...handlerArgs); if ( async ) await response; // Dispatch event for this Application instance if ( eventName ) this.dispatchEvent(new Event(eventName, { bubbles: true, cancelable: true })); // Call hooks for this Application class if ( hookName ) { for ( const cls of this.constructor.inheritanceChain() ) { if ( !cls.name ) continue; Hooks.callAll(`${hookName}${cls.name}`, this, ...hookArgs); } } return response; } /* -------------------------------------------- */ /* Rendering Life-Cycle Methods */ /* -------------------------------------------- */ /** * Test whether this Application is allowed to be rendered. * @param {RenderOptions} options Provided render options * @returns {false|void} Return false to prevent rendering * @throws {Error} An Error to display a warning message * @protected */ _canRender(options) {} /** * Actions performed before a first render of the Application. * @param {ApplicationRenderContext} context Prepared context data * @param {RenderOptions} options Provided render options * @returns {Promise} * @protected */ async _preFirstRender(context, options) {} /** * Actions performed after a first render of the Application. * Post-render steps are not awaited by the render process. * @param {ApplicationRenderContext} context Prepared context data * @param {RenderOptions} options Provided render options * @protected */ _onFirstRender(context, options) {} /** * Actions performed before any render of the Application. * Pre-render steps are awaited by the render process. * @param {ApplicationRenderContext} context Prepared context data * @param {RenderOptions} options Provided render options * @returns {Promise} * @protected */ async _preRender(context, options) {} /** * Actions performed after any render of the Application. * Post-render steps are not awaited by the render process. * @param {ApplicationRenderContext} context Prepared context data * @param {RenderOptions} options Provided render options * @protected */ _onRender(context, options) {} /** * Actions performed before closing the Application. * Pre-close steps are awaited by the close process. * @param {RenderOptions} options Provided render options * @returns {Promise} * @protected */ async _preClose(options) {} /** * Actions performed after closing the Application. * Post-close steps are not awaited by the close process. * @param {RenderOptions} options Provided render options * @protected */ _onClose(options) {} /** * Actions performed before the Application is re-positioned. * Pre-position steps are not awaited because setPosition is synchronous. * @param {ApplicationPosition} position The requested application position * @protected */ _prePosition(position) {} /** * Actions performed after the Application is re-positioned. * @param {ApplicationPosition} position The requested application position * @protected */ _onPosition(position) {} /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** * Attach event listeners to the Application frame. * @protected */ _attachFrameListeners() { // Application Click Events this.#element.addEventListener("pointerdown", this.#onPointerDown.bind(this), {capture: true}); const click = this.#onClick.bind(this); this.#element.addEventListener("click", click); this.#element.addEventListener("contextmenu", click); if ( this.hasFrame ) { this.bringToFront(); this.#window.header.addEventListener("pointerdown", this.#onWindowDragStart.bind(this)); this.#window.header.addEventListener("dblclick", this.#onWindowDoubleClick.bind(this)); this.#window.resize?.addEventListener("pointerdown", this.#onWindowResizeStart.bind(this)); } // Form handlers if ( this.options.tag === "form" ) { this.#element.addEventListener("submit", this._onSubmitForm.bind(this, this.options.form)); this.#element.addEventListener("change", this._onChangeForm.bind(this, this.options.form)); } } /* -------------------------------------------- */ /** * Handle initial pointerdown events inside a rendered Application. * @param {PointerEvent} event */ async #onPointerDown(event) { if ( this.hasFrame ) this.bringToFront(); } /* -------------------------------------------- */ /** * Centralized handling of click events which occur on or within the Application frame. * @param {PointerEvent} event */ async #onClick(event) { const target = event.target; const actionButton = target.closest("[data-action]"); if ( actionButton ) return this.#onClickAction(event, actionButton); } /* -------------------------------------------- */ /** * Handle a click event on an element which defines a [data-action] handler. * @param {PointerEvent} event The originating click event * @param {HTMLElement} target The capturing HTML element which defined a [data-action] */ #onClickAction(event, target) { const action = target.dataset.action; switch ( action ) { case "close": event.stopPropagation(); if ( event.button === 0 ) this.close(); break; case "tab": if ( event.button === 0 ) this.#onClickTab(event); break; case "toggleControls": event.stopPropagation(); if ( event.button === 0 ) this.toggleControls(); break; default: let handler = this.options.actions[action]; // No defined handler if ( !handler ) { this._onClickAction(event, target); break; } // Defined handler let buttons = [0]; if ( typeof handler === "object" ) { buttons = handler.buttons; handler = handler.handler; } if ( buttons.includes(event.button) ) handler?.call(this, event, target); break; } } /* -------------------------------------------- */ /** * Handle click events on a tab within the Application. * @param {PointerEvent} event */ #onClickTab(event) { const button = event.target; const tab = button.dataset.tab; if ( !tab || button.classList.contains("active") ) return; const group = button.dataset.group; const navElement = button.closest(".tabs"); this.changeTab(tab, group, {event, navElement}); } /* -------------------------------------------- */ /** * A generic event handler for action clicks which can be extended by subclasses. * Action handlers defined in DEFAULT_OPTIONS are called first. This method is only called for actions which have * no defined handler. * @param {PointerEvent} event The originating click event * @param {HTMLElement} target The capturing HTML element which defined a [data-action] * @protected */ _onClickAction(event, target) {} /* -------------------------------------------- */ /** * Begin capturing pointer events on the application frame. * @param {PointerEvent} event The triggering event. * @param {function} callback The callback to attach to pointer move events. */ #startPointerCapture(event, callback) { this.#window.pointerStartPosition = Object.assign(foundry.utils.deepClone(this.#position), { clientX: event.clientX, clientY: event.clientY }); this.#element.addEventListener("pointermove", callback, { passive: true }); this.#element.addEventListener("pointerup", event => this.#endPointerCapture(event, callback), { capture: true, once: true }); } /* -------------------------------------------- */ /** * End capturing pointer events on the application frame. * @param {PointerEvent} event The triggering event. * @param {function} callback The callback to remove from pointer move events. */ #endPointerCapture(event, callback) { this.#element.releasePointerCapture(event.pointerId); this.#element.removeEventListener("pointermove", callback); delete this.#window.pointerStartPosition; this.#window.pointerMoveThrottle = false; } /* -------------------------------------------- */ /** * Handle a pointer move event while dragging or resizing the window frame. * @param {PointerEvent} event * @returns {{dx: number, dy: number}|void} The amount the cursor has moved since the last frame, or undefined if * the movement occurred between frames. */ #onPointerMove(event) { if ( this.#window.pointerMoveThrottle ) return; this.#window.pointerMoveThrottle = true; const dx = event.clientX - this.#window.pointerStartPosition.clientX; const dy = event.clientY - this.#window.pointerStartPosition.clientY; requestAnimationFrame(() => this.#window.pointerMoveThrottle = false); return { dx, dy }; } /* -------------------------------------------- */ /** * Begin dragging the Application position. * @param {PointerEvent} event */ #onWindowDragStart(event) { if ( event.target.closest(".header-control") ) return; this.#endPointerCapture(event, this.#window.onDrag); this.#startPointerCapture(event, this.#window.onDrag); } /* -------------------------------------------- */ /** * Begin resizing the Application. * @param {PointerEvent} event */ #onWindowResizeStart(event) { this.#endPointerCapture(event, this.#window.onResize); this.#startPointerCapture(event, this.#window.onResize); } /* -------------------------------------------- */ /** * Drag the Application position during mouse movement. * @param {PointerEvent} event */ #onWindowDragMove(event) { if ( !this.#window.header.hasPointerCapture(event.pointerId) ) { this.#window.header.setPointerCapture(event.pointerId); } const delta = this.#onPointerMove(event); if ( !delta ) return; const { pointerStartPosition } = this.#window; let { top, left, height, width } = pointerStartPosition; left += delta.dx; top += delta.dy; this.setPosition({ top, left, height, width }); } /* -------------------------------------------- */ /** * Resize the Application during mouse movement. * @param {PointerEvent} event */ #onWindowResizeMove(event) { if ( !this.#window.resize.hasPointerCapture(event.pointerId) ) { this.#window.resize.setPointerCapture(event.pointerId); } const delta = this.#onPointerMove(event); if ( !delta ) return; const { scale } = this.#position; const { pointerStartPosition } = this.#window; let { top, left, height, width } = pointerStartPosition; if ( width !== "auto" ) width += delta.dx / scale; if ( height !== "auto" ) height += delta.dy / scale; this.setPosition({ top, left, width, height }); } /* -------------------------------------------- */ /** * Double-click events on the window title are used to minimize or maximize the application. * @param {PointerEvent} event */ #onWindowDoubleClick(event) { event.preventDefault(); if ( event.target.dataset.action ) return; // Ignore double clicks on buttons which perform an action if ( !this.options.window.minimizable ) return; if ( this.minimized ) this.maximize(); else this.minimize(); } /* -------------------------------------------- */ /** * Handle submission for an Application which uses the form element. * @param {ApplicationFormConfiguration} formConfig The form configuration for which this handler is bound * @param {Event|SubmitEvent} event The form submission event * @returns {Promise} * @protected */ async _onSubmitForm(formConfig, event) { event.preventDefault(); const form = event.currentTarget; const {handler, closeOnSubmit} = formConfig; const formData = new FormDataExtended(form); if ( handler instanceof Function ) { try { await handler.call(this, event, form, formData); } catch(err){ ui.notifications.error(err, {console: true}); return; // Do not close } } if ( closeOnSubmit ) await this.close(); } /* -------------------------------------------- */ /** * Handle changes to an input element within the form. * @param {ApplicationFormConfiguration} formConfig The form configuration for which this handler is bound * @param {Event} event An input change event within the form */ _onChangeForm(formConfig, event) { if ( formConfig.submitOnChange ) this._onSubmitForm(formConfig, event); } /* -------------------------------------------- */ /* Helper Methods */ /* -------------------------------------------- */ /** * Parse a CSS style rule into a number of pixels which apply to that dimension. * @param {string} style The CSS style rule * @param {number} parentDimension The relevant dimension of the parent element * @returns {number} The parsed style dimension in pixels */ static parseCSSDimension(style, parentDimension) { if ( style.includes("px") ) return parseInt(style.replace("px", "")); if ( style.includes("%") ) { const p = parseInt(style.replace("%", "")) / 100; return parentDimension * p; } } /* -------------------------------------------- */ /** * Wait for a CSS transition to complete for an element. * @param {HTMLElement} element The element which is transitioning * @param {number} timeout A timeout in milliseconds in case the transitionend event does not occur * @returns {Promise} * @internal */ async _awaitTransition(element, timeout) { return Promise.race([ new Promise(resolve => element.addEventListener("transitionend", resolve, {once: true})), new Promise(resolve => window.setTimeout(resolve, timeout)) ]); } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ bringToTop() { foundry.utils.logCompatibilityWarning(`ApplicationV2#bringToTop is not a valid function and redirects to ApplicationV2#bringToFront. This shim will be removed in v14.`, {since: 12, until: 14}); return this.bringToFront(); } } /** * @typedef {import("../_types.mjs").ApplicationConfiguration} ApplicationConfiguration */ /** * @typedef {Object} DialogV2Button * @property {string} action The button action identifier. * @property {string} label The button label. Will be localized. * @property {string} [icon] FontAwesome icon classes. * @property {string} [class] CSS classes to apply to the button. * @property {boolean} [default] Whether this button represents the default action to take if the user * submits the form without pressing a button, i.e. with an Enter * keypress. * @property {DialogV2ButtonCallback} [callback] A function to invoke when the button is clicked. The value returned * from this function will be used as the dialog's submitted value. * Otherwise, the button's identifier is used. */ /** * @callback DialogV2ButtonCallback * @param {PointerEvent|SubmitEvent} event The button click event, or a form submission event if the dialog was * submitted via keyboard. * @param {HTMLButtonElement} button If the form was submitted via keyboard, this will be the default * button, otherwise the button that was clicked. * @param {HTMLDialogElement} dialog The dialog element. * @returns {Promise} */ /** * @typedef {Object} DialogV2Configuration * @property {boolean} [modal] Modal dialogs prevent interaction with the rest of the UI until they * are dismissed or submitted. * @property {DialogV2Button[]} buttons Button configuration. * @property {string} [content] The dialog content. * @property {DialogV2SubmitCallback} [submit] A function to invoke when the dialog is submitted. This will not be * called if the dialog is dismissed. */ /** * @callback DialogV2RenderCallback * @param {Event} event The render event. * @param {HTMLDialogElement} dialog The dialog element. */ /** * @callback DialogV2CloseCallback * @param {Event} event The close event. * @param {DialogV2} dialog The dialog instance. */ /** * @callback DialogV2SubmitCallback * @param {any} result Either the identifier of the button that was clicked to submit the * dialog, or the result returned by that button's callback. * @returns {Promise} */ /** * @typedef {object} DialogV2WaitOptions * @property {DialogV2RenderCallback} [render] A synchronous function to invoke whenever the dialog is rendered. * @property {DialogV2CloseCallback} [close] A synchronous function to invoke when the dialog is closed under any * circumstances. * @property {boolean} [rejectClose=true] Throw a Promise rejection if the dialog is dismissed. */ /** * A lightweight Application that renders a dialog containing a form with arbitrary content, and some buttons. * @extends {ApplicationV2} * * @example Prompt the user to confirm an action. * ```js * const proceed = await foundry.applications.api.DialogV2.confirm({ * content: "Are you sure?", * rejectClose: false, * modal: true * }); * if ( proceed ) console.log("Proceed."); * else console.log("Do not proceed."); * ``` * * @example Prompt the user for some input. * ```js * let guess; * try { * guess = await foundry.applications.api.DialogV2.prompt({ * window: { title: "Guess a number between 1 and 10" }, * content: '', * ok: { * label: "Submit Guess", * callback: (event, button, dialog) => button.form.elements.guess.valueAsNumber * } * }); * } catch { * console.log("User did not make a guess."); * return; * } * const n = Math.ceil(CONFIG.Dice.randomUniform() * 10); * if ( n === guess ) console.log("User guessed correctly."); * else console.log("User guessed incorrectly."); * ``` * * @example A custom dialog. * ```js * new foundry.applications.api.DialogV2({ * window: { title: "Choose an option" }, * content: ` * * * * `, * buttons: [{ * action: "choice", * label: "Make Choice", * default: true, * callback: (event, button, dialog) => button.form.elements.choice.value * }, { * action: "all", * label: "Take All" * }], * submit: result => { * if ( result === "all" ) console.log("User picked all options."); * else console.log(`User picked option: ${result}`); * } * }).render({ force: true }); * ``` */ class DialogV2 extends ApplicationV2 { /** @inheritDoc */ static DEFAULT_OPTIONS = { id: "dialog-{id}", classes: ["dialog"], tag: "dialog", form: { closeOnSubmit: true }, window: { frame: true, positioned: true, minimizable: false } }; /* -------------------------------------------- */ /** @inheritDoc */ _initializeApplicationOptions(options) { options = super._initializeApplicationOptions(options); if ( !options.buttons?.length ) throw new Error("You must define at least one entry in options.buttons"); options.buttons = options.buttons.reduce((obj, button) => { options.actions[button.action] = this.constructor._onClickButton; obj[button.action] = button; return obj; }, {}); return options; } /* -------------------------------------------- */ /** @override */ async _renderHTML(_context, _options) { const form = document.createElement("form"); form.className = "dialog-form standard-form"; form.autocomplete = "off"; form.innerHTML = ` ${this.options.content ? `
          ${this.options.content}
          ` : ""}
          ${this._renderButtons()}
          `; form.addEventListener("submit", event => this._onSubmit(event.submitter, event)); return form; } /* -------------------------------------------- */ /** * Render configured buttons. * @returns {string} * @protected */ _renderButtons() { return Object.values(this.options.buttons).map(button => { const { action, label, icon, default: isDefault, class: cls="" } = button; return ` `; }).join(""); } /* -------------------------------------------- */ /** * Handle submitting the dialog. * @param {HTMLButtonElement} target The button that was clicked or the default button. * @param {PointerEvent|SubmitEvent} event The triggering event. * @returns {Promise} * @protected */ async _onSubmit(target, event) { event.preventDefault(); const button = this.options.buttons[target?.dataset.action]; const result = (await button?.callback?.(event, target, this.element)) ?? button?.action; await this.options.submit?.(result); return this.options.form.closeOnSubmit ? this.close() : this; } /* -------------------------------------------- */ /** @override */ _onFirstRender(_context, _options) { if ( this.options.modal ) this.element.showModal(); else this.element.show(); } /* -------------------------------------------- */ /** @inheritDoc */ _attachFrameListeners() { super._attachFrameListeners(); this.element.addEventListener("keydown", this._onKeyDown.bind(this)); } /* -------------------------------------------- */ /** @override */ _replaceHTML(result, content, _options) { content.replaceChildren(result); } /* -------------------------------------------- */ /** * Handle keypresses within the dialog. * @param {KeyboardEvent} event The triggering event. * @protected */ _onKeyDown(event) { // Capture Escape keypresses for dialogs to ensure that close is called properly. if ( event.key === "Escape" ) { event.preventDefault(); // Prevent default browser dialog dismiss behavior. event.stopPropagation(); this.close(); } } /* -------------------------------------------- */ /** * @this {DialogV2} * @param {PointerEvent} event The originating click event. * @param {HTMLButtonElement} target The button element that was clicked. * @protected */ static _onClickButton(event, target) { this._onSubmit(target, event); } /* -------------------------------------------- */ /* Factory Methods */ /* -------------------------------------------- */ /** * A utility helper to generate a dialog with yes and no buttons. * @param {Partial} [options] * @param {DialogV2Button} [options.yes] Options to overwrite the default yes button configuration. * @param {DialogV2Button} [options.no] Options to overwrite the default no button configuration. * @returns {Promise} Resolves to true if the yes button was pressed, or false if the no button * was pressed. If additional buttons were provided, the Promise resolves to * the identifier of the one that was pressed, or the value returned by its * callback. If the dialog was dismissed, and rejectClose is false, the * Promise resolves to null. */ static async confirm({ yes={}, no={}, ...options }={}) { options.buttons ??= []; options.buttons.unshift(mergeObject({ action: "yes", label: "Yes", icon: "fas fa-check", callback: () => true }, yes), mergeObject({ action: "no", label: "No", icon: "fas fa-xmark", default: true, callback: () => false }, no)); return this.wait(options); } /* -------------------------------------------- */ /** * A utility helper to generate a dialog with a single confirmation button. * @param {Partial} [options] * @param {Partial} [options.ok] Options to overwrite the default confirmation button configuration. * @returns {Promise} Resolves to the identifier of the button used to submit the dialog, * or the value returned by that button's callback. If the dialog was * dismissed, and rejectClose is false, the Promise resolves to null. */ static async prompt({ ok={}, ...options }={}) { options.buttons ??= []; options.buttons.unshift(mergeObject({ action: "ok", label: "Confirm", icon: "fas fa-check", default: true }, ok)); return this.wait(options); } /* -------------------------------------------- */ /** * Spawn a dialog and wait for it to be dismissed or submitted. * @param {Partial} [options] * @param {DialogV2RenderCallback} [options.render] A function to invoke whenever the dialog is rendered. * @param {DialogV2CloseCallback} [options.close] A function to invoke when the dialog is closed under any * circumstances. * @param {boolean} [options.rejectClose=true] Throw a Promise rejection if the dialog is dismissed. * @returns {Promise} Resolves to the identifier of the button used to submit the * dialog, or the value returned by that button's callback. If the * dialog was dismissed, and rejectClose is false, the Promise * resolves to null. */ static async wait({ rejectClose=true, close, render, ...options }={}) { return new Promise((resolve, reject) => { // Wrap submission handler with Promise resolution. const originalSubmit = options.submit; options.submit = async result => { await originalSubmit?.(result); resolve(result); }; const dialog = new this(options); dialog.addEventListener("close", event => { if ( close instanceof Function ) close(event, dialog); if ( rejectClose ) reject(new Error("Dialog was dismissed without pressing a button.")); else resolve(null); }, { once: true }); if ( render instanceof Function ) { dialog.addEventListener("render", event => render(event, dialog.element)); } dialog.render({ force: true }); }); } } /** * @typedef {import("../_types.mjs").ApplicationConfiguration} ApplicationConfiguration * @typedef {import("../_types.mjs").ApplicationRenderOptions} ApplicationRenderOptions */ /** * @typedef {Object} DocumentSheetConfiguration * @property {Document} document The Document instance associated with this sheet * @property {number} viewPermission A permission level in CONST.DOCUMENT_OWNERSHIP_LEVELS * @property {number} editPermission A permission level in CONST.DOCUMENT_OWNERSHIP_LEVELS * @property {boolean} sheetConfig Allow sheet configuration as a header button */ /** * @typedef {Object} DocumentSheetRenderOptions * @property {string} renderContext A string with the format "{operation}{documentName}" providing context * @property {object} renderData Data describing the document modification that occurred */ /** * The Application class is responsible for rendering an HTMLElement into the Foundry Virtual Tabletop user interface. * @extends {ApplicationV2< * ApplicationConfiguration & DocumentSheetConfiguration, * ApplicationRenderOptions & DocumentSheetRenderOptions * >} * @alias DocumentSheetV2 */ class DocumentSheetV2 extends ApplicationV2 { constructor(options={}) { super(options); this.#document = options.document; } /** @inheritDoc */ static DEFAULT_OPTIONS = { id: "{id}", classes: ["sheet"], tag: "form", // Document sheets are forms by default document: null, viewPermission: DOCUMENT_OWNERSHIP_LEVELS.LIMITED, editPermission: DOCUMENT_OWNERSHIP_LEVELS.OWNER, sheetConfig: true, actions: { configureSheet: DocumentSheetV2.#onConfigureSheet, copyUuid: {handler: DocumentSheetV2.#onCopyUuid, buttons: [0, 2]} }, form: { handler: this.#onSubmitDocumentForm, submitOnChange: false, closeOnSubmit: false } }; /* -------------------------------------------- */ /** * The Document instance associated with the application * @type {ClientDocument} */ get document() { return this.#document; } #document; /* -------------------------------------------- */ /** @override */ get title() { const {constructor: cls, id, name, type} = this.document; const prefix = cls.hasTypeData ? CONFIG[cls.documentName].typeLabels[type] : cls.metadata.label; return `${game.i18n.localize(prefix)}: ${name ?? id}`; } /* -------------------------------------------- */ /** * Is this Document sheet visible to the current User? * This is governed by the viewPermission threshold configured for the class. * @type {boolean} */ get isVisible() { return this.document.testUserPermission(game.user, this.options.viewPermission); } /* -------------------------------------------- */ /** * Is this Document sheet editable by the current User? * This is governed by the editPermission threshold configured for the class. * @type {boolean} */ get isEditable() { if ( this.document.pack ) { const pack = game.packs.get(this.document.pack); if ( pack.locked ) return false; } return this.document.testUserPermission(game.user, this.options.editPermission); } /* -------------------------------------------- */ /** @inheritDoc */ _initializeApplicationOptions(options) { options = super._initializeApplicationOptions(options); options.uniqueId = `${this.constructor.name}-${options.document.uuid}`; return options; } /* -------------------------------------------- */ /** @inheritDoc */ *_headerControlButtons() { for ( const control of this._getHeaderControls() ) { if ( control.visible === false ) continue; if ( ("ownership" in control) && !this.document.testUserPermission(game.user, control.ownership) ) continue; yield control; } } /* -------------------------------------------- */ /** @inheritDoc */ async _renderFrame(options) { const frame = await super._renderFrame(options); // Add form options if ( this.options.tag === "form" ) frame.autocomplete = "off"; // Add document ID copy const copyLabel = game.i18n.localize("SHEETS.CopyUuid"); const copyId = ``; this.window.close.insertAdjacentHTML("beforebegin", copyId); // Add sheet configuration button if ( this.options.sheetConfig && this.isEditable && !this.document.getFlag("core", "sheetLock") ) { const label = game.i18n.localize("SHEETS.ConfigureSheet"); const sheetConfig = ``; this.window.close.insertAdjacentHTML("beforebegin", sheetConfig); } return frame; } /* -------------------------------------------- */ /* Application Life-Cycle Events */ /* -------------------------------------------- */ /** @override */ _canRender(_options) { if ( !this.isVisible ) throw new Error(game.i18n.format("SHEETS.DocumentSheetPrivate", { type: game.i18n.localize(this.document.constructor.metadata.label) })); } /* -------------------------------------------- */ /** @inheritDoc */ _onFirstRender(context, options) { super._onFirstRender(context, options); this.document.apps[this.id] = this; } /* -------------------------------------------- */ /** @override */ _onClose(_options) { delete this.document.apps[this.id]; } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** * Handle click events to configure the sheet used for this document. * @param {PointerEvent} event * @this {DocumentSheetV2} */ static #onConfigureSheet(event) { event.stopPropagation(); // Don't trigger other events if ( event.detail > 1 ) return; // Ignore repeated clicks new DocumentSheetConfig(this.document, { top: this.position.top + 40, left: this.position.left + ((this.position.width - DocumentSheet.defaultOptions.width) / 2) }).render(true); } /* -------------------------------------------- */ /** * Handle click events to copy the UUID of this document to clipboard. * @param {PointerEvent} event * @this {DocumentSheetV2} */ static #onCopyUuid(event) { event.preventDefault(); // Don't open context menu event.stopPropagation(); // Don't trigger other events if ( event.detail > 1 ) return; // Ignore repeated clicks const id = event.button === 2 ? this.document.id : this.document.uuid; const type = event.button === 2 ? "id" : "uuid"; const label = game.i18n.localize(this.document.constructor.metadata.label); game.clipboard.copyPlainText(id); ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", {label, type, id})); } /* -------------------------------------------- */ /* Form Submission */ /* -------------------------------------------- */ /** * Process form submission for the sheet * @this {DocumentSheetV2} The handler is called with the application as its bound scope * @param {SubmitEvent} event The originating form submission event * @param {HTMLFormElement} form The form element that was submitted * @param {FormDataExtended} formData Processed data for the submitted form * @returns {Promise} */ static async #onSubmitDocumentForm(event, form, formData) { const submitData = this._prepareSubmitData(event, form, formData); await this._processSubmitData(event, form, submitData); } /* -------------------------------------------- */ /** * Prepare data used to update the Item upon form submission. * This data is cleaned and validated before being returned for further processing. * @param {SubmitEvent} event The originating form submission event * @param {HTMLFormElement} form The form element that was submitted * @param {FormDataExtended} formData Processed data for the submitted form * @returns {object} Prepared submission data as an object * @throws {Error} Subclasses may throw validation errors here to prevent form submission * @protected */ _prepareSubmitData(event, form, formData) { const submitData = this._processFormData(event, form, formData); const addType = this.document.constructor.hasTypeData && !("type" in submitData); if ( addType ) submitData.type = this.document.type; this.document.validate({changes: submitData, clean: true, fallback: false}); if ( addType ) delete submitData.type; return submitData; } /* -------------------------------------------- */ /** * Customize how form data is extracted into an expanded object. * @param {SubmitEvent} event The originating form submission event * @param {HTMLFormElement} form The form element that was submitted * @param {FormDataExtended} formData Processed data for the submitted form * @returns {object} An expanded object of processed form data * @throws {Error} Subclasses may throw validation errors here to prevent form submission */ _processFormData(event, form, formData) { return foundry.utils.expandObject(formData.object); } /* -------------------------------------------- */ /** * Submit a document update based on the processed form data. * @param {SubmitEvent} event The originating form submission event * @param {HTMLFormElement} form The form element that was submitted * @param {object} submitData Processed and validated form data to be used for a document update * @returns {Promise} * @protected */ async _processSubmitData(event, form, submitData) { await this.document.update(submitData); } /* -------------------------------------------- */ /** * Programmatically submit a DocumentSheetV2 instance, providing additional data to be merged with form data. * @param {object} options * @param {object} [options.updateData] Additional data merged with processed form data * @returns {Promise} */ async submit({updateData}={}) { const formConfig = this.options.form; if ( !formConfig?.handler ) throw new Error(`The ${this.constructor.name} DocumentSheetV2 does not support a` + ` single top-level form element.`); const form = this.element; const event = new Event("submit"); const formData = new FormDataExtended(form); const submitData = this._prepareSubmitData(event, form, formData); foundry.utils.mergeObject(submitData, updateData, {inplace: true}); await this._processSubmitData(event, form, submitData); } } /** * @typedef {import("../types.mjs").Constructor} Constructor * @typedef {import("../_types.mjs").ApplicationConfiguration} ApplicationConfiguration * @typedef {import("./types.mjs").ApplicationFormSubmission} ApplicationFormSubmission */ /** * @typedef {Object} HandlebarsRenderOptions * @property {string[]} parts An array of named template parts to render */ /** * @typedef {Object} HandlebarsTemplatePart * @property {string} template The template entry-point for the part * @property {string} [id] A CSS id to assign to the top-level element of the rendered part. * This id string is automatically prefixed by the application id. * @property {string[]} [classes] An array of CSS classes to apply to the top-level element of the * rendered part. * @property {string[]} [templates] An array of templates that are required to render the part. * If omitted, only the entry-point is inferred as required. * @property {string[]} [scrollable] An array of selectors within this part whose scroll positions should * be persisted during a re-render operation. A blank string is used * to denote that the root level of the part is scrollable. * @property {Record} [forms] A registry of forms selectors and submission handlers. */ /** * Augment an Application class with [Handlebars](https://handlebarsjs.com) template rendering behavior. * @param {Constructor} BaseApplication */ function HandlebarsApplicationMixin(BaseApplication) { /** * The mixed application class augmented with [Handlebars](https://handlebarsjs.com) template rendering behavior. * @extends {ApplicationV2} */ class HandlebarsApplication extends BaseApplication { /** * Configure a registry of template parts which are supported for this application for partial rendering. * @type {Record} */ static PARTS = {} /** * A record of all rendered template parts. * @returns {Record} */ get parts() { return this.#parts; } #parts = {}; /* -------------------------------------------- */ /** @inheritDoc */ _configureRenderOptions(options) { super._configureRenderOptions(options); options.parts ??= Object.keys(this.constructor.PARTS); } /* -------------------------------------------- */ /** @inheritDoc */ async _preFirstRender(context, options) { await super._preFirstRender(context, options); const allTemplates = new Set(); for ( const part of Object.values(this.constructor.PARTS) ) { const partTemplates = part.templates ?? [part.template]; for ( const template of partTemplates ) allTemplates.add(template); } await loadTemplates(Array.from(allTemplates)); } /* -------------------------------------------- */ /** * Render each configured application part using Handlebars templates. * @param {ApplicationRenderContext} context Context data for the render operation * @param {HandlebarsRenderOptions} options Options which configure application rendering behavior * @returns {Promise>} A single rendered HTMLElement for each requested part * @protected * @override */ async _renderHTML(context, options) { const rendered = {}; for ( const partId of options.parts ) { const part = this.constructor.PARTS[partId]; if ( !part ) { ui.notifications.warn(`Part "${partId}" is not a supported template part for ${this.constructor.name}`); continue; } const partContext = await this._preparePartContext(partId, context, options); try { const htmlString = await renderTemplate(part.template, partContext); rendered[partId] = this.#parsePartHTML(partId, part, htmlString); } catch(err) { throw new Error(`Failed to render template part "${partId}":\n${err.message}`, {cause: err}); } } return rendered; } /* -------------------------------------------- */ /** * Prepare context that is specific to only a single rendered part. * * It is recommended to augment or mutate the shared context so that downstream methods like _onRender have * visibility into the data that was used for rendering. It is acceptable to return a different context object * rather than mutating the shared context at the expense of this transparency. * * @param {string} partId The part being rendered * @param {ApplicationRenderContext} context Shared context provided by _prepareContext * @param {HandlebarsRenderOptions} options Options which configure application rendering behavior * @returns {Promise} Context data for a specific part * @protected */ async _preparePartContext(partId, context, options) { context.partId = `${this.id}-${partId}`; return context; } /* -------------------------------------------- */ /** * Parse the returned HTML string from template rendering into a uniquely identified HTMLElement for insertion. * @param {string} partId The id of the part being rendered * @param {HandlebarsTemplatePart} part Configuration of the part being parsed * @param {string} htmlString The string rendered for the part * @returns {HTMLElement} The parsed HTMLElement for the part */ #parsePartHTML(partId, part, htmlString) { const t = document.createElement("template"); t.innerHTML = htmlString; if ( (t.content.children.length !== 1) ) { throw new Error(`Template part "${partId}" must render a single HTML element.`); } const e = t.content.firstElementChild; e.dataset.applicationPart = partId; if ( part.id ) e.setAttribute("id", `${this.id}-${part.id}`); if ( part.classes ) e.classList.add(...part.classes); return e; } /* -------------------------------------------- */ /** * Replace the HTML of the application with the result provided by Handlebars rendering. * @param {Record} result The result from Handlebars template rendering * @param {HTMLElement} content The content element into which the rendered result must be inserted * @param {HandlebarsRenderOptions} options Options which configure application rendering behavior * @protected * @override */ _replaceHTML(result, content, options) { for ( const [partId, htmlElement] of Object.entries(result) ) { const priorElement = content.querySelector(`[data-application-part="${partId}"]`); const state = {}; if ( priorElement ) { this._preSyncPartState(partId, htmlElement, priorElement, state); priorElement.replaceWith(htmlElement); this._syncPartState(partId, htmlElement, priorElement, state); } else content.appendChild(htmlElement); this._attachPartListeners(partId, htmlElement, options); this.#parts[partId] = htmlElement; } } /* -------------------------------------------- */ /** * Prepare data used to synchronize the state of a template part. * @param {string} partId The id of the part being rendered * @param {HTMLElement} newElement The new rendered HTML element for the part * @param {HTMLElement} priorElement The prior rendered HTML element for the part * @param {object} state A state object which is used to synchronize after replacement * @protected */ _preSyncPartState(partId, newElement, priorElement, state) { const part = this.constructor.PARTS[partId]; // Focused element or field const focus = priorElement.querySelector(":focus"); if ( focus?.id ) state.focus = `#${focus.id}`; else if ( focus?.name ) state.focus = `${focus.tagName}[name="${focus.name}"]`; else state.focus = undefined; // Scroll positions state.scrollPositions = []; for ( const selector of (part.scrollable || []) ) { const el0 = selector === "" ? priorElement : priorElement.querySelector(selector); if ( el0 ) { const el1 = selector === "" ? newElement : newElement.querySelector(selector); if ( el1 ) state.scrollPositions.push([el1, el0.scrollTop, el0.scrollLeft]); } } } /* -------------------------------------------- */ /** * Synchronize the state of a template part after it has been rendered and replaced in the DOM. * @param {string} partId The id of the part being rendered * @param {HTMLElement} newElement The new rendered HTML element for the part * @param {HTMLElement} priorElement The prior rendered HTML element for the part * @param {object} state A state object which is used to synchronize after replacement * @protected */ _syncPartState(partId, newElement, priorElement, state) { if ( state.focus ) { const newFocus = newElement.querySelector(state.focus); if ( newFocus ) newFocus.focus(); } for ( const [el, scrollTop, scrollLeft] of state.scrollPositions ) Object.assign(el, {scrollTop, scrollLeft}); } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** * Attach event listeners to rendered template parts. * @param {string} partId The id of the part being rendered * @param {HTMLElement} htmlElement The rendered HTML element for the part * @param {ApplicationRenderOptions} options Rendering options passed to the render method * @protected */ _attachPartListeners(partId, htmlElement, options) { const part = this.constructor.PARTS[partId]; // Attach form submission handlers if ( part.forms ) { for ( const [selector, formConfig] of Object.entries(part.forms) ) { const form = htmlElement.matches(selector) ? htmlElement : htmlElement.querySelector(selector); form.addEventListener("submit", this._onSubmitForm.bind(this, formConfig)); form.addEventListener("change", this._onChangeForm.bind(this, formConfig)); } } } } return HandlebarsApplication; } var _module$b = /*#__PURE__*/Object.freeze({ __proto__: null, ApplicationV2: ApplicationV2, DialogV2: DialogV2, DocumentSheetV2: DocumentSheetV2, HandlebarsApplicationMixin: HandlebarsApplicationMixin }); /** * @typedef {object} DiceTermFulfillmentDescriptor * @property {string} id A unique identifier for the term. * @property {DiceTerm} term The term. * @property {string} method The fulfillment method. * @property {boolean} [isNew] Was the term newly-added to this resolver? */ /** * An application responsible for handling unfulfilled dice terms in a roll. * @extends {ApplicationV2} * @mixes HandlebarsApplication * @alias RollResolver */ class RollResolver extends HandlebarsApplicationMixin(ApplicationV2) { constructor(roll, options={}) { super(options); this.#roll = roll; } /** @inheritDoc */ static DEFAULT_OPTIONS = { id: "roll-resolver-{id}", tag: "form", classes: ["roll-resolver"], window: { title: "DICE.RollResolution", }, position: { width: 500, height: "auto" }, form: { submitOnChange: false, closeOnSubmit: false, handler: this._fulfillRoll } }; /** @override */ static PARTS = { form: { id: "form", template: "templates/dice/roll-resolver.hbs" } }; /** * A collection of fulfillable dice terms. * @type {Map} */ get fulfillable() { return this.#fulfillable; } #fulfillable = new Map(); /** * A function to call when the first pass of fulfillment is complete. * @type {function} */ #resolve; /** * The roll being resolved. * @type {Roll} */ get roll() { return this.#roll; } #roll; /* -------------------------------------------- */ /** * Identify any terms in this Roll that should be fulfilled externally, and prompt the user to do so. * @returns {Promise} Returns a Promise that resolves when the first pass of fulfillment is complete. */ async awaitFulfillment() { const fulfillable = await this.#identifyFulfillableTerms(this.roll.terms); if ( !fulfillable.length ) return; Roll.defaultImplementation.RESOLVERS.set(this.roll, this); this.render(true); return new Promise(resolve => this.#resolve = resolve); } /* -------------------------------------------- */ /** * Register a fulfilled die roll. * @param {string} method The method used for fulfillment. * @param {string} denomination The denomination of the fulfilled die. * @param {number} result The rolled number. * @returns {boolean} Whether the result was consumed. */ registerResult(method, denomination, result) { const query = `label[data-denomination="${denomination}"][data-method="${method}"] > input:not(:disabled)`; const term = Array.from(this.element.querySelectorAll(query)).find(input => input.value === ""); if ( !term ) { ui.notifications.warn(`${denomination} roll was not needed by the resolver.`); return false; } term.value = `${result}`; const submitTerm = term.closest(".form-fields")?.querySelector("button"); if ( submitTerm ) submitTerm.dispatchEvent(new MouseEvent("click")); else this._checkDone(); return true; } /* -------------------------------------------- */ /** @inheritDoc */ async close(options={}) { if ( this.rendered ) await this.constructor._fulfillRoll.call(this, null, null, new FormDataExtended(this.element)); Roll.defaultImplementation.RESOLVERS.delete(this.roll); this.#resolve?.(); return super.close(options); } /* -------------------------------------------- */ /** @inheritDoc */ async _prepareContext(_options) { const context = { formula: this.roll.formula, groups: {} }; for ( const fulfillable of this.fulfillable.values() ) { const { id, term, method, isNew } = fulfillable; fulfillable.isNew = false; const config = CONFIG.Dice.fulfillment.methods[method]; const group = context.groups[id] = { results: [], label: term.expression, icon: config.icon ?? '', tooltip: game.i18n.localize(config.label) }; const { denomination, faces } = term; const icon = CONFIG.Dice.fulfillment.dice[denomination]?.icon; for ( let i = 0; i < Math.max(term.number ?? 1, term.results.length); i++ ) { const result = term.results[i]; const { result: value, exploded, rerolled } = result ?? {}; group.results.push({ denomination, faces, id, method, icon, exploded, rerolled, isNew, value: value ?? "", readonly: method !== "manual", disabled: !!result }); } } return context; } /* -------------------------------------------- */ /** @inheritDoc */ async _onSubmitForm(formConfig, event) { this._toggleSubmission(false); this.element.querySelectorAll("input").forEach(input => { if ( !isNaN(input.valueAsNumber) ) return; const { term } = this.fulfillable.get(input.name); input.value = `${term.randomFace()}`; }); await super._onSubmitForm(formConfig, event); this.element?.querySelectorAll("input").forEach(input => input.disabled = true); this.#resolve(); } /* -------------------------------------------- */ /** * Handle prompting for a single extra result from a term. * @param {DiceTerm} term The term. * @param {string} method The method used to obtain the result. * @param {object} [options] * @returns {Promise} */ async resolveResult(term, method, { reroll=false, explode=false }={}) { const group = this.element.querySelector(`fieldset[data-term-id="${term._id}"]`); if ( !group ) { console.warn("Attempted to resolve a single result for an unregistered DiceTerm."); return; } const fields = document.createElement("div"); fields.classList.add("form-fields"); fields.innerHTML = ` `; group.appendChild(fields); this.setPosition({ height: "auto" }); return new Promise(resolve => { const button = fields.querySelector("button"); const input = fields.querySelector("input"); button.addEventListener("click", () => { if ( !input.validity.valid ) { input.form.reportValidity(); return; } let value = input.valueAsNumber; if ( !value ) value = term.randomFace(); input.value = `${value}`; input.disabled = true; button.remove(); resolve(value); }); }); } /* -------------------------------------------- */ /** * Update the Roll instance with the fulfilled results. * @this {RollResolver} * @param {SubmitEvent} event The originating form submission event. * @param {HTMLFormElement} form The form element that was submitted. * @param {FormDataExtended} formData Processed data for the submitted form. * @returns {Promise} * @protected */ static async _fulfillRoll(event, form, formData) { // Update the DiceTerms with the fulfilled values. for ( let [id, results] of Object.entries(formData.object) ) { const { term } = this.fulfillable.get(id); if ( !Array.isArray(results) ) results = [results]; for ( const result of results ) { const roll = { result: undefined, active: true }; // A null value indicates the user wishes to skip external fulfillment and fall back to the digital roll. if ( result === null ) roll.result = term.randomFace(); else roll.result = result; term.results.push(roll); } } } /* -------------------------------------------- */ /** * Identify any of the given terms which should be fulfilled externally. * @param {RollTerm[]} terms The terms. * @param {object} [options] * @param {boolean} [options.isNew=false] Whether this term is a new addition to the already-rendered RollResolver. * @returns {Promise} */ async #identifyFulfillableTerms(terms, { isNew=false }={}) { const config = game.settings.get("core", "diceConfiguration"); const fulfillable = Roll.defaultImplementation.identifyFulfillableTerms(terms); fulfillable.forEach(term => { if ( term._id ) return; const method = config[term.denomination] || CONFIG.Dice.fulfillment.defaultMethod; const id = foundry.utils.randomID(); term._id = id; term.method = method; this.fulfillable.set(id, { id, term, method, isNew }); }); return fulfillable; } /* -------------------------------------------- */ /** * Add a new term to the resolver. * @param {DiceTerm} term The term. * @returns {Promise} Returns a Promise that resolves when the term's results have been externally fulfilled. */ async addTerm(term) { if ( !(term instanceof foundry.dice.terms.DiceTerm) ) { throw new Error("Only DiceTerm instances may be added to the RollResolver."); } const fulfillable = await this.#identifyFulfillableTerms([term], { isNew: true }); if ( !fulfillable.length ) return; this.render({ force: true, position: { height: "auto" } }); return new Promise(resolve => this.#resolve = resolve); } /* -------------------------------------------- */ /** * Check if all rolls have been fulfilled. * @protected */ _checkDone() { // If the form has already in the submission state, we don't need to re-submit. const submitter = this.element.querySelector('button[type="submit"]'); if ( submitter.disabled ) return; // If there are any manual inputs, or if there are any empty inputs, then fulfillment is not done. if ( this.element.querySelector("input:not([readonly], :disabled)") ) return; for ( const input of this.element.querySelectorAll("input[readonly]:not(:disabled)") ) { if ( input.value === "" ) return; } this.element.requestSubmit(submitter); } /* -------------------------------------------- */ /** * Toggle the state of the submit button. * @param {boolean} enabled Whether the button is enabled. * @protected */ _toggleSubmission(enabled) { const submit = this.element.querySelector('button[type="submit"]'); const icon = submit.querySelector("i"); icon.className = `fas ${enabled ? "fa-check" : "fa-spinner fa-pulse"}`; submit.disabled = !enabled; } } var _module$a = /*#__PURE__*/Object.freeze({ __proto__: null, RollResolver: RollResolver }); /** * An abstract custom HTMLElement designed for use with form inputs. * @abstract * @template {any} FormInputValueType * * @fires {Event} input An "input" event when the value of the input changes * @fires {Event} change A "change" event when the value of the element changes */ class AbstractFormInputElement extends HTMLElement { constructor() { super(); this._internals = this.attachInternals(); } /** * The HTML tag name used by this element. * @type {string} */ static tagName; /** * Declare that this custom element provides form element functionality. * @type {boolean} */ static formAssociated = true; /** * Attached ElementInternals which provides form handling functionality. * @type {ElementInternals} * @protected */ _internals; /** * The primary input (if any). Used to determine what element should receive focus when an associated label is clicked * on. * @type {HTMLElement} * @protected */ _primaryInput; /** * The form this element belongs to. * @type {HTMLFormElement} */ get form() { return this._internals.form; } /* -------------------------------------------- */ /* Element Properties */ /* -------------------------------------------- */ /** * The input element name. * @type {string} */ get name() { return this.getAttribute("name"); } set name(value) { this.setAttribute("name", value); } /* -------------------------------------------- */ /** * The value of the input element. * @type {FormInputValueType} */ get value() { return this._getValue(); } set value(value) { this._setValue(value); this.dispatchEvent(new Event("input", {bubbles: true, cancelable: true})); this.dispatchEvent(new Event("change", {bubbles: true, cancelable: true})); this._refresh(); } /** * The underlying value of the element. * @type {FormInputValueType} * @protected */ _value; /* -------------------------------------------- */ /** * Return the value of the input element which should be submitted to the form. * @returns {FormInputValueType} * @protected */ _getValue() { return this._value; } /* -------------------------------------------- */ /** * Translate user-provided input value into the format that should be stored. * @param {FormInputValueType} value A new value to assign to the element * @throws {Error} An error if the provided value is invalid * @protected */ _setValue(value) { this._value = value; } /* -------------------------------------------- */ /** * Is this element disabled? * @type {boolean} */ get disabled() { return this.hasAttribute("disabled"); } set disabled(value) { this.toggleAttribute("disabled", value); this._toggleDisabled(!this.editable); } /* -------------------------------------------- */ /** * Is this field editable? The field can be neither disabled nor readonly. * @type {boolean} */ get editable() { return !(this.hasAttribute("disabled") || this.hasAttribute("readonly")); } /* -------------------------------------------- */ /** * Special behaviors that the subclass should implement when toggling the disabled state of the input. * @param {boolean} disabled The new disabled state * @protected */ _toggleDisabled(disabled) {} /* -------------------------------------------- */ /* Element Lifecycle */ /* -------------------------------------------- */ /** * Initialize the custom element, constructing its HTML. */ connectedCallback() { const elements = this._buildElements(); this.replaceChildren(...elements); this._refresh(); this._toggleDisabled(!this.editable); this.addEventListener("click", this._onClick.bind(this)); this._activateListeners(); } /* -------------------------------------------- */ /** * Create the HTML elements that should be included in this custom element. * Elements are returned as an array of ordered children. * @returns {HTMLElement[]} * @protected */ _buildElements() { return []; } /* -------------------------------------------- */ /** * Refresh the active state of the custom element. * @protected */ _refresh() {} /* -------------------------------------------- */ /** * Apply key attributes on the containing custom HTML element to input elements contained within it. * @internal */ _applyInputAttributes(input) { input.toggleAttribute("required", this.hasAttribute("required")); input.toggleAttribute("disabled", this.hasAttribute("disabled")); input.toggleAttribute("readonly", this.hasAttribute("readonly")); } /* -------------------------------------------- */ /** * Activate event listeners which add dynamic behavior to the custom element. * @protected */ _activateListeners() {} /* -------------------------------------------- */ /** * Special handling when the custom element is clicked. This should be implemented to transfer focus to an * appropriate internal element. * @param {PointerEvent} event * @protected */ _onClick(event) { if ( event.target === this ) this._primaryInput?.focus?.(); } } /** * @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig */ /** * @typedef {Object} StringTagsInputConfig * @property {boolean} slug Automatically slugify provided strings? */ /** * A custom HTML element which allows for arbitrary assignment of a set of string tags. * This element may be used directly or subclassed to impose additional validation or functionality. * @extends {AbstractFormInputElement>} */ class HTMLStringTagsElement extends AbstractFormInputElement { constructor() { super(); this.#slug = this.hasAttribute("slug"); this._value = new Set(); this._initializeTags(); } /** @override */ static tagName = "string-tags"; static icons = { add: "fa-solid fa-tag", remove: "fa-solid fa-times" } static labels = { add: "ELEMENTS.TAGS.Add", remove: "ELEMENTS.TAGS.Remove", placeholder: "" } /** * The button element to add a new tag. * @type {HTMLButtonElement} */ #button; /** * The input element to enter a new tag. * @type {HTMLInputElement} */ #input; /** * The tags list of assigned tags. * @type {HTMLDivElement} */ #tags; /** * Automatically slugify all strings provided to the element? * @type {boolean} */ #slug; /* -------------------------------------------- */ /** * Initialize innerText or an initial value attribute of the element as a comma-separated list of currently assigned * string tags. * @protected */ _initializeTags() { const initial = this.getAttribute("value") || this.innerText || ""; const tags = initial ? initial.split(",") : []; for ( let tag of tags ) { tag = tag.trim(); if ( tag ) { if ( this.#slug ) tag = tag.slugify({strict: true}); try { this._validateTag(tag); } catch ( err ) { console.warn(err.message); continue; } this._value.add(tag); } } this.innerText = ""; this.removeAttribute("value"); } /* -------------------------------------------- */ /** * Subclasses may impose more strict validation on what tags are allowed. * @param {string} tag A candidate tag * @throws {Error} An error if the candidate tag is not allowed * @protected */ _validateTag(tag) { if ( !tag ) throw new Error(game.i18n.localize("ELEMENTS.TAGS.ErrorBlank")); } /* -------------------------------------------- */ /** @override */ _buildElements() { // Create tags list const tags = document.createElement("div"); tags.className = "tags input-element-tags"; this.#tags = tags; // Create input element const input = document.createElement("input"); input.type = "text"; input.placeholder = game.i18n.localize(this.constructor.labels.placeholder); this.#input = this._primaryInput = input; // Create button const button = document.createElement("button"); button.type = "button"; button.className = `icon ${this.constructor.icons.add}`; button.dataset.tooltip = this.constructor.labels.add; button.ariaLabel = game.i18n.localize(this.constructor.labels.add); this.#button = button; return [this.#tags, this.#input, this.#button]; } /* -------------------------------------------- */ /** @override */ _refresh() { const tags = this.value.map(tag => this.constructor.renderTag(tag, tag, this.editable)); this.#tags.replaceChildren(...tags); } /* -------------------------------------------- */ /** * Render the tagged string as an HTML element. * @param {string} tag The raw tag value * @param {string} [label] An optional tag label * @param {boolean} [editable=true] Is the tag editable? * @returns {HTMLDivElement} A rendered HTML element for the tag */ static renderTag(tag, label, editable=true) { const div = document.createElement("div"); div.className = "tag"; div.dataset.key = tag; const span = document.createElement("span"); span.textContent = label ?? tag; div.append(span); if ( editable ) { const t = game.i18n.localize(this.labels.remove); const a = ``; div.insertAdjacentHTML("beforeend", a); } return div; } /* -------------------------------------------- */ /** @override */ _activateListeners() { this.#button.addEventListener("click", this.#addTag.bind(this)); this.#tags.addEventListener("click", this.#onClickTag.bind(this)); this.#input.addEventListener("keydown", this.#onKeydown.bind(this)); } /* -------------------------------------------- */ /** * Remove a tag from the set when its removal button is clicked. * @param {PointerEvent} event */ #onClickTag(event) { if ( !event.target.classList.contains("remove") ) return; const tag = event.target.closest(".tag"); this._value.delete(tag.dataset.key); this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); this._refresh(); } /* -------------------------------------------- */ /** * Add a tag to the set when the ENTER key is pressed in the text input. * @param {KeyboardEvent} event */ #onKeydown(event) { if ( event.key !== "Enter" ) return; event.preventDefault(); event.stopPropagation(); this.#addTag(); } /* -------------------------------------------- */ /** * Add a new tag to the set upon user input. */ #addTag() { let tag = this.#input.value.trim(); if ( this.#slug ) tag = tag.slugify({strict: true}); // Validate the proposed code try { this._validateTag(tag); } catch(err) { ui.notifications.error(err.message); this.#input.value = ""; return; } // Ensure uniqueness if ( this._value.has(tag) ) { const message = game.i18n.format("ELEMENTS.TAGS.ErrorNonUnique", {tag}); ui.notifications.error(message); this.#input.value = ""; return; } // Add hex this._value.add(tag); this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); this.#input.value = ""; this._refresh(); } /* -------------------------------------------- */ /* Form Handling */ /* -------------------------------------------- */ /** @override */ _getValue() { return Array.from(this._value); } /* -------------------------------------------- */ /** @override */ _setValue(value) { this._value.clear(); const toAdd = []; for ( let v of value ) { if ( this.#slug ) v = v.slugify({strict: true}); this._validateTag(v); toAdd.push(v); } for ( const v of toAdd ) this._value.add(v); } /* -------------------------------------------- */ /** @override */ _toggleDisabled(disabled) { this.#input.toggleAttribute("disabled", disabled); this.#button.toggleAttribute("disabled", disabled); } /* -------------------------------------------- */ /** * Create a HTMLStringTagsElement using provided configuration data. * @param {FormInputConfig & StringTagsInputConfig} config */ static create(config) { const tags = document.createElement(this.tagName); tags.name = config.name; const value = Array.from(config.value || []).join(","); tags.toggleAttribute("slug", !!config.slug); tags.setAttribute("value", value); foundry.applications.fields.setInputAttributes(tags, config); return tags; } } /** * @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig */ /** * @typedef {Object} DocumentTagsInputConfig * @property {string} [type] A specific document type in CONST.ALL_DOCUMENT_TYPES * @property {boolean} [single] Only allow referencing a single document. In this case the submitted form value will * be a single UUID string rather than an array * @property {number} [max] Only allow attaching a maximum number of documents */ /** * A custom HTMLElement used to render a set of associated Documents referenced by UUID. * @extends {AbstractFormInputElement} */ class HTMLDocumentTagsElement extends AbstractFormInputElement { constructor() { super(); this._initializeTags(); } /** @override */ static tagName = "document-tags"; /* -------------------------------------------- */ /** * @override * @type {Record} * @protected */ _value = {}; /** * The button element to add a new document. * @type {HTMLButtonElement} */ #button; /** * The input element to define a Document UUID. * @type {HTMLInputElement} */ #input; /** * The list of tagged documents. * @type {HTMLDivElement} */ #tags; /* -------------------------------------------- */ /** * Restrict this element to documents of a particular type. * @type {string|null} */ get type() { return this.getAttribute("type"); } set type(value) { if ( !value ) return this.removeAttribute("type"); if ( !CONST.ALL_DOCUMENT_TYPES.includes(value) ) { throw new Error(`"${value}" is not a valid Document type in CONST.ALL_DOCUMENT_TYPES`); } this.setAttribute("type", value); } /* -------------------------------------------- */ /** * Restrict to only allow referencing a single Document instead of an array of documents. * @type {boolean} */ get single() { return this.hasAttribute("single"); } set single(value) { this.toggleAttribute("single", value === true); } /* -------------------------------------------- */ /** * Allow a maximum number of documents to be tagged to the element. * @type {number} */ get max() { const max = parseInt(this.getAttribute("max")); return isNaN(max) ? Infinity : max; } set max(value) { if ( Number.isInteger(value) && (value > 0) ) this.setAttribute("max", String(value)); else this.removeAttribute("max"); } /* -------------------------------------------- */ /** * Initialize innerText or an initial value attribute of the element as a serialized JSON array. * @protected */ _initializeTags() { const initial = this.getAttribute("value") || this.innerText || ""; const tags = initial ? initial.split(",") : []; for ( const t of tags ) { try { this.#add(t); } catch(err) { this._value[t] = `${t} [INVALID]`; // Display invalid UUIDs as a raw string } } this.innerText = ""; this.removeAttribute("value"); } /* -------------------------------------------- */ /** @override */ _buildElements() { // Create tags list this.#tags = document.createElement("div"); this.#tags.className = "tags input-element-tags"; // Create input element this.#input = this._primaryInput = document.createElement("input"); this.#input.type = "text"; this.#input.placeholder = game.i18n.format("HTMLDocumentTagsElement.PLACEHOLDER", { type: game.i18n.localize(this.type ? getDocumentClass(this.type).metadata.label : "DOCUMENT.Document")}); // Create button this.#button = document.createElement("button"); this.#button.type = "button"; this.#button.className = "icon fa-solid fa-file-plus"; this.#button.dataset.tooltip = game.i18n.localize("ELEMENTS.DOCUMENT_TAGS.Add"); this.#button.setAttribute("aria-label", this.#button.dataset.tooltip); return [this.#tags, this.#input, this.#button]; } /* -------------------------------------------- */ /** @override */ _refresh() { if ( !this.#tags ) return; // Not yet connected const tags = Object.entries(this._value).map(([k, v]) => this.constructor.renderTag(k, v, this.editable)); this.#tags.replaceChildren(...tags); } /* -------------------------------------------- */ /** * Create an HTML string fragment for a single document tag. * @param {string} uuid The document UUID * @param {string} name The document name * @param {boolean} [editable=true] Is the tag editable? * @returns {HTMLDivElement} */ static renderTag(uuid, name, editable=true) { const div = HTMLStringTagsElement.renderTag(uuid, TextEditor.truncateText(name, {maxLength: 32}), editable); div.classList.add("document-tag"); div.querySelector("span").dataset.tooltip = uuid; if ( editable ) { const t = game.i18n.localize("ELEMENTS.DOCUMENT_TAGS.Remove"); const a = div.querySelector("a"); a.dataset.tooltip = t; a.ariaLabel = t; } return div; } /* -------------------------------------------- */ /** @override */ _activateListeners() { this.#button.addEventListener("click", () => this.#tryAdd(this.#input.value)); this.#tags.addEventListener("click", this.#onClickTag.bind(this)); this.#input.addEventListener("keydown", this.#onKeydown.bind(this)); this.addEventListener("drop", this.#onDrop.bind(this)); } /* -------------------------------------------- */ /** * Remove a single coefficient by clicking on its tag. * @param {PointerEvent} event */ #onClickTag(event) { if ( !event.target.classList.contains("remove") ) return; const tag = event.target.closest(".tag"); delete this._value[tag.dataset.key]; this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); this._refresh(); } /* -------------------------------------------- */ /** * Add a new document tag by pressing the ENTER key in the UUID input field. * @param {KeyboardEvent} event */ #onKeydown(event) { if ( event.key !== "Enter" ) return; event.preventDefault(); event.stopPropagation(); this.#tryAdd(this.#input.value); } /* -------------------------------------------- */ /** * Handle data dropped onto the form element. * @param {DragEvent} event */ #onDrop(event) { event.preventDefault(); const dropData = TextEditor.getDragEventData(event); if ( dropData.uuid ) this.#tryAdd(dropData.uuid); } /* -------------------------------------------- */ /** * Add a Document to the tagged set using the value of the input field. * @param {string} uuid The UUID to attempt to add */ #tryAdd(uuid) { try { this.#add(uuid); this._refresh(); } catch(err) { ui.notifications.error(err.message); } this.#input.value = ""; this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); this.#input.focus(); } /* -------------------------------------------- */ /** * Validate that the tagged document is allowed to be added to this field. * Subclasses may impose more strict validation as to which types of documents are allowed. * @param {foundry.abstract.Document|object} document A candidate document or compendium index entry to tag * @throws {Error} An error if the candidate document is not allowed */ _validateDocument(document) { const {type, max} = this; if ( type && (document.documentName !== type) ) throw new Error(`Incorrect document type "${document.documentName}"` + ` provided to document tag field which requires "${type}" documents.`); const n = Object.keys(this._value).length; if ( n >= max ) throw new Error(`You may only attach at most ${max} Documents to the "${this.name}" field`); } /* -------------------------------------------- */ /** * Add a new UUID to the tagged set, throwing an error if the UUID is not valid. * @param {string} uuid The UUID to add * @throws {Error} If the UUID is not valid */ #add(uuid) { // Require the UUID to exist let record; const {id} = foundry.utils.parseUuid(uuid); if ( id ) record = fromUuidSync(uuid); else if ( this.type ) { const collection = game.collections.get(this.type); record = collection.get(uuid); } if ( !record ) throw new Error(`Invalid document UUID "${uuid}" provided to document tag field.`); // Require a certain type of document this._validateDocument(record); // Replace singleton if ( this.single ) { for ( const k of Object.keys(this._value) ) delete this._value[k]; } // Record the document this._value[uuid] = record.name; } /* -------------------------------------------- */ /* Form Handling */ /* -------------------------------------------- */ /** @override */ _getValue() { const uuids = Object.keys(this._value); if ( this.single ) return uuids[0] ?? null; else return uuids; } /** @override */ _setValue(value) { this._value = {}; if ( !value ) return; if ( typeof value === "string" ) value = [value]; for ( const uuid of value ) this.#add(uuid); } /* -------------------------------------------- */ /** @override */ _toggleDisabled(disabled) { this.#input?.toggleAttribute("disabled", disabled); this.#button?.toggleAttribute("disabled", disabled); } /* -------------------------------------------- */ /** * Create a HTMLDocumentTagsElement using provided configuration data. * @param {FormInputConfig & DocumentTagsInputConfig} config * @returns {HTMLDocumentTagsElement} */ static create(config) { const tags = /** @type {HTMLDocumentTagsElement} */ document.createElement(HTMLDocumentTagsElement.tagName); tags.name = config.name; // Coerce value to an array let values; if ( config.value instanceof Set ) values = Array.from(config.value); else if ( !Array.isArray(config.value) ) values = [config.value]; else values = config.value; tags.setAttribute("value", values); tags.type = config.type; tags.max = config.max; tags.single = config.single; foundry.applications.fields.setInputAttributes(tags, config); return tags; } } /** * @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig */ /** * @typedef {Object} FilePickerInputConfig * @property {FilePickerOptions.type} [type] * @property {string} [placeholder] * @property {boolean} [noupload] */ /** * A custom HTML element responsible for rendering a file input field and associated FilePicker button. * @extends {AbstractFormInputElement} */ class HTMLFilePickerElement extends AbstractFormInputElement { /** @override */ static tagName = "file-picker"; /** * The file path selected. * @type {HTMLInputElement} */ input; /** * A button to open the file picker interface. * @type {HTMLButtonElement} */ button; /** * A reference to the FilePicker application instance originated by this element. * @type {FilePicker} */ picker; /* -------------------------------------------- */ /** * A type of file which can be selected in this field. * @see {@link FilePicker.FILE_TYPES} * @type {FilePickerOptions.type} */ get type() { return this.getAttribute("type") ?? "any"; } set type(value) { if ( !FilePicker.FILE_TYPES.includes(value) ) throw new Error(`Invalid type "${value}" provided which must be a ` + "value in FilePicker.TYPES"); this.setAttribute("type", value); } /* -------------------------------------------- */ /** * Prevent uploading new files as part of this element's FilePicker dialog. * @type {boolean} */ get noupload() { return this.hasAttribute("noupload"); } set noupload(value) { this.toggleAttribute("noupload", value === true); } /* -------------------------------------------- */ /** @override */ _buildElements() { // Initialize existing value this._value ??= this.getAttribute("value") || this.innerText || ""; this.removeAttribute("value"); // Create an input field const elements = []; this.input = this._primaryInput = document.createElement("input"); this.input.className = "image"; this.input.type = "text"; this.input.placeholder = this.getAttribute("placeholder") ?? "path/to/file.ext"; elements.push(this.input); // Disallow browsing for some users if ( game.world && !game.user.can("FILES_BROWSE") ) return elements; // Create a FilePicker button this.button = document.createElement("button"); this.button.className = "fa-solid fa-file-import fa-fw"; this.button.type = "button"; this.button.dataset.tooltip = game.i18n.localize("FILES.BrowseTooltip"); this.button.setAttribute("aria-label", this.button.dataset.tooltip); this.button.tabIndex = -1; elements.push(this.button); return elements; } /* -------------------------------------------- */ /** @override */ _refresh() { this.input.value = this._value; } /* -------------------------------------------- */ /** @override */ _toggleDisabled(disabled) { this.input.disabled = disabled; if ( this.button ) this.button.disabled = disabled; } /* -------------------------------------------- */ /** @override */ _activateListeners() { this.input.addEventListener("input", () => this._value = this.input.value); this.button?.addEventListener("click", this.#onClickButton.bind(this)); } /* -------------------------------------------- */ /** * Handle clicks on the button element to render the FilePicker UI. * @param {PointerEvent} event The initiating click event */ #onClickButton(event) { event.preventDefault(); this.picker = new FilePicker({ type: this.type, current: this.value, allowUpload: !this.noupload, callback: src => this.value = src }); return this.picker.browse(); } /* -------------------------------------------- */ /** * Create a HTMLFilePickerElement using provided configuration data. * @param {FormInputConfig & FilePickerInputConfig} config */ static create(config) { const picker = document.createElement(this.tagName); picker.name = config.name; picker.setAttribute("value", config.value || ""); picker.type = config.type; picker.noupload = config.noupload; foundry.applications.fields.setInputAttributes(picker, config); return picker; } } /** * A class designed to standardize the behavior for a hue selector UI component. * @extends {AbstractFormInputElement} */ class HTMLHueSelectorSlider extends AbstractFormInputElement { /** @override */ static tagName = "hue-slider"; /** * The color range associated with this element. * @type {HTMLInputElement|null} */ #input; /* -------------------------------------------- */ /** @override */ _buildElements() { // Initialize existing value this._setValue(this.getAttribute("value")); // Build elements this.#input = this._primaryInput = document.createElement("input"); this.#input.className = "color-range"; this.#input.type = "range"; this.#input.min = "0"; this.#input.max = "360"; this.#input.step = "1"; this.#input.disabled = this.disabled; this.#input.value = this._value * 360; return [this.#input]; } /* -------------------------------------------- */ /** * Refresh the active state of the custom element. * @protected */ _refresh() { this.#input.style.setProperty("--color-thumb", Color.fromHSL([this._value, 1, 0.5]).css); } /* -------------------------------------------- */ /** * Activate event listeners which add dynamic behavior to the custom element. * @protected */ _activateListeners() { this.#input.oninput = this.#onInputColorRange.bind(this); } /* -------------------------------------------- */ /** * Update the thumb and the value. * @param {FormDataEvent} event */ #onInputColorRange(event) { event.preventDefault(); event.stopImmediatePropagation(); this.value = this.#input.value / 360; } /* -------------------------------------------- */ /* Form Handling /* -------------------------------------------- */ /** @override */ _setValue(value) { value = Number(value); if ( !value.between(0, 1) ) throw new Error("The value of a hue-slider must be on the range [0,1]"); this._value = value; this.setAttribute("value", String(value)); } /* -------------------------------------------- */ /** @override */ _toggleDisabled(disabled) { this.#input.disabled = disabled; } } /** * An abstract base class designed to standardize the behavior for a multi-select UI component. * Multi-select components return an array of values as part of form submission. * Different implementations may provide different experiences around how inputs are presented to the user. * @extends {AbstractFormInputElement>} */ class AbstractMultiSelectElement extends AbstractFormInputElement { constructor() { super(); this._value = new Set(); this._initialize(); } /** * Predefined elements which were defined in the original HTML. * @type {(HTMLOptionElement|HTMLOptGroupElement)[]} * @protected */ _options; /** * An object which maps option values to displayed labels. * @type {Record} * @protected */ _choices = {}; /* -------------------------------------------- */ /** * Preserve existing elements which are defined in the original HTML. * @protected */ _initialize() { this._options = [...this.children]; for ( const option of this.querySelectorAll("option") ) { if ( !option.value ) continue; // Skip predefined options which are already blank this._choices[option.value] = option.innerText; if ( option.selected ) { this._value.add(option.value); option.selected = false; } } } /* -------------------------------------------- */ /** * Mark a choice as selected. * @param {string} value The value to add to the chosen set */ select(value) { const exists = this._value.has(value); if ( !exists ) { if ( !(value in this._choices) ) { throw new Error(`"${value}" is not an option allowed by this multi-select element`); } this._value.add(value); this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); this._refresh(); } } /* -------------------------------------------- */ /** * Mark a choice as un-selected. * @param {string} value The value to delete from the chosen set */ unselect(value) { const exists = this._value.has(value); if ( exists ) { this._value.delete(value); this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); this._refresh(); } } /* -------------------------------------------- */ /* Form Handling */ /* -------------------------------------------- */ /** @override */ _getValue() { return Array.from(this._value); } /** @override */ _setValue(value) { if ( !Array.isArray(value) ) { throw new Error("The value assigned to a multi-select element must be an array."); } if ( value.some(v => !(v in this._choices)) ) { throw new Error("The values assigned to a multi-select element must all be valid options."); } this._value.clear(); for ( const v of value ) this._value.add(v); } } /* -------------------------------------------- */ /** * Provide a multi-select workflow using a select element as the input mechanism. * * @example Multi-Select HTML Markup * ```html * * * * * * * * * * * * ``` */ class HTMLMultiSelectElement extends AbstractMultiSelectElement { /** @override */ static tagName = "multi-select"; /** * A select element used to choose options. * @type {HTMLSelectElement} */ #select; /** * A display element which lists the chosen options. * @type {HTMLDivElement} */ #tags; /* -------------------------------------------- */ /** @override */ _buildElements() { // Create select element this.#select = this._primaryInput = document.createElement("select"); this.#select.insertAdjacentHTML("afterbegin", ''); this.#select.append(...this._options); this.#select.disabled = !this.editable; // Create a div element for display this.#tags = document.createElement("div"); this.#tags.className = "tags input-element-tags"; return [this.#tags, this.#select]; } /* -------------------------------------------- */ /** @override */ _refresh() { // Update the displayed tags const tags = Array.from(this._value).map(id => { return HTMLStringTagsElement.renderTag(id, this._choices[id], this.editable); }); this.#tags.replaceChildren(...tags); // Disable selected options for ( const option of this.#select.querySelectorAll("option") ) { option.disabled = this._value.has(option.value); } } /* -------------------------------------------- */ /** @override */ _activateListeners() { this.#select.addEventListener("change", this.#onChangeSelect.bind(this)); this.#tags.addEventListener("click", this.#onClickTag.bind(this)); } /* -------------------------------------------- */ /** * Handle changes to the Select input, marking the selected option as a chosen value. * @param {Event} event The change event on the select element */ #onChangeSelect(event) { event.preventDefault(); event.stopImmediatePropagation(); const select = event.currentTarget; if ( !select.value ) return; // Ignore selection of the blank value this.select(select.value); select.value = ""; } /* -------------------------------------------- */ /** * Handle click events on a tagged value, removing it from the chosen set. * @param {PointerEvent} event The originating click event on a chosen tag */ #onClickTag(event) { event.preventDefault(); if ( !event.target.classList.contains("remove") ) return; if ( !this.editable ) return; const tag = event.target.closest(".tag"); this.unselect(tag.dataset.key); } /* -------------------------------------------- */ /** @override */ _toggleDisabled(disabled) { this.#select.toggleAttribute("disabled", disabled); } /* -------------------------------------------- */ /** * Create a HTMLMultiSelectElement using provided configuration data. * @param {FormInputConfig & Omit} config * @returns {HTMLMultiSelectElement} */ static create(config) { return foundry.applications.fields.createMultiSelectInput(config); } } /* -------------------------------------------- */ /** * Provide a multi-select workflow as a grid of input checkbox elements. * * @example Multi-Checkbox HTML Markup * ```html * * * * * * * * * * * * ``` */ class HTMLMultiCheckboxElement extends AbstractMultiSelectElement { /** @override */ static tagName = "multi-checkbox"; /** * The checkbox elements used to select inputs * @type {HTMLInputElement[]} */ #checkboxes; /* -------------------------------------------- */ /** @override */ _buildElements() { this.#checkboxes = []; const children = []; for ( const option of this._options ) { if ( option instanceof HTMLOptGroupElement ) children.push(this.#buildGroup(option)); else children.push(this.#buildOption(option)); } return children; } /* -------------------------------------------- */ /** * Translate an input element into a
          of checkboxes. * @param {HTMLOptGroupElement} optgroup The originally configured optgroup * @returns {HTMLFieldSetElement} The created fieldset grouping */ #buildGroup(optgroup) { // Create fieldset group const group = document.createElement("fieldset"); group.classList.add("checkbox-group"); const legend = document.createElement("legend"); legend.innerText = optgroup.label; group.append(legend); // Add child options for ( const option of optgroup.children ) { if ( option instanceof HTMLOptionElement ) { group.append(this.#buildOption(option)); } } return group; } /* -------------------------------------------- */ /** * Build an input