This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View 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();
});
}
}
/* -------------------------------------------- */

View 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);
});
}
}

View 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
View 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;
}
}

View 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 &lt;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 &lt;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;

File diff suppressed because it is too large Load Diff

View 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, "");
}
}

View 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;
}
}

View 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);
}
}

View 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));
}
}

View 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);
}
}

View 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});
}
}