Initial
This commit is contained in:
335
resources/app/client/ui/context.js
Normal file
335
resources/app/client/ui/context.js
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* @typedef {object} ContextMenuEntry
|
||||
* @property {string} name The context menu label. Can be localized.
|
||||
* @property {string} icon A string containing an HTML icon element for the menu item
|
||||
* @property {string} [classes] Additional CSS classes to apply to this menu item.
|
||||
* @property {string} group An identifier for a group this entry belongs to.
|
||||
* @property {function(jQuery)} callback The function to call when the menu item is clicked. Receives the HTML element
|
||||
* of the entry that this context menu is for.
|
||||
* @property {ContextMenuCondition|boolean} [condition] A function to call or boolean value to determine if this entry
|
||||
* appears in the menu.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback ContextMenuCondition
|
||||
* @param {jQuery} html The HTML element of the context menu entry.
|
||||
* @returns {boolean} Whether the entry should be rendered in the context menu.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback ContextMenuCallback
|
||||
* @param {HTMLElement} target The element that the context menu has been triggered for.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Display a right-click activated Context Menu which provides a dropdown menu of options
|
||||
* A ContextMenu is constructed by designating a parent HTML container and a target selector
|
||||
* An Array of menuItems defines the entries of the menu which is displayed
|
||||
*/
|
||||
class ContextMenu {
|
||||
/**
|
||||
* @param {HTMLElement|jQuery} element The containing HTML element within which the menu is positioned
|
||||
* @param {string} selector A CSS selector which activates the context menu.
|
||||
* @param {ContextMenuEntry[]} menuItems An Array of entries to display in the menu
|
||||
* @param {object} [options] Additional options to configure the context menu.
|
||||
* @param {string} [options.eventName="contextmenu"] Optionally override the triggering event which can spawn the
|
||||
* menu
|
||||
* @param {ContextMenuCallback} [options.onOpen] A function to call when the context menu is opened.
|
||||
* @param {ContextMenuCallback} [options.onClose] A function to call when the context menu is closed.
|
||||
*/
|
||||
constructor(element, selector, menuItems, {eventName="contextmenu", onOpen, onClose}={}) {
|
||||
|
||||
/**
|
||||
* The target HTMLElement being selected
|
||||
* @type {HTMLElement|jQuery}
|
||||
*/
|
||||
this.element = element;
|
||||
|
||||
/**
|
||||
* The target CSS selector which activates the menu
|
||||
* @type {string}
|
||||
*/
|
||||
this.selector = selector || element.attr("id");
|
||||
|
||||
/**
|
||||
* An interaction event name which activates the menu
|
||||
* @type {string}
|
||||
*/
|
||||
this.eventName = eventName;
|
||||
|
||||
/**
|
||||
* The array of menu items being rendered
|
||||
* @type {ContextMenuEntry[]}
|
||||
*/
|
||||
this.menuItems = menuItems;
|
||||
|
||||
/**
|
||||
* A function to call when the context menu is opened.
|
||||
* @type {Function}
|
||||
*/
|
||||
this.onOpen = onOpen;
|
||||
|
||||
/**
|
||||
* A function to call when the context menu is closed.
|
||||
* @type {Function}
|
||||
*/
|
||||
this.onClose = onClose;
|
||||
|
||||
/**
|
||||
* Track which direction the menu is expanded in
|
||||
* @type {boolean}
|
||||
*/
|
||||
this._expandUp = false;
|
||||
|
||||
// Bind to the current element
|
||||
this.bind();
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent HTML element to which the context menu is attached
|
||||
* @type {HTMLElement}
|
||||
*/
|
||||
#target;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A convenience accessor to the context menu HTML object
|
||||
* @returns {*|jQuery.fn.init|jQuery|HTMLElement}
|
||||
*/
|
||||
get menu() {
|
||||
return $("#context-menu");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a ContextMenu for this Application and dispatch hooks.
|
||||
* @param {Application|ApplicationV2} app The Application this ContextMenu belongs to.
|
||||
* @param {JQuery|HTMLElement} html The Application's rendered HTML.
|
||||
* @param {string} selector The target CSS selector which activates the menu.
|
||||
* @param {ContextMenuEntry[]} menuItems The array of menu items being rendered.
|
||||
* @param {object} [options] Additional options to configure context menu initialization.
|
||||
* @param {string} [options.hookName="EntryContext"] The name of the hook to call.
|
||||
* @returns {ContextMenu}
|
||||
*/
|
||||
static create(app, html, selector, menuItems, {hookName="EntryContext", ...options}={}) {
|
||||
// FIXME ApplicationV2 does not support these hooks yet
|
||||
app._callHooks?.(className => `get${className}${hookName}`, menuItems);
|
||||
return new ContextMenu(html, selector, menuItems, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Attach a ContextMenu instance to an HTML selector
|
||||
*/
|
||||
bind() {
|
||||
const element = this.element instanceof HTMLElement ? this.element : this.element[0];
|
||||
element.addEventListener(this.eventName, event => {
|
||||
const matching = event.target.closest(this.selector);
|
||||
if ( !matching ) return;
|
||||
event.preventDefault();
|
||||
const priorTarget = this.#target;
|
||||
this.#target = matching;
|
||||
const menu = this.menu;
|
||||
|
||||
// Remove existing context UI
|
||||
const prior = document.querySelector(".context");
|
||||
prior?.classList.remove("context");
|
||||
if ( this.#target.contains(menu[0]) ) return this.close();
|
||||
|
||||
// If the menu is already open, call its close handler on its original target.
|
||||
ui.context?.onClose?.(priorTarget);
|
||||
|
||||
// Render a new context menu
|
||||
event.stopPropagation();
|
||||
ui.context = this;
|
||||
this.onOpen?.(this.#target);
|
||||
return this.render($(this.#target), { event });
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Closes the menu and removes it from the DOM.
|
||||
* @param {object} [options] Options to configure the closing behavior.
|
||||
* @param {boolean} [options.animate=true] Animate the context menu closing.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async close({animate=true}={}) {
|
||||
if ( animate ) await this._animateClose(this.menu);
|
||||
this._close();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
_close() {
|
||||
for ( const item of this.menuItems ) {
|
||||
delete item.element;
|
||||
}
|
||||
this.menu.remove();
|
||||
$(".context").removeClass("context");
|
||||
delete ui.context;
|
||||
this.onClose?.(this.#target);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
async _animateOpen(menu) {
|
||||
menu.hide();
|
||||
return new Promise(resolve => menu.slideDown(200, resolve));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
async _animateClose(menu) {
|
||||
return new Promise(resolve => menu.slideUp(200, resolve));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Render the Context Menu by iterating over the menuItems it contains.
|
||||
* Check the visibility of each menu item, and only render ones which are allowed by the item's logical condition.
|
||||
* Attach a click handler to each item which is rendered.
|
||||
* @param {jQuery} target The target element to which the context menu is attached
|
||||
* @param {object} [options]
|
||||
* @param {PointerEvent} [options.event] The event that triggered the context menu opening.
|
||||
* @returns {Promise<jQuery>|void} A Promise that resolves when the open animation has completed.
|
||||
*/
|
||||
render(target, options={}) {
|
||||
const existing = $("#context-menu");
|
||||
let html = existing.length ? existing : $('<nav id="context-menu"></nav>');
|
||||
let ol = $('<ol class="context-items"></ol>');
|
||||
html.html(ol);
|
||||
|
||||
if ( !this.menuItems.length ) return;
|
||||
|
||||
const groups = this.menuItems.reduce((acc, entry) => {
|
||||
const group = entry.group ?? "_none";
|
||||
acc[group] ??= [];
|
||||
acc[group].push(entry);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
for ( const [group, entries] of Object.entries(groups) ) {
|
||||
let parent = ol;
|
||||
if ( group !== "_none" ) {
|
||||
const groupItem = $(`<li class="context-group" data-group-id="${group}"><ol></ol></li>`);
|
||||
ol.append(groupItem);
|
||||
parent = groupItem.find("ol");
|
||||
}
|
||||
for ( const item of entries ) {
|
||||
// Determine menu item visibility (display unless false)
|
||||
let display = true;
|
||||
if ( item.condition !== undefined ) {
|
||||
display = ( item.condition instanceof Function ) ? item.condition(target) : item.condition;
|
||||
}
|
||||
if ( !display ) continue;
|
||||
|
||||
// Construct and add the menu item
|
||||
const name = game.i18n.localize(item.name);
|
||||
const classes = ["context-item", item.classes].filterJoin(" ");
|
||||
const li = $(`<li class="${classes}">${item.icon}${name}</li>`);
|
||||
li.children("i").addClass("fa-fw");
|
||||
parent.append(li);
|
||||
|
||||
// Record a reference to the item
|
||||
item.element = li[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Bail out if there are no children
|
||||
if ( ol.children().length === 0 ) return;
|
||||
|
||||
// Append to target
|
||||
this._setPosition(html, target, options);
|
||||
|
||||
// Apply interactivity
|
||||
if ( !existing.length ) this.activateListeners(html);
|
||||
|
||||
// Deactivate global tooltip
|
||||
game.tooltip.deactivate();
|
||||
|
||||
// Animate open the menu
|
||||
return this._animateOpen(html);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Set the position of the context menu, taking into consideration whether the menu should expand upward or downward
|
||||
* @param {jQuery} html The context menu element.
|
||||
* @param {jQuery} target The element that the context menu was spawned on.
|
||||
* @param {object} [options]
|
||||
* @param {PointerEvent} [options.event] The event that triggered the context menu opening.
|
||||
* @protected
|
||||
*/
|
||||
_setPosition(html, target, { event }={}) {
|
||||
const container = target[0].parentElement;
|
||||
|
||||
// Append to target and get the context bounds
|
||||
target.css("position", "relative");
|
||||
html.css("visibility", "hidden");
|
||||
target.append(html);
|
||||
const contextRect = html[0].getBoundingClientRect();
|
||||
const parentRect = target[0].getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
// Determine whether to expand upwards
|
||||
const contextTop = parentRect.top - contextRect.height;
|
||||
const contextBottom = parentRect.bottom + contextRect.height;
|
||||
const canOverflowUp = (contextTop > containerRect.top) || (getComputedStyle(container).overflowY === "visible");
|
||||
|
||||
// If it overflows the container bottom, but not the container top
|
||||
const containerUp = ( contextBottom > containerRect.bottom ) && ( contextTop >= containerRect.top );
|
||||
const windowUp = ( contextBottom > window.innerHeight ) && ( contextTop > 0 ) && canOverflowUp;
|
||||
this._expandUp = containerUp || windowUp;
|
||||
|
||||
// Display the menu
|
||||
html.toggleClass("expand-up", this._expandUp);
|
||||
html.toggleClass("expand-down", !this._expandUp);
|
||||
html.css("visibility", "");
|
||||
target.addClass("context");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Local listeners which apply to each ContextMenu instance which is created.
|
||||
* @param {jQuery} html
|
||||
*/
|
||||
activateListeners(html) {
|
||||
html.on("click", "li.context-item", this.#onClickItem.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle click events on context menu items.
|
||||
* @param {PointerEvent} event The click event
|
||||
*/
|
||||
#onClickItem(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const li = event.currentTarget;
|
||||
const item = this.menuItems.find(i => i.element === li);
|
||||
item?.callback($(this.#target));
|
||||
this.close();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Global listeners which apply once only to the document.
|
||||
*/
|
||||
static eventListeners() {
|
||||
document.addEventListener("click", ev => {
|
||||
if ( ui.context ) ui.context.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
344
resources/app/client/ui/dialog.js
Normal file
344
resources/app/client/ui/dialog.js
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* @typedef {ApplicationOptions} DialogOptions
|
||||
* @property {boolean} [jQuery=true] Whether to provide jQuery objects to callback functions (if true) or plain
|
||||
* HTMLElement instances (if false). This is currently true by default but in the
|
||||
* future will become false by default.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DialogButton
|
||||
* @property {string} icon A Font Awesome icon for the button
|
||||
* @property {string} label The label for the button
|
||||
* @property {boolean} disabled Whether the button is disabled
|
||||
* @property {function(jQuery)} [callback] A callback function that fires when the button is clicked
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} DialogData
|
||||
* @property {string} title The window title displayed in the dialog header
|
||||
* @property {string} content HTML content for the dialog form
|
||||
* @property {Record<string, DialogButton>} buttons The buttons which are displayed as action choices for the dialog
|
||||
* @property {string} [default] The name of the default button which should be triggered on Enter keypress
|
||||
* @property {function(jQuery)} [render] A callback function invoked when the dialog is rendered
|
||||
* @property {function(jQuery)} [close] Common callback operations to perform when the dialog is closed
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a dialog window displaying a title, a message, and a set of buttons which trigger callback functions.
|
||||
* @param {DialogData} data An object of dialog data which configures how the modal window is rendered
|
||||
* @param {DialogOptions} [options] Dialog rendering options, see {@link Application}.
|
||||
*
|
||||
* @example Constructing a custom dialog instance
|
||||
* ```js
|
||||
* let d = new Dialog({
|
||||
* title: "Test Dialog",
|
||||
* content: "<p>You must choose either Option 1, or Option 2</p>",
|
||||
* buttons: {
|
||||
* one: {
|
||||
* icon: '<i class="fas fa-check"></i>',
|
||||
* label: "Option One",
|
||||
* callback: () => console.log("Chose One")
|
||||
* },
|
||||
* two: {
|
||||
* icon: '<i class="fas fa-times"></i>',
|
||||
* label: "Option Two",
|
||||
* callback: () => console.log("Chose Two")
|
||||
* }
|
||||
* },
|
||||
* default: "two",
|
||||
* render: html => console.log("Register interactivity in the rendered dialog"),
|
||||
* close: html => console.log("This always is logged no matter which option is chosen")
|
||||
* });
|
||||
* d.render(true);
|
||||
* ```
|
||||
*/
|
||||
class Dialog extends Application {
|
||||
constructor(data, options) {
|
||||
super(options);
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bound instance of the _onKeyDown method which is used to listen to keypress events while the Dialog is active.
|
||||
* @type {function(KeyboardEvent)}
|
||||
*/
|
||||
#onKeyDown;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {DialogOptions}
|
||||
*/
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
template: "templates/hud/dialog.html",
|
||||
focus: true,
|
||||
classes: ["dialog"],
|
||||
width: 400,
|
||||
jQuery: true
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get title() {
|
||||
return this.data.title || "Dialog";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
getData(options={}) {
|
||||
let buttons = Object.keys(this.data.buttons).reduce((obj, key) => {
|
||||
let b = this.data.buttons[key];
|
||||
b.cssClass = (this.data.default === key ? [key, "default", "bright"] : [key]).join(" ");
|
||||
if ( b.condition !== false ) obj[key] = b;
|
||||
return obj;
|
||||
}, {});
|
||||
return {
|
||||
content: this.data.content,
|
||||
buttons: buttons
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
activateListeners(html) {
|
||||
html.find(".dialog-button").click(this._onClickButton.bind(this));
|
||||
|
||||
// Prevent the default form submission action if any forms are present in this dialog.
|
||||
html.find("form").each((i, el) => el.onsubmit = evt => evt.preventDefault());
|
||||
if ( !this.#onKeyDown ) {
|
||||
this.#onKeyDown = this._onKeyDown.bind(this);
|
||||
document.addEventListener("keydown", this.#onKeyDown);
|
||||
}
|
||||
if ( this.data.render instanceof Function ) this.data.render(this.options.jQuery ? html : html[0]);
|
||||
|
||||
if ( this.options.focus ) {
|
||||
// Focus the default option
|
||||
html.find(".default").focus();
|
||||
}
|
||||
|
||||
html.find("[autofocus]")[0]?.focus();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a left-mouse click on one of the dialog choice buttons
|
||||
* @param {MouseEvent} event The left-mouse click event
|
||||
* @private
|
||||
*/
|
||||
_onClickButton(event) {
|
||||
const id = event.currentTarget.dataset.button;
|
||||
const button = this.data.buttons[id];
|
||||
this.submit(button, event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a keydown event while the dialog is active
|
||||
* @param {KeyboardEvent} event The keydown event
|
||||
* @private
|
||||
*/
|
||||
_onKeyDown(event) {
|
||||
|
||||
// Cycle Options
|
||||
if ( event.key === "Tab" ) {
|
||||
const dialog = this.element[0];
|
||||
|
||||
// If we are already focused on the Dialog, let the default browser behavior take over
|
||||
if ( dialog.contains(document.activeElement) ) return;
|
||||
|
||||
// If we aren't focused on the dialog, bring focus to one of its buttons
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const dialogButtons = Array.from(document.querySelectorAll(".dialog-button"));
|
||||
const targetButton = event.shiftKey ? dialogButtons.pop() : dialogButtons.shift();
|
||||
targetButton.focus();
|
||||
}
|
||||
|
||||
// Close dialog
|
||||
if ( event.key === "Escape" ) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return this.close();
|
||||
}
|
||||
|
||||
// Confirm choice
|
||||
if ( event.key === "Enter" ) {
|
||||
|
||||
// Only handle Enter presses if an input element within the Dialog has focus
|
||||
const dialog = this.element[0];
|
||||
if ( !dialog.contains(document.activeElement) || (document.activeElement instanceof HTMLTextAreaElement) ) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Prefer a focused button, or enact the default option for the dialog
|
||||
const button = document.activeElement.dataset.button || this.data.default;
|
||||
const choice = this.data.buttons[button];
|
||||
return this.submit(choice);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
async _renderOuter() {
|
||||
let html = await super._renderOuter();
|
||||
const app = html[0];
|
||||
app.setAttribute("role", "dialog");
|
||||
app.setAttribute("aria-modal", "true");
|
||||
return html;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Submit the Dialog by selecting one of its buttons
|
||||
* @param {Object} button The configuration of the chosen button
|
||||
* @param {PointerEvent} event The originating click event
|
||||
* @private
|
||||
*/
|
||||
submit(button, event) {
|
||||
const target = this.options.jQuery ? this.element : this.element[0];
|
||||
try {
|
||||
if ( button.callback ) button.callback.call(this, target, event);
|
||||
this.close();
|
||||
} catch(err) {
|
||||
ui.notifications.error(err.message);
|
||||
throw new Error(err);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async close(options={}) {
|
||||
if ( this.data.close ) this.data.close(this.options.jQuery ? this.element : this.element[0]);
|
||||
if ( this.#onKeyDown ) {
|
||||
document.removeEventListener("keydown", this.#onKeyDown);
|
||||
this.#onKeyDown = undefined;
|
||||
}
|
||||
return super.close(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Factory Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A helper factory method to create simple confirmation dialog windows which consist of simple yes/no prompts.
|
||||
* If you require more flexibility, a custom Dialog instance is preferred.
|
||||
*
|
||||
* @param {DialogData} config Confirmation dialog configuration
|
||||
* @param {Function} [config.yes] Callback function upon yes
|
||||
* @param {Function} [config.no] Callback function upon no
|
||||
* @param {boolean} [config.defaultYes=true] Make "yes" the default choice?
|
||||
* @param {boolean} [config.rejectClose=false] Reject the Promise if the Dialog is closed without making a choice.
|
||||
* @param {DialogOptions} [config.options={}] Additional rendering options passed to the Dialog
|
||||
*
|
||||
* @returns {Promise<any>} A promise which resolves once the user makes a choice or closes the
|
||||
* window.
|
||||
*
|
||||
* @example Prompt the user with a yes or no question
|
||||
* ```js
|
||||
* let d = Dialog.confirm({
|
||||
* title: "A Yes or No Question",
|
||||
* content: "<p>Choose wisely.</p>",
|
||||
* yes: () => console.log("You chose ... wisely"),
|
||||
* no: () => console.log("You chose ... poorly"),
|
||||
* defaultYes: false
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
static async confirm({title, content, yes, no, render, defaultYes=true, rejectClose=false, options={}}={}) {
|
||||
return this.wait({
|
||||
title, content, render,
|
||||
focus: true,
|
||||
default: defaultYes ? "yes" : "no",
|
||||
close: () => {
|
||||
if ( rejectClose ) return;
|
||||
return null;
|
||||
},
|
||||
buttons: {
|
||||
yes: {
|
||||
icon: '<i class="fas fa-check"></i>',
|
||||
label: game.i18n.localize("Yes"),
|
||||
callback: html => yes ? yes(html) : true
|
||||
},
|
||||
no: {
|
||||
icon: '<i class="fas fa-times"></i>',
|
||||
label: game.i18n.localize("No"),
|
||||
callback: html => no ? no(html) : false
|
||||
}
|
||||
}
|
||||
}, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A helper factory method to display a basic "prompt" style Dialog with a single button
|
||||
* @param {DialogData} config Dialog configuration options
|
||||
* @param {Function} [config.callback] A callback function to fire when the button is clicked
|
||||
* @param {boolean} [config.rejectClose=true] Reject the promise if the dialog is closed without confirming the
|
||||
* choice, otherwise resolve as null
|
||||
* @param {DialogOptions} [config.options] Additional dialog options
|
||||
* @returns {Promise<any>} The returned value from the provided callback function, if any
|
||||
*/
|
||||
static async prompt({title, content, label, callback, render, rejectClose=true, options={}}={}) {
|
||||
return this.wait({
|
||||
title, content, render,
|
||||
default: "ok",
|
||||
close: () => {
|
||||
if ( rejectClose ) return;
|
||||
return null;
|
||||
},
|
||||
buttons: {
|
||||
ok: { icon: '<i class="fas fa-check"></i>', label, callback }
|
||||
}
|
||||
}, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Wrap the Dialog with an enclosing Promise which resolves or rejects when the client makes a choice.
|
||||
* @param {DialogData} [data] Data passed to the Dialog constructor.
|
||||
* @param {DialogOptions} [options] Options passed to the Dialog constructor.
|
||||
* @param {object} [renderOptions] Options passed to the Dialog render call.
|
||||
* @returns {Promise<any>} A Promise that resolves to the chosen result.
|
||||
*/
|
||||
static async wait(data={}, options={}, renderOptions={}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
// Wrap buttons with Promise resolution.
|
||||
const buttons = foundry.utils.deepClone(data.buttons);
|
||||
for ( const [id, button] of Object.entries(buttons) ) {
|
||||
const cb = button.callback;
|
||||
function callback(html, event) {
|
||||
const result = cb instanceof Function ? cb.call(this, html, event) : undefined;
|
||||
resolve(result === undefined ? id : result);
|
||||
}
|
||||
button.callback = callback;
|
||||
}
|
||||
|
||||
// Wrap close with Promise resolution or rejection.
|
||||
const originalClose = data.close;
|
||||
const close = () => {
|
||||
const result = originalClose instanceof Function ? originalClose() : undefined;
|
||||
if ( result !== undefined ) resolve(result);
|
||||
else reject(new Error("The Dialog was closed without a choice being made."));
|
||||
};
|
||||
|
||||
// Construct the dialog.
|
||||
const dialog = new this({ ...data, buttons, close }, options);
|
||||
dialog.render(true, renderOptions);
|
||||
});
|
||||
}
|
||||
}
|
||||
209
resources/app/client/ui/drag.js
Normal file
209
resources/app/client/ui/drag.js
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* A UI utility to make an element draggable.
|
||||
* @param {Application} app The Application that is being made draggable.
|
||||
* @param {jQuery} element A JQuery reference to the Application's outer-most element.
|
||||
* @param {HTMLElement|boolean} handle The element that acts as a drag handle. Supply false to disable dragging.
|
||||
* @param {boolean|object} resizable Is the application resizable? Supply an object to configure resizing behaviour
|
||||
* or true to have it automatically configured.
|
||||
* @param {string} [resizable.selector] A selector for the resize handle.
|
||||
* @param {boolean} [resizable.resizeX=true] Enable resizing in the X direction.
|
||||
* @param {boolean} [resizable.resizeY=true] Enable resizing in the Y direction.
|
||||
* @param {boolean} [resizable.rtl] Modify the resizing direction to be right-to-left.
|
||||
*/
|
||||
class Draggable {
|
||||
constructor(app, element, handle, resizable) {
|
||||
|
||||
// Setup element data
|
||||
this.app = app;
|
||||
this.element = element[0];
|
||||
this.handle = handle ?? this.element;
|
||||
this.resizable = resizable || false;
|
||||
|
||||
/**
|
||||
* Duplicate the application's starting position to track differences
|
||||
* @type {Object}
|
||||
*/
|
||||
this.position = null;
|
||||
|
||||
/**
|
||||
* Remember event handlers associated with this Draggable class so they may be later unregistered
|
||||
* @type {Object}
|
||||
*/
|
||||
this.handlers = {};
|
||||
|
||||
/**
|
||||
* Throttle mousemove event handling to 60fps
|
||||
* @type {number}
|
||||
*/
|
||||
this._moveTime = 0;
|
||||
|
||||
// Activate interactivity
|
||||
this.activateListeners();
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate event handling for a Draggable application
|
||||
* Attach handlers for floating, dragging, and resizing
|
||||
*/
|
||||
activateListeners() {
|
||||
this._activateDragListeners();
|
||||
this._activateResizeListeners();
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Attach handlers for dragging and floating.
|
||||
* @protected
|
||||
*/
|
||||
_activateDragListeners() {
|
||||
if ( !this.handle ) return;
|
||||
|
||||
// Float to top
|
||||
this.handlers["click"] = ["pointerdown", ev => this.app.bringToTop(), {capture: true, passive: true}];
|
||||
this.element.addEventListener(...this.handlers.click);
|
||||
|
||||
// Drag handlers
|
||||
this.handlers["dragDown"] = ["pointerdown", e => this._onDragMouseDown(e), false];
|
||||
this.handlers["dragMove"] = ["pointermove", e => this._onDragMouseMove(e), false];
|
||||
this.handlers["dragUp"] = ["pointerup", e => this._onDragMouseUp(e), false];
|
||||
this.handle.addEventListener(...this.handlers.dragDown);
|
||||
this.handle.classList.add("draggable");
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Attach handlers for resizing.
|
||||
* @protected
|
||||
*/
|
||||
_activateResizeListeners() {
|
||||
if ( !this.resizable ) return;
|
||||
let handle = this.element.querySelector(this.resizable.selector);
|
||||
if ( !handle ) {
|
||||
handle = $('<div class="window-resizable-handle"><i class="fas fa-arrows-alt-h"></i></div>')[0];
|
||||
this.element.appendChild(handle);
|
||||
}
|
||||
|
||||
// Register handlers
|
||||
this.handlers["resizeDown"] = ["pointerdown", e => this._onResizeMouseDown(e), false];
|
||||
this.handlers["resizeMove"] = ["pointermove", e => this._onResizeMouseMove(e), false];
|
||||
this.handlers["resizeUp"] = ["pointerup", e => this._onResizeMouseUp(e), false];
|
||||
|
||||
// Attach the click handler and CSS class
|
||||
handle.addEventListener(...this.handlers.resizeDown);
|
||||
if ( this.handle ) this.handle.classList.add("resizable");
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the initial mouse click which activates dragging behavior for the application
|
||||
* @private
|
||||
*/
|
||||
_onDragMouseDown(event) {
|
||||
event.preventDefault();
|
||||
|
||||
// Record initial position
|
||||
this.position = foundry.utils.deepClone(this.app.position);
|
||||
this._initial = {x: event.clientX, y: event.clientY};
|
||||
|
||||
// Add temporary handlers
|
||||
window.addEventListener(...this.handlers.dragMove);
|
||||
window.addEventListener(...this.handlers.dragUp);
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Move the window with the mouse, bounding the movement to ensure the window stays within bounds of the viewport
|
||||
* @private
|
||||
*/
|
||||
_onDragMouseMove(event) {
|
||||
event.preventDefault();
|
||||
|
||||
// Limit dragging to 60 updates per second
|
||||
const now = Date.now();
|
||||
if ( (now - this._moveTime) < (1000/60) ) return;
|
||||
this._moveTime = now;
|
||||
|
||||
// Update application position
|
||||
this.app.setPosition({
|
||||
left: this.position.left + (event.clientX - this._initial.x),
|
||||
top: this.position.top + (event.clientY - this._initial.y)
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Conclude the dragging behavior when the mouse is release, setting the final position and removing listeners
|
||||
* @private
|
||||
*/
|
||||
_onDragMouseUp(event) {
|
||||
event.preventDefault();
|
||||
window.removeEventListener(...this.handlers.dragMove);
|
||||
window.removeEventListener(...this.handlers.dragUp);
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the initial mouse click which activates dragging behavior for the application
|
||||
* @private
|
||||
*/
|
||||
_onResizeMouseDown(event) {
|
||||
event.preventDefault();
|
||||
|
||||
// Limit dragging to 60 updates per second
|
||||
const now = Date.now();
|
||||
if ( (now - this._moveTime) < (1000/60) ) return;
|
||||
this._moveTime = now;
|
||||
|
||||
// Record initial position
|
||||
this.position = foundry.utils.deepClone(this.app.position);
|
||||
if ( this.position.height === "auto" ) this.position.height = this.element.clientHeight;
|
||||
if ( this.position.width === "auto" ) this.position.width = this.element.clientWidth;
|
||||
this._initial = {x: event.clientX, y: event.clientY};
|
||||
|
||||
// Add temporary handlers
|
||||
window.addEventListener(...this.handlers.resizeMove);
|
||||
window.addEventListener(...this.handlers.resizeUp);
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Move the window with the mouse, bounding the movement to ensure the window stays within bounds of the viewport
|
||||
* @private
|
||||
*/
|
||||
_onResizeMouseMove(event) {
|
||||
event.preventDefault();
|
||||
const scale = this.app.position.scale ?? 1;
|
||||
let deltaX = (event.clientX - this._initial.x) / scale;
|
||||
const deltaY = (event.clientY - this._initial.y) / scale;
|
||||
if ( this.resizable.rtl === true ) deltaX *= -1;
|
||||
const newPosition = {
|
||||
width: this.position.width + deltaX,
|
||||
height: this.position.height + deltaY
|
||||
};
|
||||
if ( this.resizable.resizeX === false ) delete newPosition.width;
|
||||
if ( this.resizable.resizeY === false ) delete newPosition.height;
|
||||
this.app.setPosition(newPosition);
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Conclude the dragging behavior when the mouse is release, setting the final position and removing listeners
|
||||
* @private
|
||||
*/
|
||||
_onResizeMouseUp(event) {
|
||||
event.preventDefault();
|
||||
window.removeEventListener(...this.handlers.resizeMove);
|
||||
window.removeEventListener(...this.handlers.resizeUp);
|
||||
this.app._onResize(event);
|
||||
}
|
||||
}
|
||||
168
resources/app/client/ui/dragdrop.js
vendored
Normal file
168
resources/app/client/ui/dragdrop.js
vendored
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* @typedef {object} DragDropConfiguration
|
||||
* @property {string} dragSelector The CSS selector used to target draggable elements.
|
||||
* @property {string} dropSelector The CSS selector used to target viable drop targets.
|
||||
* @property {Record<string,Function>} permissions An object of permission test functions for each action
|
||||
* @property {Record<string,Function>} callbacks An object of callback functions for each action
|
||||
*/
|
||||
|
||||
/**
|
||||
* A controller class for managing drag and drop workflows within an Application instance.
|
||||
* The controller manages the following actions: dragstart, dragover, drop
|
||||
* @see {@link Application}
|
||||
*
|
||||
* @param {DragDropConfiguration}
|
||||
* @example Activate drag-and-drop handling for a certain set of elements
|
||||
* ```js
|
||||
* const dragDrop = new DragDrop({
|
||||
* dragSelector: ".item",
|
||||
* dropSelector: ".items",
|
||||
* permissions: { dragstart: this._canDragStart.bind(this), drop: this._canDragDrop.bind(this) },
|
||||
* callbacks: { dragstart: this._onDragStart.bind(this), drop: this._onDragDrop.bind(this) }
|
||||
* });
|
||||
* dragDrop.bind(html);
|
||||
* ```
|
||||
*/
|
||||
class DragDrop {
|
||||
constructor({dragSelector, dropSelector, permissions={}, callbacks={}} = {}) {
|
||||
|
||||
/**
|
||||
* The HTML selector which identifies draggable elements
|
||||
* @type {string}
|
||||
*/
|
||||
this.dragSelector = dragSelector;
|
||||
|
||||
/**
|
||||
* The HTML selector which identifies drop targets
|
||||
* @type {string}
|
||||
*/
|
||||
this.dropSelector = dropSelector;
|
||||
|
||||
/**
|
||||
* A set of permission checking functions for each action of the Drag and Drop workflow
|
||||
* @type {Object}
|
||||
*/
|
||||
this.permissions = permissions;
|
||||
|
||||
/**
|
||||
* A set of callback functions for each action of the Drag and Drop workflow
|
||||
* @type {Object}
|
||||
*/
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Bind the DragDrop controller to an HTML application
|
||||
* @param {HTMLElement} html The HTML element to which the handler is bound
|
||||
*/
|
||||
bind(html) {
|
||||
|
||||
// Identify and activate draggable targets
|
||||
if ( this.can("dragstart", this.dragSelector) ) {
|
||||
const draggables = html.querySelectorAll(this.dragSelector);
|
||||
for (let el of draggables) {
|
||||
el.setAttribute("draggable", true);
|
||||
el.ondragstart = this._handleDragStart.bind(this);
|
||||
}
|
||||
}
|
||||
|
||||
// Identify and activate drop targets
|
||||
if ( this.can("drop", this.dropSelector) ) {
|
||||
const droppables = !this.dropSelector || html.matches(this.dropSelector) ? [html] :
|
||||
html.querySelectorAll(this.dropSelector);
|
||||
for ( let el of droppables ) {
|
||||
el.ondragover = this._handleDragOver.bind(this);
|
||||
el.ondrop = this._handleDrop.bind(this);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Execute a callback function associated with a certain action in the workflow
|
||||
* @param {DragEvent} event The drag event being handled
|
||||
* @param {string} action The action being attempted
|
||||
*/
|
||||
callback(event, action) {
|
||||
const fn = this.callbacks[action];
|
||||
if ( fn instanceof Function ) return fn(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether the current user has permission to perform a step of the workflow
|
||||
* @param {string} action The action being attempted
|
||||
* @param {string} selector The selector being targeted
|
||||
* @return {boolean} Can the action be performed?
|
||||
*/
|
||||
can(action, selector) {
|
||||
const fn = this.permissions[action];
|
||||
if ( fn instanceof Function ) return fn(selector);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the start of a drag workflow
|
||||
* @param {DragEvent} event The drag event being handled
|
||||
* @private
|
||||
*/
|
||||
_handleDragStart(event) {
|
||||
this.callback(event, "dragstart");
|
||||
if ( event.dataTransfer.items.length ) event.stopPropagation();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a dragged element over a droppable target
|
||||
* @param {DragEvent} event The drag event being handled
|
||||
* @private
|
||||
*/
|
||||
_handleDragOver(event) {
|
||||
event.preventDefault();
|
||||
this.callback(event, "dragover");
|
||||
return false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a dragged element dropped on a droppable target
|
||||
* @param {DragEvent} event The drag event being handled
|
||||
* @private
|
||||
*/
|
||||
_handleDrop(event) {
|
||||
event.preventDefault();
|
||||
return this.callback(event, "drop");
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
static createDragImage(img, width, height) {
|
||||
let div = document.getElementById("drag-preview");
|
||||
|
||||
// Create the drag preview div
|
||||
if ( !div ) {
|
||||
div = document.createElement("div");
|
||||
div.setAttribute("id", "drag-preview");
|
||||
const img = document.createElement("img");
|
||||
img.classList.add("noborder");
|
||||
div.appendChild(img);
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
|
||||
// Add the preview image
|
||||
const i = div.children[0];
|
||||
i.src = img.src;
|
||||
i.width = width;
|
||||
i.height = height;
|
||||
return div;
|
||||
}
|
||||
}
|
||||
933
resources/app/client/ui/editor.js
Normal file
933
resources/app/client/ui/editor.js
Normal file
@@ -0,0 +1,933 @@
|
||||
/**
|
||||
* A collection of helper functions and utility methods related to the rich text editor
|
||||
*/
|
||||
class TextEditor {
|
||||
|
||||
/**
|
||||
* A singleton text area used for HTML decoding.
|
||||
* @type {HTMLTextAreaElement}
|
||||
*/
|
||||
static #decoder = document.createElement("textarea");
|
||||
|
||||
/**
|
||||
* Create a Rich Text Editor. The current implementation uses TinyMCE
|
||||
* @param {object} options Configuration options provided to the Editor init
|
||||
* @param {string} [options.engine=tinymce] Which rich text editor engine to use, "tinymce" or "prosemirror". TinyMCE
|
||||
* is deprecated and will be removed in a later version.
|
||||
* @param {string} content Initial HTML or text content to populate the editor with
|
||||
* @returns {Promise<TinyMCE.Editor|ProseMirrorEditor>} The editor instance.
|
||||
*/
|
||||
static async create({engine="tinymce", ...options}={}, content="") {
|
||||
if ( engine === "prosemirror" ) {
|
||||
const {target, ...rest} = options;
|
||||
return ProseMirrorEditor.create(target, content, rest);
|
||||
}
|
||||
if ( engine === "tinymce" ) return this._createTinyMCE(options, content);
|
||||
throw new Error(`Provided engine '${engine}' is not a valid TextEditor engine.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of elements that are retained when truncating HTML.
|
||||
* @type {Set<string>}
|
||||
* @private
|
||||
*/
|
||||
static _PARAGRAPH_ELEMENTS = new Set([
|
||||
"header", "main", "section", "article", "div", "footer", // Structural Elements
|
||||
"h1", "h2", "h3", "h4", "h5", "h6", // Headers
|
||||
"p", "blockquote", "summary", "span", "a", "mark", // Text Types
|
||||
"strong", "em", "b", "i", "u" // Text Styles
|
||||
]);
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a TinyMCE editor instance.
|
||||
* @param {object} [options] Configuration options passed to the editor.
|
||||
* @param {string} [content=""] Initial HTML or text content to populate the editor with.
|
||||
* @returns {Promise<TinyMCE.Editor>} The TinyMCE editor instance.
|
||||
* @protected
|
||||
*/
|
||||
static async _createTinyMCE(options={}, content="") {
|
||||
const mceConfig = foundry.utils.mergeObject(CONFIG.TinyMCE, options, {inplace: false});
|
||||
mceConfig.target = options.target;
|
||||
|
||||
mceConfig.file_picker_callback = function (pickerCallback, value, meta) {
|
||||
let filePicker = new FilePicker({
|
||||
type: "image",
|
||||
callback: path => {
|
||||
pickerCallback(path);
|
||||
// Reset our z-index for next open
|
||||
$(".tox-tinymce-aux").css({zIndex: ''});
|
||||
},
|
||||
});
|
||||
filePicker.render();
|
||||
// Set the TinyMCE dialog to be below the FilePicker
|
||||
$(".tox-tinymce-aux").css({zIndex: Math.min(++_maxZ, 9999)});
|
||||
};
|
||||
if ( mceConfig.content_css instanceof Array ) {
|
||||
mceConfig.content_css = mceConfig.content_css.map(c => foundry.utils.getRoute(c)).join(",");
|
||||
}
|
||||
mceConfig.init_instance_callback = editor => {
|
||||
const window = editor.getWin();
|
||||
editor.focus();
|
||||
if ( content ) editor.resetContent(content);
|
||||
editor.selection.setCursorLocation(editor.getBody(), editor.getBody().childElementCount);
|
||||
window.addEventListener("wheel", event => {
|
||||
if ( event.ctrlKey ) event.preventDefault();
|
||||
}, {passive: false});
|
||||
editor.off("drop dragover"); // Remove the default TinyMCE dragdrop handlers.
|
||||
editor.on("drop", event => this._onDropEditorData(event, editor));
|
||||
};
|
||||
const [editor] = await tinyMCE.init(mceConfig);
|
||||
editor.document = options.document;
|
||||
return editor;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* HTML Manipulation Helpers
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Safely decode an HTML string, removing invalid tags and converting entities back to unicode characters.
|
||||
* @param {string} html The original encoded HTML string
|
||||
* @returns {string} The decoded unicode string
|
||||
*/
|
||||
static decodeHTML(html) {
|
||||
const d = TextEditor.#decoder;
|
||||
d.innerHTML = html;
|
||||
const decoded = d.value;
|
||||
d.innerHTML = "";
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {object} EnrichmentOptions
|
||||
* @property {boolean} [secrets=false] Include unrevealed secret tags in the final HTML? If false, unrevealed
|
||||
* secret blocks will be removed.
|
||||
* @property {boolean} [documents=true] Replace dynamic document links?
|
||||
* @property {boolean} [links=true] Replace hyperlink content?
|
||||
* @property {boolean} [rolls=true] Replace inline dice rolls?
|
||||
* @property {boolean} [embeds=true] Replace embedded content?
|
||||
* @property {object|Function} [rollData] The data object providing context for inline rolls, or a function that
|
||||
* produces it.
|
||||
* @property {ClientDocument} [relativeTo] A document to resolve relative UUIDs against.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Enrich HTML content by replacing or augmenting components of it
|
||||
* @param {string} content The original HTML content (as a string)
|
||||
* @param {EnrichmentOptions} [options={}] Additional options which configure how HTML is enriched
|
||||
* @returns {Promise<string>} The enriched HTML content
|
||||
*/
|
||||
static async enrichHTML(content, options={}) {
|
||||
let {secrets=false, documents=true, links=true, embeds=true, rolls=true, rollData} = options;
|
||||
if ( !content?.length ) return "";
|
||||
|
||||
// Create the HTML element
|
||||
const html = document.createElement("div");
|
||||
html.innerHTML = String(content || "");
|
||||
|
||||
// Remove unrevealed secret blocks
|
||||
if ( !secrets ) html.querySelectorAll("section.secret:not(.revealed)").forEach(secret => secret.remove());
|
||||
|
||||
// Increment embedded content depth recursion counter.
|
||||
options._embedDepth = (options._embedDepth ?? -1) + 1;
|
||||
|
||||
// Plan text content replacements
|
||||
const fns = [];
|
||||
if ( documents ) fns.push(this._enrichContentLinks.bind(this));
|
||||
if ( links ) fns.push(this._enrichHyperlinks.bind(this));
|
||||
if ( rolls ) fns.push(this._enrichInlineRolls.bind(this, rollData));
|
||||
if ( embeds ) fns.push(this._enrichEmbeds.bind(this));
|
||||
for ( const config of CONFIG.TextEditor.enrichers ) {
|
||||
fns.push(this._applyCustomEnrichers.bind(this, config));
|
||||
}
|
||||
|
||||
// Perform enrichment
|
||||
let text = this._getTextNodes(html);
|
||||
await this._primeCompendiums(text);
|
||||
|
||||
let updateTextArray = false;
|
||||
for ( const fn of fns ) {
|
||||
if ( updateTextArray ) text = this._getTextNodes(html);
|
||||
updateTextArray = await fn(text, options);
|
||||
}
|
||||
return html.innerHTML;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Scan for compendium UUIDs and retrieve Documents in batches so that they are in cache when enrichment proceeds.
|
||||
* @param {Text[]} text The text nodes to scan.
|
||||
* @protected
|
||||
*/
|
||||
static async _primeCompendiums(text) {
|
||||
// Scan for any UUID that looks like a compendium UUID. This should catch content links as well as UUIDs appearing
|
||||
// in embeds.
|
||||
const rgx = /Compendium\.[\w-]+\.[^.]+\.[a-zA-Z\d.]+/g;
|
||||
const packs = new Map();
|
||||
for ( const t of text ) {
|
||||
for ( const [uuid] of t.textContent.matchAll(rgx) ) {
|
||||
const { collection, documentId } = foundry.utils.parseUuid(uuid);
|
||||
if ( !collection || collection.has(documentId) ) continue;
|
||||
if ( !packs.has(collection) ) packs.set(collection, []);
|
||||
packs.get(collection).push(documentId);
|
||||
}
|
||||
}
|
||||
for ( const [pack, ids] of packs.entries() ) {
|
||||
await pack.getDocuments({ _id__in: ids });
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert text of the form @UUID[uuid]{name} to anchor elements.
|
||||
* @param {Text[]} text The existing text content
|
||||
* @param {EnrichmentOptions} [options] Options provided to customize text enrichment
|
||||
* @param {Document} [options.relativeTo] A document to resolve relative UUIDs against.
|
||||
* @returns {Promise<boolean>} Whether any content links were replaced and the text nodes need to be
|
||||
* updated.
|
||||
* @protected
|
||||
*/
|
||||
static async _enrichContentLinks(text, {relativeTo}={}) {
|
||||
const documentTypes = CONST.DOCUMENT_LINK_TYPES.concat(["Compendium", "UUID"]);
|
||||
const rgx = new RegExp(`@(${documentTypes.join("|")})\\[([^#\\]]+)(?:#([^\\]]+))?](?:{([^}]+)})?`, "g");
|
||||
return this._replaceTextContent(text, rgx, match => this._createContentLink(match, {relativeTo}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle embedding Document content with @Embed[uuid]{label} text.
|
||||
* @param {Text[]} text The existing text content.
|
||||
* @param {EnrichmentOptions} [options] Options provided to customize text enrichment.
|
||||
* @returns {Promise<boolean>} Whether any embeds were replaced and the text nodes need to be updated.
|
||||
* @protected
|
||||
*/
|
||||
static async _enrichEmbeds(text, options={}) {
|
||||
const rgx = /@Embed\[(?<config>[^\]]+)](?:{(?<label>[^}]+)})?/gi;
|
||||
return this._replaceTextContent(text, rgx, match => this._embedContent(match, options), { replaceParent: true });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert URLs into anchor elements.
|
||||
* @param {Text[]} text The existing text content
|
||||
* @param {EnrichmentOptions} [options] Options provided to customize text enrichment
|
||||
* @returns {Promise<boolean>} Whether any hyperlinks were replaced and the text nodes need to be updated
|
||||
* @protected
|
||||
*/
|
||||
static async _enrichHyperlinks(text, options={}) {
|
||||
const rgx = /(https?:\/\/)(www\.)?([^\s<]+)/gi;
|
||||
return this._replaceTextContent(text, rgx, this._createHyperlink);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert text of the form [[roll]] to anchor elements.
|
||||
* @param {object|Function} rollData The data object providing context for inline rolls.
|
||||
* @param {Text[]} text The existing text content.
|
||||
* @returns {Promise<boolean>} Whether any inline rolls were replaced and the text nodes need to be updated.
|
||||
* @protected
|
||||
*/
|
||||
static async _enrichInlineRolls(rollData, text) {
|
||||
rollData = rollData instanceof Function ? rollData() : (rollData || {});
|
||||
const rgx = /\[\[(\/[a-zA-Z]+\s)?(.*?)(]{2,3})(?:{([^}]+)})?/gi;
|
||||
return this._replaceTextContent(text, rgx, match => this._createInlineRoll(match, rollData));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Match any custom registered regex patterns and apply their replacements.
|
||||
* @param {TextEditorEnricherConfig} config The custom enricher configuration.
|
||||
* @param {Text[]} text The existing text content.
|
||||
* @param {EnrichmentOptions} [options] Options provided to customize text enrichment
|
||||
* @returns {Promise<boolean>} Whether any replacements were made, requiring the text nodes to be
|
||||
* updated.
|
||||
* @protected
|
||||
*/
|
||||
static async _applyCustomEnrichers({ pattern, enricher, replaceParent }, text, options) {
|
||||
return this._replaceTextContent(text, pattern, match => enricher(match, options), { replaceParent });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Preview an HTML fragment by constructing a substring of a given length from its inner text.
|
||||
* @param {string} content The raw HTML to preview
|
||||
* @param {number} length The desired length
|
||||
* @returns {string} The previewed HTML
|
||||
*/
|
||||
static previewHTML(content, length=250) {
|
||||
let div = document.createElement("div");
|
||||
div.innerHTML = content;
|
||||
div = this.truncateHTML(div);
|
||||
div.innerText = this.truncateText(div.innerText, {maxLength: length});
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/* --------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Sanitises an HTML fragment and removes any non-paragraph-style text.
|
||||
* @param {HTMLElement} html The root HTML element.
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
static truncateHTML(html) {
|
||||
const truncate = root => {
|
||||
for ( const node of root.childNodes ) {
|
||||
if ( [Node.COMMENT_NODE, Node.TEXT_NODE].includes(node.nodeType) ) continue;
|
||||
if ( node.nodeType === Node.ELEMENT_NODE ) {
|
||||
if ( this._PARAGRAPH_ELEMENTS.has(node.tagName.toLowerCase()) ) truncate(node);
|
||||
else node.remove();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clone = html.cloneNode(true);
|
||||
truncate(clone);
|
||||
return clone;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Truncate a fragment of text to a maximum number of characters.
|
||||
* @param {string} text The original text fragment that should be truncated to a maximum length
|
||||
* @param {object} [options] Options which affect the behavior of text truncation
|
||||
* @param {number} [options.maxLength] The maximum allowed length of the truncated string.
|
||||
* @param {boolean} [options.splitWords] Whether to truncate by splitting on white space (if true) or breaking words.
|
||||
* @param {string|null} [options.suffix] A suffix string to append to denote that the text was truncated.
|
||||
* @returns {string} The truncated text string
|
||||
*/
|
||||
static truncateText(text, {maxLength=50, splitWords=true, suffix="…"}={}) {
|
||||
if ( text.length <= maxLength ) return text;
|
||||
|
||||
// Split the string (on words if desired)
|
||||
let short;
|
||||
if ( splitWords ) {
|
||||
short = text.slice(0, maxLength + 10);
|
||||
while ( short.length > maxLength ) {
|
||||
if ( /\s/.test(short) ) short = short.replace(/[\s]+([\S]+)?$/, "");
|
||||
else short = short.slice(0, maxLength);
|
||||
}
|
||||
} else {
|
||||
short = text.slice(0, maxLength);
|
||||
}
|
||||
|
||||
// Add a suffix and return
|
||||
suffix = suffix ?? "";
|
||||
return short + suffix;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Text Node Manipulation
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Recursively identify the text nodes within a parent HTML node for potential content replacement.
|
||||
* @param {HTMLElement} parent The parent HTML Element
|
||||
* @returns {Text[]} An array of contained Text nodes
|
||||
* @private
|
||||
*/
|
||||
static _getTextNodes(parent) {
|
||||
const text = [];
|
||||
const walk = document.createTreeWalker(parent, NodeFilter.SHOW_TEXT);
|
||||
while ( walk.nextNode() ) text.push(walk.currentNode);
|
||||
return text;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef TextReplacementOptions
|
||||
* @property {boolean} [replaceParent] Hoist the replacement element out of its containing element if it would be
|
||||
* the only child of that element.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback TextContentReplacer
|
||||
* @param {RegExpMatchArray} match The regular expression match.
|
||||
* @returns {Promise<HTMLElement>} The HTML to replace the matched content with.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Facilitate the replacement of text node content using a matching regex rule and a provided replacement function.
|
||||
* @param {Text[]} text The text nodes to match and replace.
|
||||
* @param {RegExp} rgx The provided regular expression for matching and replacement
|
||||
* @param {TextContentReplacer} func The replacement function
|
||||
* @param {TextReplacementOptions} [options] Options to configure text replacement behavior.
|
||||
* @returns {boolean} Whether a replacement was made.
|
||||
* @private
|
||||
*/
|
||||
static async _replaceTextContent(text, rgx, func, options={}) {
|
||||
let replaced = false;
|
||||
for ( const t of text ) {
|
||||
const matches = t.textContent.matchAll(rgx);
|
||||
for ( const match of Array.from(matches).reverse() ) {
|
||||
let result;
|
||||
try {
|
||||
result = await func(match);
|
||||
} catch(err) {
|
||||
Hooks.onError("TextEditor.enrichHTML", err, { log: "error" });
|
||||
}
|
||||
if ( result ) {
|
||||
this._replaceTextNode(t, match, result, options);
|
||||
replaced = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return replaced;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Replace a matched portion of a Text node with a replacement Node
|
||||
* @param {Text} text The Text node containing the match.
|
||||
* @param {RegExpMatchArray} match The regular expression match.
|
||||
* @param {Node} replacement The replacement Node.
|
||||
* @param {TextReplacementOptions} [options] Options to configure text replacement behavior.
|
||||
* @private
|
||||
*/
|
||||
static _replaceTextNode(text, match, replacement, { replaceParent }={}) {
|
||||
let target = text;
|
||||
if ( match.index > 0 ) target = text.splitText(match.index);
|
||||
if ( match[0].length < target.length ) target.splitText(match[0].length);
|
||||
const parent = target.parentElement;
|
||||
if ( parent.parentElement && (parent.childNodes.length < 2) && replaceParent ) parent.replaceWith(replacement);
|
||||
else target.replaceWith(replacement);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Text Replacement Functions
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a dynamic document link from a regular expression match
|
||||
* @param {RegExpMatchArray} match The regular expression match
|
||||
* @param {object} [options] Additional options to configure enrichment behaviour
|
||||
* @param {Document} [options.relativeTo] A document to resolve relative UUIDs against.
|
||||
* @returns {Promise<HTMLAnchorElement>} An HTML element for the document link.
|
||||
* @protected
|
||||
*/
|
||||
static async _createContentLink(match, {relativeTo}={}) {
|
||||
const [type, target, hash, name] = match.slice(1, 5);
|
||||
|
||||
// Prepare replacement data
|
||||
const data = {
|
||||
classes: ["content-link"],
|
||||
attrs: { draggable: "true" },
|
||||
dataset: { link: "" },
|
||||
name
|
||||
};
|
||||
|
||||
let doc;
|
||||
let broken = false;
|
||||
if ( type === "UUID" ) {
|
||||
Object.assign(data.dataset, {link: "", uuid: target});
|
||||
doc = await fromUuid(target, {relative: relativeTo});
|
||||
}
|
||||
else broken = TextEditor._createLegacyContentLink(type, target, name, data);
|
||||
|
||||
if ( doc ) {
|
||||
if ( doc.documentName ) return doc.toAnchor({ name: data.name, dataset: { hash } });
|
||||
data.name = data.name || doc.name || target;
|
||||
const type = game.packs.get(doc.pack)?.documentName;
|
||||
Object.assign(data.dataset, {type, id: doc._id, pack: doc.pack});
|
||||
if ( hash ) data.dataset.hash = hash;
|
||||
data.icon = CONFIG[type].sidebarIcon;
|
||||
}
|
||||
|
||||
// The UUID lookup failed so this is a broken link.
|
||||
else if ( type === "UUID" ) broken = true;
|
||||
|
||||
// Broken links
|
||||
if ( broken ) {
|
||||
delete data.dataset.link;
|
||||
delete data.attrs.draggable;
|
||||
data.icon = "fas fa-unlink";
|
||||
data.classes.push("broken");
|
||||
}
|
||||
return this.createAnchor(data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {object} EnrichmentAnchorOptions
|
||||
* @param {Record<string, string>} [attrs] Attributes to set on the anchor.
|
||||
* @param {Record<string, string>} [dataset] Data- attributes to set on the anchor.
|
||||
* @param {string[]} [classes] Classes to add to the anchor.
|
||||
* @param {string} [name] The anchor's content.
|
||||
* @param {string} [icon] A font-awesome icon class to use as the icon.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Helper method to create an anchor element.
|
||||
* @param {Partial<EnrichmentAnchorOptions>} [options] Options to configure the anchor's construction.
|
||||
* @returns {HTMLAnchorElement}
|
||||
*/
|
||||
static createAnchor({ attrs={}, dataset={}, classes=[], name, icon }={}) {
|
||||
name ??= game.i18n.localize("Unknown");
|
||||
const a = document.createElement("a");
|
||||
a.classList.add(...classes);
|
||||
for ( const [k, v] of Object.entries(attrs) ) {
|
||||
if ( (v !== null) && (v !== undefined) ) a.setAttribute(k, v);
|
||||
}
|
||||
for ( const [k, v] of Object.entries(dataset) ) {
|
||||
if ( (v !== null) && (v !== undefined) ) a.dataset[k] = v;
|
||||
}
|
||||
a.innerHTML = `${icon ? `<i class="${icon}"></i>` : ""}${name}`;
|
||||
return a;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Embed content from another Document.
|
||||
* @param {RegExpMatchArray} match The regular expression match.
|
||||
* @param {EnrichmentOptions} [options] Options provided to customize text enrichment.
|
||||
* @returns {Promise<HTMLElement|null>} A representation of the Document as HTML content, or null if the Document
|
||||
* could not be embedded.
|
||||
* @protected
|
||||
*/
|
||||
static async _embedContent(match, options={}) {
|
||||
if ( options._embedDepth > CONST.TEXT_ENRICH_EMBED_MAX_DEPTH ) {
|
||||
console.warn(`Nested Document embedding is restricted to a maximum depth of ${CONST.TEXT_ENRICH_EMBED_MAX_DEPTH}.`
|
||||
+ ` ${match.input} cannot be fully enriched.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { label } = match.groups;
|
||||
const config = this._parseEmbedConfig(match.groups.config, { relative: options.relativeTo });
|
||||
const doc = await fromUuid(config.uuid, { relative: options.relativeTo });
|
||||
if ( doc ) return doc.toEmbed({ label, ...config }, options);
|
||||
|
||||
const broken = document.createElement("p");
|
||||
broken.classList.add("broken", "content-embed");
|
||||
broken.innerHTML = `
|
||||
<i class="fas fa-circle-exclamation"></i>
|
||||
${game.i18n.format("EDITOR.EmbedFailed", { uuid: config.uuid })}
|
||||
`;
|
||||
return broken;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {Record<string, string|boolean|number>} DocumentHTMLEmbedConfig
|
||||
* @property {string[]} values Any strings that did not have a key name associated with them.
|
||||
* @property {string} [classes] Classes to attach to the outermost element.
|
||||
* @property {boolean} [inline=false] By default Documents are embedded inside a figure element. If this option is
|
||||
* passed, the embed content will instead be included as part of the rest of the
|
||||
* content flow, but still wrapped in a section tag for styling purposes.
|
||||
* @property {boolean} [cite=true] Whether to include a content link to the original Document as a citation. This
|
||||
* options is ignored if the Document is inlined.
|
||||
* @property {boolean} [caption=true] Whether to include a caption. The caption will depend on the Document being
|
||||
* embedded, but if an explicit label is provided, that will always be used as the
|
||||
* caption. This option is ignored if the Document is inlined.
|
||||
* @property {string} [captionPosition="bottom"] Controls whether the caption is rendered above or below the embedded
|
||||
* content.
|
||||
* @property {string} [label] The label.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse the embed configuration to be passed to ClientDocument#toEmbed.
|
||||
* The return value will be an object of any key=value pairs included with the configuration, as well as a separate
|
||||
* values property that contains all the options supplied that were not in key=value format.
|
||||
* If a uuid key is supplied it is used as the Document's UUID, otherwise the first supplied UUID is used.
|
||||
* @param {string} raw The raw matched config string.
|
||||
* @param {object} [options] Options forwarded to parseUuid.
|
||||
* @returns {DocumentHTMLEmbedConfig}
|
||||
* @protected
|
||||
*
|
||||
* @example Example configurations.
|
||||
* ```js
|
||||
* TextEditor._parseEmbedConfig('uuid=Actor.xyz caption="Example Caption" cite=false');
|
||||
* // Returns: { uuid: "Actor.xyz", caption: "Example Caption", cite: false, values: [] }
|
||||
*
|
||||
* TextEditor._parseEmbedConfig('Actor.xyz caption="Example Caption" inline');
|
||||
* // Returns: { uuid: "Actor.xyz", caption: "Example Caption", values: ["inline"] }
|
||||
* ```
|
||||
*/
|
||||
static _parseEmbedConfig(raw, options={}) {
|
||||
const config = { values: [] };
|
||||
for ( const part of raw.match(/(?:[^\s"]+|"[^"]*")+/g) ) {
|
||||
if ( !part ) continue;
|
||||
const [key, value] = part.split("=");
|
||||
const valueLower = value?.toLowerCase();
|
||||
if ( value === undefined ) config.values.push(key.replace(/(^"|"$)/g, ""));
|
||||
else if ( (valueLower === "true") || (valueLower === "false") ) config[key] = valueLower === "true";
|
||||
else if ( Number.isNumeric(value) ) config[key] = Number(value);
|
||||
else config[key] = value.replace(/(^"|"$)/g, "");
|
||||
}
|
||||
|
||||
// Handle default embed configuration options.
|
||||
if ( !("cite" in config) ) config.cite = true;
|
||||
if ( !("caption" in config) ) config.caption = true;
|
||||
if ( !("inline" in config) ) {
|
||||
const idx = config.values.indexOf("inline");
|
||||
if ( idx > -1 ) {
|
||||
config.inline = true;
|
||||
config.values.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
if ( !config.uuid ) {
|
||||
for ( const [i, value] of config.values.entries() ) {
|
||||
try {
|
||||
const parsed = foundry.utils.parseUuid(value, options);
|
||||
if ( parsed?.documentId ) {
|
||||
config.uuid = value;
|
||||
config.values.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a dynamic document link from an old-form document link expression.
|
||||
* @param {string} type The matched document type, or "Compendium".
|
||||
* @param {string} target The requested match target (_id or name).
|
||||
* @param {string} name A customized or overridden display name for the link.
|
||||
* @param {object} data Data containing the properties of the resulting link element.
|
||||
* @returns {boolean} Whether the resulting link is broken or not.
|
||||
* @private
|
||||
*/
|
||||
static _createLegacyContentLink(type, target, name, data) {
|
||||
let broken = false;
|
||||
|
||||
// Get a matched World document
|
||||
if ( CONST.WORLD_DOCUMENT_TYPES.includes(type) ) {
|
||||
|
||||
// Get the linked Document
|
||||
const config = CONFIG[type];
|
||||
const collection = game.collections.get(type);
|
||||
const document = foundry.data.validators.isValidId(target) ? collection.get(target) : collection.getName(target);
|
||||
if ( !document ) broken = true;
|
||||
|
||||
// Update link data
|
||||
data.name = data.name || (broken ? target : document.name);
|
||||
data.icon = config.sidebarIcon;
|
||||
Object.assign(data.dataset, {type, uuid: document?.uuid});
|
||||
}
|
||||
|
||||
// Get a matched PlaylistSound
|
||||
else if ( type === "PlaylistSound" ) {
|
||||
const [, playlistId, , soundId] = target.split(".");
|
||||
const playlist = game.playlists.get(playlistId);
|
||||
const sound = playlist?.sounds.get(soundId);
|
||||
if ( !playlist || !sound ) broken = true;
|
||||
|
||||
data.name = data.name || (broken ? target : sound.name);
|
||||
data.icon = CONFIG.Playlist.sidebarIcon;
|
||||
Object.assign(data.dataset, {type, uuid: sound?.uuid});
|
||||
if ( sound?.playing ) data.cls.push("playing");
|
||||
if ( !game.user.isGM ) data.cls.push("disabled");
|
||||
}
|
||||
|
||||
// Get a matched Compendium document
|
||||
else if ( type === "Compendium" ) {
|
||||
|
||||
// Get the linked Document
|
||||
const { collection: pack, id } = foundry.utils.parseUuid(`Compendium.${target}`);
|
||||
if ( pack ) {
|
||||
Object.assign(data.dataset, {pack: pack.collection, uuid: pack.getUuid(id)});
|
||||
data.icon = CONFIG[pack.documentName].sidebarIcon;
|
||||
|
||||
// If the pack is indexed, retrieve the data
|
||||
if ( pack.index.size ) {
|
||||
const index = pack.index.find(i => (i._id === id) || (i.name === id));
|
||||
if ( index ) {
|
||||
if ( !data.name ) data.name = index.name;
|
||||
data.dataset.id = index._id;
|
||||
data.dataset.uuid = index.uuid;
|
||||
}
|
||||
else broken = true;
|
||||
}
|
||||
|
||||
// Otherwise assume the link may be valid, since the pack has not been indexed yet
|
||||
if ( !data.name ) data.name = data.dataset.lookup = id;
|
||||
}
|
||||
else broken = true;
|
||||
}
|
||||
return broken;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Replace a hyperlink-like string with an actual HTML <a> tag
|
||||
* @param {RegExpMatchArray} match The regular expression match
|
||||
* @returns {Promise<HTMLAnchorElement>} An HTML element for the document link
|
||||
* @private
|
||||
*/
|
||||
static async _createHyperlink(match) {
|
||||
const href = match[0];
|
||||
const a = document.createElement("a");
|
||||
a.classList.add("hyperlink");
|
||||
a.href = a.textContent = href;
|
||||
a.target = "_blank";
|
||||
a.rel = "nofollow noopener";
|
||||
return a;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Replace an inline roll formula with a rollable <a> element or an eagerly evaluated roll result
|
||||
* @param {RegExpMatchArray} match The regular expression match array
|
||||
* @param {object} rollData Provided roll data for use in roll evaluation
|
||||
* @returns {Promise<HTMLAnchorElement|null>} The replaced match. Returns null if the contained command is not a
|
||||
* valid roll expression.
|
||||
* @protected
|
||||
*/
|
||||
static async _createInlineRoll(match, rollData) {
|
||||
let [command, formula, closing, label] = match.slice(1, 5);
|
||||
const rollCls = Roll.defaultImplementation;
|
||||
|
||||
// Handle the possibility of the roll formula ending with a closing bracket
|
||||
if ( closing.length === 3 ) formula += "]";
|
||||
|
||||
// If the tag does not contain a command, it may only be an eagerly-evaluated inline roll
|
||||
if ( !command ) {
|
||||
if ( !rollCls.validate(formula) ) return null;
|
||||
try {
|
||||
const anchorOptions = {classes: ["inline-roll", "inline-result"], dataset: {tooltip: formula}, label};
|
||||
const roll = await rollCls.create(formula, rollData).evaluate({ allowInteractive: false });
|
||||
return roll.toAnchor(anchorOptions);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Otherwise verify that the tag contains a valid roll command
|
||||
const chatCommand = `${command}${formula}`;
|
||||
let parsedCommand = null;
|
||||
try {
|
||||
parsedCommand = ChatLog.parse(chatCommand);
|
||||
}
|
||||
catch { return null; }
|
||||
const [cmd, matches] = parsedCommand;
|
||||
if ( !["roll", "gmroll", "blindroll", "selfroll", "publicroll"].includes(cmd) ) return null;
|
||||
|
||||
// Extract components of the matched command
|
||||
const matchedCommand = ChatLog.MULTILINE_COMMANDS.has(cmd) ? matches.pop() : matches;
|
||||
const matchedFormula = rollCls.replaceFormulaData(matchedCommand[2].trim(), rollData || {});
|
||||
const matchedFlavor = matchedCommand[3]?.trim();
|
||||
|
||||
// Construct the deferred roll element
|
||||
const a = document.createElement("a");
|
||||
a.classList.add("inline-roll", parsedCommand[0]);
|
||||
a.dataset.mode = parsedCommand[0];
|
||||
a.dataset.flavor = matchedFlavor ?? label ?? "";
|
||||
a.dataset.formula = matchedFormula;
|
||||
a.dataset.tooltip = formula;
|
||||
a.innerHTML = `<i class="fas fa-dice-d20"></i>${label || matchedFormula}`;
|
||||
return a;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate interaction listeners for the interior content of the editor frame.
|
||||
*/
|
||||
static activateListeners() {
|
||||
const body = $("body");
|
||||
body.on("click", "a[data-link]", this._onClickContentLink);
|
||||
body.on("dragstart", "a[data-link]", this._onDragContentLink);
|
||||
body.on("click", "a.inline-roll", this._onClickInlineRoll);
|
||||
body.on("click", "[data-uuid][data-content-embed] [data-action]", this._onClickEmbeddedAction);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle click events on Document Links
|
||||
* @param {Event} event
|
||||
* @private
|
||||
*/
|
||||
static async _onClickContentLink(event) {
|
||||
event.preventDefault();
|
||||
const doc = await fromUuid(event.currentTarget.dataset.uuid);
|
||||
return doc?._onClickDocumentLink(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle actions in embedded content.
|
||||
* @param {PointerEvent} event The originating event.
|
||||
* @protected
|
||||
*/
|
||||
static async _onClickEmbeddedAction(event) {
|
||||
const { action } = event.target.dataset;
|
||||
const { uuid } = event.target.closest("[data-uuid]").dataset;
|
||||
const doc = await fromUuid(uuid);
|
||||
if ( !doc ) return;
|
||||
switch ( action ) {
|
||||
case "rollTable": doc._rollFromEmbeddedHTML(event); break;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle left-mouse clicks on an inline roll, dispatching the formula or displaying the tooltip
|
||||
* @param {MouseEvent} event The initiating click event
|
||||
* @private
|
||||
*/
|
||||
static async _onClickInlineRoll(event) {
|
||||
event.preventDefault();
|
||||
const a = event.currentTarget;
|
||||
|
||||
// For inline results expand or collapse the roll details
|
||||
if ( a.classList.contains("inline-result") ) {
|
||||
if ( a.classList.contains("expanded") ) {
|
||||
return Roll.defaultImplementation.collapseInlineResult(a);
|
||||
} else {
|
||||
return Roll.defaultImplementation.expandInlineResult(a);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the current speaker
|
||||
const cls = ChatMessage.implementation;
|
||||
const speaker = cls.getSpeaker();
|
||||
let actor = cls.getSpeakerActor(speaker);
|
||||
let rollData = actor ? actor.getRollData() : {};
|
||||
|
||||
// Obtain roll data from the contained sheet, if the inline roll is within an Actor or Item sheet
|
||||
const sheet = a.closest(".sheet");
|
||||
if ( sheet ) {
|
||||
const app = ui.windows[sheet.dataset.appid];
|
||||
if ( ["Actor", "Item"].includes(app?.object?.documentName) ) rollData = app.object.getRollData();
|
||||
}
|
||||
|
||||
// Execute a deferred roll
|
||||
const roll = Roll.create(a.dataset.formula, rollData);
|
||||
return roll.toMessage({flavor: a.dataset.flavor, speaker}, {rollMode: a.dataset.mode});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Begin a Drag+Drop workflow for a dynamic content link
|
||||
* @param {Event} event The originating drag event
|
||||
* @private
|
||||
*/
|
||||
static _onDragContentLink(event) {
|
||||
event.stopPropagation();
|
||||
const a = event.currentTarget;
|
||||
let dragData = null;
|
||||
|
||||
// Case 1 - Compendium Link
|
||||
if ( a.dataset.pack ) {
|
||||
const pack = game.packs.get(a.dataset.pack);
|
||||
let id = a.dataset.id;
|
||||
if ( a.dataset.lookup && pack.index.size ) {
|
||||
const entry = pack.index.find(i => (i._id === a.dataset.lookup) || (i.name === a.dataset.lookup));
|
||||
if ( entry ) id = entry._id;
|
||||
}
|
||||
if ( !a.dataset.uuid && !id ) return false;
|
||||
const uuid = a.dataset.uuid || pack.getUuid(id);
|
||||
dragData = { type: a.dataset.type || pack.documentName, uuid };
|
||||
}
|
||||
|
||||
// Case 2 - World Document Link
|
||||
else {
|
||||
const doc = fromUuidSync(a.dataset.uuid);
|
||||
dragData = doc.toDragData();
|
||||
}
|
||||
|
||||
event.originalEvent.dataTransfer.setData("text/plain", JSON.stringify(dragData));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle dropping of transferred data onto the active rich text editor
|
||||
* @param {DragEvent} event The originating drop event which triggered the data transfer
|
||||
* @param {TinyMCE} editor The TinyMCE editor instance being dropped on
|
||||
* @private
|
||||
*/
|
||||
static async _onDropEditorData(event, editor) {
|
||||
event.preventDefault();
|
||||
const eventData = this.getDragEventData(event);
|
||||
const link = await TextEditor.getContentLink(eventData, {relativeTo: editor.document});
|
||||
if ( link ) editor.insertContent(link);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Extract JSON data from a drag/drop event.
|
||||
* @param {DragEvent} event The drag event which contains JSON data.
|
||||
* @returns {object} The extracted JSON data. The object will be empty if the DragEvent did not contain
|
||||
* JSON-parseable data.
|
||||
*/
|
||||
static getDragEventData(event) {
|
||||
if ( !("dataTransfer" in event) ) { // Clumsy because (event instanceof DragEvent) doesn't work
|
||||
console.warn("Incorrectly attempted to process drag event data for an event which was not a DragEvent.");
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(event.dataTransfer.getData("text/plain"));
|
||||
} catch(err) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Given a Drop event, returns a Content link if possible such as @Actor[ABC123], else null
|
||||
* @param {object} eventData The parsed object of data provided by the transfer event
|
||||
* @param {object} [options] Additional options to configure link creation.
|
||||
* @param {ClientDocument} [options.relativeTo] A document to generate the link relative to.
|
||||
* @param {string} [options.label] A custom label to use instead of the document's name.
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
static async getContentLink(eventData, options={}) {
|
||||
const cls = getDocumentClass(eventData.type);
|
||||
if ( !cls ) return null;
|
||||
const document = await cls.fromDropData(eventData);
|
||||
if ( !document ) return null;
|
||||
return document._createDocumentLink(eventData, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Upload an image to a document's asset path.
|
||||
* @param {string} uuid The document's UUID.
|
||||
* @param {File} file The image file to upload.
|
||||
* @returns {Promise<string>} The path to the uploaded image.
|
||||
* @internal
|
||||
*/
|
||||
static async _uploadImage(uuid, file) {
|
||||
if ( !game.user.hasPermission("FILES_UPLOAD") ) {
|
||||
ui.notifications.error("EDITOR.NoUploadPermission", {localize: true});
|
||||
return;
|
||||
}
|
||||
|
||||
ui.notifications.info("EDITOR.UploadingFile", {localize: true});
|
||||
const response = await FilePicker.upload(null, null, file, {uuid});
|
||||
return response?.path;
|
||||
}
|
||||
}
|
||||
|
||||
// Global Export
|
||||
window.TextEditor = TextEditor;
|
||||
1081
resources/app/client/ui/filepicker.js
Normal file
1081
resources/app/client/ui/filepicker.js
Normal file
File diff suppressed because it is too large
Load Diff
204
resources/app/client/ui/filter.js
Normal file
204
resources/app/client/ui/filter.js
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @typedef {object} SearchFilterConfiguration
|
||||
* @property {object} options Options which customize the behavior of the filter
|
||||
* @property {string} options.inputSelector The CSS selector used to target the text input element.
|
||||
* @property {string} options.contentSelector The CSS selector used to target the content container for these tabs.
|
||||
* @property {Function} options.callback A callback function which executes when the filter changes.
|
||||
* @property {string} [options.initial] The initial value of the search query.
|
||||
* @property {number} [options.delay=200] The number of milliseconds to wait for text input before processing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} FieldFilter
|
||||
* @property {string} field The dot-delimited path to the field being filtered
|
||||
* @property {string} [operator=SearchFilter.OPERATORS.EQUALS] The search operator, from CONST.OPERATORS
|
||||
* @property {boolean} negate Negate the filter, returning results which do NOT match the filter criteria
|
||||
* @property {*} value The value against which to test
|
||||
*/
|
||||
|
||||
/**
|
||||
* A controller class for managing a text input widget that filters the contents of some other UI element
|
||||
* @see {@link Application}
|
||||
*
|
||||
* @param {SearchFilterConfiguration}
|
||||
*/
|
||||
class SearchFilter {
|
||||
|
||||
/**
|
||||
* The allowed Filter Operators which can be used to define a search filter
|
||||
* @enum {string}
|
||||
*/
|
||||
static OPERATORS = Object.freeze({
|
||||
EQUALS: "equals",
|
||||
CONTAINS: "contains",
|
||||
STARTS_WITH: "starts_with",
|
||||
ENDS_WITH: "ends_with",
|
||||
LESS_THAN: "lt",
|
||||
LESS_THAN_EQUAL: "lte",
|
||||
GREATER_THAN: "gt",
|
||||
GREATER_THAN_EQUAL: "gte",
|
||||
BETWEEN: "between",
|
||||
IS_EMPTY: "is_empty",
|
||||
});
|
||||
|
||||
|
||||
// Average typing speed is 167 ms per character, per https://stackoverflow.com/a/4098779
|
||||
constructor({inputSelector, contentSelector, initial="", callback, delay=200}={}) {
|
||||
|
||||
/**
|
||||
* The value of the current query string
|
||||
* @type {string}
|
||||
*/
|
||||
this.query = initial;
|
||||
|
||||
/**
|
||||
* A callback function to trigger when the tab is changed
|
||||
* @type {Function|null}
|
||||
*/
|
||||
this.callback = callback;
|
||||
|
||||
/**
|
||||
* The regular expression corresponding to the query that should be matched against
|
||||
* @type {RegExp}
|
||||
*/
|
||||
this.rgx = undefined;
|
||||
|
||||
/**
|
||||
* The CSS selector used to target the tab navigation element
|
||||
* @type {string}
|
||||
*/
|
||||
this._inputSelector = inputSelector;
|
||||
|
||||
/**
|
||||
* A reference to the HTML navigation element the tab controller is bound to
|
||||
* @type {HTMLElement|null}
|
||||
*/
|
||||
this._input = null;
|
||||
|
||||
/**
|
||||
* The CSS selector used to target the tab content element
|
||||
* @type {string}
|
||||
*/
|
||||
this._contentSelector = contentSelector;
|
||||
|
||||
/**
|
||||
* A reference to the HTML container element of the tab content
|
||||
* @type {HTMLElement|null}
|
||||
*/
|
||||
this._content = null;
|
||||
|
||||
/**
|
||||
* A debounced function which applies the search filtering
|
||||
* @type {Function}
|
||||
*/
|
||||
this._filter = foundry.utils.debounce(this.callback, delay);
|
||||
}
|
||||
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether a given object matches a provided filter
|
||||
* @param {object} obj An object to test against
|
||||
* @param {FieldFilter} filter The filter to test
|
||||
* @returns {boolean} Whether the object matches the filter
|
||||
*/
|
||||
static evaluateFilter(obj, filter) {
|
||||
const docValue = foundry.utils.getProperty(obj, filter.field);
|
||||
const filterValue = filter.value;
|
||||
|
||||
function _evaluate() {
|
||||
switch (filter.operator) {
|
||||
case SearchFilter.OPERATORS.EQUALS:
|
||||
if ( docValue.equals instanceof Function ) return docValue.equals(filterValue);
|
||||
else return (docValue === filterValue);
|
||||
case SearchFilter.OPERATORS.CONTAINS:
|
||||
if ( Array.isArray(filterValue) )
|
||||
return filterValue.includes(docValue);
|
||||
else
|
||||
return [filterValue].includes(docValue);
|
||||
case SearchFilter.OPERATORS.STARTS_WITH:
|
||||
return docValue.startsWith(filterValue);
|
||||
case SearchFilter.OPERATORS.ENDS_WITH:
|
||||
return docValue.endsWith(filterValue);
|
||||
case SearchFilter.OPERATORS.LESS_THAN:
|
||||
return (docValue < filterValue);
|
||||
case SearchFilter.OPERATORS.LESS_THAN_EQUAL:
|
||||
return (docValue <= filterValue);
|
||||
case SearchFilter.OPERATORS.GREATER_THAN:
|
||||
return (docValue > filterValue);
|
||||
case SearchFilter.OPERATORS.GREATER_THAN_EQUAL:
|
||||
return (docValue >= filterValue);
|
||||
case SearchFilter.OPERATORS.BETWEEN:
|
||||
if ( !Array.isArray(filterValue) || filterValue.length !== 2 ) {
|
||||
throw new Error(`Invalid filter value for ${filter.operator} operator. Expected an array of length 2.`);
|
||||
}
|
||||
const [min, max] = filterValue;
|
||||
return (docValue >= min) && (docValue <= max);
|
||||
case SearchFilter.OPERATORS.IS_EMPTY:
|
||||
return foundry.utils.isEmpty(docValue);
|
||||
default:
|
||||
return (docValue === filterValue);
|
||||
}
|
||||
}
|
||||
|
||||
const result = _evaluate();
|
||||
return filter.negate ? !result : result;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Bind the SearchFilter controller to an HTML application
|
||||
* @param {HTMLElement} html
|
||||
*/
|
||||
bind(html) {
|
||||
|
||||
// Identify navigation element
|
||||
this._input = html.querySelector(this._inputSelector);
|
||||
if ( !this._input ) return;
|
||||
this._input.value = this.query;
|
||||
|
||||
// Identify content container
|
||||
if ( !this._contentSelector ) this._content = null;
|
||||
else if ( html.matches(this._contentSelector) ) this._content = html;
|
||||
else this._content = html.querySelector(this._contentSelector);
|
||||
|
||||
// Register the handler for input changes
|
||||
// Use the input event which also captures clearing the filter
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event
|
||||
this._input.addEventListener("input", event => {
|
||||
event.preventDefault();
|
||||
this.filter(event, event.currentTarget.value);
|
||||
});
|
||||
|
||||
// Apply the initial filtering conditions
|
||||
const event = new KeyboardEvent("input", {key: "Enter", code: "Enter"});
|
||||
this.filter(event, this.query);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Perform a filtering of the content by invoking the callback function
|
||||
* @param {KeyboardEvent} event The triggering keyboard event
|
||||
* @param {string} query The input search string
|
||||
*/
|
||||
filter(event, query) {
|
||||
this.query = SearchFilter.cleanQuery(query);
|
||||
this.rgx = new RegExp(RegExp.escape(this.query), "i");
|
||||
this._filter(event, this.query, this.rgx, this._content);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Clean a query term to standardize it for matching.
|
||||
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize
|
||||
* @param {string} query An input string which may contain leading/trailing spaces or diacritics
|
||||
* @returns {string} A cleaned string of ASCII characters for comparison
|
||||
*/
|
||||
static cleanQuery(query) {
|
||||
return query.trim().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||
}
|
||||
}
|
||||
269
resources/app/client/ui/forms.js
Normal file
269
resources/app/client/ui/forms.js
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* An extension of the native FormData implementation.
|
||||
*
|
||||
* This class functions the same way that the default FormData does, but it is more opinionated about how
|
||||
* input fields of certain types should be evaluated and handled.
|
||||
*
|
||||
* It also adds support for certain Foundry VTT specific concepts including:
|
||||
* Support for defined data types and type conversion
|
||||
* Support for TinyMCE editors
|
||||
* Support for editable HTML elements
|
||||
*
|
||||
* @extends {FormData}
|
||||
*
|
||||
* @param {HTMLFormElement} form The form being processed
|
||||
* @param {object} options Options which configure form processing
|
||||
* @param {Record<string, object>} [options.editors] A record of TinyMCE editor metadata objects, indexed by their update key
|
||||
* @param {Record<string, string>} [options.dtypes] A mapping of data types for form fields
|
||||
* @param {boolean} [options.disabled=false] Include disabled fields?
|
||||
* @param {boolean} [options.readonly=false] Include readonly fields?
|
||||
*/
|
||||
class FormDataExtended extends FormData {
|
||||
constructor(form, {dtypes={}, editors={}, disabled=false, readonly=true}={}) {
|
||||
super();
|
||||
|
||||
/**
|
||||
* A mapping of data types requested for each form field.
|
||||
* @type {{string, string}}
|
||||
*/
|
||||
this.dtypes = dtypes;
|
||||
|
||||
/**
|
||||
* A record of TinyMCE editors which are linked to this form.
|
||||
* @type {Record<string, object>}
|
||||
*/
|
||||
this.editors = editors;
|
||||
|
||||
/**
|
||||
* The object representation of the form data, available once processed.
|
||||
* @type {object}
|
||||
*/
|
||||
Object.defineProperty(this, "object", {value: {}, writable: false, enumerable: false});
|
||||
|
||||
// Process the provided form
|
||||
this.process(form, {disabled, readonly});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Process the HTML form element to populate the FormData instance.
|
||||
* @param {HTMLFormElement} form The HTML form being processed
|
||||
* @param {object} options Options forwarded from the constructor
|
||||
*/
|
||||
process(form, options) {
|
||||
this.#processFormFields(form, options);
|
||||
this.#processEditableHTML(form, options);
|
||||
this.#processEditors();
|
||||
|
||||
// Emit the formdata event for compatibility with the parent FormData class
|
||||
form.dispatchEvent(new FormDataEvent("formdata", {formData: this}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Assign a value to the FormData instance which always contains JSON strings.
|
||||
* Also assign the cast value in its preferred data type to the parsed object representation of the form data.
|
||||
* @param {string} name The field name
|
||||
* @param {any} value The raw extracted value from the field
|
||||
* @inheritDoc
|
||||
*/
|
||||
set(name, value) {
|
||||
this.object[name] = value;
|
||||
if ( value instanceof Array ) value = JSON.stringify(value);
|
||||
super.set(name, value);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Append values to the form data, adding them to an array.
|
||||
* @param {string} name The field name to append to the form
|
||||
* @param {any} value The value to append to the form data
|
||||
* @inheritDoc
|
||||
*/
|
||||
append(name, value) {
|
||||
if ( name in this.object ) {
|
||||
if ( !Array.isArray(this.object[name]) ) this.object[name] = [this.object[name]];
|
||||
}
|
||||
else this.object[name] = [];
|
||||
this.object[name].push(value);
|
||||
super.append(name, value);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Process all standard HTML form field elements from the form.
|
||||
* @param {HTMLFormElement} form The form being processed
|
||||
* @param {object} options Options forwarded from the constructor
|
||||
* @param {boolean} [options.disabled] Process disabled fields?
|
||||
* @param {boolean} [options.readonly] Process readonly fields?
|
||||
* @private
|
||||
*/
|
||||
#processFormFields(form, {disabled, readonly}={}) {
|
||||
if ( !disabled && form.hasAttribute("disabled") ) return;
|
||||
const mceEditorIds = Object.values(this.editors).map(e => e.mce?.id);
|
||||
for ( const element of form.elements ) {
|
||||
const name = element.name;
|
||||
|
||||
// Skip fields which are unnamed or already handled
|
||||
if ( !name || this.has(name) ) continue;
|
||||
|
||||
// Skip buttons and editors
|
||||
if ( (element.tagName === "BUTTON") || mceEditorIds.includes(name) ) continue;
|
||||
|
||||
// Skip disabled or read-only fields
|
||||
if ( !disabled && (element.disabled || element.closest("fieldset")?.disabled) ) continue;
|
||||
if ( !readonly && element.readOnly ) continue;
|
||||
|
||||
// Extract and process the value of the field
|
||||
const field = form.elements[name];
|
||||
const value = this.#getFieldValue(name, field);
|
||||
this.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Process editable HTML elements (ones with a [data-edit] attribute).
|
||||
* @param {HTMLFormElement} form The form being processed
|
||||
* @param {object} options Options forwarded from the constructor
|
||||
* @param {boolean} [options.disabled] Process disabled fields?
|
||||
* @param {boolean} [options.readonly] Process readonly fields?
|
||||
* @private
|
||||
*/
|
||||
#processEditableHTML(form, {disabled, readonly}={}) {
|
||||
const editableElements = form.querySelectorAll("[data-edit]");
|
||||
for ( const element of editableElements ) {
|
||||
const name = element.dataset.edit;
|
||||
if ( this.has(name) || (name in this.editors) ) continue;
|
||||
if ( (!disabled && element.disabled) || (!readonly && element.readOnly) ) continue;
|
||||
let value;
|
||||
if (element.tagName === "IMG") value = element.getAttribute("src");
|
||||
else value = element.innerHTML.trim();
|
||||
this.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Process TinyMCE editor instances which are present in the form.
|
||||
* @private
|
||||
*/
|
||||
#processEditors() {
|
||||
for ( const [name, editor] of Object.entries(this.editors) ) {
|
||||
if ( !editor.instance ) continue;
|
||||
if ( editor.options.engine === "tinymce" ) {
|
||||
const content = editor.instance.getContent();
|
||||
this.delete(editor.mce.id); // Delete hidden MCE inputs
|
||||
this.set(name, content);
|
||||
} else if ( editor.options.engine === "prosemirror" ) {
|
||||
this.set(name, ProseMirror.dom.serializeString(editor.instance.view.state.doc.content));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Obtain the parsed value of a field conditional on its element type and requested data type.
|
||||
* @param {string} name The field name being processed
|
||||
* @param {HTMLElement|RadioNodeList} field The HTML field or a RadioNodeList of multiple fields
|
||||
* @returns {*} The processed field value
|
||||
* @private
|
||||
*/
|
||||
#getFieldValue(name, field) {
|
||||
|
||||
// Multiple elements with the same name
|
||||
if ( field instanceof RadioNodeList ) {
|
||||
const fields = Array.from(field);
|
||||
if ( fields.every(f => f.type === "radio") ) {
|
||||
const chosen = fields.find(f => f.checked);
|
||||
return chosen ? this.#getFieldValue(name, chosen) : undefined;
|
||||
}
|
||||
return Array.from(field).map(f => this.#getFieldValue(name, f));
|
||||
}
|
||||
|
||||
// Record requested data type
|
||||
const dataType = field.dataset.dtype || this.dtypes[name];
|
||||
|
||||
// Checkbox
|
||||
if ( field.type === "checkbox" ) {
|
||||
|
||||
// Non-boolean checkboxes with an explicit value attribute yield that value or null
|
||||
if ( field.hasAttribute("value") && (dataType !== "Boolean") ) {
|
||||
return this.#castType(field.checked ? field.value : null, dataType);
|
||||
}
|
||||
|
||||
// Otherwise, true or false based on the checkbox checked state
|
||||
return this.#castType(field.checked, dataType);
|
||||
}
|
||||
|
||||
// Number and Range
|
||||
if ( ["number", "range"].includes(field.type) ) {
|
||||
if ( field.value === "" ) return null;
|
||||
else return this.#castType(field.value, dataType || "Number");
|
||||
}
|
||||
|
||||
// Multi-Select
|
||||
if ( field.type === "select-multiple" ) {
|
||||
return Array.from(field.options).reduce((chosen, opt) => {
|
||||
if ( opt.selected ) chosen.push(this.#castType(opt.value, dataType));
|
||||
return chosen;
|
||||
}, []);
|
||||
}
|
||||
|
||||
// Radio Select
|
||||
if ( field.type === "radio" ) {
|
||||
return field.checked ? this.#castType(field.value, dataType) : null;
|
||||
}
|
||||
|
||||
// Other field types
|
||||
return this.#castType(field.value, dataType);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Cast a processed value to a desired data type.
|
||||
* @param {any} value The raw field value
|
||||
* @param {string} dataType The desired data type
|
||||
* @returns {any} The resulting data type
|
||||
* @private
|
||||
*/
|
||||
#castType(value, dataType) {
|
||||
if ( value instanceof Array ) return value.map(v => this.#castType(v, dataType));
|
||||
if ( [undefined, null].includes(value) || (dataType === "String") ) return value;
|
||||
|
||||
// Boolean
|
||||
if ( dataType === "Boolean" ) {
|
||||
if ( value === "false" ) return false;
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
// Number
|
||||
else if ( dataType === "Number" ) {
|
||||
if ( (value === "") || (value === "null") ) return null;
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
// Serialized JSON
|
||||
else if ( dataType === "JSON" ) {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
|
||||
// Other data types
|
||||
if ( window[dataType] instanceof Function ) {
|
||||
try {
|
||||
return window[dataType](value);
|
||||
} catch(err) {
|
||||
console.warn(`The form field value "${value}" was not able to be cast to the requested data type ${dataType}`);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
215
resources/app/client/ui/notifications.js
Normal file
215
resources/app/client/ui/notifications.js
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* A common framework for displaying notifications to the client.
|
||||
* Submitted notifications are added to a queue, and up to 3 notifications are displayed at once.
|
||||
* Each notification is displayed for 5 seconds at which point further notifications are pulled from the queue.
|
||||
*
|
||||
* @extends {Application}
|
||||
*
|
||||
* @example Displaying Notification Messages
|
||||
* ```js
|
||||
* ui.notifications.info("This is an info message");
|
||||
* ui.notifications.warn("This is a warning message");
|
||||
* ui.notifications.error("This is an error message");
|
||||
* ui.notifications.info("This is a 4th message which will not be shown until the first info message is done");
|
||||
* ```
|
||||
*/
|
||||
class Notifications extends Application {
|
||||
/**
|
||||
* An incrementing counter for the notification IDs.
|
||||
* @type {number}
|
||||
*/
|
||||
#id = 1;
|
||||
|
||||
constructor(options) {
|
||||
super(options);
|
||||
|
||||
/**
|
||||
* Submitted notifications which are queued for display
|
||||
* @type {object[]}
|
||||
*/
|
||||
this.queue = [];
|
||||
|
||||
/**
|
||||
* Notifications which are currently displayed
|
||||
* @type {object[]}
|
||||
*/
|
||||
this.active = [];
|
||||
|
||||
// Initialize any pending messages
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
popOut: false,
|
||||
id: "notifications",
|
||||
template: "templates/hud/notifications.html"
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize the Notifications system by displaying any system-generated messages which were passed from the server.
|
||||
*/
|
||||
initialize() {
|
||||
for ( let m of globalThis.MESSAGES ) {
|
||||
this.notify(game.i18n.localize(m.message), m.type, m.options);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _renderInner(...args) {
|
||||
return $('<ol id="notifications"></ol>');
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async _render(...args) {
|
||||
await super._render(...args);
|
||||
while ( this.queue.length && (this.active.length < 3) ) this.fetch();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {Object} NotifyOptions
|
||||
* @property {boolean} [permanent=false] Should the notification be permanently displayed until dismissed
|
||||
* @property {boolean} [localize=false] Whether to localize the message content before displaying it
|
||||
* @property {boolean} [console=true] Whether to log the message to the console
|
||||
*/
|
||||
|
||||
/**
|
||||
* Push a new notification into the queue
|
||||
* @param {string} message The content of the notification message
|
||||
* @param {string} type The type of notification, "info", "warning", and "error" are supported
|
||||
* @param {NotifyOptions} [options={}] Additional options which affect the notification
|
||||
* @returns {number} The ID of the notification (positive integer)
|
||||
*/
|
||||
notify(message, type="info", {localize=false, permanent=false, console=true}={}) {
|
||||
if ( localize ) message = game.i18n.localize(message);
|
||||
let n = {
|
||||
id: this.#id++,
|
||||
message: message,
|
||||
type: ["info", "warning", "error"].includes(type) ? type : "info",
|
||||
timestamp: new Date().getTime(),
|
||||
permanent: permanent,
|
||||
console: console
|
||||
};
|
||||
this.queue.push(n);
|
||||
if ( this.rendered ) this.fetch();
|
||||
return n.id;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a notification with the "info" type
|
||||
* @param {string} message The content of the notification message
|
||||
* @param {NotifyOptions} options Notification options passed to the notify function
|
||||
* @returns {number} The ID of the notification (positive integer)
|
||||
*/
|
||||
info(message, options) {
|
||||
return this.notify(message, "info", options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a notification with the "warning" type
|
||||
* @param {string} message The content of the notification message
|
||||
* @param {NotifyOptions} options Notification options passed to the notify function
|
||||
* @returns {number} The ID of the notification (positive integer)
|
||||
*/
|
||||
warn(message, options) {
|
||||
return this.notify(message, "warning", options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a notification with the "error" type
|
||||
* @param {string} message The content of the notification message
|
||||
* @param {NotifyOptions} options Notification options passed to the notify function
|
||||
* @returns {number} The ID of the notification (positive integer)
|
||||
*/
|
||||
error(message, options) {
|
||||
return this.notify(message, "error", options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove the notification linked to the ID.
|
||||
* @param {number} id The ID of the notification
|
||||
*/
|
||||
remove(id) {
|
||||
if ( !(id > 0) ) return;
|
||||
|
||||
// Remove a queued notification that has not been displayed yet
|
||||
const queued = this.queue.findSplice(n => n.id === id);
|
||||
if ( queued ) return;
|
||||
|
||||
// Remove an active HTML element
|
||||
const active = this.active.findSplice(li => li.data("id") === id);
|
||||
if ( !active ) return;
|
||||
active.fadeOut(66, () => active.remove());
|
||||
this.fetch();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Clear all notifications.
|
||||
*/
|
||||
clear() {
|
||||
this.queue.length = 0;
|
||||
for ( const li of this.active ) li.fadeOut(66, () => li.remove());
|
||||
this.active.length = 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve a pending notification from the queue and display it
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
fetch() {
|
||||
if ( !this.queue.length || (this.active.length >= 3) ) return;
|
||||
const next = this.queue.pop();
|
||||
const now = Date.now();
|
||||
|
||||
// Define the function to remove the notification
|
||||
const _remove = li => {
|
||||
li.fadeOut(66, () => li.remove());
|
||||
const i = this.active.indexOf(li);
|
||||
if ( i >= 0 ) this.active.splice(i, 1);
|
||||
return this.fetch();
|
||||
};
|
||||
|
||||
// Construct a new notification
|
||||
const cls = ["notification", next.type, next.permanent ? "permanent" : null].filterJoin(" ");
|
||||
const li = $(`<li class="${cls}" data-id="${next.id}">${next.message}<i class="close fas fa-times-circle"></i></li>`);
|
||||
|
||||
// Add click listener to dismiss
|
||||
li.click(ev => {
|
||||
if ( Date.now() - now > 250 ) _remove(li);
|
||||
});
|
||||
this.element.prepend(li);
|
||||
li.hide().slideDown(132);
|
||||
this.active.push(li);
|
||||
|
||||
// Log to console if enabled
|
||||
if ( next.console ) console[next.type === "warning" ? "warn" : next.type](next.message);
|
||||
|
||||
// Schedule clearing the notification 5 seconds later
|
||||
if ( !next.permanent ) window.setTimeout(() => _remove(li), 5000);
|
||||
}
|
||||
}
|
||||
359
resources/app/client/ui/prosemirror.js
Normal file
359
resources/app/client/ui/prosemirror.js
Normal file
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* @typedef {object} ProseMirrorHistory
|
||||
* @property {string} userId The ID of the user who submitted the step.
|
||||
* @property {Step} step The step that was submitted.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A class responsible for managing state and collaborative editing of a single ProseMirror instance.
|
||||
*/
|
||||
class ProseMirrorEditor {
|
||||
/**
|
||||
* @param {string} uuid A string that uniquely identifies this ProseMirror instance.
|
||||
* @param {EditorView} view The ProseMirror EditorView.
|
||||
* @param {Plugin} isDirtyPlugin The plugin to track the dirty state of the editor.
|
||||
* @param {boolean} collaborate Whether this is a collaborative editor.
|
||||
* @param {object} [options] Additional options.
|
||||
* @param {ClientDocument} [options.document] A document associated with this editor.
|
||||
*/
|
||||
constructor(uuid, view, isDirtyPlugin, collaborate, options={}) {
|
||||
/**
|
||||
* A string that uniquely identifies this ProseMirror instance.
|
||||
* @type {string}
|
||||
*/
|
||||
Object.defineProperty(this, "uuid", {value: uuid, writable: false});
|
||||
|
||||
/**
|
||||
* The ProseMirror EditorView.
|
||||
* @type {EditorView}
|
||||
*/
|
||||
Object.defineProperty(this, "view", {value: view, writable: false});
|
||||
|
||||
/**
|
||||
* Whether this is a collaborative editor.
|
||||
* @type {boolean}
|
||||
*/
|
||||
Object.defineProperty(this, "collaborate", {value: collaborate, writable: false});
|
||||
|
||||
this.options = options;
|
||||
this.#isDirtyPlugin = isDirtyPlugin;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A list of active editor instances by their UUIDs.
|
||||
* @type {Map<string, ProseMirrorEditor>}
|
||||
*/
|
||||
static #editors = new Map();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The plugin to track the dirty state of the editor.
|
||||
* @type {Plugin}
|
||||
*/
|
||||
#isDirtyPlugin;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retire this editor instance and clean up.
|
||||
*/
|
||||
destroy() {
|
||||
ProseMirrorEditor.#editors.delete(this.uuid);
|
||||
this.view.destroy();
|
||||
if ( this.collaborate ) game.socket.emit("pm.endSession", this.uuid);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Have the contents of the editor been edited by the user?
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isDirty() {
|
||||
return this.#isDirtyPlugin.getState(this.view.state);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle new editing steps supplied by the server.
|
||||
* @param {string} offset The offset into the history, representing the point at which it was last
|
||||
* truncated.
|
||||
* @param {ProseMirrorHistory[]} history The entire edit history.
|
||||
* @protected
|
||||
*/
|
||||
_onNewSteps(offset, history) {
|
||||
this._disableSourceCodeEditing();
|
||||
this.options.document?.sheet?.onNewSteps?.();
|
||||
const version = ProseMirror.collab.getVersion(this.view.state);
|
||||
const newSteps = history.slice(version - offset);
|
||||
|
||||
// Flatten out the data into a format that ProseMirror.collab.receiveTransaction can understand.
|
||||
const [steps, ids] = newSteps.reduce(([steps, ids], entry) => {
|
||||
steps.push(ProseMirror.Step.fromJSON(ProseMirror.defaultSchema, entry.step));
|
||||
ids.push(entry.userId);
|
||||
return [steps, ids];
|
||||
}, [[], []]);
|
||||
|
||||
const tr = ProseMirror.collab.receiveTransaction(this.view.state, steps, ids);
|
||||
this.view.dispatch(tr);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Disable source code editing if the user was editing it when new steps arrived.
|
||||
* @protected
|
||||
*/
|
||||
_disableSourceCodeEditing() {
|
||||
const textarea = this.view.dom.closest(".editor")?.querySelector(":scope > textarea");
|
||||
if ( !textarea ) return;
|
||||
textarea.disabled = true;
|
||||
ui.notifications.warn("EDITOR.EditingHTMLWarning", {localize: true, permanent: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The state of this ProseMirror editor has fallen too far behind the central authority's and must be re-synced.
|
||||
* @protected
|
||||
*/
|
||||
_resync() {
|
||||
// Copy the editor's current state to the clipboard to avoid data loss.
|
||||
const existing = this.view.dom;
|
||||
existing.contentEditable = false;
|
||||
const selection = document.getSelection();
|
||||
selection.removeAllRanges();
|
||||
const range = document.createRange();
|
||||
range.selectNode(existing);
|
||||
selection.addRange(range);
|
||||
// We cannot use navigator.clipboard.write here as it is disabled or not fully implemented in some browsers.
|
||||
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Interact_with_the_clipboard
|
||||
document.execCommand("copy");
|
||||
ui.notifications.warn("EDITOR.Resync", {localize: true, permanent: true});
|
||||
this.destroy();
|
||||
this.options.document?.sheet?.render(true, {resync: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle users joining or leaving collaborative editing.
|
||||
* @param {string[]} users The IDs of users currently editing (including ourselves).
|
||||
* @protected
|
||||
*/
|
||||
_updateUserDisplay(users) {
|
||||
const editor = this.view.dom.closest(".editor");
|
||||
editor.classList.toggle("collaborating", users.length > 1);
|
||||
const pips = users.map(id => {
|
||||
const user = game.users.get(id);
|
||||
if ( !user ) return "";
|
||||
return `
|
||||
<span class="scene-player" style="background: ${user.color}; border: 1px solid ${user.border.css};">
|
||||
${user.name[0]}
|
||||
</span>
|
||||
`;
|
||||
}).join("");
|
||||
const collaborating = editor.querySelector("menu .concurrent-users");
|
||||
collaborating.dataset.tooltip = users.map(id => game.users.get(id)?.name).join(", ");
|
||||
collaborating.innerHTML = `
|
||||
<i class="fa-solid fa-user-group"></i>
|
||||
${pips}
|
||||
`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle an autosave update for an already-open editor.
|
||||
* @param {string} html The updated editor contents.
|
||||
* @protected
|
||||
*/
|
||||
_handleAutosave(html) {
|
||||
this.options.document?.sheet?.onAutosave?.(html);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a ProseMirror editor instance.
|
||||
* @param {HTMLElement} target An HTML element to mount the editor to.
|
||||
* @param {string} [content=""] Content to populate the editor with.
|
||||
* @param {object} [options] Additional options to configure the ProseMirror instance.
|
||||
* @param {string} [options.uuid] A string to uniquely identify this ProseMirror instance. Ignored
|
||||
* for a collaborative editor.
|
||||
* @param {ClientDocument} [options.document] A Document whose content is being edited. Required for
|
||||
* collaborative editing and relative UUID generation.
|
||||
* @param {string} [options.fieldName] The field within the Document that is being edited. Required for
|
||||
* collaborative editing.
|
||||
* @param {Record<string, Plugin>} [options.plugins] Plugins to include with the editor.
|
||||
* @param {boolean} [options.relativeLinks=false] Whether to generate relative UUID links to Documents that are
|
||||
* dropped on the editor.
|
||||
* @param {boolean} [options.collaborate=false] Whether to enable collaborative editing for this editor.
|
||||
* @returns {Promise<ProseMirrorEditor>}
|
||||
*/
|
||||
static async create(target, content="", {uuid, document, fieldName, plugins={}, collaborate=false,
|
||||
relativeLinks=false}={}) {
|
||||
|
||||
if ( collaborate && (!document || !fieldName) ) {
|
||||
throw new Error("A document and fieldName must be provided when creating an editor with collaborative editing.");
|
||||
}
|
||||
|
||||
uuid = collaborate ? `${document.uuid}#${fieldName}` : uuid ?? `ProseMirror.${foundry.utils.randomID()}`;
|
||||
const state = ProseMirror.EditorState.create({doc: ProseMirror.dom.parseString(content)});
|
||||
plugins = Object.assign({}, ProseMirror.defaultPlugins, plugins);
|
||||
plugins.contentLinks = ProseMirror.ProseMirrorContentLinkPlugin.build(ProseMirror.defaultSchema, {
|
||||
document, relativeLinks
|
||||
});
|
||||
|
||||
if ( document ) {
|
||||
plugins.images = ProseMirror.ProseMirrorImagePlugin.build(ProseMirror.defaultSchema, {document});
|
||||
}
|
||||
|
||||
const options = {state};
|
||||
Hooks.callAll("createProseMirrorEditor", uuid, plugins, options);
|
||||
|
||||
const view = collaborate
|
||||
? await this._createCollaborativeEditorView(uuid, target, options.state, Object.values(plugins))
|
||||
: this._createLocalEditorView(target, options.state, Object.values(plugins));
|
||||
const editor = new ProseMirrorEditor(uuid, view, plugins.isDirty, collaborate, {document});
|
||||
ProseMirrorEditor.#editors.set(uuid, editor);
|
||||
return editor;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create an EditorView with collaborative editing enabled.
|
||||
* @param {string} uuid The ProseMirror instance UUID.
|
||||
* @param {HTMLElement} target An HTML element to mount the editor view to.
|
||||
* @param {EditorState} state The ProseMirror editor state.
|
||||
* @param {Plugin[]} plugins The editor plugins to load.
|
||||
* @returns {Promise<EditorView>}
|
||||
* @protected
|
||||
*/
|
||||
static async _createCollaborativeEditorView(uuid, target, state, plugins) {
|
||||
const authority = await new Promise((resolve, reject) => {
|
||||
game.socket.emit("pm.editDocument", uuid, state, authority => {
|
||||
if ( authority.state ) resolve(authority);
|
||||
else reject();
|
||||
});
|
||||
});
|
||||
return new ProseMirror.EditorView({mount: target}, {
|
||||
state: ProseMirror.EditorState.fromJSON({
|
||||
schema: ProseMirror.defaultSchema,
|
||||
plugins: [
|
||||
...plugins,
|
||||
ProseMirror.collab.collab({version: authority.version, clientID: game.userId})
|
||||
]
|
||||
}, authority.state),
|
||||
dispatchTransaction(tr) {
|
||||
const newState = this.state.apply(tr);
|
||||
this.updateState(newState);
|
||||
const sendable = ProseMirror.collab.sendableSteps(newState);
|
||||
if ( sendable ) game.socket.emit("pm.receiveSteps", uuid, sendable.version, sendable.steps);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a plain EditorView without collaborative editing.
|
||||
* @param {HTMLElement} target An HTML element to mount the editor view to.
|
||||
* @param {EditorState} state The ProseMirror editor state.
|
||||
* @param {Plugin[]} plugins The editor plugins to load.
|
||||
* @returns {EditorView}
|
||||
* @protected
|
||||
*/
|
||||
static _createLocalEditorView(target, state, plugins) {
|
||||
return new ProseMirror.EditorView({mount: target}, {
|
||||
state: ProseMirror.EditorState.create({doc: state.doc, plugins})
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Socket Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle new editing steps supplied by the server.
|
||||
* @param {string} uuid The UUID that uniquely identifies the ProseMirror instance.
|
||||
* @param {number} offset The offset into the history, representing the point at which it was last
|
||||
* truncated.
|
||||
* @param {ProseMirrorHistory[]} history The entire edit history.
|
||||
* @protected
|
||||
*/
|
||||
static _onNewSteps(uuid, offset, history) {
|
||||
const editor = ProseMirrorEditor.#editors.get(uuid);
|
||||
if ( editor ) editor._onNewSteps(offset, history);
|
||||
else {
|
||||
console.warn(`New steps were received for UUID '${uuid}' which is not a ProseMirror editor instance.`);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Our client is too far behind the central authority's state and must be re-synced.
|
||||
* @param {string} uuid The UUID that uniquely identifies the ProseMirror instance.
|
||||
* @protected
|
||||
*/
|
||||
static _onResync(uuid) {
|
||||
const editor = ProseMirrorEditor.#editors.get(uuid);
|
||||
if ( editor ) editor._resync();
|
||||
else {
|
||||
console.warn(`A resync request was received for UUID '${uuid}' which is not a ProseMirror editor instance.`);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle users joining or leaving collaborative editing.
|
||||
* @param {string} uuid The UUID that uniquely identifies the ProseMirror instance.
|
||||
* @param {string[]} users The IDs of the users editing (including ourselves).
|
||||
* @protected
|
||||
*/
|
||||
static _onUsersEditing(uuid, users) {
|
||||
const editor = ProseMirrorEditor.#editors.get(uuid);
|
||||
if ( editor ) editor._updateUserDisplay(users);
|
||||
else {
|
||||
console.warn(`A user update was received for UUID '${uuid}' which is not a ProseMirror editor instance.`);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update client state when the editor contents are autosaved server-side.
|
||||
* @param {string} uuid The UUID that uniquely identifies the ProseMirror instance.
|
||||
* @param {string} html The updated editor contents.
|
||||
* @protected
|
||||
*/
|
||||
static async _onAutosave(uuid, html) {
|
||||
const editor = ProseMirrorEditor.#editors.get(uuid);
|
||||
const [docUUID, field] = uuid.split("#");
|
||||
const doc = await fromUuid(docUUID);
|
||||
if ( doc ) doc.updateSource({[field]: html});
|
||||
if ( editor ) editor._handleAutosave(html);
|
||||
else doc.render(false);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Listen for ProseMirror collaboration events.
|
||||
* @param {Socket} socket The open websocket.
|
||||
* @internal
|
||||
*/
|
||||
static _activateSocketListeners(socket) {
|
||||
socket.on("pm.newSteps", this._onNewSteps.bind(this));
|
||||
socket.on("pm.resync", this._onResync.bind(this));
|
||||
socket.on("pm.usersEditing", this._onUsersEditing.bind(this));
|
||||
socket.on("pm.autosave", this._onAutosave.bind(this));
|
||||
}
|
||||
}
|
||||
101
resources/app/client/ui/secrets.js
Normal file
101
resources/app/client/ui/secrets.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @callback HTMLSecretContentCallback
|
||||
* @param {HTMLElement} secret The secret element whose surrounding content we wish to retrieve.
|
||||
* @returns {string} The content where the secret is housed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback HTMLSecretUpdateCallback
|
||||
* @param {HTMLElement} secret The secret element that is being manipulated.
|
||||
* @param {string} content The content block containing the updated secret element.
|
||||
* @returns {Promise<ClientDocument>} The updated Document.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} HTMLSecretConfiguration
|
||||
* @property {string} parentSelector The CSS selector used to target content that contains secret blocks.
|
||||
* @property {{
|
||||
* content: HTMLSecretContentCallback,
|
||||
* update: HTMLSecretUpdateCallback
|
||||
* }} callbacks An object of callback functions for each operation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A composable class for managing functionality for secret blocks within DocumentSheets.
|
||||
* @see {@link DocumentSheet}
|
||||
* @example Activate secret revealing functionality within a certain block of content.
|
||||
* ```js
|
||||
* const secrets = new HTMLSecret({
|
||||
* selector: "section.secret[id]",
|
||||
* callbacks: {
|
||||
* content: this._getSecretContent.bind(this),
|
||||
* update: this._updateSecret.bind(this)
|
||||
* }
|
||||
* });
|
||||
* secrets.bind(html);
|
||||
* ```
|
||||
*/
|
||||
class HTMLSecret {
|
||||
/**
|
||||
* @param {HTMLSecretConfiguration} config Configuration options.
|
||||
*/
|
||||
constructor({parentSelector, callbacks={}}={}) {
|
||||
/**
|
||||
* The CSS selector used to target secret blocks.
|
||||
* @type {string}
|
||||
*/
|
||||
Object.defineProperty(this, "parentSelector", {value: parentSelector, writable: false});
|
||||
|
||||
/**
|
||||
* An object of callback functions for each operation.
|
||||
* @type {{content: HTMLSecretContentCallback, update: HTMLSecretUpdateCallback}}
|
||||
*/
|
||||
Object.defineProperty(this, "callbacks", {value: Object.freeze(callbacks), writable: false});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add event listeners to the targeted secret blocks.
|
||||
* @param {HTMLElement} html The HTML content to select secret blocks from.
|
||||
*/
|
||||
bind(html) {
|
||||
if ( !this.callbacks.content || !this.callbacks.update ) return;
|
||||
const parents = html.querySelectorAll(this.parentSelector);
|
||||
for ( const parent of parents ) {
|
||||
parent.querySelectorAll("section.secret[id]").forEach(secret => {
|
||||
// Do not add reveal blocks to secrets inside @Embeds as they do not currently work.
|
||||
if ( secret.closest("[data-content-embed]") ) return;
|
||||
const revealed = secret.classList.contains("revealed");
|
||||
const reveal = document.createElement("button");
|
||||
reveal.type = "button";
|
||||
reveal.classList.add("reveal");
|
||||
reveal.textContent = game.i18n.localize(`EDITOR.${revealed ? "Hide" : "Reveal"}`);
|
||||
secret.insertBefore(reveal, secret.firstChild);
|
||||
reveal.addEventListener("click", this._onToggleSecret.bind(this));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle toggling a secret's revealed state.
|
||||
* @param {MouseEvent} event The triggering click event.
|
||||
* @returns {Promise<ClientDocument>} The Document whose content was modified.
|
||||
* @protected
|
||||
*/
|
||||
_onToggleSecret(event) {
|
||||
event.preventDefault();
|
||||
const secret = event.currentTarget.closest(".secret");
|
||||
const id = secret?.id;
|
||||
if ( !id ) return;
|
||||
const content = this.callbacks.content(secret);
|
||||
if ( !content ) return;
|
||||
const revealed = secret.classList.contains("revealed");
|
||||
const modified = content.replace(new RegExp(`<section[^i]+id="${id}"[^>]*>`), () => {
|
||||
return `<section class="secret${revealed ? "" : " revealed"}" id="${id}">`;
|
||||
});
|
||||
return this.callbacks.update(secret, modified);
|
||||
}
|
||||
}
|
||||
155
resources/app/client/ui/tabs.js
Normal file
155
resources/app/client/ui/tabs.js
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @typedef {object} TabsConfiguration
|
||||
* @property {string} [group] The name of the tabs group
|
||||
* @property {string} navSelector The CSS selector used to target the navigation element for these tabs
|
||||
* @property {string} contentSelector The CSS selector used to target the content container for these tabs
|
||||
* @property {string} initial The tab name of the initially active tab
|
||||
* @property {Function|null} [callback] An optional callback function that executes when the active tab is changed
|
||||
*/
|
||||
|
||||
/**
|
||||
* A controller class for managing tabbed navigation within an Application instance.
|
||||
* @see {@link Application}
|
||||
* @param {TabsConfiguration} config The Tabs Configuration to use for this tabbed container
|
||||
*
|
||||
* @example Configure tab-control for a set of HTML elements
|
||||
* ```html
|
||||
* <!-- Example HTML -->
|
||||
* <nav class="tabs" data-group="primary-tabs">
|
||||
* <a class="item" data-tab="tab1" data-group="primary-tabs">Tab 1</li>
|
||||
* <a class="item" data-tab="tab2" data-group="primary-tabs">Tab 2</li>
|
||||
* </nav>
|
||||
*
|
||||
* <section class="content">
|
||||
* <div class="tab" data-tab="tab1" data-group="primary-tabs">Content 1</div>
|
||||
* <div class="tab" data-tab="tab2" data-group="primary-tabs">Content 2</div>
|
||||
* </section>
|
||||
* ```
|
||||
* Activate tab control in JavaScript
|
||||
* ```js
|
||||
* const tabs = new Tabs({navSelector: ".tabs", contentSelector: ".content", initial: "tab1"});
|
||||
* tabs.bind(html);
|
||||
* ```
|
||||
*/
|
||||
class Tabs {
|
||||
constructor({group, navSelector, contentSelector, initial, callback}={}) {
|
||||
|
||||
/**
|
||||
* The name of the tabs group
|
||||
* @type {string}
|
||||
*/
|
||||
this.group = group;
|
||||
|
||||
/**
|
||||
* The value of the active tab
|
||||
* @type {string}
|
||||
*/
|
||||
this.active = initial;
|
||||
|
||||
/**
|
||||
* A callback function to trigger when the tab is changed
|
||||
* @type {Function|null}
|
||||
*/
|
||||
this.callback = callback ?? null;
|
||||
|
||||
/**
|
||||
* The CSS selector used to target the tab navigation element
|
||||
* @type {string}
|
||||
*/
|
||||
this._navSelector = navSelector;
|
||||
|
||||
/**
|
||||
* A reference to the HTML navigation element the tab controller is bound to
|
||||
* @type {HTMLElement|null}
|
||||
*/
|
||||
this._nav = null;
|
||||
|
||||
/**
|
||||
* The CSS selector used to target the tab content element
|
||||
* @type {string}
|
||||
*/
|
||||
this._contentSelector = contentSelector;
|
||||
|
||||
/**
|
||||
* A reference to the HTML container element of the tab content
|
||||
* @type {HTMLElement|null}
|
||||
*/
|
||||
this._content = null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Bind the Tabs controller to an HTML application
|
||||
* @param {HTMLElement} html
|
||||
*/
|
||||
bind(html) {
|
||||
|
||||
// Identify navigation element
|
||||
this._nav = html.querySelector(this._navSelector);
|
||||
if ( !this._nav ) return;
|
||||
|
||||
// Identify content container
|
||||
if ( !this._contentSelector ) this._content = null;
|
||||
else if ( html.matches(this._contentSelector )) this._content = html;
|
||||
else this._content = html.querySelector(this._contentSelector);
|
||||
|
||||
// Initialize the active tab
|
||||
this.activate(this.active);
|
||||
|
||||
// Register listeners
|
||||
this._nav.addEventListener("click", this._onClickNav.bind(this));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate a new tab by name
|
||||
* @param {string} tabName
|
||||
* @param {boolean} triggerCallback
|
||||
*/
|
||||
activate(tabName, {triggerCallback=false}={}) {
|
||||
|
||||
// Validate the requested tab name
|
||||
const group = this._nav.dataset.group;
|
||||
const items = this._nav.querySelectorAll("[data-tab]");
|
||||
if ( !items.length ) return;
|
||||
const valid = Array.from(items).some(i => i.dataset.tab === tabName);
|
||||
if ( !valid ) tabName = items[0].dataset.tab;
|
||||
|
||||
// Change active tab
|
||||
for ( let i of items ) {
|
||||
i.classList.toggle("active", i.dataset.tab === tabName);
|
||||
}
|
||||
|
||||
// Change active content
|
||||
if ( this._content ) {
|
||||
const tabs = this._content.querySelectorAll(".tab[data-tab]");
|
||||
for ( let t of tabs ) {
|
||||
if ( t.dataset.group && (t.dataset.group !== group) ) continue;
|
||||
t.classList.toggle("active", t.dataset.tab === tabName);
|
||||
}
|
||||
}
|
||||
|
||||
// Store the active tab
|
||||
this.active = tabName;
|
||||
|
||||
// Optionally trigger the callback function
|
||||
if ( triggerCallback ) this.callback?.(null, this, tabName);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle click events on the tab navigation entries
|
||||
* @param {MouseEvent} event A left click event
|
||||
* @private
|
||||
*/
|
||||
_onClickNav(event) {
|
||||
const tab = event.target.closest("[data-tab]");
|
||||
if ( !tab ) return;
|
||||
event.preventDefault();
|
||||
const tabName = tab.dataset.tab;
|
||||
if ( tabName !== this.active ) this.activate(tabName, {triggerCallback: true});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user